diff --git a/.github/workflows/gpu_ci_trigger.yml b/.github/workflows/gpu_ci_trigger.yml index 60fa668..7f7a8e7 100644 --- a/.github/workflows/gpu_ci_trigger.yml +++ b/.github/workflows/gpu_ci_trigger.yml @@ -23,10 +23,11 @@ name: Sync to GitLab and Run GPU CI on: push: - branches: [main, devel] + branches: [devel] pull_request: branches: [main, devel] workflow_dispatch: + workflow_call: jobs: sync-and-test: @@ -57,14 +58,14 @@ jobs: SAFE_REF="${SOURCE_REF//\//-}" TARGET_BRANCH="gpu-test-${SAFE_REF}" fi - echo "TARGET_BRANCH=$TARGET_BRANCH" >> $GITHUB_ENV + 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 - echo "PUSHED_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV + git push -f gitlab "HEAD:refs/heads/$TARGET_BRANCH" + echo "PUSHED_SHA=$(git rev-parse HEAD)" >> "$GITHUB_ENV" # 5. Provide the direct link PIPELINE_URL="https://gitlab.mpcdf.mpg.de/maxlin/cunumpy/-/pipelines?ref=$TARGET_BRANCH" @@ -84,7 +85,7 @@ jobs: # 1. GitLab creates the pipeline asynchronously after the push: wait until # the pipeline for the pushed commit exists (asking right away finds none). PIPELINE="" - for i in $(seq 1 60); do + for _ in $(seq 1 60); do PIPELINE=$(curl -sf "${AUTH[@]}" "$API/pipelines?ref=$TARGET_BRANCH&sha=$PUSHED_SHA&per_page=1" | jq -r '.[0].id // empty' || true) if [ -n "$PIPELINE" ]; then break; fi sleep 5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index eaff1cd..48386c0 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,15 +6,25 @@ on: - main jobs: + # Local reusable workflows check out this push's commit. Main validation is + # invoked here, so publishing cannot race independent CPU/GPU workflows. + cpu-tests: + uses: ./.github/workflows/testing.yml + + gpu-tests: + uses: ./.github/workflows/gpu_ci_trigger.yml + secrets: inherit + build-and-publish: + needs: [cpu-tests, gpu-tests] runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.10" @@ -26,6 +36,13 @@ jobs: - name: Build the package run: python -m build + - name: Check distributions and smoke-test the installed wheel + run: | + python -m twine check dist/* + python -m venv "$RUNNER_TEMP/wheel-smoke" + "$RUNNER_TEMP/wheel-smoke/bin/python" -m pip install dist/*.whl + "$RUNNER_TEMP/wheel-smoke/bin/python" -I testing/smoke_wheel.py + - name: Publish to PyPI env: TWINE_USERNAME: __token__ # Use API token diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 4188463..614da82 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -3,14 +3,33 @@ name: Tests on: push: branches: - - main - devel pull_request: branches: - main - devel + workflow_call: jobs: + compiled-fake-cupy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Install compilers and compiled-test dependencies + run: | + sudo apt-get update + sudo apt-get install -y gcc gfortran + python -m pip install ".[test-compiled]" + - name: Test compiled host kernels with fake device arrays + env: + CUNUMPY_FAKE_CUPY: "1" + CUNUMPY_BACKEND: cupy + CUNUMPY_REQUIRE_PYCCEL: "1" + run: python -m pytest -q tests/unit/test_pyccel_kernel.py tests/unit/test_cupy.py + build: runs-on: ubuntu-latest @@ -22,17 +41,17 @@ jobs: steps: # Checkout the repository - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v4 # Set up Python - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} # Cache pip dependencies - name: Cache pip - uses: actions/cache@v3 + uses: actions/cache@v4 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('**/pyproject.toml') }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8b303..7499713 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Compiled Pyccel regression coverage for F-ordered arrays and column-block + copy-back, run in CPU CI with fake CuPy and required compilation. +- Publishing now requires CPU and GPU validation of the release commit, checks + the built distributions, and smoke-tests the wheel in a fresh environment. + +### Fixed +- The ordering guide identifies 0.6.3 as the first release preserving F order + in CuPy-to-host conversions. + ## [0.6.3] - 2026-10-08 ### Fixed diff --git a/docs/source/guides/array-ordering.md b/docs/source/guides/array-ordering.md index 83b3a73..0312bff 100644 --- a/docs/source/guides/array-ordering.md +++ b/docs/source/guides/array-ordering.md @@ -55,9 +55,10 @@ Returning a C-ordered result from `host_call` does not make that result F-ordere just because an input was F-ordered. ```{note} -Preserving F order in CuPy-to-host conversions is an unreleased fix. Earlier -releases used `array.get()` and produced C-ordered host copies. Applications -depending on F order at a host-kernel boundary need a release containing this fix. +CuPy-to-host conversions preserve F order starting with **cuNumPy 0.6.3**. +Earlier releases used `array.get()` and produced C-ordered host copies. +Applications depending on F order at a host-kernel boundary require +`cunumpy>=0.6.3`. ``` ## Copies, masks, and assignment diff --git a/tests/unit/pyccel_kernels.py b/tests/unit/pyccel_kernels.py index 7e6ab09..b9d20d4 100644 --- a/tests/unit/pyccel_kernels.py +++ b/tests/unit/pyccel_kernels.py @@ -20,6 +20,13 @@ def scale_inplace(x: "float[:]", factor: float): x[i] = x[i] * factor +def scale_fortran_inplace(x: "float[:, :](order=F)", factor: float): # noqa: F821 + """Scale an F-ordered matrix, including complete column-block views.""" + for j in range(x.shape[1]): + for i in range(x.shape[0]): + x[i, j] = x[i, j] * factor + + def dot(x: "float[:]", y: "float[:]") -> float: """Return the dot product of `x` and `y` (a scalar return value).""" result = 0.0 diff --git a/tests/unit/test_fake_cupy_programs.py b/tests/unit/test_fake_cupy_programs.py index 25f6d00..8309f9f 100644 --- a/tests/unit/test_fake_cupy_programs.py +++ b/tests/unit/test_fake_cupy_programs.py @@ -55,13 +55,14 @@ def test_fake_cupy_session_runs_a_cupy_program(): if (i < n) x[i] *= f; }''', "scale") kernel = Kernel(lambda x, f, n: None, scale) +before = xp.get_backend() # CUNUMPY_BACKEND may already select CuPy with fake_cupy_session(): assert xp.get_backend() == "cupy" KernelCatalog({"scale": kernel}).compile_all() x = xp.arange(4.0) kernel(x, 3.0, 4, n_threads=4) print(xp.to_numpy(x).tolist()) -assert xp.get_backend() == "numpy" +assert xp.get_backend() == before """ assert "[0.0, 3.0, 6.0, 9.0]" in run(code).stdout diff --git a/tests/unit/test_pyccel_kernel.py b/tests/unit/test_pyccel_kernel.py index 36492ed..bf9b92a 100644 --- a/tests/unit/test_pyccel_kernel.py +++ b/tests/unit/test_pyccel_kernel.py @@ -6,12 +6,14 @@ run everywhere; * end-to-end tests, which compile `pyccel_kernels.py` with pyccel and drive the compiled kernels through `PyccelKernel`. They are skipped when pyccel (or a - working compiler) is unavailable. + working compiler) is unavailable, unless `CUNUMPY_REQUIRE_PYCCEL=1` makes + missing dependencies and compilation failures errors in compiled-test CI. -The CuPy-side assertions can only be exercised on a machine with a GPU; the -NumPy-side assertions run everywhere. +The CuPy-side assertions run on a GPU or with `CUNUMPY_FAKE_CUPY=1`; the +NumPy-side assertions run everywhere with the compiled-test dependencies. """ +import os import shutil from pathlib import Path @@ -36,7 +38,15 @@ def kernels(tmp_path_factory): The source is copied into a temporary directory first so that pyccel's build artefacts (`__pyccel__/`) never land in the repository. """ - pyccel = pytest.importorskip("pyccel", reason="pyccel is not installed") + require_pyccel = os.environ.get("CUNUMPY_REQUIRE_PYCCEL", "").lower() in { + "1", + "true", + "yes", + } + if require_pyccel: + import pyccel + else: + pyccel = pytest.importorskip("pyccel", reason="pyccel is not installed") import importlib.util import sys @@ -52,7 +62,9 @@ def kernels(tmp_path_factory): try: return pyccel.epyccel(module, language="c") - except Exception as exc: # noqa: BLE001 - no compiler / broken toolchain + except Exception as exc: + if require_pyccel: + raise pytest.skip(f"pyccel could not compile the example kernels: {exc}") finally: sys.modules.pop(source.stem, None) @@ -449,6 +461,30 @@ def test_outputs_is_ignored_on_the_numpy_path(): # --------------------------------------------------------------------------- +@pytest.mark.parametrize("backend", ["numpy", "cupy"]) +@pytest.mark.parametrize("column_block", [False, True]) +def test_compiled_fortran_order_and_copyback(kernels, backend, column_block): + if backend == "cupy": + _skip_without_cupy() + + expected = np.array(np.arange(30.0).reshape(5, 6), order="F") + with xp.use_backend(backend, strict=True): + storage = xp.array(expected, order="F", copy=True) + target = storage[:, 1:4] if column_block else storage + assert target.flags.f_contiguous and not target.flags.c_contiguous + + PyccelKernel(kernels.scale_fortran_inplace, outputs=(0,))(target, 3.0) + + if column_block: + expected[:, 1:4] *= 3.0 + else: + expected *= 3.0 + # Check the parent as well: copy-back must update the view without + # changing the adjacent columns or replacing the caller's storage. + np.testing.assert_array_equal(xp.to_numpy(storage), expected) + assert storage.flags.f_contiguous and target.flags.f_contiguous + + @pytest.mark.parametrize("backend", ["numpy", "cupy"]) def test_compiled_axpy(kernels, backend): if backend == "cupy":