perf(graph): ⚡ make per-rank graph memory fall as the world grows - #238
Draft
diagonal-hamiltonian wants to merge 6 commits into
Draft
perf(graph): ⚡ make per-rank graph memory fall as the world grows#238diagonal-hamiltonian wants to merge 6 commits into
diagonal-hamiltonian wants to merge 6 commits into
Conversation
Inside the engine `rank_count` is `mpi::size(comm)`, and on a partitioned run the comm is Hybrid, whose size() is the FLAT world P = ranks x partitions. Every per-rank array in a layer is therefore P long, each MPI rank holds one per partition, and the graph retains one per layer -- so a per-slot record costs O(P^2) across the job. Measured on pauli c14 at 91,273,861 terms, the graph goes 3.81 GB at P=16 to 61.94 GB at P=512 while the operator stays flat near 6.5 GB. Fitting graph = a + b*P^2 on each adjacent pair gives b = 235,709 / 223,891 / 220,345 B/P^2 -- three independent pairs agreeing to 7%, the upper two to 1.6%. CrossRankPartnerRange carried an offset and a count for each of B and D. They were always equal: GraphSink::finalize resizes both vectors from the same P + Q, so the counts match per slot and their prefix sums match with them. B and D are the two endpoints of the same rotation set. Keeping one pair drops the record from 32 to 16 bytes with no padding either way, pinned by a static_assert. The equality is now a checked precondition rather than a comment. Unchecked, a skew would not throw: cross_rank_sin_recv_index would mis-derive Q and read a wrong-but-valid endpoint, and Evolution's self-slot snapshot would run off the end of a B-sized buffer. Three consumers already bet on it silently. Also adds graph_memory_breakdown(). The operator partitions and the graph does not, and one total could not say which. It splits the fields, reports the slot occupancy that decides whether a sparse layout would pay, and counts two things total_bytes() never has: the resolve_recv transpose cache and the lazily retained derivative layout. Those stay as diagnostics rather than joining total_bytes, so graph_memory_bytes() means the same thing before and after and an A/B against an older build still compares one quantity. Assisted-by: ClaudeCode:claude-opus-5
A graph layer retained two `int[P]` arrays for the evolution exchange and, after the first gradient, two more for the derivative round. P is the FLAT WORLD SIZE (ranks x partitions), each MPI process holds one set per partition, and the graph holds one per layer -- so those arrays cost O(P^2) across a job for content that is a prefix sum of what the slot records already say. `counts[r]` is `(r == my_rank ? 0 : cross_rank.sin_send_size(r))` and `displs` is its running prefix. Both are now derived into the per-thread scratch that already owns the send and recv buffers, for the transfer being posted. That takes the retained slot-proportional footprint from 32 B/slot to 16 B/slot, and from 48 to 16 once a gradient has run: only the slot records survive. The derivative round needs no collective of its own. Its counts are the evolution counts at a hardcoded scale of 2, applied identically on every rank, and displacements are prefix sums of counts -- so scaling commutes with the transpose and the derivative recv layout is 2x the evolution recv layout. One `resolve_recv` per layer per evaluation now serves both rounds. The transpose cache stays retained (8 B/slot). It is the one piece that cannot be derived locally, and dropping it would cost an MPI_Alltoall per layer per evaluation. ## The predicate this needed first Sharing scratch across layers is only sound once `resolve_recv` can tell one send pattern from another. Its predicate was `comm_size == comm_size && counts.size() == n` -- effectively "have we ever resolved anything for a communicator this size", which is true for every layer after the first. Correct only while each cache belonged to the one layout that produced it; silently wrong the moment two patterns share a cache. The fix is NOT a checksum of the counts. A miss runs `alltoall_counts`, a collective, so two ranks disagreeing about validity is a distributed HANG rather than a wrong answer, and any rank-local key can collide on one rank and not on another. The cache now carries a `generation` assigned per LayerCore at build time. Build order is identical on every rank, so every rank misses on a layer's first resolve and hits afterwards -- the DECISION is uniform even though the id values are not. A `LayerCore` copy made by `set_parameter_mapping` now inherits that cache rather than dropping it. Relabelling changes which parameter drives the rotation, never which endpoints cross to which slot, so the cached transpose is still correct; clearing it would have cost one collective per layer (5,420 at the anchor) to rebuild an identical answer. ## Hoisting the slot resolution Resolving a world slot is an index into the P-sized `ranges` array, and the per-element accessors were doing it per ENDPOINT -- three times per term on the recv side, four times per rotation pair in the self-slot gradient loop. `cross_rank_slot()` resolves it once and the element accessors take that view, so walking a slot's endpoints pays for the P-sized lookup once. No behaviour change, and it is the precondition for ever storing slots sparsely, where resolving one stops being an array index. ## Notes for review `build_layer_exchange_layout` now has no production caller and is kept deliberately, as the reference the derivation is tested against: the new equivalence case asserts derived == built elementwise for every my_rank and both scales. Checking a derivation against an independent construction beats checking it against literals. `exchange_layout_bytes` and `derivative_layout_bytes` now report 0 rather than being removed from the breakdown, so an A/B against a build that did retain them shows the drop instead of losing the row. Build-time derivation is retained purely as eager validation: an int overflow has to throw from build_graph, not from inside the exchange where peers are already blocked in the count round.
… transpose The previous commit stopped retaining the send layout but kept a RecvLayoutCache per layer -- 8 B per world slot, 10.59 GiB at P=512 -- on the grounds that a transpose is the one thing a rank cannot work out alone. That was wrong: this transpose carries data both sides already have. Layer build gives slot r on rank m the queries r sent m, followed by the queries m sent r; rank r's slot for m holds those two swapped. The counts are therefore equal, and displacements are prefix sums of counts, so the recv layout IS the send layout. MPI reads recvcounts/recvdispls rather than writing them, so the same two arrays now serve both sides of the alltoallv. What goes with the cache: the alltoall_counts on its miss path, and the rank-uniform `exchange_generation` that existed only to keep that miss rank uniform. The hazard the previous commit documented so carefully -- a split reuse decision hanging the job -- is removed rather than managed, because there is no longer a collective on any cache-miss path. sizeof(LayerCore) 248 -> 168 B. Symmetry is an invariant of the routing, not of this file, so it is checked where it can actually break: MONOPROP_CHECK_EXCHANGE_SYMMETRY=1 re-adds the alltoall and throws naming the slot and both counts. Unguarded, a future routing change that broke it would surface as a peer blocked in MPI_Alltoallv against a size nobody sends -- a hang with no line number. Evidence: a probe comparing derived counts against a real alltoall on every resolve saw 0 mismatches in 550M slot comparisons at world 32 and 256, over the full MPI suite and a pauli c12 energy+gradient run. Gate 1826413: 214 ctest serial, and 625 Python tests on each of four geometries TWICE -- once on the production path, once with the assertion live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Docs preview: https://pr-238.monoprop-docs.pages.dev |
… breakdown
The graph does not partition. Its per-layer arrays are indexed by rank, and on a
partitioned run that index space is the FLAT world P = ranks x partitions, so they
grow with a P the MPI rank count never shows. `graph_memory_bytes` is a single
scalar and cannot say how much of it is that.
Split the two growth laws so a measurement can separate them:
d_slot_record_bytes the slice of cross_rank_bytes that is one record per
world slot, carried whether or not the slot has traffic
d_slot_records P per layer core; / d_layer_cores recovers P
d_occupied_slots slots carrying any traffic; / d_slot_records is occupancy
d_cross_rank_endpoints the traffic itself, and the ceiling on d_occupied_slots
The last one is the point of the exercise. An occupied slot holds at least one
endpoint, so endpoints bound occupied slots from above -- and endpoints do not
depend on P at all. Together the two say how much of the slot array is information
and how much is reserved-and-empty.
All of them sit OUTSIDE total_bytes(): each is a count or a slice of a field
already summed there, so adding them would double-count. Behaviour is unchanged;
this only reports.
The graph's last array indexed by the flat world size P. Each layer held one record
per POSSIBLE partner, so with P participants each holding a P-length array the job
carried L x P-squared records whether or not anything was ever sent between them. At
L=5,420 and P=512 that is 22.7 GB of slot records against 3.7 GB of actual traffic --
6.1 bytes of addressing per byte of data.
Store the occupied slots instead, ascending by slot id. That is bounded by something
with no P in it: an occupied slot holds at least one endpoint, so
occupied_slots <= total cross-rank endpoints
and the endpoint count is a property of the operator and the circuit, measured flat
in P to 0.096% across a 4x change in it. The quadratic is not merely smaller, it is
capped by the traffic it describes.
The record is 12 B, and two things are absent from it by design:
* the D range, already dropped -- B and D are one endpoint set in two orders;
* the B/D offset, which is the running prefix over stored entries in ascending
order. Empty slots contributed zero to the dense prefix, so the derived value
equals the stored one exactly. A size_t offset would have padded the record to
24 B, so deriving it is worth 2x on its own.
Access changes shape rather than getting slower. Every partner sweep in production
was already `for r in 0..P { if empty continue }` -- walking the whole world to find
the part of it with anything in it -- and becomes for_each_occupied_slot, which
carries the derived offset and never visits an empty slot. The self slot keeps O(1)
through a position resolved once at build: it is read per rotation pair in the
innermost gradient loop and cannot afford a search.
Converted: the four packing loops and the snapshot pass in Evolution.cpp, both totals
in MPGraphLayers.h, endpoint marking in PareGraph.cpp, and the layer export in
MonomialPropagator.inl (still dense in its output, since callers index it by rank,
but now scattered into rather than interrogated for).
graph_encoding_slot_record_bytes_track_the_world_not_the_traffic asserted precisely
the property being removed, so it is inverted rather than repaired: quadrupling the
world must now leave the record array byte-identical.
213/213 serial.
…slots Only needed once the two halves coexist, which is why neither branch carries it. #237 derives counts[r] by asking cross_rank.sin_send_size(r) for every r < P. That was O(1) against the dense range array it was written for. Under the sparse storage sin_send_size is a binary search over the occupied slots, so the same loop became O(P log occupied) -- per layer, per exchange -- to fill an array that is ~82% zeros at P=512 by construction, and whose zero fraction only grows with P. So fill it the other way round: zero the counts, walk the slots that actually carry traffic via for_each_occupied_slot, and scatter. O(P) + O(occupied) with no search at all. The displacement prefix stays dense because MPI_Alltoallv wants an entry per rank and an empty slot still needs a valid, repeated displacement. assign() rather than resize() for the counts: `out` is scratch reused across layers, and a slot carrying nothing this layer must read zero rather than inherit the last layer's count. graph_encoding_derived_layout_reuses_its_scratch pins exactly that. The self slot is skipped by slot id, not by the old r == my_rank test on the loop variable: under sparse storage this rank's own slot is simply one of the stored entries, and it may or may not be present at all. Equivalence is asserted elementwise against build_layer_exchange_layout, for every my_rank and both scales, by graph_encoding_derived_layout_matches_the_layout_it_replaces.
diagonal-hamiltonian
force-pushed
the
perf/sparse-world-slots
branch
from
August 16, 2026 13:41
d4a0dac to
9e22925
Compare
diagonal-hamiltonian
changed the base branch from
main
to
perf/graph-world-size
August 16, 2026 13:41
diagonal-hamiltonian
added a commit
that referenced
this pull request
Aug 16, 2026
…t main #238 is now stacked on #237, so its before/after is against its base. That is not a bookkeeping detail -- it changes the size of the result. On main the dense record is 32 B and the retained exchange layout dominates everything; on #237 the record is 16 B and the exchange layout is gone, which leaves the slot array at ~84% of the whole remaining graph (22.73 of 27.15 GB at P=512). The same commit is a far bigger lever in its new position than the 3.17x it showed against main. The collator asserts an accounting identity over MEASURED fields rather than hardcoded record widths: total drop == (slot record bytes freed) - (LayerCore growth) so it cannot silently drift when a struct gains a member -- which it just did, since world_size/self_pos/self_offset are what replace the array length once it is sparse. The 16 B and 12 B widths are still checked, but separately, as the model. The load-bearing assertion is new and is the whole point of the stack: per-rank graph memory must fall MONOTONICALLY across P=128/256/512. #238 alone left it improving to P=256 and then reversing (37.88 -> 30.95 -> 36.38 MiB), because the retained exchange layout is 8*L*P per rank and grows faster than the payload falls. If the ladder is not monotonic the collator exits non-zero, however good the ratios look.
diagonal-hamiltonian
changed the base branch from
perf/graph-world-size
to
main
August 16, 2026 15:55
diagonal-hamiltonian
added a commit
that referenced
this pull request
Aug 16, 2026
#237 and #238 are one PR and one branch off main now, so the two worktrees that held the separate arms are gone: mp-gws (perf/graph-world-size) and mp-slotsbase (main + instrument). The arms are not lost, and that is the point of the change -- an arm's identity was never the directory. Each survives as a tag, `arm/gws-only` and `arm/main-instrumented`, and the scripts now say how to recreate a worktree from it rather than naming a path that no longer resolves. build-base.sh does the recreation itself. symcheck.sh runs against the consolidated worktree instead; the probe applies to the same code either way, since the branch it used to target is now the first three commits of this one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 AI text below 🤖
Supersedes and absorbs #237, which is closed. Six commits, one branch off
main.The defect
Inside the engine
rank_countismpi::size(comm). On a partitioned run the comm isKind::Hybrid,whose
size()returns the flat worldP = mpi_ranks × partitions(HybridComm.h:81), not the MPIrank count. Every "per-rank" array in a graph layer is therefore
Plong, each MPI process holds one perpartition, and the graph retains one per layer — so per-slot state costs O(L·P²) across the job, with
L = 5,420retained layers.Measured on the
paulikicked-Ising anchor,main@6abd839, 91,273,861 terms and an identicalconfig on every rung — one problem measured four times, not four problems:
The operator is flat — it partitions perfectly. The graph is not. At P=512 the quadratic term is 93% of
the graph. It tracks
P, not the rank count: six geometries collapse to two values (1×16and8×2both give 366,649,352 B;
1×128,8×16,2×64,4×32all give 2,769,389,680 B). A single MPI rankat 1×128 pays 7.6× what the same single rank pays at 1×16.
Why this needs six commits and not three
There were two independent
P²terms, and removing either one alone leaves the other to take over.The first three commits remove the retained exchange layout —
counts/displsper layer per core,plus the
recv_cacheand the lazily-retained derivative layout. The last three make the slot recordsthemselves sparse, storing only the slots that carry traffic.
Per-rank graph memory — job total ÷ P, which is the number that matters, because adding nodes is supposed
to reduce per-node memory. Same
paulic14 workload, 8×16 geometry fixed, only the node count moving:mainmaingets steadily worse — past P=256 adding nodes costs more memory per node than it saves. Thefirst three commits alone still reverse at P=512: they remove the retained layout, and the dense slot
array they leave behind is the next
P²term and takes over immediately. Only with all six does per-rankmemory fall monotonically, which the collator asserts rather than reports.
The payload — the actual traffic — halved perfectly on every doubling the whole time (27.68 → 13.84 →
6.93 MiB per rank). It was only ever the addressing metadata that anti-scaled. At P=512:
mainWhy the sparse half is a bound, not a smaller constant
Nothing in the slot array stores a destination rank — the array index is the routing information
(
owner = splitmix(monomial) % P). So the dense form reserves a record per possible partner while onlyoccupied slots hold anything.
Every occupied slot holds at least one endpoint, so
occupied_slots ≤ total cross-rank endpoints, andthat ceiling is a property of the operator rather than of the decomposition. Measured, 8×16 fixed, only
the node count moving:
Identical to the digit. This converts an unbounded
P²term into one with a P-invariant ceiling.Observed occupancy is 27.75% / 23.98% / 17.54% at those three points — occupied slots grow as ~
P^1.55,with the exponent itself decaying. The ceiling is a proof; the exponent is a measurement, and
saturation is not demonstrated at P ≤ 512 — at P=512 occupied is still only 0.277 of the ceiling.
Memory
Two A/B waves, against two different baselines. They are reported separately and must not be composed
— two independently measured ratios cannot be multiplied into a third.
Wave 1 — the first three commits against
origin/mainPredicted with nothing fitted, and exact to the byte at all five distinct cells across a 4× range in
Pand a 3.1× range in term count:
saved = 24 B × slots + 168 B × layer_cores. The second, linear term iswhy the per-slot saving looks inconsistent (34.50 B/slot at P=16 against 25.31 B/slot at P=128) — it is
a larger share of the total when
Pis small, not noise.Wave 2 — the last three commits against the first three
End to end at P=512 that is 57.686 → 6.963 GiB, 8.3× — the one cell measured directly against
mainon both waves, reproduced to the digit across two independent runs.
The wave-2 saving was asserted before the cells ran, as an identity over measured fields rather than
hardcoded widths, so it cannot drift when a struct gains a member:
It held exactly, to the byte, at all five cells.
LayerCore growthis the honest half:world_size,self_posandself_offsetmust now be stored precisely because the array length no longer encodesP.That is 24 B per layer-core — a P-linear cost paid to remove a P-quadratic one, and at P=512 it is
0.062 GiB against 18.387 GiB freed.
Also asserted:
occupiedis identical on both arms at every cell. Occupancy is a property of thetraffic; if the format moved it, the layout would be changing what is sent rather than how it is
addressed.
What the ledger cannot show
The derivative exchange layout was a diagnostic outside
total_bytes(), so its 16 B/slot never appearedin
graphand its removal cannot appear there either. At P=512 that is 31.76 GiB of uncounted retainedstate on
mainagainst 0 — none of which the tables above can show.d_recv_cache_byteswas measuredat 11,366,563,840 B (exactly 8 B/slot) after the second commit and 0 after the third. The
mainfigure is computed from the struct layout because
mainhas no such binding, so treat that one as anestimate.
It corroborates independently:
gradientpeak transient memory on the worst rank falls 0.88 → 0.04 GiBat P=512, and a per-rank derivative layout at 16 B/slot works out to ~0.66 GiB. Same order, from a metric
that knows nothing about the ledger.
Time
*marks 6/6 paired reps agreeing, which clears a sign test at p=0.031. 5/6 is p=0.109 and does notclear, so it is reported but never claimed.
Wave 1 — first three commits vs
maingradientis the operation this moves and the win grows withP— the derivative round no longerresolves its own transpose. Peak
gradientdmemon the worst rank improves 2.28× at P=128, 6.83× atP=256, 20.53× at P=512.
The honest negative: at P=16 this is a small loss —
energyis 1.04× slower on both geometries at6/6. Deriving counts is a fixed cost paid per exchange while the saving grows with
P, so a small worldpays the cost without earning the benefit.
gradientat P=16 is unresolved: 1.01× slower on 1×16 and1.02× faster on 8×2, each at 6/6, in opposite directions — the effect is per-geometry, not per-
P.Wave 2 — last three commits vs the first three
Below 1.00 is faster. Nothing regressed at any cell — no operation anywhere in the wave was flagged
slower.
c12 1×16is in the wave precisely because it is where sparsity could lose: 31.5% occupancy, theleast saving, the most exposed to the branchier access pattern. It came back flat on every operation.
build_graphpeak RSS (dmem), which is where the graph actually lands: 0.92× at P=128, 1.32× atP=256, 2.13× at P=512.
What is in each commit
1d62773CrossRankPartnerRange32 → 16 B5f33a71cff597e020e3a8bbfa9189e22925cff597eis the one that needed an argument. Commit 2 kept aRecvLayoutCacheon the grounds that atranspose is the one thing a rank cannot work out alone. That was wrong, and reading the layer-build sink
says why: slot
ron rankmholds the queriesrsentmfollowed by the queriesmsentr; rankr's slot formholds those two swapped. Counts are therefore equal and displacements are prefix sumsof counts, so the recv layout is the send layout.
MPI_Alltoallvreadsrecvcounts/recvdisplsrather than writing them, so two arrays now serve both sides. Deleted with it:
resolve_recv,RecvLayoutCache,RecvLayout.h, the per-layeralltoall_countson the miss path, andexchange_generation.Symmetry is an invariant of the routing, not of that file, so it is checked where it can actually
break:
MONOPROP_CHECK_EXCHANGE_SYMMETRY=1re-adds the alltoall and throws naming the slot and bothcounts. Unguarded, a routing change that broke it would surface as a peer blocked in
MPI_Alltoallvagainst a size nobody sends — a hang with no line number. It is off by default because on, it costs
exactly the collective the commit removes. 550M comparisons across the gate geometries, 0 mismatches.
9e22925exists only because the two halves are combined. Commit 2 derivescounts[r]by askingcross_rank.sin_send_size(r)for everyr < P. That was O(1) against the dense array it was writtenfor; under sparse storage it is a binary search, so the same loop would have become O(P·log occupied) per
layer per exchange — filling an array that is ~82% zeros at P=512 by construction.
9e22925inverts it:zero the counts, walk the occupied slots, scatter. Equivalence is asserted elementwise against
build_layer_exchange_layoutfor everymy_rankand both scales.Notes for review
order, and empty slots contributed zero to the dense prefix, so the derived value equals the old one
exactly. Storing a
size_toffset would pad the record from 12 B to 24 B. This is safe only becausethe single-endpoint accessors have no production callers after commit 2's hoist; the self slot, which
is read in the innermost gradient loop, keeps O(1) access via
self_pos.build_layer_exchange_layoutnow has no production caller and is kept deliberately as thedifferential oracle for the derived path. The header says so, so a later reader does not delete it as
dead code.
thread_localderivation scratch is sound only if at most one exchange is in flight perthread, which
MPI_Ialltoallvrequires anyway — send counts must stay valid until the wait, andHybridComm/ShmCommpublish the raw pointer for peer partitions to read across barriers. Theinvariant is not new;
send_bufferhas always required it.perf/sparse-hot-path, which is refactoringMPICompat.hinto leasedalltoallvbuffer sets. Both answer "who owns the buffers for an exchange". Whoever merges second should move the
derivation inside the lease. Note commit 3 deletes
resolve_recvoutright.Corrections to what earlier versions of this work claimed
shrink. Wrong on the ranking — occupancy bounds what sparsifying reclaims as a fraction of
itself, which says nothing about its size beside the other levers. In bytes per slot it saves ~12 B,
less than the 16 B the record shrink shipped. It is still load-bearing, but for the scaling reason
above, not the size one.
recv_cachewas called "the one piece that genuinely cannot be derived — it takes a collective".Wrong, and shipped as commit 3. Asserted from the general fact that a transpose usually needs a
collective, without checking whether this particular one carries data both sides already hold.
P²term that was merely smaller. It is smaller,but it is also dominant once the layout is gone — which is why per-rank memory still reverses at
P=512 without commits 4–6.
Verification
ctest -L serial: 217/217 — more than either half alone, since both sides' unique tests survive.Sparsity's real failure mode is the derived prefix reading a neighbour's phases, which serial cannot
see.
_core.somd5, recorded per rep — not by__version__. An editableinstall's stamp is written at install time and is not rewritten by a later rebuild, so both arms can
advertise one version while running different binaries.
What this does not fix
The
L = 5,420retained-layer factor multiplies every term above. Removing it needs checkpointing thegraph and rebuilding forward on the gradient's reverse pass — a different change, trading memory for
time. Also out of scope:
layers_grows by doubling with noreserve(MPGraph.h:58), leaving ~34%dead capacity, ~0.08 GB at P=512 — real but tiny and unrelated to
P.These results are not reproducible from this diff. The benchmark harness, the campaign definitions
and the collator that asserts the identities above live outside this PR, which carries library code only.
Measured on Deucalion (128-core EPYC znver2, 242 GiB/node) across two waves plus gate jobs on up to 4
nodes; 6 paired reps per cell, one allocation per cell with arm order flipped per rep, ratios taken
per-rep then medianed.