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 e85fbb1fa9 [Relax][ONNX] Support scalar QDQ inputs (#20126)
e85fbb1fa9 is described below

commit e85fbb1fa93d7e417fa20d5c48925b6665daeeee
Author: Hongyi Wu <[email protected]>
AuthorDate: Wed Aug 19 07:20:39 2026 +0800

    [Relax][ONNX] Support scalar QDQ inputs (#20126)
    
    ## Summary
    
    This PR extends Relax `quantize` and `dequantize` type inference to
    support
    rank-zero inputs when their scale and zero point describe per-tensor
    quantization.
    
    The gap was exposed by the INT8 dynamic-shape BiRefNeXt anime
    segmentation
    model. Its opset-18 graph contains scalar-input `DequantizeLinear` nodes
    with
    scalar scale and zero-point initializers. This is valid ONNX per-tensor
    dequantization, but the current Relax operator rejects the canonicalized
    axis
    because a rank-zero tensor has no axis.
    
    ## Goal
    
    Import and execute valid scalar ONNX `QuantizeLinear` and
    `DequantizeLinear`
    graphs without weakening validation for per-axis quantization.
    
    ## What changed
    
    - Treat the axis as irrelevant for rank-zero Relax quantize/dequantize
    inputs.
    - Require rank-zero inputs to use scalar or singleton scale and
    zero-point
      tensors.
    - Keep the existing axis and parameter-size checks unchanged for inputs
    with
      rank one or greater.
    - Add symmetric core type-inference coverage for scalar quantize and
      dequantize.
    - Add ONNX Runtime-backed numerical and dtype coverage for scalar
    opset-18
      `QuantizeLinear` and `DequantizeLinear` graphs.
    
    ## Design
    
    ONNX uses scalar scale and zero-point tensors for per-tensor
    quantization. In
    that mode the `axis` attribute is ignored. The Relax ONNX frontend
    already
    canonicalizes the default axis to `0` for inputs with rank at most one,
    but the
    Relax operator type relation previously rejected every axis for a
    rank-zero
    input:
    
    ```text
    rank(input) = 0
    valid axis range = [0, rank(input) - 1] = [0, -1]
    ```
    
    For rank-zero input, this change therefore skips the axis-range check
    and
    instead validates the condition that makes the axis irrelevant: both
    scale and
    zero point must be scalar or singleton tensors. The legalization and
    output
    shape rules already handle rank-zero tensors, so no converter or
    lowering
    special case is needed.
    
    ## Updated behavior
    
    | Input / quantization parameters | Behavior |
    | --- | --- |
    | Rank zero + scalar/singleton scale and zero point | Accept as
    per-tensor QDQ; preserve scalar output shape |
    | Rank zero + non-singleton scale or zero point | Reject with an
    explicit error |
    | Rank one or greater | Preserve the existing axis and parameter-size
    validation |
    
    ## Safety checks
    
    - Scalar support is implemented symmetrically for quantize and
    dequantize.
    - A parametrized core test verifies that non-singleton per-axis
    parameters are
      still rejected for rank-zero input.
    - The ONNX tests compare TVM execution and output dtypes with ONNX
    Runtime.
    - The full Relax ONNX frontend test file passes in the validation
    environment,
      apart from five pre-existing Float8 saturate baseline cases that were
      excluded explicitly.
    
    ## Out of scope / non-goals
    
    - Claiming full BiRefNeXt import or execution; this PR removes only its
    first
    observed scalar-QDQ blocker, and the model still has an independent
    dynamic
      shape `Concat` blocker.
    - Adding the external 117 MB BiRefNeXt model to the TVM test suite.
    - Changing per-axis QDQ behavior for non-scalar inputs.
    - Addressing the existing Float8 saturate test baseline.
    
    ## Results
    
    Before this change, minimal scalar ONNX graphs fail during import with:
    
    ```text
    ValueError: relax.quantize: axis param is out of range (0)
    ValueError: relax.dequantize: axis param is out of range (0)
    ```
    
    With this change, opset-18 scalar graphs import, legalize, compile, and
    execute
    against ONNX Runtime:
    
    ```text
    QuantizeLinear:   x = 1.25, scale = 0.25, zero_point = 2 -> uint8 scalar 7
    DequantizeLinear: x = 7,    scale = 0.25, zero_point = 2 -> float32 scalar 
1.25
    ```
    
    ## Tests
    
    - `pre-commit run --files src/relax/op/tensor/qdq.cc
    tests/python/relax/test_frontend_onnx.py
    tests/python/relax/test_op_qdq.py`
    - Fresh CPU-only H20 Release build: passed
    - Relax QDQ operator tests: `9 passed`
    - Focused scalar ONNX Runtime-backed tests: `2 passed, 500 deselected`
    - Relax ONNX frontend H20 run: `484 passed, 9 skipped, 5 deselected, 4
    xfailed`
    
    ## References
    
    - [ONNX QuantizeLinear
    specification](https://onnx.ai/onnx/operators/onnx__QuantizeLinear.html)
    - [ONNX DequantizeLinear
    specification](https://onnx.ai/onnx/operators/onnx__DequantizeLinear.html)
    - [BiRefNeXt anime segmentation ONNX
    model](https://huggingface.co/nkta/birefnext-aniseg-ONNX)
---
 src/relax/op/tensor/qdq.cc               | 34 ++++++++++++++++++++++++--------
 tests/python/relax/test_frontend_onnx.py | 24 ++++++++++++++++++++++
 tests/python/relax/test_op_qdq.py        | 32 ++++++++++++++++++++++++++++++
 3 files changed, 82 insertions(+), 8 deletions(-)

diff --git a/src/relax/op/tensor/qdq.cc b/src/relax/op/tensor/qdq.cc
index 07b9480c67..97e3f97c61 100644
--- a/src/relax/op/tensor/qdq.cc
+++ b/src/relax/op/tensor/qdq.cc
@@ -108,7 +108,7 @@ Type InferTypeQuantize(const Call& call, const 
BlockBuilder& ctx) {
 
   // Check that "axis" attribute is not out of range:
   int axis = (attrs->axis < 0) ? (input_ty->ndim + attrs->axis) : attrs->axis;
-  if (axis < 0 || axis > input_ty->ndim - 1) {
+  if (input_ty->ndim != 0 && (axis < 0 || axis > input_ty->ndim - 1)) {
     TVM_FFI_VISIT_THROW(ValueError, call)
         << "relax.quantize: axis param is out of range (" << attrs->axis << 
")";
   }
@@ -137,9 +137,18 @@ Type InferTypeQuantize(const Call& call, const 
BlockBuilder& ctx) {
     return false;
   };
 
-  // Check size matching of scale/zp params with input shape at dim = 
attrs->axis.
-  if (!is_scalar_or_singleton_vector(scale_ty)) check_param_size(scale_ty, 
input_ty, "scale");
-  if (!is_scalar_or_singleton_vector(zp_ty)) check_param_size(zp_ty, input_ty, 
"zero_point");
+  if (input_ty->ndim == 0) {
+    // A scalar input has no channel axis and only supports per-tensor 
quantization.
+    if (!is_scalar_or_singleton_vector(scale_ty) || 
!is_scalar_or_singleton_vector(zp_ty)) {
+      TVM_FFI_VISIT_THROW(ValueError, call)
+          << "relax.quantize: scale and zero_point must be scalar or singleton 
tensors for "
+             "rank-0 input";
+    }
+  } else {
+    // Check size matching of scale/zp params with input shape at dim = 
attrs->axis.
+    if (!is_scalar_or_singleton_vector(scale_ty)) check_param_size(scale_ty, 
input_ty, "scale");
+    if (!is_scalar_or_singleton_vector(zp_ty)) check_param_size(zp_ty, 
input_ty, "zero_point");
+  }
 
   auto output_ty = ffi::make_object<TensorTypeNode>(*input_ty.get());
   output_ty->dtype = PrimType(attrs->out_dtype);
@@ -221,7 +230,7 @@ Type InferTypeDequantize(const Call& call, const 
BlockBuilder& ctx) {
 
   // Check that "axis" attribute is not out of range:
   int axis = (attrs->axis < 0) ? (input_ty->ndim + attrs->axis) : attrs->axis;
-  if (axis < 0 || axis > input_ty->ndim - 1) {
+  if (input_ty->ndim != 0 && (axis < 0 || axis > input_ty->ndim - 1)) {
     TVM_FFI_VISIT_THROW(ValueError, call)
         << "relax.dequantize: axis param is out of range (" << attrs->axis << 
")";
   }
@@ -250,9 +259,18 @@ Type InferTypeDequantize(const Call& call, const 
BlockBuilder& ctx) {
     return false;
   };
 
-  // Check size matching of scale/zp params with input shape at dim = 
attrs->axis.
-  if (!is_scalar_or_singleton_vector(scale_ty)) check_param_size(scale_ty, 
input_ty, "scale");
-  if (!is_scalar_or_singleton_vector(zp_ty)) check_param_size(zp_ty, input_ty, 
"zero_point");
+  if (input_ty->ndim == 0) {
+    // A scalar input has no channel axis and only supports per-tensor 
quantization.
+    if (!is_scalar_or_singleton_vector(scale_ty) || 
!is_scalar_or_singleton_vector(zp_ty)) {
+      TVM_FFI_VISIT_THROW(ValueError, call)
+          << "relax.dequantize: scale and zero_point must be scalar or 
singleton tensors for "
+             "rank-0 input";
+    }
+  } else {
+    // Check size matching of scale/zp params with input shape at dim = 
attrs->axis.
+    if (!is_scalar_or_singleton_vector(scale_ty)) check_param_size(scale_ty, 
input_ty, "scale");
+    if (!is_scalar_or_singleton_vector(zp_ty)) check_param_size(zp_ty, 
input_ty, "zero_point");
+  }
 
   auto output_ty = ffi::make_object<TensorTypeNode>(*input_ty.get());
   output_ty->dtype = PrimType(attrs->out_dtype);
diff --git a/tests/python/relax/test_frontend_onnx.py 
b/tests/python/relax/test_frontend_onnx.py
index 32dd4b0bef..673c25bcdd 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -12476,6 +12476,30 @@ def test_dequantizelinear_singleton_qparams_opset10():
     check_correctness(model, inputs={"x": x}, opset=10, check_dtypes=True)
 
 
[email protected](
+    ("op_type", "input_dtype", "output_dtype", "input_value"),
+    [
+        ("QuantizeLinear", TensorProto.FLOAT, TensorProto.UINT8, 
np.array(1.25, "float32")),
+        ("DequantizeLinear", TensorProto.UINT8, TensorProto.FLOAT, np.array(7, 
"uint8")),
+    ],
+)
+def test_qdqlinear_scalar_input(op_type, input_dtype, output_dtype, 
input_value):
+    node = helper.make_node(op_type, ["x", "scale", "zero_point"], ["y"])
+    graph = helper.make_graph(
+        [node],
+        f"{op_type.lower()}_scalar_input",
+        [helper.make_tensor_value_info("x", input_dtype, [])],
+        [helper.make_tensor_value_info("y", output_dtype, [])],
+        initializer=[
+            helper.make_tensor("scale", TensorProto.FLOAT, [], [0.25]),
+            helper.make_tensor("zero_point", TensorProto.UINT8, [], [2]),
+        ],
+    )
+    model = helper.make_model(graph, opset_imports=[helper.make_opsetid("", 
18)])
+
+    check_correctness(model, inputs={"x": input_value}, opset=18, 
check_dtypes=True)
+
+
 def test_quantizelinear_optional_zero_point_opset13():
     """ONNX allows missing zero_point input; importer should default it to 0 
(uint8)."""
     node = helper.make_node("QuantizeLinear", ["x", "scale"], ["y"])
diff --git a/tests/python/relax/test_op_qdq.py 
b/tests/python/relax/test_op_qdq.py
index 960dacc673..6ad35b6e85 100644
--- a/tests/python/relax/test_op_qdq.py
+++ b/tests/python/relax/test_op_qdq.py
@@ -14,6 +14,8 @@
 # KIND, either express or implied.  See the License for the
 # specific language governing permissions and limitations
 # under the License.
+import pytest
+
 import tvm
 import tvm.testing
 from tvm import relax, tirx
@@ -49,6 +51,36 @@ def test_qdq_op_infer_ty():
     )
 
 
+def test_qdq_op_infer_ty_scalar():
+    bb = relax.BlockBuilder()
+    x = relax.Var("x", R.Tensor((), "float32"))
+    dx = relax.Var("dx", R.Tensor((), "uint8"))
+    s = relax.Var("s", R.Tensor((), "float32"))
+    zp = relax.Var("zp", R.Tensor((), "uint8"))
+    _check_inference(bb, relax.op.quantize(x, s, zp, 0, "uint8"), 
relax.TensorType((), "uint8"))
+    _check_inference(
+        bb,
+        relax.op.dequantize(dx, s, zp, 0, "float32"),
+        relax.TensorType((), "float32"),
+    )
+
+
[email protected]("op_name", ["quantize", "dequantize"])
+def test_qdq_op_rejects_per_axis_params_for_scalar_input(op_name):
+    bb = relax.BlockBuilder()
+    s = relax.Var("s", R.Tensor((2,), "float32"))
+    zp = relax.Var("zp", R.Tensor((2,), "uint8"))
+    if op_name == "quantize":
+        x = relax.Var("x", R.Tensor((), "float32"))
+        call = relax.op.quantize(x, s, zp, 0, "uint8")
+    else:
+        x = relax.Var("x", R.Tensor((), "uint8"))
+        call = relax.op.dequantize(x, s, zp, 0, "float32")
+
+    with pytest.raises(ValueError, match="scalar or singleton tensors"):
+        bb.normalize(call)
+
+
 def test_qdq_op_infer_ty_unknown_dtype():
     bb = relax.BlockBuilder()
     x = relax.Var("x", R.Tensor((2, 3), dtype=None))

Reply via email to