Skip to content

perf(mpi): remove the serial O(R·S²) protocol floor from HybridComm - #166

Draft
diagonal-hamiltonian wants to merge 19 commits into
mainfrom
perf/multinode-comm-scaling
Draft

perf(mpi): remove the serial O(R·S²) protocol floor from HybridComm#166
diagonal-hamiltonian wants to merge 19 commits into
mainfrom
perf/multinode-comm-scaling

Conversation

@diagonal-hamiltonian

@diagonal-hamiltonian diagonal-hamiltonian commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

What

A partitioned multi-node run was protocol-bound, not network-bound. Every HybridComm
collective rebuilt O(R·S²) integer offset tables, and partition 0 filled all of them
alone while the other S−1 partitions spun at a barrier
. Four changes remove that serial
phase; the fifth commit adds the opt-in instrumentation that found it.

Measured with monoprop_COMM_PROFILE=1 (2 nodes, 512-site Hubbard, 4 layers, R=2, S=112),
partition 0's serial fill was 22.33 s of a 26.74 s run while MPI itself was 1.58 s — the
wire was never the problem.

# change file
1 Parallel prefix. Block offsets split into a per-(b,t) base needing global knowledge (only O(R·S), stays on partition 0) and a scan over u that partition t owns outright. The count matrix is relaid source-partition-major so each partition writes a contiguous run — dest-major would trade a serial fill for pure false sharing. HybridComm.h
2 alltoallv_reverse. The answer leg travels the query exchange's legs backwards, so its geometry is the query round's; rebuilding it cost 3·R·S² entries and a barrier for nothing. A ratio, not an identity — the query leg carries Sink::kStride elements per record and the answer leg one, so every reused offset and count is divided by the stride (exact: every forward count is a multiple of it). HybridComm.h, MPICompat.h, Engine.h
3 Empty-block veto. Each partition publishes a cache-line-padded bitmask of the ranks it sends anything to, and sizing runs source-partition-outer, so a partition that sends nothing costs one load instead of R strided probes. At early layers nearly every block is empty. HybridComm.h
4 Two-level barrier. PartitionBarrier fans in within an L3 domain then across domains, so the arrival fetch_add and the release store cost O(S/G) coherence transactions inside one L3 slice instead of O(S) across the socket interconnect. PartitionBarrier.h, CpuTopology.h, PartitionGroup.h

Correctness gate

These changes move no floating-point value — only integer offsets and barrier topology —
so the acceptance criterion is a bit-identical expectation value, not a tolerance. That
held at every step of the ladder and at every rank × partition split.

  • 211/211 ctest, plus the comm suite under mpiexec -n 3 and -n 4 (S and R interact
    in the new index arithmetic, so world 2 alone is not enough).
  • New test coverage for two gaps. Every pre-existing hybrid_comm case sent the same
    count to every destination
    per source, which cannot distinguish the
    (rank, dest partition, source partition) index order from its transpose — exactly the
    index change 1 makes. Added two asymmetric-count cases, mutation-verified: an inconsistent
    receiver index makes the old cases pass and both new ones fail. The two-level barrier gets
    6 direct cases (uneven/non-contiguous/singleton domains, poison, reset), because its
    grouped path needs pinning and ≥2 L3 domains to engage through a real ShmComm, which no
    test host is guaranteed to provide.

A/B

Leonardo DCGP, 2 nodes, --exclusive, one allocation for both sides run interleaved (a
per-allocation slowdown would otherwise land on one side and read as an effect). Two venvs built
by the same script from detached worktrees at origin/main and this branch, so benches/ is
identical and only src/ differs. Driver: the in-repo Hubbard model (60 sites / 120 qubits) via
benches/_builders, barriered per layer, expectation value compared as repr — the gate is
bit-identity, not a tolerance.

c1 — 20 layers, atol 1e-4, 1,063,245 terms (small operator: cost set by the per-gate sync count)

layout main this PR speedup layer 1 ⟨O⟩ main vs PR reps
1 rank × 112 partitions 17.49 s 2.14 s 8.17× 0.911 → 0.100 s bit-identical 2/2
4 ranks × 28 partitions 3.86 s 2.84 s 1.36× 0.553 → 0.358 s bit-identical 2/2

c2 — 29 layers, atol 1e-6, 260,928,282 terms (large operator: real work dominates)

layout main this PR speedup layer 1 ⟨O⟩ main vs PR reps
1 rank × 112 partitions 56.91 s 40.42 s 1.41× 0.916 → 0.090 s bit-identical 1/1
4 ranks × 28 partitions 31.39 s 31.36 s 1.00× 0.553 → 0.633 s bit-identical 1/1

Term counts are identical everywhere, and the expectation value is bit-identical between the two
sides at every layout. The two layouts differ from each other in the last bit — the partition
count sets the reduction order — which is pre-existing behaviour these changes do not alter.

What this actually buys, stated plainly:

  • The rank/partition layout stops being a trap. On main, putting one partition per physical
    core in a single rank — the natural configuration, and the default — costs 4.5× (c1) or
    1.8× (c2) against splitting the same 112 cores into 4 ranks. With this PR those layouts land
    within 1.36× and 1.00×. That is the O(R·S²) term draining out, and it is the result to read.
  • Protocol-bound work gets much faster; compute-bound work at an already-good layout does not.
    Layer 1 at 1×112 is 9–10× cheaper in both configs — it holds a tiny operator, so it measures
    the floor almost directly. Once the operator is 261 M terms and the layout is already 4×28,
    the floor is not what you are paying for and the change is a wash.
  • One honest regression: c2 at 4×28 has layer 1 going 0.553 → 0.633 s (+14 %, single
    replicate). Expected in kind — fix 1 adds two barriers per verb, and at S=28 there is little
    serial fill to reclaim. It is why fixes 2–4 are load-bearing rather than optional.
  • The effect grows with the world. The removed term is O(R·S²); this A/B is the smallest
    interesting case. On the 8-node / 1024-qubit problem this work was written for, the same four
    changes took the 29-layer run from 541 → 192 s at 1×112 and 163 → 146 s at 4×28, with layer
    1 going 22.7 → 2.4 s. Those numbers come from a separate harness that is not in this PR, so
    treat them as context, not as this diff's verification.

