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 5128fc0655 [Relax] Keep a call_tir's out_ty consistent with its
arguments when canonicalizing (#20494)
5128fc0655 is described below
commit 5128fc065591cc4b6e2a0fc9b51718611cc4c0ea
Author: Akaash Parthasarathy <[email protected]>
AuthorDate: Sat Oct 3 16:56:27 2026 -0700
[Relax] Keep a call_tir's out_ty consistent with its arguments when
canonicalizing (#20494)
`CanonicalizeTIRVariables` substitutes the known value of a symbolic
variable into type annotations, but keeps a `MatchCast` whose variable
is used at runtime. The argument of a later `call_tir` then still
carries the variable while the call's `out_ty` had it replaced, and the
`call_tir` validator from #20480 rejects the pair:
```
The `out_ty` argument for R.call_tir must be compatible with the PrimFunc.
However, the PrimFunc's signature implies that the output should be
R.Tensor((1, new_s1, new_s2, 2, 2, 1024), dtype="float16"), but the `out_ty`
argument was R.Tensor((1, 12, 12, 2, 2, 1024), dtype="float16")
```
The same happens when two `MatchCast`s rename one dimension, because a
value is substituted one level at a time. MLC LLM hits both while
compiling Phi-3.5-vision.
1. In the canonicalizer, visit a `call_tir`'s arguments first and derive
the type they imply. Keep each output of the canonical `out_ty` that
still follows from the arguments. For a tensor output that does not,
take the implied shape and keep its dtype and `vdevice`, which the
PrimFunc signature does not carry.
2. Expose `InferCallTIROutputTypeFromArguments` in `op_common.h` for
that
3. Add tests for a kept `MatchCast`, including an output whose shape the
arguments do not determine, and for chained `MatchCast`s
---
src/relax/op/op.cc | 2 +-
src/relax/op/op_common.h | 7 ++
src/relax/transform/canonicalize_bindings.cc | 65 ++++++++++++
.../relax/test_transform_canonicalize_bindings.py | 114 +++++++++++++++++++++
4 files changed, 187 insertions(+), 1 deletion(-)
diff --git a/src/relax/op/op.cc b/src/relax/op/op.cc
index 9c15ce1dd4..29b268250d 100644
--- a/src/relax/op/op.cc
+++ b/src/relax/op/op.cc
@@ -309,7 +309,7 @@ TVM_FFI_STATIC_INIT_BLOCK() {
* \return The `arg_ty`, if it can be inferred from the arguments.
* Otherwise, std::nullopt.
*/
-static ffi::Optional<Type> InferCallTIROutputTypeFromArguments(
+ffi::Optional<Type> InferCallTIROutputTypeFromArguments(
Type func_ty, Type arg_ty, ffi::Optional<ffi::Array<int64_t>>
opt_inplace_indices) {
auto opt_callee_ty = func_ty.as<FuncType>();
TVM_FFI_CHECK(opt_callee_ty, TypeError)
diff --git a/src/relax/op/op_common.h b/src/relax/op/op_common.h
index 7b30b84038..8d25435fe8 100644
--- a/src/relax/op/op_common.h
+++ b/src/relax/op/op_common.h
@@ -238,6 +238,13 @@ inline Type InferTypeUnary(const Call& call, const
BlockBuilder&, FType f_comput
return InferTypeUnary<require_float_dtype>(call, f_compute_out_dtype);
}
+/*!
+ * \brief Derive the output type of a call_tir from the callee signature and
the
+ * argument types, or nullopt when it cannot be derived.
+ */
+ffi::Optional<Type> InferCallTIROutputTypeFromArguments(
+ Type func_ty, Type arg_ty, ffi::Optional<ffi::Array<int64_t>>
opt_inplace_indices);
+
/*!
* \brief Infer the type by returning the type of the input argument.
* \param call The context Call to the operator.
diff --git a/src/relax/transform/canonicalize_bindings.cc
b/src/relax/transform/canonicalize_bindings.cc
index 1c7b831193..a282f745a2 100644
--- a/src/relax/transform/canonicalize_bindings.cc
+++ b/src/relax/transform/canonicalize_bindings.cc
@@ -28,6 +28,7 @@
#include <tvm/ffi/extra/structural_mutate.h>
#include <tvm/ffi/reflection/registry.h>
#include <tvm/relax/analysis.h>
+#include <tvm/relax/attrs/op.h>
#include <tvm/relax/expr.h>
#include <tvm/relax/expr_functor.h>
#include <tvm/relax/transform.h>
@@ -35,6 +36,8 @@
#include <tvm/relax/utils.h>
#include <tvm/tirx/stmt_functor.h>
+#include "../op/op_common.h"
+
namespace tvm {
namespace relax {
using namespace tvm::prim;
@@ -52,6 +55,68 @@ class SymbolicVarCanonicalizer : public ExprMutator {
return CanonicalizeShapeValue(expr);
}
+ Expr VisitExpr_(const CallNode* op) final {
+ static const Op& call_tir_op = Op::Get("relax.call_tir");
+ static const Op& call_tir_inplace_op = Op::Get("relax.call_tir_inplace");
+ static const Op& call_tir_with_grad_op =
Op::Get("relax.call_tir_with_grad");
+ bool is_call_tir = op->op.same_as(call_tir_op) ||
op->op.same_as(call_tir_inplace_op) ||
+ op->op.same_as(call_tir_with_grad_op);
+ if (!is_call_tir || op->args.size() != 2 || op->ty_args.size() != 1) {
+ return ExprMutator::VisitExpr_(op);
+ }
+ // The out_ty of a call_tir is checked against the argument types.
Canonicalizing the
+ // two independently can leave them mentioning different variables for the
same value,
+ // so the parts of the canonical out_ty that no longer follow from the
arguments are
+ // replaced by what the arguments imply.
+ Expr new_op = this->VisitExpr(op->op);
+ ffi::Array<Expr> new_args =
+ op->args.Map([this](const Expr& arg) { return this->VisitExpr(arg); });
+ Type out_ty = this->VisitExprDepTypeField(op->ty_args[0]);
+ ffi::Optional<ffi::Array<int64_t>> inplace_indices;
+ if (const auto* attrs = op->attrs.as<CallTIRInplaceAttrs>()) {
+ inplace_indices = attrs->inplace_indices;
+ }
+ auto implied = InferCallTIROutputTypeFromArguments(GetType(new_args[0]),
GetType(new_args[1]),
+ inplace_indices);
+ if (implied.has_value()) {
+ out_ty = ReconcileOutType(implied.value(), out_ty);
+ }
+ bool unchanged =
+ new_op.same_as(op->op) && new_args.same_as(op->args) &&
out_ty.same_as(op->ty_args[0]);
+ if (unchanged) {
+ return ffi::GetRef<Expr>(op);
+ }
+ return Call(Type::Missing(), new_op, new_args, op->attrs, {out_ty},
op->span);
+ }
+
+ /*!
+ * \brief Keep each output of out_ty that follows from the implied type. For
a tensor that
+ * does not, take the implied shape and keep the dtype and vdevice, since
the PrimFunc
+ * signature carries no vdevice and erases the shape of an output with a
dimension the
+ * arguments do not determine.
+ */
+ static Type ReconcileOutType(const Type& implied, const Type& out_ty) {
+ if (IsBaseOf(implied, out_ty)) {
+ return out_ty;
+ }
+ const auto* implied_tuple = implied.as<TupleTypeNode>();
+ const auto* out_tuple = out_ty.as<TupleTypeNode>();
+ if (implied_tuple && out_tuple && implied_tuple->fields.size() ==
out_tuple->fields.size()) {
+ ffi::Array<Type> fields;
+ for (size_t i = 0; i < out_tuple->fields.size(); ++i) {
+ fields.push_back(ReconcileOutType(implied_tuple->fields[i],
out_tuple->fields[i]));
+ }
+ return TupleType(fields, out_tuple->span);
+ }
+ const auto* implied_tensor = implied.as<TensorTypeNode>();
+ const auto* out_tensor = out_ty.as<TensorTypeNode>();
+ if (implied_tensor && out_tensor && implied_tensor->shape.has_value()) {
+ return TensorType(implied_tensor->shape.value(), out_tensor->dtype,
out_tensor->vdevice,
+ out_tensor->span);
+ }
+ return implied;
+ }
+
Expr VisitExpr_(const ShapeExprNode* op) final {
if (!canonicalize_shape_values_) return ffi::GetRef<Expr>(op);
ffi::Array<PrimExpr> values =
diff --git a/tests/python/relax/test_transform_canonicalize_bindings.py
b/tests/python/relax/test_transform_canonicalize_bindings.py
index 1b10229337..1a4c1d1b53 100644
--- a/tests/python/relax/test_transform_canonicalize_bindings.py
+++ b/tests/python/relax/test_transform_canonicalize_bindings.py
@@ -26,6 +26,7 @@ from tvm.ir.base import assert_structural_equal
from tvm.relax.transform.transform import CanonicalizeBindings
from tvm.script import ir as I
from tvm.script import relax as R
+from tvm.script import s_tir as Ts
from tvm.script import tirx as T
@@ -1394,3 +1395,116 @@ def test_trace_partial_tuple_through_round_trip():
if __name__ == "__main__":
tvm.testing.main()
+
+
+def test_call_tir_out_ty_follows_arguments_when_match_cast_is_kept():
+ """A call_tir's out_ty must match what its arguments imply.
+
+ The match_cast defining `n` stays because `n` is passed to a kernel at
+ runtime, so the argument of the later call_tir keeps `n` in its shape.
+ Canonicalizing the call's out_ty on its own would replace `n` with 12
+ and leave the two disagreeing. Only that output is rewritten: the second
+ output's shape cannot be derived from the arguments, and neither output's
+ vdevice is in the PrimFunc signature, so both are kept.
+ """
+ n = T.dynamic("n", "int64")
+ m = T.dynamic("m", "int64")
+ k = T.dynamic("k", "int64")
+
+ @I.ir_module
+ class Before:
+ I.module_global_infos({"vdevice": [R.vdevice("llvm", 0),
R.vdevice("llvm", 1)]})
+
+ @Ts.prim_func(private=True)
+ def transpose(
+ x: T.Buffer((1, n, 2, m, 2, 8), "float16"),
+ y: T.Buffer((1, n, m, 2, 2, 8), "float16"),
+ z: T.Buffer((k,), "float16"),
+ ):
+ for i0, i1, i2, i3, i4, i5 in T.grid(1, n, m, 2, 2, 8):
+ with Ts.sblock("b"):
+ v0, v1, v2, v3, v4, v5 = Ts.axis.remap("SSSSSS", [i0, i1,
i2, i3, i4, i5])
+ y[v0, v1, v2, v3, v4, v5] = x[v0, v1, v3, v2, v4, v5]
+
+ @Ts.prim_func(private=True)
+ def add_scalar(x: T.Buffer((1, 8), "float16"), s: T.int64, y:
T.Buffer((1, 8), "float16")):
+ for i in T.serial(8):
+ with Ts.sblock("b"):
+ vi = Ts.axis.spatial(8, i)
+ y[0, vi] = x[0, vi] + T.Cast("float16", s)
+
+ @R.function
+ def main(x: R.Tensor((1, 12, 2, 12, 2, 8), "float16"), w: R.Tensor((1,
8), "float16")):
+ cls = Before
+ with R.dataflow():
+ lv = R.match_cast(x, R.Tensor((1, n, 2, m, 2, 8), "float16"))
+ p = R.call_tir(
+ cls.transpose,
+ (lv,),
+ out_ty=[
+ R.Tensor((1, n, m, 2, 2, 8), "float16", "llvm:1"),
+ R.Tensor((16,), "float16", "llvm:1"),
+ ],
+ )
+ y = R.call_tir(cls.add_scalar, (w, n), out_ty=R.Tensor((1, 8),
"float16"))
+ gv = (p, y)
+ R.output(gv)
+ return gv
+
+ after = relax.transform.CanonicalizeBindings()(Before)
+ lv_binding, p_binding = after["main"].body.blocks[0].bindings[:2]
+ assert isinstance(lv_binding, relax.MatchCast)
+ assert p_binding.value.args[1].fields[0].same_as(lv_binding.var)
+ transposed, extra = p_binding.value.ty_args[0].fields
+ # The out_ty still names the variables the argument carries.
+ assert transposed.shape[1].same_as(n)
+ assert transposed.shape[2].same_as(m)
+ assert [int(dim) for dim in extra.shape] == [16]
+ expected_vdevice =
Before["main"].body.blocks[0].bindings[1].value.ty_args[0].fields[0].vdevice
+ for output in (transposed, extra):
+ tvm.ir.assert_structural_equal(output.vdevice, expected_vdevice)
+ relax.analysis.well_formed(after)
+
+
+def test_call_tir_out_ty_follows_arguments_through_chained_match_casts():
+ """Two match_casts rename the same dimension before a call_tir."""
+ n = T.dynamic("n", "int64")
+ a = T.dynamic("a", "int64")
+ b = T.dynamic("b", "int64")
+
+ @I.ir_module
+ class Before:
+ @Ts.prim_func(private=True)
+ def copy(x: T.Buffer((n, 8), "float16"), y: T.Buffer((n, 8),
"float16")):
+ for i, j in T.grid(n, 8):
+ with Ts.sblock("b"):
+ vi, vj = Ts.axis.remap("SS", [i, j])
+ y[vi, vj] = x[vi, vj]
+
+ @Ts.prim_func(private=True)
+ def add_scalar(x: T.Buffer((1, 8), "float16"), s: T.int64, y:
T.Buffer((1, 8), "float16")):
+ for i in T.serial(8):
+ with Ts.sblock("b"):
+ vi = Ts.axis.spatial(8, i)
+ y[0, vi] = x[0, vi] + T.Cast("float16", s)
+
+ @R.function
+ def main(x: R.Tensor((n, 8), "float16"), w: R.Tensor((1, 8),
"float16")):
+ cls = Before
+ with R.dataflow():
+ lv1 = R.match_cast(x, R.Tensor((a, 8), "float16"))
+ lv2 = R.match_cast(lv1, R.Tensor((b, 8), "float16"))
+ c = R.call_tir(cls.copy, (lv2,), out_ty=R.Tensor((b, 8),
"float16"))
+ y = R.call_tir(cls.add_scalar, (w, n), out_ty=R.Tensor((1, 8),
"float16"))
+ gv = (c, y)
+ R.output(gv)
+ return gv
+
+ after = relax.transform.CanonicalizeBindings()(Before)
+ for binding in after["main"].body.blocks[0].bindings:
+ value = binding.value
+ if isinstance(value, relax.Call) and value.op ==
tvm.ir.Op.get("relax.call_tir"):
+ # Rebuilding the call re-runs the validator, so this passes only
when the
+ # out_ty agrees with the argument types.
+ relax.Call(value.op, value.args, value.attrs, value.ty_args)
+ relax.analysis.well_formed(after)