Frontend Labs
⋯

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 length out_features
  • y — 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 .safetensors file 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

  1. What are the two things a Linear layer's weights consist of?
  2. Why does bf16 use the same amount of memory as fp16 but behave differently during training?
  3. Why is loading a .safetensors model 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.

Generated from real-life-ai-example-v1.md · 8 Oct 2026 16:36