WORKBENCH
05 · InferenceLesson 5.7

Quantization.

What do you lose when you store weights in 8 or 4 bits, and what do you gain?

Listen · 14 min0:00 / 13:33

A model is billions of numbers, and quantization means storing each of them with fewer digits: rounding every weight to one of a small set of allowed values, the way you might write 14 cm instead of 14.3. Done well, the model shrinks to a quarter of its size and you can barely tell. Done naively, it forgets how to speak.

On your Spark this is the biggest single lever you have. Writing each word-piece of an answer means reading every weight out of memory once, so the speed of your server is mostly the size of the model divided by how fast memory can be read. Shrink the model four times and the ceiling on your scoreboard moves from about 17 word-pieces per second to something near 55, without touching the server. It is also why the open models people actually run come as a menu of 8-bit and 4-bit files, and why it is worth asking how many bits a hosted provider like Fireworks keeps per weight before you compare your speed against theirs.

This builds directly on Why ~17 tokens per second?, where you found that reading bytes, not doing math, sets the speed, and it adds rows to the scoreboard from Baseline: my Spark vs Fireworks. fp32, bf16, fp16, fp8 covers how computers store numbers in the first place. Next, Speculative decoding attacks the same speed limit from a different side, and Inside vLLM and SGLang shows how real engines combine the two.

Today

Before this, read Why ~17 tokens per second? (bytes per token sets the speed) and set up bench.py and the scoreboard from Baseline: my Spark vs Fireworks. Skimming fp32, bf16, fp16, fp8 helps, but everything needed is explained here. Plan on two evenings: one on your Mac for the hand-built part, one on the Spark for serving and evals.

The Problem

Your scoreboard has a ceiling on it. Llama 3.1 8B in bf16 is about 16 GB, the Spark’s spec sheet says 273 GB/s, and you can’t decode faster than about 17 tokens per second no matter how clever the server is. The bandwidth lesson told you the cheapest way past it: read fewer bytes. Store each weight in 4 bits instead of 16 and the same pipe should carry you to something like 55 tok/s.

So you try the obvious thing. Take every weight matrix, find its biggest value, divide everything so the biggest value lands on 7, round to the nearest whole number, and store integers from −8 to 7. Four bits each. That is a real quantizer, and on paper it is fine.

I did exactly that to Qwen2.5 0.5B on my Mac. Its perplexityA score for how surprised a language model is by real text: e raised to the average negative log-probability per token. Lower is better. A perplexity of 14 means the model is, on average, about as unsure as picking among 14 equally likely tokens. on Wikipedia text went from 14.4 to 79 million. The model was not slightly worse. It was gone: it predicted tokens about as well as a random number generator with opinions.

Then I changed one thing. Instead of one scale for each whole matrix, I used one scale for every 128 weights. Same 4 bits. Perplexity: 20.0. Add a zero-point to each group and it was 17.2. And a popular 4-bit file of Llama 3.1 8B (llama.cpp’s Q4_K_M) is used by thousands of people every day and, on most chat tasks, is hard to tell from the original.

Same bit count, results that range from “unchanged” to “destroyed”. The whole lesson is about the gap between those, which comes down to three questions: which numbers share a scale, which numbers you quantize at all, and how you round.

The Idea

A ruler with fewer marks

Think of a ruler. A good one has marks every millimetre. A cheap one has marks every centimetre. Measure a pencil with the cheap one and you write down the nearest mark: 14 cm instead of 14.3. You can never be off by more than half a centimetre, and you only need to remember a small whole number.

quantizationStoring numbers with fewer bits by snapping each one to the nearest point on a coarse grid, and remembering only which grid point it was. is a cheap ruler for weights. A bf16 weight can be any of about 65,000 values. A 4-bit weight can be one of exactly 16. You choose where those 16 marks go, snap every weight to its nearest mark, and store the mark’s number. When you need the weight again you look up where mark number 9 was; that step is called dequantizationTurning the stored small integer back into an approximate real number, usually by multiplying by the scale. It happens on the fly, inside the kernel, right before the math..

The only question is where to put the marks. Put them too far apart and every weight moves a lot. Put them too close and the biggest weights fall off the end of the ruler.

Scale and zero-point

The standard ruler has evenly spaced marks, which makes it a uniform quantizerA quantizer whose grid points are evenly spaced. Every step between neighbouring levels is the same size.. Two numbers describe it completely.

With b bits you have 2^b integers, 0 through 2^b − 1. To cover the range from the smallest value to the largest:

s  = (max − min) / (2^b − 1)              the step
z  = round(−min / s)                       the integer that means 0.0

quantize:    q  = clamp(round(x / s) + z, 0, 2^b − 1)
dequantize:  x̂  = s × (q − z)

clampForce a value into a range: anything below the bottom becomes the bottom, anything above the top becomes the top. only matters for values outside the range you planned for. This is called affine quantizationUniform quantization with both a scale and a zero-point, so the grid can be shifted. Also called asymmetric. “Affine” means “multiply, then shift”.. Here it is on a number line, with a range of −0.6 to 0.9 and 4 bits.

FIG_001[ AFFINE 4-BIT, ON A NUMBER LINE ]
REAL VALUES x−0.60.00.9−0.30.30.6x = 0.33STORED q (4 BITS)0123456789101112131415z = 6s = (0.9 − (−0.6)) / 15 = 0.1q = round(0.33 / 0.1) + 6 = 9 · x̂ = 0.1 × (9 − 6) = 0.30
Sixteen evenly spaced marks cover the range from −0.6 to 0.9. The step is 0.1, and integer 6 stands for exactly 0.0. The value 0.33 rounds to integer 9, which dequantizes to 0.30. No value inside the range can be off by more than half a step.

