From b65554a9936edb1bd3306d07befefefeb26393af Mon Sep 17 00:00:00 2001 From: Rob Parolin Date: Fri, 27 Feb 2026 07:33:27 -0800 Subject: [PATCH 1/7] removing the _buffer usage example from docstring --- cuda_core/cuda/core/_graphics.pyx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/cuda_core/cuda/core/_graphics.pyx b/cuda_core/cuda/core/_graphics.pyx index 4e1620bb2f..302ba6abc7 100644 --- a/cuda_core/cuda/core/_graphics.pyx +++ b/cuda_core/cuda/core/_graphics.pyx @@ -216,13 +216,6 @@ cdef class GraphicsResource: # use buf.handle, buf.size, etc. # automatically unmapped here - Or called directly for explicit control:: - - mapped = resource.map(stream=s) - buf = mapped._buffer # or use mapped.handle, mapped.size - # ... do work ... - resource.unmap(stream=s) - Parameters ---------- stream : :class:`~cuda.core.Stream`, optional From 3539bd8b319f5ae9d994a3ef89fab444f1684290 Mon Sep 17 00:00:00 2001 From: Rob Parolin Date: Fri, 27 Feb 2026 08:32:38 -0800 Subject: [PATCH 2/7] fixes --- cuda_core/cuda/core/_graphics.pxd | 6 +- cuda_core/cuda/core/_graphics.pyx | 133 ++++++++++++++---------------- cuda_core/tests/test_graphics.py | 64 +++++++++----- 3 files changed, 110 insertions(+), 93 deletions(-) diff --git a/cuda_core/cuda/core/_graphics.pxd b/cuda_core/cuda/core/_graphics.pxd index 9a8eb84f50..dabce3f860 100644 --- a/cuda_core/cuda/core/_graphics.pxd +++ b/cuda_core/cuda/core/_graphics.pxd @@ -3,12 +3,14 @@ # SPDX-License-Identifier: Apache-2.0 from cuda.core._resource_handles cimport GraphicsResourceHandle +from cuda.core._memory._buffer cimport Buffer -cdef class GraphicsResource: +cdef class GraphicsResource(Buffer): cdef: GraphicsResourceHandle _handle bint _mapped + object _map_stream - cpdef close(self) + cpdef close(self, stream=*) diff --git a/cuda_core/cuda/core/_graphics.pyx b/cuda_core/cuda/core/_graphics.pyx index 302ba6abc7..d0fd21bd4b 100644 --- a/cuda_core/cuda/core/_graphics.pyx +++ b/cuda_core/cuda/core/_graphics.pyx @@ -7,14 +7,14 @@ from __future__ import annotations from cuda.bindings cimport cydriver from cuda.core._resource_handles cimport ( create_graphics_resource_handle, + deviceptr_create_with_owner, as_cu, as_intptr, ) +from cuda.core._memory._buffer cimport Buffer from cuda.core._stream cimport Stream, Stream_accept from cuda.core._utils.cuda_utils cimport HANDLE_RETURN -from cuda.core._memory import Buffer - __all__ = ['GraphicsResource'] _REGISTER_FLAGS = { @@ -43,47 +43,18 @@ def _parse_register_flags(flags): return result -class _MappedBufferContext: - """Context manager returned by :meth:`GraphicsResource.map`. - - Wraps a :class:`~cuda.core.Buffer` and ensures the graphics resource - is unmapped when the context exits. Can also be used without ``with`` - by calling :meth:`GraphicsResource.unmap` explicitly. - """ - __slots__ = ('_buffer', '_resource', '_stream') - - def __init__(self, buffer, resource, stream): - self._buffer = buffer - self._resource = resource - self._stream = stream - - def __enter__(self): - return self._buffer - - def __exit__(self, exc_type, exc_val, exc_tb): - self._resource.unmap(stream=self._stream) - return False - - # Delegate Buffer attributes so the return value of map() is directly usable - @property - def handle(self): - return self._buffer.handle - - @property - def size(self): - return self._buffer.size - - def __repr__(self): - return repr(self._buffer) - - -cdef class GraphicsResource: +cdef class GraphicsResource(Buffer): """RAII wrapper for a CUDA graphics resource (``CUgraphicsResource``). A :class:`GraphicsResource` represents an OpenGL buffer or image that has been registered for access by CUDA. This enables zero-copy sharing of GPU data between CUDA compute kernels and graphics renderers. + :class:`GraphicsResource` inherits from :class:`~cuda.core.Buffer`, so when + mapped it can be used directly anywhere a :class:`~cuda.core.Buffer` is + expected. The buffer properties (:attr:`handle`, :attr:`size`) are only + valid while the resource is mapped. + The resource is automatically unregistered when :meth:`close` is called or when the object is garbage collected. @@ -92,8 +63,7 @@ cdef class GraphicsResource: Examples -------- - Register an OpenGL VBO, map it to get a :class:`~cuda.core.Buffer`, and - write to it from CUDA: + Register an OpenGL VBO, map it to get a buffer, and write to it from CUDA: .. code-block:: python @@ -107,8 +77,8 @@ cdef class GraphicsResource: .. code-block:: python - buf = resource.map(stream=s) - # ... launch kernels using buf ... + resource.map(stream=s) + # ... launch kernels using resource.handle, resource.size ... resource.unmap(stream=s) """ @@ -157,6 +127,7 @@ cdef class GraphicsResource: ) self._handle = create_graphics_resource_handle(resource) self._mapped = False + self._map_stream = None return self @classmethod @@ -202,32 +173,32 @@ cdef class GraphicsResource: ) self._handle = create_graphics_resource_handle(resource) self._mapped = False + self._map_stream = None return self - def map(self, *, stream: Stream | None = None): + def map(self, *, stream: Stream): """Map this graphics resource for CUDA access. - After mapping, a CUDA device pointer into the underlying graphics - memory is available as a :class:`~cuda.core.Buffer`. + After mapping, the CUDA device pointer and size are available via + the inherited :attr:`~cuda.core.Buffer.handle` and + :attr:`~cuda.core.Buffer.size` properties. Can be used as a context manager for automatic unmapping:: with resource.map(stream=s) as buf: + # buf IS the GraphicsResource, which IS-A Buffer # use buf.handle, buf.size, etc. # automatically unmapped here Parameters ---------- - stream : :class:`~cuda.core.Stream`, optional - The CUDA stream on which to perform the mapping. If ``None``, - the default stream (``0``) is used. + stream : :class:`~cuda.core.Stream` + The CUDA stream on which to perform the mapping. Returns ------- - _MappedBufferContext - An object that is both a context manager and provides access - to the underlying :class:`~cuda.core.Buffer`. When used with - ``with``, the resource is unmapped on exit. + GraphicsResource + Returns ``self`` (which is a :class:`~cuda.core.Buffer`). Raises ------ @@ -241,12 +212,9 @@ cdef class GraphicsResource: if self._mapped: raise RuntimeError("GraphicsResource is already mapped") + cdef Stream s_obj = Stream_accept(stream) cdef cydriver.CUgraphicsResource raw = as_cu(self._handle) - cdef cydriver.CUstream cy_stream = 0 - cdef Stream s_obj = None - if stream is not None: - s_obj = Stream_accept(stream) - cy_stream = as_cu(s_obj._h_stream) + cdef cydriver.CUstream cy_stream = as_cu(s_obj._h_stream) cdef cydriver.CUdeviceptr dev_ptr = 0 cdef size_t size = 0 @@ -258,20 +226,24 @@ cdef class GraphicsResource: cydriver.cuGraphicsResourceGetMappedPointer(&dev_ptr, &size, raw) ) self._mapped = True - buf = Buffer.from_handle(int(dev_ptr), size, owner=self) - return _MappedBufferContext(buf, self, stream) + # Populate Buffer internals with the mapped device pointer + self._h_ptr = deviceptr_create_with_owner(dev_ptr, None) + self._size = size + self._owner = None + self._mem_attrs_inited = False + self._map_stream = stream + return self - def unmap(self, *, stream: Stream | None = None): + def unmap(self, *, stream: Stream): """Unmap this graphics resource, releasing it back to the graphics API. - After unmapping, the :class:`~cuda.core.Buffer` previously returned - by :meth:`map` must not be used. + After unmapping, the buffer properties (:attr:`handle`, :attr:`size`) + are no longer valid. Parameters ---------- - stream : :class:`~cuda.core.Stream`, optional - The CUDA stream on which to perform the unmapping. If ``None``, - the default stream (``0``) is used. + stream : :class:`~cuda.core.Stream` + The CUDA stream on which to perform the unmapping. Raises ------ @@ -285,34 +257,55 @@ cdef class GraphicsResource: if not self._mapped: raise RuntimeError("GraphicsResource is not mapped") + cdef Stream s_obj = Stream_accept(stream) cdef cydriver.CUgraphicsResource raw = as_cu(self._handle) - cdef cydriver.CUstream cy_stream = 0 - if stream is not None: - cy_stream = as_cu((Stream_accept(stream))._h_stream) + cdef cydriver.CUstream cy_stream = as_cu(s_obj._h_stream) with nogil: HANDLE_RETURN( cydriver.cuGraphicsUnmapResources(1, &raw, cy_stream) ) self._mapped = False + # Clear Buffer fields + self._h_ptr.reset() + self._size = 0 + self._map_stream = None + + def __enter__(self): + return self - cpdef close(self): + def __exit__(self, exc_type, exc_val, exc_tb): + if self._mapped: + self.unmap(stream=self._map_stream) + return False + + cpdef close(self, stream=None): """Unregister this graphics resource from CUDA. If the resource is currently mapped, it is unmapped first (on the default stream). After closing, the resource cannot be used again. + + Parameters + ---------- + stream : :class:`~cuda.core.Stream`, optional + Accepted for compatibility with :meth:`Buffer.close` but not + used for the graphics unmap/unregister operations. """ cdef cydriver.CUgraphicsResource raw cdef cydriver.CUstream cy_stream if not self._handle: return if self._mapped: - # Best-effort unmap before unregister + # Best-effort unmap before unregister (use stream 0 as fallback) raw = as_cu(self._handle) cy_stream = 0 with nogil: cydriver.cuGraphicsUnmapResources(1, &raw, cy_stream) self._mapped = False self._handle.reset() + # Clear Buffer fields + self._h_ptr.reset() + self._size = 0 + self._map_stream = None @property def is_mapped(self) -> bool: @@ -320,7 +313,7 @@ cdef class GraphicsResource: return self._mapped @property - def handle(self) -> int: + def resource_handle(self) -> int: """The raw ``CUgraphicsResource`` handle as a Python int.""" return as_intptr(self._handle) diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index a0dfc73edc..d52d7bf9e0 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -138,6 +138,13 @@ def _gl_context_and_texture(width=16, height=16): pass +def _create_stream(): + """Create a CUDA stream for testing.""" + dev = Device(0) + dev.set_current() + return dev.create_stream() + + # --------------------------------------------------------------------------- # Register flags parsing tests # --------------------------------------------------------------------------- @@ -188,14 +195,15 @@ class TestFromGLBuffer: def test_register_default_flags(self): with _gl_context_and_buffer() as (gl_buf, nbytes): resource = GraphicsResource.from_gl_buffer(gl_buf) - assert resource.handle != 0 + assert resource.resource_handle != 0 + assert isinstance(resource, Buffer) assert not resource.is_mapped resource.close() def test_register_write_discard(self): with _gl_context_and_buffer() as (gl_buf, nbytes): resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - assert resource.handle != 0 + assert resource.resource_handle != 0 resource.close() def test_close_is_idempotent(self): @@ -214,7 +222,7 @@ class TestFromGLImage: def test_register_image(self): with _gl_context_and_texture() as (tex_id, target): resource = GraphicsResource.from_gl_image(tex_id, target) - assert resource.handle != 0 + assert resource.resource_handle != 0 assert not resource.is_mapped resource.close() @@ -225,22 +233,24 @@ def test_register_image(self): class TestMapUnmap: - def test_map_returns_buffer(self): + def test_map_returns_self(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - mapped = resource.map() + mapped = resource.map(stream=stream) assert resource.is_mapped - # mapped is a _MappedBufferContext; its .handle and .size delegate to Buffer + assert mapped is resource assert mapped.size > 0 assert mapped.handle != 0 - resource.unmap() + resource.unmap(stream=stream) assert not resource.is_mapped resource.close() def test_context_manager_unmaps(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - with resource.map() as buf: + with resource.map(stream=stream) as buf: assert isinstance(buf, Buffer) assert resource.is_mapped assert buf.size > 0 @@ -249,8 +259,9 @@ def test_context_manager_unmaps(self): def test_context_manager_unmaps_on_exception(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - with pytest.raises(ValueError, match="test error"), resource.map() as _buf: + with pytest.raises(ValueError, match="test error"), resource.map(stream=stream) as _buf: assert resource.is_mapped raise ValueError("test error") # Must be unmapped even after exception @@ -261,8 +272,9 @@ def test_strided_memory_view_from_mapped_buffer(self): """End-to-end: register, map, create StridedMemoryView.""" nbytes = 256 * 4 # 256 float32 elements with _gl_context_and_buffer(nbytes=nbytes) as (gl_buf, _): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - with resource.map() as buf: + with resource.map(stream=stream) as buf: view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) assert view.ptr == int(buf.handle) assert view.shape == (256,) @@ -271,9 +283,7 @@ def test_strided_memory_view_from_mapped_buffer(self): def test_map_with_stream(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): - dev = Device(0) - dev.set_current() - stream = dev.create_stream() + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") with resource.map(stream=stream) as buf: assert buf.size > 0 @@ -288,39 +298,44 @@ def test_map_with_stream(self): class TestErrorHandling: def test_double_map_raises(self): with _gl_context_and_buffer() as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf) - resource.map() + resource.map(stream=stream) with pytest.raises(RuntimeError, match="already mapped"): - resource.map() - resource.unmap() + resource.map(stream=stream) + resource.unmap(stream=stream) resource.close() def test_unmap_without_map_raises(self): with _gl_context_and_buffer() as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf) with pytest.raises(RuntimeError, match="not mapped"): - resource.unmap() + resource.unmap(stream=stream) resource.close() def test_map_after_close_raises(self): with _gl_context_and_buffer() as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf) resource.close() with pytest.raises(RuntimeError, match="has been closed"): - resource.map() + resource.map(stream=stream) def test_unmap_after_close_raises(self): with _gl_context_and_buffer() as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf) resource.close() with pytest.raises(RuntimeError, match="has been closed"): - resource.unmap() + resource.unmap(stream=stream) def test_close_while_mapped(self): """close() should unmap before unregistering.""" with _gl_context_and_buffer() as (gl_buf, nbytes): + stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - resource.map() + resource.map(stream=stream) assert resource.is_mapped resource.close() # Should unmap + unregister without error assert not resource.is_mapped @@ -336,7 +351,7 @@ def test_gc_cleanup(self): """Creating and dropping a resource should not leak.""" with _gl_context_and_buffer() as (gl_buf, nbytes): resource = GraphicsResource.from_gl_buffer(gl_buf) - assert resource.handle != 0 + assert resource.resource_handle != 0 del resource gc.collect() # If we get here without a CUDA error, cleanup succeeded. @@ -355,3 +370,10 @@ def test_repr_closed(self): resource.close() r = repr(resource) assert "closed" in r + + def test_isinstance_buffer(self): + """GraphicsResource should be an instance of Buffer.""" + with _gl_context_and_buffer() as (gl_buf, nbytes): + resource = GraphicsResource.from_gl_buffer(gl_buf) + assert isinstance(resource, Buffer) + resource.close() From dbc41f2035e73787ba0f497c551718861d1a1e2f Mon Sep 17 00:00:00 2001 From: Rob Parolin Date: Fri, 27 Feb 2026 11:03:05 -0800 Subject: [PATCH 3/7] fixes --- cuda_core/cuda/core/_graphics.pyx | 15 +++++++++++---- cuda_core/tests/test_graphics.py | 15 +++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/cuda_core/cuda/core/_graphics.pyx b/cuda_core/cuda/core/_graphics.pyx index d0fd21bd4b..3530963fd1 100644 --- a/cuda_core/cuda/core/_graphics.pyx +++ b/cuda_core/cuda/core/_graphics.pyx @@ -67,9 +67,7 @@ cdef class GraphicsResource(Buffer): .. code-block:: python - resource = GraphicsResource.from_gl_buffer(vbo) - - with resource.map(stream=s) as buf: + with GraphicsResource.from_gl_buffer(vbo, stream=s) as buf: view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) # view.ptr is a CUDA device pointer into the GL buffer @@ -89,7 +87,7 @@ cdef class GraphicsResource(Buffer): ) @classmethod - def from_gl_buffer(cls, int gl_buffer, *, flags=None) -> GraphicsResource: + def from_gl_buffer(cls, int gl_buffer, *, flags=None, stream=None) -> GraphicsResource: """Register an OpenGL buffer object for CUDA access. Parameters @@ -103,11 +101,18 @@ cdef class GraphicsResource(Buffer): Multiple flags can be combined by passing a sequence (e.g., ``("surface_load_store", "read_only")``). Defaults to ``None`` (no flags). + stream : :class:`~cuda.core.Stream`, optional + If provided, the resource is immediately mapped on this stream + so it can be used directly as a context manager:: + + with GraphicsResource.from_gl_buffer(vbo, stream=s) as buf: + view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) Returns ------- GraphicsResource A new graphics resource wrapping the registered GL buffer. + If *stream* was given, the resource is already mapped. Raises ------ @@ -128,6 +133,8 @@ cdef class GraphicsResource(Buffer): self._handle = create_graphics_resource_handle(resource) self._mapped = False self._map_stream = None + if stream is not None: + self.map(stream=stream) return self @classmethod diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index d52d7bf9e0..fc75015b70 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -281,6 +281,21 @@ def test_strided_memory_view_from_mapped_buffer(self): assert view.is_device_accessible resource.close() + def test_from_gl_buffer_with_stream_context_manager(self): + """Register + auto-map via from_gl_buffer(stream=), then create StridedMemoryView.""" + nbytes = 256 * 4 # 256 float32 elements + with _gl_context_and_buffer(nbytes=nbytes) as (gl_buf, _): + stream = _create_stream() + with GraphicsResource.from_gl_buffer(gl_buf, stream=stream) as buf: + assert buf.is_mapped + assert buf.size == nbytes + view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) + assert view.ptr == int(buf.handle) + assert view.shape == (256,) + assert view.is_device_accessible + assert not buf.is_mapped + buf.close() + def test_map_with_stream(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): stream = _create_stream() From 0c9a981aa96e565875bc618ee9766ae400f341aa Mon Sep 17 00:00:00 2001 From: Rob Parolin Date: Mon, 16 Mar 2026 15:17:42 -0700 Subject: [PATCH 4/7] graphics: map GL resources to RAII buffers --- cuda_core/cuda/core/_cpp/resource_handles.cpp | 18 ++ cuda_core/cuda/core/_cpp/resource_handles.hpp | 11 + cuda_core/cuda/core/_graphics.pxd | 8 +- cuda_core/cuda/core/_graphics.pyx | 198 ++++++++++-------- cuda_core/cuda/core/_memory/_buffer.pyx | 7 + cuda_core/cuda/core/_resource_handles.pxd | 4 + cuda_core/cuda/core/_resource_handles.pyx | 6 + cuda_core/tests/test_graphics.py | 90 ++++++-- 8 files changed, 231 insertions(+), 111 deletions(-) diff --git a/cuda_core/cuda/core/_cpp/resource_handles.cpp b/cuda_core/cuda/core/_cpp/resource_handles.cpp index 033fa603e7..c9315424c2 100644 --- a/cuda_core/cuda/core/_cpp/resource_handles.cpp +++ b/cuda_core/cuda/core/_cpp/resource_handles.cpp @@ -57,6 +57,7 @@ decltype(&cuLibraryUnload) p_cuLibraryUnload = nullptr; decltype(&cuLibraryGetKernel) p_cuLibraryGetKernel = nullptr; // GL interop pointers +decltype(&cuGraphicsUnmapResources) p_cuGraphicsUnmapResources = nullptr; decltype(&cuGraphicsUnregisterResource) p_cuGraphicsUnregisterResource = nullptr; // NVRTC function pointers @@ -569,6 +570,23 @@ DevicePtrHandle deviceptr_create_with_owner(CUdeviceptr ptr, PyObject* owner) { return DevicePtrHandle(box, &box->resource); } +DevicePtrHandle deviceptr_create_mapped_graphics( + CUdeviceptr ptr, + const GraphicsResourceHandle& h_resource, + const StreamHandle& h_stream +) { + auto box = std::shared_ptr( + new DevicePtrBox{ptr, h_stream}, + [h_resource](DevicePtrBox* b) { + GILReleaseGuard gil; + CUgraphicsResource resource = as_cu(h_resource); + p_cuGraphicsUnmapResources(1, &resource, as_cu(b->h_stream)); + delete b; + } + ); + return DevicePtrHandle(box, &box->resource); +} + // ============================================================================ // MemoryResource-owned Device Pointer Handles // ============================================================================ diff --git a/cuda_core/cuda/core/_cpp/resource_handles.hpp b/cuda_core/cuda/core/_cpp/resource_handles.hpp index d91f999ac6..bb408ced05 100644 --- a/cuda_core/cuda/core/_cpp/resource_handles.hpp +++ b/cuda_core/cuda/core/_cpp/resource_handles.hpp @@ -73,6 +73,7 @@ extern decltype(&cuLibraryUnload) p_cuLibraryUnload; extern decltype(&cuLibraryGetKernel) p_cuLibraryGetKernel; // Graphics interop +extern decltype(&cuGraphicsUnmapResources) p_cuGraphicsUnmapResources; extern decltype(&cuGraphicsUnregisterResource) p_cuGraphicsUnregisterResource; // ============================================================================ @@ -244,6 +245,16 @@ DevicePtrHandle deviceptr_create_ref(CUdeviceptr ptr); // If owner is nullptr, equivalent to deviceptr_create_ref. DevicePtrHandle deviceptr_create_with_owner(CUdeviceptr ptr, PyObject* owner); +// Create a device pointer handle for a mapped graphics resource. +// The pointer structurally depends on the provided graphics resource handle. +// When the last reference is released, cuGraphicsUnmapResources is called on +// the stored stream, then the graphics resource may be unregistered when its +// own handle is released. +DevicePtrHandle deviceptr_create_mapped_graphics( + CUdeviceptr ptr, + const GraphicsResourceHandle& h_resource, + const StreamHandle& h_stream); + // Callback type for MemoryResource deallocation. // Called from the shared_ptr deleter when a handle created via // deviceptr_create_with_mr is destroyed. The implementation is responsible diff --git a/cuda_core/cuda/core/_graphics.pxd b/cuda_core/cuda/core/_graphics.pxd index dabce3f860..520a366bbd 100644 --- a/cuda_core/cuda/core/_graphics.pxd +++ b/cuda_core/cuda/core/_graphics.pxd @@ -3,14 +3,14 @@ # SPDX-License-Identifier: Apache-2.0 from cuda.core._resource_handles cimport GraphicsResourceHandle -from cuda.core._memory._buffer cimport Buffer -cdef class GraphicsResource(Buffer): +cdef class GraphicsResource: cdef: GraphicsResourceHandle _handle - bint _mapped - object _map_stream + object _mapped_buffer + object _context_manager_stream + object _entered_buffer cpdef close(self, stream=*) diff --git a/cuda_core/cuda/core/_graphics.pyx b/cuda_core/cuda/core/_graphics.pyx index 3530963fd1..c6a50e8421 100644 --- a/cuda_core/cuda/core/_graphics.pyx +++ b/cuda_core/cuda/core/_graphics.pyx @@ -7,12 +7,12 @@ from __future__ import annotations from cuda.bindings cimport cydriver from cuda.core._resource_handles cimport ( create_graphics_resource_handle, - deviceptr_create_with_owner, + deviceptr_create_mapped_graphics, as_cu, as_intptr, ) -from cuda.core._memory._buffer cimport Buffer -from cuda.core._stream cimport Stream, Stream_accept +from cuda.core._memory._buffer cimport Buffer, Buffer_from_deviceptr_handle +from cuda.core._stream cimport Stream, Stream_accept, default_stream from cuda.core._utils.cuda_utils cimport HANDLE_RETURN __all__ = ['GraphicsResource'] @@ -43,17 +43,17 @@ def _parse_register_flags(flags): return result -cdef class GraphicsResource(Buffer): +cdef class GraphicsResource: """RAII wrapper for a CUDA graphics resource (``CUgraphicsResource``). A :class:`GraphicsResource` represents an OpenGL buffer or image that has been registered for access by CUDA. This enables zero-copy sharing of GPU data between CUDA compute kernels and graphics renderers. - :class:`GraphicsResource` inherits from :class:`~cuda.core.Buffer`, so when - mapped it can be used directly anywhere a :class:`~cuda.core.Buffer` is - expected. The buffer properties (:attr:`handle`, :attr:`size`) are only - valid while the resource is mapped. + Mapping the resource returns a :class:`~cuda.core.Buffer` whose lifetime + controls when the graphics resource is unmapped. This keeps stream-ordered + cleanup tied to the mapped pointer itself rather than to mutable state on + the :class:`GraphicsResource` object. The resource is automatically unregistered when :meth:`close` is called or when the object is garbage collected. @@ -67,17 +67,19 @@ cdef class GraphicsResource(Buffer): .. code-block:: python - with GraphicsResource.from_gl_buffer(vbo, stream=s) as buf: + resource = GraphicsResource.from_gl_buffer(vbo) + + with resource.map(stream=s) as buf: view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) # view.ptr is a CUDA device pointer into the GL buffer - Or use explicit map/unmap for render loops: + Or use explicit control for render loops: .. code-block:: python - resource.map(stream=s) - # ... launch kernels using resource.handle, resource.size ... - resource.unmap(stream=s) + buf = resource.map(stream=s) + # ... launch kernels using buf.handle, buf.size ... + buf.close() """ def __init__(self): @@ -102,8 +104,8 @@ cdef class GraphicsResource(Buffer): (e.g., ``("surface_load_store", "read_only")``). Defaults to ``None`` (no flags). stream : :class:`~cuda.core.Stream`, optional - If provided, the resource is immediately mapped on this stream - so it can be used directly as a context manager:: + If provided, the resource can be used directly as a context manager + and it will be mapped on entry:: with GraphicsResource.from_gl_buffer(vbo, stream=s) as buf: view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) @@ -112,7 +114,8 @@ cdef class GraphicsResource(Buffer): ------- GraphicsResource A new graphics resource wrapping the registered GL buffer. - If *stream* was given, the resource is already mapped. + If *stream* was given, the returned resource can be used directly + as a context manager. Raises ------ @@ -131,10 +134,9 @@ cdef class GraphicsResource(Buffer): cydriver.cuGraphicsGLRegisterBuffer(&resource, cy_buffer, cy_flags) ) self._handle = create_graphics_resource_handle(resource) - self._mapped = False - self._map_stream = None - if stream is not None: - self.map(stream=stream) + self._mapped_buffer = None + self._context_manager_stream = stream + self._entered_buffer = None return self @classmethod @@ -179,33 +181,44 @@ cdef class GraphicsResource(Buffer): cydriver.cuGraphicsGLRegisterImage(&resource, cy_image, cy_target, cy_flags) ) self._handle = create_graphics_resource_handle(resource) - self._mapped = False - self._map_stream = None + self._mapped_buffer = None + self._context_manager_stream = None + self._entered_buffer = None return self - def map(self, *, stream: Stream): + def _get_mapped_buffer(self): + cdef Buffer buf + if self._mapped_buffer is None: + return None + buf = self._mapped_buffer + if not buf._h_ptr: + self._mapped_buffer = None + return None + return self._mapped_buffer + + def map(self, *, stream: Stream | None = None) -> Buffer: """Map this graphics resource for CUDA access. - After mapping, the CUDA device pointer and size are available via - the inherited :attr:`~cuda.core.Buffer.handle` and - :attr:`~cuda.core.Buffer.size` properties. + After mapping, a CUDA device pointer into the underlying graphics + memory is available as a :class:`~cuda.core.Buffer`. Can be used as a context manager for automatic unmapping:: with resource.map(stream=s) as buf: - # buf IS the GraphicsResource, which IS-A Buffer # use buf.handle, buf.size, etc. # automatically unmapped here Parameters ---------- - stream : :class:`~cuda.core.Stream` - The CUDA stream on which to perform the mapping. + stream : :class:`~cuda.core.Stream`, optional + The CUDA stream on which to perform the mapping. If ``None``, + the current default stream is used. Returns ------- - GraphicsResource - Returns ``self`` (which is a :class:`~cuda.core.Buffer`). + Buffer + A buffer whose lifetime controls when the graphics resource is + unmapped. Raises ------ @@ -214,17 +227,20 @@ cdef class GraphicsResource(Buffer): CUDAError If the mapping fails. """ + cdef Stream s_obj + cdef cydriver.CUgraphicsResource raw + cdef cydriver.CUstream cy_stream + cdef cydriver.CUdeviceptr dev_ptr = 0 + cdef size_t size = 0 + cdef Buffer buf if not self._handle: raise RuntimeError("GraphicsResource has been closed") - if self._mapped: + if self._get_mapped_buffer() is not None: raise RuntimeError("GraphicsResource is already mapped") - cdef Stream s_obj = Stream_accept(stream) - cdef cydriver.CUgraphicsResource raw = as_cu(self._handle) - cdef cydriver.CUstream cy_stream = as_cu(s_obj._h_stream) - - cdef cydriver.CUdeviceptr dev_ptr = 0 - cdef size_t size = 0 + s_obj = default_stream() if stream is None else Stream_accept(stream) + raw = as_cu(self._handle) + cy_stream = as_cu(s_obj._h_stream) with nogil: HANDLE_RETURN( cydriver.cuGraphicsMapResources(1, &raw, cy_stream) @@ -232,25 +248,26 @@ cdef class GraphicsResource(Buffer): HANDLE_RETURN( cydriver.cuGraphicsResourceGetMappedPointer(&dev_ptr, &size, raw) ) - self._mapped = True - # Populate Buffer internals with the mapped device pointer - self._h_ptr = deviceptr_create_with_owner(dev_ptr, None) - self._size = size - self._owner = None - self._mem_attrs_inited = False - self._map_stream = stream - return self + buf = Buffer_from_deviceptr_handle( + deviceptr_create_mapped_graphics(dev_ptr, self._handle, s_obj._h_stream), + size, + None, + None, + ) + self._mapped_buffer = buf + return buf - def unmap(self, *, stream: Stream): + def unmap(self, *, stream: Stream | None = None): """Unmap this graphics resource, releasing it back to the graphics API. - After unmapping, the buffer properties (:attr:`handle`, :attr:`size`) - are no longer valid. + After unmapping, the :class:`~cuda.core.Buffer` previously returned + by :meth:`map` must not be used. Parameters ---------- - stream : :class:`~cuda.core.Stream` - The CUDA stream on which to perform the unmapping. + stream : :class:`~cuda.core.Stream`, optional + If provided, overrides the stream that will be used when the + mapped buffer is closed. Otherwise the mapping stream is reused. Raises ------ @@ -259,72 +276,77 @@ cdef class GraphicsResource(Buffer): CUDAError If the unmapping fails. """ + cdef object buf_obj + cdef Buffer buf if not self._handle: raise RuntimeError("GraphicsResource has been closed") - if not self._mapped: + buf_obj = self._get_mapped_buffer() + if buf_obj is None: raise RuntimeError("GraphicsResource is not mapped") - - cdef Stream s_obj = Stream_accept(stream) - cdef cydriver.CUgraphicsResource raw = as_cu(self._handle) - cdef cydriver.CUstream cy_stream = as_cu(s_obj._h_stream) - with nogil: - HANDLE_RETURN( - cydriver.cuGraphicsUnmapResources(1, &raw, cy_stream) - ) - self._mapped = False - # Clear Buffer fields - self._h_ptr.reset() - self._size = 0 - self._map_stream = None + buf = buf_obj + buf.close(stream=stream) + self._mapped_buffer = None def __enter__(self): - return self + if self._context_manager_stream is None: + raise RuntimeError( + "GraphicsResource context manager requires a stream; " + "use resource.map(stream=...) or pass stream= to from_gl_buffer()" + ) + self._entered_buffer = self.map(stream=self._context_manager_stream) + return self._entered_buffer def __exit__(self, exc_type, exc_val, exc_tb): - if self._mapped: - self.unmap(stream=self._map_stream) + cdef object buf_obj = self._entered_buffer + cdef Buffer buf + self._entered_buffer = None + if buf_obj is not None: + buf = buf_obj + if buf._h_ptr: + buf.close() return False cpdef close(self, stream=None): """Unregister this graphics resource from CUDA. - If the resource is currently mapped, it is unmapped first (on the - default stream). After closing, the resource cannot be used again. + If the resource is currently mapped, it is unmapped first. After + closing, the resource cannot be used again. Parameters ---------- stream : :class:`~cuda.core.Stream`, optional - Accepted for compatibility with :meth:`Buffer.close` but not - used for the graphics unmap/unregister operations. + Optional override for the stream used to close the currently + mapped buffer, if one exists. """ - cdef cydriver.CUgraphicsResource raw - cdef cydriver.CUstream cy_stream + cdef object buf_obj + cdef Buffer buf if not self._handle: return - if self._mapped: - # Best-effort unmap before unregister (use stream 0 as fallback) - raw = as_cu(self._handle) - cy_stream = 0 - with nogil: - cydriver.cuGraphicsUnmapResources(1, &raw, cy_stream) - self._mapped = False + buf_obj = self._get_mapped_buffer() + if buf_obj is not None: + buf = buf_obj + buf.close(stream=stream) + self._mapped_buffer = None self._handle.reset() - # Clear Buffer fields - self._h_ptr.reset() - self._size = 0 - self._map_stream = None + self._context_manager_stream = None + self._entered_buffer = None @property def is_mapped(self) -> bool: """Whether the resource is currently mapped for CUDA access.""" - return self._mapped + return self._get_mapped_buffer() is not None @property - def resource_handle(self) -> int: + def handle(self) -> int: """The raw ``CUgraphicsResource`` handle as a Python int.""" return as_intptr(self._handle) + @property + def resource_handle(self) -> int: + """Alias for :attr:`handle`.""" + return self.handle + def __repr__(self): - mapped_str = " mapped" if self._mapped else "" + mapped_str = " mapped" if self.is_mapped else "" closed_str = " closed" if not self._handle else "" return f"" diff --git a/cuda_core/cuda/core/_memory/_buffer.pyx b/cuda_core/cuda/core/_memory/_buffer.pyx index 83009f74ae..0c0f0800f1 100644 --- a/cuda_core/cuda/core/_memory/_buffer.pyx +++ b/cuda_core/cuda/core/_memory/_buffer.pyx @@ -189,6 +189,13 @@ cdef class Buffer: """ Buffer_close(self, stream) + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + self.close() + return False + def copy_to(self, dst: Buffer = None, *, stream: Stream | GraphBuilder) -> Buffer: """Copy from this buffer to the dst buffer asynchronously on the given stream. diff --git a/cuda_core/cuda/core/_resource_handles.pxd b/cuda_core/cuda/core/_resource_handles.pxd index 7a53a4f25f..562395b1db 100644 --- a/cuda_core/cuda/core/_resource_handles.pxd +++ b/cuda_core/cuda/core/_resource_handles.pxd @@ -112,6 +112,10 @@ cdef DevicePtrHandle deviceptr_alloc(size_t size) except+ nogil cdef DevicePtrHandle deviceptr_alloc_host(size_t size) except+ nogil cdef DevicePtrHandle deviceptr_create_ref(cydriver.CUdeviceptr ptr) except+ nogil cdef DevicePtrHandle deviceptr_create_with_owner(cydriver.CUdeviceptr ptr, object owner) except+ nogil +cdef DevicePtrHandle deviceptr_create_mapped_graphics( + cydriver.CUdeviceptr ptr, + const GraphicsResourceHandle& h_resource, + const StreamHandle& h_stream) except+ nogil cdef DevicePtrHandle deviceptr_create_with_mr( cydriver.CUdeviceptr ptr, size_t size, object mr) except+ nogil diff --git a/cuda_core/cuda/core/_resource_handles.pyx b/cuda_core/cuda/core/_resource_handles.pyx index 47d0a86d04..57143c2d30 100644 --- a/cuda_core/cuda/core/_resource_handles.pyx +++ b/cuda_core/cuda/core/_resource_handles.pyx @@ -93,6 +93,10 @@ cdef extern from "_cpp/resource_handles.hpp" namespace "cuda_core": cydriver.CUdeviceptr ptr) except+ nogil DevicePtrHandle deviceptr_create_with_owner "cuda_core::deviceptr_create_with_owner" ( cydriver.CUdeviceptr ptr, object owner) except+ nogil + DevicePtrHandle deviceptr_create_mapped_graphics "cuda_core::deviceptr_create_mapped_graphics" ( + cydriver.CUdeviceptr ptr, + const GraphicsResourceHandle& h_resource, + const StreamHandle& h_stream) except+ nogil # MR deallocation callback ctypedef void (*MRDeallocCallback)( @@ -208,6 +212,7 @@ cdef extern from "_cpp/resource_handles.hpp" namespace "cuda_core": void* p_cuLibraryGetKernel "reinterpret_cast(cuda_core::p_cuLibraryGetKernel)" # Graphics interop + void* p_cuGraphicsUnmapResources "reinterpret_cast(cuda_core::p_cuGraphicsUnmapResources)" void* p_cuGraphicsUnregisterResource "reinterpret_cast(cuda_core::p_cuGraphicsUnregisterResource)" # NVRTC @@ -267,6 +272,7 @@ p_cuLibraryUnload = _get_driver_fn("cuLibraryUnload") p_cuLibraryGetKernel = _get_driver_fn("cuLibraryGetKernel") # Graphics interop +p_cuGraphicsUnmapResources = _get_driver_fn("cuGraphicsUnmapResources") p_cuGraphicsUnregisterResource = _get_driver_fn("cuGraphicsUnregisterResource") # ============================================================================= diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index fc75015b70..8a5de6f687 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -8,6 +8,7 @@ import gc import os import sys +from unittest.mock import patch import numpy as np import pytest @@ -195,15 +196,16 @@ class TestFromGLBuffer: def test_register_default_flags(self): with _gl_context_and_buffer() as (gl_buf, nbytes): resource = GraphicsResource.from_gl_buffer(gl_buf) - assert resource.resource_handle != 0 - assert isinstance(resource, Buffer) + assert resource.handle != 0 + assert resource.resource_handle == resource.handle + assert not isinstance(resource, Buffer) assert not resource.is_mapped resource.close() def test_register_write_discard(self): with _gl_context_and_buffer() as (gl_buf, nbytes): resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - assert resource.resource_handle != 0 + assert resource.handle != 0 resource.close() def test_close_is_idempotent(self): @@ -222,7 +224,7 @@ class TestFromGLImage: def test_register_image(self): with _gl_context_and_texture() as (tex_id, target): resource = GraphicsResource.from_gl_image(tex_id, target) - assert resource.resource_handle != 0 + assert resource.handle != 0 assert not resource.is_mapped resource.close() @@ -233,16 +235,19 @@ def test_register_image(self): class TestMapUnmap: - def test_map_returns_self(self): + def test_map_returns_buffer(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") mapped = resource.map(stream=stream) assert resource.is_mapped - assert mapped is resource + assert isinstance(mapped, Buffer) + assert mapped is not resource assert mapped.size > 0 assert mapped.handle != 0 + assert resource.handle != mapped.handle resource.unmap(stream=stream) + assert mapped.handle == 0 assert not resource.is_mapped resource.close() @@ -254,6 +259,8 @@ def test_context_manager_unmaps(self): assert isinstance(buf, Buffer) assert resource.is_mapped assert buf.size > 0 + assert buf.handle != 0 + assert buf.handle == 0 assert not resource.is_mapped resource.close() @@ -287,14 +294,22 @@ def test_from_gl_buffer_with_stream_context_manager(self): with _gl_context_and_buffer(nbytes=nbytes) as (gl_buf, _): stream = _create_stream() with GraphicsResource.from_gl_buffer(gl_buf, stream=stream) as buf: - assert buf.is_mapped + assert isinstance(buf, Buffer) assert buf.size == nbytes view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) assert view.ptr == int(buf.handle) assert view.shape == (256,) assert view.is_device_accessible - assert not buf.is_mapped - buf.close() + assert buf.handle == 0 + assert buf.size == 0 + + def test_resource_context_manager_requires_stream(self): + with _gl_context_and_buffer(nbytes=4096) as (gl_buf, _): + resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") + with pytest.raises(RuntimeError, match="requires a stream"): + with resource as _buf: + pass + resource.close() def test_map_with_stream(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): @@ -304,6 +319,15 @@ def test_map_with_stream(self): assert buf.size > 0 resource.close() + def test_map_with_default_stream(self): + with _gl_context_and_buffer(nbytes=4096) as (gl_buf, _): + resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") + with resource.map() as buf: + assert isinstance(buf, Buffer) + assert buf.size > 0 + assert not resource.is_mapped + resource.close() + # --------------------------------------------------------------------------- # Error handling tests @@ -318,15 +342,14 @@ def test_double_map_raises(self): resource.map(stream=stream) with pytest.raises(RuntimeError, match="already mapped"): resource.map(stream=stream) - resource.unmap(stream=stream) + resource.unmap() resource.close() def test_unmap_without_map_raises(self): with _gl_context_and_buffer() as (gl_buf, nbytes): - stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf) with pytest.raises(RuntimeError, match="not mapped"): - resource.unmap(stream=stream) + resource.unmap() resource.close() def test_map_after_close_raises(self): @@ -339,21 +362,51 @@ def test_map_after_close_raises(self): def test_unmap_after_close_raises(self): with _gl_context_and_buffer() as (gl_buf, nbytes): - stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf) resource.close() with pytest.raises(RuntimeError, match="has been closed"): - resource.unmap(stream=stream) + resource.unmap() def test_close_while_mapped(self): """close() should unmap before unregistering.""" with _gl_context_and_buffer() as (gl_buf, nbytes): stream = _create_stream() resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - resource.map(stream=stream) + buf = resource.map(stream=stream) assert resource.is_mapped resource.close() # Should unmap + unregister without error assert not resource.is_mapped + assert buf.handle == 0 + + def test_close_while_mapped_passes_stream_override(self): + with _gl_context_and_buffer() as (gl_buf, _): + map_stream = _create_stream() + close_stream = _create_stream() + resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") + resource.map(stream=map_stream) + + original_close = Buffer.close + + def tracking_close(self, stream=None): + tracking_close.calls.append(stream) + return original_close(self, stream=stream) + + tracking_close.calls = [] + + with patch.object(Buffer, "close", new=tracking_close): + resource.close(stream=close_stream) + + assert tracking_close.calls == [close_stream] + assert not resource.is_mapped + + def test_buffer_close_updates_resource_state(self): + with _gl_context_and_buffer() as (gl_buf, _): + stream = _create_stream() + resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") + buf = resource.map(stream=stream) + assert resource.is_mapped + buf.close() + assert not resource.is_mapped # --------------------------------------------------------------------------- @@ -366,7 +419,7 @@ def test_gc_cleanup(self): """Creating and dropping a resource should not leak.""" with _gl_context_and_buffer() as (gl_buf, nbytes): resource = GraphicsResource.from_gl_buffer(gl_buf) - assert resource.resource_handle != 0 + assert resource.handle != 0 del resource gc.collect() # If we get here without a CUDA error, cleanup succeeded. @@ -386,9 +439,8 @@ def test_repr_closed(self): r = repr(resource) assert "closed" in r - def test_isinstance_buffer(self): - """GraphicsResource should be an instance of Buffer.""" + def test_graphics_resource_is_not_a_buffer(self): with _gl_context_and_buffer() as (gl_buf, nbytes): resource = GraphicsResource.from_gl_buffer(gl_buf) - assert isinstance(resource, Buffer) + assert not isinstance(resource, Buffer) resource.close() From 853340b3c228732903c606fcf04ba57f2178d02f Mon Sep 17 00:00:00 2001 From: Rob Parolin Date: Mon, 16 Mar 2026 16:29:07 -0700 Subject: [PATCH 5/7] graphics: allow resource-scoped context managers --- cuda_core/cuda/core/_graphics.pyx | 35 ++++++++++++++++--------------- cuda_core/tests/test_graphics.py | 23 ++++++++++++++------ 2 files changed, 35 insertions(+), 23 deletions(-) diff --git a/cuda_core/cuda/core/_graphics.pyx b/cuda_core/cuda/core/_graphics.pyx index c6a50e8421..fc053da8cb 100644 --- a/cuda_core/cuda/core/_graphics.pyx +++ b/cuda_core/cuda/core/_graphics.pyx @@ -73,13 +73,14 @@ cdef class GraphicsResource: view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) # view.ptr is a CUDA device pointer into the GL buffer - Or use explicit control for render loops: + Or scope registration separately from mapping: .. code-block:: python - buf = resource.map(stream=s) - # ... launch kernels using buf.handle, buf.size ... - buf.close() + with GraphicsResource.from_gl_buffer(vbo) as resource: + with resource.map(stream=s) as buf: + # ... launch kernels using buf.handle, buf.size ... + pass """ def __init__(self): @@ -110,12 +111,21 @@ cdef class GraphicsResource: with GraphicsResource.from_gl_buffer(vbo, stream=s) as buf: view = StridedMemoryView.from_buffer(buf, shape=(256,), dtype=np.float32) + If omitted, the returned resource can still be used as a context + manager to scope registration and automatic cleanup:: + + with GraphicsResource.from_gl_buffer(vbo) as resource: + with resource.map(stream=s) as buf: + ... + Returns ------- GraphicsResource A new graphics resource wrapping the registered GL buffer. - If *stream* was given, the returned resource can be used directly - as a context manager. + The returned resource can be used as a context manager. If + *stream* was given, entering maps the resource and yields a + :class:`~cuda.core.Buffer`; otherwise entering yields the + :class:`GraphicsResource` itself and closes it on exit. Raises ------ @@ -289,21 +299,12 @@ cdef class GraphicsResource: def __enter__(self): if self._context_manager_stream is None: - raise RuntimeError( - "GraphicsResource context manager requires a stream; " - "use resource.map(stream=...) or pass stream= to from_gl_buffer()" - ) + return self self._entered_buffer = self.map(stream=self._context_manager_stream) return self._entered_buffer def __exit__(self, exc_type, exc_val, exc_tb): - cdef object buf_obj = self._entered_buffer - cdef Buffer buf - self._entered_buffer = None - if buf_obj is not None: - buf = buf_obj - if buf._h_ptr: - buf.close() + self.close() return False cpdef close(self, stream=None): diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index 8a5de6f687..e094763e0b 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -303,13 +303,24 @@ def test_from_gl_buffer_with_stream_context_manager(self): assert buf.handle == 0 assert buf.size == 0 - def test_resource_context_manager_requires_stream(self): + def test_resource_context_manager_auto_closes(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, _): - resource = GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") - with pytest.raises(RuntimeError, match="requires a stream"): - with resource as _buf: - pass - resource.close() + with GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") as resource: + assert isinstance(resource, GraphicsResource) + assert resource.handle != 0 + assert not resource.is_mapped + assert resource.handle == 0 + + def test_resource_context_manager_can_map_inside_scope(self): + with _gl_context_and_buffer(nbytes=4096) as (gl_buf, _): + stream = _create_stream() + with GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") as resource: + with resource.map(stream=stream) as buf: + assert isinstance(buf, Buffer) + assert resource.is_mapped + assert buf.handle != 0 + assert resource.handle == 0 + assert not resource.is_mapped def test_map_with_stream(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): From 591f0c79b0f0e848ee7ae8b1c7e510dcb7b93473 Mon Sep 17 00:00:00 2001 From: Rob Parolin Date: Mon, 16 Mar 2026 16:37:52 -0700 Subject: [PATCH 6/7] wip --- cuda_core/tests/test_graphics.py | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index e094763e0b..93c31d28ca 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -314,13 +314,9 @@ def test_resource_context_manager_auto_closes(self): def test_resource_context_manager_can_map_inside_scope(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, _): stream = _create_stream() - with GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard") as resource: - with resource.map(stream=stream) as buf: - assert isinstance(buf, Buffer) - assert resource.is_mapped - assert buf.handle != 0 - assert resource.handle == 0 - assert not resource.is_mapped + with GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard").map(stream=stream) as buf: + assert isinstance(buf, Buffer) + assert buf.handle != 0 def test_map_with_stream(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): From 0a79c694c6eaa737e1e475735fefbffba3b02f19 Mon Sep 17 00:00:00 2001 From: Rob Parolin Date: Mon, 16 Mar 2026 17:02:14 -0700 Subject: [PATCH 7/7] graphics: cover chained mapped buffer context --- cuda_core/tests/test_graphics.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cuda_core/tests/test_graphics.py b/cuda_core/tests/test_graphics.py index 93c31d28ca..cfa5a8541b 100644 --- a/cuda_core/tests/test_graphics.py +++ b/cuda_core/tests/test_graphics.py @@ -318,6 +318,16 @@ def test_resource_context_manager_can_map_inside_scope(self): assert isinstance(buf, Buffer) assert buf.handle != 0 + def test_chained_map_context_manager_unmaps(self): + with _gl_context_and_buffer(nbytes=4096) as (gl_buf, _): + stream = _create_stream() + with GraphicsResource.from_gl_buffer(gl_buf, flags="write_discard").map(stream=stream) as buf: + assert isinstance(buf, Buffer) + assert buf.handle != 0 + assert buf.size > 0 + assert buf.handle == 0 + assert buf.size == 0 + def test_map_with_stream(self): with _gl_context_and_buffer(nbytes=4096) as (gl_buf, nbytes): stream = _create_stream()