Skip to content

Commit 6c5ecf9

Browse files
authored
cuda.core: introduce copy options for Buffer.copy_{to/from} (#2636)
cuda.core: introduce copy options for Buffer.copy_to/copy_from Add an optional `options: CopyOptions` keyword argument to `Buffer.copy_to` and `Buffer.copy_from`, exposing the same dataclass introduced by `copy_batch` on the per-buffer path. When set, the copy is submitted via `cuMemcpyWithAttributesAsync` if both cuda.bindings and the driver are CUDA 13.2+, the stream isn't `LEGACY_DEFAULT_STREAM`, and it isn't currently capturing; `PER_THREAD_DEFAULT_STREAM` is accepted like any other stream. `options=None` is unaffected and always uses the existing `cuMemcpyAsync` path. Passing `options` together with `LEGACY_DEFAULT_STREAM` or a capturing stream raises `TypeError`, matching `copy_batch`; a graph cannot represent these attributes, so `GraphNode.memcpy` (a plain, non-attributed copy) is the only way to get a copy into a graph today. On an older cuda.bindings/driver, `src_access_order` values of `STREAM` and `ANY` fall back to `cuMemcpyAsync` silently, since stream-ordered access already satisfies both. `DURING_API_CALL` promises all source reads complete before the call returns; a stream-ordered fallback can't honor that, so it raises `RuntimeError` instead of silently downgrading the guarantee, which could otherwise let a caller overwrite a source buffer before the real read happens. While aligning the two APIs, this also fixes two bugs in the existing `copy_batch`: it previously rejected `PER_THREAD_DEFAULT_STREAM` outright even though the driver accepts it, and its pre-CUDA-13 fallback loop silently ignored `DURING_API_CALL` despite a stale comment claiming that case was already rejected. Both now match the per-buffer behavior via a shared `_reject_unsupported_during_api_call` helper in `_copy_enums.py`. `cuMemcpyWithAttributesAsync` is absent from cuda.bindings older than 13.2, so it's routed through a small C++ function-pointer shim (`_cpp/resource_handles.{cpp,hpp}`) resolved at runtime, avoiding a hard Cython cimport that would break older-bindings builds. Tests: new `tests/memory/test_copy_single_options.py` covers data correctness across all `CopyOptions` fields, the `TypeError`/ `RuntimeError` rejection paths, default-stream-token and graph-capture behavior (including that `options=None` is unaffected by either), and `dst=None` auto-allocation. `test_copy_batch.py`/ `test_copy_batch_options.py` gain matching coverage for the `copy_batch` fixes, plus direct unit tests of the shared `DURING_API_CALL` guard. `test_memory.py` adds previously-missing size-mismatch rejection tests for `copy_to`/`copy_from`. Closes #2365.
1 parent e0a2d1a commit 6c5ecf9

17 files changed

Lines changed: 1046 additions & 56 deletions

cuda_core/cuda/core/_cpp/resource_handles.cpp

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,13 @@ decltype(&cuDevSmResourceSplit) p_cuDevSmResourceSplit = nullptr;
111111
void* p_cuDevSmResourceSplit = nullptr;
112112
#endif
113113

114+
// cuMemcpyWithAttributesAsync (13.2+ — may be null on older drivers/bindings)
115+
#if CUDA_VERSION >= 13020
116+
decltype(&cuMemcpyWithAttributesAsync) p_cuMemcpyWithAttributesAsync = nullptr;
117+
#else
118+
void* p_cuMemcpyWithAttributesAsync = nullptr;
119+
#endif
120+
114121
// NVRTC function pointers
115122
decltype(&nvrtcDestroyProgram) p_nvrtcDestroyProgram = nullptr;
116123

@@ -2834,4 +2841,25 @@ bool has_sm_resource_split() noexcept {
28342841
return p_cuDevSmResourceSplit != nullptr;
28352842
}
28362843

2844+
// ============================================================================
2845+
// cuMemcpyWithAttributesAsync wrapper
2846+
// ============================================================================
2847+
2848+
CUresult memcpy_with_attributes_async(CUdeviceptr dst, CUdeviceptr src, size_t size,
2849+
void* attr, CUstream hStream) {
2850+
#if CUDA_VERSION >= 13020
2851+
if (!p_cuMemcpyWithAttributesAsync) {
2852+
return CUDA_ERROR_NOT_SUPPORTED;
2853+
}
2854+
return p_cuMemcpyWithAttributesAsync(
2855+
dst, src, size, static_cast<CUmemcpyAttributes*>(attr), hStream);
2856+
#else
2857+
return CUDA_ERROR_NOT_SUPPORTED;
2858+
#endif
2859+
}
2860+
2861+
bool has_memcpy_with_attributes_async() noexcept {
2862+
return p_cuMemcpyWithAttributesAsync != nullptr;
2863+
}
2864+
28372865
} // namespace cuda_core

cuda_core/cuda/core/_cpp/resource_handles.hpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,15 @@ extern decltype(&cuDevSmResourceSplit) p_cuDevSmResourceSplit;
146146
extern void* p_cuDevSmResourceSplit;
147147
#endif
148148

149+
// cuMemcpyWithAttributesAsync (13.2+ — may be null on older drivers/bindings)
150+
#if CUDA_VERSION >= 13020
151+
extern decltype(&cuMemcpyWithAttributesAsync) p_cuMemcpyWithAttributesAsync;
152+
#else
153+
// cuMemcpyWithAttributesAsync doesn't exist in CUDA < 13.2 headers, so use a
154+
// void* placeholder. The pointer is always null when built against older CUDA.
155+
extern void* p_cuMemcpyWithAttributesAsync;
156+
#endif
157+
149158
// ============================================================================
150159
// NVRTC function pointers
151160
//
@@ -1110,4 +1119,21 @@ CUresult sm_resource_split(CUdevResource* result, unsigned int nbGroups,
11101119
// Returns true if the cuDevSmResourceSplit function pointer is available.
11111120
bool has_sm_resource_split() noexcept;
11121121

1122+
// ============================================================================
1123+
// cuMemcpyWithAttributesAsync wrapper (13.2+)
1124+
//
1125+
// Calls through p_cuMemcpyWithAttributesAsync if available, otherwise returns
1126+
// CUDA_ERROR_NOT_SUPPORTED. This avoids a direct Cython cimport of the
1127+
// cydriver cdef function, which would fail at module init on cuda-bindings
1128+
// < 13.2 (see https://github.com/NVIDIA/cuda-python/issues/2063).
1129+
// ============================================================================
1130+
1131+
// attr is void* so the Cython declaration doesn't reference CUmemcpyAttributes
1132+
// (absent from cuda-bindings built against CUDA < 12.8). The C++ side casts it.
1133+
CUresult memcpy_with_attributes_async(CUdeviceptr dst, CUdeviceptr src, size_t size,
1134+
void* attr, CUstream hStream);
1135+
1136+
// Returns true if the cuMemcpyWithAttributesAsync function pointer is available.
1137+
bool has_memcpy_with_attributes_async() noexcept;
1138+
11131139
} // namespace cuda_core

cuda_core/cuda/core/_memory/_buffer.pyi

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import cython
6+
from cuda.core._memory._copy_enums import CopyOptions
67
from cuda.core._memory._device_memory_resource import DeviceMemoryResource
78
from cuda.core._memory._ipc import IPCBufferDescriptor
89
from cuda.core._memory._pinned_memory_resource import PinnedMemoryResource
@@ -170,7 +171,7 @@ class Buffer:
170171
def __exit__(self, exc_type, exc_val, exc_tb):
171172
...
172173

