Working with IR in Rust#
The tvm-tirx crate lets a Rust function read, build, and rewrite TIRx IR. The function can receive a program written in TVMScript and return the result, and nothing is converted or copied along the way. This guide covers:
TVM-FFI object system: how IR nodes are represented and used in Rust.
Structural utilities: walk, map, and compare programs.
Visitors and mutators: keep state and control the traversal.
Note
The runnable code in this guide is under examples/ir_guides in the repository.
Setting Up#
The examples are packaged as tirx_ir_guide_examples under
examples/ir_guides.
When cargo is available, installing the package builds the Rust crate in
rust/ into a library; CMakeLists.txt and pyproject.toml in the
same directory show how. The crate uses the bindings from this checkout,
so install TIRx from the same source tree. We install without build isolation
to build against that installation instead of fetching one from PyPI:
pip install scikit-build-core
pip install --no-build-isolation -e examples/ir_guides
A static_init_block! at the end of the crate registers each example as
a global function under tirx_ir_guide_examples.rust:
tvm_ffi::Function::register_global_typed(
"tirx_ir_guide_examples.rust.construct",
construct,
"Build x * 2 + 1.",
)?;
The package’s tirx_ir_guide_examples.rust module loads the library and
exposes these functions, so Python calls them by name. The driver
working_with_ir_in_rust.py calls each example this way and prints the
result.
The object system examples take no arguments: each builds its own IR in
Rust. The later sections receive the add_one function from Quick
Start, written in TVMScript:
from tvm_tirx.script import tirx as T
n = T.dynamic("n")
@T.function
def add_one(A: T.Tensor((n,), T.float32), B: T.Tensor((n,), T.float32)):
for i in range(n):
B[i] = A[i] + 1
import tirx_ir_guide_examples.rust
tirx_ir_guide_examples.rust.construct()
tirx_ir_guide_examples.rust.written_tensors(add_one)
TVM-FFI Object System#
Every TIRx IR node is a TVM-FFI object. Rust IRs are typically built as
enums and matched with match, which works well when every node type is
known in advance. TIRx is designed for dynamic extension instead: a
library can add a new node type, for example a new statement for a
hardware feature, and existing code keeps working on programs that contain
it. Generic utilities such as walking, rewriting, comparing, and printing
work on every node through its registered fields.
TVM-FFI objects also work across languages. Python, C++, and Rust share the same native, immutable, reference-counted objects, so a program moves between them without conversion.
We designed the TVM-FFI object system to be ergonomic in Rust. The key constructs are:
Construct |
Purpose |
|---|---|
Constructors, expression operators |
Build expressions with ordinary Rust operators |
|
Underlying object and its named managed reference: read fields directly, clone cheaply |
|
Convert to a parent type |
Convert to a child type, with a checked |
|
Branch on the node type |
|
Process each node type in its own method, with shared state |
The subsections below introduce each construct.
Constructing Objects#
We build IR with constructors and ordinary Rust operators. Writing
x * 2 + 1 as nested constructor calls would bury the expression, so
the crate lets us write it as we would in Rust. Let’s build it:
fn x_times_2_plus_1() -> Result<(Var, Expr)> {
let x = Var::new("x", PrimType::int(32), UNKNOWN_LOC);
let expr = (&x * 2 + 1).try_into()?;
Ok((x, expr))
}
fn construct() -> Result<tvm_ffi::String> {
let (_, expr) = x_times_2_plus_1()?;
script(&expr)
}
The output is:
x * 2 + 1
Var::new takes a name, a type, and a source location; UNKNOWN_LOC
means there is none. The operators * and + build IR directly
from x, and the literals 2 and 1 become constants of its type.
An operator can fail, for example on mismatched types, so ExprResult
carries the first error along, much like a chain of Results. The
standard .try_into() conversion extracts an Expr, and ? passes
an error on. script prints the node as TVMScript.
We now have an expression; next, let’s see what kind of Rust value it is.
Objects and Managed References#
Every node type comes as a pair: an underlying object and a named
managed reference. The top of
x * 2 + 1 is an addition, so let’s convert the expression to Add
with try_cast, which the next subsection explains, and read its two
operands:
fn objects() -> Result<Strings> {
let (_, expr) = x_times_2_plus_1()?;
let add: Add = expr.try_cast()?;
let alias = add.clone();
Ok(lines([
format!("a = {}", script(&add.a)?.as_str()),
format!("b = {}", script(&add.b)?.as_str()),
format!("same object: {}", alias.same_as(&add)),
]))
}
The output is:
a = x * 2
b = 1
same object: true
alias is the same object as add: clone copies only the managed
reference, and same_as compares identity, like Arc::ptr_eq.
AddObj is the underlying object: the struct that holds the fields. Add is a named managed reference
to it: an owning, reference-counted smart pointer, like Arc<AddObj>,
so cloning it is cheap. Functions take and return managed references, and
a managed reference dereferences to its object, so fields read as ordinary
struct fields: add.a.
We define a separate named type Add instead of using a plain pointer
to AddObj because the named type carries the interface: constructors,
methods, conversions to parent types, and the traits that let it pass
through TVM-FFI functions. It can also impose additional constraints on
the underlying object that the object alone does not express.
This is the newtype pattern. Simplified from the crate, the pair is:
#[repr(C)]
#[derive(Object)]
#[type_key = "prim.Add"]
pub struct AddObj {
base: ExprObj,
pub a: Expr,
pub b: Expr,
}
#[repr(C)]
#[derive(ObjectRef, Clone)]
pub struct Add {
base: ObjectArc<AddObj>,
}
Rust has no inheritance; instead, #[repr(C)] puts the parent’s object,
ExprObj, first in AddObj, so every addition is also an expression.
Add implements Deref to AddObj, which is why add.a works.
AddObj has the same memory layout as its C++ counterpart, so
add.a is a direct field read, as efficient as in C++.
Nodes are immutable. As with data behind an Arc, a managed
reference gives read access only; to change a node, we build a new one.
We reached Add with a cast; next, let’s look at casts on their own.
Casts#
Casts move a managed reference up and down the type hierarchy. A
function that takes any expression wants an Expr, while reading the
operands needs an Add. .into() converts to a parent type through
From/Into and cannot fail. .try_cast() converts to a child
type: like TryFrom, it checks the node’s actual type and returns a
Result.
fn casts() -> Result<Strings> {
let (_, expr) = x_times_2_plus_1()?;
let add: Add = expr.try_cast()?;
let value: Expr = add.into();
let again: Add = value.clone().try_cast()?;
let mut out = vec![format!("back to Add: {}", script(&again)?.as_str())];
if let Err(err) = value.try_cast::<Mul>() {
out.push(format!("as Mul: {}", err.message()));
}
Ok(lines(out))
}
The output is:
back to Add: x * 2 + 1
as Mul: Cannot convert from type `prim.Add` to `prim.Mul`
value is an Expr again, but the node behind it is still an
addition, so the cast back to Add succeeds.
What if we guess wrong? The cast to Mul returns an Err, and its
message names both type keys: the actual type prim.Add and the
requested prim.Mul.
Note
try_cast consumes the managed reference it converts, so we clone
value before the first cast; the clone costs one reference-count
increment. For read-only inspection, value.downcast_ref::<AddObj>()
returns Option<&AddObj> without consuming or cloning the reference.
Any and AnyView provide the same borrowed downcast.
Casting to one type at a time gets tedious when a node can be one of many; that is what matching is for.
Matching Node Types#
match_any! branches on the actual type of a node, as match does
on an enum. Let’s print an expression with explicit parentheses by
matching each node and recursing into its operands:
fn describe(expr: &Expr) -> Result<String> {
let text = match_any! {
AnyView::from(expr) {
AddObj(ref add) => format!("({} + {})", describe(&add.a)?, describe(&add.b)?),
MulObj(ref mul) => format!("({} * {})", describe(&mul.a)?, describe(&mul.b)?),
VarObj(ref var) => var.name.as_str().to_owned(),
IntImmObj(ref imm) => imm.value.to_string(),
_ => "<other>".to_owned(),
}
};
Ok(text)
}
We call it on x * 2 + 1 and on x - 1:
fn matching() -> Result<Strings> {
let (x, expr) = x_times_2_plus_1()?;
let difference = (&x - 1).try_into()?;
Ok(lines([describe(&expr)?, describe(&difference)?]))
}
The output is:
((x * 2) + 1)
<other>
Each arm names an object type and borrows the matching node with ref;
the first matching arm runs. The AddObj and MulObj arms recurse into
the operands, the VarObj and IntImmObj arms end the recursion, and
x - 1 falls to the _ arm because describe has no arm for
subtraction.
The catch-all arm keeps existing code working as node types are added.
The set of node types is open, like a #[non_exhaustive] enum from
another crate, so the match needs a _ arm. A function handles the
types it knows, and _ lets programs with new types pass through.
Note
Obj(ref node) borrows the underlying object without incrementing its
reference count. describe converts expr to an AnyView. An
owning arm such as Add(add) still produces a managed reference.
match_any! also works on Any and AnyView values, including
tensors, shapes, and typed arrays. Plain values such as integers take
the _ arm in this example.
A match looks at one node at a time; to process every node in a program while keeping state, we dispatch.
Dispatching on Node Types#
#[dispatch] lets one struct process each node type in its own method,
with shared state. A compiler pass often needs one piece of logic per
node type and state shared across them; #[dispatch] puts both on one
struct. It works like a trait whose methods all have default bodies: we
write methods for the node types we care about, and every other type gets
the default behavior.
#[dispatch(walk)] on an impl block turns its walk_* methods
into callbacks for structural_walk, which visits every node. Each
method borrows the nodes of its parameter type, and the methods are tried
in source order. Walk handlers share the Result<WalkResult> return type;
one handler declares the trailing DefRegionKind argument used by the
walker, and handlers that do not need it may omit it. Let’s count the
variables and additions:
#[derive(Default)]
struct Counter {
vars: usize,
adds: usize,
}
#[dispatch(walk)]
impl Counter {
fn walk_var(&mut self, _var: &VarObj, _kind: DefRegionKind) -> Result<WalkResult> {
self.vars += 1;
Ok(WalkResult::Advance)
}
fn walk_add(&mut self, _add: &AddObj) -> Result<WalkResult> {
self.adds += 1;
Ok(WalkResult::Advance)
}
}
We walk x * 2 + 1 with it. WalkResult::Advance continues into
the node’s children, and WalkOrder::PreOrder visits a node before its
children:
fn count() -> Result<tvm_ffi::String> {
let (_, expr) = x_times_2_plus_1()?;
let mut counter = Counter::default();
tvm_ffi::structural_walk(&expr, &mut counter, WalkOrder::PreOrder)?;
Ok(format!("variables: {}, additions: {}", counter.vars, counter.adds).into())
}
The output is:
variables: 1, additions: 1
Node types without a method fall back to the default traversal: the
walk continues into their children, so new types pass through. Here the
multiplication and the constants have no method, and the walk still
reaches x inside the multiplication.
Structural Utilities#
The structural utilities are part of TVM-FFI, and the tvm_ffi crate,
re-exported as tvm_tirx::tvm_ffi, provides them in Rust:
structural_walk, structural_map, structural_visit, and
structural_mutate. They work on every node through its registered
fields and keep definitions and uses consistent. Each piece takes a Rust
form:
Piece |
Rust form |
|---|---|
Nodes a callback receives |
Selected by the type of the callback’s parameter |
Walk callback result |
|
Traversal order |
A required argument, |
Result of a map |
An |
Definition region kind |
A trailing |
Errors |
|
Structural equality |
The global function |
Structural Walk#
A walk calls our callback on each node of the type it asks for. Let’s find
the tensors that add_one writes:
fn written_tensors(func: Function) -> Result<Strings> {
let mut writes = Vec::new();
let on_store = |store: TensorStore| -> Result<WalkResult> {
let dest: Var = store.dest.clone().try_cast()?;
writes.push(dest.name.clone());
Ok(WalkResult::Advance)
};
tvm_ffi::structural_walk(&func, on_store, WalkOrder::PostOrder)?;
Ok(writes.into_iter().collect())
}
The output is:
['B']
on_store takes a TensorStore, so the walk calls it only on
stores. Strings is our alias for Array<tvm_ffi::String>, which
Python receives as a list of strings.
WalkResult::Advance continues into the node’s children, Skip
does not descend into them, and Interrupt stops the whole walk.
The parameter list of a callback decides what it receives:
Callback |
Receives |
|---|---|
|
Each write, as a managed reference. |
|
Each write, as a borrowed object, without changing its reference count. |
|
Every value the walk reaches. |
|
Each variable, with its definition region kind. |
|
A tuple of callbacks, tried in order; only the first one whose type matches a node runs. Tuples hold up to 12 callbacks and can be nested. |
WalkResult::interrupt_with stops the walk and returns a value, which
lets a walk search for a node:
fn first_write(func: Function) -> Result<Option<TensorStore>> {
let find_write = |store: TensorStore| WalkResult::interrupt_with(store);
let found = tvm_ffi::structural_walk(&func, find_write, WalkOrder::PostOrder)?;
match found {
Some(interrupt) => Ok(Some(interrupt.value.try_into()?)),
None => Ok(None),
}
}
Python prints the dest.name of the store it receives. The output is:
B
structural_walk returns Ok(Some(interrupt)) when a callback stops
it, with the payload in interrupt.value, and Ok(None) otherwise.
Definitions and Uses#
A callback receives the definition region kind when it takes a
DefRegionKind as its last parameter. This walk lists the definition
sites of add_one:
fn definitions(func: Function) -> Result<Strings> {
let mut sites = Vec::new();
let show_definition = |var: Var, kind: DefRegionKind| {
if kind != DefRegionKind::None {
sites.push(format!("{} {:?}", var.name.as_str(), kind));
}
WalkResult::Advance
};
tvm_ffi::structural_walk(&func, show_definition, WalkOrder::PreOrder)?;
Ok(sites.iter().map(tvm_ffi::String::from).collect())
}
The output is:
A Pattern
n Pattern
B Pattern
n Pattern
i Simple
DefRegionKind::None marks a use site; Pattern and Simple are
the two kinds of definition site. structural_map callbacks take the
same trailing parameter.
Structural Map#
A map callback returns the replacement for its node. Let’s specialize
add_one for n = 128, with n passed in from Python:
fn specialize(func: Function, n: Var, value: i64) -> Result<Function> {
let specialize_var = |var: Var| -> Result<Expr> {
if var.same_as(&n) {
let ty: PrimType = var.ty.clone().try_cast()?;
return make_const(ty, value);
}
Ok(var.into())
};
tvm_ffi::structural_map(func, specialize_var, WalkOrder::PostOrder)?.try_into()
}
The output is:
from __future__ import annotations
# from tvm_tirx.script import tirx as T
@T.function
def add_one(A: T.Tensor((128,), T.float32), B: T.Tensor((128,), T.float32)):
for i in range(128):
B[i] = A[i] + T.float32(1.0)
make_const builds the constant 128 with the type of n, and
every other variable is returned as it is. structural_map takes
ownership of the managed reference we pass and returns the new root as an
Any; Python still holds add_one, which is unchanged.
Structural Equality#
The crate has no Rust function for structural equality yet, so we call the
global function ffi.StructuralEqual with its four arguments: the two values,
map_free_vars, and skip_tensor_content. Let’s specialize
add_one twice and compare the results:
fn compare_specialized(func: Function, n: Var) -> Result<Array<bool>> {
let first = specialize(func.clone(), n.clone(), 128)?;
let second = specialize(func, n, 128)?;
let (map_free_vars, skip_tensor_content) = (false, false);
let equal: bool = tvm_ffi::Function::get_global("ffi.StructuralEqual")?
.call_tuple((&first, &second, map_free_vars, skip_tensor_content))?
.try_into()?;
Ok([first.same_as(&second), equal].into_iter().collect())
}
The output is:
[False, True]
The two functions are different objects, so same_as is false, but they
are structurally equal.
Visitors and Mutators in Rust#
Stateful traversals use the same four structural utilities. State lives
on a #[dispatch] struct, as in Dispatching on Node Types.
#[dispatch(map)] and #[dispatch(visit)] group map_* and
visit_* methods. #[dispatch(mutate, MutationView)] groups
mutate_* methods and carries mutation permission through matching.
A visit or mutate callback takes control of its node’s children.
Let’s record the loops around each write. Each handler receives the node
and can take a StructuralVisitorView<'_, Self> for recursive traversal;
the loop stack and collected writes remain on self:
#[derive(Default)]
struct LoopNest {
loops: Vec<String>,
writes: Vec<String>,
}
#[dispatch(visit)]
impl LoopNest {
fn visit_for(
&mut self,
loop_: &ForObj,
visitor: &mut StructuralVisitorView<'_, Self>,
) -> Result<Option<VisitInterrupt>> {
self.loops.push(loop_.var.name.as_str().to_owned());
let result = visitor.default_visit(self, loop_);
self.loops.pop();
result
}
fn visit_store(&mut self, store: &TensorStoreObj) -> Result<Option<VisitInterrupt>> {
let loops = self.loops.join(", ");
let write = format!("{} inside [{}]", script(&store.dest)?.as_str(), loops);
self.writes.push(write);
Ok(None)
}
}
fn loops_around_writes(func: Function) -> Result<Strings> {
let mut visitor = LoopNest::default();
tvm_ffi::structural_visit(&func, &mut visitor)?;
assert!(visitor.loops.is_empty());
Ok(visitor.writes.iter().map(tvm_ffi::String::from).collect())
}
The output is:
['B inside [i]']
visit_for pushes the loop variable, lets default_visit(self, loop_)
visit the loop’s children with their usual traversal, then pops the variable.
It returns the traversal result, so an interrupt or error from a child
reaches the caller. The view also provides visit(self, child) for a
selected child and def_region_kind for the current node.
For mutation, a trailing &mut StructuralMutatorView<'_, Self> provides
def_region_kind() and child-mutation methods. A
MutationView<'_, For> carries the current loop and the engine’s
permission to reuse it. Its typed fields can be borrowed directly; after
that borrow ends, default_mutate consumes the handle to recurse with
the same permission. The returned UnchangedOr<Any> becomes the common
Any handler result without discarding an unchanged marker. Reuse still
requires unique ownership,
so an alias held by Python remains unchanged. Let’s reverse the loop
by replacing each use of i with n - 1 - i. As before,
.try_into()? extracts the built expression or returns its error:
#[derive(Default)]
struct ReverseLoops {
reversed_index: HashMap<ObjectIdentity, Expr>,
}
#[dispatch(mutate, MutationView)]
impl ReverseLoops {
fn mutate_for(
&mut self,
value: MutationView<'_, For>,
mutator: &mut StructuralMutatorView<'_, Self>,
) -> Result<Any> {
let index = (&value.end - 1 - &value.var).try_into()?;
self.reversed_index.insert(ObjectIdentity::of(&value.var), index);
mutator.default_mutate(self, value).map(Into::into)
}
fn mutate_var(
&mut self,
var: Var,
mutator: &mut StructuralMutatorView<'_, Self>,
) -> Result<Any> {
let is_use = mutator.def_region_kind() == DefRegionKind::None;
Ok(match self.reversed_index.get(&ObjectIdentity::of(&var)) {
Some(index) if is_use => index.clone().into(),
_ => var.into(),
})
}
}
fn reverse_loops(func: Function) -> Result<Function> {
tvm_ffi::structural_mutate(func, ReverseLoops::default())?.try_into()
}
The output is:
from __future__ import annotations
# from tvm_tirx.script import tirx as T
@T.function
def add_one[n](A: T.Tensor((n,), T.float32), B: T.Tensor((n,), T.float32)):
for i in range(n):
B[n - 1 - i] = A[n - 1 - i] + T.float32(1.0)
mutate_for records the replacement, then
default_mutate rewrites the loop’s children through the
same struct, so mutate_var sees the
replacement before any use of i.
Note
mutate_var replaces i only where def_region_kind() is
None, so the loop’s own i stays a variable. ObjectIdentity
keys the map by object identity, so only this loop’s i matches.
Note
TIRx also has traversals that track the facts valid at each node, such
as loop ranges and branch conditions. In Rust they are
StructuralVisitorWithConstraintContext and
StructuralMutatorWithConstraintContext; constraints() returns the
current facts.
With fields, typed callbacks, and the structural utilities, a Rust function works with the same IR that Python builds and prints, so an analysis or a transformation can move between the two languages one function at a time.
Quick Reference#
Construct |
What it does |
|---|---|
Branches on the node type; the first matching arm runs |
|
Downcasts to one type, as a managed reference in a |
|
|
Upcasts a managed reference to a parent type |
|
Creates a node; builds an expression with operators |
|
Walks with typed callbacks that return |
|
Rebuilds with replacements and returns an |
A trailing |
Receives the definition region kind |
|
Compares by structure |
|
Turns the methods of a struct into typed callbacks |
Recurse with the same dispatcher and current definition region |
|
Registers Rust functions as global functions |