Skip to content

test(bench): split the fixed-model benchmarks into four timed operations - #244

Open
diagonal-hamiltonian wants to merge 14 commits into
mainfrom
pr/bench-and-test-harness
Open

test(bench): split the fixed-model benchmarks into four timed operations#244
diagonal-hamiltonian wants to merge 14 commits into
mainfrom
pr/bench-and-test-harness

Conversation

@diagonal-hamiltonian

@diagonal-hamiltonian diagonal-hamiltonian commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Branch pr/bench-and-test-harness @ 302767e, merged up to origin/main @ 1b7533f.
main's #227 moved the shared bench modules into packages/monoprop-bench-tools/, so the
resting_rss_bytes and MODELS edits land inside that package — visible as renames with
detection on. This branch now requires monoprop-bench-tools installed, as main does.

What this changes

1. The four fixed-model benchmarks become four separately timed operations
build_graph, propagate, energy, gradient — instead of one blended figure, each with
its own time and its own memory window. That split is what makes the operator/graph memory
question answerable: build_graph and propagate are mutually exclusive per process, so a
propagate row's peak RSS is operator memory (its graph ledger reads exactly 0) and a
build_graph row's is graph memory. No new instrument was needed. test_model is
untouched — it is a tracked Bencher series, and renaming it would orphan its history.

2. monoprop_TEST_EXCLUDE_MPI_FABRIC, a new CMake option, ON for MPI builds: the
single-process test variants stop initialising every fabric device in MPI_Init. 2.03 s →
0.61 s per case
, faster on all 208 cases the two runs share. This changes the CI default
there
, and it is safe only there — the same setting hangs multi-rank runs, so it is scoped
to the serial variants through discover_tests' new SERIAL_ENVIRONMENT argument.

Supporting: the entry barrier comes out of the timed region, where it was charging each
measurement for skew in the preceding setup; an A/B arm is identified by its _core.so hash,
not by a dist-info version that goes stale after a rebuild; per-rank kernel peak RSS and
thread affinity are read from /proc and /usr/bin/time -v, neither cluster-specific; and
model benchmarks whose graph cannot fit skip instead of OOM-killing the run.

Measurement — this PR is the null control for the rest of the stack

It adds no library code. pr3 and main differ only by an upstream classtypename sweep
that is provably codegen-identical, yet their extension modules hash differently, so the A/B
harness treats them as two genuine arms and measures them like any other pair — which
measures the harness's own false-positive rate instead of assuming it.

main _core.so  f73d17f264e62f52987591dea0c79b50   (origin/main 6abd839, pinned)
port _core.so  216d7e2f0593aa46cbe12d133dd814bd   (pr/bench-and-test-harness cd58352)

0 of 24 timing tests resolved, best agreement anywhere 8 of 10 reps. Every operation flat
on both workloads, both layouts, both node counts; term counts identical in all 12 cells.
That is what the rest of the stack is read against.

The grid: layout A = 1 rank/node × 128 partitions, B = 8 × 16; N = 1 and 2; hubbard c10 /
lower_atol=1.25e-5, pauli c14 / 5e-5, plus a build_graph[hubbard] rung at
--hubbard-trotter-steps=2. A and B hold the flat world P constant (128 at N=1, 256 at N=2),
so A-vs-B is transport alone. --cpu-bind=cores --distribution=block:block, both arms,
recorded per cell. 10 interleaved reps, order flipped per (rep, cell); median of paired
per-rep ratios, sign test, Holm across the 24-test family. Ten reps because six floors the
sign test at p=0.031, which Holm over 24 tests makes 0.75.

Not reproducible from this diff. Measured at cd58352, before the merge, on Deucalion by
a private harness driving two prebuilt venvs whose hashes are above. The batch scripts and
report generators live in a separate private repo and are not proposed for this one.

diagonal-hamiltonian and others added 10 commits August 18, 2026 12:34
Adds a memory arm to the random suite and makes `propagate` measurable on its
own, so an A/B can attribute time and memory to `build_graph`, `propagate`,
`energy` and `gradient` separately rather than to a test that fuses them.

- `test_random_propagate` builds a fresh propagator per round, because
  `propagate()` raises `GraphStateConflict` on an instance already holding a
  graph, and carries `lower_atol=None` so it lands on the same operator as
  `build_graph`. `test_random_inplace` fused it with `expectation_value` behind
  a 1e-5 truncation and could not be read as either operation.
- New per-op artifact sections: `opmemdelta` (peak above the operation's own
  floor -- the headline), `opmempeak`/`opmembase`, `opbytes` from the engine's
  own accounting, and `opmembreak`. Windows cover only the timed call.
- `HighWaterMark` gains `open()`/`close()` so a window can be opened inside a
  pedantic `setup=` and closed after `pedantic()` returns, and
  `pinned_thread_summary()` reads placement from
  /proc/self/task/*/status:Cpus_allowed_list -- deliberately engine-independent,
  since a build without monoprop_COMM_PROFILE reports nothing about itself.
- `PssSampler` is now opt-in (`--bench-pss-sampler`). Above a few GiB its
  smaps_rollup read is a page-table walk under mmap_lock, and it samples only on
  GIL release, so the faster arm reports the lower peak -- a confound, not a
  lower bound.

Assisted-by: ClaudeCode:claude-opus-5
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 3a1a897)
bench_models.py had one fused test -- steps x propagate plus an
expectation value -- with no op_memory window and no operation split, so
Hubbard and Pauli had no per-operation time or memory at all. Adds
build_graph, propagate, energy and gradient, mirroring how
bench_random.py splits the random problem, and reusing op_memory,
record_opsize, barriered, bench_rounds and bench_comm unchanged.

test_model stays exactly as it was: it is a tracked Bencher series, and
replacing it would orphan the history.

build_graph and propagate issue `steps` successive calls, because
Hubbard's circuit is a single Trotter step the driver re-applies 29
times. Fewer would build a shorter graph than propagate evolves. Pauli's
step count is 1, so the pair attributes per-call cost 29x against 1x
between the two models without any extra code.

conftest gains model_graph, the fixed-model analogue of built_graph:
session-scoped and cached per model so energy and gradient share one
build. It is a factory rather than a parametrized fixture because the
benchmarks select their model with @pytest.mark.parametrize, which a
session-scoped fixture cannot see. It records the config as well as the
stats -- the graph cell runs nothing that calls record_model_config, and
a sweep over cutoff and system size is unreadable without them. gc.freeze()
as in random_problem: at these term counts the model's own objects are
millions of GC roots that can never become garbage.

record_model_stats' body moves to a module-level _record_model_stats so
the fixture and model_graph cannot drift apart.

The two groups do not belong in one process -- build_graph and propagate
each hold their own operator while energy and gradient share one, and
all four together do not fit a node at these sizes. Documented on the
module.

Assisted-by: ClaudeCode:claude-opus-5
(cherry picked from commit 73fa44d)
`build_graph` extends the graph rather than replacing it, so a model whose
driver re-applies its circuit retains every step's layer-set where `propagate`
retains one. Measured on hubbard at 1 rank x 16 partitions, lower_atol=1e-4:

  propagate    29 steps, 1,887,255 terms ->    379 MiB
  build_graph  29 steps, 1,887,255 terms -> OOM > 242 GiB

That operator is ~110 MB at the 58 B/term a 2-step run measures, so it is the
retained graph, and it does not shrink with rank count -- 1, 2 and 4 nodes all
died the same way.

`just bench-ci` passes -m "not slow" and never reached these, but `just bench`
does not, so without this the three graph-holding benchmarks this PR adds would
OOM the machine of anyone who ran them. They now skip with the measurement in
the message. The limit is 2 rather than 1 because a 2-step hubbard reaches 278M
terms in 15.4 GiB -- two steps is measured to fit, not assumed. Pauli's step
count is 1 and is unaffected; set monoprop_BENCH_ALLOW_BIG_GRAPH=1 to override.

Assisted-by: ClaudeCode:claude-opus-5
(cherry picked from commit 344fe37)
Merges pr/bench-record-placement (#230) into the per-operation profiling lineage. The two
were siblings off the same commit, each carrying something the other lacked, and shipping
either alone loses half the work.

From #230: barriered()'s entry barrier moves into a new barrier_setup() so it runs inside
pedantic's setup= rather than inside the timed call, and PssSampler becomes opt-in behind
--bench-pss-sampler with HighWaterMark as the default. #230's own note that the barrier was
worth ~1.44x of a 2.86x observed spread on build_graph is an unreplicated single-run
observation -- it traces to no job id and no RESULTS doc -- and should be read as one.

_record_placement is called from _record_model_stats rather than from either caller's body,
which is where #230 put it. That reaches both the per-operation fixture and the
session-scoped model_graph fixture; the fixed-model benchmarks open no memory window, so
before this they recorded no placement at all and ab_summary.py refused their results --
correctly, but for an instrumentation gap rather than a bad run.

Drops the "Placement must not divide an already-divided machine" paragraph this lineage had
added to AGENTS.md. It is placement documentation carried by a benches commit, it is not on
main, and it describes a design that exists on no branch it was ever committed to -- it
names classify_node_mask, NodeMask::PerRank, Shared and a test, none of which exist on
pr/partition-cgroup-placement either. It belongs with the placement fix, rewritten to that
branch's actual vocabulary and without the 437 -> 15.5 us/sync figure, which is a pair of
range midpoints measured on the pre-hwloc tree with a different branch as the arm.

Assisted-by: ClaudeCode:claude-opus-5
…n stamp

An A/B whose two arms cannot be told apart prints a full table of 1.00x and calls it flat,
which is the most expensive way to fail here: the output looks exactly like a real negative
result.

__version__ cannot tell them apart. It is a git describe of the worktree, stamped into the
dist-info when the package is installed, and a later `cmake --build` plus a copy of the .so
into the venv does not rewrite it -- so an editable-install arm advertises the commit it was
FIRST installed at while serving a binary from several commits later. Keying on it refused a
run whose arms were provably distinct, and would equally have PASSED two arms sharing one
.so across differing checkouts. The hash catches both directions; the version catches
neither.

So record the md5 of whatever `_core` actually resolved to -- not a path rebuilt from the
source tree, since site-packages holds its own copy and the point is to fingerprint the file
that was loaded. Versions stay in the report as a checkout-level breadcrumb.

Assisted-by: ClaudeCode:claude-opus-5
CTest runs every Boost case as its own process, so an MPI build pays a full
MPI_Init per case, and MPI_Init initialises every fabric device present even
though a single-process test never sends a message: 8.8 s per process against
0.2 s of user CPU on a login node with 8 HCAs, i.e. 34 minutes for 224 cases.
Excluding the fabric components takes each process to 1.9 s and the suite to
6.8 minutes, 224/224 passing.

Scoped to the per-case `serial` variants through a new SERIAL_ENVIRONMENT
argument on discover_tests, and deliberately kept off the multi-rank ones: a
per-case launch has world size 1, so no transport is used and the fabric can
only cost startup time, whereas OMPI_MCA_pml=^ucx makes a 2-rank run of
zero_cutoff_upper_atol_zero_is_exact_World hang indefinitely where it
otherwise passes in 29 ms. That hang reproduces on the pre-branch commit, so
it is component selection rather than engine code.

Assisted-by: ClaudeCode:claude-opus-5
(cherry picked from commit d8755f9)
…login node

The suite-wall figure this branch shipped ("34 min to 5.4") was never measured. It was
224 x 8.8 s of arithmetic over a login-node per-case cost and a case count that is now
stale, and the tree disagreed with itself: hpc/deucalion/README.md said 6.8 min for the
same change while AGENTS.md said 5.4.

Fabric init is much cheaper on the compute nodes the gate actually runs on, so the
login-node measurement sizes the mechanism but not the saving. Measured there instead:
ctest -L serial costs 0.61 s/case with the exclusion (208 cases in 126.03 s, job 1828011)
against 2.03 s/case without it (214 cases in 435.31 s, job 1828023) -- 1.42 s/case, a
median paired ratio of 3.42x, faster on 208 of the 208 cases the two runs share.

The comparison is across two branches rather than two arms of one experiment. The one
case that receives no exclusion on either side, the 2-rank monoprop_unit_tests.x_mpi_2,
differs by 1.3% (6.36 s against 6.44 s), which bounds the branch effect.

The login-node per-case table is kept and is now labelled as such.

Assisted-by: ClaudeCode:claude-opus-5
pinned_thread_summary() already parsed Cpus_allowed_list into a set of CPU
ids and returned only its cardinality. Counts cannot distinguish sixteen
threads on one NUMA domain from sixteen spread over four, which is the
distinction a placement investigation turns on, so return the ids too.

_record_placement gathers them per rank rather than keeping rank 0's alone:
a partial placement failure -- some ranks placed, some not -- is the shape a
Slurm cpuset confinement takes, and it is invisible in any single rank's view.
The gather sits above the rank-0 check so every rank enters the collective,
matching _reduce_min/_reduce_max.
_record_placement's docstring already gives the reason -- "a partial failure --
some ranks placed, some not -- is the interesting case, and it is the shape a
Slurm cpuset confinement takes" -- but only single_cpu_threads was reduced.
affinity_cpus came through **summary unreduced, so it carried rank 0's mask
width alone and could not distinguish a node-wide confinement from one that
confined rank 0 and left the other ranks free.

Both reductions are unconditional collectives placed with the existing ones,
before the rank-0 guard, so every rank still executes the same sequence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Brings in 1b7533f, which releases the benchmark harness as the
monoprop-bench-tools package. Three conflicts, all resolved mechanically:

  benches/bench_random.py  import block -> monoprop_bench_tools.models,
                           keeping PR 3's barrier_setup alongside barriered
  benches/bench_models.py  same import move, plus resting_rss_bytes from
                           monoprop_bench_tools.memory.cpu; docstring now
                           points at :data:`monoprop_bench_tools.models.MODELS`
                           (main's wording) with PR 3's paragraphs on the four
                           split operations kept
  AGENTS.md                main's rewritten renderer bullet taken verbatim;
                           PR 3's two additive bullets kept -- pedantic setup=
                           doubling peak memory, and PssSampler's smaps_rollup
                           cost, the latter repathed to
                           monoprop_bench_tools.memory.cpu.PssSampler

Main renamed benches/_builders.py -> monoprop_bench_tools.models (R100) and
benches/_memory_cpu.py -> monoprop_bench_tools.memory.cpu (R091); PR 3's
additions to both survive at the new paths.

THIS TREE NOW REQUIRES monoprop-bench-tools TO BE INSTALLED, and no venv in
this project has it: benches/conftest.py and benches/bench_models.py import
monoprop_bench_tools at module scope, so the suite fails at collection without
it. Installing it is a separate, deliberate step -- it touches venvs that hold
declared A/B baseline binaries.
@github-actions github-actions Bot added documentation Improvements or additions to documentation cpp labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown

Docs preview: https://pr-244.monoprop-docs.pages.dev

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (c888bc3) to head (0d3c278).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #244   +/-   ##
=======================================
  Coverage   97.70%   97.70%           
=======================================
  Files          14       14           
  Lines         742      742           
  Branches       98       98           
=======================================
  Hits          725      725           
  Misses         12       12           
  Partials        5        5           
Flag Coverage Δ
cpp 97.70% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

AGENTS.md carried a third copy of the MPI-fabric rationale already written above the
option in cpp/tests/CMakeLists.txt and in the testing guide, Slurm job ids included, and
two bullets unrelated to either: a test-methodology essay, and the editable-tree recipe
that docs/content/docs/building.mdx already holds. One bullet was also wrong -- it claimed
the `just bench` recipes pass `-s`, and no recipe does.

README.md's new paragraph linked the testing guide four lines above the pre-existing
sentence that links the same page, and testing.mdx quoted six site-specific numbers a
reader cannot act on.

cspell loses Deucalion/HCAs/Mellanox, which existed only to spell-check that prose. The
`OMPI_MCA_*` component names stay: they are literal strings in the CMake.

Assisted-by: ClaudeCode:claude-opus-5
…ield

AGENTS.md asks comments to state what the code cannot and not to restate it, narrate
history, or repeat a fact with a home elsewhere. These did all three: `_record_placement`
explained why it had moved between two functions, `_core_md5` retold the incident that
motivated it, and `barriered` carried a twelve-line measurement log ending in an open
question -- in a published package's public docstring. The invariants they were buried in
survive: which calls are collective, why the memory window opens inside `setup`, why
`settle=False`, and why the exclusion cannot reach the MPI variants.

`meta.transparent_hugepage` goes with them. Nothing reads it, and `_meta()` runs on rank 0
only, so it never described more than one node of a multi-node job anyway.

Prose added by this branch, with the docs commit beneath: 454 lines -> 295, from 49% of
the diff to 39%.

Assisted-by: ClaudeCode:claude-opus-5
@diagonal-hamiltonian diagonal-hamiltonian changed the title Split the fixed-model benchmarks into four timed operations, and stop paying MPI fabric init in single-process tests test(bench): split the fixed-model benchmarks into four timed operations Aug 19, 2026

@Panadestein Panadestein left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Take a look at the comments.

Comment thread benches/bench_models.py
Comment on lines +41 to +43
# layer-set per step: 3 steps of hubbard exceed a 242 GiB node where 2 fit in 15.4 GiB.
# Skipping beats OOM-killing the machine of whoever runs `just bench`, which does not
# pass -m "not slow". Pauli's step count is 1 and is unaffected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# layer-set per step: 3 steps of hubbard exceed a 242 GiB node where 2 fit in 15.4 GiB.
# Skipping beats OOM-killing the machine of whoever runs `just bench`, which does not
# pass -m "not slow". Pauli's step count is 1 and is unaffected.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some verbose comments.

Comment thread benches/bench_models.py
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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# `build_graph` extends the graph, so a driver that re-applies its circuit retains one
# `build_graph` extends the graph, so a driver that re-applies its circuit retains one layer-set per step

Comment thread benches/conftest.py
Comment on lines +94 to +118
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)}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking: Do you think this bunch of MPI wrappers could be reused somewhere else in the codebase?

Comment thread cpp/tests/CMakeLists.txt
Comment on lines +54 to +65
# CTest runs every Boost case as its own process, so each pays a full MPI_Init -- which
# initialises every fabric device present even though a single-process test never sends a
# message. Measured on a compute node: 2.03 s per case without the exclusion, 0.61 s with it.
#
# A per-case launch is ONE process (world size 1, `*_World` cases skip themselves, every
# other communicator is MPI_COMM_SELF), so the fabric can only cost startup time. The MPI
# variants exchange real messages and MUST keep the full component set -- `OMPI_MCA_pml=^ucx`
# hangs a 2-rank run indefinitely -- hence SERIAL_ENVIRONMENT rather than a global setting.
#
# Exclusions (`^`), never a positive component list: `vader` became `sm` in Open MPI 5, so
# naming a component breaks on the next machine. Unknown OMPI_MCA_* variables are ignored by
# other MPIs, so this is inert under MPICH.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would condense this to two lines, really:

Suggested change
# CTest runs every Boost case as its own process, so each pays a full MPI_Init -- which
# initialises every fabric device present even though a single-process test never sends a
# message. Measured on a compute node: 2.03 s per case without the exclusion, 0.61 s with it.
#
# A per-case launch is ONE process (world size 1, `*_World` cases skip themselves, every
# other communicator is MPI_COMM_SELF), so the fabric can only cost startup time. The MPI
# variants exchange real messages and MUST keep the full component set -- `OMPI_MCA_pml=^ucx`
# hangs a 2-rank run indefinitely -- hence SERIAL_ENVIRONMENT rather than a global setting.
#
# Exclusions (`^`), never a positive component list: `vader` became `sm` in Open MPI 5, so
# naming a component breaks on the next machine. Unknown OMPI_MCA_* variables are ignored by
# other MPIs, so this is inert under MPICH.
# CTest launches each Boost case as a world-size-1 process, so excluding fabric components cuts MPI_Init from 2.03 s to 0.61 s without affecting communication; real MPI tests must keep the full component set.
# Use SERIAL_ENVIRONMENT with exclusions (`^`), not a positive component list: Open MPI component names change across versions, and unknown OMPI_MCA_* variables are harmless under other MPIs.

@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cpp documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants