Skip to content

Python runtime: enforce CUDA stream selection rules on the stream itself - #24346

Open
pytorchbot wants to merge 5 commits into
release/1.6from
cherry-pick-24327-by-pytorch_bot_bot_
Open

pytorchbot wants to merge 5 commits into
release/1.6from
cherry-pick-24327-by-pytorch_bot_bot_

Conversation

@pytorchbot

Copy link
Copy Markdown
Collaborator

What is wrong today

executorch.runtime.cuda (added in #24273) lets Python code choose the CUDA stream that the CUDA and TensorRT delegates run on. Some rules were checked on what a wrapper says, not on the stream, and some docs promised too much:

  • A stream on GPU 1, given as cuda.ExternalStream(ptr) or torch.cuda.ExternalStream(ptr), was accepted, then failed in the delegate.
  • cudaStreamLegacy (handle 1) got past the refusal of the legacy default stream (handle 0).
  • The docs said a closed stream cannot be selected, a dropped scope object ends its block, and a call inside a block waits only for its stream. None of these always holds.

What this change does

  • In CUDA 13 builds, the native scope asks CUDA for the stream's device when a block starts (cudaStreamGetDevice) and refuses any device but 0. Every CUDA wheel built from main is CUDA 13. CUDA 12 builds keep only the wrapper check, since they can load a runtime older than 12.8, which lacks that function. On a stream that is capturing a CUDA graph, the query is skipped: CUDA refuses it there, and the refusal ends the caller's capture.
  • The native scope refuses handles 0 and 1, None, an exit from another thread, and a second entry. After refusing an exit out of order, it can be entered again once its block has ended.
  • _CallerStreamScope.can_check_device is a build-time attribute, not a CUDA call.
  • The docs say what is enforced, which waits a block shortens, and which steps still wait longer (outputs whose layout changes, loading CUDA weights).
  • New tests: a block inside torch.cuda.graph() leaves the graph intact, a refused native scope can be entered again, and CUDA 13 CI checks that the device query is built in.
  • unittest-cuda no longer starts for a change to runtime/cuda.py alone; nothing it runs imports it.

Tests

On Linux with A100 GPUs, bindings built with CUDA 13.2:

  • test_pybindings_cuda_dlpack.py passes in both output modes, with a second GPU so the device test runs.
  • Each new check fails without its fix: capture check removed, refused exit keeping its mark, device query compiled out, and the bindings from before these changes.
  • A library built against CUDA 12.8 that calls cudaStreamGetDevice fails to load next to the CUDA 12.6 runtime.

Not run on Windows, ROCm, or a real CUDA 12 build.

…rRT delegates (#24273)

## What is wrong today

A C++ program can choose the CUDA stream (a queue of GPU work) that the
CUDA and TensorRT delegates run on, for example a green-context stream
that keeps work on part of the GPU. Python code cannot: each delegate
uses its own stream.

## What this change does

This adds `executorch.runtime.cuda`, an experimental module that does
not need PyTorch:

```python
from executorch.runtime import cuda

s = cuda.Stream()
with cuda.stream(s):
    outputs = first.execute(inputs)  # a TensorRT delegate
    second.execute(outputs)          # a CUDA delegate in another program
s.close()
```

- `cuda.Stream()` creates a stream that `close()` destroys.
`cuda.ExternalStream(stream_ptr)` wraps one someone else owns. Any
object with `__cuda_stream__` works too, including `torch.cuda.Stream`.
- `cuda.stream(s)` selects `s` for the current thread. Blocks end on
their own thread, innermost first.
- Inside a block, host copies run on `s` and a call waits only for `s`.
Calls with GPU inputs or outputs still wait for the whole GPU.
- Limits for now: only CUDA device 0, no default stream (handle 0), no
CUDA graphs inside a block, and no `await` or `yield` inside a block. A
stream known to be on another device is refused.

## Tests

On Linux with an A100 GPU:

- Timing events show a CUDA program's kernels land on `s` inside the
block, and not outside it.
- Misuse raises: a plain integer, a stream on another device, the
default stream, closing a selected stream (also through another
wrapper), an exit out of order, an extra exit, a copy, and a closed
stream.
- Two threads share one `cuda.stream(s)` object, and an exit from
another thread is refused.
- A process that never imports PyTorch (with
`EXECUTORCH_PYBINDINGS_TENSOR_OUTPUT=executorch`) runs two
CUDA-delegated programs inside one block, and the delegates see the
selected stream.

(cherry picked from commit fb34f59)
…elf (#24327)

## What is wrong today

`executorch.runtime.cuda` (added in #24273) lets Python code choose the
CUDA stream that the CUDA and TensorRT delegates run on. Some rules were
checked on what a wrapper says, not on the stream, and some docs
promised too much:

- A stream on GPU 1, given as `cuda.ExternalStream(ptr)` or
`torch.cuda.ExternalStream(ptr)`, was accepted, then failed in the
delegate.
- `cudaStreamLegacy` (handle 1) got past the refusal of the legacy
default stream (handle 0).
- The docs said a closed stream cannot be selected, a dropped scope
object ends its block, and a call inside a block waits only for its
stream. None of these always holds.

## What this change does

- In CUDA 13 builds, the native scope asks CUDA for the stream's device
when a block starts (`cudaStreamGetDevice`) and refuses any device but
0. Every CUDA wheel built from main is CUDA 13. CUDA 12 builds keep only
the wrapper check, since they can load a runtime older than 12.8, which
lacks that function. On a stream that is capturing a CUDA graph, the
query is skipped: CUDA refuses it there, and the refusal ends the
caller's capture.
- The native scope refuses handles 0 and 1, `None`, an exit from another
thread, and a second entry. After refusing an exit out of order, it can
be entered again once its block has ended.
- `_CallerStreamScope.can_check_device` is a build-time attribute, not a
CUDA call.
- The docs say what is enforced, which waits a block shortens, and which
steps still wait longer (outputs whose layout changes, loading CUDA
weights).
- New tests: a block inside `torch.cuda.graph()` leaves the graph
intact, a refused native scope can be entered again, and CUDA 13 CI
checks that the device query is built in.
- `unittest-cuda` no longer starts for a change to `runtime/cuda.py`
alone; nothing it runs imports it.

## Tests

On Linux with A100 GPUs, bindings built with CUDA 13.2:

- `test_pybindings_cuda_dlpack.py` passes in both output modes, with a
second GPU so the device test runs.
- Each new check fails without its fix: capture check removed, refused
exit keeping its mark, device query compiled out, and the bindings from
before these changes.
- A library built against CUDA 12.8 that calls `cudaStreamGetDevice`
fails to load next to the CUDA 12.6 runtime.

Not run on Windows, ROCm, or a real CUDA 12 build.

(cherry picked from commit c030572)
@pytorch-bot

pytorch-bot Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

🔗 Helpful Links

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

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

✅ No Failures

As of commit e3e0eb8 with merge base 4db778e (image):
💚 Looks good so far! There are no failures yet. 💚

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 Oct 30, 2026
@pytorchbot
pytorchbot had a problem deploying to upload-benchmark-results October 30, 2026 19:04 — with GitHub Actions Failure
Base automatically changed from cherry-pick-24273-by-pytorch_bot_bot_ to release/1.6 October 30, 2026 21:49
release/1.6 now has the #24273 pick (#24323), squashed. The tree is release/1.6 plus the #24327 change.
@shoumikhin

Copy link
Copy Markdown
Contributor

@claude /pr-review

@claude

claude Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Claude finished @shoumikhin's task in 26m 19s —— View job


Review progress

  • Gather context (diff against origin/release/1.6, CUDA delegate callers, CI wiring)
  • Three independent reviews
  • Consolidate findings
  • Fact-check (5 kept)
  • Post review

Approve: nothing blocks this cherry-pick. There is one minor regression: the private native _CallerStreamScope can no longer be used after its entering thread ends. There are also four test gaps, which can be fixed in follow-ups on main.

Detailed review

PR Review: #24346

Summary

This cherry-picks #24327 to release/1.6. The native _CallerStreamScope now refuses:

  • the legacy default stream (handles 0 and 1)
  • a foreign stream that CUDA reports on a device other than 0 (CUDA 13 builds only)
  • a second entry
  • an exit from another thread

Docs and tests are updated to match. There are no blockers, and the issues below are minor.

Thread Safety

  • A native scope cannot be used again after its entering thread ends (extension/pybindings/pybindings.cpp:1853-1856, :1883-1886).
    • What happens: when the thread ends, ~CallerStreamScopes (:1803-1810) ends its blocks, but the PyCallerStreamScope keeps id_ and home_. From then on, __enter__ raises "was entered and has not exited" even though the block has ended. __exit__ raises "entered on another thread". It succeeds only if a later thread happens to get the dead thread's TLS address.
    • Before this PR, the object could be entered again.
    • How to hit it: a worker thread calls s.__enter__() on a _CallerStreamScope and returns without exiting. The main thread then calls s.__enter__().
    • Who is affected: _StreamScope builds a fresh native scope for each entry, so the public API is fine. However, the new test at test_pybindings_cuda_dlpack.py:525 treats direct use of the native scope as supported.
    • Fix: identify the home thread by a token that is never reused, and treat an expired token as an ended block. For example, CallerStreamScopes could own a std::shared_ptr that the scope holds as a std::weak_ptr. Fix this →

Testing

  • CI never runs the device refusal this PR adds (pybindings.cpp:1858).
    • test_stream_on_another_device_is_refused skips with fewer than two GPUs (test_pybindings_cuda_dlpack.py:374-375), and test-cuda-builds runs on a single A10G.
    • So CI covers only the accept path (:282-284) and the can_check_device assertion (:365-373).
    • That assertion reads kCanCheckDevice (pybindings.cpp:1844-1849). kCanCheckDevice has its own copy of the #if that guards the real query (:1898), so the two can drift apart without CI noticing.
    • Fix: define the condition once and use it in both places. As a follow-up on main, run this test on a runner with two GPUs. Fix this →
  • _assert_no_stream_selected can pass without checking anything (test_pybindings_cuda_dlpack.py:159-170).
    • It relies on graph mode refusing a caller stream (cuda_backend.cpp:664-668).
    • On a device without memory pools, graph mode never starts (cuda_backend.cpp:92-99, :614-630). The helper then passes even while a stream is still selected.
    • Fix: the torch-free child script in this diff asserts that the refusal really happens. Do the same here: before the unguarded call, assert that the call is refused inside a fresh cuda.stream() block. Fix this →
  • The native scopes at test_pybindings_cuda_dlpack.py:552-561 have no cleanup.
    • outer and inner are entered without addCleanup or try/finally, unlike :331-338 and :531-549 in the same file.
    • If an assertion fails partway through, the stream stays selected on the main thread for the rest of the class. Fix this →
  • Low confidence: graph capture is tested only with the capturing stream selected (test_pybindings_cuda_dlpack.py:393-405).
    • stream_device skips the CUDA query only when the selected stream is capturing (pybindings.cpp:1901-1905).
    • No test enters a different, non-capturing stream while a torch.cuda.graph() capture is open. That capture uses global mode by default.
    • Possible failure: CUDA may treat cudaStreamGetDevice as an unsafe call during a global-mode capture. If it does, entering such a stream would fail and would also invalidate the caller's capture.
    • Fix: add a case that enters a torch.cuda.Stream() created before the capture. If it fails, run the query under cudaThreadExchangeStreamCaptureMode(cudaStreamCaptureModeRelaxed). Fix this →

Recommendation

Approve

Nothing blocks this cherry-pick. The Thread Safety item is a minor regression, and only direct users of a private class are affected. The test items can land on main and be cherry-picked later.

The runtime.cuda docstring and the pybindings README said that loading a method's CUDA weights waits for the whole GPU. Only weights an older export keeps in one blob do that (cudaDeviceSynchronize). Current exports copy each weight with a plain cudaMemcpy, which waits for the legacy default stream and blocking streams but not for a non-blocking stream. Measured with a 2 s kernel on a non-blocking stream: load_method took 0.01 s. Docs only.

This branch was successfully deployed

1 active and 1 inactive deployments
upload-benchmark-results — e3e0eb85 Deployed Nov 1, 2026 by shoumikhin via upload-benchmark-results #21690
cadence — e3e0eb85 Deployed Oct 31, 2026 by shoumikhin via hifi-op-test / hifi4 #35783
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.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants