Course contents 27 / 37

27 / 37 · Adaptation and Efficient Models

Quantization

Store weights (and sometimes activations) as 8- or 4-bit integers with a scale and a zero point instead of 32-bit floats. The note derives the affine map and the integer matrix product, measures memory, accuracy and speed on a 67 M-parameter classifier through three implementations, explains GPTQ's calibration-based weight quantization, and places distillation and pruning next to it.

On this page 14

A trained model is a set of matrices of 32-bit floats, and serving it means holding them in memory and multiplying by them for every token. A 7 B-parameter model is 28 GB in 32-bit and 14 GB in 16-bit; at generation time the cost is dominated by reading those bytes, once per token. Quantization stores the numbers in fewer bits. This note derives what that does to a matrix product, measures it on a small model, and explains how the method scales to large language models.

Notation. A real value xx in a known range [a,b][a, b]; its integer code xq∈[aq,bq]x_q \in [a_q, b_q] (for int8, [−128,127][-128, 127]); scale ss and zero point zz. For a linear layer, Y=XW+bY = XW + b with X∈Rn×dX \in \mathbb R^{n \times d}.

Baseline: 32-bit floats everywhere#

Every weight is a float32: 4 bytes, about 7 significant digits, a range of 10±3810^{\pm 38}. Nothing in a trained network uses that precision; weights of a layer typically lie in a range of a few units and are not distinguishable at the third decimal. Half precision (float16 or bfloat16) halves the memory with no visible loss and is the default for serving. This note goes below 16 bits with integer codes, and integers need a map. Eight-bit floating-point formats are another route: FP8 tensor cores arrived with NVIDIA’s Hopper (H100) GPUs in 2022, and which formats run fast depends on the hardware generation. The formats covered here are integer affine codes (int8, int4) and the NF4 codebook.

The affine map#

xq=round⁡ ⁣(xs+z),x≈s (xq−z),s=b−abq−aq,z=round⁡ ⁣(b aq−a bqb−a).x_q = \operatorname{round}\!\Big(\frac{x}{s} + z\Big), \qquad x \approx s\,(x_q - z), \qquad s = \frac{b - a}{b_q - a_q}, \quad z = \operatorname{round}\!\Big(\frac{b\,a_q - a\,b_q}{b - a}\Big).

The scale is the real width of one integer step; the zero point is the integer that represents 0.00.0, chosen so that a↦aqa \mapsto a_q and b↦bqb \mapsto b_q. Codes are clipped to [aq,bq][a_q, b_q]. The round-trip error ∣x−s(xq−z)∣|x - s(x_q - z)| is at most s/2s/2 for xx inside the range, and unbounded outside it, so the choice of [a,b][a, b] is the whole art: a range that is too wide wastes codes, a range that is too narrow clips the outliers that matter most.

Worked example#

Range [a,b]=[−2,3][a, b] = [-2, 3], int8. Then s=5/255=0.0196s = 5/255 = 0.0196 and z=round⁡((3⋅(−128)−(−2)⋅127)/5)=round⁡(−26.0)=−26z = \operatorname{round}((3 \cdot (-128) - (-2) \cdot 127)/5) = \operatorname{round}(-26.0) = -26.

xxx/s+zx/s + zxqx_qs(xq−z)s(x_q - z)error
−2-2−128.0-128.0−128-128−2.000-2.0000
0.370.37−7.13-7.13−7-70.37250.37250.00250.0025
1125.025.025251.0001.0000
33127.0127.01271273.0003.0000
Figure

Affine int8 quantization of the range [−2, 3]

