Rust Guide#

Note

The Rust support is currently in an experimental stage.

This guide demonstrates how to use TVM FFI from Rust applications.

Installation#

Prerequisites#

The Rust support depends on libtvm_ffi. First, install the tvm-ffi Python package:

pip install -v -e .

Confirm that tvm-ffi-config is available:

tvm-ffi-config --libdir

Adding to Your Project#

Add to your Cargo.toml:

[dependencies]
tvm-ffi = { path = "path/to/tvm-ffi/rust/tvm-ffi" }

For published versions (when available):

[dependencies]
tvm-ffi = "0.1.0-alpha.0"

Environment Setup#

Set the library path so libtvm_ffi can be found at runtime:

export LD_LIBRARY_PATH=$(tvm-ffi-config --libdir):$LD_LIBRARY_PATH

Basic Usage#

Loading a Module#

Load a compiled TVM FFI module and call its functions:

use tvm_ffi::{Module, Result};

fn main() -> Result<()> {
    // Load compiled module
    let module = Module::load_from_file("build/add_one_cpu.so")?;

    // Get function by name
    let add_fn = module.get_function("add_one_cpu")?;

    Ok(())
}

Working with Tensors#

Create and manipulate tensors:

use tvm_ffi::Tensor;

// Create a tensor from a slice
let data = vec![1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
let tensor = Tensor::from_slice(&data, &[2, 3])?;

Calling Functions#

Call functions with tensors:

use tvm_ffi::{Module, Tensor, Result};

fn run_example() -> Result<()> {
    let module = Module::load_from_file("build/add_one_cpu.so")?;
    let func = module.get_function("add_one_cpu")?;

    // Create input and output tensors
    let input = Tensor::from_slice(&[1.0f32, 2.0, 3.0, 4.0], &[4])?;
    let output = Tensor::from_slice(&[0.0f32; 4], &[4])?;

    // Call function
    func.call_tuple((&input, &output))?;

    Ok(())
}

Advanced Topics#

Global Functions#

Register and access global functions:

use tvm_ffi::Function;

// Get global function
let func = Function::get_global("my_function")?;

// Register a new global function
let my_func = Function::from_packed(|args: &[AnyView]| -> Result<Any> {
    // Function implementation
    Ok(Any::default())
});
Function::register_global("my_custom_func", my_func)?;

Reflected Type Methods#

Libraries that register their API through the C++ reflection registry (refl::ObjectDef<T>().def(...)) store methods in a per-type method table rather than the global function table. Resolve them by type key (or type index) and method name; constructors registered via refl::init are reachable under the reserved name __ffi_init__:

use tvm_ffi::{AnyView, Function};

// Resolve the reflected constructor and construct an instance
let ctor = Function::from_type_key_method("testing.TestIntPair", "__ffi_init__")?;
let pair = ctor.call_tuple((1i64, 2i64))?;

// Resolve an instance method; the first packed argument is the object itself
let sum = Function::from_type_key_method("testing.TestIntPair", "sum")?;
let result = sum.call_packed(&[AnyView::from(&pair)])?;
assert_eq!(i64::try_from(result)?, 3);

Function::from_type_method(type_index, name) performs the same lookup when the type index is already known (e.g. from Any::type_index).

Converting Borrowed Values into Any#

Any::from(value) takes ownership of value. Use it when you own the value. When you only have a shared reference—for example, a field accessed through &AddObj—you cannot move the field into an Any. Call AnyCompatible::to_any() instead:

use tvm_ffi::{Any, AnyCompatible};

fn first_operand(node: &AddObj) -> Any {
    node.a.to_any()
}

to_any() creates an owned Any while leaving the borrowed value usable. For object-backed values, it retains the object by incrementing its reference count. It is equivalent to Any::from(node.a.clone()), without requiring an explicit clone at the call site.

Type-Erased Functions#

Create functions from Rust closures:

use tvm_ffi::{Function, Any, AnyView, Result};

// From packed closure
let func = Function::from_packed(|args: &[AnyView]| -> Result<Any> {
    // Process args and return result
    Ok(Any::default())
});

// From typed closure
let typed_func = Function::from_typed(|x: i64, y: i64| -> Result<i64> {
    Ok(x + y)
});

Error Handling#

TVM FFI uses standard Rust Result types:

use tvm_ffi::{Error, Module, Result, VALUE_ERROR};

fn may_fail(value: i32) -> Result<()> {
    // Operations that may fail
    let module = Module::load_from_file("path.so")?;

    // Custom errors
    if value < 0 {
        return Err(Error::new(
            VALUE_ERROR,
            "Value must be non-negative",
            ""
        ));
    }

    Ok(())
}

Structural Walk and Visit#

Rust provides equivalents of the C++ StructuralWalk/StructuralVisitor APIs. Put #[dispatch(walk)] on an impl to turn its walk_* methods into typed handlers, then pass it to structural_walk; each handler returns a WalkResult (Advance, Skip, or Interrupt) to steer the traversal. Handlers dispatch on their argument type and may take an optional trailing DefRegionKind argument:

use tvm_ffi::{dispatch, structural_walk, Array, DefRegionKind, WalkOrder, WalkResult};

#[derive(Default)]
struct Probe {
    total: i64,
    floats: usize,
}

#[dispatch(walk)]
impl Probe {
    fn walk_integer(&mut self, value: i64) -> WalkResult {
        self.total += value;
        WalkResult::Advance
    }

    fn walk_float(&mut self, _value: f64, _kind: DefRegionKind) -> WalkResult {
        self.floats += 1;
        WalkResult::Advance
    }
}

let values = Array::new(vec![1_i64, 2, 3]);
let mut probe = Probe::default();
structural_walk(&values, &mut probe, WalkOrder::PreOrder)?;
assert_eq!(probe.total, 6);

For Map and Dict, structural walk treats keys as structural anchors: it visits container values but does not pass keys to handlers. The map or dict object itself is still visited normally.

Lambdas also work — pass a single typed lambda, or a tuple of them tried in order with the first matching argument type winning, like the variadic C++ StructuralWalk(root, callbacks...) chain. A flat tuple holds up to 12 lambdas; a tuple is itself a link, so nest (a, b, (c, d, ...)) to chain more — order stays the flattened left-to-right order. Unmatched values simply advance; a &VisitValue lambda acts as a catch-all and must come last, since links after an always-matching one never run. Each lambda may take a trailing DefRegionKind argument:

use tvm_ffi::{structural_walk, Array, DefRegionKind, Object, WalkOrder, WalkResult};

let values = Array::new(vec![1_i64, 2, 3]);

let mut total = 0;
structural_walk(
    &values,
    |value: i64| {
        total += value;
        WalkResult::Advance
    },
    WalkOrder::PreOrder,
)?;
assert_eq!(total, 6);

let mut evens = 0;
let mut objects = 0;
structural_walk(
    &values,
    (
        |value: i64| {
            if value % 2 == 0 {
                evens += 1;
            }
            WalkResult::Advance
        },
        |_object: &Object, _kind: DefRegionKind| {
            objects += 1;
            WalkResult::Advance
        },
    ),
    WalkOrder::PreOrder,
)?;
assert_eq!((evens, objects), (1, 1));

Both entry points return Result<Option<VisitInterrupt>>: Ok(None) means the whole graph was visited, and a handler stops the walk early by returning WalkResult::interrupt_with(payload), which comes back to the caller as Ok(Some(interrupt)). Handlers may also return Result<WalkResult> and propagate errors with ?:

use tvm_ffi::{structural_walk, Array, WalkOrder, WalkResult};

let values = Array::new(vec![1_i64, 2, 3]);
let found = structural_walk(
    &values,
    |value: i64| {
        if value == 2 {
            return WalkResult::interrupt_with(value);
        }
        WalkResult::Advance
    },
    WalkOrder::PreOrder,
)?;
assert_eq!(found.map(|i| i64::try_from(i.value).unwrap()), Some(2));

structural_visit accepts typed callbacks in addition to a StructuralVisitor. Callbacks receive VisitContext, use first-match dispatch, and own traversal of matched values. VisitCallbacks adds state shared by the callback chain:

use tvm_ffi::{structural_visit, Array, VisitCallbacks, VisitContext, VisitValue};

#[derive(Default)]
struct Stats {
    total: i64,
}

let values = Array::new(vec![1_i64, 2]);
let mut visitor = VisitCallbacks::new(
    Stats::default(),
    (
        |value: i64, visitor: &mut VisitContext<'_, Stats>| {
            visitor.state_mut().total += value;
        },
        |_value: &VisitValue, visitor: &mut VisitContext<'_, Stats>| {
            visitor.visit_children()
        },
    ),
);
structural_visit(&values, &mut visitor)?;
assert_eq!(visitor.state().total, 3);

