From 22debe22fcd3c16b26f1d194b10566e89919afbe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 14:54:16 +0200 Subject: [PATCH 01/20] =?UTF-8?q?refactor(cmake):=20=E2=99=BB=EF=B8=8F=20c?= =?UTF-8?q?ompile=20the=20core=20through=20a=20reusable=20object-library?= =?UTF-8?q?=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core sources are registered once and compiled into an OBJECT library per variant, which later lets one configure build a serial core and one MPI core per ABI family. Assisted-by: Pi:claude-opus-5-5 --- CMakeLists.txt | 4 +- cmake/custom/monopropCore.cmake | 64 +++++++++++++++++++ cpp/monoprop/CMakeLists.txt | 56 ++++++++-------- .../detail/graph_encoding/CMakeLists.txt | 2 +- cpp/monoprop/detail/mpi/CMakeLists.txt | 2 +- cpp/monoprop/detail/pare/CMakeLists.txt | 2 +- cpp/monoprop/detail/partition/CMakeLists.txt | 2 +- 7 files changed, 95 insertions(+), 37 deletions(-) create mode 100644 cmake/custom/monopropCore.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index 1442722c3..650fc7dea 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -103,8 +103,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/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/cpp/monoprop/CMakeLists.txt b/cpp/monoprop/CMakeLists.txt index e2bb624ce..23318a602 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" @@ -119,6 +104,15 @@ add_subdirectory(algebra) add_subdirectory(core) add_subdirectory(detail) +# Every subdirectory has registered its sources by now. +if(monoprop_ENABLE_MPI) + monoprop_add_core_objects(monoprop-objs MPI MPI::MPI_CXX) +else() + monoprop_add_core_objects(monoprop-objs) +endif() + +target_sources(monoprop PRIVATE $) + 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) From 93b9fa034897e2e47c903032a6dda5276cdfbc74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 15:04:56 +0200 Subject: [PATCH 02/20] =?UTF-8?q?refactor(bindings):=20=E2=99=BB=EF=B8=8F?= =?UTF-8?q?=20move=20the=20mpi4py=20bridge=20out=20of=20bindings.cpp.in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit comm_serial.cpp and comm_mpi.cpp define get_mpi_comm and init_comm_bridge; each module compiles one, so the template needs no MPI guards. Assisted-by: Pi:claude-opus-5-5 --- src/monoprop/bindings/CMakeLists.txt | 7 +++++ src/monoprop/bindings/binder.h | 12 ++++++++- src/monoprop/bindings/bindings.cpp.in | 29 +-------------------- src/monoprop/bindings/comm_mpi.cpp | 37 +++++++++++++++++++++++++++ src/monoprop/bindings/comm_serial.cpp | 24 +++++++++++++++++ 5 files changed, 80 insertions(+), 29 deletions(-) create mode 100644 src/monoprop/bindings/comm_mpi.cpp create mode 100644 src/monoprop/bindings/comm_serial.cpp diff --git a/src/monoprop/bindings/CMakeLists.txt b/src/monoprop/bindings/CMakeLists.txt index 257a343e6..636a36e60 100644 --- a/src/monoprop/bindings/CMakeLists.txt +++ b/src/monoprop/bindings/CMakeLists.txt @@ -119,6 +119,12 @@ configure_file( @ONLY ) +if(monoprop_ENABLE_MPI) + set(_comm_bridge comm_mpi.cpp) +else() + set(_comm_bridge comm_serial.cpp) +endif() + nanobind_add_module(_core NB_SUPPRESS_WARNINGS # suppress warnings from nanobind and Python headers NOMINSIZE # don’t perform optimizations to minimize binary size @@ -126,6 +132,7 @@ nanobind_add_module(_core BACKEND_MODULE nanobind_backend ${CMAKE_CURRENT_BINARY_DIR}/bindings.cpp ${_bind_cpps} # List of generated binders + ${CMAKE_CURRENT_SOURCE_DIR}/${_comm_bridge} ) target_include_directories( diff --git a/src/monoprop/bindings/binder.h b/src/monoprop/bindings/binder.h index cfc69d576..9cd99d352 100644 --- a/src/monoprop/bindings/binder.h +++ b/src/monoprop/bindings/binder.h @@ -41,9 +41,19 @@ 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. + */ 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..ec0b8e746 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; @@ -98,14 +78,7 @@ auto basis_enum_2_str(Basis basis) -> std::string { NB_MODULE(_core, 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@); diff --git a/src/monoprop/bindings/comm_mpi.cpp b/src/monoprop/bindings/comm_mpi.cpp new file mode 100644 index 000000000..12e402d6a --- /dev/null +++ b/src/monoprop/bindings/comm_mpi.cpp @@ -0,0 +1,37 @@ +// 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()) { + return MPI_COMM_WORLD; + } + 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..727769786 --- /dev/null +++ b/src/monoprop/bindings/comm_serial.cpp @@ -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. + +#include "binder.h" + +namespace monoprop::bindings::detail { +auto get_mpi_comm(nb::object /*obj*/) -> MPI_Comm { + // Comm.h's fallback handle; the serial core never communicates + return MPI_COMM_WORLD; +} + +auto init_comm_bridge() -> void {} +} // namespace monoprop::bindings::detail From 66fba5366341c087b1465067887ede6dc9ad6b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 15:20:48 +0200 Subject: [PATCH 03/20] =?UTF-8?q?feat!:=20=E2=9C=A8=20replace=20has=5Fmpi?= =?UTF-8?q?=20with=20MPI=5FABIS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit monoprop.MPI_ABIS lists the MPI ABI families the installation was built for, classified from the preprocessed mpi.h at configure time, which also rejects MPI implementations below monoprop's version floors (Open MPI 4.1, MPICH 4.0, Intel MPI 2021.0, MVAPICH 3.0, Cray MPICH 8.1). The MPI-2 C++ bindings are disabled. BREAKING CHANGE: monoprop.has_mpi is removed; use bool(monoprop.MPI_ABIS). Assisted-by: Pi:claude-opus-5-5 --- CMakeLists.txt | 5 +- cmake/_mpi_abis.py.in | 22 +++ cmake/custom/monopropMPI.cmake | 30 ++++ cmake/custom/monopropMPIABI.cmake | 194 ++++++++++++++++++++++ cpp/tests/CMakeLists.txt | 13 ++ cpp/tests/cmake/inspect_mpi.cmake | 23 +++ cpp/tests/cmake/test_mpi_inspection.cmake | 68 ++++++++ justfile | 10 +- src/monoprop/__init__.py | 4 +- src/monoprop/_backend.py | 28 ++++ src/monoprop/bindings/CMakeLists.txt | 35 ++++ src/monoprop/bindings/bindings.cpp.in | 6 - tests/test_mpi_abis.py | 44 +++++ 13 files changed, 469 insertions(+), 13 deletions(-) create mode 100644 cmake/_mpi_abis.py.in create mode 100644 cmake/custom/monopropMPI.cmake create mode 100644 cmake/custom/monopropMPIABI.cmake create mode 100644 cpp/tests/cmake/inspect_mpi.cmake create mode 100644 cpp/tests/cmake/test_mpi_inspection.cmake create mode 100644 src/monoprop/_backend.py create mode 100644 tests/test_mpi_abis.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 650fc7dea..070a04d3c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,9 +65,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 +93,7 @@ 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 " Max simulable modes : ${monoprop_MAX_NUM_MODES}") message(STATUS " C++ unit tests : ${monoprop_ENABLE_CXX_UNIT_TESTS}") diff --git a/cmake/_mpi_abis.py.in b/cmake/_mpi_abis.py.in new file mode 100644 index 000000000..9b8ab8944 --- /dev/null +++ b/cmake/_mpi_abis.py.in @@ -0,0 +1,22 @@ +# 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@} diff --git a/cmake/custom/monopropMPI.cmake b/cmake/custom/monopropMPI.cmake new file mode 100644 index 000000000..ee58d08b4 --- /dev/null +++ b/cmake/custom/monopropMPI.cmake @@ -0,0 +1,30 @@ +# 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). + +include_guard(GLOBAL) + +include(${CMAKE_CURRENT_LIST_DIR}/monopropMPIABI.cmake) + +set(monoprop_MPI_ABI_NAMES "") + +if(monoprop_ENABLE_MPI) + # 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}) +endif() diff --git a/cmake/custom/monopropMPIABI.cmake b/cmake/custom/monopropMPIABI.cmake new file mode 100644 index 000000000..1c78d8f1c --- /dev/null +++ b/cmake/custom/monopropMPIABI.cmake @@ -0,0 +1,194 @@ +# 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() 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. +#]=] +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 the macros defined after including mpi.h, from the C++ compiler's preprocessor +(-E -dM), so macros from headers that mpi.h includes count too. 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(_source "${CMAKE_BINARY_DIR}/CMakeFiles/monoprop_mpi_macros.cpp") + file(WRITE "${_source}" "#include \n") + set( + _args + -E + -dM + ) + foreach(_dir IN LISTS _arg_INCLUDE_DIRS) + list(APPEND _args "-I${_dir}") + endforeach() + foreach( + _definition + IN + LISTS + _arg_DEFINITIONS + ITEMS + MPICH_SKIP_MPICXX + OMPI_SKIP_MPICXX + ) + list(APPEND _args "-D${_definition}") + endforeach() + execute_process( + COMMAND + "${CMAKE_CXX_COMPILER}" ${_args} "${_source}" + OUTPUT_VARIABLE _macros + ERROR_VARIABLE _error + RESULT_VARIABLE _result + ) + if(NOT _result EQUAL 0) + message( + FATAL_ERROR + "monoprop: cannot preprocess mpi.h with include dirs '${_arg_INCLUDE_DIRS}':\n${_error}" + ) + endif() + 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/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index 50d224e7c..c1aa509e1 100644 --- a/cpp/tests/CMakeLists.txt +++ b/cpp/tests/CMakeLists.txt @@ -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.cmake b/cpp/tests/cmake/inspect_mpi.cmake new file mode 100644 index 000000000..3bc83d967 --- /dev/null +++ b/cpp/tests/cmake/inspect_mpi.cmake @@ -0,0 +1,23 @@ +# Inspect an installed MPI the way configure does: +# cmake -DMPI_INCLUDE_DIR= -DEXPECTED_ABI= [-DCMAKE_CXX_COMPILER=] -P inspect_mpi.cmake +# Fails unless the mpi.h there has ABI family EXPECTED_ABI and meets monoprop's version floor. + +include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/custom/monopropMPIABI.cmake) + +if(NOT CMAKE_CXX_COMPILER) + set(CMAKE_CXX_COMPILER c++) +endif() +# monoprop_mpi_macros() writes its probe source under CMAKE_BINARY_DIR +set(CMAKE_BINARY_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../build/inspect-mpi") +file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/CMakeFiles") + +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/justfile b/justfile index 8697793ec..d34713109 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 @@ -124,6 +124,12 @@ test-find-package BUILD_DIR='build/find-package-smoke': cmake --build "$build_dir" "$build_dir/smoke" +# Run configure's MPI inspection on an installed MPI: ABI family and version floor. + +inspect-mpi INCLUDE_DIR ABI: + cmake -DMPI_INCLUDE_DIR={{ quote(INCLUDE_DIR) }} -DEXPECTED_ABI={{ quote(ABI) }} \ + -P cpp/tests/cmake/inspect_mpi.cmake + # 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 +388,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/src/monoprop/__init__.py b/src/monoprop/__init__.py index b36c791f9..633a874fd 100644 --- a/src/monoprop/__init__.py +++ b/src/monoprop/__init__.py @@ -18,6 +18,7 @@ import importlib.util +from ._backend import MPI_ABIS 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__ @@ -46,6 +46,7 @@ __all__ = [ "MAX_NUM_MODES", + "MPI_ABIS", "Circuit", "ExpGate", "FermiOperator", @@ -64,7 +65,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..a2aeebbb8 --- /dev/null +++ b/src/monoprop/_backend.py @@ -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. + +"""Backend selection for monoprop's extension modules.""" + +from __future__ import annotations + +from monoprop._mpi_abis import ABIS as _BUILT_MPI_ABIS + +MPI_ABIS: tuple[str, ...] = _BUILT_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. +""" diff --git a/src/monoprop/bindings/CMakeLists.txt b/src/monoprop/bindings/CMakeLists.txt index 636a36e60..32b2b5157 100644 --- a/src/monoprop/bindings/CMakeLists.txt +++ b/src/monoprop/bindings/CMakeLists.txt @@ -198,6 +198,41 @@ set_target_properties( install(TARGETS _core LIBRARY DESTINATION ${PROJECT_NAME}) +# 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} +) + # generation of Python typing stubs # NOTE must come after installing the _core target # diff --git a/src/monoprop/bindings/bindings.cpp.in b/src/monoprop/bindings/bindings.cpp.in index ec0b8e746..21be51b27 100644 --- a/src/monoprop/bindings/bindings.cpp.in +++ b/src/monoprop/bindings/bindings.cpp.in @@ -88,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/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) From a37afde23e3ae0c594f1125c17ead298fc79106e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 15:23:49 +0200 Subject: [PATCH 04/20] =?UTF-8?q?fix(ci):=20=F0=9F=90=9B=20read=20MPI=5FAB?= =?UTF-8?q?IS=20instead=20of=20has=5Fmpi=20in=20resolve=5Fcores.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- .github/workflows/scripts/resolve_cores.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From a53ddff4dfcb8e3bfe897fa0802b765ef81d8577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 15:53:01 +0200 Subject: [PATCH 05/20] =?UTF-8?q?feat(python)!:=20=E2=9C=A8=20load=20the?= =?UTF-8?q?=20MPI=20backend=20only=20for=20propagators=20given=20a=20commu?= =?UTF-8?q?nicator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every build ships a serial _core; MPI builds add _core_mpi. in a separate nanobind domain. monoprop._backend serves comm=None from _core and loads the MPI module matching mpi4py's ABI family on first use, after checking mpi4py.MPI.get_vendor() against monoprop's version floors. libmonoprop is serial in every build. BREAKING CHANGE: comm=None now always runs serially; MPI builds no longer fall back to MPI_COMM_WORLD. In MPI builds, monoprop::monoprop is serial until the MPI component lands. Assisted-by: Pi:claude-opus-5-5 --- benches/conftest.py | 4 +- cmake/_mpi_abis.py.in | 2 + cmake/custom/monopropMPI.cmake | 6 + cmake/monopropConfig.cmake.in | 10 - cpp/monoprop/CMakeLists.txt | 21 +- cpp/tests/CMakeLists.txt | 2 +- docs/content/docs/building.mdx | 7 +- docs/content/docs/features/parallelism.mdx | 15 ++ docs/content/docs/testing.mdx | 3 +- prek.toml | 2 +- pyproject.toml | 1 + src/monoprop/_backend.py | 149 +++++++++- src/monoprop/bindings/CMakeLists.txt | 142 ++++++---- src/monoprop/bindings/binder.h | 2 + src/monoprop/bindings/bindings.cpp.in | 2 +- src/monoprop/bindings/comm_mpi.cpp | 3 +- src/monoprop/bindings/comm_serial.cpp | 6 +- src/monoprop/exceptions.py | 12 + src/monoprop/monomial_propagator.py | 2 +- tests/conftest.py | 22 +- tests/mpi_support.py | 32 +++ tests/test_evolved_operator_coefficients.py | 8 +- tests/test_mpi_backend.py | 285 ++++++++++++++++++++ tools/generate-dispatch.py | 34 +-- uv.lock | 15 ++ 25 files changed, 662 insertions(+), 125 deletions(-) create mode 100644 tests/mpi_support.py create mode 100644 tests/test_mpi_backend.py 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 index 9b8ab8944..81cfff799 100644 --- a/cmake/_mpi_abis.py.in +++ b/cmake/_mpi_abis.py.in @@ -20,3 +20,5 @@ 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/monopropMPI.cmake b/cmake/custom/monopropMPI.cmake index ee58d08b4..dbf6c587d 100644 --- a/cmake/custom/monopropMPI.cmake +++ b/cmake/custom/monopropMPI.cmake @@ -27,4 +27,10 @@ if(monoprop_ENABLE_MPI) ) 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/monopropConfig.cmake.in b/cmake/monopropConfig.cmake.in index 89296ac2d..2d3395968 100644 --- a/cmake/monopropConfig.cmake.in +++ b/cmake/monopropConfig.cmake.in @@ -32,16 +32,6 @@ find_dependency( REQUIRED ) -set(monoprop_ENABLE_MPI "@monoprop_ENABLE_MPI@") -if(monoprop_ENABLE_MPI) - find_dependency( - MPI - REQUIRED - COMPONENTS - CXX - ) -endif() - if(NOT TARGET monoprop::monoprop) include("${CMAKE_CURRENT_LIST_DIR}/monopropTargets.cmake") endif() diff --git a/cpp/monoprop/CMakeLists.txt b/cpp/monoprop/CMakeLists.txt index 23318a602..14db93310 100644 --- a/cpp/monoprop/CMakeLists.txt +++ b/cpp/monoprop/CMakeLists.txt @@ -79,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 @@ -105,10 +97,17 @@ add_subdirectory(core) add_subdirectory(detail) # Every subdirectory has registered its sources by now. -if(monoprop_ENABLE_MPI) - monoprop_add_core_objects(monoprop-objs MPI MPI::MPI_CXX) +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() - monoprop_add_core_objects(monoprop-objs) + set(monoprop_CPP_TEST_OBJS monoprop-objs PARENT_SCOPE) endif() target_sources(monoprop PRIVATE $) diff --git a/cpp/tests/CMakeLists.txt b/cpp/tests/CMakeLists.txt index c1aa509e1..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 diff --git a/docs/content/docs/building.mdx b/docs/content/docs/building.mdx index 810524572..2f9ed5041 100644 --- a/docs/content/docs/building.mdx +++ b/docs/content/docs/building.mdx @@ -197,6 +197,11 @@ 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 core twice: 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 @@ -227,7 +232,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 diff --git a/docs/content/docs/features/parallelism.mdx b/docs/content/docs/features/parallelism.mdx index a56727ca9..81a3144a4 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 diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index b2fda6ca9..34fe717a7 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -56,7 +56,8 @@ 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`. 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..df4daf5ca 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", diff --git a/src/monoprop/_backend.py b/src/monoprop/_backend.py index a2aeebbb8..deca7b117 100644 --- a/src/monoprop/_backend.py +++ b/src/monoprop/_backend.py @@ -12,11 +12,34 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Backend selection for monoprop's extension modules.""" +"""Pick the extension module that serves a propagator. + +``comm=None`` is served by the serial ``monoprop._core``. A communicator is served by +``monoprop._core_mpi``, loaded on first use from the file built for the MPI ABI family mpi4py uses: +mpi4py hands over ``MPI_Comm`` handles whose type depends on that family. +""" 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 MPI_ABIS: tuple[str, ...] = _BUILT_MPI_ABIS """MPI ABI families this installation can drive, for example ``("mpich", "openmpi")``. @@ -26,3 +49,127 @@ MPI. It states what was built, not that a usable MPI library is present: that depends on the MPI library mpi4py loads at runtime. """ + +_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 32b2b5157..89b078dc2 100644 --- a/src/monoprop/bindings/CMakeLists.txt +++ b/src/monoprop/bindings/CMakeLists.txt @@ -119,38 +119,103 @@ configure_file( @ONLY ) -if(monoprop_ENABLE_MPI) - set(_comm_bridge comm_mpi.cpp) +if(APPLE) + set(_rpath "@loader_path/${CMAKE_INSTALL_LIBDIR}") else() - set(_comm_bridge comm_serial.cpp) + set(_rpath "\$ORIGIN/${CMAKE_INSTALL_LIBDIR}") endif() -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 - ${CMAKE_CURRENT_SOURCE_DIR}/${_comm_bridge} -) +#[=[ +monoprop_add_python_core( OBJS MODULE_NAME OUTPUT_NAME [MPI]) -target_include_directories( - _core - SYSTEM - PRIVATE - $,${mpi4py_INCLUDE_DIRS},> - PRIVATE - ${CMAKE_CURRENT_BINARY_DIR} - ${CMAKE_CURRENT_SOURCE_DIR} -) +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() -target_link_libraries( - _core - PRIVATE - monoprop-objs - monoprop-sanitizers -) + 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 + ) + set_target_properties( + ${target} + PROPERTIES + OUTPUT_NAME + "${_arg_OUTPUT_NAME}" + MACOSX_RPATH + ON + SKIP_BUILD_RPATH + OFF + BUILD_WITH_INSTALL_RPATH + OFF + INSTALL_RPATH + "${_rpath}" + INSTALL_RPATH_USE_LINK_PATH + ON + ) + 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. @@ -175,29 +240,6 @@ 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 -) - -install(TARGETS _core LIBRARY DESTINATION ${PROJECT_NAME}) - # 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) diff --git a/src/monoprop/bindings/binder.h b/src/monoprop/bindings/binder.h index 9cd99d352..01254cc6c 100644 --- a/src/monoprop/bindings/binder.h +++ b/src/monoprop/bindings/binder.h @@ -44,6 +44,8 @@ namespace monoprop::bindings::detail { /** @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; diff --git a/src/monoprop/bindings/bindings.cpp.in b/src/monoprop/bindings/bindings.cpp.in index 21be51b27..de8c0c245 100644 --- a/src/monoprop/bindings/bindings.cpp.in +++ b/src/monoprop/bindings/bindings.cpp.in @@ -75,7 +75,7 @@ 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"; monoprop::bindings::detail::init_comm_bridge(); diff --git a/src/monoprop/bindings/comm_mpi.cpp b/src/monoprop/bindings/comm_mpi.cpp index 12e402d6a..973eb993c 100644 --- a/src/monoprop/bindings/comm_mpi.cpp +++ b/src/monoprop/bindings/comm_mpi.cpp @@ -19,7 +19,8 @@ namespace monoprop::bindings::detail { auto get_mpi_comm(nb::object obj) -> MPI_Comm { if (!obj || obj.is_none()) { - return MPI_COMM_WORLD; + 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) { diff --git a/src/monoprop/bindings/comm_serial.cpp b/src/monoprop/bindings/comm_serial.cpp index 727769786..6430e25b4 100644 --- a/src/monoprop/bindings/comm_serial.cpp +++ b/src/monoprop/bindings/comm_serial.cpp @@ -15,7 +15,11 @@ #include "binder.h" namespace monoprop::bindings::detail { -auto get_mpi_comm(nb::object /*obj*/) -> MPI_Comm { +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; } 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_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_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/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/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" From a6f93434df85477bda45e5bf24b5f0cef48d4b2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 15:54:38 +0200 Subject: [PATCH 06/20] =?UTF-8?q?fix(nix):=20=F0=9F=90=9B=20put=20Open=20M?= =?UTF-8?q?PI=20on=20the=20dev=20shell's=20loader=20path=20for=20the=20mpi?= =?UTF-8?q?4py=20wheel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- nix/devshell.nix | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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}" ''; From 494fd204be649c02796d7903bad5f41ba6ecec8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 21:29:48 +0200 Subject: [PATCH 07/20] =?UTF-8?q?feat(cmake)!:=20=E2=9C=A8=20ship=20MPI-en?= =?UTF-8?q?abled=20libmonoprop=20variants=20behind=20an=20MPI=20component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each MPI ABI family gets libmonoprop_mpi_; find_package(monoprop COMPONENTS MPI) classifies the consumer's mpi.h and provides monoprop::monoprop_mpi, or fails naming the ABI. BREAKING CHANGE: monoprop::monoprop is serial in every build; MPI consumers use COMPONENTS MPI and monoprop::monoprop_mpi. Assisted-by: Pi:claude-opus-5-5 --- cmake/monopropConfig.cmake.in | 56 +++++++++++++++++++++ cpp/CMakeLists.txt | 1 + cpp/monoprop/CMakeLists.txt | 37 ++++++++++++++ cpp/tests/find_package_smoke/CMakeLists.txt | 16 +++++- cpp/tests/link_export_probe/CMakeLists.txt | 24 +++++++++ docs/content/docs/building.mdx | 23 +++++++-- justfile | 34 +++++++++++-- 7 files changed, 182 insertions(+), 9 deletions(-) diff --git a/cmake/monopropConfig.cmake.in b/cmake/monopropConfig.cmake.in index 2d3395968..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,8 +39,57 @@ find_dependency( REQUIRED ) +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 14db93310..3418f8e7b 100644 --- a/cpp/monoprop/CMakeLists.txt +++ b/cpp/monoprop/CMakeLists.txt @@ -112,6 +112,43 @@ 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 + ) + install( + TARGETS + ${_lib} + EXPORT monopropTargets + LIBRARY + DESTINATION ${PROJECT_NAME}/${CMAKE_INSTALL_LIBDIR} + COMPONENT lib + ) +endforeach() + install( TARGETS monoprop 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 2f9ed5041..1722f777b 100644 --- a/docs/content/docs/building.mdx +++ b/docs/content/docs/building.mdx @@ -259,16 +259,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/justfile b/justfile index d34713109..d7536e039 100644 --- a/justfile +++ b/justfile @@ -112,17 +112,45 @@ 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" # Run configure's MPI inspection on an installed MPI: ABI family and version floor. From f90f1c1541dbdfd93f384c9f09b5593e341d7191 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Fri, 23 Oct 2026 21:51:03 +0200 Subject: [PATCH 08/20] =?UTF-8?q?feat(build):=20=E2=9C=A8=20build=20MPI=20?= =?UTF-8?q?variants=20from=20MPI=20install=20prefixes=20for=20wheels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit monoprop.mpi-abis takes = entries and builds each variant against that prefix without compiler wrappers, checking the prefix's mpi.h matches the declared ABI. Under cibuildwheel the extensions drop build-machine RPATHs; macOS MPI binaries search the usual MPI prefixes. Assisted-by: Pi:claude-opus-5-5 --- CMakeLists.txt | 17 +++++ cmake/custom/monopropMPI.cmake | 100 ++++++++++++++++++++++++++- cpp/monoprop/CMakeLists.txt | 8 +++ docs/content/docs/building.mdx | 16 +++++ pyproject.toml | 7 ++ src/monoprop/bindings/CMakeLists.txt | 13 +++- 6 files changed, 158 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 070a04d3c..340195cce 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -50,6 +50,22 @@ 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. +if("$ENV{CIBUILDWHEEL}" STREQUAL "1") + set(monoprop_WHEEL_BUILD ON) +else() + set(monoprop_WHEEL_BUILD OFF) +endif() +# Where macOS MPI installations live (Homebrew, MacPorts, /usr/local), as mpi4py's wheels assume. +set( + monoprop_MACOS_MPI_RPATHS + /usr/local/lib + /opt/homebrew/lib + /opt/local/lib +) + option(monoprop_ENABLE_CXX_UNIT_TESTS "Enable C++ unit test suite" ON) set(Python_FIND_VIRTUALENV FIRST) @@ -94,6 +110,7 @@ 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}") diff --git a/cmake/custom/monopropMPI.cmake b/cmake/custom/monopropMPI.cmake index dbf6c587d..b0d979125 100644 --- a/cmake/custom/monopropMPI.cmake +++ b/cmake/custom/monopropMPI.cmake @@ -8,9 +8,107 @@ 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 "") -if(monoprop_ENABLE_MPI) +#[=[ +_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(monoprop_ENABLE_MPI AND monoprop_MPI_ABIS) + foreach(_entry IN LISTS monoprop_MPI_ABIS) + _monoprop_add_prefix_mpi_target("${_entry}") + endforeach() +elseif(monoprop_ENABLE_MPI) # 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) diff --git a/cpp/monoprop/CMakeLists.txt b/cpp/monoprop/CMakeLists.txt index 3418f8e7b..bb4f53a19 100644 --- a/cpp/monoprop/CMakeLists.txt +++ b/cpp/monoprop/CMakeLists.txt @@ -139,6 +139,14 @@ foreach(_abi IN LISTS monoprop_MPI_ABI_NAMES) $ PkgConfig::HWLOC ) + if(APPLE AND monoprop_WHEEL_BUILD) + set_target_properties( + ${_lib} + PROPERTIES + INSTALL_RPATH + "${monoprop_MACOS_MPI_RPATHS}" + ) + endif() install( TARGETS ${_lib} diff --git a/docs/content/docs/building.mdx b/docs/content/docs/building.mdx index 1722f777b..282eb9e74 100644 --- a/docs/content/docs/building.mdx +++ b/docs/content/docs/building.mdx @@ -135,6 +135,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: @@ -217,6 +218,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 diff --git a/pyproject.toml b/pyproject.toml index df4daf5ca..fc605bf2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -108,6 +108,8 @@ cache-keys = [ { env = "monoprop_ENABLE_CXX_UNIT_TESTS" }, { env = "monoprop_ENABLE_ARCH_FLAGS" }, { env = "monoprop_MPI_TEST_PROCS" }, + { env = "monoprop_MPI_ABIS" }, + { env = "CIBUILDWHEEL" }, { env = "SKBUILD_BUILD_DIR" }, { env = "SKBUILD_CMAKE_ARGS" }, { env = "SKBUILD_CMAKE_BUILD_TYPE" }, @@ -202,12 +204,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 diff --git a/src/monoprop/bindings/CMakeLists.txt b/src/monoprop/bindings/CMakeLists.txt index 89b078dc2..11105eb06 100644 --- a/src/monoprop/bindings/CMakeLists.txt +++ b/src/monoprop/bindings/CMakeLists.txt @@ -181,6 +181,15 @@ function(monoprop_add_python_core target) ${_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 @@ -193,9 +202,9 @@ function(monoprop_add_python_core target) BUILD_WITH_INSTALL_RPATH OFF INSTALL_RPATH - "${_rpath}" + "${_module_rpath}" INSTALL_RPATH_USE_LINK_PATH - ON + ${_use_link_path} ) install(TARGETS ${target} LIBRARY DESTINATION ${PROJECT_NAME}) endfunction() From 78b5266238b2bc742eeaa8fe375109516a7b4875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 24 Oct 2026 08:50:12 +0200 Subject: [PATCH 09/20] =?UTF-8?q?chore(wheels):=20=F0=9F=94=A7=20audit=20M?= =?UTF-8?q?PI=20linkage=20and=20RPATHs=20of=20repaired=20wheels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- tests/test_check_wheel.py | 166 ++++++++++++++++++++++++++++ tools/cibw/check_wheel.py | 227 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 393 insertions(+) create mode 100644 tests/test_check_wheel.py create mode 100644 tools/cibw/check_wheel.py diff --git a/tests/test_check_wheel.py b/tests/test_check_wheel.py new file mode 100644 index 000000000..1e787654c --- /dev/null +++ b/tests/test_check_wheel.py @@ -0,0 +1,166 @@ +# 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_may_search_homebrew() -> None: + binary = Binary( + "monoprop/_core_mpi.openmpi.abi3.so", + ("@rpath/libmpi.40.dylib",), + ("@loader_path/lib", "/opt/homebrew/lib"), + ) + assert check_wheel.audit_binary(binary, macos=True) == [] + + +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",)) diff --git a/tools/cibw/check_wheel.py b/tools/cibw/check_wheel.py new file mode 100644 index 000000000..1eea9d08c --- /dev/null +++ b/tools/cibw/check_wheel.py @@ -0,0 +1,227 @@ +# 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. +""" + +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") + + +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 () + 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 _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_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()) From f52dd22952726535aed195e88bcbb48941afd1dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 24 Oct 2026 10:03:55 +0200 Subject: [PATCH 10/20] =?UTF-8?q?feat(wheels):=20=E2=9C=A8=20publish=20MPI?= =?UTF-8?q?-enabled=20wheels=20for=20MPICH=20and=20Open=20MPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cibuildwheel builds _core_mpi.mpich and _core_mpi.openmpi against the PyPI mpich 5.0.2 and openmpi 5.0.11 packages, keeps MPI out of the repaired wheel, audits it with check_wheel.py, and runs the MPI suite against each PyPI MPI. Assisted-by: Pi:claude-opus-5-5 --- .github/workflows/deploy.yml | 1 + README.md | 13 +++++- docs/content/docs/features/parallelism.mdx | 28 +++++++++++-- pyproject.toml | 19 ++++++--- tools/cibw/install-mpi.sh | 37 ++++++++++++++++ tools/cibw/repair-wheel.sh | 31 ++++++++++++++ tools/cibw/run-tests.sh | 16 +++++++ tools/cibw/test-wheel-mpi.sh | 49 ++++++++++++++++++++++ 8 files changed, 184 insertions(+), 10 deletions(-) create mode 100755 tools/cibw/install-mpi.sh create mode 100755 tools/cibw/repair-wheel.sh create mode 100755 tools/cibw/run-tests.sh create mode 100755 tools/cibw/test-wheel-mpi.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 40d1fd63d..2e6f17a6d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -167,6 +167,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 ] diff --git a/README.md b/README.md index 91e2b5c79..b7145f246 100644 --- a/README.md +++ b/README.md @@ -43,8 +43,17 @@ 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; the minimum versions are listed in the +[parallelism docs](https://docs.monoprop.algorithmiq.tech/features/parallelism). Without one, add it +from PyPI: + +```bash +pip install "monoprop[mpi]" mpich # or openmpi +``` + +To build the C++ library and executables, build from source (see below). ## Quick example diff --git a/docs/content/docs/features/parallelism.mdx b/docs/content/docs/features/parallelism.mdx index 81a3144a4..5491f69f9 100644 --- a/docs/content/docs/features/parallelism.mdx +++ b/docs/content/docs/features/parallelism.mdx @@ -148,6 +148,28 @@ 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 MPICH family (MPICH, Intel MPI, MVAPICH, Cray MPICH) +and for Open MPI, and use the MPI library mpi4py finds on the machine. Install mpi4py with the +`mpi` extra; on a machine without MPI, add an implementation from PyPI as well: + +```bash +pip install "monoprop[mpi]" mpich # or openmpi +``` + +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 Cray systems, load `cray-mpich-abi` so that `libmpi.so.12` is available, as mpi4py requires. +From-source builds enable MPI with the `monoprop.mpi` option; see [Building from source](/building). diff --git a/pyproject.toml b/pyproject.toml index fc605bf2e..20b35e28e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -465,14 +465,23 @@ 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", + "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/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..39a311b4d --- /dev/null +++ b/tools/cibw/repair-wheel.sh @@ -0,0 +1,31 @@ +#!/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-wheel -v --require-archs "$(uname -m)" \ + --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..6b87b86c5 --- /dev/null +++ b/tools/cibw/test-wheel-mpi.sh @@ -0,0 +1,49 @@ +#!/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[@]}" "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 + +cd "$project" +"$python" -c "import monoprop; print('MPI_ABIS', monoprop.MPI_ABIS)" +"$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 From 2cbffd5b82b93a30c7ad1f7484321a4cefca40d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 24 Oct 2026 10:07:19 +0200 Subject: [PATCH 11/20] =?UTF-8?q?fix(wheels):=20=F0=9F=90=9B=20expand=20po?= =?UTF-8?q?ssibly=20empty=20arrays=20safely=20for=20macOS=20bash=203.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- tools/cibw/test-wheel-mpi.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/cibw/test-wheel-mpi.sh b/tools/cibw/test-wheel-mpi.sh index 6b87b86c5..2381ba674 100755 --- a/tools/cibw/test-wheel-mpi.sh +++ b/tools/cibw/test-wheel-mpi.sh @@ -29,7 +29,7 @@ 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[@]}" "monoprop[qiskit]" "mpi4py==4.1.*" "$@" \ +"$python" -m pip install --quiet ${mpi4py_args[@]+"${mpi4py_args[@]}"} "monoprop[qiskit]" "mpi4py==4.1.*" "$@" \ --group "$project/pyproject.toml:test" "$python" -m pip list From 37f94ece50e3700c493f5fb34fccaf33b962e9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 24 Oct 2026 10:09:30 +0200 Subject: [PATCH 12/20] =?UTF-8?q?test(wheels):=20=E2=9C=85=20test=20releas?= =?UTF-8?q?e=20wheels=20against=20system,=20Intel=20and=20Homebrew=20MPI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test-wheels job installs the wheels next to the supported version floors (Ubuntu 22.04's Open MPI 4.1.2 and MPICH 4.0 with source-built and PyPI mpi4py, Intel MPI 2021.1.1), the latest Intel MPI and Homebrew Open MPI, runs configure's MPI inspection on the floor headers, the MPI suite and a C++ consumer, and gates publishing. Assisted-by: Pi:claude-opus-5-5 --- .github/actions/setup/action.yml | 31 ++++++++++++----- .github/workflows/deploy.yml | 59 ++++++++++++++++++++++++++++++++ justfile | 8 +++++ tools/packages/apt-mpich.txt | 4 +++ 4 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 tools/packages/apt-mpich.txt diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 54a4f2b00..b7faff993 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,6 +42,7 @@ runs: shell: bash env: MPI: ${{ inputs.mpi }} + BUILD_DEPS: ${{ inputs.build-deps }} run: | set -euo pipefail @@ -52,9 +60,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 +82,11 @@ 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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2e6f17a6d..f75fe4ba3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -200,11 +200,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 @@ -263,6 +321,7 @@ jobs: needs: - build-sdist - build-wheels + - test-wheels - build-bench-tools if: github.event_name == 'release' && github.event.action == 'published' diff --git a/justfile b/justfile index d7536e039..aa2ee1459 100644 --- a/justfile +++ b/justfile @@ -152,6 +152,14 @@ test-find-package BUILD_DIR='build/find-package-smoke' PYTHON='': 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: 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) From ef2b2e4864cff82e7a3a65dd35f1137a1406cbaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 24 Oct 2026 10:29:44 +0200 Subject: [PATCH 13/20] =?UTF-8?q?docs(wheels):=20=F0=9F=93=9D=20document?= =?UTF-8?q?=20monoprop.MPI=5FABIS=20and=20the=20MPI=20wheels=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- README.md | 5 +++-- docs/content/docs/building.mdx | 5 +++-- docs/content/docs/getting-started.mdx | 7 ++++--- docs/content/docs/testing.mdx | 21 +++++++++++++++++++++ docs/scripts/generate-api.mjs | 3 ++- src/monoprop/__init__.py | 11 ++++++++++- src/monoprop/_backend.py | 8 +------- 7 files changed, 44 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index b7145f246..3d3675c0c 100644 --- a/README.md +++ b/README.md @@ -144,8 +144,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/docs/content/docs/building.mdx b/docs/content/docs/building.mdx index 282eb9e74..011ac30d5 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 or for the C++ library; see [Parallelism](/features/parallelism#enabling-mpi). ## Prerequisites diff --git a/docs/content/docs/getting-started.mdx b/docs/content/docs/getting-started.mdx index e084c984b..72d96d0e5 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 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 34fe717a7..fbd157865 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -61,6 +61,27 @@ is empty, and the `comm`/`serial_comm` fixtures fall back to `None` there. Accor 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/src/monoprop/__init__.py b/src/monoprop/__init__.py index 633a874fd..d221612cd 100644 --- a/src/monoprop/__init__.py +++ b/src/monoprop/__init__.py @@ -18,7 +18,7 @@ import importlib.util -from ._backend import MPI_ABIS +from . import _backend from ._core import ( MAX_NUM_MODES, __build_type__, @@ -44,6 +44,15 @@ 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", diff --git a/src/monoprop/_backend.py b/src/monoprop/_backend.py index deca7b117..3925db782 100644 --- a/src/monoprop/_backend.py +++ b/src/monoprop/_backend.py @@ -41,14 +41,8 @@ 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 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. -""" _MPI_MODULE_NAME = "monoprop._core_mpi" # mpi4py wheels ship one extension per ABI family, e.g. MPI.mpich.abi3.so From 87c686478094ec5a9999702f76f742fe6a124fa8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 24 Oct 2026 10:29:44 +0200 Subject: [PATCH 14/20] =?UTF-8?q?fix(bench):=20=F0=9F=90=9B=20use=20a=20co?= =?UTF-8?q?mmunicator=20only=20when=20monoprop=20has=20an=20MPI=20backend?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- packages/monoprop-bench-tools/tests/conftest.py | 9 +++++++-- tools/capture-baseline.py | 5 +++-- 2 files changed, 10 insertions(+), 4 deletions(-) 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/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]: From 9f771becda280f1ae95ef483c87ac6affb463a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Sat, 24 Oct 2026 10:29:44 +0200 Subject: [PATCH 15/20] =?UTF-8?q?fix(wheels):=20=F0=9F=90=9B=20prove=20the?= =?UTF-8?q?=20MPI=20backend=20loads=20in=20wheel=20tests=20and=20run=20the?= =?UTF-8?q?=20wheel=20build=20on=20wheel-only=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: Pi:claude-opus-5-5 --- .github/actions/setup/action.yml | 4 ++++ .github/workflows/deploy.yml | 24 ++++++++++++++++++++++-- src/monoprop/bindings/CMakeLists.txt | 2 +- tools/cibw/test-wheel-mpi.sh | 11 +++++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index b7faff993..4c1a71bc0 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -49,6 +49,10 @@ runs: # 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) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f75fe4ba3..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 diff --git a/src/monoprop/bindings/CMakeLists.txt b/src/monoprop/bindings/CMakeLists.txt index 11105eb06..f34d945f1 100644 --- a/src/monoprop/bindings/CMakeLists.txt +++ b/src/monoprop/bindings/CMakeLists.txt @@ -270,7 +270,7 @@ foreach(_entry IN LISTS _floors) ) string( APPEND monoprop_MPI_MIN_VERSIONS_PY - "\"${CMAKE_MATCH_1}\": (${_floor}), " + "\"${CMAKE_MATCH_1}\": (${_floor},), " ) endforeach() configure_file( diff --git a/tools/cibw/test-wheel-mpi.sh b/tools/cibw/test-wheel-mpi.sh index 2381ba674..7154575da 100755 --- a/tools/cibw/test-wheel-mpi.sh +++ b/tools/cibw/test-wheel-mpi.sh @@ -29,7 +29,7 @@ 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.*" "$@" \ +"$python" -m pip install --quiet ${mpi4py_args[@]+"${mpi4py_args[@]}"} "monoprop[qiskit]" "mpi4py==4.1.*" ${@+"$@"} \ --group "$project/pyproject.toml:test" "$python" -m pip list @@ -44,6 +44,13 @@ export OMPI_MCA_rmaps_base_oversubscribe=1 PRTE_MCA_rmaps_default_mapping_policy export PMIX_MCA_gds=hash cd "$project" -"$python" -c "import monoprop; print('MPI_ABIS', monoprop.MPI_ABIS)" +# 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 From b16d9987a62903858d5742b52b12fc9681eb3c1f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 26 Oct 2026 10:52:19 +0100 Subject: [PATCH 16/20] =?UTF-8?q?fix(wheels):=20=F0=9F=90=9B=20repair=20ma?= =?UTF-8?q?cOS=20wheels=20and=20keep=20the=20Linux=20type=20stub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delocate cannot resolve the deliberately unresolved @rpath MPI libraries, so macOS repair now ignores missing dependencies; check_wheel.py still verifies each MPI binary's MPI dependency. The Linux before-all refreshes the loader cache after installing hwloc, because install-time stub generation imports _core without build RPATHs; check_wheel.py now requires _core.pyi and py.typed so a failed stub generation fails the build. Assisted-by: Pi:claude-opus-5-5 --- pyproject.toml | 3 +++ tests/test_check_wheel.py | 15 +++++++++++++++ tools/cibw/check_wheel.py | 18 +++++++++++++++++- tools/cibw/repair-wheel.sh | 5 ++++- 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 20b35e28e..59f178970 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -475,6 +475,9 @@ test-extras = ["qiskit"] [tool.cibuildwheel.linux] 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" } diff --git a/tests/test_check_wheel.py b/tests/test_check_wheel.py index 1e787654c..6a5fb369a 100644 --- a/tests/test_check_wheel.py +++ b/tests/test_check_wheel.py @@ -164,3 +164,18 @@ def test_abi_table_with_a_missing_module_fails() -> None: 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/tools/cibw/check_wheel.py b/tools/cibw/check_wheel.py index 1eea9d08c..d93bb714e 100644 --- a/tools/cibw/check_wheel.py +++ b/tools/cibw/check_wheel.py @@ -18,7 +18,8 @@ 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. +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 @@ -54,6 +55,7 @@ r"^(?:_core_mpi\.|libmonoprop_mpi_)(?Pmpich|openmpi|mpiabi)[._]" ) _RELATIVE_RPATH = ("$ORIGIN", "@loader_path") +REQUIRED_FILES = ("monoprop/_core.pyi", "monoprop/py.typed") class Binary(NamedTuple): @@ -139,6 +141,19 @@ def audit_bundled(paths: list[str]) -> list[str]: ] +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 ( @@ -213,6 +228,7 @@ def main() -> int: ) ] errors += audit_bundled(paths) + errors += audit_required_files(paths) errors += audit_abi_table( paths, (root / "monoprop" / "_mpi_abis.py").read_text(), expected ) diff --git a/tools/cibw/repair-wheel.sh b/tools/cibw/repair-wheel.sh index 39a311b4d..850264061 100755 --- a/tools/cibw/repair-wheel.sh +++ b/tools/cibw/repair-wheel.sh @@ -14,7 +14,10 @@ case "$(uname)" in -w "$dest_dir" "$wheel" ;; Darwin) - delocate-wheel -v --require-archs "$(uname -m)" \ + # 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. + delocate-wheel -v --require-archs "$(uname -m)" --ignore-missing-dependencies \ --exclude libmpi.12.dylib --exclude libpmpi.12.dylib \ --exclude libmpi.40.dylib --exclude libmpi_abi.1.dylib \ -w "$dest_dir" "$wheel" From 27ae2a24077907f672adfa2ee984fcb57d134ed3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 26 Oct 2026 16:27:49 +0100 Subject: [PATCH 17/20] =?UTF-8?q?refactor(cmake):=20=E2=99=BB=EF=B8=8F=20i?= =?UTF-8?q?nspect=20mpi.h=20with=20try=5Fcompile=20and=20spell=20out=20the?= =?UTF-8?q?=20two=20MPI=20modes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MPI inspection compiles a probe with try_compile and reads the macro values back with file(STRINGS), as FindMPI does for MPI_VERSION, instead of running the compiler's preprocessor by hand; try_compile carries the toolchain's flags and the macOS sysroot. just inspect-mpi becomes a small project because try_compile cannot run in script mode. monopropMPI.cmake names its two modes (prefixes for wheels, FindMPI for source builds) and now owns the macOS MPI RPATH list; the CIBUILDWHEEL uv cache key goes, since only uv build sees it. Assisted-by: Pi:claude-opus-5-5 --- CMakeLists.txt | 7 -- cmake/custom/monopropMPI.cmake | 21 ++++- cmake/custom/monopropMPIABI.cmake | 91 ++++++++++++++----- .../CMakeLists.txt} | 14 ++- justfile | 4 +- pyproject.toml | 1 - 6 files changed, 96 insertions(+), 42 deletions(-) rename cpp/tests/cmake/{inspect_mpi.cmake => inspect_mpi/CMakeLists.txt} (57%) diff --git a/CMakeLists.txt b/CMakeLists.txt index 340195cce..5cd48ccf4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -58,13 +58,6 @@ if("$ENV{CIBUILDWHEEL}" STREQUAL "1") else() set(monoprop_WHEEL_BUILD OFF) endif() -# Where macOS MPI installations live (Homebrew, MacPorts, /usr/local), as mpi4py's wheels assume. -set( - monoprop_MACOS_MPI_RPATHS - /usr/local/lib - /opt/homebrew/lib - /opt/local/lib -) option(monoprop_ENABLE_CXX_UNIT_TESTS "Enable C++ unit test suite" ON) diff --git a/cmake/custom/monopropMPI.cmake b/cmake/custom/monopropMPI.cmake index b0d979125..2969afc59 100644 --- a/cmake/custom/monopropMPI.cmake +++ b/cmake/custom/monopropMPI.cmake @@ -2,7 +2,7 @@ # ========= # # 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). +# this build compiles MPI variants for (empty without MPI), and monoprop_MACOS_MPI_RPATHS. include_guard(GLOBAL) @@ -17,6 +17,15 @@ set( 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(=) @@ -104,11 +113,17 @@ if(monoprop_MPI_ABIS AND NOT monoprop_ENABLE_MPI) ) endif() -if(monoprop_ENABLE_MPI AND monoprop_MPI_ABIS) +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() -elseif(monoprop_ENABLE_MPI) +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) diff --git a/cmake/custom/monopropMPIABI.cmake b/cmake/custom/monopropMPIABI.cmake index 1c78d8f1c..6040b028c 100644 --- a/cmake/custom/monopropMPIABI.cmake +++ b/cmake/custom/monopropMPIABI.cmake @@ -4,8 +4,9 @@ # 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() works on text, so it runs in script -# mode (cpp/tests/cmake/test_mpi_inspection.cmake). +# 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) @@ -15,6 +16,10 @@ 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( @@ -31,22 +36,43 @@ endfunction() #[=[ monoprop_mpi_macros( INCLUDE_DIRS ... [DEFINITIONS ...]) -Sets to the macros defined after including mpi.h, from the C++ compiler's preprocessor -(-E -dM), so macros from headers that mpi.h includes count too. DEFINITIONS are given without -D, -as in MPI_CXX_COMPILE_DEFINITIONS; MPICH's standard-ABI mode needs MPI_ABI among them. +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(_source "${CMAKE_BINARY_DIR}/CMakeFiles/monoprop_mpi_macros.cpp") - file(WRITE "${_source}" "#include \n") set( - _args - -E - -dM + _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(_dir IN LISTS _arg_INCLUDE_DIRS) - list(APPEND _args "-I${_dir}") + 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 @@ -56,21 +82,44 @@ function(monoprop_mpi_macros out_var) MPICH_SKIP_MPICXX OMPI_SKIP_MPICXX ) - list(APPEND _args "-D${_definition}") + list(APPEND _definitions "-D${_definition}") endforeach() - execute_process( - COMMAND - "${CMAKE_CXX_COMPILER}" ${_args} "${_source}" - OUTPUT_VARIABLE _macros - ERROR_VARIABLE _error - RESULT_VARIABLE _result + # 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}" ) - if(NOT _result EQUAL 0) + 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 preprocess mpi.h with include dirs '${_arg_INCLUDE_DIRS}':\n${_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() diff --git a/cpp/tests/cmake/inspect_mpi.cmake b/cpp/tests/cmake/inspect_mpi/CMakeLists.txt similarity index 57% rename from cpp/tests/cmake/inspect_mpi.cmake rename to cpp/tests/cmake/inspect_mpi/CMakeLists.txt index 3bc83d967..a2ca16979 100644 --- a/cpp/tests/cmake/inspect_mpi.cmake +++ b/cpp/tests/cmake/inspect_mpi/CMakeLists.txt @@ -1,15 +1,13 @@ # Inspect an installed MPI the way configure does: -# cmake -DMPI_INCLUDE_DIR= -DEXPECTED_ABI= [-DCMAKE_CXX_COMPILER=] -P inspect_mpi.cmake +# 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. -include(${CMAKE_CURRENT_LIST_DIR}/../../../cmake/custom/monopropMPIABI.cmake) +cmake_minimum_required(VERSION 3.28...4.0) +project(monoprop_inspect_mpi LANGUAGES CXX) -if(NOT CMAKE_CXX_COMPILER) - set(CMAKE_CXX_COMPILER c++) -endif() -# monoprop_mpi_macros() writes its probe source under CMAKE_BINARY_DIR -set(CMAKE_BINARY_DIR "${CMAKE_CURRENT_LIST_DIR}/../../../build/inspect-mpi") -file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/CMakeFiles") +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}") diff --git a/justfile b/justfile index aa2ee1459..c89b04a74 100644 --- a/justfile +++ b/justfile @@ -163,8 +163,8 @@ test-wheel WHEELS MPI4PY='wheel' *PACKAGES: # Run configure's MPI inspection on an installed MPI: ABI family and version floor. inspect-mpi INCLUDE_DIR ABI: - cmake -DMPI_INCLUDE_DIR={{ quote(INCLUDE_DIR) }} -DEXPECTED_ABI={{ quote(ABI) }} \ - -P cpp/tests/cmake/inspect_mpi.cmake + 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. diff --git a/pyproject.toml b/pyproject.toml index 59f178970..0d8364a59 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,6 @@ cache-keys = [ { env = "monoprop_ENABLE_ARCH_FLAGS" }, { env = "monoprop_MPI_TEST_PROCS" }, { env = "monoprop_MPI_ABIS" }, - { env = "CIBUILDWHEEL" }, { env = "SKBUILD_BUILD_DIR" }, { env = "SKBUILD_CMAKE_ARGS" }, { env = "SKBUILD_CMAKE_BUILD_TYPE" }, From 40cc758324f93299514da9cd07454c9127a56a6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 26 Oct 2026 16:27:50 +0100 Subject: [PATCH 18/20] =?UTF-8?q?docs(mpi):=20=F0=9F=93=9D=20point=20users?= =?UTF-8?q?=20to=20their=20system=20MPI=20and=20cite=20the=20Cray=20guidan?= =?UTF-8?q?ce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wheels' MPI docs and README now lead with the MPI the system provides and link mpi4py's installation guide, keep the PyPI MPI wheels as a first-try option with mpi4py's production caveat, add the Ubuntu 22.04 libmpich.so.12 note, and reduce the Cray advice to what mpi4py documents. Also rewords the backend module docstring, applies review wording suggestions, and turns the compile-twice note into a callout. Assisted-by: Pi:claude-opus-5-5 --- README.md | 9 ++++---- docs/content/docs/building.mdx | 11 ++++----- docs/content/docs/features/parallelism.mdx | 26 +++++++++++++++++----- docs/content/docs/getting-started.mdx | 2 +- src/monoprop/_backend.py | 9 ++++---- 5 files changed, 38 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3d3675c0c..1bc744a94 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,15 @@ pip install monoprop # or: uv add monoprop 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; the minimum versions are listed in the -[parallelism docs](https://docs.monoprop.algorithmiq.tech/features/parallelism). Without one, add it -from PyPI: +MPI, MVAPICH, Cray MPICH) or Open MPI, as provided by your cluster, package manager or own build: ```bash -pip install "monoprop[mpi]" mpich # or openmpi +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 diff --git a/docs/content/docs/building.mdx b/docs/content/docs/building.mdx index 011ac30d5..b001a0983 100644 --- a/docs/content/docs/building.mdx +++ b/docs/content/docs/building.mdx @@ -18,7 +18,7 @@ mechanism differs by build: 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 or for the C++ library; see [Parallelism](/features/parallelism#enabling-mpi). +implementation that offers a different ABI or for the C++ library; see [Parallelism](/features/parallelism#enabling-mpi). ## Prerequisites @@ -199,10 +199,11 @@ 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 core twice: 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. + + 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 diff --git a/docs/content/docs/features/parallelism.mdx b/docs/content/docs/features/parallelism.mdx index 5491f69f9..836a066ff 100644 --- a/docs/content/docs/features/parallelism.mdx +++ b/docs/content/docs/features/parallelism.mdx @@ -148,14 +148,27 @@ one partition per rank. ### Enabling MPI -The PyPI wheels ship an MPI backend for the MPICH family (MPICH, Intel MPI, MVAPICH, Cray MPICH) -and for Open MPI, and use the MPI library mpi4py finds on the machine. Install mpi4py with the -`mpi` extra; on a machine without MPI, add an implementation from PyPI as well: +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]" mpich # or openmpi +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 | @@ -171,5 +184,8 @@ An older version is rejected when the MPI backend loads 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 Cray systems, load `cray-mpich-abi` so that `libmpi.so.12` is available, as mpi4py requires. +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 72d96d0e5..c62dc3edc 100644 --- a/docs/content/docs/getting-started.mdx +++ b/docs/content/docs/getting-started.mdx @@ -20,7 +20,7 @@ If you use [uv](https://docs.astral.sh/uv/) as package manager: uv add monoprop ``` -These prebuilt wheels include an MPI backend for the MPICH family and Open MPI. To run across +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). diff --git a/src/monoprop/_backend.py b/src/monoprop/_backend.py index 3925db782..c37742818 100644 --- a/src/monoprop/_backend.py +++ b/src/monoprop/_backend.py @@ -12,11 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Pick the extension module that serves a propagator. +"""Select the extension module that runs a propagator. -``comm=None`` is served by the serial ``monoprop._core``. A communicator is served by -``monoprop._core_mpi``, loaded on first use from the file built for the MPI ABI family mpi4py uses: -mpi4py hands over ``MPI_Comm`` handles whose type depends on that family. +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 From 43d6204e24fa6dab49048cdaec946a936ad0c2b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 26 Oct 2026 19:05:18 +0100 Subject: [PATCH 19/20] =?UTF-8?q?fix(wheels):=20=F0=9F=90=9B=20keep=20macO?= =?UTF-8?q?S=20MPI=20search=20paths=20and=20load=20floor-version=20MPIs=20?= =?UTF-8?q?in=20wheel=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delocate strips absolute RPATHs by default, which removed the Homebrew, MacPorts and /usr/local MPI search directories from the macOS MPI binaries; repair now passes --no-sanitize-rpaths and check_wheel.py requires those directories on every macOS MPI binary. For the floor-version wheel tests: the setup action applies mpi4py's documented libmpi.so.12 link for Ubuntu 22.04's MPICH, and test-wheel-mpi.sh puts impi-rt 2021.1's libfabric on the loader path as Intel's vars script does, since that release's libmpi.so.12 has no RUNPATH. The post-build link check skips HPE's documentation host, whose incomplete certificate chain lychee rejects. Assisted-by: Pi:claude-opus-5-5 --- .github/actions/setup/action.yml | 7 +++++++ .lychee.postbuild.toml | 3 +++ tests/test_check_wheel.py | 19 +++++++++++++++++-- tools/cibw/check_wheel.py | 5 +++++ tools/cibw/repair-wheel.sh | 5 ++++- tools/cibw/test-wheel-mpi.sh | 6 ++++++ 6 files changed, 42 insertions(+), 3 deletions(-) diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 4c1a71bc0..d33aa5e58 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -94,3 +94,10 @@ runs: 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/.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/tests/test_check_wheel.py b/tests/test_check_wheel.py index 6a5fb369a..8918e4f5a 100644 --- a/tests/test_check_wheel.py +++ b/tests/test_check_wheel.py @@ -114,15 +114,30 @@ def test_empty_rpath_entry_fails() -> None: ] -def test_macos_mpi_module_may_search_homebrew() -> None: +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", "/opt/homebrew/lib"), + ("@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) diff --git a/tools/cibw/check_wheel.py b/tools/cibw/check_wheel.py index d93bb714e..401e0f605 100644 --- a/tools/cibw/check_wheel.py +++ b/tools/cibw/check_wheel.py @@ -120,6 +120,11 @@ def audit_binary(binary: Binary, *, macos: bool) -> list[str]: 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( diff --git a/tools/cibw/repair-wheel.sh b/tools/cibw/repair-wheel.sh index 850264061..0986e6b8d 100755 --- a/tools/cibw/repair-wheel.sh +++ b/tools/cibw/repair-wheel.sh @@ -17,7 +17,10 @@ case "$(uname)" in # 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. - delocate-wheel -v --require-archs "$(uname -m)" --ignore-missing-dependencies \ + # --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" diff --git a/tools/cibw/test-wheel-mpi.sh b/tools/cibw/test-wheel-mpi.sh index 7154575da..23b13bb9b 100755 --- a/tools/cibw/test-wheel-mpi.sh +++ b/tools/cibw/test-wheel-mpi.sh @@ -42,6 +42,12 @@ 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. From c0e4623b8b193e1c4cdca07980ab8276b7545eb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Di=20Remigio=20Eik=C3=A5s?= Date: Mon, 26 Oct 2026 19:24:58 +0100 Subject: [PATCH 20/20] =?UTF-8?q?refactor(cmake):=20=E2=99=BB=EF=B8=8F=20t?= =?UTF-8?q?ake=20monoprop=5FWHEEL=5FBUILD=20straight=20from=20CIBUILDWHEEL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The variable now holds the environment value and is tested as a boolean, so an unset or empty CIBUILDWHEEL is false. Assisted-by: Pi:claude-opus-5-5 --- CMakeLists.txt | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 5cd48ccf4..40a37a222 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -52,12 +52,9 @@ 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. -if("$ENV{CIBUILDWHEEL}" STREQUAL "1") - set(monoprop_WHEEL_BUILD ON) -else() - set(monoprop_WHEEL_BUILD OFF) -endif() +# 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)