Quantization-aware training inside a fastai fit, with the FX and the pt2e flow
Overview
QuantizeCallback runs quantization-aware training inside a fastai fit: fake-quantize modules go in before it, the converted model comes out on learn.model. The backend picks the flow — x86, qnnpack, fbgemm and onednn give back an FX quantized model, pt2e a symmetric INT8 graph.
Both arms below continue the same fine-tuned ResNet-18.
1. A shared starting point
Oxford-IIIT Pet, two classes, 64px; three epochs of fine-tuning.
Every accuracy on this page goes through report, which prints the count k, its n, and the Wilson 95% interval for that n.
def evaluate(model, dl):"Correct predictions and n, on the CPU where a quantized model runs" model.eval().cpu() correct = total =0with torch.no_grad():for xb, yb in dl: correct +=int((model(xb.cpu()).argmax(-1) == yb.cpu()).sum()) total +=len(yb)return correct, totaldef report(name, k, n, z=1.96):"A count with its n and its Wilson 95% interval" p, d, centre = k/n, 1+ z**2/n, k/n + z**2/(2*n) half = z*((p*(1-p) + z**2/(4*n))/n)**0.5print(f"{name}: {k}/{n} = {p:.4f} Wilson 95% [{(centre-half)/d:.4f}, {(centre+half)/d:.4f}]")
2. Two more epochs, with and without the callback
Seed-matched arms: both continue from the same base weights, torch.manual_seed(0) is set again right before each one, and the only difference between them is the callback.
torch.manual_seed(0)qat = Learner(dls, deepcopy(base), metrics=accuracy)with qat.no_bar(): qat.fit_one_cycle(2, 1e-4, cbs=QuantizeCallback(backend='x86', verbose=True))print("model after the fit:", type(qat.model).__name__)print(quant_spec(qat.model).as_dict())report("x86 QAT INT8", *evaluate(qat.model, dls.valid))
Model prepared for QAT successfully
[0, 0.06549660861492157, 0.18793834745883942, 0.9357239603996277, '00:06']
[1, 0.051761697977781296, 0.19833843410015106, 0.9364005327224731, '00:06']
Converting QAT model to fully quantized model
model after the fit: GraphModule
{'backend': 'x86', 'method': 'qat', 'weight_bits': 8, 'act_bits': 8, 'qscheme': 'per_channel', 'symmetric': False, 'group_size': None, 'layer_bits': None, 'qdq_placement': None, 'skip_activations': None, 'engine': 'x86'}
x86 QAT INT8: 1385/1478 = 0.9371 Wilson 95% [0.9235, 0.9484]
The two report lines above come from seed-matched arms, and their Wilson intervals overlap, so this single run does not order them. What the cell does show is the callback running end to end: the fit hands back an FX GraphModule, scored on the same validation set as the floating-point arm, carrying the precision record quant_spec reads back — 8-bit weights and activations, per-channel, and asymmetric, which is what the FX backends produce. The pt2e arm below prints the symmetric one.
pt2e captures the model with torch.export, and a captured graph runs one batch size. fastai’s validation loader keeps a shorter last batch, so the callback refuses before training instead of during it:
Exception occured in `QuantizeCallback` when calling event `before_fit`:
pt2e QAT captures the model at one batch size, but these dataloaders yield [6, 64]: a graph captured by torch.export only runs the batch size it was captured with. Use a batch size that divides both dataset sizes, or drop the last batch of each loader.
Drop that batch, and the fit runs:
dls.valid.drop_last =True# 23 batches of 64; the 6-image remainder is droppedtorch.manual_seed(0)pt2e = Learner(dls, deepcopy(base), metrics=accuracy)with pt2e.no_bar(): pt2e.fit_one_cycle(2, 1e-4, cbs=QuantizeCallback(backend='pt2e', verbose=True))print(quant_spec(pt2e.model).as_dict())
pt2e: capturing the model with torch.export
pt2e: prepared for QAT (W8A8, per_channel, qdq_placement=per_op)
[0, 0.0709400549530983, 0.1948516070842743, 0.932744562625885, '00:07']
[1, 0.04437658190727234, 0.19175469875335693, 0.9375, '00:07']
pt2e: converting the trained graph to a quantized one
{'backend': 'pt2e', 'method': 'qat', 'weight_bits': 8, 'act_bits': 8, 'qscheme': 'per_channel', 'symmetric': True, 'group_size': None, 'layer_bits': None, 'qdq_placement': 'per_op', 'skip_activations': None, 'engine': None}
The graph it leaves behind is what export_qdq writes and what qdq_stats reads back:
sample = torch.randn(64, 3, 64, 64) # the batch size the graph was captured atqdq_path = export_qdq(pt2e.model.cpu(), sample, TMP/"resnet18_qat_int8.onnx")print(qdq_path.name, f"{qdq_path.stat().st_size /1e6:.1f} MB")print(qdq_stats(qdq_path).as_dict())
[torch.onnx] Obtain model graph for `GraphModule([...]` with `torch.export.export(..., strict=False)`...
[torch.onnx] Obtain model graph for `GraphModule([...]` with `torch.export.export(..., strict=False)`... ✅
[torch.onnx] Run decomposition...
[torch.onnx] Run decomposition... ✅
[torch.onnx] Translate the graph into ONNX...
[torch.onnx] Translate the graph into ONNX... ✅
resnet18_qat_int8.onnx 11.4 MB
{'n_quantize': 33, 'n_dequantize': 54, 'n_per_channel': 21, 'n_nonzero_zero_point': 0, 'n_unquantized_conv_add': 0}
33 QuantizeLinear and 54 DequantizeLinear nodes, 21 tensors with one scale per channel, 0 non-zero zero-points — what symmetric INT8 promises, counted on the file rather than trusted — and 0 Add inputs read straight from a convolution, which is the default qdq_placement='per_op'.
Measured on
ResNet-18 (ImageNet weights, fc replaced), Oxford-IIIT Pet as a two-class problem, 64px, batch 64, torch 2.9.1+cu128, single run, torch.manual_seed(0) set before each arm. Both accuracies score the model each arm produced — the floating-point model for the first arm, the converted INT8 model for the second — on the CPU over the whole validation set, n = 1478; the pt2e arm drops the six-image last batch and reports no accuracy. No latency is measured. The two sizes are state_dict bytes; the ONNX size is the file export_qdq wrote.
Summary
Tool
What it gives you
QuantizeCallback(backend='x86')
An FX INT8 model on learn.model after the fit, converted from the fake-quantize modules it trained through
QuantizeCallback(backend='pt2e')
A symmetric INT8 graph, refused up front when the dataloaders yield more than one batch size
quant_spec(model)
The precision record the callback attached to the model it produced
export_qdq(model, sample, path)
A QDQ ONNX file written from that graph
qdq_stats(path)
Node counts, per-channel count and zero-point audit, read off the file
See Also
Quantizer - Every backend and method, outside a training loop
Precision - Which backend can honor which precision, and why