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 0eaf1cb019 [Relax][ONNX] Import Min/Max/Sum/Mean when an input has no
static shape (#20288)
0eaf1cb019 is described below
commit 0eaf1cb019f8bd2bcb225b92aaa36d2878b19a16
Author: Arpit Jain <[email protected]>
AuthorDate: Tue Sep 8 00:39:06 2026 -0400
[Relax][ONNX] Import Min/Max/Sum/Mean when an input has no static shape
(#20288)
Fixes #20280.
`MultiInputBase._impl_v1` reads `inp.ty.shape` for every input and folds
them with `compute_broadcast_shape`, which starts with `len(shape_a)`.
Relax spells an unknown static shape `R.Tensor(dtype=..., ndim=k)`,
whose struct info carries `shape=None`, so `len(None)` raises
`TypeError: object of type 'NoneType' has no len()`. That state is not
exotic: `R.dynamic_strided_slice` produces it, so a plain ONNX `Slice`
with runtime `starts`/`ends` feeding a `Max` is enough, on a model
`onnx.checker.check_model(..., full_check=True)` accepts and onnxruntime
executes.
ONNX defines Min, Max, Sum and Mean as elementwise with multidirectional
broadcasting, so when a static target shape cannot be computed the ops
are folded pairwise instead and the binary op broadcasts. `Mean` divides
by the input count afterwards. The known-shape path is untouched, and
single-input nodes now come back as the input itself rather than
tripping the `Found null pointer node` assertion that the
stack-and-reduce path hits with an unknown shape.
Verification. I do not have a source build handy, so I ran the release
wheel (`apache-tvm` 0.26.0, arm64) with this file's `main` version of
`_normalize_shape_dim`, `_broadcast_shape_dims`,
`compute_broadcast_shape` and `MultiInputBase` transplanted in, once
without the change and once with it. That runs `main`'s exact Python
logic for this path against a real runtime.
Across `{Min, Max, Sum, Mean}` x `{1, 2, 3}` inputs, all twelve fail
before and all twelve import after:
Max n=1: FAILED InternalError: Check failed: (n.defined()) is false:
Found null pointer node
Max n=2: FAILED TypeError: object of type 'NoneType' has no len()
The new `test_multi_input_unknown_static_shape` covers the same matrix
and shows 12 failed / 12 passed the same way. The existing `-k
multi_input` tests are 37 passed with the change in place, so the static
and symbolic paths are unaffected.
One thing worth being explicit about: this fixes the import, not
end-to-end lowering. The imported IR is `R.maximum(lv8, y)` where `lv8`
is `R.Tensor(dtype="float32", ndim=2)`, and `tvm.compile` on it still
fails with `CodeGenVM cannot handle this intrinsic now: relax.maximum`.
That is not new or specific to these ops: the same graph with `Add` in
place of `Max` imports today and fails at codegen identically with
`relax.add`. Lowering elementwise ops over an unknown-shape operand is a
separate gap; this change gets Min/Max/Sum/Mean to the same place `Add`
already is, which is what the issue asks for.
Signed-off-by: Arpit Jain <[email protected]>
---
python/tvm/relax/frontend/onnx/onnx_frontend.py | 37 ++++++++++++++++++++++++
tests/python/relax/test_frontend_onnx.py | 38 +++++++++++++++++++++++++
2 files changed, 75 insertions(+)
diff --git a/python/tvm/relax/frontend/onnx/onnx_frontend.py
b/python/tvm/relax/frontend/onnx/onnx_frontend.py
index 2a9cea943c..861dc840f5 100644
--- a/python/tvm/relax/frontend/onnx/onnx_frontend.py
+++ b/python/tvm/relax/frontend/onnx/onnx_frontend.py
@@ -2697,6 +2697,25 @@ class MultiInputBase(OnnxOpConverter):
numpy_op: Callable = None
relax_op: Callable = None
+ # Pairwise equivalent, used when no static broadcast shape can be computed.
+ binary_op: Callable = None
+
+ @classmethod
+ def _impl_dynamic(cls, bb, inputs):
+ """Fold the inputs pairwise, letting the binary op broadcast.
+
+ ONNX defines Min, Max, Sum and Mean as elementwise with
multidirectional
+ broadcasting, so the pairwise form is equivalent to the
stack-and-reduce
+ form and does not need a shape known at import time.
+ """
+ if cls.binary_op is None:
+ raise NotImplementedError(
+ f"{cls.__name__} cannot import an input whose static shape is
unknown"
+ )
+ return functools.reduce(
+ lambda lhs, rhs: bb.normalize(cls.binary_op(lhs, rhs)), # pylint:
disable=not-callable
+ inputs,
+ )
@classmethod
def _impl_v1(cls, bb, inputs, attr, params):
@@ -2718,6 +2737,15 @@ class MultiInputBase(OnnxOpConverter):
return relax.const(output, output.dtype)
input_shapes = [inp.ty.shape for inp in inputs]
+ if any(shape is None for shape in input_shapes):
+ # Relax spells an unknown static shape R.Tensor(dtype=..., ndim=k),
+ # whose struct info carries shape None. R.dynamic_strided_slice
+ # produces exactly that, so a plain ONNX Slice with runtime
+ # starts/ends reaches here and compute_broadcast_shape raised
+ # `object of type 'NoneType' has no len()` on a model onnx.checker
+ # accepts and onnxruntime runs.
+ return cls._impl_dynamic(bb, inputs)
+
target_shape = functools.reduce(compute_broadcast_shape, input_shapes)
# broadcast_to, stack them, then perform minimum over the new axis.
@@ -2731,6 +2759,7 @@ class Min(MultiInputBase):
numpy_op = _np.min
relax_op = relax.op.min
+ binary_op = relax.op.minimum
class Max(MultiInputBase):
@@ -2738,6 +2767,7 @@ class Max(MultiInputBase):
numpy_op = _np.max
relax_op = relax.op.max
+ binary_op = relax.op.maximum
class Mean(MultiInputBase):
@@ -2745,6 +2775,12 @@ class Mean(MultiInputBase):
numpy_op = _np.mean
relax_op = relax.op.mean
+ binary_op = relax.op.add
+
+ @classmethod
+ def _impl_dynamic(cls, bb, inputs):
+ total = super()._impl_dynamic(bb, inputs)
+ return relax.op.divide(total, relax.const(len(inputs),
inputs[0].ty.dtype))
class Sum(MultiInputBase):
@@ -2752,6 +2788,7 @@ class Sum(MultiInputBase):
numpy_op = _np.sum
relax_op = relax.op.sum
+ binary_op = relax.op.add
class Log(OnnxOpConverter):
diff --git a/tests/python/relax/test_frontend_onnx.py
b/tests/python/relax/test_frontend_onnx.py
index 94268d477e..335694ed76 100644
--- a/tests/python/relax/test_frontend_onnx.py
+++ b/tests/python/relax/test_frontend_onnx.py
@@ -1216,6 +1216,44 @@ def test_multi_input_constant_single_input(op_name,
shape):
check_correctness(helper.make_model(graph), opset=13)
[email protected]("op_name", ["Min", "Max", "Sum", "Mean"])
[email protected]("num_inputs", [1, 2, 3])
+def test_multi_input_unknown_static_shape(op_name, num_inputs):
+ """An input with no static shape imports instead of raising len(None).
+
+ A Slice with runtime starts/ends lowers to R.dynamic_strided_slice, whose
+ struct info is R.Tensor(dtype=..., ndim=k) with shape None. That reaches
+ MultiInputBase, where compute_broadcast_shape used to call len() on it.
+ The model is valid ONNX and onnxruntime executes it.
+ """
+ slice_node = helper.make_node(
+ "Slice", ["x", "starts", "ends", "axes"], ["sliced"], name="slice0"
+ )
+ other_names = [f"y{i}" for i in range(num_inputs - 1)]
+ op_node = helper.make_node(op_name, ["sliced"] + other_names, ["output"],
name="op0")
+
+ graph = helper.make_graph(
+ [slice_node, op_node],
+ f"slice_then_{op_name.lower()}",
+ inputs=[
+ helper.make_tensor_value_info("x", TensorProto.FLOAT, [4, 3]),
+ helper.make_tensor_value_info("starts", TensorProto.INT64, [1]),
+ helper.make_tensor_value_info("ends", TensorProto.INT64, [1]),
+ helper.make_tensor_value_info("axes", TensorProto.INT64, [1]),
+ ]
+ + [
+ helper.make_tensor_value_info(name, TensorProto.FLOAT, [2, 3])
+ for name in other_names
+ ],
+ outputs=[helper.make_tensor_value_info("output", TensorProto.FLOAT,
[2, 3])],
+ )
+ model = helper.make_model(graph, opset_imports=[helper.make_opsetid("",
13)])
+ onnx.checker.check_model(model, full_check=True)
+
+ tvm_model = from_onnx(model, keep_params_in_input=True)
+ assert "dynamic_strided_slice" in str(tvm_model)
+
+
@pytest.mark.parametrize("op_name", ["And", "Or", "Xor"])
def test_binary_bool(op_name: str):
verify_binary(op_name, [32, 32], [32, 32], [32, 32],
dtype=TensorProto.BOOL)