ExecuTorch Deployment#
ExecuTorch is PyTorch’s runtime for edge devices. It loads one .pte file, holding the
program and every compiled payload, from a small C++ library that does not link libtorch.
That suits a robot, a drone, or any device where a full PyTorch install is too heavy.
Torch-TensorRT writes that file. TensorRT compiles the operators it can convert into engines,
the engines are stored inside the .pte, and a delegate hands each one back to TensorRT at
run time. Operators TensorRT will not take go to ExecuTorch’s own CUDA backend in the same
program, so the whole model stays on the GPU.
When to use ExecuTorch
Deploying to an edge device or a C++ application with no Python and no libtorch.
Shipping one file that holds the program, the TensorRT engines, and any fallback kernels.
Driving the model from your own CUDA stream, including a green-context stream that pins the work to part of the GPU.
Note
The ExecuTorch delegate runs on Linux, x86_64 and aarch64, and on Windows x86_64. It requires CUDA 13. Ordinary Torch-TensorRT builds keep their separate CUDA 12 support.
Installation#
Install Torch-TensorRT with the executorch extra from the PyTorch channel for your CUDA
version. Swap cu132 for cu134 on CUDA 13.4:
pip install "torch-tensorrt[executorch]" \
--index-url https://download.pytorch.org/whl/cu132 \
--extra-index-url https://pypi.org/simple
The PyTorch channel is needed because the CUDA build of ExecuTorch is published only there.
What the command installs#
ExecuTorch, the CUDA build, from the
executorchextra. That is all you need to export.torch-tensorrt-executorch-runtime, one shared library holding the TensorRT delegate, which you need to run a.pte. The extra installs the one built together with your Torch-TensorRT wheel, for the same CUDA version. It requires the exact ExecuTorch build it was compiled against, so you get that one rather than the newest.
This works for the standard Linux (x86-64 and aarch64) and Windows x86-64 wheels. On aarch64 it needs glibc 2.35 or newer, the same as TensorRT itself. The TensorRT-RTX and Python-only builds have no delegate.
Use a fresh virtual environment. To export a coalesced program you also need a CUDA toolkit,
because ExecuTorch’s CUDA backend compiles the leftover operators with nvcc.
Nightly builds#
To pick up a change before it ships, or to report a problem against main:
pip install --pre --upgrade "torch-tensorrt[executorch]" \
--index-url https://download.pytorch.org/whl/nightly/cu132 \
--extra-index-url https://pypi.org/simple
--preis required. Without it pip takes the stable release instead.--upgradeis required if a nightly is already installed, or pip keeps the old one and tells you nothing.PyPI stays in the list because TensorRT is not published on the nightly channel.
Keep PyTorch, ExecuTorch and Torch-TensorRT on the same channel.
Compile and Save#
The usual ir="dynamo" path, with one extra argument to torch_tensorrt.save:
output_format="executorch" selects the .pte writer. For this format save keeps
the compiled graph as it is rather than re-exporting it, so it needs no example inputs.
import torch
import torch_tensorrt
model = MyModel().eval().cuda()
example_inputs = (torch.randn(2, 3, 4, 4, device="cuda"),)
exported = torch.export.export(model, example_inputs)
trt_gm = torch_tensorrt.dynamo.compile(
exported,
arg_inputs=example_inputs,
min_block_size=1,
)
torch_tensorrt.save(trt_gm, "model.pte", output_format="executorch")
c) ExecuTorch (.pte) covers the rest: dynamic shapes, several methods in one file, a
zero-copy KV cache, and the two-step torch_tensorrt.executorch.export() path for
programs that need work before they are written.
Coalescing the leftover operators onto the CUDA backend#
TensorRT has no converter for every ATen operator, and by default the ones it rejects run on
the CPU, costing a copy each way on every call. Pass a CudaPartitioner and ExecuTorch’s
CUDA backend compiles them instead, so nothing leaves the GPU:
from executorch.backends.cuda.cuda_backend import CudaBackend
from executorch.backends.cuda.cuda_partitioner import CudaPartitioner
torch_tensorrt.save(
trt_gm,
"coalesced.pte",
output_format="executorch",
partitioners=[
CudaPartitioner([CudaBackend.generate_method_name_compile_spec("forward")])
],
)
TensorRT partitions first and CudaPartitioner takes the rest. For cos(erfinv(tanh(x))),
where TensorRT cannot take erfinv, the saved program’s delegate list reads
['TensorRTBackend', 'CudaBackend', 'TensorRTBackend'].
Warning
The CUDA backend names its external weight file per device, not per model, so saving two coalesced programs into one directory overwrites the first one’s weights. That program still loads, still reports finding its weights, and returns a wrong answer with no error. Give each export its own directory.
Keeping inputs and outputs on the GPU#
By default ExecuTorch copies host to device before the first delegate and back after the last, so a method is safe to call with CPU tensors. If your data is already on the GPU, those copies are pure overhead:
from executorch.exir import ExecutorchBackendConfig
from executorch.exir.passes import MemoryPlanningPass
from executorch.exir.passes.propagate_device_config import PropagateDeviceConfig
torch_tensorrt.save(
trt_gm,
"device_resident.pte",
output_format="executorch",
backend_config=ExecutorchBackendConfig(
propagate_device_config=PropagateDeviceConfig(
skip_h2d_for_method_inputs=True,
skip_d2h_for_method_outputs=True,
),
enable_non_cpu_memory_planning=True,
memory_planning_pass=MemoryPlanningPass(alloc_graph_input=False),
),
)
Three things are easy to miss:
Both skip flags need
enable_non_cpu_memory_planning=True, because the copies are inserted during device-aware memory planning. Asking for a skip with it off raises.Inputs must be unplanned, through
MemoryPlanningPass(alloc_graph_input=False). Otherwise the program reserves its own input buffer and the runtime fills it from the caller, which puts the copy straight back.Leave outputs planned if Python will run the program, so the program’s device arena owns the output. Add
alloc_graph_output=Falseonly for a C++ caller that supplies the address withModule::set_output. Python cannot.
The choice is baked into the file, so feed such a program CUDA tensors. A host tensor still gives the right answer, but it copies on every call, which is the cost you exported to avoid, and nothing warns you.
Python Inference#
Import the delegate package once, before any program is loaded. That import is what registers the backend. Everything after it is ExecuTorch’s own API:
from pathlib import Path
import torch
import torch_tensorrt_executorch_runtime # noqa: F401
from executorch.runtime import Runtime
program = Runtime.get().load_program(Path("model.pte"))
forward = program.load_method("forward")
outputs = forward.execute((torch.ones(2, 3, 4, 4),))
If the delegate cannot load, that import raises at once, instead of surfacing later as a program that will not load.
A coalesced program needs nothing extra: both backends are registered and the program records which parts go where. Torch-TensorRT is only needed to export, not to run.
Note
torch_tensorrt.load(path, format="executorch") still works but is deprecated. It
copies CUDA inputs to the CPU and supports embedded weights only. Use the Runtime API
above instead. A device-resident program requires it.
C++ Inference#
The wheels ship a prebuilt delegate and a CMake package, so a C++ application links them without building anything:
find_package(executorch REQUIRED COMPONENTS backend_cuda extension_cuda kernels_optimized)
find_package(executorch_backend_tensorrt REQUIRED)
target_link_libraries(my_app PRIVATE
executorch::runtime
executorch::backend_cuda
executorch::backend_tensorrt
executorch::extension_cuda
executorch::kernels_optimized
)
kernels_optimizedsupplies theet_copyoperators that move data across the method boundary.backend_cudaregisters the device allocator those copies use, so even a TensorRT-only program needs it. Without it the program loads and then the first instruction fails with_h2d_copy: no device allocator registered.extension_cudaprovidesCallerStreamGuard, used below to choose the CUDA stream.
The two packages live in two distributions, so point CMake at both. ExecuTorch is a
namespace package, so take its path from the distribution metadata, not from __file__:
cmake -DCMAKE_PREFIX_PATH="$(TORCH_TENSORRT_SKIP_DELEGATE_REGISTRATION=1 python -c 'import importlib.metadata as m, torch_tensorrt_executorch_runtime as r, pathlib; print(str(pathlib.Path(str(m.distribution("executorch").locate_file("executorch"))) / "share" / "cmake") + ";" + str(pathlib.Path(r.__file__).parent))')" ...
The environment variable keeps that a path query, so the delegate is not loaded just to print a path.
CMake 3.28 or newer is required. Older versions write the $ORIGIN token in a runtime
search path incorrectly, so backend_cuda rejects them. CMake is not in these wheels, and
a freshly imaged Jetson has none.
There is no header to include. The delegate registers itself when its library loads, and the rest is the ordinary ExecuTorch C++ API:
#include <cstdio>
#include <executorch/extension/module/module.h>
#include <executorch/extension/tensor/tensor.h>
using namespace executorch::extension;
int main() {
Module module("model.pte");
std::vector<float> data(2 * 3 * 4 * 4, 1.0f);
auto input = make_tensor_ptr({2, 3, 4, 4}, std::move(data));
const auto outputs = module.forward(input);
if (!outputs.ok()) {
printf("forward failed\n");
return 1;
}
printf("first output value: %f\n",
outputs->at(0).toTensor().const_data_ptr<float>()[0]);
return 0;
}
Linking also records the wheel’s library directory in your binary, so the application finds
the delegate with no library path set. That suits a local build and not anything you
redistribute. To turn it off, set both of these before find_package and ship the
delegate yourself:
set(EXECUTORCH_BACKEND_TENSORRT_EMBED_RUNPATH OFF)
set(CMAKE_SKIP_BUILD_RPATH ON)
You do not need to find TensorRT. The delegate locates it from the sibling wheel on its own, and your application talks to the delegate rather than to TensorRT.
Building the delegate from source#
The source ships inside libtorchtrt.tar.gz as
torch_tensorrt/src/torch_tensorrt/executorch/. Turn on ExecuTorch’s CUDA backend and the
extensions a Module app needs, add the delegate beside ExecuTorch, and link its target:
set(EXECUTORCH_BUILD_CUDA ON CACHE BOOL "" FORCE)
set(EXECUTORCH_BUILD_EXTENSION_TENSOR ON CACHE BOOL "" FORCE)
set(EXECUTORCH_BUILD_EXTENSION_DATA_LOADER ON CACHE BOOL "" FORCE)
set(EXECUTORCH_BUILD_EXTENSION_FLAT_TENSOR ON CACHE BOOL "" FORCE)
set(EXECUTORCH_BUILD_EXTENSION_MODULE ON CACHE BOOL "" FORCE)
set(EXECUTORCH_BUILD_EXTENSION_NAMED_DATA_MAP ON CACHE BOOL "" FORCE)
add_subdirectory("executorch")
add_subdirectory("torch_tensorrt/src/torch_tensorrt/executorch")
target_link_libraries(my_runner PRIVATE
executorch
executorch::backends
executorch::extensions
executorch::kernels
executorch::backend_tensorrt
)
Building it needs CUDA Toolkit 12.5 or newer. Keep libextension_cuda shared rather than
static, so every delegate in the process reads the same caller-stream state.
examples/executorch_reference_runner/ is a complete C++ example. libtorchtrt.tar.gz
also carries it prebuilt as torch_tensorrt/bin/example_executorch_runner.
Runtime Performance#
Choosing the CUDA stream#
With no stream chosen, both delegates use cudaStreamPerThread, the calling thread’s
default stream, so a coalesced program run from one thread is already ordered correctly.
To put every delegate on a stream of your own, scope a guard over the whole execution:
#include <executorch/extension/cuda/caller_stream.h>
using namespace executorch::extension;
cuda::CallerStreamGuard guard(stream);
module.forward(input);
One guard reaches every delegate, because they share one
libextension_cuda.The stream must be on the engine’s device.
The CUDA backend refuses a caller stream for a method that uses its own CUDA graphs.
Synchronize your stream before reading outputs. The TensorRT delegate always waits for its work, but ExecuTorch’s CUDA backend can return once the work is only queued, so
execute()on a coalesced program can return early. Running a coalesced .pte has the same rule for a decode loop.
That stream can be a green context, which holds a fixed number of streaming multiprocessors,
so the model stays inside that partition and the rest of the GPU is free. Create it with
cuGreenCtxStreamCreate and scope the same guard over it. The limit rides the stream, so
the green context does not have to be made current.
The reference runner has this built in. Pass the SM count:
example_executorch_runner --model_path=coalesced.pte --green_context_sms=8
If a green context cannot be created it fails with a distinct status instead of falling back,
so a passing run really used one. 0, the default, uses an ordinary stream.
CUDA graph replay#
Replay is off by default. With it on, the delegate records an engine’s kernel launches once and then replays the whole engine with a single launch. It helps fixed-shape engines that launch many short kernels, where CPU launch work dominates each call.
torch_tensorrt.save(
trt_gm,
"model.pte",
output_format="executorch",
use_cuda_graphs=True,
)
save() and torch_tensorrt.executorch.export() both take use_cuda_graphs=True,
False, or None for no baked choice. It applies to every method’s TensorRT delegates
and is read when an engine loads.
It is not free. Each engine keeps a device buffer per input and output, and every replayed call copies each one in and out. With few kernels, or large inputs and outputs, those copies can cost more than the launches they save.
Some engines never replay and still pay the copies: changing shapes, aliased outputs, the shared activation scratch below, a green-context stream, GPUs without stream-ordered memory, and drivers older than CUDA 12.5. The delegate logs the reason once.
Warning
While a recording runs, no other thread may create or destroy a TensorRT engine or
execution context, or call a whole-device sync such as cudaDeviceSynchronize or
torch.cuda.synchronize(). Every input shape change starts another recording, so
loading everything up front does not end the risk. Leave replay off when other threads
may do any of that.
A C++ host can refuse a program’s saved request for one load:
#include <executorch/extension/module/module.h>
using namespace executorch::extension;
using namespace executorch::runtime;
Error load_without_cuda_graphs(Module& module) {
BackendOptions<1> options;
const Error stored = options.set_option("use_cuda_graphs", false);
if (stored != Error::Ok) {
return stored;
}
LoadBackendOptionsMap by_backend;
const Error mapped = by_backend.set_options("TensorRTBackend", options.view());
if (mapped != Error::Ok) {
return mapped;
}
return module.load(by_backend);
}
Comparison: .pte vs .pt2#
Feature |
|
|
|---|---|---|
Python load |
|
|
C++ load |
|
|
libtorch at runtime |
Not required |
Required |
Non-TRT ops |
ExecuTorch CUDA backend, or CPU fallback |
Compiled by AOTInductor |
Platform |
Linux + Windows x86_64, CUDA 13 |
Linux + Windows |
Examples#
examples/torchtrt_executorch_example/export_static_shape.pywrites a minimal.pte.examples/torchtrt_executorch_example/export_coalesced.pysplits one graph across the TensorRT delegate and ExecuTorch’s CUDA backend.examples/torchtrt_executorch_example/export_device_resident.pydoes the same with no copies at the method boundary.examples/executorch_reference_runner/loads and runs a.ptefrom Python and from C++, including the green-context option.