Skip to content

perf(evolution): ⚡ carry cross-rank queries as positions, not dense words - #235

Draft
diagonal-hamiltonian wants to merge 1 commit into
pr/digest-cutofffrom
pr/compact-query-record
Draft

perf(evolution): ⚡ carry cross-rank queries as positions, not dense words#235
diagonal-hamiltonian wants to merge 1 commit into
pr/digest-cutofffrom
pr/compact-query-record

Conversation

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Stacked. Based on pr/digest-cutoff so the diff shown here is this change alone. It retargets to main as the stack merges.

Summary

A cross-rank query carried the partner's whole dense monomial — W words plus a phase word, at a fixed stride. At 250 modes that is 72 B a record; at 512 modes, 136 B. The payload is ~93% zero: the terms this engine cares about have small popcount, so nearly every word is empty.

The record now carries the ascending set-bit positions instead: six inline u16 positions, a 16-bit k, an i8 phase and a flags byte, packed into two words — 16 B, with inline continuation records of eight positions each for the rare wider term.

Why it wins. The two alltoallv legs are ~68% of layer time, and that funnel is bytes-through-one-serialised-thread, not latency-bound, so the record's width is close to the whole cost.

shape B/record exchange_s build_graph agree
(512, 6) 136.00 → 16.00 0.4960× 1.56× faster 6/6
(250, 10) 72.00 → 29.93 0.6976× 1.26× faster 6/6
(128, 6) 40.00 → 16.00 0.7809× 1.15× faster 6/6
(32, 6) 16.00 → 16.00 1.0003× flat
(250, 4) 72.00 → 16.00 0.9774× flat

The predictor is GiB removed from the wire, not the shrink ratio, and the two flat cells are why — they are flat for opposite reasons. (250,4) has the second-largest shrink in the sweep (4.5×) and is dead flat, because its entire cross-rank query volume is 5,940 records: at cutoff 4 nearly every product exceeds the cutoff and dies before it ever needs resolving. (32,6) is flat because at kWords == 1 the old record was already 16 B, so the two formats coincide — provably inert, not merely un-regressed. Sorted by GiB removed, exchange_s is monotone across all five shapes; sorted by shrink ratio it is not.

Across world sizes, propagate is 1.63× / 1.97× / 1.50× at 1 / 2 / 4 nodes, resolved 6/6, 6/6 and 12/12. Note the per-byte advantage decays with world size (exchange_s 0.4960× → 0.5488×) while the end-to-end win does not, because exchange's share of layer time climbs 67% → 85% → 89%. Do not quote the wire ratio as if it predicted the wall: as N grows the two move in opposite directions.

Correctness, before any timing

12/12 bit-identical operator fingerprints over {graph, propagate, pauli} × (R,S) ∈ {1×1, 2×1, 2×8, 4×8} — every term, in enumeration order, coefficients hashed as raw IEEE-754 bits, per rank and not merged (a merged digest would not notice terms moving between ranks).

Bit-identical rather than tolerance-equal is the right bar, and agreement at 1e-7 would have been a failure: the format changes no routing decision and no record order, and record order is the floating-point accumulation order, since Resolve.h mints each miss's term index in (sender, record) order. Agreement only to a tolerance would mean something reordered.

The receive and insert sides

Keeping the query in the positions it arrived as, instead of rebuilding a dense monomial, measures incoming_s 0.79× (8/8 over two cells, p=0.0078) — about twice what its instruction count predicted, because it deletes a ~110 KB-per-call staging array and so stops evicting the hash table the probe is about to walk. Group-prefetching the insert path the way find_batch already did measures incoming_s 0.90× and insert_s 0.88× (8/8 each).

Both are memory-system wins, which is the pattern in this work: the wins have been memory-system wins and the losses instruction-count guesses.

Deletes a test file on main

cpp/tests/fused_query_codec_tests.cpp — it tested the dense fused-query helpers, which no longer exist.

The dense codec itself was already written as an independently-authored oracle, so it moved to cpp/tests/dense_query_reference.h rather than being deleted. With one production format, a round-trip test can only ask whether the codec agrees with itself, and that catches a decoder mirroring its encoder's mistake not at all. The reference shares no code with CompactQuery — different layout, different width, different offset arithmetic — and it is frozen: not a second implementation to keep in sync.

Verification

ctest -L serial — 244/244 green. pytest tests --with-mpi green at 1, 2 and 4 ranks.

Checklist

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

AI/LLM disclosure

  • I used the following tool to help write this PR description: Claude Code (claude-opus-5)
  • I used the following tool to generate or modify code: Claude Code (claude-opus-5)

…ords

A cross-rank query carried the partner's whole dense monomial -- W words
plus a phase word, at a fixed stride. At 250 modes that is 72 B a
record; at 512 modes, 136 B. The payload is ~93% zero: the terms this
engine cares about have small popcount, so nearly every word is empty.

The record now carries the ascending set-bit POSITIONS instead: six
inline u16 positions, a 16-bit k, an i8 phase and a flags byte, packed
into two words -- 16 B, with inline continuation records of eight
positions each for the rare wider term.

WHY IT WINS. The two alltoallv legs are ~68% of layer time, and that
funnel is bytes-through-one-serialised-thread, not latency-bound, so the
record's width is close to the whole cost.

| shape      | B/record      | exchange_s | build_graph | agree |
|------------|---------------|-----------:|------------:|------:|
| (512, 6)   | 136.00 -> 16.00 | 0.4960x  | 1.56x faster | 6/6  |
| (250, 10)  | 72.00 -> 29.93  | 0.6976x  | 1.26x faster | 6/6  |
| (128, 6)   | 40.00 -> 16.00  | 0.7809x  | 1.15x faster | 6/6  |
| (32, 6)    | 16.00 -> 16.00  | 1.0003x  | flat         | --   |
| (250, 4)   | 72.00 -> 16.00  | 0.9774x  | flat         | --   |

THE PREDICTOR IS GiB REMOVED FROM THE WIRE, NOT THE SHRINK RATIO, and
the two flat cells are why -- they are flat for opposite reasons.
(250,4) has the second-largest shrink in the sweep (4.5x) and is dead
flat, because its entire cross-rank query volume is 5,940 records: at
cutoff 4 nearly every product exceeds the cutoff and dies before it ever
needs resolving. (32,6) is flat because at kWords == 1 the old record
was already 16 B, so the two formats coincide -- provably inert, not
merely un-regressed. Sorted by GiB removed, exchange_s is monotone
across all five shapes; sorted by shrink ratio it is not.

ACROSS WORLD SIZES, propagate is 1.63x / 1.97x / 1.52x at 1 / 2 / 4
nodes, resolved 6/6 at each. Note the per-byte advantage DECAYS with
world size (exchange_s 0.4960x -> 0.5488x) while the end-to-end win does
not, because exchange's share of layer time climbs 67% -> 85% -> 89%.
Do not quote the wire ratio as if it predicted the wall: as N grows the
two move in opposite directions.

CORRECTNESS, before any timing. 12/12 bit-identical operator
fingerprints over {graph, propagate, pauli} x (R,S) in {1x1, 2x1, 2x8,
4x8} -- every term, in enumeration order, coefficients hashed as raw
IEEE-754 bits, per rank and not merged. Bit-identical rather than
tolerance-equal is the right bar and agreement at 1e-7 would have been a
FAILURE: the format changes no routing decision and no record order, and
record order IS the floating-point accumulation order, since Resolve.h
mints each miss's term index in (sender, record) order. Agreement only
to a tolerance would mean something reordered.

The receive side keeps the query in the positions it arrived as instead
of rebuilding a dense monomial: incoming_s 0.79x, 8/8 over two cells,
p=0.0078 -- about twice what its instruction count predicted, because it
deletes a ~110 KB-per-call staging array and so stops evicting the hash
table the probe is about to walk. The insert path group-prefetches its
slot addresses the way find_batch already did: incoming_s 0.90x and
insert_s 0.88x, 8/8 each. Both are memory-system wins, which is the
pattern here -- the wins on this work have been memory-system wins and
the losses instruction-count guesses.

DELETES cpp/tests/fused_query_codec_tests.cpp. It tested the dense
fused-query helpers, which no longer exist. The dense codec itself was
already written as an independently-authored oracle, so it MOVED to
cpp/tests/dense_query_reference.h rather than being deleted: with one
production format, a round-trip test can only ask whether the codec
agrees with itself, and that catches a decoder mirroring its encoder's
mistake not at all. The reference shares no code with CompactQuery --
different layout, different width, different offset arithmetic -- and is
frozen, not a second implementation to keep in sync.

Assisted-by: ClaudeCode:claude-opus-5
@github-actions github-actions Bot added the cpp label Aug 15, 2026
@diagonal-hamiltonian diagonal-hamiltonian added the test-in-draft Run CI even in Draft mode label Aug 15, 2026
@github-actions

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Aug 15, 2026

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@                Coverage Diff                @@
##           pr/digest-cutoff     #235   +/-   ##
=================================================
  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.

@sonarqubecloud

Copy link
Copy Markdown

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

🤖 AI text below 🤖

Holding this as draft: the encoding is under revision. Nothing here is wrong — this is a scope
note so the format does not ship twice.

Measured this week on the two shipped lattice models, the compact record's win turns out to be a
function of system width, not popcount. Two independent instruments agree to within 1%
(qbytes/n_push, and records_for(k) folded over LAYERPOP's k_hist):

workload bucket NumModes U = 2N dense B/term compact B/term
bench_random 250 modes, c6 256 512 72 16.00 (4.5x fewer, 1.30x faster)
pauli kicked Ising, c6 128 256 40 27.85 (1.44x fewer)
pauli kicked Ising, c12 128 256 40 41.41 (parity)

The premise that the cutoff bounds k does not hold on these models. Measured popcounts: the random
Majorana workload is exactly {k=4: 65%, k=6: 35%}, so every term is one 16 B record — but pauli
c6 spans k=2..11 with 65% of terms at k>=7, and c12 spans 8..20 peaking at 15-16, i.e. 2.57
records/term. The continuation escape hatch, sized for "94 fully-paired rows in 20,860,967", is the
common path on kicked Ising.

So the deletion of the dense path here is defensible — the compact record is never materially worse
in the measured space. What it has is no headroom left at the narrow/high-k end: at U=256, k~15 the
combinatorial bound is ~10.4 B against the 41.41 B actually sent.

Two findings from that follow-up work bear directly on this file:

  1. The fixed-stride constraint documented at CompactQuery.h:40-46 is stale. No production site
    divides by a record stride any more — Engine.h:377 and Engine.h:518-525 both count with
    count_queries(), and their own comments say so. That reopens the delta/varint/Elias-Fano family
    the header comment rules out, and the replacement is expected to use it.
  2. phase is ternary, not int8. emit_phase returns rotation_sign for Pauli (+/-1) and
    rotation_sign * REAL_PARTS[...] for Majorana with REAL_PARTS = {1,0,-1,0}. Six of the eight
    bits the record spends on it are dead, and on the wide workload the header width is a 35% swing.

Reopening once the encoding settles, so the CI state here is worth keeping rather than closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cpp test-in-draft Run CI even in Draft mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant