diff --git a/.github/workflows/gpu_ci_trigger.yml b/.github/workflows/gpu_ci_trigger.yml new file mode 100644 index 0000000..9ed33d7 --- /dev/null +++ b/.github/workflows/gpu_ci_trigger.yml @@ -0,0 +1,49 @@ +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 + 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="pr-${{ github.event.number }}" + else + TARGET_BRANCH="${{ github.ref_name }}" + fi + + # 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 + # We construct the URL manually since the push triggers the pipeline automatically + 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" + diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 7de373c..b76c09b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,57 +1,40 @@ -# 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 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 -e . + + # Add the user bin to PATH for pytest + - export PATH="$HOME/.local/bin:$PATH" + - export ARRAY_BACKEND=cupy + + - python3 -m pytest tests/unit/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fae537..ec7876c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,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/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_cunumpy.py b/tests/unit/test_cunumpy.py new file mode 100644 index 0000000..8f07ed5 --- /dev/null +++ b/tests/unit/test_cunumpy.py @@ -0,0 +1,58 @@ +import numpy as np +import pytest + +import cunumpy as xp + + +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 diff --git a/tests/unit/test_cupy.py b/tests/unit/test_cupy.py new file mode 100644 index 0000000..935a27a --- /dev/null +++ b/tests/unit/test_cupy.py @@ -0,0 +1,54 @@ +import numpy as np +import pytest + +import cunumpy as xp + + +def test_to_cupy_available(): + try: + import cupy as cp + except ImportError: + pytest.skip("CuPy not installed") + + 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(): + try: + import cupy + + pytest.skip("CuPy is installed, cannot test missing cupy error") + except ImportError: + pass + + 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(): + try: + import cupy as cp + except ImportError: + pytest.skip("CuPy not installed") + + 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_numpy.py b/tests/unit/test_numpy.py new file mode 100644 index 0000000..30f00d7 --- /dev/null +++ b/tests/unit/test_numpy.py @@ -0,0 +1,42 @@ +import numpy as np +import pytest + +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}"