Skip to content

Run CUDA delegates on the per-thread stream - #22318

Open
shoumikhin wants to merge 1 commit into
pytorch:mainfrom
shoumikhin:fix/cuda-delegate-per-thread-stream
Open

Run CUDA delegates on the per-thread stream#22318
shoumikhin wants to merge 1 commit into
pytorch:mainfrom
shoumikhin:fix/cuda-delegate-per-thread-stream

Conversation

@shoumikhin

@shoumikhin shoumikhin commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Each delegate handle created its own CUDA stream. Delegates in one program run one after another, so a stream apiece bought no concurrency, and it meant nothing ordered one delegate's work against the next. A delegate could read an input while the delegate that produces it still had work queued on a different stream.

A program that is entirely one backend does not notice, because the stream is the same throughout. A program split across the CUDA and TensorRT backends does: the TensorRT delegate runs on the per-thread stream when no caller stream is installed, so the two backends were on different streams with nothing between them. Such a program returned a different answer on almost every call. Executing one loaded program forty times produced nine distinct output sums, one of them correct.

Using the per-thread stream for every handle makes the ordering the graph expresses the ordering the device sees, for a handoff in either direction and between two CUDA delegates. It is what the TensorRT delegate already uses, so no event handshake is needed to bridge the two. The stream is a sentinel rather than something this code owns, so the holder is released without destroying it.

Callers that want a stream of their own still have both existing mechanisms: the use_shared_cuda_stream option, and a caller stream, which continues to take precedence.

What it is, and what it is not

compute-sanitizer reports no invalid access and no uninitialized read, so this is not memory corruption. It is an ordering violation, and the program is correct under CUDA_LAUNCH_BLOCKING=1, which changes only ordering. A full cudaStreamSynchronize at the end of execute() also fixes it but costs 1.65x on a split program, because it stalls the calling thread once per delegate. Sharing the stream costs nothing per execute.

Test plan

Added backends/cuda/tests/test_coalesced_determinism.py. It exports a program split across both backends, checks the split really happened, runs it a hundred times on one loaded program, and requires every result to equal the first exactly. Equality rather than a tolerance, because a delegate reading early returns a different answer, not a nearby one.

That test passes with this change and fails without it, at the second of a hundred runs.

Measured on Linux aarch64, sm_110, on a program of twenty-five delegates:

  • before, thirty-nine of forty runs were wrong
  • after, seven thousand six hundred consecutive runs were correct, across several processes
  • programs of a single delegate on either backend were already correct and stayed so over five hundred runs each
  • median latency for the twenty-five delegate program went from about 850 to about 940 microseconds, which is the cost of ordering that was previously skipped
  • a single-delegate program is unchanged

The three C++ test binaries for this backend pass, twenty-nine tests. The Python suite for this backend has eighteen failures on this machine both with and without this change, so it introduces no regressions. Those failures are an unrelated gap: the delegate cannot lower a matmul or a convolution on this architecture, because it restricts autotuning to Triton and Triton has no template for it there.

CUDA graph paths

Those two exits from execute() are gated on an option only reachable from C++, so a small harness drove them directly, linking both delegates with --no-as-needed so each registers. With capture and replay enabled, forty runs of the same coalesced program agreed with each other and with the same program run without them. So the paths execute and are deterministic under this change.

That harness does not validate numerics, only agreement, so treat it as evidence the paths still work rather than evidence they are numerically right. Someone with the export-side setup for those paths should confirm the values.

Not covered

x86, where the reordering this fixes does not reproduce. The change should be neutral there, but I have not measured it.

@pytorch-bot

pytorch-bot Bot commented Aug 30, 2026

Copy link
Copy Markdown

🔗 Helpful Links

🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22318

Note: Links to docs will display an error until the docs builds have been completed.

✅ You can merge normally! (1 Unrelated Failure)

As of commit 95b8741 with merge base c27baa8 (image):

BROKEN TRUNK - The following job failed but were present on the merge base:

👉 Rebase onto the `viable/strict` branch to avoid these failures

This comment was automatically generated by Dr. CI and updates every 15 minutes.

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Aug 30, 2026
@linux-foundation-easycla

linux-foundation-easycla Bot commented Aug 30, 2026

Copy link
Copy Markdown

CLA Missing ID

  • ❌ The email address for the commit (95b8741) is not linked to the GitHub account, preventing the EasyCLA check. Consult this Help Article and GitHub Help to resolve. (To view the commit's email address, add .patch at the end of this PR page's URL.) For further assistance with EasyCLA, please visit our EasyCLA portal and chat with our support bot.

@shoumikhin
shoumikhin force-pushed the fix/cuda-delegate-per-thread-stream branch 3 times, most recently from 7a149ef to 315df2c Compare August 30, 2026 18:03
@shoumikhin shoumikhin added the release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs label Aug 30, 2026
Each delegate handle created its own CUDA stream. Delegates in one program run
one after another, so a stream apiece bought no ordering between them: a delegate
could read an input while the delegate that produces it still had work queued on
a different stream.

A program that is entirely one backend does not notice, because the stream is the
same throughout. A program split across the CUDA and TensorRT backends does. The
TensorRT delegate runs on the per-thread stream when no caller stream is
installed, observed on torch-tensorrt at the time of writing, so the two backends
sat on different streams with nothing between them. Such a program returned a
different answer on almost every call: executing one loaded program forty times
produced nine distinct output sums, one of them correct.

Every handle now takes the per-thread stream. That makes the ordering the graph
expresses the ordering the device sees, for a handoff in either direction and
between two CUDA delegates, and it needs no event handshake because both backends
end up on the same stream. The stream is a sentinel the runtime owns rather than
something a handle creates, so releasing a handle frees only the holder.

Ordering consecutive methods against each other is what use_shared_cuda_stream
was added to do, and the per-thread stream now does it for every method, so that
option would only create a stream nothing uses. It is accepted and ignored, with
a log line saying so.

Two behaviour changes worth knowing about, both consequences of one stream rather
than many:

Two threads calling into one loaded method are no longer ordered against each
other by the handle's stream, because the per-thread stream is a different stream
on each thread. Before, they shared the handle's one created stream and were
ordered without anyone asking. A caller driving one method from a pool, and
keeping data on the device between calls, now has to order those calls itself.

Two independent programs submitted from one host thread with no synchronization
between them now run one after the other rather than overlapping, since they
share a stream. Measured at about 2x on two equal single block kernels. Callers
who want that overlap can install a caller stream per program, which still takes
precedence over everything here.

Test plan:

Added backends/cuda/runtime/test/test_cuda_delegate_stream.cpp. It checks that
two handles resolve to the same per-thread stream, which is the ordering
guarantee, and that releasing a handle leaves that stream usable, which is what
the sentinel requires. It needs no TensorRT, so it runs wherever the other C++
tests for this backend run. Both tests pass.

backends/cuda/tests/test_coalesced_determinism.py exports a program split across
both backends, checks the split happened, runs it a hundred times on one loaded
program, and requires every result to equal the first exactly. It passes with
this change and fails without it at the second run. It skips where the TensorRT
delegate is not installed, which includes CI, so it is a local check rather than
a gate.

Measured on Linux aarch64, sm_110, on a program of twenty-five delegates: before,
thirty-nine of forty runs were wrong; after, seven thousand six hundred
consecutive runs were correct across several processes. Programs of a single
delegate on either backend were already correct and stayed so over five hundred
runs each. Median latency for that split program went from about 850 to about 940
microseconds, which is the ordering that was previously skipped. A single
delegate program is unchanged.

The three existing C++ test binaries for this backend pass, twenty-nine tests.
The Python suite for this backend has eighteen failures on this machine both with
and without this change, so it introduces no regressions; those are an unrelated
gap in matmul and convolution lowering on this architecture.

cudaStreamPerThread has no alias in the HIP compatibility header, so this adds
one next to the other stream names, or the ROCm build of this backend would not
compile.

Not measured: x86, where the reordering this fixes does not reproduce, and the
CUDA graph capture and replay paths beyond confirming that a program using them
runs and agrees with itself over forty runs.
@shoumikhin
shoumikhin force-pushed the fix/cuda-delegate-per-thread-stream branch from 315df2c to 95b8741 Compare August 30, 2026 19:27
@shoumikhin
shoumikhin marked this pull request as ready for review August 30, 2026 21:08
Copilot AI lite review requested due to automatic review settings August 30, 2026 21:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. release notes: runtime Changes related to the core runtime which loads the program methods, initializes delegates, and runs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants