perf(evolution): ⚡ join a gate's partners in one symmetric round and a half - #354
Draft
diagonal-hamiltonian wants to merge 3 commits into
Draft
diagonal-hamiltonian wants to merge 3 commits into
diagonal-hamiltonian wants to merge 3 commits into
Conversation
…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
|
Important Draft PR not reviewedDraft PRs are not automatically reviewed by default.
To automatically review draft PRs, update your CodeRabbit configuration: reviews:
auto_review:
drafts: trueThanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Docs preview: https://pr-354.monoprop-docs.pages.dev |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🤖 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
TableJoinprobes the persistent term table (
TermTable, PR 4) once per gate, and only the one case a receivercannot 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 cutoffpredicates) let the new join read a term as the row store's own ascending position list and never
expand it to a bitset.
Engine.his reduced toLayerBuildEngineandbuild_layer(one arm, thecollective one);
GateSinks.hsplits what a hit does (GraphSink,ContractSink) from how it isfound;
GateScratch.his the per-gate arena (RowMarks,MissStage,IncomingRecords) reusedacross 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 — theprotocol 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): onefind_batchgroupper 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 foldedinside the decode loop;
value_at's branch hoisted byif constexpron the sink.cpp/monoprop/detail/evolution/layer_build/Scan.h(655 lines): the emit rewritten as one templatedpass, five loop-invariant flags hoisted out of
emit_row, two per-record asserts demoted toper-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.his one loop over one stream.cpp/monoprop/detail/operator/MPOperator.h,TermTable.h:find_batchgrows anOnHitcallback;matched_scratch_bytesrenamedgate_scratch_bytes(d_gate_buffers_hwm_bytes); the fived_gate_*_maxcounters 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_undermonoprop_COMMPROF, and the renamed/dropped ledger keys.Tests
cpp/tests/position_kernels_tests.cpp(new, 165 lines): both signs againstrotation_signover12,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: theledger key rename.
Docs
docs/content/docs/features/parallelism.mdx: the protocol paragraphs (one exchange round; theround-and-a-half for asymmetric pairs; graph mode's symmetric send predicate), the join-key
paragraph,
gate_scratch_bytes/d_gate_buffers_hwm_bytes, themonoprop_COMMPROFenv row.Measurements
Gated positional (
--raw-only) against the second exactness reference (the x2 raw-bit reference), notagainst main's: gate record md5
b8eed0dbdf10f6531eee0910732e345d.bitne 0,maxULP 0,msetne 0,monoseq!= 0over 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::fmawhere main's two separate loops relied on GCC's default-ffp-contract=fasttocontract each into a single
vfmadd. Merging the two loops into one overfc.halveslets GCC hoistthe common
sin * phase * vproduct above theis_insertbranch, 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
fmapin, 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:
The memory payoff lands (peak 0.76-0.79× main;
gate_scratch_bytesreplaces the oldmatched_scratch_bytesat 0.6-0.7× on top of that). Time is +8-9% on Hubbard at both shapes measuredhere (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, inScan.h's emit pass.TableJoin.hhas nolive_bytes()and noTimeProf/MemProfcounters —diagnostics stay out of this stack per the decision recorded in PR 2's Notes.
8e25ad0's pair-once half) and dropping the absentpartner'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 toPR 7, so this PR keeps the flat
[R]per-slotstd::vectors PR 3/PR 4 leave in place.GateScratch.h(absorbs twostructures by design) and
QueryWire.h/PartnerMerge.h(small growth from theHeader-takingoverloads and
count_paired_positionsmoving in); nothing was cut from invariant documentation tohit a number.
load-bearing (
GateSinks.h'sContractSink::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_valuepins both endpoints of onepair, not the pair's sum.
Checklist
docs/,CONTRIBUTING.md) if neededCHANGELOG/ release notes updated if applicable (n/a — the repository has noCHANGELOG)AI/LLM disclosure
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.