Callbacks are Fn; mutable data belongs in the visitor state. A catch-all callback must call visit_children() explicitly, and interrupt values must be returned explicitly because ? only propagates errors.

For a named implementation, #[dispatch(visit)] generates StructuralVisitor from visit_* methods. Matching handlers own recursion; unmatched values use default child traversal:

use tvm_ffi::{
    dispatch, structural_visit, Array, DefRegionKind, Result, StructuralVisitor, VisitInterrupt,
    VisitValue,
};

#[derive(Default)]
struct Depth {
    max: usize,
    current: usize,
}

#[dispatch(visit)]
impl Depth {
    fn visit_any(
        &mut self,
        value: &VisitValue,
        def_region_kind: DefRegionKind,
    ) -> Result<Option<VisitInterrupt>> {
        self.current += 1;
        self.max = self.max.max(self.current);
        let interrupt = self.default_visit_children(value, def_region_kind)?;
        self.current -= 1;
        Ok(interrupt)
    }
}

let values = Array::new(vec![1_i64, 2]);
let mut depth = Depth::default();
structural_visit(&values, &mut depth)?;
assert_eq!(depth.max, 2);

Implement StructuralVisitor directly to override its low-level visit method.

Two safety notes: mutable List/Dict contents are snapshotted before callbacks run, so mutation during traversal cannot invalidate the walk; and a non-container type with a foreign __s_visit__ hook is rejected rather than silently walked through reflection — visit such a type’s children explicitly from a StructuralVisitor, or skip it with a pre-order WalkResult::Skip.