Real values mapped to int8 levelsTwo horizontal number lines. The top one spans the real range from minus 2 to 3; the bottom one spans the integers from minus 128 to 127. Arrows connect sample real values to their integer levels, and labels give the rounded integer and the dequantized value.real x ∈ [-2, 3]-2-10123int8 code ∈ [-128, 127], z = -26-128-64064127z ↔ 00.37-7 → 0.3725Real values mapped to int8 levelsTwo horizontal number lines. The top one spans the real range from minus 2 to 3; the bottom one spans the integers from minus 128 to 127. Arrows connect sample real values to their integer levels, and labels give the rounded integer and the dequantized value.real x ∈ [-2, 3]-2-10123int8 code ∈ [-128, 127], z = -26-128-64064127z ↔ 00.37-7 → 0.3725
With a = −2 and b = 3 the scale is s = (b − a)/255 = 0.0196 and the zero point z = -26, so that x = s (x_q − z) maps −128 to -2 and 127 to 3. Each real value (top line) is rounded to the nearest of the 256 integer levels (bottom line); dequantizing it lands within s/2 = 0.0098 of the original. 0.37 becomes -7 and comes back as 0.3725. Values outside [a, b] are clipped to the ends.

What happens to a matrix product#

Quantize the input XX (scale sXs_X, zero zXz_X), the weight WW (sW,zWs_W, z_W) and the bias. Substituting X=sX(Xq−zX)X = s_X(X_q - z_X) and W=sW(Wq−zW)W = s_W(W_q - z_W) into Yij=bj+∑kXikWkjY_{ij} = b_j + \sum_k X_{ik} W_{kj} and expanding the product of two differences:

Yij=sb(bq,j−zb)+sXsW[∑kXq,ikWq,kj−zW∑kXq,ik−zX∑kWq,kj+d zXzW].Y_{ij} = s_b (b_{q,j} - z_b) + s_X s_W \Big[\sum_k X_{q,ik} W_{q,kj} - z_W \sum_k X_{q,ik} - z_X \sum_k W_{q,kj} + d\, z_X z_W\Big].

Everything inside the bracket is integer arithmetic: an int8 × int8 matrix product accumulated in int32, two row and column sums, and a constant. The floats appear only in the final rescaling by sXsWs_X s_W and the bias. The output can be re-quantized to (sY,zY)(s_Y, z_Y) by applying the map once more. This is the identity that makes quantization a speed-up rather than only a memory saving: when the hardware has fast int8 matrix units (modern GPUs and CPUs do), the bracket runs several times faster than the float product, and the weights read from memory are a quarter the size.

Figure

A quantized linear layer

An integer matrix product with one rescalingThe float input X is quantized with its own scale and zero point; the weights are quantized in advance and stored in int8. The integer product accumulated in int32, the corrections by row and column sums and a constant, then one multiplication by the product of the two scales and the bias give the float output Y.Xinput, floatquantizescale and zero of XW, int8quantized in advanceint8 × int8int32 accumulatesums, constantzero points× both scales + boutput Y, floatAn integer matrix product with one rescalingThe float input X is quantized with its own scale and zero point; the weights are quantized in advance and stored in int8. The integer product accumulated in int32, the corrections by row and column sums and a constant, then one multiplication by the product of the two scales and the bias give the float output Y.Xinput, floatquantizescale and zero of XW, int8quantized in advanceint8 × int8int32 accumulatesums, constantzero points× both scales + boutput Y, float
The formula of the text as a pipeline. Blue blocks are integer arithmetic: the int8 × int8 product accumulated in int32, the corrections by row and column sums times the zero points, and the constant term. Floats appear only at the ends: the input is quantized from its range, and the result is rescaled once by the product of the two scales and the bias is added. The weights are quantized in advance and read from memory at a quarter of their float32 size; the speed-up comes from a fast int8 × int8 kernel on the target hardware.

Two things are required for the speed-up to appear. The kernel must exist for the hardware, and the activation ranges must be known. Dynamic quantization measures the range of XX at run time, per batch, and quantizes weights ahead of time; it needs no calibration data and pays a small cost per batch. Static quantization fixes the activation ranges in advance from a calibration set of a few hundred inputs, so that the whole graph runs in integers; it is faster and more fragile. Quantization-aware training simulates the rounding during fine-tuning so that the weights adapt to it; it recovers accuracy at the lowest bit widths at the cost of a training run.

Measurements on a small classifier#

The model is DistilBERT (66.96 M parameters), fine-tuned on IMDb sentiment for two epochs; the test set is 25,000 reviews. Baseline: 0.249 GiB in float32, accuracy 0.9313. Memory counts parameters and buffers and is given in GiB (2302^{30} bytes).

ImplementationWhat is quantizedMemory, GiBAccuracyTime
float32nothing0.2490.931324.5 s per batch of 64 (CPU); 2 min 4 s for the test set (GPU)
Hand-written int8 linear layers (the formula above)linear weights and inputs0.129—slower than float (no int kernel)
PyTorch dynamic quantization (torch.ao)linear layers, int80.089, an undercount—20.4 s per batch (CPU)
bitsandbytes 8-bit (LLM.int8())linear layers, int8 on GPU0.0850.92663 min 50 s for the test set (GPU)

Reading the table:

  • Memory halves with the hand-written layers: the embedding tables, 36 % of the parameters, stay in float32 and become two thirds of the quantized model. The two library numbers need care. Dynamic quantization stores its int8 weights as packed objects that the count of parameters and buffers does not include, so its 0.089 is essentially the float embeddings alone; the true size is close to the hand-written 0.129. bitsandbytes loads everything it does not quantize in float16, which is why it goes below 0.129. Quantizing the embeddings as well would go further.
  • Accuracy drops by half a point at 8 bits with no retraining; this is typical for post-training int8 on encoders.
  • Speed is where implementations differ. The hand-written int8 layer is 24× slower than float32 (1.23 ms against 51 μs for a 256×128256 \times 128 input on a 128×128128 \times 128 layer), because PyTorch has no optimized int8 matmul on that CPU path and the sums are done in generic int32 ops; the arithmetic is right (mean absolute error 0.0011 against the float output) and the kernel is missing. The built-in dynamic quantization is 17 % faster than float on CPU. On the GPU, comparing full passes over the test set on the same device, the 8-bit model is slower: 3 min 50 s against 2 min 4 s in float32. For a 67 M-parameter model that is not limited by memory traffic, the int8 kernels and the outlier handling of LLM.int8() cost more than they save. Quantization pays off only with a kernel that wins on the target hardware at the model’s size; otherwise it is a memory tool.

The torch.ao path also does not run on GPU at all (the dynamic int8 linear op is CPU-only), which is one reason practitioners use the bitsandbytes, GPTQ and AWQ ecosystems for Transformers rather than the framework’s own quantization.

Weight-only quantization for large language models#

Generation with a large decoder is memory-bound: each token needs every weight read once and does little arithmetic per byte. Reducing the bytes is the lever, so LLM quantization is usually weight-only: weights in 4 or 8 bits, activations kept in 16-bit, with kernels that dequantize weights on the fly inside the matrix product. Two schemes dominate:

LLM.int8() (Dettmers et al., 2022) quantizes weights and activations to int8 per row and column, except that a few activation columns with outliers (large magnitudes that appear in models above a few billion parameters) are kept in 16-bit and multiplied separately. Without the outlier path, 8-bit accuracy collapses above 6 B parameters; with it, it matches 16-bit.

NF4 (Dettmers et al., 2023) is a 4-bit format whose 16 levels are placed at the quantiles of a standard normal rather than evenly, because trained weights are approximately normal and evenly spaced levels waste codes in the tails. Combined with double quantization (quantizing the per-block scales themselves to 8 bits) it stores a 7 B model in about 3.5 GB and is the base of QLoRA in the PEFT note.

GPTQ: quantizing weights with calibration data#

Rounding every weight to its nearest level ignores that the weights of a layer act together: a rounding error in one weight can be compensated by adjusting the others. GPTQ (Frantar et al., 2023) does this compensation layer by layer.

This section follows the paper’s orientation. The layer computes WXcalW X_{\text{cal}} with W∈Rdout×dinW \in \mathbb R^{d_{\text{out}} \times d_{\text{in}}}, the transpose of the WW above, and Xcal=X⊤∈Rdin×NX_{\text{cal}} = X^\top \in \mathbb R^{d_{\text{in}} \times N} holds the NN calibration token vectors as columns. A row w∈R1×dinw \in \mathbb R^{1 \times d_{\text{in}}} of WW holds one output’s weights, and quant⁡(wq)\operatorname{quant}(w_q) is the weight wqw_q rounded to its nearest level.

  1. Run a small calibration set (128 sequences is the usual recommendation) through the model and collect XcalX_{\text{cal}}. Changing a row by δ\delta changes that output by δXcal\delta X_{\text{cal}}, so the squared output error is ∥δXcal∥2=12 δHδ⊤\|\delta X_{\text{cal}}\|^2 = \tfrac12\, \delta H \delta^\top with Hessian H=2XcalXcal⊤∈Rdin×dinH = 2 X_{\text{cal}} X_{\text{cal}}^\top \in \mathbb R^{d_{\text{in}} \times d_{\text{in}}}, the same for every row.
  2. Quantize the input coordinates (the columns of WW) one at a time, all rows at once because they share HH. Let FF be the set of coordinates not yet quantized and HFH_F the Hessian restricted to them. Rounding coordinate qq moves it from wqw_q to quant⁡(wq)\operatorname{quant}(w_q), and the update of the coordinates in FF that minimizes the output error with that coordinate fixed is the Optimal Brain Surgeon step (Hassibi and Stork, 1993; Frantar et al., 2023, Section 3, Eq. 2): δF=−wq−quant⁡(wq)[HF−1]qq (HF−1):,q.\delta_F = -\frac{w_q - \operatorname{quant}(w_q)}{[H_F^{-1}]_{qq}}\, (H_F^{-1})_{:,q}. Its qq-th entry is −(wq−quant⁡(wq))-(w_q - \operatorname{quant}(w_q)), which puts coordinate qq exactly on its level; then qq leaves FF.
  3. Proceed through the coordinates in a fixed order, with a lazy batched update and a small dampening of HH for numerical stability.

Worked example (toy numbers). Two strongly correlated inputs with XcalXcal⊤=(10.90.91)X_{\text{cal}} X_{\text{cal}}^\top = \begin{pmatrix} 1 & 0.9 \\ 0.9 & 1 \end{pmatrix}, a row w=(0.3,0.4)w = (0.3, 0.4) and integer levels. Rounding each weight on its own gives (0,0)(0, 0), an error δ=(−0.3,−0.4)\delta = (-0.3, -0.4) and ∥δXcal∥2=0.09+0.16+2⋅0.9⋅0.12=0.466\|\delta X_{\text{cal}}\|^2 = 0.09 + 0.16 + 2 \cdot 0.9 \cdot 0.12 = 0.466. GPTQ rounds w1w_1 to 00. The inverse Hessian’s first column divided by its first entry is (1,−0.9)(1, -0.9), so δ=−0.3⋅(1,−0.9)=(−0.3,0.27)\delta = -0.3 \cdot (1, -0.9) = (-0.3, 0.27) and w2w_2 moves to 0.670.67, which rounds to 11. The result (0,1)(0, 1) has error (−0.3,0.6)(-0.3, 0.6) and ∥δXcal∥2=0.09+0.36−2⋅0.9⋅0.18=0.126\|\delta X_{\text{cal}}\|^2 = 0.09 + 0.36 - 2 \cdot 0.9 \cdot 0.18 = 0.126, under a third of the independent rounding. With the sign of the update flipped, w2w_2 would move to 0.130.13 and round back to 00.

The result is a 4-bit (or 3-bit) model whose layer outputs are as close as possible, in the least-squares sense on the calibration data, to the original’s. Scales are stored per group of 128 weights along a row (group_size=128), a compromise between per-tensor (coarse) and per-weight (no saving). The quantized weights are packed eight 4-bit codes per int32 (qweight), with the zero points packed likewise (qzeros), and specialized kernels (ExLlama) read them.

