Skip to content

perf(graph): store only the world slots that carry traffic - #238

Draft
diagonal-hamiltonian wants to merge 3 commits into
perf/graph-world-sizefrom
perf/sparse-world-slots
Draft

perf(graph): store only the world slots that carry traffic#238
diagonal-hamiltonian wants to merge 3 commits into
perf/graph-world-sizefrom
perf/sparse-world-slots

Conversation

@diagonal-hamiltonian

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

Copy link
Copy Markdown
Collaborator

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, with L = 5,420 retained 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 only
occupied slots hold anything.

Every occupied slot holds at least one endpoint, so occupied_slots ≤ total cross-rank endpoints, and
that 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:

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

Identical to the digit. So this converts an unbounded 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:

P main with #237 with #237 + this PR
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. #237 alone still reverses at P=512 — it removes the retained exchange
layout, but the dense slot array it leaves behind is the next 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:

metadata per rank payload per rank ratio
#237 43.65 MiB 6.93 MiB 6.30×
+ this PR 7.00 MiB 6.93 MiB 1.01×

Memory

Against this PR's base (#237):

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

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:

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

It held exactly, to the byte, at all five cells. The LayerCore growth term is the honest half:
world_size, self_pos and self_offset have to 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.

Time

A storage-layout change should be time-neutral. port/main below 1.00 is faster; agree is how many
of 6 paired reps point the same way as the median.

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

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 propagate and gradient are
reported as unresolved rather than as small wins or losses.

c12 1×16 is in the wave precisely because it is where this could lose: 31.5% occupancy, the least
saving 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:

cell dmem main → port ratio
c14 8×16 N1 1.62 → 1.50 GiB 0.92×
c14 8×16 N2 1.13 → 0.85 GiB 1.32×
c14 8×16 N4 1.12 → 0.52 GiB 2.13×

Notes for review

  • One commit here exists only because the two branches are combined. perf(evolution): ⚡ stop retaining the P-sized exchange state per layer #237 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.
  • 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 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.
  • Rebasing onto perf(evolution): ⚡ stop retaining the P-sized exchange state per layer #237 dropped two of the original four commits as duplicates — both branches had
    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.
  • 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 (97dc2277 base, ce6acd53 this branch), recorded per rep.
    Not by __version__ — an editable install's stamp is written at install time and not rewritten by a
    later rebuild, so both arms can advertise one version while running different binaries.
  • Both arms ran with 16 threads pinned per rank, confirmed per cell.

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.

@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.
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