Skip to content
Residual Stream
Go back

Quantization from Scratch: Scale, Zero-Point, GPTQ and AWQ

Updated:
Tan Le
ENVI

Small and Specialized Models found that decoding one sequence is memory-bound: tokens per second are capped by how many bytes of weights must be read per token. Halve the bytes and the cap doubles. Quantization stores weights in fewer bits, 8 or 4 instead of 16, and it is the single biggest reason large models run on small hardware. This post builds it from the rounding step up, and measures each refinement on real layers of a real model.

BF16 to INT4: a grid, where its scales sit, where the error lands BF16 weights 16 bits each 4-bit grid: q = round(w / s) + z, the worked example 0 15 one scale per tensor, channel, or 128 weights tensor 57.7% channel 17.4% group 10.6% then place the rounding error GPTQ 6.9% AWQ scaling 8.2% 4-bit output error, layer 22 of Qwen2.5-0.5B layer 22 has outlier inputs (58× the median); in layer 12, AWQ scaling gives 10.9% against 11.0% INT4 weights 4.25 bits each 3.8× faster decode, batch ≤ 64 decode: 8B model, H100, roofline of weights only (KV cache ignored); 1.0× at a batch of 1,024

Where the scales are shared decides most of the error; GPTQ then moves the rest to where it costs less, and AWQ’s scaling helps only in the layer with outlier inputs. The grid is the worked example’s. The gain at decode lasts only while reading weights is the bottleneck.

Data: the outputs below
At a glance
  • Quantization maps each weight to an integer on a grid set by a scale and a zero-point; the error is up to half a step, set by the range of the block sharing the scale: eight weights at 4 bits get a step of 0.052, and 0.47 becomes 0.468.
  • Group-wise scales make 4 bits workable: 11% output error on a real layer, against 73% with one scale per tensor.
  • GPTQ pushes each rounding error onto correlated columns, cutting 4-bit error by a fifth to a third given enough calibration text; AWQ’s scaling helps only where activations have outliers.
  • In the roofline model, 4-bit weights make memory-bound decoding 3.8× faster, and buy nothing once a large batch makes the step compute-bound.

Table of contents

Open Table of contents

Scale and zero-point

To store a number in bb bits, map it onto the integers 00 to 2b−12^b - 1. For a block of weights with minimum wmin⁡w_{\min} and maximum wmax⁡w_{\max}, the scale is the step between grid points and the zero-point is the integer that represents zero:

s=wmax⁡−wmin⁡2b−1,z=round(−wmin⁡s),q=clip(round(ws)+z, 0, 2b−1),s = \frac{w_{\max} - w_{\min}}{2^b - 1}, \qquad z = \text{round}\Big(\frac{-w_{\min}}{s}\Big), \qquad q = \text{clip}\Big(\text{round}\Big(\frac{w}{s}\Big) + z,\ 0,\ 2^b - 1\Big),

and the weight is read back as w^=(q−z) s\hat{w} = (q - z)\, s. Storing zz makes zero exactly representable, which matters because zero padding and many activations are exactly zero. Worked for eight weights at 4 bits, in the first lines of the output below: the scale is 0.052, so every weight moves to the nearest multiple of 0.052, and 0.47 becomes 0.468. The rounding error is at most half a step, s/2s/2, and the step is set by the block’s range: one large weight in a block makes the step large for every other weight in it.

0 -0.312 1 2 3 4 -0.104 5 6 0 7 +0.052 8 +0.104 9 +0.156 10 +0.208 11 12 13 14 15 +0.468 zero-point z = 6 ↔ 0.0 0.47 → 0.468 eight weights on a 4-bit grid: scale s = 0.052, 16 integers 0–15 above: the value each integer reads back as, (q − z)·s every weight moves to the nearest multiple of s; the error is at most s/2

The worked example on its number line. Integer 6 reads back as exactly zero; every weight snaps to the nearest step.

Data: the output below

A symmetric grid drops the zero-point: it spans [−max⁡∣w∣,max⁡∣w∣][-\max|w|, \max|w|] with 2b−1−12^{b-1} - 1 levels on each side (15 levels at 4 bits, here; many kernels use the 16th as an extra negative level), so zero is a grid point by construction and dequantizing is a single multiplication. The price is range: if a block’s weights run from −0.1-0.1 to +0.3+0.3, a third of a symmetric grid covers [−0.3,−0.1][-0.3, -0.1], where there are no weights at all.

