Symmetric block quantization
Each block of weights shares one scale. The scale turns each weight into a small integer. For a block of g weights and a signed k-bit grid with q_{\max} = 2^{k-1}-1:
- Storage is g\cdot k bits plus one scale per block.
- Q4_0 stores 32 weights with an FP16 scale: 4 + 16/32 = 4.5 bits per weight.
- Kernels dequantize tiles inside the matmul. Memory traffic falls by roughly 16/bits.
- Decode is bandwidth bound. Tokens/s rises by nearly the same factor.
The formats you'll meet
Pick the format your runtime supports; the bits per weight tell you the size.
| Format | Layout | Bits/weight | Where |
|---|---|---|---|
| FP32 | 4 bytes per weight | 32 | training format; too big for most inference |
| FP16 / BF16 | 2 bytes per weight | 16 | the inference default: half of FP32, no visible error |
| GGUF Q8_0 | 32 × int8 + FP16 scale | 8.5 | near-lossless llama.cpp baseline |
| GGUF Q4_K_M | 256-weight super-blocks of 8 sub-blocks with 6-bit sub-scales and mins; some tensors kept at Q6_K | ≈4.8–4.9 | the default "good" llama.cpp/Ollama quant |
| GGUF IQ2/IQ3 + imatrix | lattice codebooks, importance-weighted | 2–3.5 | extreme compression; quality drops |
| AWQ / GPTQ INT4 (g128) | int4 + FP16 scale (and zero point) per 128; calibration on real activations | ≈4.1–4.25 | vLLM, SGLang, TensorRT-LLM |
| FP8 E4M3 | 8-bit float, per-tensor or per-channel scale | 8 | Ada, Hopper and Blackwell tensor cores; weights and KV cache |
| NVFP4 | E2M1 values, blocks of 16, FP8 E4M3 scale per block + FP32 per-tensor scale | ≈4.5 | Blackwell tensor cores (RTX 50, RTX PRO 6000, DGX Spark) |
| MXFP4 | E2M1 values, blocks of 32, 8-bit power-of-two (E8M0) scale | 4.25 | OCP standard; gpt-oss ships its MoE weights in MXFP4 |
E2M1 can represent only ±{0, 0.5, 1, 1.5, 2, 3, 4, 6}. This non-uniform grid packs more resolution near zero, where most weights live, so FP4 with small blocks beats naive INT4.
Lab: quantize real weights
Quantize a real matrix five ways and compare the output error. The lab reads the MLP up-projection of the first block of nomic-embed-text from the GGUF blob Ollama downloaded. It measures the weight error and the error of x @ Wᵀ on random activations.
Lab · runs on your own machine
uv run labs/05_quantize.pyDownload all labs (.zip)Needs uv and, for the live parts, Ollama with a small chat model: ollama pull qwen3:0.6b.
Show the code
# /// script
# requires-python = ">=3.11"
# dependencies = ["numpy", "torch"]
# ///
import _local as local
import numpy as np, torch
path = local.blob_path("nomic-embed-text:latest")
W = local.read_gguf_tensor(path, "blk.0.ffn_up.weight").astype(np.float32)
print("real weight matrix:", W.shape, "from", path.name[:20], "…")
E2M1 = np.array([0, 0.5, 1, 1.5, 2, 3, 4, 6], dtype=np.float32)
def to_grid(v, grid):
idx = np.abs(np.abs(v)[..., None] - grid).argmin(-1)
return np.sign(v) * grid[idx]
def blocks(w, g):
return w.reshape(-1, g)
def int_block(w, bits, g):
b = blocks(w, g); qmax = 2 ** (bits - 1) - 1
s = np.abs(b).max(1, keepdims=True) / qmax; s[s == 0] = 1
s = s.astype(np.float16).astype(np.float32)
return (np.clip(np.round(b / s), -qmax - 1, qmax) * s).reshape(w.shape)
def nvfp4(w):
b = blocks(w, 16)
tensor_scale = np.abs(w).max() / (6 * 448)
s = np.abs(b).max(1, keepdims=True) / 6 / tensor_scale
s = torch.tensor(s).to(torch.float8_e4m3fn).to(torch.float32).numpy() * tensor_scale
s[s == 0] = 1
return (to_grid(b / s, E2M1) * s).reshape(w.shape)
def mxfp4(w):
b = blocks(w, 32)
amax = np.abs(b).max(1, keepdims=True); amax[amax == 0] = 1
s = 2.0 ** (np.floor(np.log2(amax)) - 2)
return (to_grid(np.clip(b / s, -6, 6), E2M1) * s).reshape(w.shape)
def fp8(w):
s = np.abs(w).max() / 448
return torch.tensor(w / s).to(torch.float8_e4m3fn).to(torch.float32).numpy() * s
schemes = {
"Q8_0 (int8, g32)": (lambda w: int_block(w, 8, 32), 8.5),
"FP8 E4M3 (per-tensor)": (fp8, 8.0),
"Q4_0 (int4, g32)": (lambda w: int_block(w, 4, 32), 4.5),
"NVFP4 (E2M1, g16, FP8 scale)": (nvfp4, 4.5),
"MXFP4 (E2M1, g32, pow2 scale)": (mxfp4, 4.25),
"INT4 per-tensor (no blocks)": (lambda w: int_block(w.reshape(1, -1), 4, w.size).reshape(w.shape), 4.0),
}
x = np.random.default_rng(0).normal(size=(64, W.shape[1])).astype(np.float32)
ref = x @ W.T
rows = ["| scheme | bits/weight | weight rel. error | output rel. error |", "|---|---:|---:|---:|"]
for name, (fn, bpw) in schemes.items():
Wq = fn(W)
we = np.linalg.norm(Wq - W) / np.linalg.norm(W)
oe = np.linalg.norm(x @ Wq.T - ref) / np.linalg.norm(ref)
rows.append(f"| {name} | {bpw} | {we:.2%} | {oe:.2%} |")
print(("\n".join(rows)))
Sample output on this course's Mac (Apple M4, 32 GB)
real weight matrix: (3072, 768) from sha256-970aa74c0a90e … | scheme | bits/weight | weight rel. error | output rel. error | |---|---:|---:|---:| | Q8_0 (int8, g32) | 8.5 | 0.54% | 0.54% | | FP8 E4M3 (per-tensor) | 8.0 | 2.65% | 2.63% | | Q4_0 (int4, g32) | 4.5 | 9.79% | 9.78% | | NVFP4 (E2M1, g16, FP8 scale) | 4.5 | 9.51% | 9.46% | | MXFP4 (E2M1, g32, pow2 scale) | 4.25 | 11.52% | 11.47% | | INT4 per-tensor (no blocks) | 4.0 | 46.07% | 45.87% |
Relative error on this matrix:
-
Q8_0 ≈ 0.5%.
-
FP8 ≈ 2.6%.
-
Blocked 4-bit ≈ 10%.
-
Per-tensor INT4 ≈ 46%.
-
8-bit is close to free. Q8_0's small blocks beat per-tensor FP8.
-
Blocked 4-bit costs about 10% per matrix. Q4_K_M and NVFP4 still score close to BF16, because errors partly average out across layers.
-
NVFP4's 16-wide blocks edge out Q4_0. Both use 4.5 bits.
-
Per-tensor INT4 is catastrophic. One outlier sets the scale for all weights.
-
Good 4-bit formats use small blocks, non-uniform grids or calibration (AWQ, GPTQ, imatrix).
Measuring quality
Weight error is only a proxy; measure the model's outputs.
- Measure the KL divergence between the quantized and BF16 next-token distributions (
llama-perplexity --kl-divergence). Perplexity on held-out text also works. - Run task checks that match your use: coding pass@1, retrieval QA, tool-call validity.
- Q8_0 and FP8 are near-lossless.
- Q5_K_M and Q4_K_M are the sweet spot.
- Below 4 bits, quality falls fast, especially in small models.
The KV cache can be quantized too
An FP8 or Q8_0 KV cache halves its memory at small quality cost. Use --cache-type-k q8_0 --cache-type-v q8_0 in llama.cpp or --kv-cache-dtype fp8 in vLLM. This is often the cheapest way to fit long context.
Explain it
- "What is NVFP4?" Blackwell's 4-bit float: E2M1 values in blocks of 16 with FP8 scales, run natively by tensor cores. Near-FP8 accuracy at about half the bytes.
- "Q4_K_M or AWQ?" Choose by runtime. Q4_K_M is GGUF (llama.cpp, Ollama, LM Studio). AWQ and GPTQ are INT4 safetensors (vLLM, SGLang, TensorRT-LLM).
- "How do you prove a quant is good enough?" Compare KL divergence or perplexity with BF16 plus your own task evaluation, and report speed too.
Exercises
- In the lab, vary the INT4 block size from 16 to 1024; where is the knee in error versus bits?
- Multiply 0.1% of the weights by 20 and repeat; which formats stay robust?