Part 17 — Model compression: distillation & small models
Optional deep-dive. Everything in Parts 1–16 stands on its own — you don't need this part to have finished the guide. It exists because "model weights" and "distillation" get used constantly in the field, and were used constantly earlier in this guide too (LoRA in Chapter 42, quantization glossed over in Chapter 38's table) without ever being explained down to the mechanism. If you're curious how a big model becomes a small one, or want a real, run-it-yourself distillation project, read on.
Chapter 66 — What model weights actually are
In one sentence
A model's weights are the numbers themselves — everything else (the Python class, the tokenizer, the config file) is fixed machinery around them, and "downloading a model" really means downloading a big flat list of floats.
The problem
Chapters 9–15 taught you that training finds numbers (parameters/weights) that make a function fit data. That was true for a spam filter with a handful of weights. It's still true for a 70-billion-parameter LLM — but at that scale, "where do the numbers live, how are they stored, and what do you actually get when you download one" stops being obvious. This chapter makes weights concrete enough that the next two chapters (shrinking them, running them in a browser) make sense.
Where weights live, concretely
Every Linear layer you've seen since Chapter 14 is one weight matrix W
and one bias vector b:
y = W @ x + b
x— the input vector (length =in_features)W— a matrix of shape[out_features, in_features]b— a vector of lengthout_featuresy— the output vector (length =out_features)
A Transformer block (Chapter 25) is a handful of these: W_q, W_k, W_v,
W_o for attention, plus two or three bigger matrices for the feed-forward
network. Stack ~30–100 blocks and add one embedding matrix
([vocab_size, d_model]) at the start, and that's the entire "weights" of an
LLM — there is no other kind of stored knowledge. Everything the model
"knows" is encoded as the specific numbers in these matrices.
Parameter count, worked out: for one feed-forward sub-layer with
d_model = 4096 and hidden size 4 x d_model = 16384:
up-projection: 4096 x 16384 = 67,108,864 weights
down-projection: 16384 x 4096 = 67,108,864 weights
That's ~134M weights in one sub-layer of one block. A 32-block model repeats this (plus attention) 32 times — this is how you get from "a matrix multiply" to "70 billion numbers" without anything mysterious happening in between. It is repetition of the same small idea, at scale (Chapter 16's theme again).
Precision: the same number, different numbers of bits
A weight is a real number, and a computer has to pick how many bits to spend storing each one. This choice is independent of the model's architecture — you can take the exact same trained weights and re-save them at a different precision.
| Format | Bits | Bytes/weight | 7B model memory | Typical use |
|---|---|---|---|---|
| float32 (fp32) | 32 | 4 | 28 GB | training (Chapter 15's gradients need the range) |
| bfloat16 (bf16) | 16 | 2 | 14 GB | training and inference on modern GPUs |
| float16 (fp16) | 16 | 2 | 14 GB | older inference stacks |
| int8 | 8 | 1 | 7 GB | quantized inference |
| int4 | 4 | 0.5 | 3.5 GB | quantized inference, consumer GPUs |
bf16 and fp16 are both "16-bit floats" but split those bits differently:
bf16 keeps fp32's wide exponent range (fewer mantissa bits, so less
precision per number but the same dynamic range training needs) — this is
why bf16 became the default for training large models, while fp16 was more
prone to overflow.
The memory formula that matters in practice:
memory_bytes = num_parameters x bytes_per_parameter
For a 7B model at bf16: 7,000,000,000 x 2 = 14,000,000,000 bytes ≈ 14 GB.
This is before you add activation memory or the KV cache (Chapter 38) — the
weights are usually the smaller part of a serving system's memory budget once
requests are flowing, but they're the fixed cost you pay just to load the
model at all.
File formats: what you actually download
| Format | What it is | Notes |
|---|---|---|
.bin / .pt |
Python's pickle format |
Can execute arbitrary code on load — never load an untrusted .pt file |
.safetensors |
A flat buffer + a JSON header describing tensor names, shapes, dtypes, and byte offsets | The modern default: safe (no code execution), and can be memory-mapped |
.onnx |
A full computation graph and its weights, in a cross-framework format | Lets you run a PyTorch-trained model without PyTorch installed |
.gguf |
llama.cpp's format, built for quantized inference on CPUs/consumer GPUs | What Ollama pulls under the hood |
plain .json / arrays |
Fine for a handful of small matrices | What we'll export in Chapter 68 — no format is needed once a model is only 76 numbers |
safetensors mattering is worth pausing on: loading a model is mostly
memory-mapping a file, not "running a program." The header tells the
runtime "bytes 0–67108863 are a [4096, 16384] float32 tensor named
layers.0.mlp.up_proj.weight," and the runtime points a tensor directly at
that region of the file. This is also why big models are slow to load from a
network but fast to load from local disk — it's I/O-bound, not
compute-bound.
Worked example: scale, made concrete
| Model | Parameters | bf16 size | Where it fits |
|---|---|---|---|
| Chapter 68's distilled weather model | 76 | 304 bytes (+ header) | A <script> tag |
| Chapter 9's spam filter | ~30 | ~120 bytes | A dozen lines of Python |
| DistilBERT (Chapter 67) | 66M | 264 MB | A laptop, easily |
| Llama-3-8B | 8B | 16 GB | One consumer GPU (barely, at bf16) |
| GPT-4-class models | not published, believed ~10^12 | terabytes | A data-center cluster |
The point of this table: "model weights" is one concept spanning nine orders of magnitude. The matrix-multiply-plus-bias mechanics in this chapter are identical at every row.
Practice (10 min) — inspect a real weights file's header
safetensors headers are plain JSON — you can read one without installing
any ML library:
import json, struct
# Point this at any .safetensors file you have locally
# (e.g. one downloaded by a HuggingFace `transformers` model).
path = "model.safetensors"
with open(path, "rb") as f:
header_len = struct.unpack("<Q", f.read(8))[0] # first 8 bytes: header length
header = json.loads(f.read(header_len))
for name, info in list(header.items())[:10]:
if name == "__metadata__":
continue
print(f"{name:50s} {info['dtype']:8s} {info['shape']}")
You'll see exactly the picture this chapter described: named tensors, each with a shape and a dtype, no code, just data.
Common confusions
- "Parameters" and "weights" are used almost interchangeably. Strictly, a layer's parameters are its weights and biases; in casual usage ("a 7B-parameter model") everyone means the full count of both.
- A bigger file is not a "smarter" model — at a fixed parameter count, fp32 vs int4 is the same numbers, stored with different precision. Compare DistilBERT (66M params, genuinely different, smaller weights) against BERT-base saved at int4 (110M params, same numbers, fewer bits) — very different things that both result in a "smaller file."
- Weights are not the training data. Nothing in a
.safetensorsfile is a training example — it's the result of gradient descent (Chapter 10) having compressed patterns across billions of examples into a fixed-size set of numbers.
Check yourself
- What are the two things a
Linearlayer's weights consist of? - Why does bf16 use the same amount of memory as fp16 but behave differently during training?
- Why is loading a
.safetensorsmodel mostly an I/O operation rather than a compute operation?
Further reading
- Docs: huggingface.co/docs/safetensors — format spec and rationale (including why pickle is unsafe).
- Spec: github.com/ggerganov/ggml/blob/master/docs/gguf.md — the GGUF format used by llama.cpp/Ollama.
- Tool: onnx.ai — the ONNX format and runtime for cross-framework inference.
- Article: "Introducing Safetensors" — Hugging Face blog, on why they moved the ecosystem away from pickle.