Skip to content

Commit 8f67ff7

Browse files
authored
Fall back to cudaMalloc on CUDA devices without memory pools (pytorch#23405)
The CUDA backend's stream-ordered allocations fail on any device that does not support memory pools, which includes data-center GPUs in TCC mode on Windows (A10G, A100, H100 and the like). There cudaMemPoolCreate and cudaMallocAsync return cudaErrorNotSupported, and nothing falls back: E cuda_allocator.cpp:99] cudaMemPoolCreate failed for device 0: operation not supported. Using the default pool. E cuda_allocator.cpp:448] cudaMallocAsync failed: operation not supported (requested 512 bytes on device 0) F storage.h:126] In function allocate(), assert failed (result.ok()): CudaAllocator::allocate_async failed for 512 bytes on device 0 That is from a CUDA program running on the Windows GPU CI runner (an A10G) in pytorch#23123, the first CI job to run the delegate on Windows with a driver installed. A GeForce card in WDDM mode supports memory pools, which is why a local run passes. ## Change - CudaAllocator (extension/cuda/cuda_allocator, where pytorch#23447 moved it) asks each device once whether it supports memory pools (cudaDevAttrMemoryPoolsSupported) and caches the answer in per-device atomics. On a device that does not, allocate_async allocates with cudaMalloc and deallocate_async frees with cudaFree, deciding by the device the pointer lives on. A failed attribute query keeps the stream-ordered path. - On a device with memory pools the paths are the ones they were: allocate_async adds one atomic load, and deallocate_async looks up the pointer's device only once some device without pools has been seen, so a process whose devices all have them frees exactly as before. - The fallback refuses while the stream is capturing a CUDA graph. In relaxed capture mode cudaMalloc and cudaFree succeed, so a block freed during capture would be handed out again while every replay still writes to it; refusing keeps the loud failure the stream-ordered call gave, and a free kept during a capture is reported to the caller (deallocate_stream_ordered returns NotSupported; a failed cudaFree returns Internal). On such a device the backend also does not turn CUDA graphs on for a method that asks for them (enable_cuda_graph_for_method), and says so in the log. - sort.cu and rand.cu called cudaMallocAsync/cudaFreeAsync directly. They now call CudaAllocator::allocate_stream_ordered / deallocate_stream_ordered with their real device, which is cudaMallocAsync/cudaFreeAsync on the stream as before (the device default pool, not the allocator's retaining pool) and the cudaMalloc fallback without pools. rand passes the current device, the one its stream was created on. - rand's one-time RNG state allocation now reports a failure: the shims return an error instead of launching their kernels with a null state, and a later call tries again. ## Tests and CI - test_cuda_allocator: AllocateAsyncRoundtrip and StreamOrderedScratchRoundtrip run on every device. Four tests run on a device without pools: FallsBackWithoutMemoryPools checks that the fallback allocation is actually released by deallocate_async, FallbackRefusesWhileCapturing that neither allocator falls back inside a capture, FallbackKeepsBlocksFreedWhileCapturing that blocks freed during a capture, through both free paths, stay allocated and intact through a replay and that the kept block is reported, and CudaGraphStaysOffWithoutMemoryPools that the CUDA graph state the backend starts from (CudaGraphState::start_warmup) stays disabled. They skip on a device with pools, except where EXECUTORCH_CUDA_TEST_REQUIRE_NO_MEMORY_POOLS is set, as the Windows job does, where they fail instead so the fallback cannot go untested; with it set, no visible CUDA device fails the tests too rather than skipping them. The pool tests move to a fixture that skips on a device without pools, alongside CudaGraphStartsWithMemoryPools. - test_cuda_sort_rand (new): runs the sort shim along both dims of a 2-D tensor (the transpose path that allocates two scratch buffers, and thrust's scratch) on input with repeated values in every slice, against a host stable sort, checking values and indices, and the rand shim. Built only where sort.cu and rand.cu are (a CUDA build that found a CUDA compiler). - cuda-windows.yml gains unittest-cuda-runtime-windows, the Windows counterpart of cuda.yml's unittest-cuda-runtime: it builds llm-release-cuda with tests and runs the same runtime tests plus test_cuda_sort_rand. The Windows runner's A10G has no memory pools, so this covers the fallback; the Linux job, which now runs test_cuda_sort_rand too, covers the pool path. Its pull_request paths include the scripts and tools/cmake/Test.cmake it depends on. - The Windows GPU runners come without an NVIDIA driver (nvcuda.dll is missing and cudaGetDeviceCount returns cudaErrorInsufficientDriver), so the job first installs the data-center driver that pytorch/pytorch's Windows CUDA smoke tests install (driver_update.bat, 580.88, from the same bucket); install_nvidia_driver_windows.ps1. - et_cxx_test copies a test's runtime DLLs ($<TARGET_RUNTIME_DLLS>) beside it on Windows, which records no search path: the CUDA tests link extension_cuda.dll from another directory and could not start otherwise. - extension/cuda/test/cuda_allocator_exit.cpp (Linux only) fakes every CUDA call on its path; it now also fakes cudaDeviceGetAttribute to report pools, since the pool query is new on that path and would otherwise make the test depend on the machine's GPU. ## Test plan - Windows 11, RTX 5080 (WDDM, memory pools supported), CUDA 13.0, llm-release-cuda with tests, rebased on main with the allocator in extension/cuda: all six runtime tests pass (test_cuda_allocator 22 passed with the four no-pool tests skipped; mutable_state 15, weight_cache 11, sort_rand 2, guard 7, stream_guard 21). With EXECUTORCH_CUDA_TEST_REQUIRE_NO_MEMORY_POOLS set, the four no-pool tests fail there, as intended. test_cuda_allocator_exit is Linux only and runs in the Linux job. - The no-pool path runs in the Windows CI job on the A10G; on the previous revision of this PR that job passed the fallback tests there. - Linux: the pool path is covered by the existing unittest-cuda-runtime job. Found while adding the Windows CUDA wheel (pytorch#23123), which needs this to run on the Windows GPU runner; this PR does not depend on it.
1 parent f11a5f8 commit 8f67ff7

16 files changed

Lines changed: 893 additions & 49 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# Installs the NVIDIA display driver on a Windows GPU runner. The runner images carry the
8+
# GPU but no driver, so without this nvcuda.dll is missing and every CUDA call fails with
9+
# cudaErrorInsufficientDriver. The same driver, from the same place, that PyTorch's Windows
10+
# CUDA smoke tests install first (.ci/pytorch/windows/internal/driver_update.bat in
11+
# pytorch/pytorch), so both projects test against one driver.
12+
$ErrorActionPreference = "Stop"
13+
$version = "580.88"
14+
$installer = Join-Path $env:RUNNER_TEMP "$version-data-center-tesla-desktop-win10-win11-64bit-dch-international.exe"
15+
$url = "https://ossci-windows.s3.amazonaws.com/$(Split-Path $installer -Leaf)"
16+
17+
$nvcuda = Join-Path $env:SystemRoot "System32\nvcuda.dll"
18+
if (Test-Path $nvcuda) {
19+
Write-Host "NVIDIA driver already present: $((Get-Item $nvcuda).VersionInfo.FileVersion)"
20+
exit 0
21+
}
22+
curl.exe --retry 3 -fsSL $url --output $installer
23+
if ($LASTEXITCODE -ne 0) { throw "downloading the NVIDIA driver from $url failed" }
24+
$process = Start-Process -FilePath $installer -ArgumentList "-s", "-noreboot" -Wait -PassThru
25+
Remove-Item $installer -ErrorAction SilentlyContinue
26+
if ($process.ExitCode -ne 0) { throw "the NVIDIA driver installer exited with $($process.ExitCode)" }
27+
if (-not (Test-Path $nvcuda)) { throw "the NVIDIA driver installed but $nvcuda is missing" }
28+
Write-Host "NVIDIA driver $version installed: $((Get-Item $nvcuda).VersionInfo.FileVersion)"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
7+
# Builds and runs the CUDA backend's runtime C++ tests on a Windows GPU runner, the
8+
# counterpart of the unittest-cuda-runtime job in cuda.yml. The Windows runners carry
9+
# data-center GPUs in TCC mode, which have no memory pools, so this is also what covers the
10+
# allocator's synchronous fallback; the Linux job covers the pool path.
11+
$ErrorActionPreference = "Stop"
12+
13+
# Windows PowerShell 5.1 does not stop on a failing native command.
14+
function Invoke-Native {
15+
param([Parameter(Mandatory = $true)][scriptblock]$Command)
16+
& $Command
17+
if ($LASTEXITCODE -ne 0) {
18+
throw "exit code ${LASTEXITCODE}: $Command"
19+
}
20+
}
21+
22+
& "$PSScriptRoot\install_nvidia_driver_windows.ps1"
23+
24+
$cudaNvcc = Join-Path $env:CUDA_HOME "bin\nvcc.exe"
25+
if (-not (Test-Path $cudaNvcc)) { throw "CUDA compiler not found at '$cudaNvcc'" }
26+
$env:CUDACXX = $cudaNvcc
27+
$env:PATH = "$env:CUDA_HOME\bin\x64;$env:CUDA_HOME\bin;$env:PATH"
28+
# The runner's GPU has no memory pools, which is what makes this job cover the
29+
# allocator's fallback; fail rather than skip those tests if it ever has them.
30+
$env:EXECUTORCH_CUDA_TEST_REQUIRE_NO_MEMORY_POOLS = "1"
31+
32+
$tests = @(
33+
"test_cuda_allocator",
34+
"test_cuda_mutable_state",
35+
"test_cuda_weight_cache",
36+
"test_cuda_sort_rand",
37+
"test_cuda_guard",
38+
"test_cuda_stream_guard"
39+
)
40+
$numCores = [Math]::Max([Environment]::ProcessorCount - 1, 1)
41+
Invoke-Native {
42+
cmake --preset llm-release-cuda -DEXECUTORCH_BUILD_TESTS=ON -DCMAKE_CXX_STANDARD=20 `
43+
-T "cuda=$env:CUDA_HOME" "-DCMAKE_CUDA_COMPILER=$cudaNvcc" "-DCUDAToolkit_ROOT=$env:CUDA_HOME"
44+
}
45+
Invoke-Native { cmake --build cmake-out --config Release -j $numCores --target $tests }
46+
foreach ($test in $tests) {
47+
Invoke-Native { ctest --test-dir cmake-out -C Release -R "^$test`$" --output-on-failure -V }
48+
}

‎.github/workflows/cuda-windows.yml‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ on:
1717
- backends/cuda/**
1818
- backends/aoti/**
1919
- extension/cuda/**
20+
# Read only by unittest-cuda-runtime-windows; the other jobs filter on
21+
# their own paths and stay skipped.
22+
- .ci/scripts/test_cuda_runtime_windows.ps1
23+
- .ci/scripts/install_nvidia_driver_windows.ps1
24+
- tools/cmake/Test.cmake
2025
workflow_dispatch:
2126

2227
concurrency:
@@ -223,6 +228,41 @@ jobs:
223228
.ci/scripts/test_model_e2e_windows.ps1 -Device cuda-windows -HfModel '${{ matrix.model_repo }}/${{ matrix.model_name }}' -QuantName '${{ matrix.quant }}' -ModelDir \$artifactDir -ExpectedCudaVersion '13.0'
224229
}"
225230
231+
# The counterpart of cuda.yml's unittest-cuda-runtime on Windows. The Windows GPU runners
232+
# carry data-center GPUs in TCC mode, which have no memory pools, so this also covers the
233+
# allocator's synchronous fallback that the Linux job cannot reach.
234+
unittest-cuda-runtime-windows:
235+
name: unittest-cuda-runtime-windows
236+
needs: [changed-files, run-decision]
237+
if: |
238+
contains(needs.changed-files.outputs.changed-files, 'backends/cuda') ||
239+
contains(needs.changed-files.outputs.changed-files, 'backends/aoti') ||
240+
contains(needs.changed-files.outputs.changed-files, 'extension/cuda') ||
241+
contains(needs.changed-files.outputs.changed-files, '.github/workflows/cuda-windows.yml') ||
242+
contains(needs.changed-files.outputs.changed-files, '.ci/scripts/test_cuda_runtime_windows.ps1') ||
243+
contains(needs.changed-files.outputs.changed-files, '.ci/scripts/install_nvidia_driver_windows.ps1') ||
244+
contains(needs.changed-files.outputs.changed-files, 'tools/cmake/Test.cmake') ||
245+
needs.run-decision.outputs.is-full-run == 'true'
246+
uses: pytorch/test-infra/.github/workflows/windows_job.yml@main
247+
with:
248+
timeout: 120
249+
runner: windows.g5.4xlarge.nvidia.gpu
250+
gpu-arch-type: cuda
251+
gpu-arch-version: "13.0"
252+
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
253+
script: |
254+
git config --global http.sslBackend openssl
255+
git submodule update --init --recursive
256+
conda init powershell
257+
powershell -Command "& {
258+
Set-PSDebug -Trace 1
259+
\$ErrorActionPreference = 'Stop'
260+
\$env:CUDA_HOME = 'C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v13.0'
261+
\$env:CUDA_PATH = \$env:CUDA_HOME
262+
.ci/scripts/setup-windows.ps1
263+
.ci/scripts/test_cuda_runtime_windows.ps1
264+
}"
265+
226266
delete-model-cuda-windows-artifacts:
227267
name: delete-model-cuda-windows-artifacts
228268
# The exports exist only to hand the models to the Windows job above, about

‎.github/workflows/cuda.yml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -577,10 +577,11 @@ jobs:
577577
-v -o "addopts="
578578
579579
cmake --preset llm-release-cuda -DEXECUTORCH_BUILD_TESTS=ON
580-
cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_guard test_cuda_stream_guard -j$(nproc)
580+
cmake --build cmake-out --target test_cuda_allocator test_cuda_allocator_exit test_cuda_mutable_state test_cuda_weight_cache test_cuda_sort_rand test_cuda_guard test_cuda_stream_guard -j$(nproc)
581581
ctest --test-dir cmake-out -R test_cuda_allocator --output-on-failure -V
582582
ctest --test-dir cmake-out -R test_cuda_mutable_state --output-on-failure -V
583583
ctest --test-dir cmake-out -R test_cuda_weight_cache --output-on-failure -V
584+
ctest --test-dir cmake-out -R test_cuda_sort_rand --output-on-failure -V
584585
ctest --test-dir cmake-out -R test_cuda_guard --output-on-failure -V
585586
ctest --test-dir cmake-out -R test_cuda_stream_guard --output-on-failure -V
586587

‎backends/cuda/CMakeLists.txt‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,13 @@ if(BUILD_TESTING)
475475
EXTRA_LIBS aoti_cuda_backend
476476
)
477477
target_compile_definitions(test_cuda_weight_cache PRIVATE CUDA_AVAILABLE=1)
478+
# sort.cu and rand.cu are compiled only under this condition (see
479+
# _aoti_cuda_shim_sources), so the test that calls them is too.
480+
if(NOT EXECUTORCH_BUILD_ROCM AND CMAKE_CUDA_COMPILER)
481+
et_cxx_test(
482+
test_cuda_sort_rand SOURCES runtime/test/test_cuda_sort_rand.cpp
483+
EXTRA_LIBS aoti_cuda_backend
484+
)
485+
target_compile_definitions(test_cuda_sort_rand PRIVATE CUDA_AVAILABLE=1)
486+
endif()
478487
endif()

‎backends/cuda/runtime/cuda_backend.cpp‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,15 @@ constexpr int kCudaGraphWarmupSteps = 3;
8989
constexpr char kWeightSharingAcrossMethods[] = "weight_sharing_across_methods";
9090
} // anonymous namespace
9191

92+
bool CudaGraphState::start_warmup() {
93+
if (!CudaAllocator::memory_pools_supported(-1)) {
94+
return false;
95+
}
96+
phase = CudaGraphPhase::Warmup;
97+
warmup_remaining = kCudaGraphWarmupSteps;
98+
return true;
99+
}
100+
92101
// Advances the off-graph KV cache past a step's tokens. Every successful exit
93102
// of execute() ends here, and only those: a run that failed never wrote its
94103
// tokens, and committing them would desynchronise the cache's length from the
@@ -539,15 +548,22 @@ class ET_EXPERIMENTAL CudaBackend final
539548
handle->get_cuda_stream(),
540549
method_name.c_str());
541550

542-
// Initialize CUDA graph state if enabled for this method.
551+
// Initialize CUDA graph state if enabled for this method. Not on a device
552+
// without memory pools, where the method runs without a graph.
543553
if (should_use_cuda_graph_for_method(method_name)) {
544-
handle->cuda_graph_state.phase = CudaGraphPhase::Warmup;
545-
handle->cuda_graph_state.warmup_remaining = kCudaGraphWarmupSteps;
546-
ET_LOG(
547-
Info,
548-
"CUDA graph enabled for method '%s' (warmup=%d)",
549-
method_name.c_str(),
550-
kCudaGraphWarmupSteps);
554+
if (handle->cuda_graph_state.start_warmup()) {
555+
ET_LOG(
556+
Info,
557+
"CUDA graph enabled for method '%s' (warmup=%d)",
558+
method_name.c_str(),
559+
kCudaGraphWarmupSteps);
560+
} else {
561+
ET_LOG(
562+
Info,
563+
"CUDA graph requested for method '%s' but this device has no "
564+
"memory pools; running without a CUDA graph",
565+
method_name.c_str());
566+
}
551567
}
552568

553569
mutable_state_note_handle(handle);

‎backends/cuda/runtime/cuda_delegate_handle.h‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ struct CudaGraphState {
135135

136136
CudaGraphState() = default;
137137

138+
// Starts the warmup that leads to capture, when the current device can
139+
// capture: without memory pools the allocator falls back to cudaMalloc,
140+
// which a captured graph cannot own, so the phase stays Disabled there.
141+
// Returns whether it started. Defined with the backend, which owns the
142+
// warmup length.
143+
bool start_warmup();
144+
138145
~CudaGraphState() {
139146
release();
140147
}

‎backends/cuda/runtime/shims/rand.cu‎

Lines changed: 41 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <executorch/backends/aoti/slim/cuda/guard.h>
1212
#include <executorch/backends/aoti/slim/factory/empty.h>
1313
#include <executorch/backends/aoti/slim/util/size_util.h>
14+
#include <executorch/extension/cuda/cuda_allocator.h>
1415
#include <executorch/runtime/platform/assert.h>
1516
#include <executorch/runtime/platform/log.h>
1617

@@ -20,6 +21,7 @@
2021
#include <cstdint>
2122
#include <ctime>
2223
#include <mutex>
24+
#include <new>
2325
#include <vector>
2426

2527
namespace executorch::backends::cuda {
@@ -57,20 +59,37 @@ static std::once_flag g_rng_init_flag;
5759

5860
// Initialize RNG state on the given stream.
5961
// Must be called during warmup (before graph capture). Subsequent calls
60-
// from any thread are no-ops thanks to std::call_once.
61-
void ensure_rng_init(cudaStream_t stream) {
62-
std::call_once(g_rng_init_flag, [&]() {
63-
cudaMallocAsync(&d_rng, sizeof(RngState), stream);
64-
RngState h;
65-
h.seed = static_cast<unsigned long long>(time(nullptr));
66-
h.counter = 0;
67-
h.base_scratch = 0;
68-
cudaMemcpyAsync(
69-
d_rng, &h, sizeof(RngState), cudaMemcpyHostToDevice, stream);
70-
// Synchronize to ensure the copy completes before we return
71-
// (the host-side RngState `h` is on the stack).
72-
cudaStreamSynchronize(stream);
73-
});
62+
// from any thread are no-ops thanks to std::call_once. A failed allocation
63+
// throws out of the call_once, which leaves it not done, so the next call
64+
// tries again rather than every later launch using a null state.
65+
bool ensure_rng_init(cudaStream_t stream) {
66+
try {
67+
std::call_once(g_rng_init_flag, [&]() {
68+
// The current device: the stream callers pass from getCurrentCUDAStream
69+
// may have been created on it whatever index they looked up, and the
70+
// cudaMalloc fallback on a device without memory pools follows this
71+
// index rather than the stream.
72+
auto rng =
73+
CudaAllocator::allocate_stream_ordered(sizeof(RngState), -1, stream);
74+
if (!rng.ok()) {
75+
throw std::bad_alloc();
76+
}
77+
d_rng = static_cast<RngState*>(rng.get());
78+
RngState h;
79+
h.seed = static_cast<unsigned long long>(time(nullptr));
80+
h.counter = 0;
81+
h.base_scratch = 0;
82+
cudaMemcpyAsync(
83+
d_rng, &h, sizeof(RngState), cudaMemcpyHostToDevice, stream);
84+
// Synchronize to ensure the copy completes before we return
85+
// (the host-side RngState `h` is on the stack).
86+
cudaStreamSynchronize(stream);
87+
});
88+
} catch (const std::bad_alloc&) {
89+
ET_LOG(Error, "rand: allocating the RNG state failed");
90+
return false;
91+
}
92+
return true;
7493
}
7594

7695
// Philox-based randint kernel. Reads its base offset from `rng->base_scratch`
@@ -211,7 +230,10 @@ AOTITorchError aoti_torch_cuda_rand(
211230
"aoti_torch_cuda_rand: failed to get CUDA stream");
212231
cudaStream_t stream = stream_result.get();
213232

214-
ensure_rng_init(stream);
233+
ET_CHECK_OR_RETURN_ERROR(
234+
ensure_rng_init(stream),
235+
MemoryAllocationFailed,
236+
"aoti_torch_cuda_rand: failed to allocate the RNG state");
215237

216238
constexpr int kThreads = 256;
217239
int blocks = static_cast<int>((numel + kThreads - 1) / kThreads);
@@ -271,7 +293,10 @@ AOTITorchError aoti_torch_cuda_randint_low_out(
271293
"aoti_torch_cuda_randint_low_out: failed to get CUDA stream");
272294
cudaStream_t stream = stream_result.get();
273295

274-
ensure_rng_init(stream);
296+
ET_CHECK_OR_RETURN_ERROR(
297+
ensure_rng_init(stream),
298+
MemoryAllocationFailed,
299+
"aoti_torch_cuda_randint_low_out: failed to allocate the RNG state");
275300

276301
int64_t range = high - low;
277302
int64_t* out_data = static_cast<int64_t*>(out->data_ptr());

0 commit comments

Comments
 (0)