Two things fall out of this picture.

The worst-case error is half a step. Any value inside the range is at most s/2 from a mark. If the weights are spread out in a reasonable way, the rounding error behaves like a random number spread evenly between −s/2 and +s/2, whose typical size (its root-mean-square) is s/√12, about 0.29 s. Every extra bit doubles the number of marks, halves the step, and halves the error. Going from 8 bits to 4 makes the error roughly 16 times bigger.

Zero is exact. Integer 6 decodes to exactly 0.0. That sounds like a nicety, but neural networks are full of real zeros (padding, ReLU outputs, masked entries), and a grid that can’t represent zero turns every one of them into a small wrong number.

When the values are roughly centred on zero, as weights almost always are, people drop the zero-point and use a grid symmetric around 0: integers −7 to 7 (or −8 to 7) with s = max|x| / 7. That is symmetric quantizationAffine quantization with the zero-point fixed at 0. One number (the scale) per group instead of two, and the math in the kernel gets a little simpler. The cost is wasted levels if the values are lopsided.. It wastes a little range when the values are lopsided, and saves storing the zero-point and a subtraction in the kernel. Most int8 and many int4 schemes are symmetric; most 4-bit GPTQ/AWQ checkpoints and llama.cpp’s k-quants use a zero-point or a “min” offset.

The simple scheme of computing s from the range and rounding every weight to its nearest mark has a name: round-to-nearestQuantize each weight independently to its closest grid point. No data, no optimization. Abbreviated RTN. The baseline every smarter method compares against., or RTN. It is what I did to Qwen above. Keep it in mind; we’ll do better later.

Predict firstwrite it down

A row of 4,096 weights is roughly bell-shaped with a standard deviation of 0.02, so almost all of them are between −0.06 and 0.06. One weight is 0.5. You quantize the row to symmetric int4 with s = max|x| / 7. Roughly what fraction of the weights get rounded to exactly 0?

Reveal

Most of them. The one big weight sets s = 0.5 / 7 ≈ 0.071. A weight rounds to 0 when its size is below s/2 ≈ 0.036, which is about 1.8 standard deviations. For a bell curve, that’s around 93% of the weights. All of them become 0, and the row is now mostly the outlier plus a few survivors. On a real Qwen2.5 0.5B MLP matrix quantized per-tensor to int4 (its biggest weight is about 21 standard deviations out), I measured 88% of weights rounded to zero, and a reconstruction error of 78%. The scale is set by the one number you care least about representing precisely.

Who shares a scale

That prediction is the outlier problem in one line. A shared scale is only as good as the widest value it has to cover. So the most important design choice in quantization is not the bit count: it is how many weights share each scale. There are three standard answers.

FIG_002[ WHO SHARES A SCALE ]
PER-TENSORONE s4096 × 4096: 1 SCALE+0 BITS/WEIGHTOUTLIER HURTS: 16.8MPER-CHANNEL (ROW)s₀s₁ …4096 SCALES+0.004 BITS/WEIGHTOUTLIER HURTS: 4,096PER-GROUP, g = 128131,072 SCALES+0.125 BITS/WEIGHTOUTLIER HURTS: 128SCALES STORED IN FP16 · MORE SCALES = FINER GRIDS = MORE BYTES
The same weight matrix with three granularities. Each shaded region shares one scale. A single outlier (the dot) widens the grid for everything in its region, so the smaller the region, the fewer weights pay. The cost is storing more scales.

Why rows and not columns? Because of how the matrix multiply adds things up. Output number i is the dot product of row i of W with the input vector. If every weight in row i shares one scale s_i, you can pull it out of the sum: compute the dot product in integers, then multiply the result by s_i once. A scale that changes along a column would land inside the sum and cost a multiply per weight. Groups cut a row into pieces, so the kernel does a small integer dot product per group and rescales each partial sum, a bit more work for a lot more accuracy.

And the cost of all those scales is not zero. A group of 128 weights at 4 bits is 512 bits; one fp16 scale is 16 more, so 4 + 16/128 = 4.125 bits per weight, and a 4-bit zero-point adds 4/128 more. That is why “4-bit” models are never exactly a quarter of the bf16 size. Keep this arithmetic handy, because it decides your speed: tokens per second is bandwidth divided by all the bytes, scales included.

Here it is on a real matrix. I took the first MLP matrix of Qwen2.5 0.5B (gate_proj in layer 0, 4,864 × 896) and quantized it with symmetric RTN at different granularities. The number is the relative reconstruction error, the size of the error matrix divided by the size of the original (lower is better):

Granularityint8int4
Per-tensor4.8%78.2%
Per-channel0.85%15.3%
Group 1280.68%12.3%
Group 320.55%9.9%
Group 128, affine0.61%10.3%

At 8 bits even per-tensor is survivable. At 4 bits the granularity is everything: going from one scale to one per row cuts the error five-fold, and groups of 128 cut it a bit more. Now play with it yourself.

FIG_003[ THE QUANTIZATION GRID ]
GROUP HOLDING W[0,40]0ONE CELL PER SCALE · DARKER = MORE ERRORROW = OUTPUT CHANNEL · 512 WEIGHTS EACH
scale per
step Δ -rounded to 0 -levels used -
error ‖W−Ŵ‖/‖W‖ -bits per weight -
A toy weight matrix: 4 output channels × 512 inputs, bell-shaped, each row with its own spread. Top: the histogram of whichever group contains weight [0, 40], with the grid's levels drawn as blue lines. Bottom: one cell per scale, darker meaning more error in that group. Turn on the outlier at 4 bits with per-tensor scales, then switch to per-channel and group 128. Then drag the bits.

Things to try, in order:

  1. 4 bits, group 128, no outlier. The histogram fills the grid nicely. Error around 13%.
  2. Turn on the outlier. In the focus group, the bell collapses into a thin spike in the middle and almost all of it lands on one level: over 90% of that group rounds to 0. But the other groups’ cells don’t change at all. The damage is contained to 128 weights.
  3. Switch to per-tensor. Now the outlier’s scale applies to all 2,048 weights. Overall error jumps to about 70%. That’s my 79-million-perplexity Qwen.
  4. Per-tensor, outlier off. Still bad (about 29%), because row 1 is spread wider than the others and its range sets the step for everyone. Per-channel fixes that even without outliers.
  5. Drag bits from 4 to 8 with the outlier on, per-tensor. 256 levels instead of 16 shrinks the step 17-fold; the spike spreads across many levels again. This is why 8-bit is forgiving and 4-bit is not.
  6. Toggle affine. It helps the most when a group’s values are lopsided, and costs a few more bits per weight in the readout.

Outliers are real, and they live in activations

The planted outlier was a toy. Are real models like that? Weights mostly aren’t badly: in that Qwen matrix the largest weight is about 21 standard deviations out, enough to ruin per-tensor 4-bit but easily handled by groups.

Activations are a different animal. The activationsThe numbers flowing between layers for a specific input: the vectors that get multiplied by the weights. Unlike weights, they change with every token, so you can’t inspect them all ahead of time. going into some layers have a few channels that are hundreds or thousands of times larger than the rest, on almost every token. I hooked the inputs of every linear layer in Qwen2.5 0.5B on 512 tokens of Wikipedia. Going into the MLP down-projection of layer 2, the typical activation had size 0.04, and channel 813 hit 1,631. That is 37,000 times the median.

Dettmers found the same thing in 2022 and called them outlier featuresA few hidden dimensions whose activations are consistently huge (he used magnitude 6 or more as the cut-off) across most tokens and layers. In the OPT models they appeared suddenly around 6.7B parameters and then dominated.. In his OPT experiments they showed up abruptly around the 6.7-billion-parameter scale, sat in the same few hidden dimensions across layers and tokens, and the model’s quality depended on them: zero them out and performance collapses. They are not noise. The model uses them on purpose, as a kind of control signal.

Here is what they do to activation quantization. I quantized the input of every linear layer of Qwen2.5 0.5B to int8 on the fly, weights untouched:

ActivationsPerplexity
Unquantized14.45
int8, one scale per token14.73
int8, one scale per tensor76.6

Per-token scales mostly survive; one scale for the whole activation matrix is wrecked by the outlier channels. And you can’t fix it with per-channel scales the way you did for weights, because of the row rule: for activations, a channel is a column of the input, which sits inside the dot product. Two papers found two ways out.

LLM.int8() (Dettmers, Lewis, Belkada, Zettlemoyer, 2022) splits the matrix multiply in two: the few outlier columns go through in fp16, the other 99.9% in int8, and the results are added. Accurate, and it made 175B models fit on far fewer GPUs, but the split makes it slower than plain fp16 at small batch. It is a memory saver, not a speed-up.

SmoothQuant (Xiao, Lin and colleagues, 2022) uses a bit of algebra. For any per-channel factors s_j, X W = (X / s) (s W): divide input channel j of the activations by s_j and multiply the weights that channel feeds by the same s_j, and the output is unchanged. Choose s_j large for the outlier channels and the activation spikes shrink, while the weights, which were easy to quantize, get a little harder. The paper picks s_j = max|X_j|^α / max|W_j|^(1−α) with α around 0.5, “migrating” the difficulty from activations to weights, and gets accurate int8 for both. Then the scales are folded into the previous layer, so at runtime it costs nothing.

Weights only, or weights and activations?

That distinction, weights only versus both, decides which part of inference gets faster. The naming convention is W for weights and A for activations, with bit counts:

Now recall the two halves of a request from the bandwidth lesson. Decode reads every weight once per token and does almost no math per byte: it’s memory-bound. Prefill reuses every weight across hundreds of prompt tokens: it’s compute-bound.

FIG_004[ WHERE THE BITS GET UNPACKED ]
WEIGHT-ONLY · W4A16MEMORYint4 W + scales¼ BYTESUNPACKs × (q − z) → bf16BF16 TENSOR CORESbf16 W × bf16 Xbf16 activationsDECODE: UP TO ~4× FASTERPREFILL: SAME OR A LITTLE SLOWERWEIGHTS + ACTIVATIONS · W8A8 / FP8 (FP4 ON BLACKWELL)MEMORYfp8 W + scales½ BYTES · NO UNPACKQUANTIZE Xbf16 → fp8 + scaleFP8 TENSOR CORES~2× BF16 RATE→ fp32 sum × s_W × s_XDECODE: UP TO ~2×PREFILL: UP TO ~2×THE MATH IS ALWAYS ACCUMULATED IN FP32 (OR INT32) AND RESCALED AT THE END
Weight-only formats shrink what crosses the memory pipe, then unpack to bf16 before the math. That wins in decode, where the pipe is the bottleneck, and does nothing for prefill, where the math is. Weight-and-activation formats shrink both the bytes and the math.

So:

Predict firstwrite it down

Llama 3.1 8B on the Spark, one user. Compare W4A16 (group 128) with FP8 W8A8. Which has the higher decode ceiling, and which has the faster time to first token on a 4,000-token prompt?

Reveal

Decode: W4A16. Roughly 4.7 GB per token (7 billion layer weights at 4.125 bits, plus the output projection, which is usually kept in bf16 at about 1 GB) versus about 8 GB for FP8. At 273 GB/s that is about 58 versus 34 tok/s ceilings.

Prefill: FP8. A 4,000-token prompt is far above the ridge point, so it’s limited by matrix-multiply speed. FP8 tensor cores run at roughly twice the bf16 rate; W4A16 does bf16 math after unpacking, so it is no faster than the bf16 model, and its unpacking costs a bit extra. The best format depends on which number your users feel.

Floats in 8 bits and 4 bits

Everything so far used integers on a uniform grid. The other family uses tiny floating-point numbers. A float has an exponentThe bits that say which power of two the number sits near. More exponent bits mean a wider range, from tiny to huge. and a mantissaThe bits that say where between two powers of two the number sits. More mantissa bits mean finer precision. Also called the significand.. Its grid is not uniform: the marks are dense near zero and spread out as numbers grow, so the relative error stays roughly constant. That suits bell-shaped weights well, since most of them are small.

FP88-bit floating point. Two standard flavours: E4M3 (4 exponent, 3 mantissa bits, more precision) and E5M2 (5 exponent, 2 mantissa bits, more range). Supported in tensor cores since Hopper (H100) and Ada. comes in two flavours, standardised by NVIDIA, Arm and Intel in “FP8 Formats for Deep Learning” (Micikevicius et al., 2022):

Even with FP8 you still keep a scale (per tensor, per channel, or per block), because 448 is the largest number you can write: you scale each tensor so its biggest value lands near the top of the format’s range, exactly like the integer case.

Four-bit floats go further. FP44-bit floating point, E2M1: 1 sign, 2 exponent bits, 1 mantissa bit. Its only positive values are 0, 0.5, 1, 1.5, 2, 3, 4 and 6. Useless alone; usable only with a fine-grained block scale. (E2M1) has exactly 15 values: ±0, 0.5, 1, 1.5, 2, 3, 4, 6, and the negatives. That is so few that it only works with small blocks, each carrying its own scale. Two block formats matter:

FIG_005[ THE FORMATS, BIT BY BIT ]
FORMATMAXTENSOR CORESBF168 EXP7 MAN3.4e38AMPERE+FP8 E4M343448HOPPER, ADA+FP8 E5M25257,344HOPPER, ADA+INT88 BITS127TURING+INT44 BITS7MOSTLY UNPACKEDFP4 E2M16BLACKWELL+ BLOCK SCALESIGNEXPONENTMANTISSAFP40.511.52346INT401234567POSITIVE HALF OF EACH GRID, STRETCHED TO THE SAME WIDTH
Bit layouts (sign, exponent, mantissa) and the largest value each can hold. Bottom: the eight positive values of FP4 next to the eight of INT4. The float grid is dense near zero, where most weights live; the integer grid is even. Both need a block scale to be useful: MXFP4 shares one per 32 values, NVFP4 one per 16.

What the Spark can do with these. GB10 is a Blackwell-generation GPU, and NVIDIA’s spec sheet lists FP4 support for its fifth-generation tensor cores; the headline “1 petaFLOP” is FP4, with sparsity. So, in hardware, the Spark can run NVFP4 and MXFP4 math natively, as well as FP8. Two cautions. First, the fast numbers are spec-sheet numbers and you should measure them, as you did for bf16 in the bandwidth lesson. Second, GB10 is not the same chip as the data-center B200: it reports a different CUDA compute capability (sm_121, if I have it right; check with nvidia-smi --query-gpu=compute_cap --format=csv), and some FP4 and FP8 kernels in vLLM, TensorRT-LLM and FlashInfer were written for the data-center parts first. Whether a given engine’s FP4 path runs on your Spark today is a docs-and-release-notes question, not a hardware question. Check the DGX Spark playbooks, which include tested quantized setups, before assuming.

For decode on one user, FP4 and INT4 weight-only land in the same place: about 4.5 bits per weight, about the same bandwidth ceiling. NVFP4’s real advantage is that the math also runs in 4 bits, which helps prefill and large batches.

Rounding smarter: GPTQ and AWQ

Round-to-nearest treats every weight alone. But a layer’s job is to produce outputs, not to preserve weights. If rounding one weight up pushes an output too high, you could round a neighbour down to compensate, and the output would barely move. Two methods do this kind of thing, using a small batch of real text called calibration dataA few hundred example inputs (typically 128 sequences of a few thousand tokens) run through the model once, to see what activations each layer actually receives. Used to choose scales and rounding, not to train..

GPTQ (Frantar, Ashkboos, Hoefler, Alistarh, 2022) quantizes a layer one column at a time. After it rounds a column, it measures the error that rounding introduces in the layer’s outputs on the calibration data, and nudges all the not-yet-quantized columns to cancel as much of it as possible. Which nudge is best depends on how the input features move together, captured in the matrix XᵀX of the calibration inputs (the HessianHere: the matrix 2XᵀX built from calibration inputs X. It says how much the layer’s output error grows when you perturb each pair of weights, so GPTQ can tell which errors other weights can cancel. of the layer’s squared error). The idea comes from 1990s pruning work (“Optimal Brain Surgeon”); GPTQ’s contribution was making it fast enough to run on 175 billion weights in a few hours. It is still a one-shot procedure: no gradient descent, no retraining.

AWQ (Lin, Tang and colleagues, MIT, 2023) starts from an observation: a small fraction of weights, around 1%, matter far more than the rest, and you can find them by looking at the activations, not the weights. The salientHere: the input channels whose activations are typically large, so errors in the weights they multiply get amplified in the output. input channels are the ones whose activations are large. Keeping that 1% in fp16 would work but breaks the neat kernel. Instead AWQ uses the SmoothQuant trick in reverse: scale those weight columns up by a factor before quantizing (and divide the matching activations by the same factor), so they use more of the grid and lose less relative precision. A small grid search over the scaling strength per layer, on calibration data, and you’re done. No reconstruction, no Hessian, quick to run. AWQ and GPTQ both typically use 4-bit, group 128 with a zero-point, and both are supported by vLLM and others with fast kernels.

