Deep Learning · Module 25 of 26 · 8 concepts · about 19 min

Model Compression

Reducing model cost while measuring the quality that users actually lose or retain.

At work: Shrinking models so they run fast and cheaply, on servers or directly on phones and cameras.

Pruning, quantization, distillation and latency

What it is

Pruning removes individual weights, channels, heads, or entire structures; structured pruning is usually more likely to produce real hardware speedups than merely sparse tensors. Quantization stores and computes values with fewer bits, either after training or with quantization-aware training that teaches the model to tolerate rounding. Distillation trains a smaller student to match a teacher's soft outputs or intermediate representations, transferring useful dark knowledge beyond hard labels. Compression targets memory, latency, throughput, energy, or cost—not an abstract parameter count alone.

Why it matters

A smaller checkpoint can still be slow if kernels are unsupported or runtime is dominated by sequence length and memory movement. Benchmark the deployed path on representative inputs, hardware, batch sizes, and concurrency—not a synthetic single request. Quality evaluation should include calibration, rare slices, output format compliance, and task-specific human judgments where a headline accuracy number hides unacceptable regressions.

In the real world: Making a model small and fast enough to run on a phone or answer in milliseconds.

Visual example

Deep learning

Compression transforms a trained model, then checks quality and runtime on the real serving path.

Compression trade-off

  1. teacher / full modelreference quality and cost
  2. prune, quantize, or distilreduce weights or computation
  3. compressed modelsmaller artifact or faster kernels
  4. benchmark + evaluatequality, memory, latency, throughput

A compression result is only useful if its deployment metrics improve.

import torch

baseline = torch.load('model_fp32.pt', map_location='cpu')
size_mb = sum(p.numel() * p.element_size() for p in baseline.values()) / 1e6
print(f'checkpoint: {size_mb:.1f} MB')

# Compression is only real if this improves on the deployed path.
latency_p50, latency_p99 = benchmark(model, representative_requests)

Reporting accuracy after compression without testing calibration, rare slices, output quality, and latency. Compression can preserve a headline metric while breaking the cases that matter.

Why a trained model is too big to deploy

What it is

Training optimises for final accuracy under a compute budget; serving has entirely different constraints. A model has to fit in the memory of whatever hardware you can afford to run it on, load fast enough that autoscaling and cold starts are tolerable, and answer within a latency budget set by the product rather than by the model. A checkpoint that trained beautifully on an 80 GB accelerator may need to run on a CPU, a phone, or a GPU instance whose hourly cost has to be justified per request.

Why it matters

This is the motivation for the whole module, and it is an economics argument. Inference cost is paid per request, forever; training cost is paid once. So a model served a hundred million times a month has an inference bill that dwarfs what it cost to train, and a 4× reduction in serving cost is worth far more than a 4× reduction in training cost. The latency side is a product constraint rather than an engineering preference: a search ranker that takes 800 ms is not a slow feature, it is a feature nobody uses. Compression is how a model that is accurate enough becomes a model that is deployable at all.

In the real world: A model that needs a large GPU per request is too expensive to serve to millions of users.

# Why inference dominates the bill.
train_usd = 40_000                      # one run, paid once

requests_per_month = 100_000_000
latency_s = 0.040
gpu_usd_per_hour = 2.50

gpu_seconds = requests_per_month * latency_s
inference_usd_per_month = gpu_seconds / 3600 * gpu_usd_per_hour

print(f'training (once):     ${train_usd:>10,.0f}')
print(f'inference (monthly): ${inference_usd_per_month:>10,.0f}')

# training (once):     $    40,000
# inference (monthly): $     2,778
# ...and a 4x latency cut saves that, every month, forever.

Optimising the checkpoint size because it is the number you can see. Disk size affects cold starts; what the bill and the latency budget respond to is *computation on the serving path*, which a smaller file does not automatically reduce.

Quantization

What it is

Quantization stores weights — and often activations — with fewer bits, typically int8 instead of float32. Each tensor gets a scale and a zero point that map its float range onto the integer range, so a value is recovered as scale * (q - zero_point). Post-training quantization applies this to a finished model, needing only a small calibration set to observe activation ranges. Quantization-aware training instead simulates the rounding during fine-tuning so the model learns weights that survive it, which recovers most of the accuracy lost by the post-training route.

Why it matters

It is the highest-value compression technique because it gives three benefits from one change: the model is four times smaller, four times less data moves per operation, and integer arithmetic units are faster than floating-point ones. For the many deep-learning operations that are memory-bandwidth-bound rather than compute-bound, the bandwidth saving alone is most of the speedup. The accuracy cost is usually small and, importantly, *predictable*: int8 post-training quantization typically costs well under a point of accuracy on vision and language models, and quantization-aware training reduces that further. Below int8 — int4 and lower — the losses become task-dependent and have to be measured rather than assumed.

In the real world: Storing weights in 8 bits makes a model up to 4× smaller and faster, cutting serving costs.

Visual example

int8 round trip

One weight row through the int8 round trip. The error column is what quantization actually costs.

4 bytes each

(and 4 bytes of bandwidth per access)

1 byte each

(4x smaller, 4x less traffic)

scale * (q - zero_point)

how a float is recovered at inference

scale = max|w| / 127 = 0.00693
float32int8recoverederror
w[0]0.881270.88010.0001
w[1]-0.312-45-0.31190.0001
w[2]0.004110.00690.0028
w[3]0.0009000.0009

per-channel scales

one outlier no longer ruins the whole tensor

Large weights survive almost exactly; small weights lose the most relative precision

The arithmetic

scale: max|w| / 127 = 0.88 / 127 = 0.00693

w[0]: 0.8800 / 0.00693 = 127.0 -> 127, recovers to 0.8801

w[3]: 0.0009 / 0.00693 = 0.13 -> 0, recovers to exactly 0

why it works: networks are robust to small weights being rounded away; they contributed little

why outliers hurt: one value of 8.8 would make scale 0.0693 and round w[1] to -5 levels

Why this matters

Seeing the round trip on four numbers makes both the mechanism and its failure mode obvious: the scale is set by the largest value, so precision for everything else is hostage to the outliers.

Common misconception

Wrong idea: int8 quantization means the model computes with 8-bit accuracy everywhere.

Correct idea: it means values are stored in 8 bits with a per-tensor or per-channel scale. Accumulation still happens in int32 or fp32, which is why the accuracy cost is far smaller than 8 bits of precision would suggest.

import torch

# The mapping, explicitly, for one tensor.
def quantize_int8(x):
    scale = x.abs().max() / 127.0
    q = torch.clamp((x / scale).round(), -127, 127).to(torch.int8)
    return q, scale

def dequantize(q, scale):
    return q.to(torch.float32) * scale

w = torch.randn(4096, 4096)
q, scale = quantize_int8(w)
error = (dequantize(q, scale) - w).abs().mean()

w.numel() * 4 / 1e6, q.numel() * 1 / 1e6     # 67.1 MB -> 16.8 MB
error.item()                                   # ~0.0016 mean absolute error

# In practice, use the framework path:
qmodel = torch.ao.quantization.quantize_dynamic(
    model, {torch.nn.Linear}, dtype=torch.qint8,
)

Quantizing a model with one outlier-heavy layer using a single per-tensor scale. One extreme value stretches the range so every ordinary value collapses into a handful of integer levels; per-channel scales exist for exactly this and cost almost nothing.

Unstructured versus structured pruning

What it is

Pruning removes parameters that contribute little, usually judged by magnitude. Unstructured pruning zeroes individual weights wherever they are, which can remove 80 to 90% of a network's weights with small accuracy loss. Structured pruning removes whole units — a channel, a filter, an attention head, a whole layer — which is much coarser and therefore costs more accuracy per parameter removed.

Why it matters

The distinction is the single most commonly missed point in compression, and it is about hardware rather than accuracy. An unstructured-pruned tensor is *the same shape* with zeros in it. A dense matrix multiply does exactly as much work as before, because the hardware does not know or care that some operands are zero — so 90% sparsity gives 0% speedup and 0% memory saving unless you have a sparse kernel and a runtime that supports it. Structured pruning removes a dimension, so the tensor is genuinely smaller and *every* piece of hardware runs it faster. This is why structured pruning dominates in practice despite being the less impressive technique on paper.

In the real world: Removing whole parts of a model is what actually speeds it up on real hardware.

Visual example

Sparsity vs shape

Same fraction of weights removed, completely different outcome. Only the second row is faster anywhere.

Impressive, and useless

(on ordinary dense hardware)

Modest, and real

(smaller tensors, faster everywhere)

Pruning a (1024, 1024) layer
resulting shapedense speedupaccuracy cost
unstructured 90%(1024, 1024), 90% zeros1.0x — none-0.4 pt
structured 50%(512, 1024)1.9x-1.8 pt

needs a sparse kernel

plus hardware support, e.g. 2:4 structured sparsity

Structured pruning costs 4× more accuracy and is usually still the right choice

Why this matters

The counterintuitive ranking is the whole lesson: the technique that removes fewer parameters and costs more accuracy wins, because it is the only one that changes a tensor shape.

Common misconception

Wrong idea: more sparsity means a smaller, faster model.

Correct idea: sparsity only helps if something exploits it. A zero stored in a dense float32 tensor occupies four bytes and costs a full multiply, exactly like any other value.

import torch
import torch.nn as nn
import torch.nn.utils.prune as prune

layer = nn.Linear(1024, 1024)

# Unstructured: 90% of weights become zero. Shape is unchanged.
prune.l1_unstructured(layer, name='weight', amount=0.9)
(layer.weight == 0).float().mean().item()   # 0.90
layer.weight.shape                          # torch.Size([1024, 1024]) - still!

# Structured: remove 50% of the output channels. The shape shrinks.
keep = layer.weight.abs().sum(dim=1).topk(512).indices
smaller = nn.Linear(1024, 512)
smaller.weight.data = layer.weight.data[keep].clone()
smaller.bias.data = layer.bias.data[keep].clone()
smaller.weight.shape                        # torch.Size([512, 1024]) - genuinely smaller

Reporting '90% of parameters pruned' as a 90% reduction. Unless the zeros are removed from the tensor and run through a sparse kernel, the model is the same size on disk, in memory, and in latency — the zeros are just stored explicitly.

Knowledge distillation

What it is

Distillation trains a small student model to reproduce a large teacher's *output distribution* rather than the ground-truth labels. The loss is typically a weighted sum: ordinary cross-entropy against the hard labels, plus a KL divergence against the teacher's softened probabilities at a raised temperature. Some variants also match intermediate representations or attention patterns.

Why it matters

It works because the soft distribution carries far more information per example than a one-hot label does. A label says 'this is a 7'; a teacher's distribution says 'this is a 7, and it looks somewhat like a 1, and not at all like an 8' — encoding a similarity structure over classes that the hard label discards entirely. That extra signal is why a student trained by distillation can outperform the identical architecture trained from scratch on the same data: it is learning from a richer target. Raising the temperature matters for the same reason — at T = 1 a confident teacher is nearly one-hot and the extra information is squeezed out of the tail, so the softening is what makes it visible.

In the real world: Training a small, fast model to copy a big one, keeping most of the quality at a fraction of the cost.

Visual example

Soft targets

The teacher's tail is the extra signal. A hard label throws away every column but one.

One bit of information

(it is a 7; nothing else)

The similarity structure

(7 resembles 1, less so 9, barely 8)

softmax(logits / T)

raising T exposes the tail the teacher already knew

One image of a handwritten 7
p(7)p(1)p(9)p(8)
hard label1000
teacher T=10.940.040.0150.005
teacher T=40.520.240.160.08

This is why a distilled student can beat the same architecture trained from scratch

What the student learns from each row

hard label: one class per example. Everything about class geometry must be rediscovered

T=1: nearly one-hot: a confident teacher hides its own tail

T=4: the tail is legible, so each example teaches a ranking over all classes

Same data, same student architecture - richer target, better result

Why this matters

Distillation is often described as 'copying a bigger model', which makes the student sound necessarily worse. The soft-target view explains why it can be better: the target is more informative than the labels were.

Common misconception

Wrong idea: the student can at best match the teacher.

Correct idea: on the *training data* it is chasing the teacher, but the soft targets teach class structure that hard labels cannot, so the student often generalises better than the same architecture trained on labels alone.

import torch
import torch.nn.functional as F

def distillation_loss(student_logits, teacher_logits, labels, T=4.0, alpha=0.7):
    # Soft target: match the teacher's softened distribution.
    soft = F.kl_div(
        F.log_softmax(student_logits / T, dim=-1),
        F.softmax(teacher_logits / T, dim=-1),
        reduction='batchmean',
    ) * (T * T)          # T^2 keeps the gradient magnitude comparable

    # Hard target: ordinary supervised loss.
    hard = F.cross_entropy(student_logits, labels)
    return alpha * soft + (1 - alpha) * hard

teacher.eval()
for x, y in loader:
    with torch.no_grad():
        t_logits = teacher(x)                # frozen; no gradients
    loss = distillation_loss(student(x), t_logits, y)
    loss.backward()