A/B binaries built from ca71897; the pushed tip differs from it only by clang-format
whitespace in four files.

Notes for the reviewer

  • mpi is now the largest bucket, ~65k collectives at 25–100 µs, i.e. call latency, not
    bandwidth. Nothing in the table or barrier layer touches it; the only remaining lever is
    fewer collectives per gate (batching commuting gates), which is deliberately out of scope
    here — it touches the evolution scan/fold.

  • Change 4 engages only where a rank spans more than one L3 domain, which the profile's
    barrier_groups= field reports rather than leaving to be inferred from timing.

  • monoprop_COMM_PROFILE is off by default and allocates nothing then; the hot path pays one
    null check per instrumented region.

  • Static analysis. Sonar's reliability gate caught one real defect, now fixed: both new
    transport destructors call CommProfile::dump(), whose std::print can throw, and a throwing
    destructor running during unwinding terminates the process. dump() is now noexcept and
    swallows internally — a diagnostic print must never take the job down. Three families of
    finding are left deliberately unaddressed, and I'd rather say so than quietly churn the diff:
    the memory_order rule wants seq_cst on the barrier's atomics, which would undo fix 4 (and
    fires on the pre-existing flat barrier on main too); the void * payload parameters and the
    [&] capture in alltoallv_reverse mirror the sibling verbs in the same file, where diverging
    would be worse than complying; and the HybridComm field/method counts are pre-existing class
    size that this PR is not the place to refactor.

  • One stateful contract is introduced: alltoallv_reverse must directly follow the
    alltoallv_resolve whose layout it reverses. Guarded by a generation counter that throws
    rather than silently reading stale tables, and documented on the verb.

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown

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

@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/multinode-comm-scaling branch from ca71897 to 42de519 Compare July 29, 2026 08:29
@diagonal-hamiltonian diagonal-hamiltonian added the test-in-draft Run CI even in Draft mode label Jul 29, 2026
@codecov

codecov Bot commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (1bd8dc7) to head (fa65466).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #166   +/-   ##
=======================================
  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.

@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/multinode-comm-scaling branch from 42de519 to 55ca2f3 Compare July 29, 2026 08:40
…ives

monoprop_COMM_PROFILE=1 accumulates, per partition and per cache-line-isolated
slot, where a collective's wall time goes: table_p0 (fills one partition runs
alone) vs table_par (fills every partition runs on its own slice) vs table_move
(payload memcpy) vs mpi vs barrier wait, plus the barrier's L3-domain group
count. Off by default and never allocated then, so the hot path pays one null
check per instrumented region.

The splits are the ones that decide protocol questions. table_p0 vs table_par
separates a serial protocol from a parallel one, and attributing barrier wait
per partition is what makes the asymmetry visible -- the master's own wait stays
small precisely when the master is the bottleneck. table_par vs table_move
separates bookkeeping, which a better protocol shrinks, from data movement,
which it cannot.

Assisted-by: ClaudeCode:claude-opus-5
A partitioned multi-node run was protocol-bound, not network-bound: every
collective rebuilt O(R*S^2) integer offset tables and partition 0 filled all of
them alone while the other S-1 partitions spun at a barrier. Measured with
monoprop_COMM_PROFILE=1 (2 nodes, 512-site Hubbard, 4 layers, R=2, S=112),
partition 0's serial fill was 22.33 s of a 26.74 s run, while MPI itself was
1.58 s -- the wire was never the problem. Four changes, wall 26.74 -> 4.46 s
(6.0x), expectation value bit-identical at every step and at every rank x
partition split:

- Parallel prefix. The offset of block (rank b, dest t, source u) splits into a
  per-(b,t) base needing global knowledge, which is only O(R*S) and stays on
  partition 0, and a scan over u that partition t owns outright. The count
  matrix is relaid source-partition-major so each partition writes a contiguous
  run; dest-major would put S partitions on every cache line and trade a serial
  fill for pure false sharing.
- alltoallv_reverse. The answer leg travels the query exchange's legs backwards,
  so its geometry is the query round's, and rebuilding it costs 3*R*S^2 entries
  and a barrier for nothing. It is a ratio and not an identity: the query leg
  carries Sink::kStride elements per record and the answer leg one, so every
  reused offset and per-rank count is divided by the stride (exact -- every
  forward count is a multiple of it). Reusing them undivided would still deliver
  correct data while staging and transmitting kStride times the bytes.
- Empty-block veto. Each partition publishes a cache-line-padded bitmask of the
  ranks it sends anything to; the sizing phase then runs source-partition-outer,
  so a partition that sends nothing costs one load instead of R strided probes
  into its count array. At early layers nearly every block is empty.
- Two-level barrier. PartitionBarrier fans in within an L3 domain and then
  across domains, so both the arrival fetch_add and the release-store
  invalidation cost O(S/G) coherence transactions inside one L3 slice instead of
  O(S) across the socket interconnect. Domains are derived from the partition
  cpusets rather than from the placement logic, so the two cannot drift apart,
  and a rank spanning one domain keeps the flat barrier (a root barrier of one
  is pure overhead).

Tests: the pre-existing hybrid_comm cases all sent the same count to every
destination per source, so a transposed (b,t,u) index passed unnoticed -- two
asymmetric-count cases close that. partition_barrier_tests.cpp tests the
two-level path directly, because engaging it through a real comm needs pinning
and >=2 L3 domains, which no test host can be relied on to have. Both additions
are mutation-verified: an inconsistent receiver index and a skipped root barrier
each fail the new cases while the old ones pass.

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

