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.
🎯 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.
- 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 to an integer range . For INT8 asymmetric: ; for INT8 symmetric: ; for INT4: or .
Asymmetric (with zero-point):
Symmetric (weights usually): drop the zero-point, use .
Numeric example. Weight vector , INT8 symmetric.
- , scale
- Dequant
- Error . Under 0.5%. Nice.
2 · Per-tensor vs per-channel vs per-group
Applying one scale per tensor to a 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 . 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?
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.
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 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 on the -th column. The output error on the linear layer is . If we quantize columns one at a time, after quantizing column we can adjust remaining columns to compensate. The optimal adjustment is a closed-form solution involving the inverse Hessian of the layer's input covariance:
You need a small calibration set (~128 samples) to estimate . 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 , multiply the weight column by scalar and divide the activation column by . Elementwise math is unchanged, but weights are now smoother and quantize better.
You find per-channel by grid search minimizing 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):
| Method | Bits | PPL | Tokens/sec | Memory |
|---|---|---|---|---|
| fp16 baseline | 16 | 5.47 | 40 | 14 GB |
| RTN per-tensor | 4 | 12.9 | — | 3.5 GB |
| RTN per-group=128 | 4 | 6.09 | 65 | 3.6 GB |
| GPTQ | 4 | 5.68 | 90 | 3.6 GB |
| AWQ | 4 | 5.62 | 110 | 3.6 GB |
| bitsandbytes NF4 | 4 | 5.71 | 55 | 3.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:
| Method | Year | Bits | Hardware needed | 7B PPL vs fp16 | Notes |
|---|---|---|---|---|---|
| GPTQ | 2022 | 4 | any GPU | +0.21 | still the reference, slow to prep |
| AWQ | 2023 | 4 | any GPU | +0.15 | Marlin kernel makes it fastest |
| SqueezeLLM | 2023 | 4 | any GPU | +0.10 | non-uniform, sensitivity-based |
| SmoothQuant | 2023 | W8A8 | INT8 hw | +0.05 | migrates outliers weights↔activations |
| HQQ | 2024 | 2–4 | any GPU | +0.30 (4b) | zero-calibration, seconds to quantize |
| QuIP# | 2024 | 2 | H100 | +0.50 (2b) | lattice codebook + incoherence rotation |
| fp8 (E4M3) | H100 native | 8 | H100/Blackwell | +0.02 | drop-in in vLLM/TRT-LLM |
| BitNet b1.58 | 2024 | 1.58 | any | ~+0.4* | *trained from scratch, not post-hoc |
| FP4 (NF4) | Blackwell | 4 | B200 | +0.15 | native 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 — 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:
- Ma et al., BitNet b1.58 (2024).
- Tseng et al., QuIP# (ICML 2024).
- Xiao et al., SmoothQuant (ICML 2023).
- IST-DASLab, Marlin: fast INT4 kernels (2024).
- NVIDIA, TransformerEngine fp8 recipe.
- Egiazarian et al., AQLM: 2-bit quantization via additive quantization (2024).
8 · When quantization silently hurts
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.
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.
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.
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.
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.
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?
, then , dequant .
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?
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 , show that 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 . 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.