Omitting the T * T factor. Dividing logits by T shrinks the gradient by roughly 1/T², so at T = 4 the soft term contributes sixteen times less than intended and distillation appears not to work.

Comparing compression techniques honestly

What it is

A compression result is four numbers, not one: model size, p50 latency, p99 latency, and quality on a real evaluation set. Any technique can look good if you report only the number it improves. The comparison also has to be made on the deployment path — the same hardware, runtime, batch size and concurrency the service will actually use — because a technique that halves size and doubles latency on your target hardware has made things worse.

Why it matters

The four-column view is what makes the choice obvious, and it usually is obvious once the table exists. It also exposes the case people miss: int8 quantization is nearly always the first thing to try, because it improves size *and* latency for a fraction of a point of accuracy, while pruning and distillation cost more quality and require work. And it makes the combinations visible — distillation and quantization attack different things, so a distilled-then-quantized model is often the real answer rather than either alone.

In the real world: Measuring real speed and accuracy on the target phone or server, not just model size.

Visual example

The real comparison

Four columns per technique. Read across, not down — every row wins on something.

Try this first

(4x smaller, 2.4x faster, -0.4 pt)

Worst trade here

(-1.8 pt for 1.5x speed)

p99, not p50

the tail is what users and SLAs experience

ResNet-50 class model, batch 8, one A10G
size MBp99 msaccuracy
fp32 baseline98410.762
int8 PTQ25170.758
structured prune 40%61270.744
distilled student24130.749
distil + int8670.745

distil then quantize

16x smaller, 6x faster, -1.7 pt

Quantization is almost always the first move; the rest depends on the budget

How to read this table

size: matters for cold starts, edge devices, and how many replicas fit on a host

p99 latency: the number the SLA is written against, not the median

accuracy: on a real eval set, with rare slices checked separately

composability: the last row is not a technique, it is two techniques attacking different costs

Reporting only 'size MB' would rank the last row first and the third row second - which the accuracy column contradicts

Why this matters

Almost every compression argument dissolves once the four columns are side by side. The table is cheap to build and it is what turns a preference into a decision.

import time
import numpy as np
import torch

@torch.no_grad()
def benchmark(model, requests, batch_size, warmup=10, iters=100):
    model.eval()
    batches = [requests[i:i + batch_size] for i in range(0, len(requests), batch_size)]
    for b in batches[:warmup]:
        model(b)
    times = []
    for b in (batches * iters)[:iters]:
        start = time.perf_counter()
        model(b)
        torch.cuda.synchronize()
        times.append((time.perf_counter() - start) * 1000)
    return {
        'p50_ms': float(np.percentile(times, 50)),
        'p99_ms': float(np.percentile(times, 99)),
        'size_mb': sum(p.numel() * p.element_size() for p in model.parameters()) / 1e6,
    }

for name, m in [('fp32', base), ('int8', quantized), ('distilled', student)]:
    print(name, benchmark(m, representative_requests, batch_size=8), evaluate(m))

Benchmarking at batch size 1 on an idle GPU. Serving runs under concurrency with batching, where the ranking of techniques can reverse — a model with a small compute footprint but poor kernel support can be fastest alone and slowest under load.

The trade all of these make

What it is

Quantization, pruning and distillation look like three different techniques, and at the level of what they trade they are one. Each gives up a small, bounded amount of model quality to gain a large, measurable amount of size, latency or cost. Quantization gives up precision in the weights; pruning gives up the least-used parameters; distillation gives up the capacity of the larger architecture. In every case the exchange rate is heavily in your favour — a few tenths of a point for a factor of four.

Why it matters

Recognising the single shape is what makes the trade arguable rather than uncomfortable. The instinct in machine learning is that accuracy is the goal, so anything that reduces it is a regression to be resisted. In production the goal is user outcome per unit cost, and that calculus is different: a model 0.4 points less accurate that responds in 17 ms instead of 41 ms is better for almost any interactive product, because latency affects every user on every request while those 0.4 points affect a small fraction of predictions marginally. The trade is usually worth it — and saying *why* it is worth it, in terms of the product rather than the metric, is what gets it approved.

In the real world: Every compression step gives up a little accuracy for speed and cost; the business decides how much.

