Type at least 2 letters. Enter opens the first result, Esc closes.

    ML Systems · Lesson 11 · Inference and performance in practice

    Quantization for training and inference

    Intermediate 70 min 5 interactive figures 3 videos Colab notebook

    Before this lesson: lesson 2 (the roofline and the matmul intensity) and lesson 5 (number formats, rounding, FP8 scale factors). For the Δ2/12\Delta^2/12 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 14 GB/3.35 TB/s=4.214\ \text{GB} / 3.35\ \text{TB/s} = 4.2 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 Δ\Delta (the scale, or step size), with 0 exactly on a grid point. With bb bits there are at most 2b2^b 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 Δ/2\Delta/2. A larger Δ\Delta 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 Δ\Delta. So the range is a trade-off: wide enough to clip little, narrow enough to keep Δ\Delta small. When many values share one scale and we choose the range to cover all of them, the largest value sets Δ\Delta for every other value.

    Interactive figure: loading…

    Move the sliders. Each grey dot on the top line is a value xx, and its line goes to its quantized value x^\hat x 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 Δ\Delta 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 Δ/2\Delta/2.

    Quick check

    You quantize a tensor with max⁡∣x∣=2.54\max|x| = 2.54 to symmetric int8 with the restricted range [−127,127][-127, 127]. What is the scale Δ\Delta, and which integer stores x=−1.0x = -1.0?

    3. Definitions and main results #

    Notation. In this lesson Δ\Delta is the quantization scale (the step size) and zz the zero-point. Papers also write ss for the scale; we keep sjs_j for SmoothQuant's smoothing factors.

    Uniform affine quantization #

    Choose bb bits, an integer grid [qmin⁡,qmax⁡][q_{\min}, q_{\max}], a scale Δ>0\Delta > 0 and an integer zero-point zz. Then

    q=clamp(round(x/Δ)+z, qmin⁡, qmax⁡),x^=Δ (q−z).q = \mathrm{clamp}\big(\mathrm{round}(x/\Delta) + z,\ q_{\min},\ q_{\max}\big), \qquad \hat x = \Delta\,(q - z).

    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 clamp(v,a,b)=min⁡(max⁡(v,a),b)\mathrm{clamp}(v, a, b) = \min(\max(v, a), b).

    • Symmetric: z=0z = 0 and a signed grid. With the restricted range [−(2b−1−1), 2b−1−1][-(2^{b-1} - 1),\ 2^{b-1} - 1], that is [−127,127][-127, 127] for int8 and [−7,7][-7, 7] for int4, the grid is symmetric around 0, and Δ=max⁡∣x∣/qmax⁡\Delta = \max|x| / q_{\max}.
    • Asymmetric: an unsigned grid [0,2b−1][0, 2^b - 1], a range [min⁡(x,0),max⁡(x,0)][\min(x, 0), \max(x, 0)], Δ=(max⁡−min⁡)/(2b−1)\Delta = (\max - \min)/(2^b - 1) and z=round(−min⁡/Δ)z = \mathrm{round}(-\min/\Delta). Zero is exactly representable (q=zq = z), 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 ∑lΔx(qlx−zx) Δw(qlw−zw)\sum_l \Delta_x (q^x_l - z_x)\,\Delta_w (q^w_l - z_w). Expanding the product gives four sums. The term zw∑lqlxz_w \sum_l q^x_l depends on the input, so it must be computed at run time for every token. The term zx∑lqlwz_x \sum_l q^w_l 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 (zw=0z_w = 0) and symmetric or asymmetric activations.

    Theorem (rounding error bound) #

    If xx lies in the clipping range [Δ(qmin⁡−z), Δ(qmax⁡−z)][\Delta(q_{\min} - z),\ \Delta(q_{\max} - z)], then ∣x−x^∣≤Δ/2|x - \hat x| \le \Delta/2.

    Proof.

    1. Let v=x/Δv = x/\Delta and q′=round(v)+zq' = \mathrm{round}(v) + z, the code before the clamp. Rounding moves a number by at most 1/21/2: ∣round(v)−v∣≤1/2|\mathrm{round}(v) - v| \le 1/2. So the integer q′q' is within 1/21/2 of v+zv + z.
    2. xx in the range means v+z∈[qmin⁡,qmax⁡]v + z \in [q_{\min}, q_{\max}]. The ends are integers, so q′q' cannot leave [qmin⁡,qmax⁡][q_{\min}, q_{\max}]. (If q′≤qmin⁡−1q' \le q_{\min} - 1, then v+z≤q′+1/2≤qmin⁡−1/2v + z \le q' + 1/2 \le q_{\min} - 1/2, which is outside the range; the same argument works at the top.) So the clamp does nothing: q=q′q = q'.
    3. x^−x=Δ(q′−z)−Δv=Δ (round(v)−v)\hat x - x = \Delta(q' - z) - \Delta v = \Delta\,(\mathrm{round}(v) - v).
    4. So ∣x^−x∣≤Δ/2|\hat x - x| \le \Delta/2. ■\blacksquare

    Outside the range the error is the clipping error, which has no bound. With asymmetric min-max calibration, rounding zz shifts the grid by up to Δ/2\Delta/2, so one end value may be clipped, but by at most Δ/2\Delta/2.

    Theorem (mean squared rounding error) #

    The bound Δ/2\Delta/2 is the worst case. The typical error is smaller.

    Assumption. The rounding error e=x−x^e = x - \hat x is uniformly distributed on [−Δ/2,Δ/2][-\Delta/2, \Delta/2]. This is a good model when xx is not clipped and its density changes little over one step, that is, when the data spread over many steps.

    Result. Under this assumption, E[e]=0\mathbb{E}[e] = 0 and

    E[e2]=Δ212.\mathbb{E}[e^2] = \frac{\Delta^2}{12} .

    So the root-mean-square (RMS) error is Δ/12≈0.29 Δ\Delta/\sqrt{12} \approx 0.29\,\Delta.

    Why this is true. Inside one step, xx is spread almost evenly, so its distance to the grid point is spread evenly too.

    Proof.

    1. The density of ee is 1/Δ1/\Delta on [−Δ/2,Δ/2][-\Delta/2, \Delta/2] and 0 elsewhere.
    2. The mean is zero, because the integrand is odd:
    E[e]=1Δ∫−Δ/2Δ/2e de=0.\mathbb{E}[e] = \frac{1}{\Delta}\int_{-\Delta/2}^{\Delta/2} e\, de = 0 .
    1. The second moment:
    E[e2]=1Δ∫−Δ/2Δ/2e2 de=1Δ[e33]−Δ/2Δ/2=1Δ⋅23⋅Δ38=Δ212.■\begin{aligned} \mathbb{E}[e^2] &= \frac{1}{\Delta}\int_{-\Delta/2}^{\Delta/2} e^2\, de = \frac{1}{\Delta}\left[\frac{e^3}{3}\right]_{-\Delta/2}^{\Delta/2} \\ &= \frac{1}{\Delta} \cdot \frac{2}{3} \cdot \frac{\Delta^3}{8} = \frac{\Delta^2}{12}. \qquad \blacksquare \end{aligned}

    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 10510^5 Gaussian values and Δ=0.1\Delta = 0.1, the measured mean squared error is 8.33×10−48.33 \times 10^{-4}, and Δ2/12=8.33×10−4\Delta^2/12 = 8.33 \times 10^{-4}.

    Quick check

    Values spread over many steps are rounded to a grid with Δ=0.1\Delta = 0.1 (no clipping). What is the mean squared error, approximately?

    The clipping trade-off #

    Take symmetric quantization with range [−c,c][-c, c] and Δ=c/qmax⁡\Delta = c / q_{\max}. The mean squared error has two parts:

    MSE(c)≈Δ212 P(∣x∣≤c)⏟rounding+E[(∣x∣−c)2; ∣x∣>c]⏟clipping.\mathrm{MSE}(c) \approx \underbrace{\frac{\Delta^2}{12}\, P(|x| \le c)}_{\text{rounding}} + \underbrace{\mathbb{E}\big[(|x| - c)^2;\ |x| > c\big]}_{\text{clipping}} .

    Here E[ ⋅ ; ∣x∣>c]\mathbb{E}[\,\cdot\,;\ |x| > c] means the expectation over the clipped values only. The rounding part grows like c2c^2; the clipping part falls quickly as cc passes the bulk of the data. The sum has a minimum. Min-max calibration (c=max⁡∣x∣c = \max|x|) never clips the calibration data, but its cc is set by the most extreme sample. Percentile or MSE-optimal ranges clip a few values to get a smaller Δ\Delta (Nagel et al., 2021, section 3.1).

    Interactive figure: loading…

    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 cc. The rounding part grows with cc and follows the dashed line Δ2/12\Delta^2/12; the clipping part falls. Drag cc to the bottom of the black curve. At 4 bits, the best range for this Gaussian sample is about 2.5σ2.5\sigma, and min-max gives about twice the error. Switch to Laplace (heavier tails) and to 8 bits: the best range moves outwards.

    With 2×1052 \times 10^5 samples of unit variance, the MSE-optimal ranges are about 2.5σ2.5\sigma (Gaussian) and 3.5σ3.5\sigma (Laplace) at 4 bits, and about 4.0σ4.0\sigma and 6.9σ6.9\sigma at 8 bits. More bits make Δ\Delta 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 Y=XWY = XW with X∈Rn×kX \in \mathbb{R}^{n \times k} (nn tokens, kk input channels) and W∈Rk×mW \in \mathbb{R}^{k \times m} (mm output channels). The sum runs over the reduction dimension l=1,…,kl = 1, \dots, k.

    • Per-tensor: one scale for all of XX, one for all of WW.
    • Per-channel: one scale wjw_j per output column jj of WW; for activations, one scale aia_i per token (row ii of XX).
    • Group-wise: one scale per group of gg consecutive input elements (gg = 32 to 128) in each column of WW.

    Which scales an integer matmul can use. With Xil≈aiqilX_{il} \approx a_i q_{il} and Wlj≈wjpljW_{lj} \approx w_j p_{lj}:

    Yij≈∑l(aiqil)(wjplj)=ai wj∑lqil plj.Y_{ij} \approx \sum_l (a_i q_{il})(w_j p_{lj}) = a_i\, w_j \sum_l q_{il}\, p_{lj}.

    Scales that depend only on ii or jj (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 ll (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 ll, 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 4+16/128=4.1254 + 16/128 = 4.125 bits per weight.

    Interactive figure: loading…

    Change the granularity. Rows are the outer dimension ii, columns the reduction dimension ll. The left map shows ∣x∣|x|; 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.

    Quick check

    Activations have one outlier channel (a column ll of XX). Why can an int8 × int8 matmul not simply use one scale per input channel of XX?

    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 Δ\Delta: a channel with maximum mlm_l in a tensor with maximum mm gets only about 28ml/m2^8 m_l / m 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 s∈Rks \in \mathbb{R}^k with positive entries,

    Y=XW=(X diag(s)−1)(diag(s) W)=X^W^,Y = XW = \big(X\,\mathrm{diag}(s)^{-1}\big)\big(\mathrm{diag}(s)\,W\big) = \hat X \hat W ,

    exactly, because diag(s)−1diag(s)=I\mathrm{diag}(s)^{-1}\mathrm{diag}(s) = I. Column jj of XX is divided by sjs_j, and row jj of WW is multiplied by sjs_j. The paper chooses

    sj=max⁡∣Xj∣αmax⁡∣Wj∣1−α,s_j = \frac{\max|X_j|^{\alpha}}{\max|W_j|^{1-\alpha}},

    so that

    max⁡∣X^j∣=(max⁡∣Xj∣max⁡∣Wj∣)1−α,\max|\hat X_j| = \big(\max|X_j| \max|W_j|\big)^{1-\alpha},
    max⁡∣W^j∣=(max⁡∣Xj∣max⁡∣Wj∣)α,\max|\hat W_j| = \big(\max|X_j| \max|W_j|\big)^{\alpha},

    where XjX_j is column jj of XX, WjW_j is row jj of WW, and α∈[0,1]\alpha \in [0, 1] is the migration strength.

    Proof of the maxima. Dividing column jj of XX by sjs_j divides its maximum by sjs_j, and multiplying row jj of WW by sjs_j multiplies its maximum by sjs_j:

    max⁡∣X^j∣=max⁡∣Xj∣sj=max⁡∣Xj∣1−αmax⁡∣Wj∣1−α,\max|\hat X_j| = \frac{\max|X_j|}{s_j} = \max|X_j|^{1-\alpha} \max|W_j|^{1-\alpha},
    max⁡∣W^j∣=sjmax⁡∣Wj∣=max⁡∣Xj∣αmax⁡∣Wj∣α.■\max|\hat W_j| = s_j \max|W_j| = \max|X_j|^{\alpha} \max|W_j|^{\alpha}. \qquad \blacksquare

    At α=0.5\alpha = 0.5 both maxima equal max⁡∣Xj∣max⁡∣Wj∣\sqrt{\max|X_j| \max|W_j|}. A larger α\alpha 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 XX come from calibration data. The cost at run time is zero: diag(s)−1\mathrm{diag}(s)^{-1} is folded into the scale and bias of the LayerNorm before the layer, and diag(s)\mathrm{diag}(s) into WW, offline. The Q, K and V projections read the same LayerNorm output, so they share one ss.

    Interactive figure: loading…

    Move α\alpha. The bars are the channel maxima of the activations XX and the weights WW, faint before smoothing and solid after (log scale). Channels 2 and 6 hold outliers. At α=1\alpha = 1 every activation channel has maximum 1 and the weights take all the range; at α=0\alpha = 0 it is the other way round. The lower chart is the relative error of YY in W8A8 (one scale for XX, one per output column of WW). The last readout shows that X^W^\hat X \hat W equals XWXW up to float rounding: the identity is exact.

    Quick check

    SmoothQuant with α=0.5\alpha = 0.5 on a channel with max⁡∣Xj∣=50\max|X_j| = 50 and max⁡∣Wj∣=0.02\max|W_j| = 0.02. What are sjs_j and the new channel maxima?

    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 2X⊤X2X^\top X of the layer's squared error ∥XW−XW^∥2\|XW - X\hat W\|^2;
      • 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 WW 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 ∂x^/∂x=1\partial \hat x / \partial x = 1 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 Δ/2\Delta/2 for every value. An fp8 grid has a relative bound for normal numbers (2−42^{-4} for E4M3, 2−32^{-3} 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 x^=Δ⋅clamp(round(x/Δ),qmin⁡,qmax⁡)\hat x = \Delta \cdot \mathrm{clamp}(\mathrm{round}(x/\Delta), q_{\min}, q_{\max}). 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 x^=x\hat x = x for the backward pass only, as long as xx is inside the range, where the staircase follows the line within Δ/2\Delta/2. Outside the range, the clamp is really flat: increasing xx does not change x^\hat x. 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 BB tokens and a D×FD \times F weight, the FLOPs are 2BDF2BDF. In bf16 the weight bytes are 2DF2DF, so the intensity is about BB FLOP/byte when B≪D,FB \ll D, F (lesson 2). The H100 ridge point is R=989/3.35≈295R = 989/3.35 \approx 295.

    • W4A16 (int4 weights, bf16 activations and bf16 matmuls): 4× fewer weight bytes, the same bf16 FLOP/s. The intensity becomes about 4B4B. Decode at small BB is memory-bound, so it gets up to 4× faster. The matmul becomes compute-bound at 4B=R4B = R, that is B≈74B \approx 74. From there the gain is only R/BR/B, and there is no gain at B≥295B \ge 295 (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 B≈148B \approx 148 (where its compute time 2B/P2B/P per weight equals the W8A8 memory time 1/BW1/\mathrm{BW} per weight) and W8A8 above. Real kernels lose some speed to dequantization and to reading the scales.
    Interactive figure: loading…

    W4A16 on the roofline of an H100 in bf16, for one 4096×40964096 \times 4096 weight matmul with BB 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 B=1B = 1 and B=64B = 64 the int4 weight moves the point to the right on the memory roof: the time drops by up to 3.9×. At B=4096B = 4096 (prefill) both points are on the compute roof, and the time is the same.

    Quick check

    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)?

    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) Δ=40/127≈0.315\Delta = 40/127 \approx 0.315, so [−0.8,0.8][-0.8, 0.8] maps to q∈{−3,…,3}q \in \{-3, \dots, 3\}: only 7 of the 255 levels.

    4. Worked example #

    (a) Three quantizers. x=(−0.7,−0.15,0,0.4,2.0)x = (-0.7, -0.15, 0, 0.4, 2.0).

    • Symmetric int8. Δ=2.0/127=0.015748\Delta = 2.0/127 = 0.015748. Then x/Δ=(−44.45,−9.525,0,25.4,127)x/\Delta = (-44.45, -9.525, 0, 25.4, 127), so q=(−44,−10,0,25,127)q = (-44, -10, 0, 25, 127) and x^=(−0.6929,−0.1575,0,0.3937,2.0)\hat x = (-0.6929, -0.1575, 0, 0.3937, 2.0). Largest error 0.0075≤Δ/2=0.00790.0075 \le \Delta/2 = 0.0079.
    • Asymmetric uint8. Range [−0.7,2.0][-0.7, 2.0], Δ=2.7/255=0.010588\Delta = 2.7/255 = 0.010588, z=round(0.7/0.010588)=round(66.11)=66z = \mathrm{round}(0.7/0.010588) = \mathrm{round}(66.11) = 66. Then q=round(x/Δ)+66=(0,52,66,104,255)q = \mathrm{round}(x/\Delta) + 66 = (0, 52, 66, 104, 255) and x^=(−0.6988,−0.1482,0,0.4024,2.0012)\hat x = (-0.6988, -0.1482, 0, 0.4024, 2.0012). Largest error 0.0024≤Δ/2=0.00530.0024 \le \Delta/2 = 0.0053. The grid ends at 2.0012, not 2.0: the rounded zz shifted it.
    • Symmetric int4. Δ=2/7=0.2857\Delta = 2/7 = 0.2857, x/Δ=(−2.45,−0.525,0,1.4,7)x/\Delta = (-2.45, -0.525, 0, 1.4, 7), so q=(−2,−1,0,1,7)q = (-2, -1, 0, 1, 7) and x^=(−0.571,−0.286,0,0.286,2.0)\hat x = (-0.571, -0.286, 0, 0.286, 2.0). Largest error 0.136≤Δ/2=0.1430.136 \le \Delta/2 = 0.143.

    (b) An outlier channel and SmoothQuant. Two tokens, two channels, α=0.5\alpha = 0.5:

    X=(0.8−30−0.540),W=(0.4−0.30.10.05),XW=(−2.68−1.743.82.15).X = \begin{pmatrix} 0.8 & -30 \\ -0.5 & 40 \end{pmatrix}, \qquad W = \begin{pmatrix} 0.4 & -0.3 \\ 0.1 & 0.05 \end{pmatrix}, \qquad XW = \begin{pmatrix} -2.68 & -1.74 \\ 3.8 & 2.15 \end{pmatrix}.
    1. Per-tensor int8 of XX. Δ=40/127=0.315\Delta = 40/127 = 0.315. Channel 0 becomes q=(3,−2)q = (3, -2) and x^=(0.945,−0.630)\hat x = (0.945, -0.630): errors of 18% and 26%.
    2. Smoothing factors. max⁡∣X0∣=0.8\max|X_0| = 0.8, max⁡∣W0∣=0.4\max|W_0| = 0.4 (row 0 of WW), so s0=0.8/0.4=1.414s_0 = \sqrt{0.8/0.4} = 1.414. max⁡∣X1∣=40\max|X_1| = 40, max⁡∣W1∣=0.1\max|W_1| = 0.1, so s1=40/0.1=20s_1 = \sqrt{40/0.1} = 20.
    3. Smoothed matrices. Divide the columns of XX by ss and multiply the rows of WW by ss:
    X^=(0.566−1.5−0.3542.0),W^=(0.566−0.4242.01.0).\hat X = \begin{pmatrix} 0.566 & -1.5 \\ -0.354 & 2.0 \end{pmatrix}, \qquad \hat W = \begin{pmatrix} 0.566 & -0.424 \\ 2.0 & 1.0 \end{pmatrix}.

    The channel maxima are 0.32=0.566\sqrt{0.32} = 0.566 and 4=2\sqrt{4} = 2 in both matrices, and X^W^=XW\hat X \hat W = XW exactly. 4. W8A8 (per-tensor XX, per-output-column WW): the largest error of YY 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 (2⋅7B⋅4096=5.7×10132 \cdot 7\text{B} \cdot 4096 = 5.7 \times 10^{13} 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 Δ2/12\Delta^2/12 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 Δ/2\Delta/2 as the typical error. It is the worst case; the RMS error is about Δ/12≈0.29 Δ\Delta/\sqrt{12} \approx 0.29\,\Delta.

    8. Check yourself #

    1. Symmetric int4 [−7,7][-7, 7] of w=(0.33,−1.4,0.05)w = (0.33, -1.4, 0.05): scale, codes and largest error?
      AnswerΔ=1.4/7=0.2\Delta = 1.4/7 = 0.2; w/Δ=(1.65,−7,0.25)w/\Delta = (1.65, -7, 0.25), so q=(2,−7,0)q = (2, -7, 0) and w^=(0.4,−1.4,0)\hat w = (0.4, -1.4, 0). The largest error is 0.07≤Δ/2=0.10.07 \le \Delta/2 = 0.1.
    2. 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?
      Answer4+32/64=4.54 + 32/64 = 4.5 bits per weight; 70×109⋅4.5/8≈39.470 \times 10^9 \cdot 4.5 / 8 \approx 39.4 GB.
    3. Why does the bound ∣x−x^∣≤Δ/2|x - \hat x| \le \Delta/2 need "xx inside the range"?
      AnswerOutside the range the clamp replaces the code by qmin⁡q_{\min} or qmax⁡q_{\max}, so x^\hat x is the end of the grid, and the error is the distance from xx to that end, which can be arbitrarily large.
    4. 4-bit symmetric quantization of Gaussian data: why is the MSE-optimal range smaller than max⁡∣x∣\max|x|, and what happens to it at 8 bits?
      AnswerAt 4 bits the step c/7c/7 is large, so the rounding part Δ2/12\Delta^2/12 dominates; clipping a few tail values (about 2.5σ2.5\sigma) makes Δ\Delta much smaller and lowers the total. At 8 bits Δ=c/127\Delta = c/127 is small, so rounding matters less, and the best range moves out to about 4σ4\sigma.
    5. Why does SmoothQuant cost nothing at inference time?
      Answerdiag(s)−1\mathrm{diag}(s)^{-1} is folded into the scale and bias of the LayerNorm before the layer, and diag(s)\mathrm{diag}(s) is multiplied into WW offline. The model runs the same ops as before, with different numbers.
    6. What gradient does the STE give to a value outside the clipping range, and why?
      AnswerZero: the clamp is constant there, so x^\hat x does not change when xx changes. Inside the range the rounding is treated as the identity, so the gradient is 1.
    Recall cardsAnswer in your head first, then reveal. Add the cards to your review deck to see them again just before you would forget.
    1. Write the uniform affine quantize and dequantize maps.

    2. What is the largest rounding error inside the clipping range?

    3. Under which assumption is the mean squared rounding error Δ2/12\Delta^2/12?

    4. Compute the symmetric int8 scale for max⁡∣x∣=1.27\max|x| = 1.27.

    5. Why are weights usually quantized symmetrically?

    6. Which scales can an int8 × int8 matmul apply after the integer sum?

    7. How many bits per weight does int4 with one fp16 scale per group of 128 cost?

    8. State the SmoothQuant identity and the choice of sjs_j.

    9. What gradient does the straight-through estimator use for fake quantization?

    10. Does W4A16 speed up the prefill of a long prompt? Why?

    Open the review →

    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 sjs_j and multiply the matching weight row by sjs_j: 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 Δ2/12\Delta^2/12 the quantization noise?" If the error is uniform on one step, its variance is 1Δ∫−Δ/2Δ/2e2 de=Δ2/12\frac{1}{\Delta}\int_{-\Delta/2}^{\Delta/2} e^2\,de = \Delta^2/12.

    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.

    1. Write quantize and dequantize for symmetric and asymmetric int8 and int4, and check the error bound Δ/2\Delta/2 and the Δ2/12\Delta^2/12 result.
    2. Compute the MSE against the clipping range and find the best range for Gaussian and Laplace data.
    3. Compare per-tensor, per-row, per-column and group-wise scales on a matrix with an outlier channel.
    4. Implement SmoothQuant and plot the W8A8 error of XWXW against α\alpha.
    5. 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 #

    Number formats, linear (affine) quantization with scale and zero-point, and how an integer matmul is computed.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Granularity, clipping, post-training quantization and quantization-aware training with the straight-through estimator.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    Symmetric and asymmetric quantization worked out in code, calibration, and QAT in PyTorch.
    Open on YouTube · the player loads from youtube-nocookie.com when you press play
    • 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 α\alpha).
    • 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.