diff --git a/AGENTS.md b/AGENTS.md index 1fa7a18f..35bcd09e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,6 +84,10 @@ Key files: change to the recorded sections has to land on both sides of the package boundary. Benchmark names are Bencher's history key, so renaming or moving a `bench_*` test orphans its tracked series. +- **`rounds > 1` overlaps two rounds' live memory** (`setup=` runs before the prior round's + teardown) — pin `--bench-rounds=1`; `record_memory` measures that construction transient, + not per-op cost (use `op_memory`). +- **Peak memory is `HighWaterMark`, not sampled PSS** — exact, unlike `/proc/self/smaps_rollup`. ### Core abstractions (the propagation backbone) @@ -137,6 +141,8 @@ mp = MajoranaPropagator(operator, initial_state, cutoff=4) - Fixture msgpack schema is documented in `tests/data/README.md` - Tests validate against exact solutions for small systems - Heavy use of `@parametrize_with_cases` decorators +- **pytest's fd-level capture hides C++ stderr** (e.g. `COMMPROF`) — rerun with `-s` to see it. +- **A slow CTest run on an MPI build is `MPI_Init` fabric probing, not slow tests** — see `monoprop_TEST_EXCLUDE_MPI_FABRIC` in `cpp/tests/CMakeLists.txt`. ## Key Dependencies & Integration @@ -176,5 +182,7 @@ When changing behavior, APIs, build/test workflows, paths, or developer conventi - Check `build/*/compile_commands.json` for compilation flags - Use `rm -rf build` to clear environment-specific builds - Verify `monoprop_MAX_NUM_MODES` matches your use case (default: 250) +- **`uv sync` does not relink `bin/monoprop_unit_tests.x`** — check its mtime against the + source's; recipe for a standalone C++ build tree in `docs/content/docs/building.mdx`. This is a sophisticated scientific computing project requiring careful attention to template instantiation, build system configuration, and the C++/Python boundary. diff --git a/benches/bench_models.py b/benches/bench_models.py index c45e70f2..7a702335 100644 --- a/benches/bench_models.py +++ b/benches/bench_models.py @@ -12,21 +12,37 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Fixed-model benchmarks: heavy, Heisenberg-only, in-place simulations. +"""Fixed-model benchmarks: heavy, Heisenberg-only simulations. The 120-qubit Fermi-Hubbard trajectory and the 127-qubit Pauli-basis kicked-Ising circuit, at fixed sizes. The registry (config class, builder, steps-per-run) lives in :data:`monoprop_bench_tools.models.MODELS`; each config field is overridable via ``---``. + +Run one group per pytest process: build_graph/propagate and energy/gradient do not fit a node together. """ from __future__ import annotations +import os from typing import Any import pytest from monoprop_bench_tools.memory.cpu import resting_rss_bytes -from monoprop_bench_tools.models import MODELS, barriered +from monoprop_bench_tools.models import MODELS, barrier_setup, barriered + +# `build_graph` extends the graph, so a driver that re-applies its circuit retains one layer-set per step +MAX_GRAPH_STEPS = 2 + + +def skip_if_graph_will_not_fit(model: str, steps: int) -> None: + """Skip a graph-holding benchmark whose retained graph is known not to fit.""" + allow = os.environ.get("monoprop_BENCH_ALLOW_BIG_GRAPH") # noqa: SIM112 + if steps > MAX_GRAPH_STEPS and allow != "1": + pytest.skip( + f"{model}: {steps} successive build_graph calls retain {steps} layer-sets, " + f"measured to exceed 242 GiB. Set monoprop_BENCH_ALLOW_BIG_GRAPH=1 to run it." + ) @pytest.mark.slow @@ -59,9 +75,138 @@ def run(built, n_steps): return propagator.expectation_value() result = benchmark.pedantic( - barriered(run, bench_comm), setup=setup, rounds=1, iterations=1 + barriered(run, bench_comm), + setup=barrier_setup(bench_comm, setup), + rounds=1, + iterations=1, ) assert isinstance(result, float) propagator, _circuit = state["built"] record_model_stats(model, propagator, state["baseline_rss"]) + + +@pytest.mark.slow +@pytest.mark.parametrize("model", list(MODELS)) +def test_model_build_graph( + benchmark, + bench_comm, + bench_rounds, + model_configs, + model, + record_model_config, + op_memory, + record_opsize, +): + """Benchmark building a fixed model's propagation graph, from a fresh propagator.""" + _config_cls, build_fn, steps_fn = MODELS[model] + config = model_configs[model] + steps = steps_fn(config) + skip_if_graph_will_not_fit(model, steps) + record_model_config(model, config) + + last = [] + + def setup(): + built = build_fn(config, comm=bench_comm) + last[:] = [built[0]] + op_memory.open() # inside setup, so the window excludes the construction + return (built, steps), {} + + def build(built, n_steps): + propagator, circuit = built + for _ in range(n_steps): + propagator.build_graph(circuit) + + benchmark.pedantic( + barriered(build, bench_comm), + setup=barrier_setup(bench_comm, setup), + rounds=bench_rounds, + iterations=1, + ) + op_memory.close(last[0]) + assert record_opsize(last[0]) > 0 + assert last[0].graph_layers > 0 + + +@pytest.mark.slow +@pytest.mark.parametrize("model", list(MODELS)) +def test_model_propagate( + benchmark, + bench_comm, + bench_rounds, + model_configs, + model, + record_model_config, + op_memory, + record_opsize, +): + """Benchmark a fixed model's in-place evolution alone -- no expectation value, no graph.""" + _config_cls, build_fn, steps_fn = MODELS[model] + config = model_configs[model] + steps = steps_fn(config) + record_model_config(model, config) + + last = [] + + def setup(): + built = build_fn(config, comm=bench_comm) + last[:] = [built[0]] + op_memory.open() + return (built, steps), {} + + def run(built, n_steps): + propagator, circuit = built + for _ in range(n_steps): + propagator.propagate(circuit) + + benchmark.pedantic( + barriered(run, bench_comm), + setup=barrier_setup(bench_comm, setup), + rounds=bench_rounds, + iterations=1, + ) + op_memory.close(last[0]) + assert record_opsize(last[0]) > 0 + + +@pytest.mark.slow +@pytest.mark.parametrize("model", list(MODELS)) +def test_model_energy( + benchmark, model_graph, model, model_configs, bench_comm, bench_rounds, op_memory +): + """Benchmark evaluating a fixed model's expectation-value functional.""" + skip_if_graph_will_not_fit(model, MODELS[model][2](model_configs[model])) + propagator, parameters = model_graph(model) + functional = propagator.expectation_value_functional() + op_memory.open() + result = benchmark.pedantic( + barriered(functional, bench_comm), + args=(parameters,), + setup=barrier_setup(bench_comm), + rounds=bench_rounds, + iterations=1, + ) + op_memory.close(propagator) + assert isinstance(result, float) + + +@pytest.mark.slow +@pytest.mark.parametrize("model", list(MODELS)) +def test_model_gradient( + benchmark, model_graph, model, model_configs, bench_comm, bench_rounds, op_memory +): + """Benchmark evaluating a fixed model's expectation-value-and-gradient functional.""" + skip_if_graph_will_not_fit(model, MODELS[model][2](model_configs[model])) + propagator, parameters = model_graph(model) + functional = propagator.expectation_value_and_gradient_functional() + op_memory.open() + _value, gradient = benchmark.pedantic( + barriered(functional, bench_comm), + args=(parameters,), + setup=barrier_setup(bench_comm), + rounds=bench_rounds, + iterations=1, + ) + op_memory.close(propagator) + assert len(gradient) == len(parameters) diff --git a/benches/bench_random.py b/benches/bench_random.py index f691bbd8..31f5e75d 100644 --- a/benches/bench_random.py +++ b/benches/bench_random.py @@ -16,27 +16,73 @@ from __future__ import annotations -from monoprop_bench_tools.models import barriered +from monoprop_bench_tools.models import barrier_setup, barriered PARE_THRESHOLD = 1e-10 INPLACE_LOWER_ATOL = 1e-5 def test_random_build_graph( - benchmark, make_random_propagator, bench_comm, bench_rounds + benchmark, + make_random_propagator, + bench_comm, + bench_rounds, + op_memory, + record_opsize, ): """Benchmark building the propagation graph from a fresh propagator.""" + last = [] def setup(): - return (make_random_propagator(),), {} + built = make_random_propagator() + last[:] = [built[0]] + op_memory.open() # inside setup, so the window excludes the construction + return (built,), {} def build(built): propagator, circuit = built propagator.build_graph(circuit) benchmark.pedantic( - barriered(build, bench_comm), setup=setup, rounds=bench_rounds, iterations=1 + barriered(build, bench_comm), + setup=barrier_setup(bench_comm, setup), + rounds=bench_rounds, + iterations=1, + ) + op_memory.close(last[0]) + assert record_opsize(last[0]) > 0 + assert last[0].graph_layers > 0 + + +def test_random_propagate( + benchmark, + make_random_propagator, + bench_comm, + bench_rounds, + op_memory, + record_opsize, +): + """Benchmark in-place evolution alone, with no expectation value and no graph.""" + last = [] + + def setup(): + built = make_random_propagator() + last[:] = [built[0]] + op_memory.open() + return (built,), {} + + def run(built): + propagator, circuit = built + propagator.propagate(circuit) + + benchmark.pedantic( + barriered(run, bench_comm), + setup=barrier_setup(bench_comm, setup), + rounds=bench_rounds, + iterations=1, ) + op_memory.close(last[0]) + assert record_opsize(last[0]) > 0 def test_random_pare(benchmark, built_graph, bench_comm, bench_rounds): @@ -47,34 +93,52 @@ def pare(): pare_threshold=PARE_THRESHOLD, ) - benchmark.pedantic(barriered(pare, bench_comm), rounds=bench_rounds, iterations=1) + benchmark.pedantic( + barriered(pare, bench_comm), + setup=barrier_setup(bench_comm), + rounds=bench_rounds, + iterations=1, + ) def test_random_energy( - benchmark, built_graph, random_problem, bench_comm, bench_rounds + benchmark, built_graph, random_problem, bench_comm, bench_rounds, op_memory ): """Benchmark evaluating the expectation-value functional.""" functional = built_graph.expectation_value_functional() + op_memory.open() + + # The entry barrier must stay untimed, so it lives in a setup, and a setup that returns args forbids args=. + def setup(): + return (random_problem.parameters,), {} + result = benchmark.pedantic( barriered(functional, bench_comm), - args=(random_problem.parameters,), + setup=barrier_setup(bench_comm, setup), rounds=bench_rounds, iterations=1, ) + op_memory.close(built_graph) assert isinstance(result, float) def test_random_gradient( - benchmark, built_graph, random_problem, bench_comm, bench_rounds + benchmark, built_graph, random_problem, bench_comm, bench_rounds, op_memory ): """Benchmark evaluating the expectation-value-and-gradient functional.""" functional = built_graph.expectation_value_and_gradient_functional() + op_memory.open() + + def setup(): + return (random_problem.parameters,), {} + _value, gradient = benchmark.pedantic( barriered(functional, bench_comm), - args=(random_problem.parameters,), + setup=barrier_setup(bench_comm, setup), rounds=bench_rounds, iterations=1, ) + op_memory.close(built_graph) assert len(gradient) == len(random_problem.parameters) @@ -90,6 +154,9 @@ def run(built): return propagator.expectation_value() result = benchmark.pedantic( - barriered(run, bench_comm), setup=setup, rounds=bench_rounds, iterations=1 + barriered(run, bench_comm), + setup=barrier_setup(bench_comm, setup), + rounds=bench_rounds, + iterations=1, ) assert isinstance(result, float) diff --git a/benches/conftest.py b/benches/conftest.py index b469ca41..ef22dd80 100644 --- a/benches/conftest.py +++ b/benches/conftest.py @@ -32,6 +32,8 @@ from __future__ import annotations +import gc +import hashlib import json import os import socket @@ -43,8 +45,7 @@ import pytest from monoprop_bench_tools.memory.cpu import ( HighWaterMark, - PssSampler, - merge_peak_of_sum, + pinned_thread_summary, resting_rss_bytes, ) from monoprop_bench_tools.models import ( @@ -88,6 +89,33 @@ def _reduce_sum(comm: Any, value: int) -> int: return value +def _reduce_max(comm: Any, value: int) -> int: + """Return the largest ``value`` over ranks. Collective; serial returns ``value``.""" + if comm is not None and comm.Get_size() > 1: + return comm.allreduce(value, op=MPI.MAX) + return value + + +def _reduce_min(comm: Any, value: int) -> int: + """Return the smallest ``value`` over ranks. Collective; serial returns ``value``.""" + if comm is not None and comm.Get_size() > 1: + return comm.allreduce(value, op=MPI.MIN) + return value + + +def _gather_lists(comm: Any, values: list[int]) -> list[list[int]]: + """Gather per-rank CPU-id lists to rank 0. Collective; off root returns ``[]``.""" + if comm is None or comm.Get_size() == 1: + return [values] + gathered = comm.gather(values, root=0) + return gathered if gathered is not None else [] + + +def _spread(comm: Any, value: int) -> dict[str, int]: + """Reduce a per-rank number to ``sum`` (bounds the job) and ``max`` (bounds a node).""" + return {"sum": _reduce_sum(comm, value), "max": _reduce_max(comm, value)} + + _RANDOM_OPTIONS = ( ("gen-length", 4, "Majorana operators per generator."), ("obs-terms", 10000, "Observable terms."), @@ -98,17 +126,21 @@ def _reduce_sum(comm: Any, value: int) -> int: ("bench-rounds", 1, "Fixed timing rounds (MPI-safe)."), ) -# Non-timing results, written at session end (rank 0) to ``results/