Skip to content

perf(graph,mpi): ⚡ derive the exchange layouts and store only occupied world slots - #270

Merged
robertodr merged 48 commits into
mainfrom
perf/sparse-slots-and-funnel
Aug 25, 2026
Merged

perf(graph,mpi): ⚡ derive the exchange layouts and store only occupied world slots#270
robertodr merged 48 commits into
mainfrom
perf/sparse-slots-and-funnel

Conversation

@diagonal-hamiltonian

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

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

Per-layer graph memory no longer grows with the flat world size P = ranks x partitions x nodes.

  1. Only occupied world slots are stored. CrossRankPartnerRange was a dense array over P
    (32 B narrow / 40 B wide). CrossRankOccupiedSlot is 12 B / 24 B and exists only where there is
    traffic, with offsets from a running prefix. One such array per layer made an O(P) cost O(P^2)
    per job.
  2. Exchange layouts are derived, not keptO(P + occupied) into per-thread scratch.
    RecvLayout.h and resolve_recv are gone: the count matrix is symmetric, so the recv layout is
    the send layout. That also removes a hazard — resolve_recv could skip a collective on a cache
    hit, letting ranks disagree about whether it ran. Nothing in the replacement can split ranks. Its
    width precondition is not lost: it survives as mpi::check_exchange_layout_width, on the path
    every exchange takes.
  3. The HybridComm staging matrix is re-indexed peer-outermost, two owner-written tables publish
    before barrier B1, and scatter_off_ becomes a cursor. Still 4 barriers per payload verb, and the
    offset tables are elementwise equal — integer sums reassociated, not an exchange reordered.
    Partition 0 stays Theta(R*S^2): this is not a fix for the funnel.

ContractSink::finalize returns nullptr, so propagate builds no LayerCore — its win comes
entirely from mechanism 3, while build_graph / energy / gradient are where mechanism 1 shows up.

Also: graph_memory_breakdown in binder.h (five counters, each outside total_bytes() and inside
operator+=), and ExchangeLayoutOracle.h kept outside the library so it cannot drift into agreeing
with the code it checks.

Reviewing this PR

Where the risk is, for anyone coming to this cold:

  • The symmetry argument in mechanism 2 is the load-bearing claim. Deleting resolve_recv is only
    safe if the exchange count matrix is symmetric, so a rank can derive what it will receive from what
    its peers send. If that is wrong anywhere, the failure is a hang or silent corruption, not a failed
    assert. cpp/tests/ExchangeLayoutOracle.h checks the derivation independently, from outside the
    library.
  • HybridComm.h (mechanism 3) is the hardest file to review, and the place where a reordering
    that looks equivalent may not be. The claim is unchanged barrier count and elementwise-equal offset
    tables, with reassociation of integer sums only.
  • The occupied-slot find() is now std::ranges::lower_bound over a sorted array. It requires
    occupied to stay sorted by slot; a builder that appends out of order breaks lookup silently.
  • Everything else is mechanical: a struct shrunk, an allocation removed, comments and docs cut.

Measurement

These figures supersede the tables posted earlier. Those were measured at e25bbfb, on a binary
that still carried the since-dead PR 5 and PR 7. What follows is a fresh 12-cell campaign on main.

Setup: 10 interleaved reps, one Slurm allocation per cell holding both arms with the order flipped per
(rep, cell). Ratios are formed per rep then medianed, judged by a two-sided sign test with Holm
step-down within each family. Peak RSS is VmHWM from /usr/bin/time -v, summed over nodes. Layout
A = 1 rank x 128 partitions, B = 8 ranks x 16 partitions, each at 1 and 2 nodes. Harness ce49174,
campaign row pr6v2.

  • main 1250a27eded39f1639a6913ca3d8e101 @ 668c90b — 216/216 unit, 215/215 serial (job 1840045),
    592 passed x 4 MPI layouts (1840046)
  • port 97b9a114a6f0cac97eded7fe5c0d7b97 @ 446d60ca — 230/230, 229/229, -L mpi 1/1 (1839668),
    593 passed x 4 layouts (1839669)

