From 7fcefa167bf4fcd55db9d26763486dc8f9f684d5 Mon Sep 17 00:00:00 2001 From: Andy Jost Date: Sat, 5 Sep 2026 08:49:03 -0700 Subject: [PATCH] cuda.core: host-only memory resources need no CUDA context Since 1.2.0, Buffer.from_handle binds a default-stream deallocation token to the current context for every owning memory resource. Memory the device cannot access has no stream ordering to preserve, so skip the binding when mr.is_device_accessible is False. Such buffers record no deallocation stream and never call the driver. Fixes #2769 Co-Authored-By: Claude Sonnet 5 --- cuda_core/cuda/core/_memory/_buffer.pyi | 8 +- cuda_core/cuda/core/_memory/_buffer.pyx | 50 ++++++----- cuda_core/docs/source/release/1.2.1-notes.rst | 16 ++++ cuda_core/tests/helpers/buffers.py | 70 ++++++++++++++++ cuda_core/tests/test_memory.py | 84 ++++++++++++------- 5 files changed, 175 insertions(+), 53 deletions(-) create mode 100644 cuda_core/docs/source/release/1.2.1-notes.rst diff --git a/cuda_core/cuda/core/_memory/_buffer.pyi b/cuda_core/cuda/core/_memory/_buffer.pyi index 114e15a8de3..756b8661488 100644 --- a/cuda_core/cuda/core/_memory/_buffer.pyi +++ b/cuda_core/cuda/core/_memory/_buffer.pyi @@ -42,7 +42,9 @@ class Buffer: is provided, the owner is kept alive but no deallocation is performed. When ``mr`` is provided, a deallocation stream is recorded at creation (``stream`` if given, otherwise ``default_stream()``). Recording a - default-stream token requires a CUDA context to be current. + default-stream token requires a CUDA context to be current. Host-only + resources (``mr.is_device_accessible`` is ``False``) record no stream + and need no context. """ @staticmethod def _reduce_helper(mr, ipc_descriptor): ... @@ -69,7 +71,9 @@ class Buffer: Keyword-only. The stream used to order the buffer's deallocation when ``mr`` owns the pointer. Defaults to ``default_stream()``. Recording a default-stream token requires a CUDA context to be - current. If the buffer may be freed from a different host thread, + current. Host-only resources (``mr.is_device_accessible`` is + ``False``) record no stream and need no context. If the buffer may + be freed from a different host thread, pass a stream other than the per-thread default stream, which refers to a different stream on each thread. diff --git a/cuda_core/cuda/core/_memory/_buffer.pyx b/cuda_core/cuda/core/_memory/_buffer.pyx index 2484ad82b00..52539b8c314 100644 --- a/cuda_core/cuda/core/_memory/_buffer.pyx +++ b/cuda_core/cuda/core/_memory/_buffer.pyx @@ -63,14 +63,9 @@ cdef void _mr_dealloc_callback( cdef Stream stream try: if not h_stream: - print( - "Warning: no deallocation stream was recorded; falling back to " - "the default stream for mr.deallocate() during Buffer " - "destruction. This is an internal cuda-core error; please " - "report it with your CUDA driver, CUDA Toolkit, and " - "cuda-python versions.", - file=sys.stderr, - ) + # No stream was recorded: host-only memory (Buffer._init records + # none) or a Buffer released before one was set. The default-stream + # token needs no CUDA context to construct. stream = default_stream() else: stream = Stream._from_handle(Stream, h_stream) @@ -282,7 +277,9 @@ cdef class Buffer: is provided, the owner is kept alive but no deallocation is performed. When ``mr`` is provided, a deallocation stream is recorded at creation (``stream`` if given, otherwise ``default_stream()``). Recording a - default-stream token requires a CUDA context to be current. + default-stream token requires a CUDA context to be current. Host-only + resources (``mr.is_device_accessible`` is ``False``) record no stream + and need no context. """ if mr is not None and owner is not None: raise ValueError("owner and memory resource cannot be both specified together") @@ -292,22 +289,27 @@ cdef class Buffer: cdef uintptr_t c_ptr = (int(ptr)) cdef Stream s cdef cydriver.CUresult _ds_status + cdef bint record_stream if mr is not None: s = Stream_accept(default_stream() if stream is None else stream) + # Host-only memory needs no CUDA context to free, so no deallocation + # stream is recorded and the driver is not called. + record_stream = mr.is_device_accessible self._h_ptr = deviceptr_create_with_mr(c_ptr, size, mr) - _ds_status = set_deallocation_stream(self._h_ptr, s._h_stream) - if _ds_status != cydriver.CUresult.CUDA_SUCCESS: - # Reset before raising: the DevicePtrHandle destructor would otherwise - # invoke _mr_dealloc_callback, which catches any inner exception and - # clears the exception state, swallowing the error we're about to raise. - self._h_ptr.reset() - if _ds_status == cydriver.CUresult.CUDA_ERROR_INVALID_CONTEXT: - raise RuntimeError( - "Cannot record a default deallocation stream when no CUDA context is " - "current. Call Device.set_current() first, or pass stream= with a " - "non-default Stream." - ) - HANDLE_RETURN(_ds_status) + if record_stream: + _ds_status = set_deallocation_stream(self._h_ptr, s._h_stream) + if _ds_status != cydriver.CUresult.CUDA_SUCCESS: + # Reset before raising: the DevicePtrHandle destructor would otherwise + # invoke _mr_dealloc_callback, which catches any inner exception and + # clears the exception state, swallowing the error we're about to raise. + self._h_ptr.reset() + if _ds_status == cydriver.CUresult.CUDA_ERROR_INVALID_CONTEXT: + raise RuntimeError( + "Cannot record a default deallocation stream when no CUDA context is " + "current. Call Device.set_current() first, or pass stream= with a " + "non-default Stream." + ) + HANDLE_RETURN(_ds_status) else: self._h_ptr = deviceptr_create_with_owner(c_ptr, owner) self._size = size @@ -366,7 +368,9 @@ cdef class Buffer: Keyword-only. The stream used to order the buffer's deallocation when ``mr`` owns the pointer. Defaults to ``default_stream()``. Recording a default-stream token requires a CUDA context to be - current. If the buffer may be freed from a different host thread, + current. Host-only resources (``mr.is_device_accessible`` is + ``False``) record no stream and need no context. If the buffer may + be freed from a different host thread, pass a stream other than the per-thread default stream, which refers to a different stream on each thread. diff --git a/cuda_core/docs/source/release/1.2.1-notes.rst b/cuda_core/docs/source/release/1.2.1-notes.rst new file mode 100644 index 00000000000..bd34d0f50c0 --- /dev/null +++ b/cuda_core/docs/source/release/1.2.1-notes.rst @@ -0,0 +1,16 @@ +.. SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +.. SPDX-License-Identifier: Apache-2.0 + +.. currentmodule:: cuda.core + +``cuda.core`` 1.2.1 Release Notes +================================= + +Fixes and enhancements +---------------------- + +- :meth:`Buffer.from_handle` no longer requires a current CUDA context when + the memory resource reports ``is_device_accessible`` as ``False``. Host-only + memory records no deallocation stream, so creating and closing such buffers + does not call the driver. + (`#2769 `__) diff --git a/cuda_core/tests/helpers/buffers.py b/cuda_core/tests/helpers/buffers.py index 79d90ec5947..92a51429ab3 100644 --- a/cuda_core/tests/helpers/buffers.py +++ b/cuda_core/tests/helpers/buffers.py @@ -13,7 +13,9 @@ __all__ = [ "DummyDeviceMemoryResource", + "DummyHostMemoryResource", "DummyUnifiedMemoryResource", + "NumpyHostMemoryResource", "PatternGen", "StubMemoryResource", "compare_buffer_to_constant", @@ -153,6 +155,74 @@ def device_id(self) -> int: return self.device +class DummyHostMemoryResource(MemoryResource): + # Pure-host ctypes allocation; stream is accepted for interface + # conformance but ignored. + def __init__(self): + pass + + def allocate(self, size, *, stream=None) -> Buffer: + # Allocate a ctypes buffer of size `size` + ptr = (ctypes.c_byte * size)() + self._ptr = ptr + return Buffer.from_handle(ptr=ctypes.addressof(ptr), size=size, mr=self) + + def deallocate(self, ptr, size, *, stream=None): + del self._ptr + + @property + def is_device_accessible(self) -> bool: + return False + + @property + def is_host_accessible(self) -> bool: + return True + + @property + def device_id(self) -> int: + raise RuntimeError("the pinned memory resource is not bound to any GPU") + + +class NumpyHostMemoryResource(MemoryResource): + """Host-only resource backed by ``numpy.empty``, adapted from issue #2769. + + It never touches the CUDA driver, so it must work in a process that has not + initialized CUDA. ``deallocate`` takes ``stream`` positionally, as the + reporter's resource does. + """ + + def __init__(self): + # Strong refs keyed by pointer; Buffer carries only the int address. + self._held = {} + + def allocate(self, size, *, stream=None) -> Buffer: + import numpy as np + + arr = np.empty(size, dtype=np.uint8) + ptr = int(arr.ctypes.data) + self._held[ptr] = arr + return Buffer.from_handle(ptr=ptr, size=size, mr=self) + + def deallocate(self, ptr, size, stream=None): + self._held.pop(int(ptr), None) + + @property + def is_device_accessible(self) -> bool: + return False + + @property + def is_host_accessible(self) -> bool: + return True + + @property + def is_managed(self) -> bool: + return False + + @property + def device_id(self) -> int: + return -1 + + class PatternGen: """ Provides methods to fill a target buffer with known test patterns and diff --git a/cuda_core/tests/test_memory.py b/cuda_core/tests/test_memory.py index 769d780b36b..e36decd1f1d 100644 --- a/cuda_core/tests/test_memory.py +++ b/cuda_core/tests/test_memory.py @@ -2,6 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 import ctypes +import multiprocessing as mp import sys from cuda.bindings import driver @@ -17,11 +18,14 @@ from helpers import supports_ipc_mempool from helpers.buffers import ( DummyDeviceMemoryResource, + DummyHostMemoryResource, DummyUnifiedMemoryResource, + NumpyHostMemoryResource, StubMemoryResource, make_instrumented_memory_resource, thread_unsafe_on_windows, ) +from helpers.child_processes import child_timeout_sec, kill_subprocesses from helpers.constants import POOL_SIZE from helpers.contexts import current_context_handle, no_current_context from helpers.memory import ( @@ -62,6 +66,8 @@ from cuda.core.utils import StridedMemoryView from cuda_python_test_helpers import IS_WINDOWS +CHILD_TIMEOUT_SEC = child_timeout_sec() + def _allocate_pinned_buffer_or_xfail(mr, size, *, device): try: @@ -76,34 +82,6 @@ def _allocate_pinned_buffer_or_xfail(mr, size, *, device): raise -class DummyHostMemoryResource(MemoryResource): - # Pure-host ctypes allocation; stream is accepted for interface - # conformance but ignored. - def __init__(self): - pass - - def allocate(self, size, *, stream=None) -> Buffer: - # Allocate a ctypes buffer of size `size` - ptr = (ctypes.c_byte * size)() - self._ptr = ptr - return Buffer.from_handle(ptr=ctypes.addressof(ptr), size=size, mr=self) - - def deallocate(self, ptr, size, *, stream=None): - del self._ptr - - @property - def is_device_accessible(self) -> bool: - return False - - @property - def is_host_accessible(self) -> bool: - return True - - @property - def device_id(self) -> int: - raise RuntimeError("the pinned memory resource is not bound to any GPU") - - class DummyPinnedMemoryResource(MemoryResource): # cuMemAllocHost / cuMemFreeHost are synchronous; stream is accepted # for interface conformance but ignored. @@ -779,6 +757,56 @@ def test_from_handle_mr_explicit_stream_without_current_context(buffer_type): assert telemetry["deallocations"][-1]["stream"].handle == stream.handle +_HOST_ONLY_MRS = [ + DummyHostMemoryResource, + pytest.param(NumpyHostMemoryResource, marks=pytest.mark.skipif(np is None, reason="numpy is not installed")), +] + + +@pytest.mark.agent_authored(model="claude-fable-5-1") +@pytest.mark.parametrize("mr_cls", _HOST_ONLY_MRS) +def test_from_handle_host_only_mr_without_current_context(mr_cls, capfd): + """Host-only memory needs no current context to create or free a Buffer.""" + device = Device() + device.set_current() + mr = mr_cls() + + previous = handle_return(driver.cuCtxPopCurrent()) + assert int(previous) != 0 + try: + assert int(handle_return(driver.cuCtxGetCurrent())) == 0 + buf = mr.allocate(64) + assert buf.is_host_accessible + buf.close() + assert int(handle_return(driver.cuCtxGetCurrent())) == 0 + finally: + handle_return(driver.cuCtxSetCurrent(previous)) + + assert "Warning" not in capfd.readouterr().err + + +def _host_only_child_main(mr_cls): + """Allocate and free host-only memory in a process that never initialized CUDA.""" + buf = mr_cls().allocate(64) + assert buf.is_host_accessible + buf.close() + err, _ = driver.cuCtxGetCurrent() + assert err == driver.CUresult.CUDA_ERROR_NOT_INITIALIZED, err + + +@pytest.mark.agent_authored(model="claude-fable-5-1") +@pytest.mark.parametrize("mr_cls", _HOST_ONLY_MRS) +def test_from_handle_host_only_mr_without_cuda_init(mr_cls, capfd): + """Host-only buffers work in a spawned process that never initializes CUDA.""" + process = mp.Process(target=_host_only_child_main, args=(mr_cls,)) + process.start() + process.join(timeout=CHILD_TIMEOUT_SEC) + survivors = kill_subprocesses(process) + assert not survivors, "child did not exit within timeout" + assert process.exitcode == 0, f"child exited with {process.exitcode}" + assert "Warning" not in capfd.readouterr().err + + @pytest.mark.agent_authored(model="gpt-5.6") def test_mr_deallocation_failure_warns(capfd): """Destructor-path MR failures are contained and reported."""