Both are new behaviour from the HybridComm floor work: a runtime knob belongs in
the environment-variable table, and the barrier's L3-domain grouping is a
performance property a reader tuning partition placement needs to know about.

Assisted-by: ClaudeCode:claude-opus-5
@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/multinode-comm-scaling branch from 55ca2f3 to 69a7932 Compare July 29, 2026 08:54
diagonal-hamiltonian and others added 2 commits August 11, 2026 12:54
main restructured src/monoprop/ -> cpp/monoprop/ and independently rewrote the
same HybridComm hot path this branch parallelises, so this is a port rather than
a mechanical merge. Resolutions, all in the direction of keeping the branch's
algorithm and adopting main's newer scaffolding:

* HybridComm.h  -- branch base. Its parallel count-matrix fill, the per-column
  size_staging_parallel_ and the send-mask are the hard part to reproduce; main's
  change is an API refactor. Adopted from main: the AlltoallvArgs /
  AlltoallvResolveArgs bundles (unpacked in the public methods so the impl_ keeps
  the per-argument signature its barrier reasoning is written against),
  checked_mpi_count in place of the branch's local checked_int_, and the
  MpiThreadLevelUnsupported exception type.
* ShmComm.h -- branch's participant-indexed sync(rank) and ScopedNs profiling,
  main's typed std::byte buffers, args bundle and its two "an unpublished source
  must not be offset by its displacement" guards.
* PartitionBarrier.h -- branch's two-level barrier, plus main's comment recording
  that the acquire/release orderings are load-bearing and why cpp:S8417 is
  suppressed.
* MPICompat.h -- both arms now read main's flat bundle; alltoallv_reverse keeps a
  per-argument signature because forward_stride has no place in a bundle shared
  with transports that have no reverse leg.
* CommProfile.h and partition_barrier_tests.cpp were added by the branch inside
  directories main renamed, so git left them at the old paths; both relocated,
  and CommProfile.h registered in the mpi FILE_SET.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hybrid_comm_tests.cpp auto-merged cleanly, so it kept calling the pre-bundle
per-argument alltoallv / alltoallv_resolve signatures that the merge replaced.
These were the only two build errors; the library itself compiled unchanged.

Both sites now use the braced-init form already used at hybrid_comm_tests.cpp:236
and shm_comm_tests.cpp:262, which came from main's side of the merge -- one
convention in the file rather than two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions github-actions Bot added documentation Improvements or additions to documentation cpp labels Aug 11, 2026
The merge took main's `MpiThreadLevelUnsupported` exception type but kept this
branch's continuation indentation, which was aligned to the shorter
`std::runtime_error(` it replaced. clang-format 21.1.0 flags both continuation
lines, and that is the whole of the lint job's failure.

Whitespace only: the message text and every surrounding statement are untouched.
Verified with `clang-format@21.1.0 --dry-run -Werror` over all 11 changed C++
files (clean), plus gersemi on the changed CMakeLists.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
diagonal-hamiltonian added a commit that referenced this pull request Aug 11, 2026
Merges the PR #166 port (origin/perf/multinode-comm-scaling, plus this session's
merge-up onto main and its two follow-ups) so the paper's multi-node drivers run
on the faster partitioned collectives rather than main's serial O(R*S^2) floor.

What arrives, in five non-merge commits:

* feat(mpi) 13eb462 -- CommProfile.h and the monoprop_COMM_PROFILE flag: opt-in,
  per-partition accounting of where a collective's wall time goes. Off by
  default and never allocated then. This is the instrumentation that located the
  bottleneck, and it is what reports barrier_groups.
* perf(mpi) fef1f05 -- the optimisation itself: a parallel prefix so each
  partition fills its own slice of the offset tables instead of partition 0
  filling all of them alone; alltoallv_reverse, which reuses the query round's
  geometry for the answer leg instead of rebuilding it; an empty-block veto so a
  partition that sends nothing costs one load rather than R strided probes; and a
  two-level PartitionBarrier that fans in within an L3 domain before crossing
  domains.
* docs(parallelism) 69a7932 -- documents the env flag and the barrier.
* test(mpi) 22631cd -- moves the HybridComm cases onto main's AlltoallvArgs
  bundles, which main's SonarQube sweep introduced while this branch was open.
* style(mpi) c1e034c -- clang-format alignment left over from that merge.

Verified on Deucalion at c1e034c: 213/213 ctest serial, the MPI ctest variant,
worlds 3 and 4 by hand (225 cases each), and the Python MPI suite across four
rank/partition layouts on 2 nodes. Interleaved A/B against main, one allocation
with the order flipped per cell: at 1 rank x 128 partitions gradient is 3.42x,
inplace 3.04x, energy 2.57x and build_graph 1.61x faster, median and min
agreeing; multi-rank layouts are flat, because partition_cpusets leaves them
unpinned so the two-level barrier never engages there. See
hpc/deucalion/RESULTS-port-ab.md (untracked).

Note this is pre-review code: PR #166 is still open and in draft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three independent mechanisms made intra-node performance depend on the machine and
the launcher rather than on the work. All three degraded silently: every
equivalence and determinism test passed throughout.

Pinning disabled itself under Slurm. enumerate_physical_cores() already filters by
the process affinity mask, and partition_cpusets() then divided by the number of
co-located ranks a second time, so group_count * n exceeded the rank's share, the
guard read that as "host too small", and the rank ran unpinned -- taking the
two-level barrier's domains with it, since cpuset_domains() derives them from the
cpusets. Measured on Deucalion, both builds interleaved in one allocation on one
node: layout 8x16 went barrier_groups=0 -> 4 and ~437 -> 15.5 us/sync, layout 2x64
0 -> 16 and ~840 -> 11.5 us/sync, while the 1x128 layout that never hit the bug kept
its 32 domains and its timing. End-to-end on two nodes, 1.58x-3.84x and 2.68x-4.19x
across build_graph/energy/gradient/inplace/pare.

Mask width cannot tell a per-rank slice from a shared one -- eight ranks holding 16
cores each and eight sharing one 16-core mask both leave a rank seeing 16 of 128 --
and the two need opposite placement, so collapsing on width alone would point every
co-located rank at the same cores. PartitionGroup therefore allgathers the raw masks
over the node-local communicator it already opens, and classify_node_mask answers
PerRank only for pairwise-disjoint masks; identical, overlapping and unreadable all
answer Shared, which is both the old behaviour and the safe direction.

The barrier's spin budget was a fixed iteration count whose wall time is
architecture-dependent: cpu_relax() is one PAUSE on x86 and one YIELD on aarch64,
which differ by more than an order of magnitude, so 2048 iterations spent a wholly
different budget on each and on aarch64 skipped the on-core spin almost entirely.
It is now a time budget, calibrated by measurement on this Zen 2 part and
overridable per run with monoprop_SPIN_BUDGET_US. Parking past the yield phase was
tried and rejected (2021 vs 762 us/sync oversubscribed): one sleeper's timer
overshoot delays everyone behind the barrier.

Locality-group discovery keyed on cache/index3 alone, so a part without an L3 made
every core its own domain -- a flat barrier carrying S extra cache lines, while
barrier_groups still reported S and read as if the grouping had engaged. It now
takes the deepest cache level shared with another core, falling back to the NUMA
node. Deucalion's A64FX nodes turn out to expose no cpu cache sysfs at all, so
there the NUMA fallback is the only signal and yields 4 domains of 12 cores. On x86
the deepest shared level is still the CCX, so the measured placement is unchanged.
"Shared" deliberately means shared with another core rather than with an SMT
sibling: a per-core L1 or L2 lists both hardware threads, so a size>=2 test would
have reinstated the same defect on any SMT part exposing no cross-core cache.

Also: PartitionBarrier treats domains == participants as degenerate and takes the
flat path, so barrier_groups can no longer report a level that is not running;
parse_id no longer clamps to CPU_SETSIZE, which had collapsed every cpulist to
empty on hosts with more CPUs than cpu_set_t can address; and placement now refuses
outright rather than CPU_SET past CPU_SETSIZE, which is a silent no-op that would
pin a partition to no core at all.

Assisted-by: ClaudeCode:claude-opus-5
The handle owns the send/recv buffers that a posted MPI_Ialltoallv writes directly into,
but it was a plain aggregate: copyable, and destructible without ever completing the
request. Both are latent memory corruption on the Kind::Mpi async path.

A copy handed two owners the same MPI_Request, so the second wait_into would wait on a
request the first had already completed and set to MPI_REQUEST_NULL in its own copy --
and both copies' buffers were live targets of one transfer. Nothing copies the handle
today (both call sites are `auto h = begin_alltoallv(...)`), which is why this never
surfaced; deleting the copy keeps it that way by construction rather than by habit.

Destroying a handle without calling wait_into is what an exception or an early return
between post and unpack does, and it freed send_buffer/recv_buffer while MPI was still
writing into them. The destructor now waits, wait() is idempotent and factored out of
wait_into, and the type is [[nodiscard]] so dropping the handle at a call site is a
diagnostic instead of a use-after-free.

Assisted-by: ClaudeCode:claude-opus-5
…arser parses

env_config_spin_budget_falls_back_to_barrier_default re-implemented
`parse_positive_int(monoprop_SPIN_BUDGET_US).value_or(kDefaultSpinBudgetUs)` and asserted
the result equalled itself. That passes whether or not PartitionBarrier is wired to the
setting at all, which is the only thing the case exists to check.

config::get() caches on first call, so the env path is genuinely unreachable from an
in-process test. Make the budget injectable instead: a third constructor parameter,
defaulting to the configured value and then the compiled-in one, plus a spin_budget()
accessor. The case now observes what the barrier resolved, and an explicit override is
shown to win -- which is also what lets the default be swept and justified by measurement
rather than asserted.

Assisted-by: ClaudeCode:claude-opus-5
…an be measured

The two-level barrier's value has never been isolated, and until now it could not be. Its
domains are derived from the cpusets, so the only way to get a flat barrier from outside
the process was to turn pinning off -- which also unpins. Every before/after therefore
confounded "grouped vs flat" with "pinned vs unpinned", including the ones used to justify
the second level in the first place.

monoprop_BARRIER_GROUPING=0 forces the flat path and leaves pinning alone, which makes
"grouped vs flat, both pinned, one build, one allocation" a run rather than an argument.

There is a specific question waiting on it. At ~29M terms on two nodes the placement fix
made layout 8x16's energy 1.56x and gradient 1.45x faster, but `pare` 1.37x slower on the
median and 1.40x on the min -- median and min agreeing, so not noise. `pare` is the
shortest collective-bearing operation in the suite at ~10 ms, and the second level trades
one fetch_add for two sequential hops, so it can only pay where there is contention to
relieve. A short collective whose partitions arrive together has none.

The knob is diagnostic and tuning, defaulting to on, so behaviour is unchanged unless it
is set. If the flat barrier turns out to win broadly, this is also the measurement that
justifies deleting the second level rather than assuming it earns its keep.

Assisted-by: ClaudeCode:claude-opus-5
barrier_groups = 0 has two legitimate causes that are indistinguishable from
outside the process: nothing was pinned, or every partition landed in a single
locality domain and so has nothing to fan in across. That ambiguity is what let
the Slurm mask bug read as a tuning result rather than a defect.

pin_this_thread now returns whether the affinity call took, PartitionGroup counts
the masters that succeeded, and CommProfile prints it as `pinned=`. A sentinel of
-1 distinguishes "no PartitionGroup owns this transport" -- a bare-transport unit
test -- from a genuine zero.

Assisted-by: ClaudeCode:claude-opus-5
…r orderings

PartitionBarrier's memory orderings were documented as load-bearing and
sonar-project.properties suppresses cpp:S8417 for that file, but nothing checked
the claim -- the repo had no sanitizer configuration at all.

monoprop_ENABLE_TSAN is an option rather than a build type, so it composes with
any CMAKE_BUILD_TYPE: the threading layer is only worth auditing at the
optimization level it ships with. Flags go on CMAKE_CXX_FLAGS because
-fsanitize=thread must reach the link line as well as every compile line.
cpp/tests/tsan.supp suppresses third-party (OpenMPI/PMIx/libevent) reports only,
never anything under cpp/monoprop/, and that was verified by confirming a
deliberately weakened ordering still reports through it.

partition_barrier_tests, shm_comm_tests and partition_equivalence_tests are clean
under TSan. A clean run proves nothing on its own here, because the
synchronisation is hand-rolled atomics with no mutex for TSan to hook, so it was
checked against a mutation control: demoting any single one of the five orderings
-- either generation store, the group-generation store, the arrival fetch_adds,
or the spin load -- makes TSan report the published data as a race. Each is
individually necessary and none is stronger than it needs to be.

Assisted-by: ClaudeCode:claude-opus-5
No test combined more than one MPI rank with an asymmetric emit gate, so a defect
in the cross-rank leader/follower exchange that only appears when the leader emits
and the follower is dropped would have passed the whole suite. Both atols were
nullopt in this file; exact_upper_atol_rescue.cpp and mpi_fresh_insert_equivalence.cpp
pass upper_atol = 0, which rescues every truncated term and so restores the symmetry
they appear to test. The only asymmetric-gate case with partitions > 1 runs over the
in-process ShmComm and never crosses a rank boundary.

Covers both sinks, which fail differently: build_graph resolves through GraphSink,
which pre-sizes a response slot per incoming query, and propagate through
ContractSink, which writes a half-rotation record. Energy is compared with near()
because reduction order differs across rank counts; the term count is compared
exactly, since that is where a dropped or double-counted rotation shows up.

Two guards keep the case from passing vacuously, which matters more than the
assertions themselves. The first version used the random_exact fixture and reported
an ungated term count of 3 -- no threshold could drop anything, so it exercised
nothing. On LihFixture (n=12, 866 terms) lower_atol drops 208-232 terms on the
propagate path but *nothing* on build_graph, so a single combined flag would have
left GraphSink covered in name only; the length cap drops 32 on both and is what
actually exercises it. Each path therefore asserts, and reports, that its gate bit.

Assisted-by: ClaudeCode:claude-opus-5
A pattern containing a slash is anchored to the .gitignore's own directory, so
the previous `*/hpc/*` required a leading path component and never matched the
root-level hpc/ it was written for.

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

pytest's default capture is fd-level: it replaces fd 2 for each test and discards
the buffer when the test passes. monoprop_COMM_PROFILE=1 writes its COMMPROF line
straight to fd 2 from a transport destructor, so a passing `just bench` reported
no profile at all. Measured on one tree with one command, 2 partitions, tiny
sizes: 0 COMMPROF lines without `-s`, 6 with it, both runs 10 passed.

The failure mode is what makes this worth a commit rather than a note: an empty
profile and an unchanged profile are the same observation, so it reads as "the
change made no difference" instead of "nothing was measured".

Assisted-by: ClaudeCode:claude-opus-5
Both fail by producing a plausible result rather than an error, which is what makes
them worth writing down:

- pytest's fd-level capture discards the engine's fd-2 diagnostics on a passing
  test, so monoprop_COMM_PROFILE reports nothing and the run reads as "no
  difference between the arms" rather than "nothing was measured".
- uv sync does not relink the C++ test binary, and the editable tree cannot be
  reconfigured in place afterwards, so an edited test can be judged by a stale
  binary whose only symptom is a filter that matches nothing.

AGENTS.md also gains the rule that generalises the first one: assert the count of
what an instrument should emit, and never diagnose by comparing two zeros.

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
@github-actions github-actions Bot added the ci label Aug 13, 2026
@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

🤖 AI text below 🤖

Partition threading: portability fixes, measured

The brief was "simplify the threading, keep performance the same or better, and base it on
profiling". The simplification thesis did not survive measurement; the portability work did, and it
is where the speed-up came from.

What landed

Three independent mechanisms made intra-node threading degrade from system to system:

  1. Pinning silently turned itself off under Slurm. enumerate_physical_cores() already filters
    by the rank's affinity mask, then partition_cpusets divided by the node size a second time and
    bailed. Every multi-rank layout on our cluster ran unpinned, which also left the two-level
    barrier inert. The fix measures disjointness between co-located ranks' masks
    (classify_node_mask) rather than mask width — width cannot tell a per-rank slice from a shared
    mask, and collapsing unconditionally would point every co-located rank at the same cores.
  2. The barrier spin budget was a fixed iteration count calibrated on Sapphire Rapids, where
    PAUSE is ~140 cycles. On aarch64 cpu_relax() is a ~1-cycle yield, so the same constant gave
    a spin window two orders of magnitude shorter. It is now a time budget (steady_clock,
    injectable for tests, monoprop_SPIN_BUDGET_US).
  3. Locality domains keyed on L3 only, so a target without an L3 (A64FX-class ARM) gave every
    participant its own singleton domain — a flat barrier plus S extra cache lines, while
    barrier_groups reported the optimisation as engaged. Domains now come from the deepest shared
    cache, falling back to the NUMA node.

Measured effect of the placement fix alone (monoprop_COMM_PROFILE=1, one node):

layout barrier_groups before → after µs/sync before → after
A 1x128 (control, mask = whole node) 32 → 32 24.9 → 21.5
B 8x16 (recommended default) 0 → 4 432–443 → 10.9–19.2
C 2x64 0 → 16 837–844 → 11.5–49.3

End-to-end at ~29M terms on 2 nodes, layout B, interleaved A/B over 4 reps:
energy 2.1× faster, gradient 1.36× faster — non-overlapping distributions, i.e. main's
best run is worse than this branch's worst. inplace is unchanged (0.98×).

Also in: monoprop_BARRIER_GROUPING (so grouped-vs-flat can be measured without unpinning), a
pinned count in COMMPROF (so "believed pinned" and "actually pinned" can differ visibly), an
opt-in monoprop_ENABLE_TSAN build, and MPI-rank coverage for the asymmetric emit gate.

One known regression, not fixed

build_graph at layout A (1×128) is ~1.1× slower, position-matched (main-first 4.76 vs
port-first 5.01; main-second 5.14 vs port-second 6.06). It is one of the four operations that matter,
and the mechanism is unidentified — a spin-budget sweep moves it ≤5%, and layout A is the one layout
where pinning already worked before this branch, so the placement fix cannot be the cause. Layout B,
the recommended default, does not show it. Flagging rather than burying it: I reported this earlier,
withdrew it as non-reproducing, and the withdrawal was wrong — it only became visible after an
A/B-ordering bug was fixed.

What was tried and rejected

  • OpenMP is disqualified on correctness, not performance. #pragma omp barrier has no escape
    hatch, so it cannot honour PartitionBarrier::poison() — an engine exception in one partition
    becomes a hang instead of a propagated error. It was also slower where it matters (30 vs 382
    µs/sync at S=32), proc_bind is inert unless set in the environment (and setting OMP_PROC_BIND
    confines the Python thread as a side effect), and with -Wno-unknown-pragmas a missing -fopenmp
    compiles the pragmas away into a silent deadlock.
  • TBB: the design is SPMD — the partition index is the communicator rank is the index into
    every shared table — so work-stealing would break the bit-identical-at-fixed-(R,S) contract the
    determinism tests assert.
  • The two-level barrier stays. First measurement free of the pinning confound (that is what
    monoprop_BARRIER_GROUPING is for): grouped beats flat at S=128 (build_graph 1.16–1.20×) and
    ties at S=16, with no cell showing flat robustly faster.
  • A one-pass leader/follower merge and MPI-4 persistent collectives were both dropped on
    cost grounds, with the reasoning recorded in the branch's results notes.

Why the comm-layer work stopped here

COMMPROF accumulates over the whole process lifetime, so mpi_s has to be compared against the
sum of the operations. Against the sum of the four evaluated operations at ~29M terms on 2 nodes:

layout evaluated wall mpi_s comm share
4×32 13.20 s 1.143 8.7%
8×16 14.02 s 0.419 3.0%
16×8 14.56 s 0.405 2.8%
32×4 17.97 s 0.871 4.8%

At the recommended layout the entire MPI leg is 3% of the work, so perfectly eliminating the
funnel would buy ~3% at this scale. Layout choice is worth more: build_graph is 1.25× faster at
4×32 despite 2.7× worse comm. The robust funnel finding is barrier_peers_s ≈ mpi_s at ratio
1.02–1.07 across every layout — the S−1 non-funnel partitions idle for exactly as long as partition 0
spends inside MPI.

Caveat worth stating plainly: whether the comm share grows with node count is an inference, not
a measurement. A node-count sweep at N = 2, 4, 8, 16 would settle it, and it is the measurement that
justifies or retires the remaining comm programme.

Tests

C++ suite 224/224 green, on x86 and on aarch64 with zero delta. TSan is clean on the three
concurrency suites, and — because hand-rolled atomics give TSan nothing to hook, so "clean" and
"invisible to the tool" look identical — a mutation control demoting each ordering to relaxed one at
a time produces a race report in all five cases. New coverage: partition_cpusets under a
Slurm-style restricted mask, the invariant that co-located ranks stay disjoint, the spin budget
actually reaching the barrier, and the asymmetric emit gate across real ranks (verified at 2 and 4
ranks; a clean skip at 1).

The -L mpi label does not pass, and it does not pass on main either. Running the whole suite
under 2 ranks aborts in shm_comm_oversubscribed_repeated_collectives with terminate called without an active exception — the signature of a joinable std::thread being destroyed, i.e.
thread creation throwing inside the test's own harness loop. Established rather than assumed: it
reproduces 3/3 on the pre-branch commit c1e034c and 3/3 here, it passes at 1 rank in both, and it
is independent of rank binding (--bind-to none included). Everything before it is green — 222 of
224 cases entered on both ranks with zero failures. This is a pre-existing gap in the multi-rank
test path, not a regression from this branch, and it is not fixed here.

Test-suite runtime, and a trap that came with it

CTest runs each 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 — 34 minutes for
224 cases. monoprop_TEST_EXCLUDE_MPI_FABRIC (default on) skips fabric init and takes the suite to
5.4 minutes.

It is scoped to the single-process serial variants and deliberately kept off the multi-rank ones,
because 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 — again on main as well, so it is component
selection rather than engine code. My first version of this applied the setting to every variant and
would have shipped that hang; the scoping seam is a new SERIAL_ENVIRONMENT argument to
discover_tests.

Three developer traps are now written down in AGENTS.md and the docs, because each fails by
producing a plausible result rather than an error: pytest's fd-level capture discards the engine's
fd-2 diagnostics on a passing test (so monoprop_COMM_PROFILE reported nothing and it read as "no
difference between the arms" — just bench now passes -s; measured 0 COMMPROF lines without it
and 6 with), uv sync does not relink the C++ test binary, and a slow CTest run on an MPI build is
MPI_Init rather than slow tests.

Brings in #203 (implementations split into source/inline files), #208 (hwloc
replaces the custom topology discovery), #211, #223, #148 and #224. Resolves the
conflict that had left this PR with no CI: GitHub cannot recompute
refs/pull/166/merge while the merge conflicts, so the pull_request workflows
produced no runs at all and only the pull_request_target labeller was firing.

Four files conflicted; the rest auto-merged. Two were unions of independent
additions (AGENTS.md, parallelism.mdx) -- in the latter, main's wording for the
monoprop_PARTITION_PINNING row is now the accurate one, since hwloc replaced the
Linux-only /sys path.

The substantive resolution is CpuTopology. #208 rewrote discovery onto hwloc,
which is the better mechanism and is kept wholesale, but it also reinstated the
placement bug this branch exists to fix: enumerate_physical_cores() filters by
the calling thread's affinity, and placement_order() then rejects the request
when group_count * n > cores.size(), so a rank holding a launcher-assigned slice
divides an already-divided machine and silently runs unpinned. That is the
mechanism measured at 437 us/sync against 15.5 us/sync placed.

So this keeps main's hwloc discovery and re-applies the fix on top of it, rather
than keeping either side's file:

  - NodeMask, classify_node_mask() and this_thread_cpumask() return, with the
    PerRank collapse moved into partition_cpusets() so placement_order() stays
    pure and hardware-free the way #208 factored it.
  - CpuMask replaces cpu_set_t as the exchanged type, since main's CpuSet is now
    a single PU rather than a mask. It is a fixed-size POD because
    PartitionGroup ships it through MPI_Allgather as MPI_BYTE, and being
    hwloc-free makes classify_node_mask() unit-testable without live hardware.
    4096 bits rather than glibc's CPU_SETSIZE of 1024: a PU index past the mask
    is invisible to the disjointness test, which would misread a per-rank split
    as shared.
  - pin_this_thread() reports whether the affinity took, which CommProfile's
    pinned count needs to tell "nothing was pinned" apart from "one domain per
    rank" when barrier_groups is 0.
  - cpuset_domains() returns, so the two-level barrier keeps deriving its
    domains from the placement rather than from the placement logic.

Verified on a Deucalion login node, against live hwloc and real launcher
bindings, that the collapse is load-bearing and does not disturb the shared
case. Same live mask, same call, only the classification differs:

  2 ranks, --map-by slot:PE=4 --bind-to core, n=4  =>  PerRank
      as Shared (== main's unconditional divide): 0 cores placed
      as PerRank:                                 4 cores placed
  2 ranks, PE=8, n=8                              =>  PerRank:  0 vs 8
  2 ranks, --bind-to none, n=4                    =>  Shared:   4 vs 4

AGENTS.md records the trap under Architecture Overview, including that it has now
regressed once, because the guard lives in the placement policy and not in
discovery -- so a future rework of that layer has to re-check it.
cpu_topology_policy_per_rank_slice_starves_without_collapse pins the mechanism
deterministically, without live hardware.

Gates on the merged tree (hwloc 2.12.1, already provided transitively by
foss/2025b, so hpc/deucalion/env.sh needs no change):
  - ctest -L serial: 224/224 passed, 0 failed, 412 s
  - fabric-exclusion scoping still correct: 224/224 serial carry it, 0/1 mpi do
  - cpu_topology: 14/14 cases, 185 assertions, with per-case assertion counts
    confirming the two live restricted-mask cases asserted rather than
    early-returning
  - rank_count_matches_under_an_asymmetric_emit_gate and
    zero_cutoff_upper_atol_zero_is_exact_World: pass at 1, 2 and 4 ranks

Unchanged by this merge and still open: shm_comm_oversubscribed_repeated_collectives
aborts under 2 ranks on main as well as here, so ctest -L mpi remains unusable as
a gate; and the layout A build_graph ~1.1x regression is still unexplained.

Assisted-by: ClaudeCode:claude-opus-5
prek pins mirrors-clang-format at v21.1.0, which makes different line-break
choices than v20 in these two BOOST_TEST_MESSAGE / BOOST_CHECK_MESSAGE chains.
Whitespace only; no behaviour change.

Caught by the first CI run this branch has ever had -- the conflict with main had
been suppressing every pull_request workflow, so the lint failure predates the
merge rather than coming from it.

Assisted-by: ClaudeCode:claude-opus-5
@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

🤖 AI text below 🤖

Merged main in — and it needed more than conflict resolution

This branch was CONFLICTING with main, which is why it had been getting no CI at all: the
pull_request workflows check out refs/pull/166/merge, GitHub cannot recompute that ref while the
merge conflicts, so no runs were created. The single green check was Label PRs, the one workflow
using pull_request_target (which reads the base ref instead). The merge ref was stale at
7c7994e, parents 5836a06 + c1e034c, dated to the second of the last real CI runs.

Merged as a merge commit rather than a rebase, so the 11 commits already on this PR keep their
hashes and nothing is force-pushed.

Four files conflicted; the rest auto-merged. Two were plain unions of independent additions
(AGENTS.md, parallelism.mdx).

The one that mattered: #208 reinstated the bug this branch fixes

#208 replaced the custom /sys topology discovery with hwloc. That is the better mechanism and it
is kept wholesale — but it also brought back the placement bug this branch exists to fix, because
the guard lives in the placement policy while the affinity filtering lives in discovery, and
rewriting one does not see the other:

// topo_detail::placement_order
if (cores.empty() || group_count * n > cores.size()) { return {}; }

enumerate_physical_cores() has already filtered by the calling thread's affinity, so under
srun --cpu-bind=cores a rank's cores list is its slice — and dividing by the node-wide
group_count a second time asks for more than the slice ever held. The guard refuses, the rank runs
unpinned, and the two-level barrier loses its domains with it (cpuset_domains derives them from
the placement). That is the mechanism measured at 437 µs/sync against 15.5 µs/sync placed.

So rather than keeping either side's file, this keeps main's hwloc discovery and re-applies the fix
on top of it:

  • NodeMask, classify_node_mask() and this_thread_cpumask() return, with the PerRank collapse
    in partition_cpusets() so placement_order() stays pure and hardware-free the way refactor: 🧹 use hwloc instead of custom topology discovery #208
    factored it.
  • CpuMask replaces cpu_set_t as the exchanged type, since main's CpuSet is now a single PU
    rather than a mask. Fixed-size POD because PartitionGroup ships it through MPI_Allgather as
    MPI_BYTE; hwloc-free so classify_node_mask() is unit-testable without live hardware. 4096 bits
    rather than glibc's CPU_SETSIZE of 1024 — a PU index past the mask is invisible to the
    disjointness test, which would misread a per-rank split as shared.
  • pin_this_thread() reports whether the affinity took, which CommProfile's pinned count needs
    to tell "nothing was pinned" apart from "one domain per rank" when barrier_groups is 0.
  • cpuset_domains() returns, so the barrier keeps deriving domains from the placement.

Verified on live hardware, not argued from the code

Same live mask, same call, only the classification differs. "as Shared" is exactly main's
behaviour, since main has no NodeMask and always divides:

binding classified as Shared (== main) as PerRank (merged)
--map-by slot:PE=4 --bind-to core, n=4 PerRank 0 placed 4 placed
PE=8, n=8 PerRank 0 placed 8 placed
--bind-to none, n=4 Shared 4 placed 4 placed

The last row is the regression guard: the collapse must not change the shared case, or every
co-located rank would pin to the same cores — worse than not pinning. Reproducer is
maskprobe.cpp (~50 lines); it compiles CpuTopology.cpp in directly, because libmonoprop.so
builds with hidden visibility and does not export the detail::partition symbols.

AGENTS.md now records the trap under Architecture Overview, including that it has regressed
once
, so a future rework of that layer has to re-check it.
cpu_topology_policy_per_rank_slice_starves_without_collapse pins the mechanism deterministically,
with no live hardware needed.

Gates on the merged tree

hwloc 2.12.1 is already provided transitively by foss/2025b, so hpc/deucalion/env.sh needs no
change.

  • ctest -L serial: 224/224 passed, 0 failed, 412 s
  • Fabric-exclusion scoping still correct after main touched cpp/tests/CMakeLists.txt:
    224/224 serial carry it, 0/1 mpi do (the mpi variant must not — OMPI_MCA_pml=^ucx hangs
    2-rank collectives)
  • cpu_topology: 14/14 cases, 185 assertions. Per-case assertion counts confirm the two live
    restricted-mask cases asserted rather than early-returning (5 and 6), and that hwloc
    enumerated all 128 cores (129 assertions in the mask-coverage case)
  • rank_count_matches_under_an_asymmetric_emit_gate and
    zero_cutoff_upper_atol_zero_is_exact_World: pass at 1, 2 and 4 ranks, 24 assertions each
  • Python suite: 609 passed, 0 failed (-m "not mpi"), against a freshly rebuilt editable
    extension
  • partition_* + shm_comm_* + hybrid_comm_* together: 36 cases, 5779 assertions, all passing
    — this is the set carrying the bit-identical-at-fixed-(R,S) determinism assertions and the
    barrier poison/reset cases, which are what a placement change most endangers

One note on that last line, because the first attempt looked alarming and the cause is worth knowing
for anyone running the suite on a login node. It reported 291 failed with
RuntimeError: Resource temporarily unavailable, which is EAGAIN from thread creation, presented
via a _BoundSimulatorAdapter repr that itself raises RecursionError — nothing in it points at
threads. ulimit -u is 2,061,974 and looks innocent; the actual ceiling is the cgroup pids
controller
, /sys/fs/cgroup/pids/user.slice/user-<uid>.slice/pids.max = 150, against
pids.current ≈ 74 for a shell plus VS Code server. The default heuristic asks for one partition per
physical core = 128 threads, so it cannot start. monoprop_PARTITIONS=4 gives 609/609 and runs 3.5×
faster (52 s vs 179 s). ctest -L serial is unaffected because those cases pass explicit small
partition counts rather than using the heuristic.

I checked whether the same limit explains the shm_comm_oversubscribed abort below. It does not:
that case aborts at 2 ranks under both --bind-to core (S=8, ~16 threads) and --bind-to none
(S=64, ~128 threads) — an 8× swing in demand with no change in outcome — and in isolation with only
76/150 pids in use. So it stays unexplained rather than being quietly attributed to this.

The first CI run found one real thing

Lint failed on cpp/tests/mpi_distributed_layer_equivalence.cpp: prek pins
mirrors-clang-format at v21.1.0, which makes different line-break choices than v20 in two
BOOST_TEST_MESSAGE / BOOST_CHECK_MESSAGE chains. Whitespace only, fixed in fa65466, and the
whole repo now passes under the pinned version. Worth noting that this defect predates the merge
— it sat in the 11 commits undetected precisely because the conflict was suppressing every
pull_request workflow.

Unchanged by this merge, still open

  • shm_comm_oversubscribed_repeated_collectives aborts under 2 ranks with terminate called without an active exceptionon main as well as here (3/3 both, passes at 1 rank, independent of
    binding). So ctest -L mpi remains unusable as a gate; gate on -L serial plus targeted
    mpirun runs.
  • Layout A (1×128) build_graph ~1.1× slower, position-matched, mechanism unidentified. One of the
    four priority operations.
  • The N = 2/4/8/16 node-count sweep that would justify or retire the remaining comm programme.

@sonarqubecloud

Copy link
Copy Markdown

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

Labels

ci cpp documentation Improvements or additions to documentation test-in-draft Run CI even in Draft mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant