Research digest: migrating tiny-AI QAT to torchao pt2e¶
- Date: 2026-09-22
- Decision recorded in: ADR-1293
- Code:
ai/train/qat.py,ai/pyproject.toml
Every measurement below was taken in a venv matching the Tiny AI job: torch 2.14.0+cpu, torchvision 0.29.0+cpu, pytorch-lightning 2.6.6, torchao 0.18.0, numpy 2.5.3, Python 3.14.7.
The failure¶
DeprecationWarning: torch.ao.quantization is deprecated and will be removed in 2.10.
1. Eager mode quantization ... migrate to torchao eager mode quantize_ API
2. FX graph mode quantization (prepare_fx, convert_fx) ... migrate to
torchao pt2e quantization API instead (prepare_pt2e, convert_pt2e)
Raised by the decorator around prepare_qat_fx, not at import: importing torch.ao.quantization and torch.ao.quantization.quantize_fx under warnings.simplefilter("error") succeeds. ai/pyproject.toml sets filterwarnings = ["error"], so the call fails test_qat_smoke. Before this change the file suite is 1 failed, 1302 passed.
Recipe comparison¶
Read off the two configuration objects directly rather than inferred:
| Observer setting | get_default_qat_qconfig_mapping("x86") | get_default_x86_inductor_quantization_config(is_qat=True) |
|---|---|---|
| activation dtype | torch.quint8 | torch.uint8 |
| activation qscheme | per_tensor_affine | per_tensor_affine |
| activation range | 0 … 127 | 0 … 255 |
| weight dtype | torch.qint8 | torch.int8 |
| weight qscheme | per_channel_symmetric | per_channel_symmetric |
weight ch_axis | 0 | 0 |
| weight range | -128 … 127 | -128 … 127 |
Weights are identical. The activation range differs because the old x86 QAT default sets reduce_range=True — an FBGEMM workaround for accumulator overflow on AVX2 without VNNI. Reading that config also emits its own notice:
UserWarning: Please use quant_min and quant_max to specify the range for observers.
reduce_range will be deprecated in a future release of PyTorch.
Nothing downstream honours the 7-bit ceiling. The QAT phase only conditions weights; onnxruntime.quantization.quantize_static bakes the activation ranges afterwards and quantizes QUInt8 over the full 0 … 255. So the widened range narrows the recipe mismatch ADR-0207 §2 exists to avoid.
Does the weight transfer survive graph capture?¶
The risk was that torch.export renames parameters and _copy_qat_weights_into_fp32 — which matches state-dict keys by name and shape — transfers nothing, tripping its own RuntimeError("0 tensors copied"). Probed against a real LearnedFilter before any edit:
fp32 state_dict keys : entry.weight, entry.bias, body.0.block.0.weight, ...
pt2e state_dict keys : entry.weight, entry.bias, activation_post_process_1.scale, ...
name+shape matches : 20 / 20
forward OK (1, 1, 32, 32)
The captured graph keeps the original parameter names and adds observer buffers alongside. The matcher is unaffected.
Two things the API change forced¶
torch.export.export_for_trainingdoes not exist in torch 2.14 — it folded back intotorch.export.export. Guides written against 2.5–2.9 still name it; the first probe failed withAttributeError: module 'torch.export' has no attribute 'export_for_training'.- An exported graph module refuses mode switches:
NotImplementedError: Calling train() or eval() is not supported for exported models.
Please call `torchao.quantization.pt2e.move_exported_model_to_train(model)` (or eval) instead.
_qat_fine_tune is called with a raw Lightning module for the fp32 warm-start and with the prepared graph for the QAT phase, so _set_mode() dispatches on isinstance(module, torch.fx.GraphModule): True for the capture, False for LearnedFilter.
The exporter, unmasked¶
With the pt2e failure gone, test_qat_run_smoke failed on the next warning in the same call chain:
DeprecationWarning: You are using the legacy TorchScript-based ONNX export.
Starting in PyTorch 2.9, the new torch.export-based ONNX exporter has become the default.
_export_fp32_onnx pinned dynamo=False against "quantization-related intermediate buffers". That justification does not apply: phase 4 exports a fresh model_factory() module carrying transferred weights, with no observers. Dropping the pin and translating dynamic_axes into positional dynamic_shapes ordered by input_names clears it — the same translation ef16e5760 applied to the other export sites.
Result¶
The skip is test_e2e_frame_to_score.py, which needs a built vmaf binary that this workstation's tree does not have at core/build-cpu/tools/vmaf; the Tiny AI job builds one.
Not measured here¶
No model was retrained. ADR-0208's QAT-vs-static delta for learned_filter_v1 was measured under the old recipe and is not re-measured by this change, because no CI job retrains and the registry's .int8.onnx files are untouched. The next qat_train.py run is what will show the new recipe's effect, against the same ai-quant-accuracy PLCC budget.