Expand description
Rust bindings for the TIRx compiler objects, over TVM FFI.
The bindings are generated by tvm-ffi-stubgen --target rust from the reflection metadata of
libtvm_tirx_compiler. The modules follow the Python package tvm_tirx:
ir: the shared IR (expressions, statements, types, functions, modules and operators), with the primitive expressions inir::primand the pass infrastructure inir::transform.tirx: tensor functions and types, index maps, layouts, thread scopes, tile instructions and the TIRx passes.sym: symbolic analysis: the analyzer, integer sets and iterator maps.target: compilation targets and target kinds.backend: backend-specific objects, such as the attributes of CUDA instructions.runtime: the runtime objects the IR refers to, tensor allocation and runtime queries.codegen: compilation of IR modules into runtime modules, and the LLVM backend queries.script: printing IR objects as TVMScript.
§Using the bindings
The crate links libtvm_tirx_compiler, so its type keys and global functions are registered
when a program starts. The build script finds the library through tvm-tirx-config --libdir,
as tvm-ffi-sys finds libtvm_ffi through tvm-ffi-config --libdir: both helpers come with
their Python packages (apache-tvm-tirx and apache-tvm-ffi) and must be on PATH when the
crate is built. The native global functions have Rust wrappers, as methods of the handle types
and as functions of the modules, such as target::Target::new for target.Target. Native
functions that have no Rust wrapper are called through tvm_ffi::Function::get_global.
The crate uses the library only through the TVM FFI registry, so a linker that drops
unreferenced libraries (--as-needed, which rustc passes on Linux) keeps it only because
the crate holds a reference to a symbol of it, the link anchor, that is linked along with the
crate. A program that calls at least one wrapper therefore has the library as a dependency,
at no run-time cost. A program that reaches the registry only through tvm_ffi itself,
without calling a wrapper, must keep the library by other means, such as the linker
arguments tvm-tirx-config --libs compiler prints.
use tvm_tirx::target::Target;
let target = Target::new("llvm")?;
assert_eq!(target.kind()?.name()?.as_str(), "llvm");A handle such as ir::Expr is a reference-counted pointer to a native object, so cloning it
does not copy the IR, and its *Obj type (ir::ExprObj) is the object. Each binding says
whether its layout is complete or opaque. A complete *Obj reproduces the native object
byte for byte and exposes its reflected fields directly. The layout of an opaque one cannot be
rebuilt from the reflection (it has unreflected members, or no layout metadata), so its fields
are read through fallible getters over the C ABI. Use tvm_ffi::ObjectRefCast for checked
casts between handles.
A complete object can also be allocated from Rust. Its new takes every field and stores it
as given, without the checks, defaults or derived fields of the native constructor. Where the
native constructor does more than that (ir::prim::Add checks its operand types,
tirx::Function derives its type from the signature), new is reserved for a constructor with
those semantics, and the allocation of the fields as given is from_fields. The IR
constructors are written in Rust after the C++ ones, with the same checks, defaults, derived
fields and errors; they call into C++ only for the nodes C++ shares (UnknownLoc,
MissingType and the PrimType of each dtype, fetched once per thread), for the hooks an
operator or type registers (such as FInferType), and for printing in an error message.
A constructor returns a Result when its C++ counterpart can throw, and the node itself
otherwise: ir::Var::new or ir::UnknownLoc::new cannot fail, because the native calls
they make are to functions the linked library registers, and a failure there is a mismatch
between the crate and the library, which panics.
The common types and constants have the short constructors C++ has: ir::PrimType::int
and its siblings for PrimType::Int(32), ir::IntImm::int32, ir::IntImm::bool and
ir::FloatImm::float32 for the constants, ir::prim::make_const for a constant of any
type, and the conversions of i32, i64, bool, f32 and f64 into an ir::Expr that
C++ makes implicitly. ir::dtype spells the DLDataType of each (dtype::int(32)) without
parsing a string.
The unknown location is ir::UNKNOWN_LOC as a constructor argument and
ir::Location::unknown as a value.
The operators are written in Rust too, after src/ir/prim/op.cc: ir::prim::op::add and
its siblings match their operand types and fold constants as C++ add does, and the math
functions (ir::prim::op::floor, ir::prim::op::exp, …) build the same calls. They
take expressions or literals typed as C++ types them and build at the unknown location (the
_at variants take one), and an ir::prim::op::ExprResult gives them operator syntax:
floordiv(&x + 1, 4)? is the C++ floordiv(x + 1, 4). An ExprResult passes into any function or
constructor that takes an ir::IntoExpr, as any expression does.
Operators and types are defined from Rust in a tvm_ffi::static_init_block!, as C++ defines
them in a TVM_FFI_STATIC_INIT_BLOCK. An operator is an ir::OpDef chain, the C++ OpDef,
with the signature (ir::sig, whose descriptors generate the validator), the attributes, by
the keys of ir::op_attr and tirx::op_attr, and the type inference, validator and
region hooks as Rust functions, which C++ calls natively; a type, written outside the block as
any other (Defining a type in Rust), is registered by
ir::register_type:
use tvm_tirx::ir::op_attr::CALL_EFFECT_KIND;
use tvm_tirx::ir::sig::{self, IntExpr};
use tvm_tirx::ir::{register_type, CallEffectKind, CallObj, Op, OpDef, Type};
use tvm_tirx::tvm_ffi::{self, Result};
fn index_type(call: &CallObj) -> Result<Type> {
Ok(call.args.get(0)?.ty.clone())
}
tvm_ffi::static_init_block! {
OpDef::new("my_ext.clamp", "Clamp an index to the range `[0, bound)`.")
.signature((
sig::arg::<IntExpr>("index", "The index."),
sig::arg::<IntExpr>("bound", "The exclusive upper bound."),
))
.set_infer_type(index_type)
.set_attr(CALL_EFFECT_KIND, CallEffectKind::Pure);
register_type::<PairType>()?;
}
let clamp = Op::get("my_ext.clamp")?;A block runs when its program or library loads, as a C++ one does, wherever in it the block
is. ir::Op::get then returns the operator, as C++ Op::Get does: the registry keeps one
object per name, so call.op.same_as(&clamp) tests whether a call is to it. Every library
built on the crate exports tvm_ffi::run_static_init_blocks, which reports the errors of the
blocks that failed, as the TVM FFI function tirx_register_ir.
§The library Python loads
The crate is also built as a shared library, which the TIRx build installs as libtvm_tirx_rust
next to libtvm_tirx_compiler when it finds a cargo as new as the crate’s rust-version
(TVM_TIRX_USE_RUST, AUTO by default). import tvm_tirx loads it through the backend
autoload (tvm_tirx.backend.rust), which registers the operators and types the crate defines by
the time the import returns, as the C++ ones are, and calls tirx_register_ir to raise the
error of a block that failed. Python uses the operators as it uses the C++ ones: Op.get finds
them, a Call built in Python runs their hooks, and the TVMScript printer prints their calls.
Completing an operator, when its ir::OpDef chain ends, or a type with ir::register_type
requests API exposure through the ir.expose_op or ir.expose_type FFI receiver. Python
installs these receivers before loading native libraries and publishes the selected definitions
to registered destinations, including destinations initialized later. Op API destinations
construct raw calls; script destinations add builder behavior. Type destinations publish
constructors, such as T.<Name> for tirx.<Name>Type. Handwritten Python bindings take
precedence in each destination. Without receivers, native registration proceeds without API
exposure.
A library of operators and types of its own, a cdylib built on the crate as
examples/define_ir.rs is, registers them when Python loads it, by tvm_ffi.load_module or
ctypes, as a C++ library does, and stays loaded for the process. Each library links its own
copy of the crates it is built from, so several libraries can have the same block, as those of a
crate they share: the first library that runs it registers what it defines and the others skip
it, so the libraries load together. A crate that a library depends on but never names is not
linked into it, nor are its blocks: name it, as use my_ops as _; does.
§Defining a type in Rust
[tvm_ffi::rs_class] defines a new IR type, a type of expressions as ir::PrimType and
ir::TupleType are, from its object struct, whose first field base is the ir::TypeObj
it extends, which holds the location. It registers the type under ir.Type with the
reflection of every other field, so that the C++ structural equality and hashing, deep copy,
repr, the TVMScript printer and Python see the fields as they see a C++ type’s.
[tvm_ffi::def_object_ref] defines the reference, which converts into ir::Type. The
object’s new takes the TypeObj, then the fields in order:
use tvm_tirx::ir::{Location, PrimType, Type, TypeObj};
use tvm_tirx::tvm_ffi::{self, def_object_ref, rs_class, ObjectRefCore};
/// The type of a pair of values.
#[rs_class("my_ext.PairType", final)]
pub struct PairTypeObj {
base: TypeObj,
pub first: Type,
pub second: Type,
/// Not part of structural equality or hashing.
#[reflect(seq_hash_ignore)]
pub note: tvm_ffi::String,
}
def_object_ref!(pub PairType, Type, PairTypeObj);
impl PairType {
pub fn new(first: impl Into<Type>, second: impl Into<Type>) -> Self {
PairTypeObj::new(TypeObj::new(Location::unknown()), first, second, "").into()
}
}
fn main() {
let pair = PairType::new(PrimType::int(32), PrimType::float(32));
let ty: Type = pair.into(); // an `ir.Type` that C++ sees as `my_ext.PairType`
assert!(ty.downcast_ref::<PairTypeObj>().is_some());
}The generated code names tvm_ffi, which use tvm_tirx::tvm_ffi brings into scope in a crate
that depends only on tvm-tirx. The parent’s object must have a public new and implement
ShallowCopy and UnsafeInit; the bindings generate them for a complete type that is not
final, as ir.Type. Registered by ir::register_type in a tvm_ffi::static_init_block!,
with the operators, the type is also constructed by Python from its fields and parsed by
TVMScript, as a C++ type.
§Generated and hand-written code
The code between a // tvm-ffi-stubgen(begin) and its // tvm-ffi-stubgen(end) is generated
and rewritten in place. Everything outside the blocks is hand-written, including the
tvm-ffi-stubgen(...) directives that steer the generator.
The blocks of the objects a Python module defines are in the Rust file at the same path:
python/tvm_tirx/ir/prim/expr.py is src/ir/prim/expr.rs. target and runtime, whose
objects come from a single Python module each, are one file. A type is in the module where
Python defines it, which is not always its type-key namespace: tirx.cuda.* is in
backend::cuda and tirx.tensor.* in tirx::tile. A type Python has no class for is next
to the types it belongs to, and its file says so. A parent module re-exports its files, so
ir::Expr names ir::expr::Expr. It also holds the prefix directive of its namespace: an
object registered later that no file has a block for yet is generated there, to be moved to
its file.
Regenerate from the repository root after rebuilding libtvm_tirx_compiler (add --check to
only report stale blocks):
tvm-ffi-stubgen rust/src --target rust --dlls build/lib/libtvm_tirx_compiler.sotvm-ffi-stubgen comes with the tvm_ffi Python package, which must be the one of
3rdparty/tvm-ffi: the blocks are what its generator emits. The blocks are formatted as
rustfmt formats them, so cargo fmt --check and tvm-ffi-stubgen --check agree.
§Tests
Build libtvm_tirx_compiler first. The build scripts need tvm-ffi-config and
tvm-tirx-config on PATH, which uv run provides from the .venv after the editable
install, where tvm-tirx-config --libdir is build/lib:
uv run --no-sync cargo test --manifest-path rust/Cargo.tomlSet TVM_TIRX_LIBRARY_PATH to the directory of another libtvm_tirx_compiler to build and
test against that one; tvm-tirx-config puts it first.
The tests are laid out like the modules. Each top-level module is one test binary,
tests/<module>/main.rs, and the tests of src/<path>.rs are in tests/<path>.rs:
tests/tirx/transform/function_pass.rs tests src/tirx/transform/function_pass.rs. Run the
tests of one module with --test <module>, such as --test tirx. tests/common/ holds the
helpers the binaries share.
Re-exports§
pub use tvm_ffi;
Modules§
- Backend-specific objects.
- Code generation: IR modules compiled into runtime modules.
- The shared IR: expressions, statements, types, functions, modules and operators.
- The runtime objects the IR refers to.
- TVMScript: IR objects printed as the Python script that builds them.
- Symbolic analysis of integer expressions.
- Compilation targets.
- Tensor programs: functions, tensor types, index maps, layouts and thread scopes.
Macros§
- Add primitive-expression operators to an existing reference wrapper.