Share the CUDA device guard through the extension so other backends can use it - #22970
Merged
Merged
Conversation
🔗 Helpful Links🧪 See artifacts and rendered test results at hud.pytorch.org/pr/pytorch/executorch/22970
Note: Links to docs will display an error until the docs builds have been completed. ❗ 1 Active SEVsThere are 1 currently active SEVs. If your PR is affected, please view them below: ⏳ No Failures, 5 PendingAs of commit 087bf0d with merge base bb2683b ( This comment was automatically generated by Dr. CI and updates every 15 minutes. |
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 21, 2026 16:31
f4f076d to
41589d7
Compare
Gasoonjia
approved these changes
Sep 21, 2026
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 21, 2026 21:13
41589d7 to
8ff9309
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 21, 2026 21:14
8ff9309 to
4c67d1c
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 21, 2026 21:33
4c67d1c to
9e75639
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 21, 2026 21:48
9e75639 to
4f4c6f7
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 21, 2026 23:31
4f4c6f7 to
7ba1311
Compare
shoumikhin
requested review from
kirklandsign and
larryliu0820
as code owners
September 21, 2026 23:31
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 21, 2026 23:41
7ba1311 to
f4b38d5
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 02:39
a155b86 to
dcfdfa5
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 03:11
dcfdfa5 to
f8e43b3
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 03:39
f8e43b3 to
7335df3
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 04:01
7335df3 to
3e337c4
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 04:01
3e337c4 to
19e3200
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 04:30
19e3200 to
a273ed9
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 04:35
a273ed9 to
6cc920f
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 04:37
6cc920f to
21103b1
Compare
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 05:32
21103b1 to
b8f964a
Compare
…an use it Selecting a CUDA device and putting the caller's back is written out by hand in two places in the CUDA backend: a struct private to one source file, and the same save and restore spelled inline in a destructor. A comment on a third copy says it follows the first. The TensorRT delegate, which lives in another repository and loads into the same process, had written a fourth. The tree already had the right guard, in a backend directory. It restores on destruction, is built through a factory returning a Result so a failure cannot be ignored by accident, logs a failed restore, neuters a moved-from copy so only one of the two restores, and carries tests. What it lacked was a home any other backend could reach. So it moves to the CUDA extension, beside the caller-stream guard, for the same reason that one is there: one program can run several backends whose engines sit on different devices, so each has to leave the device as it found it or the next inherits a selection it never made. Kernel launches follow the stream they are given, but allocation follows the current device, so a backend that allocates has to select one even when the caller chose the stream. Only the guard moves. The per-device stream helpers stay where their callers are, because the extension already answers the stream question differently and two mechanisms side by side would leave a reader guessing. Their header keeps the name in scope, which it needs anyway: its own stream guard holds one. Two details carried across rather than rewritten. The destructor decides whether to restore from what the caller asked for, not from whether the switch reported success, because a failed selection can still move the calling thread once an error is pending on the device. And a negative index is refused rather than ignored, so the one call site that can reach one asks first, the way it did before. The class is marked for export the same way the stream guard beside it is, so its symbols leave the shared library on a platform that requires saying so. The build that runs here does not hide symbols, so the omission would only have shown up for a consumer on another platform. The header reaches for no CUDA header at all, which is what lets a wheel ship it. Nothing in the interface names a CUDA type, so the bodies live in a source file beside it and only a consumer that actually uses the guard links that. A wheel check compiles every shipped header against the wheel and nothing else, deliberately without a toolkit include path, and a header that only compiles with one fails in a consumer project rather than in that check. The device index is a plain int here. The old signature used an alias from the backend's compatibility layer, and that alias was the only thing tying this guard to that backend. Test plan: Measured on a two card machine: a selection that needs a switch restores the caller's device, one that does not changes nothing, a negative index and an absent device are both refused, and moving a guard restores once rather than twice. That last one matters because a defaulted move would have restored in both scopes. The guard's tests, and the stream guard's tests beside them, had a target in one build system and none in the other, so the build a pull request runs never compiled either file. Both have a target now, and the job that runs the other CUDA runtime tests builds and runs them. Twenty nine cases that compiled nowhere now compile and run. The guard's existing tests still exercise it through the header that re-exports it, including the cases for a negative index and for move assignment being unavailable. The moved header and the file it came from both compile against the real runtime headers on Arm. The definitions are compiled into the library this directory already ships, rather than into one of their own. A separate archive would have had to be shipped and declared as its own component for a consumer to link what the header declares, and it would have needed position independent code of its own, because shared objects link it and the global setting for that covers only one build configuration. Folding it in avoids all of that. The library is only built for an accelerator, so it can call the runtime, which the stream helper beside it deliberately does not. It also takes the runtime now, because the guard logs and the logging lives there, and before this source existed that library called nothing at all. It takes it through the helper the tree provides rather than as an ordinary dependency, because the ordinary form puts the static core ahead of the shared runtime on the link line and leaves the library with its own copy of the registries. It also declares the runtime path every shipped library beside it declares. The CUDA delegate takes the runtime through the same helper. It asked for the shared runtime as an ordinary dependency, which its comment says was the intent, but that form loses to the ordering the helper exists to correct, and the delegate ended up carrying its own copy of the backend registry. It went unnoticed because a library that no longer exists used to sit on that link line and happened to order it correctly. It takes those from the shared runtime where one is built, not from the static core, because this library ships in the wheel and pulling the static core into it would give a process two platform layers. The CUDA platform library beside it chooses the same way, and privately, so that a library linking this one does not inherit the runtime and end up carrying a second copy of the registries. Every place that reaches the header is wired: a target of its own in the extension, a dependency from the guard's old target, and one from each backend target whose sources include it. The Buck graph is not built by any public job, so this was checked by reading rather than by a green tick.
shoumikhin
force-pushed
the
device-guard-extension-cuda
branch
from
September 22, 2026 06:34
b8f964a to
087bf0d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Selecting a CUDA device and putting the caller's device back is written out by hand in two places in the CUDA
backend. One is a struct inside a source file, private to that file. The other is the same save and restore
spelled inline in a destructor. A comment on a third copy says it follows the first. The TensorRT delegate,
which lives in another repository and loads into the same process, had written a fourth.
The change
This tree already had the right guard, sitting in a backend directory. It restores on destruction, it is
built through a factory that returns a result so a failure cannot be ignored by accident, it logs a failed
restore, it leaves a moved-from copy looking already restored so only one of the two puts the device back,
and it has tests. What it lacked was a home another backend could reach.
So it moves to the CUDA extension, beside the caller-stream guard, for the same reason that one is there. One
program can run several backends whose engines sit on different devices, so each has to leave the device as
it found it, or the next one inherits a selection it never made. Kernel launches follow the stream they are
given, but allocation follows the current device, so a backend that allocates has to select one even when the
caller chose the stream.
Only the guard moves. The per-device stream helpers stay where their callers are, because the extension
already answers the stream question a different way and two mechanisms side by side would leave a reader
guessing which to use. Their header keeps the name in scope, which it needs regardless, since its own stream
guard holds one.
Two details were carried across rather than rewritten. The destructor decides whether to restore from what
the caller asked for, not from whether the switch reported success, because a failed selection can still move
the calling thread once an error is pending on the device. And a negative index is refused rather than
ignored, so the one call site that can reach one asks first, exactly as it did before.
The device index is a plain int here. The old signature used an alias from a backend compatibility layer, and
that alias was the only thing tying this guard to that backend.
Testing
Measured on four machines: two with several cards each, and two with a single card, on both processor
architectures. A selection that needs a switch restores the caller's device, one that does not changes
nothing, a negative index and an absent device are both refused and leave the device alone, and moving a
guard restores once rather than twice. The single-card machines take the path where the multi-device cases
are skipped, which is the same path the new test takes there.
A new test pins the move. It takes the borrow over, lets the taker go out of scope, moves the device
somewhere a second restore would be visible, and requires the moved-from guard to leave it alone. Deleting
the one line that marks a moved-from guard as already restored turns it red, which matters because a
defaulted move would have restored in both scopes.
The moved header and the file it came from both compile against the real runtime headers on Arm.
Every place that reaches the header is wired: a target of its own in the extension, a dependency from the
guard's former target, and one from each backend target whose sources include it. No public job builds that
graph, so this part was checked by reading rather than by a green tick.
The guard's tests, and the stream guard's tests beside them, had a target in one build system and none in
the other, so the build a pull request runs never compiled either file. Both have a target now, and the job
that runs the other CUDA runtime tests builds and runs them, so twenty nine cases that compiled nowhere
now compile and run.