← All tutorials

Companion notebook

Debugging a Quantized Model

Finding the layer that lost accuracy

On this page

1 Common quantization bugs 2 Finding the layer that loses accuracy 3 References

A quantized model that loses accuracy almost always has one of a few causes, and each one leaves a visible symptom. Every tensor in the model runs the round trip taught on Quantization Basics, so every check here asks one question: which tensor got a bad grid?

A float here is a 32-bit number with a fractional part, the model's usual format, and int8 is an 8-bit integer, one of 256 levels. Read Quantization Basics first if the round trip is new.

The round trip snaps a float onto the integer grid and then reads it back (Jacob et al., 2018).
xq = round(xs) + z
The scale s is the width of one grid step, so x/s counts steps from 0 and rounding picks the nearest integer level; the zero point z is the integer that stands for 0.0.
x̂ = s·(xq − z)
Reading back undoes the shift and the division. Observers pick s and z from the values they see, and FakeQuantize runs this round trip inside training during quantization-aware training (QAT).

These checks run once, after the model is quantized and its accuracy has dropped, not during training (Figure 1).

quantized, accuracy dropped2.1 print scales, zero points2.2 plot activation ranges2.3 keep one layer in float2.4 read the FX graph2.5 inspect observer ranges2.6 every layer vs floatcheapestmostthorough2.7 outliers, dead channelsthen2.1, 2.4, 2.5 and 2.7 read stored values;2.2, 2.3 and 2.6 run the model
Figure 1. Six checks, cheapest first, then the outlier check.

The listings use one toy model, TinyCNN, a small convolutional network of the kind taught in CNN Basics, from the Post-Training Quantization and Quantization-Aware Training page (Figure 2). Imports follow that page, with tq for torch.ao.quantization. PyTorch now marks torch.ao.quantization eager and FX quantization for migration to torchao (PyTorch 2.15 quantization docs); the checks carry over.

TinyCNNMNIST1×28×28conv1relu1poolfcmodel_fp32trained float copy, eval modeqat_modelits QAT-prepared copycalib_batch8 real images, 8×1×28×28
Figure 2. The toy model and the three objects every listing uses.

1 Common quantization bugs

Each row links the check that confirms it.

SymptomUsual causeFix
Big accuracy dropCalibration data unlike production, or one outlier channelCalibrate on real inputs; per‑channel weights (outliers)
Small drop everywhereUnfused layers, extra int8 to float hopsFuse conv, batch norm and ReLU (FX graph)
One layer misbehavesRange far wider than its data, or clippingPlot it against its range (plot)
QAT does not convergeObservers frozen too earlyFreeze later (scales)
convert_fx leaves a layer in floatNo qconfig for that layerCheck the qconfig mapping (FX graph)
prepare_fx failsUntraceable control flow in forwardMove the branch out of forward

2 Finding the layer that loses accuracy

The six checks of Figure 1, cheapest first, then the outlier check in 2.7.

Weight fake quants (the FakeQuantize modules of Quantization-Aware Training) keep one scale per output channel by default, so a single number would hide the bad channel. Two signs give a problem away (Figure 3). The first is a layer whose scale reads exactly 1.0 with zero point 0, often one value where one per channel was expected. That is the default PyTorch keeps when an observer never saw data, and converting such a model prints the warning must run observer before calling calculate_qparams. The second is one channel whose scale sits far above the rest.

scale sexactly 1.0, z = 0observer never ranfar above the restan outlier stretched the rangefcconv1, one scale per channel
Figure 3. Illustrative scales: a layer whose observer never ran, and an outlier channel.

The loop prints each weight fake quant's scale shape and its first four scales.

mods = qat_model.named_modules()
for name, mod in mods:
    if name.endswith("weight_fake_quant"):
        # one scale per out channel
        s = mod.scale
        print(name, s.shape, s[:4])
  1. 1Walks every module and keeps only the weight fake-quantize ones.
  2. 2Prints each weight scale, normally one value per output channel. A huge entry flags a bad channel; a single 1.0 flags an observer that never ran.

2.2 Plot activation ranges per layer

An observer's range should hug the data. When the range is much wider than the data, most grid levels sit where no value falls (Figure 4).

one layer's activationsgrid levelswastedusedwastedthe observer's range
Figure 4. A range much wider than the activations wastes most grid levels.

A forward hook is a function PyTorch runs each time a layer executes, so it can record the layer's output; the histogram goes beside the observer's range (printed in Inspect the observers).

import torch
import matplotlib.pyplot as plt

outputs = {}

def record(module, inputs, out):
    outputs["relu1"] = out.detach()

relu1 = model_fp32.relu1
hook = relu1.register_forward_hook(record)
with torch.no_grad():
    model_fp32(calib_batch)
hook.remove()
acts = outputs["relu1"].flatten()
plt.hist(acts.numpy(), bins=100)
  1. 1A forward hook stores the relu1 output each time it runs.
  2. 2Runs one calibration batch with gradients off, then removes the hook.
  3. 3Flattens the stored activations and plots their histogram.

2.3 Keep one layer in float and re-measure

Turn one layer back to float and see whether accuracy comes back. If it does, that layer's grid is the problem.

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

fq = qat_model.conv1.weight_fake_quant
fq.disable_fake_quant()

# lasting fix: conv1 stays float
qmap = tq.get_default_qat_qconfig_mapping("x86")
qmap.set_module_name("conv1", None)
model = copy.deepcopy(model_fp32)
model.train()
ex = (calib_batch,)
qat_model = qfx.prepare_qat_fx(model, qmap, ex)
  1. 1For a quick test, switch off rounding on conv1's weights and re-measure accuracy.
  2. 2For a lasting fix, map conv1 to no qconfig and prepare the model again, so it stays float.

2.4 Read the FX graph table

FX (PyTorch's graph-tracing toolkit) turns the model into a list of operations. A layer with no observer row was never quantized; a quantize and dequantize pair between two layers that should run together usually means a missed fusion.

One call prints that list as a table, one row per operation and per inserted observer.

# needs tabulate
qat_model.graph.print_tabular()

2.5 Inspect the observers' recorded ranges

Each observer's recorded range sets its tensor's scale and zero point, and anything outside that range gets clamped to the grid edge, so reading the ranges shows which tensor got a bad grid. After post-training quantization the observers can be called directly; after QAT, FX adds each activation observer as a module on the top-level model, named activation_post_process_0, activation_post_process_1 and so on, and each one is a FakeQuantize that holds the observer inside it (eager-mode prepare_qat attaches one to each layer as layer.activation_post_process instead).

The PTQ listing reuses TinyCNN, model_fp32 and calib_loader from Static quantization.

In eager mode an observer can also be called directly on a tensor: a MinMaxObserver for the activations and a PerChannelMinMaxObserver for the weights, then calculate_qparams() turns each recorded range into scales and zero points.

import torch
import torch.ao.quantization as tq

# quint8 activations
act_obs = tq.MinMaxObserver()
i8 = torch.qint8
w_obs = tq.PerChannelMinMaxObserver(0, i8)
conv = model_fp32.conv1
# 8 ranges, one per channel
w_obs(conv.weight.detach())
with torch.no_grad():
    for x, _ in calib_loader:
        a = model_fp32.relu1(conv(x))
        act_obs(a)
# 1 (scale, zero point) pair
print(act_obs.calculate_qparams())
# 8 pairs
print(w_obs.calculate_qparams())
  1. 1A per-tensor activation observer and a per-channel weight observer.
  2. 2Observes conv1's weights once: 8 channel ranges.
  3. 3Feeds calibration batches through conv1 and ReLU into the activation observer.
  4. 4Computes the scale and zero point from each recorded range.

The next figure and table come from a bigger model than TinyCNN, the companion repo's fused MNIST CNN, whose layers are named conv.0, conv.2 and fc1. In our run of the companion notebook, its calibration hook printed these activation ranges.

05101520activation value seen in calibrationconv.00 to 6.481conv.20 to 17.72
Figure 5. ① conv.0 spans 0 to 6.48 and ② conv.2 0 to 17.7, a step 2.6 times wider.

After convert() those ranges are baked into the quantized layers. The notebook uses get_default_qconfig("fbgemm"), which like the x86 default uses 7 bits for activations (0 to 127) and the histogram observer, which trims each range a little, so each scale is a bit under the range divided by 127 (PyTorch quantization docs, Best Practices). The table lists them.

LayerQuantized module and its params
conv.0QuantizedConvReLU2d(1, 32)
scale 0.050122, zero_point 0
conv.2QuantizedConvReLU2d(32, 64)
scale 0.129116, zero_point 0
fc1QuantizedLinear(9216, 128)
qscheme torch.per_channel_affine

Both convolutions get zero point 0 because ReLU outputs are never negative, so float 0 sits on the first level.

After QAT the loop below reads the same numbers from each FakeQuantize.

prefix = "activation_post_process_"
for name, fq in qat_model.named_modules():
    if not name.startswith(prefix):
        continue
    # the observer
    obs = fq.activation_post_process
    lo = obs.min_val.item()
    hi = obs.max_val.item()
    s = fq.scale.item()
    z = fq.zero_point.item()
    print(name, s, z, lo, hi)
  1. 1Keeps only the activation fake-quantize modules FX inserted.
  2. 2Reads the range the observer recorded during calibration.
  3. 3Prints the scale and zero point computed from that range.

2.6 Compare float and quantized layers with the Numeric Suite

When the cheap checks do not point at a layer, compare every layer at once. PyTorch's Numeric Suite (torch.ao.ns._numeric_suite_fx, imported as ns) logs each layer's output in a float copy and an int8 copy on the same input and scores each pair by its SQNR (signal to quantization noise ratio). It runs on the QAT model from Quantization-Aware Training. The leading underscore means the API can move between PyTorch versions.

SQNR compares one layer's float output with its int8 output on the same input, in decibels. As an illustrative example, an output of length 10 with an error of length 1 gives 20·log10 10 = 20 dB.
SQNR = 20 log10 ‖x‖‖x − x̂‖
x is the float layer's output and x̂ the int8 layer's output; the fraction is signal size over noise size (‖·‖ is a vector's length). Higher is closer, and each 6 dB is roughly one more bit of agreement, the same 6 dB per bit as the SNR rule on Quantization Basics.

The first listing builds the two copies and fills their loggers with one real batch.

import copy, torch
import torch.ao.quantization as tq
import torch.ao.ns._numeric_suite_fx as ns
from torch.ao.quantization import quantize_fx

ref = copy.deepcopy(qat_model).eval()
# float copy
ref.apply(tq.disable_fake_quant)
q = copy.deepcopy(qat_model).eval()
int8 = quantize_fx.convert_fx(q)
Logger = ns.OutputLogger
ref, int8 = ns.add_loggers("fp32", ref, "int8", int8, Logger)
with torch.no_grad():
    ref(calib_batch)
    int8(calib_batch)
  1. 1Makes the float reference, a copy of the model with rounding switched off.
  2. 2Converts a second copy to real int8 kernels.
  3. 3Adds an output logger to every matching layer of both models.
  4. 4Runs one real batch through each so the loggers fill.

The second listing prints each layer's SQNR.

import torch.ao.ns._numeric_suite_fx as ns
from torch.ao.ns.fx import utils

Logger = ns.OutputLogger
logs = ns.extract_logger_info(ref, int8, Logger, "int8")
for layer, r in logs.items():
    out = r["node_output"]
    fp = out["fp32"][0]["values"][0]
    q8 = out["int8"][0]["values"][0]
    sqnr = utils.compute_sqnr(fp, q8)
    print(layer, sqnr.item())
  1. 1Pairs the logged outputs of the two models layer by layer.
  2. 2Pulls the float and int8 output tensors for one layer.
  3. 3Prints that layer's SQNR; the first sharp drop marks the broken layer.

Figure 6 uses illustrative values from a five-layer CNN (conv1 to conv3, fc1 and fc2), deeper than TinyCNN, so the noise has later layers to carry it. The error carries forward, so the culprit is where SQNR first falls (Figure 6).

SQNR (dB)400conv1conv2conv3fc1fc2the first sharp dropmarks the culpritlater layers onlyinherit the noise
Figure 6. Illustrative values: blame the first drop, not the lowest point.

A range wider than its data is the usual reason one layer falls (the third row of the table in section 1). Twice the range doubles the grid step. Noise carries forward because each layer's output noise is roughly the larger of the noise it inherits and its own rounding. The first doubling costs only a couple of dB, because the layer's own rounding is still smaller than the noise it inherits. After that, each doubling costs about 6 dB, one bit. In large language models a few huge activation features, up to 20 times the others, stretch a range the same way (LLM.int8(), Dettmers et al., 2022; see Normalization and Quantization).

Figure 7 is a small toy with random weights and five dense layers named after Figure 6's, with int8 weights and 8-bit (256-level) activations. A healthy layer adds only a little rounding noise, so SQNR slips a few dB per layer, under 4 here; a fall of 6 dB or more from the layer before, a whole bit, marks the culprit. A layer kept in float with the buttons adds no rounding, so its bar shows only the noise it inherits.

Keep one layer in float

Figure 7. Illustrative toy. Blame the first fall of 6 dB or more.

2.7 Outliers and dead activations

An outlier is one channel whose values are far larger than the rest, as in A real weight histogram; with one scale for the whole tensor, it widens the grid step for every channel (Nagel et al., 2021). A dead activation is an output stuck at zero whatever comes in, so it carries no signal.

Both show up in two numbers, the widest channel range over the median one and the number of channels that are always zero (outputs comes from the hook listing in Plot activation ranges).

# (8, 1, 3, 3)
w = model_fp32.conv1.weight.detach()
# (8,): range per channel
rng = w.abs().amax(dim=(1, 2, 3))
print(rng.max() / rng.median())
# (8,): True if a channel is 0 everywhere
# (ReLU is never negative, so max 0 means all 0)
dead = outputs["relu1"].amax(dim=(0, 2, 3)) == 0
print(dead.sum())
  1. 1This line divides the widest channel range by the median one; a large ratio means outlier channels.
  2. 2This line counts the relu1 channels that output 0 for every input in the batch; each one is a dead channel. A share of zeros over the whole layer would not show this, since a healthy ReLU already zeros many of its outputs.

For an outlier channel, use per-channel weights (the default in get_default_qconfig_mapping("x86")) or a histogram observer that clips the tail. For dead activations, check the layer's input range: if everything going into a ReLU (which passes positive values and zeros the rest) is negative, the fix is upstream, not in the grid.

Once the model quantizes cleanly, the next lever is the architecture itself: neural architecture search looks for a network that is small from the start (Neural Architecture Search Basics), and Knowledge Distillation trains it from a larger teacher.

3 References

  1. Nagel, M., et al. (2021). A White Paper on Neural Network Quantization. arXiv:2106.08295. Source of the outlier channels that per-channel weights fix.
  2. Jacob, B., et al. (2018). Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference. arXiv:1712.05877. Source of the round trip with scale and zero point.
  3. Dettmers, T., et al. (2022). LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. arXiv:2208.07339. Source of the activation features up to 20 times the others.