The widget quantizes 128 real weights, one group from layer 22 of Qwen2.5-0.5B. Try the group with an outlier at 4 bits: with the range taken from the group, the outlier stretches the grid and most weights share a handful of levels; with the range taken from the whole row, as one scale per channel would, it gets worse; switch to symmetric and watch half the grid go unused:

Quantizing a group of 128 real weights

weights
grid
range from
levels16 · step 0.0248 levels actually used6 rounding error (RMS, relative)21.2%

Dots: the weights. Ticks: the grid. Lines: how far each weight moves.

Where to put the scales

That last point decides the first design choice, how many weights share a scale:

  • Per tensor: one scale for a whole matrix. Cheapest, and ruined by the largest weight anywhere in it.
  • Per channel: one scale per output row.
  • Per group: one scale per run of, typically, 128 consecutive weights in a row. The scales cost a little memory, one 16-bit scale and a 4-bit zero-point per 128 weights, about 0.16 bits per weight (0.25 if the zero-point is kept in 16 bits), and the step adapts to local ranges.
8 bits 4.95% 1.08% 0.64% 4 bits 72.79% 18.17% 10.98% 3 bits 94.82% 38.32% 23.31% one scale per tensor per output channel per 128 weights output error of layer 12’s down-projection, round-to-nearest, held-out tokens

At 4 bits, where the scales are shared matters more than any later refinement.

Data: the measurement below

Formats beyond integers

Integers aren’t the only small grid:

  • NF4 (Dettmers et al., 2023, for QLoRA): 16 levels placed at quantiles of a normal distribution, scaled per block of 64 by the block’s largest magnitude. Weights are roughly normal, so the levels sit where the weights are, and no zero-point is stored. The 32-bit block scales cost 0.5 bits per weight; QLoRA’s double quantization of the scales cuts that to 0.127. QLoRA (Parameter-Efficient Fine-Tuning) fine-tunes on top of it.
  • FP8 (Micikevicius et al., 2022): an 8-bit floating-point number, in two variants: E4M3 (4 exponent bits, 3 mantissa bits, up to 448) for weights and activations, and E5M2 (up to 57,344) where range matters more, as in gradients. Ada, Hopper and later GPUs multiply FP8 natively; Blackwell also runs the MX formats below.
  • MX formats (the OCP Microscaling specification, 2023): blocks of 32 values share one 8-bit power-of-two scale, with the values themselves in FP8, FP6, FP4 or INT8. A block scale in hardware is what group-wise quantization does in software.

Post-training quantization or quantization-aware training

Everything in this post is post-training quantization (PTQ): take a trained model, quantize its weights, perhaps with some calibration data. It costs minutes to a few GPU-hours and no gradient updates. Quantization-aware training (QAT) inserts the rounding into training itself: the forward pass uses quantized weights, and the backward pass treats the rounding as if it were the identity, the straight-through estimator, so the model learns weights that survive rounding. It recovers more quality at very low bit widths, at the cost of a training run.

GPTQ: rounding with error compensation

Rounding each weight to its nearest grid point minimizes each weight’s own error. It doesn’t minimize what matters, the error in the layer’s output on real inputs, ∥XW⊤−XW^⊤∥2\|XW^\top - X\hat{W}^\top\|^2, because errors in different weights can cancel or add up depending on the inputs. GPTQ (Frantar et al., 2023), descended, via Optimal Brain Quantization, from Optimal Brain Surgeon (Hassibi and Stork, 1993), quantizes one input column at a time and, after rounding each, adjusts the columns not yet quantized to compensate. The adjustment comes from the second derivative of the output error, the Hessian H=2X⊤XH = 2X^\top X, estimated on calibration text:

δ=wi−w^i[H−1]ii,W:,j←W:,j−δ [H−1]ijfor every later column j.\delta = \frac{w_i - \hat{w}_i}{[H^{-1}]_{ii}}, \qquad W_{:, j} \leftarrow W_{:, j} - \delta\, [H^{-1}]_{ij} \quad \text{for every later column } j .

