Your quantization numbers were measured in the wrong place
Draft. Every number below is measured; every limit is stated. The point is not “use our tool” — it’s five specific, checkable ways a quantization measurement lies, and one discipline that catches them.
Quantization tooling asks you to trust a number. You quantize a model, run an eval, see “1.2% perplexity increase” or “98% recovery,” and ship. The problem is that the number was almost always produced somewhere other than where the model runs in production — a different quantization backend, a proxy metric, a smaller model, a single layer in isolation — and the number does not transfer.
We built a measurement engine (it started life as a numerical-parity CI gate for model deployments) and pointed it at quantization, with one rule: always measure the model you actually ship, on the metric that matters. That rule kept catching the tooling lying — and once, our own eval. Here are five cases, each one a thing worth checking in your own pipeline.
Finding 1 — the eval distribution isn’t the serving distribution
This is the one where the tool caught us. We put Gemma 4 12B — a day-one 2026 release — through the loop: quantize, export, re-evaluate the served checkpoint against a 10% perplexity bar. The gate refused our int4-GPTQ export at +581% vs fp. Scored the same way, Google’s own QAT W4A16 checkpoint for the same model read 34,000,000 perplexity — six orders of magnitude broken. Two catastrophic verdicts, both measured on the served artifact, both replicated across two serving stacks (transformers and vLLM agree). By every rule above, publishable.
Both verdicts were wrong. One probe showed it: the “broken” QAT checkpoint answers a chat-formatted prompt perfectly (“The capital of France is Paris.”) while assigning ~−27 logprobs per token to plain prose — worse than uniform over its 262k vocab. Nothing was broken. Our eval scored raw text, and an instruction-tuned model — a chat-only-QAT’d one especially — has little raw-text language modeling left to measure. We were grading a chat model on a distribution it never serves.
Re-scored on its served distribution (200 chat-formatted samples, template masked, same bar, same code):
| gemma-4-12B-it, served (vLLM) | chat eval (what it serves) | raw-text eval |
|---|---|---|
| fp | 25.2 | 3,665 |
| int8wo (ours, 2×) | +0.7% | −0.6% |
| int4-GPTQ (ours, 4×) | +3.0% — meets the bar | +581% |
| Google QAT W4A16 (4×) | −10.4% (better than fp) | +936,000% |
Same artifacts, same engine, same 10% bar. On the raw-text eval the gate ships 2× int8; on the served distribution it ships 4× int4 at +2.1%. The vendor checkpoint goes from “catastrophically broken” to better than the fp model (QAT included more chat training). And the two columns together are a finding in their own right: quantization damage is distribution-dependent — int8 is robust on both, int4 preserves the well-trained head of the distribution while destroying the tails, and QAT optimizes the head having abandoned the tails by design. (One family so far; treat the mechanism as a hypothesis.)
The lesson: a quality bar is only meaningful on the distribution you
serve. An off-distribution eval doesn’t just add noise — it reverses
verdicts in both directions: it refuses shippable models and condemns healthy
ones. The fix is boring and structural: our eval sets now accept chat pairs
({"chat": [{"user": ..., "assistant": ...}]}), scored on the assistant turn
with the template masked, so the whole select → export → re-eval loop gates on
what the model will actually be asked to do. The full story — including the
headline we nearly published — is in the Gemma 4 case
study.
Finding 2 — the backend you measure in isn’t the backend you serve in
The most common quantization split in practice: you measure with one library (it has the nice per-layer hooks and filters) and deploy with another (it’s what your serving engine loads). We measure with torchao and deploy with compressed-tensors. Same scheme name, same bit-width — different implementation.
The same int4 weight-only scheme, measured in torchao vs served via compressed-tensors, across three architecture families:
| family | fp | int4 in torchao | int4 served (compressed-tensors) | gap |
|---|---|---|---|---|
| Qwen2.5-1.5B | 10.3 | 21.7 | 20.5 | −6% |
| Llama-arch (SmolLM2-1.7B) | 8.9 | 34.7 | 23.1 | −33% |
| Gemma-2-2B | 20.5 | 59.0 | 22.2 | −62% |
The gap is real, it grows across families, and it is directional: torchao’s int4 consistently reads worse than the served model — dramatically so on non-Qwen (Gemma int4 measures 59 in torchao but serves at 22, a 2.7× discrepancy). In other words, the cheaper-to-measure backend systematically under-sells models it wasn’t tuned on, and you’d reject an int4 deployment that would actually have been fine. (For int8 the two backends agree to ~2%; int4 is where packing and calibration differences bite.)
The lesson: a quantization number is only valid for the exact backend that produced it, and the error is worst on the architectures your measurement backend wasn’t tuned on — precisely the ones where you have the least prior intuition to sanity-check it. If you measure in framework A and serve from framework B, re-measure in B. We made this a default step — re-evaluate the served checkpoint — and it paid for itself immediately, twice more below.
Finding 3 — “98% recovery” can be a serving no-op
SmoothQuant is a well-known technique for recovering int8 activation-quantization quality. In torchao, on Qwen2.5-1.5B, it works exactly as advertised: plain w8a8 wrecks the model (18.1 perplexity vs 9.4 fp), and SmoothQuant brings it back to 9.7 — a near-full recovery.
We wired the same SmoothQuant into the served (compressed-tensors) export, expecting the same recovery. Instead, across all three families:
| w8a8, served (compressed-tensors) | plain | + SmoothQuant | torchao’s recovery for reference |
|---|---|---|---|
| Qwen2.5-1.5B | 18.71 | 18.71 | 18.6 → 10.6 (−43%) |
| Llama-arch | 52.85 | 52.85 | 52.4 → 22.4 (−57%) |
| Gemma-2-2B | 19.80 | 19.80 | — (w8a8 already fine) |
Bit-identical, every family. SmoothQuant did nothing when served — the calibration ran, the smoothing was applied to every layer (we checked the logs), and the output was unchanged — even where torchao showed it recovering 43–57%. The reason is mechanical: SmoothQuant migrates outliers from activations into weights, but compressed-tensors’ W8A8 uses a per-token / per-channel activation-quant granularity that is invariant to exactly that rescaling. The recovery it shows in torchao is real — and specific to torchao’s activation-quant granularity. It is a measurement artifact of the framework you measured in, and it does not exist in the one you serve from.
The lesson: a recovery technique’s benefit is a property of the (technique × quant-granularity × backend) triple, not the technique alone. “SmoothQuant recovers X%” is meaningless without naming where it was measured — and because this one is mechanical (it’s about the serving backend’s activation-quant granularity, not the model), it’s a bit-identical no-op on every architecture we tried. Our re-eval gate flagged it automatically — it re-scored the served model, saw no change, and refused to claim a recovery.
Finding 4 — per-layer mixed precision helps at 1.5B and hurts at 7B
“Keep the fragile layers in higher precision, quantize the rest” is the intuitive recipe for mixed-precision. We tested whether a cheap per-layer fragility ranking (measure each layer’s int4 sensitivity once) predicts which layers, kept at fp16, actually recover the served int4 model.
On Qwen2.5-1.5B it works and transfers cleanly. Keeping 4 layers fp16, choosing which four:
| kept fp16 (1.5B) | served perplexity |
|---|---|
| all-int4 | 12.66 |
| top-4 (by our ranking) | 11.28 |
| random-4 | 11.73 |
| bottom-4 | 11.96 |
Monotonic: the ranking is real. So we scaled to 7B, expecting a larger effect (outlier features sharpen with model size). Instead it inverted:
| kept fp16 (7B), K=4 | served perplexity |
|---|---|
| all-int4 | 9.93 |
| top-4 | 10.10 |
| random-4 | 10.35 |
| bottom-4 | 10.16 |
At 7B, keeping any layers fp16 made the served model worse than plain all-int4 — and the “most fragile” layers were the worst to protect. Why: bigger models are more int4-robust, so int4+GPTQ is already near-lossless at 7B — there is no per-layer fragility left to exploit, because GPTQ’s calibration correction already absorbed it. The cheap ranking measures fragility without the recovery method; the served model has it. And pulling the early layers out of the GPTQ set disrupts its sequential error-compensation, so protecting them backfires.
The lesson: per-layer sensitivity measured on a bare quantizer does not predict a served model that includes a recovery method — and the whole optimization’s value shrinks with scale exactly where you’d want to deploy it. The 1.5B result alone would have justified building a feature that’s worthless at 7B. Measuring at the scale you deploy is the only thing that caught it.
Finding 5 — for recsys, AUC tells you the wrong component to protect
Everything above is LLMs. Recommendation models are the more interesting quantization target precisely because they’re heterogeneous — big and small embedding tables, cross layers, a deep MLP — so a single precision can’t fit all of them, and you have to decide per component. We trained a DCN-v2 on MovieLens-1M and int4-quantized each component in isolation.
By AUC (the offline ranking metric), int4 is nearly free and flat across components — nothing to see. But AUC is rank-based; it is blind to calibration, and a recommendation model’s output is a probability that feeds a downstream auction, where a calibration shift is real money. Measured by calibration error (ECE) instead:
| component (int4) | ΔAUC | ΔECE |
|---|---|---|
| head | −0.0025 | +0.0058 |
| side embeddings | −0.0010 | +0.0045 |
| cross layers | −0.0009 | +0.0023 |
| big embeddings | −0.0018 | +0.0014 |
| deep MLP | −0.0001 | −0.0004 |
The calibration ranking is not the AUC ranking: by AUC you’d protect the big embeddings (2nd); by calibration they’re 4th, and the side embeddings jump to 2nd. The deep MLP is free on both. So the offline proxy metric points you at the wrong component to keep in higher precision.
The lesson: measure the metric your deployment actually cares about, per component — not the aggregate offline proxy. (Honest caveat: on a small model the magnitudes are small; this effect wants production-scale tables to become large. But the ranking flip is the point, and it’s already visible.)
What actually works (and ships)
The findings are cautionary; the constructive half is real too. Measuring the served model let us build a loop that ships what it verifies:
- int4 recovery that serves. Plain int4 RTN serves at +113% perplexity; GPTQ recovers it to +4% (~96%), AWQ ~94% — measured on the served checkpoint, on Qwen2.5-1.5B. That’s a real, deployable 4× smaller model.
- Pick the scheme by a quality bar. Give a perplexity bar; the tool ships the most-compressed scheme that meets it — bar 10% → int8wo (2×, +2.7%), bar 30% → int4wo (4×). Same model, the bar decides.
- Gate on the distribution you serve. Eval sets accept chat pairs, scored on the assistant turn — on Gemma 4 the same 10% bar ships 2× under a raw-text eval and 4× on the served distribution (Finding 1).
- Measured cost, not estimated. Real serving throughput/memory from vLLM: fp8 is +20% decode and −24% prefill (weight quant helps memory-bound decode, costs compute-bound prefill — a regime split only measurement reveals).
These live behind one command — firefly optimize <model> --quality-bar <b> →
a servable compressed-tensors checkpoint + a vllm serve line + the measured
evidence.
The engine underneath
None of this is quantization-specific. The core is a divergence-attribution
engine — capture → compare → attribute — that hooks every layer’s activations
and names the first place two model executions diverge, down to the attention head
or ATen op. Pointed at serving stacks instead of quantization, the same engine is
a numerical-parity CI gate; it’s how we found, in an earlier phase, that
FLASH_ATTN vs XFORMERS diverge at exactly one attention head across a 60×
model-scale range, and surfaced a live vLLM/FlashInfer bug that silently zeroed
two of Qwen-7B’s attention heads. The attribution is the moat: it’s why the tool
can say which layer a quant broke and whether the model it ships matches the
one it measured — which is the whole story above.
Honest scope
- Finding 1 (eval distribution) is one family (Gemma 4 12B), perplexity-only, measured on both serving stacks; the tails-first damage mechanism is a hypothesis pending cross-family replication.
- Findings 2 and 3 (backend transfer, SmoothQuant no-op) are confirmed across three architecture families (Qwen, Llama-arch, Gemma). The re-eval gate, int4 recovery, and multi-scheme search are built and GPU-validated on Qwen2.5 (1.5B/7B); a downstream task metric beyond perplexity is the open evidence work.
- Per-layer mixed precision is a real mechanism whose payoff is modest on every regime we can currently access (int4-robust LLMs, toy-scale recsys); it’s parked pending fp4/mx4 tooling or production-recsys scale, deliberately.
- The tool measures and verifies; it does not prove. There is no worst-case accuracy bound for post-training quantization — the honest guarantee is “we measured the model you’re about to ship, on your metric,” which is a great deal more than most tooling offers, and less than a proof.