Compression & distillation, section 1 of 2, 6h

Making a model smaller

By the end: You can cut serving cost substantially while keeping quality you can measure.

  • Distillation
  • Pruning
  • Quantization in this context
  • Measuring what you lost

New to compression & distillation? A large model is slow and expensive to run. Compression produces a smaller one that behaves nearly the same, by three main routes. Distillation trains a small model to copy a large model's outputs, rather than learning from the original labels. Pruning deletes the weights that were contributing almost nothing. Quantization stores each number at lower precision. Why it matters

A model that is accurate and too expensive to serve is not finished. This section covers the three ways of making one smaller — training a small model to imitate a large one, removing weights that contribute little, and storing the weights at lower precision — and what each actually buys on real hardware, which is often less than the numbers suggest. The discipline is in the last step: every one of these trades quality for cost, so you need a measurement that tells you what you gave up before it ships.

Which lever, and in what order#

The three levers do different jobs and they are not interchangeable. Quantization changes how each number is stored and nothing else; the model has the same shape and the same arithmetic, done at lower precision. Pruning deletes weights, either individually or in whole units. Distillation throws the model away and trains a different, smaller one to behave like it. Read the table as a recognition rule, then the sections below measure each claim.

symptom                                      reach for
model will not fit in the memory you have    quantization, then a smaller model
generation is slow, one token at a time      quantization (it is bandwidth bound)
batch throughput is too low                  structured pruning or a smaller model
cost per request on a hosted API is too high distillation onto a small open model
you have a big model and one narrow task     distillation
the model is already small and still slow    none of these; look at the serving stack

Order of effort matters as much as order of effect. Quantization needs no training data and no training run, so it is hours of work. Pruning needs a fine-tuning run to recover what it broke, so it is days. Distillation needs data, a training run, an eval and a decision about licensing, so it is weeks. Start at the top.

Everything below runs against one trained model, so the numbers can be compared with each other. It is a two-hidden-layer network of width 512 on a synthetic tabular problem: 284,674 parameters, test accuracy 0.9467.

import torch, torch.nn as nn
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler

X, y = make_classification(n_samples=12000, n_features=40, n_informative=15,
                           n_redundant=10, class_sep=0.8, flip_y=0.03, random_state=0)
Xtr, Xte, ytr, yte = train_test_split(X, y, test_size=4000, random_state=0)
scaler = StandardScaler().fit(Xtr)
as_t = lambda a: torch.tensor(scaler.transform(a), dtype=torch.float32)
Xtr, Xte = as_t(Xtr), as_t(Xte)
ytr, yte = torch.tensor(ytr), torch.tensor(yte)

def build(width):
    return nn.Sequential(nn.Linear(40, width), nn.ReLU(),
                         nn.Linear(width, width), nn.ReLU(), nn.Linear(width, 2))

def fit(m, epochs=25, lr=3e-3, seed=0):
    torch.manual_seed(seed)
    opt = torch.optim.AdamW(m.parameters(), lr=lr, weight_decay=1e-2)
    g = torch.Generator().manual_seed(seed)
    for _ in range(epochs):
        m.train()
        perm = torch.randperm(len(Xtr), generator=g)
        for i in range(0, len(Xtr), 128):
            idx = perm[i:i + 128]
            opt.zero_grad()
            nn.functional.cross_entropy(m(Xtr[idx]), ytr[idx]).backward()
            opt.step()
    m.eval()
    return m

def accuracy(m):
    with torch.no_grad():
        return (m(Xte).argmax(1) == yte).float().mean().item()

torch.manual_seed(0)
teacher = fit(build(512))
print(sum(p.numel() for p in teacher.parameters()), round(accuracy(teacher), 4))
# 284674 0.9467

Quantization: what it does to the numbers#

Store weights at lower precision. Usually the best ratio of saving to effort, since it needs no retraining. 8 bit is close to free; 4 bit costs something real and task-dependent.

Concretely, a weight in fp32 is a 32-bit floating point number that can represent a huge range at fine resolution. An 8-bit integer has 256 values. Quantization picks one step size for a block of weights, divides every weight by it, rounds to the nearest integer, and stores the integers plus the step size. Getting a weight back means multiplying the integer by the step size, which is not the number you started with.