# The trade, made explicit and arguable.
def compression_case(baseline, candidate, requests_per_month, gpu_usd_per_hour=2.5):
    acc_delta = candidate['accuracy'] - baseline['accuracy']
    saved_s = (baseline['p99_ms'] - candidate['p99_ms']) / 1000 * requests_per_month
    saved_usd = saved_s / 3600 * gpu_usd_per_hour
    return {
        'accuracy_cost_pt': round(acc_delta * 100, 2),
        'monthly_usd_saved': round(saved_usd),
        'p99_improvement_ms': baseline['p99_ms'] - candidate['p99_ms'],
        'affected_by_accuracy':
            f"~{abs(acc_delta) * requests_per_month:,.0f} predictions/month",
        'affected_by_latency': f"{requests_per_month:,} requests/month",
    }

# The last two lines are the argument: latency touches every request,
# accuracy touches a fraction of them.

Treating the accuracy column as sacred and the latency column as an engineering detail. They are both product metrics; the difference is that latency affects 100% of requests and a fractional accuracy change affects a fraction of them.

Compressing without re-validating

What it is

The failure that ends the module. A team quantizes or prunes a model, confirms the headline accuracy is within tolerance, and ships. What they did not check is that compression does not degrade uniformly: it disproportionately damages the rare, the long-tailed and the numerically delicate. Overall accuracy moves 0.3 points while the smallest class drops fifteen, calibration breaks so every confidence score is now wrong, or a structured output format the model used to emit reliably starts malforming under low precision.

Why it matters

The uniformity assumption is the bug. Compression removes capacity, and a model spends its capacity where the data is — so the parts removed first are the parts that served the least frequent inputs. An aggregate metric averaged over a distribution dominated by common cases is structurally incapable of showing this, which is why a compressed model can pass every dashboard check and fail exactly the users who are hardest to serve. The defence is a per-slice evaluation gate: the same evaluation you ran on the baseline, broken down by class, by segment and by calibration, with an explicit tolerance per slice rather than one tolerance on the mean.

In the real world: Shipping a quantized model without testing it can quietly hurt accuracy for some customers.

Visual example

Per-slice regression

Overall accuracy moves 0.4 points. The rarest class drops sixteen. Only one of these is on the dashboard.

fp32 baselineint4 aggressive quantization
where the capacity was removed51015200.40.50.60.70.80.91class frequency rank (1 = most common)accuracybaseline on the rarest classcommon classes: unchanged-16 pts, invisible in the mean

Why the mean cannot see it

class 1-8: carry ~85% of the traffic and are essentially unaffected

class 12-20: carry ~4% of the traffic and lose 6 to 16 points each

weighted mean: 0.913 -> 0.909. Passes any aggregate gate you would reasonably write

the gate that catches it: a per-slice tolerance, applied to every class separately

Why this matters

This is the closing argument of the module: compression removes capacity, and capacity is spent where the data is. The damage lands on the tail by construction, so a tail-blind evaluation will always pass it.

Common misconception

Wrong idea: if overall accuracy holds, the compressed model is equivalent.

Correct idea: overall accuracy is a frequency-weighted average, so it is dominated by the cases compression does not hurt. Equivalence has to be checked per slice or it has not been checked.

Weighted by frequency, overall accuracy fell from 0.913 to 0.909 — comfortably inside a 1-point tolerance.

import numpy as np

def compression_gate(baseline, candidate, eval_set, slice_tol=0.02, overall_tol=0.005):
    failures = []

    base_all, cand_all = evaluate(baseline, eval_set), evaluate(candidate, eval_set)
    if cand_all['accuracy'] < base_all['accuracy'] - overall_tol:
        failures.append(('overall', base_all['accuracy'] - cand_all['accuracy']))

    # The part people skip: every slice, with its own tolerance.
    for name, rows in slices(eval_set).items():
        b, c = evaluate(baseline, rows), evaluate(candidate, rows)
        if c['accuracy'] < b['accuracy'] - slice_tol:
            failures.append((name, b['accuracy'] - c['accuracy']))

    # And calibration, which aggregate accuracy cannot see at all.
    if abs(expected_calibration_error(candidate, eval_set)
           - expected_calibration_error(baseline, eval_set)) > 0.02:
        failures.append(('calibration', None))

    if failures:
        raise RuntimeError(f'compression regressed: {failures}')
    return cand_all

Reusing the calibration set as the evaluation set. Post-training quantization fits its activation ranges on the calibration data, so evaluating on it measures how well the ranges were fitted, not how the model behaves on anything new.