cunumpy 0.1.3__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cunumpy
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cunumpy as xp`.
5
5
  Author: Max
6
6
  Project-URL: Source, https://github.com/max-models/cunumpy
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Python: >=3.8
17
17
  Description-Content-Type: text/markdown
18
+ Requires-Dist: array-api-compat
18
19
  Requires-Dist: numpy
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: black[jupyter]; extra == "dev"
@@ -32,6 +33,7 @@ Requires-Dist: sphinx; extra == "docs"
32
33
  Requires-Dist: sphinx-book-theme; extra == "docs"
33
34
  Provides-Extra: test
34
35
  Requires-Dist: coverage; extra == "test"
36
+ Requires-Dist: pyccel; extra == "test"
35
37
  Requires-Dist: pytest; extra == "test"
36
38
 
37
39
  # CuNumpy
@@ -5,7 +5,7 @@ requires = [ "setuptools", "wheel" ]
5
5
 
6
6
  [project]
7
7
  name = "cunumpy"
8
- version = "0.1.3"
8
+ version = "0.1.4"
9
9
  description = "Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cunumpy as xp`."
10
10
  readme = "README.md"
11
11
  keywords = [ "python" ]
@@ -23,6 +23,7 @@ classifiers = [
23
23
  "Programming Language :: Python :: 3.13",
24
24
  ]
25
25
  dependencies = [
26
+ "array-api-compat",
26
27
  "numpy",
27
28
  ]
28
29
 
@@ -43,7 +44,7 @@ optional-dependencies.docs = [
43
44
  "sphinx",
44
45
  "sphinx-book-theme",
45
46
  ]
46
- optional-dependencies.test = [ "coverage", "pytest" ]
47
+ optional-dependencies.test = [ "coverage", "pyccel", "pytest" ]
47
48
  urls."Source" = "https://github.com/max-models/cunumpy"
48
49
 
49
50
  [tool.setuptools.packages.find]
@@ -2,6 +2,7 @@
2
2
  from importlib.metadata import PackageNotFoundError, version
3
3
 
4
4
  from . import xp