Checked before any timing was read: term counts identical to the term in every cell
(278,722,051 / 96,981,051 / 91,273,861, zero spread), thread placement symmetric across arms in all 12
cells (128/rank at A, 16/rank at B, zero mismatches), arms provably distinct binaries, and no MISSING
or FAILED rep anywhere.

Wall time. 13 of 24 tests resolve at the 10-rep sign-test floor, all of them improvements and none
a regression, split A=9 / B=4 so this is not one layout.

cell N operation port/main
grid-pauli-A 2 build_graph[pauli] 0.50x
grid-pauli-A 2 gradient[pauli] 0.52x
grid-pauli-A 2 energy[pauli] 0.52x
grid-hubbard-graph-A 2 build_graph[hubbard] 0.53x
grid-hubbard-A 2 propagate[hubbard] 0.57x
grid-pauli-A 2 propagate[pauli] 0.65x
grid-pauli-B 2 gradient[pauli] 0.80x
grid-pauli-B 2 energy[pauli] 0.84x
grid-pauli-B 2 build_graph[pauli] 0.84x
grid-pauli-A 1 build_graph[pauli] 0.87x
grid-pauli-A 1 gradient[pauli] 0.91x
grid-hubbard-graph-A 1 build_graph[hubbard] 0.91x
grid-pauli-B 2 propagate[pauli] / grid-hubbard-B 2 propagate 2 propagate 0.95x

The remaining tests do not resolve at 10 reps, which means indistinguishable rather than a small win
in either direction.

Peak RSS, node sum. The graph cells are the headline:

cell N port/main
grid-pauli-A 2 0.40x (2.53x smaller)
grid-pauli-B 2 0.46x (2.18x smaller)
grid-pauli-B 1 0.73x
grid-pauli-A 1 0.71x

hubbard-graph moves less (0.89x and 0.91x at N=2, ~0.97x at N=1) and plain hubbard is flat
(1.00x), as expected: propagate releases each layer as it contracts, so there is little retained
graph to shrink.

Why every large win is at N=2. The flat world is ranks x partitions x nodes, so 1 node to 2
doubles P, and what this PR removes is P^2-scaled waste in the per-layer arrays. Layout A at N=2
(P=256) is where the graph work is most P^2-dominated and it carries the biggest ratios; layout B
at the same P shows the same signs, smaller. The N=1 cells landing at 0.91-0.97x are the same effect
at P=128. This is an explanation consistent with all 24 points, not an isolated mechanism.

Direction and rough magnitude match the superseded tables — build_graph[hubbard] A/N=2 at 0.53x here
against 0.43x there, pauli graph peak RSS 0.40x against 0.39x. The timing absolutes are somewhat
smaller, which is what you would expect once the two dead PRs leave the baseline.

Caveats.

  • Four cells carry an unresolved flag (8/10 or 7/10 agreement): propagate[hubbard] at A/N=1 and
    B/N=1, build_graph[hubbard] at graph-B/N=2, propagate[pauli] at A/N=1. The fix is more reps, not
    a re-read of the ratio.
  • The pre-registered commit-level bisect over 5 perf commits was never completed, so this is
    attributed to the stack as a whole and to no named commit.
  • The cache-coherence story for the layout dependence remains inference, never isolated.
  • staging_s is not on this branch, so the funnel cannot be read as staging_s + mpi_s and the
    COMMPROF residual is invalid here — it goes negative at layout B. These are wall time and peak RSS.
  • The benchmark harness does not ship, so this is not reproducible from the diff.

Gate on the current head

Re-run on 3a87c66, after the main merges and after the review fixes, because #267 and #268 both
touch files this PR changes:

  • ctest -L unit and -L serial, and -L mpi
  • the Python suite at four MPI layouts (worlds 2, 32, 32, 256)

All green. Counts and the tested binary's md5 are in a comment on this PR rather than inlined, so the
body cannot drift from what was run.

Next, not here: most world slots carry no traffic (28.0% occupancy at P=16, 16.6% at P=128), so a
neighbourhood collective would retire MPI_Alltoallv, world_size and the dense prefix sum, and
pack_off_ is now the dominant serial term. Both false-share below S=16 and need their own
campaigns.

Checklist

  • Tests added or updated to cover the changes
  • Documentation updated (docstrings, docs/, CONTRIBUTING.md) if needed
  • CHANGELOG / release notes updated if applicable

AI/LLM disclosure

  • I did not use LLM tooling, or used it only privately for ideation
  • I used the following tool to help write this PR description: Claude Code
  • I used the following tool to generate or modify code: Claude Code

Important

By opening this PR I confirm that I have read CONTRIBUTING.md and I agree to the terms of the Contributor License Agreement.

Warning

If you're contributing on behalf of your employer, contact cla@algorithmiq.fi to arrange a Corporate CLA.

@github-actions

Copy link
Copy Markdown

Docs preview: https://pr-270.monoprop-docs.pages.dev

@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/sparse-slots-and-funnel branch from 13eb4b2 to cb3f4d3 Compare August 23, 2026 23:52
@diagonal-hamiltonian diagonal-hamiltonian changed the title perf(graph): ⚡ make per-rank graph memory fall as the world grows perf(graph,mpi): ⚡ derive the exchange layouts and store only occupied world slots Aug 23, 2026
@diagonal-hamiltonian diagonal-hamiltonian added the test-in-draft Run CI even in Draft mode label Aug 23, 2026
@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/sparse-slots-and-funnel branch from cb3f4d3 to 66b791b Compare August 23, 2026 23:54
@codecov

codecov Bot commented Aug 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (cb9a033) to head (3a87c66).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #270   +/-   ##
=======================================
  Coverage   97.70%   97.70%           
=======================================
  Files          14       14           
  Lines         742      742           
  Branches       98       98           
=======================================
  Hits          725      725           
  Misses         12       12           
  Partials        5        5           
Flag Coverage Δ
cpp 97.70% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

@diagonal-hamiltonian
diagonal-hamiltonian marked this pull request as ready for review August 24, 2026 07:44
@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/sparse-slots-and-funnel branch from 6a1ada9 to e25bbfb Compare August 24, 2026 08:01
Comment thread cpp/monoprop/detail/graph/MPGraphViews.h
Comment thread cpp/monoprop/detail/graph_encoding/MPGraphEncoding.cpp
Comment thread cpp/monoprop/detail/graph_encoding/MPGraphEncoding.cpp
Comment thread cpp/monoprop/detail/graph_encoding/MPGraphEncoding.cpp Outdated
Comment thread cpp/monoprop/detail/graph_encoding/MPGraphEncoding.cpp Outdated
Comment thread docs/content/docs/building.mdx Outdated
Comment thread docs/content/docs/testing.mdx Outdated
Comment thread docs/content/docs/testing.mdx Outdated
Comment thread AGENTS.md Outdated
Comment thread CMakeLists.txt Outdated
diagonal-hamiltonian and others added 13 commits August 24, 2026 11:23
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.

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.

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.

`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>
… 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.
On main, resolve_recv refused a send-count vector whose width was not
mpi::size(comm) on every exchange, and its comment records the case as
reachable: layouts outlive propagator copies and pare rebuilds, so a graph
built for one communicator can be replayed on another of a different size.

This branch moved that check into check_exchange_symmetry, which begins with
`if (!enabled) return;` on a MONOPROP_CHECK_EXCHANGE_SYMMETRY probe. The
default path therefore checked nothing, and begin_flat_exchange went on to
hand MPI_Alltoallv a counts array shorter than the rank count -- MPI reads one
count and one displacement per rank whatever the span holds, so a thrown
exception had degraded into an out-of-bounds read.

Hoist it above the gate. The width of the layout is a precondition of posting
the transfer at all, not a diagnostic about it, so it holds on every build and
every path; only the symmetry audit below it -- which costs a real collective,
and exists to falsify an invariant rather than to protect memory -- stays
optional. The comment at both the declaration and the definition now says
which is which, since sharing one entry point is what let the two be confused.

exchange_layout_width_is_checked_even_with_the_symmetry_audit_off pins it: a
ShmComm sized 4 driven by one thread, so a layout of the wrong width must
throw before any collective is entered. If the check ever slides back under
the gate the case hangs or fails rather than passing quietly.

Assisted-by: ClaudeCode:claude-opus-5
MONOPROP_CHECK_EXCHANGE_SYMMETRY had four defects, all confirmed in the tree
before changing anything:

- presence-tested, so MONOPROP_CHECK_EXCHANGE_SYMMETRY=0 turned it ON;
- screaming prefix, where every other knob here is monoprop_ (monoprop_NUM_THREADS,
  monoprop_PARTITION_PINNING, monoprop_PARTITIONS);
- read with a bare std::getenv, bypassing config::detail::parse_flag in
  detail/EnvConfig.h, which is the single home for this;
- and it decided whether a COLLECTIVE runs.

The last one is why the answer is not "fix the parse". The audit calls
alltoall_counts. A variable read on some ranks and not others does not make the
job misreport, it makes the ranks disagree about whether the collective happens
at all, and the job hangs with no diagnostic. A per-rank environment variable is
precisely the mechanism by which they come to disagree, and no amount of
parsing care removes it: the value is per process by construction.

So the decision moves into the binary, where every rank of a job necessarily
agrees: a CMake option monoprop_CHECK_EXCHANGE_SYMMETRY, default OFF, named to
match monoprop_ENABLE_MPI and monoprop_WIDE_TERM_INDEX, propagated as a compile
definition on monoprop-objs (PUBLIC, so the unit-test target sees it too) and
reported in the configure summary alongside the other build switches. No
replacement environment variable is introduced.

The audit itself is unchanged, and so is where it sits: below the unconditional
width precondition added in the previous commit, which is not part of it.

Assisted-by: ClaudeCode:claude-opus-5
build_layer_exchange_layout has no production caller: the engine derives the
layout from the slot records (derive_exchange_layout) and nothing else calls it.
Its only job is to be the independent reference that derivation is asserted
against, and it was compiled into libmonoprop.so to do it.

That placement voids what it is for. An oracle that ships inside its subject is
edited by whoever edits the subject, refactored by the same refactor and broken
by the same mistake; a check that travels with the thing it checks is not
evidence about it. Moved to cpp/tests/ExchangeLayoutOracle.h, where changing it
means changing a test, and the comment there says so.

checked_mpi_int stays in the library and the oracle keeps calling it. It is the
shared narrowing guard for every MPI count in the tree, not part of the layout
rule under test, so copying it would only mean asserting our own copy of an
overflow policy.

No CMake change: cpp/tests/CMakeLists.txt GLOBs the directory, and this is a
header included by graph_encoding_tests.cpp.

Assisted-by: ClaudeCode:claude-opus-5
d_recv_cache_bytes and d_derivative_layout_bytes are new on this branch, are
outside total_bytes(), and are assigned a literal 0 by construction: the recv
layout IS the send layout, so nothing is cached, and the 2x derivative layout is
derived on demand rather than retained. They can never report anything else.

The comments justified keeping them as a way for an A/B to see the memory leave.
That reading does not hold: main emits no graph_memory_breakdown() dictionary at
all -- the whole method is new here -- so there is no older build whose output a
zero row lines up against. A key that is always 0 tells a reader nothing except
that a field they cannot use exists, and main's convention is to emit no key
rather than a zero one.

exchange_layout_bytes is NOT touched. It is a real field on main
(MPGraphViews.h), it is summed by total_bytes() there, and it is fed from
MPGraph.cpp, so its value on this branch is a comparable measurement and the
comment explaining why it now reads 0 stands.

Assisted-by: ClaudeCode:claude-opus-5
It measures the resident size of a LayerExchangeLayout: counts.capacity() +
displs.capacity(), in ints. That was worth reporting while a layout was retained
per layer per partition. This branch stopped retaining one -- the layout is
derived into per-thread scratch for the exchange being posted and dropped -- so
the function survives only as a sizeof over a transient nobody holds.

The reason it is dead is this branch's own doing, not a pre-existing condition:
on main it had a caller. Its last caller here was a BOOST_CHECK_GT(..., 0U)
asserting that a freshly built layout occupies more than zero bytes, which
restates std::vector rather than testing this codebase, so it goes with it.

Nothing else referenced either the function or the key it used to feed.

Assisted-by: ClaudeCode:claude-opus-5
… widths

cross_rank_partner_range_counts_track_term_index_width asserted

    sizeof(CrossRankOccupiedSlot) == sizeof(uint32_t) + 2 * sizeof(TermIndex)

unguarded, so it runs in both builds. It is only true in one. The record is
{uint32_t slot; TermIndex sin_send_count; TermIndex in_count;}: narrow that is
4 + 4 + 4 = 12 and the sum agrees, but wide TermIndex is 8-byte aligned, so the
u32 is followed by four bytes of padding and the record is 8 + 8 + 8 = 24 against
an expected 20. Measured, not reasoned: a standalone probe compiled at both
widths reports sizeof/alignof 12/4 and 24/8, with in_count at offset 8 and 16.

Nothing else catches it, which is why it has stood. The static_assert beside the
struct is written `sizeof(TermIndex) != sizeof(uint32_t) || sizeof(...) == 12` --
it switches ITSELF off on exactly the build where the check fails, and
monoprop_WIDE_TERM_INDEX has its own `just test-wide` recipe precisely because
that configuration is otherwise never compiled.

Assert the layout rule instead: the u32 slot id occupies a whole
TermIndex-alignment slot, so the record is max(sizeof(u32), alignof(TermIndex))
+ 2 * sizeof(TermIndex), plus alignof(record) == alignof(TermIndex). Both hold at
both widths. The comment states where the padding is, so the expression does not
look like arithmetic waiting to be tidied back into a sum.

Assisted-by: ClaudeCode:claude-opus-5
…t list

slot_sin_recv_index computes Q = sin_send_count - in_count in unsigned width.
in_count larger than sin_send_count therefore does not underflow into a negative
that a later comparison would reject: Q wraps to ~2^64, `idx < out_count` is true
for every index, and each D read addresses B at in_count + idx -- past the end of
sin_send_indices, for every endpoint of the slot.

Reachability, checked rather than assumed. The only production producer is
GraphSink::finalize (layer_build/Engine.h), which sets in_count = P after sizing
sin_send_indices to P + Q from the same two values, so the engine cannot violate
it. But build_packed_cross_rank_storage takes CrossRankPartnerData from any
caller and validated only the B/D length equality; nothing in the type, and
nothing at the choke point, said the in-block had to fit. That is the same status
the B/D skew had before it was checked there, and the comment on that check
already gives the reason: at a choke point an assumption becomes a precondition.

So it is checked in the same loop, with the same std::logic_error, and the reader
keeps its branch-free subtraction behind an assert that names the invariant. The
boundary case in_count == B.size() -- an all-in slot with an empty out-block --
stays legal, and the new test pins both sides of it.

Assisted-by: ClaudeCode:claude-opus-5
diagonal-hamiltonian and others added 11 commits August 24, 2026 11:23
Three accessors survived the sparse-slot rewrite with no caller left.
Verified by `grep -rn` over cpp/ src/ packages/ tests/ (git grep is broken
on this login node); each matched exactly once, at its own definition:

  cross_rank_sin_recv_index_at   1 hit (the definition)
  cross_rank_sin_recv_phase_at   1 hit (the definition)
  LayerTraversal::cross_rank_slot(size_t)
                                 1 hit for the member; the 19 other
                                 `cross_rank_slot` hits are the free
                                 detail::cross_rank_slot(storage, rank)
                                 and detail::cross_rank_slot_record_bytes,
                                 neither reached through the member.

for_each_cross_rank_sin_send_range / _recv_range are NOT touched: they have
one caller each, in large_cosine_storage_tests.cpp. Test-only is not dead.

<stdexcept> in MPGraphEncodingStorage.h became unused when
ExchangeLayoutRankMismatch was removed; dropped after replaying every TU's
compile line with -fsyntax-only in all three configurations (default,
-Dmonoprop_WIDE_TERM_INDEX, -Dmonoprop_CHECK_EXCHANGE_SYMMETRY), each
clean with zero FAIL and zero WARN.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The diff carried 598 added comment lines under cpp/ + src/ against 1611
added lines (37.1%). Comment lines only; no code is touched.

Three things went, in order of volume:

- Five arguments each restated four to seven times, reduced to one canonical
  copy: the audit is a build option because it is a collective (kept in
  detail/mpi/Exchange.h and docs/building.mdx, pointers elsewhere); an
  in-block past the end wraps unsigned (kept at the throw site in
  MPGraphEncoding.cpp); the graph is indexed by the flat world, so O(P) per
  layer is O(P^2) per job (kept on CrossRankOccupiedSlot); the oracle lives
  outside the library or it drifts (kept in cpp/tests/ExchangeLayoutOracle.h);
  the u32 slot id costs a whole TermIndex alignment slot (the static_assert
  message already says it, so the prose went).
- Three harness campaign findings that had leaked into shipped source as
  prose. None is checkable from the tree; they belong in the PR body.
- Two essays: hybrid_comm_tests.cpp's mutation-modelling header, reduced to
  the one fact a reader needs (a consistently transposed tiling is
  indistinguishable to any black-box test), and HybridComm.h's bit-identity
  proof, reduced to the claim and its reason.

Kept, compressed rather than cut, because they say why the code is correct:
the Phase P0 lifetime / barrier-ordering argument, the occupied-slot
rationale, and every static_assert message.

Also fixed: hybrid_comm_tests.cpp credited `recv` with a reuse property that
belongs to stage_recv_'s high-water mark. And cpp/tests/README.md's suite
inventory was missing exchange_layout_precondition_tests.cpp; the bullet is
appended at the end of that list, leaving the existing entries untouched.

Verified with -fsyntax-only over every TU's real compile line in all three
configurations (default, -Dmonoprop_WIDE_TERM_INDEX,
-Dmonoprop_CHECK_EXCHANGE_SYMMETRY) and with clang-format.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…hat cannot

Nine cases in this suite open with `if (world_size() < 2) return;` and report Passed having executed
zero assertions in a world-1 variant. monoprop_MPI_TEST_PROCS defaults to 2 so the coverage exists,
but -Dmonoprop_MPI_TEST_PROCS=1 turned nine green lines into nine empty ones silently; configure now
fails instead. Empty stays legal because boostAddTests substitutes 2 for it.

The audit's throw was reached by no test at all: both existing cases cover the width check, which
sits above the #ifndef and runs in every build. The new case needs two real ranks -- at one
participant the alltoall returns this rank's own counts, so the mismatch is unreachable -- and
carries a symmetric negative control, so a function that threw unconditionally would still fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lising it

The int-overflow case built 2^30 real endpoints to reach a count that fits int at
1x but not 2x, peaking at 19 GB RSS. Free on a compute node, fatal on a 16 GB CI
runner, and never executed before because the draft matrix was skipped.
derive_exchange_layout reads only sin_send_count and rank_count(), so one declared
occupied slot reaches the same boundary: 286 MB peak, 2 of 2 assertions passing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The symmetry is a theorem about the count matrix, not a runtime risk, so the
audit bought a per-exchange collective for a property the derivation cannot
violate. No CI job built it ON, so nothing automated covered it either.

check_exchange_symmetry did two things. Its width check is not this branch's
invention: on main it lives inside resolve_recv, which this branch deletes, and
was relocated above the gate rather than added. It stays, and the function is
renamed to check_exchange_layout_width for what it now does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The codebase throws its own type 36 times against six generic ones, and all six
were in this file. These two say a slot's B and D sides disagree, or that its
in-block escapes its endpoint list -- both invariants of the build, so the type
should name them.

Derived from std::logic_error, so the cases asserting on that still hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Capture lists spelled out on the seven occupied-slot lambdas, matching what the
neighbouring lambdas in both files already do. find() takes the ranges overload
and std::to_address.

Two that are not cosmetic:
- build_layer_storage_unified took its partner vector by value, but the move
  into build_packed_cross_rank_storage was a no-op -- that overload takes a
  const reference -- so the by-value bought a move and consumed nothing. Now a
  const reference, and the inert std::move at both call sites is gone.
- Exchange.h keeps an explicit MPI_Request rather than the suggested `auto *`:
  the pointer spelling compiles only where MPI_Request is a pointer typedef, and
  is an int handle under MPICH. This is what the rest of detail/mpi already does.

Drops the S5414 suppression for MPGraphEncodingTypes.h. It was justified by
LayerCore's private cache member and named reset_derivative_exchange_layout();
this branch deletes both, and the header now has no private members at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ten blocks called out in review, plus the same treatment applied to the rest
of the diff, the binding and the Python test rather than only where a comment
happened to land.

Every shouted word is gone: 33 sites of ALL-CAPS emphasis across 12 files, now
none outside the acronym MPI.

Added comment lines go 264 -> 183 against 942 of code, 21.8% -> 16.3%. Not the
under-10% I aimed at: the remainder is mostly HybridComm.h's barrier and lifetime
rules and the test rationale, and cutting to a number from there would delete the
reasoning rather than the prose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md, docs/building.mdx and docs/testing.mdx go back to base exactly, and
the README paragraph about the removed build option goes with them.

Two are partial on purpose, and I would rather be told to finish them than do it
silently:
- features/parallelism.mdx keeps "Graph memory at large world sizes" and loses
  only the two lines about the audit. The rest is the O(P^2) rule this branch
  exists to fix, and the review comment sat on the audit sentence.
- cpp/tests/README.md reverts the rewritten graph-encoding paragraph but keeps
  the two bullets naming ExchangeLayoutOracle.h and
  exchange_layout_precondition_tests.cpp. Both files are new here, so a full
  revert would leave them undocumented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Asked in review whether we need it. We do not: no configuration checked into the
repo can trip it. The cache default is "2", and justfile / vscode set
monoprop_MPI_TEST_PROCS only as a shell variable for mpiexec -n, which never
reaches CMake. It fires only for a hand-passed -Dmonoprop_MPI_TEST_PROCS=1, so it
guarded a value someone types rather than one we ship.

The hole it aimed at is real and stays open: cases that early-return at world
size 1 report Passed having asserted nothing. Declining to catch that here is a
choice, not a fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`end-of-file-fixer` failed the `lint` job on 4807e41: deleting the dead
`layercore` suppression left its separating blank line, so the file ended
with two newlines instead of one.

Audited the other 29 changed files for the same class of problem — final
newline, trailing blank lines, CRLF, trailing whitespace — and they are
clean. `clang-format` passed on 4807e41; its "Formatting" lines are
verbose output, not diffs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@diagonal-hamiltonian
diagonal-hamiltonian force-pushed the perf/sparse-slots-and-funnel branch from 9e2ac0c to 4491fc8 Compare August 24, 2026 10:29
@matteoacrossi matteoacrossi added this to the v0.9.0 milestone Aug 24, 2026
robertodr and others added 2 commits August 25, 2026 11:56
Removed section on graph memory at large world sizes from the parallelism documentation.

Signed-off-by: Aaron Miller <61472721+diagonal-hamiltonian@users.noreply.github.com>
Comment thread cpp/monoprop/MPGraph.cpp Outdated
Comment thread cpp/monoprop/Evolution.cpp Outdated
}

// Derives both sides at once: the count matrix is symmetric, so the recv layout is the send layout.
auto derive_layer_exchange(const LayerTraversal &layer, const mpi::Comm &comm, int scale, FlatExchangeBuffers &buffers)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why FlatExchangeBuffers &buffers (IO param) instead of returning a FlatExchangeBuffers object?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because buffers is thread_local scratch — acquire_flat_exchange_buffers() hands back the same object on every call, and the point is to keep its three allocations across layers and steps. Returning a FlatExchangeBuffers would reallocate counts/displs (sized by world) and both payload buffers on every layer of every verb.

That said, the parameter was wider than the function: it only ever writes buffers.layout. It now takes LayerExchangeLayout &.

While there, resize_flat_exchange_buffers is gone — it was a one-line wrapper around a single resize. The send-buffer sizing is now inline at the call site, the same shape begin_flat_exchange already uses for the recv buffer:

derive_layer_exchange(layer, comm, scale, buffers.layout);
buffers.send_buffer.resize(buffers.layout.total_count == 0 ? 1 : buffers.layout.total_count);

Comment thread cpp/monoprop/Evolution.cpp Outdated
Comment thread cpp/monoprop/detail/graph_encoding/MPGraphEncodingTypes.h Outdated
robertodr
robertodr previously approved these changes Aug 25, 2026

@robertodr robertodr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got some comments, but overall LGTM. This is a complex piece of code in need of complete rewrite though...

- `derive_layer_exchange` takes `LayerExchangeLayout &` rather than the whole
  `FlatExchangeBuffers &`: it only writes the layout. The out-param stays
  because the buffers are thread_local scratch reused across every layer;
  returning by value would reallocate per call.
- Drop `resize_flat_exchange_buffers`, a one-line wrapper. The send-buffer
  resize is inline at the call site, matching how `begin_flat_exchange`
  already sizes the recv buffer.
- Replace the `grow` lambda in `snapshot_remote_endpoints` with four
  `resize(std::max(size(), occupied))` calls.
- Cut the comment blocks in `layer_storage_memory_usage` and above
  `CrossRankOccupiedSlot`. `exchange_layout_bytes = 0` went with them: the
  field is `= 0` in the struct and `breakdown` is default-initialised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

🤖 AI text below 🤖

Gate on 3a87c66

The run the body promises, on the current head — after the main merges and after the review fixes in 3a87c66. Deucalion, dev-x86, Open MPI 5.0.8, MONOPROP_MAX_NUM_MODES=1024.

Tested binary: _core.abi3.so md5 5ad0b693127b552f5f704a6d2dd7c95b, built from this tree by the gate job itself and verified unchanged after both CTest labels ran.

gate result job
ctest -L unit 238 / 238 passed, 0 failed 1850563
ctest -L serial 237 / 237 passed, 0 failed 1850563
ctest -L serial (2 nodes) 237 / 237 passed, 0 failed 1850573
ctest -L mpi 1 / 1 passed 1850573
pytest tests --with-mpi, ranks 2 x partitions 1 (world 2) 593 passed 1850573
pytest tests --with-mpi, ranks 2 x partitions 16 (world 32) 593 passed 1850573
pytest tests --with-mpi, ranks 4 x partitions 8 (world 32) 593 passed 1850573
pytest tests --with-mpi, ranks 16 x partitions 16 (world 256) 593 passed 1850573

ALL SUITES PASSED, no failures at any layout.

The counts are higher than the ones in the body (230 / 229 / 593) because main merged in since: the C++ suite grew, the Python suite did not.

Not re-measured. The performance tables in the body stand on the pr6v2 campaign at 446d60c. 3a87c66 is comment removal plus three mechanical changes — a narrowed parameter, an inlined one-line wrapper, and a std::max in place of an if — so it does not touch what those tables measure, but it is also not the binary they were measured on.

@sonarqubecloud

Copy link
Copy Markdown

@robertodr
robertodr merged commit cf84009 into main Aug 25, 2026
26 of 45 checks passed
@robertodr
robertodr deleted the perf/sparse-slots-and-funnel branch August 25, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cpp documentation Improvements or additions to documentation python test-in-draft Run CI even in Draft mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants