Skip to content

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

Merged
robertodr merged 21 commits into
mainfrom
pr/bench-and-test-harness
Aug 20, 2026
Merged

test(bench): split the fixed-model benchmarks into four timed operations#244
robertodr merged 21 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 @ a2f543e, merged up to origin/main @ d26b23c.
main's #227 moved the shared bench modules into packages/monoprop-bench-tools/, so the
resting_rss_bytes and MODELS edits land there — visible as renames with detection on.

Summary

The four fixed-model benchmarks were one blended figure per model, which cannot answer where the
memory goes: a single timed region that builds a graph and evolves an operator reports one peak
RSS for both. This splits each model into four separately timed operations — build_graph,
propagate, energy, gradient — each with its own time and its own memory window. 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. test_model
itself is untouched: renaming a tracked series orphans its Bencher history.

It also adds monoprop_TEST_EXCLUDE_MPI_FABRIC, a 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. Heads-up for #166: it carries the same
option and SERIAL_ENVIRONMENT block, and this one is a strict subset of it (0 non-comment lines here
are absent there), so whichever lands second should drop its own copy rather than merge both.

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 modules hash differently, so the A/B harness measures them as
two genuine arms — measuring its own false-positive rate rather than 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, layouts and node counts; term counts identical in all 12 cells.

That campaign predates the entry-barrier change below, so the four model operations were measured with
no entry barrier on either side. Both arms shared that one instrument, so the false-positive-rate
statement stands. The barrier has since been measured directly, by timing its own comm.Barrier() per
rank over 10 fresh-process reps: it removes 1.09% of makespan CV on propagate and 7.46% on
build_graph
against materiality thresholds of 0.55% and 0.48%, leave-one-out stable 10 of 10 on
both. energy and gradient measure 0.07% and 0.01%, 3–20× below threshold — they pass
barrier_setup with no inner setup, and the graph fixture's closing allreduces already resync the
ranks. So the recorded ratios stand, but "unresolved at this rep count" on the first two operations was
read against an avoidably noisy instrument.

The grid: layout A = 1 rank/node × 128 partitions, B = 8 × 16, at N = 1 and 2; hubbard c10 /
lower_atol=1.25e-5 and pauli c14 / 5e-5, plus a build_graph[hubbard] rung at
--hubbard-trotter-steps=2. A and B hold the flat world P constant, so A-vs-B is transport alone.
--cpu-bind=cores --distribution=block:block on 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 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.

Changes

  • Four timed operations per fixed model (benches/bench_models.py): test_model_build_graph,
    test_model_propagate, test_model_energy, test_model_gradient, each with its own op_memory
    window. Run one group per pytest process — the four together do not fit a node at these sizes.
  • monoprop_TEST_EXCLUDE_MPI_FABRIC (cpp/tests/CMakeLists.txt) plus SERIAL_ENVIRONMENT in
    discover_tests (cpp/tests/boost-test.cmake, boostAddTests.cmake), so the exclusion reaches the
    single-process variants only.
  • The benchmarks now assert what they measured: each of the four asserts the operation produced
    terms, and the two build_graph cells that the graph is non-empty — no new collective, since the
    count is already reduced for the report, and allreduce makes the assertion rank-uniform rather than
    aborting ranks unevenly.
  • opsize no longer leaks into Bencher's operator[...] history (bmf.py): it is keyed by pytest
    node id, which without a guard enters that permanent series beside the per-picture entries. Covered
    by this branch's first bmf.py test.
  • Supporting harness fixes: the entry barrier moves out of the timed region into pedantic's setup,
    so it no longer charges each measurement for skew in the preceding setup; an A/B arm is identified by
    its _core.so hash, not a dist-info version that goes stale after a rebuild; per-rank peak RSS and
    thread affinity come from /proc and /usr/bin/time -v; and model benchmarks whose graph cannot fit
    skip instead of OOM-killing the run.
  • Notes for the next reader: AGENTS.md and docs/content/docs/testing.mdx.

Checklist

  • Tests added or updated to cover the changes — packages/monoprop-bench-tools/tests/test_bmf.py
    covers the opsize guard; the four benchmarks assert their own output
  • Documentation updated (docstrings, docs/, CONTRIBUTING.md) if needed —
    docs/content/docs/testing.mdx for the fabric option, AGENTS.md for the bench invariants
  • CHANGELOG / release notes updated if applicable — not applicable, no library change

AI/LLM disclosure

  • I did not use LLM tooling, or used it only privately for ideation
  • I used the following tool to help write this PR description: ClaudeCode / claude-opus-5
  • I used the following tool to generate or modify code: ClaudeCode / claude-opus-5

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 (d26b23c) to head (a2f543e).
✅ 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 previously approved these changes 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 Outdated
Comment thread benches/bench_models.py Outdated
Comment thread benches/conftest.py
Comment thread cpp/tests/CMakeLists.txt Outdated
…PSS sampler

A second pass, over the whole branch rather than the parts under edit: every docstring is
one line, every comment at most two, and multi-line prose survives only where it states an
invariant whose violation costs a measurement -- which calls are collective, that the memory
window opens inside `setup`, that `settle=False` is deliberate, that `memhwm` spans `setup`
and so predicts an OOM kill rather than comparing operations, and why the fabric exclusion
cannot reach the MPI variants.

`--bench-pss-sampler` goes entirely, and with it `_peak_of_sum`, the `mem` results section
and two imports. Nothing read that section: `bmf.py` declined it in favour of `memhwm` and
`report.py` never looked. It defaulted off, so no run ever produced it. `PssSampler` and
`merge_peak_of_sum` stay in the package with their tests, so the capability is recoverable
-- note this deletes code that predates the branch, rather than an unused path the branch
added.

The prose the branch adds, measured against the merge base and excluding lint directives
and text the branch only moved: 196 lines -> 88, from 26% of the diff to 15%. The whole
diff goes +750/-60 to +588/-88. That instrument is not the one the previous commit quoted;
it attributes shifted lines correctly, and its reading for that commit is 196, not 295.
Nine one-line docstrings and every `_RESULTS` key comment are what remain in conftest,
where pydocstyle mandates the former and two readers parse the schema the latter documents.

Prose the branch never wrote is restored to its base text, including the module docstring
and four fixture docstrings: shortening those enlarges the diff without shortening the PR.

Verified by comparing the code token stream, comments and docstrings excluded, before and
after: zero tokens added, 152 removed, every one of them the PSS path. `ruff check`,
`ruff format --check` and the 33 `monoprop-bench-tools` tests pass, and a 12-benchmark
serial run writes an artifact carrying every section the harness reads and no `mem`.

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

`barriered` wraps the timed *exit* barrier; the entry barrier lives in `barrier_setup`,
which `pedantic` runs untimed inside `setup`. Every `bench_random` benchmark and the
pre-existing `test_model` call it. The four benchmarks this branch adds did not, which left
them the only ones in the suite with no entry barrier on either side: ranks entered the
timed region unsynchronised. Paired ratios cancel a systematic offset, not variance, so the
cost was statistical power on exactly the four operations every campaign measures.

`energy` and `gradient` keep `args=(parameters,)` and gain `setup=barrier_setup(bench_comm)`
with no restructuring: `pedantic` rejects `args=` beside `setup=` only when the setup
returns a truthy value (`fixture.py:270-276`, `if maybe_args: if has_args: raise TypeError`),
and `barrier_setup` with no inner setup returns `None`. `iterations=1` stays untouched --
`fixture.py:267` makes `iterations > 1` with a setup a hard error.

Every recorded `test_model_*` number so far, this branch's "0 of 24" and the funnel PR's
16/24 included, was measured on the unsynchronised instrument. Those null controls remain
valid as false-positive-rate statements, because both arms shared one instrument.

Verified serially at 6 Hubbard sites: build_graph/propagate 2 passed, energy/gradient
2 passed, run as separate processes as the module docstring requires.

Assisted-by: ClaudeCode:claude-opus-5
…perator series

`build_bmf` named an `operator[<key>]` metric for every `opsize` and `memrest` key. Those
sections are keyed by picture or model -- `operator[heisenberg]` -- but this branch's
`record_opsize` also writes pytest node ids, and `test_random_build_graph` /
`test_random_propagate` are not `slow`-marked, so `bench-ci` runs them. On merge Bencher
would have gained `operator[bench_random.py::test_random_build_graph[heisenberg]]` beside
`operator[heisenberg]`: two kinds of entity under one name, in a history keyed on that name
forever.

Fixed in the reader rather than the writer, because a private A/B harness reads those
node-id-keyed entries to refuse a comparison whose arms propagated different term counts.
`::` is the separator `report.py:178` already partitions on.

The test asserts the whole emitted name set, not the absence of one string, so it also fails
if a future change routes those keys under some other metric name. Against the unfixed
reader it fails with exactly the polluting name as an extra item; this is the branch's first
test, and the repo has already shipped a test that asserted nothing.

Assisted-by: ClaudeCode:claude-opus-5
Six of eleven benchmarks asserted nothing, `build_graph` and `propagate` among them -- the
two cells where most allocation goes. An arm that produced garbage, or nothing, would have
reported as a clean speed-up, and the A/B campaign downstream records only memory and
timing, so nothing else would have caught it either.

`record_opsize` already reduced `propagator.size()` across ranks and discarded the result;
it now returns it, so the assertion costs no new collective. Because the reduce is an
`allreduce`, every rank receives the same total and the assertion is rank-uniform -- one
that fires on a subset of ranks aborts an MPI job unevenly and can hang the rest.

The two `build_graph` cells also assert `graph_layers > 0`. That needs no collective: the
partition facade returns `first_partition_().graph_layers()`
(`MonomialPropagator.inl:316-318`), not a sum, unlike the memory accessor directly below it.
For `propagate` the reduced term count is the only invariant available -- it stores no graph
and does not advance the parameter axis.

`graph_size()` is deliberately not used: it is rank-local, terms are hash-distributed so a
rank may legitimately own none, and its first element is legitimately 0 when nothing is
truncated. `tests/test_models.py:41-42` already documents that trap.

Both assertions were fault-injected in a scratch tree. Forcing the reduced size to 0 fires
all six; forcing `graph_layers` to 0 fires exactly the two `build_graph` cells and leaves
`propagate` passing, which also shows the two kinds are independent.

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

Panadestein's suggestions verbatim: the `MAX_GRAPH_STEPS` note states the
mechanism in one line, and the fabric note is two sentences carrying the
2.03 s -> 0.61 s figures and the MPICH-inert note.

Assisted-by: ClaudeCode:claude-opus-5
@sonarqubecloud

Copy link
Copy Markdown

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

@Panadestein Good to go?

@robertodr
robertodr merged commit 3061af0 into main Aug 20, 2026
24 checks passed
@robertodr
robertodr deleted the pr/bench-and-test-harness branch August 20, 2026 07:53
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.

3 participants