On small models, both clearly beat RTN at 4 bits; at 8 bits RTN is already nearly lossless, which is why FP8 checkpoints are usually just “compute a scale and round.”

GGUF and k-quants

If you’ve downloaded models for llama.cpp you’ve seen names like Q4_K_M. GGUFThe single-file model format used by llama.cpp (and Ollama, LM Studio): weights, tokenizer and metadata in one file, with many quantization types per tensor. files hold weights in one of llama.cpp’s own block formats, all of them per-group quantization with different packing:

llama.cpp can also take an importance matrix (imatrix), computed from calibration text, which weights the rounding error of each column by how much its activations matter: the same instinct as AWQ and GPTQ. The “IQ” formats (IQ4_XS, IQ3, IQ2) push this further, with non-uniform grids, and are where to look if you want to fit a 70B model into less memory. Check llama.cpp’s docs for the current list; it grows.

What you lose, and how to tell

Here is what RTN did to Qwen2.5 0.5B, weights only, perplexity on 40,960 tokens of WikiText-2 (lower is better):

WeightsPerplexity
fp32 original14.45
int8, per-channel14.49
int8, per-tensor15.12
int4, per-channel31.7
int4, group 128, symmetric20.0
int4, group 128, affine17.2
int3, group 128, symmetric206

8 bits is nearly free. 4 bits with groups and a zero-point costs real but survivable quality, on a 0.5B model. Small models suffer more from quantization than large ones; an 8B model at 4-bit group 128 typically loses far less, and that’s part of what you’ll measure on the Spark. At 3 bits RTN falls off a cliff, which is exactly where GPTQ, AWQ and llama.cpp’s importance-weighted formats earn their keep.

But perplexity is a blunt instrument, and the losses that matter are often not where it looks:

The honest way to report a quantized model is: bits per weight including overhead, method, granularity, perplexity and KL vs the original, accuracy on a task you care about with a confidence interval, and the flip rate. Is that improvement real? covers the confidence interval part.

The cost of unpacking

Dequantization isn’t free. For each weight the kernel has to pull out 4 bits, convert to bf16, subtract the zero-point and multiply by the scale. If that’s done carelessly (unpack the whole matrix to bf16 in memory, then multiply) you’ve written the full bf16 matrix back to memory and read it again, and you’d be slower than bf16. The point of a good kernelA function that runs on the GPU. A quantized matmul kernel reads packed weights, unpacks them in registers, and multiplies, all in one launch so the unpacked weights never touch main memory. is to fuse it: read packed bytes, unpack in registers right before the tensor-core instruction, never write the bf16 copy anywhere.

MarlinA fast mixed-precision (int4 weights × fp16 activations) matrix-multiply kernel by Elias Frantar and Dan Alistarh, 2024. Designed to stay near the ideal 4× speed-up over fp16 up to batch sizes of about 16-32. Used in vLLM. (Frantar and Alistarh) is the reference example: it lays out the int4 weights in memory in exactly the order the tensor cores want them, overlaps loading the next tile with computing the current one, and stays close to the ideal ~4× decode speed-up up to batches of 16 to 32. Past that, the matmul becomes compute-bound and W4A16’s edge fades. llama.cpp has its own CUDA kernels for each GGUF type, which matter just as much: the same Q4_K_M file can run at quite different speeds depending on build flags and version.

The practical upshot: a quantized model is only as fast as its kernel on your GPU. The bandwidth prediction is a ceiling. If you’re far below it, suspect the kernel (a fallback path, an unsupported compute capability, unfused dequant) before you suspect the format.

Build It

Two halves. On your Mac, you build quantization by hand on a small real model and see each effect from The Idea yourself. On the Spark, you serve Llama 3.1 8B Instruct (the scoreboard model) in several formats and measure speed and quality.

inference/
  quant/
    q.py            # your quantize / dequantize functions
    recon.py        # reconstruction error table
    outliers.py     # find outlier channels in weights and activations
    rtn_ppl.py      # quantize a whole model by RTN, perplexity sweep
    eval_small.py   # quality eval against any OpenAI-compatible server
  results/          # bench.py output for each format
  SCOREBOARD.md
Set up a small model and some textAgent: fine

Everything on the Mac runs on CPU in float32; it’s slow but fine for a 0.5B model.

python3 -m venv .venv && source .venv/bin/activate
pip install torch transformers datasets numpy
python -c "
from transformers import AutoModelForCausalLM, AutoTokenizer
from datasets import load_dataset
AutoTokenizer.from_pretrained('Qwen/Qwen2.5-0.5B'); AutoModelForCausalLM.from_pretrained('Qwen/Qwen2.5-0.5B')
load_dataset('Salesforce/wikitext', 'wikitext-2-raw-v1', split='test')
print('ok')"

Qwen2.5 0.5B uses plain torch.nn.Linear layers with weight shape [out_features, in_features], so “a row” is one output channel. (GPT-2 in transformers uses a Conv1D with the transpose; avoid it here to keep your head straight.) Each of its hidden sizes (896 and 4,864) divides by 128 and by 32.

Write quantize and dequantize yourselfHands only

In q.py, write the four functions below. Work in PyTorch on a 2-D weight w of shape [out, in]. Return the dequantized tensor (same shape and dtype as w) so you can compare directly; also return the integer codes and scales if you want to inspect them.

import torch

