Post-Training Quantization¶
Overview¶
Post-Training Quantization (PTQ) converts a trained fp32 model to int8 (or fp8) without retraining — you calibrate scale/zero-point on a small dataset and swap weights. The win: 4x smaller, 2-4x faster inference. The risk: accuracy loss, worst on outlier-heavy distributions.
- Dynamic quantization: quantize weights only, activations stay fp16/fp32 (great for LLM/LSTM text inference).
- Static quantization: quantize both weights and activations using calibrated ranges (needs a calibration set).
- Symmetric vs asymmetric: symmetric (zero=0) simpler/faster; asymmetric better for skewed data (e.g., ReLU outputs).
torch.ao.quantization(modern) /quantize_fx(fx-based) /torch.quantization(legacy).
💡 Rule of thumb: dynamic quantization is a free 2-3x on CPU text models; static gets closer to 4x but needs calibration care.
Dynamic Quantization (weights-only)¶
import torch
class LM(torch.nn.Module):
def __init__(self):
super().__init__()
self.emb = torch.nn.Embedding(1000, 64)
self.lin = torch.nn.Linear(64, 1000)
def forward(self, ids):
return self.lin(self.emb(ids))
model = LM().eval()
dq = torch.ao.quantization.quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)
print("orig size (bytes):", sum(p.numel() * 4 for p in model.parameters()))
print("dq size (bytes):", sum(p.numel() for p in dq.parameters())) # int8
✅
{torch.nn.Linear, torch.nn.LSTM}— pick modules to convert; others stay fp32.
Static Quantization — the Full Recipe¶
1. Fuse + prepare¶
import torch, torch.nn as nn
class Net(nn.Module):
def __init__(self):
super().__init__()
self.conv = nn.Conv2d(3, 8, 3, padding=1)
self.relu = nn.ReLU()
self.fc = nn.Linear(8 * 8 * 8, 2)
def forward(self, x):
return self.fc(self.relu(self.conv(x)).flatten(1))
net = Net().eval()
net.qconfig = torch.ao.quantization.get_default_qconfig("x86") # per-tensor, symmetric
net_fused = torch.ao.quantization.fuse_modules(net, ["Conv", "Relu"](/"conv",-"relu"/))
net_prep = torch.ao.quantization.prepare(net_fused, inplace=False)
2. Calibrate on real data¶
def calibrate(model, loader, n=10):
model.eval()
with torch.no_grad():
for i, (x, _) in enumerate(loader):
model(x)
if i >= n: break
calibrate(net_prep, calib_loader) # observes activation ranges
⚠️ Calibration data should match deployment distribution; garbage-in → garbage scales → accuracy loss.
3. Convert¶
net_q = torch.ao.quantization.convert(net_prep, inplace=False)
print(net_q) # now quantized ops
out = net_q(torch.randn(1, 3, 8, 8)) # int8 kernels on CPU
Symmetric vs Asymmetric, Per-Channel¶
# per-channel (better for weights; more scales to store)
qconfig = torch.ao.quantization.get_default_qconfig("fbgemm")
# custom:
from torch.ao.quantization import QConfig, MinMaxObserver, PerChannelMinMaxObserver
qconfig = QConfig(
activation=MinMaxObserver.with_args(dtype=torch.quint8, qscheme=torch.per_tensor_affine),
weight=PerChannelMinMaxObserver.with_args(dtype=torch.qint8, qscheme=torch.per_channel_symmetric),
)
| Scheme | When |
|---|---|
| per-tensor symmetric | balanced weights (typical) |
| per-channel symmetric (weights) | big range differences across filters |
| per-tensor asymmetric | skewed activations (ReLU positive) |
Accuracy-Checking Discipline¶
def eval_accuracy(model, loader, device="cpu"):
model.eval()
correct = total = 0
with torch.no_grad():
for x, y in loader:
pred = model(x).argmax(1)
correct += (pred == y).sum().item()
total += y.numel()
return correct / total
before = eval_accuracy(net, loader)
after = eval_accuracy(net_q, loader)
print(f"fp32 acc {before:.4f} | int8 acc {after:.4f} | drop {before-after:.4f}")
# typical acceptable drop: <1% for int8; if more, try QAT (Ch 06-04)
PTQ vs QAT Decision¶
| Signal | Choice |
|---|---|
| Drop < 0.5-1% | PTQ is fine |
| Drop 1-5% | try per-channel + better calibration first |
| Drop > 5% | QAT (Ch 06-04) or mixed-precision quantization |
Deployment Notes¶
- Quantized models are CPU-first in core torch; GPU int8 needs TensorRT /
torch_tensorrtortorchaofp8. - Export: quantize then
torch.jit/torch.export(Ch 07); ONNX has its own quantize pass. - Always benchmark both latency and accuracy; small acc gains never justify 2x latency.
Key Takeaways¶
- Dynamic (weights-only) = free win on text/LLM; static = bigger win, needs calibration.
- Calibrate on representative data, fuse conv+relu, choose symmetric/per-channel by distribution.
- Validate accuracy vs fp32 before deploying; QAT if the drop is real.
- Core torch quant is CPU-oriented; GPU paths go through TRT / torchao.
- Keep the fp32 model around for fallback and comparison.