Skip to content

perf(evolution): ⚡ carry cross-rank queries as a width-adaptive record - #263

Merged
diagonal-hamiltonian merged 8 commits into
mainfrom
pr/query-wire-v3
Sep 1, 2026
Merged

diagonal-hamiltonian merged 8 commits into
mainfrom
pr/query-wire-v3

Conversation

@diagonal-hamiltonian

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

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Carry cross-rank queries as a width-adaptive record

pr/query-wire-v3, on main 999caf18. The layer-build query path carried one dense word per 64
modes 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

  1. Cross-rank queries travel as a width-adaptive record. The fixed stride the old CompactQuery
    design assumed is gone; a record is gap-coded ascending positions, sized by its own popcount.
  2. A rank resolves its own queries from positions, not through the cross-rank path.
  3. Sink::kStride becomes incoming_form() / querier_form(), fixing a latent inconsistency:
    ContractSink::kStride was the fused width while on_response_block got the plain buffer and
    used 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.
  4. bulk_insert gains a hashed entry point, so a miss found by position reuses the hash the
    probe already folded instead of rebuilding a monomial to re-fold it.

1 and 2 ship together because they share the OperatorIndex position store.

Timings

Arms: main 999caf18 — this branch's own merge-base — against this branch, both
ENABLE_PROFILE=OFF. Extension md5s b201ec44 (main) and e39ec0db (branch).

port/main, so every row below 1.00 is this branch faster. 10 interleaved reps per cell in one
allocation, 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.
rung is lower_atol, with Trotter steps where they differ, and separates cells that are different
model sizes. Every p is a raw two-sided sign test.

Production layout (8 ranks x 16 partitions, 1 node)

operation layout rung main (ms) port (ms) port/main agree p
build_graph[hubbard] B_8x16 1e-04/2st 2752.8 1926.6 0.70x (1.43x faster) 10/10 0.00195

Single thread (1 rank x 1 partition)

operation layout rung main (ms) port (ms) port/main agree p
build_graph[hubbard] X_1x1 1e-3/2st 41102.6 31110.0 0.76x (1.32x faster) 10/10 0.00195
propagate[hubbard] X_1x1 3e-5 48034.0 49811.2 1.04x 9/10 0.0215
build_graph[pauli] X_1x1 1e-4 542.0 615.8 1.13x slower 10/10 0.00195
propagate[pauli] X_1x1 1e-4 408.9 489.9 1.20x slower 10/10 0.00195

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.43x
faster, 10 of 10 reps.

build_graph wins at one rank too, and not for the reason an earlier revision of this description
gave.
That revision credited the X_1x1 gain to an inline (k, d) cutoff digest. This branch
deletes 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 d
bookkeeping from its inner loop. The digest bought ~0.8% of build_graph at 128 partitions, below
its own noise floor, and it is gone.

propagate at one rank still costs. Pauli is 1.13x / 1.20x, 10 of 10 each. The record's fixed
per-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 removed
the 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/gradient as an in-cell null
control, and peak RSS were measured on an earlier cut of this branch against 48cadcb — a baseline
the 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

ctest on this branch: unit 271/272, serial 270/270. The single unit failure is
monoprop_link_export_probe, which fails identically on untouched main 999caf18 (243/244) —
it aborts with MPI_Comm_size() called before MPI_INIT. That is #313's own regression test failing
on 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.

@github-actions

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (6c9d6c7) to head (6c606f1).
⚠️ Report is 1 commits behind head on main.
✅ All tests successful. No failed tests found.

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           
Flag Coverage Δ
cpp 97.70% <ø> (ø)

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

@robertodr robertodr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this duplicates what Claude&I have been doing in #226 🤔

Base automatically changed from perf/epoch-stamp-noshrink-v2 to main August 24, 2026 13:46
@robertodr

Copy link
Copy Markdown
Member

you did indeed tell me to stack my stuff on top of this one

diagonal-hamiltonian added a commit that referenced this pull request Aug 24, 2026
#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>
@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main (was 13 commits behind, at cb9a033a). The five commits are unchanged in
content; the pre-rebase tip is kept at the tag archive/query-wire-v3-pre-rebase.

The commit that mattered is #270 (cf84009e), the exchange-layout / HybridComm rewrite — it
derives the exchange layouts and stores only occupied world slots, and deletes RecvLayout.h. This
branch predated it, so it could not be benchmarked against anything current. Two conflicts, both
trivial: AlgebraCommon.h (keep both includes) and cpp/tests/fused_query_codec_tests.cpp (this
stack deletes it; main had only added a TypeAliases include, so the delete stands).

Verified after the rebase: 270/270 ctest, and clean under real MPI at world 2 and 4.

#296 stacks GF(2)-linear rank routing on top of this and is now based on this branch.

Panadestein added a commit that referenced this pull request Aug 27, 2026
## 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>
@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

@robertodr your back is scratched. Chop chop

robertodr pushed a commit that referenced this pull request Sep 1, 2026
#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>
robertodr pushed a commit that referenced this pull request Sep 1, 2026
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
@robertodr

Copy link
Copy Markdown
Member

I'm chopping wood like never before

robertodr pushed a commit that referenced this pull request Sep 1, 2026
#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>
robertodr pushed a commit that referenced this pull request Sep 1, 2026
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
Comment thread cpp/monoprop/detail/evolution/layer_build/QueryWire.h Outdated
Comment thread cpp/monoprop/detail/evolution/layer_build/QueryWire.h Outdated
Comment thread cpp/monoprop/detail/evolution/layer_build/QueryWire.h Outdated

@robertodr robertodr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I would ask two things:

  1. It is not straightforward to get how the encoding for QueryWire works in practice. Can you add an explicit example using a Doxygen docstring?
  2. 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 🤷

diagonal-hamiltonian added a commit that referenced this pull request Sep 1, 2026
#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>
diagonal-hamiltonian added a commit that referenced this pull request Sep 1, 2026
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
diagonal-hamiltonian and others added 6 commits September 1, 2026 15:57
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
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
@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator Author

🤖 AI text below 🤖

Review pass: 6c606f18

All three asks addressed, plus the two open threads and the Writer question you self-resolved
without an answer. No wire-format change and no change to what any of this computes.

1. An explicit example, in a docstring

QueryWire gets a Doxygen block naming each field with its bit range and working one term end to
end — {3, 7, 8, 40} at 128 modes packs into 34 bits, one word, against the four a 256-bit dense
stride spends. The bracketed field list is gone. AGENTS.md:28 already asked for Doxygen on
header declarations, which the file was not following, so this header now uses /*! @brief */
and //!< throughout. That does raise comment density here, against the previous round's
direction — deliberate, and only in this file.

2. C-isms

Pointer+length pairs and reference out-params become std::span / ranges and returned
aggregates. This is not new ground: std::span is already the idiom in MPFunctions.h:47,
detail/mpi/Exchange.h:29, InvertedIndex.h:242 and Scan.h:88,280.

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.

@diagonal-hamiltonian
diagonal-hamiltonian merged commit 364a5f5 into main Sep 1, 2026
27 checks passed
@diagonal-hamiltonian
diagonal-hamiltonian deleted the pr/query-wire-v3 branch September 1, 2026 14:23
@sonarqubecloud

sonarqubecloud Bot commented Sep 1, 2026

Copy link
Copy Markdown

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.

2 participants