Uh oh!
There was an error while loading. Please reload this page.
refactor(executorch)!: share one caller stream, and ship the CUDA delegate in the runtime wheel - #4454
Conversation
1832276 to
bba42e2Compareshoumikhin
commented
Jul 31, 2026
CI caught a real mistake in my previous push, now fixed. I had removed the The underlying reason is that Bazel's So the test step now locates the versioned library Bazel materialized and passes it tensorrt_lib_dir="$( find -L "$(bazel info output_base)/external" \ -path '*tensorrt*/lib/libnvinfer.so.*' -printf '%h\n'2>/dev/null | sort -u | head -n1)"
bazel test //tests/cpp/executorch:executorch_backend_tests \
--compilation_mode opt --config=linux --test_output=errors \
--test_env=LD_LIBRARY_PATH="${tensorrt_lib_dir}"This is still simpler than before. It keeps the sandbox enabled, since Worth noting from the same run: |
bba42e2 to
42bf7d4CompareSecond attempt at the test-runtime fix. My previous one was wrong in a way the logs I set Both libraries have the same root cause. Bazel's The step now locates the directory holding each versioned soname and appends to bazel_external="$(bazel info output_base)/external"for_sonamein libnvinfer.so libcudart.so;do
_dir="$( find -L "${bazel_external}" -path "*/lib*/${_soname}.*" -printf '%h\n'2>/dev/null | sort -u | head -n1)"if [[ -z"${_dir}" ]];thenecho"Could not locate a versioned ${_soname} under ${bazel_external}">&2exit 1
fiexport LD_LIBRARY_PATH="${_dir}${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}"doneI verified this locally by reproducing both failure modes rather than reasoning about The middle line is the failure this run hit. The guard also names the specific library This is still simpler than the original version of the step: the sandbox stays enabled, |
shoumikhin
commented
Jul 31, 2026
CI is green on the ExecuTorch gate. All three C++ tests pass: The same job also exercised the rest of this change end to end:
Remaining failures on this commit are pre-existing on
The dynamo jobs stop before running any test with Note that a large number of |
| cudaStream_t stream = g_user_stream_set ? g_user_stream : cudaStreamPerThread; | ||
| const auto caller_stream = ::executorch::extension::cuda::getCallerStream(); | ||
| const bool caller_stream_set = caller_stream.has_value(); | ||
| cudaStream_t stream = caller_stream.value_or(cudaStreamPerThread); |
There was a problem hiding this comment.
This means that eventually enqueueV3 is happening on the caller stream, right? Is it going to be a problem @narendasan
There was a problem hiding this comment.
Good question, and the short answer is yes, but that is not new here.
enqueueV3 already ran on the caller-selected stream before this change. On the base
commit the same function had:
cudaStream_t stream = g_user_stream_set ? g_user_stream : cudaStreamPerThread; // line 382
...
if (!ctx->enqueueV3(stream)) { // line 571This change only swaps where that stream value is read from. It was a thread-local
private to this delegate, and it is now ExecuTorch's shared one. TheenqueueV3 call, the staging copies, the synchronize, and the completion event are all
untouched.
What happens with no guard active
Nothing changes. getCallerStream() returns empty, so the stream iscudaStreamPerThread and execute() still synchronizes before returning, exactly as
before. Existing callers see identical behavior.
When execute() can return with work still in flight
This is the part worth being precise about. The decision is:
constbool must_sync = output_staged_to_host || input_staged_from_host || !caller_stream_set;execute() skips the end-of-call synchronize only when all of these hold:
- the caller scoped a
CallerStreamGuard, and - every input is already GPU-accessible, and
- every output is already GPU-accessible.
If any tensor is host-backed, the backend staged it through a temporary device buffer,
so it synchronizes before returning to keep the "results are ready on return" contract.
And with no guard at all it always synchronizes. So the asynchronous path only happens
when the caller explicitly asked for stream semantics and no host memory is involved.
Ordering and lifetime safety
Within execute(), stream is assigned once and never reassigned, and every CUDA
operation uses that same variable: the host-to-device copy, enqueueV3, the
device-to-host copy, the synchronize, and the event record. There is no mixing of the
caller's stream with the default stream.
For the asynchronous path, the backend records a completion event on that stream and
waits on it in two places before it could disturb TensorRT state:
- at the start of the next
execute(), before anysetInputShapeorsetTensorAddress, since those are host-side calls on the execution context; - in the handle destructor, before freeing staging buffers and releasing the execution
context.
It waits on the event rather than the stream, so teardown stays correct even if the
caller has already destroyed their stream.
This is the same pattern ExecuTorch's CUDA backend uses
The caller-stream primitive exists precisely so several CUDA delegates can share one
caller-provided stream. ExecuTorch's own CUDA/AOTI backend reads the same value and
installs it for its whole execution:
// backends/cuda/runtime/cuda_backend.cppconst std::optional<cudaStream_t> caller_stream =
executorch::extension::cuda::getCallerStream();
...
setCurrentCUDAStream(caller_stream.value_or(handle->get_cuda_stream()), 0);If the two delegates read different stream values, a program that mixes them cannot
order their work, which is the problem this change fixes.
Known limits, stated plainly
- The selected stream must be on the TensorRT engine's device. That is not validated in
code today; a mismatch surfaces as anenqueueV3failure. Pre-existing, and this
change makes the error message name the likely cause. - CUDA green-context streams need context-aware completion-event handling and are not
claimed as supported. This change narrows the previous claim rather than widening it. - The device-resident asynchronous path is not covered end to end by CI, because the
reference runner's tensors are host-backed and therefore take the synchronized path.
Happy to gate the asynchronous return behind an explicit backend option instead, if you
would prefer that a backend-neutral stream selection never changes when execute()
returns. That would be a small follow-up and would leave the default fully synchronous.
42bf7d4 to
37ea972Compare37ea972 to
80927c7Compare80927c7 to
efb3df1Compareshoumikhin
commented
Aug 19, 2026
Pushed three commits that close the gap between what this PR enables and what it The primary use case now runs, and is verified. One against an eager reference of Two things had to be fixed before that was possible.
The TensorRT delegate tolerates host-backed input by staging it. The CUDA delegate
The old comment's reason for keeping it off was wrong. It cited libtorch. Docs corrected. They previously told readers green-context streams were outside Also retitled, since removing Note this PR's CI still cannot run its own checks until #4523 lands: the |
d1b15e2 to
6377a06Compare6377a06 to
28dd8deCompareshoumikhin
commented
Aug 20, 2026
Heads up that this grew since your approval, so please re-check rather than take the Getting ExecuTorch's shared caller-stream library requires Rather than back the CUDA backend out, this keeps it and ships the rest, because it Also here: The title and description are updated to say all of this. Happy to split the CUDA One thing outside this PR: the ExecuTorch wheel build is currently red on |
Replace the TensorRT ExecuTorch backend private caller-stream TLS with ExecuTorch CallerStreamGuard/getCallerStream so CUDA-capable delegates share one process-wide selection. Link and package one shared extension_cuda instance, add ordinary caller-stream inference coverage in the reference runner, and verify both CMake-built and packaged runners consume the shared TLS without libtorch. The ExecuTorch release/1.4 pin is intentionally owned by the preceding version-bump commit.
Five things found while reviewing the switch to the shared caller-stream guard. The runtime wheel build compiles this delegate too, and it now includes extension/cuda/caller_stream.h. ExecuTorch only adds that subdirectory when EXECUTORCH_BUILD_CUDA is on, which that path never set, so the header and its library were absent there. It now enables the option, links extension_cuda, and fails with a clear message if the target is missing rather than a confusing link error. The verification step added to the build workflow called the reference-runner script with no arguments. The script takes the program to verify as its only argument, so the step exited at its usage check before building or running anything. It now exports a program first and passes it, matching the test workflow. The enqueue failure message lost its most useful hint. The old text named the common cause, that cudaStreamPerThread is invalid while a CUDA green context is current, and said what to do about it. Rewriting the message for the new guard left only a device mismatch hint, so both causes are named again. The duties a caller must obey to use the async path safely were documented on the class this change removes, in an installed header. They had moved to a README that is not installed, so a caller reading headers alone could no longer discover that execute() may return with work still in flight. They are back on execute(), with the README as the long form. That header note also states that other CUDA delegates sharing the same guard may synchronize before returning, since one guard now drives several delegates and they do not agree on whether results are ready on return. Finally, the new test's comment claimed to pin properties the backend relies on, which reads as covering the backend. It does not link the backend, so it now says what it cannot catch. Testing: the executorch test directory passes, 64 tests. The workflow YAML parses and the export script it now calls exists. The extension_cuda CMake target name was checked against the pinned ExecuTorch tree.
The delegate reads the caller-selected CUDA stream through extension/cuda/caller_stream.h, which does not exist in ExecuTorch 1.3.1. The file is absent at the v1.3.1 tag and present at v1.4.1, so installing a 1.3.x release gives a build that cannot compile the delegate. The declared floor still allowed 1.3.1 in four places while the backend README already said 1.4 was required, so the two contradicted each other. All four now say 1.4.0: the package requirement, the local task runner, the CI test runner, and the build workflow. Testing: checked that no 1.3.x floor remains anywhere in the tree, and that the edited Python files still parse. The absence of the header at 1.3.1 and its presence at 1.4.1 were both confirmed against the upstream tags.
…nner docs Three small things found while auditing the build files. Adding the caller-stream dependency left the ExecuTorch header dependency listed twice in both platform branches of the delegate's Bazel target. Removed the duplicate from each. The reference runner README told the reader to check out one ExecuTorch commit while describing it as the snapshot the package is built against, but the build pins a different one. A user following those steps would build against different source than the package was built from. The README now names the pinned commit and says to keep it in sync with the build pin. The same README said a reader could follow "the asynchronous contract below", and no contract follows in that file. It now links to the backend README that holds it. Testing: confirmed the header dependency now appears once per branch, that the README names the commit the build pins, and that the link target exists.
… stream Two changes to the runner, together making it able to exercise the thing the shared caller stream exists for: TensorRT claiming what it can, ExecuTorch's CUDA/AOTI delegate filling the gaps, both on one caller-owned stream. 1. Honour the declared device for memory-planned buffers. A .pte split across both delegates could not run here at all. It aborted: cuda_backend.cpp:529] Tensor 0 has device_type=CUDA but its data pointer is not backed by CUDA device memory (cudaMemoryType=0) method.cpp:1525] CALL_DELEGATE execute failed at instruction 2: 0x12 The TensorRT delegate tolerates host-backed input by staging it to the device. The CUDA delegate does not: it verifies the storage really is device or managed memory. The tensor passed between the two delegates comes from a memory-planned buffer, and this runner allocated every planned buffer on the host. ExecuTorch already reports where each planned buffer belongs, via MethodMeta::memory_planned_buffer_device(), and its own executor_runner allocates through the DeviceAllocator the backend library registers. Follow that pattern. 2. Add --green_context_sms=N. Creates the caller stream inside a CUDA green context holding N SMs, so every delegate that honours the caller stream is confined to that SM partition. This is the case the caller-stream API is documented for: "a single caller-provided stream, including a CUDA green-context stream, can drive several delegates in one program". Default 0 keeps the previous ordinary-stream behaviour unchanged. If a green context is requested and cannot be created, the runner aborts rather than falling back, so a test cannot silently pass on an ordinary stream. Verified on an A100 (108 SMs), one .pte whose graph genuinely splits across both delegates, all four combinations exit 0 with correct values: planned buffer[0] = 16384 bytes on CPU planned buffer[1] = 32768 bytes on device_type 1 ordinary stream first 8 values: 0.6722 0.6722 0.6722 ... green context with 8 SM(s) first 8 values: 0.6722 0.6722 0.6722 ... against an eager reference of 0.672167 for the runner's all-ones input. Before change 1 the same .pte aborted, so the coverage is real rather than vacuous.
…gate EXECUTORCH_BUILD_CUDA was FORCEd off, so -D on the command line was ignored and only the TensorRT delegate could ever be built here. That made the two-delegate case, which is the reason the caller stream is shared, impossible to exercise. Make it overridable, and correct the comment. The old comment gave libtorch as the reason to keep it off, and that is not accurate: measured on Linux x86_64, enabling it needs one companion option, EXECUTORCH_BUILD_EXTENSION_TENSOR=ON, and the resulting runner links no libtorch and no libc10. Its full shared-library set is libaoti_cuda_shims, libcudart, libcurand, libextension_cuda, libnvinfer, plus libc and libstdc++. Default stays off to keep the default build small. Also find CUDAToolkit in this scope for the CUDA driver API that the new --green_context_sms flag uses. Imported targets created by a find_package inside an add_subdirectory are not visible in the parent scope, so CUDA::cuda_driver was undefined without this.
…not covered The docs told a reader that green-context streams were outside the validated support matrix. That is the case the shared caller-stream primitive exists for: upstream's own header says a single caller-provided stream, including a CUDA green-context stream, can drive several delegates in one program. Disclaiming it described the feature as unsupported. It works. Verified on an A100 with 108 SMs: a .pte whose graph splits across the TensorRT delegate and ExecuTorch's CUDA/AOTI delegate, run under one green context holding 8 SMs, produces 0.6722 against an eager reference of 0.672167. Both stream modes and both export configurations pass. Document how to reproduce it, and keep the two real limits explicit rather than implying full coverage: it is not in CI, because the CI configuration builds the runner without the CUDA delegate, and it exercised the synchronized path, so the device-resident asynchronous return and its interaction with the internal completion event are still uncovered. Also record that enabling the CUDA delegate does not pull in libtorch, since the previous comment claimed the opposite.
The end-to-end check accepted wrong results. Fed a log with a wrong shape and mostly wrong values, both assertions passed: output[0] shape=[999] numel=1 dtype=6 first 8 values: 9 9 2.0000 9 9 9 9 9 grep -q "output\[0\] shape=" exit 0 grep -Eq "first [0-9]+ values:.* 2\.0000" exit 0 The first never compares the shape. The second passes if one correct value appears anywhere on the line. So a stream-ordering regression returning stale or partial output passes this gate, which makes it worse than no gate: it reads as coverage. The sample model is x + 1 on a (2,3,4,4) input and both runners fill inputs with 1.0f, so the shape is exactly [2,3,4,4] and every printed value is exactly 2.0000. Assert both precisely. Verified in four directions: correct output accepted the garbage above rejected on shape right shape, one wrong value rejected, and it names the value no values line at all rejected
…time The green-context flag broke the standard wheel build. The runner has two build systems and I had only wired the driver library into CMake, so the Bazel link failed: Linking examples/executorch_reference_runner/example_executorch_runner failed undefined reference to 'cuInit' undefined reference to 'cuGreenCtxCreate' ... and 8 more Wiring it into Bazel as well does not work either. Checked inside the release image, docker.io/pytorch/manylinux2_28-builder:cuda13.0: it ships no libcuda and no driver stub anywhere under /usr/local/cuda*, so there is nothing to link against. The existing @cuda//:cuda target globs lib*.so with allow_empty, so depending on it would have silently provided nothing and failed the same way. Resolve the nine entry points with dlopen when the flag is used. Then neither build system carries a CUDA driver dependency, and a machine without a driver gets a clear error from the flag rather than a link failure for everyone. Only -ldl is added, which is always present. Verified after the change: main.cpp.o undefined cu* symbols 0 (this is what broke the bazel link) CMake build exit 0 ordinary stream, two-delegate .pte first 8 values: 0.6722 ... green context with 8 SMs first 8 values: 0.6722 ... against an eager reference of 0.672167, so the feature still works through the new path. The binary still shows a transitive libcuda dependency, which comes from libextension_cuda.so and libaoti_cuda_shims.so in a CUDA-delegate build and not from the runner's own objects.
28dd8de to
4450d73CompareUh oh!
There was an error while loading. Please reload this page.
What this does
The ExecuTorch backend carried its own private thread-local for the caller-selected
CUDA stream. This replaces it with ExecuTorch's shared
executorch::extension::cuda::CallerStreamGuard, so every CUDA-capable delegate in aprocess reads one shared selection instead of one per backend.
That matters because a Torch-TensorRT export can split a graph across two delegates:
TensorRT claims what it can, and ExecuTorch's CUDA/AOTI delegate fills the gaps. Both
have to observe the same stream, or they cannot share a CUDA green context. Upstream's
own header names exactly that case as the reason the API exists.
The Python runtime now carries both GPU delegates
Reaching that shared library means building ExecuTorch with
EXECUTORCH_BUILD_CUDA=ON, and upstream then links its CUDA backend into the same_portable_libthis wheel ships:That is worth keeping rather than working around. A program split across the two
delegates needs both registered in one runtime to run at all, and ExecuTorch's
published wheel registers only
XnnpackBackend. So before this there was no Pythonruntime anywhere that could execute such a program, even though exporting one was
already supported. That gap is now closed.
Keeping it means shipping everything the backend needs. The build produces two shared
libraries that ExecuTorch's own wheel does not publish, and the extensions carry a
DT_NEEDEDon both:libextension_cuda.solibaoti_cuda_shims.so.pteShipping only the first left a CUDA backend with half its runtime missing, which
surfaces as an
ImportErroron a missing shared object. Both ship now,data_loadergets the same
$ORIGINsearch path_portable_libalready had so neither extensiondepends on module load order to find them, and the runtime test asserts
CudaBackendis registered beside the existing
TensorRTBackendandXnnpackBackendassertions.The static C++ runtime settings, and the existing check for them, now cover every
shared object the wheel ships rather than only the two Python extensions. A shared
library left on the host
libstdc++hands that dependency straight back to_portable_lib.sothrough its ownDT_NEEDED.Breaking change
torch_tensorrt::executorch_backend::CudaStreamGuardis removed from the publicheader, with no deprecated alias. Callers switch to:
This is deliberate rather than forced. An alias would have shared the same
thread-local, so keeping one would buy source compatibility at the cost of two names
for one primitive. The symbol shipped in v2.13.0 release candidates, so this is a
source break for any C++ consumer of it, taken knowingly.
The green-context path now works, and is verified
This was previously undocumented and untested, and the docs actively disclaimed it.
One
.ptewhose graph splits across the TensorRT delegate and ExecuTorch's CUDA/AOTIdelegate, driven by a single
cuGreenCtxStreamCreatestream on an A100 with 108 SMsand a green context holding 8 of them:
against an eager reference of
0.672167. Reproduce by building the reference runnerwith
-DEXECUTORCH_BUILD_CUDA=ONand passing--green_context_sms=8.Two things had to be fixed before that was even possible.
Planned buffers ignored their declared device. A two-delegate program could not run
in the reference runner at all:
The TensorRT delegate tolerates host-backed input by staging it. The CUDA delegate
verifies the storage really is device or managed memory. The tensor handed between the
two comes from a memory-planned buffer, and the runner allocated every planned buffer
on the host. ExecuTorch already reports the device per planned buffer through
MethodMeta::memory_planned_buffer_device(), and its ownexecutor_runnerallocatesaccordingly; this follows that pattern. Before this change the same
.pteaborted, sothe new coverage is not vacuous.
The runner could not host the CUDA delegate.
EXECUTORCH_BUILD_CUDAwasFORCEdoff, so
-Don the command line was ignored. It is now overridable. The old commentgave libtorch as the reason to keep it off, which is not accurate: measured on Linux
x86_64, enabling it needs one companion option and the resulting runner links no
libtorch and no libc10.
Two other defects fixed
The end-to-end assertions accepted wrong results. Fed deliberate garbage, both
passed:
The shape was never compared, and one correct value anywhere satisfied the value
check, so a stream-ordering regression returning stale output would pass. They now
assert the shape is exactly
[2,3,4,4]and that every printed value is2.0000,verified in four directions including the garbage above.
The wheel linked a library it did not ship.
native/CMakeLists.txtlinksextension_cuda, which is a shared library upstream, so both extensions gain aDT_NEEDEDonlibextension_cuda.so. It was absent fromout_shared_libsand fromsetup.py, so importing the wheel would have failed on a missing shared object. Nowinstalled to
lib/beside the extensions, collected, and copied so the existing$ORIGINrpath resolves it. Note ExecuTorch installs it toCMAKE_INSTALL_LIBDIR,which is
lib64on some distributions, so relying on that alone would have missed it.The driver API is loaded, not linked
--green_context_smsneedscuGreenCtx*from libcuda. The release build image shipsneither libcuda nor a stub, so nothing can link it there, and the runner has both a
CMake and a Bazel build. The entry points are resolved with
dlopenwhen the flag isused, so neither build system carries a CUDA driver dependency and a machine without a
driver gets a clear error from the flag rather than a link failure for everyone.
What is verified, and what is not
Verified on an A100: both stream modes on a genuine two-delegate program, both
delegate archives referencing
getCallerStream, a single sharedlibextension_cuda.soresolved, and the runner linking no libtorch.Not verified: the device-resident asynchronous return path, so how a green context
interacts with the backend's internal completion event remains untested. The
green-context result is also hand-verified rather than in CI, because the CI
configuration builds the runner without the CUDA delegate. Both limits are stated in
the docs rather than glossed.
executorch-runtime-buildandpy-corefail onmainas well, on all ten matrixrows, so neither is caused by this change. No ExecuTorch test has run in CI on any
commit; #4525 is what fixes that.