Quantization & compression
Quantization, how to deploy large models cheaply by reducing the bits per parameter. The basic float→int mapping (scale + zero point + clip) is short math; the engineering choices around it (per-tensor vs per-channel vs per-group, symmetric vs asymmetric, INT8 vs INT4 vs NF4, GPTQ vs AWQ, weight vs activation quantization) are dense and operationally consequential. Bridges back to Ch 15's QLoRA, NF4 gets its proper treatment here. Combines multiplicatively with Ch 17's optimizations, production stacks use both. The chapter that explains how to fit a 70B model on a single GPU.
Chapter 17 reduced wasted computation: KV cache, continuous batching, Flash Attention, speculative decoding, PagedAttention. This chapter takes the complementary route, reduce the bits per parameter. A B model in BF16 takes GB of weights; in INT4, the same model fits comfortably on a single A100 80GB with headroom left over for KV cache and batching. Quantization is the optimization that decides whether a model can be deployed at all: not just whether it is cheap.
The math is short. The float-to-int mapping is a linear transform with two parameters: a scale and a zero point. Pick a range, clip, round, store the integers, throw away the floats. The engineering is dense. Granularity (per-tensor vs per-channel vs per-group) determines how much quality is lost to outliers. Outlier handling is what made INT8 work at LLM scale: Dettmers’s paper found a small population of feature dimensions that broke naive quantization and gave the mixed-precision fix. NF4, which you saw in Chapter 15 as the quantization layer of QLoRA, places its levels at equiprobable normal quantiles, which is the right thing to do when the weights are normally distributed. GPTQ and AWQ are the modern post-training quantization algorithms that close the quality gap between FP16 and INT4 to under one percent on standard benchmarks.
This chapter unpacks all of it. By the end the reader should be able to compute the float-to-int mapping, choose a quantization granularity, explain why INT4 needs per-group, understand NF4’s construction, distinguish GPTQ from AWQ, and combine quantization with Ch 17’s optimizations for the full Part VI inference picture. Then Chapter 19 closes Part VI with sampling, how the decoder picks tokens given the (now-quantized) logits the model produces.
Why quantize
The memory case
A B parameter model stored in BF16 takes GB of weights, two bytes per parameter. That does not fit on a single A100 80GB, and on an H100 80GB it leaves almost no room for the KV cache, the batch, or the activations. At INT8 the same model takes GB, which fits one H100 with room for cache and batching. At INT4 it takes GB, which fits a single A100 80GB with comfortable room left over for K-context KV cache across a handful of concurrent sequences. At NF4 with double quantization it is about to GB, similar to INT4 but with measurably better quality per bit. The threshold of “what hardware can serve this model” moves dramatically with the bit width.
The bandwidth case
Recall from Ch 17 §2 that decode is memory-bound: the GPU spends most of its time pulling weights from HBM into the matmul units, not actually computing. Arithmetic intensity collapses at decode because the activation tensor is a single token while the weight matrices are still the full multi-gigabyte tensors. Halving the bytes per weight halves the time to load them, which translates almost linearly into decode throughput: about at INT8 over BF16, about at INT4. For prefill (compute-bound) the speedup is smaller because the bottleneck is FLOPs not memory; for decode it is close to linear. Quantization is therefore the single highest-leverage optimization for decode throughput, alongside KV cache.
The throughput case
Smaller weight matrices leave more memory for everything else, which means more concurrent requests can share the GPU. Combined with the continuous batching from Ch 17, quantization unlocks a larger working batch size and therefore higher sustained throughput per GPU-hour. And on the quality side, modern recipes are nearly lossless. LLM.int8, NF4 with double quantization, AWQ, and GPTQ all sit at or under one percent degradation on standard benchmarks like MMLU, HellaSwag, and GSM8K, practically lossless for almost any production use case. Sub-four-bit territory (-bit, -bit, -bit) is still an active research area where the tradeoff becomes nontrivial; INT4 is the current floor for “drop-in” quantization.
The basic mapping
Scale, zero point, clip
The float-to-int mapping is a linear transformation followed by a clip:
with scale (a float) and zero point (an integer). The clip bounds depend on the bit width: for signed INT8, for signed INT4. Dequantization runs the same map backward: , and the worst-case error from a single weight is bounded by , half the spacing between grid points. The scale is exactly the grid spacing; the smaller you make , the finer the grid, and the harder it becomes to fit the original range without clipping.
Given a range that the quantizer needs to cover, the two standard choices for and are: symmetric ( with ) and asymmetric ( with , so lands exactly on under the signed range above). Symmetric is cheaper at inference time because the zero point drops out of the matmul; asymmetric is more flexible at the cost of a few extra operations per multiply.
Symmetric and asymmetric
Symmetric quantization is the default for LLM weights. Weight distributions in a trained transformer are typically close to zero-centered, initialization is symmetric, and training does not break that much. Asymmetric is reserved for cases where the distribution is genuinely skewed: like activations after a ReLU or GELU where everything is non-negative. The cost difference is small but real: a symmetric matmul is just with one scale factor pulled out; an asymmetric one has cross terms from the zero point that the kernel has to either materialize or fuse away. In practice, weights symmetric, activations asymmetric where it helps, and modern recipes for LLMs lean symmetric on both sides because the activation distributions after LayerNorm are usually well-centered.
The gap between the two shows up clearly on a skewed input, exactly the post-ReLU case the prose above calls out. Symmetric quantization has to reserve half its grid for negative values that never occur, wasting resolution; asymmetric spends the whole grid on the range that is actually there.
Both directions round-trip correctly, but the quality gap is not small: on this skewed, non-negative input asymmetric quantization cuts the MSE by roughly at bits because every one of the (or ) grid points sits inside the range the data actually occupies, none are spent on negative values that never appear.
Storage overhead
The bit width of the quantized integers is the headline number, but the scale and zero point have their own storage cost that gets folded back into the effective bits per weight. Per-tensor quantization uses one scale per matrix and the overhead is negligible. Per-channel uses one scale per output dimension and the overhead is small. Per-group ( or ) uses one scale per group of contiguous weights, and at INT4 with and FP16 scales the overhead lands at roughly bits per weight (), small but visible. For a B model the bookkeeping works out to about GB at INT8 per-channel and about GB at INT4 per-group with the scales included. The “INT4” line item gets you over BF16, not , after you pay for the scales.
Granularity: per-tensor, per-channel, per-group
Three granularities
Per-tensor quantization uses one scale and zero point for the entire weight matrix. It is the cheapest variant in both compute and memory, but the quality drops sharply whenever the tensor has any outlier values, a single big-magnitude row sets the scale, and every other row loses resolution. Per-channel quantization uses one scale and zero point per output channel (per row of the weight matrix). Each row gets its own grid sized to its own dynamic range, which handles row-wise outliers without burning resolution on the rest. Per-channel is the standard for INT8 and the default choice in bitsandbytes, vLLM, and TGI. Per-group quantization uses one scale and zero point per group of contiguous weights within a row, where is typically or . Each group gets its own grid, and the grid is sized to the local statistics rather than the row-wide ones. Per-group is essential for INT4.
Why per-group matters for INT4
At INT4 there are only distinct values on the grid. Local statistics dominate global ones at that resolution, a group of contiguous weights almost always has a much smaller dynamic range than the whole row, so the local grid is much finer than a global one would be. The cost of per-group is storage overhead: with FP32 scales, at in INT4 the scales add about bits per weight; at they add about (with the more common FP16 scales, those numbers halve to about and bits per weight, matching the storage-overhead figures above). Group sizes of and are the practical defaults. Below the scale overhead becomes a serious tax on the compression ratio; above the grid starts losing the locality that motivated per-group in the first place and quality drops noticeably. GPTQ, AWQ, and the NF4 recipe in QLoRA all default to or , the consensus is narrow because the tradeoff is so well-explored.
INT8: the canonical example
The straightforward recipe
For each weight matrix , compute a per-channel scale , quantize clipped to , and store (INT8) alongside (one float per channel). At inference time, the GPU does the matmul in INT8 using dedicated tensor-core instructions, modern A100 and H100 hardware has fused INT8 matmul kernels that accept INT8 inputs, accumulate in INT32, and produce an FP16 or FP32 output after dequantization. The whole pipeline is one fused kernel call; there is no Python-level dequantize-then-multiply loop in production.
For pure weight quantization (W8A16, weights at INT8 and activations at BF16) the recipe basically ends there. Per-channel symmetric quantization plus a fused INT8 weight load is enough to get near-lossless quality on most architectures, and you pick up a bandwidth speedup on decode for free, because the weights are now half the size they were in BF16. This was the easy regime. Activations, on the other hand, are where the trouble starts.
The LLM.int8 wrinkle
In , Dettmers and collaborators discovered something surprising about LLM activations: a small population of feature dimensions, typically under half a percent, have magnitudes hundreds of times larger than the rest. These are the outlier features, and they emerge consistently across scales above roughly B parameters. Naive INT8 quantization on activations is destroyed by them: the outlier scales dominate the per-tensor or per-channel grid, and everything that is not an outlier gets crushed into the quantization noise. The model loses double-digit perplexity points and starts producing garbage.
The LLM.int8 fix is mixed-precision matmul. Identify the outlier dimensions dynamically per matmul: typically using a threshold like “six standard deviations above the mean”, and keep those dimensions in FP16 while quantizing the rest to INT8. The matmul splits into two paths: an INT8 × INT8 bulk path for the common dimensions, and an FP16 × FP16 path for the outlier dimensions; the two outputs are added. Result: nearly lossless INT8 quantization at LLM scale, with a small overhead from the FP16 sidecar.
INT4 and NF4: going lower
INT4 with group-wise scales
INT4 gives distinct values. That is not many. The arithmetic is the same as INT8, clip, round, scale, but the grid is so coarse that two things become necessary. First, per-group scales are essential: or to keep the local dynamic range controlled. Second, modern PTQ algorithms (next section) become valuable because the rounding error is now large enough that compensating across weights pays off. Plain round-to-nearest at INT4 per-group is typically one to three perplexity points worse than FP16; GPTQ or AWQ at INT4 per-group is under one. The combination of per-group plus a calibration-aware PTQ method is what makes INT4 production-ready.
The storage math works out cleanly. Four bits per weight, plus one FP16 or FP32 scale per group of , plus optionally a quantized zero point. At with FP16 scales, the overhead is bits per weight, so the effective footprint is about bits per weight. A B model at INT4 per-group lands at roughly GB; a B model lands at roughly GB. Both fit comfortably on hardware that could not hold them at BF16.
NF4: quantiles of the normal
Standard INT4 places its levels at evenly-spaced grid points. That is the right thing to do when the distribution being quantized is uniform, but it is the wrong thing to do when the distribution is normal, which weights typically are. Dettmers’s insight: place the levels at the equiprobable quantiles of a standard normal, so that each level represents one-sixteenth of the probability mass. Resolution concentrates where the weights actually live (near zero) and thins out where they do not (in the tails). The construction is symmetric around zero and normalized so the extreme levels sit at .
The NF4 levels are (approximately):
-1.000, -0.696, -0.526, -0.395, -0.285, -0.184, -0.091, 0.000,
0.080, 0.160, 0.246, 0.338, 0.440, 0.563, 0.723, 1.000
Notice the density: levels are tightly packed near zero (where most weights live) and spread out in the tails (where weights are sparse). On normally-distributed weights, NF4 gives roughly one to two percent better quality than INT4 round-to-nearest at the same bit width, for free, structurally, by matching the grid to the distribution.
Double quantization
The per-group scales themselves are a meaningful storage line item. FP32 scales at cost about bits per weight; that is not enormous but it is bigger than the headline “four bits per weight” suggests. Double quantization (also from the QLoRA paper) takes the scales and quantizes them, grouping every scales into a super-group and quantizing the super-group to FP8 with its own (FP32) super-group scale. The math layers cleanly and the savings come out to roughly bits per weight. NF4 with double quantization lands at about bits per weight effective, a tiny overhead above the raw -bit weights, with quality that is competitive with INT8 on most benchmarks.
Modern PTQ: GPTQ and AWQ
Round-to-nearest as baseline
The baseline post-training quantization method is round-to-nearest (RTN): quantize each weight independently to its nearest grid point and ship it. Fast, simple, no calibration data, no extra compute. The limitation is that RTN ignores correlations between weights: errors in one weight are not compensated by adjustments in others, and the per-weight errors accumulate through the layer’s matmul. At INT8 the rounding error is small enough that this does not matter; at INT4 it does, and the quality gap between RTN and the modern methods becomes worth closing.
GPTQ: Hessian-aware
GPTQ (Frantar et al., ) is the academic-quality answer. Run a small calibration set through the model, typically about sequences of tokens, and use the activations to compute an approximate Hessian of the per-layer reconstruction loss. Then quantize the weights of each layer one at a time, in a fixed order, and after each weight is rounded, adjust the remaining (unquantized) weights to compensate for the rounding error. The Hessian determines how much each remaining weight should move:
When weight gets rounded to with error , weight is updated by an amount proportional to the inverse Hessian’s off-diagonal entry . The total reconstruction error at the layer output stays small because the unquantized weights absorb what would otherwise have been propagated downstream. The cost of running GPTQ on a B model is around one GPU-hour; the quality lands near-FP16 at INT4. It is what the open-source gptq-for-llama and AutoGPTQ projects implement.
AWQ: activation-aware
AWQ (Lin et al., ) is the surprisingly simple alternative. Use the same calibration set, but instead of computing a Hessian, just measure which weight channels multiply the largest-magnitude activations during calibration. Those channels are the ones that contribute most to the layer’s output, and their quantization errors propagate hardest. Scale those important channels up before quantization (and apply the inverse scale to the activations at inference time, so the net matmul is unchanged), this pushes the important weights into the high-resolution part of the quantization grid where the rounding error per weight is smallest. Net cost: about ten times cheaper than GPTQ to compute. Net quality: competitive, often within a quarter-perplexity-point at INT4.
In practice the choice between GPTQ and AWQ is operational, not religious. AWQ is faster to apply and slightly easier to integrate; GPTQ has a marginal quality edge on the hardest benchmarks. Both are widely supported in vLLM, TensorRT-LLM, TGI, and llama.cpp. Both achieve under one percent degradation on standard benchmarks at INT4. Pick whichever your inference stack supports first; the difference is not worth a long debate.
Below INT4: Hadamard rotations
GPTQ and AWQ push round-to-nearest’s INT4 gap down to under a percent. Below INT4, at and bits, that stops being enough: the grid is so coarse that a handful of outlier weights per channel dominate the error no matter how carefully the rest are compensated.
QuIP# (Tseng et al., , arxiv.org/abs/2402.04396) and SpinQuant (Ashkboos et al., , arxiv.org/abs/2405.16406) attack this with the same core idea: multiply the weights (and, for SpinQuant, the activations too) by a random orthogonal (Hadamard) rotation before quantizing, then multiply by its inverse after dequantizing at inference time. The rotation does not change the matmul’s output, an orthogonal transform is invertible and norm-preserving, but it changes the shape of the distribution being quantized: a rotation spreads any single large outlier’s magnitude across many coordinates, so the post-rotation distribution looks close to Gaussian with no extreme outliers left to blow out the grid. A near-Gaussian, outlier-free distribution is exactly what a uniform or NF4-style grid quantizes well.
This is the same philosophy as LLM.int8’s outlier handling and SmoothQuant’s rebalancing (next section), pushed one level further: instead of routing outliers around the quantizer or rescaling them into range, rotate the whole coordinate system so outliers stop existing as a concentrated phenomenon. QuIP# combines the rotation with a lattice codebook for the rounding step and gets usable 2-bit weight quantization, previously considered impractical; SpinQuant learns the rotation rather than sampling it randomly and applies it to activations as well as weights, closing most of the remaining gap to FP16 at 4 bits and pulling 3-bit into a usable range. Both add a rotation matmul to the inference path, a real but small cost, paid once per layer.
GGUF: the deployment format for llama.cpp
GPTQ and AWQ produce quantized weights; something still has to package them into a file a runtime can load. GGUF is that container format, the successor to GGML’s older format, and it is what llama.cpp (and downstream tools like Ollama and LM Studio) load directly. A GGUF file bundles the quantized tensors with the metadata needed to use them without a separate config: tokenizer vocabulary, architecture hyperparameters, and, critically, the per-tensor quantization scheme and scale factors. Point llama.cpp at a .gguf file and it reconstructs the dequantization pipeline from what is already inside the file, no PyTorch, no separate config.json, no Python runtime at all.
GGUF ships its own family of quantization schemes, the k-quants (Q4_K_M, Q5_K_S, Q6_K, and similar names), which are close cousins of the per-group INT4/INT8 schemes covered above: small blocks of weights (typically ) share a scale, and the “K” variants add a second level of grouping so the metadata itself is compressed, similar in spirit to NF4’s double quantization. The suffix (_S, _M, _L) trades a little more size for a little more quality. Q4_K_M is the common default for “good enough on a laptop”; Q8_0 is close to lossless and closer in size to the FP16 original.
Activation quantization
Why activations are harder
Weights are static: quantization happens once, offline, and the result is shipped. Activations are dynamic: each input produces a different distribution, and the quantization has to be redone on every forward pass. That makes activation quantization both more expensive (the runtime cost is per-inference, not amortized) and more error-prone (the distribution at calibration time may not match the distribution at serving time). Activations also tend to have larger dynamic range than weights, LayerNorm centers them but does not bound them, and outlier features (the same ones from the LLM.int8 story) sit on the activation side, not the weight side. Without care, activation quantization causes much more quality loss than weight quantization.
SmoothQuant
SmoothQuant (Xiao et al., ) attacks the problem with a clean mathematical trick. The quantization difficulty of weights and activations is coupled through the matmul : if the activations have a hard outlier in channel , then weight column multiplies that outlier, and the result is sensitive to both. SmoothQuant introduces a per-channel scaling factor and rewrites the matmul as , algebraically the same product, but the activation range is now smaller (easier to quantize) and the weight range is larger (still easy because weights are static and the scaling is known in advance). The factor is chosen per channel from calibration data to balance the difficulty between the two sides.
The W8A8 stack (eight-bit weights, eight-bit activations) becomes practical once SmoothQuant is in the loop. The matmul runs entirely in INT8, both operands quantized, and the kernel returns FP16. In practice, however, W4A16 is more common than W8A8 for decode workloads. Decode is bottlenecked by weight loading from HBM, not by activation compute, so quantizing weights aggressively (INT4) while keeping activations cheap to compute (BF16) gives most of the speedup with less engineering complexity. vLLM and TGI both default to W4A16 with GPTQ or AWQ weights for their INT4 inference paths.
Exercises
Four exercises that lock in the chapter’s machinery. Each is a self-contained problem with a starting template; hints are collapsed by default; try the problem first.
Exercise 1 (easy): Symmetric quantization
Implement the basic symmetric quantization mapping. Compute scale, quantize, dequantize, and verify the per-weight error is bounded by .
Hint
Symmetric quantization:
with and .
Dequantization: .
The rounding error per weight is bounded by (worst case: a weight exactly halfway between two grid points).
Solution
One global scale from the absmax, then round-and-clip into the signed range; the max error is bounded by scale / 2 because rounding never moves a value more than half a grid step.
def quantize_symmetric(x, n_bits=8):
qmax = 2**(n_bits - 1) - 1
scale = np.abs(x).max() / qmax
x_int = np.round(x / scale).clip(-qmax - 1, qmax).astype(np.int32)
return x_int, float(scale)
def dequantize_symmetric(x_int, scale):
return scale * x_int.astype(np.float32)
np.random.seed(0)
W = np.random.normal(0, 0.1, 100)
for n_bits in [8, 4, 2]:
W_int, scale = quantize_symmetric(W, n_bits=n_bits)
W_dq = dequantize_symmetric(W_int, scale)
mse = ((W - W_dq) ** 2).mean()
max_err = np.abs(W - W_dq).max()
bound = scale / 2
print(f"{n_bits:>5} | {mse:>12.7f} | {max_err:>10.6f} | {bound:>12.6f}")Output:
Bits | MSE | Max err | Bound s/2
--------------------------------------------------
8 | 0.0000003 | 0.001002 | 0.001005
4 | 0.0000989 | 0.017992 | 0.018236
2 | 0.0050737 | 0.125280 | 0.127649Max error stays under the scale / 2 bound at every bit width, and MSE grows roughly 16x per 2 fewer bits (halving the grid resolution roughly quarters the resolution per axis, squared in the error term).
Exercise 2 (medium): Per-channel quantization
Implement per-channel symmetric quantization for a weight matrix. Each row gets its own scale.
Hint
For a weight matrix :
- Compute the per-row absmax: a vector of values
- Scale per row:
- Quantize each row using its own scale
Setup: create a matrix where one row has 10× larger weights than others (an outlier row). Compare per-tensor (single scale; destroyed by outlier) to per-channel (each row independent).
Solution
Compute the absmax along axis 1 (per row) instead of over the whole matrix, so each row gets its own scale and the outlier row can no longer wreck everyone else’s resolution.
def quantize_per_channel(W, n_bits=4):
qmax = 2**(n_bits - 1) - 1
absmax = np.abs(W).max(axis=1, keepdims=True) # shape (R, 1)
scales = absmax / qmax # shape (R, 1)
return np.round(W / scales).clip(-qmax - 1, qmax) * scalesOutput:
Per-tensor MSE: 0.008198
Per-channel MSE: 0.001299
Row | Per-tensor | Per-channel
-----------------------------------
0 | 0.009661 | 0.009661 <- outlier
1 | 0.010130 | 0.000080
2 | 0.008122 | 0.000087
3 | 0.006606 | 0.000093
4 | 0.007516 | 0.000117
5 | 0.007414 | 0.000105
6 | 0.007918 | 0.000128
7 | 0.008220 | 0.000122Per-tensor: the outlier row sets one shared scale, so every non-outlier row lands at MSE ~0.007-0.010 regardless of its own (much smaller) range. Per-channel: rows 1-7 drop to MSE ~0.0001, roughly two orders of magnitude better, because each row’s scale now tracks its own magnitude; only row 0 (the outlier itself) is unchanged, since it still has to spread its own dynamic range across 16 levels.
Exercise 3 (medium): Per-group quantization
Implement per-group symmetric quantization with adjustable group size. Compare to per-channel.
Hint
Per-group: within each row, split into groups of consecutive weights and quantize each group with its own scale.
For row and group (spanning columns to ):
- Quantize this slice with
Group size choices: . Smaller = better quality but more scale overhead. Standard for production: or .
Solution
Same idea as per-channel, but the scale is recomputed for every group_size-wide slice of a row, so within-row variation (not just row-to-row variation) gets its own resolution.
def quantize_per_group(W, n_bits=4, group_size=32):
qmax = 2**(n_bits - 1) - 1
R, C = W.shape
out = np.zeros_like(W)
for r in range(R):
for g in range(C // group_size):
start, end = g * group_size, (g + 1) * group_size
chunk = W[r, start:end]
scale = np.abs(chunk).max() / qmax
if scale == 0:
scale = 1.0
out[r, start:end] = np.round(chunk / scale).clip(-qmax - 1, qmax) * scale
return outOutput (matrix has both an outlier row and a within-row “hot region”):
Group size | Overall MSE | Scales
--------------------------------------------------
G = 16 | 0.001253 | 32
G = 32 | 0.001400 | 16
G = 64 | 0.001815 | 8Smaller groups win on MSE (G=16 beats G=64 by about 30%) because each group can chase local variation like the hot region independently; the cost is more stored scales (32 vs. 8 for this 8x64 matrix). This is exactly the per-group tradeoff from the chapter body, applied at a finer granularity than per-channel’s 8 scales.
Exercise 4 (hard): NF4 quantization
Implement NF4 quantization: 16 levels at equiprobable normal quantiles, with per-group scaling. Compare to INT4 per-group on normally-distributed weights.
Hint
NF4 levels are placed at the equiprobable quantiles of a standard normal distribution. Compute via scipy.stats.norm.ppf, following the same construction worked out earlier in this chapter:
For 16 levels:
- 8 levels on the positive side (including ) at quantile midpoints, plus 7 on the negative side (including ), plus an exact so zero is representable
- Sort the 16 values into one ascending grid
- Normalize so the absolute max is 1.0
For per-group NF4:
- For each group, compute scale = max(|chunk|)
- Normalize chunk by scale
- For each value, find the nearest NF4 level
- Dequantize: nearest level × scale
Compare to INT4 per-group on the same normally-distributed weights. Expectation: NF4 has lower MSE because levels are denser where weights are dense.
Solution
Build the 16 levels with the same offset construction from the chapter body (8 positive quantile midpoints including +1.0, 7 negative including -1.0, plus an exact 0.0, all normalized to abs max 1.0), then per group, normalize by the group’s absmax and snap each value to its nearest NF4 level before rescaling.
def compute_nf4_levels():
offset = 0.9677083
positive = norm.ppf(np.linspace(offset, 0.5, 9))[:-1] # 8 levels, +1.0 down to near 0
negative = -norm.ppf(np.linspace(offset, 0.5, 8))[:-1] # 7 levels, near 0 down to -1.0
levels = np.sort(np.concatenate([negative, [0.0], positive]))
max_level = np.abs(levels).max()
return levels / max_level
NF4_LEVELS = compute_nf4_levels()
def quantize_nf4_per_group(W, group_size=32):
R, C = W.shape
out = np.zeros_like(W)
for r in range(R):
for g in range(C // group_size):
start, end = g * group_size, (g + 1) * group_size
chunk = W[r, start:end]
scale = np.abs(chunk).max()
if scale == 0:
continue
normed = chunk / scale
idx = np.abs(normed[:, None] - NF4_LEVELS[None, :]).argmin(axis=1)
out[r, start:end] = NF4_LEVELS[idx] * scale
return outOutput on N(0, 0.1) weights, group_size=32:
MSE on N(0, 0.1) weights:
INT4 per-group: 0.0000935
NF4 per-group: 0.0000744NF4 beats INT4 by about 20% on this normally-distributed matrix, exactly the “denser near zero” effect: NF4’s tightest spacing near zero is roughly 4x finer than its tail spacing, while INT4’s grid is uniform, so NF4 spends its resolution where the weight mass actually is.
The full picture: combining with Ch 17
The optimization stack
A modern production inference stack does not pick one of these techniques; it stacks all of them. Each one attacks a different bottleneck: and they compose multiplicatively:
| Optimization | Source | Effect |
|---|---|---|
| KV cache | Ch 17 | per token |
| Continuous batching | Ch 17 | High GPU utilization |
| Flash Attention | Ch 17 | memory |
| Speculative decoding | Ch 17 | – decode amortization |
| PagedAttention | Ch 17 | – concurrent requests |
| Weight quantization | Ch 18 | – weight bandwidth |
| KV cache quantization | Ch 18 | – cache memory |
KV cache quantization
Everything so far in this chapter quantizes weights, values fixed at deployment time. The KV cache (Ch 17) is not fixed: it grows one entry per token, per layer, per sequence, and at long context it can dwarf the weights themselves. Quantizing it uses the same scale-and-clip machinery from earlier in the chapter, applied to the K and V tensors instead of : typically per-token, per-channel INT8 or INT4 scales, computed as each new token’s K/V vectors are appended to the cache.
The cache tolerates quantization better than weights do, for a structural reason: every K and V entry gets read back through a softmax-weighted sum in attention, not through a single sharp lookup. Rounding error in an individual cached key or value gets averaged across the attention distribution rather than propagating as a single large error the way a badly-quantized weight can. That averaging is why INT8 KV cache is close to lossless in practice, and why INT4 KV cache, more aggressive than most weight-quantization deployments would risk, is viable for the cache specifically. vLLM and TGI both support INT8 KV cache quantization directly, and it composes with everything else in the table: quantized weights shrink the model, a quantized cache shrinks the part of memory that grows with context length, and together they are what makes long-context, high-concurrency serving fit on hardware that plain BF16 weights and cache never would.
The combined effect on a B model running on a single A100 is roughly an order of magnitude. Naive baseline: about tokens per second, single-stream, one sequence at a time. Production stack with Ch 17 plus Ch 18: about to tokens per second aggregate, many concurrent sequences. That is a to total speedup, on identical hardware, from purely systems-level optimizations, no architectural changes, no smaller model, no different training.
The economic consequence is decisive. A service handling a billion tokens per day on naive inference needs roughly A100s of capacity. On the production stack, the same workload fits on two or three. Order-of-magnitude operational cost savings. That is the difference between an LLM product that runs at margin and one that bleeds compute money on every request.
What’s next
Chapter 19 closes Part VI with sampling: top-k, top-p, temperature, beam search, constrained decoding, JSON-mode. Where this chapter and Ch 17 reduced the cost of inference, Ch 19 governs the behavior, how the decoder actually picks tokens given the logits the (quantized) forward pass produced. After Ch 19, Part VI is complete and the back half of the curriculum opens: capabilities (Ch 20–23: reasoning, tools, RAG, multimodal), safety, interpretability, and evaluation (Ch 24–26), and agents (Ch 27–30).
Quantization is the optimization that decides whether a model can be deployed at all. A B model in BF16 needs two A100s just for weights; in INT4 it fits comfortably on one with batching headroom. The basic float-to-int mapping is short math; the engineering choices around it, granularity (per-group at INT4), outlier handling (LLM.int8), level placement (NF4 for normally-distributed weights), PTQ method (GPTQ or AWQ), activation handling (SmoothQuant), KV cache quantization, are dense and operationally consequential. Modern recipes at INT8 and INT4 are nearly lossless. Combined with Ch 17’s optimizations, they move the cost of serving an LLM by an order of magnitude. That is the lever this chapter hands you, and the one Ch 19 will finish handing you the rest of.