Skip to content

perf(graph): ⚡ make per-rank graph memory fall as the world grows - #238

Draft
diagonal-hamiltonian wants to merge 6 commits into
mainfrom
perf/sparse-world-slots
Draft

perf(graph): ⚡ make per-rank graph memory fall as the world grows#238
diagonal-hamiltonian wants to merge 6 commits into
mainfrom
perf/sparse-world-slots

Conversation

@diagonal-hamiltonian

@diagonal-hamiltonian diagonal-hamiltonian commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Supersedes and absorbs #237, which is closed. Six commits, one branch off main.

The defect

Inside the engine rank_count is mpi::size(comm). On a partitioned run the comm is Kind::Hybrid,
whose size() returns the flat world P = mpi_ranks × partitions (HybridComm.h:81), not the MPI
rank count. Every "per-rank" array in a graph layer is therefore P long, each MPI process holds one per
partition, and the graph retains one per layer — so per-slot state costs O(L·P²) across the job, with
L = 5,420 retained layers.

Measured on the pauli kicked-Ising anchor, main @ 6abd839, 91,273,861 terms and an identical
config on every rung
— one problem measured four times, not four problems:

P = ranks × partitions geometry operator graph
16 1×16, 1 node 6.93 GB 3.81 GB
128 8×16, 1 node 6.97 GB 7.61 GB
256 8×16, 2 nodes 6.61 GB 18.62 GB
512 8×16, 4 nodes 6.52 GB 61.94 GB

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×16 and 8×2
both give 366,649,352 B; 1×128, 8×16, 2×64, 4×32 all give 2,769,389,680 B). A single MPI rank
at 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 terms, and removing either one alone leaves the other to take over.

The first three commits remove the retained exchange layoutcounts/displs per layer per core,
plus the recv_cache and the lazily-retained derivative layout. The last three make the slot records
themselves 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 pauli c14 workload, 8×16 geometry fixed, only the node count moving:

P main first 3 commits all 6
128 56.73 MiB 39.57 MiB 31.31 MiB
256 69.36 MiB 36.32 MiB 19.08 MiB
512 115.37 MiB 50.58 MiB 13.93 MiB
per doubling ×1.22, ×1.66 ×0.92, ×1.39 ×0.61, ×0.73

main gets steadily worse — past P=256 adding nodes costs more memory per node than it saves. The
first three commits alone still reverse at P=512
: they remove the retained layout, and the dense slot
array they leave behind is the next term and takes over immediately. Only with all six does per-rank
memory 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:

metadata per rank payload per rank ratio
main 108.44 MiB 6.93 MiB 15.66×
first 3 commits 43.65 MiB 6.93 MiB 6.30×
all 6 7.00 MiB 6.93 MiB 1.01×

Why 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 only
occupied slots hold anything.

Every occupied slot holds at least one endpoint, so occupied_slots ≤ total cross-rank endpoints, and
that ceiling is a property of the operator rather than of the decomposition. Measured, 8×16 fixed, only
the node count moving:

P cross-rank endpoints
128 900,204,022
256 900,204,022
512 900,204,022

Identical to the digit. This converts an unbounded 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/main

cell P main port ratio
c12 1×16 16 1.442 GiB 1.397 GiB 1.03×
c12 8×2 16 1.442 GiB 1.397 GiB 1.03×
c12 1×128 128 4.982 GiB 2.888 GiB 1.72×
c12 8×16 128 4.982 GiB 2.888 GiB 1.72×
c14 8×16 N1 128 7.091 GiB 4.998 GiB 1.42×
c14 8×16 N2 256 17.340 GiB 9.183 GiB 1.89×
c14 8×16 N4 512 57.686 GiB 25.494 GiB 2.26×

Predicted with nothing fitted, and exact to the byte at all five distinct cells across a 4× range in P
and a 3.1× range in term count: saved = 24 B × slots + 168 B × layer_cores. The second, linear term is
why 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 P is small, not noise.

Wave 2 — the last three commits against the first three

cell P base port ratio slot records occupancy
c12 1×16 16 1.391 GiB 1.377 GiB 1.01× 0.021 → 0.005 GiB (4.2×) 31.46%
c12 1×128 128 2.837 GiB 1.766 GiB 1.61× 1.323 → 0.237 GiB (5.6×) 23.88%
c14 8×16 N1 128 4.946 GiB 3.914 GiB 1.26× 1.323 → 0.275 GiB (4.8×) 27.75%
c14 8×16 N2 256 9.080 GiB 4.770 GiB 1.90× 5.293 → 0.952 GiB (5.6×) 23.98%
c14 8×16 N4 512 25.288 GiB 6.963 GiB 3.63× 21.172 → 2.785 GiB (7.6×) 17.54%

End to end at P=512 that is 57.686 → 6.963 GiB, 8.3× — the one cell measured directly against main
on 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:

total drop  ==  (slot record bytes freed)  −  (LayerCore growth)

It held exactly, to the byte, at all five cells. LayerCore growth is the honest half: world_size,
self_pos and self_offset must now be stored precisely because the array length no longer 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: occupied is identical on both arms at every cell. Occupancy is a property of the
traffic; 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 appeared
in graph and its removal cannot appear there either. At P=512 that is 31.76 GiB of uncounted retained
state on main against 0
— none of which the tables above can show. d_recv_cache_bytes was measured
at 11,366,563,840 B (exactly 8 B/slot) after the second commit and 0 after the third. The main
figure is computed from the struct layout because main has no such binding, so treat that one as an
estimate.

It corroborates independently: gradient peak transient memory on the worst rank falls 0.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.

Time

* marks 6/6 paired reps agreeing, which clears a sign test at p=0.031. 5/6 is p=0.109 and does not
clear
, so it is reported but never claimed.

Wave 1 — first three commits vs main

cell P build_graph propagate energy gradient
c12 1×16 16 1.00× (4/6) 1.03× faster (5/6) 1.04× slower (6/6*) 1.01× slower (6/6*)
c12 8×2 16 1.01× slower (5/6) 1.00× (4/6) 1.04× slower (6/6*) 1.02× faster (6/6*)
c12 1×128 128 1.23× faster (5/6) 1.11× faster (3/6) 1.01× faster (5/6) 2.10× faster (6/6*)
c12 8×16 128 1.02× slower (5/6) 1.01× faster (3/6) 1.00× (3/6) 1.15× faster (6/6*)
c14 N1 128 1.02× faster (6/6*) 1.00× (3/6) 1.01× slower (4/6) 1.03× faster (5/6)
c14 N2 256 1.02× faster (5/6) 1.00× (4/6) 1.06× faster (6/6*) 1.13× faster (6/6*)
c14 N4 512 1.01× faster (5/6) 1.01× faster (4/6) 1.26× faster (6/6*) 1.43× faster (6/6*)

gradient is the operation this moves and the win grows with P — the derivative round no longer
resolves its own transpose. Peak gradient dmem on the worst rank improves 2.28× at P=128, 6.83× at
P=256, 20.53× at P=512.

The honest negative: at P=16 this is a small loss — energy is 1.04× slower on both geometries at
6/6. 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. gradient at P=16 is unresolved: 1.01× slower on 1×16 and
1.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

cell build_graph propagate energy gradient
c12 1×16 (P=16) 1.01× 5/6 1.01× 5/6 0.97× 6/6* 1.00× 5/6
c12 1×128 (P=128) 0.88× 6/6* 0.97× 4/6 0.95× 5/6 0.98× 6/6*
c14 8×16 N1 (P=128) 0.98× 6/6* 1.02× 5/6 0.99× 3/6 1.00× 4/6
c14 8×16 N2 (P=256) 0.96× 6/6* 0.98× 5/6 0.96× 6/6* 1.02× 4/6
c14 8×16 N4 (P=512) 0.98× 6/6* 1.01× 5/6 0.95× 5/6 1.02× 4/6

Below 1.00 is faster. Nothing regressed at any cell — no operation anywhere in the wave was flagged
slower. c12 1×16 is in the wave precisely because it is where sparsity could lose: 31.5% occupancy, the
least saving, the most exposed to the branchier access pattern. It came back flat on every operation.

build_graph peak RSS (dmem), which is where the graph actually lands: 0.92× at P=128, 1.32× at
P=256, 2.13× at P=512.

What is in each commit

commit
1 1d62773 stop storing the cross-rank D range twice — CrossRankPartnerRange 32 → 16 B
2 5f33a71 derive the exchange layouts into caller scratch instead of retaining them
3 cff597e derive the recv layout too — the transpose is the send layout
4 020e3a8 separate world-slot metadata from traffic in the graph breakdown (instrument only)
5 bbfa918 store only the world slots that carry traffic
6 9e22925 scatter the derived exchange layout over the occupied slots

cff597e is the one that needed an argument. Commit 2 kept a RecvLayoutCache on the grounds that a
transpose is the one thing a rank cannot work out alone. That was wrong, and reading the layer-build sink
says why: slot r on rank m holds the queries r sent m followed by the queries m sent r; rank
r's slot for m holds those two swapped. Counts are therefore equal and displacements are prefix sums
of counts, so the recv layout is the send layout. MPI_Alltoallv reads recvcounts/recvdispls
rather than writing them, so two arrays now serve both sides. Deleted with it: resolve_recv,
RecvLayoutCache, RecvLayout.h, the per-layer alltoall_counts on the miss path, and
exchange_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=1 re-adds the alltoall and throws naming the slot and both
counts. Unguarded, a 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. It is off by default because on, it costs
exactly the collective the commit removes. 550M comparisons across the gate geometries, 0 mismatches.

9e22925 exists only because the two halves are combined. Commit 2 derives counts[r] by asking
cross_rank.sin_send_size(r) for every r < P. That was O(1) against the dense array it was written
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. 9e22925 inverts it:
zero the counts, walk the occupied slots, scatter. Equivalence is asserted elementwise against
build_layer_exchange_layout for every my_rank and both scales.

Notes for review

  • The offset is derived, not stored. It is the running prefix over stored entries in ascending slot
    order, and empty slots contributed zero to the dense prefix, so the derived value equals the old one
    exactly. Storing a size_t offset would pad the record from 12 B to 24 B. This is safe only because
    the 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_layout now has no production caller and is kept deliberately as the
    differential oracle for the derived path. The header says so, so a later reader does not delete it as
    dead code.
  • The thread_local derivation scratch is sound only if at most one exchange is in flight per
    thread
    , which MPI_Ialltoallv requires anyway — send counts must stay valid until the wait, and
    HybridComm/ShmComm publish the raw pointer for peer partitions to read across barriers. The
    invariant is not new; send_buffer has always required it.
  • This overlaps perf/sparse-hot-path, which is refactoring MPICompat.h into leased alltoallv
    buffer 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_recv outright.

Corrections to what earlier versions of this work claimed

  • Sparsifying the slot records was called the largest remaining lever, "worth about 3×" the record
    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_cache was 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.
  • The first three commits were described as leaving a 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.
  • Multi-rank pytest, 4 geometries: world 2 (2×1), 32 (2×16), 32 (4×8), 256 (16×16) — 625 passed each.
    Sparsity's real failure mode is the derived prefix reading a neighbour's phases, which serial cannot
    see.
  • Arms confirmed distinct by _core.so md5, recorded per rep — not by __version__. An editable
    install'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.
  • Both arms ran 16 threads pinned per rank, confirmed per cell.

What this does not fix

The L = 5,420 retained-layer factor multiplies every term above. Removing it needs checkpointing the
graph 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 no reserve (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.

diagonal-hamiltonian and others added 3 commits August 15, 2026 15:03
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>
@github-actions

Copy link
Copy Markdown

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
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 diagonal-hamiltonian changed the title perf(graph): store only the world slots that carry traffic perf(graph): ⚡ make per-rank graph memory fall as the world grows Aug 16, 2026
@diagonal-hamiltonian
diagonal-hamiltonian changed the base branch from perf/graph-world-size to main August 16, 2026 15:55
@diagonal-hamiltonian
diagonal-hamiltonian deleted the perf/sparse-world-slots branch August 16, 2026 15:55
@diagonal-hamiltonian
diagonal-hamiltonian restored the perf/sparse-world-slots branch August 16, 2026 15:56
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant