diff --git a/.github/workflows/gpu_ci_trigger.yml b/.github/workflows/gpu_ci_trigger.yml new file mode 100644 index 0000000..4418074 --- /dev/null +++ b/.github/workflows/gpu_ci_trigger.yml @@ -0,0 +1,81 @@ +# SETUP INSTRUCTIONS: +# ------------------ +# This workflow synchronizes the code to GitLab via SSH to trigger GPU-enabled CI. +# +# 1. GENERATE SSH KEY PAIR (on your local machine): +# ssh-keygen -t ed25519 -f ~/.ssh/gitlab_sync_key -N "" -C "github-to-gitlab-sync" +# +# 2. CONFIGURE GITLAB (The Target): +# - Go to GitLab project > Settings > Repository > Deploy keys. +# - Add the content of '~/.ssh/gitlab_sync_key.pub'. +# - IMPORTANT: Check "Allow write access to this repository". +# +# 3. CONFIGURE GITHUB (The Source): +# - Go to GitHub repo > Settings > Secrets and variables > Actions. +# - Add new Repository Secrets: +# - Name: GITLAB_SSH_PRIVATE_KEY +# Value: Paste the entire content of '~/.ssh/gitlab_sync_key'. +# - Name: GITLAB_TOKEN +# Value: Your GitLab Personal Access Token (with 'api' and 'read_repository' scopes). +# + +name: Sync to GitLab and Run GPU CI + +on: + push: + branches: [main, devel] + pull_request: + branches: [main, devel] + workflow_dispatch: + +jobs: + sync-and-test: + runs-on: ubuntu-latest + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install SSH Key + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.GITLAB_SSH_PRIVATE_KEY }} + + - name: Push to GitLab via SSH & Provide Link + id: push + run: | + # 1. Setup SSH known hosts + mkdir -p ~/.ssh + ssh-keyscan gitlab.mpcdf.mpg.de >> ~/.ssh/known_hosts + + # 2. Determine target branch + if [ "${{ github.event_name }}" == "pull_request" ]; then + TARGET_BRANCH="gpu-test-pr-${{ github.event.number }}" + else + SOURCE_REF="${{ github.ref_name }}" + SAFE_REF="${SOURCE_REF//\//-}" + TARGET_BRANCH="gpu-test-${SAFE_REF}" + fi + echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_ENV + + # 3. Add GitLab SSH remote + git remote add gitlab git@gitlab.mpcdf.mpg.de:maxlin/cunumpy.git + + # 4. Force push (This automatically starts the GitLab Pipeline) + git push -f gitlab HEAD:refs/heads/$TARGET_BRANCH + + # 5. Provide the direct link + PIPELINE_URL="https://gitlab.mpcdf.mpg.de/maxlin/cunumpy/-/pipelines?ref=$TARGET_BRANCH" + + echo "::notice::GitLab GPU CI Pipeline started automatically via Push!" + echo "::notice::View Pipeline: $PIPELINE_URL" + + - name: Wait for GitLab Pipeline + uses: docker://gitlab/glab:latest + env: + GITLAB_TOKEN: ${{ secrets.GITLAB_TOKEN }} + GITLAB_HOST: gitlab.mpcdf.mpg.de + with: + entrypoint: glab + args: ci status --live --branch ${{ env.TARGET_BRANCH }} --repo maxlin/cunumpy diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml index 61b3cb6..151b4bb 100644 --- a/.github/workflows/static_analysis.yml +++ b/.github/workflows/static_analysis.yml @@ -78,7 +78,6 @@ jobs: ruff: runs-on: ubuntu-latest - continue-on-error: true steps: - name: Checkout the code uses: actions/checkout@v4 @@ -86,7 +85,7 @@ jobs: - name: Linting with ruff run: | pip install ruff - ruff check src/ || true + ruff check src/ pylint: runs-on: ubuntu-latest diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 0c86b54..2b15a25 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -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 @@ -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: | diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7de373c..79d4881 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,57 +1,46 @@ -# This file is a template, and might need editing before it works on your project. -# To contribute improvements to CI/CD templates, please follow the Development guide at: -# https://docs.gitlab.com/ee/development/cicd/templates.html -# This specific template is located at: -# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Python.gitlab-ci.yml - -# Official language image. Look for the different tagged releases at: -# https://hub.docker.com/r/library/python/tags/ -image: python:latest - -# Change pip's cache directory to be inside the project directory since we can -# only cache local items. variables: - PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" - -# https://pip.pypa.io/en/stable/topics/caching/ -cache: - paths: - - .cache/pip - -before_script: - - python --version ; pip --version # For debugging - - pip install virtualenv - - virtualenv venv - - source venv/bin/activate - -test: + # Use the specialized MPCDF HPC image + CUDA_IMAGE: "gitlab-registry.mpcdf.mpg.de/mpcdf/ci-module-image/nvhpcsdk_26:2026" + PIP_DISABLE_PIP_VERSION_CHECK: "1" + +stages: + - test + +gpu_tests: + stage: test + image: ${CUDA_IMAGE} + tags: + - gpu-nvidia + - gpu-nvidia-cc80 + before_script: + - module load python-waterboa/2025.06 + - module load nvhpcsdk/26 + - module load fftw-serial/3.3.10 script: - - pip install ruff tox # you can also use tox - - pip install --editable ".[test]" - - tox -e py,ruff - -run: - script: - - pip install . - # run the command here - artifacts: - paths: - - build/* - -pages: - script: - - pip install sphinx sphinx-rtd-theme - - cd doc - - make html - - mv build/html/ ../public/ - artifacts: - paths: - - public - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - -deploy: - stage: deploy - script: echo "Define your deployment script!" - environment: production - + - echo "--- CUDA Sanity Check ---" + - nvidia-smi + + - echo "--- Detect Compute Capability ---" + - nvidia-smi --query-gpu=compute_cap --format=csv,noheader + + - echo "--- Tool Versions ---" + - cmake --version + - python3 --version + - git --version + + - echo "--- Pytest Execution ---" + # The MPCDF image likely has a specific python environment. + # We install our dependencies into the user directory or a virtualenv. + - python3 -m pip install --user cupy-cuda12x + - python3 -m pip install --user nvidia-cublas-cu12 nvidia-cufft-cu12 nvidia-curand-cu12 nvidia-cusolver-cu12 nvidia-cusparse-cu12 + - python3 -m pip install --user -e . + + # Add the user bin to PATH for pytest + - export PATH="$HOME/.local/bin:$PATH" + + # Try to find libcublas and other libraries in the HPC environment + - export LD_LIBRARY_PATH=$(find /mpcdf/soft /opt/nvidia -name libcublas.so.12 -exec dirname {} \; 2>/dev/null | head -n 1):$LD_LIBRARY_PATH + + - export ARRAY_BACKEND=cupy + + - pytest -xvs . diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fae537..4634055 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 @@ -22,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `xp.synchronize()`: Blocks until GPU operations are complete (no-op on CPU). Essential for accurate benchmarking. - **Developer Experience**: - Added `isort` configuration to `pyproject.toml` with `black` profile compatibility. + - Reorganized test suite into specialized files: `test_numpy.py`, `test_cupy.py`, and `test_cunumpy.py`. ### Changed - **Dynamic Dispatch Architecture**: Refactored `src/cunumpy/xp.py` to use module-level `__getattr__`. This ensures that `cunumpy.` calls always resolve to the currently active backend module, enabling seamless runtime switching via `set_backend`. diff --git a/README.md b/README.md index 0fe4acd..9fd85b7 100644 --- a/README.md +++ b/README.md @@ -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__) diff --git a/docs/source/quickstart.md b/docs/source/quickstart.md index 773224c..99970f8 100644 --- a/docs/source/quickstart.md +++ b/docs/source/quickstart.md @@ -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) diff --git a/pyproject.toml b/pyproject.toml index e5c7bfa..fea3ee8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" ] diff --git a/src/cunumpy/__init__.py b/src/cunumpy/__init__.py index c6592bd..a5f42ab 100644 --- a/src/cunumpy/__init__.py +++ b/src/cunumpy/__init__.py @@ -1,10 +1,14 @@ # 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, @@ -12,19 +16,27 @@ use_backend, ) +try: + __version__ = version("cunumpy") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" + __all__ = [ - "xp", - "to_numpy", - "to_cupy", - "to_cunumpy", + "__version__", + "cupy_available", + "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", ] diff --git a/src/cunumpy/__init__.pyi b/src/cunumpy/__init__.pyi index 2a4fb33..8379680 100644 --- a/src/cunumpy/__init__.pyi +++ b/src/cunumpy/__init__.pyi @@ -7,18 +7,21 @@ 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: ... def to_cunumpy(array: Any) -> Any: ... +def cupy_available() -> bool: ... 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 diff --git a/src/cunumpy/xp.py b/src/cunumpy/xp.py index 36d3b9c..718cea1 100644 --- a/src/cunumpy/xp.py +++ b/src/cunumpy/xp.py @@ -1,4 +1,5 @@ import os +import warnings from contextlib import contextmanager from types import ModuleType from typing import TYPE_CHECKING, Any, Generator, Literal @@ -8,16 +9,42 @@ BackendType = Literal["numpy", "cupy"] +_CUPY_AVAILABLE_CACHE = None + + +def cupy_available() -> bool: + """Check if CuPy is available and functional.""" + global _CUPY_AVAILABLE_CACHE + if _CUPY_AVAILABLE_CACHE is not None: + return _CUPY_AVAILABLE_CACHE + + try: + import cupy as cp + + # Check if a GPU is available + _CUPY_AVAILABLE_CACHE = cp.is_available() + return _CUPY_AVAILABLE_CACHE + 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 @@ -27,24 +54,25 @@ def __init__( def _load_backend(self, backend: BackendType, verbose: bool = False) -> ModuleType: if backend == "cupy": - try: + if cupy_available(): import cupy as cp + self._backend = "cupy" return cp - except ImportError: + else: if verbose: - print("CuPy not available.") + 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: @@ -70,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]: @@ -102,6 +127,14 @@ 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": @@ -109,13 +142,20 @@ def synchronize() -> None: 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) @@ -123,17 +163,17 @@ def to_numpy(array: Any) -> np.ndarray: def to_cupy(array: Any) -> Any: """Convert an array to a CuPy array.""" - try: - import cupy as cp + if not cupy_available(): + raise ImportError("CuPy is not available or not functional.") - return cp.asarray(array) - except ImportError: - raise ImportError("CuPy is not available.") + import cupy as cp + + return cp.asarray(array) def to_cunumpy(array: Any) -> Any: """Convert an array to the currently active backend.""" - if array_backend.backend == "cupy": + if array_backend.backend == "cupy" and cupy_available(): return to_cupy(array) return to_numpy(array) @@ -157,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): diff --git a/tests/unit/test_app.py b/tests/unit/test_app.py deleted file mode 100644 index 53e095e..0000000 --- a/tests/unit/test_app.py +++ /dev/null @@ -1,131 +0,0 @@ -import numpy as np -import pytest - -import cunumpy as xp - - -def test_xp_array(): - - arr = xp.array([1, 2]) - arr *= 2 - - print(f"{arr = } {type(arr) = }") - - -def test_numpy_symbols_accessible(): - """All public numpy symbols must be reachable via cunumpy. - - This validates the runtime behaviour that the stub file (__init__.pyi) - declares to Pylance so that `xp.` shows numpy completions in VS Code. - """ - # Exclude our custom methods from the numpy check - custom_methods = [ - "to_numpy", - "to_cupy", - "to_cunumpy", - "get_backend", - "is_gpu", - "is_cpu", - "use_backend", - "set_backend", - "synchronize", - "numpy_backend", - "cupy_backend", - "xp", - ] - missing = [ - name - for name in np.__all__ - if not hasattr(xp, name) and name not in custom_methods - ] - assert missing == [], f"Symbols not accessible via cunumpy: {missing}" - - -def test_to_numpy(): - arr = xp.array([1, 2, 3]) - # Even if it's already numpy, to_numpy should work - arr_np = xp.to_numpy(arr) - assert isinstance(arr_np, np.ndarray) - assert np.array_equal(arr_np, [1, 2, 3]) - - -def test_to_cupy_not_available(): - try: - import cupy - - pytest.skip("CuPy is installed, cannot test missing cupy error") - except ImportError: - pass - - arr = np.array([1, 2, 3]) - - with pytest.raises(ImportError): - xp.to_cupy(arr) - - -def test_to_cunumpy(): - arr = np.array([1, 2, 3]) - arr_xp = xp.to_cunumpy(arr) - # Backend is numpy in tests usually - assert isinstance(arr_xp, (np.ndarray, xp.ndarray)) - - -def test_get_backend_and_is_gpu_cpu(): - arr = np.array([1, 2, 3]) - assert xp.get_backend(arr) == "numpy" - assert xp.is_gpu(arr) is False - assert xp.is_cpu(arr) is True - - -def test_use_backend(): - # Initial backend should be numpy (default) in this test environment - # Accessing xp.xp triggers the dynamic __getattr__ in xp.py - assert "numpy" in xp.xp.__name__ - - with xp.use_backend("numpy"): - assert "numpy" in xp.xp.__name__ - arr = xp.zeros(10) - assert isinstance(arr, np.ndarray) - - assert "numpy" in xp.xp.__name__ - - -def test_set_backend(): - # Set to numpy - xp.set_backend("numpy") - assert "numpy" in xp.xp.__name__ - arr = xp.array([1]) - assert isinstance(arr, np.ndarray) - - # Set to cupy (falls back to numpy if not available) - xp.set_backend("cupy") - # If cupy is not installed, xp.xp will be numpy module - # We just verify it doesn't crash and we can still call things - arr2 = xp.array([2]) - assert arr2 is not None - - -def test_synchronize(): - # Should not crash on any backend - xp.synchronize() - - with xp.use_backend("numpy"): - xp.synchronize() - - with xp.use_backend("cupy"): - xp.synchronize() - - -def test_backend_bools(): - with xp.use_backend("numpy"): - assert xp.numpy_backend is True - assert xp.cupy_backend is False - - # Note: in test env without cupy, cupy_backend might be false - # even inside use_backend('cupy') if fallback occurs. - # Our implementation of use_backend calls _load_backend which returns np if cp missing. - - -if __name__ == "__main__": - test_xp_array() - test_numpy_symbols_accessible() diff --git a/tests/unit/test_benchmarks.py b/tests/unit/test_benchmarks.py new file mode 100644 index 0000000..4f4e165 --- /dev/null +++ b/tests/unit/test_benchmarks.py @@ -0,0 +1,79 @@ +import time + +import pytest + +import cunumpy as xp + + +@pytest.mark.skipif( + not xp.cupy_available(), reason="CuPy/GPU not available or not functional" +) +def test_benchmark_matmul(): + """Benchmark matrix multiplication to show CuPy performance gain.""" + size = 2000 + + # --- Benchmark NumPy --- + with xp.use_backend("numpy"): + a_np = xp.random.rand(size, size).astype(xp.float32) + b_np = xp.random.rand(size, size).astype(xp.float32) + + start_np = time.perf_counter() + _ = a_np @ b_np + # No sync needed for NumPy as it is synchronous + end_np = time.perf_counter() + t_np = end_np - start_np + + # --- Benchmark CuPy --- + with xp.use_backend("cupy"): + a_cp = xp.random.rand(size, size).astype(xp.float32) + b_cp = xp.random.rand(size, size).astype(xp.float32) + + # Warm up + _ = a_cp @ b_cp + xp.synchronize() + + start_cp = time.perf_counter() + _ = 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") + + # 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 + assert t_cp < t_np, f"CuPy ({t_cp:.4f}s) was not faster than NumPy ({t_np:.4f}s)" + + +@pytest.mark.skipif( + not xp.cupy_available(), reason="CuPy/GPU not available or not functional" +) +def test_benchmark_fft(): + """Benchmark FFT performance.""" + size = 2**22 # ~4 million elements + + with xp.use_backend("numpy"): + data_np = xp.random.rand(size).astype(xp.complex64) + start = time.perf_counter() + _ = xp.fft.fft(data_np) + t_np = time.perf_counter() - start + + with xp.use_backend("cupy"): + data_cp = xp.random.rand(size).astype(xp.complex64) + # Warm up + _ = xp.fft.fft(data_cp) + xp.synchronize() + + start = time.perf_counter() + _ = xp.fft.fft(data_cp) + xp.synchronize() + t_cp = time.perf_counter() - start + + 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") + assert t_cp < t_np diff --git a/tests/unit/test_cunumpy.py b/tests/unit/test_cunumpy.py new file mode 100644 index 0000000..4c3a8b1 --- /dev/null +++ b/tests/unit/test_cunumpy.py @@ -0,0 +1,178 @@ +import sys + +import numpy as np +import pytest + +import cunumpy as xp +import cunumpy.xp as cxp # the internal submodule, to inspect array_backend directly + + +def test_to_numpy(): + arr = xp.array([1, 2, 3]) + # Even if it's already numpy, to_numpy should work + arr_np = xp.to_numpy(arr) + assert isinstance(arr_np, np.ndarray) + assert np.array_equal(arr_np, [1, 2, 3]) + + +def test_to_cunumpy(): + arr = np.array([1, 2, 3]) + arr_xp = xp.to_cunumpy(arr) + # Backend is numpy in tests usually + assert isinstance(arr_xp, (np.ndarray, xp.ndarray)) + + +def test_get_backend_and_is_gpu_cpu(): + arr = np.array([1, 2, 3]) + assert xp.get_backend(arr) == "numpy" + assert xp.is_gpu(arr) is False + assert xp.is_cpu(arr) is True + + +def test_use_backend(): + # Initial backend should be numpy (default) in this test environment + assert "numpy" in xp.xp.__name__ + + with xp.use_backend("numpy"): + assert "numpy" in xp.xp.__name__ + arr = xp.zeros(10) + assert isinstance(arr, np.ndarray) + + assert "numpy" in xp.xp.__name__ + + +def test_set_backend(): + # Set to numpy + xp.set_backend("numpy") + assert "numpy" in xp.xp.__name__ + arr = xp.array([1]) + assert isinstance(arr, np.ndarray) + + # Set to cupy (falls back to numpy if not available) + xp.set_backend("cupy") + # If cupy is not installed, xp.xp will be numpy module + arr2 = xp.array([2]) + assert arr2 is not None + + +def test_backend_bools(): + with xp.use_backend("numpy"): + assert xp.numpy_backend is True + assert xp.cupy_backend is False + + +def test_version_is_own_package_version(): + # __version__ must resolve to cunumpy's own version, not be silently + # proxied to the active backend module's __version__ via __getattr__. + assert isinstance(xp.__version__, str) + assert xp.__version__ != "" + assert xp.__version__ != np.__version__ + + +def test_set_backend_cupy_fallback_reports_effective_backend(): + """array_backend.backend must reflect what actually loaded, not what was requested.""" + try: + import cupy # noqa: F401 + + cupy_installed = True + except ImportError: + cupy_installed = False + + xp.set_backend("cupy") + + if cupy_installed: + # Only true in CI on MPCDF, where CuPy is actually available. + assert cxp.array_backend.backend == "cupy" + assert xp.cupy_backend is True + else: + # No silent lie: requesting cupy without it installed must fall + # back to numpy *and* report "numpy", not "cupy". + assert cxp.array_backend.backend == "numpy" + assert xp.numpy_backend is True + assert xp.cupy_backend is False + + xp.set_backend("numpy") + + +def test_use_backend_cupy_fallback_restores_correctly(): + try: + import cupy # noqa: F401 + + pytest.skip("CuPy is installed; fallback behaviour is not exercised here") + except ImportError: + pass + + assert cxp.array_backend.backend == "numpy" + + with xp.use_backend("cupy"): + # Falls back to numpy since CuPy isn't available here. + assert cxp.array_backend.backend == "numpy" + + # And the previous state is restored afterwards. + assert cxp.array_backend.backend == "numpy" + + +def test_to_numpy_does_not_misdetect_get_method_as_gpu_array(): + """Objects exposing a `.get` method (e.g. dict-like objects) must not be + mistaken for CuPy arrays just because they happen to have a `.get`.""" + + class MappingLike: + def get(self, key, default=None): + raise AssertionError(".get() should not be called for non-cupy objects") + + def __array__(self): + return np.array([1, 2, 3]) + + arr = xp.to_numpy(MappingLike()) + assert isinstance(arr, np.ndarray) + assert np.array_equal(arr, [1, 2, 3]) + + +def test_invalid_backend_raises_value_error(): + with pytest.raises(ValueError): + cxp.ArrayBackend(backend="tensorflow") + + +def test_set_device_is_noop_on_numpy(): + with xp.use_backend("numpy"): + # Must not raise even though there's no GPU to select on the CPU backend. + xp.set_device(0) + + +def test_set_device_selects_cuda_device(): + try: + import cupy as cp + except ImportError: + pytest.skip("CuPy not installed") + + with xp.use_backend("cupy"): + xp.set_device(0) + assert cp.cuda.Device().id == 0 + + +def test_array_backend_repr_reports_active_backend(): + with xp.use_backend("numpy"): + assert ( + repr(cxp.array_backend) == "ArrayBackend(backend='numpy', module='numpy')" + ) + + +def test_synchronize_warns_on_attribute_error(monkeypatch): + """An AttributeError from CuPy's synchronize call (e.g. API mismatch) + must surface as a warning, not be swallowed silently.""" + + class BrokenDevice: + def synchronize(self): + raise AttributeError("simulated CuPy API mismatch") + + class FakeCupy: + cuda = type("cuda", (), {"Device": staticmethod(lambda: BrokenDevice())}) + + monkeypatch.setitem(sys.modules, "cupy", FakeCupy) + monkeypatch.setattr(cxp.array_backend, "_backend", "cupy") + + try: + with pytest.warns(RuntimeWarning, match="CuPy API mismatch"): + xp.synchronize() + finally: + monkeypatch.setattr(cxp.array_backend, "_backend", "numpy") diff --git a/tests/unit/test_cupy.py b/tests/unit/test_cupy.py new file mode 100644 index 0000000..a8e804d --- /dev/null +++ b/tests/unit/test_cupy.py @@ -0,0 +1,50 @@ +import numpy as np +import pytest + +import cunumpy as xp + + +def test_to_cupy_available(): + if not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + import cupy as cp + + with xp.use_backend("cupy"): + arr = np.array([1, 2, 3]) + arr_cp = xp.to_cupy(arr) + assert isinstance(arr_cp, cp.ndarray) + + +def test_to_cupy_not_available(): + if xp.cupy_available(): + pytest.skip("CuPy is installed and functional, cannot test missing cupy error") + + with xp.use_backend("cupy"): + arr = np.array([1, 2, 3]) + with pytest.raises(ImportError): + xp.to_cupy(arr) + + +def test_synchronize(): + # Should not crash on any backend + xp.synchronize() + + with xp.use_backend("numpy"): + xp.synchronize() + + with xp.use_backend("cupy"): + xp.synchronize() + + +def test_xp_array_cupy(): + if not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + import cupy as cp + + with xp.use_backend("cupy"): + arr = xp.array([1, 2]) + arr *= 2 + assert isinstance(arr, cp.ndarray) + assert cp.asnumpy(arr).tolist() == [2, 4] diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py new file mode 100644 index 0000000..1e0aced --- /dev/null +++ b/tests/unit/test_features.py @@ -0,0 +1,156 @@ +import numpy as np +import pytest + +import cunumpy as xp + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_matrix_multiplication(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + # Test basic @ operator and matmul + a = xp.array([[1, 2], [3, 4]], dtype=float) + b = xp.array([[5, 6], [7, 8]], dtype=float) + c = a @ b + + expected = np.array([[19, 22], [43, 50]]) + assert xp.array_equal(xp.to_numpy(c), expected) + + # Test linalg.norm + norm = xp.linalg.norm(a) + assert np.isclose(float(norm), np.linalg.norm([[1, 2], [3, 4]])) + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_reductions_and_axes(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + a = xp.array([[1, 10, 100], [2, 20, 200]], dtype=float) + + assert xp.sum(a) == 333 + assert np.array_equal(xp.to_numpy(xp.max(a, axis=0)), [2, 20, 200]) + assert np.array_equal(xp.to_numpy(xp.min(a, axis=1)), [1, 2]) + assert xp.mean(a) == 333 / 6 + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_complex_elementwise(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + a = xp.array([-1, 0, 1], dtype=float) + + # Exp and Log + exp_a = xp.exp(a) + assert np.allclose(xp.to_numpy(exp_a), np.exp([-1, 0, 1])) + + # Trig + b = xp.array([0, xp.pi / 2], dtype=float) + assert np.allclose(xp.to_numpy(xp.cos(b)), [1, 0], atol=1e-7) + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_broadcasting_logic(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + # 3D + 1D broadcasting + a = xp.ones((2, 3, 4)) + b = xp.arange(4) + c = a * b + + assert c.shape == (2, 3, 4) + assert np.array_equal(xp.to_numpy(c[0, 0]), [0, 1, 2, 3]) + assert np.array_equal(xp.to_numpy(c[1, 2]), [0, 1, 2, 3]) + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_fft_parity(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + # Create a signal with two frequencies + t = xp.linspace(0, 1, 128) + sig = xp.sin(2 * xp.pi * 5 * t) + 0.5 * xp.sin(2 * xp.pi * 20 * t) + + freqs = xp.fft.fft(sig) + inv = xp.fft.ifft(freqs) + + # ifft(fft(x)) == x + assert np.allclose(xp.to_numpy(inv.real), xp.to_numpy(sig)) + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_realistic_normalization_workflow(backend): + """Workflow: Load data -> Compute Stats -> Normalize -> Mask Outliers.""" + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + # 1. Create dummy data with clear outliers + data = xp.array([1.0, 2.0, 3.0, 4.0, 100.0, -100.0]) + + # 2. Normalize + mean = xp.mean(data) + std = xp.std(data) + norm_data = (data - mean) / std + + # 3. Mask outliers (abs > 1.0 in this specific small set) + mask = xp.abs(norm_data) < 1.0 + clean_data = data[mask] + + # Verify: -100 and 100 should be gone + res = xp.to_numpy(xp.sort(clean_data)) + assert np.array_equal(res, [1.0, 2.0, 3.0, 4.0]) + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_stacking_and_concatenation(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + a = xp.array([1, 2, 3]) + b = xp.array([4, 5, 6]) + + res_cat = xp.concatenate([a, b]) + assert np.array_equal(xp.to_numpy(res_cat), [1, 2, 3, 4, 5, 6]) + + res_stack = xp.stack([a, b]) + assert res_stack.shape == (2, 3) + assert np.array_equal(xp.to_numpy(res_stack[1]), [4, 5, 6]) + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_advanced_indexing(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + a = xp.arange(10).reshape(2, 5) + + # Pick specific elements: (0,1) and (1,3) + rows = xp.array([0, 1]) + cols = xp.array([1, 3]) + + indexed = a[rows, cols] + assert np.array_equal(xp.to_numpy(indexed), [1, 8]) + + +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +def test_random_generation(backend): + if backend == "cupy" and not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + with xp.use_backend(backend): + # Test reproducibility if we were to add seed (checking existing proxy) + a = xp.random.normal(0, 1, size=(100, 100)) + assert a.shape == (100, 100) + assert xp.abs(xp.mean(a)) < 0.5 # Basic statistical sanity diff --git a/tests/unit/test_integration.py b/tests/unit/test_integration.py new file mode 100644 index 0000000..9cb4486 --- /dev/null +++ b/tests/unit/test_integration.py @@ -0,0 +1,82 @@ +import numpy as np +import pytest + +import cunumpy as xp + + +def test_data_movement_chain(): + """Test CPU -> GPU -> CPU multi-hop movement.""" + if not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + # 1. Start on CPU + data_orig = np.random.rand(100, 100).astype(np.float32) + + # 2. Move to GPU + data_gpu = xp.to_cupy(data_orig) + assert xp.is_gpu(data_gpu) + + # 3. Do operation on GPU + with xp.use_backend("cupy"): + res_gpu = xp.sin(data_gpu) ** 2 + xp.cos(data_gpu) ** 2 + + # 4. Move back to CPU + res_cpu = xp.to_numpy(res_gpu) + assert isinstance(res_cpu, np.ndarray) + assert np.allclose(res_cpu, 1.0) + + +def test_synchronize_logic(): + """Verify synchronize can be called and handles errors gracefully.""" + # This is more of a smoke test to ensure the path doesn't crash + xp.synchronize() + + if xp.cupy_available(): + import cupy as cp + + with xp.use_backend("cupy"): + a = xp.random.rand(100) + xp.synchronize() + assert xp.is_gpu(a) + assert isinstance(a, cp.ndarray) + + +def test_fft_interop(): + """Test FFT between backends.""" + if not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + # Create signal on CPU + sig_cpu = np.random.rand(1024).astype(np.complex128) + + # Move to GPU and transform + sig_gpu = xp.to_cupy(sig_cpu) + freq_gpu = xp.fft.fft(sig_gpu) + + # Move frequencies to CPU and transform back + freq_cpu = xp.to_numpy(freq_gpu) + sig_reconstructed = np.fft.ifft(freq_cpu) + + assert np.allclose(sig_cpu, sig_reconstructed) + + +def test_mixed_backend_errors(): + """Verify that mixing backends in operations raises errors (standard NumPy/CuPy behavior).""" + if not xp.cupy_available(): + pytest.skip("CuPy not installed or not functional") + + a_cpu = np.array([1, 2, 3]) + a_gpu = xp.to_cupy(a_cpu) + + # This should fail because you can't add CPU and GPU arrays directly. + # The exact exception type is NumPy/CuPy-version dependent, hence the broad catch. + with pytest.raises(Exception): # noqa: B017 + _ = a_cpu + a_gpu + + # But to_cunumpy should fix it: it must run inside the cupy backend context + # so it actually converts a_cpu to a CuPy array, not whatever the global + # backend happened to be left as by an earlier test. + with xp.use_backend("cupy"): + a_gpu_fixed = xp.to_cunumpy(a_cpu) + res = a_gpu + a_gpu_fixed + assert xp.is_gpu(res) diff --git a/tests/unit/test_numpy.py b/tests/unit/test_numpy.py new file mode 100644 index 0000000..2247f10 --- /dev/null +++ b/tests/unit/test_numpy.py @@ -0,0 +1,41 @@ +import numpy as np + +import cunumpy as xp + + +def test_xp_array(): + with xp.use_backend("numpy"): + arr = xp.array([1, 2]) + arr *= 2 + assert isinstance(arr, np.ndarray) + assert np.array_equal(arr, [2, 4]) + + +def test_numpy_symbols_accessible(): + """All public numpy symbols must be reachable via cunumpy. + + This validates the runtime behaviour that the stub file (__init__.pyi) + declares to Pylance so that `xp.` shows numpy completions in VS Code. + """ + with xp.use_backend("numpy"): + # Exclude our custom methods from the numpy check + custom_methods = [ + "to_numpy", + "to_cupy", + "to_cunumpy", + "get_backend", + "is_gpu", + "is_cpu", + "use_backend", + "set_backend", + "synchronize", + "numpy_backend", + "cupy_backend", + "xp", + ] + missing = [ + name + for name in np.__all__ + if not hasattr(xp, name) and name not in custom_methods + ] + assert missing == [], f"Symbols not accessible via cunumpy: {missing}"