TVM FFI Deep Dive: Shipping Native Kernels Without Tying Them to One PyTorch Build
A deep dive into why PyTorch native C++ extensions are often tied to a specific Torch build, how TVM FFI provides a stable framework-agnostic ABI, how PyTorch tensors cross the boundary through DLPack and TensorView, and when to choose TVM FFI versus PyTorch's stable ABI.
Why this post
If you ship CUDA/C++ kernels to Python users, sooner or later you run into the native-extension packaging problem:
I built a wheel that works with one PyTorch build. Will it still load and run correctly with the next PyTorch build?
Sometimes the answer is yes. Sometimes the answer is no in a very obvious way: the shared library fails to import. Sometimes it imports, but a symbol is missing only when a specific code path runs. Sometimes it runs, but metadata, allocation, stream, or autograd assumptions subtly differ.
This post is a deep dive into why that happens, what Apache TVM FFI provides, how it integrates with PyTorch tensors, and what tradeoffs it introduces compared with staying inside PyTorch’s extension ecosystem.
All upstream code links to TVM FFI are pinned to commit 88e066e so the references remain stable.
1. The problem: native PyTorch extension ABI instability
The most common way to expose a custom CUDA/C++ kernel to PyTorch is to build a native extension with some combination of:
torch::Tensor- ATen/C10 APIs
torch.utils.cpp_extension- pybind11
TORCH_LIBRARYcustom operator registration
That is convenient. The binding can allocate tensors, call PyTorch helpers, read the current CUDA stream, and integrate with the dispatcher/autograd stack. But it also means the resulting .so is linked to PyTorch’s C++ world.
That can tie the wheel to the exact Torch binary used at build time.
Common failure modes
Native extension ABI problems usually show up in one of four ways.
| Failure mode | What it looks like |
|---|---|
| Import/load failure | import my_extension fails because a linked libtorch symbol or dependency cannot be resolved. |
| Runtime symbol failure | The module imports, but a call path fails when it touches a missing ATen/C10/Torch symbol. |
| Runtime behavior mismatch | The module imports and runs, but tensor metadata, allocation, stream, dispatcher, or autograd behavior differs. |
| Wheel matrix explosion | You rebuild the same kernel wheel for every Torch/CUDA/Python build combination. |
The key issue is not CUDA. It is the Python-facing native boundary depending on PyTorch’s C++ ABI and libtorch symbols. If your CUDA kernel is conceptually a standalone kernel library, you probably do not want the whole wheel to be coupled to a single PyTorch build just because the binding layer uses torch::Tensor.
2. What TVM FFI provides
TVM FFI is an open ABI and FFI layer for ML systems. Its purpose is to expose native functions through a small stable ABI rather than through a framework-specific C++ extension ABI.
At the lowest level, TVM FFI represents every function call with one C ABI:
1
2
3
4
5
int tvm_ffi_c_abi(
void* handle,
const TVMFFIAny* args,
int32_t num_args,
TVMFFIAny* result);
TVMFFIAny is a tagged value representation that can carry integers, floats, strings, tensors, objects, and other supported values. The layout and calling convention are described in the TVM FFI Stable C ABI guide.
For C++ kernel authors, the API is much nicer than hand-writing the C ABI:
1
2
3
4
5
6
7
8
9
10
11
12
#include <tvm/ffi/tvm_ffi.h>
void add_one(tvm::ffi::TensorView x, tvm::ffi::TensorView y) {
int64_t n = x.size(0);
float* x_data = static_cast<float*>(x.data_ptr());
float* y_data = static_cast<float*>(y.data_ptr());
for (int64_t i = 0; i < n; ++i) {
y_data[i] = x_data[i] + 1.0f;
}
}
TVM_FFI_DLL_EXPORT_TYPED_FUNC(add_one, add_one);
Then Python loads the shared library:
1
2
3
4
5
6
7
8
import torch
import tvm_ffi
mod = tvm_ffi.load_module("my_kernels.so")
x = torch.randn(1024, device="cuda", dtype=torch.float32)
y = torch.empty_like(x)
mod.add_one(x, y)
At a high level, the Python binding flow is:
TVM_FFI_DLL_EXPORT_TYPED_FUNC(add_one, add_one)generates an exported C symbol named__tvm_ffi_add_one. The generated wrapper has the stable TVM FFI C ABI:(void* self, const TVMFFIAny* args, int32_t num_args, TVMFFIAny* result).tvm_ffi.load_module("my_kernels.so")calls into TVM FFI’s module loader, whichdlopens the shared library and exposes exported__tvm_ffi_*functions as Python-callabletvm_ffi.Functionobjects.mod.add_one(x, y)callsFunction.__call__. The binding layer packs Python arguments intoTVMFFIAnyvalues. Fortorch.Tensor, the packer uses DLPack to create a TVM FFI tensor view without copying storage.- The exported C ABI wrapper unpacks those
TVMFFIAnyarguments into the typed C++ signature, so the C++ function receivestvm::ffi::TensorView xandtvm::ffi::TensorView y. - The kernel reads metadata and pointers from
TensorViewand writes into the output tensor allocated by the caller.
The key source links for that binding path are:
- export macro generating
__tvm_ffi_<name>:include/tvm/ffi/function.h:949-993 - Python
tvm_ffi.load_module(...):python/tvm_ffi/module.py:438-477 - dynamic shared-library loader using
dlopen/dlsym:src/ffi/extra/library_module_dynamic_lib.cc:45-117 - Python
Function.__call__invoking the packed call path:python/tvm_ffi/cython/function.pxi:941-968
The native .so can be built ahead of time and packaged in a wheel. That avoids runtime JIT build latency. The TVM FFI quickstart shows both raw compiler commands and CMake integration:
3. How PyTorch tensors cross the TVM FFI boundary
The important part is that PyTorch tensors do not need to be converted into a custom Python object manually. In normal code, you pass torch.Tensor objects directly to the TVM FFI function:
1
2
3
4
5
6
7
8
import torch
import tvm_ffi
mod = tvm_ffi.load_module("my_kernel.so")
x = torch.randn(1024, device="cuda", dtype=torch.float32)
y = torch.empty_like(x)
mod.my_kernel(x, y)
There is no explicit from_dlpack(...) call here. The conversion happens inside TVM FFI’s Python binding layer when it packs the arguments for mod.my_kernel(...). PyTorch tensors implement the DLPack protocol, and TVM FFI uses that protocol to create a zero-copy tensor view for the native call.
The relevant upstream code path is:
- argument packing detects
torch.Tensorand selects the Torch fallback setter:python/tvm_ffi/cython/function.pxi:798-800 - that setter calls
torch.utils.dlpack.to_dlpack(arg)and thenfrom_dlpack(...):python/tvm_ffi/cython/function.pxi:269-280 from_dlpack(...)accepts objects with__dlpack__, consumes the DLPack capsule, and creates a TVM FFI tensor:python/tvm_ffi/cython/tensor.pxi:127-190andpython/tvm_ffi/cython/tensor.pxi:191-229- the C API that turns a
DLManagedTensorinto a TVM FFI tensor is declared ininclude/tvm/ffi/c_api.h:713-745and implemented insrc/ffi/tensor.cc:79-95
At a high level:
1
2
3
4
5
PyTorch Tensor
-> implicit DLPack-compatible view while packing Python call arguments
-> TVM FFI tensor representation
-> tvm::ffi::TensorView in C++
-> raw data pointer + metadata in the kernel
The memory is shared. No GPU copy is required just to call the native function.
You can make the same conversion explicit for debugging or explanation:
1
2
3
4
5
6
import torch
import tvm_ffi
x_torch = torch.randn(1024, device="cuda")
x_tvm = tvm_ffi.from_dlpack(x_torch, require_contiguous=True)
x_torch_again = torch.from_dlpack(x_tvm)
That explicit round trip is not required for mod.my_kernel(x, y); it just shows the mechanism. The memory is shared either way.
The TVM FFI tensor docs describe this pattern in more detail:
- Tensor concept doc
- Pinned source for TVM FFI tensor representation:
include/tvm/ffi/container/tensor.h
4. What tvm::ffi::TensorView contains
On the C++ side, a TVM FFI kernel usually accepts tvm::ffi::TensorView arguments:
1
2
3
4
5
6
7
8
9
10
11
12
void kernel(tvm::ffi::TensorView input, tvm::ffi::TensorView output) {
if (input.dtype() != DLDataType{kDLFloat, 32, 1}) {
TVM_FFI_THROW(TypeError) << "expected float32";
}
if (!input.IsContiguous()) {
TVM_FFI_THROW(ValueError) << "expected contiguous input";
}
float* input_data = static_cast<float*>(input.data_ptr());
float* output_data = static_cast<float*>(output.data_ptr());
}
The view carries:
| Metadata | API |
|---|---|
| data pointer | data_ptr() |
| dtype | dtype() |
| device | device() |
| rank | ndim() |
| shape | size(i) |
| contiguity | IsContiguous() |
This is enough for most kernels that only need raw pointers, shapes, dtypes, and devices.
Stream handling
For CUDA kernels, you also need to launch on the framework’s current stream. TVM FFI provides:
1
2
3
4
#include <tvm/ffi/extra/c_env_api.h>
cudaStream_t stream = reinterpret_cast<cudaStream_t>(
TVMFFIEnvGetStream(input.device().device_type, input.device().device_id));
In a PyTorch process, that maps to PyTorch’s current stream for the tensor’s device. See:
- TVM FFI stream/context docs: Tensor: C++ Stream Handling
- Pinned stream API source:
include/tvm/ffi/extra/c_env_api.h
5. Pros and cons of TVM FFI
Pros
- Decouples from PyTorch C++ ABI. The Python-facing native module does not need to link against libtorch just to accept PyTorch tensors.
- Framework-agnostic path. The same ABI can work with PyTorch, JAX, Paddle, NumPy, CuPy, or any framework that supports DLPack.
- Smaller wheel matrix. A kernel library can target CUDA/platform/Python ABI instead of every PyTorch build.
- Good generated-code story. DSLs and codegen systems can emit a stable C ABI or a TVM FFI C++ wrapper.
- Stream interop without framework C++ linkage. GPU kernels can use
TVMFFIEnvGetStream(...).
Cons
- Another runtime dependency. Users need
apache-tvm-ffi. - Less PyTorch C++ convenience. You should not call ATen ops or use
torch::Tensorunless you intentionally link PyTorch again. - More explicit validation. You must validate dtype, shape, device, and contiguity yourself.
- Output allocation discipline. It pushes you toward preallocated outputs, which may require API changes.
- Potential extra shim layer. Existing libraries may still need a conversion from
tvm::ffi::TensorViewto their local descriptors.
6. Alternative: PyTorch stable ABI
The alternative is to stay PyTorch-specific and use PyTorch’s stable ABI support where possible.
That may be the right choice when:
- the kernel is only ever meant for PyTorch;
- the binding needs deep ATen or dispatcher integration;
- cross-framework reuse is not important;
- the extension naturally lives as a PyTorch custom op.
But PyTorch stable ABI is still a PyTorch solution. It does not make the same kernel callable from JAX, Paddle, NumPy, or CuPy. TVM FFI is a better fit when the native code is fundamentally a framework-neutral kernel library.
| Option | Best for | Limitation |
|---|---|---|
| TVM FFI | Framework-neutral kernels and DSL-generated libraries | Requires TVM FFI runtime and explicit metadata handling |
| PyTorch stable ABI | PyTorch-only extensions | Does not generalize beyond PyTorch |
7. Practical checklist
If I were moving a native kernel to TVM FFI, I would check:
- Can the core implementation run with raw pointers, shapes, dtypes, and device IDs?
- Can Python allocate outputs before calling the kernel?
- Can the C++ side avoid
torch::Tensor, ATen, pybind11, andTORCH_LIBRARY? - Does CUDA code launch on
TVMFFIEnvGetStream(...)? - Are all tensor inputs validated for dtype, shape, device, and contiguity?
- Is the TVM FFI
.sobuilt during wheel build rather than JIT-built at import? - Does the wheel contain all dependent
.sofiles needed at load time?
If the answer is yes, TVM FFI is a strong way to make the kernel wheel more portable across framework and PyTorch versions.
Appendix: what DLPack is and where zero-copy happens
DLPack is a small C data structure convention for sharing tensor metadata and data pointers across array frameworks. The important object is a managed tensor capsule containing a data pointer, dtype, shape/strides, device type/device id, and a deleter/lifetime callback.
DLPack does not allocate memory or copy data by itself. It describes an existing tensor allocation so another framework can view the same memory safely. That is why TVM FFI can accept a PyTorch tensor without copying it: PyTorch exports the tensor as DLPack, and TVM FFI imports that DLPack capsule as a TVM FFI tensor.
In PyTorch, the Python-level tensor type exposes the DLPack protocol:
torch.Tensordefines__dlpack_c_exchange_api__and__dlpack__:torch/_tensor.py:105andtorch/_tensor.py:1543-1672torch.utils.dlpack.to_dlpackis backed by_to_dlpack:torch/utils/dlpack.py:6- PyTorch’s C++
THPModule_toDLPackImplconverts a tensor to a DLPack managed tensor:torch/csrc/Module.cpp:614-656 - the ATen DLPack conversion functions are declared in
aten/src/ATen/DLConvertor.h:14-20and implemented inaten/src/ATen/DLConvertor.cpp:494-502
On the TVM FFI side, the binding layer consumes that DLPack object:
- argument packing detects
torch.Tensor:python/tvm_ffi/cython/function.pxi:798-800 - the Torch fallback setter calls
torch.utils.dlpack.to_dlpack(arg):python/tvm_ffi/cython/function.pxi:269-280 - TVM FFI consumes the DLPack capsule and creates a TVM FFI tensor:
python/tvm_ffi/cython/tensor.pxi:127-190andpython/tvm_ffi/cython/tensor.pxi:191-229 - the C API boundary is
TVMFFITensorFromDLPack/TVMFFITensorFromDLPackVersioned:include/tvm/ffi/c_api.h:713-745 - the C API implementation wraps the DLPack tensor as a TVM FFI tensor:
src/ffi/tensor.cc:79-95
The zero-copy property comes from preserving the original data pointer through this chain. TVM FFI constructs a view/handle over the same allocation; it does not allocate a second GPU buffer just to call the kernel.
References
- Apache TVM FFI repo: https://github.com/apache/tvm-ffi
- TVM FFI quick start: https://tvm.apache.org/ffi/get_started/quickstart.html
- TVM FFI stable C ABI: https://tvm.apache.org/ffi/get_started/stable_c_abi.html
- TVM FFI tensor concept: https://tvm.apache.org/ffi/concepts/tensor.html
- TVM FFI C++ tooling: https://tvm.apache.org/ffi/packaging/cpp_tooling.html
- Pinned
TensorViewsource: https://github.com/apache/tvm-ffi/blob/88e066ea2ed100da3c51e081fd4c036a33075fe4/include/tvm/ffi/container/tensor.h - Pinned stream/env API source: https://github.com/apache/tvm-ffi/blob/88e066ea2ed100da3c51e081fd4c036a33075fe4/include/tvm/ffi/extra/c_env_api.h
- Pinned Python module loader: https://github.com/apache/tvm-ffi/blob/88e066ea2ed100da3c51e081fd4c036a33075fe4/python/tvm_ffi/module.py
- Pinned C++ extension tooling: https://github.com/apache/tvm-ffi/blob/88e066ea2ed100da3c51e081fd4c036a33075fe4/python/tvm_ffi/cpp/extension.py