Skip to content

perf(evolution): ⚡ join a gate's partners in one symmetric round and a half - #354

Draft
diagonal-hamiltonian wants to merge 3 commits into
perf/stack-4-term-tablefrom
perf/stack-5-one-round-join
Draft

diagonal-hamiltonian wants to merge 3 commits into
perf/stack-4-term-tablefrom
perf/stack-5-one-round-join

Conversation

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

A gate used to be two passes over the whole comm: every rotating row, silent or not, published a
query; a leader rank answered them against its rows; the answers came back; then the followers
replayed the pairing to learn what the leader had decided. This PR replaces that with one round and
a half, symmetric, no leader and no follower replay: only an emitting row sends a record, a TableJoin
probes the persistent term table (TermTable, PR 4) once per gate, and only the one case a receiver
cannot settle alone — a record landing on a row the receiver itself did not emit from — comes back in
a second, smaller round. An absence pass over the sent records, not over the whole operator, closes
out everything that matched nowhere.

Three algebra kernels (rotation_sign_positions, state_phase_positions, the digest-based cutoff
predicates) let the new join read a term as the row store's own ascending position list and never
expand it to a bitset. Engine.h is reduced to LayerBuildEngine and build_layer (one arm, the
collective one); GateSinks.h splits what a hit does (GraphSink, ContractSink) from how it is
found; GateScratch.h is the per-gate arena (RowMarks, MissStage, IncomingRecords) reused
across gates under the engine's existing 4× release rule.

This is PR 5 of 7, based on perf/stack-4-term-table. It is the memory payoff of the stack — the
protocol it replaces is what staged and exchanged a dense pass over every rotating row regardless of
how many actually needed to move — and it costs 8-9% time on Hubbard at this level, which PR 6 and
PR 7 are expected to recover (their own measurements are quoted in their bodies). The next PR
(perf/stack-6-pair-once) settles a mutual pair from one record instead of two.

Changes

Engine

  • cpp/monoprop/algebra/Algebra.h, AlgebraCommon.h, PauliAlgebra.h: rotation_sign_positions,
    state_phase_positions, pauli_rotation_sign_compact, and the digest cutoff predicates
    (digest_is_paired/length_keeps/support_keeps, CutoffEvaluator::has_digest_form/
    passes_from_digest) — pure additions, no existing path calls them yet.
  • cpp/monoprop/detail/evolution/layer_build/GateScratch.h (new, 280 lines): the per-gate arena
    (RowMarks, MissStage, IncomingRecords).
  • cpp/monoprop/detail/evolution/layer_build/TableJoin.h (new, 102 lines): one find_batch group
    per gate.
  • cpp/monoprop/detail/evolution/layer_build/GateSinks.h (new, 240 lines): GraphSink,
    ContractSink, append_inserted_endpoints.
  • cpp/monoprop/detail/evolution/layer_build/Engine.h (404 lines): LayerBuildEngine + build_layer,
    the collective arm only.
  • cpp/monoprop/detail/evolution/layer_build/Resolve.h (348 lines): the join and its three passes
    (join_self, join_incoming, join_absence); each record's header decoded once, its tag folded
    inside the decode loop; value_at's branch hoisted by if constexpr on the sink.
  • cpp/monoprop/detail/evolution/layer_build/Scan.h (655 lines): the emit rewritten as one templated
    pass, five loop-invariant flags hoisted out of emit_row, two per-record asserts demoted to
    per-gate.
  • cpp/monoprop/detail/evolution/layer_build/Common.h, FusedApply.h, PartnerMerge.h,
    QueryWire.h: the reshaped wire/apply records (SentRecord, HalfRotationRec, PartnerAcc,
    FusedContract) a one-round protocol needs; FusedApply.h is one loop over one stream.
  • cpp/monoprop/detail/operator/MPOperator.h, TermTable.h: find_batch grows an OnHit callback;
    matched_scratch_bytes renamed gate_scratch_bytes (d_gate_buffers_hwm_bytes); the five
    d_gate_*_max counters and histograms are dropped.
  • cpp/include/monoprop/MonomialPropagator.h/.inl, src/monoprop/bindings/binder.h:
    gate_scratch_, over_cutoff_possible_ agreed across the comm once per call,
    report_comm_profile_ under monoprop_COMMPROF, and the renamed/dropped ledger keys.

