Skip to content

Commit 30fa8ec

Browse files
shoumikhinfacebook-github-bot
authored andcommitted
Run the CUDA delegate on a caller-selected CUDA stream (#20082)
Summary: Problem: The CUDA/AOTI backend always runs a model on a CUDA stream it creates for itself, so an application cannot make it run on a stream the application picked -- for example a CUDA green-context stream that confines the work to part of the GPU. Fix: Add a small thread-local handshake to the CUDA backend's stream layer in `backends/aoti/slim/cuda/guard`: `CallerStreamGuard`, an RAII scope that records (for the calling thread) the CUDA stream the backend should run on and restores the previous choice when it goes out of scope, and `getCallerStream()`, which returns that stream or nothing if no guard is active. The CUDA/AOTI backend now consults it: when a caller stream is set, `execute()` runs the kernels and the input and output copies on that stream; when none is set, it uses its own stream exactly as before, so existing behavior is unchanged. CUDA graph capture and replay is refused while a caller stream is set, because a captured graph is bound to its own stream. The handshake lives next to the existing stream registry and device guards in the same `guard` unit, so the delegate uses it without taking on a new dependency. Differential Revision: D107698747
1 parent 189ffaa commit 30fa8ec

5 files changed

Lines changed: 198 additions & 2 deletions

File tree

backends/aoti/slim/core/storage.h

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,18 @@ struct DeviceTraits<c10::DeviceType::CUDA> {
177177
static_cast<int>(dst_device.index()));
178178
}
179179

180-
ET_CUDA_CHECK(cudaMemcpy(dst, src, nbytes, direction));
180+
// Plain cudaMemcpy is host-synchronous on the default stream, which a
181+
// green context would not confine. When a caller stream is active, copy
182+
// on it asynchronously and synchronize it to preserve blocking
183+
// semantics; otherwise fall back to the plain synchronous copy.
184+
const auto caller_stream = executorch::backends::cuda::getCallerStream();
185+
if (caller_stream) {
186+
ET_CUDA_CHECK(
187+
cudaMemcpyAsync(dst, src, nbytes, direction, *caller_stream));
188+
ET_CUDA_CHECK(cudaStreamSynchronize(*caller_stream));
189+
} else {
190+
ET_CUDA_CHECK(cudaMemcpy(dst, src, nbytes, direction));
191+
}
181192
}
182193
};
183194
#else

backends/aoti/slim/cuda/guard.cpp

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,15 @@
99
#include <executorch/backends/aoti/slim/cuda/guard.h>
1010
#include <executorch/runtime/platform/log.h>
1111
#include <limits>
12+
#include <optional>
1213
#include <unordered_map>
1314

1415
namespace executorch::backends::cuda {
1516

1617
namespace {
1718
// Thread-local stream storage (private to this file)
1819
thread_local std::unordered_map<DeviceIndex, cudaStream_t> current_streams_;
20+
thread_local std::optional<cudaStream_t> caller_stream_;
1921
} // namespace
2022

2123
Error setCurrentCUDAStream(cudaStream_t stream, DeviceIndex device_index) {
@@ -52,6 +54,46 @@ Result<cudaStream_t> getCurrentCUDAStream(DeviceIndex device_index) {
5254
return stream;
5355
}
5456

57+
std::optional<cudaStream_t> peekCurrentCUDAStream(DeviceIndex device_index) {
58+
if (device_index == -1) {
59+
int tmp_device = -1;
60+
if (cudaGetDevice(&tmp_device) != cudaSuccess) {
61+
return std::nullopt;
62+
}
63+
device_index = static_cast<DeviceIndex>(tmp_device);
64+
}
65+
66+
auto it = current_streams_.find(device_index);
67+
if (it == current_streams_.end()) {
68+
return std::nullopt;
69+
}
70+
return it->second;
71+
}
72+
73+
void clearCurrentCUDAStream(DeviceIndex device_index) {
74+
if (device_index == -1) {
75+
int tmp_device = -1;
76+
if (cudaGetDevice(&tmp_device) != cudaSuccess) {
77+
return;
78+
}
79+
device_index = static_cast<DeviceIndex>(tmp_device);
80+
}
81+
current_streams_.erase(device_index);
82+
}
83+
84+
std::optional<cudaStream_t> getCallerStream() {
85+
return caller_stream_;
86+
}
87+
88+
CallerStreamGuard::CallerStreamGuard(cudaStream_t stream)
89+
: previous_(caller_stream_) {
90+
caller_stream_ = stream;
91+
}
92+
93+
CallerStreamGuard::~CallerStreamGuard() {
94+
caller_stream_ = previous_;
95+
}
96+
5597
CUDAGuard::CUDAGuard(CUDAGuard&& other) noexcept
5698
: original_device_index_(other.original_device_index_),
5799
current_device_index_(other.current_device_index_) {

backends/aoti/slim/cuda/guard.h

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
#pragma once
1010

1111
#include <cuda_runtime.h>
12+
#include <optional>
13+
1214
#include <executorch/backends/aoti/slim/c10/core/Device.h>
1315
#include <executorch/backends/aoti/slim/c10/cuda/Exception.h>
1416
#include <executorch/runtime/core/error.h>
@@ -43,6 +45,54 @@ Error setCurrentCUDAStream(cudaStream_t stream, DeviceIndex device_index = -1);
4345
*/
4446
Result<cudaStream_t> getCurrentCUDAStream(DeviceIndex device_index = -1);
4547

48+
/**
49+
* The CUDA stream registered for the specified device, or std::nullopt if none
50+
* is set. Unlike getCurrentCUDAStream, it never creates one, so it can snapshot
51+
* the current selection without side effects. Also returns std::nullopt if the
52+
* current device cannot be queried (device_index -1), so nullopt does not
53+
* distinguish "no stream set" from "device query failed".
54+
*
55+
* @param device_index The device index (-1 to use current device)
56+
*/
57+
std::optional<cudaStream_t> peekCurrentCUDAStream(
58+
DeviceIndex device_index = -1);
59+
60+
/**
61+
* Clears any CUDA stream registered for the specified device, restoring the
62+
* "no stream selected" state. Best-effort: if device_index is -1 and the
63+
* current device cannot be queried, it silently does nothing.
64+
*
65+
* @param device_index The device index (-1 to use current device)
66+
*/
67+
void clearCurrentCUDAStream(DeviceIndex device_index = -1);
68+
69+
/**
70+
* The CUDA stream the caller selected for this thread (via CallerStreamGuard),
71+
* or std::nullopt if none. The CUDA backend runs on it when set, otherwise it
72+
* uses its own stream. Kept separate from getCurrentCUDAStream so an explicit
73+
* caller choice is distinguishable from a lazily-created stream.
74+
*/
75+
std::optional<cudaStream_t> getCallerStream();
76+
77+
/**
78+
* Scopes the CUDA stream the backend should run on for the calling thread, and
79+
* restores the previous selection on destruction. One value per thread; a
80+
* cuGreenCtxStreamCreate stream confines work to that green context's SM
81+
* partition.
82+
*/
83+
class CallerStreamGuard {
84+
public:
85+
explicit CallerStreamGuard(cudaStream_t stream);
86+
~CallerStreamGuard();
87+
CallerStreamGuard(const CallerStreamGuard&) = delete;
88+
CallerStreamGuard& operator=(const CallerStreamGuard&) = delete;
89+
CallerStreamGuard(CallerStreamGuard&&) = delete;
90+
CallerStreamGuard& operator=(CallerStreamGuard&&) = delete;
91+
92+
private:
93+
std::optional<cudaStream_t> previous_;
94+
};
95+
4696
/**
4797
* RAII guard that sets the current CUDA device and restores it on destruction.
4898
* This ensures that the device is properly restored even if an exception

backends/aoti/slim/cuda/test/test_cuda_stream_guard.cpp

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
#include <executorch/runtime/platform/platform.h>
1212
#include <gtest/gtest.h>
1313

14+
#include <type_traits>
15+
1416
using namespace executorch::backends::cuda;
1517
using namespace executorch::runtime;
1618

@@ -265,3 +267,60 @@ TEST_F(CUDAStreamGuardTest, NullStreamPointer) {
265267
auto current_stream_result = getCurrentCUDAStream(0);
266268
ASSERT_TRUE(current_stream_result.ok());
267269
}
270+
271+
// CallerStreamGuard / getCallerStream select the backend's stream through pure
272+
// thread-local state and never touch a device. They still need the CUDA headers
273+
// to build, but no CUDA device at runtime, so they run outside the device-gated
274+
// fixture above using opaque (fake) stream values.
275+
namespace {
276+
// Opaque, distinct, never-dereferenced stream handles; using object addresses
277+
// avoids an int-to-pointer cast.
278+
cudaStream_t fake_stream(int index) {
279+
static char storage[3];
280+
return reinterpret_cast<cudaStream_t>(&storage[index]);
281+
}
282+
} // namespace
283+
284+
TEST(CallerStreamGuardTest, NoGuardReportsNullopt) {
285+
EXPECT_FALSE(getCallerStream().has_value());
286+
}
287+
288+
TEST(CallerStreamGuardTest, GuardSelectsThenRestores) {
289+
const cudaStream_t selected = fake_stream(0);
290+
{
291+
CallerStreamGuard guard(selected);
292+
EXPECT_EQ(getCallerStream(), selected);
293+
}
294+
EXPECT_FALSE(getCallerStream().has_value());
295+
}
296+
297+
TEST(CallerStreamGuardTest, NestedGuardsRestoreOuter) {
298+
const cudaStream_t outer = fake_stream(1);
299+
const cudaStream_t inner = fake_stream(2);
300+
CallerStreamGuard outer_guard(outer);
301+
{
302+
CallerStreamGuard inner_guard(inner);
303+
EXPECT_EQ(getCallerStream(), inner);
304+
}
305+
EXPECT_EQ(getCallerStream(), outer);
306+
}
307+
308+
TEST(CallerStreamGuardCompileTimeTest, NotCopyable) {
309+
static_assert(
310+
!std::is_copy_constructible_v<CallerStreamGuard>,
311+
"CallerStreamGuard should not be copy constructible");
312+
static_assert(
313+
!std::is_copy_assignable_v<CallerStreamGuard>,
314+
"CallerStreamGuard should not be copy assignable");
315+
}
316+
317+
TEST(CUDAStreamRegistryTest, PeekDoesNotCreateAndClearResets) {
318+
// An explicit index skips the cudaGetDevice path, so this needs no device;
319+
// use an index no other test touches.
320+
constexpr DeviceIndex kIdx = 5;
321+
EXPECT_FALSE(peekCurrentCUDAStream(kIdx).has_value());
322+
ASSERT_EQ(setCurrentCUDAStream(fake_stream(0), kIdx), Error::Ok);
323+
EXPECT_EQ(peekCurrentCUDAStream(kIdx), fake_stream(0));
324+
clearCurrentCUDAStream(kIdx);
325+
EXPECT_FALSE(peekCurrentCUDAStream(kIdx).has_value());
326+
}

backends/cuda/runtime/cuda_backend.cpp

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <filesystem>
2323
#include <fstream>
2424
#include <mutex>
25+
#include <optional>
2526
#include <string>
2627
#include <string_view>
2728
#include <unordered_map>
@@ -32,6 +33,7 @@
3233
#include <executorch/backends/aoti/slim/c10/cuda/Exception.h>
3334
#include <executorch/backends/aoti/slim/core/slim_tensor.h>
3435
#include <executorch/backends/aoti/slim/core/storage.h>
36+
#include <executorch/backends/aoti/slim/cuda/guard.h>
3537
#include <executorch/backends/aoti/slim/factory/empty.h>
3638
#include <executorch/backends/aoti/slim/factory/from_blob.h>
3739
#include <executorch/backends/aoti/slim/factory/from_etensor.h>
@@ -482,7 +484,39 @@ class ET_EXPERIMENTAL CudaBackend final
482484
size_t n_outputs;
483485
handle->get_num_outputs(handle->container_handle, &n_outputs);
484486

485-
setCurrentCUDAStream(handle->get_cuda_stream(), 0);
487+
// Run on the caller-selected stream when one is active on this thread (e.g.
488+
// a CUDA green-context stream), otherwise the handle's own stream. Every
489+
// kernel and boundary copy reads getCurrentCUDAStream, so installing the
490+
// choice here routes the whole execution; restore the prior selection on
491+
// return so a caller stream does not linger for later work on this thread.
492+
const std::optional<cudaStream_t> caller_stream =
493+
executorch::backends::cuda::getCallerStream();
494+
495+
// A captured CUDA graph is bound to its capture stream and cannot be safely
496+
// replayed on a different, caller-provided stream.
497+
ET_CHECK_OR_RETURN_ERROR(
498+
!(caller_stream &&
499+
handle->cuda_graph_state.phase != CudaGraphPhase::Disabled),
500+
NotSupported,
501+
"CUDA graph is not supported together with a caller-provided CUDA stream.");
502+
503+
// Snapshot the prior selection without creating one (peek, not get), so the
504+
// restore is exact and we don't leak a stream just to snapshot.
505+
std::optional<cudaStream_t> prev_stream;
506+
if (caller_stream) {
507+
prev_stream = peekCurrentCUDAStream(0);
508+
}
509+
setCurrentCUDAStream(caller_stream.value_or(handle->get_cuda_stream()), 0);
510+
executorch::backends::aoti::ScopeGuard restore_stream([&]() noexcept {
511+
if (!caller_stream) {
512+
return;
513+
}
514+
if (prev_stream) {
515+
setCurrentCUDAStream(*prev_stream, 0);
516+
} else {
517+
clearCurrentCUDAStream(0);
518+
}
519+
});
486520

487521
size_t n_io_sum = 0;
488522
ET_CHECK_OR_RETURN_ERROR(

0 commit comments

Comments
 (0)