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 5cf92b27c6 [Fix][Relax][ONNX] Where: broadcast size-1 shape
expressions, materialize ShapeExpr inputs (#20210)
5cf92b27c6 is described below
commit 5cf92b27c68fe7c55b826a81900e387c8754fc3a
Author: HuEnwei <[email protected]>
AuthorDate: Sat Aug 29 12:04:23 2026 +0800
[Fix][Relax][ONNX] Where: broadcast size-1 shape expressions, materialize
ShapeExpr inputs (#20210)
Fixes: #20186
## Summary
The Relax ONNX frontend's `Where` importer (`Where._impl_v16`)
mishandled two
legal models that feed a **shape tensor** (the output of a `Shape` op,
imported
as a `relax.ShapeExpr`) into `Where`:
1. **Size-1 broadcasting in the all-constant/shape-expression path is
rejected.**
The fast path required the `condition`, `x` and `y` element lists to
have
exactly equal lengths, but ONNX `Where` follows NumPy-style
multi-directional
broadcasting, in which a size-1 dimension broadcasts against any length.
Models like
`Where(cond=[True], x=Shape((2,3))=[2,3], y=[4,5])` (legal in
onnxruntime)
failed to import with:
```
ValueError: Cannot broadcast condition to x and y
```
2. **A `ShapeExpr` mixed with a runtime tensor crashes the general
path.**
When at least one input is a runtime tensor (e.g. `condition` is a graph
input) and another input is a shape expression, the `ShapeExpr` was
passed
straight to `relax.op.where`, which only accepts tensors:
```
InternalError: Op(relax.where) requires argument 1 (x1) to be a tensor
```
## Root cause
`Where._impl_v16` has three branches. The shape-like branch (all inputs
are
`relax.Constant` or `relax.ShapeExpr`) compared element-list lengths for
exact
equality instead of applying NumPy-style broadcasting. The general
fallback
(`relax.op.where(inputs[0], inputs[1], inputs[2])`) never converted a
`relax.ShapeExpr` input into a tensor, so a shape expression
co-occurring with
a graph input reached `relax.op.where` as a non-tensor expression.
## Fix
In `Where._impl_v16`:
- **Shape-like path**: broadcast the 1-D element lists to a common
length,
repeating a length-1 list to that length (mirroring NumPy's size-1
broadcasting), then select elementwise. The output stays a shape
expression so
that downstream shape consumers (e.g. a `Reshape`'s shape input) keep
working.
If `get_prim_expr_list` raises (e.g. a rank-2 constant mixed with a
shape
tensor), the code falls through to the tensor path instead of aborting
import.
- **General path**: materialize any `relax.ShapeExpr` input into an
int64
tensor with `relax.op.shape_to_tensor` before calling `relax.op.where`,
so the
operator always operates on tensors.
## Tests
Added to `tests/python/relax/test_frontend_onnx.py`:
- `test_where_shape_expr` — correctness (via `check_correctness` against
onnxruntime) for a `Where` fed by a `Shape` output, both with
`condition` as a
size-1 initializer and as a graph input (regression for defect 2).
- `test_where_shape_expr_broadcast` — the all-constant/shape-expression
fast
path must broadcast size-1 dims (parametrized over `(1,)` and
equal-length
`condition`/`y` combinations; regression for defect 1).
## Validation
Differential testing against onnxruntime (over the `Shape`-tensor
`Where`
cases: size-1 broadcast, equal lengths, mixed graph-input `condition`,
rank-2
constant fallback) all match; no regressions on the already-supported
cases.
All 6 new/related `Where` tests pass in-tree, and the full
`test_frontend_onnx.py` suite shows no new failures (the pre-existing
`test_topk`/`test_unique` failures in the local environment are
unrelated to
`Where`).
---
python/tvm/relax/frontend/onnx/onnx_frontend.py | 34 +++++++--
tests/python/relax/test_frontend_onnx.py | 92 +++++++++++++++++++++++++
2 files changed, 120 insertions(+), 6 deletions(-)
diff --git a/python/tvm/relax/frontend/onnx/onnx_frontend.py
b/python/tvm/relax/frontend/onnx/onnx_frontend.py
index 0bbfb4e281..0b2e5063c8 100644
--- a/python/tvm/relax/frontend/onnx/onnx_frontend.py
+++ b/python/tvm/relax/frontend/onnx/onnx_frontend.py
@@ -1678,13 +1678,35 @@ class Where(OnnxOpConverter):
np_inputs = [inp.data.numpy() for inp in inputs]
output = _np.where(*np_inputs)
return relax.const(output, output.dtype)
+
if all([isinstance(inp, relax.Constant | relax.ShapeExpr) for inp in
inputs]):
- condition, x, y = [get_prim_expr_list(inp) for inp in inputs]
- if len(condition) != len(x) or len(condition) != len(y):
- raise ValueError("Cannot broadcast condition to x and y")
- output = [x if c else y for c, x, y in zip(condition, x, y)]
- return relax.ShapeExpr(output)
- return relax.op.where(inputs[0], inputs[1], inputs[2])
+ try:
+ condition, x, y = [get_prim_expr_list(inp) for inp in inputs]
+ except ValueError:
+ # Not a 1-D shape-like input (e.g. a rank-2 constant mixed with
+ # a shape tensor): fall through to the tensor path below.
+ condition = x = y = None
+ else:
+ n = max(len(condition), len(x), len(y))
+ if not all(len(v) in (1, n) for v in (condition, x, y)):
+ raise ValueError(
+ "Cannot broadcast condition, x and y with lengths "
+ f"{len(condition)}, {len(x)}, {len(y)}"
+ )
+ if n > 1:
+ condition = condition * n if len(condition) == 1 else
condition
+ x = x * n if len(x) == 1 else x
+ y = y * n if len(y) == 1 else y
+ output = [x if c else y for c, x, y in zip(condition, x, y)]
+ return relax.ShapeExpr(output)
+
+ tensors = []
+ for inp in inputs:
+ if isinstance(inp, relax.ShapeExpr):
+ tensors.append(relax.op.shape_to_tensor(inp))
+ else:
+ tensors.append(inp)
+ return relax.op.where(tensors[0], tensors[1], tensors[2])
class Clip(OnnxOpConverter):
diff --git a/tests/python/relax/test_frontend_onnx.py
b/tests/python/relax/test_frontend_onnx.py
index 984e05aff9..00c34b775b 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -3380,6 +3380,98 @@ def test_equal():
check_correctness(model)
+def _make_where_shape_expr_model(t1_shape, cond_vals, y_vals,
cond_is_input=False):
+ """Build an ONNX model whose Where input X comes from a Shape op.
+
+ X = Shape(t1) yields the int64 shape tensor of t1 (e.g. [2, 3]); cond and Y
+ are length-1 or length-rank initializers (or cond is a graph input). Where
+ must follow NumPy-style broadcasting over the element lists.
+ """
+ inits, gin, nodes = [], [], []
+ gin.append(helper.make_tensor_value_info("__t", TensorProto.FLOAT,
list(t1_shape)))
+ nodes.append(helper.make_node("Shape", ["__t"], ["X"]))
+ if cond_is_input:
+ gin.append(helper.make_tensor_value_info("cond", TensorProto.BOOL,
[len(cond_vals)]))
+ else:
+ inits.append(
+ helper.make_tensor(
+ "cond",
+ TensorProto.BOOL,
+ [len(cond_vals)],
+ list(np.array(cond_vals, dtype=np.bool_)),
+ )
+ )
+ inits.append(
+ helper.make_tensor(
+ "Y", TensorProto.INT64, [len(y_vals)], list(np.array(y_vals,
dtype=np.int64))
+ )
+ )
+ nodes.append(helper.make_node("Where", ["cond", "X", "Y"], ["Yout"]))
+ graph = helper.make_graph(
+ nodes,
+ "where_shape_expr_test",
+ gin,
+ [helper.make_tensor_value_info("Yout", TensorProto.INT64,
[len(t1_shape)])],
+ inits,
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
16)])
+ model.ir_version = 8
+ return model
+
+
+def test_where_shape_expr():
+ """Where with a shape tensor (Shape op output) must import and run.
+
+ Regression: a shape expression mixed with a runtime tensor was passed
+ straight to relax.op.where, which raised 'Op(relax.where) requires argument
+ 1 (x1) to be a tensor'. The shape expression must be materialized to an
+ int64 tensor first.
+ """
+ # cond is an initializer (1,) -> broadcasts to (2,); x = Shape((2, 3)) =
[2, 3]
+ model = _make_where_shape_expr_model((2, 3), [True], [4, 5])
+ check_correctness(model, inputs={"__t": np.ones((2, 3), dtype="float32")},
opset=16)
+
+ # cond is a graph input (2,); x = Shape((2, 3)); y initializer (2,)
+ model = _make_where_shape_expr_model((2, 3), [True, False], [4, 5],
cond_is_input=True)
+ check_correctness(
+ model,
+ inputs={
+ "__t": np.ones((2, 3), dtype="float32"),
+ "cond": np.array([True, False], dtype=np.bool_),
+ },
+ opset=16,
+ )
+
+
[email protected](
+ "cond_vals, y_vals, expected",
+ [
+ ([True], [4, 5], [2, 3]), # cond (1,) broadcasts to (2,)
+ ([True, False], [4], [2, 4]), # y (1,) broadcasts to (2,)
+ ([True], [4], [2, 3]), # both (1,) broadcast to (2,)
+ ([True, False], [4, 5], [2, 5]), # equal lengths (regression)
+ ],
+)
+def test_where_shape_expr_broadcast(cond_vals, y_vals, expected):
+ """The all-constant/shape-expression fast path must broadcast size-1 dims.
+
+ Regression: length-1 element lists were rejected with 'Cannot broadcast
+ condition to x and y' instead of being broadcast to the common length, so
+ e.g. cond=[True] with x=[2, 3] failed to import.
+ """
+ t1_shape = (2, 3)
+ model = _make_where_shape_expr_model(t1_shape, cond_vals, y_vals)
+ feeds = {"__t": np.ones(t1_shape, dtype="float32")}
+ mod = from_onnx(model, shape_dict={"__t": list(t1_shape)})
+ with tvm.transform.PassContext(opt_level=3):
+ ex = tvm.compile(mod, target="llvm")
+ vm = relax.VirtualMachine(ex, tvm.cpu())
+ out = vm["main"](feeds["__t"])
+ np.testing.assert_array_equal(
+ np.array([int(i) for i in out], dtype=np.int64), np.array(expected,
dtype=np.int64)
+ )
+
+
def test_shape():
shape_node = helper.make_node("Shape", ["data"], ["output"])