refactor!: drop non-type template parameter for number of modes - #226
refactor!: drop non-type template parameter for number of modes#226robertodr wants to merge 73 commits into
Conversation
Assisted-by: GitHub Copilot: gpt-5.6-sol (plan), claude-haiku-4.5/claude-sonnet-4.6 (execute)
with before-build we re-ran the same installation script before every build.
Assisted-by: GitHub Copilot, gpt-5.3-codex
Stage 0 of the NumModes-NTTP-removal plan: the regression instrument every later stage's "Verify" step relies on. - tools/capture-baseline.py: propagates every tests/data/*.msgpack fixture through MajoranaPropagator across a few cutoffs/cutoff_types, plus a couple of native PauliOperator smoke problems through PauliPropagator (Basis::Pauli coverage -- there is no Majorana -> Pauli operator converter in the public API to press a fermionic fixture into a qubit circuit, so this is hand-picked instead), and dumps term counts, the full (indices, coefficient) set in engine-native order, and the expectation value. Probe cutoffs are capped at 4 -- support cutoff grows combinatorially and 6 on the largest fixture (28 modes) produced 13.7M surviving terms. - justfile: `just capture-baseline [LABEL]` and `just diff-baseline [AGAINST]`. - .gitignore: exclude the capture output directory. Verified reproducible: two independent serial captures are byte-identical, and the three "exact" fixtures' captured energies agree with their fixture's actual_energy to ~1e-13. Assisted-by: ClaudeCode:claude-sonnet-5
Stage 1 of the NumModes-NTTP-removal plan: emit_term_products (the per-term hot path in fused_find_and_collect) always needs both the XORed monomial and popcount(mono & gen); today those come from two separate full-width passes over the same operands (operator^ then count_and()). Bitset::fused_xor computes both (plus popcount(result), unused here but exposed for a future caller) in one pass. Kept narrowly scoped to what's unconditionally needed every call: the arithmetic new_pop identity (mono_pop + gen_pop - 2*overlap) and the conditional cutoff_sums / monomial_hash call sites are untouched -- forcing either into the same always-run loop would trade a cheap O(1)/conditional computation for an unconditional one, which is not what this stage is after. This also keeps SplitmixHash itself untouched, per the plan's invariant. Verified bit-identical: `just diff-baseline` reports no differences, full C++ suite (198/198) and pytest (592 passed) still green. Benchmarked benches/bench_models.py's hubbard model at the W=1 regime this targets (`--hubbard-num-sites 8 --hubbard-trotter-steps 5 --hubbard-observable-site 3`, 12 samples each side): median 4.65ms before -> 3.95ms after (~15% faster), with the after distribution also markedly tighter (no outliers vs. two 3-4x outliers before). At the suite's default 120-mode/W=4 config the two are within noise of each other, as expected -- this stage's fusion only pays off in the small-W band; W=4 is already past where a runtime trip count would cost anything (see the plan's Stage 2). Assisted-by: ClaudeCode:claude-sonnet-5
…ayer Stage 2a (part 1/N) of the NumModes-NTTP-removal plan: the mechanical "deduce instead of spell" pass, scoped to cpp/monoprop/algebra/*.h plus the call sites it touches. No codegen change -- same instantiations, same compiled code -- just nobody spells the width where a MonomialLike argument already carries it. - core/Monomial.h: new MonomialLike concept (Bitset-shaped: static size(), num_words(), member count()/find_first()) recovering NumModes as decltype(mono)::size() / 2 where a value is still needed. - AlgebraCommon.h: bitset_to_indices, is_paired (2-arg and 1-arg monomial overloads), cutoff_sums, length_cutoff, support_cutoff. - MajoranaAlgebra.h: hermitian_coefficient, majorana_state_phase, interleave_phase, interleave_phase_mask, encode_coeff, decode_coeff, change_basis. - PauliAlgebra.h: pair_swap, pauli_y_count, pauli_anticommutes, pauli_rotation_sign, pauli_state_phase, make_pauli_gen_context (this one's return type still names NumModes explicitly -- it sizes PauliGenContext's fixed nz_words array, and that class stays templated until Stage 2c/2d). - Algebra.h: algebra_fold_generator, algebra_fold_needs_odd_correction, algebra_encode_coeff, algebra_decode_coeff, algebra_state_phase. Left explicit, deliberately: functions with no monomial-shaped argument to deduce from -- indices_to_bitset(_checked), initial_state_mask, monomial_from_selector, generate_paired_op, pauli_even_mask, is_paired's VecZ overload, is_fully_paired, with_algebra, algebra_score_state -- plus every class template (LengthCutoff/SupportCutoff/CutoffEvaluator, MajoranaAlgebra<N>/PauliAlgebra<N>, PauliGenContext<N>), which is 2c/2d's job, not 2a's. Verified bit-identical: `just diff-baseline` reports no differences, full C++ suite (198/198) and pytest (592 passed) green, clang-format clean. Assisted-by: ClaudeCode:claude-sonnet-5
…/evolution layer
Stage 2a (part 2/N) of the NumModes-NTTP-removal plan: continues the part-1
algebra-layer pass into TypeAliases.h, the operator layer, and the
evolution/layer_build cluster. No codegen change -- same instantiations,
same compiled code.
- TypeAliases.h: materialize_row/assign_row/row_popcount/for_each_row_position,
both overloads. The vector overload deduces via `std::vector<MonomialLike
auto>`; the detail::OperatorIndex overload via a new RowStoreLike concept
(value_type + row(i)) since OperatorIndex itself isn't Monomial-shaped.
- InvertedIndex.h: combine_columns_block. MPOperator.h: insert_absent_terms
(fully deduced, no constraint needed -- nothing in its body reads
NumModes), plus a stale forward-declaration of algebra_encode_coeff that
would otherwise silently resolve to an undefined, differently-templated
overload.
- Scan.h: build_even_parity_generator_columns, even_parity_scan_pass1,
emit_term_products, fused_find_and_collect (both keep their Algebra `A`
template parameter named -- it isn't deducible from any argument, exactly
as before).
- Resolve.h: insert_incoming_misses (fully deduced); resolve_incoming /
process_responses keep `Sink` named (referenced by name in the body and
return type; call sites already relied on deducing it from `sink`, only
the leading NumModes was ever spelled explicitly).
- Engine.h: append_inserted_endpoints, build_layer (the primary layer-build
entry point -- local_op/gen deduce, cutoff_fn stays plain auto, and a
local `num_modes` constant threads through to the still-templated
LayerBuildEngine/ContractSink/GraphSink and to Stage 2e's kWords/kQueryWords).
- CosineRecompute.h: the whole fold-cache/lazy-fold cluster except
generator_from_words (no monomial-shaped argument to deduce from).
- PartitionGroup.h: collect_on_all (map_partitions keeps NumModes named --
its R-default depends on it by name, same obstacle as Common.h's
query_read/QW).
Deliberately left untouched, and why:
- Common.h (kQueryWords, kQueryWordsFused, query_push, query_read,
query_phase, query_value, build_fused_query_value), MPIUtils.h (kWords,
append_monomial_words, read_monomial_from_words), Engine.h's
generator_words line, and CheckedCount.h -- the plan's own Stage 2e ("wire
format to a runtime stride") claims this cluster explicitly; converting it
piecemeal here would fight that stage rather than prepare for it.
- probe_incoming_queries, cutoff_function_basis_change, map_partitions --
each has a second template parameter whose default value expression
names NumModes directly; abbreviating the deducible parameter would leave
no name for that default to reference.
- core/Monomial.h's monomial_hash -- outside this stage's stated file list,
and adjacent to the hash/routing invariant (see Stage 1's commit); not
worth the risk for a spelling-only change with no callers left needing it.
Verified bit-identical: `just diff-baseline` reports no differences. Full
C++ suite green serial (198/198) and MPI-enabled build (206/206, including
the mpi-2 CTest case); pytest green serial (592 passed) and under mpiexec
at n=2 and n=4 (600/600 each). clang-format clean.
Assisted-by: ClaudeCode:claude-sonnet-5
Stage 2b of the NumModes-NTTP-removal plan. `Bitset` stops being
`Bitset<NumBits>` and carries its width as data; `Monomial<NumModes>` stops
being an alias for it and becomes a thin transitional wrapper so that no call
site outside this commit has to change yet.
Bitset (cpp/monoprop/Bitset.h, rewritten):
- Width lives in two uint32_t members (`nwords_`, `top_bits_`) instead of an
NTTP. `size()`/`num_words()` are runtime instance methods.
- Storage is inline-capacity-plus-spill: the first 8 words (`kInlineWords`,
which covers the whole shipped range -- monoprop_MAX_NUM_MODES=250 is 500
bits is 8 words) sit in a `std::array`; a wider bitset spills the *entire*
word array to a `std::vector`, so `data()`/`word(i)` stay a single
contiguous view whichever storage is live. Nothing in the shipped
configuration allocates.
- `detail::with_nwords(n, f)` replaces the `if constexpr (num_words() == 1)`
compile-time branches the NTTP used to permit: a `switch` over n in [0, 8]
handing `f` an `std::integral_constant`, so each arm keeps a compile-time
trip count and stays fully unrolled even though the width is now runtime
data. Every per-word method routes through it below the inline ceiling and
falls back to a plain loop above.
- `SplitmixHash` is no longer a template and its single-word fast path is a
runtime `if`. The hash *function* is untouched -- see the bit-identity check
below, which is what actually pins that.
- `FusedXor` is declared inside the class and defined after it: a non-template
class cannot hold a nested member of its own (still incomplete) type by
value, which the `Bitset<NumBits>` template happened to allow.
Monomial (cpp/monoprop/core/Monomial.h):
- `Monomial<NumModes>` is now an empty subclass of `Bitset` that feeds
2 * NumModes to the runtime constructor and shadows `size()`/`num_words()`
with `static constexpr` versions. That keeps every existing spelling working
unchanged -- default construction, and the array-sizing and
template-argument uses (`std::array<size_t, Monomial<N>::size()>`,
`kQueryWords<N>`, ...) that Stages 2c/2d/2f have not migrated yet.
- This is deliberately a shim, not the end state. It exists so 2b lands and
verifies on its own; the plan's "Monomial stops being an alias" wording is
reached when 2c/2d/2f remove the last compile-time-NumModes consumers, at
which point the wrapper is deleted rather than kept.
- Operators (`^`, `&`, `>>`, `fused_xor().result`, ...) are inherited and
return plain `Bitset` by value rather than being redefined to rewrap. Safe
because nothing recovers NumModes from an operator's result -- only from a
parameter or an explicitly Monomial-typed local -- but see the two bugs that
fell out of exactly this, below.
- `Monomial` needs its own `operator==(const Monomial&, uint64_t)` pair:
overload resolution will not chain Monomial's converting constructor with a
derived-to-base binding to reach the inherited
`Bitset::operator==(const Bitset&)`, so the `mono == 0b1010` literal
comparisons in the test suite stopped compiling on the constructor alone.
Fallout fixed (all of it the same root cause -- code that recovered a
compile-time width from a *qualified* `decltype(x)::size()` breaks the moment
`x` is a plain `Bitset`):
- `pair_swap`, `pauli_y_count` and `pauli_rotation_sign` took
`MonomialLike auto`, so a caller could always hand them a plain Bitset from
`a ^ b` -- and pauli_algebra_tests.cpp does. They now use instance calls
(`p.size()`, `p.num_words()`) throughout, against a new runtime-width
`pauli_even_mask(size_t num_modes)` sibling of the existing
`pauli_even_mask<NumModes>()`. `pair_swap`'s local result is
`Bitset result(p.size())` rather than a default-constructed `Mono`, which
would be zero-width if `Mono` resolved to the base.
- `even_bits`/`odd_bits` gain runtime-width overloads
(`even_bits<Ordering>(n)`) next to the existing compile-time ones; the
`detail::` helpers take `n` as an ordinary argument.
- `cutoff_sums`'s ternary needs an explicit upcast on the non-shifted branch:
one arm is `Monomial<N>`, the other is the plain `Bitset` from `mono >> k`,
and conversions exist in both directions, so the common type was ambiguous.
- Two `constexpr` mask locals demote to `const`: `Bitset` holds a vector and
is no longer a literal type. Both are loop-invariant hoists in code the
compiler can still fold; Stage 2's benchmark leg is where that gets measured
rather than asserted.
`MonomialLike` switches from qualified to instance calls, so plain `Bitset`
satisfies it too and not only the wrapper.
Verification:
- `just diff-baseline`: bit-identical to the golden capture (identical term
sets, coefficients and energies across all 9 fixtures / 34 records;
manifest sha256 matches). This is what pins SplitmixHash and the storage
rounding, per the plan's Stages 1-2 acceptance bar.
- ctest serial: 201/201 (198 before, plus three new spilled-width cases:
`bitset_trampoline_inline_spill_boundary` at n in {511, 512, 513, 576, 1024,
4096}, `bitset_spilled_copy_is_independent`, `bitset_spilled_find_chain` --
the heap-spill path is new capability, so it gets its own coverage).
- bitset_tests.cpp keeps `std::bitset<N>` as the compile-time oracle while
constructing the subject at runtime, so the differential tests still compare
against an independent implementation.
- pytest serial: 592 passed, 8 deselected.
- MPI build: ctest 209/209 (including the mpi-2 labelled run); pytest
--with-mpi 600 passed at both n=2 and n=4.
AGENTS.md's "Core abstractions" entry described Monomial<N> as Bitset<2*N>,
which this commit makes false, so it is updated here per the repo's
documentation policy. No user-facing API, path or workflow changes.
Unrelated pre-existing flake, noted so it is not mistaken for fallout from
this commit: in the MPI-enabled build, `ctest -j4` intermittently kills one
arbitrary test with SIGPIPE (a different test each run, each passing in
isolation and under -j1). It reproduces on the parent commit's MPI build too,
so it predates this work; the MPI verification above was therefore run at -j1.
Assisted-by: ClaudeCode:claude-opus-5
Stage 2b (83238fa) was bit-identical and green but regressed the benchmarks badly: 471 -> 686 ms on hubbard, 924 ms -> 1.56 s on Schrodinger build_graph, and 3546 -> 10551 MiB peak RSS on the same. Two causes, both introduced there. **Masks were rebuilt per term.** Stage 2b turned `even_bits`/`pauli_even_mask` into runtime helpers but skipped the "cached per propagator" half of the plan, so three masks that had been `constexpr` became a full object construction on every term -- including inside `pauli_rotation_sign`, the always_inline kernel that runs once per emitted rotation. Fixed by giving each one a home: - `cached_even_bits<Ordering>(n)` in `Utilities.h`: thread_local memo keyed on width. thread_local rather than shared because the scan runs concurrently on the partitions' pinned masters and a shared cache would need synchronisation on the hottest path in the library; the width only changes between propagators, so the miss branch is taken once per thread. - `PauliGenContext` carries `e_mask`, built once per layer in `make_pauli_gen_context` alongside the `nz_words` it already precomputed. - `cutoff_sums`' single-word branch spells the LSb0 pattern as a literal, which restores a constant expression -- a runtime-width Bitset is not a literal type, so `even_bits<...>().word(0)` could no longer fold. The runtime `pauli_even_mask(size_t)` overload added in 2b is deleted: it existed only so `MonomialLike auto` callers could avoid a qualified `Mono::size()`, and the cache subsumes it. **Per-term code built Bitset temporaries.** `cutoff_sums` and `is_paired` were written as `a & mask` / `(a >> 1) & mask` / `^` / `|` chains -- five and three temporaries per call. That was nearly free when a Bitset was an exactly-sized trivially copyable array; since 2b each is a full runtime-width construction. Both are now single word loops. `(word >> 1) & even_mask` equals `((bits >> 1) & mask).word(w)` because a full-width shift carries the low bit of word w+1 into bit 63, an odd position the even mask drops anyway -- the same within-word-pairs argument `pair_swap` and `pauli_uv` already rely on. Sums of popcounts are unchanged, hence bit-identity below. **Bitset is 96 -> 72 bytes.** The `std::vector` spill member cost 24 bytes on every monomial at every width; the inline words and the heap pointer are never both live, so they now share a union and Bitset owns its buffer directly (rule-of-five, with same-width copy assignment reusing the existing buffer -- the common case, and now allocation-free). Still not trivially copyable, and still sized for the widest supported bitset rather than its own width: that is inherent to owning bits inline at a runtime width, and the route out is Stage 6's arena, where a Bitset becomes a non-owning {pointer, width} view. Noted in the header and in AGENTS.md so per-term code is written accordingly. **`generate_paired_op` reserves.** It is the largest by-value monomial allocation in the library -- 11,017,633 entries at 128 modes / cutoff 6, i.e. the whole Schrodinger term count -- and grew geometrically, holding old and new buffers at the last reallocation. Reserving the exact `Sum_{k<=max_ones} C(n, k)` removes a multi-GiB transient. Measured (128 modes, cutoff 6; 83238fa -> here, against the pre-2b parent): | | parent | 83238fa | now | |---|---|---|---| | hubbard | 471 ms | +46% | +7% | | pauli | 88 ms | +21% | +8% | | Schrod. build_graph | 924 ms | +69% | +9% | | Schrod. inplace | 839 ms | +85% | +16% | | Schrod. build_graph RSS | 3546 MiB | +197% | +98% | | Schrod. inplace RSS | 5049 MiB | +137% | +66% | All Heisenberg memory and all Schrodinger steady-state memory (energy/gradient/pare) are back to parity or slightly better. Time figures are single-round and move a few percent between runs; the memory figures replicate to within 0.4%. Still outstanding: the Schrodinger *build* peak remains ~2x the parent. The 72-byte MonomialList accounts for only ~440 MiB of the ~3.5 GiB gap, so a transient is unaccounted for; attributing it needs a heap profile rather than source reading, and it is a build-time peak only. Recorded here rather than guessed at. Verification: `just diff-baseline` bit-identical (manifest sha256 matches); ctest 201/201 serial and 209/209 in the MPI build; pytest 592 passed serial and 600 passed under mpiexec at n=2 and n=4. Assisted-by: ClaudeCode:claude-opus-5
Building a propagator peaked at 7.0 GiB before this, against 974 MiB resident
once construction returned. RSS probes through the constructor put all of it in
one step, generate_paired_op, and showed why it was so much larger than the list
itself: every constructor phase ran *eight* times. A propagator with S
partitions is a facade over S single-partition propagators, each of which
generated the complete basis and then kept only the ~1/S share it owns, so S
full copies were live simultaneously.
At the benchmark's 128 modes / cutoff 6 the basis is Sum_{k<=4} C(128,k) =
11,017,633 monomials -- the entire term count -- so at 8 partitions that is
8 x 792 MiB. It also explains the memory half of the Bitset-width regression
exactly: 8 x 11.0M x (72 - 32) B = 3520 MiB predicted against 3489 MiB measured.
The redundancy predates the runtime-width Bitset; that change only scaled it.
`generate_paired_op` splits into three:
- `count_paired_op(max_ones, n)` -- the closed-form count, so a caller can size
storage without generating anything.
- `for_each_paired_op(max_ones, n, fn)` -- yields one monomial at a time. The
permutation loop is unchanged from the list version, so the yield order is the
list's order. That order is load-bearing: it fixes term indices, hence MPI
owner routing and float accumulation order.
- `generate_paired_op` -- now a thin wrapper over the two, for tests and any
caller that genuinely wants the whole list at once.
The Schrodinger arm of the constructor streams into the store through the same
insert-if-owned predicate the list loop used. Heisenberg keeps its list, which is
one entry per owned initial-operator term and already small; it also stops being
copied out of `local_heisenberg_terms` on the way in.
Measured (128 modes, cutoff 6; peak RSS, vs the pre-Stage-2b parent):
| | parent | before this | now |
|---|---|---|---|
| Schrod. build_graph | 3546 MiB | 7036 MiB | 1666 MiB (-53%) |
| Schrod. inplace | 5049 MiB | 8404 MiB | 2977 MiB (-41%) |
Construction now has no transient at all -- peak equals resident at 974 MiB,
where it was 7022 MiB -- and the whole-run peak is set by build_graph rather
than by construction. Heisenberg memory and Schrodinger steady-state are
unchanged. Time is within noise of the previous commit and still ~6-16% above
the parent; that residue is the width-sized non-trivially-copyable Bitset on
by-value paths like materialize_row, which Stage 6's arena addresses rather than
anything here. Timing figures are single-round and vary by up to ~30% run to
run, so treat the percentages as directional; the memory figures replicate to
within 0.4%.
Verification: `just diff-baseline` bit-identical (unchanged yield order is what
pins this); ctest 201/201 serial and 209/209 in the MPI build; pytest 592 passed
serial and 600 passed under mpiexec at n=2 and n=4.
Assisted-by: ClaudeCode:claude-opus-5
Stage 2c of the NumModes-NTTP-removal plan, first target. `InvertedIndex` takes its column count as a constructor argument instead of a template parameter: `kNumColumns = Monomial<NumModes>::size()` becomes `cols.size()`, exposed as `num_columns()`, and `std::array<Column, kNumColumns>` becomes a `std::vector` sized at construction. `rebuild`'s per-column `Counts` array follows it off the stack, which also drops a 4 KB stack frame at 256 modes. Result-neutral by construction, not just by test: the columns are XOR-folded and `combine_columns_block` documents that XOR associativity makes any block decomposition reproduce the full-width fold bit for bit, so column *storage* has no path to a result. The ascending-`set_rows` invariant that `lower_bound` depends on is untouched -- fill order is row order either way. `MPOperator::inverted_index()` now takes the width off the store rather than from `NumModes`, so there is one source of truth for it and it cannot drift from the monomials whose positions `rebuild()` scatters. `build_cos_callbacks` and `recompute_cos` keep their `NumModes` parameter -- they still need it for `LazyFold<NumModes>` and `Monomial<NumModes>` -- and only their `InvertedIndex` parameter type changes. `even_parity_scan_pass1` and `combine_columns_block` keep taking a deduced `auto`: the type is no longer a template, but the fold-cache tests bind those parameters to a stand-in with the same column accessors, so naming the type would narrow them for no gain. Breaking for C++ consumers of the installed headers: `InvertedIndex` is no longer a template and `kNumColumns` is no longer a `static constexpr`. Nothing in Python is affected. Verification: `just diff-baseline` bit-identical; ctest 201/201 serial and 209/209 in the MPI build; pytest 592 passed serial and 600 passed under mpiexec at n=2 and n=4. Assisted-by: ClaudeCode:claude-opus-5
init_op_map holds one entry per owned initial-operator term until each appears as a row. get_operator() erases them as that happens, and initialize_operator_caches_() calls it during construction -- so for a normal propagator the map is empty before the constructor returns. A flat map keeps its bucket count across erases, so what remained was an empty map still sized for the whole initial operator, for the propagator's whole life: 10.0 MB behind zero entries for a 100k-term observable, 28% of the operator's accounted bytes. Released by assigning a fresh map once the drain empties it, and only then -- a partially drained map still has to answer for the terms left in it. Resting RSS is unchanged if nothing else grows (the array is built either way and glibc keeps a mid-heap block), so the win is in reuse: four propagators built and propagated go from 160.42 MB to 141.16 MB, 4.8 MB each. The S-partition facade multiplies that by S, one map per partition. Two accounting gaps of the same shape fixed alongside, since they are what made this invisible: Bitset::heap_bytes() names the words a spilled monomial owns outside its own object (5.1 MB behind 2.5 MB of slots for 20k keys at 1024 modes), and d_init_operator_entries reports live occupancy, which a byte figure over a flat map cannot imply. Stage 6 step 3's own question -- flat-key map or arena-stable views for variable-length keys -- is settled in notes/monomial-storage §6d: keys stay Bitset. Their live phase ends inside the constructor, so an arena would shrink a transient buffer 3x at narrow widths and nothing at wide ones, where the key words dominate. ctest 541/541 both backends, diff-baseline byte-identical, pytest 591 both ways, 599/599 under MPI at n=2 and n=4 in both backends. Assisted-by: ClaudeCode:claude-opus-5
Stage 6 step 4 is the Stage 3 gate's either/or: delete Bitset.h and the bit helpers, or keep dense as the small-N backend. Keeping it was the standing answer, but it is only as good as the constant that routes between the two backends -- and that came from the Stage 3 representation bench, which predates the codes algebra and the support-form query record, and modelled dense as a word-at-a-time scan rather than the packed position lists that ship. Re-measured end to end with monoprop_ROW_STORE forcing each backend, holding the workload identical across widths by relabelling one operator into a wider register (equal term counts and equal expectation values asserted, so a timing difference cannot be a workload difference). Sparse comes out flat in the width and dense monotone, with the crossing in the same block at cutoffs 4, 6 and 10: arch flags on: crossing ~512-640, was 1024, now 768 arch flags off: crossing ~192-256, was 96, now 256 The wheel value mattered. At 96 modes dense is 37% faster than the sparse rows a wheel was selecting, and both shipping models sit in that band: Hubbard (120 modes) 0.580s dense vs 0.671s sparse, kicked-Ising (127 qubits) level. Both new values are the first whole 32-mode block where sparse wins by more than the run-to-run spread, so the threshold errs toward dense, where one block of machine dependence costs a few percent. The gate therefore resolves as before but for a stated reason: every model this library is run on is below the crossover, so dense is the production path rather than a legacy one. What does go is the last compile-time-width entry point, even_bits<N, Ordering>() / odd_bits<N, Ordering>(), whose purpose was call sites spelling Monomial<N>::size(). WIDE_EMBEDDING had to move with the constant -- 96 storage modes is no longer above the wheel crossover, which would have retired the only coverage of the backend as it ships. It now relabels into 260 logical / 288 storage modes, which also reaches a regime nothing had propagated in: nine words is past Bitset::kInlineWords, so every by-value monomial spills to the heap. Re-seeding that case's four baseline records changed them by relabelling alone -- identical term counts and bit-identical energies at every cutoff. Verified in both build configurations: ctest 541/541 both backends, diff-baseline byte-identical, --compare 38/38, pytest 591 both ways, 599/599 under MPI at n=2 and n=4. Assisted-by: ClaudeCode:claude-opus-5
The fixed models recorded their term counts, resting footprints and operator accounting all along; the report rendered none of it, because its per-picture sections only know the random benchmarks' two pictures. So a run of bench_models.py could not answer the two questions asked of it -- cost per term and peak RSS at a given mode count -- without reading the JSON by hand. REPORT.md now carries a Fixed models section, one table per model: terms, time, cost per term, peak / baseline / resting RSS, operator accounting and bytes per term. The two per-term figures are the width-comparable ones, since these models grow their term count with their mode count. The record it was added for, taken with it (0af75a5, 1 thread, MPI off, both ISA configurations prebuilt and swapped per width so neither arm is measured at a different time). Hubbard at 32 / 120 / 320 / 800 storage modes -- 1, 4, 10 and 25 words per monomial, the last two past Bitset's inline capacity: -march=native 1721 / 1785 / 2053 / 2319 ns per term plain -O3 1972 / 2119 / 2427 / 2718 ns per term operator 43.2 / 60.6 / 60.6 / 69.7 MiB At cutoff 6 with atol 1e-4 the operator saturates from 160 sites up, so the top three widths carry an identical operator (1,169,024 terms, same expectation value) and differ only in width and gate count. On that fixed operator 120 -> 800 modes -- 6.7x the width, 6.7x the gates, monomials from inline to spilled, and a switch to sparse rows -- costs +30%: there is no width term in this engine's cost worth naming, and the ceiling that came off in 7c8cc2d is usable rather than merely legal. Two things the accounting settles exactly, being deterministic rather than timed. At 320 modes the two ISA configurations select different row backends (the crossover is 768 with arch flags, 256 without), and the entire difference is operator_terms_bytes, 21.03 -> 30.04 MiB: crossing the crossover buys a flat-in-width scan for ~15% of the operator's footprint. And inverted_index_bytes is 14.63 MiB at 120 modes against 14.71 at 800, with its width-scaling term at 0.6% of the index -- sizeof(Column) matters in the wide-and-sparse corner only, as the step-2 measurement said. Assisted-by: ClaudeCode:claude-opus-5
|
Docs preview: https://pr-226.monoprop-docs.pages.dev |
|
benchmarks against Read rows Time
Memory (peak RSS)
|
| [[nodiscard]] auto memory_bytes() const -> size_t { | ||
| size_t total = modes_.capacity() * sizeof(ModeT); | ||
| total += codes_.capacity() * sizeof(CodesT); | ||
| total += overflow_.size() * (sizeof(value_type) + sizeof(size_t) + 24); |
There was a problem hiding this comment.
🤖 AI text below 🤖
memory_bytes() charges each spilled row sizeof(Bitset) + sizeof(size_t) + 24 but never adds what the Bitset owns off-object. AGENTS.md (this branch) states the rule: "memory accounting over a container of monomials must add Bitset::heap_bytes() per element; sizeof(Bitset) per element looks right until someone runs a wide system."
That is exactly the regime this branch adds: at 288 storage modes a monomial is 9 words, past Bitset::kInlineWords, so every overflow_ entry heap-allocates 72 bytes that operator_memory_breakdown()["operator_terms_bytes"] silently drops. monomial_map_bytes() right next door already does this correctly for init_op_map. OperatorIndex::memory_bytes() has the same gap.
| total += overflow_.size() * (sizeof(value_type) + sizeof(size_t) + 24); | |
| [[nodiscard]] auto memory_bytes() const -> size_t { | |
| size_t total = modes_.capacity() * sizeof(ModeT); | |
| total += codes_.capacity() * sizeof(CodesT); | |
| for (const auto &kv : overflow_) { | |
| total += sizeof(value_type) + sizeof(size_t) + 24 + kv.second.heap_bytes(); | |
| } | |
| return total; | |
| } |
| never below one block. Pass `storage_num_modes` to override it — the C++ tests do, via | ||
| `cpp/tests/TestPropagator.h`, because the width is part of a monomial's hash and hence of the order | ||
| coefficients accumulate in. | ||
| - `src/monoprop/bindings/binder.h`: the binding for that one class, called from |
There was a problem hiding this comment.
🤖 AI text below 🤖
src/monoprop/bindings/binder.h no longer exists — commit 70d839c ("remove binder.h") folded it into src/monoprop/bindings/bindings.cpp. Line 214 ("Add Python bindings in src/monoprop/bindings/binder.h") is stale for the same reason.
The repo's own Documentation Maintenance Policy in this file says to update AGENTS.md in the same change and to fix or remove any section that no longer reflects the codebase.
| retained_lanes_.clear(); | ||
| retained_.clear(); | ||
| capacity_ = capacity; | ||
| num_bits_ = num_bits; |
There was a problem hiding this comment.
🤖 AI text below 🤖
configure() clears lanes_, keys_, escapes_, retained_lanes_ and retained_, but not retained_bases_ or retained_escapes_.
retain() derives its handle from retained_.size() while pushing the matching base onto retained_bases_. If retained_ is cleared and retained_bases_ is not, the two arrays desynchronise: handle 0 then reads retained_bases_[0], a stale base into a retained_lanes_ that was just emptied, and retained() indexes out of bounds. retained_escapes_ has the same pairing problem plus unbounded growth.
Today this is latent — the engine's keys_ is constructed fresh per build_layer and the thread_local probe batch in Resolve.h never calls retain() — but the invariant is one configure() call away from being live.
| num_bits_ = num_bits; | |
| if (capacity_ != capacity || num_bits_ != num_bits) { | |
| lanes_.clear(); | |
| keys_.clear(); | |
| escapes_.clear(); | |
| retained_lanes_.clear(); | |
| retained_bases_.clear(); | |
| retained_escapes_.clear(); | |
| retained_.clear(); | |
| capacity_ = capacity; | |
| num_bits_ = num_bits; | |
| } |
| // term, which escapes the cutoff) spills losslessly. | ||
| if (use_sparse_rows_()) { | ||
| mp_op_.set_store(std::make_unique<detail::SparseRowStore>(2 * storage_num_modes_, | ||
| detail::SparseRowStore::slots_for_bound(cutoff_))); |
There was a problem hiding this comment.
🤖 AI text below 🤖
The dense arm below sizes its rows through packed_inline_width_(), which special-cases Schrödinger (if (schrodinger_) return kDefault;). The sparse arm bypasses that rule entirely and sizes from cutoff_ alone.
In Schrödinger the initial term set is every fully paired monomial with up to max_pairs = ceil(sc/2) occupied modes, and sc is schrodinger_cutoff — an independent user knob, not cutoff. Whenever ceil(sc/2) > cutoff_ (e.g. cutoff=1, or any explicitly large schrodinger_cutoff), every one of those initial rows exceeds slots_per_row_ and lands in the overflow_ unordered_map — a heap Bitset plus a map lookup on the per-term path, for what should be the common case.
The two backends should read the row width from one place; packed_inline_width_() already is that place for the dense store.
| # ctest at a directory with no tests. ctest reports "No tests were found" and exits 0 for that, so those | ||
| # commands were silently running nothing. | ||
| if(monoprop_ENABLE_CXX_UNIT_TESTS) | ||
| enable_testing() |
There was a problem hiding this comment.
🤖 AI text below 🤖
This block duplicates the if(monoprop_ENABLE_CXX_UNIT_TESTS) enable_testing() / include(CTest) endif() that already sits at lines 152-158, immediately before add_subdirectory(cpp) (added on main by #224). Both are now in the file, each carrying a comment that claims to be the reason the call lives where it does — and they give different reasons.
Harmless at build time (CTestTargets.cmake guards its own targets with the CTEST_TARGETS_ADDED global property), but the next reader has to work out which comment is true. Keep one.
| "cspell.json", | ||
| ] | ||
| sdist.include = ["src/monoprop/_version.py", "src/monoprop/_dispatch.py"] | ||
| sdist.include = ["src/monoprop/_version.py"] |
There was a problem hiding this comment.
🤖 AI text below 🤖
Related cleanup this PR misses (line 89, not reachable as an inline anchor):
[tool.uv]
cache-keys = [
...
{ file = "src/monoprop/bindings/bindings.cpp.in" }, # <- deleted by this PR
]This PR deletes src/monoprop/bindings/bindings.cpp.in (it became src/monoprop/bindings/bindings.cpp), but the cache key still points at the removed template, and no remaining entry covers src/monoprop/bindings/** — cpp/include/**, cpp/monoprop/**, **/CMakeLists.txt and cmake/** all miss it.
So editing the nanobind bindings no longer invalidates uv's build cache: a later uv sync can hand back a wheel built from the previous bindings.cpp. Suggest { file = "src/monoprop/bindings/*.cpp" }.
|
|
||
| template <size_t NumModes> | ||
| class MonomialPropagator; // completed before any PartitionGroup member body is instantiated (Impl.h) | ||
| // Only MonomialPropagator.inl includes this header, and it does so *after* MonomialPropagator's |
There was a problem hiding this comment.
🤖 AI text below 🤖
This comment is written against MonomialPropagator.inl, but the same PR renames that file to cpp/monoprop/detail/monomial_propagator/MonomialPropagator.cpp. The include is now from the .cpp, and the ordering contract the comment describes (include after MonomialPropagator's definition, because the member bodies are ordinary functions needing the complete type) is what a reader is meant to be able to check — against a filename that no longer exists.
| "cutoff_type"_a = "length", | ||
| "basis_change"_a = std::nullopt, | ||
| "logical_num_modes"_a = NumModes, | ||
| "logical_num_modes"_a, |
There was a problem hiding this comment.
🤖 AI text below 🤖
logical_num_modes has no default but is declared after five defaulted arguments (comm, schrodinger_cutoff, lower_atol, upper_atol, cutoff_type, basis_change). nanobind builds the signature from these in order, so the docstring/stub signature is (..., basis_change=None, logical_num_modes, basis="majorana", ...) — not valid Python, and any positional call has to pass all six preceding arguments.
src/monoprop/monomial_propagator.py only ever calls this with keywords, so nothing breaks today, but moving logical_num_modes up next to initial_state (matching the C++ constructor, where it already sits third) would make the exposed signature honest.
| } | ||
|
|
||
| // Rows in index order, as fn(row_index). Not the row itself: a spilled row has no view, so what a | ||
| // caller wants off the index is the index. |
There was a problem hiding this comment.
🤖 AI text below 🤖
"Rows in index order" is not what this does — RowHashTable::for_each_slot walks the slot array, so rows come out in hash/probe order, not ascending row index. OperatorIndex::for_each and MPOperator::for_each_term both describe it correctly as "table order" / "slot order", and the class comment 300 lines up is explicit that the two stores deliberately differ in that order.
Given this order is load-bearing (it fixes the user-visible evolved-term order and the FP accumulation order), a comment that says "index order" is the kind that gets believed.
| // | ||
| // Grow-only and never cleared between layers. That is the measured shape (see Resolve.h): an element is | ||
| // overwritten whole before any read, so a per-layer rebuild bought nothing and cost a construction per | ||
| // query. The trade is that the buffer holds the largest layer's worth until the thread exits; peak RSS is |
There was a problem hiding this comment.
🤖 AI text below 🤖
"Grow-only and never cleared between layers ... the buffer holds the largest layer's worth until the thread exits" is true of the thread_local batch in Resolve.h, but not of the other user of this class: LayerBuildEngine::keys_ is a plain member of an engine that is constructed fresh inside build_layer, i.e. once per layer. Its configure() therefore always sees a default-constructed object and ensure(kResolveBatch) re-builds 64 elements every layer — the allocation this comment says was measured away.
(It has to be per-layer, because retain() handles index into retained_, which would otherwise grow without bound across layers — so the fix is the comment, not the lifetime.) Resolve.h line ~110 repeats the claim ("the same trade nz and keys_ already make").
| // representation costs is one pass per storage word. Rows are sized from the cutoff -- a surviving | ||
| // term occupies at most `cutoff` modes under either cutoff kind -- and anything wider (a fully paired | ||
| // term, which escapes the cutoff) spills losslessly. | ||
| if (use_sparse_rows_()) { |
There was a problem hiding this comment.
🤖 AI text below 🤖
The store is created once here with a row width derived from cutoff_, but cutoff_ is mutable for the propagator's lifetime: update_cutoff() (MonomialPropagator.h) calls regenerate_cutoff_fn_() and nothing else, so the store keeps slots_per_row_ = slots_for_bound(old_cutoff).
Raising the cutoff then pushes every row wider than the old bound into overflow_ — a heap Bitset plus an unordered_map lookup on the per-term path, for the typical row rather than the ~0.07% the spill was sized for. Still correct (the spill is lossless), but it is a silent order-of-magnitude cliff with no diagnostic; partition_equivalence_tests.cpp exercises update_cutoff() today, just below the sparse crossover where it cannot show up.
Worth either re-creating the store on a cutoff change or documenting that slots_per_row_ is a construction-time bound.
| return static_cast<size_t>(e.idx); | ||
| } | ||
| } | ||
| return table_.find(fold_hash(key), [&](size_t i) { return row_eq_key(i, key); }); |
There was a problem hiding this comment.
🤖 AI text below 🤖
Note on memory_bytes() just above (line 189-193, outside the diff so not directly anchorable):
total += overflow_.size() * (sizeof(value_type) + sizeof(size_t) + 24);value_type is now a runtime-width Bitset, and above Bitset::kInlineWords (8 words / 250 modes) each spilled row owns a heap array this never counts. The line was correct when value_type was Monomial<NumModes>, a fixed-size type with no allocation — de-templating Bitset is what invalidated it. SparseRowStore::memory_bytes() copies the same shape.
AGENTS.md (added in this PR) states the rule: "memory accounting over a container of monomials must add Bitset::heap_bytes() per element; sizeof(Bitset) per element looks right until someone runs a wide system." And 288 storage modes (9 words) is precisely the regime tests/test_wide_system.py adds here. monomial_map_bytes() in MPOperator.h already does it correctly.
| sparse-rows | ||
| ENVIRONMENT | ||
| "monoprop_ROW_STORE=sparse" |
There was a problem hiding this comment.
Nice that the sparse backend gets a per-case second run here!
It looks like this env var never reaches the MPI variants, though. The register_variant at L253–262 sets only the OMPI root vars, just test-sparse-rows runs pytest -m "not mpi", and sparse_wire_tests.cpp exercises the codec in-process with no communicator. So the sparse backend is currently covered serially but never across ranks.
That's a bigger gap than it looks, because the sparse form isn't just local storage; it changes the wire format. query_payload_words_for gives a different stride per backend, and the escape tail, kOverflowLane and append_escape_tail have no dense counterpart, so that whole path only ever runs single-rank. It's also the backend that ships for wide systems, which is exactly where MPI gets used.
Could we add monoprop_ROW_STORE=sparse MPI variants, even just at 2 ranks? Happy for it to be a follow-up if there's a reason to defer.
| // Every binary op below loops *this*'s word count and indexes the other operand unchecked, so a | ||
| // narrower operand is read past its own width. That is not merely wrong-but-harmless: it only | ||
| // reads zeros from the inline array while the *result* fits inline, and once *this* is spilled | ||
| // (> kInlineWords) it reads off the end of the narrower operand's array. Widths must match at | ||
| // every call site, so this is asserted rather than handled -- Release keeps the loops bare. |
There was a problem hiding this comment.
The comment here very honest, and I agree it's the right call for the
inner loop. One consequence worth spelling out, since the comment frames it as a call-site obligation: on main this was template <size_t NumBits> class Bitset, so a width mismatch was a compile error. Now it's assert only, which compiles out under NDEBUG, so in Release a mismatch is a silent OOB read of the inline std::array<word_type, 8>, and an OOB read of a heap allocation once the operand has spilled. No crash, no diagnostic, just a wrong number.
The same applies to L282, L344, L361, L378 and L548–549.
I couldn't find a call site that actually violates this, so this isn't a bug report, just an observation. Would a width assert at those construction boundaries be worth it?
Summary
Changes
Checklist
docs/,CONTRIBUTING.md) if neededCHANGELOG/ release notes updated if applicableAI/LLM disclosure
Important
By opening this PR I confirm that I have read CONTRIBUTING.md and I agree to the terms of the Contributor License Agreement.
Warning
If you're contributing on behalf of your employer, contact cla@algorithmiq.fi to arrange a Corporate CLA.