← All tutorials

Companion notebook

Post-Training Quantization and Quantization-Aware Training

Dynamic, static and QAT workflows in PyTorch

On this page

1 Dynamic quantization 2 Static quantization 3 Quantization-Aware Training (QAT) 4 Quantization in practice 5 References
trainingeach inputdeployQATstaticPTQdynamicPTQround every stepmeasure oncemeasure per input
Figure 1. When each method measures ranges and rounds.

Post-Training Quantization (PTQ) quantizes a trained model once, with no retraining, and Quantization-Aware Training (QAT) trains with the rounding on; both run the round trip from Quantization Basics on every weight and activation. Weights can be quantized any time, but an activation's range is only known once inputs flow through the model, so the three paths differ only in when that range is fixed: per batch at run time (dynamic PTQ), once on sample inputs before deployment (static PTQ), or during training (QAT) (Figure 1).

The round trip from Quantization Basics, run on every weight and activation.
xq = round(xs) + z
The scale s is one grid step, so x/s counts steps and round picks the nearest integer; the zero point z shifts the count so float 0 lands on an integer.
xq = clamp(xq, 0, 255)
clamp keeps the stored integer xq on the 8-bit grid, 0 to 255; a value outside the range is clipped to an end.
x̂ = s·(xq − z)
Reading back undoes the shift and multiplies by the step; x̂ differs from x only by the rounding, or by the clipping when x fell outside the range s and z were set from.

Quantize one tensor walks the round trip step by step. The listings use PyTorch's torch.ao.quantization (now marked deprecated, see Quantization in practice).

1 Dynamic quantization

Dynamic quantization is the low-effort option: the weights are quantized once after training. Activations arrive in float32 and get quantized to int8 on the fly, per batch.

One call to quantize_dynamic takes a trained float model, returns qmodel, and swaps every nn.Linear for an int8 version (PyTorch's qint8, signed integers −128 to 127; quint8 is the unsigned 0 to 255 type).

import torch.nn as nn
import torch.ao.quantization as tq

model.eval()
layers = {nn.Linear}  # default dtype qint8
qmodel = tq.quantize_dynamic(model, layers)
  1. 1Inference mode.
  2. 2Swaps every linear layer for a dynamic int8 version: weights quantized now, activations per batch.

It helps most on CPUs, for LSTMs (recurrent networks that read a sequence one step at a time; see Gated RNNs: GRU and LSTM) and Linear-heavy models such as Transformers. In our run of the companion notebook, on its small MNIST (handwritten-digit) CNN (convolutional neural network), accuracy barely moved (Figure 2).

weights: int81 byte, not 4activations: fp32to int8 per batchint8 matmulthe Linear layer's multiplyoutput back to float32small MNIST CNN, test accuracyfloat91.36%dynamic int891.30%
Figure 2. Only the Linear layers are quantized; the run trained for one epoch.

Why not CNNs? PyTorch's dynamic quantization does not cover Conv2d, and it pays off only when loading weights dominates run time. In a CNN, reading and writing the activation maps between layers dominates, and dynamic quantization keeps those activations in float32. Static quantization keeps them int8, which is why Figure 12 sends CNNs to static PTQ.

2 Static quantization

Static quantization runs everything in int8: weights and activations. The activations' range is measured once, before deployment: calibration runs some inputs through the model (no labels needed) and records each activation's range. It suits CNNs on phones and small boards.

The listings on this page and on Debugging a Quantized Model share one toy model, TinyCNN, on 1×28×28 MNIST images, defined in the next listing.

import torch.nn as nn

class TinyCNN(nn.Module):
    def __init__(self):
        super().__init__()
        # 1x28x28 -> 8x26x26
        self.conv1 = nn.Conv2d(1, 8, 3)
        self.relu1 = nn.ReLU()
        self.pool = nn.MaxPool2d(2)
        self.fc = nn.Linear(8 * 13 * 13, 10)

    def forward(self, x):
        h = self.relu1(self.conv1(x))
        h = self.pool(h)
        return self.fc(h.flatten(1))
  1. 1One 3x3 convolution with 8 channels, ReLU, 2x2 pooling, and a 10-class linear layer.
  2. 2Forward pass: convolution, ReLU, pooling, flatten, classify.

Calibration needs an observer, a small module PyTorch inserts after a tensor that records its min and max (or its histogram) and computes s and z from them as in Scale and zero point. In the eager-mode listing below (eager mode places stubs and fusions by hand; FX graph mode automates them), the backend is the library that runs the int8 ops ("x86" on Intel and AMD CPUs, "qnnpack" on ARM).

import copy, torch
import torch.ao.quantization as tq

fp32 = copy.deepcopy(model_fp32).eval()
model = tq.QuantWrapper(fp32)
pair = [["conv1", "relu1"]]
tq.fuse_modules(fp32, pair, inplace=True)
model.qconfig = tq.get_default_qconfig("x86")
# insert observers
tq.prepare(model, inplace=True)
# calibrate
with torch.no_grad():
    for x, _ in calib_loader:
        model(x)
# swap in int8 modules
tq.convert(model, inplace=True)
  1. 1Float copy wrapped with quant and dequant stubs; conv1 and relu1 fused into one op.
  2. 2Default x86 QConfig, then observers inserted.
  3. 3Calibration: run real batches so the observers record ranges.
  4. 4Replaces each module with its int8 version.

In our run of the companion notebook, on its larger fused MNIST CNN trained for 10 epochs, accuracy held and the model shrank to a quarter of its size (Figure 3).

floatint8test accuracy98.08%97.85%model size4.80 MB1.21 MBone CPU batch0.0190 s0.0145 s
Figure 3. Static int8 against float on the companion repo's fused MNIST CNN.

2.1 Fuse first

Fusing Conv + BatchNorm (a layer that rescales each batch of activations to a steady range) + ReLU (which zeroes negatives and passes positives) into a single op before quantizing saves memory traffic, runs faster, and removes the rounding step between the three (Figure 4). At inference the batch norm's scale and shift fold into the convolution's weights. Dynamic mode has no fusion step.

three ops, three roundings Conv BN ReLU fused into one op, one rounding Conv + BN + ReLU a round and clamp onto the int8 grid
Figure 4. Fusing leaves one rounding where there were three.

2.2 QConfig sets the grid per tensor

One scale for the whole tensorchannel 1channel 2channel 3One scale per channelchannel 1channel 2channel 3
Figure 5. A shared scale gives narrow channels almost no grid ticks.

A QConfig tells PyTorch how to quantize each tensor: symmetric or asymmetric (both drawn in Scale and zero point), and per-tensor (one scale for the whole tensor) or per-channel (one scale per channel, a channel being the output of one convolution filter; Figure 5).

Per-channel gives each narrow channel its whole grid, so accuracy rises, for a few extra numbers per layer and a backend that supports it. PyTorch's x86 default uses per-channel weights and per-tensor activations (qconfig source).

In the listing, model is the TinyCNN.

import torch.ao.quantization as tq

# quint8 activations
act = tq.MinMaxObserver
# int8, one scale per output channel
wt = tq.default_per_channel_weight_observer
qc = tq.QConfig(act, wt)
model.qconfig = qc
  1. 1Activation observer: per-tensor min-max, unsigned 8-bit.
  2. 2Weight observer: per-channel, signed 8-bit.
  3. 3Both go into one QConfig on the model.

2.3 Calibration data

A few hundred real inputs are usually enough. The observer only knows the values calibration showed it (Figure 6): if the set is too small or does not look like production, it learns the wrong ranges and either clips real activations or wastes resolution (Figure 7).

After convert() the ranges are baked in and the int8 model is not trained further, so a bad calibration means calibrating again.

real inputs (MNIST images) model + observers min/max per layer scale & zero point int8 model real inputs (MNIST images) model + observers min/max per layer scale & zero point int8 model

Figure 6. Calibration data flows through; each layer's min and max set its scale.

Figure 7. Illustrative batches widen the range; missing loud inputs clip the tail.

2.4 Histogram observers

One outlier stretches a min-max range over every bin (A real weight histogram). A histogram observer applies the MSE strategy from Where the range should end (Nagel et al., 2021) to activations: it builds a histogram of what it sees during calibration, tries many clipping ranges, and keeps the one with the smallest round-trip error.

The observer keeps the clipping range r whose round trip loses the least.
range = argminr ‖x − x̂r‖2
x is what the observer saw during calibration and x̂r is the same values quantized with range r and read back. Squaring the gaps makes one clipped outlier cost far more than many small rounding misses, so the winner clips only the rare extreme values.

The QConfig below, as in Static quantization, swaps in a histogram observer for activations and keeps per-channel MinMax weights; model is the TinyCNN.

import torch.ao.quantization as tq

# quint8, MSE range from histogram
act = tq.HistogramObserver
# int8, one scale per output channel
wt = tq.default_per_channel_weight_observer
qc = tq.QConfig(act, wt)
model.qconfig = qc
  1. 1Activations get the histogram observer, which picks the range that minimizes the error in the card above.
  2. 2Weights keep the per-channel signed 8-bit observer from 2.2.
  3. 3Both go into one QConfig on the model.

It matters most when activations have long tails, skew, or several peaks, the cases where MinMax quietly costs accuracy.

2.5 Keeping tensors quantized between layers

Every quant-dequant boundary moves data from int8 to float and back, which kills performance and breaks layer fusion (Figure 8). ReLU, pooling and many elementwise ops quantize safely, so everything between the entry and exit stubs can stay int8. In eager mode the listing below places the stubs by hand, as QuantWrapper did above; FX inserts them itself.

stubs at the entry and exit only Q conv relu pool fc DQ int8 from the first layer to the last back to float between every layer conv DQQ relu DQQ pool DQQ fc each red hop goes int8 to float and back
Figure 8. Every hop back to float adds two conversions and blocks fusion.
import torch.nn as nn
import torch.ao.quantization as tq

class QuantTinyCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.quant = tq.QuantStub()
        self.body = TinyCNN()
        self.dequant = tq.DeQuantStub()

    def forward(self, x):
        h = self.body(self.quant(x))
        return self.dequant(h)
  1. 1A quant stub at the entry, the float network, a dequant stub at the exit.
  2. 2Inputs are quantized once and outputs dequantized once.

2.6 FX graph mode

FX, PyTorch's graph-tracing toolkit, turns the model into a graph, so FX quantization applies the qconfig to every nested layer, inserts the observers and stubs, and fuses layers itself.

In the FX version of the static workflow, qfx.prepare_fx traces model_fp32 with one example input and inserts observers, calibration fills them, and qfx.convert_fx returns the int8 model. To check where FX put the observers, see Read the FX graph table.

import torch
import torch.ao.quantization as tq
import torch.ao.quantization.quantize_fx as qfx

example = (torch.randn(1, 1, 28, 28),)
qmap = tq.get_default_qconfig_mapping("x86")
model = model_fp32.eval()
prep = qfx.prepare_fx(model, qmap, example)
# calibrate
with torch.no_grad():
    for x, _ in calib_loader:
        prep(x)
model_int8 = qfx.convert_fx(prep)
  1. 1FX traces the model and inserts observers from the default mapping.
  2. 2Calibration batches fill the observers.
  3. 3Converts to int8 kernels.

3 Quantization-Aware Training (QAT)

When PTQ costs too much accuracy, QAT fine-tunes with the rounding switched on, so training learns around it; training with simulated quantization comes from Jacob et al. (2018). The observers become FakeQuantize modules, which let a gradient pass through the rounding (by the straight-through estimator, drawn in Figure 9).

The first listing copies the trained float model in train mode and lets qfx.prepare_qat_fx insert a FakeQuantize wherever the QAT qconfig mapping asks for one; the second trains as usual with cross-entropy, switches to eval mode and converts. Each FakeQuantize runs the round trip from Quantize one tensor in every forward pass; train_loader and optimizer are the usual ones from float training.

import copy, torch
import torch.ao.quantization as tq
import torch.ao.quantization.quantize_fx as qfx

qat_qmap = tq.get_default_qat_qconfig_mapping("x86")
example = (torch.randn(1, 1, 28, 28),)
model = copy.deepcopy(model_fp32)
model.train()
qat_model = qfx.prepare_qat_fx(model, qat_qmap, example)
  1. 1The default QAT mapping for x86 and an example input for tracing.
  2. 2A train-mode copy with fake-quantize modules inserted.
import torch.nn.functional as F

for x, y in train_loader:
    logits = qat_model(x)
    loss = F.cross_entropy(logits, y)
    optimizer.zero_grad()
    loss.backward()
    optimizer.step()
qat_model.eval()
model_int8 = qfx.convert_fx(qat_model)
  1. 1The training loop is ordinary; only the forward pass runs through fake quantization.
  2. 2After training, convert to int8 kernels.

QAT can learn activation clipping, push small weights out of dead zones (the band around zero that all rounds to zero), and learn quantization-friendly distributions.

Rounding and clamping have no useful gradient, so QAT uses a Straight-Through Estimator (STE): in the backward pass it pretends the rounding output equals its input.

import torch
from torch.autograd import Function

class RoundSTE(Function):
    @staticmethod
    def forward(ctx, x):
        return torch.round(x)

    @staticmethod
    def backward(ctx, grad):
        return grad  # straight through
  1. 1Forward: real rounding.
  2. 2Backward: pass the gradient through unchanged (the straight-through estimator).
Forward passround: output in stepsinoutBackward passas if output = inputinout
Figure 9. Rounding makes steps forward; STE passes gradients straight back.

Each QAT step does two things, shown in the card: the forward pass computes with the rounded weights, and the backward pass copies the gradient straight back to the float weights.

The forward pass uses the rounded weight; the backward pass treats the rounding as if it were not there.
q = clamp(round(ws), −k, k)
The float weight w is divided by the grid step s, rounded to the nearest whole number and clamped to the codes −k to k. The result q is the integer grid index, the whole-number code the weight is stored as. Here k = 2b−1−1 is the largest code that b bits hold on a grid symmetric around zero, 3 at 3 bits.
ŵ = s·q
Multiplying the code q back by the grid step s gives ŵ, the value quantized inference will use, and the layer computes with it. It is the round trip of the first card on the symmetric weight grid from Scale and zero point, where the zero point is 0, so the shift drops out.
∂L∂w = ∂L∂ŵ
Round has zero slope almost everywhere, so the straight-through estimator copies the gradient of the loss L at ŵ onto w unchanged, and the optimizer moves the float weight. A weight already past the grid's end gets no gradient, because the clamp holds its ŵ at ±k·s however it moves.

A float weight can move for many steps without changing its rounded value, like an input sliding along one flat step of the staircase in Figure 9. Once it crosses a bin edge, halfway between two grid levels, the rounded weight jumps a whole level and the loss sees the change. Zero QAT steps is plain PTQ: round the trained weights once and ship them. Fewer bits mean fewer levels, so each weight lands further from its float value.

To see PTQ and QAT on real data, take handwritten 5s and 8s, 60 of each from the UCI digits set (Alpaydin and Kaynak, 1998), and squeeze each 8×8 image to two numbers with PCA, so each digit becomes a point on a flat map. A small MLP with two ReLU hidden layers of 12 units, trained in float with a squared hinge loss, which penalizes every digit that is not safely on its correct side, gets all 120 right. Its decision edge is the line on that map where it switches between calling a digit 5 and calling it 8. Accuracy here counts these same 120 digits, the ones the network trained on.

Rounding to b bits uses the card's grid, codes −k to k, so it has 2b−1 levels: one of the 2b codes is dropped so that +k and −k both exist. Each output channel gets its own scale, taken from the float weights, and biases and activations stay in float. Each QAT step trains through the rounding with the same loss and Adam, using a learning rate that falls to zero by step 200.

3 bits, 7 levels
0, rounding only

Rounded only (PTQ), 108 of 120 right

Figure 10. One toy run, the worst of 40 at 3 bits. QAT pulls the blue edge back.

In this run, at 3 bits, rounding alone misreads 12 digits the float network got right, the ringed ones, and two QAT steps pull the blue edge back toward the grey line and win 10 of them back, from 108 to 118 right. At 2 bits rounding misreads 30; the first three QAT steps win back only 2, and step 4 jumps to 113. From 4 bits up, rounding alone already gets all 120 right, so there is nothing left to win. QAT fits the digits, not the float edge, so where no digits sit the blue shade can stay away from the grey line, as it does at 2 bits. This run is the worst of 40 random starts at 3 bits: it loses 12 digits, while the median start loses 2. At 2 bits about 1 run in 10 stays stuck, with QAT winning back less than half.

The same trade shows up at scale. Google’s Gemma 3 QAT checkpoints (April 2025) trained about 5,000 steps against the float model’s own outputs, and afterwards 4-bit rounding raised perplexity 54% less than it did without QAT. Perplexity measures how surprised a language model is by real text, so lower is better. Meta’s quantized Llama 3.2 1B and 3B models (October 2024) used QAT with LoRA adapters for 4-bit weights. A LoRA adapter is a small pair of trainable matrices added beside a frozen weight matrix, so only a few numbers train. PTQ is cheaper and usually enough at 8 bits; QAT pays off as the bits fall.

Inside FakeQuantize the observer is usually a MovingAverageMinMaxObserver, which keeps an exponential moving average of the min and max seen in training; PTQ uses MinMaxObserver or HistogramObserver instead. prepare_qat_fx() inserts the FakeQuantize modules; convert_fx() replaces them with real quantized ops. A FakeQuantize holds its observer plus the quantize-and-read-back step, and Inspect the observers' recorded ranges prints both.

The moving-average observer's update, run once per training batch. For an illustrative example, with c = 0.01, a running min of 0.00 and a batch whose smallest activation is −0.4, the new running min is −0.004: one loud batch moves it only 1% of the way.
mt = (1 − c)·mt−1 + c·bt
mt is the running min after batch t. It keeps most of the previous value mt−1; c is the averaging constant that sets how far each new batch moves it, and bt is the smallest activation in batch t. The running max updates the same way.

Raising c makes the range follow the latest batches faster but jump with each noisy batch; lowering it smooths the range but reacts slowly, and c = 0 freezes it. PyTorch's MovingAverageMinMaxObserver defaults to c = 0.01 (its averaging_constant, observer source).

3.1 Freezing observers and BatchNorm statistics in QAT

Warm upranges updateevery batchFreezenoise fixed,model adaptsConvertreal int8layersscale
Figure 11. The scale moves in warm up, then freezes, then is baked in at convert.

QAT starts from the trained FP32 model and fine-tunes it with a small learning rate; PyTorch's tutorial uses SGD at 0.0001 for 8 epochs, freezing the batch-norm statistics after the fourth epoch and the observers after the fifth (static quantization tutorial).

A common schedule warms up first (Figure 11), letting the observers learn the scale and zero point while the model trains, then freezes them and keeps training on one fixed pattern of quantization noise. Freezing earlier gives more epochs on a fixed grid but a range measured on fewer batches; freezing later tracks the range longer but leaves less time to adapt.

import torch.nn.functional as F
import torch.ao.quantization as tq
import torch.ao.nn.intrinsic.qat as nniqat

for epoch in range(num_epochs):
    if epoch == bn_freeze_epochs:
        # freeze BN running stats
        qat_model.apply(nniqat.freeze_bn_stats)
    if epoch == warmup_epochs:
        # freeze scale and zero point
        qat_model.apply(tq.disable_observer)
    for x, y in train_loader:
        out = qat_model(x)
        loss = F.cross_entropy(out, y)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
  1. 1Freeze the batch-norm statistics in a model with fused Conv + BatchNorm, such as the companion fused MNIST CNN. Each keeps its last running mean and variance, so the batch-norm scale folded into the conv weights stops moving and the weight grid sees a steady tensor. TinyCNN has no BatchNorm, so on it this line does nothing.
  2. 2After the warm-up epochs, freeze every observer so scale and zero point stop moving.
  3. 3Training continues with fixed quantization grids.

4 Quantization in practice

The PyTorch quantization docs tie each path to a different bottleneck (Figure 12).

Dynamic PTQ loading weights dominates run timeLSTM, Transformer, small batches Static PTQ memory and compute both matterCNNs, the typical case QAT static PTQ loses too muchaccuracy, so train with rounding on
Figure 12. Which path fits which model; each row links its section.

If accuracy still dropped, which layer lost it? Debugging a Quantized Model traces it layer by layer. Pruning and Knowledge Distillation shrink a model in other ways.

5 References

  1. Alpaydin, E., and Kaynak, C. (1998). Optical Recognition of Handwritten Digits [Dataset]. UCI Machine Learning Repository. DOI 10.24432/C50P49. Source of the handwritten 5s and 8s in Figure 10, from the copy bundled with scikit-learn.
  2. Jacob, B., et al. (2018). Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference. arXiv:1712.05877. Source of the scale and zero point scheme and of training with simulated quantization.
  3. Nagel, M., et al. (2021). A White Paper on Neural Network Quantization. arXiv:2106.08295. Source of the MSE range-setting method that the histogram observer also uses.
  4. Meta AI blog (2024). Introducing quantized Llama models with increased speed and a reduced memory footprint. October 2024. Source of the Llama 3.2 1B and 3B QAT with LoRA adaptors and the 4-bit groupwise weights.
  5. Google Developers Blog (2025). Gemma 3 QAT Models: Bringing state-of-the-Art AI to consumer GPUs. April 18, 2025. Source of the Gemma 3 QAT numbers, about 5,000 QAT steps and 54% less perplexity rise from 4-bit (Q4_0) rounding than without QAT.