Structural Mapping and Mutation#

structural_map is the transforming counterpart to structural_walk. Put #[dispatch(map)] on an impl whose map_* methods return any value convertible into Any, directly or in Result. Methods are tested in source order, the first matching argument type wins, and an unmatched value is preserved. A method may take an optional trailing DefRegionKind; a &MapValue method is a catch-all and should therefore come last:

use tvm_ffi::{
    dispatch, structural_map, Any, Array, DefRegionKind, MapValue, Result, WalkOrder,
};

#[derive(Default)]
struct Increment {
    integers: usize,
}

#[dispatch(map)]
impl Increment {
    fn map_integer(&mut self, value: i64, _kind: DefRegionKind) -> Result<i64> {
        self.integers += 1;
        Ok(value + 1)
    }

    fn map_other(&mut self, value: &MapValue) -> Any {
        value.to_owned()
    }
}

let mut increment = Increment::default();
let mapped = structural_map(
    Array::new(vec![1_i64, 2]),
    &mut increment,
    WalkOrder::PostOrder,
)?;
let mapped = Array::<i64>::try_from(mapped)?;
assert_eq!(mapped.iter().collect::<Vec<_>>(), vec![2, 3]);

A single typed closure and an ordered tuple of up to twelve closures are also accepted; a tuple is itself a link, so (a, b, (c, d, ...)) nests beyond that without changing the flattened order. Tuple dispatch is first-match, not broadcast: later closures do not run after an earlier argument type matches. As with generated dispatch, a &MapValue catch-all belongs last. Numeric handlers claim the complete FFI Int or Float tag and then use Rust as conversion semantics; prefer i64 or f64 unless narrowing is deliberate.

WalkOrder::PreOrder runs the callback before default child mapping. If it returns a different root, that replacement root is not passed to the callback again, but its children are still mapped. WalkOrder::PostOrder maps children first and passes the resulting value to the callback; a replacement returned there is final. Array and List elements are mapped in order. Map and Dict keys are identity anchors and are never mapped; only their values are.

The root is consumed. A uniquely owned built-in container may reuse its storage in place; passing root.clone() keeps the source shared and selects copy-on-write behavior. The engine rechecks uniqueness after a pre-order callback, so retaining an owning MapValue::to_owned() alias before the container’s children are mapped forces the non-in-place path. Reflected objects must provide __ffi_shallow_copy__; the copy is validated before fields are mapped and discarded if no structural field changes.

Within one structural_map call, callbacks run at every occurrence; their results are not cached. Default recursion manages identity remapping with the same semantics as C++.

Default recursion uses the same type attributes as C++: it calls __s_maybe_inplace_mutate__ for a uniquely owned object when available, or falls back to __s_mutate__. Each hook receives the active Rust-backed mutator and can recurse through its language-independent vtable. This lets the implementation that registered the type own its storage and mutation rules. When a type has no hook, object-backed values use reflected fields.

Callbacks may return Result<T> for any T convertible into Any; for example, an integer handler can return Result<i64> to report failures and use ?. Errors propagate with object or reflected-field context. In-place changes completed before a later error are not rolled back, and the consumed root is not returned on error.

Callbacks can also return Unchanged or UnchangedOr<T>, optionally wrapped in Result. A pre-order map still maps the original value’s children when a callback returns Unchanged. Use mutate_result and default_mutate_result to preserve unchanged during recursion; existing owning-value helpers and top-level functions resolve the marker to the original value.

structural_mutate accepts typed callback chains in addition to a StructuralMutator. Closure callbacks receive a CallbackMutator; MutateCallbacks adds state shared by that callback chain:

use tvm_ffi::{
    structural_mutate, Array, CallbackMutator, MapValue, MutateCallbacks,
};

#[derive(Default)]
struct Stats {
    integers: usize,
}

let mut mutator = MutateCallbacks::new(
    Stats::default(),
    (
        |value: i64, mutator: &mut CallbackMutator<Stats>| {
            mutator.state_mut().integers += 1;
            value + 1
        },
        |_value: &MapValue, mutator: &mut CallbackMutator<Stats>| {
            mutator.default_mutate()
        },
    ),
);
let mutated = structural_mutate(Array::new(vec![1_i64, 2]), &mut mutator)?;
let mutated = Array::<i64>::try_from(mutated)?;
assert_eq!(mutated.iter().collect::<Vec<_>>(), vec![2, 3]);
assert_eq!(mutator.state().integers, 2);

CallbackMutator::mutate uses the copy path for a borrowed value, while maybe_inplace_mutate preserves the reuse opportunity of an owned value. Closure callbacks are Fn; mutable data belongs in the callback state.

#[dispatch(mutate)] groups typed mutate_* callbacks. The dispatch object owns its mutable pass state, while Mutator supplies recursion and the current definition region. mutator.mutate(self, child) safely reborrows that dispatch object and inherits the current region; mutate_with is available for an explicit override. An unmatched value follows default mutation with its current in-place permit. A handler that does not need these controls can omit the &mut Mutator parameter:

use tvm_ffi::{dispatch, structural_mutate, Array};

#[derive(Default)]
struct Increment {
    integers: usize,
}

#[dispatch(mutate)]
impl Increment {
    fn mutate_integer(&mut self, value: i64) -> i64 {
        self.integers += 1;
        value + 1
    }
}

let mut increment = Increment::default();
let mutated = structural_mutate(
    Array::new(vec![1_i64, 2]),
    &mut increment,
)?;
let mutated = Array::<i64>::try_from(mutated)?;
assert_eq!(mutated.iter().collect::<Vec<_>>(), vec![2, 3]);
assert_eq!(increment.integers, 2);

For a named custom recursion policy, implement StructuralMutator and pass &mut it to structural_mutate. InplaceValue is an engine-issued capability: callers cannot construct it from a read-only MapValue. Override dispatch_maybe_inplace_mutate to opt into default container reuse; default_maybe_inplace_mutate rechecks uniqueness before writing. Borrowed values can be re-entered with mutate, while owned values can use maybe_inplace_mutate:

use tvm_ffi::{
    structural_mutate, Any, Array, DefRegionKind, InplaceValue, MapValue, Result,
    StructuralMutator,
};

#[derive(Default)]
struct Increment;

impl StructuralMutator for Increment {
    fn dispatch_mutate(&mut self, value: &MapValue, kind: DefRegionKind) -> Result<Any> {
        match value.cast::<i64>() {
            Some(value) => Ok(Any::from(value + 1)),
            None => self.default_mutate(value, kind),
        }
    }

    fn dispatch_maybe_inplace_mutate(
        &mut self,
        value: InplaceValue<'_>,
        kind: DefRegionKind,
    ) -> Result<Any> {
        self.default_maybe_inplace_mutate(value, kind)
    }
}

let mutated = structural_mutate(
    Array::new(vec![1_i64, 2]),
    &mut Increment::default(),
)?;
let mutated = Array::<i64>::try_from(mutated)?;
assert_eq!(mutated.iter().collect::<Vec<_>>(), vec![2, 3]);

The default var_remap_get and var_remap_set methods use state local to one structural_mutate invocation, preserving completed FreeVar and DAGNode substitutions without adding a field to the mutator. Override them and use StructuralVarRemap when a custom identity policy is required.

Examples#

The repository includes a complete example in rust/tvm-ffi/examples/load_library.rs.

Run it with:

cd rust
cargo run --example load_library --features example

Building the Workspace#

Build the entire Rust workspace:

cd rust
cargo build

Run tests:

cargo test

API Reference#

For detailed API documentation, see the Rust API Reference.