perf(mpi): 🧭 GF(2)-linear rank routing — messages per rank flat in R - #296
perf(mpi): 🧭 GF(2)-linear rank routing — messages per rank flat in R#296diagonal-hamiltonian wants to merge 24 commits into
Conversation
Routing was two inline copies of `monomial_hash(M) % P` -- one in Scan.h's query
emission, one in `find_rank` -- which had to agree exactly or ownership splits
silently. Both now go through `routing::Router::dest` and nothing else.
The reason for an abstraction at all is that splitmix's full avalanche is what
makes the exchange dense: a gate maps M to M^G, and with an avalanching owner
function a rank's queries for ONE generator spray across all R ranks, so the
message count grows as R*(R-1). If the RANK index is instead a GF(2)-linear
function of the support, h(M^G) = h(M) ^ h(G), and -- since I own M -- every
query I emit for G lands on exactly one peer, my_rank ^ h(G).
So the router is two-level, because the two levels cost differently: across MPI
ranks the cost is the message COUNT, within a rank partitions talk through shared
memory where fanout is free and only balance matters.
part = q % S q = monomial_hash(M), unchanged
hi = (q / S) % (R >> d)
rank = (a & (2^d - 1)) | (hi << d) a = linear_hash(M)
flat = rank * S + part
d is a dial: fanout is R >> d, d = 0 collapses to today's `q % (R*S)` bit for
bit (the default, and the regression gate), d = log2(R) is fanout 1. A
non-power-of-two rank count has no XOR structure and falls back to d = 0.
Nothing yet exploits the sparsity -- the transport is still the dense pair. This
commit only makes the sparsity exist and proves it costs nothing to have.
mpi::geometry() is new because size() alone cannot tell an inter-rank message
(a network hop) from an inter-partition one (a memcpy), and the split is exactly
what the routing needs.
Measured, 20-site Hubbard cutoff 8, 5 steps, against a pristine build of the same
HEAD: d = 0 is bit-identical to today at S=1, S=8, world 2 and world 4. With
linear bits on (world 4 x {1,2} partitions, d = 1, 2) the term count is exact --
17,148 in every configuration -- and the expectation value moves by at most 1 ULP,
which is the reassociation the baseline already shows across worlds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An empty Majorana generator anticommutes with nothing, so the scan already returned zero queries on every rank -- but run_exchange's collectives fire on payload size zero all the same, and each of the two passes costs three of them. These generators are not an edge case. A gate whose every term falls below its atol expands to the identity monomial (circuit.py's deliberate `or [((), 0.0)]`), and a zero chemical potential alone contributes 60 of the 60-site Hubbard's 476 generators per Trotter layer: 360 of 2,856 collectives per rank per layer, 12.6%, moving nothing. Measured at both sizes: 60/476 at 60 sites, 20/156 at 20. `gen.none()` is unanimous -- the generator list is replicated on every rank -- so skipping needs no agreement and no collective to decide it. This is not gate fusion: no gate is merged, a no-op gate is simply not exchanged for, and the layer is still built (the graph's gate and parameter bookkeeping is untouched). finish() reads neither the query streams nor the response streams, so the skipped path lands in exactly the state the empty-payload exchange left it in. Measured on the 20-site Hubbard (cutoff 8, 5 steps, 20 of 156 generators empty): term count and expectation value bit-identical to the previous commit in all seven configurations -- S=1, S=8, and worlds 2 and 4 crossed with d = 0, 1, 2. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Linear routing makes the destination RANK of a query predictable, but until now
nothing used that: the exchange still called MPI_Alltoall on the counts and
MPI_Alltoallv on the payload, so the message count stayed R*(R-1) whatever the
data looked like.
mpi::PeerPlan{bits, shift} carries the structure from where it is known
(build_layer, which holds the generator) to where it is spent (HybridComm, and
the plain-MPI path for S == 1). Its two accessors are written so that bits == 0
degenerates exactly: peer(k) == k and count == ranks, so every loop below walks
all ranks in the old order and the verbs take their collective path unchanged.
What the plan buys, in the order the campaign's evidence ranks them:
* the SERIAL sweeps shrink. pack_count_matrix_, size_staging_send_'s two
passes, fill_recv_col_from_counts_recv_ and the scatter are all O(R*S^2) and
all run on partition 0 while the other S-1 park at a barrier -- ~25k int ops
per verb at R=128, S=14 that nothing overlaps. Restricted to the f = R>>bits
reachable ranks they become O(f*S^2), so 128x less at fanout 1.
* the messages shrink. One MPI_Alltoall + one MPI_Alltoallv become f Isend/Irecv
pairs; a self peer (a generator whose rank shift is zero) is a memcpy and
costs no message at all.
check_routing_agreement() allreduces the resolved configuration once at
construction. This is not defensive tidiness: under linear routing each rank posts
receives from the peers ITS OWN bits imply, so a rank that never saw
monoprop_ROUTING would hang rather than answer differently, and a hang at 128
ranks is much harder to read than an exception.
Measured, 20-site Hubbard cutoff 8, 5 steps, across 11 configurations -- world 4 x
S in {1,2,3} x d in {0,1,2}, world 2 S=4, world 8 d=3 (fanout 1): term count
17,148 in every one, expectation value within +-1 ULP of the dense arm, no hangs.
252/252 ctest including two new sparse-plan cases that check a non-peer never
appears in the delivery and that both the fused-counts and known-counts paths
pair correctly; the Python suite passes at world 4 under both routings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`monoprop_ROUTING` now defaults to linear; `splitmix` is the explicit opt-out, and an unrecognised value falls back to the default, matching EnvConfig.h's parse_positive_int convention. The Router still clamps to log2(R) and to d=0 when R is not a power of two, so a geometry without XOR structure keeps the dense path untouched. Measured at the production point (60 sites, cutoff 10, atol 2.6e-06, 1,569,152,761 terms): fanout 1 costs nothing on balance -- rank occupancy max/mean 1.001 at R=128 with all 128 ranks used, and the flat (memory) imbalance is slightly better than splitmix's -- while messages per rank per layer fall from 362,712 to 1,397, i.e. from proportional-to-R to flat. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ownership oracle in mpi_utils_scan_routing_agrees_with_find_rank hashed with the flat-world splitmix overload of find_rank, which is only the owner function when the router resolves to d=0. The scan calls Router::dest, so on a linear geometry the case asserted an invariant the code no longer has. Thread the Router through both helpers and run the case twice per rank count -- splitmix and fanout-1 linear -- so the agreement is checked as a property of the pair rather than of either hash. The floors are untouched; they were never tight. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Docs preview: https://pr-296.monoprop-docs.pages.dev |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## pr/query-wire-v3 #296 +/- ##
=================================================
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. |
There was a problem hiding this comment.
Pull request overview
This PR improves MPI scalability by changing term routing so that (when the MPI rank count is a power of two) each generator’s cross-rank queries route to a predictable XOR-derived peer set, enabling sparse point-to-point exchanges and keeping messages-per-rank approximately flat in R. It also adds correctness/consistency checks and new test coverage to ensure routing and exchange behavior stays aligned across the codebase.
Changes:
- Introduce
routing::Routerfor a two-level routing scheme (GF(2)-linear across MPI ranks, splitmix within-rank partitions) and route all ownership decisions through it. - Add
mpi::PeerPlanand integrate sparse peer exchange intoHybridComm/MPICompatalltoallv paths, plus skip exchange work for identity generators. - Expand tests to pin routing identities, scan/find-rank agreement under both routers, and correctness of sparse peer delivery.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| cpp/monoprop/detail/mpi/Routing.h | New routing::Router implementing two-level (linear+splitmix) routing with env-configured defaults. |
| cpp/monoprop/detail/mpi/MPIUtils.h | Route ownership via Router, add router_for(), and add a cross-rank routing-agreement check. |
| cpp/monoprop/detail/mpi/Comm.h | Add mpi::PeerPlan to describe sparse reachable peers for point-to-point exchange. |
| cpp/monoprop/detail/mpi/MPICompat.h | Thread PeerPlan through alltoallv helpers and add sparse point-to-point fallback for S==1 MPI-only comms. |
| cpp/monoprop/detail/mpi/MPICompat.cpp | Extend alltoall_counts to support sparse plans, including MPI-only point-to-point counts exchange. |
| cpp/monoprop/detail/mpi/HybridComm.h | Add sparse-plan support to count/payload phases (plan-aware packing, exchange, and scatter). |
| cpp/monoprop/detail/mpi/MPICompat.h / .cpp | Add geometry() split (ranks vs partitions) and use it to drive routing/plan decisions. |
| cpp/monoprop/detail/mpi/CMakeLists.txt | Register new Routing.h header in build sources. |
| cpp/monoprop/detail/evolution/layer_build/Scan.h | Emit query destinations via Router (single source of truth for ownership). |
| cpp/monoprop/detail/evolution/layer_build/Engine.h | Derive PeerPlan per generator from Router and use it in query/response exchanges; skip exchanges for identity generators. |
| cpp/monoprop/detail/monomial_propagator/MonomialPropagator.inl | Seed initial ownership using Router and enforce routing agreement at construction. |
| cpp/tests/routing_tests.cpp | New tests pinning routing invariants (bit-identical d=0, GF(2) linearity, shift identity, fanout, defaults). |
| cpp/tests/mpi_utils_tests.cpp | Update scan/find-rank agreement test to validate both routing modes and pass Router through. |
| cpp/tests/hybrid_comm_tests.cpp | Add tests ensuring sparse plans deliver only to plan peers on both Hybrid and plain-MPI paths. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // d = log2(R) is fanout 1. Non-power-of-two R has no XOR structure at all and falls back to d = 0. | ||
| // | ||
| // Knobs: | ||
| // monoprop_ROUTING splitmix (default) | linear -- linear defaults d to log2(R) |
There was a problem hiding this comment.
🤖 AI text below 🤖
Correct, this is a real defect — the comment was written before route GF(2)-linear by default (40041c1) flipped the default and was not updated. As written it contradicts the PR title, the commit, and routing_tests.cpp:219, which pins the shipped default at fanout 1.
Fixed the comment rather than the code: linear is the intended default, monoprop_ROUTING=splitmix is the explicit opt-out.
The comment was also the only documentation these three knobs had. They are now documented on the parallelism page in the docs, and their parsing has moved into EnvConfig.h so that an unparseable value raises instead of silently selecting d = 0 — previously monoprop_ROUTE_LINEAR_BITS=abc turned the whole feature off with no diagnostic.
| BOOST_TEST(routing::gf2_rank({}) == 0U); | ||
| BOOST_TEST(routing::gf2_rank({0ULL, 0ULL}) == 0U); | ||
| BOOST_TEST(routing::gf2_rank({0b001ULL, 0b010ULL, 0b011ULL}) == 2U); // third is the XOR of the first two | ||
| BOOST_TEST(routing::gf2_rank({0b001ULL, 0b010ULL, 0b100ULL}) == 3U); |
There was a problem hiding this comment.
🤖 AI text below 🤖
Pushing back on this one. The assertion is seed-robust by construction: it draws 200 shifts and checks they span monoprop_ROUTE_SEED changes which basis, not whether a large random sample spans.
Skipping under an override would also remove the coverage in exactly the situation the comment names: someone debugging balance with a custom seed is precisely who wants to know the shifts still reach every rank.
Added a comment stating why it holds for any seed, so the next reader does not have to re-derive it.
For contrast, the neighbouring routing_default_is_linear_where_the_geometry_allows_it does skip on an override, and correctly so — there the environment default genuinely is the thing under test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
The routing change flips a shipped default and adds three environment variables, none of which appeared anywhere in docs/. The rank hash is a homomorphism of the group the gate acts by, so state the identity, what follows from it (fanout, the involution, cosets of the kernel), and the one condition it rests on -- that the generator shifts span the rank space, which is a load-balance property and not a correctness one. Distinguishes the scheme from the block-sum-mod-N map of [@Broers2025-or]: that sum is additive modulo the rank count while the gate acts by XOR, so carries leave the destination dependent on the operand's bits and only bound the fanout. Assisted-by: ClaudeCode:claude-opus-5
Under a sparse plan begin_alltoallv copied the caller's known_recv_counts verbatim, zeroing only the self slot, while alltoall_counts already masked the counts it exchanged. A non-zero count for a rank outside the peer set therefore sized recv_buffer for bytes no Irecv ever writes, and wait_into handed the caller uninitialised memory with no error. PeerPlan::contains answers membership as a low-bits equality, so the mask costs no allocation. publish_recv_rows_ takes the plan for the same reason: it summed over every rank while its one reader masked to peers. The self slot is a copy rather than a message and its two counts are each other's transpose, so both point-to-point loops now assert that instead of reading the send buffer through the recv count -- reachable whenever a generator's shift is zero, which happens in essentially every layer. Ranks that all agree on a WRONG shift stay symmetric and never hang; they drop the blocks outside the peer set silently. pack_count_matrix_ asserts the non-peer remainder is empty, and Comm.h no longer claims a deadlock is the only failure mode. Routing knobs move into EnvConfig.h, which already owned this job: raw strtol left monoprop_ROUTE_LINEAR_BITS=abc parsing as 0, turning linear routing off with no diagnostic. Unparseable and out-of-range values now throw. monoprop_ROUTING's documented default was also backwards -- linear has been the default since 40041c1. check_routing_agreement reduces two independent digests rather than one: allreduce_sum is the only collective here and a sum is not an equality test. Partitions join the digest, since S enters Router::dest and two ranks differing only in S agreed before and still routed apart. Drops the flat-world find_rank overload. It had no production caller left and answered splitmix during a linear run, which is precisely the silent ownership split its own comment warned about. Assisted-by: ClaudeCode:claude-opus-5
The sparse plan arrived as a second copy of each dense path: four point-to-point loops differing only in member-vs-local request storage, byte-vs-typed pointers and the tag, two byte-identical scatters, two recv-column fills around one loop nest, and eleven plan.dense() branches. Five branches remain and all five earn it -- four are the collectives themselves, where R-1 Isends would be a regression against an Alltoall, and one is the known-recv-count mask, which the dense path must not pay. Pairwise.h also gives the four MPI tags one home. They were bare magic numbers in three files, and the reason Engine.h's two exchange rounds may share one on a single communicator -- non-overtaking within (src, dst, tag, comm), the query round's Waitall preceding any round-2 post, and both ends skipping a zero-count leg on the same value by transpose -- was written nowhere. It is load-bearing, so it is written down. Peers materialise once per verb rather than being recomputed S*f times inside the staging loops, and exchange_payload_ takes the extent its callers already hold instead of asking MPI for it per call. Net line count rises: the extracted helper is a new file, and four copies collapsing into one is the point rather than the arithmetic. Routing.h's derivation moves to the parallelism docs page, keeping only the invariant that Scan.h and find_rank must agree. Assisted-by: ClaudeCode:claude-opus-5
routing::gf2_rank called itself the coverage diagnostic and had no caller outside its own test, so the check it describes never ran. Linear routing reaches only the subspace the per-generator shifts span: at rank rho below linear_bits, 2^d - 2^rho ranks receive nothing all run and the imbalance looks like slow peers rather than a routing property. Not beside check_routing_agreement, where it belongs conceptually -- at construction the gate list does not exist yet. It runs once at the top of the gate loop, where the generators first arrive, and only under linear routing, so splitmix and non-power-of-two geometries pay nothing. A warning rather than a throw: every term still lands on exactly one owner, so the answer is right and only the balance is not. One COMMROUTE line per rank, in COMMPLACE's shape. An out-of-range gate index leaves the report unwritten so it cannot pre-empt build_evolve_result_'s per-gate throw. Assisted-by: ClaudeCode:claude-opus-5
The skip tested gen.any() after the scan had already run. An identity generator anticommutes with nothing, so the scan returns on its empty fold-column set having produced no query, no cosine block and no swept coefficient -- work that was being done to discover it was not needed. Hoisting the test above it also skips the cos-block concatenation. LayerBuildEngine construction stays: its ctor sizes the caller-owned matched scratch, which is reported as matched_scratch_bytes, and skipping it would move that telemetry when a propagator's first gate is identity. The ctor is O(R); everything expensive is now behind the test. Assisted-by: ClaudeCode:claude-opus-5
dest() walked the monomial's set bits, loading a basis vector and XOR-ing per bit -- a data-dependent chain ~20-28 long under the production cutoff, per term, in the hottest loop here. monomial_hash beside it is a single mix of one word, so the new hash dominated the destination, not the old one. Only the low d bits survive the mask, so transpose: plane j holds bit j of every basis vector, and bit j of the image is parity(popcount(M & plane_j)). Folding the words with XOR before the popcount is the same parity, so it is d popcounts rather than d per word -- 14 branch-free ops at R=128 over 120 slots. The planes key on the seed and the width alone, never the geometry, so one table serves every Router and dest() binds a pointer at construction instead of meeting the static-init guard per term. Bit-identical, which is the acceptance criterion and not an aspiration: routing_transposed_basis_is_bit_identical_to_the_bit_walk pins dest() and rank_shift() against an independent reference of the old walk over 100k monomials across several (R, S, d), and asserts the comparison count so it cannot pass vacuously. The constructor is private now: a router with linear bits has to come through for_modes, because reading planes bound at another width would be silent. splitmix stays width-free -- with d = 0 no plane is ever read. Assisted-by: ClaudeCode:claude-opus-5
The dense branch posted MPI_Ialltoallv and completed it in wait_into while the sparse branch waited inline, so the two transports differed in a way nothing in the signature showed. The handle now carries the request set and waits with the rest; MPI reads send_buffer and recv_buffer until those complete, and both move with the handle, so the pointers stay good. No win is claimed: both consumers call wait_into immediately, so there is nothing to overlap yet. This is the seam that a later overlap needs, and one path fewer to reason about. Sizing no longer sweeps [0, R) three times per exchange: counts and their prefix fold into one pass, and the known-recv mask copies the f peer blocks into a freshly zeroed array rather than copying all R and zeroing the remainder. At R=128 this is noise; at 4096, against ~1,400 exchanges per rank per layer, it is not. Assisted-by: ClaudeCode:claude-opus-5
Both sparse cases pinned plan.count(R) == 1, so every `for k in [0, f)` in the transport had only ever run one iteration -- and the rank list defaulted to 2, where a plan with any linear bits can resolve only one peer, so no amount of local testing would have reached f > 1 either. Adding 4 to the list is what makes the multi-peer cases runnable at all. Four gaps closed: several peers, where peer-ordered blocks interleave with the [0, R) prefix sums in the staging sizers; an empty leg, so the zero-count skip is taken on one side only -- the asymmetry that deadlocks; skip_self under shift 0, which is the self-peer slot; and two rounds back to back on one communicator, which is the pattern Pairwise.h's non-overtaking argument claims is safe, now asserted rather than argued. The scan/find_rank floors count per router. They were written for one loop and kept when a second router was added, so each arm's floor was really the pair's. Measured, all six routers agree on a total of 387 while the split runs 196/191 at R=2 to 335/52 at R=8 -- the partner count belongs to the operator and the gate, and routing only moves a partner between the cross-rank and self-owned side. That invariance is the assertion now; the floors only catch a scan that emitted nothing. Assisted-by: ClaudeCode:claude-opus-5
The GF(2)-linear rank routing supported any d in [0, log2 R], but only d = 0
(monoprop_ROUTING=splitmix) and d = log2 R (the shipped default) were ever run;
the intermediate values existed only to be tested. Router now carries a mode
flag: linear takes every rank bit from the hash, splitmix takes none.
A rank count that is not a power of two no longer falls back to d = 0 silently
under linear routing -- it raises routing::UnroutableGeometry at Router
construction. R = 1 is a power of two, takes no rank bit, and stays dense, so
every single-rank run keeps the collective transport.
PeerPlan follows: {bool sparse; int shift} with one peer, me ^ shift, which
removes the 1 << bits signed-shift hazard entirely rather than narrowing it.
monoprop_ROUTE_LINEAR_BITS and its parser are gone; monoprop_ROUTING and
monoprop_ROUTE_SEED are unchanged, and d = 0 stays bit-identical to
monomial_hash(M) % P.
BREAKING CHANGE: monoprop_ROUTE_LINEAR_BITS is removed, and a non-power-of-two
MPI rank count now raises under the default linear routing instead of falling
back to the dense all-to-all.
Assisted-by: ClaudeCode:claude-opus-5
rank(M^G) == rank(M) ^ rank_shift(G) is exact under linear routing, and build_layer already derives rank_shift(G) once per gate for the PeerPlan -- which is to say the linear planes Scan.h evaluated for every emitted query were recomputing a per-generator constant. Router::dest_from_shift takes the rank bits from this rank's own slot XOR that shift, leaving only the partition index per term; splitmix has no such identity and falls back to dest() bit for bit. Two strength reductions in dest() ride along, both blocked only by parts_ being a runtime member. `q % S` becomes `q & (S - 1)` when S is a power of two, which every production layout is (S in 1,2,4,8,16); and at S == 1 the partition index is 0 for every term, so monomial_hash is not evaluated at all. Both are identities, not approximations. Per term at the production geometry the emit path was tzcnt; log2(R) x (kW loads + kW ands + kW xors + popcnt + shift/or); imul; splitmix mix64; 64-bit divq and is now shrx; xor; imul; splitmix mix64; and. The plane loop, its popcounts and the division are gone; the mix64 stays because the partition index still needs it (and goes too at S == 1). Ownership must not move by a term, so the identity is asserted rather than argued: a debug assert at the fast path against dest(), and a sweep in routing_tests over nine geometries and both modes, including S == 1, S = 3 and S = 14 so the mask path and the division path are both covered. dest_from_shift requires that the local operator hold only terms this rank owns -- the precondition mpi::PeerPlan already carries, since it sends every query for a gate to me ^ shift. The scan/find_rank agreement test was feeding one rank the whole operator, which no rank ever holds; it now distributes by find_rank and runs every rank. The six routers still agree on a total of 387 partners, and the split becomes all-encoded under linear routing (shift 1, 3, 7) against 212/175 to 343/44 under splitmix. Assisted-by: ClaudeCode:claude-opus-5
exchange_count_blocks_ posted its Isend/Irecv pairs and MPI_Waitall'd on them in the same breath, inside the partition-0 serial section between B1 and B2, while pack_send_ -- the only real per-partition work in the verb -- did not start until after B2. The count block is S*S ints, 1 KB at S=16: an eager message that needs no cooperation from the peer, so nothing about it justified blocking the packing behind it. Split into post_count_blocks_ / wait_count_blocks_, with the wait moved to where the counts are first genuinely read. That reader is fill_recv_col_ via block_sum_, not the send-side sizing: size_staging_send_ works off the rows each partition published before B1, and pack_send_ off that sizing, so both run with the round in flight. The recv-side sizing and the payload exchange follow the wait in the B3->B4 window, and the per-partition extraction of recv_counts / recv_displs moves past B4, which is the first point at which counts_recv_ exists. Still four syncs; no byte moves differently and no delivery order changes. The dense arm is untouched by construction -- MPI_Alltoall is blocking and cannot be split, so plan.dense() completes inside the post and leaves count_posted_ at zero, making the wait a no-op. Only the sparse arm splits. The count requests live in their own count_reqs_, never the payload's reqs_. The current ordering drains the count round before exchange_payload_ posts, so one vector would in fact be safe today; separate storage is what keeps Pairwise.h's resize-once-then-index rule from turning into a use-after- realloc if the wait is ever moved again. Three cases: the same peer-masked layout through the dense and the sparse arm, compared element for element on both against each other and against the tags, so a dropped or torn count block changes a length; the self-peer plan at shift 0, where the count round posts nothing at all and the wait is the no-op path; and a zero-count peer, whose payload legs are skipped while its count block still travels, followed by an all-silent round over the first's staging high-water bytes. Assisted-by: ClaudeCode:claude-opus-5
PR #296 made rank routing GF(2)-linear and converted the graph BUILD path, but replay still posted MPI_Ialltoallv over all R ranks -- so propagate(), which Hubbard calls 29 times per build_graph, paid a full collective to move one peer's worth of doubles. post_flat_alltoallv now counts the legs carrying a payload and, at or below num_ranks/4 of them, posts Irecv/Isend pairs over those legs instead. No plan and no count round are needed: derive_exchange_layout already hands both sides the same array, so what a rank sends a peer IS that peer's recv count and both ends drop the same legs on the same value. sparse_pairwise drives it unchanged, with a dense PeerPlan walking [0, R) and posting only the non-zero legs; the self slot stays a memcpy. Its request vector moves into the Ticket, which now drains it in wait() the way PendingAlltoallv does -- resized once and indexed, never push_back'ed, because MPI holds those pointers until the wait. The branch is RANK-LOCAL, and that is a precondition rather than a proof: a rank choosing the collective waits forever on ranks that chose point-to-point. The default routing (linear, d = log2 R) gives fanout 1, so every rank's row holds at most one active leg and no row can straddle the budget. splitmix routing (monoprop_ROUTING=splitmix, or an explicit d) does not, and a layer whose per-rank partner counts land near num_ranks/4 can split the branch -- documented on flat_exchange_prefers_pairwise, not fixed here. layer_exchange_participates is unchanged for the same reason: the symmetric layout does let every rank agree on whether IT transfers anything, but the collective arm is still reachable, so skipping the round at local total 0 would strand it. Assisted-by: ClaudeCode:claude-opus-5
The leg-count budget was a rank-LOCAL predicate over a rank-varying quantity, so two ranks could land on opposite sides of it: one enters MPI_Ialltoallv and waits forever on the other, which posted point-to-point. The floor at 1 made the default routing safe by accident (fanout 1 gives every row 0 or 1 legs) and left splitmix and small-d configurations able to hang. Numeric tuning cannot fix that, so the budget is gone. The transport now keys on `wire_bits`, the resolved linear-bit count when the routing gives fanout 1, derived once in Evolution.cpp from routing::linear_bits_for -- the same number check_routing_agreement allreduces at construction and throws on. That makes it rank-uniform by construction, and it is also the actual reason the legs are empty. Any other geometry passes 0 and keeps today's collective. Exchange.h learns no routing: it takes an int. Kind::Hybrid gets it too, which is the layout that matters -- 8 ranks/node x 16 partitions went through the dense collective with no plan at all. The wire plan cannot come from the call site: only partition 0 reaches MPI, and its own row may be the empty one while a sibling holds the rank's only traffic. So partition 0 derives it in the B1->B2 window, where the published recv rows give the first view wider than one partition, and an empty rank resolves to the self peer rather than to dense -- keeping the branch a function of the gate alone. Asserted lossless against the send rows. Only the wire is narrowed; the serial O(R*S^2) staging sweeps still walk every rank, which needs the per-generator shift the recorded graph does not carry. sparse_pairwise takes an `active_legs` upper bound, so a dense plan over a one-leg layout no longer sizes its request vector at 2R (64 KB per exchange at R=4096, one malloc/free each). The Kind::Mpi arm keeps the DENSE plan on purpose: it walks all R and posts the non-zero legs, so no derived shift can drop a block there. Assisted-by: ClaudeCode:claude-opus-5
Four breaks a clean three-way merge did not surface, because each side edited a
different line:
- `PeerPlan{.bits=}` in `derived_wire_plan_` and three `hybrid_comm_tests` cases,
written against the int dial the routing commit replaced with `.sparse`.
- `routing::linear_bits_for`, deleted with the dial but still the replay
transport's gate. Restored through `Router::bits_for`, which IS the private
constructor, so the resolution and the non-power-of-two throw cannot drift.
- `sparse_count`, dropped as the deleted fanout-2 case's only helper; the count
round's new cases had since become a second user.
- The fanout-2 sweep in `..._split_count_round_matches_the_dense_arm`: a sparse
plan is fanout 1 by construction, so `f > 1` is no longer expressible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Under GF(2)-linear routing a generator's queries all land on one peer rank, so the reachable flat slots are that rank's S partitions -- one contiguous run of the P=R*S world instead of all of it. SlotWindow names the run, WindowIndex is its re-based index (a distinct type: a flat slot used as one would otherwise stay in bounds and address the wrong peer), and WindowVec is a vector over the run whose only flat-slot door asserts membership. PeerPlan::window derives it in one expression per field; dense is its count == P value, not a second case. No caller yet. Assisted-by: ClaudeCode:claude-opus-5
Under linear routing a generator's queries all land on one peer rank, so the reachable destinations are that rank's S slots, not the P=R*S world. Every per-generator per-slot structure from the scan to the wire was still allocated and swept over all P: at R=128, S=16 that is length-2048 arrays with 16 live entries, built twice per generator for ~416 non-identity generators over 29 layers. The six FusedScanResult arrays, the engine's queries_r / src_idx_r / src_val_r / combined_qv_, the probe's goff / sender ids, the resolver's responses, and begin_alltoallv's counts / pack / prefix / unpack sweeps are now window-length. begin_alltoallv derives the window from the plan alone, so dense is the count == P value of the same expression rather than a second arm; a caller may still hand it a whole [P] array, which the window then masks. Re-basing is safe by construction rather than by review: WindowVec::at_slot is the only place a flat slot becomes an index, and it asserts membership, while operator[] takes a WindowIndex so a bare flat slot will not compile. Self is inside the window only when the rank shift is zero, which resolve_self_queries now branches on and asserts against an empty self stage. GraphSink::acc stays flat [P] -- build_layer_storage_unified is P-shaped -- so the sink turns the window index back into a slot. MPICompat could not be deferred: the scan's arrays are moved into the engine and thence onto the wire with no seam that does not cost a P-allocation to bridge. Assisted-by: ClaudeCode:claude-opus-5
The sizing that partition 0 runs between B1 and B2 still swept the whole P=R*S world while S-1 partitions parked at the barrier, even though every sweep that writes these tables and every sweep that reads them walks the same peer set. col_sum_ (twice per send sizing) and recv_col_ are now zeroed over the peer slots only, and the two displacement prefixes walk peers_ instead of all R -- peers_ is ascending in both arms, so the prefix takes the same value at every peer as the full one, a non-peer contributing zero. publish_recv_rows_ runs on every partition, not just 0: it now zeroes the row and sums only the peers' blocks, replacing R*S adds with R stores and one block's worth. The row stays fully written because derived_wire_plan_ reads all of it. The [R] count and displacement arrays are still zeroed in full: MPI_Alltoallv reads every entry on the dense arm, and the zeros are what make the narrowed prefix exact. Assisted-by: ClaudeCode:claude-opus-5
alltoallv's derive_wire_bits path had no test and no library caller. It exists because only partition 0 reaches MPI while its own row may be the empty one, so the peer set has to be read off every partition's published recv rows; reading partition 0's alone resolves to the self peer, whose legs are all zero, and every block is dropped with no hang to show for it. The case puts the rank's only traffic on partition S-1 with partition 0 sending and receiving nothing, and checks the payload arrives carrying its source's global id -- so it fails on a plan that names the wrong peer as well as on one that names none. Assisted-by: ClaudeCode:claude-opus-5



Implements a GF(2)-linear rank hash, in the same spirit as arXiv:2506.13241 but not the same construction — see Relation to prior work below.
What this does
main's MPI cost obeys#263 attacks the last factor. This attacks
(R−1), which is not physics — it is a consequence of oneline. The destination of a query was
monomial_hash(M ⊕ G) % R, andmonomial_hashis splitmix,whose full avalanche is exactly what makes the exchange dense. Every earlier attempt at a sparse
transport failed because the routing stayed dense: a rank pair is non-empty if any of its
S²partition pairs has data, so at 8.7% partition-level occupancy the chance a rank pair is empty is ~2e-8.
Make the rank-level hash GF(2)-linear instead:
A rank owns
M, so every query it emits for generatorGlands onmy_rank ⊕ Δ(G)— fanout 1.XOR is an involution, so that peer sends back on the same round. Nothing about the term set, the
volume moved, or the gate structure changes; only which rank a term lives on.
Routing is two-level, because the two levels have different costs: GF(2)-linear across MPI ranks,
where message count is the cost, and full-avalanche splitmix across partitions within a rank, where
fanout is free (shared memory) and only balance matters.
monoprop_ROUTINGselects the arm:linear(default, fanout 1) orsplitmix(the previousbehaviour, bit-identical to the base). Power-of-two
Ris required and a non-power-of-twoRnow throws
UnroutableGeometryrather than silently falling back — a silent fallback was a way tomeasure the wrong arm without noticing.
R == 1takes no rank bit and so is the dense case byconstruction, which is why the collective path stays.
Commits
Routing (
22ad7049and below):route through one Routerrouting::Routerowns the term→slot map, replacing two inline copies ofhash % P.mpi::geometry()is new:size()cannot tell an inter-rank message from an inter-partition one.skip the exchange for identity generatorsexchange point-to-point over the peers routing can reachmpi::PeerPlanthreads the fanout toHybridComm; everyO(R·S²)sweep that runs serially on partition 0 shrinks toO(f·S²).check_routing_agreement()allreduces the configuration once at construction, because a rank that missed the env var would hang, not answer differently.route GF(2)-linear by defaultsplitmixis the explicit opt-out.Follow-through (
a610f048..2f8d3dc2, new):collapse the linear-bit dial to a booleandwas an integer dial where a boolean does: the shipped configuration isd = log2 Randd = 0is the control, and the intermediate values existed only to be tested.PeerPlan::bitsbecomesbool sparse;monoprop_ROUTE_LINEAR_BITS,hi_mask_, thehiterm and a 64-bit division on a runtime divisor all go. −139 lines, and one whole dimension out of the test matrix.route the emit path from the generator's shiftdest(M ⊕ G)was recomputing a per-generator constant per emitted term.rank(M ⊕ G) = my_rank ⊕ Δ(G)is an identity, andΔ(G)is already computed once per generator for thePeerPlan. Removes the parity-plane fold (28 loads + 28 AND + 28 XOR + 7 popcount per query at production widths) from the hot loop.pack the send side under the count roundS²ints — eager, and fully overlappable with packing. Split post/wait and pack between them.replay the layer exchange point-to-point+gate the pairwise replay on routingbegin_flat_exchangeposted a denseIalltoallvwith noPeerPlan, so the graph-replay path got zero benefit. Now gated on a rank-uniformwire_bitsderived from the routing configuration — the obvious gate (each rank counts its own non-zero legs) is rank-varying and deadlocks when the decision straddles.name the slot window a peer plan can reach+size the query path to the peer window+narrow the staging sweepsSof theP = R·Sflat slots can receive a query, yet the bookkeeping was sized and swept overP— ~18 sweeps of length 2048 per generator per pass at R=128, S=16.SlotWindow{base, count}is contiguous (that is what the boolean buys), and the dense case is thecount == Pvalue of the same path, not a second implementation.Measured
Balance — settled by measurement, not by the dial
One instrumented run of the production point (60 sites, cutoff 10,
lower_atol=2.6e-06→1,569,152,761 terms) recorded a joint histogram over every owned term at ten layers, so every
(R, S)is a marginal of one table andd=0is its own control:d=0Imbalance is confined to layers 1–3, which hold ≤21k of 1.57e9 terms (0.0013%). This is a property of
the map, not of the transport, so it is unchanged by the follow-through commits.
Campaign A — the full branch against #263's head
22ad7049→2f8d3dc2, two binaries, md5-gated. Deucalion x86, layout 8×16, 10 reps per cellinterleaved inside one allocation with the arm order flipped per rep, paired per-rep ratios then
median. All 15 timing tests survive Holm at family size 15 (p = 0.00195 or 0.0215 against a
0.05/15 = 0.0033 floor).
propagate[hubbard]build_graph[pauli]propagate[pauli]energy[pauli]gradient[pauli]The gain grows with R in every row, which is the signature the work removed is proportional to
P.Campaign B — the lever itself, re-baselined on the current tip
monoprop_ROUTING=splitmix→linearon one binary (633df79e, a bench-only tree), so the armsdiffer in nothing but the routing arm, read back per-rep from the in-process config. The harness
refuses same-md5 arms and printed its usual banner; the readback (
splitmixon all 20mainfiles,linearon all 20portfiles) is what makes the cells valid. All 15 tests survive Holm.propagate[hubbard]build_graph[pauli]propagate[pauli]energy[pauli]gradient[pauli]This is a per-operation figure, not the whole-run wall the 0.32× headline below measures — the two
are not directly comparable. The engine's byte ledger also shows the graph shrinking 2.06× at N=16
(14.19 → 6.87 GiB) — expected, since the retained graph is quadratic in the flat world and fanout 1
collapses the peer set — but
/usr/bin/time -vdid not run in these cells, so that is a ledgernumber (capacity, not resident bytes) and not a peak-RSS claim.
Campaign C — attribution
3e6ab805→2f8d3dc2isolates the windowing commits. Only 4 of 12 tests survive Holm at familysize 12, so read the rest as unresolved:
propagate[pauli]build_graph[pauli]energy[pauli]gradient[pauli]Subtracting C from A:
propagate's gain is the windowing, andenergy/gradient's 4.96×/3.05×at N=16 is not — it comes from the commits below
3e6ab805, of which the point-to-point graphreplay is the only one touching that path. This also explains the apparent
energy1.04× in C: thereplay commit had already taken that cell from 4589 ms to 883 ms, leaving the windowing nothing to win.
Strong ladder (Leonardo DCGP, measured at
27da5fad— routing only, no follow-through)3 arms × 2 layouts × 5 rungs × 2 reps, interleaved inside one allocation per rung, ITAC on every cell:
mainmainmsgs/rank/layerStrong efficiency 27% → 79%; on 4×28, 31% → 82%. Messages per rank per layer are flat in R
(1,274 → 1,397, +10% across a 16× increase in R) against
main's +18×, i.e. 260× fewer at R=128.The residual +10% is the self-peer effect (shift 0 ⇒ the peer is me ⇒ memcpy, no message), which
saturates.
main's reversal is gone: 4×28 turns over at n8→n16 (45.6 → 48.0 s) while this keepsfalling (28.2 → 17.2 s). Term count was 1,569,152,761 on every arm at every flat world.
The same lever applies to the Pauli path with no code change (a Pauli in symplectic form is a vector
in F₂^2n and multiplication is XOR of those vectors up to phase). On the 127-qubit heavy-hex kicked
Ising, fanout 1 gives 1.039 max/mean at R=128 against splitmix's 1.052. The identity-generator skip
does not transfer: those were an artifact of Hubbard's interaction gates.
Correctness
Routing changes which rank owns a term, so cross-rank miss indices mint in a different order and
reductions reassociate. The bar is term count exact, expectation value within a few ULP, with
bit-identity required on the
splitmixarm.splitmixis bit-identical to the base at every(world, S)pair tested — same term count andthe same 64 bits of expectation value.
ctestat2f8d3dc2, and 593/593 MPI tests at flat worlds 2, 32 (two layouts) and256.
routing_testsis 17 cases,hybrid_comm_tests20, plus a newflat_exchange_tests(6) forthe point-to-point replay — including the shift identity
route(M⊕G) == route(M) ⊕ Δ(G), fanoutexactly 1,
window()agreeing withpeer()/contains()in both the sparse and dense cases, thenon-power-of-two throw, and the derived wire plan over an empty partition-0 row.
Collapsing
dremoves tests by construction, so the surviving cases were checked to still reachboth routers rather than assumed to.
Scope
Routing/transport only — no gate fusion; gates stay atomic and there is still exactly one exchange
per generator per pass. The dense path stays, because
R == 1routes through it.Net +1,694 / −664 over the ten new commits and +3,231 / −430 for the branch, of which
HybridComm.his +331 / −127. The earlier claim of "~120 lines" was measured before thefollow-through; the windowing replaced the parallel
O(P)indexing but did not shrink the file, andthe only net deletion in the stack is the
dcollapse at −139. What actually got simpler is theper-exchange serial work (
O(R·S²)→O(f·S²)), the per-generator bookkeeping (O(P)→O(S)), oneRouterwhere routing was two inline expressions, and a boolean where there was a four-valued dial.Composing with #263 (measured at
27da5fad, before the follow-through)4 arms × 8×14 × {4, 8, 16} nodes × 2 reps, interleaved in one allocation per rung:
main* one cold rep (42.0 / 67.4 s); the #263-vs-
mainread at n8 is not usable.The two levers compose in the mechanism: the stacked arm carries this branch's message count
unchanged and roughly half its wire volume at every rung. Routing moves messages, #263 moves
bytes, and neither undoes the other. The wall gain is sub-additive and shrinks with R, which is what
fanout 1 implies: per-peer cost is multiplied by 1 rather than by (R−1).
At
27da5fadthe residual was 90–91%MPI_Waitallat a thread spread of 1.02–1.04 — latencyeveryone pays alike — which is what the follow-through commits attack.
Two questions this raises, answered
Should the splitmix arm be removed? No. It is the bit-identity control for a change that moves
term ownership, it is the opt-out arm every ratio in Campaign B is measured against, and it is the
recovery path when the generator shifts span only ρ < d dimensions and
2^d − 2^ρranks would sitempty (which
report_routing_coverage_warns about). Removing it would also not remove splitmix:SplitmixHashis the hash forOperatorIndexandMonomialMapand is untouched by routing.Should the linear hash be used elsewhere — e.g. for
OperatorIndex? No, and this is the one placeit must not go.
Tableis open-addressing with linear probing. A hash of the form⊕_{i∈supp} v_iis exactly 2-independent, not 3-independent: if
M₃ = M₁ ⊕ M₂thenh(M₃)is determined by theother two. Pătraşcu–Thorup showed 2-independence is insufficient for linear probing, and monoprop's
key set is precisely XOR-generated — the scan inserts
M ⊕ Gfor a fixed small generator set — sothe dependent triples are the workload, not a corner case. Keep
fold_hash/spreadas they are. (Forthe same reason, an XOR set checksum cancels duplicates and is not a safe agreement digest.)
One free variable is left unused:
linear_basisis drawn at random, but the generator list isreplicated and known at construction, so the basis could be fitted so the shifts provably span
F₂^d — turning the coverage warning into a guarantee. Tracked as a follow-up, not done here.
Relation to prior work
The PR description points at arXiv:2506.13241 (Broers, Sun &
Yunoki, Scalable Simulation of Quantum Many-Body Dynamics with Or-Represented Quantum Algebra;
Phys. Rev. Applied 26, 024046). Worth being precise, because it is not the same hash, and the
difference is the whole result.
ORQA's distribution map (their eq. 12) is
— the 2n-bit multi-index cut into k-bit blocks, each read as an integer, summed over ℤ_N. That is
additive over the integers mod N. The gate acts by XOR, and addition mod N carries, so the two
operations do not commute. Their eq. 13 makes the consequence explicit:
The signs depend on the current bits of
I, so the destination is not determined byJalone: theybound it at
2^(2|J|+1)processes (17 in their setting). That is a large reduction in fanout, andit is why the paper needs a stochastic perturbation (their eq. 14) to recover load balance afterwards.
This PR takes the hash linear over the same group the gate acts by. Monomials under a gate form
(F₂^{2n}, ⊕), andh(M) = ⊕_{i ∈ supp(M)} v_iis a homomorphism of it, soh(M ⊕ G) = h(M) ⊕ h(G)is an identity, not a bound. Three things follow that eq. 13 does not give:
|supp G|and ofR— not2^(2|J|+1), and not a functionof the gate weight at all.
each side derives with no communication. The response retraces the query for free.
h⁻¹(r)are cosets ofker h, all of size2^(2n-d), so auniformly drawn monomial is balanced by construction. No perturbation term is needed — measured rank
max/mean 1.001 at R=128 from layer 5 on.
Two further differences: our routing is two-level where ORQA is flat; and they move updates with
one-sided RMA (
MPI_Put) rather than the Isend/Irecv pairs here. Two things they have that we donot, both tracked as follow-ups: the RMA transport, and scale — they report strong scaling to 2^17
processes on Fugaku against the R=128 measured above.
🤖 Generated with Claude Code