Calibration data matters. A 125 M-parameter model (OPT-125M) quantized to 4 bits with a few hundred WikiText-2 sequences as the calibration set generates fluent text (“Hello my name is Alex and I’m a 25 year old male…”); the same model calibrated on a single sentence generates My name is "M"""""""". One sentence of nn tokens gives nn token vectors, so its HH has rank at most nn, far below the layers’ input sizes (768 or 3,072 in OPT-125M); it is singular, and only the dampening makes it invertible. A plausible explanation is that the compensation then rests on a few poorly estimated directions and moves the remaining weights where the real inputs do not go. The run does not measure the spectrum of HH, so this is a hypothesis, as is the general advice that calibration data should be sufficient and diverse. A pre-quantized 7 B chat model from the Hub loads into 4-bit layers and generates normally.

Distillation and pruning#

Quantization keeps the architecture and shrinks the numbers. Two other compression routes change the architecture:

Knowledge distillation (Hinton, Vinyals and Dean, 2015) trains a smaller student to match a larger teacher’s output distribution, not only the hard labels:

L=α LCE(y,ps)+(1−α) T2 DKL(softmax⁡(zt/T) ∥ softmax⁡(zs/T)),\mathcal L = \alpha\, \mathcal L_{\text{CE}}(y, p_s) + (1 - \alpha)\, T^2\, D_{\mathrm{KL}}\big(\operatorname{softmax}(z_t / T)\,\|\,\operatorname{softmax}(z_s / T)\big),

where zt,zsz_t, z_s are teacher and student logits and the temperature T>1T > 1 softens both distributions so that the student also learns which wrong classes the teacher considers close. The T2T^2 factor keeps the gradient magnitude comparable across temperatures. DistilBERT itself, the model measured above, is BERT-base distilled to 6 layers with 40 % fewer parameters and 97 % of the GLUE score (Sanh et al., 2019). For language models, distillation also takes the form of training on a large model’s generated text (the Alpaca route of the instruction tuning note).

Pruning removes weights (unstructured) or whole heads, neurons or layers (structured). Unstructured sparsity saves memory only with sparse kernels; structured pruning gives a smaller dense model and needs retraining to recover.

Trade-offs#

BitsMemory vs fp16Typical quality changeNeeds
161×nonenothing
8 (weights + activations)½small (< 1 point here)int8 kernel; outlier handling above ~6 B
4 (weights only, GPTQ / NF4 / AWQ)¼small to moderate; grows for small modelscalibration data (GPTQ) or none (NF4); dequantizing kernel
3 and below< ¼noticeable; task-dependentcareful calibration, often QAT

Throughput follows memory only when the kernel exists, and accuracy results on one benchmark do not transfer to another: a model quantized with perplexity as the criterion can lose more on reasoning or long generation than the perplexity suggests. Any specific number for a current model is a snapshot; the mechanism and the trade-off shape are what to keep.

What changed#

Float modelQuantized model
Storage per weight32 or 16 bits8 or 4 bits + a scale per group
Matrix productfloatinteger bracket + one float rescale
Accuracyreferencesmall loss at 8 bits, more at 4
Speedreferencefaster only with a hardware kernel
Trainingnormalfrozen (PTQ) or simulated rounding (QAT); LoRA on top (QLoRA)

Limitations#

  • Range and outliers. A few large activations can force a wide range that wastes most codes; mixed precision for outlier columns is the standard patch.
  • Kernels. The memory saving is universal; the speed-up is not. Without a matching kernel, quantized layers can be slower than float.
  • Small models suffer more. Fewer parameters leave less redundancy to absorb rounding; 4-bit is routine at 7 B and risky at 100 M.
  • Benchmark drift. Quantization is evaluated on a metric; losses elsewhere are easy to miss.
  • Not reversible. The original precision is gone; keep the float checkpoint.

