Search Tech Journey

Find topics, journeys and posts

back to blog
mladvanced 120m read

DL S067 · Quantization — INT8, INT4, GPTQ, AWQ

Shrink a fine-tuned LLM by 4× with almost no quality loss. The exact math of scale/zero-point, the intuition behind GPTQ's Hessian, why AWQ beats it on outliers, and the one-liner bitsandbytes command that just works.

🧠SoftwareM11 · Efficient inference + serving· Session 067 of 130 120 min

🎯 Quantize your fine-tuned model to INT8/INT4, measure the quality/latency trade, and know why GPTQ ≠ AWQ ≠ bitsandbytes.

Series: Deep Learning & LLMs From Scratch — 80 sessions · Session 67 / 80 · Module M11 · ~2 hours

The story

Yesterday (S066) we established: KV cache + fp16 weights = you can't fit two users on an A10. Today: what if the entire model were 4× smaller? Suddenly Llama-2-7B is 3.5 GiB instead of 14 GiB, and you have room to breathe.

The trick is quantization. And here's what's amazing: modern quantization methods lose less than 1 percentage point on MMLU going from fp16 to 4-bit weights. That's a factor of 4 in memory and typically 2× in latency, essentially for free. Nothing else in ML has this ROI.

But "quantization" isn't one thing. There's naive round-to-nearest (RTN), which is trash on LLMs. There's LLM.int8() (bitsandbytes) which handles outlier features but is slow at inference. There's GPTQ which uses second-order info. There's AWQ which is faster and often better. There's the newer QuaRot / SpinQuant family that rotates weights first. And there's BitNet, which trains from scratch with 1.58-bit weights and questions the entire premise of post-training quantization.

Today we derive the core math (scale + zero-point + group size), implement RTN by hand, then compare GPTQ vs AWQ vs bnb vs fp8 vs QuIP# on real numbers. By the end you'll know which one to reach for.

The origin story: Dettmers finds the outliers

August 2022. Tim Dettmers is trying to serve OPT-175B on a research cluster with too few GPUs. He tries the obvious thing: round every weight to INT8, matmul in INT8, done. On models up to 6.7B params it works. At 13B it starts to hurt. At 175B it catastrophically fails — accuracy craters, generation degenerates. Meanwhile, a well-known technique (per-channel INT8) that works fine on ResNet-50 gives the same bad result on OPT.

Dettmers pulls up a histogram of the activations and sees it. In every layer past a certain scale, ~0.1% of feature dimensions have magnitudes 20–100× larger than the median. These outlier features dominate the matmul output. Round-to-nearest quantizes to a scale that accommodates them and destroys resolution for everything else.

The fix he publishes as LLM.int8() (NeurIPS 2022) is basically "detect these columns at runtime, keep them in fp16, quantize the rest." It works. It's also slow because the mixed-precision matmul has no fused kernel. But it opens the floodgates. Within 12 months: GPTQ, AWQ, SmoothQuant, SqueezeLLM, OmniQuant. Within 24 months: BitNet, QuIP#, HQQ, AQLM.

Every one of them is answering the same question Dettmers asked in August 2022: what do you do about the outlier features? Keep that question in your head as we go through the methods; it'll organise everything.

Scanning a shelf of books into a smaller shelf
🌍 Real world
💻 Code world
You will be able to
  • Compute scale and zero-point for a weight tensor and dequantize it back.
  • Explain why per-channel/per-group quantization beats per-tensor on LLMs.
  • State the core idea of GPTQ (Hessian-weighted error compensation) and AWQ (activation-aware channel scaling).
  • Load a GPTQ or AWQ model from HuggingFace and benchmark tokens/sec vs fp16.
  • Pick between bitsandbytes, GPTQ, AWQ, and fp8 for a given deployment target.
  • Identify when quantization will silently ruin quality (long context, math, code).

Prerequisites

  • Session 066 — memory budget and per-token math.
  • Session 060 — QLoRA already uses 4-bit; now you'll know what NF4 actually is.
  • Session 016 — precision, underflow, overflow.


1 · The core math

Quantization maps a floating-point range [α,β][\alpha, \beta] to an integer range [qmin,qmax][q_{min}, q_{max}]. For INT8 asymmetric: [qmin,qmax]=[0,255][q_{min}, q_{max}] = [0, 255]; for INT8 symmetric: [127,127][-127, 127]; for INT4: [8,7][-8, 7] or [0,15][0, 15].

Asymmetric (with zero-point): scale=βαqmaxqmin,z=round(qminαscale)\text{scale} = \frac{\beta - \alpha}{q_{max} - q_{min}}, \quad z = \text{round}\left(q_{min} - \frac{\alpha}{\text{scale}}\right) q=clip(round(xscale)+z,  qmin,qmax)q = \text{clip}\left(\text{round}\left(\frac{x}{\text{scale}}\right) + z, \; q_{min}, q_{max}\right) x^=scale(qz)\hat{x} = \text{scale} \cdot (q - z)

Symmetric (weights usually): drop the zero-point, use scale=max(x)/qmax\text{scale} = \max(|x|) / q_{max}.

Numeric example. Weight vector x=[0.9,0.1,0.05,0.7,1.2]x = [-0.9, -0.1, 0.05, 0.7, 1.2], INT8 symmetric.

  • maxx=1.2\max|x| = 1.2, scale =1.2/127=0.00945= 1.2 / 127 = 0.00945
  • q=round(x/0.00945)=[95,11,5,74,127]q = \text{round}(x / 0.00945) = [-95, -11, 5, 74, 127]
  • Dequant x^=q0.00945=[0.898,0.104,0.047,0.699,1.200]\hat{x} = q \cdot 0.00945 = [-0.898, -0.104, 0.047, 0.699, 1.200]
  • Error xx^=0.004\|x - \hat{x}\|_\infty = 0.004. Under 0.5%. Nice.

2 · Per-tensor vs per-channel vs per-group

Applying one scale per tensor to a 4096×40964096 \times 4096 weight matrix is catastrophic — one giant outlier weight burns the entire scale for everyone. Fix: use per-channel scales (one per output row) or per-group (one per group of 128 consecutive weights along the input dim). Per-group with group_size=128 is the default in GPTQ/AWQ/bnb.

Storage cost: for 4-bit weights + fp16 scales at group=128, the effective bits-per-weight is 4+16/128=4.1254 + 16/128 = 4.125. Basically free.

import torch
 
def quantize_per_group(w, bits=4, group_size=128):
    """w: (out_features, in_features). Returns q (int), scale (fp16)."""
    out_f, in_f = w.shape
    w = w.view(out_f, in_f // group_size, group_size)  # (out, G, gs)
    max_abs = w.abs().amax(dim=-1, keepdim=True)       # (out, G, 1)
    qmax = 2 ** (bits - 1) - 1                          # 7 for INT4
    scale = max_abs / qmax
    q = torch.round(w / scale).clamp(-qmax - 1, qmax).to(torch.int8)
    return q, scale.squeeze(-1)  # scale: (out, G)
 
def dequantize(q, scale, group_size=128):
    out_f, G = scale.shape
    return (q.view(out_f, G, group_size).float() * scale.unsqueeze(-1)).view(out_f, G * group_size)

That's real Round-To-Nearest per-group. It's fine for CNNs and mediocre for LLMs. Why?

Try itWatch outliers destroy naive quantization by injecting one giant weight.

Generate a random weight matrix w = torch.randn(4096, 4096) * 0.02, then set w[0, 0] = 5.0 to simulate a single outlier. Run quantize_per_group(w, bits=4, group_size=128), dequantize, and print (w - dequant).abs().mean(). Now try again with group_size=4096 (per-row) and group_size=1 (per-weight, degenerate). Note how per-group=128 already recovers most of the resolution the outlier destroyed at per-row.

💡 Hint · Print max_abs before and after inserting the outlier; the scale changes 100× but only one weight benefits.

3 · The outlier problem

Dettmers et al. (LLM.int8, 2022) discovered: in LLMs beyond ~6.7B params, a small number of feature dimensions have activations that are ~20× larger than the rest. RTN quantization of those channels blows the scale, and the other 99% of features lose all resolution.

Every modern method is really a strategy for the outlier problem:

  • LLM.int8() (bitsandbytes): detect outlier columns at runtime, keep them in fp16, quantize the rest to INT8. Mixed-precision matmul. Zero calibration, works out of the box. Slow at inference.
  • GPTQ (Frantar et al., 2022): quantize column by column, at each step solve a small linear system to compensate remaining columns for the introduced error. Uses the inverse Hessian H1=(2XTX)1H^{-1} = (2 X^T X)^{-1} from a calibration set. Slow to quantize (30 min for 7B), fast at inference.
  • AWQ (Lin et al., 2023): observe that only ~1% of weight channels matter (the ones multiplied by outlier activations). Rescale weights so those channels have smaller magnitude before quantization; absorb the inverse scale into the previous layer. Fast to compute (5 min for 7B), fast at inference, often beats GPTQ.
  • fp8 (E4M3/E5M2): H100+ hardware supports fp8 matmul natively. No outlier drama, but you need Hopper.
  • NF4 (QLoRA): a 4-bit code optimized for normally-distributed weights. Info-theoretically optimal for that distribution. Used inside bitsandbytes 4-bit.

4 · GPTQ intuition

Frantar's insight: quantization introduces error δj\delta_j on the jj-th column. The output error on the linear layer is Xδ2\|X \delta\|^2. If we quantize columns one at a time, after quantizing column jj we can adjust remaining columns j+1..nj+1..n to compensate. The optimal adjustment is a closed-form solution involving the inverse Hessian of the layer's input covariance:

Update: Wj+1:=WjW^j[H1]jjHj,j+1:1\text{Update: } W_{j+1:} \mathrel{-}= \frac{W_j - \hat{W}_j}{[H^{-1}]_{jj}} \cdot H^{-1}_{j, j+1:}

You need a small calibration set (~128 samples) to estimate H=2XTXH = 2 X^T X. Then it's per-layer, embarrassingly parallel across layers. The AutoGPTQ library does this in ~30 min for a 7B model.

Practical usage:

from transformers import AutoModelForCausalLM, AutoTokenizer, GPTQConfig
 
quant_config = GPTQConfig(bits=4, dataset="c4", tokenizer=tokenizer, group_size=128)
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf", quantization_config=quant_config)
model.save_pretrained("llama-2-7b-gptq-4bit")

Or just download a pre-quantized one from TheBloke on HuggingFace and skip the 30 minutes.


5 · AWQ intuition

Lin et al. observed that on Llama-7B, quantizing just 1% of the weight channels — the ones aligned with outlier activation channels — in fp16 and the rest in INT3 recovers nearly all quality. But mixed precision is a pain to serve. Their trick: for each salient channel jj, multiply the weight column by scalar sj>1s_j > 1 and divide the activation column by sjs_j. Elementwise math is unchanged, but weights are now smoother and quantize better.

You find sjs_j per-channel by grid search minimizing WXQ(Ws)(X/s)\|W \cdot X - Q(W \cdot s) \cdot (X / s)\| on a tiny calibration set. Takes minutes. Very cheap.

from awq import AutoAWQForCausalLM
from transformers import AutoTokenizer
 
model = AutoAWQForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf")
tokenizer = AutoTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
 
quant_config = {"zero_point": True, "q_group_size": 128, "w_bit": 4, "version": "GEMM"}
model.quantize(tokenizer, quant_config=quant_config)
model.save_quantized("llama-2-7b-awq")

6 · bitsandbytes — the "just make it fit" button

If you don't want to think, load with:

from transformers import AutoModelForCausalLM, BitsAndBytesConfig
import torch
 
bnb_config = BitsAndBytesConfig(
    load_in_4bit=True,
    bnb_4bit_quant_type="nf4",
    bnb_4bit_compute_dtype=torch.bfloat16,
    bnb_4bit_use_double_quant=True,
)
model = AutoModelForCausalLM.from_pretrained("meta-llama/Llama-2-7b-hf", quantization_config=bnb_config)

That's the QLoRA config from S060. Zero calibration, works everywhere, ~30% slower at inference than GPTQ/AWQ. Perfect for training and prototyping, not ideal for production serving.


7 · Benchmarks that matter

Numbers from the AWQ paper + our own runs on A100, Llama-2-7B, WikiText-2 perplexity (lower better):

MethodBitsPPLTokens/secMemory
fp16 baseline165.474014 GB
RTN per-tensor412.93.5 GB
RTN per-group=12846.09653.6 GB
GPTQ45.68903.6 GB
AWQ45.621103.6 GB
bitsandbytes NF445.71553.7 GB

Takeaways:

  • Per-tensor RTN is unusable. Group-wise RTN is fine.
  • GPTQ/AWQ recover essentially all quality.
  • AWQ is faster because its kernel (INT4 GEMM) is simpler than GPTQ's.
  • bnb is slowest but most flexible.

Latency picture: AWQ ~2.7× faster than fp16 at batch=1 on A100. On memory-bound decode, roughly speedup ≈ compression_ratio.

The 2024–2025 quantization scoreboard (updated)

The 2022–2023 methods above are now the baseline. Here's what shipped since:

MethodYearBitsHardware needed7B PPL vs fp16Notes
GPTQ20224any GPU+0.21still the reference, slow to prep
AWQ20234any GPU+0.15Marlin kernel makes it fastest
SqueezeLLM20234any GPU+0.10non-uniform, sensitivity-based
SmoothQuant2023W8A8INT8 hw+0.05migrates outliers weights↔activations
HQQ20242–4any GPU+0.30 (4b)zero-calibration, seconds to quantize
QuIP#20242H100+0.50 (2b)lattice codebook + incoherence rotation
fp8 (E4M3)H100 native8H100/Blackwell+0.02drop-in in vLLM/TRT-LLM
BitNet b1.5820241.58any~+0.4**trained from scratch, not post-hoc
FP4 (NF4)Blackwell4B200+0.15native tensor cores 2025

fp8: the boring win

Hopper's E4M3 fp8 (4-bit exponent, 3-bit mantissa) is post-training quantizable with almost zero quality loss. NVIDIA's TransformerEngine does it automatically. vLLM 0.5+ ships --quantization fp8 as a one-liner. On Llama-3-70B, fp8 weights + fp8 KV halve memory and double throughput vs bf16 with a MMLU delta of <0.1.

If you have H100/H200, fp8 is the answer 90% of the time. GPTQ/AWQ are for A100 and older.

BitNet 1.58: the "train it quantized" gambit

Ma et al., "The Era of 1-bit LLMs" (Microsoft, 2024) trained a 3B model where every weight is in {1,0,+1}\{-1, 0, +1\} — log₂(3) ≈ 1.58 bits. Matmul becomes addition; no multiplier needed. At 3B scale they match fp16 baselines on perplexity. At 70B (BitNet b1.58 70B, 2024) the ppl gap closes further.

The catch: post-training BitNet-ification of a normal LLM fails badly. You have to pretrain with the quantization in the forward pass. Nobody has spent Llama-3-scale compute on it yet, so the 70B+ regime is untested. If a lab did it, inference cost would drop 10× overnight. Watch this space.

QuIP# and 2-bit territory

Tseng et al. (QuIP#, 2024) push post-training quantization down to 2 bits per weight by first rotating weights with a random orthogonal matrix (incoherence processing) then encoding with an E8 lattice codebook. Llama-2-70B at 2-bit QuIP# loses ~0.5 perplexity — shockingly good. The 70B model fits on a single 24GB 4090.

Marlin kernels (IST-DASLab, 2024) provide fused INT4/INT2 matmuls on Ampere+ that hit >90% of memory bandwidth peak. If you serve AWQ or GPTQ in 2025, you're probably going through Marlin whether you know it or not — vLLM auto-dispatches.

W8A8, W4A16, W4A8: what do these codes mean?

Modern papers name their scheme with W<weight_bits>A<activation_bits>:

  • W16A16: bf16 baseline. Nothing quantized.
  • W8A16 / W4A16: weight-only quantization (GPTQ, AWQ, bnb-4bit). Activations stay bf16. Best quality, easy to implement.
  • W8A8 (SmoothQuant, LLM.int8): both weights and activations INT8. Needs INT8 hardware (T4, A100, H100). 2× compute throughput on top of memory savings.
  • W4A8: exotic. Only pays off with fp8 activations on H100. TensorRT-LLM supports it.
  • W4A4: extreme; QuaRot (2024), Atom (2024). Massive throughput but 1–2 ppl points.

Rule of thumb: weight-only wins in memory-bound (single-request, long context). Weight-and-activation wins in compute-bound (large batches, short context). vLLM's autoconfig picks reasonably.

Further reading — the 2025 frontier:


8 · When quantization silently hurts

War story Long context degradation

Most quantization papers evaluate at 2k context. At 32k+, INT4 GPTQ can drop 3–5 points on needle-in-haystack. Fix: keep KV cache in bf16 even if weights are INT4 (they're decoupled). Only quantize KV if you've benchmarked at your target context length.

War story Math and code tasks

GSM8K and HumanEval are unusually sensitive to weight noise. A model that scores 55 on GSM8K in fp16 can drop to 48 in INT4-GPTQ. Chat/summarization loses ~0. Always re-run your task's eval, not just perplexity.

War story Mixing quantized weights with LoRA adapters

QLoRA does this correctly by keeping adapters in bf16 and only quantizing the base weights. If you naively merge a bf16 LoRA into a GPTQ base and re-quantize, you regress. Serve them separately or re-run GPTQ after merging.

War story The Marlin kernel silent-fallback trap

vLLM will use Marlin for AWQ/GPTQ only if group_size matches (128 or -1) and shape is aligned. Otherwise it falls back to a 3–4× slower kernel with no warning. Symptom: your fancy 4-bit model runs slower than fp16. Check the logs at startup for Using Marlin kernel. If missing, re-quantize with group_size=128.

War story fp8 KV + bf16 weights: worse than either alone

On H100, if you enable --kv-cache-dtype fp8 but leave weights in bf16, the K-projection output has to be down-converted every step. It's a ~10% throughput regression vs bf16-KV or fp8-both. Match precisions.

War story Calibration set contamination

A colleague quantized a chat model using the C4 web-crawl calibration set. It scored fine on MMLU but produced garbled Chinese. Cause: C4 is 99% English; the outlier statistics for Chinese-token activations were never sampled. For multilingual models, calibrate with a mix that covers your target languages. GPTQ and AWQ both care.


9 · Mermaid: decision tree


Recall

1. Symmetric INT8 scale formula? scale=max(x)/127\text{scale} = \max(|x|) / 127, then q=round(x/scale)q = \text{round}(x / \text{scale}), dequant =qscale= q \cdot \text{scale}.

2. Why is per-tensor quantization catastrophic on LLM weights? A single outlier weight sets the scale, crushing the resolution of the other 99.99% of weights. Per-channel or per-group avoids this.

3. One-line summary of GPTQ vs AWQ? GPTQ: quantize column-by-column and compensate remaining columns using an inverse-Hessian update. AWQ: pre-scale salient weight channels (the ones hit by outlier activations) so they quantize better; absorb the inverse scale into the prior layer.

4. Storage cost of 4-bit + fp16 group scales at group=128? 4+16/128=4.1254 + 16/128 = 4.125 bits per weight. Effectively 4-bit.

5. When would you not quantize? (a) H100 + fp8 gives you similar wins with less pain. (b) You already fit in fp16 and latency isn't a bottleneck. (c) Task is math/code and eval regressed. (d) Very long context and KV became the dominant cost — quantize KV instead.

Stretch: derive the AWQ scaling equivalence: for a linear layer y=Wxy = Wx, show that y=(Wdiag(s))(diag(1/s)x)y = (W \cdot \text{diag}(s)) \cdot (\text{diag}(1/s) \cdot x) is mathematically equivalent. Where do you push diag(1/s)? Answer: into the previous layer's weights (LayerNorm or the prior Linear), so activations arriving here are already pre-divided.

Stretch 2: BitNet b1.58 uses ternary weights {1,0,+1}\{-1, 0, +1\}. Show that matmul can be done with only additions (no multiplies). Estimate the energy-per-token improvement vs bf16 GEMM assuming FP16 multiply-adds are 10× more energy than integer adds.

In your own words: the outlier problem in LLM quantization is __________________________.

Spaced review: S060 QLoRA (NF4 double-quant), S016 numerical stability, S066 KV cache (memory is why we're doing this).

Next session (S068): we go beyond quantization to pruning (removing weights entirely) and distillation (training a small model to mimic a big one). When do you reach for each?

Bring back tomorrow:

  • The scale/zero-point formula.
  • The outlier framing of quantization methods.
  • Your fp16-vs-INT4 latency + PPL numbers on a model you actually use.
Common misconception
✗ What most people think

"Quantisation is lossy compression of the weights, so accuracy degrades smoothly with bit width. INT8 is a little worse than FP16, INT4 is a little worse than INT8 — I can pick a point on that curve to suit my budget."

✓ What is actually true

The dominant loss is not spread evenly across weights, and the curve is not smooth. Transformer activations contain a small number of persistent outlier channels whose magnitude is orders of magnitude above the rest. A single scale per tensor is set by those outliers, which crushes everything else into a handful of levels. Quality holds up remarkably well until the granularity is too coarse to isolate the outliers, and then it falls off a cliff. Which is why per-channel or per-group scales, and outlier-aware methods, matter far more than the nominal bit count.

Why the myth is so sticky

The smooth-degradation intuition is correct for the domain most engineers meet it in: images and audio, where the error is roughly uniform and perceptual quality really does track bit depth gently. It is also correct for a weight matrix considered in isolation with well-behaved statistics. What breaks it is that a transformer is a deep composition, and quantisation error at layer k is an input perturbation for every layer after it. So the question is not "how much error did I add to this tensor" but "how much does the network amplify it" — and amplification is concentrated in a few channels rather than spread out. A mental model built on uniform error can be perfectly predictive at 8 bits and completely wrong at 4.

Prove it to yourself

Look at the distribution before you trust the average error:

import torch
# W: any linear weight, or capture an activation tensor with a forward hook
per_tensor = W.abs().max()
per_channel = W.abs().amax(dim=1)          # one scale per output row
print('max/median channel ratio:', (per_channel.max()/per_channel.median()).item())

def q(x, scale, bits):
    n = 2**(bits-1) - 1
    return torch.round(x/scale*n).clamp(-n, n) / n * scale

for bits in (8, 4):
    e_t = (q(W, per_tensor, bits) - W).abs().mean()
    e_c = (q(W, per_channel[:,None], bits) - W).abs().mean()
    print(bits, 'per-tensor', e_t.item(), 'per-channel', e_c.item())

The gap between the two columns widens sharply as bits drop. That gap is the whole reason granularity beats bit count.

From first principles
Start with the question

Why does quantising only the weights, while keeping activations and accumulation in higher precision, work so much better than quantising both? On paper the activations carry just as much information.

  1. 1
    A linear layer computes a sum of many products. Rounding error in each product is roughly independent, and errors that are independent and zero-mean add in quadrature rather than linearly.
    forced by · the variance of a sum of independent terms is the sum of variances, so relative error shrinks as the reduction width grows
  2. 2
    Weights are fixed at deploy time, so their exact distribution is known and their scales can be fitted offline — per channel, per group, even with a calibration set optimising for output error rather than weight error.
    forced by · anything static can be optimised once with unlimited compute, which is a strictly stronger position than deciding at runtime
  3. 3
    Activations are input-dependent, so their scale must either be estimated at runtime, costing a pass over the tensor in the critical path, or fixed from calibration data and then be wrong whenever the input distribution shifts.
    forced by · you cannot fit a scale to data you have not seen, and a clipped activation loses information irrecoverably
  4. 4
    Decode is memory-bandwidth-bound, and the bytes moved per step are dominated by weights, not by the one activation row being processed.
    forced by · weights are read in full every step while activations for a single token are tiny by comparison
  5. 5
    So compressing weights alone captures nearly all the bandwidth benefit, while leaving the error-sensitive, distribution-shifting half of the computation at full precision.
    forced by · the accumulator staying wide keeps the quadrature-averaging argument intact, which is what makes the per-product error tolerable
⇒ Therefore

Therefore weight-only quantisation is not a half-measure — it is the point on the design space where the compression is nearly free and the risk is nearly zero, precisely because weights are static and activations are not.

And note the prediction, which you can verify. First: weight-only quantisation should speed up decode substantially while barely helping prefill, because prefill is compute-bound and you have not reduced the FLOPs at all — you have added dequantisation work. Measure time-to-first-token before and after and expect it flat or slightly worse, while per-token decode latency drops. Second: the benefit should scale with how memory-bound you are, so it should shrink as you raise batch size. Third: if you do quantise activations too, the damage should be concentrated where distributions are widest — measure per-layer output error and expect the later layers and the MLP down-projections to be the worst, not a uniform spread.

Mental modelScale per what

Quantisation is a ruler with a fixed number of tick marks. Bit width sets how many ticks you get. The scale sets how long the ruler is. Everything that matters is the question of how many numbers are forced to share one ruler.

One ruler per tensor means a single huge outlier stretches the ruler until every ordinary weight lands on the same two ticks. One ruler per channel, or per group of 64 or 128 weights, means the outlier stretches only its own small neighbourhood and leaves the rest at fine resolution. This is why a 4-bit model with group-wise scales can beat an 8-bit model with one scale per tensor.

  • Quality tracks granularity at least as much as it tracks bit width. Always ask "scale per what" before you ask "how many bits".
  • Weights are static so they can be fitted offline; activations are dynamic so they must be clipped or measured. That asymmetry is why weight-only is the safe default.
  • Quantisation buys bandwidth, not FLOPs, unless the hardware has native low-precision matmul. So it helps decode much more than prefill.
  • Calibration-based methods are only as good as the calibration distribution. Calibrate on text that looks like production traffic, or you have optimised for the wrong inputs.
  • The KV cache is a separate quantisation decision from the weights, with a separate failure mode — it degrades with context length rather than uniformly.
🔔 Fires when you see

Fire this the moment you see: a quantised model that is fine on short prompts and wanders on long ones · a bit-width comparison that does not state the group size · quantisation that produced no speedup (you were compute-bound, or dequant is on the critical path) · degradation concentrated in one capability such as arithmetic or code · a calibration set of generic web text used for a domain-specific model · someone reaching for 4-bit before checking whether the KV cache, not the weights, is what is full.

The tradeoff

You need the model to fit and to serve faster. Do you quantise with a fast round-to-nearest scheme, a calibration-based scheme, or not at all and shrink the model another way?

Naive post-training quantisation with group-wise scales
+ you gain takes minutes, needs no data, no training loop, and no trust in a calibration set; the memory saving is immediate and the implementation is simple enough to audit
− you pay it minimises weight error, which is not the thing you care about — it makes no attempt to minimise output error, so it is the scheme most likely to fall off the cliff at low bit widths, and the damage shows up unevenly across capabilities
pick when you are at 8 bits, or you are exploring whether quantisation helps at all before investing effort — this is the right first experiment precisely because it is cheap to discard
Calibration-based quantisation that optimises for layer output error
+ you gain keeps quality far closer to the original at aggressive bit widths, because it allocates precision where the network is actually sensitive rather than where the weights happen to be large; this is what makes 4-bit deployment viable rather than merely possible
− you pay requires a calibration corpus and a one-off compute pass, introduces a silent dependency on that corpus being representative, and produces a format that needs a matching inference kernel — so you inherit whichever runtimes support it
pick when you are going below 8 bits on a model you will serve for a long time, and you can assemble calibration data that resembles production traffic
Do not quantise — use a smaller or distilled model, or add memory
+ you gain no format lock-in, no quantisation-specific failure modes to debug, and full-precision behaviour that matches whatever you evaluated during development; a genuinely smaller model also cuts FLOPs, which quantisation alone does not
− you pay a smaller model is worse at everything in a way quantisation usually is not, and distillation costs a training pipeline; adding memory costs money linearly and does nothing for bandwidth
pick when the deployment is compute-bound rather than memory-bound, or the task is high-stakes enough that an unquantifiable long-tail quality risk is not acceptable
What a senior engineer actually does

Quantise the weights, keep the accumulation wide, and choose granularity before bit width. The practical sequence is: measure whether you are memory-bound at all, then try the cheap scheme at 8 bits to confirm the plumbing and the speedup are real, then move to a calibration-based scheme if you need to go lower. Skipping the cheap step wastes a day when the answer was that you were compute-bound the whole time.

The part people skip and should not is evaluation. Perplexity on a generic corpus is close to useless here because it is dominated by easy tokens and hides exactly the concentrated damage this technique causes. Evaluate on the capabilities you actually ship — the reasoning, code, or structured-output tasks in your own suite — and evaluate at your real context length, because cache and position effects only appear there. If your evaluation cannot detect the difference between the quantised and original model, that is a statement about your evaluation, not about the quantisation.


Quick recall · click to reveal
★ = stretch question

Previous: ← DL S066 · Next: DL S068 →