perf(evolution): ⚡ stop retaining the P-sized exchange state per layer - #237
Draft
diagonal-hamiltonian wants to merge 3 commits into
Draft
perf(evolution): ⚡ stop retaining the P-sized exchange state per layer#237diagonal-hamiltonian wants to merge 3 commits into
diagonal-hamiltonian wants to merge 3 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
|
Docs preview: https://pr-237.monoprop-docs.pages.dev |
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>
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 🤖
The defect
Inside the engine
rank_countismpi::size(comm). On a partitioned run the comm isKind::Hybrid, whosesize()returns the flat worldP = mpi_ranks x partitions(
HybridComm.h:81), not the MPI rank count. So every "per-rank" array in a graph layer isPlong, each MPI process holds one per partition, and the graph retains one per layer.Per-slot state therefore costs O(P²) across the job.
Measured on the
paulikicked-Ising anchor,main@6abd839, with 91,273,861 terms andan identical config 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 MPI rank count
This is the observation that could have refuted the diagnosis. Holding
Pfixed and moving therank count across its whole range:
total_bytesSix geometries, two values. A single MPI rank at 1x128 pays 7.6x what the same single rank
pays at 1x16. The rank-count reading predicts that cell is cheap; it is the expensive one.
The layer count falls out of the instrument independently: 86,720 layer-cores over 16 partitions
is 5,420 layers =
20 x (127 X + 144 heavy-hex ZZ), the circuit's gate count.What was retained per layer, and what is left
Per world slot, per layer, per partition:
mainCrossRankPartnerRangecounts+displsrecv_cache* lazily allocated, so only after a gradient has run.
Plus a per-layer cost, not per-slot:
sizeof(LayerCore)falls 416 B -> 168 B (measured),because two
LayerExchangeLayoutstructs, an embeddedRecvLayoutCacheand the generation id thatguarded it all left the struct.
So the retained slot-proportional footprint goes 40 -> 16 B/slot counted — 48 -> 16
counting what the ledger does not — plus a second, linear-in-
Pterm of 248 B per layer-core.The measured saving understates the real one. The derivative layout was a diagnostic outside
total_bytes(), so its 16 B/slot never appeared ingraphand its removal cannot appear thereeither. Counting what the ledger does not, at P=512 (
d_fields, port arm, measured):mainrecv_cacheAt P=512 that is 31.76 GiB of uncounted state on
mainagainst 0 now — none of which thetables below can show.
d_recv_cache_byteswas measured at 11,366,563,840 B (exactly 8 B/slot)after the second commit and 0 after the third, both on the port arm. The
mainfigure iscomputed from the struct layout, because
mainhas no such binding, so treat that one as anestimate rather than a measurement.
It is corroborated independently:
gradientpeak transient memory on the worst rank falls0.88 -> 0.04 GiB at 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.
Nothing sized by
Pis retained per layer any more. What is left of the quadratic term is theslot records themselves, at 16 B/slot.
The three commits
1d62773— the record was storing the same range twice.CrossRankPartnerRangecarried anoffset and a count for each of B and D. They are always equal:
GraphSink::finalizeresizesboth vectors from the same
P + Qexpression, so the counts match per slot and their prefix sumsmatch with them. That equality is now a checked precondition rather than a comment — unchecked
it would not throw, it would mis-derive Q and read a wrong-but-valid endpoint.
5f33a71— derive the layouts instead of retaining them.counts[r]is(r == my_rank ? 0 : cross_rank.sin_send_size(r))anddisplsis its running prefix, so both arederived into the per-thread scratch that already owns the send and recv buffers.
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: the derivative recv layout is 2x the evolution
recv layout.
mainran a separateresolve_recvper layer for the derivative round, each withits own cache. One
resolve_recvper layer per evaluation now serves both rounds.The predicate this needed first
Sharing scratch across layers is only sound once
resolve_recvcan tell one send pattern fromanother. Its predicate was
— effectively "have we ever resolved anything for a communicator this size", true for every layer
after the first. Correct only while each cache belonged to the one layout that produced it.
The fix is not a checksum of the counts. A miss runs
alltoall_counts, a collective, so tworanks disagreeing about validity is a distributed hang, not a wrong answer — and any rank-local
key can collide on one rank and not on another. The cache now carries a
generationassigned perLayerCoreat build time. Build order is identical on every rank, so every rank misses on alayer's first resolve and hits afterwards: the decision is rank-uniform even though the id
values are not.
Hoisting the slot resolution
Resolving a world slot is an index into the P-sized
rangesarray, and the element accessors weredoing 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 once and the accessors take that view.No behaviour change, and it is the precondition for ever storing slots sparsely.
cff597e— the transpose was the send layout all alongThe second commit kept a
RecvLayoutCacheper layer on the grounds that a transpose is the onething a rank cannot work out alone. That was wrong, and reading the layer-build sink says why:
slot
ron rankmholds the queriesrsentm, followed by the queriesmsentr; rankr's slot formholds those two swapped. The counts are therefore equal, and displacements areprefix sums of counts, so the recv layout is the send layout.
MPI_Alltoallvreadsrecvcounts/recvdisplsrather than writing them, so the same 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 this file, so it is checked where it can actually
break.
MONOPROP_CHECK_EXCHANGE_SYMMETRY=1re-adds the alltoall and throws naming the slot andboth counts. Unguarded, a routing change that broke the invariant would surface as a peer blocked
in
MPI_Alltoallvagainst a size nobody sends — a hang with no line number. It is off by defaultbecause on, it costs exactly the collective the commit removes.
Measured effect
Interleaved A/B, one allocation per cell, order flipped per (rep, cell), 6 reps,
--bench-rounds=1, ratio = median of per-rep paired ratios. main0.8.1.dev37+g6abd839e6(=
origin/main) against the port build of this branch. Arms confirmed distinct by.sohash(
f73d17f2…vsd37b0f1e…), not by version string. Term counts identical across arms on everycell; every cell pinned at
distinct_pinned_cpus == PARTITIONS.Memory — the graph ledger
The saving is predicted with nothing fitted, and it is exact. Job-total slots =
L·P²andlayer-cores =
L·P, soTo the byte, across a 4x range in
Pand a 3.1x range in term count, with zero free parameters.The 24 B/slot is the two record fields plus
counts/displs; the 168 B/core issizeof(LayerCore)falling 416 -> 248.That second term is why the per-slot saving looks inconsistent (34.50 B/slot at P=16 against
25.31 B/slot at P=128): the linear term is a larger share of the total when
Pis small. It is notnoise, and the earlier one-term reading of it was wrong.
The operator is byte-identical between arms on every cell (
port/main = 1.0000). The change isconfined to the graph.
Time
*marks 6/6, which clears a sign test at p=0.031. 5/6 is p=0.109 and does not clear, so it isreported but not claimed.
gradientis the operation this moves, and the win grows withP— 1.15x at P=128, 1.13x atP=256, 1.43x at P=512, and 2.10x at 1x128 where 128 partitions sit inside one rank (per-rep ratios
0.466–0.486, the tightest cell in the campaign). That is the derivative round no longer resolving
its own transpose:
mainpaid one extraalltoall_countsper layer on the first gradient.Transient memory on
gradientfalls by more than the ledger does, because the retained derivativelayout is never allocated at all. Peak
dmemon the worst rank, port against main:gradientdmemIt is not monotone in
Palone — c14 at P=128 sits below c12 at the sameP— because the cell'sown working set scales with the term count while the removed layout scales with
P.build_graphandpropagateare flat everywhere. The 1.23x onbuild_graphat 1x128 is thelargest number in the table and the one I would most like to claim; it is 5/6 and not a result.
The honest negative
At P=16 the change is a small loss:
energyis 1.04x slower on both geometries at 6/6. Thatis the expected shape rather than a surprise — deriving counts is a fixed cost paid per exchange,
while the saving grows with
P, so a small world pays the cost without earning the benefit.gradientat P=16 is unresolved: 1.01x slower on 1x16 and 1.02x faster on 8x2, each at 6/6, inopposite directions. Two geometries at the same
Pdisagreeing in direction while both reach 6/6means the effect is per-geometry, not per-
P, and neither figure should be read as the P=16gradient result.
The third commit, measured on its own
main -> HEADmixes all three commits, and two independently measured ratios cannot be differencedinto an increment. So a third arm was built from the second commit's binaries and run against HEAD
under the same protocol (arms
5ad94feevs97dc2277by.sohash).Memory, against
origin/main, cellsgws3-*:5f33a71cff597eThe increment was predicted before the cells ran, and the collator asserts it rather than
printing it: 80 B per layer core, and nothing per slot. Measured 80.00 B/core at both cells,
ratio measured/predicted 1.00000. The per-slot half is the load-bearing one — the cache sat
outside
total_bytes(), so any per-slot movement would have meant the model of that field waswrong.
cores / L = Pandslots / cores = Pcame out exact at both cells forL = 5,420.The larger half moves no shipped metric:
d_recv_cache_bytes0.662 -> 0 GiB at P=128 and10.586 -> 0 GiB at P=512, measured at exactly 8.00 B/slot.
Time, port-vs-port, 6 reps, same protocol:
build_graphpropagateenergygradientbuild_graphdmemThe mechanism is 5,420
alltoall_countsper first evaluation that no longer happen.build_graphdmem falling is what locates the old cache's allocation in the build rather than the first eval.
A second honest negative, 6/6 and unexplained.
gradientdmem at P=512 is 1.16x LARGERafter this commit, 0.04 -> 0.05 GiB. It is ~10 MiB against a 10.6 GiB resident saving in the same
cell, and gradient time is 1.17x faster, so it is not a blocker — but it agrees on all six reps,
which is not noise, and I have no mechanism for it.
ab_summaryrefused both port-vs-port cells, correctly by its own rule and wrongly in fact:both arms are the same worktree, so they report the same
monoprop_version. That is the blind spotthis PR's own instrumentation notes describe — a version is a git describe, not a fingerprint of
the extension. The arms were confirmed distinct by
.somd5, and each venv confirmed to import itsown, before submission. The table is quoted on that basis and no other.
Instrumentation
graph_memory_breakdown()gives the graph a per-field split, and reports slot occupancy — thefraction of
Pslots carrying any traffic — which nothing reported before.exchange_layout_bytesandderivative_layout_bytesnow report 0 rather than being removed,so an A/B against a build that did retain them shows the drop instead of losing the row.
recv_cache_bytesstays a diagnostic outsidetotal_bytes(), sograph_memory_bytes()meansthe same thing before and after.
Correctness
Full gate on
5f33a71, MPI build,MAX_NUM_MODES=1024, built withninjareportingno work to do(up to date against every declared dependency, not an mtime guess):ctest -L serialpartition_*(in-process cross-rank gradient cover)graph_encoding_*--with-mpi, 2x1 / 2x8 / 4x1 / 4x8 / 8x1 / 8x8Re-gated on
cff597e(job 1826413, md597dc2277, the exact binary these numbers come from):214
ctest -L serial, and 625 Python tests on each of four geometries twice — world 2, 32,32 and 256, once on the production path and once with
MONOPROP_CHECK_EXCHANGE_SYMMETRY=1asserting the recv-equals-send equality on every layer. The second pass is the one that
carries weight; the first only says nobody noticed.
Independently of that, a temporary 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
paulic12 energy+gradient run.The claim of the derivation is equivalence, so it is tested against an independent
construction rather than literals:
graph_encoding_derived_layout_matches_the_layout_it_replacesasserts the derived layout equals
build_layer_exchange_layout's output elementwise —counts,displsandtotal_count— for everymy_rankand both scales.build_layer_exchange_layoutnowhas no production caller and is kept deliberately as that oracle, which the header says so a
later reader does not delete it as dead code.
Also covered: a zero-traffic slot still gets a valid non-decreasing displacement (where an
off-by-one in a prefix sum would hide), the scratch is reused rather than reallocated, the
derivative overflow throws at build time, and a built layer retains no exchange layout at all.
Notes for review
perf/sparse-hot-path. That branch is refactoringMPICompat.hinto leasedalltoallvbuffer sets (begin_alltoallv/wait_into). Both changes answer "who owns thebuffers for an exchange": this one derives counts into a
thread_localscratch, that one leasesa set per call. Whoever merges second should move the derivation inside the lease. Note the
third commit deletes
resolve_recvoutright, so if that branch grew a caller for it after Ilast looked, this is a real conflict rather than the clean one it was.
thread_localscratch is sound only if at most one exchange is in flight per thread,which
MPI_Ialltoallvrequires anyway (the send counts must stay valid until the wait, andHybridComm/ShmCommpublish the raw pointer for peer partitions to read across barriers).That invariant is not new —
send_bufferhas always required it.from
build_graph, not from inside the exchange where peers are already committed to a transferof that size.
build assigns endpoints to slots, not of the exchange code, so a future routing change could
break it from a distance. That is why the audit exists and why I would run the multi-rank CI
geometries with
MONOPROP_CHECK_EXCHANGE_SYMMETRY=1set.Corrected from the earlier version of this PR
The earlier description said sparsifying
rangeswas the largest remaining lever, "worth about 3x"the record shrink. That was wrong. Occupancy of ~25% bounds what sparsifying could reclaim as a
fraction of itself; it says nothing about its size beside the other levers. In bytes per slot,
sparsifying
rangessaves ~12 B — less than the 16 B the record shrink already shipped — and itis the one option that changes a structure three consumers index by global slot. The retained
layouts were the larger prize and needed no such change.
It also said
recv_cachewas "the one piece that genuinely cannot be derived — it takes acollective". Also wrong, and shipped as the third commit. I asserted it from the general fact
that a transpose usually needs a collective, without checking whether this particular one carries
data both sides already hold.
Occupancy at the measured sizes, which does still pick the next fix: 31.5% (c12 P=16), 23.9%
(c12 P=128), 27.8% (c14 P=128), 17.5% (c14 P=512).
None of this removes the
P²term, and it is worth being plain about that. What remains is16 B/slot of slot records, of which the 8 B
sin_send_offsetis itself a prefix sum and derivableby the same trick. The floor for a dense layout is 8 B/slot; sparse at ~25% occupancy is ~3.4 B.
Only dropping the
L = 5,420retained-layer factor — streaming or checkpointing the graph —makes the quadratic irrelevant, and that trades it for forward-rebuild time on the gradient's
reverse pass.
Results are not reproducible from this diff: the benchmark harness, the campaign driver and the
symmetry probe all live on an unmerged branch, and the numbers come from 11 cells plus 3 gate jobs
on up to 4 Deucalion nodes.