Quantization stores the numbers of a neural network in fewer bits. A weight trained as a 16-bit floating-point number is stored instead as an 8-bit or 4-bit integer. A group of such integers shares one scale factor that turns them back into real numbers. Done well, the model computes almost the same outputs as before. Done carelessly, it falls apart. This section is about the difference.
It matters because serving a language model is paid for in bytes. Recall from §20.1 that each decoding step, the forward pass that writes one new token for every sequence in the batch, reads every weight of the model from GPU memory once. At small batch sizes the time spent reading those bytes, not the arithmetic, sets the speed. A model stored in 4 bits instead of 16 reads a quarter of the bytes. It can produce tokens up to four times faster, and it fits on fewer GPUs.
Where rounding hurts is not where most people expect. We measure the damage with perplexity, the exponential of the average next-token loss, so lower is better. Take a language model with 13 billion parameters. Store its weights, and also its activations (the inputs to every matrix product), as 8-bit integers with one scale factor for each whole matrix. Its perplexity on C4, a held-out set of web text, rises from 12.45 to 19.08 (Dettmers et al. 2022, Table 1). That is worse than an unquantized model ten times smaller, which scores 15.91. Now give every token and every output column its own scale. Also take out the handful of activation columns that hold huge values, and multiply those in 16 bits instead of 8. Perplexity is back to 12.45. The weights were never the problem. A few activation columns were.
The reason is easiest to see on a ruler. A quantizer replaces each number by the nearest of a few evenly spaced marks, and the spacing is set by the largest number the marks must reach. One huge value stretches the spacing, so all the ordinary values crowd onto the same few marks. Large language models grow a few such huge values in their activations, in the same few coordinates at most tokens. Beyond about 6.7 billion parameters these values appear in every layer, and every 8-bit scheme that does not treat them specially loses accuracy.
This section builds quantizers from scratch and works out how much error they add and where it goes. It shows how a matrix product runs on integers and proves which scale factors can be pulled out of it. That proof explains why the large activation values are so hard to handle, and it leads to the fix above. The section then separates quantizing only the weights from quantizing weights and activations, by what each one speeds up, and it ends with training a model through its quantizer. §20.3 builds the methods that real LLMs ship with on these foundations.
A note on symbols. b is a bit width, Δ the step between neighboring marks, and g a group size. As in §11.2, π is a GPU's peak arithmetic rate in FLOPs per second and β its memory bandwidth in bytes per second. Weights use the book's (input, output) layout, W∈Rdin×dout and Y=XW, with one token per row of X. So an output channel, the weights that produce one output feature, is a column of W. An input channel is a row of W, and it multiplies one column of X.
One large value sets the step for everyone
Here is the whole problem in four numbers. We store each one in 4 bits, as a signed integer from −7 to 7. We choose the step between marks so that the largest magnitude lands exactly on the top mark, and every other number goes to its nearest mark.
One large value set the step for everyone. The rest of this section is about keeping that from happening, or paying for it as little as possible. There are two levers, how many numbers share one step and what is done with the rare large values.
A uniform quantizer divides, rounds, and clamps
A uniform quantizer turns a real number into a small integer, its code, and back again, using a fixed step. To store a number, divide it by the step, round to the nearest integer, and clamp the result into the range the bits allow. To read it back, multiply the code by the step.
There are two standard versions. The symmetric one centers its marks on zero. The asymmetric one shifts its marks by an integer called the zero point, so that it can cover a lopsided range. The outputs of a ReLU, the activation function max(0,x), are the usual example, since they are never negative.
In plain words, divide by the step, round, keep the result inside the integer range, and multiply back. The symmetric version stores one extra number for each group of values, the step. The asymmetric version stores two, the step and the zero point.
Three details are easy to get wrong, and each has a reason.
The symmetric range leaves out −2b−1. An int8 can hold −128, but the symmetric grid uses only [−127,127]. That way x and −x always get codes of the same size, so the grid really is symmetric about zero.
The zero point is an integer, and the range contains 0. With an integer z, the real value 0 gets the code round(0)+z=z, and it comes back as Q(0)=Δ(z−z)=0 exactly. Zeros are everywhere in a network, in padding, masked positions, ReLU outputs, and pruned weights. A quantizer that turned every zero into ±0.003 would add the same small bias to every sum. If z were not an integer, no code would map to 0. If the range did not contain 0 (say every value lies in [2,5]), the zero point would be negative and fall outside the codes.
The clamp is needed even without clipping. In the asymmetric version both xmax/Δ and z are rounded. When both land exactly halfway between two integers and both round up, the code of xmax is 2b, one past the end. With x=(−1.5,1.5) and b=2, the step is Δ=1 and z=round(1.5)=2. The code of 1.5 would be round(1.5)+2=4, outside [0,3].
Which version goes where? A symmetric quantizer wastes half its codes on a ReLU output, because no value is negative. Take (0,0.5,1.0,2.55) in 8 bits. The asymmetric quantizer uses the step 2.55/255=0.01 and the codes (0,50,100,255). The symmetric one needs the step 2.55/127=0.020, twice as coarse. But a zero point costs extra work inside a matrix product, as we derive below. So practice settles on symmetric weights and, where the hardware allows, asymmetric activations. The int8 embeddings of §7.3 use a close cousin of the asymmetric quantizer, with one range per dimension and the range's minimum itself as the shift instead of an integer zero point.
Not every grid is uniform. Take the NF4 format of §14.6, which stores frozen weights while small adapters are fine-tuned on top. It keeps one absmax scale per block, but it places its 16 levels at quantiles of a normal distribution. That is a non-uniform grid. The floating-point formats of §8.5 are non-uniform too, with marks that grow sparser as numbers grow. This section is about uniform grids, the ones that integer matrix units multiply directly.
The cell writes both quantizers and replays the hand example at 4 and 8 bits.
Python · cell 1
With 8 bits, the outlier still makes the total error of the three small numbers about eight times larger (0.0169 against 0.0020). But each error here is about 18 times smaller than at 4 bits, because the step shrinks by 127/7=18.1. That is why one scale for a whole tensor is often fine at 8 bits and rarely at 4. An interviewer who asks why int8 tolerates coarse scales and int4 does not wants this ratio.
Warm-up
Symmetric and zero-point quantizers
Write quantize(x, b, mode), where mode is "symmetric" or "asymmetric", exactly as in the definition above. It returns (x_q, delta, z), the codes as an integer array, the step as a float, and the zero point as an int (0 in the symmetric case). Use the default ranges of the definition (the absolute maximum for the symmetric case, and a range stretched to contain 0 for the asymmetric one), round with np.round, and use delta = 1.0 when every input is 0. Then write dequantize(x_q, delta, z=0). The tests check the hand example, ties, a ReLU output, and the boundary cases that break careless quantizers.
Tests, verified solutions, and AI help, open once you add your API key.
How much error rounding adds
Inside the clipping range, the quantizer moves each number to its nearest mark. So the error e=Q(x)−x is never larger than half a step, ∣e∣≤Δ/2. That is the worst case. For the typical error we need a model of where x falls between two marks. The simplest model says anywhere, with equal chance.
∂ Proof of the rounding-noise model
The uniform density on [−Δ/2,Δ/2] is 1/Δ, so E[e]=0 by symmetry, and
If x is uniform on a cell [kΔ−Δ/2,kΔ+Δ/2], every point of the cell rounds to kΔ, so e=kΔ−x is exactly uniform on [−Δ/2,Δ/2]. A mixture of such cells gives the same distribution of e. If the density of x is smooth on the scale of Δ, then within each cell x is close to uniform, and so is e. ∎
This is a model, not a law. It fails when the values span only a few steps. In the 2-bit Gaussian of the next cell, most values round to 0, and the error of each is −x itself. The same Δ2/12 sets the noise floor of digital audio in §35.1.
To compare quantizers we measure the error against the size of the signal. The signal-to-quantization-noise ratio (SQNR) says how many times larger the signal's power is than the error's. It is usually quoted in decibels (dB), ten times its base-10 logarithm. So 10 dB means ten times, 20 dB means a hundred times, and every 3 dB is about a doubling.
In plain words, each extra bit roughly doubles the number of marks. So it halves the step, divides the noise power by about 4, and adds about 20log102=6.02 dB. When an interviewer asks what one more bit buys, this is the answer.
∂ Proof of 6 dB per bit
Var(x)=(2c)2/12=c2/3. With Δ=c/qmax, the interval [−c,c] is 2qmax−1 whole cells plus a half cell at each end. In a whole cell the error is uniform on [−Δ/2,Δ/2]. In an end half cell it is uniform on an interval of length Δ/2 that starts at 0, whose mean square is (Δ/2)2/3=Δ2/12 as well. So E[e2]=Δ2/12=c2/(12qmax2) exactly. The ratio is (c2/3)⋅12qmax2/c2=4qmax2, and 2qmax=2b−2. For large b, 2b−2≈2b and 20log102b=6.02b. ∎
The cell checks both results by simulation. A full-scale uniform signal matches Δ2/12 to within 0.3% at every width. A Gaussian clipped at four standard deviations matches it from 3 bits up. At 2 bits the Gaussian's step is 4 standard deviations, most values round to 0, and the model is off by a third.
Python · cell 2
Large activations amplify weight error
A network never uses one weight by itself. It computes dot products, y=∑k=1dinxkwk, so what matters is the error in y. Quantize the weights with step Δw, keep the input x exact, and ask how far the quantized output y^ lands from y.
Proof. The output error is y^−y=∑kxkek. Its mean is 0 because each ek has mean 0. The terms are uncorrelated, so the variance of the sum is the sum of the variances, xk2Δw2/12 each. ∎
In plain words, each weight's rounding error is multiplied by the activation it meets. A channel with a large activation therefore amplifies the error of every weight in its row of W. So the weights that matter most are not the largest weights. They are the ones that meet large activations. §20.3 builds a method on exactly this, which scales those rows up before rounding so that their relative error shrinks (activation-aware weight quantization, Lin et al. 2023).
If the activations are quantized too, with step Δx and independent errors ek′, the output error gains the terms ∑kwkek′ and ∑kekek′. Their variances add, which gives Var(y^−y)≈(Δw2/12)∥x∥2+(Δx2/12)∥w∥2+dinΔx2Δw2/144.
The cell builds an activation vector of length 4,096 in which four channels are large. It quantizes a thousand random weight columns to 4 bits and compares the measured error variance with the formula.
Python · cell 3
Four channels out of 4,096 carry 63% of the output error, because they carry 63% of ∥x∥2. An interviewer who asks why a method for quantizing weights needs to look at activations wants this formula.
Clipping trades rounding error for tail error
The absmax rule puts the top mark at the largest value, so nothing is ever clipped. But the step grows with the threshold, and a heavy-tailed distribution spends most of its range on a few rare values. Lowering the threshold to c<max∣x∣ shrinks the step for everyone. Only the values beyond c pay, because they are all mapped to ±c.
The error splits into two parts. Values inside the range suffer rounding error, which grows with c because the step does. Values outside suffer clipping error, which shrinks as c grows.
∂ Proof of the clipped-quantizer MSE
Split the expectation on the events ∣x∣≤c and ∣x∣>c. On the first, the noise model gives a conditional mean square of Δ2/12, which contributes (Δ2/12)P(∣x∣≤c). On the second, take x>c. Then x/Δ>qmax, so round(x/Δ)≥qmax, the clamp returns qmax, and Q(x)=Δqmax=c. The error is exactly x−c, with no model needed. By symmetry the negative tail contributes the same, which gives the factor 2.
For the Gaussian, integration by parts gives ∫c∞x2φ(x)dx=cφ(c)+1−Φ(c), and ∫c∞xφ(x)dx=φ(c) because φ′(x)=−xφ(x). Expanding (x−c)2,
For the Laplace density, substitute u=x−c, so ∫c∞(x−c)221e−xdx=21e−c∫0∞u2e−udu=21e−c⋅2=e−c. ∎
The two terms pull in opposite directions, and that has a sharp consequence.
Proof. At c=cmax there is no mass beyond c, so the clipping term and its derivative, −4∫c∞(x−c)p(x)dx, are both 0. The derivative of the rounding term is 6qmax2cP(∣x∣≤c)+6qmax2c2p(c). At c=cmax its first part is cmax/(6qmax2)>0, and its second part is never negative. ∎
So the question is never whether to clip, only how much. The cell minimizes the closed form for a Gaussian and a Laplace input at several bit widths. It checks each answer against a simulation of the real quantizer.
Python · cell 4
The gain per bit also falls short of 6 dB for a Gaussian. From 4 to 8 bits the optimal SQNR rises from 18.9 to 40.5 dB, about 5.4 dB per bit. The best threshold creeps outward as the step shrinks, because the Gaussian's tail never ends. So each bit buys a little less than a halving of the step. The plot shows the trade for each bit width, with the minimum marked.
Python · cell 5
In practice the threshold is chosen per tensor or per channel on calibration data, a few hundred typical inputs run through the model to record the values each layer sees. Three rules are common. Minimize the MSE, as here. Take a high percentile of ∣x∣, such as the 99.99th. Or minimize the KL divergence between the histograms of x and Q(x). Wu et al. (2020) compared the percentile and KL rules with absmax on many networks and found that no single rule was best for all of them. Nagel et al. (2021) recommend the MSE criterion as the default for weights and for most activations.
Interview
The MSE-optimal clip, in closed form and from data
First write expected_mse_gaussian(c, b), the closed-form mean squared error of the symmetric b-bit quantizer clipped at c for x∼N(0,1), from the theorem above (use scipy.stats.norm). Then write best_clip(x, b, n_grid=200). It tries the thresholds np.linspace(0.01, 1.0, n_grid) * max|x| on the data x, quantizes with the symmetric quantizer at each one, and returns (c, mse) for the threshold with the smallest empirical MSE. The tests compare your closed form with numerical integration and with a simulation, check the optimal thresholds from the example, and run best_clip on Gaussian and Laplace data.
import numpy as np
from scipy.stats import norm
def expected_mse_gaussian(c, b):
raise NotImplementedError
def best_clip(x, b, n_grid=200):
raise NotImplementedError
Tests, verified solutions, and AI help, open once you add your API key.
Granularity decides how many numbers share a scale
The hand example showed the cure for an outlier, which is to give it its own scale. So the question is how many numbers should share each scale, and what the extra scales cost. A weight matrix offers natural groupings, and so does a matrix of activations.
In plain words, smaller groups confine each outlier's damage to fewer neighbors and follow the local size of the weights more closely. They cost a few hundredths to a few tenths of a bit per weight. Per-channel scales for weights cost almost nothing, 16/din bits per weight, about 0.004 bits for din=4,096.
The cell builds a synthetic layer whose output columns differ in size (a lognormal spread) and whose entries have heavier tails than a Gaussian. It quantizes the layer at four granularities and reports the SQNR of each.
Python · cell 6
The widget quantizes 2,048 weights in 16 channels and can plant one outlier in channel 5. It draws every weight over its group's levels, with the step of every group in a strip below.
What does one outlier do to the levels everyone else gets?
2,048 weights in 16 channels of 128, quantized with one absmax step per group. Faint lines are the levels, and pink weights round to 0.
Loading interactive figure…
Core
Per-tensor, per-channel, and group-wise weight quantization
Write quantize_weight(W, b, granularity, g=None) for a weight matrix W of shape (d_in, d_out), in the (in, out) layout. Use the symmetric absmax quantizer with one step per group. A group is the whole tensor ("tensor"), one output column ("channel"), or g consecutive input rows within one column ("group", with d_in divisible by g). Return (W_hat, bbar), the dequantized weights and the bits per weight, counting one 16-bit step per group. An all-zero group must come back as zeros.
Tests, verified solutions, and AI help, open once you add your API key.
A matrix product can run on integers
Storing numbers in 8 bits saves memory. Multiplying them in 8 bits saves time as well. A GPU's tensor cores, its dedicated matrix-multiply units, run faster at lower precision. On an H100 SXM they do 1,979 trillion dense int8 (or FP8) operations per second, twice the 989 trillion of BF16, the 16-bit float format models are usually served in (§11.1). Spec sheets also quote twice these numbers, but only for sparse matrices. To use the int8 rate, the whole matrix product must run on integer codes. Kernel writers call a matrix product a GEMM (general matrix multiply). Every step and zero point has to be applied outside its inner loop, the sum of products that the tensor cores run. A tiny example shows how.
The same steps work for whole matrices. Quantize the activations X∈Rn×K asymmetrically, with one step Δx,i and zero point zx,i per token. Quantize the weights W∈RK×m symmetrically, with one step Δw,j per output column. Here K=din is the length of each dot product, called the reduction dimension, and m=dout.
Proof. Expand (Xq,ik−zx,i)Wq,kj and sum over k. The steps Δx,i and Δw,j do not depend on k, so they leave the sum. ∎
In plain words, multiply the integer codes, subtract one correction per output entry built from the precomputed column sums, and only then multiply by the two steps. Everything inside the parentheses is integer arithmetic, and u is computed once, when the model is loaded.
Three practical points follow from the formula.
The accumulator must be wide. A uint8 code times an int8 code is at most 255×127=32,385 in magnitude, just inside the int16 limit of 32,767, and a dot product adds K of them. With K=4,096 the sum can reach 1.3×108. So tensor cores add the products in an int32 accumulator, the running total of the dot product. It is safe up to K≈231/32,385≈66,000.
Zero points on both sides cost a term that depends on the input. If the weights also had zero points zw,j, the expansion would be
The row sums vi of the activation codes depend on the input, so they must be recomputed for every batch. Nagel et al. (2021) note that this extra pass over Xq costs about as much as adding an extra channel to the layer. That is the reason for the convention of symmetric weights and, optionally, asymmetric activations. Interviewers ask for exactly this expansion.
The output is rescaled with one multiplier. If the next layer wants int8 inputs with step Δy, the int32 accumulator is multiplied by M=ΔxΔw/Δy. It depends only on the steps. With static per-tensor steps it is one number fixed before inference. With dynamic per-token steps it is the outer product of the token and column steps divided by Δy, formed at run time.
The cell runs the integer path on 4 tokens of length 4,096. It checks the result against the product of the dequantized matrices and against the float product.
Python · cell 7
The naive NumPy product of a uint8 and an int8 array is computed in int16, while the true sums reach 445,752, so it silently wraps around. Widened to 64 bits, the integer path equals the product of the dequantized matrices to rounding. The whole 8-bit product is within about 1% of the float one.
Only some scales can leave the sum
The derivation pulled Δx,i and Δw,j out of the sum over k. That worked because of where those steps live, one per token and one per output column. The rule is worth stating as a theorem, because it decides how every quantized kernel is designed. We call it the factoring theorem, and later sections use that name.
Proof. In case 1, ai and bj do not depend on the summation index, so they factor out, ∑kaiX~ikW~kjbj=aibj∑kX~ikW~kj. In case 2, take K=2, X~i1=X~i2=1, and two weight columns, (W~1j,W~2j)=(1,0) and (0,1). Both have the integer product 1, but the outputs are c1 and c2. If c1=c2, no function of the integer products can produce both. ∎
Three consequences shape all of LLM quantization.
Activations are quantized per token and weights per output channel, because those are exactly the scales that factor out. Per tensor is the special case of constant scales.
Activations are never quantized per input channel inside an integer GEMM, even though, as we are about to see, that is where their outliers live. A per-input-channel scale can instead be moved into the weights, since XW=(Xdiag(c)−1)(diag(c)W). The rescaled weights are then quantized per output channel as usual. §20.3 builds a method on this identity that divides each large activation channel down and multiplies the matching weight row up (Xiao et al. 2022).
Group-wise weight scales vary along k, so they break case 1. They still work in two ways. A kernel can add up each group of g products separately and rescale the partial sums, as kernels for 8-bit floats (FP8) with one scale per block do. Or, when only the weights are quantized, it can turn the weights back into 16-bit numbers in registers and multiply in 16-bit arithmetic, where any scale is allowed.
Interview
An integer GEMM with zero points
Write int_gemm(Xq, zx, dx, Wq, dw). Xq is a uint8 array of shape (n, K) with one zero point zx[i] and one step dx[i] per token. Wq is an int8 array of shape (K, m), symmetric, with one step dw[j] per output column. Return (Y, acc). acc is the integer accumulator XqWq−zxu⊤ as an int32 or int64 array, computed with integer arithmetic only, and Y is the float result after the final rescale. Inference teams ask this in interviews, and the tests use shapes whose sums overflow 16 bits.
Tests, verified solutions, and AI help, open once you add your API key.
Outliers are why LLM activations resist
Back to the opening puzzle. The weights of trained networks are roughly bell-shaped, and per-channel scales absorb the differences in size between their columns. So 8-bit weights have been standard practice since the integer-only inference work of Jacob et al. (2018). Wu et al. (2020) brought every network they studied, BERT included, within 1% of its float accuracy in int8, though a few needed some layers left in float or a short fine-tune. The activations of large language models are different.
Dettmers et al. (2022) measured how. They speak of feature dimensions, the coordinates of the hidden vector that enters a matrix product. A feature dimension is one column of X, and it meets one input channel of W.
Now the failure follows from the hand example. Most tokens carry a value near −60 in a few columns. A per-tensor step is 60/127≈0.47, and so is the per-token step of every token that carries one, about three quarters of them. The ordinary values, of size about 1, are rounded with a step almost half their own size.
The factoring theorem rules out the easy fixes. Per-token scales factor out of the GEMM, but they cannot help, because the outliers live in columns and these scales vary by row. A per-column scale for X, one per input channel, would fix the problem, but it varies along the sum, so an integer GEMM cannot apply it. That is the trap. An interviewer who asks why 8-bit activations are harder than 8-bit weights is asking for this argument.
The widget shows the trap on a small activation matrix, 32 tokens by 64 channels. Two of the channels carry outliers near −40, in a share of the tokens that you set. The left map is the size of each entry, and the right map is its quantization error under the scheme you pick. The LLM.int8 split, explained just below the widget, keeps the outlier columns in 16 bits.
Why can't activations share one scale?
A layer input X, 32 tokens (rows) by 64 channels (columns), quantized to int8. Two channels carry outliers near −40.
Loading interactive figure…
The fix multiplies the outlier columns in 16 bits
Dettmers et al. escaped the trap by using the structure of the outliers. They live in a handful of columns of X. So take those columns out and multiply them, with the matching rows of W, in 16 bits. Multiply everything else in 8 bits with the scales that factor out. The paper calls this method LLM.int8().
In plain words, the columns of X that contain an outlier, and the matching rows of W, are multiplied in 16 bits. With 7 outlier columns out of 5,120, that is about 0.1% of the work. The other 99.9% runs in int8 with scales that the factoring theorem allows.
The cell builds a realistic activation and measures the error of XW under four schemes. The activation has 4,096 channels and six one-sided outlier dimensions near −58 (the median the paper reports for its 13B model), present in 75% of the tokens.
Python · cell 8
The decomposition is not free. The outlier columns change with the input, so they are found at run time, and the 16-bit and 8-bit products are computed separately and added. Dettmers et al. measured the cost in their appendix. They timed the first feed-forward matrix multiply at the shapes of GPT-3 models of every size. With the decomposition it ran slower than in 16 bits at every size up to 6.7B, and only the 13B and 175B shapes ran faster. End-to-end generation with BLOOM-176B took 247 ms per token at batch 1 on three A100 GPUs, against 239 ms in 16 bits on eight. LLM.int8 bought memory, not speed.
Getting 8-bit activations and speed together needed new ideas. One is to move each outlier channel's scale into the weights, dividing the channel down and multiplying the matching row of W up, by the identity XW=(Xdiag(c)−1)(diag(c)W). Another is to rotate the channels so that no single one is large. Both are in §20.3.
Interview
LLM.int8 matrix multiplication
Write llm_int8_matmul(X, W, thr=6.0), returning (Y, O). O holds the sorted indices of the input channels of X with any absolute value greater than thr. Y is the product, with those columns of X and the matching rows of W multiplied in float and the rest in vector-wise int8. Vector-wise means symmetric absmax with one step per token for X and one step per output column for W. The integer products are added up first and then rescaled by the outer product of the steps. Use a step of 1 for any all-zero row or column, and if every column is an outlier, the whole product is the float one.
import numpy as np
def llm_int8_matmul(X, W, thr=6.0):
raise NotImplementedError
Tests, verified solutions, and AI help, open once you add your API key.
Weight-only and weight-activation quantization speed up different things
So far we have asked how accurate a quantizer is. The other question is what it buys, and the answer depends on what limits the GPU. Recall the roofline of §11.2. A piece of work that does F floating-point operations needs at least F/π seconds of arithmetic. The bytes it moves need at least their number divided by β seconds of memory traffic. The work takes at least the larger of the two times.
§20.1 applied this to one decode step of one model on one GPU. A dense model with N parameters does about 2N FLOPs per token, plus attention. The step reads the weights, MW=sWN bytes with sW bytes per weight, plus every sequence's KV cache, the stored keys and values of all its earlier tokens (§6.5). Quantization can change two of these numbers. It can shrink the bytes per weight sW (and the cache's bytes, in §20.3). And if the multiply itself runs in low precision, it raises the peak π.
The roofline then predicts where each one helps. Decode at small batch is memory-bound, so it runs as fast as the bytes allow. There, weight-only quantization speeds it up almost in proportion to the bytes saved. Prefill, the single forward pass over the whole prompt, is compute-bound, and so is decode at large batch. There, fewer weight bytes do nothing, and only a higher π helps.
The cell evaluates the step model for Llama 3.1 8B on one H100. Like §20.1, it counts all N=8.03 billion weights as read each step. The input embedding table is really read only one row per token, and it holds 0.53 billion of those weights, so a careful count reads 6.5% less.
Python · cell 9
So weight-only quantization is the tool for small-batch serving, where latency matters most, and for fitting a model in memory. Weight-activation quantization is the tool for high-throughput serving and for prefill. An interviewer will often ask why W4A16 speeds up decode but not prefill, and this table is the answer.
Real W4A16 kernels have one more subtlety. Turning codes back into 16-bit numbers costs instructions. As the batch grows the kernel becomes compute-bound, and a naive kernel loses its advantage. Kernels built to hide that work inside the matrix-multiply pipeline do much better. One of them, MARLIN, keeps close to the ideal 3.87x speedup of 4-bit weights with group 128 up to batches of about 16 to 32 (Frantar et al. 2024). §20.3 returns to them.
The table collects the common formats. E4M3 is the 8-bit float with 4 exponent bits and 3 mantissa bits (§8.5). NVFP4 and MXFP4 are 4-bit float formats in which each small block of values shares one scale.
Format
What it reduces
Speeds up
Accuracy risk
Typical use
W8A16
weight bytes by 2x
memory-bound decode
very low, per-channel scales suffice
conservative serving
W4A16 (group 128)
weight bytes by about 3.9x
memory-bound decode, fits larger models
low to moderate, needs groups and a good rounding method (§20.3)
local and latency-bound serving
W8A8 INT8
weight and activation bytes by 2x, doubles π
prefill, large-batch decode
activation outliers, needs scale migration or a decomposition
Everything so far quantizes a model that has already been trained. That is the cheap option, and for 8 bits it is usually enough. At 4 bits and below, the model can instead be taught to live with its quantizer, by training with the rounding in the forward pass.
In plain words, the forward pass sees the rounded weights, and the backward pass pretends the rounding was not there, except that clipped weights get no gradient. Bengio et al. (2013) named the estimator. They credited the idea to a 2012 lecture by Geoffrey Hinton, which passed gradients through a hard threshold as if it were the identity, and they noted that it is clearly biased.
The bias is easy to state. The STE gradient is the gradient of the loss at the quantized point Q(w), applied to w as if the quantizer were not there. The true gradient of L(Q(w)) with respect to w is 0 almost everywhere, so the STE is not an estimate of it at all. What it gives is a useful search direction. Gradient steps add up in the latent weight until it crosses a rounding boundary, and then its quantized value moves.
§0.3 wrote the STE as one line with a stop-gradient, w + (q(w) - w).detach(), and §0.9 wrote it as a custom autograd.Function. The clip mask is the one addition QAT needs.
Two cautions come with it. First, a latent weight near a rounding boundary can flip between two grid points every step. That adds noise, and in networks with batch normalization it corrupts the running statistics the normalization keeps. Nagel et al. (2022) measured these oscillations and proposed damping them or freezing the weights that oscillate. A decaying learning rate also lets the latent weights settle. Second, a weight pushed beyond cclip receives no gradient and stays clipped. Methods that learn the step Δ itself, such as learned step size quantization (Esser et al. 2020), apply the STE to Δ as well.
How much can QAT gain? On a small problem we can see the best possible answer exactly. The cell fits a linear model with eight correlated inputs and rounds the least-squares weights to a 2-bit grid {−Δ,0,Δ}. Then it compares that with the best of all 38=6,561 grid choices.
Python · cell 10
Choosing each rounding with its neighbors in mind is the idea that §20.3 develops into a whole algorithm, which rounds one input channel at a time and corrects the rest for its error (Frantar et al. 2022). QAT finds such choices by gradient descent through the quantizer, as the exercise shows.
Core
Quantization-aware training with the straight-through estimator
The setup defines a regression problem, with inputs X_reg (256 by 8, with correlated columns) and targets y_reg. It also defines the least-squares solution w_float and a 2-bit step delta_reg, so the grid is {−Δ,0,Δ}. Write three functions.
fake_quant(w, delta, b), the symmetric quantize-dequantize map with qmax=2b−1−1.
ste_grad(w, X, y, delta, b), the STE gradient of L(w)=n1∥XQ(w)−y∥2. That is n2X⊤(XQ(w)−y), set to zero where ∣wk∣>Δqmax.
qat_train(X, y, w0, delta, b, lr=0.02, steps=1000), gradient descent on the latent weights from w0. Use the step size ηt=η⋅21(1+cos(πt/T)) at step t=0,…,T−1, and return the final latent weights.
The tests check that QAT beats rounding the float solution and comes close to the best possible 2-bit weights.
import numpy as np
def fake_quant(w, delta, b):
raise NotImplementedError
def ste_grad(w, X, y, delta, b):
raise NotImplementedError
Tests, verified solutions, and AI help, open once you add your API key.
In PyTorch
PyTorch ships fake quantization as built-in operators, torch.fake_quantize_per_tensor_affine and torch.fake_quantize_per_channel_affine. Their backward pass is the masked STE above with one small difference. The mask tests the rounded code rather than w, so a value up to half a step beyond cclip, which still rounds to qmax, keeps its gradient. The block first shows the gradient mask. Then it runs QAT on a linear layer with 3-bit weights and one step per output column, and compares the result with rounding the float solution.
🔥 In PyTorchfor a machine with PyTorch
The two values outside [−1.75,1.75], −2 and 3, lie more than half a step beyond it, so they are clipped and get zero gradient. QAT lowers the error of the 3-bit layer from 0.0621 to 0.0389. For real models, QAT costs a training run (or a long fine-tune) and the training data, which PTQ does not need. That is why PTQ dominates for 8 bits and for most 4-bit weight-only deployments. QAT is used where the last fraction of quality at 4 bits or below is worth a training run, as in the natively low-precision releases that §20.3 surveys.
Nagel et al. (2021). A White Paper on Neural Network Quantization. The clearest single treatment of uniform quantizers, granularity, why asymmetric weights cost a data-dependent term, range setting, PTQ, and QAT with the STE.
🎬 Quantization explained with PyTorch - Post-Training Quantization, Quantization-Aware Training · Umar JamilSymmetric and asymmetric quantizers, calibration, PTQ, and QAT with the straight-through estimator, coded from scratch in PyTorch.🎬 EfficientML.ai Lecture 5 - Quantization (Part I) (MIT 6.5940, Fall 2023) · MIT HAN LabSong Han's lecture on numeric formats and linear quantization, including the integer matrix multiply with scales and zero points.🎬 8-bit Methods for Efficient Deep Learning -- Tim Dettmers (University of Washington) · Center for Language & Speech Processing(CLSP), JHUThe LLM.int8 author explains how outlier features emerge at scale and why the mixed-precision decomposition is needed.