Skip to content

Commit 29acb74

Browse files
authored
cuda.core: Add copy_batch to cuda.core.utils (#2593)
* cuda.core: Add copy_batch to cuda.core.utils * fallback for CUDA 12 and type annotations * be more precise about CUDA requirements * skip tests on Windows that require managed memory * rework some tests * Deduplicate _to_cumemlocation * add missing file * address review feedback * review feedback: don't assume NUMA capabilities * review feedback: clarify buffer requirements for async batched copies * review feedback: explicitly reject special default streams * review feedback: explicitly reject capturing streams * review feedback: drop warning about unsupported PREFER_OVERLAP_WITH_COMPUTE hint * review feedback: add missing descriptions for copy options values * review feedback: align CopyOptions validation with existing practice * review feedback: drop conditional imports for type checking * account for CUDA 12/13 driver differences * CUDA 12: drop rejection of unsupported copy options * simplify tests
1 parent b1c5024 commit 29acb74

21 files changed

Lines changed: 1622 additions & 45 deletions

cuda_core/AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ a `StrEnum` is accepted as an argument, a `str` should also be acceptable. An
151151
invalid value should raise an exception. When a function returns a `str` drawn
152152
from a small number of values, return a `StrEnum` subclass instead.
153153

154+
For `__post_init__` validation in frozen dataclasses, use the
155+
`not isinstance(value, EnumType) → try EnumType(value) except (ValueError,
156+
TypeError)` pattern (modelled on `_normalize_enum` in
157+
`cuda/core/texture/_texture.pyx`). This accepts the enum itself or a valid
158+
string, and raises `ValueError` eagerly for any other type rather than
159+
silently storing it.
160+
154161
### Exception handling
155162

156163
Raising exceptions is preferred over a C-style return code that must be checked

cuda_core/cuda/core/_memory/_buffer.pxd

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,9 @@ cdef Buffer Buffer_from_deviceptr_handle(
4444
object ipc_descriptor = *,
4545
type cls = *,
4646
)
47+
48+
49+
# Shared argument coercion for the batched free functions (copy_batch,
50+
# prefetch_batch, discard_batch, discard_prefetch_batch). `single_hint`
51+
# names the per-buffer API to use instead when a bare Buffer is passed.
52+
cdef tuple Buffer_coerce_batch(object buffers, str what, str single_hint)

cuda_core/cuda/core/_memory/_buffer.pyx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ from cuda.core._stream cimport Stream, Stream_accept, default_stream
2929
from cuda.core._utils.cuda_utils cimport HANDLE_RETURN, _parse_fill_value
3030

3131
import sys
32+
from collections.abc import Sequence
3233
from typing import TYPE_CHECKING
3334

3435
from cuda.core._utils.pycompat import BufferProtocol
@@ -619,6 +620,32 @@ cdef Buffer Buffer_from_deviceptr_handle(
619620
return buf
620621

621622

623+
cdef tuple Buffer_coerce_batch(object buffers, str what, str single_hint):
624+
"""Coerce ``buffers`` to a ``tuple[Buffer, ...]``; reject a bare Buffer.
625+
626+
Shared by the batched free functions. Passing one Buffer is rejected
627+
rather than treated as a one-element batch so that the per-buffer API
628+
named by ``single_hint`` stays the single obvious way to do it.
629+
"""
630+
cdef list out
631+
if isinstance(buffers, Buffer):
632+
raise TypeError(
633+
f"{what}: pass a sequence of Buffers; for a single buffer use {single_hint}"
634+
)
635+
if not isinstance(buffers, Sequence):
636+
raise TypeError(
637+
f"{what}: buffers must be a sequence of Buffer, got {type(buffers).__name__}"
638+
)
639+
if not buffers:
640+
raise ValueError(f"{what}: empty buffers sequence")
641+
out = []
642+
for item in buffers:
643+
if not isinstance(item, Buffer):
644+
raise TypeError(f"{what}: expected Buffer, got {type(item).__name__}")
645+
out.append(item)
646+
return tuple(out)
647+
648+
622649
cdef inline void Buffer_close(Buffer self, object stream):
623650
"""Close a buffer, freeing its memory."""
624651
cdef Stream s
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
#
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
from __future__ import annotations
6+
7+
import dataclasses
8+
from collections.abc import Sequence
9+
10+
from cuda.core._device import Device
11+
from cuda.core._host import Host
12+
from cuda.core._utils.cuda_utils import driver
13+
from cuda.core._utils.pycompat import StrEnum
14+
from cuda.core._utils.version import binding_version
15+
16+
__all__ = ["CopyOptions", "MemcpyOverlapMode", "MemcpySrcAccessOrder"]
17+
18+
19+
class MemcpySrcAccessOrder(StrEnum):
20+
"""Source access order hint for batched memcpy operations.
21+
22+
Maps to ``CUmemcpySrcAccessOrder``.
23+
24+
``STREAM``
25+
Source reads follow stream order. Earlier stream work may still be
26+
accessing the source when the copy is enqueued.
27+
``DURING_API_CALL``
28+
The driver may read the source out of stream order, but all reads
29+
are complete before :func:`copy_batch` returns. No earlier stream
30+
work may be accessing the source at the time of the call.
31+
``ANY``
32+
The driver may read the source after the call returns. The caller
33+
must keep the source unchanged until the copy completes in stream
34+
order. No earlier stream work may be accessing the source.
35+
"""
36+
37+
STREAM = "stream"
38+
DURING_API_CALL = "during_api_call"
39+
ANY = "any"
40+
41+
42+
class MemcpyOverlapMode(StrEnum):
43+
"""Overlap mode hint for batched memcpy operations.
44+
45+
Maps to ``CUmemcpyFlags``.
46+
47+
``DEFAULT``
48+
No overlap preference; the driver uses its default scheduling.
49+
``PREFER_OVERLAP_WITH_COMPUTE``
50+
Hint that the copy should preferably overlap with concurrent
51+
compute work. This is advisory and may be ignored depending on
52+
the platform and copy parameters.
53+
"""
54+
55+
DEFAULT = "default"
56+
PREFER_OVERLAP_WITH_COMPUTE = "prefer_overlap_with_compute"
57+
58+
59+
@dataclasses.dataclass(frozen=True)
60+
class CopyOptions:
61+
"""Attribute bundle for a single copy within a batched memcpy.
62+
63+
Parameters
64+
----------
65+
src_access_order : :class:`MemcpySrcAccessOrder` or str
66+
Hint describing how the source will be accessed.
67+
Default is ``"stream"`` (stream-ordered access).
68+
src_location_hint : :class:`cuda.core.Device` | :class:`cuda.core.Host` | None
69+
Hint for the source memory location. Honored only for managed
70+
memory on devices with concurrent managed access and for
71+
system-allocated pageable memory on devices with pageable memory
72+
access; ignored for all other memory types. Does not prefetch
73+
memory and does not set persistent memory advice.
74+
``None`` means no hint.
75+
dst_location_hint : :class:`cuda.core.Device` | :class:`cuda.core.Host` | None
76+
Hint for the destination memory location. Same semantics and
77+
restrictions as ``src_location_hint``. ``None`` means no hint.
78+
overlap_mode : :class:`MemcpyOverlapMode` or str
79+
Hint requesting that the copy overlap with concurrent compute work.
80+
This is advisory; it has an effect only on devices that support it.
81+
Default is ``"default"``.
82+
"""
83+
84+
src_access_order: MemcpySrcAccessOrder | str = "stream"
85+
src_location_hint: Device | Host | None = None
86+
dst_location_hint: Device | Host | None = None
87+
overlap_mode: MemcpyOverlapMode | str = "default"
88+
89+
def __post_init__(self):
90+
# Frozen, unlike the other *Options dataclasses in cuda.core, because
91+
# the batched-API contract agreed in NVIDIA/cuda-python#1775 specifies
92+
# immutable per-call options:
93+
# https://github.com/NVIDIA/cuda-python/pull/1775#issuecomment-4355502334
94+
#
95+
# Normalizing str -> StrEnum therefore has to go through
96+
# object.__setattr__; a plain assignment would raise
97+
# FrozenInstanceError. Done here rather than at use so that a typo
98+
# fails at construction and the field always holds the enum.
99+
if not isinstance(self.src_access_order, MemcpySrcAccessOrder):
100+
try:
101+
object.__setattr__(
102+
self,
103+
"src_access_order",
104+
MemcpySrcAccessOrder(self.src_access_order),
105+
)
106+
except (ValueError, TypeError) as exc:
107+
raise ValueError(f"invalid src_access_order: {self.src_access_order!r}") from exc
108+
if not isinstance(self.overlap_mode, MemcpyOverlapMode):
109+
try:
110+
object.__setattr__(
111+
self,
112+
"overlap_mode",
113+
MemcpyOverlapMode(self.overlap_mode),
114+
)
115+
except (ValueError, TypeError) as exc:
116+
raise ValueError(f"invalid overlap_mode: {self.overlap_mode!r}") from exc
117+
118+
def _to_driver_enum(self) -> int:
119+
"""Return the driver CUmemcpySrcAccessOrder value."""
120+
if not _SRC_ACCESS_ORDER_TO_DRIVER:
121+
raise NotImplementedError(_CUDA13_REQUIRED)
122+
return _SRC_ACCESS_ORDER_TO_DRIVER[MemcpySrcAccessOrder(self.src_access_order)]
123+
124+
def _to_driver_flags(self) -> int:
125+
"""Return the driver CUmemcpyFlags value."""
126+
if not _OVERLAP_MODE_TO_DRIVER:
127+
raise NotImplementedError(_CUDA13_REQUIRED)
128+
return _OVERLAP_MODE_TO_DRIVER[MemcpyOverlapMode(self.overlap_mode)]
129+
130+
131+
_CUDA13_REQUIRED = "copy attributes require cuda.bindings 13.0 or newer"
132+
133+
# CUmemcpySrcAccessOrder and CUmemcpyFlags are exposed by cuda.bindings 13.0+,
134+
# so these maps are empty when it is older. Nothing reaches them there:
135+
# copy_batch refuses non-default CopyOptions when the batched entry point is
136+
# unavailable.
137+
#
138+
# Keyed by ``str``: under ``python_version = "3.10"`` mypy resolves StrEnum to
139+
# the unstubbed backports shim and so infers the members as plain ``str``.
140+
# StrEnum members are ``str`` instances, so this holds on every version. The
141+
# values are wrapped in ``int()`` because the driver enums are untyped.
142+
_SRC_ACCESS_ORDER_TO_DRIVER: dict[str, int]
143+
_OVERLAP_MODE_TO_DRIVER: dict[str, int]
144+
145+
if binding_version() >= (13, 0, 0):
146+
_src_order = driver.CUmemcpySrcAccessOrder
147+
_flags = driver.CUmemcpyFlags
148+
_SRC_ACCESS_ORDER_TO_DRIVER = {
149+
MemcpySrcAccessOrder.STREAM: int(_src_order.CU_MEMCPY_SRC_ACCESS_ORDER_STREAM),
150+
MemcpySrcAccessOrder.DURING_API_CALL: int(_src_order.CU_MEMCPY_SRC_ACCESS_ORDER_DURING_API_CALL),
151+
MemcpySrcAccessOrder.ANY: int(_src_order.CU_MEMCPY_SRC_ACCESS_ORDER_ANY),
152+
}
153+
_OVERLAP_MODE_TO_DRIVER = {
154+
MemcpyOverlapMode.DEFAULT: int(_flags.CU_MEMCPY_FLAG_DEFAULT),
155+
MemcpyOverlapMode.PREFER_OVERLAP_WITH_COMPUTE: int(_flags.CU_MEMCPY_FLAG_PREFER_OVERLAP_WITH_COMPUTE),
156+
}
157+
del _src_order, _flags
158+
else:
159+
_SRC_ACCESS_ORDER_TO_DRIVER = {}
160+
_OVERLAP_MODE_TO_DRIVER = {}
161+
162+
163+
def _attr_run_starts(attrs: Sequence[CopyOptions]) -> list[int]:
164+
"""Return the start index of each maximal run of equal attributes.
165+
166+
This mirrors the ``attrsIdxs`` indirection that ``cuMemcpyBatchAsync``
167+
expects: ``attrs[k]`` applies to the copies in
168+
``[starts[k], starts[k + 1])``. Collapsing equal neighbours means a
169+
broadcast attribute is passed to the driver once (``numAttrs == 1``)
170+
rather than repeated per copy.
171+
"""
172+
starts: list[int] = []
173+
prev: CopyOptions | None = None
174+
for i, attr in enumerate(attrs):
175+
if i == 0 or attr != prev:
176+
starts.append(i)
177+
prev = attr
178+
return starts
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# This file was generated by stubgen-pyx v0.2.6 from cuda_core/cuda/core/_memory/_copy_ops.pyx
2+
3+
from __future__ import annotations
4+
5+
from collections.abc import Sequence
6+
7+
from cuda.core._memory._buffer import Buffer
8+
from cuda.core._memory._copy_enums import CopyOptions
9+
from cuda.core._stream import Stream
10+
11+
_SINGLE_COPY_HINT = 'Buffer.copy_to / Buffer.copy_from'
12+
13+
def _normalize_copy_options(options: CopyOptions | Sequence[CopyOptions] | None, n: int) -> tuple[CopyOptions, ...]:
14+
"""Expand ``options`` to exactly one :class:`CopyOptions` per copy.
15+
16+
``None`` and a scalar broadcast; a sequence pairs by index and must
17+
already have length ``n``.
18+
19+
Internal, but deliberately importable: options are hints that change
20+
how the driver stages a transfer and never the bytes it produces, so
21+
this expansion (and the run encoding applied to it) is the only
22+
observable evidence that a scalar reached every copy.
23+
"""
24+
25+
def copy_batch(stream: Stream, srcs: Sequence[Buffer], dsts: Sequence[Buffer], *, options: CopyOptions | Sequence[CopyOptions] | None=None) -> None:
26+
"""Copy a batch of buffers asynchronously.
27+
28+
Source buffer and destination buffer sizes must match. For a single
29+
buffer, use :meth:`Buffer.copy_to` or :meth:`Buffer.copy_from`.
30+
31+
The driver provides no graph-node form of ``cuMemcpyBatchAsync``, so
32+
this cannot be captured into a graph. Both passing a
33+
:class:`~graph.GraphBuilder` and passing its underlying
34+
:attr:`~graph.GraphBuilder.stream` while capture is active are
35+
rejected. Build graph copies with
36+
:meth:`graph.GraphNode.memcpy` or per-buffer :meth:`Buffer.copy_to`.
37+
38+
Parameters
39+
----------
40+
stream : :class:`~_stream.Stream`
41+
Stream for the asynchronous copy. First positional and required
42+
(mirrors :func:`launch`). Does not accept a capturing stream
43+
(including a :class:`~graph.GraphBuilder`'s underlying stream); use
44+
:meth:`graph.GraphNode.memcpy` or per-buffer
45+
:meth:`Buffer.copy_to` to build copies into a graph.
46+
srcs : Sequence[:class:`Buffer`]
47+
Source buffers. Must be a sequence, not a single Buffer.
48+
dsts : Sequence[:class:`Buffer`]
49+
Destination buffers. Must match ``len(srcs)``.
50+
options : :class:`CopyOptions` | Sequence[:class:`CopyOptions`] | None
51+
Per-copy options. A single value applies to every copy; a
52+
sequence pairs by index and must match ``len(srcs)``. ``None``
53+
uses stream-ordered defaults.
54+
55+
Raises
56+
------
57+
ValueError
58+
If lengths or sizes mismatch.
59+
TypeError
60+
If a single Buffer is passed instead of a sequence, if a
61+
default-stream token (``LEGACY_DEFAULT_STREAM`` /
62+
``PER_THREAD_DEFAULT_STREAM``) is passed, or if the stream is
63+
currently in graph capture mode.
64+
65+
Notes
66+
-----
67+
Batching through ``cuMemcpyBatchAsync`` requires all three of:
68+
``cuda.core`` built against CUDA 13 headers, ``cuda.bindings`` 13.0 or
69+
newer, and a driver reporting CUDA 13.0 or newer
70+
(``cuDriverGetVersion() >= 13000``). ``cuda.bindings`` binds only the
71+
CUDA 13.0 revision of the entry point, so a driver that predates it is
72+
refused even where it implements the earlier CUDA 12.8 signature.
73+
74+
The driver may execute batch items concurrently and in any order.
75+
A batch must therefore not contain copies where the source range of
76+
one copy overlaps the destination range of another; such aliasing
77+
produces undefined results. Detecting overlaps at runtime is
78+
impractical; callers are responsible for ensuring no aliasing exists.
79+
80+
On pre-CUDA 13 installs the copies fall back to a Python-level loop
81+
over ``cuMemcpyAsync``, so the potential performance benefit of
82+
asynchronous batched copies is not realized. :class:`CopyOptions` are
83+
silently ignored on the fallback path.
84+
85+
"""

0 commit comments

Comments
 (0)