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
11 changes: 6 additions & 5 deletions .github/workflows/gpu_ci_trigger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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
Expand Down
21 changes: 19 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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
Expand Down
27 changes: 23 additions & 4 deletions .github/workflows/testing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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') }}
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions docs/source/guides/array-ordering.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions tests/unit/pyccel_kernels.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion tests/unit/test_fake_cupy_programs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
46 changes: 41 additions & 5 deletions tests/unit/test_pyccel_kernel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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":
Expand Down
Loading