This is an automated email from the ASF dual-hosted git repository.
tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git
The following commit(s) were added to refs/heads/main by this push:
new 1729c726bf [Relax][Frontend][ONNX] Support Modern QDQ opset attributes
(#19993)
1729c726bf is described below
commit 1729c726bf068011fec4a921b9d1ffff5014ec12
Author: Ronald Nap <[email protected]>
AuthorDate: Mon Jul 13 15:11:42 2026 -0700
[Relax][Frontend][ONNX] Support Modern QDQ opset attributes (#19993)
## Summary
Adds support for newer `QuantizeLinear` and `DequantizeLinear`
attributes in the Relax ONNX frontend.
This includes `output_dtype`, `saturate`, and newer opset behavior,
while rejecting unsupported blocked quantization and `precision` cases.
For `QuantizeLinear` and `DequantizeLinear`, opsets 24 and 25 use the
existing converter for currently supported types. Support for
`float8e8m0`, `int2`, and `uint2` are outside this PR’s scope.
## Testing
Added structural and rejection tests for opsets 19, 21, 23, 24, and 25.
---
python/tvm/relax/frontend/onnx/onnx_frontend.py | 113 +++++++++++
tests/python/relax/test_frontend_onnx.py | 249 ++++++++++++++++++++++++
2 files changed, 362 insertions(+)
diff --git a/python/tvm/relax/frontend/onnx/onnx_frontend.py
b/python/tvm/relax/frontend/onnx/onnx_frontend.py
index 70e90d3731..0ca7ef9e0c 100644
--- a/python/tvm/relax/frontend/onnx/onnx_frontend.py
+++ b/python/tvm/relax/frontend/onnx/onnx_frontend.py
@@ -356,6 +356,78 @@ class QuantizeLinear(OnnxOpConverter):
zp = relax.const(0, out_dtype)
return relax.op.quantize(x, scale, zp, axis=axis, out_dtype=out_dtype)
+ @classmethod
+ def _impl_v19(cls, bb, inputs, attr, params):
+ x, scale = inputs[0], inputs[1]
+ zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
+ axis = attr.get("axis", 1)
+ if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
+ axis = 0
+ out_dtype = "uint8" if zp is None else zp.ty.dtype.dtype
+ if attr.get("saturate", 1) != 1 and
str(out_dtype).startswith("float8"):
+ raise ValueError(
+ "QuantizeLinear float8 quantization with saturate=0 is not
supported yet."
+ )
+ if zp is None:
+ zp = relax.const(0, out_dtype)
+ return relax.op.quantize(x, scale, zp, axis=axis, out_dtype=out_dtype)
+
+ @classmethod
+ def _impl_v21(cls, bb, inputs, attr, params):
+ if attr.get("block_size", 0) != 0:
+ raise ValueError("QuantizeLinear blocked quantization is not
supported yet.")
+ x, scale = inputs[0], inputs[1]
+ zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
+ axis = attr.get("axis", 1)
+ if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
+ axis = 0
+ output_dtype = attr.get("output_dtype", 0)
+ out_dtype = (
+ get_type(output_dtype) if output_dtype else "uint8" if zp is None
else zp.ty.dtype.dtype
+ )
+ if attr.get("saturate", 1) != 1 and
str(out_dtype).startswith("float8"):
+ raise ValueError(
+ "QuantizeLinear float8 quantization with saturate=0 is not
supported yet."
+ )
+ if zp is not None and zp.ty.dtype.dtype != out_dtype:
+ raise ValueError(
+ "QuantizeLinear output_dtype must match the zero-point dtype, "
+ f"but got {out_dtype} and {zp.ty.dtype.dtype}."
+ )
+ if zp is None:
+ zp = relax.const(0, out_dtype)
+ return relax.op.quantize(x, scale, zp, axis=axis, out_dtype=out_dtype)
+
+ @classmethod
+ def _impl_v23(cls, bb, inputs, attr, params):
+ if attr.get("block_size", 0) != 0:
+ raise ValueError("QuantizeLinear blocked quantization is not
supported yet.")
+ if attr.get("precision", 0) != 0:
+ raise ValueError(
+ "QuantizeLinear with a non-default precision attribute is not
supported yet."
+ )
+ x, scale = inputs[0], inputs[1]
+ zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
+ axis = attr.get("axis", 1)
+ if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
+ axis = 0
+ output_dtype = attr.get("output_dtype", 0)
+ out_dtype = (
+ get_type(output_dtype) if output_dtype else "uint8" if zp is None
else zp.ty.dtype.dtype
+ )
+ if attr.get("saturate", 1) != 1 and
str(out_dtype).startswith("float8"):
+ raise ValueError(
+ "QuantizeLinear float8 quantization with saturate=0 is not
supported yet."
+ )
+ if zp is not None and zp.ty.dtype.dtype != out_dtype:
+ raise ValueError(
+ "QuantizeLinear output_dtype must match the zero-point dtype, "
+ f"but got {out_dtype} and {zp.ty.dtype.dtype}."
+ )
+ if zp is None:
+ zp = relax.const(0, out_dtype)
+ return relax.op.quantize(x, scale, zp, axis=axis, out_dtype=out_dtype)
+
class DequantizeLinear(OnnxOpConverter):
@classmethod
@@ -380,6 +452,47 @@ class DequantizeLinear(OnnxOpConverter):
zp = relax.const(0, x.ty.dtype.dtype)
return relax.op.dequantize(x, scale, zp, axis=axis,
out_dtype="float32")
+ @classmethod
+ def _impl_v19(cls, bb, inputs, attr, params):
+ x, scale = inputs[0], inputs[1]
+ zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
+ axis = attr.get("axis", 1)
+ if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
+ axis = 0
+ out_dtype = scale.ty.dtype.dtype
+ if zp is None:
+ zp = relax.const(0, x.ty.dtype.dtype)
+ return relax.op.dequantize(x, scale, zp, axis=axis,
out_dtype=out_dtype)
+
+ @classmethod
+ def _impl_v21(cls, bb, inputs, attr, params):
+ if attr.get("block_size", 0) != 0:
+ raise ValueError("DequantizeLinear blocked quantization is not
supported yet.")
+ x, scale = inputs[0], inputs[1]
+ zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
+ axis = attr.get("axis", 1)
+ if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
+ axis = 0
+ out_dtype = scale.ty.dtype.dtype
+ if zp is None:
+ zp = relax.const(0, x.ty.dtype.dtype)
+ return relax.op.dequantize(x, scale, zp, axis=axis,
out_dtype=out_dtype)
+
+ @classmethod
+ def _impl_v23(cls, bb, inputs, attr, params):
+ if attr.get("block_size", 0) != 0:
+ raise ValueError("DequantizeLinear blocked quantization is not
supported yet.")
+ x, scale = inputs[0], inputs[1]
+ zp = inputs[2] if len(inputs) > 2 and inputs[2] is not None else None
+ axis = attr.get("axis", 1)
+ if hasattr(x.ty, "ndim") and x.ty.ndim <= 1 and axis == 1:
+ axis = 0
+ output_dtype = attr.get("output_dtype", 0)
+ out_dtype = get_type(output_dtype) if output_dtype else
scale.ty.dtype.dtype
+ if zp is None:
+ zp = relax.const(0, x.ty.dtype.dtype)
+ return relax.op.dequantize(x, scale, zp, axis=axis,
out_dtype=out_dtype)
+
class DynamicQuantizeLinear(OnnxOpConverter):
@classmethod
diff --git a/tests/python/relax/test_frontend_onnx.py
b/tests/python/relax/test_frontend_onnx.py
index c1bf8802f0..a21b3d4e7a 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -12291,5 +12291,254 @@ def test_dequantizelinear_default_axis_opset10():
check_correctness(model, inputs={"x": x}, opset=10, check_dtypes=True)
[email protected]("opset", [21, 23, 24, 25])
+def test_quantizelinear_output_dtype(opset):
+ node = helper.make_node("QuantizeLinear", ["x", "scale"], ["y"],
output_dtype=TensorProto.INT16)
+ graph = helper.make_graph(
+ [node],
+ "quantizelinear_output_dtype",
+ [
+ helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT, []),
+ ],
+ [helper.make_tensor_value_info("y", TensorProto.INT16, [2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ tvm_model = from_onnx(model, opset=opset, keep_params_in_input=True)
+
+ @I.ir_module
+ class Expected:
+ @R.function
+ def main(
+ x: R.Tensor((2, 3), dtype="float32"),
+ scale: R.Tensor((), dtype="float32"),
+ ) -> R.Tensor((2, 3), dtype="int16"):
+ R.func_attr({"num_input": 2})
+ with R.dataflow():
+ gv: R.Tensor((2, 3), dtype="int16") = R.quantize(
+ x, scale, R.const(0, "int16"), out_dtype="int16", axis=1
+ )
+ R.output(gv)
+ return gv
+
+ tvm.ir.assert_structural_equal(tvm_model, Expected)
+
+
[email protected]("opset", [19, 21, 23, 24, 25])
+def test_dequantizelinear_scale_dtype(opset):
+ node = helper.make_node("DequantizeLinear", ["x", "scale", "zero_point"],
["y"])
+ graph = helper.make_graph(
+ [node],
+ "dequantizelinear_scale_dtype",
+ [
+ helper.make_tensor_value_info("x", TensorProto.INT8, [2, 3]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT16, []),
+ helper.make_tensor_value_info("zero_point", TensorProto.INT8, []),
+ ],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT16, [2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ tvm_model = from_onnx(model, opset=opset, keep_params_in_input=True)
+
+ @I.ir_module
+ class Expected:
+ @R.function
+ def main(
+ x: R.Tensor((2, 3), dtype="int8"),
+ scale: R.Tensor((), dtype="float16"),
+ zero_point: R.Tensor((), dtype="int8"),
+ ) -> R.Tensor((2, 3), dtype="float16"):
+ R.func_attr({"num_input": 3})
+ with R.dataflow():
+ gv: R.Tensor((2, 3), dtype="float16") = R.dequantize(
+ x, scale, zero_point, out_dtype="float16", axis=1
+ )
+ R.output(gv)
+ return gv
+
+ tvm.ir.assert_structural_equal(tvm_model, Expected)
+
+
[email protected]("opset", [23, 24, 25])
+def test_dequantizelinear_output_dtype(opset):
+ node = helper.make_node(
+ "DequantizeLinear",
+ ["x", "scale", "zero_point"],
+ ["y"],
+ output_dtype=TensorProto.FLOAT,
+ )
+ graph = helper.make_graph(
+ [node],
+ "dequantizelinear_output_dtype",
+ [
+ helper.make_tensor_value_info("x", TensorProto.INT8, [2, 3]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT16, []),
+ helper.make_tensor_value_info("zero_point", TensorProto.INT8, []),
+ ],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT, [2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ tvm_model = from_onnx(model, opset=opset, keep_params_in_input=True)
+
+ @I.ir_module
+ class Expected:
+ @R.function
+ def main(
+ x: R.Tensor((2, 3), dtype="int8"),
+ scale: R.Tensor((), dtype="float16"),
+ zero_point: R.Tensor((), dtype="int8"),
+ ) -> R.Tensor((2, 3), dtype="float32"):
+ R.func_attr({"num_input": 3})
+ with R.dataflow():
+ gv: R.Tensor((2, 3), dtype="float32") = R.dequantize(
+ x, scale, zero_point, out_dtype="float32", axis=1
+ )
+ R.output(gv)
+ return gv
+
+ tvm.ir.assert_structural_equal(tvm_model, Expected)
+
+
[email protected]("opset", [19, 21, 23, 24, 25])
+def test_quantizelinear_integer_saturate(opset):
+ node = helper.make_node("QuantizeLinear", ["x", "scale"], ["y"],
saturate=0)
+ graph = helper.make_graph(
+ [node],
+ "quantizelinear_integer_saturate",
+ [
+ helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT, []),
+ ],
+ [helper.make_tensor_value_info("y", TensorProto.UINT8, [2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ tvm_model = from_onnx(model, opset=opset, keep_params_in_input=True)
+
+ @I.ir_module
+ class Expected:
+ @R.function
+ def main(
+ x: R.Tensor((2, 3), dtype="float32"),
+ scale: R.Tensor((), dtype="float32"),
+ ) -> R.Tensor((2, 3), dtype="uint8"):
+ R.func_attr({"num_input": 2})
+ with R.dataflow():
+ gv: R.Tensor((2, 3), dtype="uint8") = R.quantize(
+ x, scale, R.const(0, "uint8"), out_dtype="uint8", axis=1
+ )
+ R.output(gv)
+ return gv
+
+ tvm.ir.assert_structural_equal(tvm_model, Expected)
+
+
[email protected]("opset", [19, 21, 23, 24, 25])
+def test_quantizelinear_float8_saturate_rejected(opset):
+ node = helper.make_node(
+ "QuantizeLinear",
+ ["x", "scale", "zero_point"],
+ ["y"],
+ saturate=0,
+ )
+ graph = helper.make_graph(
+ [node],
+ "quantizelinear_float8_saturate",
+ [
+ helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT, []),
+ helper.make_tensor_value_info("zero_point",
TensorProto.FLOAT8E4M3FN, []),
+ ],
+ [helper.make_tensor_value_info("y", TensorProto.FLOAT8E4M3FN, [2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ with pytest.raises(ValueError, match="saturate=0"):
+ from_onnx(model, opset=opset, keep_params_in_input=True)
+
+
[email protected]("opset", [21, 23, 24, 25])
[email protected](
+ "op_name,input_dtype,output_dtype",
+ [
+ ("QuantizeLinear", TensorProto.FLOAT, TensorProto.INT8),
+ ("DequantizeLinear", TensorProto.INT8, TensorProto.FLOAT),
+ ],
+)
+def test_qdq_blocked_quantization_rejected(opset, op_name, input_dtype,
output_dtype):
+ node = helper.make_node(
+ op_name,
+ ["x", "scale", "zero_point"],
+ ["y"],
+ axis=1,
+ block_size=2,
+ )
+ graph = helper.make_graph(
+ [node],
+ "qdq_blocked_quantization",
+ [
+ helper.make_tensor_value_info("x", input_dtype, [1, 4]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT, [1, 2]),
+ helper.make_tensor_value_info("zero_point", TensorProto.INT8, [1,
2]),
+ ],
+ [helper.make_tensor_value_info("y", output_dtype, [1, 4])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ with pytest.raises(ValueError, match="blocked quantization"):
+ from_onnx(model, opset=opset, keep_params_in_input=True)
+
+
[email protected]("opset", [23, 24, 25])
+def test_quantizelinear_precision_rejected(opset):
+ node = helper.make_node(
+ "QuantizeLinear",
+ ["x", "scale"],
+ ["y"],
+ output_dtype=TensorProto.INT8,
+ precision=TensorProto.FLOAT16,
+ )
+ graph = helper.make_graph(
+ [node],
+ "quantizelinear_precision",
+ [
+ helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT, []),
+ ],
+ [helper.make_tensor_value_info("y", TensorProto.INT8, [2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ with pytest.raises(ValueError, match="precision attribute"):
+ from_onnx(model, opset=opset, keep_params_in_input=True)
+
+
[email protected]("opset", [21, 23, 24, 25])
+def test_quantizelinear_output_dtype_mismatch(opset):
+ node = helper.make_node(
+ "QuantizeLinear",
+ ["x", "scale", "zero_point"],
+ ["y"],
+ output_dtype=TensorProto.UINT8,
+ )
+ graph = helper.make_graph(
+ [node],
+ "quantizelinear_output_dtype_mismatch",
+ [
+ helper.make_tensor_value_info("x", TensorProto.FLOAT, [2, 3]),
+ helper.make_tensor_value_info("scale", TensorProto.FLOAT, []),
+ helper.make_tensor_value_info("zero_point", TensorProto.INT8, []),
+ ],
+ [helper.make_tensor_value_info("y", TensorProto.UINT8, [2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
opset)])
+
+ with pytest.raises(ValueError, match="must match the zero-point dtype"):
+ from_onnx(model, opset=opset, keep_params_in_input=True)
+
+
if __name__ == "__main__":
tvm.testing.main()