Skip to content

refactor(executorch)!: share one caller stream, and ship the CUDA delegate in the runtime wheel - #4454

Merged
lanluo-nvidia merged 9 commits into
pytorch:mainfrom
shoumikhin:executorch-shared-caller-stream
Aug 20, 2026
Merged

refactor(executorch)!: share one caller stream, and ship the CUDA delegate in the runtime wheel#4454
lanluo-nvidia merged 9 commits into
pytorch:mainfrom
shoumikhin:executorch-shared-caller-stream

Conversation

@shoumikhin

@shoumikhinshoumikhin commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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 a
process 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_lib this wheel ships:

if(EXECUTORCH_BUILD_CUDA)
list(APPEND _dep_libs aoti_cuda_backend) # registers CudaBackendendif()
target_link_libraries(portable_libPRIVATE${_dep_libs})

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 Python
runtime 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_NEEDED on both:

libraryroleshipped before
libextension_cuda.soowns the caller-stream thread-local every CUDA-capable delegate readsyes
libaoti_cuda_shims.sothe runtime half of the CUDA backend, called by the kernels compiled into a .pteno

Shipping only the first left a CUDA backend with half its runtime missing, which
surfaces as an ImportError on a missing shared object. Both ship now, data_loader
gets the same $ORIGIN search path _portable_lib already had so neither extension
depends on module load order to find them, and the runtime test asserts CudaBackend
is registered beside the existing TensorRTBackend and XnnpackBackend assertions.

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.so through its own DT_NEEDED.

Breaking change

torch_tensorrt::executorch_backend::CudaStreamGuard is removed from the public
header, with no deprecated alias. Callers switch to:

#include<executorch/extension/cuda/caller_stream.h>
executorch::extension::cuda::CallerStreamGuard guard(stream);
module.forward(inputs);

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 .pte whose graph splits across the TensorRT delegate and ExecuTorch's CUDA/AOTI
delegate, driven by a single cuGreenCtxStreamCreate stream on an A100 with 108 SMs
and a green context holding 8 of them:

planned buffer[0] = 16384 bytes on CPU
planned buffer[1] = 32768 bytes on device_type 1
ordinary stream exit 0 first 8 values: 0.6722 0.6722 0.6722 ...
green context with 8 SM(s) exit 0 first 8 values: 0.6722 0.6722 0.6722 ...

against an eager reference of 0.672167. Reproduce by building the reference runner
with -DEXECUTORCH_BUILD_CUDA=ON and 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:

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. 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 own executor_runner allocates
accordingly; this follows that pattern. Before this change the same .pte aborted, so
the new coverage is not vacuous.

The runner could not host the CUDA delegate.EXECUTORCH_BUILD_CUDA was FORCEd
off, so -D on the command line was ignored. It is now overridable. The old comment
gave 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:

output[0] shape=[999] numel=1 dtype=6
first 8 values: 9 9 2.0000 9 9 9 9 9
shape check: exit 0 value check: exit 0

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 is 2.0000,
verified in four directions including the garbage above.

The wheel linked a library it did not ship.native/CMakeLists.txt links
extension_cuda, which is a shared library upstream, so both extensions gain a
DT_NEEDED on libextension_cuda.so. It was absent from out_shared_libs and from
setup.py, so importing the wheel would have failed on a missing shared object. Now
installed to lib/ beside the extensions, collected, and copied so the existing
$ORIGIN rpath resolves it. Note ExecuTorch installs it to CMAKE_INSTALL_LIBDIR,
which is lib64 on some distributions, so relying on that alone would have missed it.

The driver API is loaded, not linked

--green_context_sms needs cuGreenCtx* from libcuda. The release build image ships
neither 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 dlopen when the flag is
used, 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 shared
libextension_cuda.so resolved, 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-build and py-core fail on main as well, on all ten matrix
rows, so neither is caused by this change. No ExecuTorch test has run in CI on any
commit; #4525 is what fixes that.

@github-actionsgithub-actionsBot added component: tests Issues re: Tests component: build system Issues re: Build system component: api [C++] Issues re: C++ API labels Jul 31, 2026
@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from 1832276 to bba42e2CompareJuly 31, 2026 07:57
@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

CI caught a real mistake in my previous push, now fixed.

I had removed the LD_LIBRARY_PATH setup around the Bazel test step, on the assumption
that only the caller-stream test needed TensorRT at run time. That was wrong.
test_executorch_binding_names also depends on @tensorrt//:nvinfer, so it failed to
start:

//tests/cpp/executorch:test_caller_stream PASSED
//tests/cpp/executorch:test_executorch_blob_header PASSED
//tests/cpp/executorch:test_executorch_binding_names FAILED
error while loading shared libraries: libnvinfer.so.11:
cannot open shared object file: No such file or directory

The underlying reason is that Bazel's cc_import provides lib/libnvinfer.so, but a
binary linked against it records a DT_NEEDED entry for the versioned soname
libnvinfer.so.11, which is not in the test runfiles. I reproduced this locally
against the same TensorRT SDK and got the identical message, then confirmed that
pointing LD_LIBRARY_PATH at the SDK's lib directory resolves it.

