from IPython.display import Markdown
Markdown(precision_table())Precision
Overview
Compression asks four questions. What block of parameters to remove is granularity, which ones matter is criteria, when to act is schedules — and how few bits to keep the survivors in is precision.
A precision request has six parts:
| Part | Argument | Values |
|---|---|---|
| Weight width | weight_bits |
4, 8, 16, or a {layer_name: width} dict |
| Activation width | act_bits |
8, 16 |
| Weight axis | qscheme |
'per_tensor', 'per_channel', 'per_group' |
| Symmetry | symmetric |
True (every zero-point is 0), False (affine) |
| Q/DQ placement | qdq_placement |
'per_op', 'skip_conv_add' (the pt2e cell only) |
| Activations left in floating point | skip_activations |
a list of module names (the pt2e cell only) |
Two conventions run through the grammar:
- A width of 16 means not quantized: the tensor keeps the model’s floating-point dtype. One label then covers fp16 and fp32 models, so INT8 weight-only quantization is written
W8A16whatever the model’s float type.weight_bits={'head': 16}is how you keep one layer out of the quantization. - The finer the axis, the more scales:
per_tensorkeeps one scale for the whole tensor,per_channelone per output channel,per_groupone per fixed-size block of weights. - A placement is about where the pairs sit, not how many bits:
per_opquantizes the result of every operator the flow annotates;skip_conv_addleaves one edge — the residual branch’s convolution feeding its add — without a Q/DQ pair, so that result reaches the add at accumulator precision. It changes the arithmetic, so it is opt-in and recorded on the spec.
Not every combination exists. A backend can only apply the cells its observers and kernels implement, and only some of those survive an ONNX export. PRECISION_SUPPORT is that matrix, and Quantizer validates every request against it at construction: a cell a backend cannot honor raises immediately, naming a backend that can, instead of being silently rounded to something the backend can do.
Precision cells
A PrecisionCell is one row of the matrix: a backend, a precision, and what that pair can do.
The support matrix
The table below is rendered from PRECISION_SUPPORT rather than written by hand, so it cannot drift away from what the code enforces.
The resolved spec
_resolve_spec turns a request into exactly one QuantSpec, or raises. Quantizer attaches the result to the model it quantizes, where quant_spec(model) reads it back — that is what export_qdq consults to refuse a precision it cannot write.
FakeQuantSpec is the same idea for a precision nothing deploys: [FakeQuantizer](https://FasterAI-Labs.github.io/fasterai/quantize/fake_quantizer.html#fakequantizer) rounds a model onto a width pair in floating point and attaches one of these, which fake_quant_spec(model) reads back. It sits here so that “what precision is this model at?” needs no quantizer import.
Three divergences from QuantSpec are deliberate, not slips:
QuantSpec |
FakeQuantSpec |
|
|---|---|---|
| a tensor left in floating point | 16, since no backend runs a 16-bit grid |
None; 16 is a real 16-bit grid |
label renders that as |
W8A16 |
W8AF |
| per-layer activations | skip_activations, a tuple of names |
layer_act_bits, a width per name |
One field has no counterpart on the deployed side: trained is True when the model was fitted through the rounding by [FakeQuantizeCallback](https://FasterAI-Labs.github.io/fasterai/quantize/fake_quantize_callback.html#fakequantizecallback), and False on every post-training path.
Resolution
One resolver, one place where a request is accepted or refused. Every refusal names the argument at fault and, when there is one, a backend that can honor it.
Usage Examples
The grammar is reached through Quantizer, which forwards its precision arguments to _resolve_spec:
from fasterai.quantize.quantizer import Quantizer
from fasterai.core.all import quant_spec
# The deployable cell: symmetric INT8, per-channel weights, exportable as QDQ ONNX
quantizer = Quantizer(backend='pt2e', weight_bits=8, act_bits=8, qscheme='per_channel', symmetric=True)
model_q = quantizer.quantize(model, calibration_dl=dls.valid)
print(quant_spec(model_q).as_dict())
# {'backend': 'pt2e', 'method': 'static', '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}
# INT8 weight-only, one scale per group of 64 weights
Quantizer(backend='torchao', weight_bits=8, act_bits=16, qscheme='per_group', group_size=64)
# Keep the classifier out of the quantization (16 = left in floating point)
Quantizer(backend='x86', weight_bits={'fc': 16})
# The same dict on torchao, over the Linear layers it rewrites: INT8 everywhere except one layer
Quantizer(backend='torchao', weight_bits={'layers.0.linear1': 16}, act_bits=16)A {layer_name: width} dict says which layers are quantized; the width they are quantized at is the uniform one, so the two spellings below resolve to the same cell. Layers the dict does not name keep that uniform width — the dict is an override list, not a whitelist.
_resolve_spec('torchao', weight_bits={'fc': 16}, act_bits=16).weight_bits # 8
_resolve_spec('torchao', weight_bits={'fc': 16, 'head': 8}, act_bits=16).weight_bits # 8qdq_placement names where the Q/DQ pairs sit rather than how many bits they keep. Only the pt2e cell has that axis, so it is the only one whose spec carries a value; everywhere else the field stays None and naming a placement raises:
Quantizer(backend='pt2e', qdq_placement='skip_conv_add') # the residual conv->add edge stays unquantized
_resolve_spec('pt2e').qdq_placement # 'per_op' — asking for it explicitly is the same request
_resolve_spec('x86').qdq_placement # None — the FX flow has no such axisskip_activations is the same kind of axis: it names modules whose activations keep the model’s float dtype, while their weights and their inputs stay INT8. Only the pt2e cell can do it, because it is the only flow fasterai annotates itself:
Quantizer(backend='pt2e', skip_activations=['features.0']) # this block's activations stay in floating point
_resolve_spec('pt2e', skip_activations=['features.0']).skip_activations # ('features.0',)
_resolve_spec('x86', skip_activations=['features.0'])
# ValueError: backend='x86' quantizes its activations for the whole graph at once: it cannot leave one
# module's activations in floating point. The backend(s) that can: ['pt2e'].Asking for a cell a backend cannot honor raises, and says who can:
Quantizer(backend='x86', symmetric=True)
# ValueError: backend='x86' W8A8 cannot honor symmetric=True — its observers are affine by
# construction: activations carry a non-zero zero-point. The backend(s) that can: ['pt2e', 'torchao'].
Quantizer(backend='torchao', weight_bits={'fc': 8}) # W8A8: torchao's dynamic-activation recipe
# ValueError: backend='torchao' cannot honor a per-layer `weight_bits` dict at W8A8, only at W8A16:
# add act_bits=16.See Also
- Granularity - What block of parameters to remove
- Criteria - Which parameters matter
- Schedules - When compression happens
- Quantizer - The class that applies a precision cell
- FakeQuantizer - Rounds onto any width pair in floating point, without a backend
- FakeQuantizeCallback - Trains through a width, and sets
trainedon the spec it attaches - ONNX Exporter -
export_qdq, which refuses the cells it cannot write
Tests live in nbs/tests/test_precision.ipynb.