perf(evolution): ⚡ carry cross-rank queries as a width-adaptive record - #263
Conversation
1a7bb1e to
407a6a2
Compare
|
Docs preview: https://pr-263.monoprop-docs.pages.dev |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #263 +/- ##
=======================================
Coverage 97.70% 97.70%
=======================================
Files 14 14
Lines 742 742
Branches 98 98
=======================================
Hits 725 725
Misses 12 12
Partials 5 5
Flags with carried forward coverage won't be shown. Click here to find out more. |
407a6a2 to
dce15d1
Compare
dce15d1 to
d05733a
Compare
d05733a to
249aee8
Compare
8dcc0fd to
8579fa9
Compare
|
you did indeed tell me to stack my stuff on top of this one |
#267 landed on main while #263 was open, and its self_resolve_mark_bounded_by_combined_size fixture feeds the engine through detail::query_push -- the dense record #263 retires. The rebase onto that main left the call in place, so origin/pr/query-wire-v3 does not compile: query_push now lives in cpp/tests/dense_query_reference.h as test_ref::query_push, an oracle for the codec differential and not an engine entry point. Ported rather than renamed. Under the positions-staged self leg the fixture would have tripped resolve_self_queries' own assertion -- "a self-owned query was encoded instead of staged" -- because at my_rank == 0 the engine reads self_stage_ and requires queries_r[my_rank] empty. It now pushes each term's ascending positions onto self_stage_, which is what the scan does, and every assertion the case made about marking past combined_size is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
94a33f5 to
9ff9d73
Compare
|
Rebased onto current The commit that mattered is #270 ( Verified after the rebase: 270/270 #296 stacks GF(2)-linear rank routing on top of this and is now based on this branch. |
## What this is Two commits that make a benchmark result **identify the geometry it was measured at**, plus the reader change that surfaces them. Nothing in the engine changes; both commits are Python, outside `cpp/`. - `benches/conftest.py` records `nodes`, `ranks_per_node`, `partitions_env` and `memhwm_max`. - `report.py` / `test_report.py` surface them. ## Why A scaling result is `(nodes, ranks_per_node, partitions)`, and until now a recorded run carried **none** of the three. `meta.ranks` is the MPI world size, which is the product of two of them and cannot be factored back. That matters more than it sounds, because the engine's flat world is `P = ranks_per_node × partitions × nodes` and `HybridComm::size()` returns `r_ * s_` — so `8 ranks × 16 partitions` on 32 nodes and `1 rank × 128 partitions` on 32 nodes are both `P = 4096` and are *not* the same measurement. They differ by 2.06x in wall time (table below). Two runs that differ 2x were previously indistinguishable in the recorded JSON. Three details worth review: - **`nodes` is collective.** It is an `allgather` of `socket.gethostname()`, so `pytest_configure` had to call it *before* the rank-0 early return — every rank must enter it or it hangs. Serial short-circuits to `(1, 1)` without touching MPI. - **`partitions_env` is the environment variable, not a resolved count**, and is labelled "Partitions (requested)" for that reason. No property exposes the count the engine actually chose. A bare `Partitions` column would have claimed otherwise. - **`memhwm_max` exists because `memhwm` is a sum over ranks.** The two differ by the rank count. The old heading said neither, so this renames the existing table to "summed across ranks" and gives the max its own. On a 1024-rank run the sum is 460 GiB and the max is 0.48 GiB; a reader who guessed wrong about which one they were looking at would be off by 1000x. `world` is deliberately *not* emitted: it is derivable from what is recorded, and a stored derived field is a second source of truth that can disagree. ## What it was used for A strong- and weak-scaling study of `propagate` on Hubbard at **1,569,152,761 terms** (cutoff 10, 60 sites, `lower_atol=2.6e-06`), on Deucalion x86 (2x EPYC 7742, 128 cores/node, SMT off). Layout `8 ranks x 16 partitions` per node, so the engine's flat world is `P = 128 x nodes`. Baseline is this branch's parent; the port arm is #263. 3 reps per cell, A/B interleaved inside one allocation with the arm order flipped per rep, ratios taken as the median of per-rep *paired* ratios. ### Strong scaling — the problem is fixed, only the hardware grows | N | P | propagate s | speedup | efficiency | ledger coverage | | ---: | ---: | ---: | ---: | ---: | ---: | | 1 | 128 | 281.0 | 1.00 | 100% | 91.0% | | 2 | 256 | 152.6 | 1.84 | 92% | 85.4% | | 4 | 512 | 103.2 | 2.72 | 68% | 76.5% | | 8 | 1024 | 82.2 | 3.42 | 43% | 65.2% | | 16 | 2048 | 69.4 | **4.05** | 25% | 49.9% | | 32 | 4096 | 88.5 | **3.18** | 10% | 40.4% | **Scaling reverses between 16 and 32 nodes.** Reproduced in two independent passes (a 1-rep pass gave 4.07 → 3.21, a 3-rep pass 4.05 → 3.18). Weak efficiency falls **100.0 / 81.6 / 56.2 / 38.5 / 27.1 / 16.0%** over N = 1…32, from main-arm walls of 18.7 / 21.7 / 32.4 / 49.0 / 69.8 / 116.6 s. It is normalised by the **measured** terms/node recorded in each cell's ledger (92.06–98.07M across the six rungs), not by an assumed-flat target — the rungs are close enough that the distinction is small, and far enough apart that assuming it would have been wrong. Instrument self-check, and the reason the `nodes` key earns its place: `strong/16` and `weak/16` are the *identical* cell (1.569G terms at N=16) run in two separate allocations, and they agree to **0.56%** (base) and **0.26%** (port). Before this commit the two were indistinguishable in the recorded JSON and could not have been cross-checked at all. ### Where the time goes — this is what `time -v` says once User and System are separated Same runs, main arm, median rep, summed over ranks: | N | ranks | user core-s | system core-s | system share | user vs N=1 | system vs N=1 | | ---: | ---: | ---: | ---: | ---: | ---: | ---: | | 1 | 8 | 29,008 | 6,262 | 17.8% | 1.00x | 1.00x | | 4 | 32 | 30,316 | 21,382 | 41.4% | 1.05x | 3.41x | | 8 | 64 | 38,402 | 45,287 | 54.1% | 1.32x | 7.23x | | 16 | 128 | 56,555 | 83,546 | 59.6% | 1.95x | 13.34x | | 32 | 256 | 117,014 | 241,152 | **67.3%** | 4.03x | **38.51x** | Total CPU consumed grows **10.14x** for a problem that does not change, against a 3.18x speedup — so ~90% of the CPU burned at N=32 does not exist at N=1. It is overwhelmingly *system* time, and system time is very nearly linear in MPI rank count (exponent log 38.51 / log 32 = 1.05). ### Layout at fixed flat world — six geometries, same P At N=32, `P = 4096` is reachable at six splits of 128 cores/node, with the MPI world size spanning 32x. This is the measurement `ranks_per_node` exists to make legible: | layout | partitions/rank | MPI ranks | propagate s | vs 8x16 | threads busy | peak RSS sum | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | 1x128 | 128 | 32 | 212.8 | 2.24x | 95% | 173.3 GiB | | 2x64 | 64 | 64 | 122.5 | 1.29x | 70% | 177.6 GiB | | 4x32 | 32 | 128 | **94.4** | 0.99x | 78% | 190.6 GiB | | 8x16 | 16 | 256 | **95.0** | 1.00x | 58% | 218.7 GiB | | 16x8 | 8 | 512 | 127.3 | 1.34x | 66% | 278.6 GiB | | 32x4 | 4 | 1024 | 300.2 | 3.16x | 62% | 460.4 GiB | Three things worth having in the record: - **A U with its minimum at the current default.** `8x16` is within 0.2% of the best cell, so there is no free win in the geometry, and the 3.18x spread across the row is entirely invisible to a JSON that records only `ranks`. - **Thread occupancy is anti-correlated with speed.** `1x128` saturates its cores at 95% busy and is the *slowest* cell; `8x16` runs 58% busy and is the *fastest*. Busy is not productive. - **Peak RSS is linear in MPI ranks at ~0.29 GiB per process** (173.3 GiB at 32 ranks to 460.4 GiB at 1024, so +287 GiB over +992 ranks). This is what `memhwm_max` beside `memhwm` is for: the summed figure spans 173→460 GiB across the row while the per-rank max *falls* 5.55→0.48 GiB. A reader who mistook one for the other would be wrong by three orders of magnitude. ### #263, for reference Its paired ratio is ~0.8 at narrow widths and **0.96 at N=32**, and it tracks the paired CPU-seconds ratio to within +0.014/−0.025 at every rung — i.e. predominantly work removal, diluted as that work becomes a smaller share of a growing overhead. Per layout at N=32 it runs 0.95 / 0.85 / 0.80 / 0.91 / 0.97 / 0.73 for 1x128 / 2x64 / 4x32 / 8x16 / 16x8 / 32x4. **The last is not quotable and is shown only for completeness:** 32x4's three paired ratios are 0.975 / 0.727 / 0.541, and its main arm alone spans **1.93x** within the cell (300.2 / 332.1 / 579.2 s) where every other cell holds inside 6%. An earlier revision of this body printed 0.975 for it — that was rep 1 read while the cell was still filling, which is exactly the failure mode a 1024-rank cell invites. Any claim at this width needs far more than 3 reps. **Every ratio above is descriptive, not resolved.** Three paired reps floor the uncorrected two-sided sign test at p=0.25, so no 3-rep cell in this comment carries statistical significance; the ladder buys the shape. The layout comparison is additionally *unpaired* — six allocations on six node sets — so no sign test applies to it at all. #### Resolved at 10 reps, at the two rungs where the answer was in doubt | N | reps | port/main median | agree | sign p | system ratio | | ---: | ---: | ---: | ---: | ---: | ---: | | 16 | 10 | **0.691x** | **10/10** | **0.0020** | 0.633x | | 32 | 10 | 0.950x | 6/10 | 0.754 | 0.938x | Per-rep ratios, N=16: 0.671 / 0.677 / 0.680 / 0.689 / 0.689 / 0.693 / 0.715 / 0.723 / 0.755 / 0.858. N=32: 0.890 / 0.894 / 0.897 / 0.917 / 0.942 / 0.959 / 1.004 / 1.005 / 1.007 / 1.019. **The two distributions do not overlap at all** — every N=16 ratio is below every N=32 ratio. An exact two-sided Mann-Whitney (the appropriate test, because comparing two rungs is *unpaired*: separate allocations, separate node sets) gives **U = 0, p = 1.08e-5**, which is the floor for a 10-vs-10 design. So this is not "0.69 versus 0.95, probably different" — it is the strongest separation the experiment can express. **Conclusion: #263 buys a resolved ~1.45x at 16 nodes and is indistinguishable from no change at 32.** Quoting a single number for "#263's improvement" is therefore wrong; the benefit is scale-dependent and disappears at the flat world size where `propagate` itself reverses (P = 4096). Two independent ladders agree on this: the weak ladder's 32-node rung is likewise its weakest cell. **A calibration warning that applies to every 3-rep figure above, including mine.** The same comparison, same two binaries (installed `_core.so` md5 `edb00c44…` vs `7dbc7de1…`, neither rebuilt between runs — both `.so` files predate both jobs), byte-identical configuration, differing only in rep count, measured **0.857x at 3 reps and 0.691x at 10** on two different 16-node allocations. The 3-rep median landed at the very top of the 10-rep per-rep range. So a 3-rep ratio here can be ~24% away from the resolved value even with paired, order-flipped interleaving inside one allocation — far beyond the ~1.7% between-job drift seen elsewhere. Read the 3-rep tables above for shape only, never for magnitude. ## A large part of this wall was our own benchmark environment, not the engine Reported here because it changes how the walls above should be read, and because it is a trap any HPC harness for a multi-threaded engine can fall into. The harness that produced every number above exported `MALLOC_ARENA_MAX=$PARTITIONS` = 16. The engine runs **19** threads per rank (16 partition masters, the main thread, the OFI `async` progress thread, and one more Python thread), so late claimants share an arena and every `malloc`/`free` in the local-work region becomes a futex sleep. Unsetting it, one binary, 10 paired reps per rung with the arm order flipped every rep inside a single allocation: | N | unset / `=16` | reps agreeing | sign p | median wall | saving | peak RSS | | ---: | ---: | ---: | ---: | --- | ---: | ---: | | 16 | **0.7732x** | 10/10 | 0.0020 | 69.02 → 53.34 s | 15.67 s (22.7%) | +0.080 GiB/node | | 32 | **0.8304x** | 10/10 | 0.0020 | 94.52 → 76.32 s | 18.19 s (19.2%) | +0.107 GiB/node | **Do not subtract these savings from the walls in the tables above.** The `=16` arms here read **94.52 s** at N=32 where the strong-scaling table reports 88.5 s — a 6.8% gap — while a separate, independently-instrumented probe of the same configuration read 94.50 s, agreeing with these runs to **0.02%**. Two runs agreeing to two parts in ten thousand means 94.5 s is the figure for *this* provisioning and the 88.5 s cell was provisioned differently. So "88.5 − 18.19 = 70.3 s" is a configuration mix, and it looks entirely reasonable on the page. The paired ratios are safe precisely because each lives inside one allocation; only the cross-quoting of absolute seconds is not. `MALLOC_ARENA_MAX` is a **cap**, not an allocation: glibc creates an arena only when a thread finds the existing ones contended, at most one per thread, so unsetting it yields ~19 arenas rather than the `8 × ncores` = 1024 the documentation's formula suggests. That is why the memory cost is ~0.1 GiB/node against ~245 GiB of headroom, and why there is no trade to weigh here. **This is a wall win at every width, not a scaling fix, and the distinction is easy to miss.** Both walls fall substantially and the reversal gets *worse*: 1.370x → **1.431x**, because the relative saving is larger at the smaller rung. Removing a cost that is roughly rung-independent always shrinks the smaller wall proportionally more. A reader checking only that both numbers improved would conclude the opposite of what happened. Two consequences for the tables above, stated rather than quietly corrected: - **The A/B ratios stand.** Both arms of every comparison ran under the same contended allocator, and the comparisons are paired within one allocation. The #263 result (0.691x at N=16, 0.950x at N=32) is unaffected. - **The attribution *shares* are distorted**, because arena contention is charged to the barrier-wait and system-time terms. The `system share` column in particular — 17.8% → 67.3% across the ladder — is measuring the engine and this misconfiguration together, and no split between the two is offered here. One diagnostic worth recording, because it is what ruled out every topology explanation: **which** partition indices are penalised changes between runs of the identical configuration on the same nodes. A core, cache, NUMA, or IRQ-affinity story predicts a stable set. Only a lazy, racy arena assignment reproduces that. `voluntary_ctxt_switches` on an affected thread tracked the penalty exactly (557,537 vs 68) while minor faults stayed flat, so it is lock contention rather than page-fault churn. ## Reproducibility **The numbers above are not reproducible from this diff.** They were produced by a private harness (allocation management, A/B interleaving, `/usr/bin/time -v` collection, collation) that is not in this repo and is not proposed for it. This PR contains only the recording and reporting change that makes such a campaign *interpretable*. Every figure is quoted against the commit that measured it. --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ramón L. Panadés-Barrueta <rpana92@gmail.com>
|
@robertodr your back is scratched. Chop chop |
#267 landed on main while #263 was open, and its self_resolve_mark_bounded_by_combined_size fixture feeds the engine through detail::query_push -- the dense record #263 retires. The rebase onto that main left the call in place, so origin/pr/query-wire-v3 does not compile: query_push now lives in cpp/tests/dense_query_reference.h as test_ref::query_push, an oracle for the codec differential and not an engine entry point. Ported rather than renamed. Under the positions-staged self leg the fixture would have tripped resolve_self_queries' own assertion -- "a self-owned query was encoded instead of staged" -- because at my_rank == 0 the engine reads self_stage_ and requires queries_r[my_rank] empty. It now pushes each term's ascending positions onto self_stage_, which is what the scan does, and every assertion the case made about marking past combined_size is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses the review on #263. Four changes, no wire-format change. Drop the (k, d) structural-cutoff fast path. Its evidence was ~0.8% of build_graph, below its own noise floor at 128 partitions, against ~410 lines: SparseMonomial.h, the digest overloads in AlgebraCommon.h, the d bookkeeping in the merge, and two test files. AlgebraCommon.h and core/CMakeLists.txt now diff empty against main. The emit site is back to passes_with_popcount, which this stack had left dead in production, and the merge loops lost the duplicated body that only existed to fold d. cutoff_sums' width coverage moved into majorana_cutoff_tests.cpp rather than going with the deleted files. Fold QueryCodec.h into the record type, now QueryWire<N> in QueryWire.h. Seven of its members were pure forwarders; the six that did work moved onto the record. QueryLayout{bool} becomes enum class QueryForm. Deletes the QueryRecord and CQ aliases, kReserveWordsPerQuery, and the dense-monomial push_mono/read_mono pair, which had no production caller -- removing them also removes this header's use of Monomial, which it named without including. Capture explicitly in bulk_insert's hash lambda, and drop the Writer's unreachable width == 64 branch for an assert. Cut the comments to the house budget: 22.7% of the added lines to 8.9%, against a 13.1% baseline on untouched neighbours. Gone are the measurements, the profiling and callgrind references, the comparisons to the replaced code, six banner dividers, five //: prefixes, and the shouted words. Also gone is a test that re-implemented a deleted encoder purely to price it -- history, which git already has. Assisted-by: ClaudeCode:claude-opus-5
4d9f477 to
3098f5c
Compare
|
I'm chopping wood like never before |
#267 landed on main while #263 was open, and its self_resolve_mark_bounded_by_combined_size fixture feeds the engine through detail::query_push -- the dense record #263 retires. The rebase onto that main left the call in place, so origin/pr/query-wire-v3 does not compile: query_push now lives in cpp/tests/dense_query_reference.h as test_ref::query_push, an oracle for the codec differential and not an engine entry point. Ported rather than renamed. Under the positions-staged self leg the fixture would have tripped resolve_self_queries' own assertion -- "a self-owned query was encoded instead of staged" -- because at my_rank == 0 the engine reads self_stage_ and requires queries_r[my_rank] empty. It now pushes each term's ascending positions onto self_stage_, which is what the scan does, and every assertion the case made about marking past combined_size is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses the review on #263. Four changes, no wire-format change. Drop the (k, d) structural-cutoff fast path. Its evidence was ~0.8% of build_graph, below its own noise floor at 128 partitions, against ~410 lines: SparseMonomial.h, the digest overloads in AlgebraCommon.h, the d bookkeeping in the merge, and two test files. AlgebraCommon.h and core/CMakeLists.txt now diff empty against main. The emit site is back to passes_with_popcount, which this stack had left dead in production, and the merge loops lost the duplicated body that only existed to fold d. cutoff_sums' width coverage moved into majorana_cutoff_tests.cpp rather than going with the deleted files. Fold QueryCodec.h into the record type, now QueryWire<N> in QueryWire.h. Seven of its members were pure forwarders; the six that did work moved onto the record. QueryLayout{bool} becomes enum class QueryForm. Deletes the QueryRecord and CQ aliases, kReserveWordsPerQuery, and the dense-monomial push_mono/read_mono pair, which had no production caller -- removing them also removes this header's use of Monomial, which it named without including. Capture explicitly in bulk_insert's hash lambda, and drop the Writer's unreachable width == 64 branch for an assert. Cut the comments to the house budget: 22.7% of the added lines to 8.9%, against a 13.1% baseline on untouched neighbours. Gone are the measurements, the profiling and callgrind references, the comparisons to the replaced code, six banner dividers, five //: prefixes, and the shouted words. Also gone is a test that re-implemented a deleted encoder purely to price it -- history, which git already has. Assisted-by: ClaudeCode:claude-opus-5
3098f5c to
5068659
Compare
robertodr
left a comment
There was a problem hiding this comment.
I would ask two things:
- It is not straightforward to get how the encoding for
QueryWireworks in practice. Can you add an explicit example using a Doxygen docstring? - Could you maybe try to tone down the C-isms? I'm not sure all of them are warranted, even in performance-sensitive contexts.
Also a concern, which might be me overthinking: there are a lot of asserts but these do nothing in Release so I don't know if we'll ever catch anything with them... Raising exceptions/returning std::expected seems also a bad idea though 🤷
#267 landed on main while #263 was open, and its self_resolve_mark_bounded_by_combined_size fixture feeds the engine through detail::query_push -- the dense record #263 retires. The rebase onto that main left the call in place, so origin/pr/query-wire-v3 does not compile: query_push now lives in cpp/tests/dense_query_reference.h as test_ref::query_push, an oracle for the codec differential and not an engine entry point. Ported rather than renamed. Under the positions-staged self leg the fixture would have tripped resolve_self_queries' own assertion -- "a self-owned query was encoded instead of staged" -- because at my_rank == 0 the engine reads self_stage_ and requires queries_r[my_rank] empty. It now pushes each term's ascending positions onto self_stage_, which is what the scan does, and every assertion the case made about marking past combined_size is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses the review on #263. Four changes, no wire-format change. Drop the (k, d) structural-cutoff fast path. Its evidence was ~0.8% of build_graph, below its own noise floor at 128 partitions, against ~410 lines: SparseMonomial.h, the digest overloads in AlgebraCommon.h, the d bookkeeping in the merge, and two test files. AlgebraCommon.h and core/CMakeLists.txt now diff empty against main. The emit site is back to passes_with_popcount, which this stack had left dead in production, and the merge loops lost the duplicated body that only existed to fold d. cutoff_sums' width coverage moved into majorana_cutoff_tests.cpp rather than going with the deleted files. Fold QueryCodec.h into the record type, now QueryWire<N> in QueryWire.h. Seven of its members were pure forwarders; the six that did work moved onto the record. QueryLayout{bool} becomes enum class QueryForm. Deletes the QueryRecord and CQ aliases, kReserveWordsPerQuery, and the dense-monomial push_mono/read_mono pair, which had no production caller -- removing them also removes this header's use of Monomial, which it named without including. Capture explicitly in bulk_insert's hash lambda, and drop the Writer's unreachable width == 64 branch for an assert. Cut the comments to the house budget: 22.7% of the added lines to 8.9%, against a 13.1% baseline on untouched neighbours. Gone are the measurements, the profiling and callgrind references, the comparisons to the replaced code, six banner dividers, five //: prefixes, and the shouted words. Also gone is a test that re-implemented a deleted encoder purely to price it -- history, which git already has. Assisted-by: ClaudeCode:claude-opus-5
5068659 to
8697f6d
Compare
Five changes to the layer-build query path, squashed because the middle three share one data structure and the first is not measurable without them. The structural cutoff is decided from a (k, d) digest carried inline rather than from the wider comparison it replaced. Cross-rank queries then travel as a width-adaptive record, dropping the fixed stride the older CompactQuery design assumed. Queries whose owner is this rank resolve from positions instead of going through the cross-rank path at all. Two further changes ride with them and are named here because a reviewer cannot revert along a mechanism the message does not mention. Sink::kStride becomes incoming_layout()/querier_layout(): ContractSink's stride was the fused width while on_response_block was called with the plain buffer and took query_phase's plain-stride default, so the two agreed only by accident, and a wrong layout reads a neighbouring record's phase -- a coefficient sign flip, not a crash. And bulk_insert gains a group-prefetched hashed path, shared by the cross-rank and self inserts. Tests cover the paths that can disagree: a dense reference implementation the sparse path is differentially compared against, the digest's tie cases, the spill boundary where a row exceeds the inline width, and the prefetched insert. OperatorIndex gains a public overflow_size() so a test can assert the dense and positional insert paths spill identically -- the claim this change rests on, and otherwise unreachable from outside the class. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The self leg encoded a query record and decoded it straight back on the same rank: Scan pushed through QueryCodec for every destination including my_rank, and resolve_range_ inflated it again into self_pos_flat_ before calling find_batch_positions -- whose signature is already the flat-positions form the encoder had just destroyed. At one partition that round trip is 100% of queries against exchange_s = 0.0000. SelfQueryStage carries them as positions instead, and merge_partner_positions supplies the positions, k, the cutoff digest's d and the overlap in the one merge that replaces the walk. Four measured shapes were rejected on the way and are recorded at their sites so they are not tried again: a lambda in the merge (15,279,191 non-inlined calls), thread_local for the position buffer (__tls_get_addr, 54.7M), a byte loop for row_eq_positions (glibc's AVX2 memcmp wins at ~5 bytes), and walking the dense partner instead of merging (+229.1M, find_next is a serial dependence chain). Layout 1x1 against the query-wire record, 10 reps interleaved, 10/10 p=.00: build_graph[hubbard] 0.87x, propagate[hubbard] 0.90x, build_graph[pauli] 0.79x, propagate[pauli] 0.76x. energy/gradient 1.00x as the null control, peak RSS flat. Callgrind: 9,720.99M -> 9,265.72M instructions, floor 0.052%. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The record chose per-term between three closed forms -- raw lanes, gap coding and a raw bitmap -- behind a 2-bit mode field, and sized a stack Writer array from the worst case of the three. Replayed over the 106,368 captured query records, BITMAP was chosen 0 times and FIXED only on word-count ties, so the argmin was paying for two branches that never won. Gap coding alone, with the mode field's two bits and one of k's six spent on a narrower header: 11 bits against 14. gw = bit_width(max gap) <= kPosBits, so kPosBits + (k-1)*gw <= k*kPosBits and gap is never wider than raw lanes at any k. A raw mask does win once k*kPosBits > kBits, from k = 34 at 128 modes, where this record costs one word more -- above the k <= 32 that Pauli cutoff 16 implies and 11 above the widest k ever captured. That is the price of one code path, and sparse_record_documents_what_deleting_the_argmin_cost asserts it against the deleted encoder's own three formulas rather than leaving it to be rediscovered. Dropping the argmin also dropped the array it sized: gap coding emits bits monotonically, so the encoder streams into the output buffer through one accumulator and no longer zeroes 48 B per push. Bytes per record, predicted from the captured streams before the code existed and confirmed by it: hubbard c10 8.289 -> 8.038, pauli c12 15.519 -> 15.180, pauli c6 8.750 -> 8.438. Both formats decode the same positions on all 106,368 records (identical checksums). Callgrind over a standalone replay, where the binary is bit-exactly repeatable: push 345.1 -> 292.5 instructions per record (-15.2%), decode -3.0, every libc counter unchanged. The byte win is ~3% of a payload whose byte-proportional share of exchange is ~24%, so it is worth <= 0.2% of wall clock and no timing claim is made for it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#267 landed on main while #263 was open, and its self_resolve_mark_bounded_by_combined_size fixture feeds the engine through detail::query_push -- the dense record #263 retires. The rebase onto that main left the call in place, so origin/pr/query-wire-v3 does not compile: query_push now lives in cpp/tests/dense_query_reference.h as test_ref::query_push, an oracle for the codec differential and not an engine entry point. Ported rather than renamed. Under the positions-staged self leg the fixture would have tripped resolve_self_queries' own assertion -- "a self-owned query was encoded instead of staged" -- because at my_rank == 0 the engine reads self_stage_ and requires queries_r[my_rank] empty. It now pushes each term's ascending positions onto self_stage_, which is what the scan does, and every assertion the case made about marking past combined_size is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
prek pins clang-format v21.1.0 and three of the lines PR A and PR B wrote were not in its shape: two comment columns in PartnerMerge.h, a four-line parameter list in mpi_utils_tests.cpp that fits on one, and a BOOST_TEST message split a token earlier than needed. `git diff -w` reports PartnerMerge.h as empty and the other two as line joins with every token conserved, so nothing here can reach codegen -- it is the lint leg only, which was the one red left on the PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Addresses the review on #263. Four changes, no wire-format change. Drop the (k, d) structural-cutoff fast path. Its evidence was ~0.8% of build_graph, below its own noise floor at 128 partitions, against ~410 lines: SparseMonomial.h, the digest overloads in AlgebraCommon.h, the d bookkeeping in the merge, and two test files. AlgebraCommon.h and core/CMakeLists.txt now diff empty against main. The emit site is back to passes_with_popcount, which this stack had left dead in production, and the merge loops lost the duplicated body that only existed to fold d. cutoff_sums' width coverage moved into majorana_cutoff_tests.cpp rather than going with the deleted files. Fold QueryCodec.h into the record type, now QueryWire<N> in QueryWire.h. Seven of its members were pure forwarders; the six that did work moved onto the record. QueryLayout{bool} becomes enum class QueryForm. Deletes the QueryRecord and CQ aliases, kReserveWordsPerQuery, and the dense-monomial push_mono/read_mono pair, which had no production caller -- removing them also removes this header's use of Monomial, which it named without including. Capture explicitly in bulk_insert's hash lambda, and drop the Writer's unreachable width == 64 branch for an assert. Cut the comments to the house budget: 22.7% of the added lines to 8.9%, against a 13.1% baseline on untouched neighbours. Gone are the measurements, the profiling and callgrind references, the comparisons to the replaced code, six banner dividers, five //: prefixes, and the shouted words. Also gone is a test that re-implemented a deleted encoder purely to price it -- history, which git already has. Assisted-by: ClaudeCode:claude-opus-5
8697f6d to
105ceca
Compare
Addresses robertodr's review on #263. No wire-format change and no change to what any of this computes. QueryWire carries a Doxygen docstring with the bit layout spelled out and a worked example: positions {3, 7, 8, 40} at 128 modes pack into 34 bits, one word, against the four a 256-bit dense stride spends. The bracketed field list the review could not read is gone in favour of that. QueryForm keeps the one-line description the review suggested. Pointer+length pairs and reference out-params become std::span and returned aggregates, which is already the idiom in MPFunctions.h, Exchange.h, InvertedIndex.h and Scan.h. push, read_positions, read_query, gap_width and pair_count take a contiguous range -- the element type stays deduced because the store's position width is narrower than the wire's below 129 modes -- and the two decode calls return Decoded{next, phase} instead of writing through an int&. merge_partner_positions loses two lengths, an out-pointer and an overlap& for three ranges and MergedPartner. OperatorIndex's five position entry points take spans, so find_batch_positions is five arguments rather than six with a nullable tail, and RowPositions holds a span. IncomingProbe grows positions_at(g), which replaces pos_flat.data() + pos_off[g] at three call sites. Writer and Reader stay: they are a variable-width bit-field packer, which the repo has no other instance of, and bitset_to_indices answers a different question in a different index convention. [[gnu::always_inline]] stays too -- seven prior uses on main. Drops 17 of the 24 runtime asserts this stack added. main carries 9, all in detail/, and NDEBUG is set in Release and RelWithDebInfo, which is the only build test.yml ever runs -- so the ones that restated the code's own arithmetic or walked a stream were documentation with no reader. What is left prevents undefined behaviour or a silently wrong encode at an entry point. The popcount identity dropped from the emit site is already asserted in partner_merge_tests.cpp. Assisted-by: ClaudeCode:claude-opus-5
…assert QueryWire::check_header existed only to be called from an assert inside read_positions, so it never ran: the suite builds Release, where NDEBUG removes it. It moves to dense_query_reference.h as wire_header_is_consistent and is asserted from the differential, which also lets it check k against the positions handed back -- the library version took k from the header it was validating. Assisted-by: ClaudeCode:claude-opus-5
|
🤖 AI text below 🤖 Review pass:
|
| was | is |
|---|---|
push(buf, const PosU *pos, size_t k, int phase) |
push(buf, pos, phase) |
read_query(buf, form, off, OutT *out, int &phase_out) |
read_query(buf, form, off, out) -> Decoded{next, phase} |
merge_partner_positions(a, ka, b, kb, out, size_t &overlap_out) |
merge_partner_positions(a, b, out) -> MergedPartner{count, overlap} |
find_batch_positions(pos_flat, pos_off, k_of, n, out, hash_out = nullptr) |
five spans; n is pos_off.size(), the optional tail is an empty span |
RowPositions{const PosT *pos; size_t count;} |
holds a std::span |
pos_flat.data() + pos_off[g], at three sites |
IncomingProbe::positions_at(g) |
gap_width, pair_count, read_positions, set_positions, row_eq_positions,
find_positions_, fold_hash_positions and SelfQueryStage::push go the same way. The element
type stays deduced (a contiguous_range) rather than fixed, because OperatorIndex::PosT is
uint8_t/uint16_t/uint32_t by NumModes while the wire's is always uint16_t — that
genericity is real, not decoration. Every call site got shorter; they all passed
.data(), .size().
Two things I kept, with reasons rather than by default: Writer/Reader (answered on the
thread — a variable-width bit-field packer, which bitset_to_indices is not and the repo has no
other instance of) and [[gnu::always_inline]], which has seven prior uses on main.
Resolve.h's std::vector<double> *lv is still a nullable-pointer-as-optional; I left it, as
it is an owning container rather than a view, but say the word.
3. The asserts — you were right, and it was worse than it looked
main carries 9 runtime asserts, all in detail/. This stack had added 24 on top. And
pyproject.toml:131 pins cmake.build-type = "Release", CMakeLists.txt:41 defaults to it, and
every cmake/compiler_flags/*.cmake puts -DNDEBUG in RELEASE and RELWITHDEBINFO — while
test.yml:239,248 and the justfile only ever run ctest --test-dir build/editable/Release. So
none of them executed anywhere, including CI. Only the coverage job keeps them live. clang-tidy
also runs against the Release compile_commands.json, so their bodies are not even linted.
Rule applied: keep only what prevents undefined behaviour or a silently wrong encode at an
entry point; anything restating the code's own arithmetic or walking a stream becomes a test.
24 added asserts → 7; the branch total goes 33 → 16 against main's 9, and main's own 9 are
untouched. The 26 static_asserts all stay — compile-time, always on, and they are what pin the
header layout.
The one that mattered: check_header existed only to be called from an assert inside
read_positions, so it had never run. It moves to cpp/tests/dense_query_reference.h as
wire_header_is_consistent and is asserted from the differential — which also lets it check k
against the positions handed back, where the library version took k from the header it was
validating. 13 dead asserts became one check that runs in CI. The popcount identity dropped from
the emit site is already asserted in partner_merge_tests.cpp:74.
Gates
ctest -L serial on a build made from this source: 100% tests passed, 0 tests failed out of 270, 538 s. clang-format --dry-run --Werror clean on every touched file.
monoprop_link_export_probe remains the one red unit case, and it is red on a24143fb itself
(#313's own regression test, MPI_Comm_size before MPI_Init) — unchanged by this branch.
Performance
You asked whether this slowed anything down, so I measured it rather than asserting it from
"semantically identical". It does not.
Interleaved A/B in one allocation, arms alternating with the order flipped every rep, 10 reps,
ratio taken as the median of per-rep paired ratios. main = 8697f6d9 (the branch as you
reviewed it), port = 6c606f18. The arms are provably different binaries: _core.so
e39ec0db… vs c27cb883…, and .text 7,144,041 → 7,147,113 bytes, so this is not two copies
of one build being compared.
| layout | operation | main med | port med | port/main | raw p | Holm p | |
|---|---|---|---|---|---|---|---|
| X_1x1 | propagate[hubbard] |
2440.9 ms | 2415.2 ms | 0.99x | .0020 | .0117 | resolved, faster |
| X_1x1 | propagate[pauli] |
500.4 ms | 495.0 ms | 0.99x | .0020 | .0117 | resolved, faster |
| X_1x1 | build_graph[pauli] |
627.0 ms | 618.8 ms | 0.99x | .0215 | .086 | unresolved |
| B_8x16 | build_graph[pauli] |
789.4 ms | 763.8 ms | 0.98x | .34 | .69 | unresolved |
| B_8x16 | propagate[hubbard] |
1949.1 ms | 1962.6 ms | 1.01x | .11 | .33 | unresolved |
| B_8x16 | propagate[pauli] |
699.9 ms | 700.4 ms | 1.01x | .75 | .75 | unresolved |
Holm across the family of six. No cell resolves as slower. Two resolve as ~1% faster, and I
am not going to claim that as a win: I cannot name the line that would cause it, the two B_8x16
propagate cells point the other way while unresolved, and a 3 KB shift in .text moves inlining
decisions on its own. The honest reading is "no detectable cost, with a hint of a small
improvement I can't attribute".
Peak RSS, kernel truth from /usr/bin/time -v around every rank: 1.00x at B_8x16 and 1.01x at
X_1x1, both flat.
Provenance held on both cells, including the placement gate, which is a property of the layout and
was read off the baseline arm rather than declared: B_8x16 pins 16 NUMA-aligned threads per rank on
both arms, X_1x1 pins nothing on either.
Two limits worth stating rather than burying. The rung is small — hubbard 1.17M terms, pauli
223K, ~250 MB/rank — so this resolves a regression of a few percent, not a fraction of one; and
build_graph[hubbard] is not covered, because the suite skips it by its own guard (29
successive calls retain 29 layer-sets, over 242 GiB) unless monoprop_BENCH_ALLOW_BIG_GRAPH=1,
which at the default config would OOM the node.
One for someone else
harness/build.sh broke on #314: passing monoprop_ENABLE_MPI as a config-setting gets the cmake
define but not the build.requires injection, because a [[tool.scikit-build.overrides]] if.
clause matches the environment only. Configure then dies in src/monoprop/bindings/CMakeLists.txt
while mpi4py sits in the project venv — the missing copy is the one in the isolated build
environment. Fixed on my side by exporting the variable; flagging it in case other tooling drives
the build the same way. The new FATAL_ERROR message is what made it a five-minute diagnosis
rather than an afternoon, so that part of #314 earned its keep.
|



🤖 AI text below 🤖
Carry cross-rank queries as a width-adaptive record
pr/query-wire-v3, onmain999caf18. The layer-build query path carried one dense word per 64modes per query, at a fixed stride. This replaces it with a per-term-minimal record and stops the two
paths that never needed the wire from using it.
What changes
CompactQuerydesign assumed is gone; a record is gap-coded ascending positions, sized by its own popcount.
Sink::kStridebecomesincoming_form()/querier_form(), fixing a latent inconsistency:ContractSink::kStridewas the fused width whileon_response_blockgot the plain buffer andused the plain-stride default — correct only by accident. A wrong form reads a neighbouring
record's phase, which is a coefficient sign flip, not a crash.
bulk_insertgains a hashed entry point, so a miss found by position reuses the hash theprobe already folded instead of rebuilding a monomial to re-fold it.
1 and 2 ship together because they share the
OperatorIndexposition store.Timings
Arms:
main999caf18— this branch's own merge-base — against this branch, bothENABLE_PROFILE=OFF. Extension md5sb201ec44(main) ande39ec0db(branch).port/main, so every row below 1.00 is this branch faster. 10 interleaved reps per cell in oneallocation, order flipped per rep; ratios divided per rep then median, never a ratio of medians —
which is why the ratio column does not equal the two median columns divided.
rungislower_atol, with Trotter steps where they differ, and separates cells that are differentmodel sizes. Every
pis a raw two-sided sign test.Production layout (8 ranks x 16 partitions, 1 node)
build_graph[hubbard]Single thread (1 rank x 1 partition)
build_graph[hubbard]propagate[hubbard]build_graph[pauli]propagate[pauli]What these cells show, and where they do not
The production win is the point, and it is intact.
build_graph[hubbard]at B_8x16 runs 1.43xfaster, 10 of 10 reps.
build_graphwins at one rank too, and not for the reason an earlier revision of this descriptiongave. That revision credited the X_1x1 gain to an inline
(k, d)cutoff digest. This branchdeletes that digest path outright, and the cell got faster — 0.76x here against 0.899 there. So
the digest was not what made it win; it was the record, and plausibly the merge losing the
dbookkeeping from its inner loop. The digest bought ~0.8% of
build_graphat 128 partitions, belowits own noise floor, and it is gone.
propagateat one rank still costs. Pauli is 1.13x / 1.20x, 10 of 10 each. The record's fixedper-query cost needs something to amortise against, and at one partition there is no wire traffic to
save. This is not a narrow-system effect — the axis is amortisation, not width. It is also smaller
than it was: those cells were 1.43x and 1.54x before the follow-up commits and this cleanup.
A correction to
RESULTS-qpos-stack.md. That file reads as though the follow-up commits removedthe X_1x1 regression. They reduce it by roughly a quarter, and it is still there. The stronger
reading came from composing a stack-vs-#263 ratio onto a #263-vs-main one; the two campaigns have
different baselines, so the product is not a measurement. The rows above are measured directly.
Scope. Four cells, chosen to answer two questions: does the production layout still win, and what
does one rank still cost. The A_1x128 rows, the 2-node rows,
energy/gradientas an in-cell nullcontrol, and peak RSS were measured on an earlier cut of this branch against
48cadcb— a baselinethe rebase moved off — and are not re-measured here. Treat that earlier grid as evidence about
the commit it measured, not about this one.
Gates
cteston this branch: unit 271/272, serial 270/270. The single unit failure ismonoprop_link_export_probe, which fails identically on untouchedmain999caf18(243/244) —it aborts with
MPI_Comm_size() called before MPI_INIT. That is #313's own regression test failingon
main, not a defect of this branch, and it is worth a separate issue.Python MPI suite: 600 passed at each of ranks x partitions 2x1, 2x16, 4x8 and 16x16 (world 2 to
256), plus the C++ MPI ctest 1/1.
Not reproducible from this diff
Deucalion, 2x AMD EPYC 7742 / 128 cores / SMT off / NPS4 / 242 GiB per node; a private harness that
never ships; two prebuilt venvs at the md5s above.