Tests

  • cpp/tests/position_kernels_tests.cpp (new, 165 lines): both signs against rotation_sign over
    12,000 (generator, term) pairs per algebra at four widths, the digest predicates against
    cutoff_sums/is_paired/length_cutoff/support_cutoff.
  • cpp/tests/evolution_detail_tests.cpp (rewritten, 882 lines), sparse_resolve_tests.cpp,
    sparse_query_tests.cpp, mpi_utils_tests.cpp: adapted to the join/scaffolding rewrite.
  • cpp/tests/graph_pair_order_tests.cpp (new, 334 lines), fused_cos_sweep_tests.cpp (+101 lines:
    fused_sink_recovers_only_the_found_endpoints_value, fused_apply_rounds_each_arm_once).
  • cpp/tests/mp_operator_tests.cpp, tests/test_monoprop_smoke.py, cpp/tests/README.md: the
    ledger key rename.

Docs

  • docs/content/docs/features/parallelism.mdx: the protocol paragraphs (one exchange round; the
    round-and-a-half for asymmetric pairs; graph mode's symmetric send predicate), the join-key
    paragraph, gate_scratch_bytes/d_gate_buffers_hwm_bytes, the monoprop_COMMPROF env row.

Measurements

Gated positional (--raw-only) against the second exactness reference (the x2 raw-bit reference), not
against main's: gate record md5 b8eed0dbdf10f6531eee0910732e345d. bitne 0, maxULP 0,
msetne 0, monoseq!= 0 over all 35 golden cells.

Exactness in detail. PRs 1-4 are bit-identical to main's raw coefficient bits. PR 5 differs from
main's raw bits in exactly two coefficients (Δ = 1.39e-17): the merged apply loop pins one rounding
with std::fma where main's two separate loops relied on GCC's default -ffp-contract=fast to
contract each into a single vfmadd. Merging the two loops into one over fc.halves lets GCC hoist
the common sin * phase * v product above the is_insert branch, leaving the plain arm a bare add —
two roundings instead of one, on every rotation half of every gate. Measured directly (not inferred):
without the fma pin, 2,639,965 of 6,024,567 coefficients over the 35 golden cells move from main
(median 1-8 ULP, worst case 17,501 ULP), with term sets and monomial sets identical throughout —
the signature of an extra rounding, not a wrong value. With the pin, the result is bit-identical to
the x2 raw-bit reference (the x2 raw-bit reference itself differs from main by the same two coefficients, for
the unrelated reason recorded in its own history). The 1e-12-quantised results are identical to main's
in both cases.

Paired A/B, 3 interleaved reps against origin/main c5e88c8 and the predecessor, ratios only:

rung time st5/mainB peak RSS (kernel) st5/mainB time st4/mainB (predecessor) peak st4/mainB (predecessor)
L1-hubbard 1.093 0.763 1.002 0.797
L1-pauli 0.982 0.791 1.005 0.784
M2a-hubbard 1.081 0.776 0.987 0.811

The memory payoff lands (peak 0.76-0.79× main; gate_scratch_bytes replaces the old
matched_scratch_bytes at 0.6-0.7× on top of that). Time is +8-9% on Hubbard at both shapes measured
here (L1-hubbard 1.093, M2a-hubbard 1.081) and within noise on Pauli (0.982) — consistent with this
protocol's own standalone ladder, so it is the one-round design at this level, not a port defect. This
level's stop rule (M2a ≤ 1.05× main) is not met on its own; PR 6 and PR 7 are expected to recover it,
and the decision is made at PR 7's tip-vs-main ladder.

Notes for reviewers

  • RowBlock/row_block/positions_at (added inert in PR 4) get their first consumer here, in
    Scan.h's emit pass. TableJoin.h has no live_bytes() and no TimeProf/MemProf counters —
    diagnostics stay out of this stack per the decision recorded in PR 2's Notes.
  • Settling a mutual pair from one record (8e25ad0's pair-once half) and dropping the absent
    partner's buffered zero in Heisenberg (0f6b9b3) are not in this PR — they are PR 6's.
    mpi::WindowVec-typed per-slot arrays stay out of scope too: PR 3 deferred that array re-basing to
    PR 7, so this PR keeps the flat [R] per-slot std::vectors PR 3/PR 4 leave in place.
  • Every touched file is at or below its pre-stack line count except GateScratch.h (absorbs two
    structures by design) and QueryWire.h/PartnerMerge.h (small growth from the Header-taking
    overloads and count_paired_positions moving in); nothing was cut from invariant documentation to
    hit a number.
  • Which endpoint of a pair reads the recovered value vs. the record's own pre-cos double is fixed and
    load-bearing (GateSinks.h's ContractSink::hit): the half landing on an emitting row recovers,
    the half on a silent row reads its record's own value. Getting this backwards is 1 ULP on every
    asymmetric pair; fused_sink_recovers_only_the_found_endpoints_value pins both endpoints of one
    pair, not the pair's sum.

Checklist

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

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 (claude-sonnet-5)
  • I used the following tool to generate or modify code: Claude Code (claude-opus-5)

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.

…rm's positions

The gate exchange that follows reads a term as an ascending position list -- the
row store's own layout -- and never as a bitset. Three kernels are added so it
never has to expand one.

`A::rotation_sign_positions(ctx, pos, k)` is `rotation_sign` over the positions.
Majorana XORs the generator's interleave mask at those positions, which is the
parity of the intersection by definition. Pauli cannot read its mask
position-wise, so `make_pauli_gen_context` gathers the two bits of every qubit G
acts on into ONE 64-bit word, preserving the (even, odd) lane structure, and
`pauli_rotation_sign_compact` folds the same exponent `pauli_rotation_sign`
folds over the whole bitset -- restricted to the qubits that can contribute,
which is why the two agree bit for bit. `compact_slot[p]` is p's compact bit,
`kNotInGen` elsewhere; `compact_ok` says whether the word exists at all, and it
does not above 32 qubits, so `A::sign_from_positions_ok(ctx)` reports that
rather than guessing.

`A::state_phase_positions(pos, k, mask)` is the diagonal initial-state score
over the same list, and is meaningful on exactly the terms `state_phase` is.

`digest_is_paired(k, d)`, `length_keeps(k, d, cutoff)` and
`support_keeps(k, d, cutoff)` are the two structural cutoffs as functions of the
(k, d) digest -- k set positions of which d are paired modes -- since
`xor_sum = k - 2d`, `popcount_sum = k` and `or_sum = k - d`. The merge that
builds a partner's positions already knows both numbers, so it answers the
cutoff without a bitset. `CutoffEvaluator::has_digest_form()` says whether the
evaluator's cutoff is one of those two forms, because an opaque cutoff (a basis
change) has no digest and its call site must materialise the partner;
`passes_from_digest(k, d)` is the answer when it has.

Every one of these is an addition: no existing path calls them yet, no stored
byte moves, and the results are unchanged.

`cpp/tests/position_kernels_tests.cpp` is new and differential throughout: both
signs against `rotation_sign` over 12,000 (generator, term) pairs per algebra at
four widths up to 250 modes, the compact word's own refusal above 32 qubits, and
the digest predicates against `cutoff_sums`, `is_paired`, `length_cutoff`,
`support_cutoff` and `passes_with_popcount` over 5,000 monomials x four cutoffs
on a logical width below the storage width.

Assisted-by: ClaudeCode:claude-opus-5
The structures the symmetric round-and-a-half protocol needs, ahead of the swap
that uses them. Nothing here changes a result: no existing path calls the new
types, and the reshaped records are written and read only by the layer build.

`GateScratch.h` is the per-gate arena, owned by the propagator and outliving no
gate: `RowMarks` is the six per-row bitsets the protocol decides with (`rot`,
`foll`, `matched`, `received`, `partner_rot`, `answered`), cleared per nonzero
word rather than per layer so the cost tracks the rows a gate actually touches;
`MissStage` accumulates the partners no row holds so a gate inserts them once,
in one pass, in query order; `IncomingRecords` is the decoded form of one
delivery. The three buffers, and the collective receive buffer, are reused
across gates under the existing 4x release rule (a gate 4x wider than the next
must not pin its footprint), which is why the propagator holds the arena for
capacity only and rebinds `RowMarks`'s cached bases in `begin()` -- a stored
base would be stale the moment the row store grows.

`TableJoin.h` is the join itself: `begin_queries` sizes the hit slots,
`run` batches every query through `TermTable::find_batch`, and `hit(q)` is the
row query q matched or `kMissing`. One probe per record and none per
anticommuting row, which is the whole point of the persistent table.

`Common.h` reshapes the wire and apply records for a protocol in which only
emitting terms send: `SentRecord` is what a sender remembers about its own
record so a response can name it back by ordinal (`kResponseWords = 2`,
`push_response`), `HalfRotationRec` is two words because a half now carries a
partner's value rather than an epoch, `PartnerAcc` keeps four lists because the
merge classifies a partner four ways, and `FusedContract` is one stream because
the two passes it used to separate no longer exist. `MatchedEpochSet` and
`RotationRec` go with them, and `EvenParityNzWord` moves in beside the scan that
is now its only caller.

`GateSinks.h` splits what the join does with a hit from how it finds one.
`GraphSink` records the pair structure a `build_graph` layer needs;
`ContractSink` accumulates the rotation halves a `propagate` layer applies. Both
answer `wants_values` / `wants_responses` at compile time, so the resolve
specialises on the sink rather than branching per record.

Assisted-by: ClaudeCode:claude-opus-5
…a half

A gate used to be two passes over the whole comm. Every rotating term, silent or
not, published a query; a leader rank answered them against its rows; the
answers came back; then the followers replayed the pairing to learn what the
leader had decided. The work was O(rotating rows) in queries and O(2) in
collectives, and both halves of a pair did the pairing.

It is now one round and a half, and it is symmetric -- no leader, no follower
replay:

- **Only an emitting term sends.** A rotating row whose coefficient is silent
  (|c| < atol) contributes nothing to the layer unless a partner emits into it,
  and a partner that emits will name it. So the scan sends a record only from
  the rows it emits from, and `sent` is exactly those. On the rungs' operators
  that is a fraction of the rotating rows.

- **A record joins the persistent table once.** The record carries the partner's
  join key and positions; `TableJoin` probes the table (one `find_batch` group
  per gate) and the row is found or it is not. There is no by-value search, no
  second pairing pass, and nothing is recomputed on the way back: the receiver
  decides the pair from what the record says and what its own row says, and the
  sender learns the decision from the response or from its absence.

- **A silent hit is answered in round two.** The one case a receiver cannot
  settle alone is a record that lands on a row the receiver itself did not emit
  from: the sender needs that row's value and the receiver has it. Those and
  only those go back as `{sent_idx, value}` pairs, which is why the second round
  is a half -- it carries `|hits|` responses at most, and in practice the silent
  share of the hits.

- **The absence pass runs over `sent`.** After the responses, a sent record was
  `received` (its partner minted), `answered` (a silent hit came back) or
  neither, and neither means the partner is absent everywhere. That is a walk
  over `sent`, not over the operator.

The receiver rule that makes this consistent without a leader is fixed, and
`ContractSink::hit` is where it is written down: of a rotating pair, the half
that lands on the *emitting* endpoint reads the recovered value and the half
that lands on the silent one reads the record's own pre-cos double. Mint indices
are assigned self stage first, then incoming sources ascending, each in stream
order, so a sender can name a response by ordinal and a receiver can name a mint
by position. Both endpoints must agree on whether the cutoff can reject a
partner at all, so `over_cutoff_possible_` is agreed across the comm once per
call rather than decided locally.

`Engine.h` is reduced to `LayerBuildEngine` and `build_layer`: one arm, the
collective one, with the round-two block extracted. `Resolve.h` is the join and
the three passes over it (`join_self`, `join_incoming`, `join_absence`), with
each record's header decoded once and its tag folded inside the decode loop, and
`value_at`'s branch hoisted by `if constexpr` on the sink. `Scan.h`'s emit is
one templated pass with the five loop-invariant flags hoisted out of `emit_row`
and the two per-record asserts demoted to per-gate. `FusedApply.h` is one loop
over one stream.

Results are unchanged, bit for bit, including under the fused cos sweep. That
sweep scales every anticommuting coefficient in place, so a silent row's slot
holds `v * cos` by the time its partner's record arrives -- and the value the
pairing needs is `v`. It is recovered from the slot rather than carried beside
it: `stored = fl(v * cos_build)` exactly, cos being a single multiply, so
`(stored * cos_build) * inv_cos` returns the *same* double the two-pass path
read, because that path was `op_coeffs[found] * inv_cos`. Which half recovers is
not symmetric -- only the half landing on an emitting leader does, the other
reading the record's exact pre-cos double -- and getting it backwards is 1 ULP on
every asymmetric pair, so `fused_sink_recovers_only_the_found_endpoints_value`
pins both endpoints of one pair rather than the pair's sum.

The merged apply loop pins its own rounding with `std::fma`, and that is part of
being unchanged rather than a change of its own. Main applies each half in an
unmerged loop as `c[tgt] += sin * phase * v`, which GCC contracts under its
default `-ffp-contract=fast` into one `vfmadd`: one rounding. Merging the arms
lets GCC hoist the common `sin * phase * v` product above the `is_insert` test,
and the plain arm is left with a bare add -- two roundings, on every rotation
half of every gate. Measured over the 35 golden cells that costs 2,639,965 of
6,024,567 coefficients a difference from main (median 1-8 ULP, worst relative
7.3e-9, compounding over the layers), with the term sets and monomial sets
identical throughout. `fma(cos, c, sin_phase * v)` and `fma(sin_phase, v, c)`
put the single rounding back where main had it, and the result is bit-identical.
`fused_apply_rounds_each_arm_once` constructs the double-rounding case directly
and compares both arms against the reference `fma`.

The protocol's derivation -- why one round suffices, why the send predicate is
symmetric, and what each of the four partner classes costs -- is in
`docs/content/docs/features/parallelism.mdx`.

Assisted-by: ClaudeCode:claude-opus-5
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation python cpp labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown

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

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant