---
title: "Module 14: Quantization"
format:
html:
code-fold: false
toc: true
ipynb: default
jupyter: python3
---
{{< include ../_diagram-lib.qmd >}}
{{< include ../_components/step-control.qmd >}}
## Introduction
You have trained a model (Modules 06–08). Every weight in it is a **32-bit
float** — 4 bytes each. A 7-billion-parameter model is therefore 28 GB just to
*store*, and every matmul reads all of it from memory. **Quantization** is the
single most effective way to shrink that: round each weight onto a small grid of
integers and keep one floating-point **scale** to undo the rounding at run time.
Store 8-bit integers instead of 32-bit floats and the model is **4× smaller**;
store 4-bit integers and it is **8× smaller** — often the difference between a
model fitting on your GPU or not. The cost is a small, *measurable* rounding
error, and this module builds the whole machinery from scratch so you can see
exactly what that error is and how to keep it tiny.
### What You'll Learn
- Why a trained weight has far more precision than inference needs
- **Symmetric (absmax)** and **affine (zero-point)** quantization, from the formulas
- How to quantize a tensor to int8 / int4 and measure the error in **SNR**
- Why **per-channel** scales beat a single per-tensor scale (the outlier problem)
- How to build a **`QuantizedLinear`** that replaces `nn.Linear` at a fraction of the memory
- How **GPTQ** pushes past int4 by minimizing *output* error with second-order (Hessian) error feedback
- How **AWQ** protects the *salient* weight channels using activation magnitudes — no Hessian, no backprop
- How **KIVI** shrinks the **KV cache** — the other half of inference memory — by quantizing Keys **per-channel** and Values **per-token**, with a full-precision residual window
- How the **Microscaling (MX)** formats — the block-floats Blackwell runs in hardware — fuse fp8 elements with a shared **E8M0** power-of-two scale (MXFP4 at 4.25 bits)
- How **NVFP4** trades E8M0's exactness for an **E4M3** block scale that lands *between* the powers of two, held in range by a second per-tensor fp32 scale (**two-level scaling**)
- How **SmoothQuant** quantizes the **activations** too — migrating their outliers into the weights with an equivalent per-channel rescale — to reach per-tensor int8 **W8A8**
- Why the shipping activation granularity is **per-token** — a scale per *row* of $X$ — and how it and per-channel weights factor out of the int8 GEMM as an **outer product** (the LLM.int8 vector-wise recipe)
### Prerequisites
- [Module 01: Tensors](../m01_tensors/lesson.qmd) — shapes, broadcasting, dtypes
- [Module 06: Transformer](../m06_transformer/lesson.qmd) — the linear layers whose weights we quantize
- [Module 07: Training](../m07_training/lesson.qmd) — where those weights come from
## Intuition: A Weight Is a Number With Too Many Digits
A trained weight might be `0.0417328...`. During inference, does the model really
need all those digits? Almost never. The weights in a given tensor span some
range — say roughly `[-0.2, 0.2]` — and if you chop that range into a few hundred
evenly spaced buckets, rounding each weight to the nearest bucket barely changes
the model's output.
That is the whole idea. Pick a grid of integer levels, store which bucket each
weight fell into (a small integer), and remember **one scale** — the width of a
bucket — to turn buckets back into real numbers:
$$w \approx \text{scale} \times q, \qquad q \in \{-127, \dots, 127\}\ \text{(int8)}$$
Eight bits gives $2^8 = 256$ buckets; four bits gives just $16$. Fewer buckets =
a coarser grid = more rounding error. The art of quantization is choosing the
grid — how wide, how centred, and how many independent grids to keep — so that
the error stays negligible while the integers stay tiny.
::: {.callout-note}
## Key Insight
Quantization trades **precision for size**. A float carries ~7 decimal digits of
precision that a trained, redundant network mostly does not use. Rounding weights
onto a 256-level (int8) grid typically changes outputs by well under a percent,
while cutting the model's memory — and its memory-bandwidth cost, which usually
dominates inference — by 4×.
:::
## The Math: Symmetric and Affine Quantization
**Symmetric (absmax).** The simplest scheme centres the grid on zero. For $b$
bits we use the signed range $[-q_{\max}, q_{\max}]$ with $q_{\max} = 2^{b-1}-1$
(so $127$ for int8). One scale covers the whole tensor:
$$s = \frac{\max_i |w_i|}{q_{\max}}, \qquad q_i = \text{clamp}\!\left(\text{round}\!\left(\frac{w_i}{s}\right),\, -q_{\max},\, q_{\max}\right), \qquad \hat{w}_i = s\, q_i$$
Because the grid is symmetric, $0.0$ maps exactly to the integer $0$ — no offset
needed. This is the default for **weights**, which are roughly zero-centred.
**Affine (zero-point).** When the values are *one-sided* — think the output of a
ReLU, all $\geq 0$ — a symmetric grid wastes half its levels on negatives that
never occur. Affine quantization maps the true range $[w_{\min}, w_{\max}]$ onto
the *unsigned* range $[0, 2^b - 1]$ using an integer **zero-point** $z$ (the code
that represents $0.0$):
$$s = \frac{w_{\max} - w_{\min}}{2^b - 1}, \qquad z = \text{round}\!\left(-\frac{w_{\min}}{s}\right), \qquad q_i = \text{clamp}\!\left(\text{round}\!\left(\tfrac{w_i}{s}\right) + z,\, 0,\, 2^b - 1\right)$$
and we recover $\hat{w}_i = s\,(q_i - z)$. No levels are wasted, so affine is the
better choice for activations.
::: {.callout-note}
## Key Insight
Symmetric needs only a scale; affine needs a scale **and** a zero-point but uses
the full code range. Weights → symmetric (zero-centred, and a zero-point would
slow the matmul). Activations → affine (often one-sided). Both are just a linear
map between reals and integers; the only lossy step is `round`.
:::
## Code: Quantize a Tensor from Scratch
Everything lives in `quantization.py`. Load it and quantize a weight tensor to
int8, then read back the reconstruction error.
```{python}
import importlib.util
import sys
from pathlib import Path
import torch
import torch.nn as nn
spec = importlib.util.spec_from_file_location("quantization", Path("quantization.py").resolve())
q = importlib.util.module_from_spec(spec)
sys.modules["quantization"] = q
spec.loader.exec_module(q)
torch.manual_seed(0)
w = torch.randn(4, 4) * 0.1 # a small weight matrix
qi, scale = q.absmax_quantize(w, num_bits=8)
print("original[0]: ", w[0].tolist())
print("integers[0]: ", qi[0].tolist(), f" (dtype {qi.dtype})")
print(f"scale: {scale.item():.6f}")
print("dequantized[0]:", q.dequantize(qi, scale)[0].round(decimals=4).tolist())
```
The four floats became four small integers plus one shared scale. Now measure
how much precision that cost, in int8 versus int4:
```{python}
w = torch.randn(512, 512) * 0.05
for bits in (8, 4):
qi, s = q.absmax_quantize(w, num_bits=bits)
err = q.quantization_error(w, q.dequantize(qi, s))
print(f"int{bits}: max error {err['max_abs']:.5f} SNR {err['snr_db']:5.1f} dB")
```
int8 reconstructs the tensor at ~40 dB (rounding error ~10 000× smaller than the
signal); int4, with only 16 levels, drops to the mid-teens. **Signal-to-noise
ratio** is the honest way to compare: every extra bit adds ~6 dB, so int8 is
worth about 24 dB more than int4.
## Per-Tensor vs Per-Channel: The Outlier Problem
One scale for a whole matrix has a weakness. Neural-network weight matrices
routinely contain a few **outlier channels** whose magnitudes dwarf the rest.
The absmax scale is set by that single largest value, so every *other* channel is
forced onto a needlessly coarse grid.
The fix is **per-channel** quantization: give each output channel (row) its own
scale. One extra float per row buys back the precision the outlier stole. Watch
it on a matrix where channel 0 is 20× louder than the rest:
```{python}
#| output: false
torch.manual_seed(0)
w = torch.randn(24, 64) * 0.05
w[0] *= 20.0 # one loud output channel
qt, st = q.absmax_quantize(w, num_bits=8) # one scale for everything
qc, sc = q.quantize_per_channel(w, num_bits=8, axis=0) # one scale per row
# Per-channel mean-squared error under each scheme (for the visual below).
mse_tensor = ((w - q.dequantize(qt, st)) ** 2).mean(dim=1)
mse_chan = ((w - q.dequantize(qc, sc)) ** 2).mean(dim=1)
ojs_define(quantChan = {
"perTensor": mse_tensor.tolist(),
"perChannel": mse_chan.tolist(),
"outlier": 0,
})
```
```{python}
et = q.quantization_error(w, q.dequantize(qt, st))
ec = q.quantization_error(w, q.dequantize(qc, sc))
print(f"per-tensor : SNR {et['snr_db']:.1f} dB")
print(f"per-channel : SNR {ec['snr_db']:.1f} dB ← the quiet channels keep their precision")
```
Per-tensor lets one loud channel drag every quiet channel's error up by the same
factor it is louder — here ~20×. Per-channel absorbs the outlier into its own
row and leaves the rest untouched, recovering well over 10 dB of SNR. This is exactly why
production weight quantization is **always** per-channel (and why methods like
LLM.int8() go further and pull genuine outlier *features* out into fp16).
## Code: A Quantized Linear Layer
Now assemble the piece that actually saves memory in a transformer: a drop-in for
`nn.Linear` whose weight is stored per-channel int8. `QuantizedLinear.from_linear`
quantizes a trained layer; its `forward` dequantizes the weight and runs an
ordinary linear — the faithful, readable stand-in for what an integer kernel does
(integer matmul, then a per-channel rescale).
```{python}
torch.manual_seed(0)
linear = nn.Linear(512, 512)
qlinear = q.QuantizedLinear.from_linear(linear, num_bits=8)
x = torch.randn(8, 512)
ref = linear(x)
out = qlinear(x)
rel = (out - ref).norm() / ref.norm()
print(f"output relative error: {rel.item():.4%}") # well under 1%
mem = qlinear.memory_bytes()
print(f"fp32 weight bytes: {mem['fp32_weight_bytes']:,}")
print(f"int8 stored bytes: {mem['weight_bytes'] + mem['scale_bytes']:,.0f}"
f" ({mem['compression']:.2f}× smaller)")
```
The quantized layer's output is within a fraction of a percent of the original,
at ~4× less memory. Scale that up to a whole model:
```{python}
for params, name in [(7e9, "7B"), (13e9, "13B"), (70e9, "70B")]:
line = f"{name:>4}: "
for bits in (32, 16, 8, 4):
line += f"int{bits}={q.model_footprint(int(params), bits)/1e9:6.1f}GB "
print(line)
```
A 70B model is 280 GB in fp32 — many GPUs — but 35 GB in int4, within reach of a
single high-memory card. That table is why quantization is not optional at the
frontier.
## Group-wise Quantization: One Scale Per Block
Per-channel fixed the outlier *channel*. But it left a subtler leak: a single
loud weight *inside* a row still sets that whole row's scale, so every other
weight in the row — dozens or thousands of them — is forced onto a needlessly
coarse grid. The fix is to stop sharing a scale across the whole row: split each
row into **groups** of `group_size` consecutive weights and give every group its
own absmax scale. This is the granularity every production int4 model actually
uses. When a checkpoint says "int4," it almost always means **int4, group size
128** — GPTQ-g128, AWQ-g128, and llama.cpp's k-quants are all group-wise.
The key idea is that **group size is a dial** spanning the two granularities you
just built:
$$
\underbrace{g = d_{\text{in}}}_{\text{per-channel (one scale/row)}}
\;\;\longleftrightarrow\;\;
\underbrace{g = 1}_{\text{one scale/weight (lossless)}}
$$
Everything useful lives in between. `grouped.py` holds the whole thing — load it
and confirm the two endpoints are exactly the granularities the module already
knows:
```{python}
spec_g = importlib.util.spec_from_file_location("grouped", Path("grouped.py").resolve())
gq = importlib.util.module_from_spec(spec_g)
sys.modules["grouped"] = gq
spec_g.loader.exec_module(gq)
torch.manual_seed(0)
w = torch.randn(32, 128) * 0.05
w[3, 5] *= 40.0 # loud weights buried in
w[17, 100] *= 40.0 # otherwise-quiet rows
# Endpoint 1: a group as wide as the row IS per-channel — bit for bit.
qg, sg = gq.quantize_grouped(w, num_bits=4, group_size=128, axis=1)
qc, sc = q.quantize_per_channel(w, num_bits=4, axis=0)
print("g = d_in == per-channel:", bool(torch.equal(qg, qc) and torch.equal(sg, sc)))
# Endpoint 2: a group of one gives every weight its own scale — exact.
q1, s1 = gq.quantize_grouped(w, num_bits=4, group_size=1, axis=1)
recon = gq.dequantize_grouped(q1, s1, group_size=1, axis=1)
print("g = 1 == exact reconstruction:", torch.allclose(recon, w, atol=1e-6))
```
::: {.callout-note}
## Key Insight
Group-wise quantization is not a new idea bolted on — it is the **continuous
interpolation** between the two granularities you already have. Per-channel is
one endpoint (`g = d_in`), a scale per weight is the other (`g = 1`). Choosing a
group size in between is choosing a point on the accuracy–memory curve.
:::
## The Math: Group Size Is a Dial
Split a weight row into $n_g = d_{\text{in}} / g$ groups. Each group $G$ carries
its own symmetric absmax scale, then rounds against it:
$$
s_G = \frac{\max_{i \in G} |w_i|}{q_{\max}}, \qquad
\hat{w}_i = s_G \cdot \operatorname{clamp}\!\Big(\operatorname{round}\big(w_i / s_G\big),\, -q_{\max},\, q_{\max}\Big)
$$
Why does a smaller group help? Because refining a group into sub-groups can only
**lower** each sub-group's absmax (a subset's maximum never exceeds the whole
set's). So as $g$ shrinks, the scale seen by any given weight is **non-increasing**,
and with it the worst-case rounding error:
$$
|w_i - \hat{w}_i| \;\le\; \frac{s_G}{2} \quad\text{is monotone non-increasing in decreasing } g.
$$
The price is storage. Each group stores one extra scale (a 16-bit float), so the
scale amortizes to $\text{scale\_bits} / g$ extra bits on **every** weight:
$$
b_{\text{eff}} = b + \frac{\text{scale\_bits}}{g}.
$$
At the industry-default int4, $g = 128$: $b_{\text{eff}} = 4 + 16/128 = 4.125$
bits — about 0.4% over raw int4, for a large slice of the accuracy back. The
GPTQ paper measures this exact trade in perplexity: "group-size 128 (≈ 0.15
extra bits) improves perplexities by another 0.1."
### Step by Step
Drive the dial on a real weight row below. Each step halves the group size,
subdividing the strip into finer blocks — watch each block's scale tighten to its
own contents and the worst-case error bound shrink, while the bit cost climbs.
```{python}
#| output: false
# One real, outlier-bearing weight row for the step-through (64 values).
torch.manual_seed(7)
row = torch.randn(64) * 0.06
row[19] *= 30.0 # a loud weight mid-row
row_list = row.tolist()
q_max4 = 2 ** (4 - 1) - 1
group_scales = {}
for g in (64, 32, 16, 8, 4, 2, 1):
absmax = gq.group_absmax(row.unsqueeze(0), group_size=g, axis=1)[0] # (n_g,)
group_scales[str(g)] = (absmax / q_max4).tolist()
ojs_define(groupRow = {
"values": row_list,
"scales": group_scales,
"sizes": [64, 32, 16, 8, 4, 2, 1],
"qMax": q_max4,
})
```
```{ojs}
//| echo: false
viewof groupStep = stepControl({min: 0, max: 6, value: 0, label: "Halve the group"})
```
```{ojs}
//| echo: false
groupStripChart = {
const width = 760, height = 250;
const margin = { top: 52, right: 20, bottom: 46, left: 20 };
const theme = diagramTheme;
const data = groupRow;
const n = data.values.length;
const g = data.sizes[groupStep];
const scales = data.scales[String(g)];
const nGroups = n / g;
const effBits = 4 + 16 / g;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const iw = width - margin.left - margin.right;
const cw = iw / n; // per-weight column width
const absMax = d3.max(data.values, d => Math.abs(d));
const y0 = margin.top + 70;
const yScale = d3.scaleLinear().domain([0, absMax]).range([0, 58]);
// Per-weight magnitude bars.
svg.selectAll("rect.wt").data(data.values).join("rect")
.attr("class", "wt")
.attr("x", (d, i) => margin.left + i * cw + 0.5)
.attr("width", Math.max(0.6, cw - 1))
.attr("y", d => y0 - yScale(Math.abs(d)))
.attr("height", d => yScale(Math.abs(d)))
.attr("fill", (d) => Math.abs(d) > absMax * 0.5 ? theme.error : theme.nodeStroke)
.attr("opacity", 0.85);
svg.append("line").attr("x1", margin.left).attr("x2", width - margin.right)
.attr("y1", y0).attr("y2", y0).attr("stroke", theme.edgeStroke).attr("stroke-width", 1);
// Group boxes with each block's own scale + error bound.
const gb = svg.selectAll("g.grp").data(d3.range(nGroups)).join("g").attr("class", "grp");
gb.append("rect")
.attr("x", k => margin.left + k * g * cw + 1)
.attr("width", g * cw - 2)
.attr("y", margin.top - 8)
.attr("height", 82)
.attr("fill", "none")
.attr("stroke", theme.highlight)
.attr("stroke-width", 1.5)
.attr("rx", 4)
.attr("opacity", 0.9);
// Error-bound band (scale/2) inside each block — the guaranteed precision.
gb.append("rect")
.attr("x", k => margin.left + k * g * cw + 1)
.attr("width", g * cw - 2)
.attr("y", k => y0 - yScale(scales[k] / 2))
.attr("height", k => yScale(scales[k] / 2))
.attr("fill", theme.highlight).attr("opacity", 0.14);
if (g >= 4) {
gb.append("text")
.attr("x", k => margin.left + k * g * cw + g * cw / 2)
.attr("y", margin.top + 6)
.attr("text-anchor", "middle").attr("font-size", g >= 8 ? "10px" : "8px")
.attr("fill", theme.nodeText)
.text(k => `s=${scales[k].toFixed(3)}`);
}
// Header readout.
svg.append("text").attr("x", margin.left).attr("y", 26)
.attr("font-size", "15px").attr("font-weight", 700).attr("fill", theme.nodeText)
.text(`group_size = ${g}`);
svg.append("text").attr("x", width - margin.right).attr("y", 26)
.attr("text-anchor", "end").attr("font-size", "13px").attr("fill", theme.accent)
.text(`${nGroups} scale${nGroups > 1 ? "s" : ""} · ${effBits.toFixed(3)} bits/weight`);
const worst = d3.max(scales) / 2;
svg.append("text").attr("x", margin.left).attr("y", height - 16)
.attr("font-size", "12px").attr("fill", theme.nodeText)
.html(`Shaded band = each block's guaranteed error bound s/2. `);
svg.append("text").attr("x", width - margin.right).attr("y", height - 16)
.attr("text-anchor", "end").attr("font-size", "12px").attr("fill", theme.nodeText)
.text(g === 1 ? "g=1 → every weight exact" : `worst block bound ≈ ${worst.toFixed(3)}`);
return svg.node();
}
```
::: {.callout-note}
## Key Insight
Notice the loud weight (red) sits in one block. At `group_size = 64` its scale is
imposed on the *whole* row; as you halve the group, the outlier gets quarantined
into an ever-smaller block and every other block collapses onto a tight scale of
its own. The outlier's *own* block never gets cheaper — that residual is exactly
what the error-correcting methods below (GPTQ, AWQ) attack from a different angle.
:::
## The Accuracy–Memory Dial
Turning the dial has a measurable price and a measurable payoff. Sweep the group
size on the outlier matrix and read both axes at once — reconstruction SNR
(higher is better) against the effective bits per weight (`4 + 16/g`):
```{python}
#| output: false
sizes = [128, 64, 32, 16, 8, 4, 2, 1]
sweep = []
for g in sizes:
qi, s = gq.quantize_grouped(w, num_bits=4, group_size=g, axis=1)
err = q.quantization_error(w, gq.dequantize_grouped(qi, s, group_size=g, axis=1))
sweep.append({
"g": g,
"snr": None if err["snr_db"] == float("inf") else round(err["snr_db"], 2),
"bits": round(gq.grouped_effective_bits(4, g), 4),
})
ojs_define(groupSweep = sweep)
```
```{python}
print(f"{'group':>6} {'eff.bits':>8} {'SNR(dB)':>8}")
for r in sweep:
snr = " exact" if r["snr"] is None else f"{r['snr']:8.1f}"
tag = " ← per-channel" if r["g"] == 128 else (" ← per-weight" if r["g"] == 1 else "")
print(f"{r['g']:>6} {r['bits']:>8.3f} {snr}{tag}")
```
```{ojs}
//| echo: false
groupTradeoffChart = {
const width = 760, height = 320;
const margin = { top: 40, right: 64, bottom: 56, left: 56 };
const theme = diagramTheme;
const pts = groupSweep.filter(d => d.snr !== null); // drop the exact g=1 point
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const x = d3.scalePoint().domain(pts.map(d => d.g)).range([margin.left, width - margin.right]).padding(0.5);
const ySnr = d3.scaleLinear().domain([0, d3.max(pts, d => d.snr) * 1.1]).range([height - margin.bottom, margin.top]);
const yBit = d3.scaleLinear().domain([4, d3.max(pts, d => d.bits) * 1.05]).range([height - margin.bottom, margin.top]);
// Axes
svg.append("g").attr("transform", `translate(0,${height - margin.bottom})`)
.call(d3.axisBottom(x).tickFormat(d => `g=${d}`))
.attr("color", theme.nodeText).selectAll("text").attr("font-size", "11px");
svg.append("g").attr("transform", `translate(${margin.left},0)`)
.call(d3.axisLeft(ySnr).ticks(5)).attr("color", theme.accent);
svg.append("g").attr("transform", `translate(${width - margin.right},0)`)
.call(d3.axisRight(yBit).ticks(5)).attr("color", theme.highlight);
svg.append("text").attr("x", margin.left).attr("y", 24).attr("fill", theme.accent)
.attr("font-size", "12px").attr("font-weight", 600).text("SNR (dB) — accuracy ↑");
svg.append("text").attr("x", width - margin.right).attr("y", 24).attr("text-anchor", "end")
.attr("fill", theme.highlight).attr("font-size", "12px").attr("font-weight", 600).text("bits/weight — cost ↑");
const line = (key, sc, color) => {
svg.append("path").datum(pts)
.attr("fill", "none").attr("stroke", color).attr("stroke-width", 2.5)
.attr("d", d3.line().x(d => x(d.g)).y(d => sc(d[key])));
svg.selectAll(null).data(pts).join("circle")
.attr("cx", d => x(d.g)).attr("cy", d => sc(d[key])).attr("r", 4).attr("fill", color);
};
line("bits", yBit, theme.highlight);
line("snr", ySnr, theme.accent);
// Mark the g=128 default (the practical knee).
const knee = pts.find(d => d.g === 128);
if (knee) {
svg.append("line").attr("x1", x(128)).attr("x2", x(128))
.attr("y1", margin.top).attr("y2", height - margin.bottom)
.attr("stroke", theme.nodeText).attr("stroke-dasharray", "4 4").attr("opacity", 0.4);
svg.append("text").attr("x", x(128) + 6).attr("y", margin.top + 14)
.attr("font-size", "11px").attr("fill", theme.nodeText).text("g=128 default");
}
return svg.node();
}
```
The two curves cross the story: shrinking the group buys SNR fast at first, then
flattens — while the bit cost stays nearly flat until it explodes past `g ≈ 16`.
The sweet spot sits where they diverge, which is exactly why `g = 128` (and, for
even lower bit-widths, `g = 64`) is the near-universal default.
Now assemble the payoff — a `GroupQuantizedLinear`, the sibling of the
per-channel `QuantizedLinear`, storing one fp16 scale per input block:
```{python}
torch.manual_seed(0)
linear = nn.Linear(1024, 1024)
qlin = gq.GroupQuantizedLinear.from_linear(linear, num_bits=4, group_size=128)
x = torch.randn(8, 1024)
rel = (qlin(x) - linear(x)).norm() / linear(x).norm()
mem = qlin.memory_bytes()
print(f"int4 g=128 output error: {rel.item():.3%}")
print(f"effective bits/weight: {mem['effective_bits']:.3f}")
print(f"compression vs fp32: {mem['compression']:.2f}×")
```
That is genuine 4-bit storage (~7.8× smaller than fp32) whose output still
tracks the fp32 layer — the honest starting point the error-correcting methods
below refine.
### Where the Precision Goes
The per-group scale map makes the localization visible. Each cell is one group's
scale; group-wise gives the outlier its own bright block and lets every other
block stay dark (precise), where per-channel would smear one column and
per-tensor a single value across the whole matrix.
```{python}
#| output: false
g_map = 16
_, s_grp = gq.quantize_grouped(w, num_bits=4, group_size=g_map, axis=1)
_, s_chan = q.quantize_per_channel(w, num_bits=4, axis=0)
qt, s_tensor = q.absmax_quantize(w, num_bits=4)
ojs_define(scaleMap = {
"grouped": s_grp.tolist(), # (rows, n_groups)
"perChannel": s_chan.squeeze(-1).unsqueeze(-1).tolist(), # (rows, 1)
"perTensor": float(s_tensor),
"rows": s_grp.shape[0],
"groupSize": g_map,
})
```
```{ojs}
//| echo: false
viewof scaleView = Inputs.radio(["group-wise (g=16)", "per-channel", "per-tensor"], {
value: "group-wise (g=16)", label: "Granularity"
})
```
```{ojs}
//| echo: false
scaleMapChart = {
const width = 760, height = 300;
const margin = { top: 44, right: 24, bottom: 30, left: 44 };
const theme = diagramTheme;
const rows = scaleMap.rows;
let grid, label;
if (scaleView.startsWith("group")) { grid = scaleMap.grouped; label = `${grid[0].length} groups × ${rows} rows`; }
else if (scaleView === "per-channel") { grid = scaleMap.perChannel; label = `1 scale × ${rows} rows`; }
else { grid = Array.from({length: rows}, () => [scaleMap.perTensor]); label = "1 scale for the whole matrix"; }
const nCols = grid[0].length;
const iw = width - margin.left - margin.right;
const ih = height - margin.top - margin.bottom;
const cw = iw / nCols, ch = ih / rows;
const maxS = d3.max(scaleMap.grouped.flat());
const color = d3.scaleSequential(d3.interpolateInferno).domain([0, maxS]);
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height).attr("fill", theme.bg).attr("rx", 12);
for (let r = 0; r < rows; r++) {
for (let c = 0; c < nCols; c++) {
svg.append("rect")
.attr("x", margin.left + c * cw).attr("y", margin.top + r * ch)
.attr("width", Math.max(1, cw - 0.5)).attr("height", Math.max(1, ch - 0.5))
.attr("fill", color(grid[r][c]));
}
}
svg.append("text").attr("x", margin.left).attr("y", 26)
.attr("font-size", "13px").attr("font-weight", 600).attr("fill", theme.nodeText)
.text(`Scale map — ${label}`);
svg.append("text").attr("x", margin.left).attr("y", height - 10)
.attr("font-size", "11px").attr("fill", theme.nodeText)
.text("bright = large scale (coarse grid) · dark = small scale (fine grid)");
svg.append("text").attr("x", margin.left - 6).attr("y", margin.top + ih / 2)
.attr("text-anchor", "middle").attr("transform", `rotate(-90 ${margin.left - 6} ${margin.top + ih / 2})`)
.attr("font-size", "11px").attr("fill", theme.nodeText).text("output rows");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Flip to per-tensor.** The whole map turns one uniform bright colour — the
single outlier has coarsened *every* weight.
2. **Flip to per-channel.** Only the two outlier rows light up; the rest go dark.
Better — but those two rows are still coarse across all 128 of their weights.
3. **Back to group-wise.** Now only the *blocks* holding an outlier are bright.
The other seven blocks in each outlier row keep their own fine scale. That
reclaimed precision is the SNR the curve above measured.
:::
## Beyond Round-to-Nearest: GPTQ
Everything above rounds each weight **in a vacuum** — nearest grid point, one
weight at a time, blind to how the layer will actually be used. That is
**round-to-nearest** (RTN), and it minimizes the *weight* error $\lVert W -
\hat{W}\rVert$. But a layer does not ship its weights to the user; it ships
$\hat{W}x$. What matters is the **output** error over the inputs the layer really
sees:
$$\arg\min_{\hat{W}}\ \lVert W X - \hat{W} X \rVert_2^2$$
where $X$ is a small **calibration** batch — a few hundred real activations, one
per column. When the input features are correlated (they always are in a trained
network), the weight that minimizes rounding error is *not* the one that
minimizes output error. RTN leaves accuracy on the table, and at 3–4 bits that
gap is the difference between a usable model and a broken one.
**GPTQ** (Frantar et al., 2022) fixes this with one idea: **error feedback**.
Quantize the weight matrix one column at a time; after you snap a column to the
grid, look at the output error you just created and **nudge the columns you have
not quantized yet** to cancel it. The later weights *absorb* the earlier weights'
rounding error. By the time you reach the last column, the accumulated damage to
the layer's output has been continuously paid down instead of piling up.
::: {.callout-note}
## Key Insight
RTN asks "what is the nearest grid point for this weight?" GPTQ asks "given that I
must round this weight, how should I adjust the *remaining* weights so the layer's
output barely moves?" The first ignores the calibration data; the second is
steered by it. That is the whole difference — and it is why GPTQ took large models
to 4 bits when RTN could not.
:::
## The Math: Second-Order Error Compensation
How much should the remaining weights move? The objective $\lVert W X - \hat{W} X
\rVert^2$ is a quadratic in the weights, and a quadratic's shape is its **Hessian**
$$H = 2 X X^\top \qquad (\text{shape } d_\text{in} \times d_\text{in}),$$
the correlation matrix of the input features. This is the **Optimal Brain
Surgeon** (OBS) setup from network pruning: if we *fix* one weight $w_q$ to its
quantized value $\text{quant}(w_q)$, the update to the other free weights that
keeps the loss lowest, and the loss it costs, are both closed-form:
$$\boldsymbol{\delta} = -\frac{w_q - \text{quant}(w_q)}{[H^{-1}]_{qq}}\, H^{-1}_{:,\,q}, \qquad \Delta L = \frac{\bigl(w_q - \text{quant}(w_q)\bigr)^2}{[H^{-1}]_{qq}}.$$
The off-diagonal of $H^{-1}$ is exactly the "how correlated is weight $q$ with the
others" signal that tells the remaining weights how to compensate. Applying this
greedily, column by column, is **OBQ**. GPTQ's contribution is making it *fast*:
quantize the columns in a **fixed left-to-right order** (the order barely matters
for a big layer), and precompute every conditional $H^{-1}$ quantity with a single
**Cholesky factorization** $C$ (upper-triangular, $C^\top C = H^{-1}$) instead of
re-inverting a shrinking matrix at each step. The per-column update becomes:
```
q_j = quant(W[:, j]) # round column j on its fixed per-row grid
err = (W[:, j] − q_j) / C[j, j]
W[:, j:] −= err ⊗ C[j, j:] # push the error onto the not-yet-done columns
```
A tiny constant — 1% of the mean diagonal — is added to $H$ before inverting so it
stays invertible. That is the entire algorithm.
::: {.callout-note}
## Key Insight
If the features are **uncorrelated**, $H$ is diagonal, $C$ has no off-diagonal
terms, and `C[j, j:]` touches only column $j$ — no error propagates and **GPTQ
becomes RTN exactly**. Every bit of GPTQ's advantage is the correlation
correction. We prove this bit-for-bit in the code below.
:::
### Step by Step
Walk one GPTQ pass over an $6\times 8$ layer at **2 bits**. Each step quantizes one
column (it turns solid), then propagates its error rightward onto the columns still
to come. Watch the running output loss track *below* the RTN line the whole way —
that gap is the compensation working.
```{ojs}
//| echo: false
viewof gptqStep = stepControl({min: 0, max: 7, value: 0, label: "Column"})
```
```{ojs}
//| echo: false
gptqStepData = gptqTrace.map((t, j) => ({
column: j,
errNorm: t.err_norm,
loss: t.loss,
rtnLoss: t.rtn_loss,
}))
```
```{ojs}
//| echo: false
gptqStepDiagram = {
const width = 720, height = 300;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const nCols = gptqStepData.length;
const cw = 56, gap = 12, x0 = 40, y = 40, ch = 96;
// Column blocks: done (quantized) | active | float (still to come).
for (let j = 0; j < nCols; j++) {
const x = x0 + j * (cw + gap);
const done = j < gptqStep, active = j === gptqStep;
const fill = active ? theme.highlight : (done ? theme.success : theme.nodeFill);
const stroke = active ? theme.highlight : (done ? theme.success : theme.nodeStroke);
svg.append("rect").attr("x", x).attr("y", y)
.attr("width", cw).attr("height", ch).attr("rx", 6)
.attr("fill", fill).attr("fill-opacity", done ? 0.85 : (active ? 1 : 0.25))
.attr("stroke", stroke).attr("stroke-width", active ? 2.5 : 1.5);
svg.append("text").attr("x", x + cw / 2).attr("y", y + ch / 2)
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("fill", active ? theme.bgOpaque : theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600")
.attr("font-family", "var(--pg-mono)")
.text(done ? "int2" : (active ? "→ round" : "fp"));
svg.append("text").attr("x", x + cw / 2).attr("y", y + ch + 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "11px").text(`w${j}`);
}
// Error-propagation arrow from the active column to the remaining ones.
if (gptqStep < nCols - 1) {
const ax = x0 + gptqStep * (cw + gap) + cw;
const ax2 = x0 + (nCols - 1) * (cw + gap) + cw;
svg.append("line").attr("x1", ax + 4).attr("x2", ax2)
.attr("y1", y + ch + 30).attr("y2", y + ch + 30)
.attr("stroke", theme.accent).attr("stroke-width", 2)
.attr("stroke-dasharray", "5 4").attr("marker-end", "url(#gptq-arrow)");
svg.append("text").attr("x", (ax + ax2) / 2).attr("y", y + ch + 24)
.attr("text-anchor", "middle").attr("fill", theme.accent)
.attr("font-size", "11px").attr("font-style", "italic")
.text("propagate error →");
}
svg.append("defs").append("marker").attr("id", "gptq-arrow")
.attr("viewBox", "0 0 10 10").attr("refX", 8).attr("refY", 5)
.attr("markerWidth", 6).attr("markerHeight", 6).attr("orient", "auto")
.append("path").attr("d", "M0,0 L10,5 L0,10 z").attr("fill", theme.accent);
// Running loss readout: GPTQ vs RTN after this column.
const d = gptqStepData[gptqStep];
const ly = 250;
svg.append("text").attr("x", width / 2).attr("y", ly)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "13px")
.text(`After column ${gptqStep}: error pushed = ${d.errNorm.toFixed(2)}`);
const readout = svg.append("text").attr("x", width / 2).attr("y", ly + 24)
.attr("text-anchor", "middle").attr("font-size", "14px").attr("font-weight", "700");
readout.append("tspan").attr("fill", theme.success)
.text(`GPTQ loss ${d.loss.toFixed(1)}`);
readout.append("tspan").attr("fill", theme.nodeText).attr("font-weight", "400")
.text(" vs ");
readout.append("tspan").attr("fill", theme.error)
.text(`RTN loss ${d.rtnLoss.toFixed(1)}`);
return svg.node();
}
```
## Code: GPTQ from Scratch
The algorithm lives in `gptq.py`: `layer_hessian` builds $2XX^\top$,
`round_to_nearest` is the RTN baseline, and `gptq_quantize` runs the Cholesky
Algorithm 1 above. Build a small layer, a **correlated** calibration batch, and
compare the two on output loss.
```{python}
gptq_spec = importlib.util.spec_from_file_location("gptq", Path("gptq.py").resolve())
gptq = importlib.util.module_from_spec(gptq_spec)
sys.modules["gptq"] = gptq
gptq_spec.loader.exec_module(gptq)
torch.manual_seed(0)
W = torch.randn(64, 64) * 0.1 # a layer's weight matrix
mix = torch.randn(64, 64)
X = mix @ torch.randn(64, 128) # 128 correlated calibration inputs
H = gptq.layer_hessian(X) # 2 X Xᵀ, damped
for bits in (4, 3, 2):
rtn, _ = gptq.round_to_nearest(W, num_bits=bits)
gp, _ = gptq.gptq_quantize(W, H, num_bits=bits)
lr = gptq.output_loss(W, rtn, X)
lg = gptq.output_loss(W, gp, X)
print(f"int{bits}: RTN loss {lr:9.1f} GPTQ loss {lg:9.1f} "
f"({1 - lg / lr:.0%} lower)")
```
GPTQ roughly halves the output error at every bit width, and the win is largest
where it matters most — the aggressive low-bit regime. Now the two facts that
pin the algorithm down. First, **uncorrelated features collapse GPTQ to RTN,
exactly**:
```{python}
# Orthogonal calibration features → diagonal Hessian → no error to propagate.
H_diag = gptq.layer_hessian(torch.eye(64) * 3.0, damp=0.0)
gp_diag, _ = gptq.gptq_quantize(W, H_diag, num_bits=3)
rtn3, _ = gptq.round_to_nearest(W, num_bits=3)
print("diagonal H → GPTQ == RTN, bit-for-bit:", torch.equal(gp_diag, rtn3))
```
Second, GPTQ's Cholesky reformulation is not an approximation — it reproduces the
slow, explicit **Optimal Brain Surgeon** recursion (`obq_reference`, which shrinks
$H^{-1}$ by a Schur complement at every step) to floating-point precision:
```{python}
gp, _ = gptq.gptq_quantize(W, H, num_bits=3)
ref = gptq.obq_reference(W, H, num_bits=3)
print("GPTQ (Cholesky) == OBS reference:", torch.allclose(gp, ref, atol=1e-4))
```
### RTN vs GPTQ at a Glance
The bars below come straight from `demonstrate_gptq`. Each bit width is normalized
so RTN is a full bar; GPTQ's shorter bar is the output loss that survives — the
rest is what the error feedback recovered.
```{python}
#| output: false
demo = gptq.demonstrate_gptq(verbose=False)
ojs_define(gptqBars = {
"bits": [4, 3, 2],
"reduction": [demo["results"][b]["reduction"] for b in (4, 3, 2)],
})
# The stepper trace: a compact 6×8 layer at 2 bits where the win is vivid.
torch.manual_seed(0)
W_trace = torch.randn(6, 8) * 0.1
X_trace = torch.randn(8, 8) @ torch.randn(8, 64)
ojs_define(gptqTrace = gptq.gptq_trace(W_trace, X_trace, num_bits=2))
```
```{ojs}
//| echo: false
gptqBarChart = {
const width = 720, height = 300;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const groups = gptqBars.bits.map((b, i) => ({
bits: b, reduction: gptqBars.reduction[i],
}));
const m = {top: 30, right: 30, bottom: 50, left: 60};
const iw = width - m.left - m.right, ih = height - m.top - m.bottom;
const gw = iw / groups.length, bw = 70;
const yScale = d3.scaleLinear().domain([0, 1]).range([ih, 0]);
// y grid + axis label.
[0, 0.25, 0.5, 0.75, 1.0].forEach(v => {
const y = m.top + yScale(v);
svg.append("line").attr("x1", m.left).attr("x2", width - m.right)
.attr("y1", y).attr("y2", y).attr("stroke", theme.nodeStroke)
.attr("stroke-opacity", 0.25);
svg.append("text").attr("x", m.left - 8).attr("y", y + 4)
.attr("text-anchor", "end").attr("fill", theme.nodeText)
.attr("font-size", "10px").text(v.toFixed(2));
});
svg.append("text").attr("x", 16).attr("y", m.top + ih / 2)
.attr("transform", `rotate(-90 16 ${m.top + ih / 2})`)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "11px").text("output loss (RTN = 1.0)");
groups.forEach((g, i) => {
const cx = m.left + i * gw + gw / 2;
// RTN reference bar (full height = 1.0).
svg.append("rect").attr("x", cx - bw - 4).attr("y", m.top + yScale(1))
.attr("width", bw).attr("height", ih - yScale(1))
.attr("fill", theme.error).attr("fill-opacity", 0.55).attr("rx", 4);
svg.append("text").attr("x", cx - bw / 2 - 4).attr("y", m.top + yScale(1) - 6)
.attr("text-anchor", "middle").attr("fill", theme.error)
.attr("font-size", "11px").attr("font-weight", "600").text("RTN");
// GPTQ survivor bar.
const frac = 1 - g.reduction;
svg.append("rect").attr("x", cx + 4).attr("y", m.top + yScale(frac))
.attr("width", bw).attr("height", ih - yScale(frac))
.attr("fill", theme.success).attr("rx", 4);
svg.append("text").attr("x", cx + bw / 2 + 4).attr("y", m.top + yScale(frac) - 6)
.attr("text-anchor", "middle").attr("fill", theme.success)
.attr("font-size", "11px").attr("font-weight", "600")
.text(`GPTQ −${(g.reduction * 100).toFixed(0)}%`);
svg.append("text").attr("x", cx).attr("y", height - m.bottom + 22)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600").text(`int${g.bits}`);
});
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Kill the correlation.** In the code, replace `X = mix @ torch.randn(64, 128)`
with plain `X = torch.randn(64, 128)`. The features are now near-orthogonal, $H$
is nearly diagonal, and GPTQ's advantage almost vanishes — you can *see* that
its whole edge is the correlation correction.
2. **Push to the extreme.** Add `1` to the bit sweep. At the same 1-bit ternary
edge where RTN is hopeless, GPTQ still recovers a large fraction of the loss —
this is why the paper reports usable 2-bit and even ternary models.
:::
## A Different Lever: AWQ
GPTQ fixes rounding *after the fact*: round a column, then use the Hessian to push
the error onto the columns you haven't touched yet. **AWQ** (Lin et al., 2023)
reshapes the problem *before* you quantize at all — with no backprop, no Hessian,
and no reconstruction. It is the method most int4 open weights actually ship with.
The idea starts from a fact GPTQ and RTN both ignore: **not all weights are equally
important, and importance lives in the *activations*, not the weights.** A weight
column multiplied by a channel whose activations are large contributes far more to
the output than one multiplied by a near-silent channel. Protect those **salient
channels** and you protect the output.
How do you protect a channel without spending extra bits on it? An **equivalent
transformation**. For a linear $y = Wx$, scale one input channel's weight column up
by $s > 1$ and divide that channel's activation by the same $s$. In full precision
the product is unchanged — but now the salient column occupies more of the integer
grid, so quantizing *it* costs less.
::: {.callout-note}
## Key Insight
GPTQ asks "given this rounding, how do I cancel its output error?" AWQ asks "which
channels can I *afford* to round?" — and rescales the layer so the answer is "all
of them." One uses second-order weight statistics; the other uses first-order
activation statistics. They attack the same $\lVert WX - \hat W X\rVert$ from
opposite ends.
:::
Step through the equivalent transform — watch the salient column grow, its
activation shrink, and the output stay put:
```{ojs}
//| echo: false
viewof awqStep = stepControl({min: 0, max: 4, value: 0, label: "Step"})
```
```{ojs}
//| echo: false
awqSteps = [
{title: "y = W x", caption: "A linear layer. The activation vector x has one loud (salient) channel — its bar towers over the rest. Its weight column drives most of the output."},
{title: "Read salience from x", caption: "AWQ never looks at the weights to rank importance. It measures s_X = mean(|x|) per input channel from a calibration batch. The loud channel scores high."},
{title: "Scale the column up (×s)", caption: "Multiply the salient weight column by s > 1. It now spans more of the integer grid, so rounding resolves it far more finely."},
{title: "Scale the activation down (÷s)", caption: "Divide that channel's activation by the same s. In full precision W·s and x/s cancel exactly — the layer computes the same y."},
{title: "Quantize the reshaped W", caption: "Round the rescaled weights. The salient column's error is now ~1/s of what it was; the output error collapses. The ÷s folds into the previous layer — free at run time."},
]
```
```{ojs}
//| echo: false
awqTransformDiagram = {
const width = 720, height = 300;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const step = awqStep;
// Six input channels; index 1 is the salient one.
const base = [0.25, 1.0, 0.2, 0.35, 0.18, 0.28];
const salient = 1;
const scaled = step >= 2; // column scaled up
const actDown = step >= 3; // activation scaled down
const s = 2.0;
// --- Activation bars (left) ---
const ax = 70, ay0 = 60, bw = 26, gap = 12, maxH = 150;
svg.append("text").attr("x", ax).attr("y", 40).attr("fill", theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600").text("activation x");
base.forEach((v, j) => {
const isSal = j === salient;
const mag = (isSal && actDown) ? v / s : v;
const h = mag * maxH;
const x = ax + j * (bw + gap);
const hot = isSal && step >= 1;
svg.append("rect").attr("x", x).attr("y", ay0 + (maxH - h))
.attr("width", bw).attr("height", h)
.attr("fill", hot ? theme.highlight : theme.nodeFill)
.attr("stroke", theme.nodeStroke).attr("rx", 3)
.attr("filter", hot && step === 1 ? theme.highlightGlow : null);
if (isSal)
svg.append("text").attr("x", x + bw / 2).attr("y", ay0 + maxH + 16)
.attr("text-anchor", "middle").attr("fill", theme.highlight)
.attr("font-size", "10px").attr("font-weight", "600")
.text(actDown ? "÷s" : "salient");
});
// --- Weight column strip (middle) ---
const wx = 340, wy = 70, cw = 30, ch = 130;
svg.append("text").attr("x", wx + 55).attr("y", 40).attr("fill", theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600").text("weight W (columns)");
for (let j = 0; j < 6; j++) {
const isSal = j === salient;
const grow = isSal && scaled;
const colw = grow ? cw * 1.5 : cw;
const x = wx + j * (cw + 6);
svg.append("rect").attr("x", x).attr("y", wy - (grow ? 12 : 0))
.attr("width", colw).attr("height", ch + (grow ? 24 : 0))
.attr("fill", isSal ? (grow ? theme.highlight : theme.accent) : theme.surfaceTertiary || theme.nodeFill)
.attr("fill-opacity", isSal ? 0.85 : 0.5)
.attr("stroke", theme.nodeStroke).attr("rx", 3)
.attr("filter", grow && step === 2 ? theme.highlightGlow : null);
if (isSal && scaled)
svg.append("text").attr("x", x + colw / 2).attr("y", wy - 18)
.attr("text-anchor", "middle").attr("fill", theme.highlight)
.attr("font-size", "10px").attr("font-weight", "600").text("×s");
}
// --- Output (right) ---
const ox = 640;
svg.append("text").attr("x", ox).attr("y", 40).attr("fill", theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600").text("y");
const yColor = step >= 3 ? theme.success : theme.nodeFill;
svg.append("rect").attr("x", ox).attr("y", 70).attr("width", 40).attr("height", 130)
.attr("fill", yColor).attr("fill-opacity", 0.8)
.attr("stroke", theme.nodeStroke).attr("rx", 4);
svg.append("text").attr("x", ox + 20).attr("y", 220)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "10px").text(step >= 3 ? "= same y" : "y");
// caption
const st = awqSteps[step];
svg.append("text").attr("x", width / 2).attr("y", height - 40)
.attr("text-anchor", "middle").attr("fill", theme.highlight)
.attr("font-size", "13px").attr("font-weight", "600").text(st.title);
svg.append("foreignObject").attr("x", 40).attr("y", height - 32)
.attr("width", width - 80).attr("height", 30)
.append("xhtml:div")
.style("color", theme.nodeText).style("font-size", "11px")
.style("text-align", "center").style("line-height", "1.3")
.text(st.caption);
return svg.node();
}
```
## The Math: Protect the Salient Channels
Recall the symmetric grid: $\Delta = \max(|w|) / 2^{N-1}$ is the width of one
quantization bucket, and rounding a weight introduces an error of about
$\text{RoundErr} \approx 0.25$ buckets (uniform on $[0, 0.5]$). The output error a
single weight contributes is that rounding error times the activation it feeds:
$$
\text{Err}\big(Q(w)\,x\big) = \Delta \cdot \text{RoundErr} \cdot x .
$$
Now apply the equivalent transform — scale the weight by $s$ and the activation by
$1/s$:
$$
\text{Err}\big(Q(w\,s)\,(x / s)\big) = \Delta' \cdot \text{RoundErr} \cdot x \cdot \tfrac{1}{s},
\qquad
\frac{\text{Err}'}{\text{Err}} = \frac{\Delta'}{\Delta}\cdot\frac{1}{s} .
$$
Here is the whole trick. Scaling **one** channel up barely moves the group's
maximum, so $\Delta' \approx \Delta$ and the ratio is $\approx 1/s < 1$: the salient
channel's error shrinks. Push $s$ too far, though, and that column *becomes* the new
maximum — $\Delta'$ jumps, coarsening every other weight in the group. So $s$ is a
trade-off, and AWQ tunes it with a single knob $\alpha$ tied to activation salience:
$$
s_X = \text{mean}(|X|) \ \text{(per input channel)}, \qquad s = s_X^{\alpha},
$$
$$
\alpha^{*} = \arg\min_{\alpha \in [0,1]} \ \big\lVert \, Q\!\big(W\,\text{diag}(s)\big)\,\text{diag}(s)^{-1} X \;-\; W X \, \big\rVert .
$$
At $\alpha = 0$ every scale is $s = s_X^{0} = 1$ — no rescaling, i.e. **plain
round-to-nearest.** At $\alpha = 1$ the scale is the raw activation magnitude, which
over-protects the loud channels and wrecks the quiet ones. The best $\alpha$ sits
between, found by a cheap grid search (the paper uses 20 points over $[0,1]$).
::: {.callout-note}
## Key Insight
Because $\alpha = 0$ *is* round-to-nearest and *is* one of the grid points, the
search can never return something worse than RTN — it takes the arg-min. AWQ's
output loss is $\le$ RTN's, guaranteed, exactly the way GPTQ's is. Two very
different mechanisms, the same safety net.
:::
## Code: AWQ from Scratch
The method lives in `awq.py`. `activation_scale` reads salience straight off a
calibration batch; `apply_awq_scale` is the equivalent transform $W\,\text{diag}(s)$;
`awq_effective` quantizes the reshaped weights and folds the inverse scale back so
the result is comparable to the original $W$; and `awq_search` grid-searches
$\alpha$. First, prove the transform is free until you quantize:
```{python}
awq_spec = importlib.util.spec_from_file_location("awq", Path("awq.py").resolve())
awq = importlib.util.module_from_spec(awq_spec)
sys.modules["awq"] = awq
awq_spec.loader.exec_module(awq)
torch.manual_seed(0)
W_awq = torch.randn(64, 64) * 0.1 # a layer's weights
X_awq = torch.randn(64, 128) # calibration inputs
salient_ch = torch.randperm(64)[:4]
X_awq[salient_ch] *= 12.0 # a few loud (salient) channels
s_X = awq.activation_scale(X_awq) # per-channel salience
s = s_X ** 0.5 # some scale
same = torch.allclose(awq.equivalent_output(W_awq, s, X_awq), W_awq @ X_awq, atol=1e-4)
print(f"(W·diag(s))(x/s) == W·x in full precision: {same}")
```
Now quantize. RTN rounds blindly; AWQ searches $\alpha$, scales the salient columns
up, and quantizes the reshaped matrix:
```{python}
for bits in (4, 3, 2):
rtn = awq.round_to_nearest(W_awq, num_bits=bits)
awq_w, alpha, _ = awq.awq_quantize(W_awq, X_awq, num_bits=bits)
rtn_loss = awq.output_loss(W_awq, rtn, X_awq)
awq_loss = awq.output_loss(W_awq, awq_w, X_awq)
reduction = 1 - awq_loss / rtn_loss
print(f"int{bits}: RTN {rtn_loss:8.1f} AWQ {awq_loss:8.1f} "
f"α*={alpha:.2f} −{reduction:.0%}")
```
AWQ roughly halves the output loss here, and — like GPTQ — the gap *widens* as bits
shrink: fewer bits mean more rounding error, so protecting the salient channels
matters more. The `alpha=0` collapse is exact:
```{python}
s_one = torch.ones(64) # s = s_X ** 0
awq_rtn = awq.awq_effective(W_awq, s_one, num_bits=3)
rtn_only = awq.round_to_nearest(W_awq, num_bits=3)
print(f"AWQ at α=0 is RTN, bit-for-bit: {torch.equal(awq_rtn, rtn_only)}")
```
### The α Search
The whole method is one grid search. Plot the output loss against $\alpha$ and the
shape tells the story: a floor at $\alpha = 0$ (RTN), a dip where salient protection
pays off, and a wall on the right where over-scaling coarsens everything. Drag the
slider to read the loss at each $\alpha$ and watch the marker slide along the curve.
```{python}
#| output: false
awq_curve = awq.awq_search(W_awq, X_awq, num_bits=3, grid=20)
ojs_define(awqCurve = {
"alphas": awq_curve["alphas"],
"losses": awq_curve["losses"],
"best_alpha": awq_curve["best_alpha"],
"rtn_loss": awq_curve["rtn_loss"],
})
```
```{ojs}
//| echo: false
viewof awqAlpha = Inputs.range([0, 1], {value: awqCurve.best_alpha, step: 0.05, label: "α"})
```
```{ojs}
//| echo: false
awqCurveChart = {
const width = 720, height = 340;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const pts = awqCurve.alphas.map((a, i) => ({a, loss: awqCurve.losses[i]}));
const m = {top: 30, right: 30, bottom: 50, left: 70};
const iw = width - m.left - m.right, ih = height - m.top - m.bottom;
const xS = d3.scaleLinear().domain([0, 1]).range([m.left, width - m.right]);
const yMax = d3.max(pts, d => d.loss) * 1.05;
const yS = d3.scaleLinear().domain([0, yMax]).range([m.top + ih, m.top]);
// axes
[0, 0.25, 0.5, 0.75, 1].forEach(v => {
svg.append("line").attr("x1", xS(v)).attr("x2", xS(v))
.attr("y1", m.top).attr("y2", m.top + ih)
.attr("stroke", theme.nodeStroke).attr("stroke-opacity", 0.15);
svg.append("text").attr("x", xS(v)).attr("y", m.top + ih + 22)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "11px").text(v.toFixed(2));
});
svg.append("text").attr("x", m.left + iw / 2).attr("y", height - 8)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "12px").text("α (scale exponent)");
svg.append("text").attr("x", 18).attr("y", m.top + ih / 2)
.attr("transform", `rotate(-90 18 ${m.top + ih / 2})`)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "12px").text("output loss ‖WX − ŴX‖²");
// the curve
const line = d3.line().x(d => xS(d.a)).y(d => yS(d.loss)).curve(d3.curveMonotoneX);
svg.append("path").datum(pts).attr("fill", "none")
.attr("stroke", theme.accent).attr("stroke-width", 2.5).attr("d", line);
// RTN marker (alpha = 0) and best marker
const rtnP = pts[0];
svg.append("circle").attr("cx", xS(rtnP.a)).attr("cy", yS(rtnP.loss)).attr("r", 5)
.attr("fill", theme.error);
svg.append("text").attr("x", xS(rtnP.a) + 8).attr("y", yS(rtnP.loss) - 8)
.attr("fill", theme.error).attr("font-size", "11px").attr("font-weight", "600")
.text("RTN (α=0)");
const bestP = pts.reduce((b, d) => d.loss < b.loss ? d : b, pts[0]);
svg.append("circle").attr("cx", xS(bestP.a)).attr("cy", yS(bestP.loss)).attr("r", 5)
.attr("fill", theme.success);
svg.append("text").attr("x", xS(bestP.a)).attr("y", yS(bestP.loss) + 22)
.attr("text-anchor", "middle").attr("fill", theme.success)
.attr("font-size", "11px").attr("font-weight", "600")
.text(`α*=${bestP.a.toFixed(2)}`);
// draggable marker at the chosen alpha (nearest grid point)
const gi = Math.round(awqAlpha / (pts[1].a - pts[0].a));
const cur = pts[Math.max(0, Math.min(pts.length - 1, gi))];
svg.append("line").attr("x1", xS(cur.a)).attr("x2", xS(cur.a))
.attr("y1", m.top).attr("y2", m.top + ih)
.attr("stroke", theme.highlight).attr("stroke-width", 1.5)
.attr("stroke-dasharray", "4 3");
svg.append("circle").attr("cx", xS(cur.a)).attr("cy", yS(cur.loss)).attr("r", 6)
.attr("fill", theme.highlight).attr("filter", theme.highlightGlow);
svg.append("text").attr("x", xS(cur.a)).attr("y", m.top - 8)
.attr("text-anchor", "middle").attr("fill", theme.highlight)
.attr("font-size", "12px").attr("font-weight", "600")
.text(`loss ${cur.loss.toFixed(0)} (−${(100 * (1 - cur.loss / rtnP.loss)).toFixed(0)}% vs RTN)`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Kill the salience.** In the code, drop the `X_awq[salient_ch] *= 12.0` line so
every channel is equally loud. The U-curve flattens: with no salient channel to
protect, the best $\alpha$ is $0$ and AWQ *is* RTN.
2. **Turn the loudness up.** Change `12.0` to `40.0`. The dip deepens and $\alpha^*$
creeps up — the louder the outlier channel, the more there is to gain by
protecting it.
:::
### RTN vs AWQ, Channel by Channel
Where does the win actually come from? Break the output loss down per input channel
— each bar is a channel's weight error weighted by how loud its activations are. RTN
leaves the salient channels towering. AWQ pulls exactly those down.
```{python}
#| output: false
W_awq_hat, _, _ = awq.awq_quantize(W_awq, X_awq, num_bits=3)
rtn_hat = awq.round_to_nearest(W_awq, num_bits=3)
rtn_ch = awq.channel_output_error(W_awq, rtn_hat, X_awq)
awq_ch = awq.channel_output_error(W_awq, W_awq_hat, X_awq)
# show the 12 loudest channels so the salient ones are visible
order = torch.argsort(rtn_ch, descending=True)[:12]
ojs_define(awqChannels = {
"idx": order.tolist(),
"rtn": rtn_ch[order].tolist(),
"awq": awq_ch[order].tolist(),
"salient": salient_ch.tolist(),
})
```
```{ojs}
//| echo: false
awqChannelBars = {
const width = 720, height = 320;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const data = awqChannels.idx.map((id, i) => ({
id, rtn: awqChannels.rtn[i], awq: awqChannels.awq[i],
salient: awqChannels.salient.includes(id),
}));
const m = {top: 40, right: 30, bottom: 55, left: 60};
const iw = width - m.left - m.right, ih = height - m.top - m.bottom;
const xS = d3.scaleBand().domain(data.map((_, i) => i)).range([m.left, width - m.right]).padding(0.3);
const yMax = d3.max(data, d => d.rtn) * 1.05;
const yS = d3.scaleLinear().domain([0, yMax]).range([m.top + ih, m.top]);
svg.append("text").attr("x", 18).attr("y", m.top + ih / 2)
.attr("transform", `rotate(-90 18 ${m.top + ih / 2})`)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "11px").text("per-channel output error");
svg.append("text").attr("x", m.left + iw / 2).attr("y", height - 10)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "12px").text("input channel (12 loudest, ★ = salient)");
data.forEach((d, i) => {
const bx = xS(i), bw = xS.bandwidth() / 2;
// RTN bar
svg.append("rect").attr("x", bx).attr("y", yS(d.rtn))
.attr("width", bw).attr("height", m.top + ih - yS(d.rtn))
.attr("fill", theme.error).attr("fill-opacity", 0.6).attr("rx", 2);
// AWQ bar
svg.append("rect").attr("x", bx + bw).attr("y", yS(d.awq))
.attr("width", bw).attr("height", m.top + ih - yS(d.awq))
.attr("fill", theme.success).attr("rx", 2);
svg.append("text").attr("x", bx + xS.bandwidth() / 2).attr("y", m.top + ih + 16)
.attr("text-anchor", "middle").attr("fill", d.salient ? theme.highlight : theme.nodeText)
.attr("font-size", "10px").attr("font-weight", d.salient ? "700" : "400")
.text((d.salient ? "★" : "") + d.id);
});
// legend
svg.append("rect").attr("x", width - 190).attr("y", 16).attr("width", 12).attr("height", 12)
.attr("fill", theme.error).attr("fill-opacity", 0.6).attr("rx", 2);
svg.append("text").attr("x", width - 174).attr("y", 26).attr("fill", theme.nodeText)
.attr("font-size", "11px").text("RTN");
svg.append("rect").attr("x", width - 120).attr("y", 16).attr("width", 12).attr("height", 12)
.attr("fill", theme.success).attr("rx", 2);
svg.append("text").attr("x", width - 104).attr("y", 26).attr("fill", theme.nodeText)
.attr("font-size", "11px").text("AWQ");
return svg.node();
}
```
AWQ and GPTQ are complementary, not rivals: GPTQ steers on the weight-side Hessian,
AWQ on activation salience, and production quantizers often **stack** them — AWQ's
scaling first, GPTQ's error feedback second. Both take large models to honest int4.
## Activations Fight Back: the W8A8 Problem
Everything so far quantizes a **weight**. But the matmul that actually runs is
$Y = XW$ — an **activation** $X$ times a weight $W$ — and to make that matmul
*fast* on int8 tensor cores you must quantize **both** operands. That is a **W8A8**
GEMM: 8-bit weights *and* 8-bit activations, multiplied as integers and accumulated
in int32. Keep $X$ in fp16 and the tensor core has to convert on the fly; the int8
speedup evaporates.
Here is the catch, and it is the whole reason this section exists. Weights are
**flat** — a well-behaved bell curve you have quantized five different ways already.
Activations are **not**. Run real text through a transformer and a *few input
channels* light up with magnitudes 10–100× everything else, and — crucially — they
are the **same channels for every token**. A single per-tensor int8 scale on $X$ has
to reach that loud channel, so it spends almost all of its 255 codes covering one
column and leaves ordinary channels with a code or two. The activations are, in a
word, **unquantizable** by the plain per-tensor recipe.
You cannot fix this the way you fixed weights. A *per-channel* weight scale folds
cleanly into the matmul (that is the whole `QuantizedLinear` story). But activation
channels run along the **contraction axis** of $XW$ — a per-channel activation scale
does *not* commute through the sum, so you can't just fold it away. This is exactly
the wall `LLM.int8()` hit and answered with slow mixed precision: pull the outlier
channels out into a separate fp16 matmul. SmoothQuant answers it without any mixed
precision at all.
::: {.callout-note}
## Key Insight
Activation outliers are **hard** but **systematic** — the same channels are loud for
every token. Weights are **easy** and have **headroom**. So don't fight the outlier
where it lives; **move it**. Rescale each input channel so some of its magnitude
slides out of the activation and into the weight, using a transform that leaves the
product $XW$ *exactly* unchanged.
:::
### The Math: Migrate the Difficulty
Insert a per-input-channel diagonal $\operatorname{diag}(s)$ and its inverse between
the two operands. Because they cancel, the product is untouched:
$$
Y = X W = \big(X\,\operatorname{diag}(s)^{-1}\big)\big(\operatorname{diag}(s)\,W\big)
= \hat X \,\hat W .
$$
$\hat X = X\,\operatorname{diag}(s)^{-1}$ divides the **columns** of $X$ (input
channels) by $s$; $\hat W = \operatorname{diag}(s)\,W$ multiplies the **rows** of $W$
(same input channels) by $s$. Choose $s$ per channel $j$ to balance how hard each
side is to quantize:
$$
s_j = \frac{\max_t |X_{t,j}|^{\,\alpha}}{\max_i |W_{j,i}|^{\,1-\alpha}}, \qquad \alpha \in [0, 1].
$$
The exponent $\alpha$ is a **migration dial**:
- $\alpha = 1 \Rightarrow s_j = \max_t|X_{t,j}|$: every activation channel is divided
by its own peak, so $\hat X$ flattens to magnitude $1$ everywhere — but $\hat W$
absorbs the entire outlier and may itself become hard.
- $\alpha = 0 \Rightarrow s_j = 1/\max_i|W_{j,i}|$: the weights flatten and the
activations keep all their range — you are back where you started.
- $\alpha = 0.5$ (the default) is the geometric split $s_j = \sqrt{\max|X_j|/\max|W_j|}$:
push *half* the difficulty across. For most layers that is the sweet spot.
And because $s$ is fixed offline (from a calibration pass over a little text), the
$\operatorname{diag}(s)^{-1}$ factor **folds into the previous layer's LayerNorm
weights** — exactly like AWQ's scale folds backward. So at run time there is no extra
op: you ship $\hat W$ and a LayerNorm whose scale already carries $1/s$, and both
operands are plain per-tensor int8.
::: {.callout-note}
## The same trick, a different goal
Look back at AWQ: it uses the *identical* equivalent transform
$X\operatorname{diag}(s)^{-1}\cdot\operatorname{diag}(s)W$. The difference is the
**purpose**. AWQ keeps activations in fp16 (W4A16) and scales to *protect salient
weights*. SmoothQuant scales to make the **activations themselves quantizable**
(W8A8). One algebraic identity, two quantization regimes.
:::
### Step by Step
Watch one smoothing pass on a small layer. Channel 2 is the loud one; drag the
control and see its magnitude slide out of the activations and into the weights while
the product stays put.
```{python}
#| output: false
spec_sq = importlib.util.spec_from_file_location("smoothquant", Path("smoothquant.py").resolve())
sq = importlib.util.module_from_spec(spec_sq)
sys.modules["smoothquant"] = sq
spec_sq.loader.exec_module(sq)
torch.manual_seed(3)
_Xs = torch.randn(8, 6)
_Ws = torch.randn(6, 6)
_Xs[:, 2] *= 40.0 # the loud input channel
_xmax = sq.channel_absmax(_Xs, dim=0)
_wmax = sq.channel_absmax(_Ws, dim=1)
_Xh, _Wh, _s = sq.smooth(_Xs, _Ws, alpha=0.5)
_xmax_after = sq.channel_absmax(_Xh, dim=0)
_wmax_after = sq.channel_absmax(_Wh, dim=1)
_inv = (_Xh @ _Wh - _Xs @ _Ws).abs().max().item()
ojs_define(smoothWalk = {
"actMax": _xmax.tolist(),
"wtMax": _wmax.tolist(),
"scale": _s.tolist(),
"actMaxAfter": _xmax_after.tolist(),
"wtMaxAfter": _wmax_after.tolist(),
"outlier": 2,
"invErr": _inv,
})
```
```{ojs}
//| echo: false
viewof smoothStep = stepControl({min: 0, max: 5, value: 0, label: "Migrate the outlier"})
```
```{ojs}
//| echo: false
smoothWalkChart = {
const width = 760, height = 300;
const theme = diagramTheme;
const d = smoothWalk;
const n = d.actMax.length;
const steps = [
{title: "0 · The layer: one loud activation channel, flat weights",
act: d.actMax, wt: d.wtMax, tag: `channel ${d.outlier} towers over the rest`},
{title: "1 · Read each input channel's max magnitude, |X| and |W|",
act: d.actMax, wt: d.wtMax, tag: `max|X| and max|W| per channel`},
{title: "2 · Smoothing scale sⱼ = √(max|Xⱼ| / max|Wⱼ|) (α = 0.5)",
act: d.actMax, wt: d.wtMax, tag: `s[${d.outlier}] = ${d.scale[d.outlier].toFixed(1)}`},
{title: "3 · Divide activations by s — the outlier channel deflates",
act: d.actMaxAfter, wt: d.wtMax, tag: `X ÷ s`},
{title: "4 · Multiply weights by s — that channel's row inflates to match",
act: d.actMaxAfter, wt: d.wtMaxAfter, tag: `W · s`},
{title: "5 · Product unchanged: X̂Ŵ = XW (both sides now quantizable)",
act: d.actMaxAfter, wt: d.wtMaxAfter, tag: `max|X̂Ŵ − XW| = ${d.invErr.toExponential(1)}`},
];
const st = steps[smoothStep];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
svg.append("text").attr("x", 20).attr("y", 28)
.attr("fill", theme.nodeText).attr("font-size", "14px").attr("font-weight", 600)
.text(st.title);
svg.append("text").attr("x", width - 20).attr("y", 28).attr("text-anchor", "end")
.attr("fill", theme.accent).attr("font-size", "12px").text(st.tag);
const m = {left: 54, right: 20};
const iw = width - m.left - m.right;
const xS = d3.scaleBand().domain(d3.range(n)).range([m.left, width - m.right]).padding(0.28);
const allVals = [...d.actMax, ...d.wtMax, ...d.actMaxAfter, ...d.wtMaxAfter];
const yMax = d3.max(allVals) * 1.05;
// Two stacked bar panels: activations (top), weights (bottom).
const panels = [
{label: "activation max |X|", vals: st.act, y0: 60, h: 96, base: theme.info},
{label: "weight max |W|", vals: st.wt, y0: 190, h: 96, base: theme.success},
];
panels.forEach(p => {
const yS = d3.scaleLinear().domain([0, yMax]).range([0, p.h]);
svg.append("text").attr("x", m.left).attr("y", p.y0 - 8)
.attr("fill", theme.nodeText).attr("font-size", "11px").attr("opacity", 0.8)
.text(p.label);
p.vals.forEach((v, i) => {
const isOut = i === d.outlier;
svg.append("rect")
.attr("x", xS(i)).attr("width", xS.bandwidth())
.attr("y", p.y0 + p.h - yS(v)).attr("height", yS(v))
.attr("fill", isOut ? theme.highlight : p.base)
.attr("fill-opacity", isOut ? 1 : 0.55)
.attr("rx", 2)
.attr("filter", isOut ? "url(#smglow)" : null);
svg.append("text").attr("x", xS(i) + xS.bandwidth() / 2).attr("y", p.y0 + p.h + 13)
.attr("text-anchor", "middle").attr("font-size", "9px")
.attr("fill", isOut ? theme.highlight : theme.nodeText).text("c" + i);
});
});
const defs = svg.append("defs");
const g = defs.append("filter").attr("id", "smglow").attr("x", "-40%").attr("y", "-40%")
.attr("width", "180%").attr("height", "180%");
g.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "b");
const mg = g.append("feMerge");
mg.append("feMergeNode").attr("in", "b");
mg.append("feMergeNode").attr("in", "SourceGraphic");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Follow one channel.** Step to the end and watch channel 2 only: its activation
bar shrinks by $s$ and its weight bar grows by $s$ — the product of the two is
invariant, which is why the output never moves.
2. **Read the invariance tag.** At the last step the max output difference is
~$10^{-6}$: pure float round-off. Smoothing adds *no* error of its own; every bit
of the eventual error comes from the int8 rounding that follows.
:::
## Code: SmoothQuant from Scratch
`smoothquant.py` is short because the idea is one transform. First, the invariance
that makes it safe — for *any* $\alpha$, the smoothed product equals the original:
```{python}
torch.manual_seed(0)
X = torch.randn(32, 16)
W = torch.randn(16, 16)
X[:, 3] *= 100.0 # a persistent ~100× outlier channel
X_hat, W_hat, s = sq.smooth(X, W, alpha=0.5)
print(f"max |X̂Ŵ − XW| = {(X_hat @ W_hat - X @ W).abs().max():.2e} (float round-off only)")
print(f"activation channel 3 max: {sq.channel_absmax(X, 0)[3]:7.1f} → {sq.channel_absmax(X_hat, 0)[3]:6.2f}")
print(f"weight channel 3 max: {sq.channel_absmax(W, 1)[3]:7.2f} → {sq.channel_absmax(W_hat, 1)[3]:6.2f}")
```
The outlier moved out of the activations and into the weights. Now quantize both
operands to per-tensor int8 — naively, then after smoothing — and read the output SNR:
```{python}
ref = X @ W
naive = sq.quantize_w8a8(X, W) # per-tensor int8 both operands
smart = sq.smoothquant_linear(X, W, alpha=0.5) # smooth, then W8A8
naive_snr = q.quantization_error(ref, naive)["snr_db"]
smart_snr = q.quantization_error(ref, smart)["snr_db"]
print(f"naive W8A8 : {naive_snr:6.2f} dB")
print(f"smooth W8A8 : {smart_snr:6.2f} dB (+{smart_snr - naive_snr:.1f} dB)")
```
Roughly **+9–10 dB** — about a bit and a half of accuracy — bought by a per-channel
rescale that costs nothing at run time.
### Drive the Migration Dial
$\alpha$ is a dial, and the picture is a trade. Slide it right and the activation
range collapses while the weight range climbs; the W8A8 output SNR traces a **U**
that peaks where the two are balanced — near $\alpha = 0.5$.
```{python}
#| output: false
_demo = sq.demonstrate_smoothquant(verbose=False)
ojs_define(sqSweep = {
"alphas": [r["alpha"] for r in _demo["sweep"]],
"snr": [r["snr_db"] for r in _demo["sweep"]],
"actRange": [r["act_range"] for r in _demo["sweep"]],
"wtRange": [r["wt_range"] for r in _demo["sweep"]],
"best": _demo["best_alpha"],
"naiveSnr": _demo["naive_snr_db"],
})
```
```{ojs}
//| echo: false
viewof sqAlpha = Inputs.range([0, 1], {value: 0.5, step: 0.1, label: "α (migration strength)"})
```
```{ojs}
//| echo: false
sqDial = {
const width = 760, height = 320;
const theme = diagramTheme;
const d = sqSweep;
const i = Math.round(sqAlpha * 10); // 0.1 steps → index into the sweep
const clampedI = Math.max(0, Math.min(d.alphas.length - 1, i));
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
// Left panel: the range trade at the current α (log scale — ranges span decades).
const lx = 40, lw = 300, ly = 60, lh = 210;
svg.append("text").attr("x", lx).attr("y", 34)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", 600)
.text(`Range trade at α = ${(clampedI / 10).toFixed(1)}`);
const ranges = [
{label: "activation", v: d.actRange[clampedI], c: theme.info},
{label: "weight", v: d.wtRange[clampedI], c: theme.success},
];
const rMax = d3.max([...d.actRange, ...d.wtRange]);
const yLog = d3.scaleLog().domain([0.5, rMax * 1.1]).range([ly + lh, ly]).clamp(true);
const bx = d3.scaleBand().domain(ranges.map(r => r.label)).range([lx + 30, lx + lw]).padding(0.4);
ranges.forEach(r => {
svg.append("rect").attr("x", bx(r.label)).attr("width", bx.bandwidth())
.attr("y", yLog(r.v)).attr("height", ly + lh - yLog(r.v))
.attr("fill", r.c).attr("rx", 3);
svg.append("text").attr("x", bx(r.label) + bx.bandwidth() / 2).attr("y", yLog(r.v) - 6)
.attr("text-anchor", "middle").attr("fill", r.c).attr("font-size", "12px")
.text(r.v.toFixed(1));
svg.append("text").attr("x", bx(r.label) + bx.bandwidth() / 2).attr("y", ly + lh + 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText).attr("font-size", "11px")
.text(r.label);
});
// Right panel: SNR vs α, with the current α marked.
const rx0 = 400, rw = 330, ry = 60, rh = 210;
svg.append("text").attr("x", rx0).attr("y", 34)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", 600)
.text("W8A8 output SNR vs α");
const xS = d3.scaleLinear().domain([0, 1]).range([rx0, rx0 + rw]);
const yS = d3.scaleLinear()
.domain([d3.min(d.snr) - 1, d3.max(d.snr) + 1]).range([ry + rh, ry]);
// axes
svg.append("line").attr("x1", rx0).attr("x2", rx0 + rw).attr("y1", ry + rh).attr("y2", ry + rh)
.attr("stroke", theme.edgeStroke);
[0, 0.5, 1].forEach(a => {
svg.append("text").attr("x", xS(a)).attr("y", ry + rh + 16).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "10px").text(a);
});
const line = d3.line().x((_, k) => xS(d.alphas[k])).y(v => yS(v));
svg.append("path").datum(d.snr).attr("fill", "none")
.attr("stroke", theme.accent).attr("stroke-width", 2).attr("d", line);
d.snr.forEach((v, k) => {
svg.append("circle").attr("cx", xS(d.alphas[k])).attr("cy", yS(v)).attr("r", 3)
.attr("fill", theme.accent);
});
// current α marker
svg.append("circle").attr("cx", xS(clampedI / 10)).attr("cy", yS(d.snr[clampedI]))
.attr("r", 6).attr("fill", theme.highlight).attr("filter", "url(#sqglow)");
svg.append("text").attr("x", xS(clampedI / 10)).attr("y", yS(d.snr[clampedI]) - 12)
.attr("text-anchor", "middle").attr("fill", theme.highlight).attr("font-size", "12px")
.text(`${d.snr[clampedI].toFixed(1)} dB`);
// naive baseline (α that leaves activations loud → the flat dashed line)
svg.append("line").attr("x1", rx0).attr("x2", rx0 + rw)
.attr("y1", yS(d.naiveSnr)).attr("y2", yS(d.naiveSnr))
.attr("stroke", theme.error).attr("stroke-dasharray", "4 3").attr("opacity", 0.7);
svg.append("text").attr("x", rx0 + rw).attr("y", yS(d.naiveSnr) - 5).attr("text-anchor", "end")
.attr("fill", theme.error).attr("font-size", "10px").text("naive (no smoothing)");
const defs = svg.append("defs");
const gg = defs.append("filter").attr("id", "sqglow").attr("x", "-60%").attr("y", "-60%")
.attr("width", "220%").attr("height", "220%");
gg.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "b");
const mg = gg.append("feMerge");
mg.append("feMergeNode").attr("in", "b");
mg.append("feMergeNode").attr("in", "SourceGraphic");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Find the peak.** Sweep $\alpha$ from $0$ to $1$. At $\alpha = 0$ nothing moves and
you sit on the naive baseline; push toward $1$ and you *over*-migrate — the weights
become the outlier and SNR falls again. The best point is in the middle.
2. **Watch the two bars cross.** As $\alpha$ climbs, the activation range dives and the
weight range rises. The output is happiest where neither dominates — the geometric
balance SmoothQuant defaults to.
:::
::: {.callout-warning}
## Per-channel means the *contraction* axis — and only there
SmoothQuant's scale is per **input** channel because that is the one axis you can fold
away: $\operatorname{diag}(s)^{-1}$ merges into the previous LayerNorm, $s$ into this
layer's weights, so smoothing costs nothing at run time. A per-input-channel scale on
the *activations themselves* has no such escape — it sits on the **contraction axis**
$k$, buried inside the $\sum_k$, and cannot be pulled out of the matmul at all. That is
the axis SmoothQuant refuses to quantize directly. The **token** axis is a different
story: you cannot *fold* a per-token scale, but you can *factor* it — and the next
section does exactly that.
:::
## A Scale Per Token: Granularity on the Axis That Survives
Weights in this module climbed a granularity ladder — per-tensor → per-channel →
group — and every rung bought accuracy by keeping a loud channel from setting the
step for the rest. Activations never left the bottom rung: SmoothQuant and fp8 both
quantize $X$ **per-tensor**, one scale for the whole matrix. The callout above is why
the obvious upgrade looked blocked — a per-*channel* activation scale is trapped on the
contraction axis.
But $X$ has shape $(\text{tokens} \times C_\text{in})$, and only *one* of its two axes
is the contraction axis. The **token** axis is an **output** dimension: it indexes the
rows of $Y$, exactly the way an output channel of $W$ indexes the columns. A scale on an
output axis survives the matmul. So the activation granularity that ships is not
per-channel — it is **per-token**.
### Intuition: A Different Outlier, One Axis Over
SmoothQuant's villain was a loud *column* — the same input channel large for **every**
token. There is a second, equally common pattern: a loud *row* — a **token** whose
activations are large across all channels. Attention sinks, the beginning-of-sequence
token, and the "massive activation" tokens transformers park huge norms on all look like
this. Lay $X$ out as a grid and the two outliers are perpendicular:
- SmoothQuant fixed the loud **column** by migrating it into the weights (a
per-**channel** move on the contraction axis).
- A loud **row** is fixed by giving that row its **own** scale — a per-**token** move on
the output axis.
One per-tensor scale is set by the single loudest token, so every quiet token is
quantized on a step far too coarse for it and collapses toward zero. Per-token hands each
row the tightest step *it* can use.
::: {.callout-note}
## Key Insight
**Per-token quantization is per-channel quantization on the other axis.** Per-channel
weights keep the *output-column* axis of $W$ independent; per-token activations keep the
*row* axis of $X$ independent. In code it is the *same* function
(`quantize_per_channel`) with `axis=0` instead of `axis=1`. Both axes are *outer*
dimensions of the GEMM, which is the whole reason the scales factor out.
:::
### The Math: Two Outer Axes, One Outer Product
Per-token symmetric int8 gives row $t$ its own absmax scale:
$$
s^x_t = \frac{\max_j \lvert X_{t,j}\rvert}{2^{b-1}-1}, \qquad
X_{t,j} \approx s^x_t \, X^q_{t,j}.
$$
Pair it with per-channel weights ($s^w_o$ for output column $o$) and watch the two scales
walk straight out of the accumulation:
$$
Y_{t,o} = \sum_k X_{t,k} W_{k,o}
\approx \sum_k \big(s^x_t X^q_{t,k}\big)\big(s^w_o W^q_{k,o}\big)
= s^x_t \, s^w_o \sum_k X^q_{t,k} W^q_{k,o}
= \big(s^x \otimes s^w\big)_{t,o}\,\big(X^q W^q\big)_{t,o}.
$$
The hardware accumulates $\sum_k X^q_{t,k} W^q_{k,o}$ in **int32**, then a single
**outer-product** rescale $s^x \otimes s^w$ — one multiply per output element, *after* the
matmul — turns it back into $Y$. This is LLM.int8()'s **vector-wise** quantization: a
scale per row of $X$, a scale per column of $W$.
Contrast the forbidden move. A per-*channel activation* scale $s^x_k$ would sit **inside**
the sum, $\sum_k s^x_k X^q_{t,k} W^q_{k,o}$, glued to the contraction index $k$; no
factoring pulls it out, so it would have to be applied *during* accumulation and breaks the
int8 GEMM. The token scale $s^x_t$ escapes only because $t$ is an outer index.
::: {.callout-note}
## Key Insight
You could not *fold* a per-token scale offline — row $t$'s range depends on the actual
activation, unknown until you see it. So per-token is **dynamic**: computed at run time from
each row's own absmax. No calibration set, no offline pass — just one cheap max-reduction
per token, and every token keeps its own dynamic range.
:::
### Code: Vector-Wise W8A8 from Scratch
`per_token.py` is thin because per-token quantization reuses the module's own
`quantize_per_channel` — with `axis=0`. First, the scales: a loud token gets its **own**
larger step instead of imposing it on everyone.
```{python}
spec_pt = importlib.util.spec_from_file_location("per_token", Path("per_token.py").resolve())
pt = importlib.util.module_from_spec(spec_pt)
spec_pt.loader.exec_module(pt)
torch.manual_seed(0)
X = torch.randn(24, 16)
W = torch.randn(16, 16)
for r in (3, 11, 19):
X[r] *= 40.0 # a few loud *tokens* (rows)
_, s_tok = pt.quantize_per_token(X) # one scale per row of X
print(f"per-token scale shape: {tuple(s_tok.shape)} (one per token)")
print(f"quiet row 0 scale : {s_tok[0].item():.4f}")
print(f"loud row 3 scale : {s_tok[3].item():.4f} (its own, larger step)")
```
Now the two W8A8 recipes — naive per-tensor vs vector-wise — and their output SNR:
```{python}
ref = X @ W
naive = pt.w8a8_per_tensor(X, W) # one scale each for X and W
vector = pt.w8a8_vector_wise(X, W) # per-token X, per-channel W
naive_snr = q.quantization_error(ref, naive)["snr_db"]
vector_snr = q.quantization_error(ref, vector)["snr_db"]
print(f"naive W8A8 : {naive_snr:6.2f} dB")
print(f"vector-wise W8A8 : {vector_snr:6.2f} dB (+{vector_snr - naive_snr:.1f} dB)")
```
And the factoring is exact — rescaling the int32 accumulation by the outer product equals
dequantizing both operands first:
```{python}
Xq, sx = pt.quantize_per_token(X)
Wq, sw = pt.quantize_weight_per_channel(W)
dequant_first = q.dequantize(Xq, sx) @ q.dequantize(Wq, sw)
print(f"max |vector-wise − dequant-then-matmul| = {(vector - dequant_first).abs().max():.2e}")
print("(the scales pull out of the sum — the identity is algebraic, not approximate)")
```
### Drive the Loud-Token Magnitude
The left panel is the per-row reconstruction error at this layer: per-tensor (one shared
scale) spikes on the **quiet** rows it crushes, while per-token stays flat. The right panel
sweeps how loud the outlier tokens are — slide it and watch per-tensor's output SNR dive
while vector-wise barely moves.
```{python}
#| output: false
_demo = pt.demonstrate_per_token(verbose=False)
ojs_define(ptDemo = {
"loudRows": _demo["loud_rows"],
"errTensor": _demo["err_per_tensor"],
"errToken": _demo["err_per_token"],
"factors": [r["loud_factor"] for r in _demo["sweep"]],
"snrTensor": [r["per_tensor_snr_db"] for r in _demo["sweep"]],
"snrVector": [r["vector_wise_snr_db"] for r in _demo["sweep"]],
})
```
```{ojs}
//| echo: false
viewof ptFactorIdx = Inputs.range([0, 7], {value: 5, step: 1, label: "loud-token magnitude ×"})
```
```{ojs}
//| echo: false
ptDial = {
const width = 760, height = 340;
const theme = diagramTheme;
const d = ptDemo;
const idx = Math.max(0, Math.min(d.factors.length - 1, Math.round(ptFactorIdx)));
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const loud = new Set(d.loudRows);
// Left: per-row reconstruction error (log scale — quiet rows differ by decades).
const lx = 44, lw = 300, ly = 60, lh = 240;
svg.append("text").attr("x", lx).attr("y", 34)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", 600)
.text("Per-row error (log): per-tensor vs per-token");
const n = d.errTensor.length;
const allErr = [...d.errTensor, ...d.errToken].filter(v => v > 0);
const eMin = d3.min(allErr), eMax = d3.max(allErr);
const yE = d3.scaleLog().domain([eMin * 0.5, eMax * 1.5]).range([ly + lh, ly]).clamp(true);
const xR = d3.scaleLinear().domain([-0.5, n - 0.5]).range([lx + 24, lx + lw]);
const bw = (xR(1) - xR(0)) * 0.42;
d.errTensor.forEach((v, t) => {
svg.append("rect").attr("x", xR(t) - bw).attr("width", bw)
.attr("y", yE(Math.max(v, eMin * 0.5))).attr("height", ly + lh - yE(Math.max(v, eMin * 0.5)))
.attr("fill", theme.error).attr("opacity", loud.has(t) ? 0.35 : 0.85);
});
d.errToken.forEach((v, t) => {
svg.append("rect").attr("x", xR(t)).attr("width", bw)
.attr("y", yE(Math.max(v, eMin * 0.5))).attr("height", ly + lh - yE(Math.max(v, eMin * 0.5)))
.attr("fill", theme.success).attr("opacity", 0.9);
});
svg.append("text").attr("x", lx).attr("y", ly + lh + 18)
.attr("fill", theme.mutedText || theme.nodeText).attr("font-size", "10px")
.text("token (row) index →");
// legend
[["per-tensor", theme.error], ["per-token", theme.success]].forEach((L, k) => {
svg.append("rect").attr("x", lx + 150 + k * 90).attr("y", 44).attr("width", 10).attr("height", 10)
.attr("fill", L[1]);
svg.append("text").attr("x", lx + 164 + k * 90).attr("y", 53)
.attr("fill", theme.nodeText).attr("font-size", "10px").text(L[0]);
});
// Right: SNR vs loud-factor sweep, current factor marked.
const rx0 = 400, rw = 330, ry = 60, rh = 240;
svg.append("text").attr("x", rx0).attr("y", 34)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", 600)
.text("Output SNR vs loud-token magnitude");
const xF = d3.scalePoint().domain(d3.range(d.factors.length)).range([rx0 + 10, rx0 + rw]);
const allSnr = [...d.snrTensor, ...d.snrVector];
const yS = d3.scaleLinear().domain([d3.min(allSnr) - 2, d3.max(allSnr) + 2]).range([ry + rh, ry]);
svg.append("line").attr("x1", rx0).attr("x2", rx0 + rw).attr("y1", ry + rh).attr("y2", ry + rh)
.attr("stroke", theme.edgeStroke);
d.factors.forEach((f, k) => {
svg.append("text").attr("x", xF(k)).attr("y", ry + rh + 15).attr("text-anchor", "middle")
.attr("fill", theme.mutedText || theme.nodeText).attr("font-size", "9px").text(`${f}×`);
});
const mkLine = (arr, color, label, dy) => {
const line = d3.line().x((_, k) => xF(k)).y(v => yS(v));
svg.append("path").datum(arr).attr("fill", "none").attr("stroke", color)
.attr("stroke-width", 2).attr("d", line);
arr.forEach((v, k) => svg.append("circle").attr("cx", xF(k)).attr("cy", yS(v)).attr("r", 2.5).attr("fill", color));
svg.append("text").attr("x", rx0 + rw).attr("y", yS(arr[arr.length - 1]) + dy)
.attr("text-anchor", "end").attr("fill", color).attr("font-size", "10px").text(label);
};
mkLine(d.snrTensor, theme.error, "per-tensor", -6);
mkLine(d.snrVector, theme.success, "vector-wise", -6);
// current-factor marker
svg.append("line").attr("x1", xF(idx)).attr("x2", xF(idx)).attr("y1", ry).attr("y2", ry + rh)
.attr("stroke", theme.highlight).attr("stroke-dasharray", "3 3").attr("opacity", 0.7);
[["snrTensor", theme.error], ["snrVector", theme.success]].forEach(([key, c]) => {
svg.append("circle").attr("cx", xF(idx)).attr("cy", yS(d[key][idx])).attr("r", 5).attr("fill", c);
});
svg.append("text").attr("x", xF(idx)).attr("y", ry - 4).attr("text-anchor", "middle")
.attr("fill", theme.highlight).attr("font-size", "11px")
.text(`gap ${(d.snrVector[idx] - d.snrTensor[idx]).toFixed(1)} dB`);
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Push the magnitude right.** At $1\times$ the two curves nearly touch — with no loud
token, per-tensor is already fine. Past $\sim\!20\times$ per-tensor dives while
vector-wise holds flat: that gap *is* the value of per-token granularity.
2. **Read the left bars.** The per-tensor error (red) is worst on the *quiet* rows, not the
loud ones — those are the tokens whose real signal fell below the shared coarse step.
Per-token (green) gives each row a step its own size, so the bars stay level.
:::
### Where the Two Axes Meet
Per-token and SmoothQuant are not rivals — they tame **perpendicular** outliers, so
production stacks use both: SmoothQuant migrates the loud *channels* into the weights
offline, then a **per-token dynamic + per-channel weight** W8A8 GEMM handles whatever
per-*token* range remains at run time. Weights get per-channel (or group); activations get
per-token. Every scale that ships lives on an outer axis — the one place a scale can sit and
still let the int8 tensor cores do their job.
## FP8: The Same Bits, Spent on Range
SmoothQuant paid for a per-channel transform to make **int8** activations survive.
There is another way to pay — change the *grid* instead of the *data*. Keep eight
bits, but arrange the 256 codes as a tiny **floating-point** format (a *minifloat*)
rather than an integer. This is **fp8**, the native 8-bit type of H100-class tensor
cores, and it is Module 07's `precision.py` put to work at inference.
The whole difference is where the codes sit:
- **int8** spaces its codes **uniformly** — a fixed step $\Delta = \text{scale}/127$
everywhere. Constant *absolute* error. But one loud value sets $\Delta$ for the
entire tensor, so every quiet value is left with a handful of codes across the empty
span up to the outlier — and anything below $\Delta/2$ rounds to **zero**.
- **fp8** spaces its codes **exponentially** — dense near zero, coarse for large
values, the step doubling every binade. Constant *relative* error across its normal
range. A quiet value keeps its relative precision no matter how loud its neighbour.
A per-tensor scale over an outlier tensor is exactly the case where constant-relative
beats constant-absolute. Drive a value through both grids and watch what each does with
it:
```{python}
#| output: false
spec_fp8 = importlib.util.spec_from_file_location("fp8_gemm", Path("fp8_gemm.py").resolve())
fg = importlib.util.module_from_spec(spec_fp8)
sys.modules["fp8_gemm"] = fg
spec_fp8.loader.exec_module(fg)
_e4m3_grid = fg.fp8_grid(fg.E4M3)
_peak = _e4m3_grid[-1] # 448 — e4m3's max_normal
_int8_step = _peak / 127.0
_int8_grid = [k * _int8_step for k in range(128)] # a uniform grid at the same peak
ojs_define(
fp8Grid = [v for v in _e4m3_grid if v > 0],
int8Grid = [v for v in _int8_grid if v > 0],
fp8Peak = _peak,
int8Step = _int8_step,
)
```
```{ojs}
//| echo: false
viewof fp8Exp = Inputs.range([-2, 2.65], {value: -0.3, step: 0.05, label: "value (log₁₀)"})
```
```{ojs}
//| echo: false
fp8GridView = {
const width = 760, height = 300;
const theme = diagramTheme;
const v = Math.pow(10, fp8Exp);
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const x = d3.scaleLog().domain([0.02, 448]).range([70, width - 30]).clamp(true);
// snap a value to the nearest point of a grid (0 allowed → "underflow")
const snap = (grid, val) => {
let best = 0, bd = Math.abs(val - 0);
for (const g of grid) { const d = Math.abs(val - g); if (d < bd) { bd = d; best = g; } }
return best;
};
const relErr = (s, val) => val === 0 ? 0 : Math.abs(s - val) / val;
const tracks = [
{label: "fp8 (e4m3) — exponential", grid: fp8Grid, y: 90, c: theme.success},
{label: "int8 — uniform", grid: int8Grid, y: 200, c: theme.info},
];
tracks.forEach(t => {
svg.append("text").attr("x", 70).attr("y", t.y - 34)
.attr("fill", t.c).attr("font-size", "13px").attr("font-weight", 600).text(t.label);
// grid ticks
t.grid.forEach(g => {
if (g < 0.02) return;
svg.append("line").attr("x1", x(g)).attr("x2", x(g)).attr("y1", t.y - 12).attr("y2", t.y + 12)
.attr("stroke", t.c).attr("stroke-width", 0.7).attr("opacity", 0.5);
});
// baseline
svg.append("line").attr("x1", 70).attr("x2", width - 30).attr("y1", t.y).attr("y2", t.y)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1);
// the snapped value
const s = snap(t.grid, v);
const err = relErr(s, v);
if (s >= 0.02) {
svg.append("circle").attr("cx", x(s)).attr("cy", t.y).attr("r", 7)
.attr("fill", theme.highlight).attr("filter", "url(#fp8glow)");
}
svg.append("text").attr("x", width - 30).attr("y", t.y - 20).attr("text-anchor", "end")
.attr("fill", t.c).attr("font-size", "12px")
.text(s < 0.02 ? "→ 0 (underflow, 100% error)"
: `→ ${s.toPrecision(3)} rel err ${(err * 100).toFixed(1)}%`);
});
// the target value line spanning both tracks
svg.append("line").attr("x1", x(v)).attr("x2", x(v)).attr("y1", 60).attr("y2", 224)
.attr("stroke", theme.accent).attr("stroke-width", 1.5).attr("stroke-dasharray", "3 3");
svg.append("text").attr("x", x(v)).attr("y", 52).attr("text-anchor", "middle")
.attr("fill", theme.accent).attr("font-size", "13px").attr("font-weight", 600)
.text(`v = ${v.toPrecision(3)}`);
// x ticks
[0.03, 0.1, 1, 10, 100, 448].forEach(t => {
svg.append("text").attr("x", x(t)).attr("y", 262).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "10px").attr("opacity", 0.7).text(t);
});
svg.append("text").attr("x", (width) / 2).attr("y", 284).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "10px").attr("opacity", 0.6)
.text("magnitude (log scale) — fp8 ticks stay evenly spaced; int8 ticks bunch at the top");
const defs = svg.append("defs");
const gg = defs.append("filter").attr("id", "fp8glow").attr("x", "-60%").attr("y", "-60%")
.attr("width", "220%").attr("height", "220%");
gg.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "b");
const mg = gg.append("feMerge");
mg.append("feMergeNode").attr("in", "b");
mg.append("feMergeNode").attr("in", "SourceGraphic");
return svg.node();
}
```
Slide down toward the quiet channels' scale (near $0.1$) and int8's story falls apart:
its first nonzero code sits at $\Delta \approx `{python} f"{_int8_step:.1f}"`$, so any
value below $\Delta/2$ snaps to **zero** — the signal is simply gone. fp8 keeps a few
percent relative error the whole way down, because its ticks stay evenly spaced in *log*.
::: {.callout-note}
## Key Insight
int8 gives you constant **absolute** precision; fp8 gives you constant **relative**
precision. When a single per-tensor scale must cover a wide dynamic range, absolute
precision is spent almost entirely on the loud end and starves the quiet end — which is
why fp8 tolerates the activation outlier that int8 needs SmoothQuant to remove.
:::
### The Two Formats, and the fp8 GEMM
Module 07 built both fp8 formats; we reuse them, never re-derive them:
| format | exp | mantissa | max | note |
|--------|-----|----------|-----|------|
| **e4m3** | 4 | 3 | 448 | no infinity, one NaN slot — the **forward** format (weights, activations) |
| **e5m2** | 5 | 2 | 57344 | IEEE-like inf + NaN — more range, the **gradient** format |
Both operands of $Y = XW$ get a per-tensor scale that maps their largest magnitude onto
the format max $M$, are rounded to fp8, and the matmul rescales **once** at the end —
because the two scalars factor cleanly out of the sum:
$$
s_X = \frac{M}{\max|X|}, \quad s_W = \frac{M}{\max|W|}, \qquad
Y \;\approx\; \frac{Q(s_X X)\,Q(s_W W)}{s_X\, s_W}
$$
where $Q$ rounds to fp8. That factoring is exactly what fp8 tensor cores do: fp8 inputs,
an fp32 accumulator, a single rescale. `fp8_gemm.py` is that one line.
Now run all three W8A8 paths on the **same** layer — but with the realistic
*massive-activation* shape (Sun et al., 2024): the loud channel is loud yet
**downstream-weak** (its weight row is small — the model has learned to mostly ignore
it), so the signal the output needs lives in the *quiet* channels:
```{python}
torch.manual_seed(0)
Xf = torch.randn(32, 16)
Wf = torch.randn(16, 16)
Xf[:, 3] *= 100.0 # a loud channel...
Wf[3, :] *= 0.01 # ...that barely couples downstream
ref_f = Xf @ Wf
int8_naive = fg.quantize_w8a8_int8(Xf, Wf) # per-tensor int8, no help
int8_smooth = fg.smoothquant_linear(Xf, Wf, alpha=0.5) # int8 after SmoothQuant
fp8_e4m3 = fg.fp8_gemm(Xf, Wf, fg.E4M3) # fp8, no smoothing at all
snr = lambda a: q.quantization_error(ref_f, a)["snr_db"]
print(f"int8 W8A8, naive : {snr(int8_naive):6.2f} dB (loud channel crushes the scale)")
print(f"int8 W8A8 + SmoothQuant : {snr(int8_smooth):6.2f} dB (needs a per-channel transform)")
print(f"fp8 W8A8, e4m3 : {snr(fp8_e4m3):6.2f} dB (no transform — the grid does it)")
```
fp8 rides out the outlier **for free**, landing far above collapsed int8 — though a few
dB short of *smoothed* int8's peak (e4m3 spends only 3 bits on the mantissa, so where
int8 is well-scaled it is genuinely more precise). And the scale-factoring is exact, so
an fp8 GEMM is just two rounded operands and one rescale:
```{python}
s_x = fg.fp8_per_tensor_scale(Xf, fg.E4M3)
s_w = fg.fp8_per_tensor_scale(Wf, fg.E4M3)
factored = fg.fp8_round_trip(Xf, fg.E4M3, s_x) @ fg.fp8_round_trip(Wf, fg.E4M3, s_w)
print(f"max |fp8_gemm − roundtrip(X)@roundtrip(W)| = {(factored - fp8_e4m3).abs().max():.2e}")
```
### Where the Grid Wins — and Where It Doesn't
The honest picture is a *crossover*. With no real outlier, int8's uniform grid is the
more precise of the two — seven effective bits beat three mantissa bits. Grow the outlier
and int8's per-tensor scale is dragged up until the quiet channels vanish, while fp8 holds
flat. Drive the outlier magnitude and watch the two curves swap places:
```{python}
#| output: false
_fp8_demo = fg.demonstrate_fp8_gemm(verbose=False)
ojs_define(fp8Sweep = {
"outlier": [r["outlier"] for r in _fp8_demo["sweep"]],
"int8": [r["int8_snr_db"] for r in _fp8_demo["sweep"]],
"fp8": [r["fp8_snr_db"] for r in _fp8_demo["sweep"]],
})
```
```{ojs}
//| echo: false
viewof fp8OutIdx = Inputs.range([0, fp8Sweep.outlier.length - 1],
{value: 4, step: 1, label: "outlier magnitude (index)"})
```
```{ojs}
//| echo: false
fp8SweepView = {
const width = 760, height = 330;
const theme = diagramTheme;
const d = fp8Sweep;
const i = Math.max(0, Math.min(d.outlier.length - 1, Math.round(fp8OutIdx)));
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
svg.append("text").attr("x", 60).attr("y", 34)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", 600)
.text("W8A8 output SNR vs. outlier magnitude");
const mx = 60, mw = width - 100, my = 60, mh = 200;
const x = d3.scalePoint().domain(d.outlier.map((_, k) => k)).range([mx, mx + mw]).padding(0.5);
const allSnr = [...d.int8, ...d.fp8];
const y = d3.scaleLinear().domain([Math.min(0, d3.min(allSnr) - 2), d3.max(allSnr) + 2])
.range([my + mh, my]);
// zero line
svg.append("line").attr("x1", mx).attr("x2", mx + mw).attr("y1", y(0)).attr("y2", y(0))
.attr("stroke", theme.edgeStroke).attr("opacity", 0.4).attr("stroke-dasharray", "2 3");
// axes labels
d.outlier.forEach((o, k) => {
svg.append("text").attr("x", x(k)).attr("y", my + mh + 18).attr("text-anchor", "middle")
.attr("fill", theme.nodeText).attr("font-size", "10px").attr("opacity", 0.75).text(`${o}×`);
});
[0, 20, 40].forEach(t => {
if (t < y.domain()[1]) {
svg.append("text").attr("x", mx - 8).attr("y", y(t) + 3).attr("text-anchor", "end")
.attr("fill", theme.nodeText).attr("font-size", "10px").attr("opacity", 0.7).text(t);
}
});
const series = [
{vals: d.int8, c: theme.info, name: "int8 (naive)"},
{vals: d.fp8, c: theme.success, name: "fp8 e4m3"},
];
series.forEach((s, si) => {
const line = d3.line().x((_, k) => x(k)).y(v => y(v));
svg.append("path").datum(s.vals).attr("fill", "none").attr("stroke", s.c)
.attr("stroke-width", 2.5).attr("d", line);
s.vals.forEach((v, k) => {
svg.append("circle").attr("cx", x(k)).attr("cy", y(v)).attr("r", k === i ? 6 : 3)
.attr("fill", s.c).attr("filter", k === i ? "url(#fp8sglow)" : null);
});
svg.append("text").attr("x", mx + mw + 4).attr("y", y(s.vals[s.vals.length - 1]) + (si ? 12 : -4))
.attr("fill", s.c).attr("font-size", "11px").attr("font-weight", 600).text(s.name);
});
// current-index readout
svg.append("text").attr("x", 60).attr("y", height - 16)
.attr("fill", theme.nodeText).attr("font-size", "12px")
.text(`at ${d.outlier[i]}× outlier: int8 ${d.int8[i].toFixed(1)} dB · fp8 ${d.fp8[i].toFixed(1)} dB`
+ (d.fp8[i] > d.int8[i] ? " → fp8 wins" : " → int8 wins"));
const defs = svg.append("defs");
const gg = defs.append("filter").attr("id", "fp8sglow").attr("x", "-60%").attr("y", "-60%")
.attr("width", "220%").attr("height", "220%");
gg.append("feGaussianBlur").attr("stdDeviation", 3).attr("result", "b");
const mg = gg.append("feMerge");
mg.append("feMergeNode").attr("in", "b");
mg.append("feMergeNode").attr("in", "SourceGraphic");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Find the crossover.** At $1$–$3\times$ (no real outlier) int8 sits *above* fp8 — its
seven bits of precision beat fp8's three mantissa bits. Push past $\sim10\times$ and int8
dives while fp8 barely moves. That swap is the entire fp8 value proposition in one picture.
2. **Read the grid widget alongside it.** The int8 curve falls off exactly when the quiet
channels drop below its step $\Delta$ and snap to zero — the same underflow you can watch
in the grid explorer above.
:::
::: {.callout-warning}
## fp8 is not a free lunch — and per-tensor is not the whole story
fp8's constant-relative precision holds only in its **normal** range: near zero it degrades
into subnormals, and above $448$ (e4m3) values **saturate** — a truly enormous activation
still clips. And per-tensor fp8 is not universally enough: frontier fp8 *training*
(DeepSeek-V3) keeps a finer **per-tile / per-block** scale precisely because a single scalar
can still lose the tail. The lesson is not "fp8 beats int8" but "fp8 trades peak precision
for dynamic range" — choose the grid that matches your data.
:::
## The Other Half of Memory: the KV Cache
Everything so far shrinks the **weights**. But quantized weights are only half the
story of a *running* model. During autoregressive decode (Module 08) each weight is
read **once per step**, while the **KV cache** — the stored keys and values of every
past token — grows linearly with the sequence and is re-read on **every** step. At
long context and large batch, the KV cache, not the weights, is the memory that runs
you out of GPU.
The size is easy to write down. For one token the cache holds a key and a value
vector in every head of every layer:
$$
\text{KV bytes} = \underbrace{2}_{K,\,V}\times n_\text{layers}\times n_\text{heads}
\times d_\text{head}\times L \times \frac{b}{8}
$$
For a Llama-2-7B-shaped model ($32$ layers, $32$ heads, $d_\text{head}=128$) at
$L=4096$ tokens in fp16, that is **over 2 GB** — for the cache alone, on top of the
weights. Double the context and it doubles. This is the lever quantization has not
pulled yet, and it is the single biggest one for long-context inference.
We build the fix the way **KIVI** does it, and the whole lesson pays off in one
picture: the KV cache forces you to quantize its two halves along **opposite axes**.
### Intuition: Keys and Values Want Opposite Axes
Lay one head's cache out as a matrix of shape (tokens $\times$ channels). The Key
cache and the Value cache look nothing alike:
```{python}
#| output: false
#| echo: false
import torch
import sys
from pathlib import Path
sys.path.insert(0, str(Path.cwd()))
import kv_cache_quant as kvq # noqa: F401 (registers the module for later cells)
torch.manual_seed(0)
_T, _C = 24, 16
_K = torch.randn(_T, _C)
_K[:, 5] = 6.0 + 0.4 * torch.randn(_T) # a persistent outlier channel in K
_V = torch.randn(_T, _C)
ojs_define(kvOutlier = {
"tokens": _T, "channels": _C,
"kMag": _K.abs().tolist(),
"vMag": _V.abs().tolist(),
"outlierCh": 5,
})
```
```{ojs}
//| echo: false
kvHeatmaps = {
const cell = 20, pad = 4;
const cols = kvOutlier.channels, rows = kvOutlier.tokens;
const gw = cols * cell, gh = rows * cell;
const width = 720, height = gh + 96;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const allK = kvOutlier.kMag.flat(), allV = kvOutlier.vMag.flat();
const vmax = Math.max(d3.max(allK), d3.max(allV));
const color = d3.scaleSequential(d3.interpolateInferno).domain([0, vmax]);
function grid(mag, ox, title, note) {
const g = svg.append("g").attr("transform", `translate(${ox}, 56)`);
mag.forEach((row, r) => row.forEach((val, c) => {
g.append("rect").attr("x", c * cell).attr("y", r * cell)
.attr("width", cell - 1).attr("height", cell - 1)
.attr("fill", color(val)).attr("rx", 2);
}));
svg.append("text").attr("x", ox).attr("y", 30)
.attr("fill", theme.nodeText).attr("font-size", "14px").attr("font-weight", "600")
.text(title);
svg.append("text").attr("x", ox).attr("y", 48)
.attr("fill", theme.nodeText).attr("font-size", "11px").attr("opacity", 0.75)
.text(note);
svg.append("text").attr("x", ox + gw / 2).attr("y", 56 + gh + 20)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "11px").text("channels →");
}
grid(kvOutlier.kMag, 40, "Key cache |K|", "one column stays bright — a fixed outlier channel");
grid(kvOutlier.vMag, 400, "Value cache |V|", "no column structure — magnitude is uniform");
return svg.node();
}
```
The Key cache has a few **fixed channels** whose magnitude is far larger than the
rest, and they stay large *across every token* — a bright vertical stripe. (This is
the same emergent-outlier-channel phenomenon that broke naive int8 in `LLM.int8()`,
now in the cache.) The Value cache has no such structure: its magnitude is spread
evenly, with no channel or token standing out.
That single difference dictates the axis. Recall the outlier lesson from the top of
this module: **a scale is only as fine as its widest value.** So put each scale where
it isolates the wide values:
- **Key → per-channel.** Give every *channel* its own scale (take min/max over the
token axis). The one outlier channel gets its own coarse scale; every other channel
keeps a fine one. Quantize the Key *per-token* instead and that outlier sets a
coarse scale for the **whole token**, wrecking the informative channels.
- **Value → per-token.** Give every *token* its own scale (min/max over the channel
axis). There is no outlier channel to isolate, and the attention output is a
weighted **sum over tokens** — a contraction along the token axis — so per-token
error stays confined to a token and averages out under the softmax weights.
::: {.callout-note}
## Key Insight
The Key and the Value cache are quantized along **opposite axes** — per-channel Keys,
per-token Values — and it is not a convention, it is forced by the data: the Key has
persistent outlier *channels* to isolate; the Value does not, and its token-axis
contraction makes per-token error benign.
:::
### The Math: Per-Channel Keys, Per-Token Values
The quantizer itself is the **affine (asymmetric) min/max** scheme from the very top
of this module — the one built for one-sided or offset ranges — now pointed at
*activations* (the cache) instead of weights. Per group of values:
$$
z = \min(X), \qquad s = \frac{\max(X) - \min(X)}{2^{B}-1}, \qquad
Q = \Big\lfloor \tfrac{X - z}{s} \Big\rceil, \qquad \hat X = s\,Q + z
$$
"Per-channel" and "per-token" are just *which axis the min/max is taken over*, and
KIVI adds two practical wrinkles — a **group size** and a **residual window**:
- **Group size $G$.** Instead of one scale over the whole token axis, split it into
groups of $G$ (KIVI uses $G=32$) so a scale stays local. This is the same
block-quantization dial as int4-g128 for weights — here it also means a
per-channel Key scale needs **$G$ tokens to exist** before you can compute it.
- **Residual length $R$.** Keep the most recent $R$ tokens (KIVI uses $R=128$) in
**full precision** — a small fp16 sliding window — and quantize only the older
tokens. This solves the streaming problem the group size creates: tokens arrive one
at a time, but a per-channel scale spans a whole group, so new tokens sit in the fp
residual until a full group has accumulated behind them.
Step through quantizing one cache, group by group:
```{ojs}
//| echo: false
viewof kvqStep = stepControl({min: 0, max: 4, value: 0, label: "Step"})
```
```{ojs}
//| echo: false
kvqSteps = [
{title: "The cache", caption: "One head's cache: rows are tokens, columns are channels. The last R tokens (green) stay full precision — the residual window."},
{title: "Pick the axis", caption: "Keys quantize per-channel (a scale down each column); Values per-token (a scale across each row). Opposite axes."},
{title: "Group + measure", caption: "Split the reduced axis into groups of G. For each group take z = min, s = (max−min)/(2ᴮ−1)."},
{title: "Round to integers", caption: "Q = round((X − z)/s). Two bits is four levels — but each group's scale keeps the error local."},
{title: "Dequantize on read", caption: "X̂ = s·Q + z reconstructs the cache for the attention matmul. Residual tokens come back exactly."},
]
```
```{ojs}
//| echo: false
kvqDiagram = {
const width = 720, height = 380;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const rows = 10, cols = 8, G = 3, R = 3, cell = 26;
const ox = 60, oy = 70;
const step = kvqStep;
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
const isResidual = r >= rows - R;
let fill = theme.nodeFill, stroke = theme.nodeStroke, op = 1;
if (step >= 1 && !isResidual) {
// axis highlight: per-channel (column 2) for keys shown as a lit column
if (c === 2) { fill = theme.highlight; op = 0.85; }
}
if (step >= 2 && !isResidual) {
const grp = Math.floor(r / G);
if (grp % 2 === 1) op = 0.5; // alternate groups shaded
}
if (step >= 1 && isResidual) { fill = theme.success; op = 0.45; }
if (step >= 3 && !isResidual && c === 2) { stroke = theme.accent; }
svg.append("rect")
.attr("x", ox + c * cell).attr("y", oy + r * cell)
.attr("width", cell - 2).attr("height", cell - 2)
.attr("fill", fill).attr("fill-opacity", op)
.attr("stroke", stroke).attr("stroke-width", (step >= 3 && c === 2 && !isResidual) ? 2 : 1)
.attr("rx", 3)
.attr("filter", (step >= 3 && c === 2 && r < rows - R) ? theme.highlightGlow : null);
}
}
// axis labels
svg.append("text").attr("x", ox - 12).attr("y", oy + rows * cell / 2)
.attr("transform", `rotate(-90 ${ox - 12} ${oy + rows * cell / 2})`)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "12px").text("tokens →");
svg.append("text").attr("x", ox + cols * cell / 2).attr("y", oy - 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "12px").text("channels →");
// per-channel scale bracket appears at step 1+
if (step >= 1) {
svg.append("text").attr("x", ox + 2 * cell + cell / 2).attr("y", oy + rows * cell + 22)
.attr("text-anchor", "middle").attr("fill", theme.highlight)
.attr("font-size", "11px").attr("font-weight", "600").text("one scale / channel (Key)");
}
if (step >= 1) {
svg.append("text").attr("x", ox + cols * cell + 16).attr("y", oy + (rows - 1) * cell + cell / 2)
.attr("fill", theme.success).attr("font-size", "11px").attr("font-weight", "600")
.text("residual (fp16)");
}
// caption
const s = kvqSteps[step];
svg.append("text").attr("x", width / 2).attr("y", 34)
.attr("text-anchor", "middle").attr("fill", theme.accent)
.attr("font-size", "14px").attr("font-weight", "700").text(`${step + 1}. ${s.title}`);
const words = s.caption.split(" ");
let line = "", ly = height - 46;
const lines = [];
words.forEach(w => {
if ((line + w).length > 84) { lines.push(line); line = ""; }
line += w + " ";
});
lines.push(line);
lines.forEach((l, i) => svg.append("text")
.attr("x", width / 2).attr("y", ly + i * 16)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "12px").text(l));
return svg.node();
}
```
### Code: Quantize the KV Cache from Scratch
Everything lives in `kv_cache_quant.py`. The one primitive is `quantize_along` —
asymmetric group-wise quantization along a chosen axis — and Keys and Values differ
*only in which axis they reduce over*:
```{python}
import torch
import kv_cache_quant as kvq
torch.manual_seed(0)
T, C = 256, 64
K = torch.randn(T, C)
K[:, 7] = 40.0 + 0.5 * torch.randn(T) # a persistent outlier channel
V = torch.randn(T, C)
qk = kvq.quantize_key_cache(K, num_bits=2, group_size=32) # per-channel
qv = kvq.quantize_value_cache(V, num_bits=2, group_size=32) # per-token
print(f"Key scale shape {tuple(qk.scale.shape)} -> one per channel, grouped over tokens")
print(f"Value scale shape {tuple(qv.scale.shape)} -> one per token, grouped over channels")
```
The axis is not cosmetic. Quantize the Key the *wrong* way — per-token, like a Value
— and the outlier channel sets a coarse scale for every token, destroying the
informative channels. Measure the reconstruction error on the non-outlier channels:
```{python}
per_channel = kvq.dequantize_along(kvq.quantize_key_cache(K, num_bits=2, group_size=32))
per_token = kvq.dequantize_along(kvq.quantize_value_cache(K, num_bits=2, group_size=32))
mask = torch.ones(C, dtype=torch.bool); mask[7] = False # informative channels only
err_pc = (K[:, mask] - per_channel[:, mask]).pow(2).mean()
err_pt = (K[:, mask] - per_token[:, mask]).pow(2).mean()
print(f"per-channel (correct) MSE on informative channels: {err_pc:.4f}")
print(f"per-token (wrong) MSE on informative channels: {err_pt:.4f}")
print(f"the wrong axis is {err_pt / err_pc:.1f}x worse")
```
What ultimately matters is the **attention output**. `attention_output_error` runs one
attention step against the true cache and against the 2-bit cache, both ways round:
```{python}
query = torch.randn(C)
right = kvq.attention_output_error(query, K, V, num_bits=2, group_size=32)
wrong = kvq.attention_output_error(query, K, V, num_bits=2, group_size=32, swap_axes=True)
print(f"per-channel K / per-token V (KIVI): {right['snr_db']:5.2f} dB SNR")
print(f"swapped axes (wrong): {wrong['snr_db']:5.2f} dB SNR")
```
Finally, `QuantizedKVCache` is the streaming object: it keeps the last
`residual_length` tokens in fp16 and quantizes the rest in groups as they age out —
so the recent, most-attended tokens are always exact.
```{python}
cache = kvq.QuantizedKVCache(num_bits=2, group_size=32, residual_length=128)
true_k = []
for _ in range(600):
k, v = torch.randn(C), torch.randn(C)
true_k.append(k)
cache.append(k, v)
true_k = torch.stack(true_k)
recent_exact = torch.allclose(cache.keys()[-128:], true_k[-128:], atol=1e-6)
oldest_lossy = not torch.allclose(cache.keys()[0], true_k[0], atol=1e-4)
print(f"tokens cached: {cache.n_tokens}, key cache shape: {tuple(cache.keys().shape)}")
print(f"last 128 tokens bit-exact (residual): {recent_exact}")
print(f"oldest token quantized (lossy): {oldest_lossy}")
```
### Interactive: The Memory–Error Dial
Drive the two knobs quantization gives you on the cache — the **bit width** and the
**residual length** — and watch the two things they trade: how much GPU the cache
costs, and how much the attention output degrades. The bit width sets the error; the
residual buys back exactness on recent tokens at a memory cost.
```{python}
#| output: false
#| echo: false
_bits = [16, 8, 4, 3, 2]
_res = [0, 128, 512]
torch.manual_seed(0)
_Kd = torch.randn(256, 64); _Kd[:, 7] = 40.0 + 0.5 * torch.randn(256)
_Vd = torch.randn(256, 64); _qd = torch.randn(64)
_snr, _snr_wrong = [], []
for b in _bits:
_snr.append(kvq.attention_output_error(_qd, _Kd, _Vd, num_bits=b, group_size=32)["snr_db"])
_snr_wrong.append(
kvq.attention_output_error(_qd, _Kd, _Vd, num_bits=b, group_size=32, swap_axes=True)["snr_db"]
)
# memory (GB) for a Llama-2-7B-shaped model @ 4096 ctx, 32 layers
_mem = [[kvq.kv_cache_bytes(4096, 32, 128, num_bits=b, residual_length=r,
n_layers=32) / 1e9 for r in _res] for b in _bits]
_fp16 = kvq.kv_cache_bytes(4096, 32, 128, num_bits=16, n_layers=32) / 1e9
ojs_define(kvDial = {
"bits": _bits, "res": _res, "snr": _snr, "snrWrong": _snr_wrong,
"mem": _mem, "fp16": _fp16,
})
```
```{ojs}
//| echo: false
viewof kvBits = Inputs.select(kvDial.bits, {value: 2, label: "bit width B"})
```
```{ojs}
//| echo: false
viewof kvRes = Inputs.select(kvDial.res, {value: 128, label: "residual length R (fp16 tokens)"})
```
```{ojs}
//| echo: false
kvDialChart = {
const width = 720, height = 260;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const bi = kvDial.bits.indexOf(kvBits);
const ri = kvDial.res.indexOf(kvRes);
const mem = kvDial.mem[bi][ri];
const snr = kvDial.snr[bi];
const snrW = kvDial.snrWrong[bi];
// memory bar (relative to fp16)
const barX = 40, barY = 70, barW = 640, barH = 34;
svg.append("text").attr("x", barX).attr("y", 50)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", "600")
.text("KV cache memory (Llama-2-7B shape, 4096 ctx, 32 layers)");
svg.append("rect").attr("x", barX).attr("y", barY).attr("width", barW).attr("height", barH)
.attr("fill", theme.nodeStroke).attr("fill-opacity", 0.18).attr("rx", 6);
const frac = Math.min(1, mem / kvDial.fp16);
svg.append("rect").attr("x", barX).attr("y", barY).attr("width", barW * frac).attr("height", barH)
.attr("fill", theme.accent).attr("rx", 6);
svg.append("text").attr("x", barX + barW * frac + 10).attr("y", barY + 23)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", "700")
.text(`${mem.toFixed(2)} GB (${(kvDial.fp16 / mem).toFixed(1)}x smaller than fp16)`);
// SNR readout
const sy = 165;
svg.append("text").attr("x", barX).attr("y", sy)
.attr("fill", theme.nodeText).attr("font-size", "13px").attr("font-weight", "600")
.text("attention-output quality (Key has an outlier channel)");
const good = snr > 10 ? theme.success : (snr > 3 ? theme.highlight : theme.error);
svg.append("text").attr("x", barX).attr("y", sy + 30)
.attr("fill", good).attr("font-size", "22px").attr("font-weight", "800")
.text(`${snr.toFixed(1)} dB`);
svg.append("text").attr("x", barX + 130).attr("y", sy + 30)
.attr("fill", theme.nodeText).attr("font-size", "12px").attr("opacity", 0.8)
.text("per-channel K / per-token V");
svg.append("text").attr("x", barX).attr("y", sy + 56)
.attr("fill", theme.error).attr("font-size", "13px").attr("font-weight", "600")
.text(`${snrW.toFixed(1)} dB`);
svg.append("text").attr("x", barX + 130).attr("y", sy + 56)
.attr("fill", theme.nodeText).attr("font-size", "12px").attr("opacity", 0.8)
.text("swapped axes — the wrong choice");
return svg.node();
}
```
::: {.callout-tip}
## Try This
1. **Ride the bit width down.** Start at $B=16$ (the fp16 baseline: perfect output,
full memory) and step to $2$. Memory collapses ~$5\times$ while the *correct*-axis
output stays usable — and the *swapped*-axis line falls apart much faster.
2. **Buy back the recent tokens.** Raise $R$ from $0$ to $512$. Memory creeps up
because those tokens are fp16 again — the price of keeping the most-attended tokens
exact. KIVI's default $R=128$ is the knee of that trade.
3. **Delete the outlier.** In the bridge cell, drop the `_Kd[:, 7] = ...` line. With
no outlier channel the two SNR lines converge — the axis only matters *because* the
Key has outlier channels.
:::
## Microscaling (MX): The Format the Hardware Speaks
So far every scheme in this module has stored **integers** and paid for a
**floating-point scale** to undo the rounding — int4 weights with an fp16 group
scale, a KV cache with an fp16 min/max per group. Two of those choices are
quietly expensive. The scale is a 16-bit float even though all it ever does is
*stretch* a block, and the elements are integers even though a trained weight is
really a float with a wide dynamic range.
**Microscaling (MX)** — an Open Compute Project standard (2023) that NVIDIA's
Blackwell tensor cores run natively — flips both. It fuses the two ideas this
module already built:
- the **fp8 minifloats** of [Module 07](../m07_training/lesson.qmd#going-to-8-bits-fp8-and-per-tensor-scaling)
(a number is a tiny float, not an integer), and
- the **block scale** of the group-wise section above (one scale per block of 32).
An MX block is just those together:
$$
\underbrace{P_0, P_1, \dots, P_{31}}_{\text{32 tiny floats (E2M1 / E2M3 / E4M3)}}
\quad + \quad
\underbrace{X}_{\text{one shared scale (E8M0)}},
\qquad
v_i = X \cdot P_i .
$$
The magic is in what the shared scale *is*.
### The Math: An E8M0 Scale and an E2M1 Element
**The scale $X$ is E8M0 — eight bits, all exponent.** No sign, no mantissa. It
encodes exactly one power of two, $X = 2^{\,e-127}$ for $e \in \{0,\dots,254\}$
(bias 127), with $e = 255$ reserved for NaN. Because it is a pure power of two,
**multiplying by $X$ never rounds anything** — it only shifts the binary point.
Its entire job is to slide a block into the element format's range. That is why
one 8-bit exponent can replace a 16-bit fp16 scale with *no* loss: an fp16 scale
spends 10 mantissa bits it did not need.
**The element is a tiny float.** MXFP4 uses **E2M1**: 1 sign, 2 exponent, 1
mantissa bit. Its whole non-negative vocabulary is eight numbers:
$$
\text{E2M1} = \{\,0,\ 0.5,\ 1,\ 1.5,\ 2,\ 3,\ 4,\ 6\,\}
$$
— note the widening gaps ($0.5$ steps near zero, then $1$, then $2$): a float
spends its precision where the values are, unlike an integer's even ladder. There
are no infinities and no NaNs; all sixteen bit patterns are finite numbers, and
the largest is $6.0$. (MXFP6 uses E2M3/E3M2; MXFP8 reuses m07's E4M3/E5M2.)
**Quantizing a block** is the OCP *Algorithm 1* — line the loudest value up with
the top of the element range, then snap:
$$
\text{shared\_exp} = \Big\lfloor \log_2 \max_i |v_i| \Big\rfloor - e_{\max}^{\text{elem}},
\qquad
X = 2^{\text{shared\_exp}},
\qquad
P_i = \operatorname{round\_to\_grid}\!\left(\frac{v_i}{X}\right),
$$
where $e_{\max}^{\text{elem}} = 2$ for E2M1 (its largest normal exponent). Drive a
value onto the E2M1 grid below and watch it snap — the resolution is dense near
zero and coarse out at $6$.
```{python}
#| output: false
spec_mx = importlib.util.spec_from_file_location("mx", Path("mx.py").resolve())
mx = importlib.util.module_from_spec(spec_mx)
sys.modules["mx"] = mx
spec_mx.loader.exec_module(mx)
_grid = mx.element_grid(2, 1) # the E2M1 vocabulary
_probes = [0.3, 0.7, 1.7, 2.6, 4.9, 9.0] # values to snap, one per step
_snapped = [mx.round_to_grid(v, _grid) for v in _probes]
ojs_define(mxGridData = {
"grid": _grid,
"probes": _probes,
"snapped": _snapped,
})
```
```{ojs}
//| echo: false
viewof mxGridStep = stepControl({min: 0, max: 5, value: 2, label: "Snap a value"})
```
```{ojs}
//| echo: false
mxNumberLine = {
const width = 760, height = 168;
const margin = { top: 58, right: 34, bottom: 40, left: 34 };
const theme = diagramTheme;
const grid = mxGridData.grid;
const probe = mxGridData.probes[mxGridStep];
const snap = mxGridData.snapped[mxGridStep];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const x = d3.scaleLinear().domain([0, 6.4]).range([margin.left, width - margin.right]);
const yb = height - margin.bottom;
// Baseline axis.
svg.append("line").attr("x1", margin.left).attr("x2", width - margin.right)
.attr("y1", yb).attr("y2", yb).attr("stroke", theme.edgeStroke).attr("stroke-width", 1.5);
// Grid ticks + labels — the eight E2M1 values.
const gt = svg.selectAll("g.tick").data(grid).join("g").attr("class", "tick");
gt.append("line")
.attr("x1", d => x(d)).attr("x2", d => x(d))
.attr("y1", yb - 9).attr("y2", yb + 9)
.attr("stroke", d => d === snap ? theme.accent : theme.nodeStroke)
.attr("stroke-width", d => d === snap ? 3 : 1.5);
gt.append("text")
.attr("x", d => x(d)).attr("y", yb + 26).attr("text-anchor", "middle")
.attr("font-size", "11px")
.attr("fill", d => d === snap ? theme.accent : theme.nodeText)
.text(d => d);
// The probe value (clamped to the drawable range for its marker).
const px = x(Math.min(probe, 6.3));
svg.append("line").attr("x1", px).attr("x2", px)
.attr("y1", margin.top - 8).attr("y2", yb)
.attr("stroke", theme.error).attr("stroke-width", 2).attr("stroke-dasharray", "4 3");
svg.append("circle").attr("cx", px).attr("cy", margin.top - 8).attr("r", 5)
.attr("fill", theme.error);
svg.append("text").attr("x", px).attr("y", margin.top - 18).attr("text-anchor", "middle")
.attr("font-size", "12px").attr("font-weight", 700).attr("fill", theme.error)
.text(`v = ${probe}`);
// Snap arrow from probe to grid point.
const sx = x(snap);
svg.append("path")
.attr("d", `M ${px} ${margin.top + 14} Q ${(px + sx) / 2} ${margin.top - 2} ${sx} ${yb - 12}`)
.attr("fill", "none").attr("stroke", theme.accent).attr("stroke-width", 2)
.attr("marker-end", "url(#mxarrow)");
svg.append("defs").append("marker").attr("id", "mxarrow")
.attr("viewBox", "0 0 10 10").attr("refX", 8).attr("refY", 5)
.attr("markerWidth", 6).attr("markerHeight", 6).attr("orient", "auto")
.append("path").attr("d", "M 0 0 L 10 5 L 0 10 z").attr("fill", theme.accent);
svg.append("text").attr("x", margin.left).attr("y", 26)
.attr("font-size", "14px").attr("font-weight", 700).attr("fill", theme.nodeText)
.text("E2M1 grid — the whole MXFP4 vocabulary");
svg.append("text").attr("x", width - margin.right).attr("y", 26)
.attr("text-anchor", "end").attr("font-size", "13px").attr("fill", theme.accent)
.text(probe > 6 ? `${probe} → clamps to 6` : `${probe} → ${snap}`);
return svg.node();
}
```
::: {.callout-note}
## Key Insight
An integer grid is evenly spaced; a float grid is **dense near zero and coarse
far out**. That is exactly the shape a trained weight wants — most weights are
small, a few are large — which is why 4-bit *floats* often beat 4-bit *integers*
for the same bit budget once each block gets its own power-of-two exponent.
:::
### Code: MX from Scratch
`mx.py` builds the whole format. First the element vocabulary, enumerated
directly from the bit layout — no lookup table:
```{python}
print("E2M1 grid:", mx.element_grid(2, 1)) # MXFP4
print("E2M3 max: ", mx.element_grid(2, 3)[-1]) # MXFP6
print("E4M3 max: ", mx.element_grid(4, 3)[-1]) # MXFP8 element
```
Now quantize one real block. These weights are tiny (~$10^{-2}$) — far below
E2M1's smallest step of $0.5$ — so casting them straight to fp4 would round
almost everything to zero. The shared exponent rescues them:
```{python}
torch.manual_seed(0)
block = torch.randn(32) * 0.015
block[7] = 0.3 # the block's loudest value
fmt = mx.MX_FORMATS["mxfp4"]
X, P = mx.quantize_mx(block, fmt)
print(f"shared exponent : 2^{int(round(__import__('math').log2(X)))} → X = {X}")
print(f"element values : {sorted(set(P))[:6]} ... (all on the E2M1 grid)")
recon = torch.tensor(mx.dequantize_mx(X, P))
mx_snr = q.quantization_error(block, recon)["snr_db"]
# Baseline: no block scale (X = 1), snap straight to E2M1.
naive = torch.tensor([mx.round_to_grid(v, mx.element_grid(2, 1)) for v in block.tolist()])
naive_snr = q.quantization_error(block, naive)["snr_db"]
print(f"\nMXFP4 (shared E8M0 exponent) : {mx_snr:6.2f} dB")
print(f"fp4 with no block scale : {naive_snr:6.2f} dB")
```
The shared power-of-two exponent buys roughly **+9 dB** on this block — for the
price of a single 8-bit number shared across all 32 elements.
Watch the algorithm run, step by step, on that same block:
```{python}
#| output: false
_amax = block.abs().max().item()
_sexp = mx.shared_exponent(block, fmt)
_X = mx.e8m0_scale(_sexp)
_scaled = (block / _X).tolist()
_snap = [mx.round_to_grid(v, mx.element_grid(2, 1)) for v in _scaled]
_recon = mx.dequantize_mx(_X, _snap)
ojs_define(mxBlock = {
"values": block.tolist(),
"amax": _amax,
"emax": 2,
"sharedExp": _sexp,
"scale": _X,
"scaled": _scaled,
"snapped": _snap,
"recon": _recon,
"gridMax": 6.0,
})
```
```{ojs}
//| echo: false
viewof mxBlockStep = stepControl({min: 0, max: 5, value: 0, label: "Run Algorithm 1"})
```
```{ojs}
//| echo: false
mxBlockChart = {
const width = 760, height = 264;
const margin = { top: 64, right: 20, bottom: 30, left: 20 };
const theme = diagramTheme;
const b = mxBlock;
const n = b.values.length;
const steps = [
{ title: "1 · Find the block's largest magnitude (amax)", show: "values", tag: `amax = ${b.amax.toFixed(3)}` },
{ title: "2 · Shared exponent = ⌊log₂ amax⌋ − eₘₐₓ", show: "values", tag: `⌊log₂ ${b.amax.toFixed(3)}⌋ − ${b.emax} = ${b.sharedExp}` },
{ title: "3 · The E8M0 scale is a pure power of two", show: "values", tag: `X = 2^${b.sharedExp} = ${b.scale}` },
{ title: "4 · Divide the block by X (only shifts the exponent)", show: "scaled", tag: `values ÷ ${b.scale}` },
{ title: "5 · Snap each scaled value onto the E2M1 grid", show: "snapped", tag: `→ {0, .5, 1, 1.5, 2, 3, 4, 6}` },
{ title: "6 · Reconstruct: vᵢ = X · Pᵢ", show: "recon", tag: `× ${b.scale}` },
];
const st = steps[mxBlockStep];
const data = b[st.show];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const iw = width - margin.left - margin.right;
const cw = iw / n;
const domainMax = (st.show === "scaled" || st.show === "snapped") ? b.gridMax : b.amax;
const y0 = margin.top + 150;
const yScale = d3.scaleLinear().domain([0, domainMax]).range([0, 130]);
// Grid guide lines when we are in scaled/snapped space.
if (st.show === "scaled" || st.show === "snapped") {
for (const gv of [0.5, 1, 1.5, 2, 3, 4, 6]) {
svg.append("line").attr("x1", margin.left).attr("x2", width - margin.right)
.attr("y1", y0 - yScale(gv)).attr("y2", y0 - yScale(gv))
.attr("stroke", theme.edgeStroke).attr("stroke-width", 0.5).attr("opacity", 0.5);
}
}
// Magnitude bars.
svg.selectAll("rect.v").data(data).join("rect")
.attr("class", "v")
.attr("x", (d, i) => margin.left + i * cw + 0.5)
.attr("width", Math.max(0.8, cw - 1.4))
.attr("y", d => y0 - yScale(Math.abs(d)))
.attr("height", d => yScale(Math.abs(d)))
.attr("fill", (d) => Math.abs(d) >= domainMax * 0.999 ? theme.error : theme.nodeStroke)
.attr("opacity", 0.9);
svg.append("line").attr("x1", margin.left).attr("x2", width - margin.right)
.attr("y1", y0).attr("y2", y0).attr("stroke", theme.edgeStroke).attr("stroke-width", 1);
// amax marker for the first three steps.
if (mxBlockStep <= 2) {
svg.append("line").attr("x1", margin.left).attr("x2", width - margin.right)
.attr("y1", y0 - yScale(b.amax)).attr("y2", y0 - yScale(b.amax))
.attr("stroke", theme.error).attr("stroke-width", 1.5).attr("stroke-dasharray", "5 3");
}
svg.append("text").attr("x", margin.left).attr("y", 26)
.attr("font-size", "14px").attr("font-weight", 700).attr("fill", theme.nodeText)
.text(st.title);
svg.append("text").attr("x", margin.left).attr("y", 48)
.attr("font-size", "13px").attr("fill", theme.accent).text(st.tag);
return svg.node();
}
```
### The Bit Budget: Why 4.25 Bits
The whole trade is one number. An E8M0 scale is 8 bits, shared across a block of
32, so every value costs its element size plus $8/32 = 0.25$ bits:
$$
\text{bits}_{\text{MXFP4}} = 4 + \frac{8}{32} = 4.25,
\qquad
\text{bits}_{\text{MXFP8}} = 8 + \frac{8}{32} = 8.25 .
$$
Compare that to the group-wise section's int4 block with an **fp16** scale at the
same block of 32: $4 + 16/32 = 4.5$ bits. MX is **cheaper per block** *and* its
scale is exact. Drive the block size below and watch the two levers move — a
smaller block fits each shared exponent more tightly (higher SNR) but pays more
scale overhead per value (more bits). MXFP4 at block 32 sits right at the knee.
```{python}
#| output: false
torch.manual_seed(3)
_w = torch.randn(256) * 0.05
_w[100] = 1.4 # one loud weight
_sizes = [1, 2, 4, 8, 16, 32, 64, 128]
_curve = {"sizes": _sizes, "snr4": [], "snr8": [], "bits4": [], "bits8": []}
for s in _sizes:
f4 = mx.MXFormat("mxfp4", 2, 1, block_size=s)
f8 = mx.MXFormat("mxfp8", 4, 3, block_size=s)
r4 = mx.mx_quantize_dequantize(_w, f4)
r8 = mx.mx_quantize_dequantize(_w, f8)
_curve["snr4"].append(q.quantization_error(_w, r4)["snr_db"])
_curve["snr8"].append(q.quantization_error(_w, r8)["snr_db"])
_curve["bits4"].append(mx.mx_effective_bits(f4))
_curve["bits8"].append(mx.mx_effective_bits(f8))
ojs_define(mxDial = _curve)
```
```{ojs}
//| echo: false
viewof mxDialStep = stepControl({min: 0, max: 7, value: 5, label: "Block size (1→128)"})
```
```{ojs}
//| echo: false
mxDialChart = {
const width = 760, height = 300;
const margin = { top: 56, right: 62, bottom: 48, left: 56 };
const theme = diagramTheme;
const d = mxDial;
const idx = mxDialStep;
const size = d.sizes[idx];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const x = d3.scalePoint().domain(d3.range(d.sizes.length)).range([margin.left, width - margin.right]);
const allSnr = d.snr4.concat(d.snr8);
const yS = d3.scaleLinear().domain([d3.min(allSnr) - 2, d3.max(allSnr) + 2]).range([height - margin.bottom, margin.top]);
// x ticks (block sizes).
d.sizes.forEach((s, i) => {
svg.append("text").attr("x", x(i)).attr("y", height - margin.bottom + 20)
.attr("text-anchor", "middle").attr("font-size", "10px")
.attr("fill", i === idx ? theme.accent : theme.nodeText).text(s);
});
svg.append("text").attr("x", (margin.left + width - margin.right) / 2).attr("y", height - 10)
.attr("text-anchor", "middle").attr("font-size", "11px").attr("fill", theme.nodeText)
.text("block size (elements per shared E8M0 scale)");
// active block-size guide.
svg.append("line").attr("x1", x(idx)).attr("x2", x(idx))
.attr("y1", margin.top).attr("y2", height - margin.bottom)
.attr("stroke", theme.highlight).attr("stroke-width", 1).attr("opacity", 0.5);
const line = key => d3.line().x((_, i) => x(i)).y(v => yS(v))(d[key]);
const series = [
{ key: "snr4", color: theme.accent, label: "MXFP4" },
{ key: "snr8", color: theme.nodeStroke, label: "MXFP8" },
];
for (const s of series) {
svg.append("path").attr("d", line(s.key)).attr("fill", "none")
.attr("stroke", s.color).attr("stroke-width", 2.5).attr("opacity", 0.9);
svg.selectAll(`circle.${s.key}`).data(d[s.key]).join("circle")
.attr("class", s.key)
.attr("cx", (_, i) => x(i)).attr("cy", v => yS(v)).attr("r", (_, i) => i === idx ? 5 : 3)
.attr("fill", s.color);
svg.append("text").attr("x", x(d.sizes.length - 1) + 8).attr("y", yS(d[s.key][d.sizes.length - 1]))
.attr("font-size", "11px").attr("fill", s.color).attr("dy", "0.32em").text(s.label);
}
// Readout of the active point.
svg.append("text").attr("x", margin.left).attr("y", 26)
.attr("font-size", "14px").attr("font-weight", 700).attr("fill", theme.nodeText)
.text(`block size = ${size}`);
svg.append("text").attr("x", width - margin.right).attr("y", 26)
.attr("text-anchor", "end").attr("font-size", "12px").attr("fill", theme.accent)
.text(`MXFP4: ${d.snr4[idx].toFixed(1)} dB · ${d.bits4[idx].toFixed(3)} bpw`);
svg.append("text").attr("x", width - margin.right).attr("y", 44)
.attr("text-anchor", "end").attr("font-size", "12px").attr("fill", theme.nodeText)
.text(`MXFP8: ${d.snr8[idx].toFixed(1)} dB · ${d.bits8[idx].toFixed(3)} bpw`);
// y label.
svg.append("text").attr("transform", `translate(16, ${(margin.top + height - margin.bottom) / 2}) rotate(-90)`)
.attr("text-anchor", "middle").attr("font-size", "11px").attr("fill", theme.nodeText)
.text("reconstruction SNR (dB)");
return svg.node();
}
```
::: {.callout-tip}
## Try This
Step the block size from $128$ down to $1$. The SNR climbs the whole way — a
smaller block means each shared exponent hugs its own values more tightly — but
so does the bit cost, from $4.06$ at block $128$ to $12$ at block $1$ (one scale
per weight). The industry picks $32$: nearly all the SNR of a tiny block for a
quarter-bit of overhead.
:::
### NVFP4: A Scale That Lands Between the Powers of Two
MX is not a paper curiosity — Blackwell's 5th-generation tensor cores execute
MXFP8, MXFP6, and MXFP4 in silicon. But they also execute a *different* 4-bit
format, NVIDIA's own **NVFP4**, and the difference is the one thing E8M0 can't do.
E8M0 is **exact** — a power of two only shifts the binary point, so the scale is
never itself rounded — but that is also its cage: it can line a block's peak up
only to the nearest *binade*. A block whose ideal scale is $0.15$ is stuck
choosing between $0.125$ and $0.25$; it cannot sit in between. **NVFP4** spends
that exactness to buy a tighter fit. It changes two dials and adds a level:
- a **16-element** microblock (half of MX's 32) — twice as many scales, each
hugging a smaller slice of the row;
- an **E4M3** block scale instead of E8M0 — a *real* fp8 float (max $448$), so the
scale can land **between** the powers of two, at the cost that the scale is now
itself rounded (E4M3 has 3 mantissa bits);
- a second, **per-tensor fp32** scale on top — **two-level scaling**, the piece
that makes the E4M3 block scale expressible at all (next).
Step across a handful of blocks and watch the two scale formats choose. E8M0's
dots are pinned to the power-of-two gridlines; E4M3's dots float free, landing
right next to the ideal scale wherever it happens to fall:
```{python}
#| output: false
import sys
from pathlib import Path
spec_nv = importlib.util.spec_from_file_location("nvfp4", Path("nvfp4.py").resolve())
nv = importlib.util.module_from_spec(spec_nv)
sys.modules["nvfp4"] = nv
spec_nv.loader.exec_module(nv)
ojs_define(nvSnaps = nv.scale_snap_demo())
```
```{ojs}
//| echo: false
viewof nvScaleStep = stepControl({min: 0, max: nvSnaps.length - 1, value: 0, label: "Block"})
```
```{ojs}
//| echo: false
nvScaleChart = {
const width = 760, height = 300;
const margin = { top: 64, right: 90, bottom: 40, left: 20 };
const theme = diagramTheme;
const rows = nvSnaps;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
// A log axis over the scale values in play.
const all = rows.flatMap(r => [r.ideal, r.e8m0, r.e4m3]);
const lo = Math.min(...all) * 0.7, hi = Math.max(...all) * 1.3;
const x = d3.scaleLog().domain([lo, hi]).range([margin.left + 90, width - margin.right]);
// Power-of-two gridlines — the only places E8M0 can land.
const p2 = [];
for (let e = -6; e <= 3; e++) { const v = 2 ** e; if (v >= lo && v <= hi) p2.push(v); }
for (const v of p2) {
svg.append("line").attr("x1", x(v)).attr("x2", x(v)).attr("y1", margin.top).attr("y2", height - margin.bottom)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 0.6).attr("opacity", 0.45);
svg.append("text").attr("x", x(v)).attr("y", height - margin.bottom + 16)
.attr("text-anchor", "middle").attr("font-size", "10px").attr("fill", theme.nodeText).attr("opacity", 0.6)
.text(`2^${Math.round(Math.log2(v))}`);
}
const rowH = (height - margin.top - margin.bottom) / rows.length;
rows.forEach((r, i) => {
const cy = margin.top + rowH * (i + 0.5);
const active = i === nvScaleStep;
if (active) {
svg.append("rect").attr("x", margin.left).attr("y", cy - rowH / 2 + 2)
.attr("width", width - margin.left - margin.right + 80).attr("height", rowH - 4)
.attr("fill", theme.highlight).attr("opacity", 0.08).attr("rx", 6);
}
// connector from ideal to each snap
svg.append("line").attr("x1", x(r.ideal)).attr("x2", x(r.e8m0)).attr("y1", cy).attr("y2", cy)
.attr("stroke", theme.error).attr("stroke-width", active ? 1.5 : 0.7).attr("opacity", active ? 0.7 : 0.3);
// ideal scale — a diamond
svg.append("path").attr("transform", `translate(${x(r.ideal)},${cy})`)
.attr("d", d3.symbol(d3.symbolDiamond, active ? 90 : 55)())
.attr("fill", active ? theme.highlight : theme.nodeStroke);
// E8M0 snap — a square, on a gridline
svg.append("rect").attr("x", x(r.e8m0) - 5).attr("y", cy - 5).attr("width", 10).attr("height", 10)
.attr("fill", "none").attr("stroke", theme.error).attr("stroke-width", 1.8).attr("opacity", active ? 1 : 0.5);
// E4M3 snap — a filled circle, between the gridlines
svg.append("circle").attr("cx", x(r.e4m3)).attr("cy", cy).attr("r", active ? 6 : 4)
.attr("fill", theme.accent).attr("opacity", active ? 1 : 0.5);
svg.append("text").attr("x", margin.left + 4).attr("y", cy + 4)
.attr("font-size", "11px").attr("fill", theme.nodeText).text(`amax ${r.amax.toFixed(1)}`);
});
const st = rows[nvScaleStep];
svg.append("text").attr("x", margin.left).attr("y", 26)
.attr("font-size", "14px").attr("font-weight", 700).attr("fill", theme.nodeText)
.text("Ideal scale ◆ vs E8M0 ▢ (power of two) vs E4M3 ● (fp8)");
svg.append("text").attr("x", margin.left).attr("y", 47)
.attr("font-size", "13px").attr("fill", theme.accent)
.text(`amax ${st.amax.toFixed(1)}: ideal ${st.ideal.toFixed(3)} E8M0 off by ${(st.e8m0_gap * 100).toFixed(0)}% E4M3 off by ${(st.e4m3_gap * 100).toFixed(0)}%`);
return svg.node();
}
```
::: {.callout-note}
## Key Insight
E8M0 and E4M3 are both 8-bit scales. E8M0 spends all 8 bits on exponent, so it is
*exact* but can only be a power of two. E4M3 spends 3 of them on a mantissa, so it
**rounds** — but it can sit *between* the powers of two, right where the block's
ideal scale usually is. NVFP4 bets the finer fit is worth the rounded scale.
:::
#### The Two-Level Recipe
There is a catch. An E4M3 scale maxes out at $448$, but a block's peak can be
anything — a scale of, say, $5000$ simply isn't representable. NVFP4's fix is the
second level: first divide the **whole tensor** by one fp32 **global** scale,
$$
s_{\text{global}} = \frac{\text{amax}_{\text{tensor}}}{448 \times 6}
= \frac{\text{amax}_{\text{tensor}}}{M_{\text{E4M3}} \cdot M_{\text{E2M1}}},
$$
then give each 16-block its own E4M3 scale on top. Why *this* value? Because it is
exactly what makes every block's ideal scale representable. A block's ideal
multiplier (peak lands on the E2M1 grid's top, $6$) is $\text{amax}_b / 6$; divide
it by $s_{\text{global}}$ and
$$
\frac{\text{amax}_b / 6}{s_{\text{global}}}
= 448 \cdot \frac{\text{amax}_b}{\text{amax}_{\text{tensor}}} \le 448,
$$
because $\text{amax}_b \le \text{amax}_{\text{tensor}}$ for every block. The
inequality is the whole reason the global level exists: it guarantees each local
E4M3 scale fits, with the block holding the tensor's own peak landing at *exactly*
$448$. Every value then decodes as the **product of both levels**:
$$
v \approx s_{\text{global}} \cdot s_{\text{block}} \cdot P,
\qquad s_{\text{block}} \in \text{E4M3}, \quad P \in \text{E2M1}.
$$
The global scale's only job is to make the local scale *expressible*; the local
E4M3 scale does the accuracy work.
#### Code: NVFP4 from Scratch
`nvfp4.py` builds the two levels on top of the pieces you already have — the E2M1
grid from `mx.py`, and Module 07's **E4M3 rounder** for the block scale (NVFP4's
scale *is* an fp8 number, so it snaps to the exact same grid). First the global
scale and the range guarantee it buys:
```{python}
torch.manual_seed(0)
weights = torch.randn(256) * 0.02
weights[42] = 0.7 # the tensor's loud outlier
s_global = nv.per_tensor_scale(weights) # amax / (448 * 6)
headroom = nv.block_scale_headroom(weights) # largest ideal E4M3 scale over blocks
print(f"s_global (fp32) : {s_global:.3e}")
print(f"loudest block's ideal E4M3 scale: {headroom:.1f} (E4M3 max = {nv.E4M3_MAX})")
print(f"range guarantee holds : {headroom <= nv.E4M3_MAX}")
```
The loudest block lands on $448$ exactly — the global scale is calibrated to that
edge. Now the full two-level round-trip, and the payoff versus a naive fp4 cast:
```{python}
recon = nv.nvfp4_quantize_dequantize(weights) # 16-blocks, E4M3 scale, fp32 global
nvfp4_snr = q.quantization_error(weights, recon)["snr_db"]
naive = torch.tensor([mx.round_to_grid(v, mx.element_grid(2, 1)) for v in weights.tolist()])
naive_snr = q.quantization_error(weights, naive)["snr_db"]
print(f"NVFP4 (two-level) : {nvfp4_snr:6.2f} dB")
print(f"fp4, no scale at all : {naive_snr:6.2f} dB")
```
::: {.callout-warning}
## NVFP4's scale is *not* a power of two
Because the E4M3 block scale can be any fp8 value, the **effective** scale
$s_{\text{global}}\cdot s_{\text{block}}$ is generally *not* a power of two — so a
plain value like $0.5$ is no longer guaranteed to round-trip exactly, the way it
always does under MX's E8M0 scale. That is the price of landing between the powers
of two; the tighter fit more than pays it back on real weights.
:::
#### MXFP4 vs NVFP4
Put the two 4-bit formats on the same weights. To separate the *block-size* win
from the *scale-format* win, we cast four ways: naive fp4 (no scale), standard
**MXFP4** (block 32, E8M0), the same E8M0 scale at **block 16**, and full
**NVFP4** (block 16, E4M3, two-level). The jump from block-16 E8M0 to NVFP4 is the
E4M3 scale alone:
```{python}
#| output: false
def _casts(x):
c = nv.compare_mxfp4_nvfp4(x)
order = ["naive_fp4", "mxfp4", "e8m0_block16", "nvfp4"]
labels = {"naive_fp4": "fp4 (no scale)", "mxfp4": "MXFP4 · blk 32 · E8M0",
"e8m0_block16": "blk 16 · E8M0", "nvfp4": "NVFP4 · blk 16 · E4M3"}
return [{"name": labels[k], "snr": c[k]["snr_db"], "bpw": c[k]["bpw"]} for k in order]
torch.manual_seed(1)
_normal = torch.randn(512) * 0.03; _normal[77] = 0.9 # weights + one outlier
_heavy = (torch.randn(512) ** 3) * 0.1 # heavy-tailed
_uniform = (torch.rand(512) - 0.5) * 0.4 # flat, no outliers
ojs_define(nvCompare = {
"weights + outlier": _casts(_normal),
"heavy-tailed": _casts(_heavy),
"uniform": _casts(_uniform),
})
```
```{ojs}
//| echo: false
viewof nvDist = Inputs.radio(["weights + outlier", "heavy-tailed", "uniform"],
{value: "weights + outlier", label: "Distribution"})
```
```{ojs}
//| echo: false
nvCompareChart = {
const width = 760, height = 300;
const margin = { top: 54, right: 24, bottom: 40, left: 176 };
const theme = diagramTheme;
const data = nvCompare[nvDist];
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const y = d3.scaleBand().domain(data.map(d => d.name))
.range([margin.top, height - margin.bottom]).padding(0.28);
const x = d3.scaleLinear().domain([0, d3.max(data, d => d.snr) * 1.14]).range([margin.left, width - margin.right]);
data.forEach((d, i) => {
const isNv = d.name.startsWith("NVFP4");
svg.append("rect").attr("x", margin.left).attr("y", y(d.name))
.attr("width", x(d.snr) - margin.left).attr("height", y.bandwidth())
.attr("fill", isNv ? theme.highlight : theme.nodeStroke).attr("opacity", isNv ? 0.95 : 0.6).attr("rx", 4);
svg.append("text").attr("x", margin.left - 10).attr("y", y(d.name) + y.bandwidth() / 2 + 4)
.attr("text-anchor", "end").attr("font-size", "11.5px").attr("fill", theme.nodeText).text(d.name);
svg.append("text").attr("x", x(d.snr) + 8).attr("y", y(d.name) + y.bandwidth() / 2 + 4)
.attr("font-size", "12px").attr("font-weight", 700).attr("fill", theme.accent)
.text(`${d.snr.toFixed(1)} dB · ${d.bpw.toFixed(2)} bpw`);
});
svg.append("text").attr("x", margin.left - 10).attr("y", 30)
.attr("text-anchor", "end").attr("font-size", "13px").attr("font-weight", 700).attr("fill", theme.nodeText)
.text("reconstruction SNR");
svg.append("text").attr("x", width - margin.right).attr("y", 30)
.attr("text-anchor", "end").attr("font-size", "12px").attr("fill", theme.nodeText).attr("opacity", 0.7)
.text("longer = more faithful");
return svg.node();
}
```
::: {.callout-tip}
## Try This
Switch the distribution. On **weights + outlier**, NVFP4 opens a clear gap over
standard MXFP4 — part from the smaller block (see block-16 E8M0), part from the
E4M3 scale on top. On **uniform** weights, with no outliers to fit around, the
block-scale format barely matters and the four casts nearly converge: NVFP4's edge
is a *dynamic-range* tool, and it earns its keep exactly where the range is wide.
:::
::: {.callout-warning}
## Not every "block-scaled fp8" is MX
DeepSeek-V3 famously trained end-to-end in fp8 with **tile/block-wise scaling**
(1×128 activations, 128×128 weights). That is the same *family* of idea — group
the numbers, scale each group — but it is **not** the OCP MX format: the elements
are ordinary E4M3/E5M2, the block shapes are DeepSeek's own, and the scales are
fp32, not E8M0. When you read "fp8 with fine-grained scaling," check whether it
means *MX* (a ratified standard with an E8M0 scale over 32 elements) or a bespoke
block-scaling recipe. They are cousins, not the same format.
:::
## Interactive Exploration
### Step Through the Pipeline
Every quantization is the same six steps: measure the range, form a scale, divide,
round, store the integers, and dequantize on the way back out. Step through them
and watch where the lossy `round` sits.
```{ojs}
//| echo: false
viewof quantStep = stepControl({min: 0, max: 5, value: 0, label: "Step"})
```
```{ojs}
//| echo: false
quantSteps = [
{title: "fp32 weights", caption: "Start with the trained weights, e.g. [-0.18, 0.04, 0.11, -0.09]. Each is a full 32-bit float."},
{title: "Find the scale", caption: "s = max|w| / q_max. With q_max = 127 (int8) and max|w| = 0.18, s ≈ 0.00142 — the width of one bucket."},
{title: "Divide by s", caption: "w / s maps the weights onto the integer grid: [-127, 28, 78, -63] before rounding."},
{title: "Round + clamp", caption: "round() snaps to the nearest integer and clamp() keeps it in [-127, 127]. This is the ONLY lossy step."},
{title: "Store {int8, s}", caption: "Keep the 8-bit integers and the single fp32 scale. This is what ships — 4× smaller than the floats."},
{title: "Dequantize", caption: "At run time, w_hat = s × q reconstructs the weights. The tiny gap w − w_hat is the quantization error."},
]
```
```{ojs}
//| echo: false
quantPipeline = {
const width = 720, height = 260;
const theme = diagramTheme;
const svg = d3.create("svg")
.attr("viewBox", `0 0 ${width} ${height}`)
.attr("width", "100%").attr("height", height)
.style("max-width", `${width}px`)
.style("font-family", "var(--pg-mono)");
svg.append("rect").attr("width", width).attr("height", height)
.attr("fill", theme.bg).attr("rx", 12);
const boxes = quantSteps.map(s => s.title);
const bw = 104, bh = 52, gap = (width - 40 - boxes.length * bw) / (boxes.length - 1);
const y = 60;
boxes.forEach((label, i) => {
const x = 20 + i * (bw + gap);
const active = i === quantStep;
const lossy = i === 3; // round is the lossy step
const gcol = active ? theme.highlight : (lossy ? theme.error : theme.nodeStroke);
if (active) {
svg.append("rect").attr("x", x - 3).attr("y", y - 3)
.attr("width", bw + 6).attr("height", bh + 6).attr("rx", 10)
.attr("fill", "none").attr("stroke", theme.highlight).attr("stroke-width", 3)
.attr("opacity", 0.4);
}
svg.append("rect").attr("x", x).attr("y", y)
.attr("width", bw).attr("height", bh).attr("rx", 8)
.attr("fill", active ? theme.highlight : theme.nodeFill)
.attr("stroke", gcol).attr("stroke-width", active ? 2.5 : 1.5);
svg.append("text").attr("x", x + bw / 2).attr("y", y + bh / 2)
.attr("text-anchor", "middle").attr("dominant-baseline", "middle")
.attr("fill", active ? theme.bgOpaque : theme.nodeText)
.attr("font-size", "12px").attr("font-weight", "600")
.attr("font-family", "var(--pg-mono)")
.selectAll("tspan").data(label.split(" ")).join("tspan")
.attr("x", x + bw / 2).attr("dy", (d, j) => j === 0 ? "-0.1em" : "1.1em")
.text(d => d);
if (i < boxes.length - 1) {
const ax = x + bw, ax2 = x + bw + gap;
svg.append("line").attr("x1", ax + 2).attr("x2", ax2 - 2)
.attr("y1", y + bh / 2).attr("y2", y + bh / 2)
.attr("stroke", theme.edgeStroke).attr("stroke-width", 1.5)
.attr("marker-end", "url(#quant-arrow)");
}
});
svg.append("defs").append("marker").attr("id", "quant-arrow")
.attr("viewBox", "0 0 10 10").attr("refX", 8).attr("refY", 5)
.attr("markerWidth", 6).attr("markerHeight", 6).attr("orient", "auto")
.append("path").attr("d", "M0,0 L10,5 L0,10 z").attr("fill", theme.edgeStroke);
// Caption for the active step.
const s = quantSteps[quantStep];
svg.append("text").attr("x", width / 2).attr("y", 156)
.attr("text-anchor", "middle").attr("fill", theme.highlight)
.attr("font-size", "14px").attr("font-weight", "700").text(s.title);
const words = s.caption.split(" ");
const lines = [];
let line = "";
for (const wd of words) {
if ((line + wd).length > 74) { lines.push(line); line = ""; }
line += wd + " ";
}
lines.push(line);
svg.selectAll("text.cap").data(lines).join("text")
.attr("x", width / 2).attr("y", (_, i) => 182 + i * 20)
.attr("text-anchor", "middle").attr("fill", theme.nodeText)
.attr("font-size", "13px").text(d => d);
return svg.node();
}
```
### Drive the Grid Yourself
Slide the bit-width and switch schemes. Fewer bits coarsen the grid; the arrows
show each weight snapping to its nearest level, and the readouts track the error.
Below it, the per-channel chart (built from the tested code above) shows the
outlier channel's damage — and how per-channel scales contain it.
{{< include _quant-viz.qmd >}}
::: {.callout-tip}
## Try This
1. **Watch int4 break.** In the grid explorer, drag bits from 8 down to 2. At
int2 there are only 4 levels — the snapped dots barely resemble the weights,
and the SNR collapses. This is why naive int4 needs help (grouping, GPTQ).
2. **Re-centre with affine.** At low bit-widths, switch from *symmetric* to
*affine*. The grid slides to hug the data's actual range instead of straddling
zero, and the error drops — the payoff of spending a zero-point.
3. **Read the per-channel chart.** The red bars (per-tensor) tower over the green
(per-channel) for every quiet channel, because the outlier set their shared
scale. Only the outlier channel itself is a near-tie.
:::
## Common Pitfalls
1. **Forgetting to clamp.** After `round(w/s)` a value can land at $\pm(q_{\max}+1)$
from floating-point error. Without `clamp` it overflows the integer type and
corrupts the weight. Always clamp to the representable range.
2. **Symmetric quantization on skewed data.** A symmetric grid centred on zero
wastes half its levels on a range the data never visits (e.g. post-ReLU
activations). Use affine — with a zero-point — whenever the values are
one-sided.
3. **Per-tensor scales on weights.** One outlier channel forces a coarse grid on
the whole matrix. Per-channel (per-row) scales are nearly free — one float per
channel — and are the standard for weight quantization.
4. **Expecting int4 to just work.** Four bits is 16 levels; plain per-channel
int4 usually loses too much. Real 4-bit uses smaller **groups** (a scale per 64
or 128 weights, built above) *and* error-correcting methods like GPTQ and AWQ.
5. **Feeding GPTQ the wrong calibration data.** GPTQ steers on the Hessian $H =
2XX^\top$ of a calibration batch. Too few samples, or samples off the model's
real distribution, give a poor $H$ and the compensation can *hurt* — the method
is only as good as the activations you show it. A few hundred in-distribution
sequences is the usual recipe.
6. **Confusing storage bits with compute.** Quantizing to int8 shrinks *storage*
and memory bandwidth immediately. Getting the *speedup* also needs an integer
matmul kernel; the `QuantizedLinear` here dequantizes-then-matmuls to stay
readable, which is correct but not itself faster.
7. **Over-scaling in AWQ.** Bigger $s$ is not always better. Scaling a salient
column *past* the group maximum raises $\Delta$ for every weight in the group,
trading the salient channel's error for everyone else's — this is the right wall
of the $\alpha$ curve. Search $\alpha$; don't hand-pick a large scale. And read
salience from *activations*, not weight magnitude: the two disagree, and it is
the activation-heavy channels that dominate the output.
8. **Forgetting to count the group scales.** Group-wise storage is *not* `num_bits`
per weight — it is `num_bits + scale_bits/group_size`. Quote int4-g128 as
**4.125** bits, not 4. Shrink the group without accounting for the scales and
you can end up *larger* than you think: at `g = 8` those fp16 scales already add
two full bits per weight. Use `grouped_footprint`, not `model_footprint`, once
groups are small.
9. **A group size that doesn't divide the row.** `group_size` must partition the
quantized axis evenly. Real kernels pad the last partial group (llama.cpp uses
fixed 32-wide blocks precisely so every tensor tiles cleanly); `quantize_grouped`
raises rather than silently truncate. Pick a `group_size` that divides
`in_features` (32/64/128 are the standard choices) or pad first.
10. **Reaching for fp8 as a strictly-better int8.** It isn't. fp8's constant-*relative*
precision only helps when the data's dynamic range is wide; on a well-scaled tensor
with no outlier, int8's uniform grid is *more* precise (seven bits vs e4m3's three
mantissa bits — see the crossover widget). And fp8 still **saturates**: an activation
above $448$ clips in e4m3. Pick fp8 for range, int8 for peak precision — and remember
per-tensor fp8 can still need a finer per-tile scale on the hardest layers.
11. **Trying to quantize activations *per channel*.** It is the intuitive mirror of
per-channel weights, and it is a trap: an activation channel is the **contraction**
axis of $XW$, so its scale is buried inside $\sum_k$ and cannot be factored out of the
int8 GEMM — the kernel would have to rescale mid-accumulation. Activations quantize
**per-token** (an output axis) or per-tensor; the channel axis belongs to the weights,
or to SmoothQuant's fold. When you *do* go per-token, remember it must be **dynamic**
(each row's absmax at run time) — a single calibrated activation scale cannot know a
future token's range.
12. **Treating NVFP4's scale like E8M0's.** MX's E8M0 scale is a power of two, so it
never rounds and a plain value like $0.5$ always round-trips exactly. NVFP4's
E4M3 block scale *does* round (3 mantissa bits), and its effective scale
$s_{\text{global}}\cdot s_{\text{block}}$ is generally **not** a power of two —
so don't assume nice round weights survive untouched. And don't drop the
per-tensor fp32 level: without it a loud block's ideal scale can exceed E4M3's
$448$ and become unrepresentable. Two levels, not one.
## Exercises
### Exercise 1: int4 vs int8 error
Quantize the same tensor to int8 and int4 and confirm int4's SNR is roughly 24 dB
lower (about 6 dB per bit).
```{python}
x = torch.randn(10_000) * 0.1
q8, s8 = q.absmax_quantize(x, num_bits=8)
q4, s4 = q.absmax_quantize(x, num_bits=4)
snr8 = q.quantization_error(x, q.dequantize(q8, s8))["snr_db"]
snr4 = q.quantization_error(x, q.dequantize(q4, s4))["snr_db"]
print(f"int8 {snr8:.1f} dB, int4 {snr4:.1f} dB, gap {snr8 - snr4:.1f} dB")
```
### Exercise 2: Build the per-channel win
Take a matrix, make one channel an outlier, and show per-channel MSE is far below
per-tensor MSE. (Hint: `quantize_per_channel(..., axis=0)` vs `absmax_quantize`.)
```{python}
# Your implementation here
```
### Exercise 3: Footprint of a model that fits
What is the largest model (in billions of parameters) that fits in 24 GB of GPU
memory at int4, leaving 4 GB for activations? Use `model_footprint`.
```{python}
# budget = 20 GB for weights; solve params from model_footprint(params, 4)
budget_bytes = 20e9
params = budget_bytes / q.bytes_per_param(4)
print(f"~{params/1e9:.0f}B parameters fit in int4 within a 20 GB weight budget")
```
### Exercise 4: GPTQ beats RTN where it counts
On a correlated layer, confirm GPTQ's output loss is below RTN's at int3, and that
the gap *widens* as you drop to int2 (fewer bits ⇒ more rounding error ⇒ more for
the compensation to recover).
```{python}
torch.manual_seed(1)
W_ex = torch.randn(48, 48) * 0.1
X_ex = torch.randn(48, 48) @ torch.randn(48, 128) # correlated inputs
H_ex = gptq.layer_hessian(X_ex)
for bits in (3, 2):
rtn, _ = gptq.round_to_nearest(W_ex, num_bits=bits)
gp, _ = gptq.gptq_quantize(W_ex, H_ex, num_bits=bits)
red = 1 - gptq.output_loss(W_ex, gp, X_ex) / gptq.output_loss(W_ex, rtn, X_ex)
print(f"int{bits}: GPTQ recovers {red:.0%} of RTN's output loss")
```
### Exercise 5: AWQ finds the salient channel
Give a layer one loud activation channel, quantize with AWQ, and confirm two things:
the searched $\alpha^{*}$ is greater than $0$ (the search left RTN behind), and the
loud channel's own per-channel error is smaller than under RTN.
```{python}
torch.manual_seed(2)
W_a = torch.randn(32, 32) * 0.1
X_a = torch.randn(32, 128)
loud = 7
X_a[loud] *= 20.0 # one salient channel
W_a_hat, alpha_star, _ = awq.awq_quantize(W_a, X_a, num_bits=3)
rtn_a = awq.round_to_nearest(W_a, num_bits=3)
rtn_e = awq.channel_output_error(W_a, rtn_a, X_a)[loud]
awq_e = awq.channel_output_error(W_a, W_a_hat, X_a)[loud]
print(f"α* = {alpha_star:.2f} (> 0: {alpha_star > 0})")
print(f"channel {loud} error: RTN {rtn_e:.1f} → AWQ {awq_e:.1f} ({awq_e < rtn_e})")
```
### Exercise 6: The group-size dial
Confirm the two endpoints and the monotone middle: `quantize_grouped` with
`group_size == in_features` reproduces `quantize_per_channel` bit-for-bit, and
each halving of the group can only lower a weight's scale. Then read off the bit
cost of the accuracy you buy.
```{python}
torch.manual_seed(1)
Wg = torch.randn(16, 64) * 0.05
Wg[4, 9] *= 40.0 # a weight buried in a quiet row
# Endpoint: a full-width group is exactly per-channel.
qg, sg = gq.quantize_grouped(Wg, num_bits=4, group_size=64, axis=1)
qc, sc = q.quantize_per_channel(Wg, num_bits=4, axis=0)
print("full group == per-channel:", bool(torch.equal(qg, qc) and torch.equal(sg, sc)))
prev = None
for g in (64, 32, 16, 8, 4, 2, 1):
qi, s = gq.quantize_grouped(Wg, num_bits=4, group_size=g, axis=1)
err = q.quantization_error(Wg, gq.dequantize_grouped(qi, s, group_size=g, axis=1))
bits = gq.grouped_effective_bits(4, g)
snr = "exact " if err["snr_db"] == float("inf") else f"{err['snr_db']:5.1f}"
print(f"g={g:>3}: {bits:5.3f} bits SNR {snr} dB")
```
### Exercise 7: The KV cache wants opposite axes
Plant a persistent outlier channel in a Key cache and confirm the KIVI rule: quantize
Keys **per-channel** and Values **per-token**, and swapping the axes is much worse.
Measure the reconstruction error on the *informative* (non-outlier) channels.
```{python}
torch.manual_seed(2)
Kx = torch.randn(128, 32)
Kx[:, 6] = 30.0 + 0.4 * torch.randn(128) # a persistent outlier channel
per_channel = kvq.dequantize_along(kvq.quantize_key_cache(Kx, num_bits=2, group_size=32))
per_token = kvq.dequantize_along(kvq.quantize_value_cache(Kx, num_bits=2, group_size=32))
mask = torch.ones(32, dtype=torch.bool); mask[6] = False
err_pc = (Kx[:, mask] - per_channel[:, mask]).pow(2).mean()
err_pt = (Kx[:, mask] - per_token[:, mask]).pow(2).mean()
print(f"per-channel (correct): {err_pc:.4f}")
print(f"per-token (wrong): {err_pt:.4f} -> {err_pt / err_pc:.0f}x worse")
# Your turn: sweep num_bits in (2, 3, 4, 8) and watch the gap shrink as bits rise.
```
### Exercise 8: MX turns a coarse fp4 into a usable one
Take a block of small weights, cast it straight to E2M1 (no scale), then quantize
it as MXFP4 with its shared power-of-two exponent. Confirm the block scale buys a
large SNR jump — and that the exact scale means a grid-aligned block reconstructs
with *no* error at all.
```{python}
torch.manual_seed(5)
blk = torch.randn(32) * 0.02
blk[11] = 0.4 # the block's loudest value
grid = mx.element_grid(2, 1)
naive = torch.tensor([mx.round_to_grid(v, grid) for v in blk.tolist()])
Xb, Pb = mx.quantize_mx(blk, mx.MX_FORMATS["mxfp4"])
mxrec = torch.tensor(mx.dequantize_mx(Xb, Pb))
print(f"no scale : {q.quantization_error(blk, naive)['snr_db']:5.1f} dB")
print(f"MXFP4 : {q.quantization_error(blk, mxrec)['snr_db']:5.1f} dB")
# The E8M0 scale is exact: a block that already lies on grid x 2^k is lossless.
onGrid = torch.tensor([0.5, 1.0, 1.5, 3.0]) * 2 ** -3
print("grid-aligned block exact:",
bool(torch.equal(mx.mx_quantize_dequantize(onGrid, mx.MX_FORMATS["mxfp4"]), onGrid)))
# Your turn: sweep the block size (MXFormat(..., block_size=s)) and plot SNR vs bits.
```
### Exercise 9: SmoothQuant migrates the outlier
Give a layer one ~100× activation outlier channel. Confirm the smoothing transform is
*exactly* invariant, then that per-tensor int8 **W8A8** on the smoothed operands beats
the naive W8A8 by several dB — and that the balanced $\alpha = 0.5$ beats over-migrating
to $\alpha = 1$.
```{python}
torch.manual_seed(4)
Xs = torch.randn(32, 24)
Ws = torch.randn(24, 24)
Xs[:, 5] *= 100.0 # the persistent loud channel
ref = Xs @ Ws
Xh, Wh, _ = sq.smooth(Xs, Ws, alpha=0.5)
print("invariant:", bool(torch.allclose(Xh @ Wh, ref, atol=1e-3)))
snr = lambda y: q.quantization_error(ref, y)["snr_db"]
print(f"naive W8A8 : {snr(sq.quantize_w8a8(Xs, Ws)):5.1f} dB")
print(f"α=0.5 W8A8 : {snr(sq.smoothquant_linear(Xs, Ws, 0.5)):5.1f} dB")
print(f"α=1.0 W8A8 : {snr(sq.smoothquant_linear(Xs, Ws, 1.0)):5.1f} dB")
# Your turn: sweep alpha in 0..1 and confirm the SNR peaks in the middle (the U-curve).
```
### Exercise 10: fp8 rides out the outlier that int8 can't
Take the *massive-activation* layer — a loud channel that is downstream-weak — and confirm
fp8 W8A8 (no smoothing) clears naive int8 by a wide margin, yet **loses** to int8 once you
remove the outlier. That crossover *is* the point of fp8.
```{python}
torch.manual_seed(7)
Xe = torch.randn(32, 24)
We = torch.randn(24, 24)
snr = lambda ref, y: q.quantization_error(ref, y)["snr_db"]
# With a loud-but-weak outlier: fp8 wins for free.
Xo, Wo = Xe.clone(), We.clone()
Xo[:, 5] *= 100.0; Wo[5, :] *= 0.01
refo = Xo @ Wo
print(f"outlier layer int8 {snr(refo, fg.quantize_w8a8_int8(Xo, Wo)):5.1f} dB "
f"fp8 {snr(refo, fg.fp8_gemm(Xo, Wo, fg.E4M3)):5.1f} dB")
# Without it: int8's uniform grid is more precise.
refc = Xe @ We
print(f"clean layer int8 {snr(refc, fg.quantize_w8a8_int8(Xe, We)):5.1f} dB "
f"fp8 {snr(refc, fg.fp8_gemm(Xe, We, fg.E4M3)):5.1f} dB")
# Your turn: try e5m2 instead of e4m3 — more range, one fewer mantissa bit. When does it help?
```
### Exercise 11: Per-token rescues the quiet tokens
Build a layer with one very loud *token* (a whole row scaled up) and confirm that per-tensor
W8A8 crushes the *quiet* rows while per-token (vector-wise) keeps them. Then verify the
outer-product factoring is exact.
```{python}
torch.manual_seed(11)
Xt = torch.randn(16, 32)
Wt = torch.randn(32, 32)
Xt[7] *= 80.0 # one loud token
reft = Xt @ Wt
snr = lambda ref, y: q.quantization_error(ref, y)["snr_db"]
print(f"per-tensor W8A8 : {snr(reft, pt.w8a8_per_tensor(Xt, Wt)):5.1f} dB")
print(f"vector-wise W8A8 : {snr(reft, pt.w8a8_vector_wise(Xt, Wt)):5.1f} dB")
# The factoring identity: rescale-the-int32 == dequant-then-matmul.
Xq, sx = pt.quantize_per_token(Xt)
Wq, sw = pt.quantize_weight_per_channel(Wt)
err = (pt.w8a8_vector_wise(Xt, Wt) - q.dequantize(Xq, sx) @ q.dequantize(Wq, sw)).abs().max()
print(f"factoring max error : {err:.2e} (algebraic — the scales pull out of the sum)")
# Your turn: which rows have the largest per-tensor error — the loud one, or the quiet ones?
# (Use pt.per_token_row_error(Xt) and look at the quiet rows.)
```
### Exercise 12: NVFP4's E4M3 scale lands between the powers of two
Take a block of weights and compare the block scale two ways at the *same* block
size: MX's power-of-two **E8M0** scale versus NVFP4's fp8 **E4M3** scale. Confirm
the E4M3 scale sits closer to the ideal $\text{amax}/6$, and that the full
two-level NVFP4 round-trip beats the E8M0 scale on the same 16-element blocks.
```{python}
torch.manual_seed(9)
w = torch.randn(256) * 0.03
w[100] = 0.8 # a loud outlier
# One block: the ideal scale vs the two formats' snaps (s_global = 1).
snap = nv.scale_snap_demo([w[:16].abs().max().item()])[0]
print(f"ideal scale {snap['ideal']:.3f} E8M0 {snap['e8m0']:.3f} (off {snap['e8m0_gap']:.0%}) "
f"E4M3 {snap['e4m3']:.3f} (off {snap['e4m3_gap']:.0%})")
# Whole tensor, block 16: E8M0 scale vs NVFP4's two-level E4M3 scale.
e8m0_16 = mx.mx_quantize_dequantize(w, mx.MX_FORMATS["nvfp4"]) # block 16, E8M0
nvfp4 = nv.nvfp4_quantize_dequantize(w) # block 16, E4M3, 2-level
print(f"blk16 E8M0 : {q.quantization_error(w, e8m0_16)['snr_db']:5.1f} dB")
print(f"NVFP4 : {q.quantization_error(w, nvfp4)['snr_db']:5.1f} dB")
# Your turn: verify block_scale_headroom(w) == 448 — the global scale calibrates the amax block.
```
## Summary
Key takeaways:
1. **Quantization stores integers, not floats.** Round each weight onto a grid of
$2^b$ levels and keep a scale to undo it: $w \approx s\,q$. int8 is 4× smaller
than fp32, int4 is 8×.
2. **Two schemes.** *Symmetric* (absmax) centres the grid on zero — best for
weights. *Affine* adds a zero-point to use the full code range — best for
one-sided activations.
3. **SNR is the honest metric.** Every bit is worth ~6 dB; int8 reconstructs
weights near 40 dB, int4 near the mid-teens.
4. **Per-channel beats per-tensor.** A single outlier channel wrecks a per-tensor
scale; one scale per row costs almost nothing and recovers the lost precision.
5. **`QuantizedLinear` is the payoff.** Stored per-channel int8, it matches the
fp32 layer to a fraction of a percent at ~4× less memory — the building block
of every quantized transformer.
6. **Group size is a dial.** Split each row into blocks with their own scales:
`g = d_in` is per-channel, `g = 1` is lossless per-weight, and the useful
middle costs `b + scale_bits/g` bits — 4.125 for the near-universal int4-g128.
It is *the* granularity real int4 checkpoints ship.
7. **GPTQ minimizes output error, not weight error.** Round-to-nearest rounds each
weight blindly; GPTQ quantizes column by column and uses the calibration
Hessian $H = 2XX^\top$ to push each rounding error onto the not-yet-quantized
weights. On correlated layers it roughly halves the int3/int2 output loss — and
collapses *exactly* to RTN when the features are uncorrelated.
8. **AWQ protects the channels the activations care about.** No Hessian, no
backprop: measure per-channel salience $s_X = \text{mean}(|X|)$, scale each weight
column by $s = s_X^{\alpha}$ (and the activation by $1/s$ — an equivalent
transform), and grid-search $\alpha \in [0,1]$ to minimize the same output loss.
Since $\alpha = 0$ is RTN, AWQ is never worse; with real salient channels it
roughly halves the int3 output loss and stacks cleanly with GPTQ.
9. **The KV cache is the other half of memory — quantize it on opposite axes.**
Weights are read once per step; the KV cache grows with context and is re-read
every step, so at long context it dominates. **KIVI** quantizes the **Key** cache
**per-channel** (isolating its persistent outlier channels) and the **Value** cache
**per-token** (matching attention's token-axis contraction) with the same affine
min/max scheme, a group size $G$, and a full-precision residual window of the most
recent tokens. Swap the axes and the Key's outlier channel wrecks every token — the
choice is forced by the data, and it cuts the cache to ~2-bit at near-baseline quality.
10. **Microscaling (MX) is fp8 elements + a shared power-of-two scale.** The format
Blackwell runs in hardware fuses this module's two threads: tiny float elements
(E2M1's `{0, .5, 1, 1.5, 2, 3, 4, 6}` for MXFP4) sharing one **E8M0** scale — 8
bits, all exponent — over a block of 32, with $v_i = X\,P_i$. Because the scale
is a pure power of two it never rounds, and 8 bits shared over 32 elements is
just $8/32 = 0.25$ extra bits: **MXFP4 = 4.25 bits**, cheaper *and* more exact
than int4 with an fp16 scale (4.5) at the same block. **NVFP4** varies it with
**two-level scaling**: a 16-element block whose scale is a real **E4M3** fp8
value (so it can land *between* the powers of two, unlike E8M0), held in range
by one per-tensor fp32 scale $s_{\text{global}} = \text{amax}/(448\cdot 6)$ —
decode is $v \approx s_{\text{global}}\,s_{\text{block}}\,P$, ~4.5 bits. Both
ship on current hardware.
11. **SmoothQuant quantizes the activations, not just the weights.** A real int8 GEMM
is **W8A8** — both operands int8 — but activations carry a few outlier channels
10–100× louder than the rest that wreck a per-tensor scale. Since those channels
are *systematic* and the weights have headroom, **migrate the difficulty**: the
equivalent transform $XW = (X\operatorname{diag}(s)^{-1})(\operatorname{diag}(s)W)$
with $s_j = \max|X_j|^{\alpha}/\max|W_j|^{1-\alpha}$ (default $\alpha = 0.5$) slides
the outlier out of $X$ and into $W$, and $\operatorname{diag}(s)^{-1}$ folds into
the previous LayerNorm — free at run time. Both operands are then plain per-tensor
int8, worth several dB of output SNR and no mixed precision.
12. **fp8 spends its bits on range, not peak precision.** Swap int8's *uniform* grid for
an 8-bit *minifloat* (e4m3, max 448 / e5m2, max 57344 — from Module 07) and the codes
become *exponential*: constant relative error instead of constant absolute. That rides
out the activation outlier with **no** SmoothQuant transform, because the quiet channels
keep their relative precision. The per-tensor scales factor cleanly out of the GEMM
($Y \approx Q(s_X X)\,Q(s_W W)/(s_X s_W)$ — the fp8-tensor-core recipe). But it is a
*trade*: with no outlier, int8's seven bits beat e4m3's three mantissa bits — a genuine
crossover — and fp8 still saturates past its max.
13. **Activations quantize per-token, on the axis that survives the GEMM.** A per-*channel*
activation scale is trapped on the contraction axis and cannot leave the sum — so the
activation granularity that ships is **per-token**: one scale per *row* of $X$
(an output axis). Paired with per-channel weights, the two scales factor out as an
**outer product** $s^x\!\otimes s^w$ applied to the int32 result — LLM.int8's
*vector-wise* recipe. It is **dynamic** (each row's absmax at run time, no calibration),
it is literally per-channel quantization with `axis=0`, and it complements SmoothQuant:
SmoothQuant tames loud *channels* offline, per-token tames loud *tokens* at run time.
## What's Next
Quantization is the first lever of **fast inference**. You have now built the
integer round-trip, **group-wise** storage (the int4-g128 layout every checkpoint
ships), GPTQ's Hessian-guided error feedback, *and* AWQ's activation-aware
scaling — the two dominant int4 weight-quant methods, from opposite directions.
With **SmoothQuant** you crossed to the *other* operand — quantizing the
**activations** for a genuine per-tensor int8 W8A8 GEMM — and with **fp8** you saw
the same W8A8 problem dissolve by changing the *grid* instead of the data: an
exponential minifloat (E4M3/E5M2, from [Module 07](../m07_training/lesson.qmd)) rides
out the outlier for free, at the cost of peak precision. The frontier goes further
with **grouped int4 threaded *through* GPTQ/AWQ** (the error-correcting methods
choosing per-group scales), **AWQ's tuned scale search**, and **fine-grained
per-tile fp8** (DeepSeek-V3's training recipe).
You have also quantized the **KV cache**
itself — the long-context memory lever — which hands off to the rest of fast
inference: **speculative decoding** (a small draft model proposes, the big model
verifies) and **paged attention** (vLLM's KV-cache memory manager), where a quantized
cache means even more tokens per block.
## Going Deeper
**Core Papers:**
- Jacob et al., *Quantization and Training of Neural Networks for Efficient
Integer-Arithmetic-Only Inference* (2017) — the affine/zero-point scheme.
<https://arxiv.org/abs/1712.05877>
- Dettmers et al., *LLM.int8(): 8-bit Matrix Multiplication for Transformers at
Scale* (2022) — outlier features and why plain int8 breaks at scale; the
mixed-precision baseline SmoothQuant replaces. <https://arxiv.org/abs/2208.07339>
- Xiao et al., *SmoothQuant: Accurate and Efficient Post-Training Quantization for
Large Language Models* (ICML 2023) — migrate activation outliers into the weights
with the equivalent transform $s = \max|X|^{\alpha}/\max|W|^{1-\alpha}$ for
per-tensor int8 W8A8; the activation quantizer built above. Up to 1.56× faster,
2× smaller, 530B on one node. <https://arxiv.org/abs/2211.10438>
- Frantar et al., *GPTQ: Accurate Post-Training Quantization for Generative
Pre-trained Transformers* (2022) — accurate one-shot 4-bit weight quantization;
the Cholesky Algorithm 1 built above. <https://arxiv.org/abs/2210.17323>
- Frantar & Alistarh, *Optimal Brain Compression* (2022) — the OBQ greedy
quantization/pruning framework GPTQ accelerates. <https://arxiv.org/abs/2208.11580>
- Hassibi & Stork, *Second Order Derivatives for Network Pruning: Optimal Brain
Surgeon* (1993) — the $\delta = -(w_q - \hat w_q)/[H^{-1}]_{qq}\,H^{-1}_{:,q}$
update at the heart of it.
- Lin et al., *AWQ: Activation-aware Weight Quantization for LLM Compression and
Acceleration* (2023) — protect the salient channels with an activation-derived
scale $s = s_X^{\alpha}$; the $\alpha$-search built above. MLSys 2024 best paper.
<https://arxiv.org/abs/2306.00978>
- Dettmers & Zettlemoyer, *The case for 4-bit precision: k-bit Inference Scaling
Laws* (2022) — the block-size vs. bit-width Pareto frontier; why a small group
plus 4 bits beats other budgets. <https://arxiv.org/abs/2212.09720>
- Liu et al., *KIVI: A Tuning-Free Asymmetric 2bit Quantization for KV Cache* (2024,
ICML) — per-channel Keys, per-token Values, group size $G{=}32$, residual $R{=}128$;
~2.6× less peak memory and up to 4× larger batch at near-baseline accuracy. The
KV-cache quantizer built above. <https://arxiv.org/abs/2402.02750>
- Hooper et al., *KVQuant: Towards 10 Million Context Length LLM Inference with KV
Cache Quantization* (2024) — 3-bit non-uniform quant with pre-RoPE per-channel Keys,
per-token Values, and dense-and-sparse outlier isolation. <https://arxiv.org/abs/2401.18079>
- Micikevicius et al., *FP8 Formats for Deep Learning* (2022) — the E4M3/E5M2
minifloats the fp8 W8A8 section and MXFP8 reuse; the format story of
[Module 07](../m07_training/lesson.qmd). <https://arxiv.org/abs/2209.05433>
- Peng et al., *FP8-LM: Training FP8 Large Language Models* (2023) — per-tensor fp8
end to end, the recipe the fp8-GEMM section builds toward. <https://arxiv.org/abs/2310.18313>
- Rouhani et al., *Microscaling Data Formats for Deep Learning* (2023) — the MX
family built above: a block of 32 elements sharing one E8M0 power-of-two scale,
$v_i = X\,P_i$, and the OCP Algorithm 1 quantizer. <https://arxiv.org/abs/2310.10537>
- Open Compute Project, *Microscaling Formats (MX) Specification v1.0* (2023) — the
ratified standard: E8M0 scales, E2M1/E2M3/E3M2 elements, MXINT8.
<https://www.opencompute.org/documents/ocp-microscaling-formats-mx-v1-0-spec-final-pdf>
- NVIDIA, *Introducing NVFP4 for Efficient and Accurate Low-Precision Inference* (2024)
— the Blackwell 4-bit variant: 16-element microblocks, an E4M3 block scale, and a
second per-tensor fp32 scale.
<https://developer.nvidia.com/blog/introducing-nvfp4-for-efficient-and-accurate-low-precision-inference/>
- NVIDIA et al., *Pretraining Large Language Models with NVFP4* (2025) — the two-level
recipe in full: the global $s_{\text{global}} = \text{amax}/(448\cdot 6)$ encode
scale, per-block E4M3, and stability tricks for training in 4-bit.
<https://arxiv.org/abs/2509.25149>
- DeepSeek-AI, *DeepSeek-V3 Technical Report* (2024) — end-to-end fp8 training with
fine-grained tile/block scaling (a cousin of MX, *not* the MX format).
<https://arxiv.org/abs/2412.19437>
**Practical Resources:**
- *AutoAWQ* / *llm-awq* — the reference AWQ implementations that ship most int4
open weights. <https://github.com/mit-han-lab/llm-awq>