test(bench): record the geometry a scaling rung is identified by - #287
Merged
Conversation
A benchmark artifact recorded `ranks` and rank 0's hostname, which is not enough to say which rung of a node-scaling ladder it came from -- every consumer had to trust the launcher's filename instead of the artifact. Records `nodes` and `ranks_per_node`, derived on the ranks themselves from the distinct hostnames rather than from the environment, and `partitions_env` under a name that does not claim to be the effective value, because no binding exposes the count the engine actually resolved. Adds `memhwm_max` alongside the summed `memhwm`: the sum is a job total, and having only the sum has inverted per-rank memory readings before. Also records the calibrated Hubbard sizing in the bench README, including that both nominal size axes saturate at the default tolerance -- measured, four points on the lattice axis and six on the tolerance axis. Assisted-by: claude-code:claude-opus-5
The previous commit records four keys that no reader surfaced: `nodes`, `ranks_per_node`, `partitions_env` and `memhwm_max`. report.py's docstring asks for schema changes in both readers, so this is the other half. `partitions_env` is labelled "Partitions (requested)". It is the environment variable, not a resolved count -- no property exposes the count the engine actually chose -- and a bare "Partitions" column would claim otherwise. `memhwm` is now labelled "summed across ranks" rather than left bare, and `memhwm_max` gets its own table labelled "max across ranks". The two differ by the rank count, so the old unqualified heading was the ambiguity. Absent keys render as the file's existing em-dash, verified against a fixture that predates the keys: absent stays visibly absent rather than becoming 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Docs preview: https://pr-287.monoprop-docs.pages.dev |
diagonal-hamiltonian
marked this pull request as ready for review
August 25, 2026 16:55
diagonal-hamiltonian
requested review from
fpietra,
ludmilaasb and
robertodr
as code owners
August 25, 2026 16:55
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #287 +/- ##
=======================================
Coverage 97.70% 97.70%
=======================================
Files 14 14
Lines 742 742
Branches 98 98
=======================================
Hits 725 725
Misses 12 12
Partials 5 5
Flags with carried forward coverage won't be shown. Click here to find out more. |
Panadestein
approved these changes
Aug 27, 2026
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



What this is
Two commits that make a benchmark result identify the geometry it was measured at, plus the reader change that surfaces them. Nothing in the engine changes; both commits are Python, outside
cpp/.benches/conftest.pyrecordsnodes,ranks_per_node,partitions_envandmemhwm_max.report.py/test_report.pysurface them.Why
A scaling result is
(nodes, ranks_per_node, partitions), and until now a recorded run carried none of the three.meta.ranksis the MPI world size, which is the product of two of them and cannot be factored back. That matters more than it sounds, because the engine's flat world isP = ranks_per_node × partitions × nodesandHybridComm::size()returnsr_ * s_— so8 ranks × 16 partitionson 32 nodes and1 rank × 128 partitionson 32 nodes are bothP = 4096and are not the same measurement. They differ by 2.06x in wall time (table below). Two runs that differ 2x were previously indistinguishable in the recorded JSON.Three details worth review:
nodesis collective. It is anallgatherofsocket.gethostname(), sopytest_configurehad to call it before the rank-0 early return — every rank must enter it or it hangs. Serial short-circuits to(1, 1)without touching MPI.partitions_envis the environment variable, not a resolved count, and is labelled "Partitions (requested)" for that reason. No property exposes the count the engine actually chose. A barePartitionscolumn would have claimed otherwise.memhwm_maxexists becausememhwmis a sum over ranks. The two differ by the rank count. The old heading said neither, so this renames the existing table to "summed across ranks" and gives the max its own. On a 1024-rank run the sum is 460 GiB and the max is 0.48 GiB; a reader who guessed wrong about which one they were looking at would be off by 1000x.worldis deliberately not emitted: it is derivable from what is recorded, and a stored derived field is a second source of truth that can disagree.What it was used for
A strong- and weak-scaling study of
propagateon Hubbard at 1,569,152,761 terms (cutoff 10, 60 sites,lower_atol=2.6e-06), on Deucalion x86 (2x EPYC 7742, 128 cores/node, SMT off). Layout8 ranks x 16 partitionsper node, so the engine's flat world isP = 128 x nodes. Baseline is this branch's parent; the port arm is #263. 3 reps per cell, A/B interleaved inside one allocation with the arm order flipped per rep, ratios taken as the median of per-rep paired ratios.Strong scaling — the problem is fixed, only the hardware grows
Scaling reverses between 16 and 32 nodes. Reproduced in two independent passes (a 1-rep pass gave 4.07 → 3.21, a 3-rep pass 4.05 → 3.18). Weak efficiency falls 100.0 / 81.6 / 56.2 / 38.5 / 27.1 / 16.0% over N = 1…32, from main-arm walls of 18.7 / 21.7 / 32.4 / 49.0 / 69.8 / 116.6 s. It is normalised by the measured terms/node recorded in each cell's ledger (92.06–98.07M across the six rungs), not by an assumed-flat target — the rungs are close enough that the distinction is small, and far enough apart that assuming it would have been wrong.
Instrument self-check, and the reason the
nodeskey earns its place:strong/16andweak/16are the identical cell (1.569G terms at N=16) run in two separate allocations, and they agree to 0.56% (base) and 0.26% (port). Before this commit the two were indistinguishable in the recorded JSON and could not have been cross-checked at all.Where the time goes — this is what
time -vsays once User and System are separatedSame runs, main arm, median rep, summed over ranks:
Total CPU consumed grows 10.14x for a problem that does not change, against a 3.18x speedup — so ~90% of the CPU burned at N=32 does not exist at N=1. It is overwhelmingly system time, and system time is very nearly linear in MPI rank count (exponent log 38.51 / log 32 = 1.05).
Layout at fixed flat world — six geometries, same P
At N=32,
P = 4096is reachable at six splits of 128 cores/node, with the MPI world size spanning 32x. This is the measurementranks_per_nodeexists to make legible:Three things worth having in the record:
8x16is within 0.2% of the best cell, so there is no free win in the geometry, and the 3.18x spread across the row is entirely invisible to a JSON that records onlyranks.1x128saturates its cores at 95% busy and is the slowest cell;8x16runs 58% busy and is the fastest. Busy is not productive.memhwm_maxbesidememhwmis for: the summed figure spans 173→460 GiB across the row while the per-rank max falls 5.55→0.48 GiB. A reader who mistook one for the other would be wrong by three orders of magnitude.#263, for reference
Its paired ratio is ~0.8 at narrow widths and 0.96 at N=32, and it tracks the paired CPU-seconds ratio to within +0.014/−0.025 at every rung — i.e. predominantly work removal, diluted as that work becomes a smaller share of a growing overhead. Per layout at N=32 it runs 0.95 / 0.85 / 0.80 / 0.91 / 0.97 / 0.73 for 1x128 / 2x64 / 4x32 / 8x16 / 16x8 / 32x4. The last is not quotable and is shown only for completeness: 32x4's three paired ratios are 0.975 / 0.727 / 0.541, and its main arm alone spans 1.93x within the cell (300.2 / 332.1 / 579.2 s) where every other cell holds inside 6%. An earlier revision of this body printed 0.975 for it — that was rep 1 read while the cell was still filling, which is exactly the failure mode a 1024-rank cell invites. Any claim at this width needs far more than 3 reps.
Every ratio above is descriptive, not resolved. Three paired reps floor the uncorrected two-sided sign test at p=0.25, so no 3-rep cell in this comment carries statistical significance; the ladder buys the shape. The layout comparison is additionally unpaired — six allocations on six node sets — so no sign test applies to it at all.
Resolved at 10 reps, at the two rungs where the answer was in doubt
Per-rep ratios, N=16: 0.671 / 0.677 / 0.680 / 0.689 / 0.689 / 0.693 / 0.715 / 0.723 / 0.755 / 0.858. N=32: 0.890 / 0.894 / 0.897 / 0.917 / 0.942 / 0.959 / 1.004 / 1.005 / 1.007 / 1.019.
The two distributions do not overlap at all — every N=16 ratio is below every N=32 ratio. An exact two-sided Mann-Whitney (the appropriate test, because comparing two rungs is unpaired: separate allocations, separate node sets) gives U = 0, p = 1.08e-5, which is the floor for a 10-vs-10 design. So this is not "0.69 versus 0.95, probably different" — it is the strongest separation the experiment can express.
Conclusion: #263 buys a resolved ~1.45x at 16 nodes and is indistinguishable from no change at 32. Quoting a single number for "#263's improvement" is therefore wrong; the benefit is scale-dependent and disappears at the flat world size where
propagateitself reverses (P = 4096). Two independent ladders agree on this: the weak ladder's 32-node rung is likewise its weakest cell.A calibration warning that applies to every 3-rep figure above, including mine. The same comparison, same two binaries (installed
_core.somd5edb00c44…vs7dbc7de1…, neither rebuilt between runs — both.sofiles predate both jobs), byte-identical configuration, differing only in rep count, measured 0.857x at 3 reps and 0.691x at 10 on two different 16-node allocations. The 3-rep median landed at the very top of the 10-rep per-rep range. So a 3-rep ratio here can be ~24% away from the resolved value even with paired, order-flipped interleaving inside one allocation — far beyond the ~1.7% between-job drift seen elsewhere. Read the 3-rep tables above for shape only, never for magnitude.A large part of this wall was our own benchmark environment, not the engine
Reported here because it changes how the walls above should be read, and because it is a trap any
HPC harness for a multi-threaded engine can fall into.
The harness that produced every number above exported
MALLOC_ARENA_MAX=$PARTITIONS= 16. The engineruns 19 threads per rank (16 partition masters, the main thread, the OFI
asyncprogress thread,and one more Python thread), so late claimants share an arena and every
malloc/freein thelocal-work region becomes a futex sleep. Unsetting it, one binary, 10 paired reps per rung with the
arm order flipped every rep inside a single allocation:
=16Do not subtract these savings from the walls in the tables above. The
=16arms here read94.52 s at N=32 where the strong-scaling table reports 88.5 s — a 6.8% gap — while a separate,
independently-instrumented probe of the same configuration read 94.50 s, agreeing with these runs to
0.02%. Two runs agreeing to two parts in ten thousand means 94.5 s is the figure for this
provisioning and the 88.5 s cell was provisioned differently. So "88.5 − 18.19 = 70.3 s" is a
configuration mix, and it looks entirely reasonable on the page. The paired ratios are safe precisely
because each lives inside one allocation; only the cross-quoting of absolute seconds is not.
MALLOC_ARENA_MAXis a cap, not an allocation: glibc creates an arena only when a thread findsthe existing ones contended, at most one per thread, so unsetting it yields ~19 arenas rather than the
8 × ncores= 1024 the documentation's formula suggests. That is why the memory cost is ~0.1 GiB/nodeagainst ~245 GiB of headroom, and why there is no trade to weigh here.
This is a wall win at every width, not a scaling fix, and the distinction is easy to miss. Both
walls fall substantially and the reversal gets worse: 1.370x → 1.431x, because the relative
saving is larger at the smaller rung. Removing a cost that is roughly rung-independent always shrinks
the smaller wall proportionally more. A reader checking only that both numbers improved would conclude
the opposite of what happened.
Two consequences for the tables above, stated rather than quietly corrected:
the comparisons are paired within one allocation. The perf(evolution): ⚡ carry cross-rank queries as a width-adaptive record #263 result (0.691x at N=16, 0.950x at N=32) is
unaffected.
and system-time terms. The
system sharecolumn in particular — 17.8% → 67.3% across the ladder —is measuring the engine and this misconfiguration together, and no split between the two is offered
here.
One diagnostic worth recording, because it is what ruled out every topology explanation: which
partition indices are penalised changes between runs of the identical configuration on the same nodes.
A core, cache, NUMA, or IRQ-affinity story predicts a stable set. Only a lazy, racy arena assignment
reproduces that.
voluntary_ctxt_switcheson an affected thread tracked the penalty exactly (557,537vs 68) while minor faults stayed flat, so it is lock contention rather than page-fault churn.
Reproducibility
The numbers above are not reproducible from this diff. They were produced by a private harness (allocation management, A/B interleaving,
/usr/bin/time -vcollection, collation) that is not in this repo and is not proposed for it. This PR contains only the recording and reporting change that makes such a campaign interpretable. Every figure is quoted against the commit that measured it.