def quantize(W, bits):
    """Symmetric per-tensor quantization: integers, step size, and the values you get back."""
    qmax = 2 ** (bits - 1) - 1
    scale = W.abs().max() / qmax
    q = torch.clamp(torch.round(W / scale), -qmax - 1, qmax)
    return q, scale, q * scale

W = teacher[0].weight.data                       # the 512 x 40 first layer
q, scale, back = quantize(W, 8)
print("largest weight", round(W.abs().max().item(), 4), "step size", round(scale.item(), 6))
print("fp32", [round(v, 6) for v in W[0, :5].tolist()])
print("int8", [int(v) for v in q[0, :5].tolist()])
print("back", [round(v, 6) for v in back[0, :5].tolist()])
# largest weight 0.5442 step size 0.004285
# fp32 [-0.038751, 0.016033, -0.121082,  0.002565, -0.158385]
# int8 [-9,         4,       -28,         1,       -37]
# back [-0.038569,  0.017142, -0.119992,  0.004285, -0.158561]
Every weight is now a multiple of 0.004285. The fourth weight, 0.002565, is less than one step, so it rounds to 1 and comes back as 0.004285: a 67% error on that weight.

That last observation is the whole story of quantization error. The step size is set by the largest weight in the block, and small weights are the ones it hurts. Measured across the matrix, and then across the whole model.

import copy

for bits in (8, 4):
    _, _, back = quantize(W, bits)
    err = (back - W).abs()
    rel = (back - W).pow(2).mean().sqrt() / W.pow(2).mean().sqrt()
    print(bits, round(err.max().item(), 5), round(err.mean().item(), 5), round(rel.item(), 4))
# 8  0.00214  0.00107  0.0088      error is 0.88% of the typical weight size
# 4  0.03887  0.01949  0.1605      error is 16% of it

for bits in (8, 4):
    m = copy.deepcopy(teacher)
    for layer in m:
        if isinstance(layer, nn.Linear):
            layer.weight.data = quantize(layer.weight.data, bits)[2]
    print(bits, round(accuracy(m), 4))
# 8  0.9467      identical to fp32
# 4  0.9457      one tenth of a point

So on this model int4 is nearly free, which is not what happens to large language models, and the difference is worth understanding rather than memorising. The damage from low-bit quantization is caused by outliers: one weight far larger than the rest drags the step size up and every ordinary weight then rounds to almost nothing. Large transformers have exactly that structure in some layers and activations. Here is the mechanism, forced by hand.

def rel_rms(a, b):
    return ((a - b).pow(2).mean().sqrt() / b.pow(2).mean().sqrt()).item()

Wo = W.clone()
Wo[0, 0] = W.abs().max() * 20                    # one weight twenty times bigger than any other
ordinary = torch.ones_like(W, dtype=torch.bool)
ordinary[0, 0] = False

print(round(rel_rms(quantize(W, 4)[2][ordinary], W[ordinary]), 4))
print(round(rel_rms(quantize(Wo, 4)[2][ordinary], Wo[ordinary]), 4))
s = Wo.abs().amax(dim=1, keepdim=True) / 7       # one step size per output unit
per_channel = torch.clamp(torch.round(Wo / s), -8, 7) * s
print(round(rel_rms(per_channel[ordinary], Wo[ordinary]), 4))
# 0.1605   int4, no outlier, one step size for the tensor
# 1.0000   int4, one outlier, one step size for the tensor: every ordinary weight rounds to zero
# 0.1033   int4, one outlier, one step size per output unit: the damage is contained
Measured on the ordinary weights only, with the outlier excluded from the error. One bad weight destroyed the other 20,479.

This is why real quantization schemes never use one step size for a whole model. Per-channel scaling gives each output unit its own step size; group-wise schemes give every block of 64 or 128 weights its own. That metadata costs a little: at group size 64 with a 16-bit scale per group, you are paying an extra 0.25 bits per weight, so int4 is really 4.25 bits. It is worth it.

What you actually buy is memory and bandwidth, and the arithmetic is a multiplication. A 7-billion-parameter model needs 4 bytes per weight in fp32, so 28 GB; 14 GB in fp16; 7 GB in int8; 3.5 GB in int4. That decides which accelerator it fits on, and it is also most of the latency story for generation, because producing one token reads every weight once and does very little arithmetic with each. Generation is bandwidth bound, so roughly halving the bytes roughly halves the time per token. Prompt processing and large-batch serving are compute bound instead, and there the gain depends on whether your kernels have low-precision arithmetic at all, or dequantize back to fp16 and do the maths as before. Measure rather than assume; a quantized model that dequantizes in the kernel saves memory and not time.

Pruning, and why structured wins in practice#

Remove weights that contribute little. Unstructured pruning zeroes individual weights, which reduces size and rarely speeds anything up because the hardware still processes the zeros. Structured pruning removes whole units or layers, which is a real speedup and a larger quality cost.

allw = torch.cat([l.weight.data.abs().flatten() for l in teacher if isinstance(l, nn.Linear)])

def unstructured(frac):
    """Zero the smallest frac of weights. Shapes are unchanged."""
    m = copy.deepcopy(teacher)
    thr = torch.quantile(allw, frac)
    for layer in m:
        if isinstance(layer, nn.Linear):
            layer.weight.data = layer.weight.data * (layer.weight.data.abs() > thr)
    return m

def structured(keep):
    """Keep the most useful hidden units and rebuild a genuinely smaller network."""
    l0, l2, l4 = teacher[0], teacher[2], teacher[4]
    imp1 = l0.weight.data.norm(dim=1) * l2.weight.data.norm(dim=0)
    k1 = torch.topk(imp1, keep).indices.sort().values
    imp2 = l2.weight.data[:, k1].norm(dim=1) * l4.weight.data.norm(dim=0)
    k2 = torch.topk(imp2, keep).indices.sort().values
    m = build(keep)
    m[0].weight.data, m[0].bias.data = l0.weight.data[k1], l0.bias.data[k1]
    m[2].weight.data, m[2].bias.data = l2.weight.data[k2][:, k1], l2.bias.data[k2]
    m[4].weight.data, m[4].bias.data = l4.weight.data[:, k2], l4.bias.data
    m.eval()
    return m

for frac in (0.5, 0.8, 0.9, 0.95, 0.99):
    print(frac, round(accuracy(unstructured(frac)), 4))
for keep in (256, 128, 64, 32):
    print(keep, sum(p.numel() for p in structured(keep).parameters()),
          round(accuracy(structured(keep)), 4))
unstructured, no fine-tuning        structured, no fine-tuning
50% zeroed     0.9455               512 -> 256   76,802 params   0.9240
80% zeroed     0.9240               512 -> 128   22,018 params   0.8662
90% zeroed     0.8508               512 ->  64    6,914 params   0.7552
95% zeroed     0.7235               512 ->  32    2,434 params   0.5500
99% zeroed     0.5065

baseline: 284,674 params, 0.9467
Measured. Half the weights can go for a fifth of a point; the last few per cent are the whole model.

Now the part that decides which one you use. Time all four on the same batch, single threaded.

dense, width 512          0.884 ms
95% of weights zeroed     0.923 ms     slower than dense
structured to width 128   0.146 ms     6.1x faster
structured to width 64    0.083 ms    10.7x faster
Measured, median of seven rounds of 50 forward passes on a batch of 1,024.

The 95%-sparse model is a fraction of a millisecond slower than the dense one it came from. It has the same tensor shapes, so the same multiply-accumulate operations happen; the zeros are multiplied like any other number, and the masking added a little work. This is the single most useful fact about pruning, and it is why the literature on unstructured sparsity and the practice of serving models diverge. Unstructured sparsity pays off only where something downstream exploits it: a sparse storage format that shrinks the file, or hardware with a sparsity mode, such as the 2:4 pattern some accelerators support, which requires exactly two of every four adjacent weights to be zero rather than any 50% you like.

Structured pruning is slower to recover from and is the one that pays. Fine-tuning after the cut is not optional: it is where the accuracy comes back.

                    after pruning   after 8 epochs   width trained
                                    of fine-tuning   from scratch
512 -> 128              0.8662          0.9450           0.9482
512 ->  64              0.7552          0.9370           0.9337
Measured. Fine-tuning recovers nearly all of it - and at width 128, training the small network from scratch was better than pruning into it.

That last column is the comparison people skip, and on this problem it kills the case for pruning at width 128. Pruning is worth the trouble when the large model encodes something the small one cannot learn on its own: not enough labelled data to train the small model directly, a very expensive pretraining run you are not going to repeat, or a compression ratio steep enough that training from scratch does not converge. Otherwise the honest answer is to train the smaller model.

Distillation#

Train a small model to imitate a large one's outputs. The small model learns from the large model's full probability distribution rather than only hard labels, which carries more information and is why it can approach the teacher's quality at a fraction of the size.

The biggest cost lever in LLM products

Use a strong model to produce high-quality outputs on your specific task, then train a much smaller model on those. For a narrow task the small model frequently matches the large one at a small fraction of the price, and unlike prompt tuning the saving is permanent.

Distillation earns its keep in one specific situation, and the measurement is in the next section of this topic. The short version: on a six-class problem with 8,000 labels available, a small network trained on those labels scored 0.8220 and the same network distilled from a large teacher scored 0.8294, which is three quarters of a point for a lot of machinery. With only 500 labels and 6,000 unlabelled inputs, the same small network scored 0.4854 on the labels alone, 0.7949 on the teacher's hard guesses for the unlabelled pool, and 0.8205 on the teacher's full distributions, which is 0.15 points off having all 8,000 real labels. Distillation converts a teacher plus unlabelled data into labelled data. Where you already have the labels, it buys very little.

Measure what you lost#

                  size    latency   your eval
teacher            26 GB    840 ms     0.91
distilled           1.4 GB   95 ms     0.88
distilled + int8    0.7 GB   61 ms     0.87

Whether losing 0.04 is acceptable is a product decision,
and it should be made with the numbers in front of you.
Illustrative shape. Produce your own before deciding.

Published compression benchmarks are aggregates and hide uneven degradation: some capabilities survive and others fall off sharply. Always measure on your own task.

Break the eval down rather than reporting one number. The pattern that shows up repeatedly is that easy, frequent cases survive compression and rare or multi-step cases do not, so an aggregate that is 96% easy cases moves by half a point while the capability your product depends on has halved. Slice by input length, by task type, by language, and by whatever your traffic is heavy in. Compare the compressed and original models on the same inputs and look at where they disagree, not only at the totals.

When compression is not the answer#

  • The cost is in the prompt, not the model. If two thousand tokens of retrieved context go in on every request, halving the model does not halve the bill. Shorten the input first, and cache what repeats.
  • Traffic is low. A compression project is weeks of engineering; below a few million requests a month a larger instance is cheaper than your time, and the comparison is worth doing explicitly in money.
  • The bottleneck is elsewhere. If p50 latency is 900 ms and 700 of it is a database call, a 6x faster model buys 170 ms.
  • Quality has no headroom. If you are already at the edge of acceptable, there is nothing to trade. Fix quality first; compression only ever spends it.
  • A smaller off-the-shelf model already does the job. Try it before building anything. It costs an afternoon and it frequently ends the project.

Where people go wrong

Trusting published compression benchmarks. They average over tasks and hide that the capability you depend on may be one of the ones that collapsed.

If this comes up in an interview

What is knowledge distillation?

Training a small student model to reproduce a large teacher's outputs. Learning from the teacher's full probability distribution carries more information than hard labels, so the student can approach the teacher's quality at a fraction of the size.

Why does unstructured pruning often not speed anything up?

It zeroes individual weights but leaves the tensor shape unchanged, so the hardware still processes the zeros. Structured pruning removes whole units or layers, which is a genuine speedup at a larger quality cost.

How do you decide whether a compressed model is acceptable?

Measure on your own eval rather than trusting published aggregates, because degradation is uneven across capabilities. Then compare size, latency and quality together, which makes it a product decision with numbers rather than an argument.

Build this: Distil a large model onto a small one for one narrow task using its outputs as training data. Compare size, latency and quality, and work out the volume at which the switch pays for the effort.

This is one section of Compression & distillation, 10 hours across 2 sections. Read it in full to get the whole topic in order.