Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions docs/source/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions docs/source/array-api-compat.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/source/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ NumPy if CuPy cannot be used.
:caption: Guides and reference:

quickstart
array-api-compat
api
pyodide
```
6 changes: 6 additions & 0 deletions docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/cunumpy/xp.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_array_api_compat_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
36 changes: 33 additions & 3 deletions tests/unit/test_cunumpy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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():
Expand Down
3 changes: 3 additions & 0 deletions tests/unit/test_pyccel_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading