From b16df62ab7afe5d4e4e6ebc9a2ba33ba8a78fd8d Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Mon, 28 Sep 2026 18:20:59 +0200 Subject: [PATCH 1/2] Updated tests --- CHANGELOG.md | 15 +++++++-- src/cunumpy/xp.py | 4 +++ tests/unit/test_array_api_compat_backend.py | 2 +- tests/unit/test_cunumpy.py | 36 +++++++++++++++++++-- tests/unit/test_pyccel_kernel.py | 3 ++ 5 files changed, 54 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c346c3..3093847 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-09-28 + +### Changed +- `xp.get_backend()` now returns the active global backend, mirroring `xp.set_backend()`. Use the new `xp.get_array_backend(array)` to inspect an individual array. Calls to the former `xp.get_backend(array)` must be updated. +- Expanded the README and documentation with backend selection, array conversion, GPU controls, kernel adaptation, and Pyodide examples. + +### Fixed +- GPU shape round-trip coverage now creates a zero-dimensional NumPy array for the scalar case instead of calling `.astype()` on a Python float. +- GPU tests now account for CuPy's retained split memory blocks and provide an array conversion method on the custom host-array test fixture. +- `set_backend()` and `use_backend()` now reject unsupported backend names with `ValueError` without changing the active selection. + ### Added -- `xp.get_array_module(array)`: Return the array-api-compat module (`numpy`/`cupy`) matching a given array's own backend, regardless of the process-wide active backend. Mirrors `cupy.get_array_module`, but works in Pyodide and returns array-api-compat modules for consistency with `xp.xp`. +- `xp.get_array_module(array)`: Return the array-api-compat module (`numpy`/`cupy`) matching a given array's own backend, regardless of the process-wide active backend. Mirrors `cupy.get_array_module` and works in Pyodide. - `xp.get_rng(seed=None)`: Return a `numpy.random.Generator`/`cupy.random.Generator` matching the active backend, without having to branch on the backend yourself. -- `xp.device_count()`: Number of visible CUDA devices (`0` on the NumPy backend or without a functional CuPy/CUDA install), independent of the currently active backend. +- `xp.device_count()`: Number of visible CUDA devices (`0` without a functional CuPy/CUDA install), independent of the currently active backend. - `xp.set_device_for_rank(rank, devices_per_node=None)`: Convenience for one-MPI-rank-per-GPU codes; selects `rank % devices_per_node` (defaulting `devices_per_node` to `device_count()`) via `set_device()` and returns the chosen device id. - `xp.memory_info()`: `(free, total)` bytes of memory on the active CUDA device, or `None` on the NumPy backend. - `xp.free_memory()`: Release all free blocks held by CuPy's device and pinned-host memory pools (no-op on the NumPy backend). diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 49ea0f4..839c467 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -86,6 +86,8 @@ def xp(self) -> ModuleType: @contextmanager def use_backend(self, backend: BackendType) -> Generator[None, None, None]: """Temporarily change the backend.""" + if backend not in ("numpy", "cupy"): + raise ValueError("Array backend must be either 'numpy' or 'cupy'.") old_backend = self._backend old_xp = self._xp @@ -114,6 +116,8 @@ def use_backend(backend: BackendType) -> Generator[None, None, None]: def set_backend(backend: BackendType) -> None: """Set the backend globally.""" + if backend not in ("numpy", "cupy"): + raise ValueError("Array backend must be either 'numpy' or 'cupy'.") array_backend._backend = backend array_backend._xp = array_backend._load_backend(backend) diff --git a/tests/unit/test_array_api_compat_backend.py b/tests/unit/test_array_api_compat_backend.py index a70fb33..175be61 100644 --- a/tests/unit/test_array_api_compat_backend.py +++ b/tests/unit/test_array_api_compat_backend.py @@ -69,7 +69,7 @@ def test_round_trip_preserves_values_and_dtype(dtype): def test_round_trip_preserves_shape(shape): _skip_without_cupy() - original = np.random.rand(*shape).astype(np.float64) + original = np.asarray(np.random.rand(*shape), dtype=np.float64) gpu = xp.to_cupy(original) back = xp.to_numpy(gpu) diff --git a/tests/unit/test_cunumpy.py b/tests/unit/test_cunumpy.py index d794449..938c9bd 100644 --- a/tests/unit/test_cunumpy.py +++ b/tests/unit/test_cunumpy.py @@ -29,6 +29,32 @@ def test_get_backend_and_is_gpu_cpu(): assert xp.is_cpu(arr) is True +def test_get_backend_reports_active_selection_independently_of_array(): + arr = np.array([1, 2, 3]) + with xp.use_backend("numpy"): + assert xp.get_backend() == "numpy" + assert xp.get_array_backend(arr) == "numpy" + + with xp.use_backend("cupy"): + expected = "cupy" if xp.cupy_available() else "numpy" + assert xp.get_backend() == expected + assert xp.get_array_backend(arr) == "numpy" + + assert xp.get_backend() == "numpy" + + +def test_invalid_backend_selection_preserves_active_backend(): + with xp.use_backend("numpy"): + with pytest.raises(ValueError, match="Array backend"): + xp.set_backend("invalid") + assert xp.get_backend() == "numpy" + + with pytest.raises(ValueError, match="Array backend"): + with xp.use_backend("invalid"): + pass + assert xp.get_backend() == "numpy" + + def test_get_array_module_numpy(): arr = np.array([1, 2, 3]) mod = xp.get_array_module(arr) @@ -162,15 +188,19 @@ def test_free_memory_is_noop_on_numpy_backend(): xp.free_memory() # must not raise -def test_free_memory_releases_cupy_pool(): +def test_free_memory_does_not_increase_cupy_pool_cache(): if not xp.cupy_available(): pytest.skip("CuPy not installed or not functional") import cupy as cp with xp.use_backend("cupy"): - _ = xp.to_cupy(np.ones(1_000)) + array = xp.to_cupy(np.ones(1_000)) + del array + pool = cp.get_default_memory_pool() + cached_before = pool.free_bytes() xp.free_memory() # must not raise - assert cp.get_default_memory_pool().n_free_blocks() == 0 + # CuPy can retain split blocks even after free_all_blocks(). + assert pool.free_bytes() <= cached_before def test_pin_memory_requires_cupy(): diff --git a/tests/unit/test_pyccel_kernel.py b/tests/unit/test_pyccel_kernel.py index 0d6f8e7..e25abc5 100644 --- a/tests/unit/test_pyccel_kernel.py +++ b/tests/unit/test_pyccel_kernel.py @@ -293,6 +293,9 @@ class _HostArrayLike: def __init__(self, data: np.ndarray) -> None: self.data = data + def __array__(self, dtype=None): + return np.asarray(self.data, dtype=dtype) + def test_default_is_array_ignores_custom_array_like_return_value(): _skip_without_cupy() From 5e8c9168621b1d840664d50e49b84c7de23898d1 Mon Sep 17 00:00:00 2001 From: Max Lindqvist Date: Mon, 28 Sep 2026 18:22:06 +0200 Subject: [PATCH 2/2] Added array-api-compat.md to docs --- README.md | 6 ++ docs/source/api.md | 3 + docs/source/array-api-compat.md | 103 ++++++++++++++++++++++++++++++++ docs/source/index.md | 1 + docs/source/quickstart.md | 6 ++ 5 files changed, 119 insertions(+) create mode 100644 docs/source/array-api-compat.md diff --git a/README.md b/README.md index 3d23b87..3df254f 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,12 @@ install a CuPy package compatible with your CUDA environment as well. CuPy installation depends on the CUDA version and platform; follow the CuPy installation instructions for your system. CuNumpy does not install CUDA. +`array-api-compat` supplies NumPy and CuPy compatibility modules with more +consistent behavior for shared array operations. CuNumpy uses them internally; +your arrays remain ordinary NumPy or CuPy arrays. See [why CuNumpy uses +`array-api-compat`](docs/source/array-api-compat.md) for a plain-language +explanation and examples. + ## Choose a backend CuNumpy starts with NumPy unless `ARRAY_BACKEND=cupy` is set before import. diff --git a/docs/source/api.md b/docs/source/api.md index 69989e8..f7d63e3 100644 --- a/docs/source/api.md +++ b/docs/source/api.md @@ -20,6 +20,9 @@ operations and some details can therefore vary with the installed NumPy and CuPy versions. In normal use, access those operations through the top-level `cunumpy` namespace, commonly imported as `xp`. +For an explanation of what the compatibility module does and why CuNumpy +uses it, read [Why CuNumpy uses `array-api-compat`](array-api-compat.md). + NumPy and CuPy are not interchangeable for every function or object. A function that needs to follow an input array's location should use `get_array_module(array)` instead of assuming the global backend matches it. diff --git a/docs/source/array-api-compat.md b/docs/source/array-api-compat.md new file mode 100644 index 0000000..b79e6c5 --- /dev/null +++ b/docs/source/array-api-compat.md @@ -0,0 +1,103 @@ +# Why CuNumpy uses `array-api-compat` + +You can use CuNumpy without importing `array-api-compat` yourself. It is a +dependency that sits between CuNumpy and the array libraries it selects. This +page explains what that layer does and when you might notice it. + +## Start with the three pieces + +**NumPy** stores arrays in regular computer memory and performs operations on +the CPU. **CuPy** offers many similar operations on arrays stored on an NVIDIA +GPU. Their APIs overlap substantially, but the same function name does not +always accept the same arguments or follow the same rules. + +The **Python Array API standard** describes a shared set of array operations +and their expected behavior. It is a specification, not an array library that +stores data or runs calculations. [`array-api-compat`](https://data-apis.org/array-api-compat/) +provides small compatibility modules for existing libraries, including NumPy +and CuPy. Those modules expose the underlying libraries' functions while +adjusting operations covered by the standard to behave more consistently. + +CuNumpy uses those modules as its two backends: + +| CuNumpy backend | Module CuNumpy calls | Where the array lives | +| --- | --- | --- | +| `"numpy"` | `array_api_compat.numpy` | CPU memory | +| `"cupy"` | `array_api_compat.cupy` | GPU memory | + +For example, after `xp.set_backend("numpy")`, `xp.asarray(...)` is resolved +through the NumPy compatibility module. After `xp.set_backend("cupy")`, it is +resolved through the CuPy compatibility module. You still write `import +cunumpy as xp` and call `xp.asarray(...)`; CuNumpy chooses the module. + +## Why add this layer? + +Without a compatibility layer, a program that switches between NumPy and +CuPy must account for differences in shared operations. CuNumpy uses +`array-api-compat` so those operations have a more consistent interface. For +example, the compatibility namespace supports the Array API's `device` +argument on `asarray` for both backends: + +```python +import cunumpy as xp + +with xp.use_backend("numpy"): + values = xp.asarray([1, 2, 3], device=None) +``` + +`array-api-compat` also supplies array-type checks used by CuNumpy to tell +whether a particular value is a CuPy array. That is how +`xp.get_array_backend(values)` and helpers such as `xp.is_gpu(values)` can +inspect an array independently of the currently selected backend. + +This layer does **not** create a third kind of array. A NumPy-backed result is +still a NumPy array; a CuPy-backed result is still a CuPy array. It also does +not install CuPy or CUDA, move existing arrays when the global backend +changes, or make every NumPy operation available in CuPy. Functions outside +the shared standard may still differ between libraries. + +## When should you think about it? + +For code that creates arrays and works entirely on the active backend, you +usually do not need to think about the compatibility layer: + +```python +import cunumpy as xp + +xp.set_backend("numpy") +values = xp.arange(5) +print(xp.sum(values)) +``` + +It becomes useful when a function receives an array created elsewhere. The +global backend may be NumPy even though the argument is a CuPy array, or the +other way around. `xp.get_array_module(array)` returns the matching +compatibility module for that *array*, so operations inside the function use +the correct library: + +```python +def mean_center(values): + array_xp = xp.get_array_module(values) + return values - array_xp.mean(values) +``` + +`array_xp` is `array_api_compat.numpy` for a NumPy array and +`array_api_compat.cupy` for a CuPy array. The returned value stays on the +same backend as `values`. You do not need to import either compatibility +module directly for this pattern. + +If a function combines several inputs, first check that they live on the +same backend with `xp.assert_same_backend(a, b)`, or convert them explicitly +with `xp.to_numpy()`, `xp.to_cupy()`, or `xp.to_cunumpy()`. The compatibility +layer does not make mixed CPU/GPU arithmetic automatic. + +## What to remember + +* Use `xp.get_backend()` to ask which backend CuNumpy currently selects. +* Use `xp.get_array_backend(array)` to ask where a particular array lives. +* Use `xp.get_array_module(array)` when a function should follow its input + array rather than the global selection. +* Use explicit conversion helpers when data must move between CPU and GPU. + +The [`array-api-compat` documentation](https://data-apis.org/array-api-compat/) +explains the compatibility modules and the Array API standard in more depth. diff --git a/docs/source/index.md b/docs/source/index.md index 03bc1a4..29b1c3f 100644 --- a/docs/source/index.md +++ b/docs/source/index.md @@ -22,6 +22,7 @@ NumPy if CuPy cannot be used. :caption: Guides and reference: quickstart +array-api-compat api pyodide ``` diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 65a98e0..e8976c4 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -16,6 +16,12 @@ CuNumpy depends on NumPy and `array-api-compat`. To use NVIDIA GPUs, install a CuPy distribution that matches your CUDA environment separately. CUDA itself is not installed by CuNumpy. +`array-api-compat` is a small adapter that gives NumPy and CuPy a more +consistent interface for shared array operations. You do not need to import +it directly when using CuNumpy. See [Why CuNumpy uses +`array-api-compat`](array-api-compat.md) for a beginner-friendly explanation +and examples. + Import CuNumpy using the familiar alias `xp`: ```python