def quant_sym(w: torch.Tensor, bits: int, group: str | int) -> torch.Tensor:
    """Symmetric RTN. group is 'tensor', 'channel', or an int group size along `in`.
    Levels are -2**(bits-1) .. 2**(bits-1)-1, s = max|w| / (2**(bits-1)-1) per group."""
    ...

def quant_affine(w: torch.Tensor, bits: int, group: int) -> torch.Tensor:
    """Affine RTN per group: s = (max-min)/(2**bits-1), z = round(-min/s), q in [0, 2**bits-1]."""
    ...

def rel_err(w: torch.Tensor, w_hat: torch.Tensor) -> float:
    """||w - w_hat|| / ||w|| (Frobenius norms)."""
    ...

def bits_per_weight(bits: int, group: int, scale_bits: int = 16, zero_bits: int = 0) -> float:
    ...

Hints:

  • For groups, w.reshape(out, in // g, g) makes the last axis one group. amax(dim=-1, keepdim=True) gives the per-group max with a shape that broadcasts. Reshape back at the end.
  • torch.round rounds halves to even. That’s fine, just know it.
  • Guard against a group of all zeros (s = 0): s.clamp_min(1e-12) or similar.
  • Test on something you can check by hand first: the Figure 001 example. torch.tensor([[-0.6, 0.0, 0.33, 0.9]]) with affine 4-bit and one group of 4 should give back [-0.6, 0.0, 0.3, 0.9].

Then recon.py: load the model, take model.model.layers[0].mlp.gate_proj.weight, and print the table from The Idea for int8 and int4 at per-tensor, per-channel, group 128 and group 32, plus affine group 128. Then plant an outlier, w[0, 0] = 20 * w.abs().max(), and rerun int4 per-tensor, per-channel and group 128. Before you run, predict which numbers move.

Find the outliers in a real modelHands only

outliers.py has two parts.

Weights. For every nn.Linear in the model (skip lm_head), compute w.abs().max() / w.std(): how many standard deviations the biggest weight sits out. Print the ten worst layers. Then for the worst one, find which input columns hold the biggest weights (w.abs().amax(dim=0).topk(5)).

Activations. Register a forward hook on every linear layer that records, for its input, the max absolute value per channel across all tokens, and the median absolute value overall:

stats = {}
def make_hook(name):
    def hook(module, inputs, output):
        x = inputs[0].detach().reshape(-1, inputs[0].shape[-1]).abs()
        ...  # stats[name] = (per-channel max over tokens, overall median)
    return hook
handles = [m.register_forward_hook(make_hook(n)) for n, m in model.named_modules()
           if isinstance(m, torch.nn.Linear) and "lm_head" not in n]

Run 512 tokens of WikiText through the model under torch.no_grad(), remove the hooks, and print the layers with the biggest max ÷ median ratio, with the channel indices. Questions to answer in your notes: are the same channel indices big across different layers? Across different input texts (try a code snippet)? Which layer types (q/k/v projections, o_proj, gate_proj/up_proj, down_proj) have the worst inputs, and why might down_proj, whose input is the output of the gated MLP’s multiply, be special?

RTN a whole model and watch perplexityHands only

rtn_ppl.py: write a perplexity function, then quantize every linear layer’s weight in place (keep a copy of the originals so you can restore between runs) and sweep formats.

def perplexity(model, ids: torch.Tensor, n_tokens: int = 40_960, ctx: int = 1024) -> float:
    """Non-overlapping windows of ctx tokens over the first n_tokens of ids.
    model(x, labels=x).loss is the mean NLL over the window's predicted tokens (ctx - 1 of them).
    Weight each window's loss by its token count, average, exponentiate."""
    ...

Sweep: original; int8 per-channel; int8 per-tensor; int4 per-tensor; int4 per-channel; int4 group 128 symmetric and affine; int3 group 128. Predict the ordering before you run. On an M-series Mac each perplexity pass takes on the order of 30 seconds; the whole sweep took me about four minutes.

Then the activation experiment: add a register_forward_pre_hook to every linear that replaces its input with an int8-quantized-and-dequantized copy, once with one scale per token (max over the last dimension) and once with one scale for the whole tensor. Weights unquantized. You should be able to reproduce the “76” row.

Stretch: a tiny AWQAgent: review after

Make RTN int4 group 128 better without changing the format. For each linear layer, from the activation stats you collected in t2, compute the mean absolute activation per input channel, a_j. Then, for a strength α in [0, 0.25, 0.5, 0.75, 1.0]:

  1. Multiply column j of W by s_j = a_j ** α (normalize s so its mean or geometric mean is 1).
  2. Quantize the scaled W with your int4 group-128 function, then divide column j back by s_j. (In a real deployment you’d fold 1/s_j into the previous layer instead; here dividing back is mathematically the same thing.)
  3. Measure the layer’s output error on calibration inputs, ‖X Wᵀ − X Ŵᵀ‖, and keep the best α for that layer.

Then rerun perplexity. Did it beat plain RTN group 128? Have an agent review your code for the classic mistake (calibrating and evaluating on the same tokens, which flatters the result) and for whether you scaled columns or rows.

Serve the same model in bf16, fp8 and 4-bit on the SparkAgent: explain only

Now the real thing, with Llama 3.1 8B Instruct so it lines up with your scoreboard. You want at least these, each serving the same OpenAI-compatible API your bench.py already speaks:

FormatHow (shape only; check current docs)
bf16vLLM: vllm serve meta-llama/Llama-3.1-8B-Instruct
FP8 (W8A8)vLLM’s online FP8: add --quantization fp8 (dynamic per-tensor scales computed at load time), or a pre-quantized FP8 checkpoint from Hugging Face
4-bit weight-onlyvLLM with an AWQ or GPTQ int4 checkpoint of the same model (search Hugging Face; vLLM detects the format from the checkpoint’s config)
GGUF Q8_0 and Q4_K_Mllama.cpp’s llama-server -m model.gguf, which also speaks the OpenAI API
NVFP4 (optional)Only if your engine’s release notes say FP4 works on GB10. NVIDIA publishes NVFP4 checkpoints for some models.

Before anything: read the DGX Spark playbooks and user guide on NVIDIA’s site. They include tested containers and commands for vLLM and llama.cpp on GB10, and they are much more likely to be right than this table: the ARM64 + Blackwell combination is new enough that the generic install instructions often don’t apply. Confirm the GPU’s compute capability and which quantization kernels the engine actually selected: vLLM logs the quantization method and kernel at startup. Read them, and write them down.

For each format, record the actual bytes: the size of the checkpoint on disk, or better, the weight memory the engine reports after loading. That’s your bytes per token for the prediction.

Then run your harness from the baseline lesson against each one, at c = 1 and c = 16, short prompts, 256 tokens, ignore_eos, and once with prompts/long.jsonl at c = 1 to see prefill. Keep everything else fixed: same prompts, same sampling, same server flags apart from the format.

A small quality eval you can trustHands only

Speed without quality is meaningless. Write eval_small.py: it takes a base URL and model name, runs a fixed set of questions through the chat API at temperature 0, and scores them.

  • Math: the first 200 problems of the GSM8K test set (on Hugging Face as openai/gsm8k). Prompt for step-by-step reasoning ending in “The answer is N”. Extract the last number; compare to the reference. Allow 512 output tokens.
  • Flips: save every answer per format. Report not just accuracy but how many questions changed from right to wrong and wrong to right compared with bf16.
  • Confidence: a bootstrap 95% interval on accuracy (resample the 200 questions with replacement 1,000 times). With n = 200 the interval will be several points wide; that’s the point. Two formats whose intervals overlap heavily are not demonstrably different. For a sharper comparison, a paired test on the per-question results is more powerful than comparing intervals.
def score(answers: list[str], refs: list[str]) -> list[bool]: ...
def bootstrap_ci(correct: list[bool], n_boot: int = 1000) -> tuple[float, float]: ...
def flips(base: list[bool], other: list[bool]) -> tuple[int, int]:
    """(right->wrong, wrong->right)"""

Optional, and more sensitive: for the GGUF files, run llama.cpp’s llama-perplexity on the WikiText-2 test file for each quant, with the KL-divergence mode against the F16/BF16 model’s saved logits. Its docs describe the two-step process (save base logits, then compare).

If you’d rather use a standard harness, EleutherAI’s lm-evaluation-harness can talk to an OpenAI-compatible local server; check its docs for the current model type name. Write your own first anyway: you should know exactly what is being scored.

Append to the scoreboardAgent: fine

One row per format, using scoreboard.py from the baseline lesson. Put the format, its bits per weight, the engine and its quantization kernel in the notes column, plus the GSM8K accuracy with its interval:

|      | spark | vLLM bf16 |  |  |  | bf16, 16.1 GB weights |
|      | spark | vLLM FP8 (W8A8, online) |  |  |  | ~8.0 GB, GSM8K __ ± __ |
|      | spark | vLLM AWQ int4 g128 |  |  |  | ~4.7 GB, kernel: __, GSM8K __ ± __ |
|      | spark | llama.cpp Q4_K_M |  |  |  | 4.9 GB, GSM8K __ ± __ |

Note that the vLLM bf16 row is itself a big change from the naive server: Inside vLLM and SGLang covers why. The rows that isolate this lesson are the format rows compared with the vLLM bf16 row, all on the same engine.

Done when
  • Your quant_affine reproduces Figure 001 exactly, and recon.py prints a granularity table close to the one in The Idea.
  • You can name the worst outlier channel in Qwen2.5 0.5B’s activations and say which layer type it feeds.
  • rtn_ppl.py prints the perplexity sweep, including the per-tensor int4 disaster and the activation per-tensor row.
  • At least three formats of Llama 3.1 8B served on the Spark, each with a measured decode speed, a prediction next to it, and a GSM8K accuracy with an interval and a flip count against bf16.
  • SCOREBOARD.md has the new rows.

Measure

On the Mac, compare against my numbers (Qwen2.5 0.5B, fp32 base, CPU, WikiText-2 test, first 40,960 tokens, 1,024-token windows):

Format (weights, RTN)My perplexityYours
original14.45
int8 per-channel14.49
int8 per-tensor15.12
int4 per-tensor~7.9e7
int4 per-channel31.7
int4 g128 symmetric20.0
int4 g128 affine17.2
int3 g128 symmetric206
activations int8 per-token (weights original)14.73
activations int8 per-tensor (weights original)76.6

Your numbers should match closely if your windows and token counts match; small differences in how you count tokens at window edges shift the second decimal.

On the Spark, predict first, using the bandwidth lesson’s formula with the spec-sheet 273 GB/s and with your measured bandwidth. Bytes per token ≈ weights read per token: everything except the embedding table, which is just a lookup. For Llama 3.1 8B that’s about 7.0 billion layer weights plus the 0.53-billion-weight output projection, which most quantized checkpoints keep in bf16.

FormatBytes / token (approx.)Ceiling @ 273Ceiling @ measured BWMeasured tok/s (c=1)% of ceilingTTFT, long promptThroughput (c=16)GSM8K (95% CI)Flips vs bf16
bf16~15.0 GB~18—
FP8 W8A8~8.0 GB~34
int4 AWQ/GPTQ g128~4.7 GB~58
GGUF Q8_0~8.5 GB~32
GGUF Q4_K_M~4.9 GB~56
NVFP4 (if it runs)~5.0 GB~55

How these were estimated: 7.0e9 × 1 byte + 1.05 GB for FP8; 7.0e9 × 4.125/8 + 1.05 GB for int4 group 128; the file size for GGUF. Use the engine’s reported weight memory instead if it differs.

What you should see, and why:

Break It

1. Quantize per-tensor at 4 bits. You already did it on the Mac. Now do it on purpose with a story: print the fraction of weights that round to zero for the worst layer, and generate a few sentences from the model. Read the output. This is what “one scale shared by too many weights” feels like.

2. Go down to 2 or 3 bits with RTN, then with GPTQ. RTN at 3 bits on the 0.5B model gave me a perplexity of 206. If you have time on the Spark, quantize a model to 3-bit with a GPTQ tool (several libraries do it; check which currently support your setup) and compare with RTN at the same bits and group size. The gap between them is the value of error compensation, and it grows as bits shrink.

3. Calibrate on the wrong data. Calibrate an AWQ or GPTQ quantization (or your tiny AWQ from t4) on 128 samples of Python code, then evaluate perplexity on Wikipedia, and the other way round. Then try 8 calibration samples instead of 128. Calibration data decides which channels count as important; the wrong data makes the wrong ones important.

4. Quantize what you shouldn’t. Quantize the embedding table and the output projection (lm_head) too, per-tensor, and watch what happens to perplexity. Then, on the Spark, quantize the KV cache to 8-bit (vLLM’s --kv-cache-dtype fp8, check docs) and run a long-context retrieval test: hide a sentence in a 30,000-token document and ask for it. The bytes saved are real (see the bandwidth lesson’s KV numbers); so are the errors.

Explain It Back

1. What is quantization, in one breath, and what does the scale do?

Storing each number with fewer bits by snapping it to the nearest point on a coarse, evenly spaced grid and storing only the point’s integer index. The scale is the spacing of the grid in real units: dequantizing is integer times scale (minus the zero-point first, if there is one). The scale sets the trade-off: a big scale covers a wide range but rounds coarsely; the maximum rounding error for in-range values is half the scale. Each bit halves the scale for the same range, so 4-bit has 16× the error of 8-bit.

2. Why does one outlier ruin per-tensor quantization, and what do people do about it?

The scale has to stretch to cover the biggest value, so one weight 20× larger than the rest makes the step 20× coarser for everyone sharing that scale, and most ordinary weights round to zero. The fixes: share scales among fewer weights (per-channel, then per-group of 128 or 32, so an outlier only damages its own group); keep outliers in higher precision (LLM.int8() for activation outlier channels); move the difficulty somewhere easier with an exact rescaling (SmoothQuant moves activation outliers into the weights; AWQ scales up important weight columns); or round cleverly so neighbouring weights compensate (GPTQ). Activation outliers are harder than weight outliers because they show up per input channel, which is inside the dot product, and change per token.

3. Which format would you pick for a single-user chat on the Spark, and which for a busy server on H100s? Why are they different?

Single user: decode dominates and it’s memory-bound, so the fewest bytes wins: a 4-bit weight-only format (AWQ/GPTQ int4 group 128, or GGUF Q4_K_M) gets close to 4× the bf16 decode ceiling, and its unchanged bf16 math doesn’t matter because the math units are idle anyway. Busy server: large batches push decode towards compute-bound, and prefill is a big share of the work, so you want the math itself to be cheaper. FP8 W8A8 halves both bytes and math cost and loses almost nothing in quality, which is why it’s the default for big fleets on Hopper, with FP4 (NVFP4) on Blackwell the next step. On the Spark, which is Blackwell, NVFP4 is attractive for both if the engine supports it on GB10.

4. How does the speed-up from quantization scale with batch size?

At batch 1, tokens/sec is bandwidth over bytes, so halving bytes nearly doubles speed (minus fixed overheads). As batch grows, each weight read serves more tokens, so arithmetic intensity grows and you move up the roofline. Once you are near the ridge point, the matmul is limited by math, not bytes. A weight-only format then has no advantage left (its math is bf16, plus unpacking) and can be slower than bf16; a W8A8/FP8 format still runs about twice as fast as bf16 because its math is cheaper. In between, the KV cache also grows with batch and isn’t touched by weight quantization, which shrinks the share of bytes the weight format controls. So: weight-only quantization is a small-batch win; weight-and-activation quantization keeps paying at scale.

5. Someone says “4-bit is lossless, perplexity only went up 2%.” What do you ask?

Which model size (small models suffer more), which method and group size, and how many bits per weight really, including scales. Then: perplexity on what text, and what’s the KL divergence against the original, which catches changes perplexity averages away. On a task I care about, what’s the accuracy with a confidence interval, and how many answers flipped compared with the original; equal accuracy can hide many changed answers. And does it hold at long context and on multi-step math or code, where small errors compound. “Lossless” should mean “no difference I can detect with this eval at this sample size,” and I’d want to know the sample size.

Go Deeper

Connections

This lesson is the first escape route from Why ~17 tokens per second?: fewer bytes per weight. The predictions are the same division, with a smaller numerator, and the rows go on the scoreboard from Baseline: my Spark vs Fireworks.

The float formats get their full treatment in fp32, bf16, fp16, fp8, and the same 8-bit ideas show up for optimizer states in Why training needs so much memory. How much memory quantization frees, and how many more users and how much more context fit, is How much memory does a model need?. Why weight-only formats lose their edge at high batch is Batching. The kernels that make all of this fast, and why a naive dequantize is slower than bf16, are the subject of FlashAttention and kernels and Inside vLLM and SGLang. And deciding whether a 2-point drop on GSM8K is real is Is that improvement real?.