Chapter 40

Quantization & Serving

Integer and low-bit float formats, GPTQ, AWQ, paged attention, batching, and rooflines.

Inference systems spend most of their time moving weights and KV-cache pages, not inventing new math. Quantization stores numbers with fewer bits, and serving systems keep those smaller tensors flowing through memory without wasting cache. This chapter builds the core quantizers in NumPy, measures their error, and uses a roofline calculator to see why one-token decoding is memory-bound.

40.1 Integer quantization

A quantizer maps a float xx to a small integer qq plus scale metadata. Symmetric absmax quantization chooses

s=max⁡i∣xi∣2b−1−1,qi=round⁡(xi/s).(40.1)s = \frac{\max_i |x_i|}{2^{b-1}-1}, \qquad q_i = \operatorname{round}(x_i / s) .\tag{40.1}

The dequantized value is x^i=sqi\hat{x}_i = s q_i. It represents zero exactly and uses signed levels, so it is natural for centered weights. Asymmetric zero-point quantization instead maps xmin⁡x_{\min} to an unsigned integer minimum and xmax⁡x_{\max} to a maximum:

s=xmax⁡−xmin⁡qmax⁡−qmin⁡,z=round⁡(qmin⁡−xmin⁡/s).(40.2)s = \frac{x_{\max}-x_{\min}}{q_{\max}-q_{\min}}, \qquad z = \operatorname{round}(q_{\min} - x_{\min}/s) .\tag{40.2}

Then qi=round⁡(xi/s+z)q_i = \operatorname{round}(x_i/s+z) and x^i=s(qi−z)\hat{x}_i=s(q_i-z). This spends the codebook on the observed interval, which helps non-centered activations.

Listing 40.1 Absmax and zero-point quantization
def quantize_absmax(x, bits=8, axis=None):
    """Symmetric signed integer quantization with an absmax scale."""
    x = np.asarray(x, dtype=np.float32)
    qmax = 2 ** (bits - 1) - 1
    scale = np.max(np.abs(x), axis=axis, keepdims=True) / qmax
    scale = np.where(scale == 0, 1.0, scale)
    q = np.clip(np.round(x / scale), -qmax, qmax).astype(np.int32)
    return q, q.astype(np.float32) * scale, scale


def quantize_zero_point(x, bits=8, axis=None):
    """Asymmetric integer quantization with a learned zero point."""
    x = np.asarray(x, dtype=np.float32)
    qmin, qmax = 0, 2 ** bits - 1
    xmin = np.min(x, axis=axis, keepdims=True)
    xmax = np.max(x, axis=axis, keepdims=True)
    scale = (xmax - xmin) / max(qmax - qmin, 1)
    scale = np.where(scale == 0, 1.0, scale)
    zero = np.clip(np.round(qmin - xmin / scale), qmin, qmax)
    q = np.clip(np.round(x / scale + zero), qmin, qmax).astype(np.int32)
    return q, (q.astype(np.float32) - zero) * scale, scale, zero

40.2 Tensor, channel, and group scales

Quantization error is controlled by the scale’s granularity. A per-tensor scale is cheapest but lets one outlier set the step size for every element. A per-channel scale gives each output or input channel its own range. A per-group scale splits a channel into short blocks; it stores more scales but usually reduces error because each block has a smaller dynamic range.

The code below reshapes the last axis into groups and reuses the same absmax formula. The tests build a matrix with small and large regions and assert that 4-bit mean-squared error decreases from per-tensor to per-channel to per-group quantization. That is the central engineering tradeoff: metadata and kernel complexity buy lower reconstruction error.

A useful way to reason about the error is to look at the quantization step. With absmax scale ss, rounding introduces an elementwise error no larger than s/2s/2 before clipping. A single outlier doubles ss and doubles that worst-case error for every ordinary value sharing the scale. Per-channel and per-group schemes reduce ss locally, but the dequantizer must now load scale values and the kernel must multiply each block by the right scale. Weight-only int4 inference is therefore a systems feature, not just a file-format feature: the compressed representation pays off only when the serving kernel keeps the metadata overhead small.

Listing 40.2 Group-wise quantization and measured MSE
def quantize_absmax_groups(x, bits=4, group_size=16):
    """Absmax quantization with one scale per group along the last axis."""
    x = np.asarray(x, dtype=np.float32)
    if x.shape[-1] % group_size:
        raise ValueError("last dimension must be divisible by group_size")
    grouped = x.reshape(*x.shape[:-1], x.shape[-1] // group_size, group_size)
    q, dequant, scale = quantize_absmax(grouped, bits=bits, axis=-1)
    return q.reshape(x.shape), dequant.reshape(x.shape), scale


def mean_squared_error(x, y):
    return float(np.mean((np.asarray(x) - np.asarray(y)) ** 2))

40.3 Low-bit floating point

Integer quantization uses one scale for a whole block. Low-bit floating point gives each number its own tiny exponent. FP8 E4M3 uses more mantissa bits and less exponent range; E5M2 uses fewer mantissa bits and more range, so E4M3 is more accurate near one while E5M2 reaches larger magnitudes [micikevicius2022fp8]. MXFP4-style block scaling combines the ideas: a block scale handles the coarse range, and each value stores a 4-bit float-like code within the block.

The emulation is intentionally finite-only: it rounds to a power-of-two step chosen by the exponent and clips to the representable maximum. That is enough to test the qualitative behavior without depending on hardware instructions.

Block-scaled FP4 is especially easy to confuse with ordinary int4. Int4 has uniformly spaced levels after one scale; FP4 has nonuniform levels, so it spends more codes near zero and fewer at the largest magnitudes. The shared block scale then makes those nonuniform levels follow the local range. This is why the test compares a single global block with two smaller blocks: the smaller blocks use the same 4-bit codebook but choose better local scales.

Listing 40.3 FP8 and block-scaled FP4 emulation
def _quantize_float(x, mantissa_bits, min_exp, max_exp, max_value=None):
    x = np.asarray(x, dtype=np.float32)
    sign = np.sign(x)
    ax = np.abs(x)
    exponent = np.floor(np.log2(np.maximum(ax, 2.0 ** min_exp)))
    exponent = np.clip(exponent, min_exp, max_exp)
    step = 2.0 ** (exponent - mantissa_bits)
    rounded = np.round(ax / step) * step
    if max_value is None:
        max_value = (2.0 - 2.0 ** (-mantissa_bits)) * 2.0 ** max_exp
    return sign * np.minimum(rounded, max_value).astype(np.float32)


def fp8_e4m3(x):
    return _quantize_float(x, mantissa_bits=3, min_exp=-6, max_exp=8,
                           max_value=448.0)


def fp8_e5m2(x):
    return _quantize_float(x, mantissa_bits=2, min_exp=-14, max_exp=15)


def mxfp4(x, block_size=16):
    """Emulate MXFP4: shared block scale plus nearest E2M1-like code."""
    code = np.array([0, .5, 1, 1.5, 2, 3, 4, 6], dtype=np.float32)
    code = np.concatenate([-code[:0:-1], code])
    x = np.asarray(x, dtype=np.float32)
    if x.shape[-1] % block_size:
        raise ValueError("last dimension must be divisible by block_size")
    blocks = x.reshape(*x.shape[:-1], x.shape[-1] // block_size, block_size)
    scale = np.max(np.abs(blocks), axis=-1, keepdims=True) / 6.0
    scale = np.where(scale == 0, 1.0, scale)
    normalized = blocks / scale
    nearest = code[np.argmin(np.abs(normalized[..., None] - code), axis=-1)]
    return (nearest * scale).reshape(x.shape)

40.4 Outliers, SmoothQuant, and GPTQ

Activation outliers make one activation column set a large scale, wasting most int8 levels on ordinary values. LLM.int8() keeps outlier channels in higher precision for the matrix multiply [dettmers2022llmint8]. SmoothQuant uses the identity

XW=(Xdiag⁡(s)−1)(diag⁡(s)W)(40.3)\mX\mW = (\mX \operatorname{diag}(\vs)^{-1}) (\operatorname{diag}(\vs)\mW)\tag{40.3}

to migrate scale from activations into weights before quantization [xiao2022smoothquant]. The product is unchanged, but the activation range is smoother.

GPTQ starts from the layer reconstruction loss ∥X(w−q)∥22=(w−q)⊤H(w−q)\|\mX(\vw-\vq)\|_2^2 = (\vw-\vq)^\T\mH(\vw-\vq), where H=X⊤X\mH=\mX^\T\mX. Quantizing coordinate ii creates an error; using H−1\mH^{-1} gives a local update to later coordinates that compensates for the error before they are quantized [frantar2022gptq]. AWQ instead protects the weight channels most important under observed activations, a calibration-time rule that is simpler to serve than second-order compensation [lin2023awq].

Listing 40.4 A tiny GPTQ step
def gptq_quantize_vector(w, hessian, bits=2, damping=1e-8):
    """Quantize coordinates and compensate later ones with H^{-1}."""
    w = np.asarray(w, dtype=np.float64)
    hessian = np.asarray(hessian, dtype=np.float64)
    inv_h = np.linalg.inv(hessian + damping * np.eye(len(w)))
    work = w.copy()
    quantized = np.zeros_like(work)
    qmax = 2 ** (bits - 1) - 1
    scale = np.max(np.abs(w)) / qmax
    for i in range(len(w)):
        qi = np.clip(np.round(work[i] / scale), -qmax, qmax) * scale
        error = work[i] - qi
        quantized[i] = qi
        if i + 1 < len(w):
            work[i + 1:] -= error * inv_h[i + 1:, i] / inv_h[i, i]
    return quantized


def reconstruction_loss(w, q, hessian):
    error = np.asarray(w) - np.asarray(q)
    return float(error @ hessian @ error)

The small GPTQ implementation is not a production quantizer: it handles one vector, one fixed scale, and one left-to-right order. It is still enough to expose the key idea. When two calibration columns are correlated, the loss has off-diagonal Hessian terms, so a bad rounding choice in one coordinate can be partly repaired by nudging a later coordinate before rounding it. The test constructs exactly that correlated case and checks that the compensated quantizer beats independent round-to-nearest on the reconstruction loss, not merely on elementwise error.

40.5 Paged caches, batching, and rooflines

During prefill, a request consumes many prompt tokens at once. During decode, it consumes one new token but must read model weights and append one KV-cache entry per layer. Paged KV caches store keys and values in fixed-size blocks and keep a block table per sequence, so finished or short requests do not force one huge contiguous allocation [kwon2023efficient]. Continuous batching admits new requests whenever old ones finish, mixing many decode steps into one device batch instead of waiting for a whole batch to drain.

The roofline argument is simple. If a decoder reads PP parameters stored in two bytes and does about 2P2P FLOPs for one token, arithmetic intensity is about one FLOP per byte before KV-cache traffic. Modern accelerators can do far more FLOPs per byte than that, so token-by-token decoding is limited by memory bandwidth unless weights or cache reads are reduced.

Continuous batching improves utilization but does not change this per-token accounting. More active sequences let the device process a larger matrix-vector batch, and that amortizes launch overhead and some cache behavior. Yet every generated token still needs the model weights, plus the KV-cache reads for attention over its prefix. Quantization helps because it reduces bytes moved; paged caches help because they keep useful KV blocks packed and reusable instead of stranded in overallocated buffers.

Listing 40.5 Paged block tables and decoding roofline
def paged_block_table(lengths, block_size):
    """Map each sequence to physical KV-cache blocks."""
    tables, next_block = [], 0
    for length in lengths:
        count = int(np.ceil(length / block_size))
        tables.append(list(range(next_block, next_block + count)))
        next_block += count
    return tables


def decoding_intensity(parameters, weight_bytes=2, kv_bytes=0):
    """Approximate FLOPs per byte for one generated token."""
    flops = 2 * parameters
    bytes_read = weight_bytes * parameters + kv_bytes
    return flops / bytes_read


def roofline_tokens_per_second(parameters, bandwidth, peak_flops,
                               weight_bytes=2, kv_bytes=0):
    bytes_read = weight_bytes * parameters + kv_bytes
    flops = 2 * parameters
    return min(peak_flops / flops, bandwidth / bytes_read)
In practice

Serving stacks combine several of these ideas: int8 or int4 weights, activation-aware calibration, paged KV caches, and continuous batching. LLM.int8() and SmoothQuant target activation outliers in matrix multiplies [dettmers2022llmint8] [xiao2022smoothquant]. GPTQ and AWQ are post-training weight-only methods often used when retraining is unavailable [frantar2022gptq] [lin2023awq]. PagedAttention made KV-cache fragmentation a first-class serving problem rather than an allocator afterthought [kwon2023efficient].

Key equations
s=max⁡i∣xi∣2b−1−1s = \frac{\max_i |x_i|}{2^{b-1}-1}
x^i=sqi\hat{x}_i=sq_i
XW=(Xdiag⁡(s)−1)(diag⁡(s)W)\mX\mW=(\mX\operatorname{diag}(\vs)^{-1})(\operatorname{diag}(\vs)\mW)
L(q)=(w−q)⊤H(w−q)L(\vq)=(\vw-\vq)^\T\mH(\vw-\vq)
Idecode≈2P2P+BKVI_{\text{decode}} \approx \frac{2P}{2P+B_{\mathrm{KV}}}

40.6 Teach it

One sentence: quantization trades scale metadata for fewer bytes, and serving wins when those fewer bytes reduce the memory traffic per token. Analogy: per-tensor quantization is one ruler for a whole workshop; per-group quantization gives each bench its own ruler. Board steps: 1. draw scale, integer code, dequantization; 2. compare tensor, channel, group scales; 3. show SmoothQuant’s inserted diagonal; 4. compute 2P2P FLOPs over 2P2P bytes. Misconceptions: int4 is not automatically faster if kernels are bad; asymmetric quantization is not better for centered weights; GPTQ changes later weights to reduce layer-output error, not just scalar rounding error. Check: why can moving scale from X\mX into W\mW leave XW\mX\mW unchanged?

40.7 Exercises

Exercise 40.1 ★ Absmax codes

Quantize (−1,−0.25,0,0.5,1)(-1, -0.25, 0, 0.5, 1) with 3-bit signed absmax quantization. Give the integer codes, scale, and dequantized values.

Exercise 40.2 ★★ Zero-point derivation

Derive the zero-point formula z=qmin⁡−xmin⁡/sz = q_{\min} - x_{\min}/s by requiring xmin⁡x_{\min} to map to qmin⁡q_{\min}. Why is zz rounded and clipped in code?

Exercise 40.3 ★★★ GPTQ compensation

For L(q)=(w−q)⊤H(w−q)L(\vq)=(\vw-\vq)^\T\mH(\vw-\vq), explain why correlated columns make independent round-to-nearest suboptimal, then run the tiny GPTQ implementation on the tested example.

Exercise 40.4 ★★★ Roofline calculator

A 7-billion-parameter decoder stores weights in two bytes each and ignores KV-cache traffic. Compute its arithmetic intensity and the bandwidth-limited tokens/s at 3 TB/s. Then say what int4 changes.

References

  • [dettmers2022llmint8] T. Dettmers et al. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. 2022. arXiv:2208.07339

  • [frantar2022gptq] E. Frantar et al. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. 2022. arXiv:2210.17323

  • [kwon2023efficient] W. Kwon et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. 2023. arXiv:2309.06180

  • [lin2023awq] J. Lin et al. AWQ: Activation-aware Weight Quantization for On-Device LLM Compression and Acceleration. 2023. arXiv:2306.00978

  • [micikevicius2022fp8] P. Micikevicius et al. FP8 Formats for Deep Learning. 2022. arXiv:2209.05433

  • [xiao2022smoothquant] G. Xiao et al. SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models. 2022. arXiv:2211.10438