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
3 changes: 1 addition & 2 deletions .github/workflows/static_analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,15 +78,14 @@ jobs:

ruff:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- name: Checkout the code
uses: actions/checkout@v4

- name: Linting with ruff
run: |
pip install ruff
ruff check src/ || true
ruff check src/

pylint:
runs-on: ubuntu-latest
Expand Down
11 changes: 8 additions & 3 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ jobs:
build:
runs-on: ubuntu-latest

strategy:
fail-fast: false
matrix:
python-version: ["3.8", "3.10", "3.13"]

steps:
# Checkout the repository
- name: Checkout code
Expand All @@ -23,16 +28,16 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10' # Adjust as needed
python-version: ${{ matrix.python-version }}

# Cache pip dependencies
- name: Cache pip
uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/pyproject.toml') }}
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-
${{ runner.os }}-pip-${{ matrix.python-version }}-

- name: Install dependencies
run: |
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,22 @@ All notable changes to the `cunumpy` library are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.3] - 2026-07-31

### Added
- `xp.set_device(device_id)`: Select the active CUDA device for the current process (no-op on the NumPy backend).
- `xp.__version__`: Reports the installed `cunumpy` package version.

### Fixed
- `ArrayBackend` no longer silently reports `"cupy"` as the active backend when CuPy was requested but is unavailable; it now correctly falls back to reporting `"numpy"` so `xp.cupy_backend`/`xp.numpy_backend` reflect what actually loaded.
- `to_numpy()` no longer misdetects CPU objects that merely expose a `.get` method (e.g. dict-like objects) as CuPy arrays; it now checks `get_backend()` instead of `hasattr(array, "get")`.
- Invalid backend names now raise `ValueError` instead of relying on a bare `assert`, which was previously stripped under `python -O`.

### Changed
- Removed the redundant `ArrayBackend.__init_post__` double-initialization path.
- `ArrayBackend` documents that it is not thread-safe (global mutable backend state).
- CI now runs the test suite across a Python 3.8/3.10/3.13 matrix instead of only 3.10, and `ruff` is now an enforced check rather than advisory.

## [0.1.2] - 2026-05-27

### Added
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ export ARRAY_BACKEND=cupy

```python
import cunumpy as xp
arr = xp.array([1,2])

arr = xp.array([1, 2])

print(type(arr))
print(xp.__version__)
Expand Down
2 changes: 1 addition & 1 deletion docs/source/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ xp.set_backend("cupy")
# Scoped backend switching
with xp.use_backend("numpy"):
arr_cpu = xp.zeros(10)
print(xp.is_cpu(arr_cpu)) # True
print(xp.is_cpu(arr_cpu)) # True

# Explicit conversion
arr_np = xp.to_numpy(arr)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ requires = [ "setuptools", "wheel" ]

[project]
name = "cunumpy"
version = "0.1.2"
version = "0.1.3"
description = "Simple wrapper for numpy and cupy. Replace `import numpy as np` with `import cunumpy as xp`."
readme = "README.md"
keywords = [ "python" ]
Expand Down
26 changes: 18 additions & 8 deletions src/cunumpy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
# cunumpy/__init__.py
from importlib.metadata import PackageNotFoundError, version

from . import xp
from .xp import (
cupy_available,
get_backend,
is_cpu,
is_gpu,
set_backend,
set_device,
synchronize,
to_cunumpy,
to_cupy,
to_numpy,
use_backend,
)

try:
__version__ = version("cunumpy")
except PackageNotFoundError:
__version__ = "0.0.0+unknown"

__all__ = [
"xp",
"__version__",
"cupy_available",
"to_numpy",
"to_cupy",
"to_cunumpy",
"cupy_backend",
"get_backend",
"is_gpu",
"is_cpu",
"use_backend",
"is_gpu",
"numpy_backend",
"set_backend",
"set_device",
"synchronize",
"numpy_backend",
"cupy_backend",
"to_cunumpy",
"to_cupy",
"to_numpy",
"use_backend",
"xp",
]


Expand Down
6 changes: 4 additions & 2 deletions src/cunumpy/__init__.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ from typing import Any, Generator
import numpy as np
from numpy import *

from . import xp
from . import xp as xp

def to_numpy(array: Any) -> np.ndarray: ...
def to_cupy(array: Any) -> Any: ...
Expand All @@ -17,9 +17,11 @@ def get_backend(array: Any) -> str: ...
def is_gpu(array: Any) -> bool: ...
def is_cpu(array: Any) -> bool: ...
@contextmanager
def use_backend(backend: str) -> Generator[None, None, None]: ...
def use_backend(backend: str) -> Generator[None]: ...
def set_backend(backend: str) -> None: ...
def set_device(device_id: int) -> None: ...
def synchronize() -> None: ...

numpy_backend: bool
cupy_backend: bool
__version__: str
56 changes: 38 additions & 18 deletions src/cunumpy/xp.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import os
import warnings
from contextlib import contextmanager
from types import ModuleType
from typing import TYPE_CHECKING, Any, Generator, Literal
Expand All @@ -23,21 +24,27 @@ def cupy_available() -> bool:
# Check if a GPU is available
_CUPY_AVAILABLE_CACHE = cp.is_available()
return _CUPY_AVAILABLE_CACHE
except (ImportError, Exception):
except Exception: # noqa: BLE001 - tolerate any driver/runtime failure
_CUPY_AVAILABLE_CACHE = False
return False


class ArrayBackend:
"""Holds the process-wide active backend (NumPy or CuPy).

Not thread-safe: `set_backend`/`use_backend` mutate this single shared
instance in place, so concurrent code (threads, async tasks) switching
backends independently will race. Safe for the typical single-threaded
script/notebook usage this library targets.
"""

def __init__(
self,
backend: BackendType = "numpy",
verbose: bool = False,
) -> None:
assert backend.lower() in [
"numpy",
"cupy",
], "Array backend must be either 'numpy' or 'cupy'."
if backend.lower() not in ("numpy", "cupy"):
raise ValueError("Array backend must be either 'numpy' or 'cupy'.")

self._backend: BackendType = "cupy" if backend.lower() == "cupy" else "numpy"
self._xp: ModuleType = np # Placeholder
Expand All @@ -50,21 +57,22 @@ def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleTy
if cupy_available():
import cupy as cp

self._backend = "cupy"
return cp
else:
if verbose:
print("CuPy not available or not functional.")
print(
"CuPy not available or not functional. Falling back to NumPy."
)
self._backend = "numpy"
return np
import numpy as np_mod

self._backend = "numpy"
return np_mod

def __init_post__(self, verbose: bool = False) -> None:
# This is now redundant but kept for compatibility if called
self._xp = self._load_backend(self._backend, verbose)
assert isinstance(self._xp, ModuleType)
if verbose:
print(f"Using {self._xp.__name__} backend.")
def __repr__(self) -> str:
return f"ArrayBackend(backend={self._backend!r}, module={self._xp.__name__!r})"

@property
def backend(self) -> BackendType:
Expand All @@ -90,15 +98,12 @@ def use_backend(self, backend: BackendType) -> Generator[None, None, None]:
self._xp = old_xp


# TODO: Make this configurable via environment variable or config file.
array_backend = ArrayBackend(
backend=(
"cupy" if os.getenv("ARRAY_BACKEND", "numpy").lower() == "cupy" else "numpy"
),
verbose=False,
)
# Re-run initialization logic properly after backend selection
array_backend.__init_post__(verbose=False)


def use_backend(backend: BackendType) -> Generator[None, None, None]:
Expand All @@ -122,20 +127,35 @@ def _numpy_backend() -> bool:
return array_backend.backend == "numpy"


def set_device(device_id: int) -> None:
"""Select the active CUDA device for the current process (no-op on NumPy)."""
if array_backend.backend == "cupy":
import cupy as cp

cp.cuda.Device(device_id).use()


def synchronize() -> None:
"""Wait for all kernels in all streams on current device to complete."""
if array_backend.backend == "cupy":
try:
import cupy as cp

cp.cuda.Device().synchronize()
except (ImportError, AttributeError):
except ImportError:
pass
except AttributeError as e:
warnings.warn(
f"CuPy synchronize() failed unexpectedly, this may indicate a "
f"CuPy API mismatch: {e}",
RuntimeWarning,
stacklevel=2,
)


def to_numpy(array: Any) -> np.ndarray:
"""Convert an array to a NumPy array."""
if hasattr(array, "get"):
if get_backend(array) == "cupy":
return array.get()

return np.asarray(array)
Expand Down Expand Up @@ -177,7 +197,7 @@ def is_cpu(array: Any) -> bool:
# TYPE_CHECKING is True when type checking (e.g., mypy), but False at runtime.
# This allows us to use autocompletion for xp (i.e., numpy/cupy) as if numpy was imported.
if TYPE_CHECKING:
import numpy as xp
import numpy as xp # noqa: F401 - type-checker-only alias for autocompletion
else:
# Use module-level __getattr__ for dynamic xp (Python 3.7+)
def __getattr__(name):
Expand Down
9 changes: 4 additions & 5 deletions tests/unit/test_benchmarks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import time

import numpy as np
import pytest

import cunumpy as xp
Expand All @@ -19,7 +18,7 @@ def test_benchmark_matmul():
b_np = xp.random.rand(size, size).astype(xp.float32)

start_np = time.perf_counter()
c_np = a_np @ b_np
_ = a_np @ b_np
# No sync needed for NumPy as it is synchronous
end_np = time.perf_counter()
t_np = end_np - start_np
Expand All @@ -34,15 +33,15 @@ def test_benchmark_matmul():
xp.synchronize()

start_cp = time.perf_counter()
c_cp = a_cp @ b_cp
_ = a_cp @ b_cp
xp.synchronize() # CRITICAL for benchmarking GPU
end_cp = time.perf_counter()
t_cp = end_cp - start_cp

print(f"\n[Benchmark] Size: {size}x{size}")
print(f"NumPy time: {t_np:.4f}s")
print(f"CuPy time: {t_cp:.4f}s")
print(f"Speedup: {t_np/t_cp:.2f}x")
print(f"Speedup: {t_np / t_cp:.2f}x")

# On a real GPU (A100/A30), CuPy should be significantly faster
# We use a conservative threshold of 1.5x for the test to pass on various hardware
Expand Down Expand Up @@ -76,5 +75,5 @@ def test_benchmark_fft():
print(f"\n[Benchmark] FFT Size: {size}")
print(f"NumPy time: {t_np:.4f}s")
print(f"CuPy time: {t_cp:.4f}s")
print(f"Speedup: {t_np/t_cp:.2f}x")
print(f"Speedup: {t_np / t_cp:.2f}x")
assert t_cp < t_np
Loading
Loading