diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 54a4f2b00..d33aa5e58 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -14,9 +14,16 @@ inputs: restored cache carries a wheel built for another one. required: true mpi: - description: Install an MPI implementation ("on" or "off"). + description: >- + Install an MPI implementation: "on" (Open MPI), "mpich", or "off". required: false default: "off" + build-deps: + description: >- + Install the packages needed to build monoprop (tools/packages/.txt). Jobs that only + test a built wheel skip them; Ubuntu 22.04 lacks some of them. + required: false + default: "true" runs: using: composite @@ -35,12 +42,17 @@ runs: shell: bash env: MPI: ${{ inputs.mpi }} + BUILD_DEPS: ${{ inputs.build-deps }} run: | set -euo pipefail # macOS runners still ship bash 3.2, so no mapfile and no ${var,,}. packages=() add_list() { + if [[ ! -f "$1" ]]; then + echo "no package list $1 for mpi=$MPI on $RUNNER_OS" >&2 + exit 2 + fi while IFS= read -r package; do packages+=("$package") done < <(sed -e 's/#.*//' -e 's/[[:space:]]*$//' "$1" | grep -v '^$' || true) @@ -52,9 +64,14 @@ runs: prefix=tools/packages/apt fi - add_list "$prefix.txt" - if [[ "$(printf '%s' "$MPI" | tr '[:upper:]' '[:lower:]')" =~ ^(on|true|yes|1)$ ]]; then + if [[ "$BUILD_DEPS" == "true" ]]; then + add_list "$prefix.txt" + fi + mpi="$(printf '%s' "$MPI" | tr '[:upper:]' '[:lower:]')" + if [[ "$mpi" =~ ^(on|true|yes|1)$ ]]; then add_list "$prefix-mpi.txt" + elif [[ "$mpi" == mpich ]]; then + add_list "$prefix-mpich.txt" fi # Derive the package from CXX, so a matrix lane needs no list of its own. @@ -69,9 +86,18 @@ runs: esac fi - if [[ "$RUNNER_OS" == "macOS" ]]; then - brew install "${packages[@]}" - else - sudo apt-get update - sudo apt-get install -y "${packages[@]}" + if (( ${#packages[@]} > 0 )); then + if [[ "$RUNNER_OS" == "macOS" ]]; then + brew install "${packages[@]}" + else + sudo apt-get update + sudo apt-get install -y "${packages[@]}" + fi + fi + + # Ubuntu 22.04 and older name MPICH's library libmpich.so.12, not the libmpi.so.12 the MPICH + # ABI requires; this is the system-wide fix mpi4py's installation guide gives. + libdir="/usr/lib/$(uname -m)-linux-gnu" + if [[ "$mpi" == mpich && -e "$libdir/libmpich.so.12" && ! -e "$libdir/libmpi.so.12" ]]; then + sudo ln -sr "$libdir/libmpich.so.12" "$libdir/libmpi.so.12" fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 40d1fd63d..dde8935fc 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,6 +11,10 @@ on: - VERSION - pyproject.toml - packages/monoprop-bench-tools/pyproject.toml + - tools/cibw/** + - tools/packages/** + - cmake/custom/monopropMPI*.cmake + - .github/actions/setup/** concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -53,6 +57,15 @@ jobs: uses: tj-actions/changed-files@v47.0.6 with: files: packages/monoprop-bench-tools/pyproject.toml + - id: changed-wheel-support + if: ${{ github.event_name == 'pull_request' }} + uses: tj-actions/changed-files@v47.0.6 + with: + files: | + tools/cibw/** + tools/packages/** + cmake/custom/monopropMPI*.cmake + .github/actions/setup/** - id: check env: EVENT_NAME: ${{ github.event_name }} @@ -62,6 +75,7 @@ jobs: VERSION_CHANGED: ${{ steps.changed-version.outputs.any_changed }} PYPROJECT_CHANGED: ${{ steps.changed-pyproject.outputs.any_changed }} BENCH_TOOLS_PYPROJECT_CHANGED: ${{ steps.changed-bench-tools-pyproject.outputs.any_changed }} + WHEEL_SUPPORT_CHANGED: ${{ steps.changed-wheel-support.outputs.any_changed }} run: | if [[ "$EVENT_NAME" != "pull_request" ]]; then echo "should-run=true" >> "$GITHUB_OUTPUT" @@ -79,8 +93,14 @@ jobs: version_changed = os.environ["VERSION_CHANGED"] == "true" pyproject_changed = os.environ["PYPROJECT_CHANGED"] == "true" bench_tools_changed = os.environ["BENCH_TOOLS_PYPROJECT_CHANGED"] == "true" - - if deploy_workflow_changed or version_changed or bench_tools_changed: + wheel_support_changed = os.environ["WHEEL_SUPPORT_CHANGED"] == "true" + + if ( + deploy_workflow_changed + or version_changed + or bench_tools_changed + or wheel_support_changed + ): should_run = True elif not pyproject_changed: should_run = False @@ -167,6 +187,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || needs.check-pr-changes.outputs.should-run == 'true' }} name: Build Python wheels for ${{ matrix.os }} runs-on: ${{ matrix.runs-on }} + timeout-minutes: 90 strategy: matrix: os: [ linux-intel, linux-arm, macos-arm ] @@ -199,11 +220,69 @@ jobs: name: cibw-wheels-${{ matrix.os }}-${{ strategy.job-index }} path: wheelhouse/*.whl + test-wheels: + name: Test wheel with ${{ matrix.mpi }} (${{ matrix.mpi4py }} mpi4py) on ${{ matrix.runner }} + needs: build-wheels + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + # Version floors: Ubuntu 22.04 ships Open MPI 4.1.2 and MPICH 4.0; impi-rt 2021.1.1 is the + # oldest Intel MPI on PyPI. Its Boost (1.74) is below monoprop's 1.85, so the C++ + # consumer runs only on macOS here (and in test.yml); inspect-mpi still checks the headers. + - { runner: ubuntu-22.04, artifact: linux-intel, mpi: openmpi-4.1, setup-mpi: "on", build-deps: "false", mpi4py: source, packages: "", abi: openmpi, mpi-include: /usr/lib/x86_64-linux-gnu/openmpi/include, cpp: false } + - { runner: ubuntu-22.04, artifact: linux-intel, mpi: openmpi-4.1, setup-mpi: "on", build-deps: "false", mpi4py: wheel, packages: "", abi: "", mpi-include: "", cpp: false } + - { runner: ubuntu-22.04, artifact: linux-intel, mpi: mpich-4.0, setup-mpi: mpich, build-deps: "false", mpi4py: source, packages: "", abi: mpich, mpi-include: /usr/include/x86_64-linux-gnu/mpich, cpp: false } + - { runner: ubuntu-22.04, artifact: linux-intel, mpi: mpich-4.0, setup-mpi: mpich, build-deps: "false", mpi4py: wheel, packages: "", abi: "", mpi-include: "", cpp: false } + - { runner: ubuntu-22.04, artifact: linux-intel, mpi: intel-2021.1, setup-mpi: "off", build-deps: "false", mpi4py: wheel, packages: "impi-rt==2021.1.1", abi: "", mpi-include: "", cpp: false } + # Latest Intel MPI, and Homebrew Open MPI (the macOS rpath allowlist) + - { runner: ubuntu-26.04, artifact: linux-intel, mpi: intel-latest, setup-mpi: "off", build-deps: "false", mpi4py: wheel, packages: impi-rt, abi: "", mpi-include: "", cpp: false } + - { runner: macos-15, artifact: macos-arm, mpi: homebrew-openmpi, setup-mpi: "on", build-deps: "true", mpi4py: wheel, packages: "", abi: "", mpi-include: "", cpp: true } + env: + MONOPROP_TEST_VENV: ${{ github.workspace }}/.wheel-test-venv + PYTHON: python + steps: + - uses: actions/checkout@v7.0.1 + + # test-wheel-mpi.sh creates its venv from $PYTHON; pin the interpreter instead of relying on + # the runner image's python3 and its venv module. + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - uses: actions/download-artifact@v8 + with: + pattern: cibw-wheels-${{ matrix.artifact }}-* + path: dist + merge-multiple: true + + - name: Set up the build environment + uses: ./.github/actions/setup + with: + python-version: "3.12" + cache-suffix: wheel-test-${{ matrix.mpi }}-${{ matrix.mpi4py }} + mpi: ${{ matrix.setup-mpi }} + build-deps: ${{ matrix.build-deps }} + + - name: Check configure's MPI inspection on the system headers + if: matrix.mpi-include != '' + run: just inspect-mpi "${{ matrix.mpi-include }}" ${{ matrix.abi }} + + - name: Test the wheel under MPI + run: just test-wheel dist ${{ matrix.mpi4py }} ${{ matrix.packages }} + + - name: Build a C++ consumer against the installed wheel + if: matrix.cpp + run: just test-find-package build/find-package-smoke "$MONOPROP_TEST_VENV/bin/python" + upload_pypi: name: Upload to PyPI needs: - build-sdist - build-wheels + - test-wheels if: github.event_name == 'release' && github.event.action == 'published' runs-on: ubuntu-26.04 @@ -262,6 +341,7 @@ jobs: needs: - build-sdist - build-wheels + - test-wheels - build-bench-tools if: github.event_name == 'release' && github.event.action == 'published' diff --git a/.github/workflows/scripts/resolve_cores.py b/.github/workflows/scripts/resolve_cores.py index 196ba958f..0e63e09fe 100644 --- a/.github/workflows/scripts/resolve_cores.py +++ b/.github/workflows/scripts/resolve_cores.py @@ -45,7 +45,7 @@ def visible_physical_cores(cpus): def main() -> str | None: - if not monoprop.has_mpi: + if not monoprop.MPI_ABIS: return "monoprop was built without MPI; every rung shares this binary" # PHYSICAL cores, and only the ones this process may run on. Physical, because that is what diff --git a/.lychee.postbuild.toml b/.lychee.postbuild.toml index def919829..34c35df8b 100644 --- a/.lychee.postbuild.toml +++ b/.lychee.postbuild.toml @@ -17,6 +17,9 @@ accept = [200, 403, 429] # False positives: # - localhost:3000: development server URL used as an example in the docs. +# - cpe.ext.hpe.com: HPE's documentation server sends an incomplete certificate chain, which +# browsers repair and lychee rejects as an unknown issuer. exclude = [ "^http://localhost", + "^https://cpe\\.ext\\.hpe\\.com/", ] diff --git a/CMakeLists.txt b/CMakeLists.txt index 1442722c3..40a37a222 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,12 @@ set( ) option(monoprop_ENABLE_MPI "Enable MPI parallelization" OFF) +# cibuildwheel exports CIBUILDWHEEL=1. Wheels must not carry build-machine library paths, which could +# shadow the MPI library mpi4py selects; source installs keep them to find Spack or conda libraries. +# Read on every configure, not cached: a build tree may be reused outside cibuildwheel. Used as a +# boolean, so unset or empty is false. +set(monoprop_WHEEL_BUILD "$ENV{CIBUILDWHEEL}") + option(monoprop_ENABLE_CXX_UNIT_TESTS "Enable C++ unit test suite" ON) set(Python_FIND_VIRTUALENV FIRST) @@ -65,9 +71,7 @@ find_package( find_package(Threads REQUIRED) -if(monoprop_ENABLE_MPI) - find_package(MPI REQUIRED COMPONENTS CXX) -endif() +include(${PROJECT_SOURCE_DIR}/cmake/custom/monopropMPI.cmake) include(${PROJECT_SOURCE_DIR}/cmake/compiler_flags/Sanitizers.cmake) include(${PROJECT_SOURCE_DIR}/cmake/compiler_flags/CXXFlags.cmake) @@ -95,6 +99,8 @@ message(STATUS " User-appended : ${EXTRA_CXXFLAGS}") message(STATUS " Sanitizer profile : ${monoprop_SANITIZER}") message(STATUS " MPI parallelization : ${monoprop_ENABLE_MPI}") +message(STATUS " MPI ABI families : ${monoprop_MPI_ABI_NAMES}") +message(STATUS " cibuildwheel build : ${monoprop_WHEEL_BUILD}") message(STATUS " Max simulable modes : ${monoprop_MAX_NUM_MODES}") message(STATUS " C++ unit tests : ${monoprop_ENABLE_CXX_UNIT_TESTS}") @@ -103,8 +109,8 @@ include(GNUInstallDirs) set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_BINDIR}) set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${CMAKE_INSTALL_LIBDIR}) -add_library(monoprop-objs OBJECT "") -add_library(monoprop SHARED $) +# Its objects are attached in cpp/monoprop once every subdirectory has registered its sources. +add_library(monoprop SHARED) # must run before add_subdirectory(cpp): CTest's enabled-ness does not propagate # back up to a parent directory that has already been added as a subdirectory. diff --git a/README.md b/README.md index 91e2b5c79..1bc744a94 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,18 @@ with [Bencher](https://bencher.dev/) to catch performance regressions. pip install monoprop # or: uv add monoprop ``` -The prebuilt PyPI wheels are single-process (built **without** MPI). For multi-rank -runs, or to build the C++ library and executables, build from source (see below). +The PyPI wheels support MPI: install mpi4py with the `mpi` extra and pass a communicator to a +propagator. They use the MPI library already on your machine, from the MPICH family (MPICH, Intel +MPI, MVAPICH, Cray MPICH) or Open MPI, as provided by your cluster, package manager or own build: + +```bash +pip install "monoprop[mpi]" +``` + +The [parallelism docs](https://docs.monoprop.algorithmiq.tech/features/parallelism) list the +minimum versions and how to get an MPI onto a machine that has none. + +To build the C++ library and executables, build from source (see below). ## Quick example @@ -135,8 +145,9 @@ select the MPI variant with `monoprop_ENABLE_MPI=ON` in the environment (or `-C monoprop.mpi=ON`). Standalone MPI-enabled C++ programs must initialize MPI before constructing a propagator and finalize it only after all propagators have been destroyed. The -exported CMake target preserves the package's MPI setting independently of any -`MPI::MPI_CXX` target already present in the consuming project. QA coverage +exported `monoprop::monoprop` CMake target is always serial; MPI consumers request the `MPI` +component (`find_package(monoprop CONFIG REQUIRED COMPONENTS MPI)`) and link +`monoprop::monoprop_mpi`. QA coverage merges separate serial and MPI-instrumented builds, including two-rank Python and C++ MPI runs, so both compatibility paths contribute to the reports. Run `just code-coverage` for the same combined report locally; it builds both diff --git a/benches/conftest.py b/benches/conftest.py index 40ab612cd..9929b4f80 100644 --- a/benches/conftest.py +++ b/benches/conftest.py @@ -303,8 +303,8 @@ def pytest_sessionfinish() -> None: @pytest.fixture(scope="session") def bench_comm() -> Any: - """Return the benchmark communicator (``MPI.COMM_WORLD`` or ``None``).""" - return None if MPI is None else MPI.COMM_WORLD + """Return the benchmark communicator (``MPI.COMM_WORLD``, or ``None`` without an MPI backend).""" + return None if MPI is None or not monoprop.MPI_ABIS else MPI.COMM_WORLD @pytest.fixture(scope="session") diff --git a/cmake/_mpi_abis.py.in b/cmake/_mpi_abis.py.in new file mode 100644 index 000000000..81cfff799 --- /dev/null +++ b/cmake/_mpi_abis.py.in @@ -0,0 +1,24 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""MPI ABI families this build of monoprop ships. Generated by CMake from cmake/_mpi_abis.py.in.""" + +from __future__ import annotations + +ABIS: tuple[str, ...] = (@monoprop_MPI_ABIS_PY@) + +# Version floors per MPI implementation, from cmake/custom/monopropMPIABI.cmake. +MIN_VERSIONS: dict[str, tuple[int, ...]] = {@monoprop_MPI_MIN_VERSIONS_PY@} + +MODULE_FILES: dict[str, str] = {@monoprop_MPI_MODULE_FILES_PY@} diff --git a/cmake/custom/monopropCore.cmake b/cmake/custom/monopropCore.cmake new file mode 100644 index 000000000..1bda1d575 --- /dev/null +++ b/cmake/custom/monopropCore.cmake @@ -0,0 +1,64 @@ +# Core object libraries +# ===================== +# +# monoprop_ENABLE_MPI changes class layouts (cpp/monoprop/detail/mpi/Comm.h), so every MPI ABI +# variant compiles the core sources again into an OBJECT library of its own. Subdirectories +# register their translation units once with monoprop_add_core_sources(); +# monoprop_add_core_objects() then stamps out one variant. + +include_guard(GLOBAL) + +#[=[ +monoprop_add_core_sources(...) + +Registers core translation units, given relative to the calling directory. +#]=] +function(monoprop_add_core_sources) + foreach(_source IN LISTS ARGN) + cmake_path( + ABSOLUTE_PATH _source + BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + OUTPUT_VARIABLE _absolute + ) + set_property( + GLOBAL + APPEND + PROPERTY + monoprop_CORE_SOURCES + "${_absolute}" + ) + endforeach() +endfunction() + +#[=[ +monoprop_add_core_objects( [MPI ]) + +Compiles every registered core source into the OBJECT library . With MPI, provides +the MPI headers and library, and monoprop_ENABLE_MPI is defined for and its consumers. +#]=] +function(monoprop_add_core_objects name) + cmake_parse_arguments(PARSE_ARGV 1 _arg "" "MPI" "") + get_property(_sources GLOBAL PROPERTY monoprop_CORE_SOURCES) + add_library(${name} OBJECT ${_sources}) + target_link_libraries( + ${name} + PUBLIC + monoprop-core-usage + PRIVATE + PkgConfig::HWLOC + ) + if(_arg_MPI) + target_link_libraries(${name} PUBLIC ${_arg_MPI}) + target_compile_definitions(${name} PUBLIC monoprop_ENABLE_MPI) + endif() + set_target_properties( + ${name} + PROPERTIES + CXX_VISIBILITY_PRESET + hidden + VISIBILITY_INLINES_HIDDEN + YES + POSITION_INDEPENDENT_CODE + ON + ) +endfunction() diff --git a/cmake/custom/monopropMPI.cmake b/cmake/custom/monopropMPI.cmake new file mode 100644 index 000000000..2969afc59 --- /dev/null +++ b/cmake/custom/monopropMPI.cmake @@ -0,0 +1,149 @@ +# MPI setup +# ========= +# +# Included from the top-level CMakeLists.txt. Sets monoprop_MPI_ABI_NAMES: the MPI ABI families +# this build compiles MPI variants for (empty without MPI), and monoprop_MACOS_MPI_RPATHS. + +include_guard(GLOBAL) + +include(${CMAKE_CURRENT_LIST_DIR}/monopropMPIABI.cmake) + +set( + monoprop_MPI_ABIS + "" + CACHE STRING + "MPI ABI variants to build from install prefixes, as ';'-separated = entries (wheel builds)" +) + +set(monoprop_MPI_ABI_NAMES "") + +# Where macOS MPI installations live (Homebrew, MacPorts, /usr/local), as mpi4py's wheels assume. +# Wheel builds add them to the RPATHs of the MPI binaries only. +set( + monoprop_MACOS_MPI_RPATHS + /usr/local/lib + /opt/homebrew/lib + /opt/local/lib +) + +#[=[ +_monoprop_add_prefix_mpi_target(=) + +Creates monoprop::mpi_ from an MPI installed under (include/mpi.h, lib/libmpi or +lib/libmpi_abi) without compiler wrappers, and appends to monoprop_MPI_ABI_NAMES. +#]=] +function(_monoprop_add_prefix_mpi_target entry) + if(NOT entry MATCHES "^(mpich|openmpi|mpiabi)=(.+)$") + message( + FATAL_ERROR + "monoprop_MPI_ABIS entry '${entry}' is not = with one of mpich, openmpi, mpiabi" + ) + endif() + set(_abi "${CMAKE_MATCH_1}") + set(_root "${CMAKE_MATCH_2}") + if(_abi IN_LIST monoprop_MPI_ABI_NAMES) + message(FATAL_ERROR "monoprop_MPI_ABIS names '${_abi}' twice") + endif() + if(_abi STREQUAL "mpiabi") + # MPICH's mpi.h switches to mpi_abi.h on MPI_ABI + set(_library mpi_abi) + set(_definitions MPI_ABI) + else() + set(_library mpi) + set(_definitions "") + endif() + if(NOT EXISTS "${_root}/include/mpi.h") + message( + FATAL_ERROR + "monoprop_MPI_ABIS entry '${entry}': no ${_root}/include/mpi.h" + ) + endif() + find_library( + _library_path + NAMES + ${_library} + PATHS + "${_root}/lib" + NO_DEFAULT_PATH + NO_CACHE + REQUIRED + ) + monoprop_inspect_mpi( + _found _problem + INCLUDE_DIRS "${_root}/include" + DEFINITIONS ${_definitions} + ) + if(NOT _found STREQUAL _abi) + message( + FATAL_ERROR + "monoprop_MPI_ABIS entry '${entry}': the mpi.h under ${_root}/include is '${_found}', not '${_abi}'" + ) + endif() + if(_problem) + message(FATAL_ERROR "monoprop_MPI_ABIS entry '${entry}': ${_problem}") + endif() + add_library(monoprop::mpi_${_abi} INTERFACE IMPORTED) + target_include_directories( + monoprop::mpi_${_abi} + SYSTEM + INTERFACE + "${_root}/include" + ) + target_compile_definitions( + monoprop::mpi_${_abi} + INTERFACE + ${_definitions} + MPICH_SKIP_MPICXX + OMPI_SKIP_MPICXX + ) + target_link_libraries(monoprop::mpi_${_abi} INTERFACE "${_library_path}") + message(STATUS "MPI variant ${_abi}: ${_library_path}") + set( + monoprop_MPI_ABI_NAMES + ${monoprop_MPI_ABI_NAMES} + ${_abi} + PARENT_SCOPE + ) +endfunction() + +if(monoprop_MPI_ABIS AND NOT monoprop_ENABLE_MPI) + message( + FATAL_ERROR + "monoprop_MPI_ABIS is set but monoprop_ENABLE_MPI is OFF; enable MPI (monoprop.mpi) to build MPI variants" + ) +endif() + +if(NOT monoprop_ENABLE_MPI) + return() +endif() + +if(monoprop_MPI_ABIS) + # Wheel builds: one variant per = entry, each against the MPI under its prefix + foreach(_entry IN LISTS monoprop_MPI_ABIS) + _monoprop_add_prefix_mpi_target("${_entry}") + endforeach() +else() + # Source builds: one variant, for the ABI family of the MPI that FindMPI finds. + # The MPI-2 C++ bindings would add libmpicxx/libmpi_cxx, outside the soname contract the wheels + # rely on; monoprop only uses the C API. + set(MPI_CXX_SKIP_MPICXX ON) + find_package(MPI REQUIRED COMPONENTS CXX) + monoprop_inspect_mpi( + _monoprop_mpi_abi _monoprop_mpi_problem + INCLUDE_DIRS ${MPI_CXX_INCLUDE_DIRS} + DEFINITIONS ${MPI_CXX_COMPILE_DEFINITIONS} + ) + if(_monoprop_mpi_problem) + message( + FATAL_ERROR + "${_monoprop_mpi_problem} (the MPI CMake found: ${MPI_CXX_INCLUDE_DIRS})" + ) + endif() + set(monoprop_MPI_ABI_NAMES ${_monoprop_mpi_abi}) + add_library(monoprop::mpi_${_monoprop_mpi_abi} INTERFACE IMPORTED) + target_link_libraries( + monoprop::mpi_${_monoprop_mpi_abi} + INTERFACE + MPI::MPI_CXX + ) +endif() diff --git a/cmake/custom/monopropMPIABI.cmake b/cmake/custom/monopropMPIABI.cmake new file mode 100644 index 000000000..6040b028c --- /dev/null +++ b/cmake/custom/monopropMPIABI.cmake @@ -0,0 +1,243 @@ +# MPI inspection +# ============== +# +# Names the MPI ABI family of an mpi.h ("mpiabi": the MPI-5 standard ABI; "openmpi": Open MPI's +# libmpi.so.40; "mpich": the MPICH ABI of MPICH, Intel MPI, MVAPICH and Cray MPICH), identifies the +# implementation, and checks it against monoprop's version floors. Used by monoprop's build and by +# monopropConfig.cmake. Everything except monoprop_mpi_macros() and monoprop_inspect_mpi(), which +# compile a probe, works on text, so it runs in script mode +# (cpp/tests/cmake/test_mpi_inspection.cmake). + +include_guard(GLOBAL) + +#[=[ +monoprop_mpi_min_versions() + +Sets to monoprop's version floors as = entries, keyed by implementation as +mpi4py.MPI.get_vendor() names it. monoprop supports each from its floor to the latest release; +configure time and the Python loader (through the generated monoprop/_mpi_abis.py) enforce them. + +A function rather than a variable because of include_guard(GLOBAL): monopropConfig.cmake includes +this file in the consumer's scope, so a second find_package(monoprop) from a sibling directory skips +the include and would not see a variable, while function definitions are global. +#]=] +function(monoprop_mpi_min_versions out_var) + set( + ${out_var} + "Open MPI=4.1" + "MPICH=4.0" + "Intel MPI=2021.0" + "MVAPICH=3.0" + "Cray MPICH=8.1" + PARENT_SCOPE + ) +endfunction() + +#[=[ +monoprop_mpi_macros( INCLUDE_DIRS ... [DEFINITIONS ...]) + +Sets to "#define " lines for the macros the functions below read, with the +values the compiler sees after including mpi.h, so macros from headers that mpi.h includes count +too. As FindMPI and CMake's compiler identification do, a probe is compiled with try_compile, never +linked or run, and the values are read back from it with file(STRINGS). DEFINITIONS are given +without -D, as in MPI_CXX_COMPILE_DEFINITIONS; MPICH's standard-ABI mode needs MPI_ABI among them. +#]=] +function(monoprop_mpi_macros out_var) + cmake_parse_arguments(PARSE_ARGV 1 _arg "" "" "INCLUDE_DIRS;DEFINITIONS") + set( + _names + MPI_ABI_VERSION + OPEN_MPI + OMPI_MAJOR_VERSION + OMPI_MINOR_VERSION + OMPI_RELEASE_VERSION + MPICH_VERSION + MVAPICH_VERSION + MVAPICH2_VERSION + CRAY_MPICH_VERSION + I_MPI_NUMVERSION + ) + set( + _source + "#include \n#define MONOPROP_STR_(x) #x\n#define MONOPROP_STR(x) MONOPROP_STR_(x)\n" + ) + foreach(_name IN LISTS _names) + # extern: a namespace-scope const array has internal linkage and could be dropped + string( + APPEND _source + "#ifdef ${_name}\n" + "extern char const monoprop_${_name}[];\n" + "char const monoprop_${_name}[] =\n" + " \"INFO:monoprop-mpi[${_name}=\" MONOPROP_STR(${_name}) \"]\";\n" + "#endif\n" + ) + endforeach() + set(_definitions "") + foreach( + _definition + IN + LISTS + _arg_DEFINITIONS + ITEMS + MPICH_SKIP_MPICXX + OMPI_SKIP_MPICXX + ) + list(APPEND _definitions "-D${_definition}") + endforeach() + # A static library is never linked, so the probe needs no MPI library. + set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) + set( + _probe + "${CMAKE_BINARY_DIR}/CMakeFiles/monoprop_mpi_probe${CMAKE_STATIC_LIBRARY_SUFFIX}" + ) + try_compile( + _compiled + SOURCE_FROM_CONTENT + monoprop_mpi_probe.cpp + "${_source}" + CMAKE_FLAGS + "-DINCLUDE_DIRECTORIES=${_arg_INCLUDE_DIRS}" + COMPILE_DEFINITIONS + ${_definitions} + NO_CACHE + COPY_FILE "${_probe}" + OUTPUT_VARIABLE _output + ) + if(NOT _compiled) + message( + FATAL_ERROR + "monoprop: cannot compile mpi.h with include dirs '${_arg_INCLUDE_DIRS}':\n${_output}" + ) + endif() + file( + STRINGS "${_probe}" + _entries + REGEX "INFO:monoprop-mpi\\[[A-Z0-9_]+=[^]]*\\]" + ) + set(_macros "") + foreach(_entry IN LISTS _entries) + if(_entry MATCHES "INFO:monoprop-mpi\\[([A-Z0-9_]+)=([^]]*)\\]") + string(APPEND _macros "#define ${CMAKE_MATCH_1} ${CMAKE_MATCH_2}\n") + endif() + endforeach() + set(${out_var} "${_macros}" PARENT_SCOPE) +endfunction() + +#[=[ +monoprop_mpi_abi_from_macros( ) + +Sets to mpiabi, openmpi or mpich. +#]=] +function(monoprop_mpi_abi_from_macros out_var macros) + set(_abi "mpich") + if(macros MATCHES "#define MPI_ABI_VERSION ([0-9]+)") + if(CMAKE_MATCH_1 GREATER_EQUAL 1) + set(_abi "mpiabi") + endif() + endif() + if(_abi STREQUAL "mpich" AND macros MATCHES "#define OPEN_MPI ") + set(_abi "openmpi") + endif() + set(${out_var} "${_abi}" PARENT_SCOPE) +endfunction() + +#[=[ +monoprop_mpi_vendor_from_macros( ) + +Sets to the implementation, named as mpi4py.MPI.get_vendor() names it, or "unknown", +and to its dotted version (empty when unknown). MPICH derivatives also define +MPICH_VERSION, their MPICH base, so they are recognised first. +#]=] +function(monoprop_mpi_vendor_from_macros name_var version_var macros) + set(_number "[0-9]+(\\.[0-9]+)*") + set(_name "unknown") + set(_version "") + if(macros MATCHES "#define CRAY_MPICH_VERSION (${_number})") + set(_name "Cray MPICH") + set(_version "${CMAKE_MATCH_1}") + elseif(macros MATCHES "#define MVAPICH2?_VERSION \"(${_number})") + set(_name "MVAPICH") + set(_version "${CMAKE_MATCH_1}") + elseif(macros MATCHES "#define I_MPI_NUMVERSION ([0-9]+)") + # major * 10^7 + minor * 10^5 + micro * 10^3 + build, decoded as mpi4py's pympivendor.h does + math(EXPR _packed "${CMAKE_MATCH_1} / 1000") + math(EXPR _major "${_packed} / 10000") + math(EXPR _minor "(${_packed} % 10000) / 100") + math(EXPR _micro "${_packed} % 100") + set(_name "Intel MPI") + set(_version "${_major}.${_minor}.${_micro}") + elseif(macros MATCHES "#define OPEN_MPI ") + set(_parts "") + foreach(_part MAJOR MINOR RELEASE) + string( + REGEX MATCH "#define OMPI_${_part}_VERSION ([0-9]+)" + _match + "${macros}" + ) + list(APPEND _parts "${CMAKE_MATCH_1}") + endforeach() + set(_name "Open MPI") + list(JOIN _parts "." _version) + elseif(macros MATCHES "#define MPICH_VERSION \"(${_number})") + set(_name "MPICH") + set(_version "${CMAKE_MATCH_1}") + endif() + set(${name_var} "${_name}" PARENT_SCOPE) + set(${version_var} "${_version}" PARENT_SCOPE) +endfunction() + +#[=[ +monoprop_mpi_version_problem( ) + +Sets to an error message when is below monoprop's floor, and to an +empty string otherwise. An implementation without a floor only warns. +#]=] +function(monoprop_mpi_version_problem out_var name version) + monoprop_mpi_min_versions(_floors) + set(_problem "") + set(_known FALSE) + foreach(_entry IN LISTS _floors) + if(_entry MATCHES "^(.+)=(.+)$") + set(_vendor "${CMAKE_MATCH_1}") + set(_floor "${CMAKE_MATCH_2}") + if(_vendor STREQUAL name) + set(_known TRUE) + if(version VERSION_LESS _floor) + set(_problem "monoprop needs ${name} >= ${_floor}, found ${version}") + endif() + endif() + endif() + endforeach() + if(NOT _known) + list(JOIN _floors ", " _supported) + message( + WARNING + "monoprop has not been validated with this MPI implementation (${name} ${version}); it supports ${_supported}" + ) + endif() + set(${out_var} "${_problem}" PARENT_SCOPE) +endfunction() + +#[=[ +monoprop_inspect_mpi( INCLUDE_DIRS ... [DEFINITIONS ...]) + +Sets to the ABI family of the mpi.h found in INCLUDE_DIRS and to the +version-floor error (empty when there is none). Callers decide whether that is fatal. +#]=] +function(monoprop_inspect_mpi abi_var problem_var) + cmake_parse_arguments(PARSE_ARGV 2 _arg "" "" "INCLUDE_DIRS;DEFINITIONS") + monoprop_mpi_macros( + _macros + INCLUDE_DIRS ${_arg_INCLUDE_DIRS} + DEFINITIONS ${_arg_DEFINITIONS} + ) + monoprop_mpi_abi_from_macros(_abi "${_macros}") + set(_problem "") + # The standard ABI header names no implementation, and every implementation of it is recent. + if(NOT _abi STREQUAL "mpiabi") + monoprop_mpi_vendor_from_macros(_name _version "${_macros}") + monoprop_mpi_version_problem(_problem "${_name}" "${_version}") + endif() + set(${abi_var} "${_abi}" PARENT_SCOPE) + set(${problem_var} "${_problem}" PARENT_SCOPE) +endfunction() diff --git a/cmake/monopropConfig.cmake.in b/cmake/monopropConfig.cmake.in index 89296ac2d..17064ca86 100644 --- a/cmake/monopropConfig.cmake.in +++ b/cmake/monopropConfig.cmake.in @@ -13,11 +13,18 @@ # find_package(monoprop CONFIG REQUIRED) # target_link_libraries(my_target PRIVATE monoprop::monoprop) # +# # MPI: the libmonoprop variant for the MPI ABI family of the MPI CMake finds for my_target +# find_package(monoprop CONFIG REQUIRED COMPONENTS MPI) +# target_link_libraries(my_target PRIVATE monoprop::monoprop_mpi) +# # Available imported targets # -------------------------- # # ``monoprop::monoprop`` # Shared library target for the monoprop C++ core API. +# ``monoprop::monoprop_mpi`` +# With the ``MPI`` component: MPI-enabled libmonoprop matching the consumer's MPI ABI family, +# plus ``MPI::MPI_CXX``. ``monoprop_MPI_ABIS`` lists the families this installation ships. include(CMakeFindDependencyMacro) @@ -32,18 +39,57 @@ find_dependency( REQUIRED ) -set(monoprop_ENABLE_MPI "@monoprop_ENABLE_MPI@") -if(monoprop_ENABLE_MPI) - find_dependency( - MPI - REQUIRED - COMPONENTS - CXX - ) +set(monoprop_MPI_ABIS "@monoprop_MPI_ABI_NAMES@") + +# Checked before the targets are imported, so a mismatch names the ABI rather than a missing file. +if("MPI" IN_LIST monoprop_FIND_COMPONENTS) + set(monoprop_MPI_FOUND FALSE) + if(NOT monoprop_MPI_ABIS) + set( + monoprop_NOT_FOUND_MESSAGE + "this monoprop installation was built without MPI (monoprop_MPI_ABIS is empty)" + ) + else() + # monoprop uses the MPI C API only; leave a consumer's explicit choice alone. + if(NOT DEFINED MPI_CXX_SKIP_MPICXX) + set(MPI_CXX_SKIP_MPICXX ON) + endif() + find_dependency( + MPI + COMPONENTS + CXX + ) + include("${CMAKE_CURRENT_LIST_DIR}/monopropMPIABI.cmake") + monoprop_inspect_mpi( + _monoprop_consumer_mpi_abi _monoprop_consumer_mpi_problem + INCLUDE_DIRS ${MPI_CXX_INCLUDE_DIRS} + DEFINITIONS ${MPI_CXX_COMPILE_DEFINITIONS} + ) + if(_monoprop_consumer_mpi_problem) + set(monoprop_NOT_FOUND_MESSAGE "${_monoprop_consumer_mpi_problem}") + elseif(_monoprop_consumer_mpi_abi IN_LIST monoprop_MPI_ABIS) + set(monoprop_MPI_FOUND TRUE) + else() + set( + monoprop_NOT_FOUND_MESSAGE + "the MPI found for this project uses the '${_monoprop_consumer_mpi_abi}' ABI, but this monoprop installation supports: ${monoprop_MPI_ABIS}" + ) + endif() + endif() endif() if(NOT TARGET monoprop::monoprop) include("${CMAKE_CURRENT_LIST_DIR}/monopropTargets.cmake") endif() +if(monoprop_MPI_FOUND AND NOT TARGET monoprop::monoprop_mpi) + add_library(monoprop::monoprop_mpi INTERFACE IMPORTED) + target_link_libraries( + monoprop::monoprop_mpi + INTERFACE + monoprop::monoprop_mpi_${_monoprop_consumer_mpi_abi} + MPI::MPI_CXX + ) +endif() + check_required_components(monoprop) diff --git a/cpp/CMakeLists.txt b/cpp/CMakeLists.txt index 361e4531f..5a7792549 100644 --- a/cpp/CMakeLists.txt +++ b/cpp/CMakeLists.txt @@ -47,6 +47,7 @@ install( FILES ${PROJECT_BINARY_DIR}/${PROJECT_NAME}Config.cmake ${PROJECT_BINARY_DIR}/${PROJECT_NAME}ConfigVersion.cmake + ${PROJECT_SOURCE_DIR}/cmake/custom/monopropMPIABI.cmake DESTINATION ${monoprop_INSTALL_CMAKEDIR} ) diff --git a/cpp/monoprop/CMakeLists.txt b/cpp/monoprop/CMakeLists.txt index e2bb624ce..bb4f53a19 100644 --- a/cpp/monoprop/CMakeLists.txt +++ b/cpp/monoprop/CMakeLists.txt @@ -8,65 +8,50 @@ message( "Using hwloc: ${HWLOC_LINK_LIBRARIES} (version ${HWLOC_VERSION})" ) -target_sources( - monoprop-objs - PRIVATE - Evolution.cpp - MPFunctions.cpp - MPGraph.cpp - Validation.cpp -) +include(${PROJECT_SOURCE_DIR}/cmake/custom/monopropCore.cmake) -target_compile_definitions( - monoprop-objs - PUBLIC - $<$:monoprop_ENABLE_MPI> -) +# Usage requirements every core variant compiles with and hands on to its consumers. +add_library(monoprop-core-usage INTERFACE) # flags to prepend target_compile_options( - monoprop-objs + monoprop-core-usage BEFORE - PUBLIC + INTERFACE "${monoprop_CXX_FLAGS}" "${ARCH_FLAG}" ) # flags to append -target_compile_options(monoprop-objs PUBLIC "${EXTRA_CXXFLAGS}") +target_compile_options(monoprop-core-usage INTERFACE "${EXTRA_CXXFLAGS}") target_include_directories( - monoprop-objs - PUBLIC + monoprop-core-usage + INTERFACE $ $ $ ) target_link_libraries( - monoprop-objs - PUBLIC + monoprop-core-usage + INTERFACE monoprop-sanitizers Boost::boost Threads::Threads - $<$:MPI::MPI_CXX> - PRIVATE - PkgConfig::HWLOC ) -set_target_properties( - monoprop-objs - PROPERTIES - CXX_VISIBILITY_PRESET - hidden - VISIBILITY_INLINES_HIDDEN - YES +monoprop_add_core_sources( + Evolution.cpp + MPFunctions.cpp + MPGraph.cpp + Validation.cpp ) include(GenerateExportHeader) generate_export_header( - monoprop-objs + monoprop BASE_NAME "${PROJECT_NAME}" EXPORT_FILE_NAME "${PROJECT_BINARY_DIR}/include/${PROJECT_NAME}/${PROJECT_NAME}Export.h" @@ -94,18 +79,10 @@ target_sources( "${PROJECT_SOURCE_DIR}/cpp/monoprop/Validation.h" ) -target_compile_definitions( - monoprop - INTERFACE - $<$:monoprop_ENABLE_MPI> -) - target_compile_features(monoprop INTERFACE cxx_std_23) target_link_libraries( monoprop - PUBLIC - $<$:MPI::MPI_CXX> INTERFACE Boost::boost Threads::Threads @@ -119,6 +96,67 @@ add_subdirectory(algebra) add_subdirectory(core) add_subdirectory(detail) +# Every subdirectory has registered its sources by now. +monoprop_add_core_objects(monoprop-objs) +foreach(_abi IN LISTS monoprop_MPI_ABI_NAMES) + monoprop_add_core_objects(monoprop-objs-${_abi} MPI monoprop::mpi_${_abi}) +endforeach() + +# The C++ unit tests exercise MPI whenever the build has it. +if(monoprop_MPI_ABI_NAMES) + list(GET monoprop_MPI_ABI_NAMES 0 _test_abi) + set(monoprop_CPP_TEST_OBJS monoprop-objs-${_test_abi} PARENT_SCOPE) +else() + set(monoprop_CPP_TEST_OBJS monoprop-objs PARENT_SCOPE) +endif() + +target_sources(monoprop PRIVATE $) + +# One MPI-enabled libmonoprop per ABI family. Headers are shared with libmonoprop; MPI is switched on +# by the compile definition these targets hand to consumers. +foreach(_abi IN LISTS monoprop_MPI_ABI_NAMES) + set(_lib monoprop_mpi_${_abi}) + add_library(${_lib} SHARED $) + target_compile_definitions(${_lib} INTERFACE monoprop_ENABLE_MPI) + target_compile_features(${_lib} INTERFACE cxx_std_23) + target_include_directories( + ${_lib} + INTERFACE + $ + $ + $ + $ + ) + target_link_libraries( + ${_lib} + PUBLIC + # consumers bring their own MPI; monopropConfig.cmake links it + $ + INTERFACE + Boost::boost + Threads::Threads + PRIVATE + $ + PkgConfig::HWLOC + ) + if(APPLE AND monoprop_WHEEL_BUILD) + set_target_properties( + ${_lib} + PROPERTIES + INSTALL_RPATH + "${monoprop_MACOS_MPI_RPATHS}" + ) + endif() + install( + TARGETS + ${_lib} + EXPORT monopropTargets + LIBRARY + DESTINATION ${PROJECT_NAME}/${CMAKE_INSTALL_LIBDIR} + COMPONENT lib + ) +endforeach() + install( TARGETS monoprop diff --git a/cpp/monoprop/detail/graph_encoding/CMakeLists.txt b/cpp/monoprop/detail/graph_encoding/CMakeLists.txt index ebf1faea8..deb414ee7 100644 --- a/cpp/monoprop/detail/graph_encoding/CMakeLists.txt +++ b/cpp/monoprop/detail/graph_encoding/CMakeLists.txt @@ -8,4 +8,4 @@ target_sources( "MPGraphEncodingTypes.h" ) -target_sources(monoprop-objs PRIVATE MPGraphEncoding.cpp) +monoprop_add_core_sources(MPGraphEncoding.cpp) diff --git a/cpp/monoprop/detail/mpi/CMakeLists.txt b/cpp/monoprop/detail/mpi/CMakeLists.txt index 9128c8efa..43193c85c 100644 --- a/cpp/monoprop/detail/mpi/CMakeLists.txt +++ b/cpp/monoprop/detail/mpi/CMakeLists.txt @@ -17,4 +17,4 @@ target_sources( "ShmComm.h" ) -target_sources(monoprop-objs PRIVATE MPICompat.cpp) +monoprop_add_core_sources(MPICompat.cpp) diff --git a/cpp/monoprop/detail/pare/CMakeLists.txt b/cpp/monoprop/detail/pare/CMakeLists.txt index a32339099..167c11f56 100644 --- a/cpp/monoprop/detail/pare/CMakeLists.txt +++ b/cpp/monoprop/detail/pare/CMakeLists.txt @@ -7,4 +7,4 @@ target_sources( "PareGraph.h" ) -target_sources(monoprop-objs PRIVATE PareGraph.cpp) +monoprop_add_core_sources(PareGraph.cpp) diff --git a/cpp/monoprop/detail/partition/CMakeLists.txt b/cpp/monoprop/detail/partition/CMakeLists.txt index 19d4b69bc..9a29fcb1d 100644 --- a/cpp/monoprop/detail/partition/CMakeLists.txt +++ b/cpp/monoprop/detail/partition/CMakeLists.txt @@ -8,4 +8,4 @@ target_sources( "PartitionGroup.h" ) -target_sources(monoprop-objs PRIVATE CpuTopology.cpp) +monoprop_add_core_sources(CpuTopology.cpp) diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 50d224e7c..75ec9ecc9 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -44,7 +44,7 @@ target_link_libraries( monoprop_unit_tests.x PRIVATE monoprop-sanitizers - monoprop-objs + ${monoprop_CPP_TEST_OBJS} Boost::unit_test_framework msgpack-cxx PkgConfig::HWLOC @@ -85,3 +85,16 @@ discover_tests( ) add_subdirectory(link_export_probe) + +add_test( + NAME monoprop_mpi_inspection + COMMAND + ${CMAKE_COMMAND} -P + ${CMAKE_CURRENT_LIST_DIR}/cmake/test_mpi_inspection.cmake +) +set_tests_properties( + monoprop_mpi_inspection + PROPERTIES + LABELS + "unit" +) diff --git a/cpp/tests/cmake/inspect_mpi/CMakeLists.txt b/cpp/tests/cmake/inspect_mpi/CMakeLists.txt new file mode 100644 index 000000000..a2ca16979 --- /dev/null +++ b/cpp/tests/cmake/inspect_mpi/CMakeLists.txt @@ -0,0 +1,21 @@ +# Inspect an installed MPI the way configure does: +# cmake --fresh -S cpp/tests/cmake/inspect_mpi -B build/inspect-mpi \ +# -DMPI_INCLUDE_DIR= -DEXPECTED_ABI= +# Fails unless the mpi.h there has ABI family EXPECTED_ABI and meets monoprop's version floor. +# A project rather than a script: the inspection compiles a probe with try_compile. + +cmake_minimum_required(VERSION 3.28...4.0) +project(monoprop_inspect_mpi LANGUAGES CXX) + +include(${CMAKE_CURRENT_LIST_DIR}/../../../../cmake/custom/monopropMPIABI.cmake) + +monoprop_mpi_macros(_macros INCLUDE_DIRS "${MPI_INCLUDE_DIR}") +monoprop_mpi_vendor_from_macros(_name _version "${_macros}") +monoprop_inspect_mpi(_abi _problem INCLUDE_DIRS "${MPI_INCLUDE_DIR}") +message(STATUS "${MPI_INCLUDE_DIR}: ABI family ${_abi}, ${_name} ${_version}") +if(NOT _abi STREQUAL EXPECTED_ABI) + message(FATAL_ERROR "expected the ${EXPECTED_ABI} ABI family, found ${_abi}") +endif() +if(_problem) + message(FATAL_ERROR "${_problem}") +endif() diff --git a/cpp/tests/cmake/test_mpi_inspection.cmake b/cpp/tests/cmake/test_mpi_inspection.cmake new file mode 100644 index 000000000..e3b1666fb --- /dev/null +++ b/cpp/tests/cmake/test_mpi_inspection.cmake @@ -0,0 +1,68 @@ +# Script-mode test of the MPI inspection helpers: cmake -P cpp/tests/cmake/test_mpi_inspection.cmake + +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/custom/monopropMPIABI.cmake) + +function(expect_abi macros expected) + monoprop_mpi_abi_from_macros(_abi "${macros}") + if(NOT _abi STREQUAL expected) + message( + FATAL_ERROR + "ABI of [${macros}]: got '${_abi}', expected '${expected}'" + ) + endif() +endfunction() + +function(expect_vendor macros name version) + monoprop_mpi_vendor_from_macros(_name _version "${macros}") + if(NOT _name STREQUAL name OR NOT _version STREQUAL version) + message( + FATAL_ERROR + "vendor of [${macros}]: got '${_name}' '${_version}', expected '${name}' '${version}'" + ) + endif() +endfunction() + +function(expect_problem name version expected) + monoprop_mpi_version_problem(_problem "${name}" "${version}") + if(NOT _problem STREQUAL expected) + message( + FATAL_ERROR + "${name} ${version}: got '${_problem}', expected '${expected}'" + ) + endif() +endfunction() + +expect_abi("#define MPI_ABI_VERSION 1\n#define MPICH_VERSION \"5.0.2\"\n" mpiabi +) +expect_abi("#define MPI_ABI_VERSION 0\n#define MPICH_VERSION \"5.0.2\"\n" mpich) +expect_abi("#define OPEN_MPI 1\n#define OMPI_MAJOR_VERSION 5\n" openmpi) +expect_abi("#define MPICH_VERSION \"4.2.0\"\n" mpich) + +expect_vendor( + "#define OPEN_MPI 1\n#define OMPI_MAJOR_VERSION 4\n#define OMPI_MINOR_VERSION 0\n#define OMPI_RELEASE_VERSION 7\n" + "Open MPI" "4.0.7" +) +expect_vendor("#define MPICH_VERSION \"4.0\"\n#define MPICH_NUMVERSION 40000300\n" "MPICH" "4.0" +) +# derivatives also define MPICH_VERSION (their MPICH base), so they must win over it +expect_vendor("#define MPICH_VERSION \"3.4a2\"\n#define CRAY_MPICH_VERSION 8.1.29\n" "Cray MPICH" "8.1.29" +) +expect_vendor("#define MPICH_VERSION \"4.1.2\"\n#define MVAPICH_VERSION \"3.0\"\n" "MVAPICH" "3.0" +) +expect_vendor("#define MVAPICH2_VERSION \"2.3.7\"\n" "MVAPICH" "2.3.7") +expect_vendor("#define MPICH_VERSION \"3.4\"\n#define I_MPI_NUMVERSION 20211900300\n" "Intel MPI" "2021.19.0" +) +expect_vendor("#define MPI_VERSION 3\n" "unknown" "") + +expect_problem("Open MPI" "4.0.7" "monoprop needs Open MPI >= 4.1, found 4.0.7") +expect_problem("Open MPI" "4.1.0" "") +expect_problem("MPICH" "4.0" "") +expect_problem("MPICH" "3.4.3" "monoprop needs MPICH >= 4.0, found 3.4.3") +expect_problem("Intel MPI" "2021.1.1" "") +expect_problem("Intel MPI" "2019.12.0" "monoprop needs Intel MPI >= 2021.0, found 2019.12.0") +expect_problem("Cray MPICH" "8.1.29" "") +expect_problem("MVAPICH" "2.3.7" "monoprop needs MVAPICH >= 3.0, found 2.3.7") +# an unrecognised implementation only warns +expect_problem("unknown" "" "") + +message(STATUS "MPI inspection: all checks passed") diff --git a/cpp/tests/find_package_smoke/CMakeLists.txt b/cpp/tests/find_package_smoke/CMakeLists.txt index 3cfa5e754..77ccd993d 100644 --- a/cpp/tests/find_package_smoke/CMakeLists.txt +++ b/cpp/tests/find_package_smoke/CMakeLists.txt @@ -3,7 +3,19 @@ project(monoprop_find_package_smoke LANGUAGES CXX) set(CMAKE_CXX_EXTENSIONS OFF) -find_package(monoprop CONFIG REQUIRED) +option( + monoprop_SMOKE_MPI + "Consume the MPI component (monoprop::monoprop_mpi)" + OFF +) + +if(monoprop_SMOKE_MPI) + find_package(monoprop CONFIG REQUIRED COMPONENTS MPI) + set(_monoprop_target monoprop::monoprop_mpi) +else() + find_package(monoprop CONFIG REQUIRED) + set(_monoprop_target monoprop::monoprop) +endif() add_executable(smoke ../link_export_probe/link_export_probe.cpp) -target_link_libraries(smoke PRIVATE monoprop::monoprop) +target_link_libraries(smoke PRIVATE ${_monoprop_target}) diff --git a/cpp/tests/link_export_probe/CMakeLists.txt b/cpp/tests/link_export_probe/CMakeLists.txt index fab629bed..51c217315 100644 --- a/cpp/tests/link_export_probe/CMakeLists.txt +++ b/cpp/tests/link_export_probe/CMakeLists.txt @@ -31,3 +31,27 @@ set_tests_properties( LABELS "unit" ) + +# The same probe against every MPI-enabled libmonoprop, in singleton MPI (no mpiexec). +foreach(_abi IN LISTS monoprop_MPI_ABI_NAMES) + set(_probe monoprop_mpi_${_abi}_link_export_probe) + add_executable(${_probe}.x link_export_probe.cpp) + target_link_libraries( + ${_probe}.x + PRIVATE + monoprop-sanitizers + monoprop_mpi_${_abi} + ) + if(NOT APPLE) + target_link_options(${_probe}.x PRIVATE "-Wl,--no-undefined") + endif() + add_test(NAME ${_probe} COMMAND ${_probe}.x) + set_tests_properties( + ${_probe} + PROPERTIES + LABELS + "unit" + ENVIRONMENT + "${_monoprop_serial_env_entries}" + ) +endforeach() diff --git a/docs/content/docs/building.mdx b/docs/content/docs/building.mdx index 810524572..b001a0983 100644 --- a/docs/content/docs/building.mdx +++ b/docs/content/docs/building.mdx @@ -16,8 +16,9 @@ mechanism differs by build: | --- | --- | | Python bindings and C++ tests (scikit-build / `uv` / `pip`) | `monoprop_ENABLE_MPI=ON` in the environment, or the `monoprop.mpi=ON` config-setting | -The prebuilt wheels published to PyPI (`pip install monoprop`) are also built -without MPI, so a from-source build is required for multi-rank runs. +The prebuilt wheels published to PyPI (`pip install monoprop`) already include an MPI +backend for the MPICH family and Open MPI, so a from-source build is only needed for another MPI +implementation that offers a different ABI or for the C++ library; see [Parallelism](/features/parallelism#enabling-mpi). ## Prerequisites @@ -135,6 +136,7 @@ as a PEP 517 config-setting or through an environment variable: | `monoprop.cxx-unit-tests` | `monoprop_ENABLE_CXX_UNIT_TESTS` | bool (default on) | build the C++ unit test suite | | `monoprop.arch-flags` | `monoprop_ENABLE_ARCH_FLAGS` | bool (default on, forced off for Debug and sanitizer builds) | architecture-specific compiler flags | | `monoprop.mpi-test-procs` | `monoprop_MPI_TEST_PROCS` | `;`-separated rank counts (default `2;4`) | rank counts the C++ MPI tests are registered for | +| `monoprop.mpi-abis` | `monoprop_MPI_ABIS` | `;`-separated `=` | build one MPI variant per entry from an MPI installed under `` instead of the MPI CMake finds (release wheels; needs `monoprop.mpi`) | Booleans accept `ON`/`OFF`, `true`/`false`, `1`/`0` and similar. These two commands are equivalent: @@ -197,6 +199,12 @@ option, and, through the `[[tool.scikit-build.overrides]]` block in `pyproject.toml`, it adds `mpi4py` to the build requirements and moves the build to its own tree, `build/editable/Release-mpi`. + + An MPI build compiles the serial `_core`, and `_core_mpi.` for the MPI ABI family of the + MPI CMake finds (`openmpi` or `mpich`; see [Parallelism and distribution](/features/parallelism)). + Expect about twice the build time of a serial build. + + mpi4py is a build-time input — the bindings need its headers to hand an MPI communicator across the nanobind boundary — but only for MPI builds, so it is injected per-build through PEP 517's `get_requires_for_build_wheel` rather than @@ -212,6 +220,21 @@ Setting the CMake option directly (`cmake.define.monoprop_ENABLE_MPI=ON` or and the build fails at configure time with a message telling you as much. Use `monoprop.mpi` instead. +### Release wheels + +The published wheels contain one MPI variant per ABI family, built against MPI installations +under fixed prefixes rather than the MPI CMake would find. To reproduce one locally, install +`mpich` and `openmpi` from PyPI with `pip install --target`, then: + +```bash +CIBUILDWHEEL=1 monoprop_ENABLE_MPI=ON \ + monoprop_MPI_ABIS="mpich=$PWD/mpi/mpich;openmpi=$PWD/mpi/openmpi" \ + uv build --wheel +``` + +`CIBUILDWHEEL=1` is what cibuildwheel exports; it keeps the build machine's library directories +out of the RPATHs. + ### Verify the install ```bash @@ -227,7 +250,7 @@ uv run python your_script.py ``` For a multi-rank run, launch the same script under `mpiexec` (requires an MPI -build) and pass `comm=MPI.COMM_WORLD` to the simulator: +build) and pass `comm=MPI.COMM_WORLD` to the simulator. Without `comm`, every rank runs its own serial propagation: ```bash mpiexec -n 8 uv run python your_script.py @@ -254,16 +277,31 @@ ctest --test-dir build/editable/Release This uses the scikit-build-core Release tree at `build/editable/Release` and runs `bin/monoprop_unit_tests.x` there, along with `bin/monoprop_link_export_probe.x` — a link-time check that the installed shared `monoprop` library exports every `detail/**` free -function reachable from the public template chain. The installed CMake target records whether -monoprop itself was built with MPI; a serial package does not acquire MPI compile definitions or -link dependencies merely because the consuming project already has an `MPI::MPI_CXX` target. +function reachable from the public template chain. In an MPI build the same probe also runs +against every `monoprop_mpi_` library. + +`monoprop::monoprop` is always serial: a serial target never acquires MPI compile definitions or +link dependencies, even if the consuming project has an `MPI::MPI_CXX` target. MPI consumers +request the `MPI` component, which picks the libmonoprop variant for the MPI ABI family of the MPI +CMake finds for the consuming project: + +```cmake +find_package(monoprop CONFIG REQUIRED COMPONENTS MPI) +target_link_libraries(my_target PRIVATE monoprop::monoprop_mpi) +``` + +Configuration fails, naming both, when the installation was built without that family; +`monoprop_MPI_ABIS` lists the families it ships. Projects that linked `monoprop::monoprop` from an +MPI build before must switch to the `MPI` component. `just test-find-package` goes one step further out: it builds `cpp/tests/find_package_smoke` — a standalone project that consumes the *installed* package through `find_package(monoprop CONFIG)` — and runs it. That is the only leg that exercises `monopropConfig.cmake`, so it is what catches a missing `find_dependency` or a usage requirement the package fails to export. It compiles the same source as the in-tree -probe, so the two cannot drift. +probe, so the two cannot drift. With an MPI installation it also builds the +consumer through the `MPI` component, runs it under `mpiexec -n 2`, and checks that a package +lacking the consumer's MPI ABI fails at configure time. ### Debug tree diff --git a/docs/content/docs/features/parallelism.mdx b/docs/content/docs/features/parallelism.mdx index a56727ca9..836a066ff 100644 --- a/docs/content/docs/features/parallelism.mdx +++ b/docs/content/docs/features/parallelism.mdx @@ -107,6 +107,21 @@ over XOR, so a gate's queries still reach several ranks rather than one. Both variables, and the partition count, must match on every rank. A mismatch would deadlock the exchange, so it is checked collectively at startup and raised. +### Serial or MPI backend + +monoprop picks the backend per propagator. `comm=None` runs serially, whatever the installation; +an mpi4py communicator runs on the MPI backend, which monoprop loads the first time it is needed. +Importing monoprop never imports mpi4py or initializes MPI. Under `mpiexec`, a propagator built +without `comm` therefore runs serially on every rank: pass `comm=MPI.COMM_WORLD` to distribute it. + +[MPI_ABIS][monoprop.MPI_ABIS] lists the MPI ABI families the installation can drive; it is empty +without MPI support. The MPI backend matches the family of the MPI library mpi4py uses. Passing a +communicator to an installation without MPI support raises +[MPIUnavailableError][monoprop.exceptions.MPIUnavailableError]; a family the installation was not +built for raises [MPIAbiMismatchError][monoprop.exceptions.MPIAbiMismatchError], and an MPI +library older than the supported versions (see [Enabling MPI](#enabling-mpi)) raises +[MPIVersionError][monoprop.exceptions.MPIVersionError]. + ### Single-node (`MPI.COMM_SELF`) ```python notest @@ -133,6 +148,44 @@ one partition per rank. ### Enabling MPI -MPI is **off by default**, so the prebuilt PyPI wheels run single-rank and the -communicators above only distribute work after a from-source build with MPI -enabled. See [Building from source](/building) for the full build instructions. +The PyPI wheels ship an MPI backend for the ABIs of the MPICH family (MPICH, Intel MPI, MVAPICH, +Cray MPICH) and of Open MPI, and use the MPI library mpi4py finds on the machine. Install mpi4py +with the `mpi` extra: + +```bash +pip install "monoprop[mpi]" +``` + +Use the MPI your system provides: a module on a cluster (`module load ...`), your distribution's +package manager, or your own build. mpi4py's +[installation guide](https://mpi4py.readthedocs.io/en/stable/install.html) covers the details for +each platform, such as setting `LD_LIBRARY_PATH` so the MPI library is found at run time. On a +machine without MPI, for a first try, mpi4py's MPICH and Open MPI wheels (`mpich`, `openmpi`) and +Intel's `impi-rt` install from PyPI; mpi4py recommends a system-provided or custom-built MPI for +production runs, because these wheels may lack features such as GPU awareness. + +On Ubuntu 22.04 and older, the MPICH package names its library `libmpich.so.12` instead of the +`libmpi.so.12` that the MPICH ABI requires; mpi4py's +[guide](https://mpi4py.readthedocs.io/en/stable/install.html#debian-ubuntu) gives the symbolic link +that fixes it. + +Supported MPI versions, from the version below up to the latest release: + +| Implementation | ABI family | Minimum | +| --- | --- | --- | +| Open MPI | `openmpi` | 4.1 | +| MPICH | `mpich` | 4.0 | +| Intel MPI | `mpich` | 2021.0 | +| MVAPICH | `mpich` | 3.0 | +| Cray MPICH | `mpich` | 8.1 | + +An older version is rejected when the MPI backend loads +([MPIVersionError][monoprop.exceptions.MPIVersionError]) and when CMake configures a from-source +build or a C++ consumer; an implementation not in the table runs with a warning. Open MPI 4.1, +MPICH 4.0 and Intel MPI 2021.1 are tested in CI at those versions; MVAPICH and Cray MPICH are not. + +On HPE Cray systems, load the `cray-mpich-abi` module, as mpi4py's +[installation guide](https://mpi4py.readthedocs.io/en/stable/install.html#hpe-cray-os) requires; +see HPE's [`intro_mpi`](https://cpe.ext.hpe.com/docs/latest/mpt/mpich/intro_mpi.html#using-mpich-abi-compatibility) +for details. +From-source builds enable MPI with the `monoprop.mpi` option; see [Building from source](/building). diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/getting-started.mdx index e084c984b..c62dc3edc 100644 --- a/docs/content/docs/getting-started.mdx +++ b/docs/content/docs/getting-started.mdx @@ -20,9 +20,10 @@ If you use [uv](https://docs.astral.sh/uv/) as package manager: uv add monoprop ``` -These prebuilt wheels run on a single process — they are published *without* -MPI. For a multi-rank / MPI-enabled build, or to build the C++ library and -executables, build from source as described in [Building from source](/building). +These prebuilt wheels include an MPI backend for the ABIs of the MPICH family and Open MPI. To run across +ranks, install mpi4py with the `mpi` extra and pass a communicator to a propagator, as described in +[Parallelism](/features/parallelism#enabling-mpi). To build the C++ library and executables, +build from source as described in [Building from source](/building). ## Introduction diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index b2fda6ca9..fbd157865 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -56,10 +56,32 @@ mpiexec --allow-run-as-root -n 2 uv run --no-sync python -m pytest tests --with- `monoprop_ENABLE_MPI` (or the `monoprop.mpi` config-setting) also provisions mpi4py as a build input; see [Building from source](/building#with-mpi) for why. Installing the `mpi` extra alone only provides -mpi4py and does not enable C++ MPI support. Accordingly, the CI matrix has +mpi4py and does not enable C++ MPI support. Tests marked `mpi` are skipped whenever `monoprop.MPI_ABIS` +is empty, and the `comm`/`serial_comm` fixtures fall back to `None` there. Accordingly, the CI matrix has explicit cache-free MPI build lanes on Linux x86-64, Linux ARM64, and macOS; only those lanes run the suite under `mpiexec`. +### Testing release wheels + +Release wheels carry one MPI backend per ABI family, so CI tests them against real MPI libraries +rather than the editable build. To do the same locally, point `just test-wheel` at a built wheel +(or a directory of them); it installs the wheel and mpi4py into a fresh venv, checks that the MPI +backend loads, and runs the MPI-marked tests under `mpiexec` and then `tests/test_mpi_backend.py`: + +```bash +just test-wheel dist/ wheel # mpi4py binary wheel against the MPI on this machine +just test-wheel dist/ source # mpi4py built against the local mpicc +just test-wheel dist/ wheel impi-rt # extra requirements, e.g. Intel MPI from PyPI +``` + +`just inspect-mpi ` runs the configure-time MPI inspection on an installed MPI +and checks that it reports the expected ABI family (`mpich`, `openmpi` or `mpiabi`) and meets the +version floor. + +The CI setup action (`.github/actions/setup`) takes two inputs for these jobs: `build-deps: "false"` +skips the build dependencies when a job only tests a built wheel, and `mpi: mpich` installs MPICH +instead of Open MPI (`mpi: "on"`). + ### C++ unit tests The C++ tests run through CTest. The build tree is produced by `uv sync` (via diff --git a/docs/scripts/generate-api.mjs b/docs/scripts/generate-api.mjs index 4717d1029..40abe221f 100644 --- a/docs/scripts/generate-api.mjs +++ b/docs/scripts/generate-api.mjs @@ -176,7 +176,8 @@ async function main() { pkg.modules = Object.fromEntries( Object.entries(pkg.modules ?? {}).filter(([name]) => KEEP.has(name)), ); - pkg.attributes = []; // drop the package-level __all__ dump + // Keep the documented package-level attributes (e.g. `MPI_ABIS`), drop the `__all__` dump. + pkg.attributes = (pkg.attributes ?? []).filter((attr) => !attr.name.startsWith('__')); pruneModule(pkg); normalizeFunctionReturns(pkg); diff --git a/justfile b/justfile index 8697793ec..c89b04a74 100644 --- a/justfile +++ b/justfile @@ -65,7 +65,7 @@ diff-baseline AGAINST='golden': # Report what the installed extension was actually built as. info: - uv run --no-sync python -c 'import pprint, monoprop as mp; pprint.pprint({"version": mp.__version__, "variant": mp.__variant__, "compiler_flags": mp.__compiler_flags__, "MPI": mp.has_mpi})' + uv run --no-sync python -c 'import pprint, monoprop as mp; pprint.pprint({"version": mp.__version__, "variant": mp.__variant__, "compiler_flags": mp.__compiler_flags__, "MPI ABIs": mp.MPI_ABIS})' test: build test-py test-cpp @@ -112,17 +112,59 @@ test-mpi RANKS='': && (test-py-mpi RANKS) (test-cpp '' mpi_build_dir) test-cpp-m ranks="${requested_ranks:-${monoprop_MPI_TEST_PROCS:-2}}"; \ monoprop_ENABLE_MPI=ON monoprop_MPI_TEST_PROCS="$ranks" {{ uv_sync }} --group workspace-test -# Build and run a consumer project against the installed package. +# Build and run a consumer project against the installed package: serially, then, when the +# installation ships MPI ABIs, through the MPI component under mpiexec, and finally against a +# package that lacks the consumer's MPI ABI, which must fail at configure time. -test-find-package BUILD_DIR='build/find-package-smoke': +test-find-package BUILD_DIR='build/find-package-smoke' PYTHON='': #!/usr/bin/env bash set -euo pipefail build_dir={{ quote(BUILD_DIR) }} - site_packages="$(uv run --no-sync python -c 'import sysconfig; print(sysconfig.get_path("purelib"))')" + python=({{ if PYTHON == '' { 'uv run --no-sync python' } else { quote(PYTHON) } }}) + site_packages="$("${python[@]}" -c 'import sysconfig; print(sysconfig.get_path("purelib"))')" cmake -S cpp/tests/find_package_smoke -B "$build_dir" \ -Dmonoprop_DIR="$site_packages/monoprop/cmake" cmake --build "$build_dir" "$build_dir/smoke" + abis="$("${python[@]}" -c 'import monoprop; print(" ".join(monoprop.MPI_ABIS))')" + if [[ -z "$abis" ]]; then + exit 0 + fi + cmake -S cpp/tests/find_package_smoke -B "$build_dir-mpi" \ + -Dmonoprop_DIR="$site_packages/monoprop/cmake" -Dmonoprop_SMOKE_MPI=ON + cmake --build "$build_dir-mpi" + mpiexec -n 2 "$build_dir-mpi/smoke" + # The targets file locates the libraries relative to the config directory, so the altered + # copy sits in a mirror of the installation tree whose other entries link to the real ones. + mismatch="$(mktemp -d)" + mkdir "$mismatch/monoprop" + for entry in "$site_packages/monoprop"/*; do + if [[ "$(basename "$entry")" != cmake ]]; then + ln -s "$entry" "$mismatch/monoprop/" + fi + done + cp -R "$site_packages/monoprop/cmake" "$mismatch/monoprop/cmake" + sed -i.orig 's/^set(monoprop_MPI_ABIS .*/set(monoprop_MPI_ABIS "none")/' "$mismatch/monoprop/cmake/monopropConfig.cmake" + if cmake -S cpp/tests/find_package_smoke -B "$mismatch/build" \ + -Dmonoprop_DIR="$mismatch/monoprop/cmake" -Dmonoprop_SMOKE_MPI=ON > "$mismatch/log" 2>&1; then + echo "a package without the consumer's MPI ABI was accepted" >&2 + exit 1 + fi + grep -q "supports: none" "$mismatch/log" + +# Test built wheels (a file or a directory) against the MPI already on this machine. MPI4PY is +# "wheel" (the PyPI binary; its file name names the ABI) or "source" (built against the local +# mpicc; the ABI is read from the library). PACKAGES are extra requirements, e.g. impi-rt. +# MONOPROP_TEST_VENV keeps the venv at a known path for a follow-up `just test-find-package`. + +test-wheel WHEELS MPI4PY='wheel' *PACKAGES: + bash tools/cibw/test-wheel-mpi.sh "{{ project_source_dir }}" {{ quote(WHEELS) }} {{ quote(MPI4PY) }} {{ PACKAGES }} + +# Run configure's MPI inspection on an installed MPI: ABI family and version floor. + +inspect-mpi INCLUDE_DIR ABI: + cmake --fresh -S cpp/tests/cmake/inspect_mpi -B build/inspect-mpi \ + -DMPI_INCLUDE_DIR={{ quote(INCLUDE_DIR) }} -DEXPECTED_ABI={{ quote(ABI) }} # The sanitizer legs run against a tree built with monoprop_SANITIZER=asan-ubsan (or tsan) # exported, which also points build_dir at it. @@ -382,7 +424,7 @@ bench LABEL *ARGS: # Run under MPI: RANKS ranks recorded as one LABEL column. bench-mpi LABEL RANKS *MPIARGS: - uv run --no-sync python -c "import monoprop, sys; sys.exit(0 if monoprop.has_mpi else 'monoprop was built without MPI; run just bench-build-mpi first')" + uv run --no-sync python -c "import monoprop, sys; sys.exit(0 if monoprop.MPI_ABIS else 'monoprop was built without MPI; run just bench-build-mpi first')" @mkdir -p "{{ bench_results }}" label="$1"; ranks="$2"; shift 2; \ monoprop_BENCH_LABEL="$label" monoprop_BENCH_RESULTS="{{ bench_results }}" \ diff --git a/nix/devshell.nix b/nix/devshell.nix index 22ec63470..65db88971 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -57,11 +57,13 @@ mkShell { export UV_PYTHON="${python}/bin/python" # manylinux wheels (numpy, qiskit, ...) resolve libstdc++ through the ambient - # loader path, which is empty on NixOS. + # loader path, which is empty on NixOS. The mpi4py wheel finds libmpi the same + # way, so Open MPI goes on it too. export LD_LIBRARY_PATH="${ lib.makeLibraryPath [ stdenv.cc.cc.lib zlib + openmpi ] }''${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" ''; diff --git a/packages/monoprop-bench-tools/tests/conftest.py b/packages/monoprop-bench-tools/tests/conftest.py index 24d3d43f7..bf3e4f15d 100644 --- a/packages/monoprop-bench-tools/tests/conftest.py +++ b/packages/monoprop-bench-tools/tests/conftest.py @@ -20,6 +20,8 @@ import pytest +import monoprop + try: from mpi4py import MPI except ImportError: @@ -28,5 +30,8 @@ @pytest.fixture def serial_comm() -> Any: - """Single-rank communicator for builders whose state is rank-local.""" - return None if MPI is None else MPI.COMM_SELF + """Single-rank communicator for builders whose state is rank-local. + + ``None`` unless mpi4py imports and this monoprop build has an MPI backend. + """ + return None if MPI is None or not monoprop.MPI_ABIS else MPI.COMM_SELF diff --git a/prek.toml b/prek.toml index b37626109..d449aefb4 100644 --- a/prek.toml +++ b/prek.toml @@ -30,7 +30,7 @@ hooks = [ { id = "debug-statements" }, { id = "name-tests-test", args = [ "--pytest-test-first", - ], exclude = "^tests/(cases|reference_data)\\.py$|.*\\.json$" }, + ], exclude = "^tests/(cases|reference_data|mpi_support)\\.py$|.*\\.json$" }, { id = "forbid-submodules" }, ] diff --git a/pyproject.toml b/pyproject.toml index fe73bde1a..0d8364a59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ interactive = [ # members: cibuildwheel installs this group to exercise a *built wheel*, and a # workspace member would drag monoprop back in from source. test = [ + "nanobind==3.1.0", "psutil>=7.2", "pytest-cov>=7.1.0", "pytest-datadir", @@ -107,6 +108,7 @@ cache-keys = [ { env = "monoprop_ENABLE_CXX_UNIT_TESTS" }, { env = "monoprop_ENABLE_ARCH_FLAGS" }, { env = "monoprop_MPI_TEST_PROCS" }, + { env = "monoprop_MPI_ABIS" }, { env = "SKBUILD_BUILD_DIR" }, { env = "SKBUILD_CMAKE_ARGS" }, { env = "SKBUILD_CMAKE_BUILD_TYPE" }, @@ -201,12 +203,17 @@ help = "Semicolon-separated MPI rank counts the C++ MPI tests run with" default = "2;4" env = "monoprop_MPI_TEST_PROCS" +[tool.scikit-build.config-setting."monoprop.mpi-abis"] +help = "MPI ABI variants to build from install prefixes, as ';'-separated = entries with one of mpich, openmpi, mpiabi (wheel builds; needs monoprop.mpi)" +env = "monoprop_MPI_ABIS" + [tool.scikit-build.cmake.define] monoprop_ENABLE_MPI = { config-setting = "monoprop.mpi" } monoprop_SANITIZER = { config-setting = "monoprop.sanitizer" } monoprop_ENABLE_CXX_UNIT_TESTS = { config-setting = "monoprop.cxx-unit-tests" } monoprop_ENABLE_ARCH_FLAGS = { config-setting = "monoprop.arch-flags" } monoprop_MPI_TEST_PROCS = { config-setting = "monoprop.mpi-test-procs" } +monoprop_MPI_ABIS = { config-setting = "monoprop.mpi-abis" } # mpi4py is a build input only for MPI builds, so it is requested per build # rather than listed in build-system.requires. MPI builds get their own tree so @@ -457,14 +464,26 @@ build = "cp3??-*" build-frontend = "uv" build-verbosity = 1 skip = ["*-musllinux_*"] -test-command = "python -m pytest {package}/tests" +repair-wheel-command = "bash {project}/tools/cibw/repair-wheel.sh {dest_dir} {wheel}" +test-command = "bash {project}/tools/cibw/run-tests.sh {project} {wheel}" test-groups = ["test"] test-extras = ["qiskit"] +# One MPI variant per ABI family, built against the PyPI MPI packages that install-mpi.sh places +# under these prefixes; repair-wheel.sh reads the same variable. [tool.cibuildwheel.linux] -before-all = ["./tools/install-deps.sh --skip-boost-test --skip-msgpack"] -environment = { SKBUILD_CMAKE_ARGS = "-Dmonoprop_ENABLE_ARCH_FLAGS=OFF;-Dmonoprop_ENABLE_CXX_UNIT_TESTS=OFF;-Dmonoprop_ENABLE_MPI=OFF" } +before-all = [ + "./tools/install-deps.sh --skip-boost-test --skip-msgpack", + # Install-time stub generation imports _core without build RPATHs, so hwloc in /usr/local/lib + # must be in the loader cache. + "ldconfig", + "bash tools/cibw/install-mpi.sh /opt/mpi", +] +environment = { monoprop_ENABLE_MPI = "ON", monoprop_MPI_ABIS = "mpich=/opt/mpi/mpich;openmpi=/opt/mpi/openmpi", monoprop_ENABLE_ARCH_FLAGS = "OFF", monoprop_ENABLE_CXX_UNIT_TESTS = "OFF" } [tool.cibuildwheel.macos] -before-all = "brew install boost hwloc" -environment = { MACOSX_DEPLOYMENT_TARGET = "15.0", SKBUILD_CMAKE_ARGS = "-Dmonoprop_ENABLE_ARCH_FLAGS=OFF;-Dmonoprop_ENABLE_CXX_UNIT_TESTS=OFF;-Dmonoprop_ENABLE_MPI=OFF" } +before-all = [ + "brew install boost hwloc", + "bash tools/cibw/install-mpi.sh /tmp/monoprop-cibw-mpi", +] +environment = { MACOSX_DEPLOYMENT_TARGET = "15.0", monoprop_ENABLE_MPI = "ON", monoprop_MPI_ABIS = "mpich=/tmp/monoprop-cibw-mpi/mpich;openmpi=/tmp/monoprop-cibw-mpi/openmpi", monoprop_ENABLE_ARCH_FLAGS = "OFF", monoprop_ENABLE_CXX_UNIT_TESTS = "OFF" } diff --git a/src/monoprop/__init__.py b/src/monoprop/__init__.py index b36c791f9..d221612cd 100644 --- a/src/monoprop/__init__.py +++ b/src/monoprop/__init__.py @@ -18,6 +18,7 @@ import importlib.util +from . import _backend from ._core import ( MAX_NUM_MODES, __build_type__, @@ -25,7 +26,6 @@ __nanobind_version__, __variant__, antihermitian_generator_correction, - has_mpi, is_antihermitian, ) from ._version import version as __version__ @@ -44,8 +44,18 @@ from .pauli_propagator import PauliPropagator from .utils import jordan_wigner_basis_change +MPI_ABIS: tuple[str, ...] = _backend.MPI_ABIS +"""MPI ABI families this installation can drive, for example `("mpich", "openmpi")`. + +`"mpich"` covers MPICH, Intel MPI, MVAPICH and Cray MPICH; `"openmpi"` covers Open MPI 4 and 5; +`"mpiabi"` is the MPI-5 standard ABI. The tuple is empty for an installation built without MPI. It +states what was built, not that a usable MPI library is present: that depends on the MPI library +mpi4py loads at runtime. +""" + __all__ = [ "MAX_NUM_MODES", + "MPI_ABIS", "Circuit", "ExpGate", "FermiOperator", @@ -64,7 +74,6 @@ "__version__", "antihermitian_generator_correction", "expand_monomials", - "has_mpi", "integrals_to_fermion", "is_antihermitian", "jordan_wigner_basis_change", diff --git a/src/monoprop/_backend.py b/src/monoprop/_backend.py new file mode 100644 index 000000000..c37742818 --- /dev/null +++ b/src/monoprop/_backend.py @@ -0,0 +1,170 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Select the extension module that runs a propagator. + +Propagators without a communicator run on the serial ``monoprop._core``. Propagators given an +mpi4py communicator run on ``monoprop._core_mpi``, which is built once per MPI ABI family. The +first such propagator loads the build that matches the MPI library mpi4py uses, because that +library determines the type of the ``MPI_Comm`` handle mpi4py passes in. +""" + +from __future__ import annotations + +import importlib.util +import re +import sys +import warnings +from pathlib import Path +from typing import TYPE_CHECKING + +from monoprop import _core +from monoprop._mpi_abis import ABIS as _BUILT_MPI_ABIS +from monoprop._mpi_abis import MIN_VERSIONS as _MIN_VERSIONS +from monoprop._mpi_abis import MODULE_FILES as _MODULE_FILES +from monoprop.exceptions import ( + MPIAbiMismatchError, + MPIUnavailableError, + MPIVersionError, +) + +if TYPE_CHECKING: + from types import ModuleType + +# Re-exported as the documented `monoprop.MPI_ABIS`; read here (not there) so tests can monkeypatch it. +MPI_ABIS: tuple[str, ...] = _BUILT_MPI_ABIS + +_MPI_MODULE_NAME = "monoprop._core_mpi" +# mpi4py wheels ship one extension per ABI family, e.g. MPI.mpich.abi3.so +_MPI4PY_VARIANT = re.compile(r"^MPI\.(?Pmpich|openmpi|mpiabi)\.") + + +def core_module(comm: object | None) -> ModuleType: + """Return the extension module that serves ``comm``. + + Args: + comm: ``None`` for a serial propagator, otherwise an mpi4py communicator. + + Returns: + ``monoprop._core`` for ``None``, otherwise the MPI extension module. + + Raises: + MPIUnavailableError: this installation was built without MPI. + MPIAbiMismatchError: mpi4py uses an MPI ABI family this installation was not built for. + MPIVersionError: the MPI library mpi4py uses is older than monoprop supports. + """ + if comm is None: + return _core + loaded = sys.modules.get(_MPI_MODULE_NAME) + if loaded is not None: + return loaded + if not MPI_ABIS: + msg = ( + "this monoprop installation has no MPI support (monoprop.MPI_ABIS is empty); " + "pass comm=None or install an MPI-enabled monoprop" + ) + raise MPIUnavailableError(msg) + mpi = _mpi4py() + abi = detect_mpi4py_abi(mpi) + if abi not in MPI_ABIS: + msg = ( + f"mpi4py is using the {abi!r} MPI ABI, but this monoprop installation supports " + f"{', '.join(MPI_ABIS)}" + ) + raise MPIAbiMismatchError(msg) + check_mpi_version(*mpi.get_vendor()) + return _load_mpi_core(abi) + + +def check_mpi_version(vendor: str, version: tuple[int, ...]) -> None: + """Reject an MPI implementation older than monoprop supports. + + Args: + vendor: The implementation, named as ``mpi4py.MPI.get_vendor()`` names it. + version: Its version, as ``mpi4py.MPI.get_vendor()`` reports it. + + Raises: + MPIVersionError: ``version`` is below monoprop's floor for ``vendor``. An implementation + without a floor only emits a ``RuntimeWarning``. + """ + floor = _MIN_VERSIONS.get(vendor) + if floor is None: + supported = ", ".join( + f"{name} >= {_dotted(low)}" for name, low in _MIN_VERSIONS.items() + ) + msg = ( + f"monoprop has not been validated with the MPI implementation {vendor!r} " + f"{_dotted(version)}; it supports {supported}" + ) + warnings.warn(msg, RuntimeWarning, stacklevel=3) + return + if tuple(version) < floor: + msg = f"monoprop needs {vendor} >= {_dotted(floor)}, found {_dotted(version)}" + raise MPIVersionError(msg) + + +def _dotted(version: tuple[int, ...]) -> str: + return ".".join(map(str, version)) + + +def _mpi4py() -> ModuleType: + # only callers holding a communicator get here, so mpi4py is already loaded + from mpi4py import MPI # noqa: PLC0415 + + return MPI + + +def detect_mpi4py_abi(mpi: ModuleType) -> str: + """Return the MPI ABI family of the MPI library mpi4py uses. + + Args: + mpi: The ``mpi4py.MPI`` module. + + Returns: + ``"mpich"``, ``"openmpi"`` or ``"mpiabi"``. + """ + origin = getattr(getattr(mpi, "__spec__", None), "origin", None) + if origin: + match = _MPI4PY_VARIANT.match(Path(origin).name) + if match is not None: + return match["abi"] + # conda-forge and source builds of mpi4py carry no ABI in the file name: ask the library + try: + abi_major = mpi.Get_abi_version()[0] + except (NotImplementedError, mpi.Exception): + abi_major = 0 + if abi_major > 0: + return "mpiabi" + if mpi.Get_library_version().startswith("Open MPI"): + return "openmpi" + # MPICH, Intel MPI, MVAPICH and Cray MPICH share the MPICH ABI + return "mpich" + + +def _load_mpi_core(abi: str) -> ModuleType: + serial = Path(_core.__file__) + # e.g. ".abi3.so": the MPI modules come from the same build, hence the same suffix + path = serial.with_name(_MODULE_FILES[abi] + serial.name.removeprefix("_core")) + spec = importlib.util.spec_from_file_location(_MPI_MODULE_NAME, path) + if spec is None or spec.loader is None: + msg = f"cannot load the {abi!r} MPI backend from {path}" + raise ImportError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[_MPI_MODULE_NAME] = module + try: + spec.loader.exec_module(module) + except BaseException: + del sys.modules[_MPI_MODULE_NAME] + raise + return module diff --git a/src/monoprop/bindings/CMakeLists.txt b/src/monoprop/bindings/CMakeLists.txt index 257a343e6..f34d945f1 100644 --- a/src/monoprop/bindings/CMakeLists.txt +++ b/src/monoprop/bindings/CMakeLists.txt @@ -119,31 +119,112 @@ configure_file( @ONLY ) -nanobind_add_module(_core - NB_SUPPRESS_WARNINGS # suppress warnings from nanobind and Python headers - NOMINSIZE # don’t perform optimizations to minimize binary size - LTO # perform link-time optimization - BACKEND_MODULE nanobind_backend - ${CMAKE_CURRENT_BINARY_DIR}/bindings.cpp - ${_bind_cpps} # List of generated binders -) +if(APPLE) + set(_rpath "@loader_path/${CMAKE_INSTALL_LIBDIR}") +else() + set(_rpath "\$ORIGIN/${CMAKE_INSTALL_LIBDIR}") +endif() -target_include_directories( - _core - SYSTEM - PRIVATE - $,${mpi4py_INCLUDE_DIRS},> - PRIVATE - ${CMAKE_CURRENT_BINARY_DIR} - ${CMAKE_CURRENT_SOURCE_DIR} -) +#[=[ +monoprop_add_python_core( OBJS MODULE_NAME OUTPUT_NAME [MPI]) -target_link_libraries( - _core - PRIVATE - monoprop-objs - monoprop-sanitizers -) +One nanobind extension over a core variant. Every MPI module is named _core_mpi and lives in the +nanobind domain monoprop_mpi, so one of them can share a process with the serial _core. +#]=] +function(monoprop_add_python_core target) + cmake_parse_arguments( + PARSE_ARGV 1 + _arg + "MPI" + "OBJS;MODULE_NAME;OUTPUT_NAME" + "" + ) + if(_arg_MPI) + set(_bridge comm_mpi.cpp) + set( + _domain + NB_DOMAIN + monoprop_mpi + ) + else() + set(_bridge comm_serial.cpp) + set(_domain "") + endif() + + nanobind_add_module(${target} + NB_SUPPRESS_WARNINGS # suppress warnings from nanobind and Python headers + NOMINSIZE # don’t perform optimizations to minimize binary size + LTO # perform link-time optimization + BACKEND_MODULE nanobind_backend + ${_domain} + ${CMAKE_CURRENT_BINARY_DIR}/bindings.cpp + ${_bind_cpps} # List of generated binders + ${CMAKE_CURRENT_SOURCE_DIR}/${_bridge} + ) + target_compile_definitions( + ${target} + PRIVATE + monoprop_PY_MODULE_NAME=${_arg_MODULE_NAME} + ) + target_include_directories( + ${target} + PRIVATE + ${CMAKE_CURRENT_BINARY_DIR} + ${CMAKE_CURRENT_SOURCE_DIR} + ) + if(_arg_MPI) + target_include_directories(${target} SYSTEM PRIVATE ${mpi4py_INCLUDE_DIRS}) + endif() + target_link_libraries( + ${target} + PRIVATE + ${_arg_OBJS} + monoprop-sanitizers + ) + if(monoprop_WHEEL_BUILD) + set(_use_link_path OFF) + else() + set(_use_link_path ON) + endif() + set(_module_rpath "${_rpath}") + if(APPLE AND monoprop_WHEEL_BUILD AND _arg_MPI) + list(APPEND _module_rpath ${monoprop_MACOS_MPI_RPATHS}) + endif() + set_target_properties( + ${target} + PROPERTIES + OUTPUT_NAME + "${_arg_OUTPUT_NAME}" + MACOSX_RPATH + ON + SKIP_BUILD_RPATH + OFF + BUILD_WITH_INSTALL_RPATH + OFF + INSTALL_RPATH + "${_module_rpath}" + INSTALL_RPATH_USE_LINK_PATH + ${_use_link_path} + ) + install(TARGETS ${target} LIBRARY DESTINATION ${PROJECT_NAME}) +endfunction() + +monoprop_add_python_core(_core OBJS monoprop-objs MODULE_NAME _core OUTPUT_NAME _core) + +set(monoprop_MPI_MODULE_FILES_PY "") +foreach(_abi IN LISTS monoprop_MPI_ABI_NAMES) + monoprop_add_python_core( + _core_mpi_${_abi} + OBJS monoprop-objs-${_abi} + MODULE_NAME _core_mpi + OUTPUT_NAME _core_mpi.${_abi} + MPI + ) + string( + APPEND monoprop_MPI_MODULE_FILES_PY + "\"${_abi}\": \"_core_mpi.${_abi}\", " + ) +endforeach() if(monoprop_SANITIZER STREQUAL "asan-ubsan") # GCC bounds-strict misdiagnoses nanobind's valid access to a tuple's trailing allocation. @@ -168,28 +249,40 @@ COMMAND_ERROR_IS_FATAL ANY )" ) -if(APPLE) - set(_rpath "@loader_path/${CMAKE_INSTALL_LIBDIR}") -else() - set(_rpath "\$ORIGIN/${CMAKE_INSTALL_LIBDIR}") -endif() - -set_target_properties( - _core - PROPERTIES - MACOSX_RPATH - ON - SKIP_BUILD_RPATH - OFF - BUILD_WITH_INSTALL_RPATH - OFF - INSTALL_RPATH - "${_rpath}" - INSTALL_RPATH_USE_LINK_PATH - ON +# monoprop/_mpi_abis.py records the shipped MPI ABI families for monoprop._backend. +set(monoprop_MPI_ABIS_PY "") +foreach(_abi IN LISTS monoprop_MPI_ABI_NAMES) + string(APPEND monoprop_MPI_ABIS_PY "\"${_abi}\", ") +endforeach() +set(monoprop_MPI_MIN_VERSIONS_PY "") +monoprop_mpi_min_versions(_floors) +foreach(_entry IN LISTS _floors) + string( + REGEX MATCH "^(.+)=(.+)$" + _match + "${_entry}" + ) + string( + REPLACE "." + ", " + _floor + "${CMAKE_MATCH_2}" + ) + string( + APPEND monoprop_MPI_MIN_VERSIONS_PY + "\"${CMAKE_MATCH_1}\": (${_floor},), " + ) +endforeach() +configure_file( + ${PROJECT_SOURCE_DIR}/cmake/_mpi_abis.py.in + ${CMAKE_CURRENT_BINARY_DIR}/_mpi_abis.py + @ONLY +) +install( + FILES + ${CMAKE_CURRENT_BINARY_DIR}/_mpi_abis.py + DESTINATION ${PROJECT_NAME} ) - -install(TARGETS _core LIBRARY DESTINATION ${PROJECT_NAME}) # generation of Python typing stubs # NOTE must come after installing the _core target diff --git a/src/monoprop/bindings/binder.h b/src/monoprop/bindings/binder.h index cfc69d576..01254cc6c 100644 --- a/src/monoprop/bindings/binder.h +++ b/src/monoprop/bindings/binder.h @@ -41,9 +41,21 @@ namespace nb = nanobind; using namespace nanobind::literals; namespace monoprop::bindings::detail { -// mpi4py comm object -> MPI_Comm. +/** @brief Converts the Python `comm` argument to the handle this module's core takes. + * + * Defined in comm_serial.cpp or comm_mpi.cpp; every extension module compiles exactly one. + * The serial module accepts only None and MPI modules only an mpi4py communicator; both throw + * nb::type_error otherwise. + */ auto get_mpi_comm(nb::object obj) -> MPI_Comm; +/** @brief Prepares the communicator bridge while the extension module initialises. + * + * Imports mpi4py's C API in MPI modules and does nothing in the serial module. + * Throws nb::python_error when the import fails. + */ +auto init_comm_bridge() -> void; + auto cutoff_type_str_2_enum(const std::string &cutoff_type) -> CutoffType; auto cutoff_type_enum_2_str(CutoffType cutoff_type) -> std::string; diff --git a/src/monoprop/bindings/bindings.cpp.in b/src/monoprop/bindings/bindings.cpp.in index 9fd6ca24c..de8c0c245 100644 --- a/src/monoprop/bindings/bindings.cpp.in +++ b/src/monoprop/bindings/bindings.cpp.in @@ -15,9 +15,6 @@ #include "binder.h" #include -#ifdef monoprop_ENABLE_MPI -#include -#endif #include "generated/bind.h" #include "monoprop/Info.h" @@ -28,23 +25,6 @@ using namespace monoprop; using namespace nanobind::literals; namespace monoprop::bindings::detail { -auto get_mpi_comm(nb::object obj) -> MPI_Comm { -#ifdef monoprop_ENABLE_MPI - if (!obj || obj.is_none()) { - return MPI_COMM_WORLD; - } - auto *comm_ptr = PyMPIComm_Get(obj.ptr()); - - if (!comm_ptr) - throw nb::python_error(); - - return *comm_ptr; -#else - (void)obj; - return MPI_COMM_WORLD; // dummy when MPI disabled -#endif -} - auto cutoff_type_str_2_enum(const std::string &cutoff_type) -> CutoffType { if (cutoff_type == "length") { return monoprop::CutoffType::Length; @@ -95,17 +75,10 @@ auto basis_enum_2_str(Basis basis) -> std::string { } } // namespace monoprop::bindings::detail -NB_MODULE(_core, m) { +NB_MODULE(monoprop_PY_MODULE_NAME, m) { m.doc() = "Monomial Propagator"; -#ifdef monoprop_ENABLE_MPI - // initialize mpi4py's C-API - if (import_mpi4py() < 0) { - // mpi4py calls the Python C API - // we let nanobind give us the detailed traceback - throw nb::python_error(); - } -#endif + monoprop::bindings::detail::init_comm_bridge(); // clang-format off m.attr("MAX_NUM_MODES") = static_cast(@monoprop_MAX_NUM_MODES@); @@ -115,12 +88,6 @@ NB_MODULE(_core, m) { m.attr("__nanobind_version__") = std::string("@nanobind_VERSION@"); m.attr("__variant__") = std::string(variant()); -#ifdef monoprop_ENABLE_MPI - m.attr("has_mpi") = true; -#else - m.attr("has_mpi") = false; -#endif - m.def("is_antihermitian", &monoprop::is_antihermitian, "indices"_a, diff --git a/src/monoprop/bindings/comm_mpi.cpp b/src/monoprop/bindings/comm_mpi.cpp new file mode 100644 index 000000000..973eb993c --- /dev/null +++ b/src/monoprop/bindings/comm_mpi.cpp @@ -0,0 +1,38 @@ +// Copyright 2026 Algorithmiq +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "binder.h" + +#include + +namespace monoprop::bindings::detail { +auto get_mpi_comm(nb::object obj) -> MPI_Comm { + if (!obj || obj.is_none()) { + throw nb::type_error("monoprop._core_mpi needs an mpi4py communicator; comm=None selects the " + "serial backend"); + } + auto *comm_ptr = PyMPIComm_Get(obj.ptr()); + if (comm_ptr == nullptr) { + throw nb::python_error(); + } + return *comm_ptr; +} + +auto init_comm_bridge() -> void { + // mpi4py reports through the Python C API; nanobind turns that into the traceback + if (import_mpi4py() < 0) { + throw nb::python_error(); + } +} +} // namespace monoprop::bindings::detail diff --git a/src/monoprop/bindings/comm_serial.cpp b/src/monoprop/bindings/comm_serial.cpp new file mode 100644 index 000000000..6430e25b4 --- /dev/null +++ b/src/monoprop/bindings/comm_serial.cpp @@ -0,0 +1,28 @@ +// Copyright 2026 Algorithmiq +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "binder.h" + +namespace monoprop::bindings::detail { +auto get_mpi_comm(nb::object obj) -> MPI_Comm { + if (obj.is_valid() && !obj.is_none()) { + throw nb::type_error("monoprop._core is the serial backend and takes comm=None; communicators " + "are served by monoprop._core_mpi"); + } + // Comm.h's fallback handle; the serial core never communicates + return MPI_COMM_WORLD; +} + +auto init_comm_bridge() -> void {} +} // namespace monoprop::bindings::detail diff --git a/src/monoprop/exceptions.py b/src/monoprop/exceptions.py index a83bee729..fc1725966 100644 --- a/src/monoprop/exceptions.py +++ b/src/monoprop/exceptions.py @@ -19,3 +19,15 @@ class NumberOfModesInvalidError(ValueError): """Raised when the number of Fermionic modes is invalid.""" + + +class MPIUnavailableError(RuntimeError): + """Raised when a communicator is passed to a monoprop installation built without MPI.""" + + +class MPIAbiMismatchError(RuntimeError): + """Raised when mpi4py uses an MPI ABI family this monoprop installation was not built for.""" + + +class MPIVersionError(RuntimeError): + """Raised when the MPI library mpi4py uses is older than monoprop supports.""" diff --git a/src/monoprop/monomial_propagator.py b/src/monoprop/monomial_propagator.py index 475cb0870..ca3a711cc 100644 --- a/src/monoprop/monomial_propagator.py +++ b/src/monoprop/monomial_propagator.py @@ -122,7 +122,7 @@ def _init_simulator( self._initial_state = list(initial_state) # dispatch() returns the concrete adapter class for this mode count; # call it with keyword args matching the public constructor. - self._simulator = dispatch(num_modes)( # type: ignore[call-arg] + self._simulator = dispatch(num_modes, comm)( # type: ignore[call-arg] initial_operator=majorana_operator.terms, cutoff=cutoff, initial_state=list(initial_state), diff --git a/tests/conftest.py b/tests/conftest.py index 4bab8c411..c1b984a5d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ import pytest +from tests.mpi_support import HAS_MPI_BACKEND, MPI, SERIAL_COMM from tests.reference_data import load_problem if TYPE_CHECKING: @@ -34,10 +35,17 @@ from tests.reference_data import FermionicProblem -try: - from mpi4py import MPI -except ImportError: # pragma: no cover - exercised in wheel-test environments - MPI = None + +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Skip ``mpi``-marked tests when monoprop has no MPI backend, even under ``--with-mpi``.""" + if HAS_MPI_BACKEND: + return + skip = pytest.mark.skip( + reason="this monoprop installation has no MPI backend (MPI_ABIS is empty)" + ) + for item in items: + if item.get_closest_marker("mpi") is not None: + item.add_marker(skip) def pytest_addoption(parser: pytest.Parser) -> None: @@ -83,7 +91,7 @@ def pytest_runtest_setup(item: pytest.Item) -> None: _COMM_PARAMS = ( [pytest.param(None, id="comm_none")] - if MPI is None + if not HAS_MPI_BACKEND else [ pytest.param(MPI.COMM_SELF, id="comm_self"), pytest.param(MPI.COMM_WORLD, id="comm_world"), @@ -93,14 +101,14 @@ def pytest_runtest_setup(item: pytest.Item) -> None: @pytest.fixture(params=_COMM_PARAMS) def comm(request: pytest.FixtureRequest) -> Any: # noqa: ANN401 - """Parametrized communicator; ``None`` when ``mpi4py`` is unavailable (the non-MPI wheel).""" + """Parametrized communicator; ``None`` when mpi4py is unavailable or monoprop has no MPI backend.""" return request.param @pytest.fixture def serial_comm() -> Any: # noqa: ANN401 """Single-rank communicator for tests that inspect rank-local operator state.""" - return None if MPI is None else MPI.COMM_SELF + return SERIAL_COMM @pytest.fixture( diff --git a/tests/mpi_support.py b/tests/mpi_support.py new file mode 100644 index 000000000..be9e3217a --- /dev/null +++ b/tests/mpi_support.py @@ -0,0 +1,32 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Whether this test run can exercise monoprop's MPI backend.""" + +from __future__ import annotations + +from typing import Any + +import monoprop + +try: + from mpi4py import MPI +except ImportError: # pragma: no cover - exercised in wheel-test environments + MPI = None + +HAS_MPI_BACKEND: bool = MPI is not None and bool(monoprop.MPI_ABIS) +"""True when mpi4py is importable and this monoprop installation ships an MPI backend.""" + +SERIAL_COMM: Any = MPI.COMM_SELF if HAS_MPI_BACKEND else None +"""Single-rank communicator for rank-local checks; ``None`` selects the serial backend.""" diff --git a/tests/test_check_wheel.py b/tests/test_check_wheel.py new file mode 100644 index 000000000..8918e4f5a --- /dev/null +++ b/tests/test_check_wheel.py @@ -0,0 +1,196 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""The post-repair wheel audit run by tools/cibw/repair-wheel.sh.""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +_MODULE_PATH = Path(__file__).resolve().parents[1] / "tools" / "cibw" / "check_wheel.py" +_spec = importlib.util.spec_from_file_location("check_wheel", _MODULE_PATH) +assert _spec is not None +assert _spec.loader is not None +check_wheel = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(check_wheel) + +Binary = check_wheel.Binary + +READELF = """ +Dynamic section at offset 0x1 contains 4 entries: + Tag Type Name/Value + 0x0000000000000001 (NEEDED) Shared library: [libmpi.so.12] + 0x0000000000000001 (NEEDED) Shared library: [libstdc++.so.6] + 0x000000000000001d (RUNPATH) Library runpath: [$ORIGIN/../monoprop.libs:$ORIGIN/lib64] +""" + +OTOOL = """ +x.so: +Load command 10 + cmd LC_LOAD_DYLIB + cmdsize 56 + name @rpath/libmpi.40.dylib (offset 24) +Load command 11 + cmd LC_LOAD_DYLIB + cmdsize 56 + name /usr/lib/libc++.1.dylib (offset 24) +Load command 12 + cmd LC_RPATH + cmdsize 32 + path @loader_path/lib (offset 12) +Load command 13 + cmd LC_RPATH + cmdsize 32 + path /opt/homebrew/lib (offset 12) +""" + + +def test_parse_readelf() -> None: + assert check_wheel.parse_readelf(READELF) == ( + ("libmpi.so.12", "libstdc++.so.6"), + ("$ORIGIN/../monoprop.libs", "$ORIGIN/lib64"), + ) + + +def test_parse_readelf_keeps_empty_entries() -> None: + text = ( + " 0x000000000000001d (RUNPATH) Library runpath: [:$ORIGIN/lib64]\n" + ) + assert check_wheel.parse_readelf(text) == ((), ("", "$ORIGIN/lib64")) + + +def test_parse_otool() -> None: + assert check_wheel.parse_otool(OTOOL) == ( + ("@rpath/libmpi.40.dylib", "/usr/lib/libc++.1.dylib"), + ("@loader_path/lib", "/opt/homebrew/lib"), + ) + + +def test_mpi_module_with_its_soname_passes() -> None: + binary = Binary( + "monoprop/_core_mpi.mpich.abi3.so", + ("libmpi.so.12", "libc.so.6"), + ("$ORIGIN/../monoprop.libs",), + ) + assert check_wheel.audit_binary(binary, macos=False) == [] + + +def test_mpi_module_with_the_wrong_soname_fails() -> None: + binary = Binary("monoprop/_core_mpi.mpich.abi3.so", ("libmpi.so.40",), ()) + assert check_wheel.audit_binary(binary, macos=False) + + +def test_mpi_module_without_mpi_fails() -> None: + binary = Binary("monoprop/lib64/libmonoprop_mpi_openmpi.so", ("libc.so.6",), ()) + assert check_wheel.audit_binary(binary, macos=False) + + +def test_serial_binary_with_mpi_fails() -> None: + binary = Binary("monoprop/_core.abi3.so", ("libmpi.so.40",), ()) + assert check_wheel.audit_binary(binary, macos=False) + + +def test_absolute_rpath_fails() -> None: + binary = Binary("monoprop/_core.abi3.so", (), ("/opt/mpi/openmpi/lib",)) + assert check_wheel.audit_binary(binary, macos=False) + + +def test_empty_rpath_entry_fails() -> None: + binary = Binary("monoprop/_core.abi3.so", (), ("",)) + assert check_wheel.audit_binary(binary, macos=False) == [ + "monoprop/_core.abi3.so: empty RPATH entry (resolves to the current directory)" + ] + + +def test_macos_mpi_module_searches_the_mpi_prefixes() -> None: + binary = Binary( + "monoprop/_core_mpi.openmpi.abi3.so", + ("@rpath/libmpi.40.dylib",), + ("@loader_path/lib", *check_wheel.MACOS_MPI_RPATHS), + ) + assert check_wheel.audit_binary(binary, macos=True) == [] + + +def test_macos_mpi_module_missing_an_mpi_prefix_fails() -> None: + # delocate strips absolute RPATHs by default, which left MPI unloadable from Homebrew + binary = Binary( + "monoprop/_core_mpi.openmpi.abi3.so", + ("@rpath/libmpi.40.dylib",), + ("@loader_path/lib", "/usr/local/lib"), + ) + assert check_wheel.audit_binary(binary, macos=True) == [ + ( + "monoprop/_core_mpi.openmpi.abi3.so: RPATH lacks the MPI search directories " + "['/opt/homebrew/lib', '/opt/local/lib']" + ) + ] + + +def test_macos_serial_module_may_not_search_homebrew() -> None: + binary = Binary("monoprop/_core.abi3.so", (), ("/opt/homebrew/lib",)) + assert check_wheel.audit_binary(binary, macos=True) + + +def test_macos_mpi_dependency_must_go_through_rpath() -> None: + binary = Binary( + "monoprop/_core_mpi.openmpi.abi3.so", + ("/opt/mpi/openmpi/lib/libmpi.40.dylib",), + (), + ) + assert check_wheel.audit_binary(binary, macos=True) + + +def test_bundled_mpi_library_fails() -> None: + assert check_wheel.audit_bundled(["monoprop.libs/libhwloc-1a2b.so.15"]) == [] + assert check_wheel.audit_bundled(["monoprop.libs/libopen-pal-1a2b.so.80"]) + assert check_wheel.audit_bundled(["monoprop/.dylibs/libmpi.40.dylib"]) + + +TABLE = ( + 'ABIS: tuple[str, ...] = ("mpich", "openmpi", )\n' + 'MODULE_FILES: dict[str, str] = {"mpich": "_core_mpi.mpich", "openmpi": "_core_mpi.openmpi", }\n' +) +SHIPPED = [ + "monoprop/_core.abi3.so", + "monoprop/_core_mpi.mpich.abi3.so", + "monoprop/_core_mpi.openmpi.abi3.so", +] + + +def test_abi_table_matching_the_files_passes() -> None: + assert check_wheel.audit_abi_table(SHIPPED, TABLE, ("mpich", "openmpi")) == [] + + +def test_abi_table_with_a_missing_module_fails() -> None: + assert check_wheel.audit_abi_table(SHIPPED[:2], TABLE, ("mpich", "openmpi")) + + +def test_abi_table_with_unexpected_abis_fails() -> None: + assert check_wheel.audit_abi_table(SHIPPED, TABLE, ("mpich",)) + + +def test_audit_required_files_passes_when_stub_and_marker_present() -> None: + paths = ["monoprop/__init__.py", "monoprop/_core.pyi", "monoprop/py.typed"] + assert check_wheel.audit_required_files(paths) == [] + + +def test_audit_required_files_reports_each_missing_file() -> None: + errors = check_wheel.audit_required_files(["monoprop/__init__.py"]) + assert len(errors) == 2 + assert any("monoprop/_core.pyi" in error for error in errors) + assert any("monoprop/py.typed" in error for error in errors) + errors = check_wheel.audit_required_files(["monoprop/py.typed"]) + assert len(errors) == 1 + assert "monoprop/_core.pyi" in errors[0] diff --git a/tests/test_evolved_operator_coefficients.py b/tests/test_evolved_operator_coefficients.py index 13db3ff84..a2419a9cb 100644 --- a/tests/test_evolved_operator_coefficients.py +++ b/tests/test_evolved_operator_coefficients.py @@ -33,19 +33,13 @@ ) from monoprop.majorana import Majorana, MajoranaOperator from monoprop.pauli import Pauli, PauliOperator +from tests.mpi_support import SERIAL_COMM from tests.reference_data import load_problem -try: - from mpi4py import MPI -except ImportError: # pragma: no cover - exercised in wheel-test environments - MPI = None - DATA = Path(__file__).parent / "data" N_QUBITS = 6 -SERIAL_COMM = None if MPI is None else MPI.COMM_SELF - @pytest.fixture(scope="module") def problem(): diff --git a/tests/test_mpi_abis.py b/tests/test_mpi_abis.py new file mode 100644 index 000000000..63de1ef24 --- /dev/null +++ b/tests/test_mpi_abis.py @@ -0,0 +1,44 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""``monoprop.MPI_ABIS``: the MPI ABI families an installation was built for.""" + +from __future__ import annotations + +import os + +import pytest + +import monoprop + +_KNOWN_ABIS = {"mpich", "openmpi", "mpiabi"} +_ON = {"on", "true", "yes", "1"} + + +def test_mpi_abis_names_known_families_once() -> None: + assert isinstance(monoprop.MPI_ABIS, tuple) + assert set(monoprop.MPI_ABIS) <= _KNOWN_ABIS + assert len(set(monoprop.MPI_ABIS)) == len(monoprop.MPI_ABIS) + + +def test_has_mpi_was_removed() -> None: + assert not hasattr(monoprop, "has_mpi") + + +def test_mpi_abis_follow_the_build_switch() -> None: + """CI exports monoprop_ENABLE_MPI to both the build and the test steps.""" + switch = os.environ.get("monoprop_ENABLE_MPI") # noqa: SIM112 + if switch is None: + pytest.skip("monoprop_ENABLE_MPI is not set in the test environment") + assert bool(monoprop.MPI_ABIS) == (switch.lower() in _ON) diff --git a/tests/test_mpi_backend.py b/tests/test_mpi_backend.py new file mode 100644 index 000000000..842b8f2c1 --- /dev/null +++ b/tests/test_mpi_backend.py @@ -0,0 +1,285 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Backend selection: ``comm=None`` runs on the serial extension, a communicator on the MPI one.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import numpy as np +import pytest + +import monoprop +from monoprop import Circuit, MajoranaOperator, MajoranaPropagator, _backend +from monoprop.exceptions import ( + MPIAbiMismatchError, + MPIUnavailableError, + MPIVersionError, +) +from tests.mpi_support import HAS_MPI_BACKEND, MPI + +requires_mpi_backend = pytest.mark.skipif( + not HAS_MPI_BACKEND, reason="needs mpi4py and an MPI-enabled monoprop" +) + +N_MODES = 6 + + +class _FakeMPIError(Exception): + pass + + +def _fake_mpi(origin, *, library, abi_version, vendor=("Open MPI", (5, 0, 11))): + """Stand-in for ``mpi4py.MPI`` with the attributes detection and the version check read.""" + + def get_abi_version(): + if abi_version is None: + raise NotImplementedError + return abi_version + + return SimpleNamespace( + __spec__=SimpleNamespace(origin=origin), + Exception=_FakeMPIError, + Get_abi_version=get_abi_version, + Get_library_version=lambda: library, + get_vendor=lambda: vendor, + ) + + +@pytest.mark.parametrize( + ("origin", "library", "abi_version", "expected"), + [ + pytest.param( + "/site/mpi4py/MPI.mpich.abi3.so", + "Open MPI v5.0.11", + (-1, -1), + "mpich", + id="wheel-name-wins", + ), + pytest.param( + "/site/mpi4py/MPI.openmpi.abi3.so", + "MPICH Version: 5.0.2", + (-1, -1), + "openmpi", + id="wheel-openmpi", + ), + pytest.param( + "/site/mpi4py/MPI.mpiabi.cpython-312-x86_64-linux-gnu.so", + "MPICH Version: 5.0.2", + (1, 0), + "mpiabi", + id="wheel-mpiabi", + ), + pytest.param( + "/conda/mpi4py/MPI.cpython-312-x86_64-linux-gnu.so", + "Open MPI v4.1.6, package: Open MPI", + (-1, -1), + "openmpi", + id="source-openmpi", + ), + pytest.param( + "/conda/mpi4py/MPI.cpython-312-darwin.so", + "MPICH Version: 4.2.0", + (-1, -1), + "mpich", + id="source-mpich", + ), + pytest.param( + "/opt/mpi4py/MPI.abi3.so", + "Intel(R) MPI Library 2021.19 for Linux* OS", + None, + "mpich", + id="source-intel", + ), + pytest.param( + "/opt/mpi4py/MPI.abi3.so", + "CRAY MPICH version 8.1.29", + (0, 0), + "mpich", + id="source-cray", + ), + pytest.param( + "/opt/mpi4py/MPI.abi3.so", + "MPICH Version: 5.0.2", + (1, 0), + "mpiabi", + id="source-standard-abi", + ), + pytest.param(None, "Open MPI v5.0.11", None, "openmpi", id="no-origin"), + ], +) +def test_detect_mpi4py_abi(origin, library, abi_version, expected) -> None: + fake = _fake_mpi(origin, library=library, abi_version=abi_version) + assert _backend.detect_mpi4py_abi(fake) == expected + + +def test_detect_mpi4py_abi_survives_an_mpi_error() -> None: + fake = _fake_mpi("/x/MPI.abi3.so", library="Open MPI v5.0.11", abi_version=(-1, -1)) + + def failing(): + raise _FakeMPIError + + fake.Get_abi_version = failing + assert _backend.detect_mpi4py_abi(fake) == "openmpi" + + +def test_comm_none_is_served_by_the_serial_module() -> None: + assert _backend.core_module(None) is monoprop._core + + +def test_communicator_without_mpi_support_raises(monkeypatch) -> None: + monkeypatch.delitem(sys.modules, "monoprop._core_mpi", raising=False) + monkeypatch.setattr(_backend, "MPI_ABIS", ()) + with pytest.raises(MPIUnavailableError, match="no MPI support"): + _backend.core_module(object()) + + +def test_mpi4py_abi_outside_the_installation_raises(monkeypatch) -> None: + monkeypatch.delitem(sys.modules, "monoprop._core_mpi", raising=False) + monkeypatch.setattr(_backend, "MPI_ABIS", ("openmpi",)) + fake = _fake_mpi( + "/site/mpi4py/MPI.mpich.abi3.so", + library="MPICH Version: 5.0.2", + abi_version=(-1, -1), + ) + monkeypatch.setattr(_backend, "_mpi4py", lambda: fake) + with pytest.raises(MPIAbiMismatchError, match=r"'mpich'.*supports openmpi"): + _backend.core_module(object()) + + +def test_mpi_below_its_floor_is_rejected_before_loading(monkeypatch) -> None: + monkeypatch.delitem(sys.modules, "monoprop._core_mpi", raising=False) + monkeypatch.setattr(_backend, "MPI_ABIS", ("openmpi",)) + fake = _fake_mpi( + "/site/mpi4py/MPI.openmpi.abi3.so", + library="Open MPI v4.0.7", + abi_version=(-1, -1), + vendor=("Open MPI", (4, 0, 7)), + ) + monkeypatch.setattr(_backend, "_mpi4py", lambda: fake) + + def unexpected_load(abi): + raise AssertionError(abi) + + monkeypatch.setattr(_backend, "_load_mpi_core", unexpected_load) + with pytest.raises(MPIVersionError, match=r"Open MPI >= 4\.1, found 4\.0\.7"): + _backend.core_module(object()) + + +@pytest.mark.parametrize( + ("vendor", "version"), + [ + ("Open MPI", (4, 1, 0)), + ("Open MPI", (5, 0, 11)), + ("MPICH", (4, 0, 0)), + ("Intel MPI", (2021, 1, 1)), + ("MVAPICH", (3, 0, 0)), + ("Cray MPICH", (8, 1, 29)), + ], +) +def test_mpi_at_or_above_its_floor_is_accepted(vendor, version) -> None: + _backend.check_mpi_version(vendor, version) + + +@pytest.mark.parametrize( + ("vendor", "version", "floor"), + [ + ("Open MPI", (4, 0, 7), "4.1"), + ("MPICH", (3, 4, 3), "4.0"), + ("Intel MPI", (2019, 12, 0), "2021.0"), + ("MVAPICH", (2, 3, 7), "3.0"), + ("Cray MPICH", (8, 0, 16), "8.1"), + ], +) +def test_mpi_below_its_floor_raises(vendor, version, floor) -> None: + with pytest.raises(MPIVersionError, match=f"{vendor} >= {floor}"): + _backend.check_mpi_version(vendor, version) + + +def test_unrecognised_mpi_only_warns() -> None: + with pytest.warns(RuntimeWarning, match="not been validated"): + _backend.check_mpi_version("unknown", (0, 0, 0)) + + +def test_serial_module_rejects_a_communicator() -> None: + with pytest.raises(TypeError, match="serial backend"): + monoprop._core.MonomialPropagator032( + initial_operator={(0,): 1.0}, cutoff=3, initial_state=[], comm=object() + ) + + +def test_import_does_not_import_mpi4py() -> None: + code = "import sys, monoprop; sys.exit('mpi4py' in sys.modules)" + assert subprocess.run([sys.executable, "-c", code], check=False).returncode == 0 # noqa: S603 + + +def _evolved(comm): + propagator = MajoranaPropagator( + MajoranaOperator({(0,): 1.0}, N_MODES), [], cutoff=N_MODES // 2, comm=comm + ) + propagator.propagate( + Circuit.from_dense_arrays( + majoranas=[(5,)], + gen_coeffs=[-1.0], + param_inds=[0], + parameters=[1.0], + system_size=N_MODES, + ) + ) + return propagator, propagator.evolved_operator().terms + + +@requires_mpi_backend +def test_serial_and_mpi_backends_coexist_and_agree() -> None: + serial, serial_terms = _evolved(None) + distributed, mpi_terms = _evolved(MPI.COMM_SELF) + assert type(serial._simulator._core).__module__ == "monoprop._core" + assert type(distributed._simulator._core).__module__ == "monoprop._core_mpi" + assert serial_terms.keys() == mpi_terms.keys() + for term, value in serial_terms.items(): + assert np.isclose(value, mpi_terms[term]) + + +@requires_mpi_backend +def test_mpi_module_rejects_comm_none() -> None: + mpi_core = _backend.core_module(MPI.COMM_SELF) + with pytest.raises(TypeError, match="mpi4py communicator"): + mpi_core.MonomialPropagator032( + initial_operator={(0,): 1.0}, cutoff=3, initial_state=[], comm=None + ) + + +@requires_mpi_backend +def test_mpi_module_file_matches_mpi4py() -> None: + mpi_core = _backend.core_module(MPI.COMM_SELF) + abi = _backend.detect_mpi4py_abi(MPI) + assert Path(mpi_core.__file__).name.startswith(f"_core_mpi.{abi}.") + + +@requires_mpi_backend +def test_mpi_module_api_matches_the_serial_module() -> None: + """Drift guard: the MPI modules carry no stub, so their API must equal _core's.""" + stubgen = pytest.importorskip("nanobind.stubgen") + + def render(module): + generator = stubgen.StubGen(module) + generator.put(module) + return generator.get() + + mpi_core = _backend.core_module(MPI.COMM_SELF) + assert render(mpi_core).replace("_core_mpi", "_core") == render(monoprop._core) diff --git a/tools/capture-baseline.py b/tools/capture-baseline.py index ea23a23de..0d3377881 100755 --- a/tools/capture-baseline.py +++ b/tools/capture-baseline.py @@ -67,6 +67,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import monoprop from monoprop import Circuit, ExpGate, MajoranaPropagator, PauliPropagator from monoprop.pauli import PauliOperator @@ -113,8 +114,8 @@ def _load_reference_data_module() -> Any: # noqa: ANN401 def _comm() -> Any: # noqa: ANN401 - """COMM_WORLD if run under mpiexec with mpi4py available, else None (serial).""" - return None if MPI is None else MPI.COMM_WORLD + """COMM_WORLD if mpi4py imports and monoprop has an MPI backend, else None (serial).""" + return None if MPI is None or not monoprop.MPI_ABIS else MPI.COMM_WORLD def _cutoffs_for(num_modes: int, case_name: str) -> list[int]: diff --git a/tools/cibw/check_wheel.py b/tools/cibw/check_wheel.py new file mode 100644 index 000000000..401e0f605 --- /dev/null +++ b/tools/cibw/check_wheel.py @@ -0,0 +1,248 @@ +# Copyright 2026 Algorithmiq +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# ruff: noqa: INP001, T201 + +"""Audit a repaired monoprop wheel: MPI linkage, RPATHs, bundled libraries and the ABI table. + +Each MPI extension and MPI libmonoprop must depend on exactly its ABI family's MPI soname, left +unresolved for the user's MPI; nothing else may depend on MPI; no binary may carry an absolute +RPATH, except the macOS MPI search directories on MPI binaries; no MPI library may be bundled; +the type stub and ``py.typed`` must be present. +""" + +from __future__ import annotations + +import argparse +import ast +import re +import subprocess +import sys +import tempfile +import zipfile +from pathlib import Path +from typing import NamedTuple + +MPI_SONAMES: dict[str, tuple[str, str]] = { + "mpich": ("libmpi.so.12", "libmpi.12.dylib"), + "openmpi": ("libmpi.so.40", "libmpi.40.dylib"), + "mpiabi": ("libmpi_abi.so.1", "libmpi_abi.1.dylib"), +} +ALL_MPI_SONAMES = {name for names in MPI_SONAMES.values() for name in names} +MACOS_MPI_RPATHS = ("/usr/local/lib", "/opt/homebrew/lib", "/opt/local/lib") +BUNDLED_MPI_PREFIXES = ( + "libmpi", + "libpmpi", + "libopen-", + "libpmix", + "libprrte", + "libfabric", + "libuc", + "libevent", +) +_MPI_BINARY = re.compile( + r"^(?:_core_mpi\.|libmonoprop_mpi_)(?Pmpich|openmpi|mpiabi)[._]" +) +_RELATIVE_RPATH = ("$ORIGIN", "@loader_path") +REQUIRED_FILES = ("monoprop/_core.pyi", "monoprop/py.typed") + + +class Binary(NamedTuple): + """A shared object in the wheel and what its dynamic section records.""" + + path: str + needed: tuple[str, ...] + rpaths: tuple[str, ...] + + +def parse_readelf(text: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return (NEEDED entries, RPATH/RUNPATH entries) from ``readelf -d`` output.""" + needed = tuple(re.findall(r"\(NEEDED\)\s+Shared library: \[([^\]]+)\]", text)) + rpaths: list[str] = [] + for value in re.findall( + r"\((?:RPATH|RUNPATH)\)\s+Library r(?:un)?path: \[([^\]]*)\]", text + ): + # An empty entry is kept: the loader resolves it to the current directory. An empty + # tag value has no entries at all, so it is skipped. + if value: + rpaths.extend(value.split(":")) + return needed, tuple(rpaths) + + +def parse_otool(text: str) -> tuple[tuple[str, ...], tuple[str, ...]]: + """Return (LC_LOAD_DYLIB names, LC_RPATH paths) from ``otool -l`` output.""" + needed: list[str] = [] + rpaths: list[str] = [] + command = None + for line in text.splitlines(): + fields = line.split() + if len(fields) >= 2 and fields[0] == "cmd": + command = fields[1] + elif ( + len(fields) >= 2 + and fields[0] == "name" + and command in {"LC_LOAD_DYLIB", "LC_LOAD_WEAK_DYLIB"} + ): + needed.append(fields[1]) + elif len(fields) >= 2 and fields[0] == "path" and command == "LC_RPATH": + rpaths.append(fields[1]) + return tuple(needed), tuple(rpaths) + + +def audit_binary(binary: Binary, *, macos: bool) -> list[str]: + """Return one message per rule ``binary`` breaks.""" + errors: list[str] = [] + match = _MPI_BINARY.match(Path(binary.path).name) + abi = match["abi"] if match else None + mpi_deps = [dep for dep in binary.needed if Path(dep).name in ALL_MPI_SONAMES] + if abi is None: + if mpi_deps: + errors.append(f"{binary.path}: serial binary depends on MPI: {mpi_deps}") + else: + want = MPI_SONAMES[abi][1 if macos else 0] + if [Path(dep).name for dep in mpi_deps] != [want]: + errors.append( + f"{binary.path}: needs {want} and no other MPI library, has {mpi_deps}" + ) + if macos and any(not dep.startswith("@rpath/") for dep in mpi_deps): + errors.append( + f"{binary.path}: MPI must be loaded through @rpath: {mpi_deps}" + ) + allowed = MACOS_MPI_RPATHS if macos and abi else () + missing = [rpath for rpath in allowed if rpath not in binary.rpaths] + if missing: + errors.append( + f"{binary.path}: RPATH lacks the MPI search directories {missing}" + ) + for rpath in binary.rpaths: + if rpath == "": + errors.append( + f"{binary.path}: empty RPATH entry (resolves to the current directory)" + ) + elif not rpath.startswith(_RELATIVE_RPATH) and rpath not in allowed: + errors.append( + f"{binary.path}: RPATH entry {rpath!r} is not relative to the binary" + ) + return errors + + +def audit_bundled(paths: list[str]) -> list[str]: + """Return one message per MPI library vendored into the wheel.""" + return [ + f"{path}: MPI library bundled into the wheel" + for path in paths + if Path(path).name.startswith(BUNDLED_MPI_PREFIXES) + ] + + +def audit_required_files(paths: list[str]) -> list[str]: + """Return one message per required file missing from the wheel. + + Stub generation at install time is non-fatal, so a failure there only shows up as a missing + ``_core.pyi``. + """ + return [ + f"{required}: missing from the wheel" + for required in REQUIRED_FILES + if required not in paths + ] + + +def _literal(source: str, name: str) -> object: + for node in ast.parse(source).body: + if ( + isinstance(node, ast.AnnAssign) + and isinstance(node.target, ast.Name) + and node.target.id == name + ): + return ast.literal_eval(node.value) + msg = f"_mpi_abis.py defines no {name}" + raise ValueError(msg) + + +def audit_abi_table( + paths: list[str], source: str, expected: tuple[str, ...] +) -> list[str]: + """Check ``_mpi_abis.py`` against the expected ABIs and the ``_core_mpi.*`` files shipped.""" + abis = tuple(_literal(source, "ABIS")) + module_files = dict(_literal(source, "MODULE_FILES")) + shipped = { + ".".join(Path(path).name.split(".")[:2]) + for path in paths + if Path(path).parent.name == "monoprop" + and Path(path).name.startswith("_core_mpi.") + } + errors: list[str] = [] + if abis != expected: + errors.append(f"_mpi_abis.py: ABIS is {abis}, expected {expected}") + if set(module_files) != set(abis): + errors.append( + f"_mpi_abis.py: MODULE_FILES keys {sorted(module_files)} differ from ABIS {abis}" + ) + if set(module_files.values()) != shipped: + errors.append( + f"_mpi_abis.py: MODULE_FILES {sorted(module_files.values())} but the wheel ships {sorted(shipped)}" + ) + return errors + + +def _read_binary(root: Path, path: Path, *, macos: bool) -> Binary: + command = ["otool", "-l", str(path)] if macos else ["readelf", "-d", str(path)] + output = subprocess.run(command, check=True, capture_output=True, text=True).stdout # noqa: S603 + needed, rpaths = parse_otool(output) if macos else parse_readelf(output) + return Binary(path.relative_to(root).as_posix(), needed, rpaths) + + +def main() -> int: + """Audit the wheel named on the command line; print findings and return 1 if any.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("wheel", type=Path) + parser.add_argument( + "--expect-abis", required=True, help="comma-separated, in build order" + ) + args = parser.parse_args() + expected = tuple(abi for abi in args.expect_abis.split(",") if abi) + macos = sys.platform == "darwin" + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + with zipfile.ZipFile(args.wheel) as wheel: + wheel.extractall(root) + paths = wheel.namelist() + binaries = [ + root / path + for path in paths + if path.endswith((".so", ".dylib")) or ".so." in Path(path).name + ] + errors = [ + error + for path in binaries + for error in audit_binary( + _read_binary(root, path, macos=macos), macos=macos + ) + ] + errors += audit_bundled(paths) + errors += audit_required_files(paths) + errors += audit_abi_table( + paths, (root / "monoprop" / "_mpi_abis.py").read_text(), expected + ) + + for error in errors: + print(error, file=sys.stderr) + print(f"{args.wheel.name}: {len(binaries)} binaries, {len(errors)} finding(s)") + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/cibw/install-mpi.sh b/tools/cibw/install-mpi.sh new file mode 100755 index 000000000..b3e991eab --- /dev/null +++ b/tools/cibw/install-mpi.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# cibuildwheel before-all: installs the MPI implementations the wheel's MPI variants compile against, +# from their PyPI wheels, one prefix each (/mpich, /openmpi). Only mpi.h and the +# libmpi soname matter; the build uses no compiler wrappers. +set -euo pipefail + +prefix="${1:?usage: install-mpi.sh }" + +# Bumping these changes the headers every MPI variant is compiled against. +mpich_version=5.0.2 +openmpi_version=5.0.11 + +# manylinux's system python3 is too old for manylinux_2_28 wheel tags +python=python3 +if [[ -x /opt/python/cp312-cp312/bin/python3 ]]; then + python=/opt/python/cp312-cp312/bin/python3 +fi + +for spec in "mpich==$mpich_version" "openmpi==$openmpi_version"; do + name="${spec%%==*}" + rm -rf "${prefix:?}/$name" + # --prefix, not --target: pip drops a wheel's lib/ directory when installing with --target. + "$python" -m pip install --quiet --no-deps --prefix "$prefix/$name" "$spec" +done + +if [[ "$(uname)" == Darwin ]]; then + # The extensions must record libmpi as @rpath/..., as mpi4py's do, to find the user's MPI. + for dylib in "$prefix"/*/lib/libmpi*.dylib; do + [[ -L "$dylib" ]] && continue + id="$(otool -D "$dylib" | tail -n 1)" + if [[ "$id" != @rpath/* ]]; then + install_name_tool -id "@rpath/$(basename "$id")" "$dylib" + fi + done +fi + +ls "$prefix"/*/include/mpi.h diff --git a/tools/cibw/repair-wheel.sh b/tools/cibw/repair-wheel.sh new file mode 100755 index 000000000..0986e6b8d --- /dev/null +++ b/tools/cibw/repair-wheel.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# cibuildwheel repair-wheel-command: vendor non-system libraries as usual, but never MPI. Every MPI +# binary keeps a bare dependency on its ABI family's soname, resolved at runtime against the MPI +# library mpi4py loads. check_wheel.py then audits the result. +set -euo pipefail + +dest_dir="${1:?usage: repair-wheel.sh }" +wheel="${2:?usage: repair-wheel.sh }" + +case "$(uname)" in + Linux) + auditwheel repair \ + --exclude libmpi.so.12 --exclude libmpi.so.40 --exclude libmpi_abi.so.1 \ + -w "$dest_dir" "$wheel" + ;; + Darwin) + # delocate resolves @rpath names only through each binary's own LC_RPATHs and fails on the MPI + # libraries (they live in per-ABI prefixes, not on those rpaths) before --exclude applies. + # check_wheel.py verifies each MPI binary's MPI dependency afterwards. + # --no-sanitize-rpaths: delocate would otherwise strip the absolute MPI search directories + # (Homebrew, MacPorts, /usr/local) that the MPI binaries need; check_wheel.py requires them. + delocate-wheel -v --require-archs "$(uname -m)" \ + --ignore-missing-dependencies --no-sanitize-rpaths \ + --exclude libmpi.12.dylib --exclude libpmpi.12.dylib \ + --exclude libmpi.40.dylib --exclude libmpi_abi.1.dylib \ + -w "$dest_dir" "$wheel" + ;; + *) + echo "repair-wheel.sh: unsupported platform $(uname)" >&2 + exit 1 + ;; +esac + +# The ABI list comes from the same variable that selected what to build. +abis="$(printf '%s' "${monoprop_MPI_ABIS:?monoprop_MPI_ABIS is not set}" | tr ';' '\n' | cut -d= -f1 | paste -sd, -)" +# `python` is cibuildwheel's build interpreter; manylinux's system python3 is too old for the script. +python "$(dirname "$0")/check_wheel.py" --expect-abis "$abis" "$dest_dir"/*.whl diff --git a/tools/cibw/run-tests.sh b/tools/cibw/run-tests.sh new file mode 100755 index 000000000..d768380cb --- /dev/null +++ b/tools/cibw/run-tests.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# cibuildwheel test-command: the serial suite in cibuildwheel's test environment (no mpi4py), then +# the MPI suite once per shipped ABI family, each against that family's MPI from PyPI. +set -euo pipefail + +project="${1:?usage: run-tests.sh }" +wheel="${2:?usage: run-tests.sh }" + +cd "$project" +python -m pytest tests -p no:cacheprovider + +for mpi in mpich openmpi; do + echo "::group::MPI tests against PyPI $mpi" + PYTHON="$(command -v python)" bash tools/cibw/test-wheel-mpi.sh "$project" "$wheel" wheel "$mpi" + echo "::endgroup::" +done diff --git a/tools/cibw/test-wheel-mpi.sh b/tools/cibw/test-wheel-mpi.sh new file mode 100755 index 000000000..23b13bb9b --- /dev/null +++ b/tools/cibw/test-wheel-mpi.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Installs a built monoprop wheel into a fresh venv with mpi4py and whatever MPI the extra +# requirements or the machine provide, then runs the MPI-marked tests under mpiexec and the +# backend tests serially. +# +# usage: test-wheel-mpi.sh [requirement...] +# env: PYTHON (default python3), MONOPROP_TEST_VENV (default: a new temporary directory) +set -euo pipefail + +project="${1:?usage: test-wheel-mpi.sh [requirement...]}" +wheels="${2:?}" +mpi4py_mode="${3:?}" +shift 3 + +venv="${MONOPROP_TEST_VENV:-$(mktemp -d)/venv}" +"${PYTHON:-python3}" -m venv --clear "$venv" +python="$venv/bin/python" +"$python" -m pip install --quiet --upgrade pip + +# Install monoprop itself from the given wheels only: a PyPI release could outrank a dev version. +if [[ -f "$wheels" ]]; then + "$python" -m pip install --quiet --no-deps "$wheels" +else + "$python" -m pip install --quiet --no-deps --no-index --find-links "$wheels" monoprop +fi + +mpi4py_args=() +if [[ "$mpi4py_mode" == source ]]; then + mpi4py_args=(--no-binary mpi4py) +fi +# "monoprop" is already satisfied, so this only adds its dependencies; the suite imports qiskit. +"$python" -m pip install --quiet ${mpi4py_args[@]+"${mpi4py_args[@]}"} "monoprop[qiskit]" "mpi4py==4.1.*" ${@+"$@"} \ + --group "$project/pyproject.toml:test" +"$python" -m pip list + +mpiexec="$venv/bin/mpiexec" +if [[ ! -x "$mpiexec" ]]; then + mpiexec="$(command -v mpiexec)" +fi +# cibuildwheel's Linux container runs as root; Open MPI refuses that without these. +export OMPI_ALLOW_RUN_AS_ROOT=1 OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1 +export OMPI_MCA_rmaps_base_oversubscribe=1 PRTE_MCA_rmaps_default_mapping_policy=:oversubscribe +# Open MPI's shared-memory PMIx store cannot attach at its base address in some containers. +export PMIX_MCA_gds=hash +# Older impi-rt wheels keep libfabric in lib/libfabric and give libmpi.so.12 no RUNPATH, leaving +# the loader path to Intel's vars script; newer ones set a RUNPATH and have no such directory. +if [[ -d "$venv/lib/libfabric" ]]; then + export LD_LIBRARY_PATH="$venv/lib:$venv/lib/libfabric${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + export FI_PROVIDER_PATH="$venv/lib/libfabric/prov" +fi + +cd "$project" +# The MPI tests skip when mpi4py cannot load a library, so prove the backend loads before relying on them. +"$python" -c " +from mpi4py import MPI +import monoprop +assert monoprop.MPI_ABIS, 'monoprop reports no MPI ABIs' +from monoprop._backend import core_module +print('MPI_ABIS', monoprop.MPI_ABIS, core_module(MPI.COMM_SELF).__file__) +" +"$mpiexec" -n 2 "$python" -m pytest tests --with-mpi -m mpi -p no:cacheprovider +"$python" -m pytest tests/test_mpi_backend.py -p no:cacheprovider diff --git a/tools/generate-dispatch.py b/tools/generate-dispatch.py index 6932a83e9..929b59e86 100644 --- a/tools/generate-dispatch.py +++ b/tools/generate-dispatch.py @@ -21,7 +21,7 @@ from pathlib import Path # Run as a script, so sys.path[0] is tools/ regardless of the working directory CMake picks. -from _binding_layout import binding_block, binding_blocks +from _binding_layout import binding_block def python_comment_header_from_text(header_text: str) -> str: @@ -86,32 +86,14 @@ def __repr__(self) -> str: """ -def _cores_table(blocks: list[int], class_name: str) -> str: - """Emit a dict literal mapping each storage-block width to its core type alias.""" - entries = ",\n".join( - f" {block}: _{class_name}{block:03d}Core" for block in blocks - ) - return f"_CORES: dict[int, type] = {{\n{entries},\n}}" - - def _core_dispatch_source( *, max_logical_num_modes: int, - blocks: list[int], license_header: str, class_name: str, module_name: str, ) -> str: - import_line = ( - "(\n" - + ",\n".join( - f" {class_name}{block:03d} as _{class_name}{block:03d}Core" - for block in blocks - ) - + "\n)" - ) modes_per_block = binding_block(1) # == MODES_PER_STORAGE_BLOCK - cores_table = _cores_table(blocks, class_name) return f'''{license_header} @@ -124,7 +106,7 @@ def _core_dispatch_source( from __future__ import annotations -from {module_name}._core import {import_line} +from {module_name}._backend import core_module from monoprop.exceptions import NumberOfModesInvalidError _MAX_LOGICAL_NUM_MODES: int = {max_logical_num_modes} @@ -134,16 +116,13 @@ def _core_dispatch_source( {_simulator_adapter_source()} -{cores_table} - - def _binding_block(n: int) -> int: blocks = (n + _MODES_PER_STORAGE_BLOCK - 1) // _MODES_PER_STORAGE_BLOCK return max(_MODES_PER_STORAGE_BLOCK, blocks * _MODES_PER_STORAGE_BLOCK) -def dispatch(num_modes: int) -> type[_SimulatorAdapter]: - """Return the concrete {class_name} adapter for *num_modes*.""" +def dispatch(num_modes: int, comm=None) -> type[_SimulatorAdapter]: + """Return the concrete {class_name} adapter for *num_modes*, served by the backend for *comm*.""" if not isinstance(num_modes, int) or num_modes <= 0: errmsg = f"Number of Fermionic modes {{num_modes}} invalid. num_modes must be > 0." raise NumberOfModesInvalidError(errmsg) @@ -156,7 +135,7 @@ def dispatch(num_modes: int) -> type[_SimulatorAdapter]: ) raise NumberOfModesInvalidError(errmsg) - core_type = _CORES[_binding_block(num_modes)] + core_type = getattr(core_module(comm), f"{class_name}{{_binding_block(num_modes):03d}}") class _BoundSimulatorAdapter(_SimulatorAdapter): def __init__( @@ -197,8 +176,6 @@ def main( module_name: str, ) -> None: """Generate dispatch modules for core Python bindings.""" - blocks = binding_blocks(max_logical_num_modes) - project_root = Path(__file__).resolve().parents[1] license_header_path = project_root / ".github" / "license-header.txt" license_header = python_comment_header_from_text(license_header_path.read_text()) @@ -206,7 +183,6 @@ def main( Path("_dispatch.py").write_text( _core_dispatch_source( max_logical_num_modes=max_logical_num_modes, - blocks=blocks, license_header=license_header, class_name=class_name, module_name=module_name, diff --git a/tools/packages/apt-mpich.txt b/tools/packages/apt-mpich.txt new file mode 100644 index 000000000..0fe7a1900 --- /dev/null +++ b/tools/packages/apt-mpich.txt @@ -0,0 +1,4 @@ +# Added to tools/packages/apt.txt when the MPICH runtime is requested (mpi: mpich). + +libmpich-dev # MPI::MPI_CXX for the C++ consumer, and the headers a source mpi4py builds against +mpich # mpiexec (hydra) diff --git a/uv.lock b/uv.lock index 7275c7ec3..2c8885afb 100644 --- a/uv.lock +++ b/uv.lock @@ -1299,6 +1299,7 @@ qiskit = [ [package.dev-dependencies] bench = [ { name = "monoprop-bench-tools" }, + { name = "nanobind" }, { name = "psutil" }, { name = "pytest" }, { name = "pytest-benchmark" }, @@ -1328,6 +1329,7 @@ interactive = [ { name = "qiskit" }, ] test = [ + { name = "nanobind" }, { name = "psutil" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1336,6 +1338,7 @@ test = [ ] workspace-test = [ { name = "monoprop-bench-tools" }, + { name = "nanobind" }, { name = "psutil" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -1356,6 +1359,7 @@ provides-extras = ["mpi", "qiskit"] [package.metadata.requires-dev] bench = [ { name = "monoprop-bench-tools", editable = "packages/monoprop-bench-tools" }, + { name = "nanobind", specifier = "==3.1.0" }, { name = "psutil", specifier = ">=7.2" }, { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-benchmark", specifier = ">=5" }, @@ -1385,6 +1389,7 @@ interactive = [ { name = "qiskit", specifier = ">=2.0.0" }, ] test = [ + { name = "nanobind", specifier = "==3.1.0" }, { name = "psutil", specifier = ">=7.2" }, { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -1393,6 +1398,7 @@ test = [ ] workspace-test = [ { name = "monoprop-bench-tools", editable = "packages/monoprop-bench-tools" }, + { name = "nanobind", specifier = "==3.1.0" }, { name = "psutil", specifier = ">=7.2" }, { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, @@ -1569,6 +1575,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/cd/0c3aa439bc7a7bf24684fef3a0ad776cba170e18ed94445e723bce42fce7/msgpack-1.2.3-cp315-cp315t-win_arm64.whl", hash = "sha256:f41ca154b7737b11893cdce3c78c61d703398a1cd54d4297bdad908392338a8e", size = 77572, upload-time = "2026-09-29T02:33:50.729Z" }, ] +[[package]] +name = "nanobind" +version = "3.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/1f/e28fae715401e8f57400e72aeaff4fde12046d31828164f34f61f0816da8/nanobind-3.1.0.tar.gz", hash = "sha256:6661e3dc1434d78781ccf7913442d90361dab395c3424efadb63bbb6ddea7ea3", size = 1145390, upload-time = "2026-09-18T15:59:42.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/c1/6fd56f7a02a478d54a1a3e6000eccd34b446b1c45cbf0d002fe5d2937126/nanobind-3.1.0-py3-none-any.whl", hash = "sha256:96984dbe8154ffb86e653b1b60aaeed6985c710c5e75ad0279eb87943c66292d", size = 316917, upload-time = "2026-09-18T15:59:37.796Z" }, +] + [[package]] name = "nanobind-backend" version = "1.0.0"