5
+ from .kernel import PyccelKernel
5
6
  from .xp import (
6
7
  cupy_available,
7
8
  get_backend,
@@ -22,6 +23,7 @@ except PackageNotFoundError:
22
23
  __version__ = "0.0.0+unknown"
23
24
 
24
25
  __all__ = [
26
+ "PyccelKernel",
25
27
  "__version__",
26
28
  "cupy_available",
27
29
  "cupy_backend",
@@ -8,6 +8,7 @@ import numpy as np
8
8
  from numpy import *
9
9
 
10
10
  from . import xp as xp
11
+ from .kernel import PyccelKernel as PyccelKernel
11
12
 
12
13
  def to_numpy(array: Any) -> np.ndarray: ...
13
14
  def to_cupy(array: Any) -> Any: ...
@@ -0,0 +1,344 @@
1
+ """Interface for calling Pyccel-compiled kernels with CuPy arrays.
2
+
3
+ Kernels generated by `pyccel <https://github.com/pyccel/pyccel>`_ are compiled
4
+ C/Fortran routines that only understand NumPy (host) arrays. :class:`PyccelKernel`
5
+ wraps such a kernel so that it can be called transparently with CuPy (device)
6
+ arrays: the arguments are copied to the host before the call, in-place updates
7
+ made by the kernel are copied back to the device afterwards, and any arrays
8
+ returned by the kernel are moved back to the device.
9
+
10
+ On the NumPy backend the wrapper is a no-op and the kernel is called directly.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import copy
16
+ from typing import Any, Callable, Sequence
17
+
18
+ import array_api_compat
19
+ import numpy as np
20
+
21
+ from .xp import _cupy_backend, to_cupy, to_numpy
22
+
23
+ __all__ = ["PyccelKernel"]
24
+
25
+
26
+ class PyccelKernel:
27
+ """Call a Pyccel-compiled kernel with NumPy or CuPy arrays.
28
+
29
+ Parameters
30
+ ----------
31
+ kernel : callable
32
+ The pyccelized kernel (or any callable expecting NumPy arrays).
33
+ use_cupy : bool, optional
34
+ Force host/device conversion on (``True``) or off (``False``). By
35
+ default (``None``) it is decided at call time: conversion happens when
36
+ the active backend is CuPy or when a CuPy array is passed in.
37
+ object_modules : sequence of str, optional
38
+ Module prefixes (e.g. ``("struphy.", "feectools.")``) whose instances
39
+ should be traversed attribute-by-attribute when looking for arrays to
40
+ convert. Objects from other modules are passed through untouched.
41
+ outputs : sequence of int or str, optional
42
+ Which arguments the kernel writes to. Only those are copied back to the
43
+ device after the call, which avoids pointless device transfers for the
44
+ (usually much larger) read-only inputs. Positional arguments are named
45
+ by index, keyword arguments by name::
46
+
47
+ interpolate = PyccelKernel(some_interpolation_kernel, outputs=(5,))
48
+ interpolate(x, y, z, basis, coeffs, out) # `out` is argument 5
49
+
50
+ Pyccel-compiled kernels are builtins with no introspectable signature,
51
+ so an index and a name are *not* interchangeable: declare the form you
52
+ actually call with. An empty sequence declares that the kernel writes to
53
+ none of its arguments. By default (``None``) every converted array is
54
+ copied back, which is always correct but does more work.
55
+
56
+ Examples
57
+ --------
58
+ >>> from cunumpy.kernel import PyccelKernel
59
+ >>> kernel = PyccelKernel(my_pyccelized_function)
60
+ >>> kernel(out, x, y) # `out`, `x`, `y` may be NumPy or CuPy arrays
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ kernel: Callable[..., Any],
66
+ use_cupy: bool | None = None,
67
+ object_modules: Sequence[str] = (),
68
+ outputs: Sequence[int | str] | None = None,
69
+ ) -> None:
70
+ self._kernel = kernel
71
+ self._use_cupy = use_cupy
72
+ self._object_modules = tuple(object_modules)
73
+
74
+ if outputs is None:
75
+ self._outputs: tuple[int | str, ...] | None = None
76
+ else:
77
+ if isinstance(outputs, (int, str)):
78
+ raise TypeError(
79
+ "outputs must be a sequence of argument indices/names, "
80
+ f"not a bare {type(outputs).__name__} "
81
+ f"(did you mean outputs=({outputs!r},)?)"
82
+ )
83
+ for entry in outputs:
84
+ if not isinstance(entry, (int, str)) or isinstance(entry, bool):
85
+ raise TypeError(
86
+ "outputs entries must be argument indices (int) or "
87
+ f"names (str), got {entry!r}"
88
+ )
89
+ self._outputs = tuple(outputs)
90
+
91
+ def __repr__(self) -> str:
92
+ return (
93
+ f"PyccelKernel(kernel={self.name!r}, use_cupy={self.use_cupy!r}, "
94
+ f"outputs={self._outputs!r})"
95
+ )
96
+
97
+ def _convert_to_numpy(
98
+ self,
99
+ value: Any,
100
+ converted: list[tuple[Any, np.ndarray]],
101
+ memo: dict[int, Any],
102
+ ) -> Any:
103
+ """Recursively replace CuPy arrays in `value` by host copies.
104
+
105
+ Every replacement is appended to `converted` as a
106
+ ``(device_array, host_copy)`` pair.
107
+
108
+ `memo` maps ``id(original) -> converted`` and is shared across all
109
+ arguments of a single call. It serves two purposes: a device array
110
+ reachable by several paths is copied to the host exactly once (so the
111
+ kernel sees one shared array, as the caller intended, and the write-back
112
+ happens once), and reference cycles terminate instead of recursing
113
+ forever. Everything traversed here stays reachable from the caller's
114
+ arguments for the duration of the call, so the `id` keys cannot be
115
+ reused by unrelated objects.
116
+ """
117
+ key = id(value)
118
+ if key in memo:
119
+ return memo[key]
120
+
121
+ if array_api_compat.is_cupy_array(value):
122
+ value_np = to_numpy(value)
123
+ memo[key] = value_np
124
+ converted.append((value, value_np))
125
+ return value_np
126
+
127
+ if isinstance(value, tuple):
128
+ # A tuple cannot be memoized before its items are converted, but it
129
+ # can only take part in a cycle through a mutable container, and
130
+ # those are memoized before they are filled in below.
131
+ value_np = tuple(
132
+ self._convert_to_numpy(item, converted, memo) for item in value
133
+ )
134
+ memo[key] = value_np
135
+ return value_np
136
+
137
+ if isinstance(value, list):
138
+ value_np = []
139
+ memo[key] = value_np
140
+ value_np.extend(
141
+ self._convert_to_numpy(item, converted, memo) for item in value
142
+ )
143
+ return value_np
144
+
145
+ if isinstance(value, dict):
146
+ value_np = {}
147
+ memo[key] = value_np
148
+ for k, v in value.items():
149
+ value_np[k] = self._convert_to_numpy(v, converted, memo)
150
+ return value_np
151
+
152
+ if hasattr(value, "__dict__") and value.__class__.__module__.startswith(
153
+ self._object_modules
154
+ ):
155
+ # Shallow-copy the object so the caller's instance keeps pointing at
156
+ # its device arrays; only the copy holds the host views.
157
+ value_np = copy.copy(value)
158
+ memo[key] = value_np
159
+ for name, attr in vars(value).items():
160
+ setattr(value_np, name, self._convert_to_numpy(attr, converted, memo))
161
+ return value_np
162
+
163
+ return value
164
+
165
+ @staticmethod
166
+ def _convert_from_numpy(value: Any) -> Any:
167
+ """Move NumPy arrays returned by the kernel back to the device."""
168
+ if isinstance(value, np.ndarray):
169
+ return to_cupy(value)
170
+ if isinstance(value, tuple):
171
+ return tuple(PyccelKernel._convert_from_numpy(item) for item in value)
172
+ if isinstance(value, list):
173
+ return [PyccelKernel._convert_from_numpy(item) for item in value]
174
+ return value
175
+
176
+ def _collect_host_arrays(self, value: Any, found: set[int], seen: set[int]) -> None:
177
+ """Record the id of every host array reachable from `value`.
178
+
179
+ Runs over the *converted* arguments, using the same traversal rules as
180
+ :meth:`_convert_to_numpy`, so that an output declared as a container or
181
+ an object contributes the arrays nested inside it.
182
+ """
183
+ if isinstance(value, np.ndarray):
184
+ found.add(id(value))
185
+ return
186
+
187
+ if id(value) in seen:
188
+ return
189
+
190
+ if isinstance(value, (tuple, list)):
191
+ seen.add(id(value))
192
+ for item in value:
193
+ self._collect_host_arrays(item, found, seen)
194
+ return
195
+
196
+ if isinstance(value, dict):
197
+ seen.add(id(value))
198
+ for item in value.values():
199
+ self._collect_host_arrays(item, found, seen)
200
+ return
201
+
202
+ if hasattr(value, "__dict__") and value.__class__.__module__.startswith(
203
+ self._object_modules
204
+ ):
205
+ seen.add(id(value))
206
+ for attr in vars(value).values():
207
+ self._collect_host_arrays(attr, found, seen)
208
+
209
+ def _output_host_arrays(
210
+ self, args_np: list[Any], kwargs_np: dict[str, Any]
211
+ ) -> set[int]:
212
+ """Ids of the host arrays reachable from the declared output arguments.
213
+
214
+ Raises
215
+ ------
216
+ IndexError, KeyError
217
+ If a declared output does not correspond to an argument of this
218
+ call -- typically because an argument declared by index was passed
219
+ as a keyword, or vice versa.
220
+ """
221
+ found: set[int] = set()
222
+ seen: set[int] = set()
223
+
224
+ for entry in self._outputs or ():
225
+ if isinstance(entry, int):
226
+ index = entry + len(args_np) if entry < 0 else entry
227
+ if not 0 <= index < len(args_np):
228
+ raise IndexError(
229
+ f"{self.name}() was declared with output argument "
230
+ f"{entry}, but was called with {len(args_np)} "
231
+ "positional argument(s). Note that an output passed as "
232
+ "a keyword must be declared by name, not by index."
233
+ )
234
+ self._collect_host_arrays(args_np[index], found, seen)
235
+ else:
236
+ if entry not in kwargs_np:
237
+ raise KeyError(
238
+ f"{self.name}() was declared with output argument "
239
+ f"{entry!r}, but no such keyword argument was passed. "
240
+ "Note that an output passed positionally must be "
241
+ "declared by index, not by name."
242
+ )
243
+ self._collect_host_arrays(kwargs_np[entry], found, seen)
244
+
245
+ return found
246
+
247
+ def _contains_cupy(self, value: Any, seen: set[int] | None = None) -> bool:
248
+ """Whether `value` holds a CuPy array, following the same traversal
249
+ rules as :meth:`_convert_to_numpy`.
250
+
251
+ `seen` tracks already-visited containers so that reference cycles
252
+ terminate.
253
+ """
254
+ if array_api_compat.is_cupy_array(value):
255
+ return True
256
+
257
+ if seen is None:
258
+ seen = set()
259
+ if id(value) in seen:
260
+ return False
261
+
262
+ if isinstance(value, (tuple, list)):
263
+ seen.add(id(value))
264
+ return any(self._contains_cupy(item, seen) for item in value)
265
+
266
+ if isinstance(value, dict):
267
+ seen.add(id(value))
268
+ return any(self._contains_cupy(item, seen) for item in value.values())
269
+
270
+ if hasattr(value, "__dict__") and value.__class__.__module__.startswith(
271
+ self._object_modules
272
+ ):
273
+ seen.add(id(value))
274
+ return any(self._contains_cupy(attr, seen) for attr in vars(value).values())
275
+
276
+ return False
277
+
278
+ def _needs_conversion(self, args: tuple[Any, ...], kwargs: dict[str, Any]) -> bool:
279
+ if self._use_cupy is not None:
280
+ return self._use_cupy
281
+ if _cupy_backend():
282
+ return True
283
+ # The backend is NumPy, but individual CuPy arrays may still have been
284
+ # passed in explicitly.
285
+ return any(self._contains_cupy(value) for value in (*args, *kwargs.values()))
286
+
287
+ def __call__(self, *args: Any, **kwargs: Any) -> Any:
288
+ if not self._needs_conversion(args, kwargs):
289
+ return self._kernel(*args, **kwargs)
290
+
291
+ # Convert CuPy arrays in args/kwargs to NumPy arrays on the host. The
292
+ # memo is shared across args and kwargs so that an array passed more
293
+ # than once stays a single array on the host too.
294
+ converted: list[tuple[Any, np.ndarray]] = []
295
+ memo: dict[int, Any] = {}
296
+ args_np = [self._convert_to_numpy(x, converted, memo) for x in args]
297
+ kwargs_np = {
298
+ k: self._convert_to_numpy(v, converted, memo) for k, v in kwargs.items()
299
+ }
300
+
301
+ # Which arrays the kernel may have written to is resolved before the
302
+ # call, so a mis-declared output is reported even if the kernel itself
303
+ # would have raised first.
304
+ writeable = (
305
+ None
306
+ if self._outputs is None
307
+ else self._output_host_arrays(args_np, kwargs_np)
308
+ )
309
+
310
+ result = self._kernel(*args_np, **kwargs_np)
311
+
312
+ # Copy in-place kernel updates back to the device arrays.
313
+ for device_array, host_array in converted:
314
+ if writeable is None or id(host_array) in writeable:
315
+ device_array[...] = to_cupy(host_array)
316
+
317
+ return self._convert_from_numpy(result)
318
+
319
+ @property
320
+ def name(self) -> str:
321
+ """Name of the wrapped kernel."""
322
+ return getattr(self._kernel, "__name__", type(self._kernel).__name__)
323
+
324
+ @property
325
+ def kernel(self) -> Callable[..., Any]:
326
+ """The wrapped kernel."""
327
+ return self._kernel
328
+
329
+ @property
330
+ def use_cupy(self) -> bool:
331
+ """Whether calls currently convert between device and host arrays."""
332
+ if self._use_cupy is not None:
333
+ return self._use_cupy
334
+ return _cupy_backend()
335
+
336
+ @property
337
+ def object_modules(self) -> tuple[str, ...]:
338
+ """Module prefixes whose instances are traversed for arrays."""
339
+ return self._object_modules
340
+
341
+ @property
342
+ def outputs(self) -> tuple[int | str, ...] | None:
343
+ """Declared output arguments, or ``None`` if every array is copied back."""
344
+ return self._outputs
@@ -4,7 +4,8 @@ from contextlib import contextmanager
4
4
  from types import ModuleType
5
5
  from typing import TYPE_CHECKING, Any, Generator, Literal
6
6
 
7
- import numpy as np
7
+ import array_api_compat
8
+ import array_api_compat.numpy as np
8
9
 
9
10
  BackendType = Literal["numpy", "cupy"]
10
11
 
@@ -55,7 +56,7 @@ class ArrayBackend:
55
56
  def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleType:
56
57
  if backend == "cupy":
57
58
  if cupy_available():
58
- import cupy as cp
59
+ import array_api_compat.cupy as cp
59
60
 
60
61
  self._backend = "cupy"
61
62
  return cp
@@ -66,10 +67,8 @@ class ArrayBackend:
66
67
  )
67
68
  self._backend = "numpy"
68
69
  return np
69
- import numpy as np_mod
70
-
71
70
  self._backend = "numpy"
72
- return np_mod
71
+ return np
73
72
 
74
73
  def __repr__(self) -> str:
75
74
  return f"ArrayBackend(backend={self._backend!r}, module={self._xp.__name__!r})"
@@ -166,7 +165,7 @@ def to_cupy(array: Any) -> Any:
166
165
  if not cupy_available():
167
166
  raise ImportError("CuPy is not available or not functional.")
168
167
 
169
- import cupy as cp
168
+ import array_api_compat.cupy as cp
170
169
 
171
170
  return cp.asarray(array)
172
171
 
@@ -180,8 +179,7 @@ def to_cunumpy(array: Any) -> Any:
180
179
 
181
180
  def get_backend(array: Any) -> BackendType:
182
181
  """Return 'cupy' or 'numpy' depending on the array type."""
183
- module = getattr(type(array), "__module__", "")
184
- return "cupy" if "cupy" in module else "numpy"
182
+ return "cupy" if array_api_compat.is_cupy_array(array) else "numpy"
185
183
 
186
184
 
187
185
  def is_gpu(array: Any) -> bool:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cunumpy
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cunumpy as xp`.
5
5
  Author: Max
6
6
  Project-URL: Source, https://github.com/max-models/cunumpy
@@ -15,6 +15,7 @@ Classifier: Programming Language :: Python :: 3.12
15
15
  Classifier: Programming Language :: Python :: 3.13
16
16
  Requires-Python: >=3.8
17
17
  Description-Content-Type: text/markdown
18
+ Requires-Dist: array-api-compat
18
19
  Requires-Dist: numpy
19
20
  Provides-Extra: dev
20
21
  Requires-Dist: black[jupyter]; extra == "dev"
@@ -32,6 +33,7 @@ Requires-Dist: sphinx; extra == "docs"
32
33
  Requires-Dist: sphinx-book-theme; extra == "docs"
33
34
  Provides-Extra: test
34
35
  Requires-Dist: coverage; extra == "test"
36
+ Requires-Dist: pyccel; extra == "test"
35
37
  Requires-Dist: pytest; extra == "test"
36
38
 
37
39
  # CuNumpy
@@ -2,6 +2,7 @@ README.md
2
2
  pyproject.toml
3
3
  src/cunumpy/__init__.py
4
4
  src/cunumpy/__init__.pyi
5
+ src/cunumpy/kernel.py
5
6
  src/cunumpy/main.py
6
7
  src/cunumpy/py.typed
7
8
  src/cunumpy/xp.py
@@ -1,3 +1,4 @@
1
+ array-api-compat
1
2
  numpy
2
3
 
3
4
  [dev]
@@ -18,4 +19,5 @@ sphinx-book-theme
18
19
 
19
20
  [test]
20
21
  coverage
22
+ pyccel
21
23
  pytest
File without changes
File without changes
File without changes
File without changes