diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index bd71052d5..875b48381 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,7 @@ jobs: mpi: "on" runs-on: ${{ matrix.runner}} + timeout-minutes: 60 env: monoprop_ENABLE_MPI: "${{ matrix.mpi }}" @@ -114,13 +115,11 @@ jobs: - name: Run Python tests under MPI if: matrix.mpi == 'on' - run: just test-py-mpi 2 - - - name: Run Python MPI marker rank matrix - if: matrix.mpi == 'on' - run: just test-py-mpi "1;2;4" -m mpi + run: just test-py-mpi "${{ steps.test-group-label.outputs.label }}" + # !cancelled(): a failing test step must still report the results it wrote. - name: Upload serial test results to Codecov + if: ${{ !cancelled() }} uses: codecov/codecov-action@v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} @@ -129,12 +128,18 @@ jobs: disable_search: true fail_ci_if_error: true - - name: Upload MPI C++ test results to Codecov - if: matrix.mpi == 'on' + - name: Upload MPI test results to Codecov + if: ${{ !cancelled() && matrix.mpi == 'on' }} uses: codecov/codecov-action@v7.1.1 with: token: ${{ secrets.CODECOV_TOKEN }} - files: ctest-mpi-${{ steps.test-group-label.outputs.label }}.xml + files: >- + ctest-mpi-${{ steps.test-group-label.outputs.label }}.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks1.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks2.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks4.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks3-splitmix.xml, + pytest-mpi-${{ steps.test-group-label.outputs.label }}-ranks2-partitions2.xml report_type: test_results disable_search: true fail_ci_if_error: true diff --git a/README.md b/README.md index 1bc744a94..12314f87d 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ just test-mpi # Python + C++ tests under MPI ``` See the [testing guide](https://docs.monoprop.algorithmiq.tech/testing) -for the with/without-MPI details and the rank matrix. CI has explicit MPI-enabled +for the with/without-MPI details and the MPI configurations. CI has explicit MPI-enabled lanes on Linux x86-64, Linux ARM64, and macOS; installing the `mpi` extra alone does not enable the C++ MPI build. CI requires a registered MPI CTest variant, and each whole-suite MPI run has a 600-second deadlock timeout. Source builds diff --git a/cpp/tests/README.md b/cpp/tests/README.md index f33e03b16..b9f14c9fc 100644 --- a/cpp/tests/README.md +++ b/cpp/tests/README.md @@ -10,11 +10,15 @@ discovers every Boost.Test case and registers it twice: - **`serial`**: the case run in-process with `MPI_COMM_SELF`. - **`mpi`** (+ rank-specific `mpi-`): the whole suite wrapped in - `mpiexec -n ` for each rank in `monoprop_MPI_TEST_PROCS` (default `2;4`), + `mpiexec -n ` for each rank in `monoprop_MPI_TEST_PROCS` (default `2;4`, + also labelled `mpi-linear`), plus splitmix-routing entries for each rank in + `monoprop_MPI_SPLITMIX_TEST_PROCS` (default `2;3;4`, also labelled + `mpi-splitmix`; a CMake cache variable only, set through `SKBUILD_CMAKE_DEFINE`), registered when an MPI launcher is detected. -Cases that need multiple ranks check `monoprop::mpi::size(MPI_COMM_WORLD)` and -skip (with a message) when run with too few. +Cases that need a particular world declare it with a precondition from +`RankPreconditions.h`; Boost reports them as skipped elsewhere, and CTest marks +those serial entries as skipped. The custom `main()` in `unit_tests.cpp` initializes MPI and forces `monoprop_PARTITIONS=off`, so white-box tests observe the single-partition engine. @@ -77,6 +81,8 @@ name and cannot address suite-nested cases, tests use flat - **`dense_query_reference.h`**: the retired dense query record, frozen as the independent oracle for `sparse_query_tests.cpp`. Test-only, and not kept in sync with the wire format. +- **`RankPreconditions.h`**: Boost preconditions `min_ranks`, `power_of_two_ranks`, + `even_ranks`, `linear_routing`, `default_routing`. - **`TestData.{h,cpp}`**: the `CaseData` struct and msgpack fixture loader. - **`boost-test.cmake` / `boostAddTests.cmake`**: CMake test discovery. @@ -117,7 +123,9 @@ name and cannot address suite-nested cases, tests use flat (MPI-only), `partition_equivalence_tests.cpp`, `mpi_distributed_layer_equivalence.cpp`, `mpi_fresh_insert_equivalence.cpp` (serial↔world equivalence of the Schrödinger fused-resolve fresh-insert arms, - Majorana + native Pauli; self-skips at world size 1). + Majorana + native Pauli; self-skips at world size 1), + `mpi_routing_geometry_tests.cpp` (linear routing refuses a 3-rank + communicator). - **Simulator / operator lifecycle**: `simulator_copy_tests.cpp`, `update_initial_operator.cpp`, `ctor_validation_tests.cpp` (constructor guard rails + MPGraph bounds). @@ -134,9 +142,7 @@ CMake wraps the whole suite in `mpiexec -n ` for each rank in `monoprop_MPI_TEST_PROCS` (default `2;4`) — one CTest entry per rank count, not per case, because the ranks have to reach the same collectives. Each entry has a 600-second timeout, so a collective deadlock fails instead of occupying the -runner indefinitely. For exhaustive -rank coverage, run `just test-mpi '1;2;4'` or configure -`-Dmonoprop_MPI_TEST_PROCS='1;2;4'`. To run a single case under MPI while +runner indefinitely. `just test-mpi` runs the same entries CI does. To run a single case under MPI while debugging, invoke the binary directly: `mpirun -n 2 build/editable/Release-mpi/bin/monoprop_unit_tests.x --run_test=`. @@ -144,6 +150,7 @@ debugging, invoke the binary directly: 1. Add a `*.cpp` with flat `BOOST_AUTO_TEST_CASE`s (shared name prefix). 2. Reuse the shared helpers above rather than copying oracle/harness code. -3. For MPI-required scenarios, check `monoprop::mpi::size(MPI_COMM_WORLD)` and - skip if `< 2`. +3. For a case that needs several ranks, add a precondition: + `BOOST_AUTO_TEST_CASE(name, *test_utils::min_ranks(2))`. Never `return` early: + that passes without checking anything. 4. Rebuild to register the new cases with CTest. diff --git a/cpp/tests/RankPreconditions.h b/cpp/tests/RankPreconditions.h new file mode 100644 index 000000000..ac8735ed1 --- /dev/null +++ b/cpp/tests/RankPreconditions.h @@ -0,0 +1,89 @@ +// 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. + +#pragma once + +#include +#include +#include +#include +#include +#include + +#include + +#include "monoprop/detail/mpi/MPICompat.h" +#include "monoprop/detail/mpi/Routing.h" + +/* + * Boost.Test preconditions for cases that need a particular MPI world. A case whose precondition + * fails is reported as skipped, with the reason, instead of passing without checking anything; + * CTest turns that report into a skip for the per-case serial entries. + */ +namespace test_utils { + +namespace rank_detail { + +inline auto world_size_precondition(std::function accepts, std::string need) + -> boost::unit_test::decorator::precondition { + return boost::unit_test::precondition( + [accepts = std::move(accepts), need = std::move(need)](boost::unit_test::test_unit_id) { + const int ranks = monoprop::mpi::size(MPI_COMM_WORLD); + boost::test_tools::assertion_result result(accepts(ranks)); + result.message() << "needs " << need << ", MPI_COMM_WORLD has " << ranks; + return result; + }); +} + +} // namespace rank_detail + +/// Requires at least @p count ranks in MPI_COMM_WORLD. +inline auto min_ranks(int count) -> boost::unit_test::decorator::precondition { + return rank_detail::world_size_precondition([count](int ranks) { return ranks >= count; }, + std::format("at least {} ranks", count)); +} + +/// Requires a power-of-two rank count of at least @p count, which the XOR pairing needs. +inline auto power_of_two_ranks(int count = 2) -> boost::unit_test::decorator::precondition { + return rank_detail::world_size_precondition( + [count](int ranks) { return ranks >= count && std::has_single_bit(static_cast(ranks)); }, + std::format("a power-of-two rank count of at least {}", count)); +} + +/// Requires an even rank count, so that `rank ^ 1` pairs every rank. +inline auto even_ranks() -> boost::unit_test::decorator::precondition { + return rank_detail::world_size_precondition([](int ranks) { return ranks >= 2 && ranks % 2 == 0; }, + "an even rank count"); +} + +/// Requires monoprop_ROUTING to resolve to linear routing. +inline auto linear_routing() -> boost::unit_test::decorator::precondition { + return boost::unit_test::precondition([](boost::unit_test::test_unit_id) { + boost::test_tools::assertion_result result(monoprop::routing::linear_requested()); + result.message() << "needs linear routing, but monoprop_ROUTING selects splitmix"; + return result; + }); +} + +/// Requires monoprop_ROUTING to be unset, so that the shipped default is what runs. +inline auto default_routing() -> boost::unit_test::decorator::precondition { + return boost::unit_test::precondition([](boost::unit_test::test_unit_id) { + const char *mode = std::getenv("monoprop_ROUTING"); + boost::test_tools::assertion_result result(mode == nullptr || *mode == '\0'); + result.message() << "needs monoprop_ROUTING unset, but it is set"; + return result; + }); +} + +} // namespace test_utils diff --git a/cpp/tests/boost-test.cmake b/cpp/tests/boost-test.cmake index f1d4c1a5a..1b8151d5d 100644 --- a/cpp/tests/boost-test.cmake +++ b/cpp/tests/boost-test.cmake @@ -1,12 +1,23 @@ # 4 as well as 2: at R = 2 the only shifts are 0 and 1, so a non-zero shift always pairs a rank with # its single neighbour and no case distinguishes the shift from the rank index. R = 4 gives shifts # 0..3 and a dense pairwise round of 2 * (R - 1) posts, which R = 2 cannot tell from a single leg. +# Of these defaults only 4 runs mpi_routing_geometry_tests.cpp, which needs at least 3 ranks +# under linear routing. set( monoprop_MPI_TEST_PROCS "2;4" CACHE STRING "Semicolon-separated list of ranks for MPI test variants" ) + +# Linear routing, the default, refuses a rank count that is not a power of two, so 3 ranks run +# only here; 2 and 4 rerun the dense all-to-all at the counts linear routing also covers. +set( + monoprop_MPI_SPLITMIX_TEST_PROCS + "2;3;4" + CACHE STRING + "Semicolon-separated list of ranks for the MPI test variants run with monoprop_ROUTING=splitmix" +) set(_monoprop_mpiexec "${MPIEXEC_EXECUTABLE}") if(NOT _monoprop_mpiexec) find_program( @@ -73,6 +84,7 @@ function(discover_tests TARGET) "TEST_LIST=${_TEST_LIST}" -D "CTEST_FILE=${ctest_tests_file}" -D "TEST_ENABLE_MPI_VARIANTS=${_enable_mpi_variants}" -D "TEST_MPI_NUMPROCS=${monoprop_MPI_TEST_PROCS}" -D + "TEST_MPI_SPLITMIX_NUMPROCS=${monoprop_MPI_SPLITMIX_TEST_PROCS}" -D "MPIEXEC_EXECUTABLE=${_monoprop_mpiexec}" -D "MPIEXEC_NUMPROC_FLAG=${_monoprop_mpiexec_numproc_flag}" -D "MPIEXEC_PREFLAGS=${MPIEXEC_PREFLAGS}" -D diff --git a/cpp/tests/boostAddTests.cmake b/cpp/tests/boostAddTests.cmake index 32834ca29..8014ff29a 100644 --- a/cpp/tests/boostAddTests.cmake +++ b/cpp/tests/boostAddTests.cmake @@ -18,23 +18,29 @@ if(TEST_ENABLE_MPI_VARIANTS AND NOT MPIEXEC_NUMPROC_FLAG) ) endif() -set(_mpi_ranks) -if(TEST_ENABLE_MPI_VARIANTS) - if("${TEST_MPI_NUMPROCS}" STREQUAL "") - set(TEST_MPI_NUMPROCS 2) - endif() - - foreach(_rank IN LISTS TEST_MPI_NUMPROCS) +# Positive integers, deduplicated; an empty list is legal and registers nothing. +function(validated_ranks OUT LIST_NAME) + set(_ranks ${${LIST_NAME}}) + foreach(_rank IN LISTS _ranks) if(NOT _rank MATCHES "^[1-9][0-9]*$") message( FATAL_ERROR - "Invalid MPI rank '${_rank}' in TEST_MPI_NUMPROCS='${TEST_MPI_NUMPROCS}'. Use positive integers." + "Invalid MPI rank '${_rank}' in ${LIST_NAME}='${${LIST_NAME}}'. Use positive integers." ) endif() endforeach() + list(REMOVE_DUPLICATES _ranks) + set(${OUT} ${_ranks} PARENT_SCOPE) +endfunction() - set(_mpi_ranks ${TEST_MPI_NUMPROCS}) - list(REMOVE_DUPLICATES _mpi_ranks) +set(_mpi_ranks) +set(_mpi_splitmix_ranks) +if(TEST_ENABLE_MPI_VARIANTS) + if("${TEST_MPI_NUMPROCS}" STREQUAL "") + set(TEST_MPI_NUMPROCS 2) + endif() + validated_ranks(_mpi_ranks TEST_MPI_NUMPROCS) + validated_ranks(_mpi_splitmix_ranks TEST_MPI_SPLITMIX_NUMPROCS) endif() set(extra_args ${TEST_EXTRA_ARGS}) @@ -192,73 +198,110 @@ string( "${output}" ) -# process each line foreach(LINE ${LINES}) - # Remove trailing asterisk + # A data-driven case lists its children (_0, _1, ...) indented beneath it, and running the parent + # runs them all. Matching the name instead would also drop a case like `..._32_bit`. + if(LINE MATCHES "^[ \t]") + continue() + endif() string( REGEX REPLACE "\\*$" "" - CLEANED_LINE + test "${LINE}" ) - - # Trim whitespace - string(STRIP "${CLEANED_LINE}" test) - - # Check if the line doesn't contain _0, _1, etc. and is not empty - if(NOT test MATCHES "_[0-9]+" AND NOT "${test}" STREQUAL "") - register_variant("${test}" - COMMAND - "${TEST_EXECUTABLE}" - "--run_test=${test}" - "--report_level=detailed" - "--catch_system_errors=yes" - ${extra_args} - LABELS - serial - ENVIRONMENT - ${serial_env} - ) + string(STRIP "${test}" test) + if(test STREQUAL "") + continue() endif() -endforeach() - -# The MPI variants wrap the WHOLE suite in one mpiexec per rank count: the ranks must reach the -# same collectives, which per-case launches cannot guarantee. -if(TEST_ENABLE_MPI_VARIANTS AND MPIEXEC_EXECUTABLE) - foreach(_mpi_rank IN LISTS _mpi_ranks) - set(mpi_cmd "${MPIEXEC_EXECUTABLE}") - list( - APPEND mpi_cmd - "${MPIEXEC_NUMPROC_FLAG}" - "${_mpi_rank}" - ) - if(MPIEXEC_PREFLAGS) - list(APPEND mpi_cmd ${MPIEXEC_PREFLAGS}) - endif() - list( - APPEND mpi_cmd + # Boost reports a case whose precondition failed (e.g. too few ranks) as skipped with exit 0; + # without this CTest would count it as a pass. + register_variant("${test}" + COMMAND "${TEST_EXECUTABLE}" + "--run_test=${test}" "--report_level=detailed" "--catch_system_errors=yes" ${extra_args} - ) - if(MPIEXEC_POSTFLAGS) - list(APPEND mpi_cmd ${MPIEXEC_POSTFLAGS}) - endif() + LABELS + serial + ENVIRONMENT + ${serial_env} + PROPERTIES + SKIP_REGULAR_EXPRESSION + "Test case \"[^\"]+\" was skipped" + ) +endforeach() - register_variant("${TEST_TARGET}_mpi_${_mpi_rank}" - COMMAND - ${mpi_cmd} - LABELS - mpi - "mpi-${_mpi_rank}" - ENVIRONMENT - "OMPI_ALLOW_RUN_AS_ROOT=1" - "OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1" - PROPERTIES - TIMEOUT - 600 - ) +# The MPI variants wrap the WHOLE suite in one mpiexec per rank count: the ranks must reach the +# same collectives, which per-case launches cannot guarantee. No skip regex here: a case may skip at +# one rank count (e.g. it needs 4) while the rest of the suite runs. +# +# Open MPI refuses more ranks than slots, and 4 ranks exceed a 3-core runner; both spellings +# cover ORTE (4.x) and PRRTE (5.x), and other MPIs ignore them. +set( + _mpi_env + "OMPI_ALLOW_RUN_AS_ROOT=1" + "OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1" + "OMPI_MCA_rmaps_base_oversubscribe=1" + "PRTE_MCA_rmaps_default_mapping_policy=:oversubscribe" +) + +function(register_mpi_variant RANKS ROUTING) + set( + mpi_cmd + "${MPIEXEC_EXECUTABLE}" + "${MPIEXEC_NUMPROC_FLAG}" + "${RANKS}" + ) + if(MPIEXEC_PREFLAGS) + list(APPEND mpi_cmd ${MPIEXEC_PREFLAGS}) + endif() + list( + APPEND mpi_cmd + "${TEST_EXECUTABLE}" + "--report_level=detailed" + "--catch_system_errors=yes" + ${extra_args} + ) + if(MPIEXEC_POSTFLAGS) + list(APPEND mpi_cmd ${MPIEXEC_POSTFLAGS}) + endif() + + set(_name "${TEST_TARGET}_mpi_${RANKS}") + # Set for linear too: an inherited monoprop_ROUTING=splitmix would otherwise run splitmix under + # the mpi-linear label and leave linear routing untested. + set( + _env + ${_mpi_env} + "monoprop_ROUTING=${ROUTING}" + ) + if(ROUTING STREQUAL "splitmix") + string(APPEND _name "_splitmix") + endif() + register_variant("${_name}" + COMMAND + ${mpi_cmd} + LABELS + mpi + "mpi-${RANKS}" + "mpi-${ROUTING}" + ENVIRONMENT + ${_env} + PROPERTIES + TIMEOUT + 600 + ) + set(script "${script}" PARENT_SCOPE) + set(tests ${tests} PARENT_SCOPE) +endfunction() + +if(TEST_ENABLE_MPI_VARIANTS AND MPIEXEC_EXECUTABLE) + foreach(_mpi_rank IN LISTS _mpi_ranks) + register_mpi_variant("${_mpi_rank}" linear) + endforeach() + foreach(_mpi_rank IN LISTS _mpi_splitmix_ranks) + register_mpi_variant("${_mpi_rank}" splitmix) endforeach() endif() diff --git a/cpp/tests/exact_upper_atol_rescue.cpp b/cpp/tests/exact_upper_atol_rescue.cpp index 3243d965d..9c48464b0 100644 --- a/cpp/tests/exact_upper_atol_rescue.cpp +++ b/cpp/tests/exact_upper_atol_rescue.cpp @@ -18,6 +18,7 @@ #include #include +#include "RankPreconditions.h" #include "TestUtilities.h" // upper_atol rescue: a structural cutoff of 0 rejects every non-identity partner, and upper_atol = 0 @@ -32,8 +33,6 @@ using namespace monoprop; constexpr double kEnergyAtol = 1e-9; -enum class CommMode { Self, World }; - // build_simulator cannot express this: it hardcodes cutoff = 2*NumModes. template auto build_zero_cutoff_full_rescue(const CaseData& data, MPI_Comm comm) -> MonomialPropagator { @@ -58,22 +57,22 @@ auto evaluate_zero_cutoff_full_rescue_energy(MonomialPropagator& simul } // namespace // One test per (fixture, comm) so a failure pinpoints the configuration. -#define MAKE_ZERO_CUTOFF_RESCUE_TEST(NAME, FixtureType, CommToken) \ - BOOST_FIXTURE_TEST_CASE(NAME##_##CommToken, FixtureType) { \ - MPI_Comm comm = (CommMode::CommToken == CommMode::Self) ? MPI_COMM_SELF : MPI_COMM_WORLD; \ - if (CommMode::CommToken == CommMode::World && mpi::size(comm) == 1) { \ - BOOST_TEST_MESSAGE("Skipping multi-rank scenario for " #NAME " (world size=1)"); \ - return; \ - } \ - auto simulator = build_zero_cutoff_full_rescue(data, comm); \ +#define MAKE_ZERO_CUTOFF_RESCUE_TEST(NAME, FixtureType, COMM, ...) \ + BOOST_FIXTURE_TEST_CASE(NAME, FixtureType __VA_OPT__(, ) __VA_ARGS__) { \ + auto simulator = build_zero_cutoff_full_rescue(data, COMM); \ const double energy = evaluate_zero_cutoff_full_rescue_energy(simulator, data); \ BOOST_CHECK_SMALL(std::abs(energy - data.actual_expval), kEnergyAtol); \ } -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact, ExampleDataFix, Self) -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact, ExampleDataFix, World) - -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih, LihFixture, Self) -MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih, LihFixture, World) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_Self, ExampleDataFix, MPI_COMM_SELF) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_World, + ExampleDataFix, + MPI_COMM_WORLD, + *test_utils::min_ranks(2)) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih_Self, LihFixture, MPI_COMM_SELF) +MAKE_ZERO_CUTOFF_RESCUE_TEST(zero_cutoff_upper_atol_zero_is_exact_lih_World, + LihFixture, + MPI_COMM_WORLD, + *test_utils::min_ranks(2)) #undef MAKE_ZERO_CUTOFF_RESCUE_TEST diff --git a/cpp/tests/flat_exchange_tests.cpp b/cpp/tests/flat_exchange_tests.cpp index c50083231..7d0608724 100644 --- a/cpp/tests/flat_exchange_tests.cpp +++ b/cpp/tests/flat_exchange_tests.cpp @@ -24,6 +24,7 @@ #include #include +#include "RankPreconditions.h" #include "monoprop/detail/mpi/Comm.h" #include "monoprop/detail/mpi/Exchange.h" @@ -118,11 +119,8 @@ auto run_round(int n, // A dense gate is today's collective whatever the layout holds -- including the sparse layout the // pairwise arm exists for. The layout must not be able to move the branch. -BOOST_AUTO_TEST_CASE(flat_exchange_dense_gate_always_takes_the_collective) { +BOOST_AUTO_TEST_CASE(flat_exchange_dense_gate_always_takes_the_collective, *test_utils::even_ranks()) { const int n = world_size(); - if (n < 2 || (n % 2) != 0) { - return; - } const int me = world_rank(); const int peer = me ^ 1; for (const bool dense : {true, false}) { @@ -147,11 +145,8 @@ BOOST_AUTO_TEST_CASE(flat_exchange_dense_gate_always_takes_the_collective) { // One peer, me ^ 1 -- an involution, so the count matrix stays symmetric and both ends drop the same // n - 1 legs. Two requests (one Irecv, one Isend) and the same bytes delivered. -BOOST_AUTO_TEST_CASE(flat_exchange_single_leg_takes_the_pairwise_path) { +BOOST_AUTO_TEST_CASE(flat_exchange_single_leg_takes_the_pairwise_path, *test_utils::even_ranks()) { const int n = world_size(); - if (n < 2 || (n % 2) != 0) { - return; - } const int me = world_rank(); const int peer = me ^ 1; constexpr int len = 3; @@ -170,11 +165,8 @@ BOOST_AUTO_TEST_CASE(flat_exchange_single_leg_takes_the_pairwise_path) { // Every leg active on the pairwise arm: it posts a pair per non-zero leg rather than falling back, so // the transport really is the gate's choice and not the layout's. The self leg is a copy, not a pair. -BOOST_AUTO_TEST_CASE(flat_exchange_dense_layout_on_the_pairwise_arm) { +BOOST_AUTO_TEST_CASE(flat_exchange_dense_layout_on_the_pairwise_arm, *test_utils::power_of_two_ranks()) { const int n = world_size(); - if (n < 2 || (n & (n - 1)) != 0) { - return; // off a power of two the gate resolves to dense, i.e. the collective - } const int me = world_rank(); const std::vector counts(static_cast(n), 1); const auto displs = displs_of(counts); @@ -192,11 +184,8 @@ BOOST_AUTO_TEST_CASE(flat_exchange_dense_layout_on_the_pairwise_arm) { // No active leg anywhere: nothing is posted, wait() drains nothing, and the recv buffer is left as the // caller sized it. A layer with no cross-rank partners takes this shape on EVERY rank at once -- the // symmetric layout is what makes that true -- so the pairwise arm is still the agreed one. -BOOST_AUTO_TEST_CASE(flat_exchange_empty_layout_posts_nothing) { +BOOST_AUTO_TEST_CASE(flat_exchange_empty_layout_posts_nothing, *test_utils::power_of_two_ranks()) { const int n = world_size(); - if (n < 2 || (n & (n - 1)) != 0) { - return; - } const std::vector counts(static_cast(n), 0); BOOST_REQUIRE_EQUAL(total_of(counts), 0); const std::vector send(1, 0); diff --git a/cpp/tests/hybrid_comm_tests.cpp b/cpp/tests/hybrid_comm_tests.cpp index 2910881ee..0545528e0 100644 --- a/cpp/tests/hybrid_comm_tests.cpp +++ b/cpp/tests/hybrid_comm_tests.cpp @@ -20,6 +20,8 @@ #include +#include "RankPreconditions.h" + #ifdef monoprop_ENABLE_MPI #include @@ -65,10 +67,7 @@ auto world_rank() -> int { } // namespace -BOOST_AUTO_TEST_CASE(hybrid_comm_flat_size_and_rank) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_flat_size_and_rank, *test_utils::min_ranks(2)) { const int R = world_size(); for (const int S : {1, 2, 3}) { std::vector seen_size(static_cast(S), -1); @@ -89,10 +88,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_flat_size_and_rank) { } // Each partition contributes its global id, so the expected total is sum_{g int { // The only direct coverage of HybridComm::alltoallv, and the only count matrix varying along both // indices, which is what pins the indexing of col_sum_ and recv_col_. -BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_pairwise_counts) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_pairwise_counts, *test_utils::min_ranks(2)) { const int R = world_size(); const int rounds = 12; for (const int S : {1, 2, 3}) { @@ -355,10 +339,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_pairwise_counts) { // The same pairwise counts through the fused verb, whose recv staging is sized from the count matrix // rather than from published rows. rc/rd are outputs here, and are checked. -BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_resolve_pairwise_counts) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_resolve_pairwise_counts, *test_utils::min_ranks(2)) { const int R = world_size(); const int rounds = 12; for (const int S : {1, 2, 3}) { @@ -424,10 +405,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_alltoallv_resolve_pairwise_counts) { } } -BOOST_AUTO_TEST_CASE(hybrid_comm_allreduce_sum_inplace_global) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_allreduce_sum_inplace_global, *test_utils::min_ranks(2)) { const int R = world_size(); for (const int S : {1, 2, 3}) { const int P = R * S; @@ -464,10 +442,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_allreduce_sum_inplace_global) { // Poison releases barrier waiters on every rank; the test completing at all proves that. Partition 0 poisons // before entering a collective, so no rank is committed to MPI and the partition-0 guard deliberately does // not fire. Poisoning inside a collective calls MPI_Abort instead, so it cannot be a ctest case. -BOOST_AUTO_TEST_CASE(hybrid_comm_poison_releases_waiters) { - if (world_size() < 2) { - return; - } +BOOST_AUTO_TEST_CASE(hybrid_comm_poison_releases_waiters, *test_utils::min_ranks(2)) { for (const int S : {2, 3}) { auto errs = run_hybrid(S, [&](HybridComm &hyb, int u) { if (u == 0) { @@ -515,11 +490,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_peer_is_an_involution) { // the two failure modes it can have are DROPPED data and a HANG -- neither of which a dense-path test // can see. Every rank derives the same pairing from the same shift, and a block whose destination is // not the peer must be empty: send only to the plan's peer and check the delivery is exactly that. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_delivers_only_to_its_peers) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_delivers_only_to_its_peers, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; // the XOR pairing needs a power-of-two rank count - } for (const int S : {1, 2, 3}) { const int P = R * S; for (int shift = 0; shift < R; ++shift) { @@ -570,11 +542,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_delivers_only_to_its_peers) { // Same contract on the S == 1 world, which takes the pure-MPI branch (MPI_Ialltoallv vs Isend/Irecv) // rather than HybridComm's staged one, and on the KNOWN-recv-counts path the response round uses. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_on_the_plain_mpi_path) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_on_the_plain_mpi_path, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } Comm c{MPI_COMM_WORLD}; for (int shift = 0; shift < R; ++shift) { const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = shift}; @@ -609,11 +578,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_on_the_plain_mpi_path) { // the response round's transpose is only as masked as whatever produced it. No receive is ever posted // for a non-peer, so an unmasked count sizes recv_buffer for bytes nothing writes and wait_into would // hand that slot to the caller as data. Both the plain-MPI and the staged HybridComm path must drop it. -BOOST_AUTO_TEST_CASE(hybrid_comm_known_recv_counts_are_masked_through_the_plan) { +BOOST_AUTO_TEST_CASE(hybrid_comm_known_recv_counts_are_masked_through_the_plan, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } constexpr int kReal = 4; constexpr int kBogus = 7; // what a stale or unmasked transpose would claim a non-peer is sending for (int shift = 0; shift < R; ++shift) { @@ -694,11 +660,8 @@ auto sparse_tag(int src, int dst, int j) -> int { // A zero-count leg is where a send/recv posting asymmetry deadlocks rather than mis-delivers: both ends // must skip on the SAME value. Nothing above ever sends an empty block over a real message, so force // one -- the lower-numbered end of every pair sends nothing while its peer sends four. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_with_an_empty_leg) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_with_an_empty_leg, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); constexpr int kLen = 4; for (int shift = 1; shift < R; ++shift) { // shift 0 is the self peer, covered separately @@ -760,11 +723,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_with_an_empty_leg) { // shift == 0 makes every rank its own and only peer, so the sparse path's self slot is the whole // exchange -- and skip_self then removes the one leg that would have moved anything for a partition. // The remaining S-1 in-rank legs must still arrive. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_skip_self_at_shift_zero) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_skip_self_at_shift_zero, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = 0}; BOOST_REQUIRE_EQUAL(plan.peer(me, 0), me); @@ -820,11 +780,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_skip_self_at_shift_zero) { // Engine.h's run_exchange posts both its rounds on ONE communicator under kFlatPayloadTag, and the // non-overtaking argument in Pairwise.h is what says a round-2 receive cannot match a round-1 send. // Two back-to-back sparse rounds, the second on the transpose of the first's counts, assert it. -BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_back_to_back_rounds) { +BOOST_AUTO_TEST_CASE(hybrid_comm_sparse_plan_back_to_back_rounds, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); Comm c{MPI_COMM_WORLD}; for (int shift = 0; shift < R; ++shift) { @@ -912,11 +869,8 @@ auto muted_count(int src, int dst, int S) -> int { // pack_send_ running underneath. Only the sparse arm splits -- plan.dense() is a blocking MPI_Alltoall // -- so drive the SAME peer-masked layout through both arms and require the delivered blocks to agree // element for element. A dropped or torn count block changes a length here, not just a value. -BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_matches_the_dense_arm) { +BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_matches_the_dense_arm, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; // the XOR pairing needs a power-of-two rank count - } const int me = world_rank(); for (int shift = 0; shift < R; ++shift) { const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = shift}; @@ -971,11 +925,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_matches_the_dense_arm // shift == 0 at full bits makes this rank its own and only peer, so the count round posts NOTHING: // sparse_pairwise memcpys the S*S-int block in place and the B3→B4 wait must be a no-op rather than a // wait on stale requests. The S^2 in-rank payload legs still have to arrive. -BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_self_peer_only) { +BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_self_peer_only, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = 0}; BOOST_REQUIRE_EQUAL(plan.peer(me, 0), me); @@ -1015,11 +966,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_self_peer_only) { // A peer with nothing to send is where the split can hang: pack_send_ copies zero bytes in B2→B3 while // the count round is still live, and the payload round posts no leg for it at all. Back-to-back rounds, // the second silent, so the second also runs over the first's staging high-water bytes. -BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_zero_count_peer) { +BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_zero_count_peer, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); for (int shift = 1; shift < R; ++shift) { // shift 0 is the self peer, covered above const monoprop::mpi::PeerPlan plan{.sparse = true, .shift = shift}; @@ -1076,11 +1024,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_resolve_split_count_round_zero_count_peer) { // The multi-peer and lossy-plan refusals in the same window are NOT exercised here: they reach MPI // through guard_partition0_, which turns a throw into MPI_Abort, so a case for them would take the // runner down with it. -BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_reads_every_partitions_row) { +BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_reads_every_partitions_row, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; // the XOR pairing needs a power-of-two rank count - } const int me = world_rank(); const int peer = me ^ 1; // shift 1, so every rank derives the same pairing constexpr int kLen = 5; @@ -1137,11 +1082,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_reads_every_partitions_row) { // The rank-uniform gate picks the transport; the peer set is only a narrowing, so a rank whose rows do // not pin one peer down keeps the full set and posts point-to-point over all of it. Before, this shape // aborted the job from inside guard_partition0_. -BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_falls_back_to_every_peer) { +BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_falls_back_to_every_peer, *test_utils::power_of_two_ranks(4)) { const int R = world_size(); - if (R < 4 || (R & (R - 1)) != 0) { - return; // two distinct non-self peers is the point, so R = 2 cannot show it - } const int me = world_rank(); // XOR by 1 and by 2: an involution each, so the count matrix stays symmetric and every rank spans // exactly two peers -- which is what makes this a valid layout the narrowing simply cannot use. @@ -1203,11 +1145,8 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_derived_wire_plan_falls_back_to_every_peer) { // A sparse plan is a public aggregate, so a shift outside the rank-index width reaches the transports // as an out-of-range vector index and an invalid MPI rank. Every sparse entry point refuses it first, // and it must be an exception rather than an assert: release builds are where it would be indexed. -BOOST_AUTO_TEST_CASE(hybrid_comm_unroutable_peer_plan_is_refused) { +BOOST_AUTO_TEST_CASE(hybrid_comm_unroutable_peer_plan_is_refused, *test_utils::power_of_two_ranks()) { const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const monoprop::mpi::PeerPlan bad{.sparse = true, .shift = R}; const int P = R; std::vector> send(static_cast(P)); @@ -1222,7 +1161,7 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_unroutable_peer_plan_is_refused) { // MPI reads the payload buffers until the requests retire, so the handle that owns them must not be // copyable (two owners, one set of requests) and must not be destroyable while they are live. -BOOST_AUTO_TEST_CASE(hybrid_comm_pending_alltoallv_owns_its_requests) { +BOOST_AUTO_TEST_CASE(hybrid_comm_pending_alltoallv_owns_its_requests, *test_utils::power_of_two_ranks()) { using Pending = monoprop::mpi::PendingAlltoallv; static_assert(!std::is_copy_constructible_v); static_assert(!std::is_copy_assignable_v); @@ -1231,9 +1170,6 @@ BOOST_AUTO_TEST_CASE(hybrid_comm_pending_alltoallv_owns_its_requests) { // Dropping a handle without waiting must still complete the transfer, not free the buffers under it. const int R = world_size(); - if (R < 2 || (R & (R - 1)) != 0) { - return; - } const int me = world_rank(); Comm c{MPI_COMM_WORLD}; std::vector> send(static_cast(R)); diff --git a/cpp/tests/mpi_distributed_layer_equivalence.cpp b/cpp/tests/mpi_distributed_layer_equivalence.cpp index 152eb2b75..9b8e62a81 100644 --- a/cpp/tests/mpi_distributed_layer_equivalence.cpp +++ b/cpp/tests/mpi_distributed_layer_equivalence.cpp @@ -14,6 +14,7 @@ #include +#include #include #include #include @@ -21,6 +22,7 @@ #include #include "PauliTestOracle.h" +#include "RankPreconditions.h" #include "TestUtilities.h" #include "monoprop/MonomialPropagator.h" #include "monoprop/detail/mpi/MPICompat.h" @@ -36,7 +38,8 @@ using namespace test_utils; using pauli_oracle::slots_of_string; constexpr size_t kNumModes = 8; -constexpr unsigned int kCutoff = 4; +// random_exact's observable has only terms of length >= 10, so a lower cutoff leaves the energy 0. +constexpr unsigned int kCutoff = 10; struct TestInputs { CaseData data; @@ -46,6 +49,12 @@ auto load_inputs() -> TestInputs { return {load_case_data("random_exact.msgpack")}; } +// A reference that truncation zeroes out, or leaves exact, matches a broken exchange too. +auto require_truncated_reference(double serial, double exact) -> void { + BOOST_REQUIRE_MESSAGE(std::abs(serial) > 1e-6, "serial reference is trivially zero: " << serial); + BOOST_REQUIRE_MESSAGE(!near(serial, exact, 1e-6), "serial reference is not truncated: " << serial); +} + auto run_energy(const TestInputs& inputs, MPI_Comm comm) -> double { MonomialPropagator sim(inputs.data.hamiltonian, kCutoff, @@ -61,23 +70,16 @@ auto run_energy(const TestInputs& inputs, MPI_Comm comm) -> double { return fn(inputs.data.parameters); } -BOOST_AUTO_TEST_CASE(rank_count_energy_within_fp_tolerance) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping cross-rank-count case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(rank_count_energy_within_fp_tolerance, *test_utils::min_ranks(2)) { const auto inputs = load_inputs(); const double e_serial = run_energy(inputs, MPI_COMM_SELF); + require_truncated_reference(e_serial, inputs.data.actual_expval); const double e_world = run_energy(inputs, MPI_COMM_WORLD); BOOST_TEST_MESSAGE("serial=" << e_serial << " world=" << e_world << " diff=" << (e_world - e_serial)); BOOST_TEST(near(e_serial, e_world)); } -BOOST_AUTO_TEST_CASE(gradient_rank_count_within_fp_tolerance) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping gradient cross-rank-count case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(gradient_rank_count_within_fp_tolerance, *test_utils::min_ranks(2)) { const auto& inputs = load_inputs(); auto run_gradient = [&](MPI_Comm comm) -> VecD { @@ -96,6 +98,8 @@ BOOST_AUTO_TEST_CASE(gradient_rank_count_within_fp_tolerance) { }; const auto g_serial = run_gradient(MPI_COMM_SELF); + BOOST_REQUIRE_MESSAGE(std::ranges::any_of(g_serial, [](double g) { return std::abs(g) > 1e-6; }), + "serial gradient is trivially zero"); const auto g_world = run_gradient(MPI_COMM_WORLD); BOOST_REQUIRE_EQUAL(g_serial.size(), g_world.size()); @@ -149,11 +153,7 @@ auto run_pauli_energy(MPI_Comm comm) -> double { return sim.expectation_value({}); } -BOOST_AUTO_TEST_CASE(pauli_rank_count_energy_within_fp_tolerance) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping Pauli cross-rank-count case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(pauli_rank_count_energy_within_fp_tolerance, *test_utils::min_ranks(2)) { const double e_serial = run_pauli_energy(MPI_COMM_SELF); const double e_world = run_pauli_energy(MPI_COMM_WORLD); BOOST_TEST_MESSAGE("pauli serial=" << e_serial << " world=" << e_world); @@ -184,13 +184,10 @@ auto run_energy_partitioned(const TestInputs& inputs, MPI_Comm comm, size_t part return {e, sim.size()}; } -BOOST_AUTO_TEST_CASE(hybrid_mpi_partition_energy_and_size_equivalence) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping hybrid case: requires at least 2 ranks."); - return; - } +BOOST_AUTO_TEST_CASE(hybrid_mpi_partition_energy_and_size_equivalence, *test_utils::min_ranks(2)) { const auto inputs = load_inputs(); const auto [e_serial, n_serial] = run_energy_partitioned(inputs, MPI_COMM_SELF, 1); + require_truncated_reference(e_serial, inputs.data.actual_expval); const auto [e_hybrid, n_local] = run_energy_partitioned(inputs, MPI_COMM_WORLD, 2); // Each rank's facade holds only its local partitions; the global term count is the cross-rank sum. const size_t n_hybrid_global = mpi::allreduce_sum(n_local, MPI_COMM_WORLD); diff --git a/cpp/tests/mpi_fresh_insert_equivalence.cpp b/cpp/tests/mpi_fresh_insert_equivalence.cpp index 2973131b6..8e86bb69d 100644 --- a/cpp/tests/mpi_fresh_insert_equivalence.cpp +++ b/cpp/tests/mpi_fresh_insert_equivalence.cpp @@ -20,12 +20,14 @@ #include +#include #include #include #include #include #include "PauliTestOracle.h" +#include "RankPreconditions.h" #include "TestUtilities.h" #include "monoprop/MonomialPropagator.h" #include "monoprop/detail/mpi/MPICompat.h" @@ -38,12 +40,14 @@ using pauli_oracle::slots_of_string; // schrodinger_cutoff engages the picture; a low structural cutoff plus the upper_atol = 0 rescue // forces most partners to be fresh inserts, so the miss arm runs on nearly every partner. +// Schrödinger 10, not lower: random_exact's observable terms all have length >= 10, and a lower +// state cutoff reads them all as 0. template auto run_schrodinger_majorana(const CaseData& data, MPI_Comm comm) -> double { MonomialPropagator sim(data.hamiltonian, /*cutoff=*/2U, data.initial_state, - /*schrodinger_cutoff=*/std::optional{4U}, + /*schrodinger_cutoff=*/std::optional{10U}, comm, /*lower_atol=*/std::nullopt, /*upper_atol=*/std::optional{0.0}, @@ -54,12 +58,11 @@ auto run_schrodinger_majorana(const CaseData& data, MPI_Comm comm) -> double { return energy_fn(VecD{}); } -BOOST_FIXTURE_TEST_CASE(mpi_fresh_insert_schrodinger_majorana_serial_world_equiv, ExampleDataFix) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping Schrödinger Majorana fresh-insert equivalence (world size = 1)."); - return; - } +BOOST_FIXTURE_TEST_CASE(mpi_fresh_insert_schrodinger_majorana_serial_world_equiv, + ExampleDataFix, + *test_utils::min_ranks(2)) { const double e_serial = run_schrodinger_majorana(data, MPI_COMM_SELF); + BOOST_REQUIRE_MESSAGE(std::abs(e_serial) > 1e-6, "serial reference is trivially zero: " << e_serial); const double e_world = run_schrodinger_majorana(data, MPI_COMM_WORLD); BOOST_TEST_MESSAGE("schrodinger majorana serial=" << e_serial << " world=" << e_world); BOOST_TEST(near(e_serial, e_world)); @@ -107,11 +110,7 @@ auto run_schrodinger_pauli(MPI_Comm comm) -> double { return sim.expectation_value({}); } -BOOST_AUTO_TEST_CASE(mpi_fresh_insert_schrodinger_pauli_serial_world_equiv) { - if (mpi::size(MPI_COMM_WORLD) < 2) { - BOOST_TEST_MESSAGE("Skipping Schrödinger Pauli fresh-insert equivalence (world size = 1)."); - return; - } +BOOST_AUTO_TEST_CASE(mpi_fresh_insert_schrodinger_pauli_serial_world_equiv, *test_utils::min_ranks(2)) { const double e_serial = run_schrodinger_pauli(MPI_COMM_SELF); const double e_world = run_schrodinger_pauli(MPI_COMM_WORLD); BOOST_TEST_MESSAGE("schrodinger pauli serial=" << e_serial << " world=" << e_world); diff --git a/cpp/tests/mpi_pare.cpp b/cpp/tests/mpi_pare.cpp index 0e57f1620..969952dfc 100644 --- a/cpp/tests/mpi_pare.cpp +++ b/cpp/tests/mpi_pare.cpp @@ -16,16 +16,13 @@ #include +#include "RankPreconditions.h" #include "TestUtilities.h" using namespace test_utils; -BOOST_AUTO_TEST_CASE(multi_rank_pare_expval_is_finite) { +BOOST_AUTO_TEST_CASE(multi_rank_pare_expval_is_finite, *test_utils::min_ranks(2)) { const int world_size = monoprop::mpi::size(MPI_COMM_WORLD); - if (world_size < 2) { - BOOST_TEST_MESSAGE("Skipping multi_rank_pare_expval_is_finite: MPI world size=" << world_size); - return; - } constexpr size_t NumModes = 8; constexpr double kExpvalAtol = 1e-9; diff --git a/cpp/tests/mpi_routing_geometry_tests.cpp b/cpp/tests/mpi_routing_geometry_tests.cpp new file mode 100644 index 000000000..557bc180b --- /dev/null +++ b/cpp/tests/mpi_routing_geometry_tests.cpp @@ -0,0 +1,62 @@ +// 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 + +#include + +#include "RankPreconditions.h" +#include "TestUtilities.h" +#include "monoprop/MonomialPropagator.h" +#include "monoprop/detail/mpi/MPICompat.h" +#include "monoprop/detail/mpi/Routing.h" + +#ifdef monoprop_ENABLE_MPI + +#include + +namespace { + +using namespace monoprop; +using namespace test_utils; + +constexpr size_t kNumModes = 8; + +} // namespace + +// Linear routing XORs rank indices, so it has no routing for 3 ranks: construction must refuse on +// every member rank instead of hanging the first exchange. +BOOST_AUTO_TEST_CASE(linear_routing_refuses_a_three_rank_communicator, + *test_utils::min_ranks(3) * test_utils::linear_routing()) { + const int world_rank = mpi::rank(MPI_COMM_WORLD); + MPI_Comm three_ranks = MPI_COMM_NULL; + MPI_Comm_split(MPI_COMM_WORLD, world_rank < 3 ? 0 : MPI_UNDEFINED, world_rank, &three_ranks); + if (three_ranks == MPI_COMM_NULL) { + return; // outside the three-rank communicator there is nothing to construct + } + const auto data = load_case_data("random_exact.msgpack"); + BOOST_CHECK_THROW(static_cast(MonomialPropagator(data.hamiltonian, + 2 * kNumModes, + data.initial_state, + std::nullopt, + three_ranks, + std::nullopt, + std::nullopt, + CutoffType::Length, + std::nullopt)), + routing::UnroutableGeometry); + MPI_Comm_free(&three_ranks); +} + +#endif // monoprop_ENABLE_MPI diff --git a/cpp/tests/routing_tests.cpp b/cpp/tests/routing_tests.cpp index 1e3375b75..97be6f0bf 100644 --- a/cpp/tests/routing_tests.cpp +++ b/cpp/tests/routing_tests.cpp @@ -31,6 +31,7 @@ #include #include +#include "RankPreconditions.h" #include "monoprop/algebra/MajoranaAlgebra.h" #include "monoprop/detail/mpi/Comm.h" #include "monoprop/detail/mpi/MPIUtils.h" @@ -404,14 +405,8 @@ BOOST_AUTO_TEST_CASE(routing_gf2_rank_detects_a_degenerate_shift_set) { // The SHIPPED default, pinned by a test rather than left to a comment: with no environment override a // power-of-two rank count routes linearly at fanout 1, a single rank routes densely, and a geometry -// with no XOR structure is refused. Skipped when the environment does override it, because then the -// default is not what is under test. -BOOST_AUTO_TEST_CASE(routing_default_is_linear_where_the_geometry_allows_it) { - const char *mode = std::getenv("monoprop_ROUTING"); - if (mode != nullptr && *mode != '\0') { - BOOST_TEST_MESSAGE("routing overridden in the environment; default not under test"); - return; - } +// with no XOR structure is refused. +BOOST_AUTO_TEST_CASE(routing_default_is_linear_where_the_geometry_allows_it, *test_utils::default_routing()) { BOOST_TEST(routing::make_router(8, 14).is_linear()); BOOST_TEST(routing::make_router(128, 14).linear_bits() == 7U); BOOST_TEST(!routing::make_router(1, 112).is_linear()); // single rank: nothing to route between diff --git a/docs/content/docs/features/parallelism.mdx b/docs/content/docs/features/parallelism.mdx index 0a4d493d4..c135ae733 100644 --- a/docs/content/docs/features/parallelism.mdx +++ b/docs/content/docs/features/parallelism.mdx @@ -112,7 +112,7 @@ exchange, so it is checked collectively at startup and raised. 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. +without `comm` therefore runs serially on every rank: pass a duplicate of `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 @@ -122,11 +122,14 @@ built for raises [MPIAbiMismatchError][monoprop.exceptions.MPIAbiMismatchError], library older than the supported versions (see [Enabling MPI](#enabling-mpi)) raises [MPIVersionError][monoprop.exceptions.MPIVersionError]. -### Single-node (`MPI.COMM_SELF`) +### Single process (`comm=None`) + +Leave `comm` out, or pass `None`, to run in one process. That uses the serial backend, which +needs neither mpi4py nor an MPI library, and still partitions the operator across the cores (see +[Operator partitioning](#operator-partitioning-single-node)). ```python notest -from mpi4py import MPI -sim = MajoranaPropagator(..., comm=MPI.COMM_SELF) +sim = MajoranaPropagator(...) # comm=None ``` ### Multi-node (`MPI.COMM_WORLD`) diff --git a/docs/content/docs/testing.mdx b/docs/content/docs/testing.mdx index 9914593ef..04233d685 100644 --- a/docs/content/docs/testing.mdx +++ b/docs/content/docs/testing.mdx @@ -36,14 +36,25 @@ produce. The `just` recipes build an MPI-enabled extension and launch the suite under `mpiexec`: ```bash -just test-mpi # build with MPI, then run every leg -just test-py-mpi "1;2;4" -m mpi # MPI-marked tests across a rank matrix +just test-mpi # build with MPI, then every MPI leg CI runs +just test-py-mpi # the Python MPI legs only, against the installed build +``` + +`just test-py-mpi` runs the tests that use a world communicator (`-m mpi`) five times: on 1, 2 +and 4 ranks; on 3 ranks with `monoprop_ROUTING=splitmix`, since linear routing refuses a rank +count that is not a power of two; and on 2 ranks with `monoprop_PARTITIONS=2`. Each test times +out after 300 seconds, so a hung collective fails instead of stalling the run. To run one file +by hand: + +```bash +mpiexec --map-by :OVERSUBSCRIBE -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi ``` The suite's `tests/conftest.py` provides `--with-mpi`; no MPI pytest plugin is needed. Tests marked with `@pytest.mark.mpi` are skipped without that option. -Use `@pytest.mark.mpi(min_size=2)` when a test needs at least two ranks. -Under `--with-mpi`, only rank zero reports pytest output. +Use `@pytest.mark.mpi(min_size=2)` when a test needs at least two ranks. Under +`--with-mpi`, only rank zero reports pytest output; the other ranks print their failures to +stderr, tagged `[rank ]`. Under more than one rank, `tests/conftest.py` gives each rank its own pytest `--basetemp`, `pytest-of-/mpi-rank-` under `$PYTEST_DEBUG_TEMPROOT` when set, else the system temporary @@ -64,7 +75,7 @@ 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. 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 +is empty, and the `comm` fixture then has only its `None` case. 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`. @@ -135,20 +146,35 @@ fabric device per case, whether or not the test sends anything. `monoprop_TEST_E keep the full component set, since they exchange real messages. Turn it off by adding `SKBUILD_CMAKE_DEFINE="monoprop_TEST_EXCLUDE_MPI_FABRIC=OFF"` to the MPI build above. -CTest registers every Boost case individually as a `serial` variant. When the -build has MPI enabled and a launcher is found, it also registers the whole suite -once per rank count in `monoprop_MPI_TEST_PROCS`, labelled `mpi` and `mpi-` — -one entry per rank count rather than per case, because the ranks have to reach -the same collectives. Each MPI entry times out after 600 seconds, preventing a -collective deadlock from occupying the runner indefinitely. +CTest registers every Boost case individually as a `serial` variant. A case that needs +several ranks declares it with a precondition (`cpp/tests/RankPreconditions.h`); in a serial +entry Boost skips it and CTest reports it as skipped. When the build has MPI enabled and a +launcher is found, CTest also registers the whole suite once per rank count: linear routing +on the counts in `monoprop_MPI_TEST_PROCS` (default `2;4`, labels `mpi`, `mpi-`, +`mpi-linear`) and `monoprop_ROUTING=splitmix` on `monoprop_MPI_SPLITMIX_TEST_PROCS` +(default `2;3;4`, labels `mpi`, `mpi-`, `mpi-splitmix`). Each runs one `mpiexec` because +the ranks have to reach the same collectives, allows oversubscription, and times out after +600 seconds. Only `monoprop_MPI_TEST_PROCS` has a config-setting and an environment +variable; `monoprop_MPI_SPLITMIX_TEST_PROCS` is a CMake cache variable. Set a single rank count +with `SKBUILD_CMAKE_DEFINE="monoprop_MPI_SPLITMIX_TEST_PROCS=3" monoprop_ENABLE_MPI=ON uv sync --all-extras`. +`SKBUILD_CMAKE_DEFINE` splits on `;`, so set a list on the configured tree instead: +`cmake -D monoprop_MPI_SPLITMIX_TEST_PROCS="2;3" build/editable/Release-mpi`. Rebuild afterwards, +since CTest registers the variants when the test binary is built, not at configure time, and check +the result: + +```bash +cmake --build build/editable/Release-mpi +ctest --test-dir build/editable/Release-mpi -N -L mpi-splitmix +``` CI runs the non-MPI and MPI labels separately with `--no-tests=error`, so an MPI lane fails if its MPI variant was not registered. Standalone MPI-enabled C++ test executables initialize MPI before constructing propagators and destroy the propagators before finalizing MPI. Select the groups locally with -`just test-cpp` and `just test-cpp-mpi`, which are the same commands CI runs. The -`just test-mpi '1;2;4'` recipe configures and runs the corresponding C++ CTest -rank entries as well as the Python rank matrix. +`just test-cpp` and `just test-cpp-mpi`, which are the same commands CI runs. `just test-cpp` picks +the build tree from `monoprop_ENABLE_MPI`, so run the serial group of an MPI build with +`monoprop_ENABLE_MPI=ON just test-cpp`, as CI's MPI jobs do. +`just test-mpi` runs both C++ groups and the Python MPI legs. Run `just code-coverage` to collect coverage in separate serial and MPI-enabled `Coverage` builds and render `monoprop-coverage/index.html`. The MPI collection @@ -226,7 +252,11 @@ The `problem` fixture in `tests/conftest.py` is parametrized over the three general reference problems: LiH, RX/RZ/RY/RZ rotations, and a random circuit. Each invocation loads a fresh problem from `tests/data/` using `load_problem()` in `tests/reference_data.py`. Its explicit case IDs make failures easy to identify. -The `serial_comm` and `comm` fixtures are also provided by `conftest.py`. +`conftest.py` also provides `comm`: `None` (serial) and, under `--with-mpi` with an MPI build, +a duplicate of `COMM_WORLD`. Use it for results every rank agrees on, such as energies and +gradients; test rank-local state such as +[evolved_operator][monoprop.monomial_propagator.MonomialPropagator.evolved_operator] serially, +with `comm=None`. ```python notest from monoprop import MajoranaPropagator @@ -249,7 +279,7 @@ request `lih_problem`. For orbital-rotation checks using the S0 molecule, reques `orbital_rotation_problem`. ```python notest -def test_lih_reference(lih_problem, serial_comm): ... +def test_lih_reference(lih_problem): ... ``` Use `@pytest.mark.parametrize` with `pytest.param(..., id="...")` for small diff --git a/justfile b/justfile index 30cfcd274..ef5023a3e 100644 --- a/justfile +++ b/justfile @@ -89,28 +89,48 @@ test-cpp-mpi LABEL='': ctest --test-dir {{ mpi_build_dir }} --output-on-failure --no-tests=error --label-regex mpi \ {{ if LABEL == '' { '' } else { '--output-junit ctest-mpi-' + LABEL + '.xml' } }} -# RANKS is a single integer or a semicolon-separated list (e.g. "1;2;4"); the suite runs -# once per entry. Extra arguments go to pytest, e.g. `just test-py-mpi "1;2;4" -m mpi`. +# The Python MPI legs: every test that runs on a world communicator (`-m mpi`), under mpiexec, in +# each configuration CI covers. Linear routing refuses 3 ranks, so 3 runs with splitmix; 2 ranks of 2 +# partitions each make the hybrid ranks x partitions world. A hung collective fails its test after +# 300 s. LABEL, when given, names the JUnit reports, which rank 0 alone writes. -test-py-mpi RANKS='' *PYTEST_ARGS: +test-py-mpi LABEL='': #!/usr/bin/env bash set -euo pipefail - shift 1 - requested_ranks={{ quote(RANKS) }} - ranks="${requested_ranks:-${monoprop_MPI_TEST_PROCS:-2}}" - for r in ${ranks//;/ }; do - echo "Running the Python test suite with ${r} MPI rank(s)" - {{ mpiexec }} -n "$r" uv run --no-sync pytest tests --with-mpi -v "$@" - done - -# Build MPI-enabled, then run every leg. The C++ -# rank matrix is a build-time setting. The MPI switch is local to the build line, so the -# C++ legs are pointed at the MPI tree explicitly. - -test-mpi RANKS='': && (test-py-mpi RANKS) (test-cpp '' mpi_build_dir) test-cpp-mpi - requested_ranks={{ quote(RANKS) }}; \ - ranks="${requested_ranks:-${monoprop_MPI_TEST_PROCS:-2}}"; \ - monoprop_ENABLE_MPI=ON monoprop_MPI_TEST_PROCS="$ranks" {{ uv_sync }} --group workspace-test + label={{ quote(LABEL) }} + leg() { + local name="$1" ranks="$2" + shift 2 + local report="" + if [[ -n "$label" ]]; then + report="pytest-mpi-$label-$name.xml" + fi + echo "Python MPI leg $name: $ranks rank(s)${*:+, $*}" + # OMPI_COMM_WORLD_RANK is Open MPI's rank variable, PMI_RANK the MPICH family's. A leg runs + # exactly the configuration it names: settings inherited from the shell are dropped first. + env -u monoprop_ROUTING -u monoprop_PARTITIONS "$@" {{ mpiexec }} -n "$ranks" bash -c ' + report="$1" + shift + rank="${OMPI_COMM_WORLD_RANK:-${PMI_RANK:-0}}" + if [[ -n "$report" && "$rank" == 0 ]]; then + set -- --junit-xml="$report" -o junit_family=legacy "$@" + fi + exec uv run --no-sync pytest "$@" + ' bash "$report" tests --with-mpi -m mpi -v --timeout=300 --timeout-method=thread + } + leg ranks1 1 + leg ranks2 2 + # Of these legs only ranks4 runs the three-rank refusal in test_monoprop_mpi.py, which needs + # at least 3 ranks under linear routing. + leg ranks4 4 monoprop_ROUTING=linear + leg ranks3-splitmix 3 monoprop_ROUTING=splitmix + leg ranks2-partitions2 2 monoprop_PARTITIONS=2 + +# Build MPI-enabled, then run every MPI leg CI runs. The C++ rank counts are passed because an MPI +# build tree caches them, and an older version of this recipe cached `2` alone. + +test-mpi: && test-py-mpi (test-cpp '' mpi_build_dir) test-cpp-mpi + monoprop_ENABLE_MPI=ON monoprop_MPI_TEST_PROCS="2;4" {{ uv_sync }} --group workspace-test # 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 @@ -266,7 +286,8 @@ code-coverage-collect MPI BUILD_DIR OUTPUT_DIR: export OMPI_ALLOW_RUN_AS_ROOT_CONFIRM=1 {{ mpiexec }} -n 2 \ uv run --no-sync coverage run --parallel-mode \ - -m pytest tests --with-mpi -m mpi + -m pytest tests --with-mpi -m mpi \ + --timeout=300 --timeout-method=thread fi gcovr_args=( diff --git a/packages/monoprop-bench-tools/tests/conftest.py b/packages/monoprop-bench-tools/tests/conftest.py index bf3e4f15d..c488905d4 100644 --- a/packages/monoprop-bench-tools/tests/conftest.py +++ b/packages/monoprop-bench-tools/tests/conftest.py @@ -15,23 +15,3 @@ """Pytest configuration for the monoprop-bench-tools test suite.""" from __future__ import annotations - -from typing import Any - -import pytest - -import monoprop - -try: - from mpi4py import MPI -except ImportError: - MPI = None - - -@pytest.fixture -def serial_comm() -> Any: - """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/packages/monoprop-bench-tools/tests/test_models.py b/packages/monoprop-bench-tools/tests/test_models.py index 02a6c6bed..33610c486 100644 --- a/packages/monoprop-bench-tools/tests/test_models.py +++ b/packages/monoprop-bench-tools/tests/test_models.py @@ -36,15 +36,15 @@ def test_random_default_sizes_are_meaningful() -> None: assert defaults["seed"] is None -def test_built_graph_is_populated(serial_comm) -> None: +def test_built_graph_is_populated() -> None: # gen_length=4: a length-4 Majorana monomial is Hermitian with real coefficients, while a # length-2 one is anti-Hermitian and would be rejected. - # serial_comm because graph_size() is rank-local: on COMM_WORLD a problem this small leaves + # Serial: graph_size() is rank-local, and on COMM_WORLD a problem this small leaves # some ranks with no local cycles. problem = make_random_problem( gen_length=4, obs_terms=3, num_generators=5, num_modes=6, cutoff=3, seed=0 ) - propagator, circuit = build_random_propagator(problem, comm=serial_comm) + propagator, circuit = build_random_propagator(problem) propagator.build_graph(circuit) _n_cos_indices, n_cycles = propagator.graph_size() assert n_cycles > 0 diff --git a/pyproject.toml b/pyproject.toml index 0d8364a59..177a3019c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -78,6 +78,7 @@ test = [ "psutil>=7.2", "pytest-cov>=7.1.0", "pytest-datadir", + "pytest-timeout>=2.3", "pytest>=8.2.2,<9.2", "tabulate>=0.9", ] diff --git a/tests/conftest.py b/tests/conftest.py index e5c700a37..a151a8534 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,15 +16,17 @@ MPI selection is handled here (``--with-mpi``, ``@pytest.mark.mpi``). -``comm`` is parametrized over COMM_SELF and COMM_WORLD: use it for energies and gradients, which -allreduce internally. ``serial_comm`` is always COMM_SELF: use it for rank-local state such as -``evolved_operator``, ``contract_partially``, ``size()``, or ``graph_size()``. - -Both hand out session-wide duplicates of those communicators, never the predefined handles -themselves, as the parallelism docs tell users to do: monoprop's point-to-point traffic then -cannot match messages that conftest or mpi4py exchange on the predefined communicators. -Each duplicate is created on first use and freed at session teardown: ``Dup`` is collective, and -doing it per test would add a collective to every one of them for no extra isolation. +``comm`` is parametrized over ``None``, the serial run, and, with an MPI backend, ``comm_world``. +The world case carries the ``mpi`` marker, so it runs only under ``--with-mpi`` and is what +``-m mpi`` selects. Use ``comm`` for results every rank agrees on: energies, gradients, +``term_expectation_values``. Test rank-local state (``evolved_operator``, ``contract_partially``, +``size()``, ``graph_size()``) serially, with ``comm=None``. + +``comm_world`` is a session-wide duplicate of COMM_WORLD, never the predefined handle itself, as +the parallelism docs tell users to do: monoprop's point-to-point traffic then cannot match +messages that conftest or mpi4py exchange on COMM_WORLD. It is created on first use and freed at +session teardown: ``Dup`` is collective, and doing it per test would add a collective to every +one of them for no extra isolation. Under more than one rank each rank gets its own ``--basetemp``; see ``_rank_basetemp``. """ @@ -34,6 +36,7 @@ import getpass import os import stat +import sys import tempfile from pathlib import Path from typing import TYPE_CHECKING @@ -126,6 +129,25 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) +class _RankFailureReporter: + """Writes a muted rank's failures to stderr, tagged with the rank. + + Only rank 0 keeps the terminal reporter, so without this a failure on another rank shows only + as mpiexec's non-zero exit. + """ + + def __init__(self, rank: int) -> None: + self._rank = rank + + def pytest_runtest_logreport(self, report: pytest.TestReport) -> None: + """Print a failed phase's report.""" + if report.failed: + sys.stderr.write( + f"[rank {self._rank}] FAILED {report.nodeid} ({report.when})\n" + f"{report.longreprtext}\n" + ) + + @pytest.hookimpl(trylast=True) def pytest_configure(config: pytest.Config) -> None: """Enable ``--with-mpi`` under the VS Code adapter, which bypasses @@ -142,6 +164,9 @@ def pytest_configure(config: pytest.Config) -> None: reporter = config.pluginmanager.getplugin("terminalreporter") if reporter is not None: config.pluginmanager.unregister(reporter) + config.pluginmanager.register( + _RankFailureReporter(MPI.COMM_WORLD.rank), "monoprop-rank-failures" + ) @pytest.hookimpl(tryfirst=True) @@ -157,17 +182,6 @@ def pytest_runtest_setup(item: pytest.Item) -> None: pytest.skip(f"Test requires {min_size} MPI processes") -@pytest.fixture(scope="session") -def comm_self() -> Iterator[Intracomm | None]: - """Session-wide duplicate of COMM_SELF; ``None`` when monoprop has no MPI backend.""" - if not HAS_MPI_BACKEND: - yield None - return - duplicate = MPI.COMM_SELF.Dup() - yield duplicate - duplicate.Free() - - @pytest.fixture(scope="session") def comm_world() -> Iterator[Intracomm | None]: """Session-wide duplicate of COMM_WORLD; ``None`` when monoprop has no MPI backend.""" @@ -179,33 +193,21 @@ def comm_world() -> Iterator[Intracomm | None]: duplicate.Free() -_COMM_PARAMS = ( - [pytest.param(None, id="comm_none")] - if not HAS_MPI_BACKEND - else [ - pytest.param("comm_self", id="comm_self"), - pytest.param("comm_world", id="comm_world"), - ] -) +_COMM_PARAMS = [pytest.param(None, id="comm_none")] +if HAS_MPI_BACKEND: + _COMM_PARAMS.append( + pytest.param("comm_world", id="comm_world", marks=pytest.mark.mpi) + ) @pytest.fixture(params=_COMM_PARAMS) def comm(request: pytest.FixtureRequest) -> Intracomm | None: - """Parametrized communicator; ``None`` when mpi4py is unavailable or monoprop has no MPI backend. - - Resolved through the session-scoped duplicates, so parametrizing does not reorder the run. - """ + """``None`` (serial), or ``comm_world`` under ``--with-mpi`` when monoprop has an MPI backend.""" if request.param is None: return None return request.getfixturevalue(request.param) -@pytest.fixture -def serial_comm(comm_self: Intracomm | None) -> Intracomm | None: - """Single-rank communicator for tests that inspect rank-local operator state.""" - return comm_self - - @pytest.fixture( params=[ pytest.param("lih_fermionic_spin_exact", id="LiH_fermionic_spin"), diff --git a/tests/test_basis.py b/tests/test_basis.py index 02aad0a94..67e6e4562 100644 --- a/tests/test_basis.py +++ b/tests/test_basis.py @@ -35,19 +35,19 @@ N_MODES = 6 -def _propagator(comm=None) -> MajoranaPropagator: +def _propagator() -> MajoranaPropagator: return MajoranaPropagator( - MajoranaOperator({(0,): 1.0}, N_MODES), [], cutoff=N_MODES // 2, comm=comm + MajoranaOperator({(0,): 1.0}, N_MODES), [], cutoff=N_MODES // 2 ) -def test_basis_change(serial_comm) -> None: +def test_basis_change() -> None: """A Jordan-Wigner cutoff basis reproduces the exact single-rotation result. - serial_comm because evolved_operator() is rank-local: on COMM_WORLD the single term lives on + Serial: evolved_operator() is rank-local, and on COMM_WORLD the single term lives on whichever rank owns its hash partition. """ - propagator = _propagator(serial_comm) + propagator = _propagator() propagator._simulator.basis_change = jordan_wigner_basis_change(N_MODES) propagator.propagate( Circuit.from_dense_arrays( diff --git a/tests/test_coeff_trunc.py b/tests/test_coeff_trunc.py index da224017c..65d4bdbe7 100644 --- a/tests/test_coeff_trunc.py +++ b/tests/test_coeff_trunc.py @@ -30,7 +30,6 @@ def _create_mp( *, op, initial_state, - comm, upper_atol=None, lower_atol=None, cutoff=4, @@ -43,7 +42,6 @@ def _create_mp( upper_atol=upper_atol, lower_atol=lower_atol, schrodinger_cutoff=schrodinger_cutoff, - comm=comm, ) @@ -60,7 +58,7 @@ def _check_dicts(d1, d2): ) -def test_coeff_trunc(serial_comm): +def test_coeff_trunc(): n_modes = 5 op = MajoranaOperator({(0, 1): 1.0j}, num_modes=n_modes) @@ -82,7 +80,6 @@ def get_test_dict(upper_atol, lower_atol, cutoff): mp = _create_mp( op=op, initial_state=sequence.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -114,7 +111,6 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( upper_atol, lower_atol, cutoff, - serial_comm, ): """Check that build graph with coeffs and inplace version match.""" n_modes = lih_problem.n_modes @@ -127,7 +123,6 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( mp_inplace = _create_mp( op=fermionic_operator, initial_state=monomial_circuit.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -139,7 +134,6 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( mp_build = _create_mp( op=fermionic_operator, initial_state=monomial_circuit.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -153,7 +147,7 @@ def test_coeff_trunc_build_graph_and_inplace_equiv( assert np.isclose(expval_inplace, expval_build, atol=1e-12) -def test_evolution_coeff_trunc_no_atols(serial_comm): +def test_evolution_coeff_trunc_no_atols(): n_modes = 2 p = np.pi / 16 @@ -180,7 +174,6 @@ def test_evolution_coeff_trunc_no_atols(serial_comm): sequence.initial_state, cutoff=cutoff, schrodinger_cutoff=None, - comm=serial_comm, ) mp.build_graph(circuit) assert mp.graph_size()[1] == 1 @@ -192,7 +185,7 @@ def test_evolution_coeff_trunc_no_atols(serial_comm): _check_dicts(test_op, final_operator) -def test_evolution_coeff_trunc_small_coeffs(serial_comm): +def test_evolution_coeff_trunc_small_coeffs(): n_modes = 2 p = 1e-5 init_op = {(0, 1): 1.0j, (0, 2): 1e-7j} @@ -229,7 +222,6 @@ def test_evolution_coeff_trunc_small_coeffs(serial_comm): mp = _create_mp( op=op, initial_state=sequence.initial_state, - comm=serial_comm, upper_atol=upper_atol, lower_atol=lower_atol, cutoff=cutoff, @@ -245,7 +237,7 @@ def test_evolution_coeff_trunc_small_coeffs(serial_comm): _check_dicts(test_op, final_operator) -def test_graph_size_counts_real_cosine_indices(serial_comm): +def test_graph_size_counts_real_cosine_indices(): """``graph_size()[0]`` is the real cosine-only count, not a structural zero. Cutoff 4 truncates the weight-6 sine partners of these weight-4 terms, leaving their sources @@ -263,7 +255,7 @@ def test_graph_size_counts_real_cosine_indices(serial_comm): ) def sized(cutoff: int) -> tuple[int, int]: - mp = MajoranaPropagator(operator, [], cutoff=cutoff, comm=serial_comm) + mp = MajoranaPropagator(operator, [], cutoff=cutoff) mp.build_graph(circuit) return mp.graph_size() diff --git a/tests/test_evolved_operator_coefficients.py b/tests/test_evolved_operator_coefficients.py index a28bf7a05..e9fee5ae0 100644 --- a/tests/test_evolved_operator_coefficients.py +++ b/tests/test_evolved_operator_coefficients.py @@ -15,7 +15,7 @@ """Coverage for ``evolved_operator_coefficients``, the term-probing companion to ``evolved_operator``. Oracle throughout: ``evolved_operator(atol=0.0)``, which enumerates the whole index. Both are -rank-local, so every propagator here is built on ``serial_comm`` (see the note in ``test_basis.py``). +rank-local, so every propagator here runs serially (see the note in ``test_basis.py``). """ from __future__ import annotations @@ -47,7 +47,7 @@ def problem(): @pytest.fixture(scope="module") -def prop_and_params(problem, comm_self): +def prop_and_params(problem): """A built propagator over ``problem`` and its parameters, shared across the module. ``evolved_operator`` and ``evolved_operator_coefficients`` leave simulator state alone, so one @@ -57,20 +57,18 @@ def prop_and_params(problem, comm_self): problem.operator, problem.monomial_circuit.initial_state, cutoff=2 * problem.n_modes, - comm=comm_self, ) prop.build_graph(problem.monomial_circuit.to_circuit()) return prop, problem.monomial_circuit.parameters -def _pauli_propagator(serial_comm, schrodinger_cutoff=None): +def _pauli_propagator(schrodinger_cutoff=None): """A Pauli propagator with a graph whose evolution spreads the operator over many terms.""" prop = PauliPropagator( PauliOperator({Pauli("ZZ", (0, 1)): 1.0, Pauli("XY", (2, 3)): 0.5}, N_QUBITS), initial_state=[], cutoff=N_QUBITS, schrodinger_cutoff=schrodinger_cutoff, - comm=serial_comm, ) circuit = Circuit( tuple( @@ -89,9 +87,9 @@ def _pauli_propagator(serial_comm, schrodinger_cutoff=None): return prop, np.linspace(0.1, 0.9, prop.n_parameters) -def test_pauli_coefficients_match_evolved_operator(serial_comm) -> None: +def test_pauli_coefficients_match_evolved_operator() -> None: """Every term the evolved Pauli operator carries reads back with the same coefficient.""" - prop, parameters = _pauli_propagator(serial_comm) + prop, parameters = _pauli_propagator() evolved = prop.evolved_operator(parameters, atol=0.0) terms = list(evolved.terms) @@ -166,13 +164,12 @@ def test_non_canonical_term_in_a_long_query_names_the_offender(prop_and_params) prop.evolved_operator_coefficients(terms, parameters) -def test_absent_term_reads_back_zero(serial_comm) -> None: +def test_absent_term_reads_back_zero() -> None: """A term the evolved operator does not carry is 0, not a raise and not noise.""" prop = PauliPropagator( PauliOperator({Pauli("Z", (0,)): 1.0}, N_QUBITS), initial_state=[], cutoff=N_QUBITS, - comm=serial_comm, ) absent = Pauli("X", (5,)) @@ -184,9 +181,9 @@ def test_absent_term_reads_back_zero(serial_comm) -> None: assert coefficients[0] == 0 -def test_schrodinger_coefficients_match_evolved_state(serial_comm) -> None: +def test_schrodinger_coefficients_match_evolved_state() -> None: """The motivating case: reading a few amplitudes out of an evolved state.""" - prop, parameters = _pauli_propagator(serial_comm, schrodinger_cutoff=4) + prop, parameters = _pauli_propagator(schrodinger_cutoff=4) evolved = prop.evolved_operator(parameters, atol=0.0) terms = list(evolved.terms) @@ -221,7 +218,7 @@ def test_query_order_is_preserved(prop_and_params) -> None: ) -def test_identity_term_is_the_core_term(problem, serial_comm) -> None: +def test_identity_term_is_the_core_term(problem) -> None: """In the Heisenberg picture the empty term is the core term ``evolved_operator`` re-adds. The fixture Hamiltonian carries no identity term, so one is injected: with a zero core term the @@ -234,7 +231,6 @@ def test_identity_term_is_the_core_term(problem, serial_comm) -> None: with_identity, problem.monomial_circuit.initial_state, cutoff=2 * problem.n_modes, - comm=serial_comm, ) prop.build_graph(problem.monomial_circuit.to_circuit()) parameters = problem.monomial_circuit.parameters @@ -247,9 +243,9 @@ def test_identity_term_is_the_core_term(problem, serial_comm) -> None: assert coefficients[0] == pytest.approx(evolved.terms[()]) -def test_schrodinger_identity_is_a_state_amplitude(serial_comm) -> None: +def test_schrodinger_identity_is_a_state_amplitude() -> None: """In the Schrodinger picture the identity is an ordinary term, not a core term.""" - prop, parameters = _pauli_propagator(serial_comm, schrodinger_cutoff=4) + prop, parameters = _pauli_propagator(schrodinger_cutoff=4) evolved = prop.evolved_operator(parameters, atol=0.0) identity = Pauli("", ()) @@ -354,17 +350,17 @@ def test_majorana_front_end_rejects_a_pauli_term(prop_and_params) -> None: prop.evolved_operator_coefficients(paulis, parameters) -def test_pauli_front_end_rejects_a_raw_slot_tuple(serial_comm) -> None: +def test_pauli_front_end_rejects_a_raw_slot_tuple() -> None: """Symplectic slots are an engine-internal encoding, so the Pauli front-end takes Pauli only.""" - prop, parameters = _pauli_propagator(serial_comm) + prop, parameters = _pauli_propagator() with pytest.raises(TypeError, match="Pauli objects"): prop.evolved_operator_coefficients([(0, 1)], parameters) -def test_repeated_term_is_answered_once_per_occurrence(serial_comm) -> None: +def test_repeated_term_is_answered_once_per_occurrence() -> None: """The query is a list, not a set: each occurrence gets its own slot with the same value.""" - prop, parameters = _pauli_propagator(serial_comm) + prop, parameters = _pauli_propagator() evolved = prop.evolved_operator(parameters, atol=0.0) term = next(iter(evolved.terms)) diff --git a/tests/test_infinite_cutoff.py b/tests/test_infinite_cutoff.py index 13bdc2628..ebcf30de1 100644 --- a/tests/test_infinite_cutoff.py +++ b/tests/test_infinite_cutoff.py @@ -35,7 +35,7 @@ def _create_mp(problem, comm, *, schrodinger_cutoff=None): @pytest.mark.parametrize("pare_threshold", [None, 1e-10]) @pytest.mark.parametrize("schrodinger_cutoff", [None, "max"]) -@pytest.mark.parametrize("evolution_mode", ["deferred", "with_coeffs", "inplace"]) +@pytest.mark.parametrize("evolution_mode", ["deferred", "inplace"]) def test_infinite_cutoff( problem, pare_threshold, schrodinger_cutoff, evolution_mode, comm ): @@ -50,12 +50,6 @@ def test_infinite_cutoff( parameters ) - case "with_coeffs": - mp.build_graph(circuit) - test_expval = mp.expval_functional(pare_threshold=pare_threshold)( - parameters - ) - case "inplace": mp.propagate(circuit) test_expval = mp.expval_functional(pare_threshold=pare_threshold)() diff --git a/tests/test_monoprop_mpi.py b/tests/test_monoprop_mpi.py index ad02d6d0a..8ccf85bd4 100644 --- a/tests/test_monoprop_mpi.py +++ b/tests/test_monoprop_mpi.py @@ -12,45 +12,100 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""MPI tests for the Majorana Propagator. +"""Distributed propagation, checked against exact values or the same propagation run serially. -Run with: mpirun -np uv run pytest tests/test_monoprop_mpi.py --with-mpi -m mpi -v +Every test carries the ``mpi`` marker and runs on ``comm_world``, the session's duplicate of +COMM_WORLD. ``just test-py-mpi`` runs them in each configuration CI covers; by hand: + + mpiexec --map-by :OVERSUBSCRIBE -n 4 uv run --no-sync pytest tests/test_monoprop_mpi.py --with-mpi -m mpi """ from __future__ import annotations +import os + import numpy as np import pytest +from monoprop import Circuit, ExpGate, MajoranaPropagator, PauliPropagator +from monoprop.pauli import Pauli, PauliOperator from tests.reference_data import load_problem MPI = pytest.importorskip("mpi4py").MPI -from monoprop import MajoranaPropagator # noqa: E402 +pytestmark = pytest.mark.mpi + +# The C++ suite's near(): a reduction sums in an order that depends on the rank count. +RTOL = 1e-7 +ATOL = 1e-9 + +# random_exact's observable has only terms of length >= 10: a lower cutoff leaves the energy 0 on +# every rank count, which cannot tell a broken exchange apart. +MAJORANA_CUTOFF = 10 +PAULI_CUTOFF = 3 +N_QUBITS = 6 +PAULI_OBSERVABLES = ( + Pauli("ZZ", (0, 1)), + Pauli("XY", (2, 3)), + Pauli("Z", (4,)), + Pauli("YX", (1, 5)), + Pauli("ZZ", (2, 4)), +) @pytest.fixture -def lih_fermionic_spin_exact(lazy_shared_datadir): - data_path = lazy_shared_datadir / "lih_fermionic_spin_exact.msgpack" - return load_problem(data_path) +def random_exact(lazy_shared_datadir): + return load_problem(lazy_shared_datadir / "random_exact.msgpack") -def _make_mp(problem, comm, *, schrodinger=False): - mp = MajoranaPropagator( +def _majorana(problem, comm, *, cutoff=None, schrodinger=False): + cutoff = 2 * problem.n_modes if cutoff is None else cutoff + propagator = MajoranaPropagator( problem.operator, problem.monomial_circuit.initial_state, - cutoff=2 * problem.n_modes, - schrodinger_cutoff=2 * problem.n_modes if schrodinger else None, + cutoff=cutoff, + schrodinger_cutoff=cutoff if schrodinger else None, + comm=comm, + ) + return propagator, problem.monomial_circuit.to_circuit() + + +def _pauli_circuit() -> Circuit: + """Rotations about each axis between nearest-neighbour ZZ layers, enough to spread every observable.""" + gates = [] + for layer in range(3): + axis = "XYZ"[layer % 3] + gates += [ + ExpGate(PauliOperator({Pauli(axis, (q,)): 1.0}, N_QUBITS)) + for q in range(N_QUBITS) + ] + gates += [ + ExpGate(PauliOperator({Pauli("ZZ", (q, q + 1)): 1.0}, N_QUBITS)) + for q in range(layer % 2, N_QUBITS - 1, 2) + ] + return Circuit(tuple(gates), N_QUBITS) + + +def _pauli(comm, *, cutoff, schrodinger): + """A Pauli propagator over ``PAULI_OBSERVABLES`` with the circuit built, and its angles.""" + circuit = _pauli_circuit() + weights = np.linspace(0.3, 1.2, len(PAULI_OBSERVABLES)) + propagator = PauliPropagator( + PauliOperator(dict(zip(PAULI_OBSERVABLES, weights)), N_QUBITS), + initial_state=[0, 3], + cutoff=cutoff, + schrodinger_cutoff=cutoff if schrodinger else None, comm=comm, ) - circuit = problem.monomial_circuit.to_circuit() - return mp, circuit + propagator.build_graph(circuit) + return propagator, np.random.default_rng(7).uniform( + 0, 2 * np.pi, circuit.n_parameters + ) -def _assert_expval(expval, expected, atol=1e-9): - assert np.isclose(expval, expected, atol=atol), ( - f"Expectation value mismatch: {expval} vs {expected}" - ) +def _pauli_energy(comm, *, cutoff=PAULI_CUTOFF, schrodinger=False) -> float: + propagator, parameters = _pauli(comm, cutoff=cutoff, schrodinger=schrodinger) + return propagator.expectation_value(parameters) def _finite_difference_gradient(expval_fn, parameters, eps=1e-6): @@ -64,130 +119,153 @@ def _finite_difference_gradient(expval_fn, parameters, eps=1e-6): return gradient -@pytest.mark.mpi -class TestMPISimulator: - def test_evolve_expectation_value_functional(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters - ) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_evolve_build_graph_with_coeffs(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - parameters = lih_fermionic_spin_exact.monomial_circuit.parameters - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)(parameters) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_immediate_contraction(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.propagate(circuit) - _assert_expval( - mp.expval_functional(pare_threshold=1e-10)(), - lih_fermionic_spin_exact.exact_expval, - ) - - def test_schrodinger_picture(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp( - lih_fermionic_spin_exact, MPI.COMM_WORLD, schrodinger=True - ) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters - ) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_gradient(self, lih_fermionic_spin_exact): - """Check the analytic gradient against central finite differences.""" - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.build_graph(circuit) - - expval_fn = mp.expval_functional(pare_threshold=1e-10) - grad_fn = mp.expval_and_grad_functional(pare_threshold=1e-10) - - rng = np.random.default_rng(42) - xk = rng.random(size=len(lih_fermionic_spin_exact.monomial_circuit.parameters)) - - _, analytical_grad = grad_fn(xk) - fd_gradient = _finite_difference_gradient(expval_fn, xk) - - assert np.allclose(analytical_grad, fd_gradient, atol=1e-9) +def test_expval_functional_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.build_graph(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)( + lih_problem.monomial_circuit.parameters + ) + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) - def test_expectation_value_and_gradient_functional(self, lih_fermionic_spin_exact): - """Combined expectation value + gradient is consistent and matches exact.""" - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_WORLD) - mp.build_graph(circuit) - parameters = lih_fermionic_spin_exact.monomial_circuit.parameters - expval_grad_fn = mp.expval_and_grad_functional(pare_threshold=1e-10) - expval_fn = mp.expval_functional(pare_threshold=1e-10) +def test_immediate_contraction_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.propagate(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)() + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) - combined_expval, combined_grad = expval_grad_fn(parameters) - individual_expval = expval_fn(parameters) - assert np.isclose(combined_expval, individual_expval, atol=1e-12) - _assert_expval(combined_expval, lih_fermionic_spin_exact.exact_expval) +def test_schrodinger_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world, schrodinger=True) + propagator.build_graph(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)( + lih_problem.monomial_circuit.parameters + ) + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) + + +def test_gradient_matches_finite_differences(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.build_graph(circuit) + expval_fn = propagator.expval_functional(pare_threshold=1e-10) + grad_fn = propagator.expval_and_grad_functional(pare_threshold=1e-10) + rng = np.random.default_rng(42) + parameters = rng.random(size=len(lih_problem.monomial_circuit.parameters)) + _, gradient = grad_fn(parameters) + assert np.allclose( + gradient, _finite_difference_gradient(expval_fn, parameters), atol=1e-9 + ) - assert np.allclose( - combined_grad, lih_fermionic_spin_exact.exact_gradient, atol=1e-9 - ) - def test_evolved_operator_coefficients_is_rank_local( - self, lih_fermionic_spin_exact - ): - """Each rank answers for the terms it owns, and the ranks' answers sum to the serial result.""" - problem = lih_fermionic_spin_exact - parameters = problem.monomial_circuit.parameters - - serial, serial_circuit = _make_mp(problem, MPI.COMM_SELF) - serial.build_graph(serial_circuit) - # Sorted so every rank queries the same terms in the same order. - terms = sorted( - t for t in serial.evolved_operator(parameters, atol=0.0).terms if t - ) - assert terms - expected = serial.evolved_operator_coefficients(terms, parameters) - - world, world_circuit = _make_mp(problem, MPI.COMM_WORLD) - world.build_graph(world_circuit) - local = world.evolved_operator_coefficients(terms, parameters) - - summed = np.zeros_like(local) - MPI.COMM_WORLD.Allreduce(local, summed, op=MPI.SUM) - - np.testing.assert_allclose(summed, expected, atol=1e-9) - - if MPI.COMM_WORLD.size > 1: - # Counted against the terms each rank *owns* rather than against its nonzero - # coefficients. - local_terms = world.evolved_operator(parameters, atol=0.0).terms - owned = sum(1 for t in local_terms if t) # the identity is on every rank - busiest = MPI.COMM_WORLD.allreduce(owned, op=MPI.MAX) - assert busiest < len(terms) - - @pytest.mark.mpi(min_size=2) - def test_custom_communicator_split(self, lih_fermionic_spin_exact): - rank = MPI.COMM_WORLD.Get_rank() - sub_comm = MPI.COMM_WORLD.Split(color=rank % 2, key=rank) - - try: - mp, circuit = _make_mp(lih_fermionic_spin_exact, sub_comm) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters - ) - finally: - mp = None - sub_comm.Free() - - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) - - def test_mpi_comm_self(self, lih_fermionic_spin_exact): - mp, circuit = _make_mp(lih_fermionic_spin_exact, MPI.COMM_SELF) - mp.build_graph(circuit) - expval = mp.expval_functional(pare_threshold=1e-10)( - lih_fermionic_spin_exact.monomial_circuit.parameters +def test_expval_and_grad_functional_matches_exact(lih_problem, comm_world): + propagator, circuit = _majorana(lih_problem, comm_world) + propagator.build_graph(circuit) + parameters = lih_problem.monomial_circuit.parameters + combined_expval, combined_grad = propagator.expval_and_grad_functional( + pare_threshold=1e-10 + )(parameters) + individual_expval = propagator.expval_functional(pare_threshold=1e-10)(parameters) + assert np.isclose(combined_expval, individual_expval, atol=1e-12) + assert np.isclose(combined_expval, lih_problem.exact_expval, atol=1e-9) + assert np.allclose(combined_grad, lih_problem.exact_gradient, atol=1e-9) + + +def test_truncated_majorana_matches_serial(random_exact, comm_world): + """A truncated evolution distributes to the serial energy and gradient.""" + parameters = random_exact.monomial_circuit.parameters + results = [] + for comm in (None, comm_world): + propagator, circuit = _majorana(random_exact, comm, cutoff=MAJORANA_CUTOFF) + propagator.build_graph(circuit) + results.append(propagator.expectation_value_and_gradient(parameters)) + (serial_energy, serial_gradient), (world_energy, world_gradient) = results + assert abs(serial_energy) > 1e-6 + assert not np.isclose(serial_energy, random_exact.exact_expval, atol=1e-6) + assert np.isclose(world_energy, serial_energy, rtol=RTOL, atol=ATOL) + np.testing.assert_allclose(world_gradient, serial_gradient, rtol=RTOL, atol=ATOL) + + +@pytest.mark.parametrize( + "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] +) +def test_truncated_pauli_matches_serial(schrodinger, comm_world): + serial = _pauli_energy(None, schrodinger=schrodinger) + assert not np.isclose(serial, _pauli_energy(None, cutoff=N_QUBITS), atol=1e-6) + world = _pauli_energy(comm_world, schrodinger=schrodinger) + assert np.isclose(world, serial, rtol=RTOL, atol=ATOL) + + +def test_update_initial_operator_matches_serial(comm_world): + """Re-weighting reaches the terms each rank owns, so the distributed energy follows the serial one.""" + reweighted = PauliOperator( + dict(zip(PAULI_OBSERVABLES, np.linspace(1.2, 0.3, len(PAULI_OBSERVABLES)))), + N_QUBITS, + ) + energies = [] + for comm in (None, comm_world): + propagator, parameters = _pauli(comm, cutoff=PAULI_CUTOFF, schrodinger=False) + propagator.update_initial_operator(reweighted) + energies.append(propagator.expectation_value(parameters)) + serial, world = energies + assert not np.isclose(serial, _pauli_energy(None), atol=1e-6) + assert np.isclose(world, serial, rtol=RTOL, atol=ATOL) + + +def test_evolved_operator_coefficients_is_rank_local(lih_problem, comm_world): + """Each rank answers for the terms it owns, and the ranks' answers sum to the serial result.""" + parameters = lih_problem.monomial_circuit.parameters + + serial, serial_circuit = _majorana(lih_problem, None) + serial.build_graph(serial_circuit) + # Sorted so every rank queries the same terms in the same order. + terms = sorted(t for t in serial.evolved_operator(parameters, atol=0.0).terms if t) + assert terms + expected = serial.evolved_operator_coefficients(terms, parameters) + + world, world_circuit = _majorana(lih_problem, comm_world) + world.build_graph(world_circuit) + local = world.evolved_operator_coefficients(terms, parameters) + + summed = np.zeros_like(local) + comm_world.Allreduce(local, summed, op=MPI.SUM) + np.testing.assert_allclose(summed, expected, atol=1e-9) + + if comm_world.size > 1: + # Counted against the terms each rank *owns* rather than against its nonzero coefficients. + local_terms = world.evolved_operator(parameters, atol=0.0).terms + owned = sum(1 for t in local_terms if t) # the identity is on every rank + busiest = comm_world.allreduce(owned, op=MPI.MAX) + assert busiest < len(terms) + + +@pytest.mark.mpi(min_size=4) +def test_split_communicators_each_match_exact(lih_problem, comm_world): + """Two halves of the world propagate independently; each half holds at least two ranks.""" + sub_comm = comm_world.Split(color=comm_world.rank % 2, key=comm_world.rank) + propagator = None + try: + propagator, circuit = _majorana(lih_problem, sub_comm) + propagator.build_graph(circuit) + expval = propagator.expval_functional(pare_threshold=1e-10)( + lih_problem.monomial_circuit.parameters ) - _assert_expval(expval, lih_fermionic_spin_exact.exact_expval) + finally: + propagator = None # the communicator must outlive the propagator + sub_comm.Free() + assert np.isclose(expval, lih_problem.exact_expval, atol=1e-9) + + +@pytest.mark.mpi(min_size=3) +def test_linear_routing_refuses_a_three_rank_communicator(random_exact, comm_world): + """Linear routing XORs rank indices, so construction refuses a rank count that is not a power of two.""" + if os.environ.get("monoprop_ROUTING", "") not in ("", "linear"): # noqa: SIM112 + pytest.skip("monoprop_ROUTING selects splitmix, which routes any rank count") + color = 0 if comm_world.rank < 3 else MPI.UNDEFINED + three_ranks = comm_world.Split(color=color, key=comm_world.rank) + if three_ranks == MPI.COMM_NULL: + return # outside the three-rank communicator there is nothing to construct + try: + with pytest.raises(RuntimeError, match="power-of-two rank count"): + _majorana(random_exact, three_ranks) + finally: + three_ranks.Free() diff --git a/tests/test_monoprop_smoke.py b/tests/test_monoprop_smoke.py index 9176a333d..7f42b01ab 100644 --- a/tests/test_monoprop_smoke.py +++ b/tests/test_monoprop_smoke.py @@ -19,22 +19,22 @@ from monoprop import MajoranaPropagator, jordan_wigner_basis_change -def _bound_simulator_type(problem, serial_comm): +def _bound_simulator_type(problem): wrapper = MajoranaPropagator( problem.operator, problem.monomial_circuit.initial_state, cutoff=2 * problem.n_modes, - comm=serial_comm, + comm=None, ) return type(wrapper._simulator) -def _make_bound_simulator(bound_type, problem, serial_comm, *, schrodinger: bool): +def _make_bound_simulator(bound_type, problem, *, schrodinger: bool): return bound_type( initial_operator=problem.operator.terms, cutoff=2 * problem.n_modes, initial_state=problem.monomial_circuit.initial_state, - comm=serial_comm, + comm=None, schrodinger_cutoff=2 * problem.n_modes if schrodinger else None, lower_atol=None, upper_atol=None, @@ -43,12 +43,11 @@ def _make_bound_simulator(bound_type, problem, serial_comm, *, schrodinger: bool ) -def _make_bound_core(problem, serial_comm, *, schrodinger: bool): - bound_type = _bound_simulator_type(problem, serial_comm) +def _make_bound_core(problem, *, schrodinger: bool): + bound_type = _bound_simulator_type(problem) return _make_bound_simulator( bound_type, problem, - serial_comm, schrodinger=schrodinger, ) @@ -69,10 +68,8 @@ def _evolve_bound_core(core, monomial_circuit): @pytest.mark.parametrize( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) -def test_bound_constructor_accepts_declared_arguments( - lih_problem, serial_comm, schrodinger -): - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) +def test_bound_constructor_accepts_declared_arguments(lih_problem, schrodinger): + core = _make_bound_core(lih_problem, schrodinger=schrodinger) assert core.num_modes == lih_problem.n_modes assert core.schrodinger is schrodinger @@ -81,10 +78,8 @@ def test_bound_constructor_accepts_declared_arguments( @pytest.mark.parametrize( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) -def test_bound_mutators_accept_declared_arguments( - lih_problem, serial_comm, schrodinger -): - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) +def test_bound_mutators_accept_declared_arguments(lih_problem, schrodinger): + core = _make_bound_core(lih_problem, schrodinger=schrodinger) core.lower_atol = 1e-12 core.lower_atol = None @@ -104,10 +99,10 @@ def test_bound_mutators_accept_declared_arguments( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) def test_bound_expectation_value_methods_accept_declared_arguments( - lih_problem, serial_comm, schrodinger + lih_problem, schrodinger ): monomial_circuit = lih_problem.monomial_circuit - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) + core = _make_bound_core(lih_problem, schrodinger=schrodinger) parameters, coeffs = _evolve_bound_core(core, monomial_circuit) expectation_value_fn = core.expectation_value_functional(pare_threshold=None) @@ -127,11 +122,9 @@ def test_bound_expectation_value_methods_accept_declared_arguments( @pytest.mark.parametrize( "schrodinger", [False, True], ids=["heisenberg", "schrodinger"] ) -def test_bound_graph_methods_accept_declared_arguments( - lih_problem, serial_comm, schrodinger -): +def test_bound_graph_methods_accept_declared_arguments(lih_problem, schrodinger): monomial_circuit = lih_problem.monomial_circuit - core = _make_bound_core(lih_problem, serial_comm, schrodinger=schrodinger) + core = _make_bound_core(lih_problem, schrodinger=schrodinger) parameters, _ = _evolve_bound_core(core, monomial_circuit) evolved_operator = core.evolved_operator(parameters, 1e-12) @@ -164,10 +157,13 @@ def test_bound_graph_methods_accept_declared_arguments( ) -def test_graph_memory_breakdown_keys_and_totals(lih_problem, serial_comm): +@pytest.mark.parametrize("partitions", [1, 2]) +def test_graph_memory_breakdown_keys_and_totals(lih_problem, partitions, monkeypatch): """``graph_memory_breakdown()`` had no Python-side coverage at all before this.""" + # Read when the propagator is built: the run's flat world is P = partitions. + monkeypatch.setenv("monoprop_PARTITIONS", str(partitions)) monomial_circuit = lih_problem.monomial_circuit - core = _make_bound_core(lih_problem, serial_comm, schrodinger=False) + core = _make_bound_core(lih_problem, schrodinger=False) _evolve_bound_core(core, monomial_circuit) breakdown = core.graph_memory_breakdown() @@ -189,11 +185,9 @@ def test_graph_memory_breakdown_keys_and_totals(lih_problem, serial_comm): # deleted so an A/B against a build that retained them shows the drop instead of losing the row. assert breakdown["exchange_layout_bytes"] == 0 - # The slot array is indexed by the flat world, so slot_records / layer_cores recovers P. Stated as - # divisibility, not `== 1`, because the MPI gate also runs this at 16 partitions per rank. + # The slot array is indexed by the flat world, so slot_records / layer_cores is P. assert breakdown["d_layer_cores"] > 0 - assert breakdown["d_slot_records"] % breakdown["d_layer_cores"] == 0 - assert breakdown["d_slot_records"] // breakdown["d_layer_cores"] >= 1 + assert breakdown["d_slot_records"] == partitions * breakdown["d_layer_cores"] # An occupied slot holds at least one endpoint, so the endpoint count is the P-independent ceiling. assert breakdown["d_occupied_slots"] <= breakdown["d_slot_records"] diff --git a/tests/test_monoprop_trivial.py b/tests/test_monoprop_trivial.py index d811f90e3..2fb04030c 100644 --- a/tests/test_monoprop_trivial.py +++ b/tests/test_monoprop_trivial.py @@ -40,7 +40,7 @@ ], ) def test_trivial_evolved_operator_cases( - initial_op, cutoff, schrodinger_cutoff, expected, serial_comm + initial_op, cutoff, schrodinger_cutoff, expected ): kwargs = {"schrodinger_cutoff": schrodinger_cutoff} if schrodinger_cutoff else {} quantum_circuit = Circuit( @@ -50,21 +50,18 @@ def test_trivial_evolved_operator_cases( initial_op, quantum_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, **kwargs, ) result = mp.evolved_operator() assert result.terms == expected.terms -def test_trivial_evolved_operator(serial_comm): +def test_trivial_evolved_operator(): initial_op = MajoranaOperator({(0, 1, 2, 4): 1}, 8) quantum_circuit = Circuit( initial_state=[], system_size=initial_op.num_modes, gates=[] ) - mp = MajoranaPropagator( - initial_op, quantum_circuit.initial_state, cutoff=16, comm=serial_comm - ) + mp = MajoranaPropagator(initial_op, quantum_circuit.initial_state, cutoff=16) op = mp.contract_partially(inplace=False) assert op == np.array([-1.0]) @@ -79,7 +76,7 @@ def test_trivial_evolved_operator(serial_comm): "expval_check", ), [ - # Regular picture: checks contract_partially (rank-local) → serial_comm + # Regular picture: checks contract_partially (rank-local), so serial ( MajoranaOperator({(0, 1, 2, 4): 1}, 8), MajoranaOperator({(0, 1, 2, 4): 2.0 + 0j}, 8), @@ -106,7 +103,6 @@ def test_update_initial_operator( schrodinger_cutoff, expected_new, expval_check, - serial_comm, ): kwargs = {"schrodinger_cutoff": schrodinger_cutoff} if schrodinger_cutoff else {} quantum_circuit = Circuit(initial_state=[], system_size=init_op.num_modes, gates=[]) @@ -114,7 +110,6 @@ def test_update_initial_operator( init_op, quantum_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, **kwargs, ) diff --git a/tests/test_mpi_configuration.py b/tests/test_mpi_configuration.py index 34a6cd886..636342181 100644 --- a/tests/test_mpi_configuration.py +++ b/tests/test_mpi_configuration.py @@ -106,8 +106,12 @@ def test_only_rank_zero_reports_under_mpi( mpi_config.pluginmanager.unregister.assert_called_once_with( mpi_config.pluginmanager.getplugin.return_value ) + (plugin, name), _ = mpi_config.pluginmanager.register.call_args + assert isinstance(plugin, conftest._RankFailureReporter) + assert name == "monoprop-rank-failures" else: mpi_config.pluginmanager.unregister.assert_not_called() + mpi_config.pluginmanager.register.assert_not_called() def test_mpi_configuration_without_terminal_reporter(mpi_config, monkeypatch): @@ -116,6 +120,39 @@ def test_mpi_configuration_without_terminal_reporter(mpi_config, monkeypatch): mpi_config.pluginmanager.getplugin.return_value = None conftest.pytest_configure(mpi_config) mpi_config.pluginmanager.unregister.assert_not_called() + mpi_config.pluginmanager.register.assert_called_once() + + +def test_muted_rank_reports_its_failures_on_stderr(capsys): + reporter = conftest._RankFailureReporter(rank=3) + report = Mock( + failed=True, + nodeid="tests/test_x.py::test_y", + when="call", + longreprtext="AssertionError: boom", + ) + reporter.pytest_runtest_logreport(report) + err = capsys.readouterr().err + assert "[rank 3] FAILED tests/test_x.py::test_y (call)" in err + assert "AssertionError: boom" in err + + +def test_muted_rank_is_quiet_on_success(capsys): + conftest._RankFailureReporter(rank=1).pytest_runtest_logreport(Mock(failed=False)) + assert capsys.readouterr().err == "" + + +def test_comm_runs_serially_and_on_the_world_only_under_mpi(): + """``comm`` is ``None`` first; its world case exists only with an MPI backend and is ``mpi``-marked.""" + serial, *world = conftest._COMM_PARAMS + assert serial.values == (None,) + assert not serial.marks + if conftest.HAS_MPI_BACKEND: + (param,) = world + assert param.values == ("comm_world",) + assert [mark.name for mark in param.marks] == ["mpi"] + else: + assert world == [] @pytest.mark.parametrize( diff --git a/tests/test_nonfermi.py b/tests/test_nonfermi.py index e72ec8d60..01fda4a7e 100644 --- a/tests/test_nonfermi.py +++ b/tests/test_nonfermi.py @@ -18,7 +18,7 @@ from monoprop.majorana import MajoranaOperator -def test_nonfermi(serial_comm): +def test_nonfermi(): # Oracle: a hand-computed evolution of a non-fermionic operator. num_modes = 2 majoranas = [(0,), (0, 1), (0, 1, 3), (2, 3)] @@ -52,7 +52,6 @@ def test_nonfermi(serial_comm): fermionic_operator, circuit.initial_state, cutoff=4, - comm=serial_comm, ) mp.propagate(circuit) test_evolved_op = mp.evolved_operator() diff --git a/tests/test_only_rotate_k.py b/tests/test_only_rotate_k.py index 01f1001d1..553f83a69 100644 --- a/tests/test_only_rotate_k.py +++ b/tests/test_only_rotate_k.py @@ -25,9 +25,9 @@ @pytest.fixture -def serial_mp_kwargs(serial_comm): - """Common MP constructor arguments — always single-rank.""" - return {"cutoff": 6, "schrodinger_cutoff": 8, "comm": serial_comm} +def serial_mp_kwargs(): + """Common MP constructor arguments.""" + return {"cutoff": 6, "schrodinger_cutoff": 8} def _split_orbital_gates(gates): @@ -103,7 +103,7 @@ def get_expval(mp): assert np.isclose(expval_none, expval_default, atol=1e-12) -def test_basic_orbital_rotation(serial_comm): +def test_basic_orbital_rotation(): n_modes = 4 operator = MajoranaOperator({}, n_modes) @@ -116,7 +116,7 @@ def test_basic_orbital_rotation(serial_comm): system_size=n_modes, ) circuit = sequence - kwargs = {"cutoff": 6, "schrodinger_cutoff": 8, "comm": serial_comm} + kwargs = {"cutoff": 6, "schrodinger_cutoff": 8} mp_act = MajoranaPropagator(operator, sequence.initial_state, **kwargs) initial_state = mp_act.evolved_operator() @@ -142,7 +142,7 @@ def test_basic_orbital_rotation(serial_comm): @pytest.mark.parametrize("inplace", [False, True]) def test_only_rotate_len_k(orbital_rotation_problem, inplace, serial_mp_kwargs): - """Tests size/graph_size (rank-local) so uses serial_comm.""" + """Serial: size/graph_size are rank-local.""" circuit = orbital_rotation_problem.monomial_circuit.to_circuit() gates = circuit.gates parameters = orbital_rotation_problem.monomial_circuit.parameters diff --git a/tests/test_parameter_validation.py b/tests/test_parameter_validation.py index 9e0c37b91..f024d4e63 100644 --- a/tests/test_parameter_validation.py +++ b/tests/test_parameter_validation.py @@ -23,10 +23,10 @@ from monoprop.pauli import PauliOperator -def _two_gate_graph(serial_comm): +def _two_gate_graph(): """A propagator with a two-layer, two-parameter graph already built.""" operator = MajoranaOperator({(0, 1): 1.0j, (2, 3): 0.5j}, num_modes=2) - mp = MajoranaPropagator(operator, [0, 1], cutoff=4, comm=serial_comm) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4) circuit = Circuit( ( ExpGate(MajoranaOperator({(0,): 1.0}, num_modes=2)), @@ -39,7 +39,7 @@ def _two_gate_graph(serial_comm): class TestConstructorValidation: - def test_invalid_tolerances(self, serial_comm): + def test_invalid_tolerances(self): operator = MajoranaOperator({(0, 1): 1.0j}, num_modes=2) with pytest.raises( RuntimeError, @@ -51,10 +51,9 @@ def test_invalid_tolerances(self, serial_comm): cutoff=4, upper_atol=0.1, lower_atol=0.5, - comm=serial_comm, ) - def test_schrodinger_cutoff_beyond_the_term_index_raises(self, serial_comm): + def test_schrodinger_cutoff_beyond_the_term_index_raises(self): """A Schrodinger cutoff near the mode count admits 2**num_modes paired basis terms. The basis is enumerated, not listed, so an over-wide cutoff is no longer an @@ -72,19 +71,18 @@ def test_schrodinger_cutoff_beyond_the_term_index_raises(self, serial_comm): [], cutoff=4, schrodinger_cutoff=2 * num_modes, - comm=serial_comm, ) class TestGraphAndParameterValidation: - def test_build_graph_accumulates_layers(self, serial_comm): - mp, _ = _two_gate_graph(serial_comm) + def test_build_graph_accumulates_layers(self): + mp, _ = _two_gate_graph() assert mp.graph_layers == 2 assert mp.n_parameters == 2 @pytest.mark.parametrize("parameters", [[1.0], [1.0, 2.0, 3.0]]) - def test_wrong_parameter_length_raises(self, serial_comm, parameters): - mp, _ = _two_gate_graph(serial_comm) + def test_wrong_parameter_length_raises(self, parameters): + mp, _ = _two_gate_graph() with pytest.raises(RuntimeError, match="Parameter length"): mp.expval(parameters) @@ -104,9 +102,9 @@ def test_mixed_param_scheme_rejected(self): with pytest.raises(ValueError, match="every gate must set"): Circuit(gates, 2) - def test_shared_mapping_index_ties_gates(self, serial_comm): + def test_shared_mapping_index_ties_gates(self): operator = MajoranaOperator({(0, 1): 1.0j, (2, 3): 0.5j}, num_modes=2) - mp = MajoranaPropagator(operator, [0, 1], cutoff=4, comm=serial_comm) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4) circuit = Circuit( ( ExpGate(MajoranaOperator({(0,): 1.0}, num_modes=2), index=0), @@ -118,9 +116,9 @@ def test_shared_mapping_index_ties_gates(self, serial_comm): assert mp.graph_layers == 2 assert mp.n_parameters == 1 - def test_functional_invalidated_after_graph_mutation(self, serial_comm): + def test_functional_invalidated_after_graph_mutation(self): operator = MajoranaOperator({(0, 1): 1.0j, (2, 3): 0.5j}, num_modes=2) - mp = MajoranaPropagator(operator, [0, 1], cutoff=4, comm=serial_comm) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4) mp.build_graph( Circuit( ( @@ -186,7 +184,6 @@ def test_functional_invalidated_after_graph_mutation(self, serial_comm): ) def test_functional_invalidated_after_initial_operator_update( self, - serial_comm, propagator_cls, initial_operator, updated_operator, @@ -200,7 +197,6 @@ def test_functional_invalidated_after_initial_operator_update( [0, 1], cutoff=cutoff, schrodinger_cutoff=schrodinger_cutoff, - comm=serial_comm, ) mp.build_graph( Circuit(tuple(ExpGate(generator) for generator in gate_generators), 2) @@ -222,10 +218,8 @@ def test_functional_invalidated_after_initial_operator_update( class TestEvolvedOperatorBothPictures: - def test_schrodinger_returns_state_dict(self, serial_comm): + def test_schrodinger_returns_state_dict(self): operator = MajoranaOperator({(0, 1): 1.0j}, num_modes=2) - mp = MajoranaPropagator( - operator, [0, 1], cutoff=4, schrodinger_cutoff=2, comm=serial_comm - ) + mp = MajoranaPropagator(operator, [0, 1], cutoff=4, schrodinger_cutoff=2) result = mp.evolved_operator() assert isinstance(result, MajoranaOperator) diff --git a/tests/test_pauli.py b/tests/test_pauli.py index a43d5847e..e2b8bdd87 100644 --- a/tests/test_pauli.py +++ b/tests/test_pauli.py @@ -24,28 +24,27 @@ class TestPauliPropagatorCutoff: """PauliPropagator fixes the cutoff to Pauli weight ('support').""" - def _propagator(self, serial_comm): + def _propagator(self): return PauliPropagator( PauliOperator({"ZZ": 1.0}, num_qubits=2), initial_state=[], cutoff=4, - comm=serial_comm, ) - def test_cutoff_type_is_support(self, serial_comm): - assert self._propagator(serial_comm).cutoff_type == "support" + def test_cutoff_type_is_support(self): + assert self._propagator().cutoff_type == "support" - def test_cutoff_type_is_read_only(self, serial_comm): - mp = self._propagator(serial_comm) + def test_cutoff_type_is_read_only(self): + mp = self._propagator() with pytest.raises(AttributeError): mp.cutoff_type = "length" - def test_num_qubits(self, serial_comm): - mp = self._propagator(serial_comm) + def test_num_qubits(self): + mp = self._propagator() assert mp.num_qubits == 2 # "ZZ" operator @pytest.mark.parametrize("schrodinger_cutoff", [3, 4, 5]) - def test_schrodinger_cutoff(self, schrodinger_cutoff, serial_comm): + def test_schrodinger_cutoff(self, schrodinger_cutoff): """schrodinger_cutoff is a Pauli weight in qubits, matching ``cutoff``. PauliPropagator doubles it internally (qubit weight -> slot popcount). @@ -55,7 +54,6 @@ def test_schrodinger_cutoff(self, schrodinger_cutoff, serial_comm): initial_state=[], cutoff=4, schrodinger_cutoff=schrodinger_cutoff, - comm=serial_comm, ) op = mp.evolved_operator() assert max(len(p.qubits) for p in op.terms) == schrodinger_cutoff @@ -73,13 +71,12 @@ def test_schrodinger_cutoff(self, schrodinger_cutoff, serial_comm): pytest.param(PauliOperator({"IZZI": 1.1}, num_qubits=4), id="izzi"), ], ) -def test_evolved_operator_returns_pauli_operator(initial_operator, serial_comm) -> None: +def test_evolved_operator_returns_pauli_operator(initial_operator) -> None: """Without a graph, evolved_operator returns the Pauli-domain initial operator.""" propagator = PauliPropagator( initial_operator, initial_state=[], cutoff=2 * initial_operator.num_qubits, - comm=serial_comm, ) evolved = propagator.evolved_operator() @@ -89,10 +86,10 @@ def test_evolved_operator_returns_pauli_operator(initial_operator, serial_comm) assert evolved.isclose(initial_operator) -def test_update_initial_operator(serial_comm): +def test_update_initial_operator(): """Make sure the initial operator update works correctly with PauliOperator input.""" operator = PauliOperator({"Z": 1.0}, num_qubits=1) - mp = PauliPropagator(operator, initial_state=[], cutoff=1, comm=serial_comm) + mp = PauliPropagator(operator, initial_state=[], cutoff=1) updated_operator = PauliOperator({"Z": 2.75}, num_qubits=1) mp.update_initial_operator(updated_operator) diff --git a/tests/test_term_expectation_values.py b/tests/test_term_expectation_values.py index 5e73cb941..827d56482 100644 --- a/tests/test_term_expectation_values.py +++ b/tests/test_term_expectation_values.py @@ -17,6 +17,9 @@ The values are checked against the expectation value they reassemble, the expectation value with each term alone, and the Schrodinger evolved state, which holds the same values when untruncated. + +Values are allreduced, so the tests run on ``comm``; the Schrodinger read compares rank-local +evolved-state coefficients and stays serial. """ from __future__ import annotations @@ -58,7 +61,7 @@ def _circuit() -> Circuit: return Circuit(tuple(gates), N_QUBITS) -def _heisenberg(cutoff: int, serial_comm, coefficients=None) -> PauliPropagator: +def _heisenberg(cutoff: int, comm, coefficients=None) -> PauliPropagator: """A Heisenberg propagator over ``OBSERVABLES`` with the circuit stored as a graph.""" coefficients = ( np.linspace(0.3, 1.2, len(OBSERVABLES)) @@ -69,7 +72,7 @@ def _heisenberg(cutoff: int, serial_comm, coefficients=None) -> PauliPropagator: PauliOperator(dict(zip(OBSERVABLES, coefficients)), N_QUBITS), initial_state=[0, 3], cutoff=cutoff, - comm=serial_comm, + comm=comm, ) prop.build_graph(_circuit()) return prop @@ -83,10 +86,10 @@ def parameters() -> np.ndarray: @pytest.mark.parametrize("cutoff", [2, 3, N_QUBITS]) def test_pauli_values_reassemble_the_expectation_value( - cutoff, parameters, serial_comm + cutoff, parameters, comm ) -> None: """Initial coefficients times values sum to the expectation value, truncated or not.""" - prop = _heisenberg(cutoff, serial_comm) + prop = _heisenberg(cutoff, comm) values = prop.term_expectation_values(OBSERVABLES, parameters) assert values.dtype == np.float64 @@ -98,11 +101,9 @@ def test_pauli_values_reassemble_the_expectation_value( @pytest.mark.parametrize("cutoff", [2, N_QUBITS]) -def test_each_value_matches_a_single_term_reweight( - cutoff, parameters, serial_comm -) -> None: +def test_each_value_matches_a_single_term_reweight(cutoff, parameters, comm) -> None: """Each value matches the expectation value once the initial operator is that term alone.""" - prop = _heisenberg(cutoff, serial_comm) + prop = _heisenberg(cutoff, comm) values = prop.term_expectation_values(OBSERVABLES, parameters) for i in range(len(OBSERVABLES)): @@ -110,9 +111,9 @@ def test_each_value_matches_a_single_term_reweight( assert prop.expectation_value(parameters) == pytest.approx(values[i], abs=1e-12) -def test_values_match_a_schrodinger_read(parameters, serial_comm) -> None: +def test_values_match_a_schrodinger_read(parameters) -> None: """Untruncated, Heisenberg values equal the Schrodinger evolved-state coefficients.""" - heisenberg = _heisenberg(N_QUBITS, serial_comm).term_expectation_values( + heisenberg = _heisenberg(N_QUBITS, None).term_expectation_values( OBSERVABLES, parameters ) @@ -121,7 +122,6 @@ def test_values_match_a_schrodinger_read(parameters, serial_comm) -> None: initial_state=[0, 3], cutoff=N_QUBITS, schrodinger_cutoff=N_QUBITS, - comm=serial_comm, ) schrodinger.build_graph(_circuit()) state = schrodinger.evolved_operator_coefficients(OBSERVABLES, parameters) @@ -134,22 +134,22 @@ def test_values_match_a_schrodinger_read(parameters, serial_comm) -> None: ) -def test_terms_follow_query_order(parameters, serial_comm) -> None: +def test_terms_follow_query_order(parameters, comm) -> None: """``terms`` reorders and repeats exactly as given.""" - prop = _heisenberg(3, serial_comm) + prop = _heisenberg(3, comm) values = prop.term_expectation_values(OBSERVABLES, parameters) order = [3, 0, 3, 4] queried = prop.term_expectation_values([OBSERVABLES[i] for i in order], parameters) np.testing.assert_array_equal(queried, values[order]) -def test_identity_returns_one(parameters, serial_comm) -> None: +def test_identity_returns_one(parameters, comm) -> None: """The identity returns 1, and its coefficient still counts when reassembling the expectation value.""" prop = PauliPropagator( PauliOperator({Pauli("", ()): 0.5, OBSERVABLES[0]: 1.0}, N_QUBITS), initial_state=[], cutoff=3, - comm=serial_comm, + comm=comm, ) prop.build_graph(_circuit()) values = prop.term_expectation_values([Pauli("", ()), OBSERVABLES[0]], parameters) @@ -159,20 +159,20 @@ def test_identity_returns_one(parameters, serial_comm) -> None: ) -def test_heisenberg_absent_term_raises(parameters, serial_comm) -> None: +def test_heisenberg_absent_term_raises(parameters, comm) -> None: """Heisenberg raises for a term absent from the evolved operator.""" - prop = _heisenberg(2, serial_comm) + prop = _heisenberg(2, comm) terms = [Pauli("XXXXX", (0, 1, 2, 3, 4))] with pytest.raises(RuntimeError, match="not found"): prop.term_expectation_values(terms, parameters) -def test_heisenberg_rejects_absorbed_gates(parameters, serial_comm) -> None: +def test_heisenberg_rejects_absorbed_gates(parameters, comm) -> None: """Heisenberg raises after an in-place contract_partially or propagate. The values would miss the absorbed gates. """ - contracted = _heisenberg(2, serial_comm) + contracted = _heisenberg(2, comm) contracted.contract_partially(parameters, inplace=True) with pytest.raises(RuntimeError, match="absorbed gates"): contracted.term_expectation_values(OBSERVABLES) @@ -181,7 +181,7 @@ def test_heisenberg_rejects_absorbed_gates(parameters, serial_comm) -> None: PauliOperator(dict.fromkeys(OBSERVABLES, 1.0), N_QUBITS), initial_state=[0, 3], cutoff=2, - comm=serial_comm, + comm=comm, ) circuit = _circuit() propagated.propagate(Circuit(circuit.gates, N_QUBITS, parameters=tuple(parameters))) @@ -189,28 +189,28 @@ def test_heisenberg_rejects_absorbed_gates(parameters, serial_comm) -> None: propagated.term_expectation_values(OBSERVABLES) -def test_schrodinger_absent_term_returns_zero(parameters, serial_comm) -> None: +def test_schrodinger_absent_term_returns_zero(parameters, comm) -> None: """Schrodinger returns 0 for a term past the state's cutoff, which the truncated state lacks.""" prop = PauliPropagator( PauliOperator({OBSERVABLES[0]: 1.0}, N_QUBITS), initial_state=[], cutoff=2, schrodinger_cutoff=2, - comm=serial_comm, + comm=comm, ) prop.build_graph(_circuit()) values = prop.term_expectation_values([Pauli("XXXXX", (0, 1, 2, 3, 4))], parameters) np.testing.assert_array_equal(values, [0.0]) -def test_majorana_values_reassemble_the_expectation_value(serial_comm) -> None: +def test_majorana_values_reassemble_the_expectation_value(comm) -> None: """Majorana values are complex, and coefficients times values sum to the expectation value.""" problem = load_problem(DATA / "random_exact.msgpack") prop = MajoranaPropagator( problem.operator, problem.monomial_circuit.initial_state, cutoff=problem.n_modes // 2, - comm=serial_comm, + comm=comm, ) prop.build_graph(problem.monomial_circuit.to_circuit()) parameters = problem.monomial_circuit.parameters diff --git a/tests/test_update_methods.py b/tests/test_update_methods.py index 7e80335c2..d59197241 100644 --- a/tests/test_update_methods.py +++ b/tests/test_update_methods.py @@ -24,14 +24,13 @@ class TestUpdateMethods: @pytest.fixture - def mp(self, serial_comm): + def mp(self): return MajoranaPropagator( initial_operator=MajoranaOperator({(0, 1): 1.0j}, num_modes=4), initial_state=[], cutoff=4, lower_atol=1e-8, upper_atol=1e-4, - comm=serial_comm, ) @pytest.mark.parametrize("valid_value", [1e-10, None]) @@ -132,7 +131,7 @@ def test_update_cutoff_type_enum_validation(self, mp, invalid_value): with pytest.raises((ValueError, RuntimeError, TypeError)): mp.cutoff_type = invalid_value - def test_integration(self, serial_comm): + def test_integration(self): sequence = Circuit.from_dense_arrays( initial_state=[], majoranas=[(0, 2), (1, 3)], @@ -145,7 +144,6 @@ def test_integration(self, serial_comm): initial_operator=MajoranaOperator({(0, 1): 1.0j}, num_modes=4), initial_state=sequence.initial_state, cutoff=4, - comm=serial_comm, ) updates = { "lower_atol": 1e-10, @@ -196,7 +194,7 @@ def test_reset_to_defaults(self, mp): @pytest.mark.parametrize("test_type", ["cutoff", "lower_atol", "upper_atol"]) -def test_evolutions_after_updates(lih_problem, test_type, serial_comm): +def test_evolutions_after_updates(lih_problem, test_type): circuit = lih_problem.monomial_circuit.to_circuit() # upper_atol only rescues over-cutoff partners, of which cutoff 6 produces none here. @@ -206,7 +204,6 @@ def test_evolutions_after_updates(lih_problem, test_type, serial_comm): lih_problem.operator, lih_problem.monomial_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, ) mp.propagate(circuit) mp_size = mp.size() @@ -215,7 +212,6 @@ def test_evolutions_after_updates(lih_problem, test_type, serial_comm): lih_problem.operator, lih_problem.monomial_circuit.initial_state, cutoff=cutoff, - comm=serial_comm, ) if test_type == "lower_atol": mp_tes.lower_atol = 1 diff --git a/tools/cibw/test-wheel-mpi.sh b/tools/cibw/test-wheel-mpi.sh index af886dc57..b0fa8c0ba 100755 --- a/tools/cibw/test-wheel-mpi.sh +++ b/tools/cibw/test-wheel-mpi.sh @@ -65,5 +65,5 @@ 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 +"$mpiexec" -n 2 "$python" -m pytest tests --with-mpi -m mpi -p no:cacheprovider --timeout=300 --timeout-method=thread "$python" -m pytest tests/test_mpi_backend.py -p no:cacheprovider diff --git a/tools/vscode-pytest-wrapper.sh b/tools/vscode-pytest-wrapper.sh index ce82fd243..0547a9f5b 100755 --- a/tools/vscode-pytest-wrapper.sh +++ b/tools/vscode-pytest-wrapper.sh @@ -3,8 +3,8 @@ set -euo pipefail # VS Code Python Testing wrapper: # - Keeps discovery stable by running collect-only directly (no mpiexec). -# - Runs tests through mpiexec so both [comm_self] and [comm_world] -# variants exercise the correct code paths. +# - Runs tests through mpiexec so the [comm_world] cases run on a real world +# communicator. # # Environment variables: # monoprop_VSCODE_MPI_MODE all (default) | auto | off diff --git a/uv.lock b/uv.lock index 2c8885afb..1303048f6 100644 --- a/uv.lock +++ b/uv.lock @@ -1305,6 +1305,7 @@ bench = [ { name = "pytest-benchmark" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, + { name = "pytest-timeout" }, { name = "tabulate" }, ] dev = [ @@ -1334,6 +1335,7 @@ test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, + { name = "pytest-timeout" }, { name = "tabulate" }, ] workspace-test = [ @@ -1343,6 +1345,7 @@ workspace-test = [ { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-datadir" }, + { name = "pytest-timeout" }, { name = "tabulate" }, ] @@ -1365,6 +1368,7 @@ bench = [ { name = "pytest-benchmark", specifier = ">=5" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "tabulate", specifier = ">=0.9" }, ] dev = [ @@ -1394,6 +1398,7 @@ test = [ { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "tabulate", specifier = ">=0.9" }, ] workspace-test = [ @@ -1403,6 +1408,7 @@ workspace-test = [ { name = "pytest", specifier = ">=8.2.2,<9.2" }, { name = "pytest-cov", specifier = ">=7.1.0" }, { name = "pytest-datadir" }, + { name = "pytest-timeout", specifier = ">=2.3" }, { name = "tabulate", specifier = ">=0.9" }, ] @@ -2212,6 +2218,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0c/b9/c3df11997d29e69b3f8edae1e903bf44eaf4774ccf4c5b6ddcebde88931c/pytest_markdown_docs-0.9.2-py3-none-any.whl", hash = "sha256:9c05a5bee48214cb36583d4a5131d3a23b327a8d82c92ae860106053fd7d1f9e", size = 13536, upload-time = "2026-03-23T12:35:03.862Z" }, ] +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973, upload-time = "2025-05-05T19:44:34.99Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382, upload-time = "2025-05-05T19:44:33.502Z" }, +] + [[package]] name = "python-dateutil" version = "3.0.0"