ml-models / Inference and deployment / Quantization core

Quantization

How a model's weights and activations are stored as 8-bit or 4-bit integers: the scale and zero point that map floats to integers, per-tensor versus per-channel and per-group scales, post-training quantization versus quantization-aware training with fake quantization, and why outliers make large language models hard to quantize.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Scale and zero point
  5. Granularity: per tensor, per channel, per group
  6. Post-training quantization
  7. Quantization-aware training
  8. Weight-only int8 and int4 for LLMs
  9. The PyTorch API
  10. Worked example
  11. Implementation
  12. Tradeoffs
  13. Connections
  14. Read first
  15. Read next
  16. References

Problem

A trained network stores every weight as a 32-bit or 16-bit float, but it rarely needs that much precision to give the same answers. Inference cost is dominated by moving bytes: during LLM decoding every weight is read from memory once per generated token (see the KV cache and how a GPU runs a model), so halving the bytes per weight nearly halves the time per token, and lets a model fit on a smaller device. Integer arithmetic is also cheaper than floating point on most hardware. Quantization replaces floats with small integers plus a few floats that say how to read them. The question is how to choose those few floats so the model's outputs barely change.

Intuition

An 8-bit integer has 256 values. Quantizing a tensor means laying a ruler with 256 evenly spaced marks over the range the tensor's values actually occupy, and storing each value as the number of its nearest mark. The spacing between marks is the scale; the mark that sits exactly on 0.0 is the zero point. Every value moves by at most half a spacing. So the whole game is keeping the ruler short: the narrower the range one scale has to cover, the finer its marks. That is why quantizers use a separate scale per output channel or per small group of weights, and why one huge value (an outlier) stretches the ruler and wipes out the precision of everything else that shares its scale.

Five activations on a float axis and their codes under affine quint8 and symmetric qint8 A float axis from −2.6 to 2.6 shows five values: −0.62, 0.0, 0.35, 1.17, 2.48. Below it, the affine quint8 ruler covers exactly the observed range from −0.62 to 2.48 with codes 0 to 255 and zero point 51; the values get codes 0, 51, 80, 147, 255. Below that, the symmetric qint8 ruler is centred on zero and covers −2.48 to 2.48; the values get codes −32, 0, 18, 60, 127, and the codes from −128 to −33 are never used, so its spacing is 1.60 times coarser. float values −2 −1 0 1 2 −0.62 0.0 0.35 1.17 2.48 quint8, affine: scale 0.01216, zero point 51 0 51 80 147 255 qint8, symmetric: scale 0.01945, zero point 0 codes −128…−33 unused −32 0 18 60 127 every float lands on its nearest mark: at most half a scale away

Both rulers have 256 marks. The affine one is laid over exactly the range the values occupy; the symmetric one is centred on zero, so with mostly positive values almost half its codes go unused and its marks are 1.60× farther apart.

Mechanics

Scale and zero point

An integer type has a range [qmin, qmax]: [−128, 127] for torch.qint8, [0, 255] for torch.quint8. Affine (asymmetric) quantization maps a float x to an integer q and back with a scale s and an integer zero point z:

q = clamp(round(x / s) + z, qmin, qmax)     x̂ = (q − z) × s

The rounding is to the nearest integer with ties to even (PyTorch's kernels call std::nearbyint). Given the minimum and maximum a tensor takes, PyTorch's observers first stretch the range to include 0, then divide it evenly:

min⁻ = min(min, 0), max⁺ = max(max, 0)     s = (max⁺ − min⁻) / (qmax − qmin)     z = qmin − round(min⁻ / s)

Including 0 guarantees that 0.0 is represented exactly (it becomes z), which matters because padding, ReLU outputs and masked values are exact zeros. Symmetric quantization fixes z = 0 for signed types (128 for unsigned) and sets s = max(|min⁻|, max⁺) / ((qmax − qmin) / 2), which divides by 127.5 for qint8. The quantized matmul is then simpler (no zero-point cross terms), at the cost of wasting codes when the values are lopsided, as in the diagram. PyTorch's defaults follow that split: weights are symmetric qint8, activations affine quint8. Values outside the range are clamped, so the error per value is at most s/2 inside the range and unbounded outside it.

Granularity: per tensor, per channel, per group

One scale for a whole tensor (per_tensor_affine, per_tensor_symmetric) is cheapest but lets the largest value anywhere set the spacing for everything. A weight matrix's output channels (rows of a linear layer's weight) often differ in magnitude by an order of magnitude, so PyTorch's PerChannelMinMaxObserver keeps a separate min and max, and so a separate scale, along ch_axis (0 by default). Because each output channel's scale multiplies a whole output element, per-channel weight scales cost nothing in the matmul: the integer dot product is computed first and scaled once at the end. Weight-only LLM quantization goes further, with one scale per group of 32 to 128 consecutive weights within a row. Each fp16 scale over a group of g weights adds 16/g bits per weight. PyTorch's observer module names the same granularities as PerTensor, PerAxis, PerGroup, PerRow, PerToken and PerBlock.

The int8 codes used by each row of a 3 × 4 weight matrix, with one scale per tensor and one per row With one symmetric scale for the whole tensor, set by the largest weight 2.4, row 1 uses only codes −6 to 6 of the −127 to 127 range and loses 5.9% of its magnitude to rounding; row 2 uses −14 to 16 and loses 3.2%. With one scale per row, every row reaches 127 and every row's error is below 1%. one scale for the tensor: s = 0.01882 row 1 · max |w| 0.12 error 5.9% row 2 · max |w| 0.31 error 3.2% row 3 · max |w| 2.4 error 0.6% one scale per row (output channel) row 1 · s = 0.000941 error 0.2% row 2 · s = 0.00243 error 0.3% row 3 · s = 0.0188 error 0.6% −127 0 127

Filled bars are the span of int8 codes each row actually uses; dashed outlines are the full range. Error is the row's total absolute rounding error relative to its total magnitude.

Post-training quantization

Post-training quantization (PTQ) quantizes a trained model without further training. In dynamic quantization the weights are quantized ahead of time and each activation's scale and zero point are computed from that activation at run time; quantize_dynamic's docstring calls this "dynamic (i.e. weights-only)", and by default it swaps nn.Linear and the recurrent layers for int8 versions (or fp16 with dtype=torch.float16). In static quantization the activation scales are fixed too: prepare inserts observers after each quantizable module, you run a sample of representative batches through the model (calibration), and convert replaces each module with a quantized one that uses the observed ranges. A MinMaxObserver tracks the running minimum and maximum; a MovingAverageMinMaxObserver averages them; a HistogramObserver chooses a range that minimizes quantization error, which can clip rare extreme values on purpose. Which observer each module gets comes from its QConfig: default_qconfig pairs a MinMaxObserver for activations (quint8, codes 0-127) with a symmetric qint8 per-tensor weight observer; default_per_channel_qconfig uses the per-channel weight observer instead.

Quantization-aware training

When PTQ loses too much accuracy, quantization-aware training (QAT) fine-tunes the model with the quantization error in the loop. prepare_qat inserts FakeQuantize modules that quantize and immediately dequantize, so values stay floats but take only the 256 representable levels:

x_out = (clamp(round(x / s + z), qmin, qmax) − z) × s

Rounding has zero derivative almost everywhere, which would stop all gradients. The fix is the straight-through estimator: treat rounding as the identity in the backward pass. PyTorch's kernel records a boolean mask in the forward pass, true where round(x/s) + z fell inside [qmin, qmax], and the backward pass is just dY * mask: the gradient passes through unchanged where the value was rounded and is zero where it was clamped. The weights learn to sit where rounding hurts least, and the observers inside each FakeQuantize keep updating the scales during training. After training, convert produces the real integer model.

Quantization-aware training: fake quantization in the forward pass, the straight-through estimator in the backward pass Forward, left to right: the fp32 weight goes into a FakeQuantize module, where an observer computes the scale and zero point and the value is quantized and dequantized; the result feeds the matmul and the loss. Backward, right to left: the gradient with respect to the fake-quantized weight is multiplied by the mask (1 where the value was inside the integer range, 0 where it was clamped) and becomes the gradient of the fp32 weight, which the optimizer updates. weight wfp32, trained FakeQuantize observer → s, z ŵ = (clamp(round(w/s + z)) − z) × s matmul with x, loss Lsees only 256 levels ∂L/∂w = ∂L/∂ŵ × mask mask = 1 where round(w/s) + z was in [q_min, q_max], 0 where it was clamped

Forward (green) sees quantized values; backward (amber) pretends rounding was the identity, except where clamping cut the value off.

