Skip to content

[Bug][Relax] Constant folding a comparison drops its bool dtype: the function returns int32/float32 instead of bool #20282

Description

@lackjava-creator

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 + FuseTIRwrong 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

  • Needs triage

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs-triagePRs or issues that need to be investigated by maintainers to find the right assignees to address ittype: bug

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions