Quantization for training and inference
Before this lesson: lesson 2 (the roofline and the matmul intensity) and lesson 5 (number formats, rounding, FP8 scale factors). For the result you need the expectation of a continuous random variable (probability 2). The model view of quantization (GPTQ and AWQ in more detail, KV-cache quantization) will be in the LLMs track (in preparation). Time: about 70 minutes, plus the notebook.
Why this matters #
- Memory. A 70B-parameter model in bf16 has 140 GB of weights: two 80 GB GPUs just to hold them. In int4 with group scales (4.125 bits per weight, section 3) it needs about 36 GB.
- Bytes per step. LLM decoding reads every weight once per step (lesson 2). A 7B model at batch 1 needs at least ms per token in bf16 on an H100 SXM (2022), and about 1.1 ms in int4.
- Arithmetic. H100 tensor cores do 1,979 dense TFLOP/s in fp8 (and as many TOP/s in int8), twice the 989 of bf16.
So quantization buys bytes or FLOPs, and which one you need depends on the phase. Every large LLM deployment uses it: int4 weight-only models (GPTQ, AWQ) on single GPUs and laptops, int8 and fp8 in high-throughput serving, and fp8 matmuls in training (DeepSeek-V3).
1. Predict first #
2. The picture #
A grid on the number line. Put grid points at equal spacing (the scale, or step size), with 0 exactly on a grid point. With bits there are at most grid points. To quantize a value, replace it by the nearest grid point. Values beyond the last grid point are replaced by the last grid point: this is clipping.
Two errors.
- Rounding error. Inside the grid, a value moves at most half a step: the error is at most . A larger means a larger rounding error.
- Clipping error. Outside the grid, a value moves to the end of the grid. Its error is its distance to the end, with no bound.
With a fixed number of grid points, a wider range needs a larger . So the range is a trade-off: wide enough to clip little, narrow enough to keep small. When many values share one scale and we choose the range to cover all of them, the largest value sets for every other value.
Move the sliders. Each grey dot on the top line is a value , and its line goes to its quantized value on the grid below (the grey ticks are the grid points). Raise the bits: the grid gets finer and the errors shrink. Now add one outlier: the step grows, and the 16 small values crowd onto 3 grid points. Lower the range: the outlier is clipped, but the small values get a finer grid. The readout compares the largest error with the bound .
You quantize a tensor with to symmetric int8 with the restricted range . What is the scale , and which integer stores ?
Symmetric quantization with the restricted range maps to , so . Then , and . Dividing by 255 is the asymmetric (unsigned) grid; dividing by 128 would map 2.54 to 128, which is outside .
3. Definitions and main results #
Notation. In this lesson is the quantization scale (the step size) and the zero-point. Papers also write for the scale; we keep for SmoothQuant's smoothing factors.
Uniform affine quantization #
Choose bits, an integer grid , a scale and an integer zero-point . Then
The first map is quantize (real to integer), the second dequantize (integer back to real). Here round is round to nearest, ties to even (as in NumPy and PyTorch), and .
- Symmetric: and a signed grid. With the restricted range , that is for int8 and for int4, the grid is symmetric around 0, and .
- Asymmetric: an unsigned grid , a range , and . Zero is exactly representable (), so zero padding and ReLU zeros cost no error. An asymmetric grid fits one-sided data (such as ReLU outputs) better.
Why weights are usually symmetric. Write one output of a quantized matmul as . Expanding the product gives four sums. The term depends on the input, so it must be computed at run time for every token. The term depends only on the weights, so it is computed once, offline (Nagel et al., 2021, section 2.4). So the usual choice is symmetric weights () and symmetric or asymmetric activations.
Theorem (rounding error bound) #
If lies in the clipping range , then .
Proof.
- Let and , the code before the clamp. Rounding moves a number by at most : . So the integer is within of .
- in the range means . The ends are integers, so cannot leave . (If , then , which is outside the range; the same argument works at the top.) So the clamp does nothing: .
- .
- So .
Outside the range the error is the clipping error, which has no bound. With asymmetric min-max calibration, rounding shifts the grid by up to , so one end value may be clipped, but by at most .
Theorem (mean squared rounding error) #
The bound is the worst case. The typical error is smaller.
Assumption. The rounding error is uniformly distributed on . This is a good model when is not clipped and its density changes little over one step, that is, when the data spread over many steps.
Result. Under this assumption, and
So the root-mean-square (RMS) error is .
Why this is true. Inside one step, is spread almost evenly, so its distance to the grid point is spread evenly too.
Proof.
- The density of is on and 0 elsewhere.
- The mean is zero, because the integrand is odd:
- The second moment:
When the assumption fails. With very few levels (2–3 bits) or with many exact zeros, the error is not uniform. Values that sit on grid points (such as ReLU zeros with an asymmetric grid) have no error at all. Check it numerically: for Gaussian values and , the measured mean squared error is , and .
Values spread over many steps are rounded to a grid with (no clipping). What is the mean squared error, approximately?
is the largest error, reached only by values exactly halfway between two grid points. With the error uniform on , . The mean error is 0 (unbiased), but the mean squared error is not.
The clipping trade-off #
Take symmetric quantization with range and . The mean squared error has two parts:
Here means the expectation over the clipped values only. The rounding part grows like ; the clipping part falls quickly as passes the bulk of the data. The sum has a minimum. Min-max calibration () never clips the calibration data, but its is set by the most extreme sample. Percentile or MSE-optimal ranges clip a few values to get a smaller (Nagel et al., 2021, section 3.1).
Find the minimum. The curves show the mean squared error of 4-bit (or 8-bit) symmetric quantization of 8192 samples with unit variance, against the range . The rounding part grows with and follows the dashed line ; the clipping part falls. Drag to the bottom of the black curve. At 4 bits, the best range for this Gaussian sample is about , and min-max gives about twice the error. Switch to Laplace (heavier tails) and to 8 bits: the best range moves outwards.
With samples of unit variance, the MSE-optimal ranges are about (Gaussian) and (Laplace) at 4 bits, and about and at 8 bits. More bits make smaller, so the rounding part matters less and the best range moves out. Heavy tails also push it out.
Granularity: which values share a scale #
Write a linear layer as with ( tokens, input channels) and ( output channels). The sum runs over the reduction dimension .
- Per-tensor: one scale for all of , one for all of .
- Per-channel: one scale per output column of ; for activations, one scale per token (row of ).
- Group-wise: one scale per group of consecutive input elements ( = 32 to 128) in each column of .
Which scales an integer matmul can use. With and :
Scales that depend only on or (the outer dimensions) move out of the sum. The kernel runs an int8 × int8 matmul with int32 accumulation, then does one multiplication per output. A scale that depends on (the reduction dimension), such as a per-input-channel activation scale or a group-wise weight scale, changes every term of the sum. It must be applied inside the loop over , once per group:
- weight-only kernels (W4A16) do this: they dequantize each group to bf16 in registers;
- DeepSeek-V3's FP8 training does it every 128 elements, on the CUDA cores;
- microscaling formats (blocks of 32 values with a shared power-of-two scale) do it in hardware on NVIDIA Blackwell GPUs (2024).
Memory cost of scales. int4 with one fp16 scale per group of 128 costs bits per weight.
Change the granularity. Rows are the outer dimension , columns the reduction dimension . The left map shows ; the right map shows the error on one fixed colour scale. With one scale per tensor, the outlier column sets the step and every other value has a large error. One scale per row helps little: the outlier channel is large in every row. One scale per column removes the problem, but the verdict says why an int8 matmul cannot use it for activations. Then try the weights: their rows have different sizes, and one scale per row (per output channel) is enough.
Activations have one outlier channel (a column of ). Why can an int8 × int8 matmul not simply use one scale per input channel of ?
Only scales indexed by the outer dimensions (tokens) or (output channels) factor out: . A scale indexed by multiplies each term differently, so the integer sum is no longer enough. Per-token activation scales and per-output-channel weight scales are fine. SmoothQuant solves the outlier problem in another way.
Outliers and SmoothQuant #
Activation outliers. Dettmers et al. (LLM.int8(), 2022) found that from about 6.7B parameters on, LLM activations contain systematic outlier features: a few hidden channels with very large values, in almost all layers and for most tokens. SmoothQuant (Xiao et al., 2022) reports outliers about 100× larger than most activation values. They sit in fixed channels. With one scale per tensor or per token, the outlier sets : a channel with maximum in a tensor with maximum gets only about of the levels. Weights are flat and well-behaved, so int8 and even int4 weights work well.
Per-input-channel activation scales would fix this, but they sit on the reduction dimension. LLM.int8() instead runs the outlier channels in fp16 and the rest in int8. SmoothQuant moves the problem into the weights. For any vector with positive entries,
exactly, because . Column of is divided by , and row of is multiplied by . The paper chooses
so that
where is column of , is row of , and is the migration strength.
Proof of the maxima. Dividing column of by divides its maximum by , and multiplying row of by multiplies its maximum by :
At both maxima equal . A larger moves more of the difficulty to the weights (the paper uses 0.5 for most models and 0.75 for GLM-130B). The maxima of come from calibration data. The cost at run time is zero: is folded into the scale and bias of the LayerNorm before the layer, and into , offline. The Q, K and V projections read the same LayerNorm output, so they share one .
Move . The bars are the channel maxima of the activations and the weights , faint before smoothing and solid after (log scale). Channels 2 and 6 hold outliers. At every activation channel has maximum 1 and the weights take all the range; at it is the other way round. The lower chart is the relative error of in W8A8 (one scale for , one per output column of ). The last readout shows that equals up to float rounding: the identity is exact.
SmoothQuant with on a channel with and . What are and the new channel maxima?
. Then and : both equal . The product does not change, because the column of is divided by exactly what the row of is multiplied by.
PTQ and QAT #
- PTQ (post-training quantization) takes a trained model. Weight scales come from the weights;
activation ranges come from a small calibration set (a few hundred samples, no labels). It takes
minutes to hours. For LLM weights there are three common methods:
- round to nearest (RTN), as in this lesson;
- GPTQ (Frantar et al., 2022) quantizes the weights of a layer one input row at a time, and after each row updates the rows not yet quantized to compensate the error. It uses the inverse of the Hessian of the layer's squared error ;
- AWQ (Lin et al., 2023) observes that about 1% of the input channels, found from the activation magnitudes, matter most. It scales these channels of up before rounding and the activations down by the same factor: the identity of SmoothQuant, used for weight-only quantization.
- QAT (quantization-aware training) inserts fake quantization (quantize, then dequantize) in the forward pass and trains. The model learns weights that work well after rounding. The difficulty: round has zero derivative almost everywhere, so no gradient flows. The straight-through estimator (STE) uses inside the clipping range and 0 outside (Nagel et al., 2021, section 4.1). Scales can be learned too (LSQ). QAT is worth its training cost at 4 bits or less, or when PTQ loses too much accuracy.
- FP8 training (lesson 5) is quantization with floating-point grids: E4M3 for weights and activations, E5M2 for gradients, a scale per tensor or block, fp32 accumulation and fp32 master weights.
Integer grid or float grid? An int8 grid has an absolute error bound for every value. An fp8 grid has a relative bound for normal numbers ( for E4M3, for E5M2: the unit roundoff of lesson 5). So with fp8 a small value keeps its relative precision next to a large one, and fp8 tolerates outliers better with simple per-tensor scales.
Why the STE gradient is 1 inside and 0 outside
Fake quantization is . It is a staircase: piecewise constant, with jumps at the half-steps. Its true derivative is 0 everywhere except at the jumps, where it does not exist. The STE replaces the staircase by the straight line for the backward pass only, as long as is inside the range, where the staircase follows the line within . Outside the range, the clamp is really flat: increasing does not change . So the STE keeps the gradient 0 there. A consequence: weights or activations that drift outside the range stop learning, so set good initial ranges before QAT. With very few levels, latent weights can also oscillate around a rounding boundary; the notebook shows a small example.
Which phase gets faster #
For a weight matmul with tokens and a weight, the FLOPs are . In bf16 the weight bytes are , so the intensity is about FLOP/byte when (lesson 2). The H100 ridge point is .
- W4A16 (int4 weights, bf16 activations and bf16 matmuls): 4× fewer weight bytes, the same bf16 FLOP/s. The intensity becomes about . Decode at small is memory-bound, so it gets up to 4× faster. The matmul becomes compute-bound at , that is . From there the gain is only , and there is no gain at (prefill, large batches).
- W8A8 (int8 or fp8 weights and activations): 2× fewer weight bytes and 2× the peak FLOP/s. Up to 2× faster in both regimes.
- With ideal kernels, W4A16 is faster below (where its compute time per weight equals the W8A8 memory time per weight) and W8A8 above. Real kernels lose some speed to dequantization and to reading the scales.
W4A16 on the roofline of an H100 in bf16, for one weight matmul with tokens. "W16" is the bf16 weight, "W4" the int4 weight with group scales (4.125 bits). Click a point and read its minimum time. At and the int4 weight moves the point to the right on the memory roof: the time drops by up to 3.9×. At (prefill) both points are on the compute roof, and the time is the same.
A dense model is served on an H100 at batch 256 (256 tokens per weight read). With ideal kernels, which is faster: W4A16 or W8A8 (fp8 or int8 matmuls)?
Per weight, W4A16 needs s of bf16 compute (more than its s of memory). W8A8 needs s of memory (more than its s of compute). So W8A8 is about 1.7× faster. The crossover is at .
Answers to section 1. (1) Prefill: no. It is compute-bound and still runs bf16 matmuls; W4A16 removes bytes, not FLOPs. Decode at batch 1: yes, up to about 4× (3.9× with group scales), because it is limited by the weight bytes. (2) , so maps to : only 7 of the 255 levels.
4. Worked example #
(a) Three quantizers. .
- Symmetric int8. . Then , so and . Largest error .
- Asymmetric uint8. Range , , . Then and . Largest error . The grid ends at 2.0012, not 2.0: the rounded shifted it.
- Symmetric int4. , , so and . Largest error .
(b) An outlier channel and SmoothQuant. Two tokens, two channels, :
- Per-tensor int8 of . . Channel 0 becomes and : errors of 18% and 26%.
- Smoothing factors. , (row 0 of ), so . , , so .
- Smoothed matrices. Divide the columns of by and multiply the rows of by :
The channel maxima are and in both matrices, and exactly. 4. W8A8 (per-tensor , per-output-column ): the largest error of falls from 0.042 to 0.0093, and the relative (Frobenius) error from 1.1% to 0.2%.
(c) The roofline for a 7B model on one H100.
| weights | decode, batch 1 (≥ bytes / 3.35 TB/s) | prefill, 4,096 tokens ( FLOPs) | |
|---|---|---|---|
| bf16 | 14 GB | 4.2 ms | 58 ms at 989 TFLOP/s |
| W4A16 (4.125 bits) | 3.6 GB | 1.1 ms | 58 ms (bf16 matmuls) |
| W8A8 / FP8 | 7 GB | 2.1 ms | 29 ms at 1,979 TFLOP/s |
5. In code #
NumPy checks the worked example, the result and SmoothQuant. The notebook has these cells and the exercises.
import numpy as np
def quantize(x, D, z, qmin, qmax):
return np.clip(np.round(x / D) + z, qmin, qmax) # np.round: ties to even
def dequantize(q, D, z):
return D * (q - z)
# 1. The three quantizers of the worked example
x = np.array([-0.7, -0.15, 0.0, 0.4, 2.0])
for name, D, z, qmin, qmax in [("int8 sym", 2.0 / 127, 0, -127, 127),
("uint8 asym", 2.7 / 255, 66, 0, 255),
("int4 sym", 2.0 / 7, 0, -7, 7)]:
q = quantize(x, D, z, qmin, qmax)
err = np.abs(x - dequantize(q, D, z)).max()
print(f"{name:10s} q = {q.astype(int)} max error {err:.4f} <= D/2 = {D / 2:.4f}")
# int8 sym q = [-44 -10 0 25 127] max error 0.0075 <= D/2 = 0.0079
# uint8 asym q = [ 0 52 66 104 255] max error 0.0024 <= D/2 = 0.0053
# int4 sym q = [-2 -1 0 1 7] max error 0.1357 <= D/2 = 0.1429
# 2. Mean squared rounding error is about D^2/12 when the values span many steps
v = np.random.default_rng(0).standard_normal(100_000)
D = 0.1
print(np.mean((v - D * np.round(v / D))**2), D**2 / 12) # 0.000833 0.000833
# 3. SmoothQuant on the worked example (alpha = 0.5)
X = np.array([[0.8, -30.0], [-0.5, 40.0]])
W = np.array([[0.4, -0.3], [0.1, 0.05]])
s = np.sqrt(np.abs(X).max(axis=0) / np.abs(W).max(axis=1))
Xs, Ws = X / s, W * s[:, None]
print(s, np.allclose(Xs @ Ws, X @ W)) # [ 1.41421356 20. ] True
def w8a8(X, W):
dx = np.abs(X).max() / 127 # one scale for X
dw = np.abs(W).max(axis=0) / 127 # one scale per output column of W
return (dx * np.round(X / dx)) @ (dw * np.round(W / dw))
for A, B in [(X, W), (Xs, Ws)]:
E = w8a8(A, B) - X @ W
print(f"max error {np.abs(E).max():.4f}, relative {np.linalg.norm(E) / np.linalg.norm(X @ W):.4f}")
# max error 0.0423, relative 0.0110
# max error 0.0093, relative 0.0020
In PyTorch, fake_quantize_per_tensor_affine quantizes and dequantizes in one op, and its
gradient is the straight-through estimator:
import torch
x = torch.tensor([-3.0, -0.5, 0.2, 0.9, 2.5], requires_grad=True)
D = 2.0 / 127 # symmetric int8, clipping range [-2, 2]
y = torch.fake_quantize_per_tensor_affine(x, D, 0, -127, 127)
y.sum().backward()
print(y.detach()) # tensor([-2.0000, -0.5039, 0.2047, 0.8976, 2.0000]): -3 and 2.5 are clipped
print(x.grad) # tensor([0., 1., 1., 1., 0.]): 1 inside the range, 0 outside
6. Where it shows up #
- LLM serving. W4A16 (GPTQ, AWQ) for small batches and single-GPU or laptop deployment; W8A8 or FP8 for high-throughput serving; an int8 or fp8 KV cache when the cache dominates memory (lesson 12).
- Training. FP8 matmuls on H100 and newer GPUs (DeepSeek-V3 was trained with FP8 matmuls); QAT for models that must run in int8 or int4 on phones and other devices.
- Fine-tuning. QLoRA stores the frozen base model in 4 bits and trains small bf16 adapters, so a large model fits on one GPU.
- ML for science. Weather, climate and seismic surrogate models run in large ensembles, so the bytes and FLOPs per sample matter. But in science the tails are often the events you care about (extreme rainfall, peak ground acceleration). A range set by the bulk clips them; a range set by them makes the step coarse for the bulk. Check the error on the extremes, not only the mean.
7. Common mistakes #
- Expecting weight-only int4 to speed up prefill or training. It removes bytes, not FLOPs. Compute-bound phases do not get faster.
- Per-input-channel activation scales in an int8 matmul. They sit on the reduction dimension and cannot be applied after the integer sum.
- Min-max calibration of activations with outliers. Use percentile or MSE-optimal ranges, or SmoothQuant.
- Forgetting the scale overhead. Group size 128 with fp16 scales is 4.125 bits per weight, not 4.
- Rounding mismatch. A simulation that rounds ties away from zero and a kernel that rounds ties to even give different codes on ties.
- Ignoring the STE's zero gradient outside the range. Clipped values stop learning; set good initial ranges before QAT.
- Taking as the typical error. It is the worst case; the RMS error is about .
8. Check yourself #
- Symmetric int4 of : scale, codes and largest error?
Answer
; , so and . The largest error is . - int4 with group size 64, with one fp16 scale and one fp16 zero-point per group: how many bits per weight, and how much memory for the weights of a 70B model?
Answer
bits per weight; GB. - Why does the bound need " inside the range"?
Answer
Outside the range the clamp replaces the code by or , so is the end of the grid, and the error is the distance from to that end, which can be arbitrarily large. - 4-bit symmetric quantization of Gaussian data: why is the MSE-optimal range smaller than , and what happens to it at 8 bits?
Answer
At 4 bits the step is large, so the rounding part dominates; clipping a few tail values (about ) makes much smaller and lowers the total. At 8 bits is small, so rounding matters less, and the best range moves out to about . - Why does SmoothQuant cost nothing at inference time?
Answer
is folded into the scale and bias of the LayerNorm before the layer, and is multiplied into offline. The model runs the same ops as before, with different numbers. - What gradient does the STE give to a value outside the clipping range, and why?
Answer
Zero: the clamp is constant there, so does not change when changes. Inside the range the rounding is treated as the identity, so the gradient is 1.
Write the uniform affine quantize and dequantize maps.
and .
What is the largest rounding error inside the clipping range?
: rounding moves by at most , and inside the range the clamp does nothing.
Under which assumption is the mean squared rounding error ?
The error is uniform on , which holds when the data spread over many steps and are not clipped.
Compute the symmetric int8 scale for .
.
Why are weights usually quantized symmetrically?
A weight zero-point creates the term , which depends on the input and must be computed at run time; an activation zero-point only adds a precomputable term.
Which scales can an int8 × int8 matmul apply after the integer sum?
Scales indexed by the outer dimensions: per token of and per output channel of . Scales indexed by the reduction dimension must be applied inside the sum.
How many bits per weight does int4 with one fp16 scale per group of 128 cost?
bits.
State the SmoothQuant identity and the choice of .
, with .
What gradient does the straight-through estimator use for fake quantization?
inside the clipping range and 0 outside it.
Does W4A16 speed up the prefill of a long prompt? Why?
No. Prefill is compute-bound, and W4A16 still runs bf16 matmuls; it removes weight bytes, which helps only memory-bound decode.
9. Interview angle #
- "Decode is too slow at batch 1. What do you quantize?" The weights (int4 or int8 weight-only): decode is memory-bound on the weight bytes. Check the batch against the ridge point.
- "int8 or fp8 for an inference deployment?" Both give 8-bit tensor cores. fp8 has a relative error and tolerates outliers with simple per-tensor scales; int8 activations need SmoothQuant-like handling. fp8 tensor cores need an H100 (Hopper), an Ada GPU or newer; the A100 has int8 but no fp8.
- "Explain SmoothQuant in one sentence." Divide each activation channel by and multiply the matching weight row by : the product does not change, the outliers move into the weights, and the factors are folded into the previous layer offline.
- "PTQ or QAT?" PTQ first (cheap, calibration data only); QAT when 4 bits or less lose too much accuracy.
- "Why is the quantization noise?" If the error is uniform on one step, its variance is .
10. Practice #
The notebook for this lesson runs in Colab, on a CPU, with NumPy only. Each exercise has a check cell and a folded solution.
- Write
quantizeanddequantizefor symmetric and asymmetric int8 and int4, and check the error bound and the result. - Compute the MSE against the clipping range and find the best range for Gaussian and Laplace data.
- Compare per-tensor, per-row, per-column and group-wise scales on a matrix with an outlier channel.
- Implement SmoothQuant and plot the W8A8 error of against .
- Train a small linear model with fake-quantized weights and the straight-through estimator, and compare it with post-training quantization.
11. Watch and read #
- Nagel et al., A White Paper on Neural Network Quantization (2021): section 2.2 (uniform affine and symmetric quantization), 2.4 (granularity, symmetric vs asymmetric), 3.1 (range setting), 4.1 (the straight-through estimator).
- Xiao et al., SmoothQuant (2022): section 3 (why activations are hard), section 4 (the migration strength ).
- Dettmers et al., LLM.int8() (2022): emergent outlier features and mixed-precision decomposition.
- Frantar et al., GPTQ (2022) and Lin et al., AWQ (2023): weight-only post-training quantization of LLMs.
- Micikevicius et al., FP8 Formats for Deep Learning (2022): E4M3, E5M2 and scaling.
Runs in your browser with nothing to install. The notebook repeats the key demos and has exercises with checks; solutions are inside, folded.
No Google account needed: Python runs inside your browser. The first start downloads about 40 MB; your changes are saved in this browser only.