Here H−1H^{-1} is the inverse Hessian over the columns not yet quantized, which changes after every step; row ii of the Cholesky factor of H−1H^{-1} gives all of these at once, which is why GPTQ uses it. Columns whose inputs are correlated with column ii absorb its error. The whole layer is processed in one pass, in blocks of 128 columns to keep it fast on a GPU; Frantar et al. quantized 175-billion-parameter models to 3 and 4 bits in about four GPU hours.

rounded, fixed column i not yet quantized error δ pushed onto later columns, weighted by H⁻¹ round column i, then W[:, j] ← W[:, j] − δ · H⁻¹[i, j] H = 2XᵀX from calibration text: correlated columns absorb each other’s error

Round one column, then let the rest compensate. Schematic.

AWQ: protect the channels that matter

Activations are not uniform: a few input channels carry much larger values than the rest, so errors in the weights that multiply them cost more. AWQ (Lin et al., 2024) scales those weights up before quantizing, and the corresponding inputs down by the same factor: Wx=(Wdiag⁡(s))(diag⁡(s)−1x)W x = (W \operatorname{diag}(s)) (\operatorname{diag}(s)^{-1} x), exact in full precision. The scaled-up weights get finer relative steps. With sj=∣xj∣‾αs_j = \overline{|x_j|}^{\alpha}, one exponent α\alpha per layer is chosen on calibration data, and the inverse scaling is folded into the previous operation, so it costs nothing at inference. The full method also searches, for each group, how far to clip its largest weights before rounding: a smaller range with a few clipped weights can beat a wider one with a coarser step. Both halves are measured below.

W x = (W · diag(s)) · (diag(s)⁻¹ x): exact in full precision before W |x| after scaling W |x| the channel with large inputs gets larger weights, so a finer relative step; its input shrinks to match

An exact identity in full precision that changes where rounding error lands. Schematic.

Measured on two real layers

All four, implemented from the descriptions above, on the MLP down-projections of layers 12 and 22 of Qwen2.5-0.5B, with their real inputs from this blog’s text. Error is measured on the layer’s output, on 2,048 tokens that calibration never saw. capture.py records those inputs first (it downloads the model, about 1 GB), and measure.py then runs on the CPU in about 2 minutes.

Full script: capture.py

Round-to-nearest with a scale and a zero-point:

def quantize(w, bits, axis=None):
    """Asymmetric round-to-nearest: an integer grid 0 .. 2^bits - 1, a scale
    and a zero-point so the grid spans [min, max] along `axis`."""
    lo, hi = w.min(axis=axis, keepdims=True), w.max(axis=axis, keepdims=True)
    scale = (hi - lo) / (2 ** bits - 1)
    scale = np.where(scale == 0, 1, scale)
    zero = np.round(-lo / scale)
    q = np.clip(np.round(w / scale) + zero, 0, 2 ** bits - 1)
    return (q - zero) * scale

Full script: measure.py

The same file holds the symmetric and NF4 variants and rtn, which applies quantize per tensor, per output row or per group of 128. GPTQ and AWQ’s scaling search, as the script implements them:

def gptq(W, bits, H, block=128, damp=0.01):
    """GPTQ (Frantar et al., 2023): quantize one input column at a time, in
    groups of 128, and push each column's rounding error onto the columns
    not yet quantized, weighted by the inverse Hessian of the layer's
    squared output error, H = 2 X^T X."""
    W = W.copy(); n = W.shape[1]
    H = H.copy()
    H += damp * np.mean(np.diag(H)) * np.eye(n)
    Hinv = np.linalg.cholesky(np.linalg.inv(H)).T              # upper Cholesky factor of H^-1
    Q = np.zeros_like(W)
    for i1 in range(0, n, block):
        i2 = i1 + block
        W1, E1 = W[:, i1:i2].copy(), np.zeros((W.shape[0], block))
        lo, hi = W1.min(1), W1.max(1)                             # this group's grid, from current weights
        scale = np.where(hi > lo, (hi - lo) / (2 ** bits - 1), 1); zero = np.round(-lo / scale)
        for i in range(block):
            col = W1[:, i]
            q = (np.clip(np.round(col / scale) + zero, 0, 2 ** bits - 1) - zero) * scale
            Q[:, i1 + i] = q
            e = (col - q) / Hinv[i1 + i, i1 + i]
            W1[:, i:] -= np.outer(e, Hinv[i1 + i, i1 + i:i2])     # compensate within the block
            E1[:, i] = e
        W[:, i2:] -= E1 @ Hinv[i1:i2, i2:]                        # and in the columns after it
    return Q

def awq(W, bits, H, mag, grid=np.linspace(0, 1, 11)):
    """AWQ (Lin et al., 2024): scale input channel j of the weights up by
    s_j = mean|x_j|^alpha and its inputs down by the same factor, so the
    channels that matter most get finer steps; alpha chosen on calibration
    data. The scaling is exact in full precision."""
    best = None
    for a in grid:
        s = mag ** a; s /= np.sqrt(s.max() * s.min())
        Q = rtn(W * s, bits, "group 128") / s
        D = W - Q
        e = np.sum((D @ H) * D)                                   # calibration output error, from H
        if best is None or e < best[0]:
            best = (e, a, Q)
    return best[2], best[1]

Full script: measure.py

AWQ’s clipping search (awq_clip) follows in the file, then the worked example and the loop that measures every method on both layers.

worked, 8 weights to 4 bits: scale 0.0520, zero-point 6
  integers [0, 4, 6, 7, 8, 9, 10, 15]
  back     [-0.312, -0.104, 0.0, 0.052, 0.104, 0.156, 0.208, 0.468]

=== layer 12: input channels' mean |x|: median 0.067, largest 0.52 (8x); top 1% of channels carry 3% of the total
round-to-nearest, output error (held-out tokens):
                   8 bits       4 bits       3 bits
per tensor          4.95%       72.79%       94.82%
per channel         1.08%       18.17%       38.32%
group 128           0.64%       10.98%       23.31%
4 bits, blocks of 64: asymmetric 9.66%, symmetric 11.74%, NF4 9.34%
group 128, output error (held-out tokens):
  4 bits: round-to-nearest 10.98%; GPTQ 8.99%; AWQ scaling only 10.90% (alpha 0.2), with clipping 10.37%
  3 bits: round-to-nearest 23.31%; GPTQ 19.44%; AWQ scaling only 23.25% (alpha 0.2), with clipping 20.52%
  GPTQ at 4 bits with 2,048 calibration tokens, by damping (fraction of H's mean diagonal): 0.01: 12.78%, 0.1: 10.48%, 1: 9.46%; on its own calibration tokens (0.01): 3.76%

=== layer 22: input channels' mean |x|: median 0.146, largest 8.53 (58x); top 1% of channels carry 12% of the total
round-to-nearest, output error (held-out tokens):
                   8 bits       4 bits       3 bits
per tensor          3.69%       57.74%       85.07%
per channel         1.03%       17.38%       36.14%
group 128           0.63%       10.64%       22.62%
4 bits, blocks of 64: asymmetric 9.40%, symmetric 11.53%, NF4 8.51%
group 128, output error (held-out tokens):
  4 bits: round-to-nearest 10.64%; GPTQ 6.87%; AWQ scaling only 8.15% (alpha 0.4), with clipping 7.70%
  3 bits: round-to-nearest 22.62%; GPTQ 15.06%; AWQ scaling only 17.13% (alpha 0.4), with clipping 15.47%
  GPTQ at 4 bits with 2,048 calibration tokens, by damping (fraction of H's mean diagonal): 0.01: 8.23%, 0.1: 7.03%, 1: 6.95%; on its own calibration tokens (0.01): 2.64%
  • 8 bits is nearly free; 4 bits needs care; 3 bits hurts. With group scales, 8-bit weights change the output by 0.6%, 4-bit by about 11%, and 3-bit by about 23%, in both layers.
  • Granularity matters more than anything else at low bits. At 4 bits in layer 12, one scale per tensor gives 73% output error, per channel 18%, per group of 128 11%. Smaller blocks mean a smaller range per block, so a smaller step.
  • GPTQ’s compensation helps, given enough calibration. At 4 bits it cuts the group-128 error from 11.0% to 9.0% in layer 12 and from 10.6% to 6.9% in layer 22; at 3 bits, from 23% to 19% and 15%. The counterfactual is calibration size. With 2,048 calibration tokens instead of 32,768, GPTQ in layer 12 is worse than plain rounding (12.8% against 11.0%). X⊤XX^\top X from 2,048 tokens of 4,864-dimensional inputs has rank at most 2,048; in the directions those tokens never exercised, the Hessian is only the small damping term, so GPTQ treats them as free and pushes rounding error into them, and held-out text does use them. On its own calibration tokens it scored 3.8%: overfitting. Raising the damping from 1% to 100% of the Hessian’s mean diagonal brings it back to 9.5%; in layer 22, the 2,048-token run still beat rounding (8.2% against 10.6%). For scale, the GPTQ paper calibrates on 128 sequences of 2,048 tokens, 262,000 tokens.
  • AWQ’s scaling helps where there are outliers, and only there. Layer 12’s largest input channel averages 8 times the median, and the scaling changes nothing (10.9% against 11.0%). Layer 22’s largest is 58 times the median, the top 1% of channels carry 12% of the input’s size, and the scaling cuts the 4-bit error from 10.6% to 8.2% and the 3-bit from 22.6% to 17.1%. Its clipping search helps both layers: with it, AWQ reaches 10.4% in layer 12 and 7.7% in layer 22 at 4 bits. GPTQ is still ahead in both.
layer 12 (largest input channel 8× the median) 4 bits 11.0 9.0 10.9 10.4 3 bits 23.3 19.4 23.2 20.5 layer 22 (58× the median) 4 bits 10.6 6.9 8.2 7.7 3 bits 22.6 15.1 17.1 15.5 round-to-nearest GPTQ AWQ scaling AWQ + clipping output error (%), group of 128, held-out tokens

GPTQ helps in both layers. AWQ’s scaling (pale) does nothing in layer 12 and a lot in layer 22, the one with outlier inputs.

Data: the output above

At 4 bits with QLoRA’s blocks of 64, the formats line up as the theory says: symmetric absmax is worst (11.7% and 11.5%): its range is set by whichever side of zero is larger, and its grid has 15 levels, not 16, which together make its step coarser, and its error about 20% higher. Asymmetric with a zero-point gets 9.7% and 9.4%, paying a few bits for its zero-points; NF4, with levels shaped like the weights, 9.3% and 8.5%, without one.

Output error in one layer isn’t the model’s quality: errors compound through 24 layers, and the papers’ own numbers are perplexity over whole models on WikiText-2 and C4. These per-layer errors, on two of the 24 layers and one of the seven linear layers in each, are a proxy for that, good for seeing the mechanisms, not for ranking the methods on a model.

What it buys at decode

# What 4-bit weights buy at decode, by batch size, for an 8B model on an
# H100 (3.35 TB/s; 989 TFLOP/s dense BF16). One decode step for a batch of
# B sequences reads every weight once and does 2 FLOPs per weight per
# sequence; its time is at least the larger of the two ("How a GPU Runs Deep Learning"'s roofline;
# KV-cache reads and dequantization ignored). INT4 weights with group
# scales take about 4.25 bits each.
N, BW, FLOPS = 8e9, 3.35e12, 989e12
print(f"{'batch':>6s} {'BF16 step':>10s} {'INT4 step':>10s} {'speedup':>8s}   bound by")
for B in (1, 8, 64, 128, 256, 1024):
    compute = 2 * N * B / FLOPS
    bf16, int4 = max(2 * N / BW, compute), max(4.25 / 8 * N / BW, compute)
    print(f"{B:6d} {bf16 * 1e3:8.2f} ms {int4 * 1e3:8.2f} ms {bf16 / int4:7.1f}x   "
          f"BF16 {'memory' if 2 * N / BW > compute else 'compute'}, INT4 {'memory' if 4.25 / 8 * N / BW > compute else 'compute'}")
print(f"compute-bound from a batch of {4.25 / 8 * N / BW * FLOPS / (2 * N):.0f} (INT4) and "
      f"{2 * N / BW * FLOPS / (2 * N):.0f} (BF16)")

Full script: decode_speed.py

 batch  BF16 step  INT4 step  speedup   bound by
     1     4.78 ms     1.27 ms     3.8x   BF16 memory, INT4 memory
     8     4.78 ms     1.27 ms     3.8x   BF16 memory, INT4 memory
    64     4.78 ms     1.27 ms     3.8x   BF16 memory, INT4 memory
   128     4.78 ms     2.07 ms     2.3x   BF16 memory, INT4 compute
   256     4.78 ms     4.14 ms     1.2x   BF16 memory, INT4 compute
  1024    16.57 ms    16.57 ms     1.0x   BF16 compute, INT4 compute
compute-bound from a batch of 78 (INT4) and 295 (BF16)

At small batches a decode step only reads weights, so 4-bit weights make it 3.8 times faster. As the batch grows, the same weights serve more sequences per read and compute catches up: 4-bit weights are compute-bound from a batch of about 80, so at 128 the speedup is 2.3 and at 256, 1.2. At 1,024 both are compute-bound and weight-only quantization buys nothing. Hence the split in practice: weight-only 4-bit for local and low-batch serving, and 8-bit weights and activations, using the GPU’s integer or FP8 units, for high-throughput serving. Two things this arithmetic ignores both shrink the gain: dequantizing in the kernel costs time, and the KV cache is read too. An 8B model with GQA, such as Llama 3 8B (32 layers, 8 key-value heads of 128 dimensions), stores 32×8×12832 \times 8 \times 128 keys and as many values per token, 2 bytes each: 128 KiB. At a batch of 64 with 4,000-token contexts, that is 128 KiB×4,000×64≈33.6128\ \text{KiB} \times 4{,}000 \times 64 \approx 33.6 GB read per step, twice its 16 GB of 16-bit weights. With 4.25 GB of 4-bit weights the step then speeds up only (16+33.6)/(4.25+33.6)≈1.3(16 + 33.6) / (4.25 + 33.6) \approx 1.3 times. The KV cache has its own quantization (see KV Cache Compression).

1 2 5 10 20 1 8 64 128 256 1024 batch (sequences decoded together) ms per decode step, 8B on H100 3.8× 2.3× 1.2× both compute-bound BF16 weights INT4 weights INT4 becomes compute-bound from a batch of 78, BF16 from 295

Weight-only quantization pays while reading weights is the bottleneck; once arithmetic is, the two lines meet.

Data: the output above

Weights only, or activations too

Everything above quantizes weights only: they are stored in 4 bits and expanded to 16 for the multiplication, which helps exactly where Small and Specialized Models said the bottleneck is, reading weights during decode. Quantizing the activations too, to run the multiplication itself in 8-bit integers, also speeds up compute-bound prefill and large batches, but activation outliers make it harder: LLM.int8() (Dettmers et al., 2022) keeps the few outlier dimensions in 16 bits, and SmoothQuant (Xiao et al., 2023) migrates the outliers’ range from activations into weights with the same per-channel scaling trick as AWQ.

Where this leads

Takeaways

  • Quantization stores each weight as an integer on a grid set by a scale and a zero-point; the error is up to half a step, and the step is set by the range of the block sharing the scale.
  • Group-wise scales (128 weights) are what make 4 bits workable: 11% output error on a real layer, against 73% with one scale per tensor.
  • GPTQ rounds column by column and pushes each error onto correlated columns, cutting 4-bit output error by a fifth to a third here, given enough calibration text; with 2,048 tokens and default damping it overfit, and in layer 12 was worse than plain rounding.
  • AWQ’s scaling by activation size helped only in the layer with large activation outliers (10.6% to 8.2%); its clipping search helped both.
  • At 4 bits in blocks of 64, NF4 beat asymmetric and symmetric integer grids on both layers (9.3% and 8.5% output error).
  • Weight-only quantization speeds up memory-bound decoding, 3.8 times at small batches for 4-bit weights, and nothing once the batch makes the step compute-bound; quantizing activations as well has to handle their outliers.

References

Start with the GPTQ and AWQ papers, the two methods this post implements; QLoRA introduces NF4, and SmoothQuant covers quantizing activations.


Written by

Tan Le

AI engineer. I write these notes to learn LLM internals and AI engineering in depth, and to keep what I learn where I can find it again.