perf(mpi): remove the serial O(R·S²) protocol floor from HybridComm - #166
perf(mpi): remove the serial O(R·S²) protocol floor from HybridComm#166diagonal-hamiltonian wants to merge 19 commits into
Conversation
|
Docs preview: https://pr-166.monoprop-docs.pages.dev |
ca71897 to
42de519
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. |
42de519 to
55ca2f3
Compare
…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
55ca2f3 to
69a7932
Compare
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>
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>
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
|
🤖 AI text below 🤖 Partition threading: portability fixes, measuredThe brief was "simplify the threading, keep performance the same or better, and base it on What landedThree independent mechanisms made intra-node threading degrade from system to system:
Measured effect of the placement fix alone (
End-to-end at ~29M terms on 2 nodes, layout B, interleaved A/B over 4 reps: Also in: One known regression, not fixed
What was tried and rejected
Why the comm-layer work stopped here
At the recommended layout the entire MPI leg is 3% of the work, so perfectly eliminating the Caveat worth stating plainly: whether the comm share grows with node count is an inference, not TestsC++ suite 224/224 green, on x86 and on aarch64 with zero delta. TSan is clean on the three The Test-suite runtime, and a trap that came with itCTest runs each Boost case as its own process, so an MPI build pays a full It is scoped to the single-process Three developer traps are now written down in |
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
|
🤖 AI text below 🤖 Merged
|
| 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=^ucxhangs
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_gateand
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_collectivesaborts under 2 ranks withterminate called without an active exception— onmainas well as here (3/3 both, passes at 1 rank, independent of
binding). Soctest -L mpiremains unusable as a gate; gate on-L serialplus targeted
mpirunruns.- 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.
|



What
A partitioned multi-node run was protocol-bound, not network-bound. Every
HybridCommcollective rebuilt
O(R·S²)integer offset tables, and partition 0 filled all of themalone while the other
S−1partitions spun at a barrier. Four changes remove that serialphase; 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.
(b,t)base needing global knowledge (onlyO(R·S), stays on partition 0) and a scan overuthat partitiontowns 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.halltoallv_reverse. The answer leg travels the query exchange's legs backwards, so its geometry is the query round's; rebuilding it cost3·R·S²entries and a barrier for nothing. A ratio, not an identity — the query leg carriesSink::kStrideelements 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.hRstrided probes. At early layers nearly every block is empty.HybridComm.hPartitionBarrierfans in within an L3 domain then across domains, so the arrivalfetch_addand the release store costO(S/G)coherence transactions inside one L3 slice instead ofO(S)across the socket interconnect.PartitionBarrier.h,CpuTopology.h,PartitionGroup.hCorrectness 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.
ctest, plus the comm suite undermpiexec -n 3and-n 4(SandRinteractin the new index arithmetic, so world 2 alone is not enough).
hybrid_commcase sent the samecount to every destination per source, which cannot distinguish the
(rank, dest partition, source partition)index order from its transpose — exactly theindex 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 notest host is guaranteed to provide.
A/B
Leonardo DCGP, 2 nodes,
--exclusive, one allocation for both sides run interleaved (aper-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/mainand this branch, sobenches/isidentical and only
src/differs. Driver: the in-repo Hubbard model (60 sites / 120 qubits) viabenches/_builders, barriered per layer, expectation value compared asrepr— the gate isbit-identity, not a tolerance.
c1 — 20 layers,
atol1e-4, 1,063,245 terms (small operator: cost set by the per-gate sync count)c2 — 29 layers,
atol1e-6, 260,928,282 terms (large operator: real work dominates)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:
main, putting one partition per physicalcore 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.Layer 1 at
1×112is 9–10× cheaper in both configs — it holds a tiny operator, so it measuresthe 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.
4×28has layer 1 going 0.553 → 0.633 s (+14 %, singlereplicate). Expected in kind — fix 1 adds two barriers per verb, and at
S=28 there is littleserial fill to reclaim. It is why fixes 2–4 are load-bearing rather than optional.
O(R·S²); this A/B is the smallestinteresting 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×112and 163 → 146 s at4×28, with layer1 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-formatwhitespace in four files.
Notes for the reviewer
mpiis now the largest bucket, ~65k collectives at 25–100 µs, i.e. call latency, notbandwidth. 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_PROFILEis off by default and allocates nothing then; the hot path pays onenull check per instrumented region.
Static analysis. Sonar's reliability gate caught one real defect, now fixed: both new
transport destructors call
CommProfile::dump(), whosestd::printcan throw, and a throwingdestructor running during unwinding terminates the process.
dump()is nownoexceptandswallows 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_orderrule wantsseq_cston the barrier's atomics, which would undo fix 4 (andfires on the pre-existing flat barrier on
maintoo); thevoid *payload parameters and the[&]capture inalltoallv_reversemirror the sibling verbs in the same file, where divergingwould be worse than complying; and the
HybridCommfield/method counts are pre-existing classsize that this PR is not the place to refactor.
One stateful contract is introduced:
alltoallv_reversemust directly follow thealltoallv_resolvewhose layout it reverses. Guarded by a generation counter that throwsrather than silently reading stale tables, and documented on the verb.
🤖 Generated with Claude Code