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 to a small integer plus scale metadata. Symmetric absmax quantization chooses
The dequantized value is . It represents zero exactly and uses signed levels, so it is natural for centered weights. Asymmetric zero-point quantization instead maps to an unsigned integer minimum and to a maximum:
Then and . This spends the codebook on the observed interval, which helps non-centered activations.
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 , rounding introduces an elementwise error no larger than before clipping. A single outlier doubles and doubles that worst-case error for every ordinary value sharing the scale. Per-channel and per-group schemes reduce 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.
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.
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
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 , where . Quantizing coordinate creates an error; using 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].
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 parameters stored in two bytes and does about 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.
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]. |
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 FLOPs over 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 into leave unchanged?
40.7 Exercises
Quantize with 3-bit signed absmax quantization. Give the integer codes, scale, and dequantized values.
Derive the zero-point formula by requiring to map to . Why is rounded and clipped in code?
For , explain why correlated columns make independent round-to-nearest suboptimal, then run the tiny GPTQ implementation on the tested example.
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