Skip to content

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

Draft
diagonal-hamiltonian wants to merge 3 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 3 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

❌ Patch coverage is 60.40609% with 78 lines in your changes missing coverage. Please review.
✅ Project coverage is 83.36%. Comparing base (92685db) to head (69a7932).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/monoprop/detail/mpi/CommProfile.h 4.65% 40 Missing and 1 partial ⚠️
src/monoprop/detail/mpi/ShmComm.h 82.27% 8 Missing and 6 partials ⚠️
src/monoprop/detail/partition/CpuTopology.h 12.50% 13 Missing and 1 partial ⚠️
src/monoprop/detail/mpi/PartitionBarrier.h 82.69% 1 Missing and 8 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #166      +/-   ##
==========================================
- Coverage   84.81%   83.36%   -1.46%     
==========================================
  Files          59       60       +1     
  Lines        4202     4333     +131     
  Branches     1497     1552      +55     
==========================================
+ Hits         3564     3612      +48     
- Misses        248      310      +62     
- Partials      390      411      +21     
Flag Coverage Δ
cpp 81.12% <60.40%> (-1.65%) ⬇️
python 95.65% <ø> (ø)

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
@sonarqubecloud

Copy link
Copy Markdown

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

Labels

test-in-draft Run CI even in Draft mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant