TORQ ONNX Quantization
This document describes TORQ’s ONNX integer quantization support.
Table of Contents
1. Overview
TORQ’s ONNX quantization pipeline is built around
ONNX Runtime (ORT) static quantization.
ORT takes a floating-point ONNX model, runs it over a representative dataset,
and records the numeric range of each activation. Those ranges become fixed
scale and zero_point values that are baked into the quantized graph.
TORQ wraps ORT quantization and integrates it into the torq-gen-config
discovery and run workflows. The resulting quantized ONNX model is imported to
MLIR, compiled with the TORQ compiler, and compared against an ONNX Runtime
reference running the same quantized model.
High-level lowering flow
A quantized Conv2D can arrive from ONNX in either QDQ or QOperator form. The two
representations look different at the ONNX and Torch-MLIR levels, but both end
up at the same linalg.conv_2d_nchw_fchw_q and finally torq_hl.conv2d.
┌─────────────────────────┐ ┌─────────────────────────┐
│ ONNX QDQ graph │ │ ONNX QOperator graph │
│ (full-integer I/O) │ │ │
│ │ │ │
│ input_i8 → DQ → f32 │ │ input_i8 ──────────┐ │
│ weight_i8 → DQ → f32 │ │ weight_i8 │ │
│ Conv(f32) → f32 │ │ scale/zp inputs │ │
│ f32 → Q → output_i8 │ │ QLinearConv → i8 │ │
└───────────┬─────────────┘ └───────────┬─────────────┘
│ import_onnx │ import_onnx
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Torch-MLIR │ │ Torch-MLIR │
│ aten.dequantize │ │ onnx.QLinearConv │
│ aten.convolution (f32) │ │ │
│ aten.quantize_per_tensor│ │ │
└───────────┬─────────────┘ └───────────┬─────────────┘
│ torch-fuse-quantized-ops │ (importer
│ (fuses DQ/Conv/Q into │ lowers directly)
│ quantized aten.convolution) │
▼ ▼
┌─────────────────────────┐ ┌─────────────────────────┐
│ Torch-MLIR quantized │ │ Torch-MLIR quantized │
│ │ │ │
│ aten.convolution │ │ aten.convolution │
│ (qint8 input/weight, │ │ (qint8 input/weight, │
│ si32 output) │ │ si32 output) │
│ dequantize → quant │ │ dequantize → quant │
│ (output rescale) │ │ (output rescale) │
└───────────┬─────────────┘ └───────────┬─────────────┘
│ convert-torch-to-linalg │ convert-torch-to-linalg
▼ ▼
┌─────────────────────────────────────────────────────────┐
│ Linalg quantized conv │
│ │
│ linalg.conv_2d_nchw_fchw_q(ins: i8, i8, i32 zp, i32 zp) │
│ → i32 │
│ linalg.generic (re-quant i32 → i8) │
└───────────────────────────┬─────────────────────────────┘
│
│ LinalgToTorqHL (TORQ compiler)
▼
┌───────────────────────────────┐
│ torq_hl.conv2d │
│ (i8 input, i16 weight, │
│ i32 scale_bias) → i8 │
└───────────────────────────────┘
At the ONNX level, QDQ keeps the original FP32 Conv and wraps it with
QuantizeLinear/DequantizeLinear nodes. QOperator replaces the op with a
native int8 op such as QLinearConv. After import:
QDQ is imported as
aten.dequantize → aten.convolution(f32) → aten.quantize_per_tensor. Thetorch-fuse-quantized-opspass folds that sequence into a quantizedaten.convolutionwithqint8inputs/weight andsi32output, wrapped by the outputdequantize → quantizerescale.QOperator is imported as
onnx.QLinearConv, which torch-mlir lowers to the same quantizedaten.convolutionform.
convert-torch-to-linalg then lowers both to
linalg.conv_2d_nchw_fchw_q. The TORQ compiler’s LinalgToTorqHL conversion
takes that quantized conv and lowers it to torq_hl.conv2d.
Not every op follows this exact path. QuantizeLinear/DequantizeLinear
operations that are not fused into a weighted op are lowered as standalone
torq_hl operations by the fallback patterns in QuantizePattern.cpp. See
QuantizePattern.cpp (fallback) for details.
2. ONNX Quantization Formats
ORT supports two quantized graph representations.
QDQ
In Quantize/Dequantize mode, ORT inserts QuantizeLinear /
DequantizeLinear nodes around the original FP32 ops. The original op is
preserved and only its input/output types change:
DQ → Conv → Q
DQ → Add → Q
DQ → ReduceMean → Q
ORT can quantize virtually any op this way; there is no need for a dedicated
quantized op such as QLinearReduceMean.
QOperator
In QOperator mode, ORT replaces supported ops with native int8 ops:
Conv→QLinearConvMatMul/Gemm→QLinearMatMul/QGemmAdd→QLinearAddMaxPool,AveragePool,GlobalAveragePoolRelu,Clip,LeakyRelu,Sigmoid,Softmax
Unsupported ops are left in FP32 and wrapped with DQ/Q nodes.
Hybrid
A hybrid quantized model mixes QDQ and QOperator representations. When a
full model is quantized with --quant-format=qoperator, ORT already produces a
hybrid graph automatically: supported ops become QLinear* nodes and
unsupported ops stay as DQ/Q islands. For example:
QLinearConv → DequantizeLinear → ReduceMean → Reshape → QuantizeLinear → QGemm
TORQ’s --quant-format=hybrid extends this idea to per extracted layer:
Use qoperator when every compute op in the layer is in TORQ’s preferred set (e.g.
Conv,Add,MatMul,MaxPool,Relu).Use QDQ otherwise (e.g.
ReduceMean,Gemm).
Because each layer is quantized independently, one layer can be a compact
QLinearConv while the next remains a DQ/Q-wrapped ReduceMean. On full
ResNet18 this places 47 layers on NSS (Conv, Add, Relu, MaxPool,
Reshape) and 2 layers on HOST (ReduceMean, Gemm), with pure qoperator
failing on Gemm and pure QDQ missing the more compact qoperator form.
3. torq-gen-config Quantization Workflow
torq-gen-config discover and torq-gen-config run are the two entry points for
quantized ONNX models.
The Python static-quantization helpers accept ONNX Runtime’s
CalibrationDataReader through calibration_data_reader; use it to yield
representative dictionaries keyed by ONNX input name. This lets applications use
ONNX Runtime’s existing readers or their own dataset adapters without a Torq
dataset format. Calls without a reader fall back to deterministic synthetic
random calibration inputs.
Supported Quantization Options
Option |
Effect |
|---|---|
|
Enable static int8 quantization for layer tests and the full model. |
|
Use per-channel weight scales instead of per-tensor scales. |
|
Rewrite graph inputs/outputs to int8 by stripping the input-side |
|
ONNX quantization format: |
|
Activation/weight integer dtype. Currently only |
Important: discover and run must use the same quantization flags. The
compiler JSON is built from the final quantized MLIR; mismatched flags produce a
mismatched executor map.
Per-Layer vs. Full-Model Quantization
Each extracted layer is quantized independently during discovery. A Relu
layer becomes its own tiny quantized graph:
input → QuantizeLinear → Relu → DequantizeLinear → output
So the per-layer test produces a valid reference and a recommended_executor
for Relu in the report JSON.
When the whole model is quantized, the quantizer can fuse Relu into the
preceding Conv. In the final quantized MLIR there is no separate Relu line,
so the compiler JSON assigns the fused Conv+Relu entirely through the Conv
entry. This is why you may see a Relu_... entry in the report JSON but no
matching line in the compiler JSON.
CLI Usage
# Basic quantized discovery (QDQ)
torq-gen-config discover --model model.onnx --skip-mode --quantize
# Full-integer int8 I/O
torq-gen-config discover --model model.onnx --skip-mode --quantize --full-integer
# Quantized, skipping CSS and deduplicating identical layers
torq-gen-config discover \
--model model.onnx \
--skip-mode \
--quantize --full-integer --quant-format=qdq \
--skip-executors=css --dedup-layers
run applies the compiler JSON produced by discover to the full model. Pass
the same quantization flags so the JSON line numbers match the final quantized
MLIR:
torq-gen-config run \
--model model.onnx \
--output-dir results/ \
--quantize --full-integer --quant-format=qdq \
--debug-ir=tmp
If the flags do not match, the compiler JSON line numbers will not align with the full-model MLIR and executor assignment will fail or target the wrong ops.
4. Quantized Convolution Zero-Point Correction
Quantized ops with weights need two corrections:
Fold the weight zero-point into the weight tensor.
Add
-input_zp * sum(adjusted_weight)as a per-output bias.
This section derives those corrections for Conv2D. The same structure applies to
any weighted op of the form y = x * w + b.
4.1 Operation definition
A quantized convolution is typically represented in Linalg as:
%out = linalg.conv_2d_nchw_fchw_q {
dilations = dense<1> : vector<2xi64>,
strides = dense<2> : vector<2xi64>
} ins(
%input, // tensor<...xi8>, quantized activation
%weight, // tensor<...xi8>, quantized weight
%input_zp, // i32 scalar, input zero-point
%weight_zp // i32 scalar, weight zero-point
: tensor<...xi8>, tensor<...xi8>, i32, i32
) outs(%init : tensor<...xi32>) -> tensor<...xi32>
The four input operands are:
Index |
Operand |
Meaning |
|---|---|---|
0 |
|
quantized input activation tensor ( |
1 |
|
quantized weight/filter tensor ( |
2 |
|
input zero-point ( |
3 |
|
weight zero-point ( |
The integer convolution computes, for each output element:
conv_out[c, y, x] = sum_{kh,kw,ic} input[y+kh, x+kw, ic] * weight[c, kh, kw, ic]
But the mathematically correct result in the integer domain is:
correct[c, y, x] = sum_{kh,kw,ic}
(input[y+kh, x+kw, ic] - input_zp) *
(weight[c, kh, kw, ic] - weight_zp)
The hardware path keeps the input tensor in its as-quantized form, so explicit
dequantization to floating point is avoided. Two corrections are required to
recover correct from the raw integer convolution.
4.2 Mechanical rewriting
Expand the product inside the summation:
(input - input_zp) * (weight - weight_zp)
= input * weight
- input_zp * weight // term A
- weight_zp * input // term B
+ input_zp * weight_zp // term C
The full sum over the kernel window therefore becomes:
correct = conv(input_quantized, weight_quantized)
- input_zp * sum(weight_quantized) // term A
- weight_zp * sum(input_quantized) // term B
+ input_zp * weight_zp * K // term C, K = kernel size
Because the integer convolution only computes conv(input_quantized, weight_quantized), the missing terms must be added explicitly. The terms are
rewritten so that each one is a sub-component that can be lowered independently
by the compiler:
conv(input_quantized, weight_quantized)is computed by the integer convolution hardware.-input_zp * sum(weight_quantized)is the input-zero-point correction.-weight_zp * inputis absorbed by foldingweight_zpinto the weight tensor before the convolution.input_zp * weight_zp * Kis a constant that is already folded into the bias (or is zero whenweight_zp == 0).
After these rewrites the effective computation is:
intermediate_i32[c, y, x] = conv_i32(
input_quantized,
adjusted_weight
)
+ input_zp_correction[c]
+ bias[c]
where:
adjusted_weight = weight - weight_zpinput_zp_correction[c] = -input_zp * sum(adjusted_weight[c])bias[c]is the optional per-channel bias, typically already ini32.
4.3 Sub-component definitions
4.3.1 Weight zero-point folding
For every weight element:
adjusted_weight[c, kh, kw, ic] = weight[c, kh, kw, ic] - weight_zp
This removes term B from the expanded expression because the convolution now
operates on (weight - weight_zp).
Implementation: PatternUtils.cpp provides buildWeightWithZp(weights, weightZp, rewriter). It sign-extends the i8 weight to i16, subtracts the
weight zero-point, and materializes an i16 tensor. For the QConv2D path the
same effect is obtained through preConversionWeights(...) in
Conv2DPattern.cpp.
Generalization: this step is the same for any weighted op. The weights are simply adjusted by their zero-point before use.
4.3.2 Input zero-point correction
Because the input tensor is not dequantized, term A is missing from the raw convolution output. We compute it explicitly as a per-channel bias:
input_zp_correction[c] = -input_zp * sum_{kh,kw,ic} adjusted_weight[c, kh, kw, ic]
This is added to the convolution output before the final rescale.
Implementation: QConv2DPattern.cpp provides two helpers:
computeInputZpCorrection(adjustedWeights, inputZp, rewriter)— generic reduce+multiply path usinglinalg.reduceover all non-output dimensions.buildInputZpCorrectionConstant(weights, inputZp, rewriter)— constant-only fast path that reads the constant weight tensor directly and produces a 1-Di32constant without creating alinalg.reduce.
Both helpers compute the same value; the constant path is used when weights are constants and the reduce path is the fallback for dynamic weights.
4.3.3 Bias addition
The original QDQ graph may contain a + bias after the convolution. The bias is
added to the corrected integer accumulator:
intermediate_i32 = conv_i32(...) + input_zp_correction + bias
Implementation: addPerChannelBias(lhs, rhs, rewriter) in
QConv2DPattern.cpp emits a linalg.generic that adds two 1-D i32 tensors.
When both operands are constants (or slices of constants),
addI32SlicesOfConstants(lhs, rhs, rewriter) folds them into a single constant
tensor at compile time.
4.3.4 Output rescale and clamp
After the corrected integer accumulation, the result must be rescaled to the output quantization range. Because the hardware does not perform a floating-point multiplication at runtime, the FP32 scale is approximated by a fixed-point multiply-and-shift:
output_i8[c, y, x] = saturate(
round(intermediate_i32[c, y, x] * multiplier / 2^shift) + output_zp
)
The scale comes from the surrounding QDQ graph:
scale = dequant_scale / quant_scale
= (input_scale * weight_scale) / output_scale
computeMultiplierAndShift(scale, multiplier, shift) searches for the largest
multiple-of-4 shift whose rounded multiplier fits in a signed 32-bit integer. If
scale == 0, the multiplier is set to 0 and the shift to 28.
For example, with scale = 0.5:
shift = 28: multiplier = round(0.5 * 2^28) = 134217728 (fits in i32)
so scale ~= 134217728 / 2^28 = 0.5. The actual integer rescale multiplies by
134217728 and right-shifts by 28 bits.
4.4 Lowering each sub-component
4.4.1 QConv2DPattern.cpp
QConv2dConvert matches the chain:
linalg.conv_2d_nchw_fchw_q -> (optional bias add) -> dequant generic -> quant generic
It extracts:
%inputfrom the conv operand 0,%weightsfrom the conv operand 1,%input_zpfrom the conv operand 2,%weight_zpfrom the conv operand 3,dequantScalefrom the dequant generic,quantScale,quantZp,quantMin,quantMaxfrom the quant generic.
The rewriter then:
Computes
scaleFactor = dequantScale / quantScaleand(multiplier, shift).Folds the bias quant generic to a constant
i32tensor when possible viafoldQuantGenericToConstant.Calls
preConversionWeights(weights, optionalWeightZpV, ...)to foldweight_zpinto the weights.Computes the input-zero-point correction using the constant fast path when possible, otherwise the generic reduce path.
Adds the correction into the bias.
Folds backward padding so the pad op is fused into the conv.
Emits a
torq_hl.conv2dop with:input_zpset to the original input zero-point,weight_zpset to0because it has already been folded into the weights,output_zp,output_min,output_max,shift,an interleaved
scale_biastensor of shape{2 * C}holding[bias_0, multiplier, bias_1, multiplier, ...].
The matching of dequant -> quant is intentional: the rewrite only fires when
it can replace the entire QDQ sandwich with a single hardware op. If the chain
is not matched, the fallback patterns in QuantizePattern.cpp handle the
standalone quant and dequant generics.
4.4.2 QuantizePattern.cpp (fallback)
When a standalone quantize or dequantize generic survives (for example, because
folding into a weighted op failed), QuantizePattern.cpp lowers them to
torq_hl operations.
Quantize generic
A quantize generic computes:
out_i8 = clamp(round(input / scale) + zp, min, max)
The pattern QuantizeOpConversion rewrites it as:
torq_hl.actf2f(if input is f32, cast to bf16),torq_hl.mulwith multiplier1/scaleand biaszpin bf16,torq_hl.actf2i(bf16 -> i32),torq_hl.fmawith identity weights/bias to clamp to[min, max]and cast toi8.
This is a fallback and is not the preferred path for quant ops that can be
folded into torq_hl.conv2d.
Dequantize generic
A dequantize generic computes:
out = (input_i8 - zp) * scale
The pattern DequantizeOpConversion rewrites it as:
torq_hl.acti2f(i8 -> bf16),torq_hl.mulwith multiplierscalein bf16,torq_hl.actf2f(bf16 -> f32) if the original output type is f32.
Again, this is a fallback for cases where the dequant cannot be fused with a preceding quantized op.
4.5 Special cases
4.5.1 weight_zp == 0
When the weight zero-point is zero, term B and term C of the expansion both vanish. The correction simplifies to:
input_zp_correction[c] = -input_zp * sum(weight[c])
and the weight tensor can be used without adjustment. This is the common case for ONNX Runtime static quantization, where symmetric weight quantization is used.
4.5.2 input_zp == 0
When the input zero-point is zero, term A vanishes. The input-zero-point correction becomes a zero tensor and can be skipped entirely. The effective computation reduces to:
intermediate_i32 = conv_i32(input, adjusted_weight) + bias
4.5.3 Both zero-points are zero
Both corrections vanish and the integer convolution directly computes the correct result:
intermediate_i32 = conv_i32(input, weight) + bias
4.5.4 Dynamic weights
If the weights are not compile-time constants, buildInputZpCorrectionConstant
returns nullptr and the pattern falls back to computeInputZpCorrection,
which emits a linalg.reduce followed by a linalg.generic multiply. The
result is then added to the bias with addPerChannelBias. Later constant-folding
passes can still simplify the graph if the weights become constant later in the
pipeline.
4.6 Why the other expanded terms disappear
Recall the full expansion:
(input - input_zp) * (weight - weight_zp)
= input * weight
- input_zp * weight
- weight_zp * input
+ input_zp * weight_zp
input * weightis the raw integer convolution output.-input_zp * weightis the input-zero-point correction.-weight_zp * inputbecomes zero after folding the weight zero-point into the weights.+input_zp * weight_zp * Kis a constant folded into the bias or omitted whenweight_zp == 0.
4.7 Generalization to other weighted ops
The same two corrections apply to any quantized op with weights:
Op |
Weight zero-point |
Input zero-point correction |
|---|---|---|
Conv2D |
|
|
Conv1D |
|
|
Conv3D |
|
|
DepthwiseConv2D |
|
|
MatMul / FC |
|
|
The only difference is which dimensions are summed to compute the input-zero-point correction. The principle remains:
Fold
weight_zpinto the weight tensor.Add
-input_zp * sum(adjusted_weight)as a per-output bias.
5. References
ONNX Runtime quantization
ONNX Runtime Quantization docs: https://onnxruntime.ai/docs/performance/model-optimizations/quantization.html
Quantization theory and best-practice papers
Wu et al., “Integer Quantization for Deep Learning Inference: Principles and Empirical Evaluation”, 2020.
arXiv: https://arxiv.org/abs/2004.09602
PDF: https://arxiv.org/pdf/2004.09602
Nagel et al., “A White Paper on Neural Network Quantization”, 2021.
https://arxiv.org/abs/2106.08295
Krishnamoorthi, “Quantizing Deep Convolutional Networks for Efficient Inference: A Whitepaper”, 2018.
https://arxiv.org/abs/1806.08342
Jacob et al., “Quantization and Training of Neural Networks for Efficient Integer-Arithmetic-Only Inference”, CVPR 2018.
https://arxiv.org/abs/1712.05877