So the test step now locates the versioned library Bazel materialized and passes it
through:

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
--test_strategy=standalone is no longer needed, and it drops the
CUDA_VISIBLE_DEVICES passthrough, since none of these tests run CUDA work. The glob
only matches versioned sonames, which is what the loader actually looks for.

Worth noting from the same run: test_caller_stream passed, and the full
bazel build //:libtorchtrt completed successfully with the reworked CMake, so the
ELF check and the switch to ExecuTorch's own extension/cuda build are exercised and
working.

@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from bba42e2 to 42bf7d4CompareJuly 31, 2026 09:07
@shoumikhin

shoumikhin commented Jul 31, 2026

Copy link
Copy Markdown
ContributorAuthor

Second attempt at the test-runtime fix. My previous one was wrong in a way the logs
made obvious.

I set LD_LIBRARY_PATH to only the TensorRT directory, which replaced the value
instead of adding to it. That fixed libnvinfer.so.11 and immediately broke
libcudart.so.13, so the run went from one failing test to two:

test_executorch_blob_header PASSED
test_caller_stream FAILED <- regressed, previously passed
test_executorch_binding_names FAILED
error while loading shared libraries: libcudart.so.13

Both libraries have the same root cause. Bazel's cc_import ships the unversioned
libnvinfer.so and libcudart.so, but a binary linked against them records
DT_NEEDED entries for the versioned sonames, which are not in the test runfiles.

The step now locates the directory holding each versioned soname and appends to
LD_LIBRARY_PATH, keeping the toolchain entries that were already there:

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}}"done

I verified this locally by reproducing both failure modes rather than reasoning about
them. Copying the two libraries somewhere off the default search path and running the
loader with --inhibit-cache, so the system library cache cannot mask the problem:

no LD_LIBRARY_PATH -> libnvinfer.so.11: cannot open shared object file
TensorRT directory only -> libcudart.so.12: cannot open shared object file
both directories appended -> both libraries loaded OK

The middle line is the failure this run hit. The guard also names the specific library
if either directory cannot be found, instead of failing later with a loader error.

This is still simpler than the original version of the step: the sandbox stays enabled,
since --test_strategy=standalone is not needed, and the CUDA_VISIBLE_DEVICES
passthrough stays removed, since none of these tests run CUDA work.

@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

CI is green on the ExecuTorch gate. All three C++ tests pass:

//tests/cpp/executorch:test_caller_stream PASSED in 0.1s
//tests/cpp/executorch:test_executorch_binding_names PASSED in 0.1s
//tests/cpp/executorch:test_executorch_blob_header PASSED in 0.1s
Executed 3 out of 3 tests: 3 tests pass.

The same job also exercised the rest of this change end to end:

  • Built libextension_cuda.so through ExecuTorch's own extension/cuda CMake, which
    is the definition this change now reuses instead of re-declaring the target.
  • Verified both the CMake-built and the packaged runner carry a real DT_NEEDED entry
    for libextension_cuda.so and import the caller-stream symbols rather than defining
    private copies.
  • Ran real inference in both runners inside a CallerStreamGuard, each producing the
    expected values:
output[0] shape=[2,3,4,4] numel=96 dtype=6
first 8 values: 2.0000 2.0000 2.0000 2.0000 2.0000 2.0000 2.0000 2.0000

Remaining failures on this commit are pre-existing on main and unrelated to this
change:

CheckHereOn main
Python-only dynamo runtime, two variantsfailfail
RTX Python-only dynamo runtime, two variantsfailfail / cancelled
L0 core pythonfailflaky

The dynamo jobs stop before running any test with HTTP 403 FORBIDDEN for channel pkgs/main, a package-index problem in the job image. The L0 failure is
test_libtorchtrt_linkage.py, which fails on a ctypes open of
libnvinfer_plugin.so.11 that is missing from the runner image; this change touches no
files under tests/py.

Note that a large number of pytorch/executorch checks are also attached to this
commit SHA because it appears in that repository's CI as well. Those are not from this
pull request. Filtering by originating repository, the counts are 65 checks from
pytorch/TensorRT and 100 from pytorch/executorch.

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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that eventually enqueueV3 is happening on the caller stream, right? Is it going to be a problem @narendasan

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 571

This 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. The
enqueueV3 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 is
cudaStreamPerThread 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:

  1. the caller scoped a CallerStreamGuard, and
  2. every input is already GPU-accessible, and
  3. 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 any setInputShape or
    setTensorAddress, 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 an enqueueV3 failure. 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.

@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from 42bf7d4 to 37ea972CompareAugust 7, 2026 04:01
@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from 37ea972 to 80927c7CompareAugust 18, 2026 17:59
@lanluo-nvidialanluo-nvidia added this to the v2.14.0 milestone Aug 18, 2026
@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from 80927c7 to efb3df1CompareAugust 18, 2026 18:59
@github-actionsgithub-actionsBot added the component: api [Python] Issues re: Python API label Aug 18, 2026
@shoumikhinshoumikhin changed the title refactor(executorch): use shared caller streamrefactor(executorch)!: use ExecuTorch's shared caller stream (removes CudaStreamGuard)Aug 19, 2026
@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Pushed three commits that close the gap between what this PR enables and what it
demonstrates.

The primary use case now runs, and is verified. One .pte whose graph splits
across the TensorRT delegate and ExecuTorch's CUDA/AOTI delegate, driven by a single
cuGreenCtxStreamCreate stream, on an A100 with 108 SMs and a green context holding
8 of them:

planned buffer[0] = 16384 bytes on CPU
planned buffer[1] = 32768 bytes on device_type 1
ordinary stream exit 0 first 8 values: 0.6722 0.6722 0.6722 ...
green context with 8 SM(s) exit 0 first 8 values: 0.6722 0.6722 0.6722 ...

against an eager reference of 0.672167 for the runner's all-ones input.

Two things had to be fixed before that was possible.

  1. A two-delegate program could not run in the reference runner at all:
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. The CUDA delegate
verifies the storage really is device or managed memory. The tensor passed between
the two delegates comes from a memory-planned buffer, and the runner allocated every
planned buffer on the host. ExecuTorch already reports the device per planned buffer
via MethodMeta::memory_planned_buffer_device(), and its own executor_runner
allocates accordingly; the runner now follows that pattern. This is also the reason
the coverage is not vacuous: before the change, the same .pte aborted.

  1. EXECUTORCH_BUILD_CUDA was FORCEd off, so -D on the command line was ignored
    and only the TensorRT delegate could ever be built here. It is now overridable.

The old comment's reason for keeping it off was wrong. It cited libtorch.
Measured on Linux x86_64, enabling the CUDA delegate needs one companion option,
EXECUTORCH_BUILD_EXTENSION_TENSOR=ON, now set automatically, 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++.

Docs corrected. They previously told readers green-context streams were outside
the validated support matrix, while upstream's own header names exactly that case as
the reason the API exists. The docs now say it works, show how to reproduce it, and
keep the two real limits explicit: it is not in CI, because the CI configuration
builds the runner without the CUDA delegate, and it took the synchronized path, so
device-resident asynchronous return and its interaction with the internal completion
event are still uncovered.

Also retitled, since removing CudaStreamGuard is source-breaking for C++ consumers
rather than a plain refactor.

Note this PR's CI still cannot run its own checks until #4523 lands: the
executorch-runtime-build job has failed on every commit since it was introduced, and
executorch-runtime-test is gated on it.

@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from d1b15e2 to 6377a06CompareAugust 19, 2026 19:53
@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from 6377a06 to 28dd8deCompareAugust 20, 2026 00:28
@shoumikhinshoumikhin changed the title refactor(executorch)!: use ExecuTorch's shared caller stream (removes CudaStreamGuard)refactor(executorch)!: share one caller stream, and ship the CUDA delegate in the runtime wheelAug 20, 2026
@shoumikhin

Copy link
Copy Markdown
ContributorAuthor

Heads up that this grew since your approval, so please re-check rather than take the
existing one as covering it.

Getting ExecuTorch's shared caller-stream library requires EXECUTORCH_BUILD_CUDA=ON,
and upstream then links its CUDA backend into the same _portable_lib this wheel
ships. The wheel was carrying that backend with half its runtime missing: the
extensions have a DT_NEEDED on both libextension_cuda.so and
libaoti_cuda_shims.so, ExecuTorch's published wheel provides neither, and only the
first was being shipped. That is an ImportError waiting to happen.

Rather than back the CUDA backend out, this keeps it and ships the rest, because it
closes a real gap: ExecuTorch's published wheel registers only XnnpackBackend, so
until now no Python runtime anywhere could execute a .pte split across the TensorRT
and CUDA delegates, even though exporting one was already supported. The runtime test
now asserts CudaBackend is registered.

Also here: data_loader gets the same $ORIGIN search path _portable_lib already
had, so neither extension depends on module load order to find those libraries, and
the static C++ runtime check now covers every shared object the wheel ships instead of
only the two Python extensions.

The title and description are updated to say all of this. Happy to split the CUDA
delegate part into its own PR if you would rather review it separately.

One thing outside this PR: the ExecuTorch wheel build is currently red on main for an
unrelated reason, a libstdc++ link-order bug. #4534 fixes that, and this PR needs it
before its build can get far enough to prove any of the above.

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.
guac e2eand others added 8 commits August 20, 2026 11:28
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.
@shoumikhin
shoumikhinforce-pushed the executorch-shared-caller-stream branch from 28dd8de to 4450d73CompareAugust 20, 2026 18:30
@lanluo-nvidia
lanluo-nvidia merged commit ad5facb into pytorch:mainAug 20, 2026
30 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla signedcomponent: api [C++]Issues re: C++ APIcomponent: api [Python]Issues re: Python APIcomponent: build systemIssues re: Build systemcomponent: testsIssues re: Tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@shoumikhin@cehongwang@lanluo-nvidia