diff --git a/.github/scripts/ci_changes.py b/.github/scripts/ci_changes.py index c39bca10..408502d9 100644 --- a/.github/scripts/ci_changes.py +++ b/.github/scripts/ci_changes.py @@ -96,7 +96,9 @@ def classify(paths): selected.add("cpp_tidy") elif path.startswith("scripts/") and path.endswith(".sh"): selected.add("shell") - if path == "scripts/check-python.sh": + if path == "scripts/build-macos-openmp.sh": + selected.add("wheels") + elif path == "scripts/check-python.sh": selected.add("python_quality") elif path == "scripts/check-tidy.sh": selected.add("cpp_tidy") diff --git a/.github/scripts/test_ci_changes.py b/.github/scripts/test_ci_changes.py index 9d3d15a7..d8dedcf0 100644 --- a/.github/scripts/test_ci_changes.py +++ b/.github/scripts/test_ci_changes.py @@ -63,6 +63,7 @@ def test_language_specific_changes(self): }, ".github/scripts/prepare_release.py": {"python_quality"}, "scripts/check-python.sh": {"shell", "python_quality"}, + "scripts/build-macos-openmp.sh": {"shell", "wheels"}, "scripts/check-tidy.sh": {"shell", "cpp_tidy"}, "scripts/check-format.sh": {"shell", "cpp_format"}, "scripts/check-includes.sh": {"shell"}, diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0f3c3e99..625d53ce 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -197,7 +197,7 @@ jobs: !cancelled() && needs.prepare.result == 'success' && needs.prepare.outputs.build == 'true' && (needs.changes.result != 'success' || needs.changes.outputs.wheels == 'true') - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 90 strategy: fail-fast: false diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ebfce9da..5d2988bb 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -256,7 +256,7 @@ jobs: name: Native Apple Silicon C++ needs: changes if: ${{ !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.cpp == 'true') }} - runs-on: macos-14 + runs-on: macos-15 timeout-minutes: 45 env: OMP_NUM_THREADS: "2" diff --git a/pyproject.toml b/pyproject.toml index 4887c62a..804a3a8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,11 +53,15 @@ test-environment = { RABITQ_TEST_WHEEL = "1", OMP_NUM_THREADS = "2" } [tool.cibuildwheel.macos] archs = ["arm64"] -before-all = "brew install libomp" -environment = { CMAKE_ARGS = "-DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF -DOpenMP_ROOT=$(brew --prefix libomp)", MACOSX_DEPLOYMENT_TARGET = "14.0" } +before-all = 'bash "{project}/scripts/build-macos-openmp.sh" "$RABITQ_OPENMP_PREFIX"' repair-wheel-command = "delocate-wheel --require-archs arm64 -w {dest_dir} -v {wheel}" test-environment = { RABITQ_TEST_WHEEL = "1", OMP_NUM_THREADS = "2" } +[tool.cibuildwheel.macos.environment] +MACOSX_DEPLOYMENT_TARGET = "14.0" +RABITQ_OPENMP_PREFIX = "$(pwd)/build/wheel-openmp" +CMAKE_ARGS = '-DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF -DOpenMP_ROOT="$RABITQ_OPENMP_PREFIX" -DCMAKE_OSX_DEPLOYMENT_TARGET=$MACOSX_DEPLOYMENT_TARGET' + [tool.cibuildwheel.windows] before-build = "python -m pip install delvewheel" repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}" diff --git a/scripts/build-macos-openmp.sh b/scripts/build-macos-openmp.sh new file mode 100644 index 00000000..c353a13e --- /dev/null +++ b/scripts/build-macos-openmp.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +set -euo pipefail + +readonly install_prefix="${1:?OpenMP install prefix is required}" +readonly deployment_target="${MACOSX_DEPLOYMENT_TARGET:?macOS deployment target is required}" +source_dir="$(mktemp -d)" +readonly source_dir +trap 'rm -rf "$source_dir"' EXIT + +# Homebrew bottles target the runner OS. Build its checksum-verified source +# ourselves so the bundled runtime supports the wheel's older deployment target. +brew fetch --build-from-source libomp +tar -xf "$(brew --cache --build-from-source libomp)" \ + -C "$source_dir" --strip-components=1 +cmake -S "$source_dir/runtimes" -B "$source_dir/build" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_INSTALL_PREFIX="$install_prefix" \ + -DCMAKE_OSX_ARCHITECTURES=arm64 \ + -DCMAKE_OSX_DEPLOYMENT_TARGET="$deployment_target" \ + -DLLVM_ENABLE_RUNTIMES=openmp \ + -DOPENMP_INSTALL_LIBDIR=lib \ + -DOPENMP_ENABLE_OMPT_TOOLS=OFF \ + -DLIBOMP_INSTALL_ALIASES=OFF +cmake --build "$source_dir/build" --parallel "$(sysctl -n hw.logicalcpu)" +cmake --install "$source_dir/build" diff --git a/tests/README.md b/tests/README.md index d62fe882..0021e22c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -84,9 +84,13 @@ CMake excludes AVX source files on ARM64. Reference tests run through generic an NEON kernels; explicit AVX backend tests are guarded or skipped. For Python source builds, pass `-Ccmake.define.OpenMP_ROOT=` as well -as the native-optimization setting below. Release CI uses native `macos-14` runners, +as the native-optimization setting below. Release CI uses native `macos-15` runners, builds CPython 3.11–3.14 ARM64 wheels, bundles OpenMP with delocate, then installs and runs the full Python suite for each wheel. The minimum wheel OS target is macOS 14. +The wheel build compiles Homebrew's OpenMP source with that deployment target via +`scripts/build-macos-openmp.sh`; the runner's prebuilt OpenMP bottle may require a +newer macOS. The installed-wheel check verifies both the extension and bundled +runtime support macOS 14. Set `RABITQ_TEST_WHEEL=1` when testing a repaired wheel locally to also check its ARM64 extension and bundled OpenMP loader path. diff --git a/tests/python/test_wheel_macos.py b/tests/python/test_wheel_macos.py index 379dce1d..7cd07629 100644 --- a/tests/python/test_wheel_macos.py +++ b/tests/python/test_wheel_macos.py @@ -2,6 +2,7 @@ import os import platform +import re import subprocess from pathlib import Path @@ -37,3 +38,10 @@ def test_native_arm64_wheel_bundles_openmp(): subprocess.check_output(["lipo", "-archs", str(runtime)], text=True).strip() == "arm64" ) + for binary in (path, runtime): + load_commands = subprocess.check_output(["otool", "-l", str(binary)], text=True) + minimum_versions = re.findall(r"\bminos (\d+)\.(\d+)", load_commands) + assert minimum_versions, load_commands + assert all( + (int(major), int(minor)) <= (14, 0) for major, minor in minimum_versions + ), f"{binary} requires macOS newer than 14.0: {minimum_versions}"