Measuring the Damage: Evaluations That Catch Quantization Errors
On all 1,319 GSM8K problems, eight quantized versions of Qwen3-8B scored within 0.7 points of the original; only the 2-bit one fell further. Read that way, 4-bit is free. It isn't: compared problem by problem, every format changed dozens of answers, and on a code benchmark most of them lost 4 to 8 points, FP8 included. This tutorial builds the four checks that showed it, with the code, so you can run them on your own model and formats.
The setup for everything below: an NVIDIA GB10, vLLM 0.31.0, Qwen3-8B in BF16 as the reference, and eight formats of the same model, the checkpoints of our size, speed, and quality sweep: FP8 (Qwen's block-scaled checkpoint), INT4 with AWQ, GPTQ, and plain round-to-nearest (group 128), NVFP4, and three GGUF files (Q4_K_M, Q3_K_M, Q2_K). Every evaluation is greedy, so differences come from the model, not from sampling.
Step 1: measure the noise first
Before blaming a format for a difference, know how much two runs of the same model differ. Greedy decoding on a serving engine is not bit-for-bit reproducible: requests are batched with whatever else is running, and a different batch shape changes the order of floating-point additions, which can flip a close choice between two tokens, and from there the rest of the answer.
So we served BF16 twice and ran everything twice:
| BF16 against BF16 | Result |
|---|---|
| Token log-probabilities (81,880 positions) | Identical: top-1 agreement 100%, KL 0 |
| GSM8K, 1,319 problems | 92.7% then 93.1%: 5 problems lost, 10 gained |
| GSM8K with thinking, 200 problems | 96.0% both times, no flip |
| HumanEval, 164 problems | 87.2% then 87.8%: 1 gained |
Two lessons. Scoring a fixed text (log-probabilities over a prompt) is deterministic here, so any difference in it is the format's. Generating answers is not: 15 GSM8K answers change between identical runs. A format whose GSM8K results differ from BF16 by a handful of problems has shown nothing.
Step 2: token-level divergence, the cheap ranking
The most sensitive check doesn't generate anything. Send the same text to both models and compare, at every position, the probability each one gives to the possible next tokens. vLLM returns them with prompt_logprobs:
# scripts/gx10/quant_evals.py (abridged): the same 40 windows of 2,048 WikiText-2 tokens for every model
r = await client.completions.create(model="m", prompt=window_token_ids, max_tokens=1, temperature=0,
extra_body={"prompt_logprobs": 20})
plp = r.model_extra.get("prompt_logprobs") or r.choices[0].model_extra.get("prompt_logprobs")
positions = [{tok: v["logprob"] for tok, v in pos.items()} for pos in plp[1:]] # top 20 per position
For each position, two numbers against the reference's top 20:
- top-1 agreement: does the format rank the same token first?
- KL divergence, KL(BF16 ‖ format) = Σ p_BF16(t) · (log p_BF16(t) − log p_format(t)) over BF16's top 20 tokens. A token missing from the format's top 20 gets the format's 20th log-probability, an approximation that tends to underestimate the true KL.
| Format | KL from BF16 | Top-1 agreement | Perplexity |
|---|---|---|---|
| FP8 block | 0.014 | 95.0% | 9.83 |
| GGUF Q4_K_M | 0.025 | 93.3% | 9.82 |
| INT4 GPTQ | 0.055 | 90.7% | 10.05 |
| INT4 AWQ | 0.056 | 90.2% | 10.18 |
| GGUF Q3_K_M | 0.082 | 88.4% | 10.19 |
| NVFP4 | 0.095 | 88.1% | 10.14 |
| INT4 round-to-nearest | 0.119 | 86.0% | 10.29 |
| GGUF Q2_K | 0.213 | 81.7% | 10.84 |
(BF16's perplexity is 9.72, from the sweep.) KL and perplexity rank the formats almost the same way, but KL measures the right thing: how far the format's predictions moved from the reference, position by position, rather than how well either model predicts the text. It ran in a few minutes per format, with no answers to grade, with no answers to grade. Use it to sort candidates (on HumanEval, FP8 had the lowest KL and still lost 5.5 points, while GPTQ, at four times the KL, lost 0.6); it can't tell you whether a given shift matters for your task.
Step 3: compare answers in pairs, and test the difference
Accuracy on GSM8K was flat. The paired view, each problem compared with BF16's answer, tells a different story:


Every format changed 50 to 105 answers, three to seven times the noise floor, but in both directions, so the score stayed put. That is the pattern Dutta et al. called flips: the quantized model is a different model that happens to score the same. Whether the difference is real is a statistical question with a standard answer for paired results, McNemar's test: under "no difference", a problem that one model solves and the other doesn't is equally likely to go either way.
from math import comb
def mcnemar_p(lost: int, gained: int) -> float:
"""Exact two-sided McNemar test: probability of a split at least this uneven if both models were equal."""
n, k = lost + gained, min(lost, gained)
return min(1.0, 2 * sum(comb(n, i) for i in range(k + 1)) / 2 ** n)
print(mcnemar_p(77, 28)) # GGUF Q2_K on GSM8K: 1.853e-06
print(mcnemar_p(33, 25)) # NVFP4 on GSM8K: 0.36
On GSM8K, only Q2_K's loss is significant. Everything else is churn: you can't tell those formats from BF16 on this benchmark, which is not the same as their being as good.
Step 4: add a benchmark that punishes small errors
Math word problems tolerate a slightly different phrasing on the way to the answer. Code doesn't: one wrong token, a misnamed variable or a missing bracket, fails the tests. We ran HumanEval (164 Python functions, the original tests), each completion run in a separate Python process (python -I) in a temporary directory, with a 10-second timeout:


| Format | HumanEval | Lost / gained vs BF16 | McNemar p |
|---|---|---|---|
| BF16 | 87.2% | ||
| BF16, run again | 87.8% | 0 / 1 | 1.0 |
| FP8 block | 81.7% | 10 / 1 | 0.012 |
| GGUF Q4_K_M | 82.9% | 9 / 2 | 0.065 |
| INT4 GPTQ | 86.6% | 5 / 4 | 1.0 |
| INT4 AWQ | 82.3% | 12 / 4 | 0.077 |
| GGUF Q3_K_M | 82.3% | 10 / 2 | 0.039 |
| NVFP4 | 79.3% | 17 / 4 | 0.007 |
| INT4 round-to-nearest | 80.5% | 15 / 4 | 0.019 |
| GGUF Q2_K | 79.9% | 18 / 6 | 0.023 |
Here the losses are one-sided: formats lose problems BF16 solved and rarely gain any. FP8, the format usually called lossless, lost 10 and gained 1 (p = 0.012). GPTQ was the exception, statistically indistinguishable from BF16, while AWQ, at the same KL, lost 12. Taken one at a time, any of these p-values could be luck among eight formats tested; what is not luck is the direction: seven of eight formats lost more than they gained, and BF16 run twice lost none. The losses also cluster: ten problems were lost by four formats or more, so a handful of fragile tasks carries much of the difference. With only 164 problems, borderline cases (p between 0.05 and 0.1) need a larger code set before you conclude either way.
Step 5: watch the length of long answers
With thinking on, GSM8K answers run to about 1,100 tokens, and errors compound over them. Accuracy on 200 problems separated only Q2_K (88.5% against 96.0%, 16 lost, 1 gained, p = 0.0003). But the length of the reasoning moved, longer or shorter, for several formats:
| Format | Mean tokens per answer | Hit the 6,144-token limit |
|---|---|---|
| BF16 | 1,092 | 2 |
| FP8 block | 1,005 | 2 |
| GGUF Q4_K_M | 916 | 2 |
| INT4 round-to-nearest | 1,220 | 5 |
| NVFP4 | 1,324 | 3 |
| GGUF Q2_K | 1,713 | 23 |
A model that reasons 21% longer to reach the same answers (NVFP4) costs 21% more decode time and tokens per request, which can cancel part of the speed its smaller weights bought. Record output length next to accuracy for any reasoning workload.
The recipe
For a new format of a model you already serve:
- Serve both the reference and the candidate with the same engine version, chat template, and generation settings.
- KL on a fixed text (a few minutes): rank candidates; drop the obvious outliers.
- Run the reference twice on your task suite: that is your noise floor.
- Paired task evaluation on several hundred problems, including at least one unforgiving task close to your use: code, structured output, exact retrieval. Test lost-versus-gained with McNemar, not the accuracy difference.
- For reasoning workloads, compare output lengths and truncations, not only answers.
Each format took 12 to 18 minutes for all four evaluations on our machine, and about an hour for the GGUF files, which run slowly on vLLM.
Gotchas
The chat template is part of the model. GGUF files and some community checkpoints ship their own templates; an evaluation that silently uses a different one measures the template. We passed the reference tokenizer to every GGUF run (--tokenizer Qwen/Qwen3-8B).
Truncation looks like a wrong answer. A reasoning answer cut at the token limit has no final answer to grade. Count truncations separately, and give long-reasoning evaluations a generous limit.
Running generated code needs isolation. HumanEval executes what the model wrote. Run it in a throwaway directory with a timeout, at least; in a container if the model or the prompts are untrusted.
One benchmark's noise is not another's. Our noise floor was 15 flips on GSM8K and 1 on HumanEval. Measure it for each task you use; it depends on the task's length and on how close the model's choices are.
Weights and activations are not the only thing a serving stack rounds: the KV cache is quantized too, and quantizing the KV cache applies the same checks to seven cache formats.
This post is part of the Quantization in Practice collection (12 of 12)
Inference engineering and AI in production — depth-first, no hype.
Inference Notes
Once a week: what changed in serving AI models, and what it means if you build, run or pay for them, in about five minutes.
Related Articles
Benchmark an Inference Server Without Lying to Yourself
Common load-test mistakes made on purpose next to the correct run, on one vLLM server, so each rule comes with how far it moves the numbers.
Rotations for 4-Bit Activations: QuaRot, SpinQuant, and Friends
A rotation flattens activation outliers and, on real layers, rescues INT4 activations. On Qwen3-8B in NVFP4, it cost 5 GSM8K points instead.
SmoothQuant and W8A8: Quantizing Activations Too
Why activation outliers break INT8, how SmoothQuant moves them into the weights, and what it changes for FP8 W8A8, measured on Qwen3-8B with vLLM.