Skip to content

Commit 52ef259

Browse files
committed
Handle memory exhaustion exceptions
1 parent e957c5c commit 52ef259

9 files changed

Lines changed: 190 additions & 9 deletions

File tree

‎cuda_core/cuda/core/_cpp/rt/api.hpp‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -239,8 +239,9 @@ StreamHandle deallocation_stream(const DevicePtrHandle& h) noexcept;
239239
// Set the deallocation stream for a device pointer handle.
240240
// Returns CUDA_ERROR_INVALID_CONTEXT when a default-stream token cannot be
241241
// bound because no CUDA context is current.
242+
// May throw std::bad_alloc.
242243
CUresult set_deallocation_stream(
243-
const DevicePtrHandle& h, const StreamHandle& h_stream) noexcept;
244+
const DevicePtrHandle& h, const StreamHandle& h_stream);
244245

245246
// ============================================================================
246247
// Virtual memory management (VMM_DESIGN.md)

‎cuda_core/cuda/core/_cpp/rt/internal.hpp‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,9 @@ struct DeallocationStream {
5959

6060
// Implemented in stream.cpp
6161
ContextHandle deallocation_context(const DeallocationStream& stream) noexcept;
62-
// Implemented in stream.cpp
63-
bool make_deallocation_stream(const StreamHandle& h, DeallocationStream& out) noexcept;
62+
// Implemented in stream.cpp. Not noexcept: binding a default-stream token
63+
// allocates. May throw std::bad_alloc.
64+
bool make_deallocation_stream(const StreamHandle& h, DeallocationStream& out);
6465

6566
// Implemented in virtual_memory.cpp. Synchronize the stream a VMM buffer
6667
// recorded before its mappings are released. Skips the sync, with a report

‎cuda_core/cuda/core/_cpp/rt/memory.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,7 @@ StreamHandle deallocation_stream(const DevicePtrHandle& h) noexcept {
171171
}
172172

173173
// Replace the stream that orders a device pointer's deallocation.
174-
CUresult set_deallocation_stream(const DevicePtrHandle& h, const StreamHandle& h_stream) noexcept {
174+
CUresult set_deallocation_stream(const DevicePtrHandle& h, const StreamHandle& h_stream) {
175175
if (!h) {
176176
return CUDA_ERROR_INVALID_VALUE;
177177
}

‎cuda_core/cuda/core/_cpp/rt/stream.cpp‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,11 @@ StreamHandle create_context_bound_legacy_stream(const ContextHandle& h_context)
189189
namespace detail {
190190
// Real streams are copied unchanged. Default-stream tokens without an embedded
191191
// context are bound to the current context. Returns false (and sets err) when a
192-
// default-stream token cannot be bound because no context is current.
192+
// default-stream token cannot be bound because no context is current. Binding
193+
// a default-stream token allocates (the bound StreamBox and the current-context
194+
// handle), so this may throw std::bad_alloc and must not be noexcept.
193195
bool make_deallocation_stream(
194-
const StreamHandle& h, DeallocationStream& out) noexcept {
196+
const StreamHandle& h, DeallocationStream& out) {
195197
out = {};
196198
if (!h) {
197199
return true;

‎cuda_core/cuda/core/_memory/_buffer.pyx‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,13 @@ cdef class Buffer:
316316
record_stream = mr.is_device_accessible
317317
self._h_ptr = deviceptr_create_with_mr(c_ptr, size, mr)
318318
if record_stream:
319-
_ds_status = set_deallocation_stream(self._h_ptr, s._h_stream)
319+
try:
320+
_ds_status = set_deallocation_stream(self._h_ptr, s._h_stream)
321+
except MemoryError:
322+
# Reset for the same reason as below, inside the handler so the
323+
# destructor cannot swallow the MemoryError that is propagating.
324+
self._h_ptr.reset()
325+
raise
320326
if _ds_status != cydriver.CUresult.CUDA_SUCCESS:
321327
# Reset before raising: the DevicePtrHandle destructor would otherwise
322328
# invoke _mr_dealloc_callback, which catches any inner exception and

‎cuda_core/cuda/core/_rt.pxd‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ cdef void register_mr_dealloc_callback(MRDeallocCallback cb) noexcept
292292
cdef DevicePtrHandle deviceptr_import_ipc(
293293
const MemoryPoolHandle& h_pool, const void* export_data, const StreamHandle& h_stream) except+ nogil
294294
cdef StreamHandle deallocation_stream(const DevicePtrHandle& h) noexcept nogil
295-
cdef cydriver.CUresult set_deallocation_stream(const DevicePtrHandle& h, const StreamHandle& h_stream) noexcept nogil
295+
cdef cydriver.CUresult set_deallocation_stream(const DevicePtrHandle& h, const StreamHandle& h_stream) except+ nogil
296296

297297
# Virtual memory management (VMM_DESIGN.md)
298298
cdef MemAllocationHandle create_mem_allocation_handle(

‎cuda_core/cuda/core/_rt.pyx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ cdef extern from "_cpp/rt/rt.hpp" namespace "cuda_core::rt":
172172
StreamHandle deallocation_stream "cuda_core::rt::deallocation_stream" (
173173
const DevicePtrHandle& h) noexcept nogil
174174
cydriver.CUresult set_deallocation_stream "cuda_core::rt::set_deallocation_stream" (
175-
const DevicePtrHandle& h, const StreamHandle& h_stream) noexcept nogil
175+
const DevicePtrHandle& h, const StreamHandle& h_stream) except+ nogil
176176

177177
# Virtual memory management (VMM_DESIGN.md)
178178
MemAllocationHandle create_mem_allocation_handle "cuda_core::rt::create_mem_allocation_handle" (

‎cuda_core/docs/source/release/1.3.0-notes.rst‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,10 @@ New features
104104
Fixes and enhancements
105105
----------------------
106106

107+
- :meth:`Buffer.set_deallocation_stream` and :meth:`Buffer.from_handle` now raise
108+
:class:`MemoryError` when the host runs out of memory while binding a default
109+
stream, instead of aborting the process with ``std::terminate``.
110+
107111
- :attr:`system.Device.uuid_without_prefix` preserves UUIDs that NVML already
108112
reports without a prefix, such as on Orin. This also allows
109113
:meth:`system.Device.to_cuda_device` to match those devices correctly.
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Host allocation failure in the C++ handle layer must raise, never terminate.
6+
7+
``std::bad_alloc`` that escapes a ``noexcept`` function calls ``std::terminate``
8+
and aborts the interpreter. These tests make the Nth C++ allocation on a thread
9+
throw ``std::bad_alloc`` by preloading a tiny ``operator new`` replacement into a
10+
child process, so an abort shows up as a non-zero exit status of the child
11+
instead of taking down the test run.
12+
"""
13+
14+
import os
15+
import shutil
16+
import subprocess
17+
import sys
18+
import textwrap
19+
20+
import pytest
21+
22+
pytestmark = pytest.mark.skipif(
23+
not sys.platform.startswith("linux"), reason="the allocation shim is preloaded with LD_PRELOAD"
24+
)
25+
26+
# The Nth C++ allocation made by the arming thread throws std::bad_alloc. Python
27+
# allocates through its own allocators, so only C++ allocations are affected.
28+
SHIM_SOURCE = textwrap.dedent(
29+
"""
30+
#include <cstdlib>
31+
#include <new>
32+
33+
static thread_local long countdown = 0;
34+
35+
extern "C" void arm_bad_alloc(long n) { countdown = n; }
36+
37+
void* operator new(std::size_t size) {
38+
if (countdown > 0 && --countdown == 0) {
39+
throw std::bad_alloc();
40+
}
41+
if (void* p = std::malloc(size ? size : 1)) {
42+
return p;
43+
}
44+
throw std::bad_alloc();
45+
}
46+
void operator delete(void* p) noexcept { std::free(p); }
47+
void operator delete(void* p, std::size_t) noexcept { std::free(p); }
48+
"""
49+
)
50+
51+
# Runs in the child. argv[1] is the shim path, argv[2] the scenario.
52+
CHILD = textwrap.dedent(
53+
"""
54+
import ctypes
55+
import sys
56+
57+
shim = ctypes.CDLL(sys.argv[1])
58+
scenario = sys.argv[2]
59+
60+
from cuda.core import Buffer, Device, MemoryResource
61+
from cuda.core._stream import default_stream
62+
from cuda.core._utils.cuda_utils import driver, handle_return
63+
64+
65+
class DeviceMR(MemoryResource):
66+
def allocate(self, size, *, stream=None):
67+
raise NotImplementedError
68+
69+
def deallocate(self, ptr, size, *, stream=None):
70+
handle_return(driver.cuMemFree(ptr))
71+
72+
is_device_accessible = True
73+
is_host_accessible = False
74+
device_id = 0
75+
76+
77+
def arm(n):
78+
shim.arm_bad_alloc(ctypes.c_long(n))
79+
80+
81+
Device(0).set_current()
82+
mr = DeviceMR()
83+
outcomes = set()
84+
85+
if scenario == "set_deallocation_stream":
86+
buf = Buffer.from_handle(handle_return(driver.cuMemAlloc(64)), 64, mr=mr)
87+
token = default_stream()
88+
arm(1)
89+
try:
90+
buf.set_deallocation_stream(token)
91+
except MemoryError:
92+
outcomes.add("MemoryError")
93+
else:
94+
outcomes.add("ok")
95+
finally:
96+
arm(0)
97+
buf.close()
98+
elif scenario == "from_handle":
99+
# Fail each successive allocation on the way through Buffer.from_handle.
100+
for n in range(1, 9):
101+
ptr = handle_return(driver.cuMemAlloc(64))
102+
arm(n)
103+
try:
104+
buf = Buffer.from_handle(ptr, 64, mr=mr)
105+
except MemoryError:
106+
outcomes.add("MemoryError")
107+
else:
108+
outcomes.add("ok")
109+
buf.close()
110+
finally:
111+
arm(0)
112+
else:
113+
raise SystemExit("unknown scenario " + scenario)
114+
115+
print(",".join(sorted(outcomes)))
116+
"""
117+
)
118+
119+
120+
@pytest.fixture(scope="module")
121+
def alloc_shim(tmp_path_factory):
122+
compiler = shutil.which("c++") or shutil.which("g++") or shutil.which("clang++")
123+
if compiler is None:
124+
pytest.skip("no C++ compiler available to build the allocation shim")
125+
workdir = tmp_path_factory.mktemp("alloc_shim")
126+
source = workdir / "shim.cpp"
127+
source.write_text(SHIM_SOURCE)
128+
shim = workdir / "libshim.so"
129+
subprocess.run([compiler, "-shared", "-fPIC", "-O1", "-o", str(shim), str(source)], check=True) # noqa: S603
130+
return shim
131+
132+
133+
def run_child(shim, scenario):
134+
env = dict(os.environ)
135+
preload = [str(shim)]
136+
if env.get("LD_PRELOAD"):
137+
preload.append(env["LD_PRELOAD"])
138+
env["LD_PRELOAD"] = " ".join(preload)
139+
return subprocess.run( # noqa: S603
140+
[sys.executable, "-c", CHILD, str(shim), scenario],
141+
env=env,
142+
capture_output=True,
143+
text=True,
144+
timeout=300,
145+
check=False,
146+
)
147+
148+
149+
@pytest.mark.agent_authored(model="claude-sonnet-5-5")
150+
def test_set_deallocation_stream_raises_memory_error_instead_of_terminating(init_cuda, alloc_shim):
151+
"""Binding a default-stream token allocates; failure there must be a MemoryError.
152+
153+
Before the fix, ``set_deallocation_stream`` and ``make_deallocation_stream``
154+
were ``noexcept``, so the ``std::bad_alloc`` called ``std::terminate`` and the
155+
child died with SIGABRT.
156+
"""
157+
result = run_child(alloc_shim, "set_deallocation_stream")
158+
assert result.returncode == 0, f"child exited with {result.returncode}\n{result.stderr}"
159+
assert result.stdout.strip() == "MemoryError"
160+
161+
162+
@pytest.mark.agent_authored(model="claude-sonnet-5-5")
163+
def test_buffer_from_handle_survives_allocation_failure(init_cuda, alloc_shim):
164+
"""Failing each allocation in ``Buffer.from_handle`` raises MemoryError or succeeds, never aborts."""
165+
result = run_child(alloc_shim, "from_handle")
166+
assert result.returncode == 0, f"child exited with {result.returncode}\n{result.stderr}"
167+
assert "MemoryError" in result.stdout.strip().split(",")

0 commit comments

Comments
 (0)