173-
def copy_to(self, dst: Buffer | None=None, *, stream: Stream | GraphBuilder) -> Buffer:
174+
def copy_to(self, dst: Buffer | None=None, *, stream: Stream | GraphBuilder, options: CopyOptions | None=None) -> Buffer:
174175
"""Copy from this buffer to the dst buffer asynchronously on the given stream.
175176
176177
Copies the data from this buffer to the provided dst buffer.
@@ -185,10 +186,32 @@ class Buffer:
185186
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`
186187
Keyword argument specifying the stream for the
187188
asynchronous copy
189+
options : :class:`~utils.CopyOptions`, optional
190+
Transfer hints (source access order, location hints, overlap mode).
191+
Honored when cuda.bindings and the driver are both CUDA 13.2 or
192+
newer. Not accepted with ``LEGACY_DEFAULT_STREAM``; use
193+
``PER_THREAD_DEFAULT_STREAM`` instead. Not accepted with a
194+
capturing stream either, since a graph cannot represent these
195+
attributes; use :meth:`graph.GraphNode.memcpy` for a plain,
196+
non-attributed copy node, or pass ``options=None``. On an older
197+
cuda.bindings/driver, ``src_access_order`` values of ``STREAM``
198+
and ``ANY`` are silently ignored; ``DURING_API_CALL`` raises
199+
instead of silently downgrading its guarantee.
200+
201+
Raises
202+
------
203+
TypeError
204+
If ``options`` is not a :class:`~utils.CopyOptions` instance, or
205+
if ``options`` is given together with ``LEGACY_DEFAULT_STREAM``
206+
or a stream currently in graph capture mode.
207+
RuntimeError
208+
If ``options.src_access_order`` is ``DURING_API_CALL`` and
209+
cuda.bindings or the driver is older than CUDA 13.2: falling
210+
back to a plain copy cannot honor that guarantee.
188211
189212
"""
190213

191-
def copy_from(self, src: Buffer, *, stream: Stream | GraphBuilder) -> None:
214+
def copy_from(self, src: Buffer, *, stream: Stream | GraphBuilder, options: CopyOptions | None=None) -> None:
192215
"""Copy from the src buffer to this buffer asynchronously on the given stream.
193216
194217
Parameters
@@ -198,7 +221,28 @@ class Buffer:
198221
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`
199222
Keyword argument specifying the stream for the
200223
asynchronous copy
224+
options : :class:`~utils.CopyOptions`, optional
225+
Transfer hints (source access order, location hints, overlap mode).
226+
Honored when cuda.bindings and the driver are both CUDA 13.2 or
227+
newer. Not accepted with ``LEGACY_DEFAULT_STREAM``; use
228+
``PER_THREAD_DEFAULT_STREAM`` instead. Not accepted with a
229+
capturing stream either, since a graph cannot represent these
230+
attributes; use :meth:`graph.GraphNode.memcpy` for a plain,
231+
non-attributed copy node, or pass ``options=None``. On an older
232+
cuda.bindings/driver, ``src_access_order`` values of ``STREAM``
233+
and ``ANY`` are silently ignored; ``DURING_API_CALL`` raises
234+
instead of silently downgrading its guarantee.
201235
236+
Raises
237+
------
238+
TypeError
239+
If ``options`` is not a :class:`~utils.CopyOptions` instance, or
240+
if ``options`` is given together with ``LEGACY_DEFAULT_STREAM``
241+
or a stream currently in graph capture mode.
242+
RuntimeError
243+
If ``options.src_access_order`` is ``DURING_API_CALL`` and
244+
cuda.bindings or the driver is older than CUDA 13.2: falling
245+
back to a plain copy cannot honor that guarantee.
202246
"""
203247

204248
def fill(self, value: int | BufferProtocol, *, stream: Stream | GraphBuilder) -> None:

cuda_core/cuda/core/_memory/_buffer.pyx

Lines changed: 128 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,20 @@ from cuda.core._resource_handles cimport (
2727
)
2828
from cuda.core.typing import DevicePointerType
2929

30-
from cuda.core._stream cimport Stream, Stream_accept, default_stream
30+
from cuda.core._memory._copy_attributes cimport _with_attributes_available
31+
from cuda.core._memory._copy_attributes cimport _to_cu_memcpy_attributes # no-cython-lint
32+
33+
IF CUDA_CORE_BUILD_MAJOR >= 13:
34+
from cuda.core._resource_handles cimport memcpy_with_attributes_async
35+
36+
from cuda.core._stream cimport Stream, Stream_accept, Stream_is_legacy_default_token, default_stream
3137
from cuda.core._utils.cuda_utils cimport HANDLE_RETURN, _parse_fill_value
3238

3339
import sys
3440
from collections.abc import Sequence
3541
from typing import TYPE_CHECKING
3642

43+
from cuda.core._memory._copy_enums import CopyOptions, _reject_unsupported_during_api_call
3744
from cuda.core._utils.pycompat import BufferProtocol
3845
from cuda.core._dlpack import classify_dl_device, make_py_capsule
3946
from cuda.core._device import Device
@@ -159,6 +166,75 @@ cdef inline void _init_memory_attrs(Buffer self):
159166
self._mem_attrs_inited.store(True, memory_order_release)
160167

161168

169+
cdef bint _stream_is_capturing(Stream s):
170+
cdef cydriver.CUstreamCaptureStatus cap_status
171+
IF CUDA_CORE_BUILD_MAJOR >= 13:
172+
HANDLE_RETURN(cydriver.cuStreamGetCaptureInfo(as_cu(s._h_stream), &cap_status,
173+
NULL, NULL, NULL, NULL, NULL))
174+
ELSE:
175+
HANDLE_RETURN(cydriver.cuStreamGetCaptureInfo(as_cu(s._h_stream), &cap_status,
176+
NULL, NULL, NULL, NULL))
177+
return cap_status == cydriver.CU_STREAM_CAPTURE_STATUS_ACTIVE
178+
179+
180+
cdef void _do_copy_with_attributes(
181+
cydriver.CUdeviceptr dst, cydriver.CUdeviceptr src, size_t nbytes,
182+
object options, cydriver.CUstream hstream,
183+
):
184+
IF CUDA_CORE_BUILD_MAJOR >= 13:
185+
# Routed through the memcpy_with_attributes_async() C++ shim since
186+
# cydriver.cuMemcpyWithAttributesAsync is absent from cuda-bindings < 13.2.
187+
cdef cydriver.CUmemcpyAttributes cu_attr = _to_cu_memcpy_attributes(options)
188+
with nogil:
189+
HANDLE_RETURN(memcpy_with_attributes_async(dst, src, nbytes, <void*>&cu_attr, hstream))
190+
ELSE:
191+
pass # unreachable: _with_attributes_available() is always False on CUDA 12
192+
193+
194+
cdef void _dispatch_buffer_copy(
195+
cydriver.CUdeviceptr dst, cydriver.CUdeviceptr src, size_t nbytes,
196+
Stream s, object options, str method_name,
197+
):
198+
"""Submit a single copy, honoring CopyOptions when the attributes path is usable."""
199+
if options is None:
200+
with nogil:
201+
HANDLE_RETURN(cydriver.cuMemcpyAsync(dst, src, nbytes, as_cu(s._h_stream)))
202+
return
203+
if not isinstance(options, CopyOptions):
204+
raise TypeError(
205+
f"{method_name}: options must be CopyOptions, got {type(options).__name__}"
206+
)
207+
if Stream_is_legacy_default_token(s):
208+
raise TypeError(
209+
f"{method_name} does not accept LEGACY_DEFAULT_STREAM with options "
210+
"(matches copy_batch); cuMemcpyWithAttributesAsync rejects it outright, "
211+
"unlike PER_THREAD_DEFAULT_STREAM, which is a real stream to the driver "
212+
"and is accepted. Pass an explicit stream, PER_THREAD_DEFAULT_STREAM, "
213+
"or options=None."
214+
)
215+
if _stream_is_capturing(s):
216+
raise TypeError(
217+
f"{method_name} does not support graph capture with options "
218+
"(matches copy_batch); the driver has no graph-node form of "
219+
"cuMemcpyWithAttributesAsync, so options cannot be honored in a graph. "
220+
"Use GraphNode.memcpy for a plain (non-attributed) copy node, or pass "
221+
"options=None."
222+
)
223+
if _with_attributes_available():
224+
_do_copy_with_attributes(dst, src, nbytes, options, as_cu(s._h_stream))
225+
else:
226+
_reject_unsupported_during_api_call(
227+
options.src_access_order,
228+
"cuda.bindings and the driver to both report CUDA 13.2 or newer "
229+
"(cuMemcpyWithAttributesAsync is unavailable here)",
230+
)
231+
# STREAM and ANY never require access sooner than stream order, so
232+
# cuMemcpyAsync satisfies them; options are otherwise silently
233+
# ignored on this pre-CUDA-13.2 fallback path, matching copy_batch.
234+
with nogil:
235+
HANDLE_RETURN(cydriver.cuMemcpyAsync(dst, src, nbytes, as_cu(s._h_stream)))
236+
237+
162238
cdef class Buffer:
163239
"""Represent a handle to allocated memory.
164240
@@ -393,7 +469,8 @@ cdef class Buffer:
393469
self.close()
394470
return False
395471

396-
def copy_to(self, dst: Buffer | None = None, *, stream: Stream | GraphBuilder) -> Buffer:
472+
def copy_to(self, dst: Buffer | None = None, *, stream: Stream | GraphBuilder,
473+
options: CopyOptions | None = None) -> Buffer:
397474
"""Copy from this buffer to the dst buffer asynchronously on the given stream.
398475

399476
Copies the data from this buffer to the provided dst buffer.
@@ -408,6 +485,28 @@ cdef class Buffer:
408485
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`
409486
Keyword argument specifying the stream for the
410487
asynchronous copy
488+
options : :class:`~utils.CopyOptions`, optional
489+
Transfer hints (source access order, location hints, overlap mode).
490+
Honored when cuda.bindings and the driver are both CUDA 13.2 or
491+
newer. Not accepted with ``LEGACY_DEFAULT_STREAM``; use
492+
``PER_THREAD_DEFAULT_STREAM`` instead. Not accepted with a
493+
capturing stream either, since a graph cannot represent these
494+
attributes; use :meth:`graph.GraphNode.memcpy` for a plain,
495+
non-attributed copy node, or pass ``options=None``. On an older
496+
cuda.bindings/driver, ``src_access_order`` values of ``STREAM``
497+
and ``ANY`` are silently ignored; ``DURING_API_CALL`` raises
498+
instead of silently downgrading its guarantee.
499+
500+
Raises
501+
------
502+
TypeError
503+
If ``options`` is not a :class:`~utils.CopyOptions` instance, or
504+
if ``options`` is given together with ``LEGACY_DEFAULT_STREAM``
505+
or a stream currently in graph capture mode.
506+
RuntimeError
507+
If ``options.src_access_order`` is ``DURING_API_CALL`` and
508+
cuda.bindings or the driver is older than CUDA 13.2: falling
509+
back to a plain copy cannot honor that guarantee.
411510

412511
"""
413512
cdef Stream s = Stream_accept(stream)
@@ -424,12 +523,12 @@ cdef class Buffer:
424523
raise ValueError( "buffer sizes mismatch between src and dst (sizes "
425524
f"are: src={src_size}, dst={dst_size})"
426525
)
427-
with nogil:
428-
HANDLE_RETURN(cydriver.cuMemcpyAsync(
429-
as_cu(dst._h_ptr), as_cu(self._h_ptr), src_size, as_cu(s._h_stream)))
526+
_dispatch_buffer_copy(
527+
as_cu(dst._h_ptr), as_cu(self._h_ptr), src_size, s, options, "copy_to")
430528
return dst
431529

432-
def copy_from(self, src: Buffer, *, stream: Stream | GraphBuilder) -> None:
530+
def copy_from(self, src: Buffer, *, stream: Stream | GraphBuilder,
531+
options: CopyOptions | None = None) -> None:
433532
"""Copy from the src buffer to this buffer asynchronously on the given stream.
434533

435534
Parameters
@@ -439,7 +538,28 @@ cdef class Buffer:
439538
stream : :obj:`~_stream.Stream` | :obj:`~graph.GraphBuilder`
440539
Keyword argument specifying the stream for the
441540
asynchronous copy
541+
options : :class:`~utils.CopyOptions`, optional
542+
Transfer hints (source access order, location hints, overlap mode).
543+
Honored when cuda.bindings and the driver are both CUDA 13.2 or
544+
newer. Not accepted with ``LEGACY_DEFAULT_STREAM``; use
545+
``PER_THREAD_DEFAULT_STREAM`` instead. Not accepted with a
546+
capturing stream either, since a graph cannot represent these
547+
attributes; use :meth:`graph.GraphNode.memcpy` for a plain,
548+
non-attributed copy node, or pass ``options=None``. On an older
549+
cuda.bindings/driver, ``src_access_order`` values of ``STREAM``
550+
and ``ANY`` are silently ignored; ``DURING_API_CALL`` raises
551+
instead of silently downgrading its guarantee.
442552

553+
Raises
554+
------
555+
TypeError
556+
If ``options`` is not a :class:`~utils.CopyOptions` instance, or
557+
if ``options`` is given together with ``LEGACY_DEFAULT_STREAM``
558+
or a stream currently in graph capture mode.
559+
RuntimeError
560+
If ``options.src_access_order`` is ``DURING_API_CALL`` and
561+
cuda.bindings or the driver is older than CUDA 13.2: falling
562+
back to a plain copy cannot honor that guarantee.
443563
"""
444564
cdef Stream s = Stream_accept(stream)
445565
cdef size_t dst_size = self._size
@@ -449,9 +569,8 @@ cdef class Buffer:
449569
raise ValueError( "buffer sizes mismatch between src and dst (sizes "
450570
f"are: src={src_size}, dst={dst_size})"
451571
)
452-
with nogil:
453-
HANDLE_RETURN(cydriver.cuMemcpyAsync(
454-
as_cu(self._h_ptr), as_cu(src._h_ptr), dst_size, as_cu(s._h_stream)))
572+
_dispatch_buffer_copy(
573+
as_cu(self._h_ptr), as_cu(src._h_ptr), dst_size, s, options, "copy_from")
455574

456575
def fill(self, value: int | BufferProtocol, *, stream: Stream | GraphBuilder) -> None:
457576
"""Fill this buffer with a repeating byte pattern.

cuda_core/cuda/core/_memory/_copy_attributes.pxd

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,19 @@ from cuda.core._utils.version cimport cy_binding_version, cy_driver_version # n
1111

1212

1313
IF CUDA_CORE_BUILD_MAJOR >= 13:
14+
from cuda.core._resource_handles cimport has_memcpy_with_attributes_async
15+
1416
cdef inline bint _with_attributes_available():
15-
return cy_driver_version() >= (13, 2, 0) and cy_binding_version() >= (13, 2, 0)
17+
# has_memcpy_with_attributes_async() says whether the installed
18+
# cuda-bindings actually exports cuMemcpyWithAttributesAsync (13.2+);
19+
# the version checks alone are not sufficient, since cuda.core's build
20+
# can be paired with a cuda-bindings install older than what it built
21+
# against (see https://github.com/NVIDIA/cuda-python/issues/2063).
22+
return (
23+
has_memcpy_with_attributes_async()
24+
and cy_driver_version() >= (13, 2, 0)
25+
and cy_binding_version() >= (13, 2, 0)
26+
)
1627
ELSE:
1728
cdef inline bint _with_attributes_available():
1829
return False

0 commit comments

Comments
 (0)