perf(graph): store only the world slots that carry traffic - #238
Draft
diagonal-hamiltonian wants to merge 3 commits into
Draft
perf(graph): store only the world slots that carry traffic#238diagonal-hamiltonian wants to merge 3 commits into
diagonal-hamiltonian wants to merge 3 commits into
Conversation
|
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.
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.
Stacked on #237. Base is
perf/graph-world-size; review that one first.What
The graph kept, per layer, a dense array indexed by the flat world size
P = mpi_ranks × partitions— one record per possible partner, whether or not that partner ever receives anything. Job-total
L · P²records, withL = 5,420retained layers.This stores only the slots that carry traffic.
Why it is a bound, not a smaller constant
Nothing in the 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 bound is a property of the operator rather than of the decomposition. Measured, holding the 8×16
geometry fixed and moving only the node count:
Identical to the digit. So this converts an unbounded
P²term into one with a P-invariant ceiling.The result: per-rank memory falls again
Adding nodes is supposed to reduce per-node memory. The payload does exactly that — it halves on every
doubling, because the traffic is fixed and split across more ranks. The metadata did not, and past
P=256 it won.
Per-rank graph memory (job total ÷ P), pauli c14, same 8×16 geometry, only the node count moving:
mainmaingets steadily worse. #237 alone still reverses at P=512 — it removes the retained exchangelayout, but the dense slot array it leaves behind is the next
P²term and takes over immediately.Only with both does per-rank memory fall monotonically, which the collator asserts rather than reports.
At P=512 the addressing overhead now roughly equals the traffic it describes:
Memory
Against this PR's base (#237):
Against
main, the same P=512 cell is 57.686 → 6.963 GiB, 8.3× (memory only, from a separate wave;memory is deterministic here and reproduced to the digit across two independent runs).
The 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. The
LayerCore growthterm is the honest half:world_size,self_posandself_offsethave to be stored precisely because the array length nolonger encodes P. 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.
Time
A storage-layout change should be time-neutral.
port/mainbelow 1.00 is faster;agreeis how manyof 6 paired reps point the same way as the median.
Nothing regressed at any cell — no operation anywhere in the wave was flagged slower. Only 6/6
clears a sign test (p=0.031); 5/6 is p=0.109 and is not a result, so
propagateandgradientarereported as unresolved rather than as small wins or losses.
c12 1×16is in the wave precisely because it is where this could lose: 31.5% occupancy, the leastsaving and the most exposed to the branchier access pattern. It came back flat on every operation.
Peak RSS added by
build_graph(dmem), which is where the graph actually lands:Notes for review
counts[r]byasking
cross_rank.sin_send_size(r)for everyr < P. That was O(1) against the dense array it waswritten for; 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 isasserted elementwise against
build_layer_exchange_layoutfor everymy_rankand both scales.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 perf(evolution): ⚡ stop retaining the P-sized exchange state per layer #237's hoist; the self slot, which
is read in the innermost gradient loop, keeps O(1) access via
self_pos.independently removed the doubled cross-rank D range, and both had hoisted the per-slot resolution
out of
apply_self_slot_derivative_paired.Verification
ctest -L serial: 217/217.Sparsity's real failure mode is the derived prefix reading a neighbour's phases, which serial cannot
see.
_core.somd5 (97dc2277base,ce6acd53this branch), recorded per rep.Not by
__version__— an editable install's stamp is written at install time and not rewritten by alater rebuild, so both arms can advertise one version while running different binaries.
These results are not reproducible from this diff. The benchmark harness, the campaign definitions
and the collator that asserts the identity above live outside this PR, which carries library code only.
Measured on Deucalion (128-core EPYC znver2, 242 GiB/node), 6 paired reps per cell, one allocation per
cell with arm order flipped per rep, ratios taken per-rep then medianed.