Weight-only int8 and int4 for LLMs

For LLM decoding, which is memory-bound, the usual recipe is weight-only quantization: weights are stored in int8 or int4 and dequantized to fp16 or bf16 inside the matmul kernel, while activations stay in 16-bit. The speedup comes from reading fewer bytes, not from integer arithmetic. At 8 bits, symmetric per-channel round-to-nearest is usually close to lossless. At 4 bits there are only 16 levels, so round-to-nearest needs small groups, and better methods choose the rounding with data:

  • GPTQ quantizes a layer's weights one column at a time and, after each column, adjusts the columns not yet quantized to compensate for the rounding error just introduced, using second-order information (the inverse Hessian of the layer's reconstruction error) computed from a small calibration set. It reaches 3 to 4 bits per weight on large models in a single pass, with no retraining.
  • Outliers. In large transformers a few hidden dimensions carry activations far larger than the rest. With one scale per activation vector, a single outlier stretches the scale so far that the ordinary values round to zero (worked example below). LLM.int8() handles this with a mixed-precision decomposition: the hidden dimensions whose activations exceed a threshold (6.0 in the paper) are multiplied in fp16, and everything else in int8 with a separate scale per row of the activations and per column of the weights.

A note on the PyTorch API: in v2.15.0 the entry points in torch.ao.quantization's quantize.py (prepare, convert, quantize_dynamic, prepare_qat and the rest) are marked deprecated, with a warning that points to the separate torchao library (its quantize_ API for eager models, and prepare_pt2e and convert_pt2e for exported graphs). They still work, and the observers, fake quantization and the formulas above are the same ideas torchao builds on.

The PyTorch API

ComponentShape/ParamsMeaningSource
quantize_dynamic model, qconfig_spec=None, dtype=torch.qint8, mapping=None, inplace=False Dynamic PTQ: weights quantized now, activation qparams computed per batch. Defaults cover Linear, LSTM, GRU and the RNN cells. quantize.py 483-572
prepare, convert (model, inplace=False, ...) Static PTQ: insert observers per the module's qconfig; after calibration, swap in quantized modules. quantize.py 343-410, 629-680
prepare_qat (model, mapping=None, inplace=False) QAT: swap modules for versions with FakeQuantize on weights and outputs. quantize.py 574-604
QConfig defaults default_qconfig, default_per_channel_qconfig, default_dynamic_qconfig Which observer to use for activations and for weights. qconfig.py 153-178
MinMaxObserver dtype=torch.quint8, qscheme=torch.per_tensor_affine, reduce_range=False Records the running min and max of what passes through; computes s and z from them. observer.py 440-585
PerChannelMinMaxObserver ch_axis=0, dtype=torch.quint8, qscheme=torch.per_channel_affine The same, with one min, max, scale and zero point per slice along ch_axis. observer.py 686-896
_calculate_qparams (min_val, max_val) → (scale, zero_point) The formulas above: include 0, then symmetric, affine or float-zero-point qparams; scale at least eps. observer.py 348-430
calculate_qmin_qmax quant_min, quant_max, has_customized_qrange, dtype, reduce_range Default integer ranges: qint8 [−128, 127], quint8 [0, 255]; reduce_range halves them to [−64, 63] and [0, 127]. utils.py 445-506
FakeQuantize observer=MovingAverageMinMaxObserver, quant_min, quant_max, is_dynamic=False Quantize-dequantize in float during training; toggled with enable_fake_quant and enable_observer. fake_quantize.py 128-329
Fake-quant kernel and gradient (input, scale, zero_point, quant_min, quant_max) → (output, mask) Forward rounds with std::nearbyint and saves the in-range mask; backward returns dY * mask. QuantizedOpKernels.cpp 2655-2715, FakeQuantPerTensorAffine.cpp 121-134
torch.quantize_per_tensor, quantize_per_tensor_dynamic (input, scale, zero_point, dtype), (input, dtype, reduce_range) Make a quantized tensor with given or computed qparams; .int_repr() shows the codes. _torch_docs.py 13817-13881
DEPRECATION_WARNING string Points eager-mode users to torchao's quantize_ and graph-mode users to prepare_pt2e/convert_pt2e. utils.py 818-829

Worked example

Affine qparams by hand. PyTorch's quantize_per_tensor_dynamic docstring quantizes [-1.0, 0.0, 1.0, 2.0] to quint8. With min = −1 and max = 2:

  • s = (2 − (−1)) / (255 − 0) = 3/255 = 0.011764705882352941
  • z = 0 − round(−1 / s) = −round(−85) = 85
  • codes: round(x/s) + 85 = [0, 85, 170, 255], exactly the docstring's int_repr(), and every value dequantizes back exactly because each is a multiple of s from 0.

Rounding error, affine versus symmetric. The five activations from the first diagram:

xquint8 codex̂Errorqint8 sym. codex̂Error
-0.620-0.62000.0000-32-0.6224-0.0024
0.0510.00000.000000.00000.0000
0.35800.35250.0025180.35010.0001
1.171471.1671-0.0029601.1671-0.0029
2.482552.48000.00001272.4703-0.0097

Affine: s = 0.01216, z = 51, largest error 0.0029 (at most s/2 = 0.0061); the endpoints and 0 are exact. Symmetric: s = 2.48/127.5 = 0.01945, largest error 0.0097, and even the maximum is no longer exact, because 2.48 maps to 127.5 and rounds to 127.

Per tensor versus per channel. The weight matrix from the second diagram, symmetric qint8:

RowWeightsPer-tensor codesErrorPer-channel sCodesError
10.12, −0.08, 0.05, −0.116, −4, 3, −65.9%0.000941127, −85, 53, −1170.2%
20.31, −0.27, 0.18, 0.0216, −14, 10, 13.2%0.00243127, −111, 74, 80.3%
32.4, −1.1, 0.65, −0.3127, −58, 35, −160.6%0.0188127, −58, 35, −160.6%

With one scale, row 1's weights become ±3 to ±6 out of 127, about 4 of the 8 bits in use, and a 5.9% error. Per channel, row 1 gets a scale 20× finer and its error drops to 0.20%. Row 3 sets the tensor's scale, so it is identical both ways.

Fake quantization and its gradient. The fake_quantize_per_tensor_affine docstring example, s = 0.1, z = 0, range [0, 255]:

xround(x/s) + zOutputMaskGradient passed (upstream 1.0)
0.055210.100011.0
0.973101.000011.0
0.397340.400011.0
-1.078−11, clamped to 00.000000.0

The outputs [0.1, 1.0, 0.4, 0.0] match the docstring. The last value was clamped, so the straight-through estimator gives it no gradient: nudging it slightly would not change the output.

int4 groups and memory. One row of 8 weights with an outlier, [0.21, −0.15, 0.08, −0.26, 0.19, 1.85, −0.12, 0.04], symmetric absmax int4 (codes −8 to 7, scale = max |w| / 7):

  • One scale for the row: s = 0.2643; the row becomes 0.264, −0.264, 0.0, −0.264, 0.264, 1.85, 0.0, 0.0, error 16.8%.
  • Groups of 4: scales 0.0371 and 0.2643; the first group no longer shares the outlier's scale, and the error falls to 8.8%.
  • For comparison, int8 with one scale: error 1.0%.

Weight memory for an 8-billion-parameter model, with an fp16 scale per group:

FormatBits per weightWeights
fp1616.016.00 GB
int8, per channel8.08.00 GB
int4, group 1284.1254.12 GB
int4, group 324.54.50 GB

Per-channel int8 scales add one fp16 per row, negligible next to the weights; groups of 32 cost half a bit per weight.

One outlier. An activation vector x = [0.5, −0.3, 0.8, −0.6, 0.2, 60.0] with one outlier dimension, dotted with weights w = [0.4, 0.7, −0.5, 0.3, 0.9, 0.02]. Exact x · w = 0.7900.

  • absmax int8 on the whole vector: s = 60/127 = 0.4724, so the ordinary values become [0.472, −0.472, 0.945, −0.472, 0.0] (0.2 rounds to 0) and x · w = 0.4441, off by 44%.
  • Split the outlier out (|x| ≥ 6) and keep it in 16-bit, LLM.int8()'s decomposition: the remaining 5 values get s = 0.8/127 = 0.00630, and x · w = 0.7893, off by 0.09%.

Implementation

import torch
from torch.ao.quantization import (
    get_default_qconfig, prepare, convert, quantize_dynamic,
    get_default_qat_qconfig, prepare_qat,
)

# The primitives: a quantized tensor, its codes and its qparams.
t = torch.quantize_per_tensor_dynamic(torch.tensor([-1.0, 0.0, 1.0, 2.0]), torch.quint8, False)
t.int_repr()                       # tensor([  0,  85, 170, 255], dtype=torch.uint8)
t.q_scale(), t.q_zero_point()      # (0.011764705882352941, 85)

# Dynamic PTQ: int8 weights for every nn.Linear, activations quantized per batch.
qmodel = quantize_dynamic(model, {torch.nn.Linear}, dtype=torch.qint8)

# Static PTQ: observe, calibrate, convert.
model.eval()
model.qconfig = get_default_qconfig("x86")
prepared = prepare(model)          # inserts observers (model needs QuantStub/DeQuantStub)
with torch.no_grad():
    for x, _ in calibration_loader:
        prepared(x)                # observers record min/max
qmodel = convert(prepared)

# QAT: fake-quantize during fine-tuning, then convert.
model.train()
model.qconfig = get_default_qat_qconfig("x86")
qat = prepare_qat(model)
for x, y in loader:
    loss_fn(qat(x), y).backward()  # gradients flow through FakeQuantize via the STE
    optimizer.step(); optimizer.zero_grad()
qmodel = convert(qat.eval())

prepare, convert, quantize_dynamic and prepare_qat raise a deprecation warning in v2.15.0; new code should use torchao, which provides the same flows (and the int4 weight-only and GPTQ-style recipes for LLMs) on top of the same ideas. Eager-mode static quantization also needs QuantStub and DeQuantStub at the model's float/int boundaries and usually module fusion (conv + batch norm + ReLU) before prepare.

Tradeoffs

  • Bits versus accuracy. int8 with per-channel weight scales is close to lossless for most models. int4 needs groups and, for the best results, data-aware rounding such as GPTQ; below 4 bits accuracy drops quickly.
  • Weight-only versus weights and activations. Weight-only is simple and speeds up memory-bound decoding. Quantizing activations too unlocks integer matmuls for compute-bound work (large batches, prefill) but must handle activation outliers and needs calibration.
  • Dynamic versus static. Dynamic quantization needs no calibration data but computes activation ranges on every call; static is faster at run time but only as good as the calibration set.
  • PTQ versus QAT. PTQ takes minutes and no labels; QAT recovers more accuracy at low bit widths but costs a training run.
  • Granularity. Finer scales (per channel, per group) cut error but add storage (16/g bits per weight) and kernel complexity; per-token activation scales help with outliers but must be computed on the fly.
  • Range choice. Min/max ranges never clip but let one extreme value coarsen every other; histogram-based ranges clip a few values to keep the rest precise.
A ReLU output is always ≥ 0. Why is affine quint8 a better fit for it than symmetric qint8?

Symmetric qint8 reserves the negative codes −128 to −1 for values that never occur, so the 0..max range gets only 128 levels. Affine quint8 puts z = 0 and spends all 256 codes on [0, max], halving the spacing.

Why does PyTorch stretch the observed range to include 0 before computing the scale?

So that 0.0 maps exactly to the integer z. Zeros are common (padding, ReLU, masks) and must stay exactly zero; it also means a zero-padded convolution or matmul needs no correction for the padding.

Round has derivative zero almost everywhere. How does QAT still get gradients for the weights?

With the straight-through estimator: the backward pass treats quantize-dequantize as the identity, multiplying the incoming gradient by a mask that is 1 where the value fell inside the integer range and 0 where it was clamped.

Connections

Read first

Read next

No page builds on this one yet.

References

  • Jacob, Kligys, Chen, Zhu, Tang, Howard, Adam and Kalenichenko. Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference. CVPR, 2018. arXiv:1712.05877 (the affine scheme with scale and zero point).
  • Bengio, Léonard and Courville. Estimating or Propagating Gradients Through Stochastic Neurons for Conditional Computation. 2013. arXiv:1308.3432 (the straight-through estimator).
  • Dettmers, Lewis, Belkada and Zettlemoyer. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. NeurIPS, 2022. arXiv:2208.07339.
  • Frantar, Ashkboos, Hoefler and Alistarh. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. ICLR, 2023. arXiv:2210.17323.
  • PyTorch v2.15.0: torch/ao/quantization/quantize.py, torch/ao/quantization/observer.py, torch/ao/quantization/fake_quantize.py.