Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions cuda_core/cuda/core/_memory/_buffer.pyi
Original file line numberDiff line numberDiff line change
Expand Up@@ -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): ...
Expand All@@ -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.

Expand Down
50 changes: 27 additions & 23 deletions cuda_core/cuda/core/_memory/_buffer.pyx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)
Expand DownExpand Up@@ -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")
Expand All@@ -292,22 +289,27 @@ cdef class Buffer:
cdef uintptr_t c_ptr = <uintptr_t>(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
Expand DownExpand Up@@ -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.

Expand Down
16 changes: 16 additions & 0 deletions cuda_core/docs/source/release/1.2.1-notes.rst
Original file line numberDiff line numberDiff line change
@@ -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 <https://github.com/NVIDIA/cuda-python/issues/2769>`__)
70 changes: 70 additions & 0 deletions cuda_core/tests/helpers/buffers.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -13,7 +13,9 @@

__all__ = [
"DummyDeviceMemoryResource",
"DummyHostMemoryResource",
"DummyUnifiedMemoryResource",
"NumpyHostMemoryResource",
"PatternGen",
"StubMemoryResource",
"compare_buffer_to_constant",
Expand DownExpand Up@@ -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
Expand Down
84 changes: 56 additions & 28 deletions cuda_core/tests/test_memory.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@
# SPDX-License-Identifier: Apache-2.0

import ctypes
import multiprocessing as mp
import sys

from cuda.bindings import driver
Expand All@@ -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 (
Expand DownExpand Up@@ -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:
Expand All@@ -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.
Expand DownExpand Up@@ -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."""
Expand Down
Loading