Environment
-
TVM version: 0.26.0 (release wheel) and main @ 48242ec33403f2b6e4fac6e763ca7a683fb9d5df (2026-09-03, reports 0.26.dev0). Both verified 2026-09-06.
-
Build / install: 0.26.0 from a venv wheel; main built from source, LLVM 15.0.7
-
OS / Python: Ubuntu 24.04.3 LTS, x86_64, Python 3.10
-
Reference implementation: onnxruntime 1.23.2 with ORT_DISABLE_ALL
-
Target: llvm (CPU). Reproduces at opt_level=0 (get_pipeline("zero")) and opt_level=3 (get_pipeline("default_build")).
-
Pipeline coverage (relax.get_pipeline("default_build") performs no operator fusion, so all three are reported separately). Verified 2026-09-06 on 0.26.0, llvm, for both the 3-node minimal graph and the 30-node original witness:
get_pipeline("default_build") (no FuseOps/FuseTIR) — wrong dtype (int32 instead of bool)
get_pipeline("zero") (fuses) — wrong dtype, identical
- forced
FuseOps + FuseTIR — wrong dtype, identical
All three agreeing places the defect in constant folding, not in FuseOps/FuseTIR.
Minimal reproducer
Three-node ONNX graph in which a Less is fed only by initialisers, so it is fully constant-foldable. The declared graph output is BOOL. model_constfold_cmp_no_div.onnx + feed_constfold_cmp_no_div.npz attached, with run.py.
import numpy as np, onnx, onnxruntime as ort, tvm
from onnx import helper, numpy_helper as nh, TensorProto as TP
from tvm import relax
from tvm.relax.frontend.onnx import from_onnx
A = np.array([[1], [5]], "int32"); B = np.array([[3]], "int32"); A2 = np.array([[3]], "int32")
g = helper.make_graph(
[helper.make_node("Identity", ["d"], ["dummy"]), # keeps one real graph input
helper.make_node("Concat", ["A", "B"], ["v4"], axis=0),
helper.make_node("Less", ["v4", "A2"], ["y0"])], "g",
[helper.make_tensor_value_info("d", TP.INT32, [1])],
[helper.make_tensor_value_info("y0", TP.BOOL, [3, 1]), # <-- declared BOOL
helper.make_tensor_value_info("dummy", TP.INT32, [1])],
initializer=[nh.from_array(A, "A"), nh.from_array(B, "B"), nh.from_array(A2, "A2")])
m = helper.make_model(g, opset_imports=[helper.make_opsetid("", 18)], ir_version=9)
feed = {"d": np.array([7], "int32")}
so = ort.SessionOptions(); so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_DISABLE_ALL
print("onnxruntime:", ort.InferenceSession(m.SerializeToString(), so, providers=["CPUExecutionProvider"]).run(None, feed)[0].dtype)
with tvm.transform.PassContext(opt_level=3):
ex = tvm.compile(relax.get_pipeline("default_build")(from_onnx(m, shape_dict={"d": [1]}, keep_params_in_input=False)), target="llvm")
out = relax.VirtualMachine(ex, tvm.cpu())["main"](tvm.runtime.tensor(feed["d"], tvm.cpu()))
print("tvm :", [np.asarray(o.numpy()).dtype for o in list(out)])
Expected vs actual
- Expected, from the ONNX spec:
Less-13 has output type tensor(bool) — the type constraint T1 : tensor(bool) is unconditional. The graph declares y0 as BOOL, onnx.checker.check_model accepts it, and onnxruntime returns numpy.bool_.
- Actual (0.26.0 and
main @ 48242ec, opt 0 and 3):
| model |
graph declares |
onnxruntime |
TVM |
model_constfold_cmp_no_div.onnx (3 nodes) |
y0 : BOOL |
bool [True, False, False] |
int32 [1, 0, 0] |
model_constfold_cmp_with_div.onnx (4 nodes, Less fed by a folded Div) |
y0 : BOOL |
bool [True, True, True] |
int32 [1, 1, 1] |
mo4_601_79.onnx (30 nodes, the original witness) |
v31 : BOOL |
bool [False, False, True, False, False] |
int32 [0, 0, 1, 0, 0] |
- The values are correct (
0/1 in the right positions); only the dtype is wrong. The returned dtype is the operand's dtype, not bool: a float32 version of the same graph returns float32.
- A comparison that is not constant-foldable is correct.
y = Less(a, b) with a, b as real graph inputs returns bool on the same build, for int32, int64 and float32 alike. The imported Relax IR is also correct (gv: R.Tensor((3,), dtype="bool") = R.less(a, b)). It is only when the comparison's operands are constants — so the fold actually fires — that the dtype is lost.
Root cause (if known)
Relax's constant folding evaluates the comparison and materialises the result as a constant, but builds that constant with the operand's dtype rather than the comparison's bool output dtype. Value-correct, type-incorrect. The 30-node original witness is what exposed it: it made a folded comparison reach a graph output, where the dtype is observable.
Present on main @ 48242ec — never fixed.
Why this is a bug (not tolerance / not undefined behaviour)
The compiled module's observable signature contradicts the model it was compiled from: the ONNX graph declares a bool output and the runtime hands back an int32 NDArray. Any consumer that dispatches on dtype — a mask applied with numpy boolean indexing, a where, a serialised tensor with a declared element type — silently takes the wrong path or writes 4 bytes where 1 was promised. There is no tolerance question (integers), no undefined behaviour, and no ambiguity in the spec: Less returns tensor(bool).
How found
Found by EquiAutomaton (differential testing against onnxruntime at ORT_DISABLE_ALL): 7 mismatches in 5000 generated multi-output graphs, all of the form "an output declared bool comes back as the operand dtype". Minimised from 30 nodes to 3 by observing that the affected output was always a fully constant comparison.
Reproducer archive
TVM-MULTIOUT-reproducer.zip
Triage
Environment
TVM version:
0.26.0(release wheel) andmain @ 48242ec33403f2b6e4fac6e763ca7a683fb9d5df(2026-09-03, reports0.26.dev0). Both verified 2026-09-06.Build / install: 0.26.0 from a venv wheel; main built from source, LLVM 15.0.7
OS / Python: Ubuntu 24.04.3 LTS, x86_64, Python 3.10
Reference implementation: onnxruntime 1.23.2 with
ORT_DISABLE_ALLTarget:
llvm(CPU). Reproduces atopt_level=0(get_pipeline("zero")) andopt_level=3(get_pipeline("default_build")).Pipeline coverage (
relax.get_pipeline("default_build")performs no operator fusion, so all three are reported separately). Verified 2026-09-06 on0.26.0,llvm, for both the 3-node minimal graph and the 30-node original witness:get_pipeline("default_build")(no FuseOps/FuseTIR) — wrong dtype (int32instead ofbool)get_pipeline("zero")(fuses) — wrong dtype, identicalFuseOps+FuseTIR— wrong dtype, identicalAll three agreeing places the defect in constant folding, not in
FuseOps/FuseTIR.Minimal reproducer
Three-node ONNX graph in which a
Lessis fed only by initialisers, so it is fully constant-foldable. The declared graph output isBOOL.model_constfold_cmp_no_div.onnx+feed_constfold_cmp_no_div.npzattached, withrun.py.Expected vs actual
Less-13has output typetensor(bool)— the type constraintT1 : tensor(bool)is unconditional. The graph declaresy0asBOOL,onnx.checker.check_modelaccepts it, and onnxruntime returnsnumpy.bool_.main @ 48242ec, opt 0 and 3):model_constfold_cmp_no_div.onnx(3 nodes)y0 : BOOLbool [True, False, False]int32 [1, 0, 0]model_constfold_cmp_with_div.onnx(4 nodes,Lessfed by a foldedDiv)y0 : BOOLbool [True, True, True]int32 [1, 1, 1]mo4_601_79.onnx(30 nodes, the original witness)v31 : BOOLbool [False, False, True, False, False]int32 [0, 0, 1, 0, 0]0/1in the right positions); only the dtype is wrong. The returned dtype is the operand's dtype, notbool: afloat32version of the same graph returnsfloat32.y = Less(a, b)witha,bas real graph inputs returnsboolon the same build, for int32, int64 and float32 alike. The imported Relax IR is also correct (gv: R.Tensor((3,), dtype="bool") = R.less(a, b)). It is only when the comparison's operands are constants — so the fold actually fires — that the dtype is lost.Root cause (if known)
Relax's constant folding evaluates the comparison and materialises the result as a constant, but builds that constant with the operand's dtype rather than the comparison's
booloutput dtype. Value-correct, type-incorrect. The 30-node original witness is what exposed it: it made a folded comparison reach a graph output, where the dtype is observable.Present on
main @ 48242ec— never fixed.Why this is a bug (not tolerance / not undefined behaviour)
The compiled module's observable signature contradicts the model it was compiled from: the ONNX graph declares a
booloutput and the runtime hands back anint32NDArray. Any consumer that dispatches on dtype — a mask applied withnumpyboolean indexing, awhere, a serialised tensor with a declared element type — silently takes the wrong path or writes 4 bytes where 1 was promised. There is no tolerance question (integers), no undefined behaviour, and no ambiguity in the spec:Lessreturnstensor(bool).How found
Found by EquiAutomaton (differential testing against onnxruntime at
ORT_DISABLE_ALL): 7 mismatches in 5000 generated multi-output graphs, all of the form "an output declared bool comes back as the operand dtype". Minimised from 30 nodes to 3 by observing that the affected output was always a fully constant comparison.Reproducer archive
TVM-MULTIOUT-reproducer.zip
Triage