What you should remember#

  • Affine map: xq=round⁡(x/s+z)x_q = \operatorname{round}(x/s + z), x≈s(xq−z)x \approx s(x_q - z), with s=(b−a)/(bq−aq)s = (b - a)/(b_q - a_q); error ≤s/2\le s/2 inside the range, clipping outside.
  • A quantized linear layer is an integer matrix product plus row/column sums, rescaled once by sXsWs_X s_W.
  • DistilBERT on IMDb: 0.249 → 0.085 GiB and 0.9313 → 0.9266 at 8 bits; on the GPU the 8-bit model was slower than float32, because speed depends entirely on the kernel and the model size.
  • LLMs are quantized weight-only (int8 with outlier columns, NF4, GPTQ 4-bit with group size 128); GPTQ compensates rounding errors one input coordinate at a time using a Hessian from calibration data, and in this run a one-sentence calibration set ruined the output.
  • Distillation trains a small model on a large model’s soft outputs; pruning removes parameters; both change the architecture, quantization does not.

Notebook#

Two notebooks. Cell numbers count every cell, markdown included, from 1. Quoted numbers come from the run saved with the course; cells changed since then for current library versions have no saved output.

week12_pt0_Torch_and_ONNX_Quantization_+_PEFT.ipynb:

  • Cell 4 states the distillation loss; cells 6–18 load IMDb and a DistilBERT fine-tuned on it (now the public lvwerra/distilbert-imdb, so current accuracies differ slightly; the numbers here are from the course’s own checkpoint, trained for two epochs), build batches of 64, print 66,955,010 parameters, 0.249 GiB (memory is get_memory_footprint() divided by 2302^{30}), and accuracy 0.9313 (2 min 4 s over the test set on GPU).
  • Cell 19 derives the integer matrix product reproduced above; cells 20–22 implement QuantizedTensor (per-tensor min–max range with a 10−610^{-6} margin) and QuantizedLinear; cells 24–29 time a 128→128128 \to 128 layer (51 μs float, 1.23 ms quantized) and measure the mean absolute error 0.0011; cells 31–32 replace every linear layer and print 0.129 GiB.
  • Cells 36–44 apply torch.ao.quantization.quantize_dynamic (0.089 GiB as counted by get_memory_footprint(), which skips the packed int8 weights; 20.4 s against 24.5 s per batch on CPU); cell 46 shows the GPU failure of the dynamic int8 op (it runs only when a CUDA device is present).
  • Cells 50–57 load the model with BitsAndBytesConfig(load_in_8bit=True): 0.085 GiB, accuracy 0.9266, 3 min 50 s over the test set on GPU; the markdown after it claims a 1.5× speed-up by comparing one GPU batch (3.6 s) with one CPU batch; the same-device comparison is the two full test-set runs on GPU (3 min 50 s against 2 min 4 s).
  • Cells 59–69 run static quantization with ONNX Runtime: export to ONNX, min–max ranges from 200 padded calibration examples with LayerNorm, GELU, residual additions and softmax kept in float, the accuracy of the quantized model, and dynamic int8 through the same pipeline for comparison. The markdown in cell 68 reports about 0.80 for static int8 against 0.91 for the float model on the same subset of reviews, the cost of plain min–max calibration; the course version of these cells was marked as not working and had no outputs.
  • Cells 71–76 are the QLoRA template described in the PEFT note.

week12_pt1_Distillation_and_GPTQ_Quantization.ipynb:

  • Cells 19–23 quantize facebook/opt-125m to 4 bits with GPTQConfig(bits=4, group_size=128, dataset="wikitext2", desc_act=False), inspect the packed qweight and qzeros int32 buffers, and generate the fluent sentence quoted above.
  • Cells 26–28 repeat with a one-sentence calibration set and generate the degenerate output.
  • Cells 32–38 load a pre-quantized Llama-2-7B chat model, print the QuantLinear layers and the quantization config (4-bit, group size 128, ExLlama kernel), and generate 50 tokens.
  • Cells 41–51 fine-tune it with LoRA, as described in the PEFT note.

Next#

The course has now built, pretrained, adapted and compressed models. The next section turns to the tasks those models are used for and asks, for each, what the input and output are, which metric measures it, what the classical baseline was, and what the Transformer-era approach changed. The text classification note opens it with the task that has run through the whole course, and collects every AG News result in one table.

References#