Skip to content

refactor!: drop non-type template parameter for number of modes - #226

Draft
robertodr wants to merge 73 commits into
mainfrom
refactor-drop-nttp
Draft

refactor!: drop non-type template parameter for number of modes#226
robertodr wants to merge 73 commits into
mainfrom
refactor-drop-nttp

Conversation

@robertodr

Copy link
Copy Markdown
Member

Summary

Changes

Checklist

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

AI/LLM disclosure

  • I did not use LLM tooling, or used it only privately for ideation
  • I used the following tool to help write this PR description:
  • I used the following tool to generate or modify code:

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.

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
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file python cpp ci tools labels Aug 13, 2026
@github-actions

Copy link
Copy Markdown

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

@robertodr

robertodr commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

benchmarks against main for the Hubbard model

Read rows <num_sites>_<cutoff>_<lower_atol>

Time

Operation main refactor-drop-nttp
32_6_6 52.871 s 61.417 s
32_8_5 15.661 s 16.081 s
64_6_6 61.750 s 67.608 s
64_8_5 16.498 s 17.360 s
124_6_6 81.586 s 80.307 s
124_8_5 19.680 s 20.404 s

Memory (peak RSS)

Operation main refactor-drop-nttp
32_6_6 18617.14 MiB 19386.70 MiB
32_8_5 6700.80 MiB 8945.71 MiB
64_6_6 18929.92 MiB 19462.99 MiB
64_8_5 6766.88 MiB 9028.16 MiB
124_6_6 19570.04 MiB 19675.12 MiB
124_8_5 8200.38 MiB 8295.32 MiB

[[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);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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.

Suggested change
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;
}

Comment thread AGENTS.md
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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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.

Suggested change
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_)));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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.

Comment thread CMakeLists.txt
# 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()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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.

Comment thread pyproject.toml
"cspell.json",
]
sdist.include = ["src/monoprop/_version.py", "src/monoprop/_dispatch.py"]
sdist.include = ["src/monoprop/_version.py"]

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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_()) {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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); });

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 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.

Comment on lines +222 to +224
sparse-rows
ENVIRONMENT
"monoprop_ROW_STORE=sparse"

@Panadestein Panadestein Aug 17, 2026

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.

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.

Comment thread cpp/monoprop/Bitset.h
Comment on lines +256 to +260
// 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.

@Panadestein Panadestein Aug 17, 2026

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.

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci cpp dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation python tools

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants