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 b01df356ef [Relax][Frontend][ONNX] Support symbolic shapes in Min/Max 
broadcast (#20218)
b01df356ef is described below

commit b01df356ef5c782be296c87be43aba4055fad4d2
Author: Neo Chien <[email protected]>
AuthorDate: Sun Aug 30 09:00:06 2026 +0800

    [Relax][Frontend][ONNX] Support symbolic shapes in Min/Max broadcast 
(#20218)
    
    Hi Committers,
    
    This PR fixes issue https://github.com/apache/tvm/issues/20175. Any
    suggestions would be appreciated if you are available.
    
    ### Root Cause
    The `compute_broadcast_shape()` function in the ONNX frontend attempts
    to use the built-in `max()` function and boolean operators to broadcast
    dimensions. These operations fail when the dimensions are symbolic TIR
    expressions because:
    - `Expr` objects cannot be used in boolean conditions.
    - Built-in `max()` requires comparison operators that function within a
    boolean context.
    - The original implementation assumed all dimensions were of the same
    type.
    
    ### Solution
    Implement three new components to handle dimension-type-aware
    broadcasting:
    - `_normalize_shape_dim()` converts IntImm constants to ints, ensuring
    consistent type handling.
    - `_broadcast_shape_dims()` implements broadcast rules
    - Refactor `compute_broadcast_shape()` to use `_broadcast_shape_dims()`
    
    ---------
    
    Co-authored-by: cchung100m <[email protected]>
---
 python/tvm/relax/frontend/onnx/onnx_frontend.py |  66 +++++++-
 tests/python/relax/test_frontend_onnx.py        | 215 ++++++++++++++++++++++++
 2 files changed, 277 insertions(+), 4 deletions(-)

diff --git a/python/tvm/relax/frontend/onnx/onnx_frontend.py 
b/python/tvm/relax/frontend/onnx/onnx_frontend.py
index 8a40e4a612..1afbf208b2 100644
--- a/python/tvm/relax/frontend/onnx/onnx_frontend.py
+++ b/python/tvm/relax/frontend/onnx/onnx_frontend.py
@@ -58,6 +58,7 @@ import tvm_ffi
 
 import tvm
 from tvm import relax, tirx, topi
+from tvm.ir import Expr as TvmExpr
 from tvm.ir import IRModule
 from tvm.ir.supply import UniqueNameSupply
 from tvm.runtime import DataType, DataTypeCode
@@ -2618,6 +2619,66 @@ class Sqrt(OnnxOpConverter):
         return relax.op.sqrt(inputs[0])
 
 
+def _normalize_shape_dim(dim: int | tirx.Expr) -> int | tirx.Expr:
+    """Normalize a shape dimension, converting IntImm to int if needed."""
+    if isinstance(dim, tirx.IntImm):
+        return int(dim.value)
+    return dim
+
+
+def _broadcast_shape_dims(ai: int | tirx.Expr, bi: int | tirx.Expr) -> int | 
tirx.Expr:
+    """Broadcast two shape dimensions according to ONNX broadcasting rules.
+
+    Handles both constant and symbolic dimensions. Returns the broadcasted 
dimension.
+    Raises ValueError if the dimensions are incompatible.
+
+    Broadcasting rules:
+    - If one dimension is 1, broadcast to the other dimension
+    - If both dimensions are equal (same value or same symbolic variable), use 
that dimension
+    - Otherwise, dimensions are incompatible
+    """
+    ai = _normalize_shape_dim(ai)
+    bi = _normalize_shape_dim(bi)
+
+    # Check if either dimension is symbolic (Expr)
+    ai_is_expr = isinstance(ai, TvmExpr)
+    bi_is_expr = isinstance(bi, TvmExpr)
+
+    if not ai_is_expr and not bi_is_expr:
+        # Both are constants: unchanged from original
+        if ai == bi or ai == 1 or bi == 1:
+            return max(ai, bi)
+        else:
+            raise ValueError(f"Cannot broadcast {ai} and {bi}")
+    elif ai_is_expr and bi_is_expr:
+        # Both are symbolic: Check if they're the same variable
+        # If so, return one of them; otherwise, cannot determine at import time
+        is_equal = tvm_ffi.structural_equal(ai, bi)
+
+        if is_equal:
+            return ai
+        else:
+            raise ValueError(
+                f"Cannot broadcast symbolic dimensions {ai} and {bi} - "
+                "both are symbolic but structurally different. "
+                "ONNX frontend requires dimensions names to match for symbolic 
shapes."
+            )
+    else:
+        # One is symbolic, one is constant
+        const_val = bi if ai_is_expr else ai
+        expr_val = ai if ai_is_expr else bi
+
+        if const_val == 1:
+            # Constant dimension is 1, broadcast to symbolic dimension
+            return expr_val
+        else:
+            # Constant dimension is not 1
+            raise ValueError(
+                f"Cannot broadcast symbolic dimension {expr_val} with non-1 
constant {const_val}: "
+                f"runtime value of symbolic dimension is unknown at compile 
time."
+            )
+
+
 def compute_broadcast_shape(shape_a, shape_b):
     """Compute target shape for Multidirectional Broadcasting"""
     rank = max(len(shape_a), len(shape_b))
@@ -2627,10 +2688,7 @@ def compute_broadcast_shape(shape_a, shape_b):
 
     target = []
     for ai, bi in zip(a, b):
-        if ai == bi or ai == 1 or bi == 1:
-            target.append(max(ai, bi))
-        else:
-            raise ValueError(f"Cannot broadcast {ai} and {bi}")
+        target.append(_broadcast_shape_dims(ai, bi))
     return tuple(target)
 
 
diff --git a/tests/python/relax/test_frontend_onnx.py 
b/tests/python/relax/test_frontend_onnx.py
index a2081adab9..7fb80d55dc 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -928,6 +928,221 @@ def test_multi_input_constant_rank_axis_bounds(op_name, 
rank):
     check_correctness(helper.make_model(graph), opset=13)
 
 
+def _make_onnx_min_max_model(
+    x_shape: list,
+    y_shape: list,
+    out_shape: list,
+    op_name: str = "Min",
+    graph_name: str = "test_graph",
+) -> onnx.ModelProto:
+    """Construct a Min/Max ONNX model.
+
+    Args:
+        x_shape: Shape of the first input tensor
+        y_shape: Shape of the second input tensor
+        out_shape: Shape of the output tensor
+        op_name: The operation name, either "Min" or "Max"
+        graph_name: The name of the ONNX graph
+
+    Returns:
+        An ONNX ModelProto object representing the Min/Max operation.
+    """
+    x = helper.make_tensor_value_info("x", TensorProto.FLOAT, x_shape)
+    y = helper.make_tensor_value_info("y", TensorProto.FLOAT, y_shape)
+    out = helper.make_tensor_value_info("out", TensorProto.FLOAT, out_shape)
+
+    graph = helper.make_graph(
+        [helper.make_node(op_name, ["x", "y"], ["out"])],
+        graph_name,
+        [x, y],
+        [out],
+    )
+    return helper.make_model(graph, opset_imports=[helper.make_opsetid("", 
18)])
+
+
+def _make_expected_broadcast_ir_min(
+    x_shape: tuple,
+    y_shape: tuple,
+):
+    """Generate expected broadcast IR module for Min operation.
+
+    Args:
+        x_shape: Shape of the first input tensor
+        y_shape: Shape of the second input tensor
+
+    Returns:
+        Expected IR module for the Min operation.
+    """
+    output_shape = (x_shape[0], 4)
+
+    @I.ir_module
+    class ExpectedMin:
+        @R.function
+        def main(
+            x: R.Tensor(x_shape, dtype="float32"),
+            y: R.Tensor(y_shape, dtype="float32"),
+        ) -> R.Tensor(output_shape, dtype="float32"):
+            n = T.int64()
+            R.func_attr({"num_input": 2})
+            with R.dataflow():
+                lv = R.broadcast_to(x, R.shape((n, 4)))
+                lv1 = R.broadcast_to(y, R.shape((n, 4)))
+                lv2 = R.stack((lv, lv1), axis=0)
+                gv = R.min(lv2, axis=[0], keepdims=False)
+                R.output(gv)
+            return gv
+
+    return ExpectedMin
+
+
+def _make_expected_broadcast_ir_max(
+    x_shape: tuple,
+    y_shape: tuple,
+):
+    """Generate expected broadcast IR module for Max operation.
+
+    Args:
+        x_shape: Shape of the first input tensor
+        y_shape: Shape of the second input tensor
+
+    Returns:
+        Expected IR module for the Max operation.
+    """
+    output_shape = (x_shape[0], 4)
+
+    @I.ir_module
+    class ExpectedMax:
+        @R.function
+        def main(
+            x: R.Tensor(x_shape, dtype="float32"),
+            y: R.Tensor(y_shape, dtype="float32"),
+        ) -> R.Tensor(output_shape, dtype="float32"):
+            n = T.int64()
+            R.func_attr({"num_input": 2})
+            with R.dataflow():
+                lv = R.broadcast_to(x, R.shape((n, 4)))
+                lv1 = R.broadcast_to(y, R.shape((n, 4)))
+                lv2 = R.stack((lv, lv1), axis=0)
+                gv = R.max(lv2, axis=[0], keepdims=False)
+                R.output(gv)
+            return gv
+
+    return ExpectedMax
+
+
+def _test_symbolic_broadcast_case(
+    x_shape: list,
+    y_shape: list,
+    out_shape: list,
+    op_name: str,
+    graph_name: str,
+    should_pass: bool = True,
+    error_pattern: str | None = None,
+):
+    """Execute a single symbolic broadcast test case.
+
+    Args:
+        x_shape: Shape of the first input tensor
+        y_shape: Shape of the second input tensor
+        out_shape: Shape of the output tensor
+        op_name: The operation name, either "Min" or "Max"
+        graph_name: The name of the ONNX graph
+        should_pass: Whether the test is expected to pass or raise an error
+        error_pattern: The expected error message pattern if should_pass is 
False
+    """
+    model = _make_onnx_min_max_model(x_shape, y_shape, out_shape, op_name, 
graph_name)
+    onnx.checker.check_model(model)
+
+    if should_pass:
+        tvm_model = from_onnx(model, opset=18, keep_params_in_input=True)
+        if op_name == "Min":
+            expected = _make_expected_broadcast_ir_min(tuple(x_shape), 
tuple(y_shape))
+        else:
+            expected = _make_expected_broadcast_ir_max(tuple(x_shape), 
tuple(y_shape))
+        tvm.ir.assert_structural_equal(tvm_model, expected)
+    else:
+        with pytest.raises(ValueError, match=error_pattern):
+            from_onnx(model, opset=18, keep_params_in_input=True)
+
+
+def test_multi_input_broadcasting_symbolic_shapes():
+    """Comprehensive test for symbolic shape broadcasting in Min/Max 
operations.
+
+    Tests four levels:
+    1. LEVEL 1 - Compile-time: Import and symbolic dimension handling (4 cases)
+    2. LEVEL 2 - Shape lowering: Pass through LegalizeOps pipeline
+    3. LEVEL 3 - IR verification: Check broadcast_to operations
+    4. LEVEL 4 - Conservative rejection: Verify symbolic vs non-1 constant is 
rejected
+
+    When dimensions are symbolic (not constant), broadcasting should handle 
them correctly
+    without raising "Cannot use and / or / not operator to Expr".
+    Conservative rejection prevents silent miscompilation where TOPI cannot 
correctly handle
+    symbolic source dimensions in broadcast operations.
+    """
+    # LEVEL 1 - Compile-time: Import and symbolic dimension handling (4 cases)
+    _test_symbolic_broadcast_case(
+        x_shape=["n", 4],
+        y_shape=["n", 4],
+        out_shape=["n", 4],
+        op_name="Min",
+        graph_name="min_symbolic_broadcast",
+    )
+
+    _test_symbolic_broadcast_case(
+        x_shape=["n", 4],
+        y_shape=["n", 4],
+        out_shape=["n", 4],
+        op_name="Max",
+        graph_name="max_symbolic_broadcast",
+    )
+
+    _test_symbolic_broadcast_case(
+        x_shape=["n", 1],
+        y_shape=["n", 4],
+        out_shape=["n", 4],
+        op_name="Min",
+        graph_name="min_mixed_symbolic_broadcast",
+    )
+
+    _test_symbolic_broadcast_case(
+        x_shape=["n", 4],
+        y_shape=["m", 4],
+        out_shape=["n", 4],
+        op_name="Min",
+        graph_name="min_different_symbolic",
+        should_pass=False,
+        error_pattern="Cannot broadcast symbolic dimensions",
+    )
+
+    # LEVEL 2 - Shape lowering: Pass through LegalizeOps pipeline
+    model_lower = _make_onnx_min_max_model(
+        ["n", 4], ["n", 4], ["n", 4], "Min", "min_shape_lower_test"
+    )
+    onnx.checker.check_model(model_lower)
+
+    tvm_model = from_onnx(model_lower, opset=18, keep_params_in_input=True)
+
+    tvm_model = relax.transform.DecomposeOpsForInference()(tvm_model)
+    tvm_model = relax.transform.LegalizeOps()(tvm_model)
+
+    tvm_model, _ = relax.frontend.detach_params(tvm_model)
+
+    # LEVEL 3 - IR verification: Check broadcast_to operations
+    ir_str = str(tvm_model)
+    assert "broadcast" in ir_str, "IR should contain broadcast operation"
+
+    # LEVEL 4 - Conservative rejection: Verify symbolic vs non-1 constant is 
rejected
+    _test_symbolic_broadcast_case(
+        x_shape=["n", 4],
+        y_shape=[2, 4],
+        out_shape=[2, 4],
+        op_name="Min",
+        graph_name="min_symbolic_vs_constant",
+        should_pass=False,
+        error_pattern="runtime value of symbolic dimension is unknown",
+    )
+
+
 @pytest.mark.parametrize("op_name", ["Min", "Max", "Sum", "Mean"])
 @pytest.mark.parametrize("shape", [[], [5], [2, 3]])
 def test_multi_input_constant_single_input(op_name, shape):

Reply via email to