Skip to content

Commit 1e716a6

Browse files
shoumikhinr
andauthored
Stop ignoring a failed cudaSetDevice in CudaAllocator (pytorch#22093)
### Summary `CudaAllocator` switches the current CUDA device before it allocates, frees or copies, and switches back afterwards. The result of that switch was thrown away in all three places: ```cpp (void)cudaSetDevice(index); ``` When the switch fails, for example because the requested device index does not exist on this machine, the work still went ahead on whatever device happened to be current. `allocate` then returned success with a pointer that lives on a different device than the caller asked for. The caller stores the requested index next to that pointer, so the pointer and its recorded device disagree from then on, and the mistake surfaces much later as a wrong result or an unrelated CUDA error somewhere else. On a machine with one GPU: ```cpp auto r = CudaAllocator::instance().allocate(1024, /*index=*/1); // before: r.ok() == true, and the pointer is really on device 0 // after: r.ok() == false, r.error() == Error::Internal ``` What changes: * `allocate` logs the CUDA error and returns `Error::Internal` instead of allocating on the wrong device. A device that cannot be selected is not an out-of-memory condition, and `Error::Internal` is what `Exception.h` maps every CUDA error to and what `cuda_mutable_state.cpp` already returns for this same call. * The host to device and device to host copy helpers log the CUDA error and return `Error::Internal` instead of copying against the wrong device. * All three paths also fail when `cudaGetDevice` fails, instead of carrying on without switching. Without the current device there is no way to switch to the requested one or restore afterwards, so the work would run against whatever device happened to be current. This is the branch the tests cannot reach: on a working CUDA host `cudaGetDevice` does not fail, so it is verified by inspection rather than by the new tests. * `deallocate` returns `void`, and `cudaFree` accepts a pointer from any device under unified addressing, so it logs the error and still frees the pointer rather than leaking it. The failure is no longer silent. Only the switch to the requested device is checked. The switch back to the previous device stays best effort, because there is nothing useful to do if restoring fails. ### Test plan Three new tests in `backends/cuda/runtime/test/test_cuda_allocator.cpp` ask for device index `device_count`, which is one past the last valid ordinal on any machine, so the switch always fails: * `AllocateOnMissingDeviceFails` * `CopyHostToDeviceOnMissingDeviceFails` * `CopyDeviceToHostOnMissingDeviceFails` `DeviceIndex` is `int8_t`, so each of the three first checks that one-past-the-last ordinal still fits in it: at 128 or more visible GPUs the count wraps to a negative index, or at 256 back onto real device 0, and neither is a valid absent ordinal. The check is per-test rather than in `SetUp` so it does not also skip the six device-0 tests. 127 devices still runs; 128 skips. Built and ran the suite on one NVIDIA H100. With this change: ``` [ PASSED ] 9 tests. ``` Confirmed the tests really catch the bug. With the allocator reverted to the old code and the same three tests: ``` [ FAILED ] CudaAllocatorTest.AllocateOnMissingDeviceFails [ FAILED ] CudaAllocatorTest.CopyHostToDeviceOnMissingDeviceFails [ FAILED ] CudaAllocatorTest.CopyDeviceToHostOnMissingDeviceFails ``` The allocate failure shows the old behavior directly: the call reported success for a device that does not exist. The message the fixed build logs: ``` CudaAllocator::allocate: cudaSetDevice(1) failed: invalid device ordinal ``` The six tests already in that file still pass, so the change does not disturb the normal single device path. `clang-format` reports no changes needed on either touched file. ### Not covered The `cudaGetDevice` failure branch is not covered by these tests. On a working CUDA host that call does not fail, and there is no injection point in the suite, so reverting just that branch leaves all three tests passing. Reaching it would need a function-pointer seam or an injected fake CUDA runtime, both larger than the guard they would protect. `deallocate` only logs, so no test asserts on it. Everything here ran on a single GPU machine, so the switch always failed with an invalid ordinal. A device that exists but is unavailable, for example one in exclusive compute mode, takes the same code path but was not exercised. --------- Co-authored-by: r <r@e>
1 parent 4a61ef4 commit 1e716a6

2 files changed

Lines changed: 158 additions & 19 deletions

File tree

‎backends/cuda/runtime/cuda_allocator.cpp‎

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,36 @@ Error copy_impl(
6161
}
6262

6363
int prev_device = 0;
64-
cudaError_t prev_device_err = cudaSuccess;
64+
bool switched_device = false;
6565

6666
if (index >= 0) {
67-
prev_device_err = cudaGetDevice(&prev_device);
68-
if (prev_device_err == cudaSuccess) {
69-
(void)cudaSetDevice(index);
67+
// Without the current device there is no way to switch to `index` and no
68+
// way to restore afterwards, so copying would run against whatever device
69+
// happens to be current and report success. Fail instead, as
70+
// cuda_mutable_state.cpp does for the same call.
71+
cudaError_t prev_device_err = cudaGetDevice(&prev_device);
72+
if (prev_device_err != cudaSuccess) {
73+
ET_LOG(
74+
Error,
75+
"%s: cudaGetDevice failed: %s",
76+
method,
77+
cudaGetErrorString(prev_device_err));
78+
return Error::Internal;
79+
}
80+
if (static_cast<int>(index) != prev_device) {
81+
cudaError_t set_err = cudaSetDevice(index);
82+
if (set_err != cudaSuccess) {
83+
// Nothing was switched, so there is nothing to restore. Copying now
84+
// would silently run against whatever device is still current.
85+
ET_LOG(
86+
Error,
87+
"%s: cudaSetDevice(%d) failed: %s",
88+
method,
89+
static_cast<int>(index),
90+
cudaGetErrorString(set_err));
91+
return Error::Internal;
92+
}
93+
switched_device = true;
7094
}
7195
}
7296
cudaError_t err = cudaSuccess;
@@ -79,7 +103,7 @@ Error copy_impl(
79103
err = cudaMemcpy(dst, src, nbytes, kind);
80104
}
81105

82-
if (index >= 0 && prev_device_err == cudaSuccess) {
106+
if (switched_device) {
83107
(void)cudaSetDevice(prev_device);
84108
}
85109

@@ -130,14 +154,41 @@ CudaAllocator::allocate(size_t nbytes, DeviceIndex index, size_t alignment) {
130154

131155
void* ptr = nullptr;
132156
int prev_device = 0;
133-
cudaError_t prev_device_err = cudaGetDevice(&prev_device);
157+
bool switch_device = false;
158+
159+
// If index == -1, fall back to the current device and skip the set/restore
160+
// round-trip.
161+
if (index >= 0) {
162+
// Without the current device there is no way to switch to `index` and no
163+
// way to restore afterwards, so the allocation would land on whatever
164+
// device happens to be current while the caller records it as living on
165+
// `index`. Fail instead.
166+
cudaError_t prev_device_err = cudaGetDevice(&prev_device);
167+
if (prev_device_err != cudaSuccess) {
168+
ET_LOG(
169+
Error,
170+
"CudaAllocator::allocate: cudaGetDevice failed: %s",
171+
cudaGetErrorString(prev_device_err));
172+
return Error::Internal;
173+
}
174+
switch_device = static_cast<int>(index) != prev_device;
175+
}
134176

135-
// If index == -1, fall back to the current device returned by cudaGetDevice
136-
// and skip the set/restore round-trip.
137-
const bool switch_device = index >= 0 && prev_device_err == cudaSuccess &&
138-
static_cast<int>(index) != prev_device;
139177
if (switch_device) {
140-
(void)cudaSetDevice(index);
178+
cudaError_t set_err = cudaSetDevice(index);
179+
if (set_err != cudaSuccess) {
180+
// Allocating now would return a pointer on the current device while the
181+
// caller records it as living on the requested one. cudaSetDevice reports
182+
// more than a bad ordinal here (a valid device can be unavailable or in
183+
// prohibited mode), so report it the way the rest of the CUDA runtime
184+
// code does rather than blaming the caller's argument.
185+
ET_LOG(
186+
Error,
187+
"CudaAllocator::allocate: cudaSetDevice(%d) failed: %s",
188+
static_cast<int>(index),
189+
cudaGetErrorString(set_err));
190+
return Error::Internal;
191+
}
141192
}
142193

143194
cudaError_t err = cudaMalloc(&ptr, nbytes);
@@ -178,18 +229,37 @@ void CudaAllocator::deallocate(void* ptr, DeviceIndex index) {
178229
}
179230

180231
int prev_device = 0;
181-
cudaError_t prev_device_err = cudaSuccess;
232+
bool switched_device = false;
182233

183234
if (index >= 0) {
184-
prev_device_err = cudaGetDevice(&prev_device);
185-
if (prev_device_err == cudaSuccess) {
186-
(void)cudaSetDevice(index);
235+
cudaError_t prev_device_err = cudaGetDevice(&prev_device);
236+
if (prev_device_err != cudaSuccess) {
237+
// cudaFree accepts a pointer from any device under unified addressing, so
238+
// free it anyway rather than leak, but do not try to restore a device we
239+
// never read.
240+
ET_LOG(
241+
Error,
242+
"CudaAllocator::deallocate: cudaGetDevice failed: %s",
243+
cudaGetErrorString(prev_device_err));
244+
} else if (static_cast<int>(index) != prev_device) {
245+
cudaError_t set_err = cudaSetDevice(index);
246+
if (set_err != cudaSuccess) {
247+
// Same reasoning: keep going rather than leak it, but do not stay
248+
// silent about it.
249+
ET_LOG(
250+
Error,
251+
"CudaAllocator::deallocate: cudaSetDevice(%d) failed: %s",
252+
static_cast<int>(index),
253+
cudaGetErrorString(set_err));
254+
} else {
255+
switched_device = true;
256+
}
187257
}
188258
}
189259

190260
cudaError_t err = cudaFree(ptr);
191261

192-
if (index >= 0 && prev_device_err == cudaSuccess) {
262+
if (switched_device) {
193263
(void)cudaSetDevice(prev_device);
194264
}
195265

‎backends/cuda/runtime/test/test_cuda_allocator.cpp‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include <executorch/extension/cuda/runtime_api.h>
1212

1313
#include <cstdint>
14+
#include <limits>
1415
#include <vector>
1516

1617
#include <executorch/backends/cuda/runtime/cuda_allocator.h>
@@ -20,18 +21,35 @@
2021

2122
using executorch::backends::cuda::CudaAllocator;
2223
using executorch::runtime::Error;
24+
using executorch::runtime::etensor::DeviceIndex;
2325

2426
class CudaAllocatorTest : public testing::Test {
2527
protected:
2628
void SetUp() override {
2729
et_pal_init();
2830

29-
int device_count = 0;
30-
cudaError_t err = cudaGetDeviceCount(&device_count);
31-
if (err != cudaSuccess || device_count == 0) {
31+
cudaError_t err = cudaGetDeviceCount(&device_count_);
32+
if (err != cudaSuccess || device_count_ == 0) {
3233
GTEST_SKIP() << "CUDA not available";
3334
}
3435
}
36+
37+
// One past the last valid device ordinal, so switching to it always fails.
38+
// Only the tests that need such an ordinal call this, so the fit check lives
39+
// here rather than in SetUp, where it would also skip the device-0 tests.
40+
DeviceIndex missing_device() const {
41+
return static_cast<DeviceIndex>(device_count_);
42+
}
43+
44+
// missing_device() has to stay a valid-but-absent ordinal. DeviceIndex is
45+
// int8_t, so on a host with more than 127 visible GPUs the count wraps to a
46+
// negative index (which the >= -1 argument check rejects for a different
47+
// reason) or, at 256, back onto real device 0.
48+
bool missing_device_fits() const {
49+
return device_count_ <= std::numeric_limits<DeviceIndex>::max();
50+
}
51+
52+
int device_count_ = 0;
3553
};
3654

3755
TEST_F(CudaAllocatorTest, CopyRoundtrip) {
@@ -112,3 +130,54 @@ TEST_F(CudaAllocatorTest, CopyDeviceToHostNullSrcReturnsInvalidArgument) {
112130
<< "expected InvalidArgument for null src, got "
113131
<< static_cast<uint32_t>(e);
114132
}
133+
134+
TEST_F(CudaAllocatorTest, AllocateOnMissingDeviceFails) {
135+
if (!missing_device_fits()) {
136+
GTEST_SKIP() << "device count " << device_count_
137+
<< " leaves no absent ordinal in DeviceIndex";
138+
}
139+
CudaAllocator& a = CudaAllocator::instance();
140+
auto res = a.allocate(1024, missing_device());
141+
ASSERT_FALSE(res.ok()) << "allocate must not report success for device "
142+
<< static_cast<int>(missing_device())
143+
<< ", which does not exist";
144+
EXPECT_EQ(res.error(), Error::Internal);
145+
}
146+
147+
TEST_F(CudaAllocatorTest, CopyHostToDeviceOnMissingDeviceFails) {
148+
if (!missing_device_fits()) {
149+
GTEST_SKIP() << "device count " << device_count_
150+
<< " leaves no absent ordinal in DeviceIndex";
151+
}
152+
CudaAllocator& a = CudaAllocator::instance();
153+
constexpr size_t N = 64;
154+
auto res = a.allocate(N, 0);
155+
ASSERT_TRUE(res.ok());
156+
void* dptr = res.get();
157+
158+
std::vector<uint8_t> h(N, 7);
159+
EXPECT_EQ(
160+
a.copy_host_to_device(dptr, h.data(), N, missing_device()),
161+
Error::Internal);
162+
163+
a.deallocate(dptr, 0);
164+
}
165+
166+
TEST_F(CudaAllocatorTest, CopyDeviceToHostOnMissingDeviceFails) {
167+
if (!missing_device_fits()) {
168+
GTEST_SKIP() << "device count " << device_count_
169+
<< " leaves no absent ordinal in DeviceIndex";
170+
}
171+
CudaAllocator& a = CudaAllocator::instance();
172+
constexpr size_t N = 64;
173+
auto res = a.allocate(N, 0);
174+
ASSERT_TRUE(res.ok());
175+
void* dptr = res.get();
176+
177+
std::vector<uint8_t> h(N, 0);
178+
EXPECT_EQ(
179+
a.copy_device_to_host(h.data(), dptr, N, missing_device()),
180+
Error::Internal);
181+
182+
a.deallocate(dptr, 0);
183+
}

0 commit comments

Comments
 (0)