Skip to content

Commit e2a2269

Browse files
authored
CUDA backend: one current stream on Windows, shim copies during another thread's graph capture, and low-memory mode moves weights off the GPU while autotuning (#24597)
### What is wrong today **Windows stream state.** The CUDA delegate and its shims share a per-thread "current stream" map. In the default Windows build (`EXECUTORCH_BUILD_SHARED` off) it exists twice, in `aoti_cuda_shims.dll` (not exported) and in the program. The shims read the empty copy and make their own stream, so they race with generated kernels. On every platform, a thread with no stream selected also leaked a stream. **Copies during another thread's graph capture.** With no caller stream, a shim copy used `cudaMemcpy` on the legacy stream. CUDA rejects that during a capture on a blocking stream like the delegate's, so the process aborted. Reading back a strided output also failed. **Export memory.** AOTInductor's final autotuning pass keeps the real weights on the GPU, unused. A video policy needing about 60 GiB ran out of memory on an 80 GB GPU. ### What this change does - Move the current-stream map into the shared library `extension_cuda`, in an internal namespace. With no stream selected, use `cudaStreamPerThread`, the stream the delegate runs on. - Copy with `cudaMemcpyAsync` on the current stream, and read strided outputs back on the delegate's stream. - Still on the legacy stream, among others: the allocator's copy and `synchronize()`, mutable-state and load-time copies, and the capture step's seed copies. - In the existing `low_memory_mode`, move the weights to a file in PyTorch's cache directory during that pass. It needs PyTorch 2.15 or a nightly from 2026-09-24 or later; with older PyTorch the mode works as before. It needs free space there, and PyTorch skips the move when the last generated kernel runs on the CPU. ### Tested - A100: the CUDA runtime and shim tests, and most CUDA Python tests (PyTorch 2.14), pass. The new per-thread, strided read-back and cross-thread copy tests fail without their fix. - With the Windows layout emulated on Linux, `test_cuda_current_stream` fails before the change and passes after. CI's Windows CUDA runtime job runs it in that layout, but does not build the shim tests, so `RefusedInsideGraphCapture` and `CopiesWhileAnotherThreadCaptures` run only on Linux. Windows was not built locally. - A100 with PyTorch 2.15: the low-memory tests pass and fail without the change. In low-memory mode, a model with 512 MiB of weights peaks at about 0.66 GiB of GPU memory instead of 1.16 GiB and matches eager. With PyTorch 2.14, nothing changes. - Pending: exporting the video policy in low-memory mode.
1 parent dadc829 commit e2a2269

23 files changed

Lines changed: 531 additions & 173 deletions

‎.ci/scripts/test_cuda_runtime_windows.ps1‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ $tests = @(
3535
"test_cuda_weight_cache",
3636
"test_cuda_output_copy",
3737
"test_cuda_sort_rand",
38+
"test_cuda_current_stream",
3839
"test_cuda_guard",
3940
"test_cuda_stream_guard"
4041
)

‎.ci/scripts/wheel/test_shared_libraries.py‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,14 @@
8181
_CUDA_BACKEND_SYMBOLS = ("executorch::backends::cuda::load_library",)
8282
_CUDA_EXTENSION_SYMBOLS = (
8383
"executorch::extension::cuda::getCallerStream",
84+
"executorch::extension::cuda::internal::getCurrentCUDAStream",
8485
"executorch::extension::cuda::CudaAllocator::instance",
8586
)
8687

87-
# The AOTI shim layer and the stream-guard state that lives with it. This is the state that was
88-
# genuinely duplicated: extracting the shims with a PUBLIC whole-archive replayed the extraction at
89-
# every consumer's link, so the guard's thread_local and these shims landed in three shipped binaries
90-
# at once, and a stream selected through one copy was invisible to the other two. The row above cannot
91-
# catch that, because its symbol only ever existed in one unconditionally shared library.
88+
# The AOTI shim layer. This is what was once genuinely duplicated: extracting the shims with a PUBLIC
89+
# whole-archive replayed the extraction at every consumer's link, so these shims, and the current-stream
90+
# thread_local that then lived with them, landed in three shipped binaries at once, and a stream selected
91+
# through one copy was invisible to the other two. That state now lives in the extension library above.
9292
_AOTI_SHIM_SYMBOLS = (
9393
"aoti_torch_empty_strided",
9494
"aoti_torch_delete_tensor_object",

‎.github/workflows/cuda.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -782,12 +782,13 @@ jobs:
782782
-v -o "addopts="
783783
784784
cmake --preset llm-release-cuda -DEXECUTORCH_BUILD_TESTS=ON
785-
cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_output_copy test_cuda_sort_rand test_cuda_kv_cache test_cuda_guard test_cuda_stream_guard test_step_plan test_cuda_executor -j$(nproc)
785+
cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_output_copy test_cuda_sort_rand test_cuda_current_stream test_cuda_kv_cache test_cuda_guard test_cuda_stream_guard test_step_plan test_cuda_executor -j$(nproc)
786786
ctest --test-dir cmake-out -R test_cuda_allocator --output-on-failure -V
787787
ctest --test-dir cmake-out -R test_cuda_mutable_state --output-on-failure -V
788788
ctest --test-dir cmake-out -R test_cuda_weight_cache --output-on-failure -V
789789
ctest --test-dir cmake-out -R test_cuda_output_copy --output-on-failure -V
790790
ctest --test-dir cmake-out -R test_cuda_sort_rand --output-on-failure -V
791+
ctest --test-dir cmake-out -R test_cuda_current_stream --output-on-failure -V
791792
ctest --test-dir cmake-out -R test_cuda_kv_cache --output-on-failure -V
792793
ctest --test-dir cmake-out -R test_step_plan --output-on-failure -V
793794
# CudaExecutor end to end on a toy decoder, against its eager greedy

‎backends/aoti/common_shims_slim.cpp‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
#include <executorch/runtime/platform/abort.h>
1111
#include <executorch/runtime/platform/log.h>
1212

13+
// Keep this file free of static and thread_local state: a Windows build
14+
// without EXECUTORCH_BUILD_SHARED links it twice (see backends/cuda).
15+
1316
namespace executorch {
1417
namespace backends {
1518
namespace aoti {

‎backends/aoti/slim/core/storage.h‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -178,17 +178,25 @@ struct DeviceTraits<c10::DeviceType::CUDA> {
178178
static_cast<int>(dst_device.index()));
179179
}
180180

181-
// Plain cudaMemcpy is host-synchronous on the default stream, which a
182-
// green context would not confine. When a caller stream is active, copy
183-
// on it asynchronously and synchronize it to preserve blocking
184-
// semantics; otherwise fall back to the plain synchronous copy.
181+
// Plain cudaMemcpy runs on the legacy default stream, which a green
182+
// context would not confine, and which fails while another thread is
183+
// capturing a CUDA graph. So copy on the caller stream, or else on the
184+
// current stream.
185185
const auto caller_stream = executorch::extension::cuda::getCallerStream();
186186
if (caller_stream) {
187187
ET_CUDA_CHECK(
188188
cudaMemcpyAsync(dst, src, nbytes, direction, *caller_stream));
189189
ET_CUDA_CHECK(cudaStreamSynchronize(*caller_stream));
190190
} else {
191-
ET_CUDA_CHECK(cudaMemcpy(dst, src, nbytes, direction));
191+
auto current = executorch::backends::cuda::getCurrentCUDAStream(
192+
src_device.is_cuda() ? src_device.index() : dst_device.index());
193+
ET_CHECK_MSG(current.ok(), "Failed to get current CUDA stream");
194+
ET_CUDA_CHECK(
195+
cudaMemcpyAsync(dst, src, nbytes, direction, current.get()));
196+
// cudaMemcpy does not wait for a copy between device buffers.
197+
if (direction != cudaMemcpyDeviceToDevice) {
198+
ET_CUDA_CHECK(cudaStreamSynchronize(current.get()));
199+
}
192200
}
193201
}
194202
};

‎backends/aoti/slim/cuda/guard.cpp‎

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

15-
namespace executorch::backends::cuda {
16-
17-
namespace {
18-
// Thread-local stream storage (private to this file)
19-
thread_local std::unordered_map<DeviceIndex, cudaStream_t> current_streams_;
20-
} // namespace
21-
22-
Error setCurrentCUDAStream(cudaStream_t stream, DeviceIndex device_index) {
23-
if (device_index == -1) {
24-
// Get current device if not specified
25-
// CUDA API returns int, explicit cast to DeviceIndex (int8_t) following
26-
// ATen
27-
int tmp_device = -1;
28-
ET_CUDA_CHECK_OR_RETURN_ERROR(cudaGetDevice(&tmp_device));
29-
device_index = static_cast<DeviceIndex>(tmp_device);
30-
}
31-
32-
current_streams_[device_index] = stream;
33-
return Error::Ok;
34-
}
35-
36-
Result<cudaStream_t> getCurrentCUDAStream(DeviceIndex device_index) {
37-
if (device_index == -1) {
38-
// CUDA API returns int, explicit cast to DeviceIndex (int8_t) following
39-
// ATen
40-
int tmp_device = -1;
41-
ET_CUDA_CHECK_OR_RETURN_ERROR(cudaGetDevice(&tmp_device));
42-
device_index = static_cast<DeviceIndex>(tmp_device);
43-
}
13+
// Keep this file free of static and thread_local state: a Windows build
14+
// without EXECUTORCH_BUILD_SHARED links it twice (see backends/cuda).
4415

45-
auto it = current_streams_.find(device_index);
46-
if (it != current_streams_.end()) {
47-
return it->second;
48-
}
49-
50-
cudaStream_t stream;
51-
ET_CUDA_CHECK_OR_RETURN_ERROR(cudaStreamCreate(&stream));
52-
setCurrentCUDAStream(stream, device_index);
53-
return stream;
54-
}
55-
56-
std::optional<cudaStream_t> peekCurrentCUDAStream(DeviceIndex device_index) {
57-
if (device_index == -1) {
58-
int tmp_device = -1;
59-
if (cudaGetDevice(&tmp_device) != cudaSuccess) {
60-
return std::nullopt;
61-
}
62-
device_index = static_cast<DeviceIndex>(tmp_device);
63-
}
64-
65-
auto it = current_streams_.find(device_index);
66-
if (it == current_streams_.end()) {
67-
return std::nullopt;
68-
}
69-
return it->second;
70-
}
71-
72-
void clearCurrentCUDAStream(DeviceIndex device_index) {
73-
if (device_index == -1) {
74-
int tmp_device = -1;
75-
if (cudaGetDevice(&tmp_device) != cudaSuccess) {
76-
return;
77-
}
78-
device_index = static_cast<DeviceIndex>(tmp_device);
79-
}
80-
current_streams_.erase(device_index);
81-
}
16+
namespace executorch::backends::cuda {
8217

8318
CUDAStreamGuard::CUDAStreamGuard(CUDAStreamGuard&& other) noexcept
8419
: device_guard_(std::move(other.device_guard_)),

‎backends/aoti/slim/cuda/guard.h‎

Lines changed: 5 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88

99
#pragma once
1010

11-
#include <optional>
12-
1311
#include <executorch/backends/aoti/slim/c10/core/Device.h>
1412
#include <executorch/backends/aoti/slim/c10/cuda/Exception.h>
13+
#include <executorch/extension/cuda/current_stream.h>
1514
#include <executorch/extension/cuda/device_guard.h>
1615
#include <executorch/extension/cuda/runtime_api.h>
1716
#include <executorch/runtime/core/error.h>
@@ -20,6 +19,10 @@
2019
namespace executorch::backends::cuda {
2120

2221
using ::executorch::extension::cuda::CUDAGuard;
22+
using ::executorch::extension::cuda::internal::clearCurrentCUDAStream;
23+
using ::executorch::extension::cuda::internal::getCurrentCUDAStream;
24+
using ::executorch::extension::cuda::internal::peekCurrentCUDAStream;
25+
using ::executorch::extension::cuda::internal::setCurrentCUDAStream;
2326

2427
using executorch::runtime::Error;
2528
using executorch::runtime::Result;
@@ -29,46 +32,6 @@ using executorch::runtime::Result;
2932
// calls, and explicit static_cast handles inbound conversions from CUDA APIs.
3033
using executorch::backends::aoti::slim::c10::DeviceIndex;
3134

32-
/**
33-
* Set the current CUDA stream for the specified device.
34-
*
35-
* @param stream The CUDA stream to set as current
36-
* @param device_index The device index (-1 to use current device)
37-
* @return Error code indicating success or failure
38-
*/
39-
Error setCurrentCUDAStream(cudaStream_t stream, DeviceIndex device_index = -1);
40-
41-
/**
42-
* Get the current CUDA stream for the specified device.
43-
* If no stream has been set, creates a new stream and sets it as current.
44-
*
45-
* @param device_index The device index (-1 to use current device)
46-
* @return Result containing the current stream on success, or an error code on
47-
* failure
48-
*/
49-
Result<cudaStream_t> getCurrentCUDAStream(DeviceIndex device_index = -1);
50-
51-
/**
52-
* The CUDA stream registered for the specified device, or std::nullopt if none
53-
* is set. Unlike getCurrentCUDAStream, it never creates one, so it can snapshot
54-
* the current selection without side effects. Also returns std::nullopt if the
55-
* current device cannot be queried (device_index -1), so nullopt does not
56-
* distinguish "no stream set" from "device query failed".
57-
*
58-
* @param device_index The device index (-1 to use current device)
59-
*/
60-
std::optional<cudaStream_t> peekCurrentCUDAStream(
61-
DeviceIndex device_index = -1);
62-
63-
/**
64-
* Clears any CUDA stream registered for the specified device, restoring the
65-
* "no stream selected" state. Best-effort: if device_index is -1 and the
66-
* current device cannot be queried, it silently does nothing.
67-
*
68-
* @param device_index The device index (-1 to use current device)
69-
*/
70-
void clearCurrentCUDAStream(DeviceIndex device_index = -1);
71-
7235
/**
7336
* RAII guard that sets the current CUDA device and stream, restoring both on
7437
* destruction. This is useful for temporarily switching to a different device

‎backends/cuda/CMakeLists.txt‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -310,10 +310,9 @@ if(_cuda_is_msvc_toolchain)
310310
else()
311311
# The whole-archive pair is PRIVATE so it applies to this library's own link
312312
# and does not replay at the link of anything that links this one. In PUBLIC
313-
# scope the archive was extracted again by each consumer, so the translation
314-
# unit holding SlimTensor's per-device current stream thread_local landed in
315-
# three shipped binaries, and one copy per process is what makes that state
316-
# agree. The MSVC branch above uses PRIVATE for the same reason.
313+
# scope the archive was extracted again by each consumer, so its objects
314+
# landed in three shipped binaries. The MSVC branch above uses PRIVATE for the
315+
# same reason.
317316
target_link_libraries(
318317
aoti_cuda_shims
319318
PRIVATE cuda_platform -Wl,--whole-archive aoti_common_shims_slim
@@ -337,10 +336,9 @@ endif()
337336
# source install keeps its own paths.
338337
if(EXECUTORCH_BUILD_SHARED)
339338
if(_cuda_is_windows_msvc)
340-
# In the shared layout the delegate is its own DLL and imports the stream
341-
# guard functions (get/set/peek/clearCurrentCUDAStream) from this one. They
342-
# carry no AOTI_SHIM_EXPORT, so the annotations alone would not export them;
343-
# export everything, as every other shipped DLL does.
339+
# In the shared layout the delegate is its own DLL and links this one.
340+
# Export everything, as every other shipped DLL does, not only the shims
341+
# marked AOTI_SHIM_EXPORT.
344342
set_target_properties(
345343
aoti_cuda_shims PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON
346344
)
@@ -426,9 +424,11 @@ if(EXECUTORCH_BUILD_EXTENSION_LLM)
426424
endif()
427425

428426
if(_cuda_is_msvc_toolchain)
429-
# cuda_backend.cpp uses SlimTensor CUDA utilities (e.g. getCurrentCUDAStream)
430-
# from aoti_common_shims_slim via headers; propagate the static lib so final
431-
# MSVC links (e.g. parakeet_runner) can resolve those C++ symbols.
427+
# The shims DLL compiles these sources itself (aoti_common_shims_slim_obj),
428+
# and cuda_backend.cpp still needs the SlimTensor headers and definitions that
429+
# come with the static library. So the program carries a second copy of that
430+
# code, which must hold no state; the current stream lives in extension_cuda
431+
# for that reason.
432432
target_link_libraries(
433433
aoti_cuda_backend
434434
PRIVATE aoti_cuda_shims
@@ -508,6 +508,11 @@ if(BUILD_TESTING)
508508
)
509509
target_compile_definitions(test_cuda_sort_rand PRIVATE CUDA_AVAILABLE=1)
510510
endif()
511+
et_cxx_test(
512+
test_cuda_current_stream SOURCES runtime/test/test_cuda_current_stream.cpp
513+
EXTRA_LIBS aoti_cuda_backend
514+
)
515+
target_compile_definitions(test_cuda_current_stream PRIVATE CUDA_AVAILABLE=1)
511516

512517
if(EXECUTORCH_BUILD_EXTENSION_LLM)
513518
et_cxx_test(

‎backends/cuda/cuda_backend.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,18 @@ def get_aoti_compile_options(
13591359
options["max_autotune"] = max_autotune
13601360
if autotune_at_compile_time is not None:
13611361
options["triton.autotune_at_compile_time"] = autotune_at_compile_time
1362+
# The final autotuning pass picks each kernel's launch settings by
1363+
# running it on random tensors shaped like its inputs, weights included,
1364+
# so in low-memory mode the real weights wait in a file meanwhile instead
1365+
# of taking GPU memory next to their random stand-ins. The threshold of
1366+
# 0.0 moves them for every model, because low-memory mode is for models
1367+
# that are short of GPU memory, and moving a small model costs little.
1368+
# TODO: drop the hasattr once every torch the repo installs has the option.
1369+
if cls._is_low_memory_mode(compile_specs) and hasattr(
1370+
torch._inductor.config.aot_inductor,
1371+
"autotune_offload_constants_min_device_fraction",
1372+
):
1373+
options["aot_inductor.autotune_offload_constants_min_device_fraction"] = 0.0
13621374
# Add platform-specific options
13631375

13641376
if platform == "windows":

‎backends/cuda/runtime/cuda_backend.cpp‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -695,8 +695,7 @@ class ET_EXPERIMENTAL CudaBackend final
695695
NotSupported,
696696
"CUDA graph is not supported together with a caller-provided CUDA stream.");
697697

698-
// Snapshot the prior selection without creating one (peek, not get), so the
699-
// restore is exact and we don't leak a stream just to snapshot.
698+
// Snapshot the prior selection with peek, not get, so the restore is exact.
700699
std::optional<cudaStream_t> prev_stream;
701700
if (caller_stream) {
702701
prev_stream = peekCurrentCUDAStream(0);

0 commit comments

Comments
 (0)