Coverage for cuda/core/_memory/_peer_access_utils.pyx: 38.89%
234 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 02:27 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-10 02:27 +0000
1# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2#
3# SPDX-License-Identifier: Apache-2.0
5from __future__ import annotations
7from collections.abc import Callable, Iterable, Iterator, MutableSet, Set
8from dataclasses import dataclass
9from typing import TYPE_CHECKING, Any, TypeVar
11_S = TypeVar("_S")
13from cuda.bindings cimport cydriver
14from cuda.core._memory._device_memory_resource cimport DeviceMemoryResource
15from cuda.core._memory._location cimport cumemlocation_from_id
16from cuda.core._memory._memory_pool cimport MP_check_open
17from cuda.core._resource_handles cimport as_cu
18from cuda.core._utils.cuda_utils cimport HANDLE_RETURN
19from cpython.mem cimport PyMem_Malloc, PyMem_Free
20from libcpp.vector cimport vector
22if TYPE_CHECKING:
23 from cuda.core._device import Device
26@dataclass(frozen=True)
27class PeerAccessPlan:
28 """Normalized peer-access target state and the driver updates it requires."""
30 target_ids: tuple[int, ...]
31 to_add: tuple[int, ...]
32 to_remove: tuple[int, ...]
35def normalize_peer_access_targets(
36 owner_device_id: int,
37 requested_devices: Iterable[object],
38 *,
39 resolve_device_id: Callable[[object], int],
40) -> tuple[int, ...]:
41 """Return sorted, unique peer device IDs, excluding the owner device."""
43 target_ids = {resolve_device_id(device) for device in requested_devices} 1badef
44 target_ids.discard(owner_device_id) 1adef
45 return tuple(sorted(target_ids)) 1adef
48def plan_peer_access_update(
49 owner_device_id: int,
50 current_peer_ids: Iterable[int],
51 requested_devices: Iterable[object],
52 *,
53 resolve_device_id: Callable[[object], int],
54 can_access_peer: Callable[[int], bool],
55) -> PeerAccessPlan:
56 """Compute the peer-access target state and add/remove deltas."""
58 target_ids = normalize_peer_access_targets( 1adef
59 owner_device_id,
60 requested_devices,
61 resolve_device_id=resolve_device_id, 1adef
62 )
63 bad = tuple(dev_id for dev_id in target_ids if not can_access_peer(dev_id)) 1adef
64 if bad: 1adef
65 bad_ids = ", ".join(str(dev_id) for dev_id in bad) 1f
66 raise ValueError(f"Device {owner_device_id} cannot access peer(s): {bad_ids}") 1f
68 current_ids = set(current_peer_ids) 1ade
69 target_id_set = set(target_ids) 1ade
70 return PeerAccessPlan( 1ade
71 target_ids=target_ids,
72 to_add=tuple(sorted(target_id_set - current_ids)), 1ade
73 to_remove=tuple(sorted(current_ids - target_id_set)), 1ade
74 )
77def _resolve_peer_device_id(value: Device | int | None) -> int:
78 """Coerce ``Device | int`` into a device-ordinal int."""
79 from cuda.core._device import Device 1c
81 return Device(value).device_id 1c
84# ---- driver-touching helpers (cdef inline, called from .pyx code) -----------
86cdef inline DeviceMemoryResource _check_peer_access_open(object mr):
87 cdef DeviceMemoryResource mr_typed = <DeviceMemoryResource>mr 1gca
88 MP_check_open(mr_typed) 1gca
89 return mr_typed 1ca
92cdef inline tuple _query_peer_access_ids(DeviceMemoryResource mr):
93 """Return the current peer device IDs as a sorted tuple of ints.
95 The full driver loop runs inside a single ``nogil`` block. Because
96 ``range(total)`` ascends, the result is already sorted.
97 """
98 MP_check_open(mr) 1ca
99 cdef int total
100 cdef int dev_id
101 cdef int owner_id = mr._dev_id 1ca
102 cdef cydriver.CUmemAccess_flags flags
103 cdef cydriver.CUmemLocation location
104 cdef cydriver.CUmemoryPool h_pool = as_cu(mr._h_pool) 1ca
105 cdef vector[int] peers
106 cdef size_t i
108 location.type = cydriver.CUmemLocationType.CU_MEM_LOCATION_TYPE_DEVICE 1ca
110 with nogil: 1ca
111 HANDLE_RETURN(cydriver.cuDeviceGetCount(&total)) 1ca
112 for dev_id in range(total): 1ca
113 if dev_id == owner_id: 1ca
114 continue 1ca
115 location.id = dev_id
116 HANDLE_RETURN(cydriver.cuMemPoolGetAccess(&flags, h_pool, &location))
117 if flags == cydriver.CUmemAccess_flags.CU_MEM_ACCESS_FLAGS_PROT_READWRITE:
118 peers.push_back(dev_id)
120 cdef size_t n = peers.size() 1ca
121 return tuple(peers[i] for i in range(n)) 1ca
124cdef inline bint _peer_access_includes(DeviceMemoryResource mr, int dev_id):
125 """Return True if peer access from ``dev_id`` is currently granted."""
126 MP_check_open(mr)
127 cdef cydriver.CUmemAccess_flags flags
128 cdef cydriver.CUmemLocation location = cumemlocation_from_id(
129 cydriver.CUmemLocationType.CU_MEM_LOCATION_TYPE_DEVICE, dev_id)
130 with nogil:
131 HANDLE_RETURN(cydriver.cuMemPoolGetAccess(&flags, as_cu(mr._h_pool), &location))
132 return flags == cydriver.CUmemAccess_flags.CU_MEM_ACCESS_FLAGS_PROT_READWRITE
135def _set_pool_access(mr: object, to_add: tuple[int, ...], to_remove: tuple[int, ...]) -> None:
136 """Issue one ``cuMemPoolSetAccess`` for the given add/remove deltas.
138 The thin Python-callable layer that wraps the actual driver call: building
139 the ``CUmemAccessDesc`` array and invoking ``cuMemPoolSetAccess`` happens
140 in here. Tests monkeypatch this on the module to spy on real driver work
141 without intercepting earlier no-op paths.
143 Preconditions: ``len(to_add) + len(to_remove) > 0`` (the caller is
144 responsible for skipping empty diffs).
145 """
146 cdef DeviceMemoryResource mr_typed = <DeviceMemoryResource>mr
147 MP_check_open(mr_typed)
148 cdef size_t count = len(to_add) + len(to_remove)
149 cdef cydriver.CUmemAccessDesc* access_desc = NULL
150 cdef size_t i = 0
152 access_desc = <cydriver.CUmemAccessDesc*>PyMem_Malloc(count * sizeof(cydriver.CUmemAccessDesc))
153 if access_desc == NULL:
154 raise MemoryError("Failed to allocate memory for access descriptors")
156 try:
157 for dev_id in to_add:
158 access_desc[i].flags = cydriver.CUmemAccess_flags.CU_MEM_ACCESS_FLAGS_PROT_READWRITE
159 access_desc[i].location.type = cydriver.CUmemLocationType.CU_MEM_LOCATION_TYPE_DEVICE
160 access_desc[i].location.id = dev_id
161 i += 1
162 for dev_id in to_remove:
163 access_desc[i].flags = cydriver.CUmemAccess_flags.CU_MEM_ACCESS_FLAGS_PROT_NONE
164 access_desc[i].location.type = cydriver.CUmemLocationType.CU_MEM_LOCATION_TYPE_DEVICE
165 access_desc[i].location.id = dev_id
166 i += 1
168 with nogil:
169 HANDLE_RETURN(cydriver.cuMemPoolSetAccess(as_cu(mr_typed._h_pool), access_desc, count))
170 finally:
171 if access_desc != NULL:
172 PyMem_Free(access_desc)
175def _apply_peer_access_diff(mr: DeviceMemoryResource, to_add: Iterable[int], to_remove: Iterable[int]) -> None:
176 """Apply a peer-access diff in at most one driver call.
178 Every write path on :class:`PeerAccessibleBySetProxy` and the
179 ``peer_accessible_by`` setter routes through this function. Empty diffs
180 short-circuit here so the driver-level helper :func:`_set_pool_access` is
181 only invoked when there is actual work for ``cuMemPoolSetAccess`` to do.
182 """
183 MP_check_open(mr) 1a
184 add_tuple = tuple(to_add) 1a
185 remove_tuple = tuple(to_remove) 1a
186 if not add_tuple and not remove_tuple: 1a
187 return 1a
188 _set_pool_access(mr, add_tuple, remove_tuple)
191cpdef void replace_peer_accessible_by(DeviceMemoryResource mr, object devices):
192 """Replace the full peer-access set in a single batched driver call.
194 Backs the ``mr.peer_accessible_by = [...]`` setter. Uses the same planner
195 as the proxy's bulk ops; the only difference is that adds and removes are
196 derived from the symmetric difference between current driver state and the
197 requested target set.
198 """
199 MP_check_open(mr) 1a
200 from cuda.core._device import Device 1a
202 this_dev = Device(mr._dev_id) 1a
203 plan = plan_peer_access_update( 1a
204 owner_device_id=mr._dev_id, 1a
205 current_peer_ids=_query_peer_access_ids(mr), 1a
206 requested_devices=devices,
207 resolve_device_id=_resolve_peer_device_id, 1a
208 can_access_peer=this_dev.can_access_peer, 1a
209 )
210 _apply_peer_access_diff(mr, plan.to_add, plan.to_remove) 1a
213# ---- Python MutableSet proxy ------------------------------------------------
215class PeerAccessibleBySetProxy(MutableSet["Device"]):
216 """Live driver-backed view of the peer devices granted access to a memory pool.
218 Reads (``__contains__``, ``__iter__``, ``len(...)``) call ``cuMemPoolGetAccess``;
219 writes (``add``, ``discard``, and bulk ops) call ``cuMemPoolSetAccess``. There
220 is no in-memory mirror, so the view always reflects the current driver state
221 and stays consistent across multiple wrappers around the same pool.
223 Iteration yields :class:`~cuda.core.Device` objects. ``add``, ``discard``, and
224 ``__contains__`` accept either a :class:`~cuda.core.Device` or a device-ordinal
225 ``int``; the owner device is silently ignored when supplied.
227 All bulk operations (``update``, ``|=``, ``&=``, ``-=``, ``^=``, ``clear``)
228 issue exactly one ``cuMemPoolSetAccess`` call. This matters: peer-access
229 transitions can take seconds per pool because every existing memory mapping
230 is updated, so coalescing into a single driver call lets the toolkit handle
231 the mappings in parallel.
232 """
234 __slots__ = ("_mr",)
236 def __init__(self, mr: DeviceMemoryResource) -> None:
237 self._mr = mr 1gca
239 @classmethod
240 def _from_iterable(cls, it: Iterable[_S]) -> set[_S]:
241 # Binary set operators (&, |, -, ^) collect their result through
242 # _from_iterable. Returning a plain set lets the user reason about
243 # the result independently of any pool's driver state.
244 return set(it)
246 # --- abstract MutableSet methods ---
248 def __contains__(self, value: object) -> bool:
249 cdef DeviceMemoryResource mr = _check_peer_access_open(self._mr) 1c
250 try: 1c
251 dev_id = _resolve_peer_device_id(value) 1c
252 except (TypeError, ValueError):
253 return False
254 if dev_id == mr._dev_id: 1c
255 return False 1c
256 return _peer_access_includes(mr, dev_id)
258 def __iter__(self) -> Iterator[Device]:
259 cdef DeviceMemoryResource mr = _check_peer_access_open(self._mr) 1ca
260 from cuda.core._device import Device 1ca
262 return iter(Device(dev_id) for dev_id in _query_peer_access_ids(mr)) 1ca
264 def __len__(self) -> int:
265 cdef DeviceMemoryResource mr = _check_peer_access_open(self._mr) 1g
266 return len(_query_peer_access_ids(mr))
268 def add(self, value: Device | int) -> None:
269 """Grant peer access from ``value`` to allocations in this pool."""
270 cdef DeviceMemoryResource mr = _check_peer_access_open(self._mr)
271 dev_id = _resolve_peer_device_id(value)
272 if dev_id == mr._dev_id:
273 return
274 if _peer_access_includes(mr, dev_id):
275 return
276 from cuda.core._device import Device
277 if not Device(mr._dev_id).can_access_peer(dev_id):
278 raise ValueError(f"Device {mr._dev_id} cannot access peer: {dev_id}")
279 _apply_peer_access_diff(mr, (dev_id,), ())
281 def discard(self, value: Device | int) -> None:
282 """Revoke peer access from ``value`` to allocations in this pool."""
283 cdef DeviceMemoryResource mr = _check_peer_access_open(self._mr)
284 try:
285 dev_id = _resolve_peer_device_id(value)
286 except (TypeError, ValueError):
287 return
288 if dev_id == mr._dev_id:
289 return
290 if not _peer_access_includes(mr, dev_id):
291 return
292 _apply_peer_access_diff(mr, (), (dev_id,))
294 # --- bulk overrides: one driver call per op ---
296 def clear(self) -> None:
297 """Revoke all peer access in a single driver call."""
298 _check_peer_access_open(self._mr)
299 self._apply((), _query_peer_access_ids(self._mr))
301 def update(self, *others: Iterable[Device | int]) -> None:
302 """Grant peer access to every device in ``others`` in one driver call."""
303 _check_peer_access_open(self._mr)
304 to_add = []
305 for other in others:
306 to_add.extend(other)
307 if to_add:
308 self._apply(to_add, ())
310 def difference_update(self, *others: Iterable[Device | int]) -> None:
311 """Revoke peer access for every device in ``others`` in one driver call."""
312 _check_peer_access_open(self._mr)
313 revoke_ids = set()
314 for other in others:
315 for value in other:
316 try:
317 revoke_ids.add(_resolve_peer_device_id(value))
318 except (TypeError, ValueError):
319 continue
320 current = set(_query_peer_access_ids(self._mr))
321 to_remove = revoke_ids & current
322 if to_remove:
323 self._apply((), to_remove)
325 def intersection_update(self, *others: Iterable[Device | int]) -> None:
326 """Restrict peer access to the intersection in a single driver call."""
327 _check_peer_access_open(self._mr)
328 keep_ids = None
329 for other in others:
330 ids = set()
331 for value in other:
332 try:
333 ids.add(_resolve_peer_device_id(value))
334 except (TypeError, ValueError):
335 continue
336 keep_ids = ids if keep_ids is None else keep_ids & ids
337 if keep_ids is None:
338 return # ``set.intersection_update()`` with no args is a no-op
339 current = set(_query_peer_access_ids(self._mr))
340 to_remove = current - keep_ids
341 if to_remove:
342 self._apply((), to_remove)
344 def symmetric_difference_update(self, other: Iterable[Device | int]) -> None:
345 """Toggle peer access for every device in ``other`` in one driver call."""
346 _check_peer_access_open(self._mr)
347 toggle_ids = set()
348 for value in other:
349 try:
350 toggle_ids.add(_resolve_peer_device_id(value))
351 except (TypeError, ValueError):
352 continue
353 current = set(_query_peer_access_ids(self._mr))
354 to_add = toggle_ids - current
355 to_remove = toggle_ids & current
356 if to_add or to_remove:
357 self._apply(to_add, to_remove)
359 def __ior__(self, other: Set[Any]) -> PeerAccessibleBySetProxy: # type: ignore[misc]
360 self.update(other)
361 return self
363 def __iand__(self, other: Set[Any]) -> PeerAccessibleBySetProxy:
364 self.intersection_update(other)
365 return self
367 def __isub__(self, other: Set[Any]) -> PeerAccessibleBySetProxy:
368 if other is self:
369 self.clear()
370 else:
371 self.difference_update(other)
372 return self
374 def __ixor__(self, other: Set[Any]) -> PeerAccessibleBySetProxy: # type: ignore[misc]
375 self.symmetric_difference_update(other)
376 return self
378 def __repr__(self) -> str:
379 return f"PeerAccessibleBySetProxy({set(self)!r})"
381 # --- internal: route every write through one batched driver call ---
383 def _apply(self, additions, removals) -> None:
384 """Compute the diff and issue a single ``cuMemPoolSetAccess``.
386 ``additions`` and ``removals`` are user-supplied (``Device | int``);
387 only the owner device is filtered out. Adds are validated through
388 :meth:`Device.can_access_peer` via :func:`plan_peer_access_update`;
389 removals bypass that check (revoking is always permitted).
390 """
391 from cuda.core._device import Device
393 cdef DeviceMemoryResource mr = _check_peer_access_open(self._mr)
394 owner_id = mr._dev_id
395 owner = Device(owner_id)
396 current = _query_peer_access_ids(mr)
398 # Plan additions through the existing helper (validates can_access_peer).
399 plan = plan_peer_access_update(
400 owner_device_id=owner_id,
401 current_peer_ids=current,
402 # union of (current set + requested adds) so the planner emits
403 # exactly the to_add deltas for these additions, no removals.
404 requested_devices=[*current, *additions],
405 resolve_device_id=_resolve_peer_device_id,
406 can_access_peer=owner.can_access_peer,
407 )
408 to_add = plan.to_add
410 # Removals: resolve, drop owner and unknowns, intersect with current.
411 current_set = set(current)
412 revoke_ids = set()
413 for value in removals:
414 try:
415 dev_id = _resolve_peer_device_id(value)
416 except (TypeError, ValueError):
417 continue
418 if dev_id == owner_id:
419 continue
420 if dev_id in current_set:
421 revoke_ids.add(dev_id)
422 to_remove = tuple(sorted(revoke_ids))
424 if not to_add and not to_remove:
425 return
426 _apply_peer_access_diff(mr, to_add, to_remove)