Coverage for src/arraybridge/types.py: 83%
537 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 11:15 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 11:15 +0000
1"""
2Memory type definitions for arraybridge.
4This module defines the MemoryType enum and related constants for managing
5different array/tensor frameworks.
6"""
8import importlib
9import importlib.util
10import logging
11import os
12import sys
13from collections.abc import Callable, Iterator, Mapping, MutableMapping
14from contextlib import AbstractContextManager, contextmanager, nullcontext
15from dataclasses import dataclass
16from enum import Enum
17from pathlib import Path
18from typing import Any, TypeVar, cast
20from arraybridge.array_operations import (
21 CUPY_OPERATIONS,
22 JAX_OPERATIONS,
23 NUMPY_OPERATIONS,
24 PYCLESPERANTO_OPERATIONS,
25 TENSORFLOW_OPERATIONS,
26 TORCH_OPERATIONS,
27 ArrayOperations,
28)
30T = TypeVar("T")
31logger = logging.getLogger(__name__)
32ConversionFunc = Callable[[Any], Any]
33DeviceIdsResolver = Callable[[Any], tuple[int, ...]]
34DeviceIdResolver = Callable[[Any, Any], int | None]
35DeviceScopeFactory = Callable[[Any, int], AbstractContextManager[None]]
36DeviceActivator = Callable[[Any, int], None]
37FrameworkCleanup = Callable[[Any], None]
38ActiveDeviceMover = Callable[[Any, Any, int], Any]
39CurrentDeviceResolver = Callable[[Any], int | None]
40StreamFactory = Callable[[Any], Any]
41StreamScopeFactory = Callable[[Any, Any], AbstractContextManager[None]]
42DLPackExporter = Callable[[Any, Any], Any | None]
43DLPackValidator = Callable[[Any, Any], bool]
44OOMMatcher = Callable[[BaseException, Any | None], bool]
45SubprocessEnvironmentResolver = Callable[[Mapping[str, str]], dict[str, str]]
48def _identity_subprocess_environment(
49 environment: Mapping[str, str],
50) -> dict[str, str]:
51 return dict(environment)
54def _nvidia_wheel_library_paths() -> tuple[str, ...]:
55 """Return native-library directories declared by installed NVIDIA wheels."""
57 try:
58 spec = importlib.util.find_spec("nvidia")
59 except (ImportError, ModuleNotFoundError, ValueError):
60 return ()
61 if spec is None or spec.submodule_search_locations is None:
62 return ()
64 paths: set[str] = set()
65 for package_root in map(Path, spec.submodule_search_locations):
66 try:
67 components = tuple(package_root.iterdir())
68 except OSError:
69 continue
70 for component in components:
71 for library_directory_name in ("lib", "bin"):
72 candidate = component / library_directory_name
73 if candidate.is_dir():
74 paths.add(str(candidate))
75 return tuple(sorted(paths))
78def _nvidia_wheel_subprocess_environment(
79 environment: Mapping[str, str],
80) -> dict[str, str]:
81 """Prepend installed NVIDIA wheel libraries to a child environment."""
83 prepared = dict(environment)
84 library_paths = _nvidia_wheel_library_paths()
85 if not library_paths:
86 return prepared
88 search_variable = "PATH" if os.name == "nt" else "LD_LIBRARY_PATH"
89 existing_paths = tuple(
90 path for path in prepared.get(search_variable, "").split(os.pathsep) if path
91 )
92 prepared[search_variable] = os.pathsep.join(dict.fromkeys((*library_paths, *existing_paths)))
93 return prepared
96class MemoryContractAttribute(str, Enum):
97 """Callable metadata keys owned by ArrayBridge's decorator contract."""
99 INPUT = "input_memory_type"
100 OUTPUT = "output_memory_type"
101 EXECUTION = "execution_memory_type"
103 def read(self, namespace: Any, default: Any = None) -> Any:
104 """Read this declaration from an attribute or mapping namespace."""
106 if isinstance(namespace, Mapping):
107 return namespace.get(self.value, default)
108 return getattr(namespace, self.value, default)
110 def write(self, namespace: Any, value: Any) -> None:
111 """Write this declaration to an attribute or mutable mapping namespace."""
113 if isinstance(namespace, MutableMapping):
114 namespace[self.value] = value
115 return
116 setattr(namespace, self.value, value)
119@dataclass(frozen=True, slots=True)
120class DLPackPayload:
121 """One exported capsule plus its original protocol-bearing array."""
123 source: Any
124 capsule: Any
127DLPackImporter = Callable[[DLPackPayload, Any], Any]
130def _no_device_ids(module: Any) -> tuple[int, ...]:
131 del module
132 return ()
135def _no_device_id(data: Any, module: Any) -> None:
136 del data, module
137 return None
140def _cupy_device_id(data: Any, module: Any) -> int:
141 del module
142 return int(data.device.id)
145def _torch_device_id(data: Any, module: Any) -> int | None:
146 del module
147 return int(data.device.index) if data.is_cuda else None
150def _tensorflow_device_id(data: Any, module: Any) -> int | None:
151 del module
152 device = data.device.lower()
153 return int(device.rsplit(":", maxsplit=1)[-1]) if "gpu" in device else None
156def _jax_device_id(data: Any, module: Any) -> int | None:
157 device = data.device
158 device = device() if callable(device) else device
159 if getattr(device, "platform", None) != "gpu":
160 return None
161 gpu_devices = tuple(candidate for candidate in module.devices() if candidate.platform == "gpu")
162 identity_match = next(
163 (index for index, candidate in enumerate(gpu_devices) if candidate is device),
164 None,
165 )
166 if identity_match is not None:
167 return identity_match
168 return next(
169 (index for index, candidate in enumerate(gpu_devices) if str(candidate) == str(device)),
170 None,
171 )
174def _pyclesperanto_device_id(data: Any, module: Any) -> int | None:
175 declared_device = getattr(data, "device", None)
176 if declared_device is None:
177 declared_device = module.get_device()
178 declared_selector = getattr(declared_device, "name", None)
179 if declared_selector is None:
180 declared_selector = str(declared_device)
181 devices = _pyclesperanto_devices(module)
182 return next(
183 (
184 index
185 for index, device in enumerate(devices)
186 if getattr(device, "name", str(device)) == declared_selector
187 ),
188 None,
189 )
192def _cupy_device_ids(module: Any) -> tuple[int, ...]:
193 return tuple(range(int(module.cuda.runtime.getDeviceCount())))
196def _torch_device_ids(module: Any) -> tuple[int, ...]:
197 if not module.cuda.is_available():
198 return ()
199 return tuple(range(int(module.cuda.device_count())))
202def _tensorflow_device_ids(module: Any) -> tuple[int, ...]:
203 return tuple(range(len(module.config.list_logical_devices("GPU"))))
206def _jax_device_ids(module: Any) -> tuple[int, ...]:
207 return tuple(
208 range(len(tuple(device for device in module.devices() if device.platform == "gpu")))
209 )
212def _pyclesperanto_devices(module: Any) -> tuple[Any, ...]:
213 try:
214 return tuple(module.list_available_devices("gpu"))
215 except TypeError:
216 return tuple(module.list_available_devices())
219def _pyclesperanto_select_device(
220 module: Any,
221 selector: str | int,
222 device_type: str | None = None,
223) -> None:
224 if device_type is None:
225 module.select_device(selector)
226 return
227 try:
228 module.select_device(selector, device_type)
229 except TypeError:
230 module.select_device(selector)
233def _pyclesperanto_device_ids(module: Any) -> tuple[int, ...]:
234 return tuple(range(len(_pyclesperanto_devices(module))))
237def _null_device_scope(module: Any, device_id: int) -> AbstractContextManager[None]:
238 del module, device_id
239 return nullcontext()
242def _cupy_device_scope(module: Any, device_id: int) -> AbstractContextManager[None]:
243 return cast(AbstractContextManager[None], module.cuda.Device(device_id))
246def _torch_device_scope(module: Any, device_id: int) -> AbstractContextManager[None]:
247 return cast(AbstractContextManager[None], module.cuda.device(device_id))
250def _tensorflow_device_scope(
251 module: Any,
252 device_id: int,
253) -> AbstractContextManager[None]:
254 return cast(AbstractContextManager[None], module.device(f"/device:GPU:{device_id}"))
257def _jax_device_scope(module: Any, device_id: int) -> AbstractContextManager[None]:
258 gpu_devices = tuple(device for device in module.devices() if device.platform == "gpu")
259 return cast(AbstractContextManager[None], module.default_device(gpu_devices[device_id]))
262@contextmanager
263def _pyclesperanto_device_scope(module: Any, device_id: int) -> Iterator[None]:
264 current_device = module.get_device()
265 current_id = _pyclesperanto_device_id(None, module)
266 current_selector = getattr(current_device, "name", None)
267 if current_selector is None:
268 current_selector = str(current_device)
269 _pyclesperanto_select_device(module, device_id, "gpu")
270 try:
271 yield
272 finally:
273 if current_id != device_id:
274 _pyclesperanto_select_device(module, current_selector)
277def _no_device_activation(module: Any, device_id: int) -> None:
278 del module, device_id
281def _cupy_device_activation(module: Any, device_id: int) -> None:
282 module.cuda.Device(device_id).use()
285def _torch_device_activation(module: Any, device_id: int) -> None:
286 module.cuda.set_device(device_id)
289def _pyclesperanto_device_activation(module: Any, device_id: int) -> None:
290 _pyclesperanto_select_device(module, device_id, "gpu")
293def _cupy_cleanup(module: Any) -> None:
294 module.get_default_memory_pool().free_all_blocks()
295 module.get_default_pinned_memory_pool().free_all_blocks()
296 module.cuda.runtime.deviceSynchronize()
299def _torch_cleanup(module: Any) -> None:
300 module.cuda.empty_cache()
301 module.cuda.synchronize()
304def _identity_device_move(data: Any, module: Any, device_id: int) -> Any:
305 del module, device_id
306 return data
309def _cupy_device_move(data: Any, module: Any, device_id: int) -> Any:
310 del module, device_id
311 return data.copy()
314def _torch_device_move(data: Any, module: Any, device_id: int) -> Any:
315 del module
316 return data.to(f"cuda:{device_id}")
319def _tensorflow_device_move(data: Any, module: Any, device_id: int) -> Any:
320 del device_id
321 return module.identity(data)
324def _jax_device_move(data: Any, module: Any, device_id: int) -> Any:
325 gpu_devices = tuple(device for device in module.devices() if device.platform == "gpu")
326 return module.device_put(data, gpu_devices[device_id])
329def _pyclesperanto_device_move(data: Any, module: Any, device_id: int) -> Any:
330 del device_id
331 result = module.create_like(data)
332 module.copy(data, result)
333 return result
336def _no_current_device(module: Any) -> None:
337 del module
338 return None
341def _cupy_current_device(module: Any) -> int:
342 return int(module.cuda.runtime.getDevice())
345def _torch_current_device(module: Any) -> int | None:
346 return int(module.cuda.current_device()) if module.cuda.is_available() else None
349def _cupy_stream(module: Any) -> Any:
350 return module.cuda.Stream()
353def _torch_stream(module: Any) -> Any:
354 return module.cuda.Stream()
357def _cupy_stream_scope(module: Any, stream: Any) -> AbstractContextManager[None]:
358 del module
359 return cast(AbstractContextManager[None], stream)
362def _torch_stream_scope(module: Any, stream: Any) -> AbstractContextManager[None]:
363 return cast(AbstractContextManager[None], module.cuda.stream(stream))
366def _null_stream_scope(module: Any, stream: Any) -> AbstractContextManager[None]:
367 del module, stream
368 return nullcontext()
371def _has_modern_dlpack_protocol(data: Any) -> bool:
372 return callable(getattr(data, "__dlpack__", None)) and callable(
373 getattr(data, "__dlpack_device__", None)
374 )
377def _cupy_from_dlpack(payload: DLPackPayload, module: Any) -> Any:
378 if _has_modern_dlpack_protocol(payload.source):
379 return module.from_dlpack(payload.source)
380 legacy_importer = getattr(module, "fromDlpack", None)
381 return NotImplemented if legacy_importer is None else legacy_importer(payload.capsule)
384def _torch_from_dlpack(payload: DLPackPayload, module: Any) -> Any:
385 return module.from_dlpack(payload.capsule)
388def _export_dlpack(data: Any) -> Any:
389 for attribute in ("__dlpack__", "to_dlpack", "toDlpack"):
390 exporter = getattr(data, attribute, None)
391 if callable(exporter):
392 return exporter()
393 raise TypeError(f"{type(data).__name__} does not expose a DLPack exporter")
396def _tensorflow_from_dlpack(payload: DLPackPayload, module: Any) -> Any:
397 return module.experimental.dlpack.from_dlpack(payload.capsule)
400def _dlpack_data_pointer(data: Any) -> int | None:
401 """Return a protocol source pointer when its framework exposes one."""
403 data_ptr = getattr(data, "data_ptr", None)
404 if callable(data_ptr):
405 return int(data_ptr())
406 cuda_interface = getattr(data, "__cuda_array_interface__", None)
407 if isinstance(cuda_interface, dict):
408 pointer, _read_only = cuda_interface.get("data", (None, False))
409 return None if pointer is None else int(pointer)
410 return None
413def _jax_aligned_dlpack_source(data: Any) -> Any:
414 """Copy a DLPack source only when JAX's 16-byte alignment is unmet."""
416 pointer = _dlpack_data_pointer(data)
417 if pointer is None or pointer % 16 == 0:
418 return data
419 for attribute in ("clone", "copy"):
420 copier = getattr(data, attribute, None)
421 if callable(copier):
422 return copier()
423 return data
426def _jax_from_dlpack(payload: DLPackPayload, module: Any) -> Any:
427 if not _has_modern_dlpack_protocol(payload.source):
428 return NotImplemented
429 return module.dlpack.from_dlpack(_jax_aligned_dlpack_source(payload.source))
432def _protocol_dlpack(data: Any, module: Any) -> bool:
433 del module
434 return any(
435 callable(getattr(data, attribute, None))
436 for attribute in ("__dlpack__", "toDlpack", "to_dlpack")
437 )
440def _protocol_dlpack_export(data: Any, module: Any) -> Any | None:
441 del module
442 try:
443 return _export_dlpack(data)
444 except TypeError:
445 return None
448def _tensorflow_dlpack(data: Any, module: Any) -> bool:
449 try:
450 major, minor = map(int, module.__version__.split(".")[:2])
451 except (AttributeError, TypeError, ValueError):
452 return False
453 if (major, minor) < (2, 12):
454 return False
455 if "gpu" not in str(getattr(data, "device", "")).lower():
456 return False
457 dlpack = getattr(getattr(module, "experimental", None), "dlpack", None)
458 if not callable(getattr(dlpack, "to_dlpack", None)):
459 return False
460 return True
463def _tensorflow_dlpack_export(data: Any, module: Any) -> Any | None:
464 if not _tensorflow_dlpack(data, module):
465 return None
466 return module.experimental.dlpack.to_dlpack(data)
469def _message_matches(error: BaseException, *patterns: str) -> bool:
470 message = str(error).lower()
471 return any(pattern in message for pattern in patterns)
474def _exception_type(module: Any, *path: str) -> type[BaseException] | None:
475 candidate = module
476 for attribute in path:
477 candidate = getattr(candidate, attribute, None)
478 if candidate is None:
479 return None
480 return (
481 candidate if isinstance(candidate, type) and issubclass(candidate, BaseException) else None
482 )
485def _matches_declared_exception(error: BaseException, module: Any | None, *path: str) -> bool:
486 if module is None:
487 return False
488 exception_type = _exception_type(module, *path)
489 return exception_type is not None and isinstance(error, exception_type)
492def _numpy_oom(error: BaseException, module: Any | None) -> bool:
493 del module
494 return _message_matches(error, "cannot allocate memory", "memory exhausted")
497def _never_oom(error: BaseException, module: Any | None) -> bool:
498 del error, module
499 return False
502def _cupy_oom(error: BaseException, module: Any | None) -> bool:
503 return (
504 _matches_declared_exception(error, module, "cuda", "memory", "OutOfMemoryError")
505 or _matches_declared_exception(error, module, "cuda", "runtime", "CUDARuntimeError")
506 or _message_matches(error, "out of memory", "cuda_error_out_of_memory")
507 )
510def _torch_oom(error: BaseException, module: Any | None) -> bool:
511 return _matches_declared_exception(
512 error, module, "cuda", "OutOfMemoryError"
513 ) or _message_matches(error, "out of memory", "cuda_error_out_of_memory")
516def _tensorflow_oom(error: BaseException, module: Any | None) -> bool:
517 return _matches_declared_exception(
518 error, module, "errors", "ResourceExhaustedError"
519 ) or _message_matches(error, "out of memory", "resource_exhausted")
522def _jax_oom(error: BaseException, module: Any | None) -> bool:
523 del module
524 return _message_matches(error, "out of memory", "oom when allocating", "allocation failure")
527def _pyclesperanto_oom(error: BaseException, module: Any | None) -> bool:
528 del module
529 return _message_matches(
530 error,
531 "cl_mem_object_allocation_failure",
532 "cl_out_of_resources",
533 "out of memory",
534 )
537@dataclass(frozen=True, slots=True)
538class FrameworkRuntime:
539 """Typed execution leaves carried by one ``MemoryType`` declaration."""
541 device_ids: DeviceIdsResolver = _no_device_ids
542 device_id: DeviceIdResolver = _no_device_id
543 device_scope: DeviceScopeFactory = _null_device_scope
544 activate_device: DeviceActivator = _no_device_activation
545 cleanup: FrameworkCleanup | None = None
546 move_to_active_device: ActiveDeviceMover = _identity_device_move
547 current_device: CurrentDeviceResolver = _no_current_device
548 stream_factory: StreamFactory | None = None
549 stream_scope: StreamScopeFactory = _null_stream_scope
550 dlpack_importer: DLPackImporter | None = None
551 dlpack_exporter: DLPackExporter | None = None
552 dlpack_validator: DLPackValidator = _protocol_dlpack
553 oom_matcher: OOMMatcher = _never_oom
554 subprocess_environment: SubprocessEnvironmentResolver = _identity_subprocess_environment
557class _MemoryTypeFields:
558 import_name: str
559 display_name: str
560 is_gpu: bool
561 module_aliases: tuple[str, ...]
562 import_environment: tuple[tuple[str, str], ...]
563 _runtime: FrameworkRuntime
564 _operations: ArrayOperations
567class MemoryType(_MemoryTypeFields, Enum):
568 """Array-framework declarations with member-owned runtime capability leaves."""
570 def __new__(
571 cls,
572 value: str,
573 *declaration: Any,
574 ) -> "MemoryType":
575 (
576 import_name,
577 display_name,
578 is_gpu,
579 module_aliases,
580 import_environment,
581 runtime,
582 operations,
583 ) = declaration
584 member = object.__new__(cls)
585 member._value_ = value
586 member.import_name = cast(str, import_name)
587 member.display_name = cast(str, display_name)
588 member.is_gpu = cast(bool, is_gpu)
589 member.module_aliases = cast(tuple[str, ...], module_aliases)
590 member.import_environment = cast(tuple[tuple[str, str], ...], import_environment)
591 member._runtime = cast(FrameworkRuntime, runtime)
592 member._operations = cast(ArrayOperations, operations)
593 return member
595 NUMPY = (
596 "numpy",
597 "numpy",
598 "NumPy",
599 False,
600 (),
601 (),
602 FrameworkRuntime(oom_matcher=_numpy_oom),
603 NUMPY_OPERATIONS,
604 )
605 CUPY = (
606 "cupy",
607 "cupy",
608 "CuPy",
609 True,
610 (),
611 (),
612 FrameworkRuntime(
613 device_ids=_cupy_device_ids,
614 device_id=_cupy_device_id,
615 device_scope=_cupy_device_scope,
616 activate_device=_cupy_device_activation,
617 cleanup=_cupy_cleanup,
618 move_to_active_device=_cupy_device_move,
619 current_device=_cupy_current_device,
620 stream_factory=_cupy_stream,
621 stream_scope=_cupy_stream_scope,
622 dlpack_importer=_cupy_from_dlpack,
623 dlpack_exporter=_protocol_dlpack_export,
624 oom_matcher=_cupy_oom,
625 subprocess_environment=_nvidia_wheel_subprocess_environment,
626 ),
627 CUPY_OPERATIONS,
628 )
629 TORCH = (
630 "torch",
631 "torch",
632 "PyTorch",
633 True,
634 (),
635 (),
636 FrameworkRuntime(
637 device_ids=_torch_device_ids,
638 device_id=_torch_device_id,
639 device_scope=_torch_device_scope,
640 activate_device=_torch_device_activation,
641 cleanup=_torch_cleanup,
642 move_to_active_device=_torch_device_move,
643 current_device=_torch_current_device,
644 stream_factory=_torch_stream,
645 stream_scope=_torch_stream_scope,
646 dlpack_importer=_torch_from_dlpack,
647 dlpack_exporter=_protocol_dlpack_export,
648 oom_matcher=_torch_oom,
649 ),
650 TORCH_OPERATIONS,
651 )
652 TENSORFLOW = (
653 "tensorflow",
654 "tensorflow",
655 "TensorFlow",
656 True,
657 (),
658 (("TF_FORCE_GPU_ALLOW_GROWTH", "true"),),
659 FrameworkRuntime(
660 device_ids=_tensorflow_device_ids,
661 device_id=_tensorflow_device_id,
662 device_scope=_tensorflow_device_scope,
663 move_to_active_device=_tensorflow_device_move,
664 dlpack_importer=_tensorflow_from_dlpack,
665 dlpack_exporter=_tensorflow_dlpack_export,
666 dlpack_validator=_tensorflow_dlpack,
667 oom_matcher=_tensorflow_oom,
668 ),
669 TENSORFLOW_OPERATIONS,
670 )
671 JAX = (
672 "jax",
673 "jax",
674 "JAX",
675 True,
676 ("jaxlib",),
677 (("XLA_PYTHON_CLIENT_PREALLOCATE", "false"),),
678 FrameworkRuntime(
679 device_ids=_jax_device_ids,
680 device_id=_jax_device_id,
681 device_scope=_jax_device_scope,
682 move_to_active_device=_jax_device_move,
683 dlpack_importer=_jax_from_dlpack,
684 dlpack_exporter=_protocol_dlpack_export,
685 oom_matcher=_jax_oom,
686 ),
687 JAX_OPERATIONS,
688 )
689 PYCLESPERANTO = (
690 "pyclesperanto",
691 "pyclesperanto",
692 "pyclesperanto",
693 True,
694 (),
695 (),
696 FrameworkRuntime(
697 device_ids=_pyclesperanto_device_ids,
698 device_id=_pyclesperanto_device_id,
699 device_scope=_pyclesperanto_device_scope,
700 activate_device=_pyclesperanto_device_activation,
701 move_to_active_device=_pyclesperanto_device_move,
702 oom_matcher=_pyclesperanto_oom,
703 ),
704 PYCLESPERANTO_OPERATIONS,
705 )
707 @property
708 def recognized_module_names(self) -> frozenset[str]:
709 """Return top-level module names owned by this framework declaration."""
711 return frozenset((self.import_name, *self.module_aliases))
713 def is_installed(self) -> bool:
714 """Check package presence without importing the optional framework."""
716 try:
717 return importlib.util.find_spec(self.import_name) is not None
718 except (ImportError, ModuleNotFoundError, ValueError):
719 return False
721 def prepare_import(self) -> None:
722 """Apply declaration-owned coexistence defaults before framework import."""
724 missing_names = tuple(
725 name for name, _value in self.import_environment if name not in os.environ
726 )
727 if missing_names and self.loaded_module() is not None:
728 logger.warning(
729 "%s was loaded before its ArrayBridge import defaults were set; "
730 "the current process cannot guarantee those import-time settings: %s",
731 self.display_name,
732 ", ".join(missing_names),
733 )
734 for name, value in self.import_environment:
735 os.environ.setdefault(name, value)
737 @classmethod
738 def subprocess_environment(
739 cls,
740 environment: Mapping[str, str] | None = None,
741 ) -> dict[str, str]:
742 """Project framework import requirements into a child environment."""
744 prepared = dict(os.environ if environment is None else environment)
745 for memory_type in cls:
746 for name, value in memory_type.import_environment:
747 prepared.setdefault(name, value)
748 prepared = memory_type._runtime.subprocess_environment(prepared)
749 return prepared
751 def loaded_module(self) -> Any | None:
752 """Return an already-loaded framework without causing an import."""
754 return sys.modules.get(self.import_name)
756 def import_module(self) -> Any:
757 """Import this declaration's framework module."""
759 self.prepare_import()
760 return importlib.import_module(self.import_name)
762 def import_if_installed(self) -> Any | None:
763 """Import this framework only when its package is present."""
765 loaded = self.loaded_module()
766 if loaded is not None:
767 self.prepare_import()
768 return loaded
769 if not self.is_installed():
770 return None
771 return self.import_module()
773 def to_numpy(self, data: Any, module: Any | None = None) -> Any:
774 """Project one array to NumPy through this declaration's leaf."""
776 framework = module if module is not None else self.import_module()
777 return self._operations.to_numpy(data, framework)
779 def from_numpy(
780 self,
781 data: Any,
782 device_id: int,
783 module: Any | None = None,
784 ) -> Any:
785 """Create one array from NumPy on a declared framework-local device."""
787 framework = module if module is not None else self.import_module()
788 with self.device_scope(device_id, framework):
789 return self._operations.from_numpy(data, framework, device_id)
791 def stack_arrays(
792 self,
793 arrays: list[Any],
794 device_id: int,
795 module: Any | None = None,
796 ) -> Any:
797 """Stack prepared arrays on a declared framework-local device."""
799 framework = module if module is not None else self.import_module()
800 with self.device_scope(device_id, framework):
801 return self._operations.stack(arrays, framework)
803 def scale_dtype(
804 self,
805 data: Any,
806 target_dtype: Any,
807 module: Any | None = None,
808 ) -> Any:
809 """Scale an array through this declaration's typed operation leaf."""
811 framework = module if module is not None else self.import_if_installed()
812 if framework is None:
813 return data
814 device_id = self.device_id_of(data, framework) if hasattr(data, "dtype") else None
815 scope = nullcontext() if device_id is None else self.device_scope(device_id, framework)
816 with scope:
817 return self._operations.scale_dtype(data, target_dtype, framework)
819 def canonical_dtype_name(self, dtype: Any) -> str:
820 """Return this framework member's portable dtype identity."""
822 return self._operations.dtype_name(dtype)
824 def astype(
825 self,
826 data: Any,
827 dtype: Any,
828 module: Any | None = None,
829 ) -> Any:
830 """Cast one array through this framework member's operation leaf."""
832 framework = module if module is not None else self.import_module()
833 device_id = self.device_id_of(data, framework)
834 scope = nullcontext() if device_id is None else self.device_scope(device_id, framework)
835 with scope:
836 return self._operations.cast(data, dtype, framework)
838 def logical_and(
839 self,
840 left: Any,
841 right: Any,
842 module: Any | None = None,
843 ) -> Any:
844 """Intersect two arrays through this framework member's operation leaf."""
846 framework = module if module is not None else self.import_module()
847 device_id = self.device_id_of(left, framework)
848 scope = nullcontext() if device_id is None else self.device_scope(device_id, framework)
849 with scope:
850 return self._operations.logical_and(left, right, framework)
852 def available_device_ids(self, module: Any | None = None) -> tuple[int, ...]:
853 """Return every framework-local GPU device identifier."""
855 if not self.is_gpu:
856 return ()
857 framework = module if module is not None else self.import_if_installed()
858 return () if framework is None else self._runtime.device_ids(framework)
860 def require_device(self, device_id: int, module: Any | None = None) -> Any:
861 """Return the framework after proving that its local device exists."""
863 if not self.is_gpu:
864 return module
865 framework = module if module is not None else self.import_module()
866 available = self.available_device_ids(framework)
867 if device_id not in available:
868 raise ValueError(
869 f"{self.display_name} device {device_id} is unavailable; "
870 f"available device IDs are {available}"
871 )
872 return framework
874 def device_id_of(self, data: Any, module: Any | None = None) -> int | None:
875 """Return this framework's local device identifier for an array."""
877 if not self.is_gpu:
878 return None
879 framework = module if module is not None else self.import_module()
880 return self._runtime.device_id(data, framework)
882 def device_scope(
883 self,
884 device_id: int,
885 module: Any | None = None,
886 ) -> AbstractContextManager[None]:
887 """Return this framework member's scoped device activation leaf."""
889 if not self.is_gpu:
890 return nullcontext()
891 framework = self.require_device(device_id, module)
892 return self._runtime.device_scope(framework, device_id)
894 def activate_device(self, device_id: int, module: Any | None = None) -> None:
895 """Activate a process-global device where the framework supports it."""
897 if not self.is_gpu:
898 return
899 framework = self.require_device(device_id, module)
900 self._runtime.activate_device(framework, device_id)
902 def move_to_device(
903 self,
904 data: Any,
905 device_id: int,
906 module: Any | None = None,
907 ) -> Any:
908 """Move an array to one framework-local device without leaking selection."""
910 if not self.is_gpu:
911 return data
912 framework = self.require_device(device_id, module)
913 if self.device_id_of(data, framework) == device_id:
914 return data
915 with self.device_scope(device_id, framework):
916 return self._runtime.move_to_active_device(data, framework, device_id)
918 @property
919 def supports_dlpack(self) -> bool:
920 """Whether this framework declares a DLPack import leaf."""
922 return self._runtime.dlpack_importer is not None
924 def supports_dlpack_data(self, data: Any, module: Any | None = None) -> bool:
925 """Validate DLPack export for an array owned by this framework."""
927 if self._runtime.dlpack_exporter is None:
928 return False
929 framework = module if module is not None else self.import_module()
930 return self._runtime.dlpack_validator(data, framework)
932 def export_dlpack(self, data: Any, module: Any | None = None) -> DLPackPayload | None:
933 """Export one array through this framework's declaration, if supported."""
935 exporter = self._runtime.dlpack_exporter
936 if exporter is None:
937 return None
938 framework = module if module is not None else self.import_module()
939 capsule = exporter(data, framework)
940 return None if capsule is None else DLPackPayload(source=data, capsule=capsule)
942 def from_dlpack(
943 self,
944 data: DLPackPayload | Any,
945 module: Any | None = None,
946 ) -> Any:
947 """Import DLPack data through this framework's declared leaf."""
949 importer = self._runtime.dlpack_importer
950 if importer is None:
951 raise NotImplementedError(f"DLPack not supported for {self.value}")
952 framework = module if module is not None else self.import_module()
953 payload = data if isinstance(data, DLPackPayload) else DLPackPayload(data, data)
954 return importer(payload, framework)
956 def convert_to(self, data: Any, target: "MemoryType", device_id: int) -> Any:
957 """Convert one array through the source and target declarations."""
959 if self is target:
960 return target.move_to_device(data, device_id)
962 if target.supports_dlpack:
963 try:
964 payload = self.export_dlpack(data)
965 if payload is not None:
966 module = target.import_module()
967 with target.device_scope(device_id, module):
968 result = target.from_dlpack(payload, module)
969 if result is not NotImplemented:
970 return target.move_to_device(result, device_id, module)
971 except Exception as error:
972 logger.warning(
973 "DLPack conversion from %s to %s failed: %s. Using CPU roundtrip.",
974 self.value,
975 target.value,
976 error,
977 )
979 return target.from_numpy(self.to_numpy(data), device_id)
981 def current_device_id(self, module: Any | None = None) -> int | None:
982 """Return the framework-local process device used for new streams."""
984 if not self.is_gpu:
985 return None
986 framework = module if module is not None else self.import_module()
987 return self._runtime.current_device(framework)
989 def create_stream(self, module: Any | None = None) -> Any | None:
990 """Create a stream on the framework's current device when supported."""
992 factory = self._runtime.stream_factory
993 if factory is None:
994 return None
995 framework = module if module is not None else self.import_module()
996 return factory(framework)
998 def stream_scope(
999 self,
1000 stream: Any,
1001 module: Any | None = None,
1002 ) -> AbstractContextManager[None]:
1003 """Return this framework member's execution scope for one stream."""
1005 if stream is None:
1006 return nullcontext()
1007 framework = module if module is not None else self.import_module()
1008 return self._runtime.stream_scope(framework, stream)
1010 def is_oom_error(self, error: BaseException) -> bool:
1011 """Classify an error without importing an optional framework."""
1013 return self._runtime.oom_matcher(error, self.loaded_module())
1015 def cleanup_loaded(self, device_id: int | None = None) -> None:
1016 """Clean an already-loaded GPU framework without importing absent modules."""
1018 if not self.is_gpu:
1019 return
1020 framework = self.loaded_module()
1021 if framework is None:
1022 return
1023 cleanup = self._runtime.cleanup
1024 if cleanup is None:
1025 return
1026 available = self.available_device_ids(framework)
1027 if not available:
1028 return
1029 targets = available if device_id is None else (device_id,)
1030 for target in targets:
1031 with self.device_scope(target, framework):
1032 cleanup(framework)
1035# Memory type sets
1036CPU_MEMORY_TYPES: frozenset[MemoryType] = frozenset(
1037 memory_type for memory_type in MemoryType if not memory_type.is_gpu
1038)
1039GPU_MEMORY_TYPES: frozenset[MemoryType] = frozenset(
1040 memory_type for memory_type in MemoryType if memory_type.is_gpu
1041)
1042SUPPORTED_MEMORY_TYPES: frozenset[MemoryType] = CPU_MEMORY_TYPES | GPU_MEMORY_TYPES
1044# String value sets for validation
1045VALID_MEMORY_TYPES = frozenset(mt.value for mt in MemoryType)
1046VALID_GPU_MEMORY_TYPES = frozenset(mt.value for mt in GPU_MEMORY_TYPES)
1048# Compatibility constants are generated projections of the enum declaration.
1049for _memory_type in MemoryType:
1050 globals()[f"MEMORY_TYPE_{_memory_type.name}"] = _memory_type.value