Skip to content

perf: 📦 build fat binary for multiple microarchitectures - #276

Closed
robertodr wants to merge 85 commits into
refactor-drop-nttpfrom
feat-fat-binary-narrow-seam
Closed

perf: 📦 build fat binary for multiple microarchitectures#276
robertodr wants to merge 85 commits into
refactor-drop-nttpfrom
feat-fat-binary-narrow-seam

Conversation

@robertodr

@robertodr robertodr commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

Enabling autovectorization has been done with the -march=native flag (monoprop_ENABLE_ARCH_FLAGS=ON) in local builds, but to make portable wheels, we have been compiling to the baseline of the manylinux container. For the various uses of popcount in this codebase that means effectively not using any CPU primitives (neither POPCNT nor VPOPCNTDQ).

The approach here creates a so-called fat binary where a single wheel ships multiple DSO, each compiled for a specific microarchitecture, and then there's a dispatcher wrapping and routing the calls to the best match for the machine once runs on.

Fixes #13.

x86-64

The fat binary includes the following microarchitectures:1

  • x86-64-v1 which is the baseline
  • x86-64-v2 which adds POPCNT
  • `x86-64-v3
  • x86-64-v4-vpopcntdq-vw256 AVX512 + VPOPCNTDQ with 256 bit vector width
  • x86-64-v4-vpopcntdq-vw512 AVX512 + VPOPCNTDQ with 512 bit vector width

aarch64

WIP

Changes

  • Retire entirely the use of -march=native
  • Fat-binary build is default on Linux x86-64
  • Fat-binary build is default on Linux aarch64

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: ClaudeCode: claude-opus-5

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.

Footnotes

  1. https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels

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
Ubuntu and others added 11 commits August 24, 2026 12:07
The width-bound kernel landed as a second copy of things the codebase already
had. Nothing here changes a term or an energy -- the baseline capture is
byte-identical to the previous commit -- but four of these were copies of a
computation that decides emitted term signs or feeds a cutoff, where drift is
silent and result-changing.

Duplicated definitions, now one each:

- pauli_rotation_sign was a verbatim copy of pauli_rotation_sign_words, and
  delegates to it. It also gains the hoisted storage-pointer select.
- WordKernel's fused_xor_into and parity_and restated the *unrolled* arms of
  the Bitset methods, which already bind W through with_nwords. Both sides now
  call detail::fused_xor_words<W> / and_fold_words<W>, declared ahead of them.
  splitmix stays a second implementation on purpose: it is monomial_hash, so
  the differential test against SplitmixHash is what pins owner routing, and
  delegation would make that test a tautology.
- with_kernel_width's eight-case switch is detail::with_nwords behind the
  width guard that helper's own contract asks a caller for.
- kOverflowMarker came back as a per-width class constant; the class comment
  and the static_assert referred to a name that no longer existed.
- OperatorIndex::row_addr hand-rolled the branch with_rows already is, and
  slot_bytes()/row_slots_capacity() make capacity(), row_bytes_capacity() and
  slack_bytes() plain arithmetic.
- The 1..kInlineWords sweep was byte-identical under two names in two new test
  files; it is now cpp/tests/InlineWidths.h, so the regime cannot be narrowed
  in one of them alone.
- tests/test_mode_width.py restated the circuit and its rationale twice.

Per-gate work that was never read:

- DenseTermProductsW held a whole DenseTermProducts<A> to answer record_words(),
  i.e. query_words(W). That second construction re-ran A::make_gen_context --
  interleave_phase_mask over the whole register on Majorana, a second nz_words
  allocation on Pauli -- plus two scratch Bitsets, per gate.
- even_mask_ was built per gate even on the Pauli path, which never reads it.
  A storage width is a whole number of words, so even_bits<LSb0> is 0x5555...
  in every one of them: fully_paired carries the literal and loses W mask loads
  per call as well as the member.

TermKernelFor's primary template handed out the dense kernel for *any* store,
so <SparseRowStore, A, 3> was silently dense while the "sparse never binds W"
invariant lived only in the dispatcher. The width folds into TermProductsFor
instead, where that combination is now an incomplete type, and the dispatch asks
a kBindsKernelWidth<Store> trait rather than an is_same_v.

Assisted-by: ClaudeCode:claude-opus-5
Each resolved on its own terms rather than as a batch.

Two were deferred as "real wins, but new perf work": packing the Pauli
generator's per-word constants into PauliGenContext, and binding the query
record's word count in push(). Both were implemented and measured pinned
single-threaded, which is the only setup here where the numbers mean
anything -- with the thread pool live it spins hard enough to inflate the
instruction count ~14x, and wall clock on this machine cannot resolve
better than ~2%. Both move either shipping model by under 0.05%: the
optimizer already hoists the per-gate derivations out of the inlined scan
loop, and a vector::insert of a constant range is no cheaper than the
push_backs it replaces. So push() keeps query_push, and the Pauli packing
is kept only for what it does to the code -- PauliGenContext goes from five
members to three, losing a shadow counter and a Bitset held solely so the
per-term loop could rebuild G's x-plane, and the hot loop loses two lines.

The other three:

- fully_paired moves out of WordKernel into AlgebraCommon.h as
  fully_paired_words<W>, beside the cutoff_sums it answers for and the
  even-bit literal it shares with CutoffMasks::make. "Paired" is a fact
  about the algebra, not about the storage; standing in for a Bitset method
  is now what decides WordKernel membership.
- monoprop_NARROW_KERNEL_MAX_WORDS' ceiling is read out of Bitset.h rather
  than restated as ^[0-8]$, so kInlineWords has one home. The check stays at
  configure time, and fails loudly if the declaration it scrapes moves.
- benches/conftest.py reads rows_are_sparse directly. A benchmark whose job
  is to name the backend that ran must fail against an extension that cannot
  say, not quietly record nothing.

Also makes DenseTermProductsW non-copyable: its three word pointers point
into its own bitsets, so a copy would read and write the original's storage.

No functional change: a fresh capture is byte-identical to clean HEAD, and
the sparse backend agrees with it to rtol 1e-10.

Assisted-by: ClaudeCode:claude-opus-5
Main's side of every C++ conflict was the pre-NTTP-removal code, so its changes
were ported onto this branch's runtime-width structure rather than either side
being taken whole. Main's three functional imports are preserved:

- the `found[j] < combined_size` guard on the self-resolve mark (#267), which
  auto-merged into this branch's `with_store`-bound engine;
- the `init_op_map` bucket release (#268). Its `rehash(0)` supersedes this
  branch's `init_op_map = MonomialMap{}`, so this branch's partial-drain test
  relaxes from `==` to `<=` on the bucket count -- main's version shrinks on any
  erase, where this branch's released only on a full drain;
- `matched_scratch_bytes` (#259), beside this branch's
  `inverted_index_columns_bytes`.

Main's nanobind 3 split mode (`BACKEND_MODULE nanobind_backend`, hence no
`STABLE_ABI`/`NB_STATIC`) applies to this branch's real `bindings.cpp`. With
`bindings.cpp.in` deleted there is nowhere to substitute `@nanobind_VERSION@`,
so `__nanobind_version__` -- which `src/monoprop/__init__.py` imports -- now
arrives as a `monoprop_NANOBIND_VERSION` compile definition from CMake, and
pyproject's build cache-keys point at `bindings/*.cpp` instead of the two gone
template files.

Main's repo-wide `class` -> `typename` style (#240) is applied to the 15
branch-only template sites the merge left behind, leaving the same single
`template <class>` main keeps in MPICompat.h.

Verified byte-identical to the pre-merge tip d37c408 (`capture-baseline`, 38
records over 10 cases), so the merge moves no term and no energy.
`.baseline-capture/golden` predates 9b43667 and d37c408 and differs from both
in `majorana_lattice_layer_30` by term order alone; it needs a refresh.

ctest 590/590 (295 under the sparse-rows label), pytest 620 passed, sparse-row
pytest 583 passed, sparse-vs-dense baselines agree to rtol 1e-10.

Assisted-by: ClaudeCode:claude-opus-5
`_meta()` still recorded `monoprop.MAX_NUM_MODES`, which this branch removed --
the width is a runtime argument, so there is no ceiling to report. The line
survived the merge from `main` because `_meta()` was not a conflict region, and
it aborted every benchmark run on this branch at `pytest_configure` with an
`AttributeError`.

Assisted-by: ClaudeCode:claude-opus-5
Published x86-64 wheels compiled with no architecture flags at all, because the
only alternative in the tree was `-march=native`, which cannot be shipped. A
source build therefore got a fully vectorized library and a PyPI install got one
targeting the 2003 baseline -- where `std::popcount` has no instruction and
lowers to `call __popcountdi2@PLT`, in a library whose inner loops are
population counts.

Compile the engine once per ISA tier instead, and select one at import:

  x86-64, x86-64-v2, x86-64-v3, x86-64-v4 + avx512vpopcntdq

all with `-mtune=skylake`. The tiers come out of a compile-time sweep of GCC's
`-fopt-info-vec-loop-all` reports across the psABI levels; the flag-level
evidence, the `-mtune` sweep and the ablation behind each choice are written up
in `docs/content/docs/fat-binary.mdx`.

Three of those choices are load-bearing:

* Whole libraries, not `target_clones`. The vectorization lands in headers that
  are inlined into their callers and instantiated a dozen times over across the
  basis, row-backend and word-width seams, so a per-function dispatch boundary
  would suppress the inlining it exists to enable.
* Not glibc-hwcaps either, which would need no code: its directory names are the
  four psABI levels, and `x86-64-v4` does not imply `avx512vpopcntdq`. Skylake-X
  and Cascade Lake are v4 with no vector popcount and would fault. The predicate
  has to be ours, so the dispatch does too.
* The baseline ISA is a global floor, not just the baseline tier's flags. A wheel
  contains objects from targets nobody tiered -- nanobind's static library --
  compiled at whatever the toolchain defaults to, and that default is not the
  psABI baseline (recent Ubuntu GCC is built `--with-arch-64=x86-64-v3`).
  Without the floor the v1 and v2 variants carried AVX2 in their glue and the CPU
  probe itself faulted on the machines it exists to detect.

Also fixes a provenance bug in the way: `Variants.h` was configured once from a
query of `-march=native` whenever `monoprop_ENABLE_ARCH_FLAGS` was ON, so
`__variant__`, `__compiler_flags__` and every benchmark artifact's machine-flags
entry reported the host's ISA regardless of what had been compiled. It is now
generated per variant, which is what makes it possible to tell which tier loaded.
The same one-decision rule closes the older disagreement where a Debug build
compiled portable code, advertised the native ISA and took the native-tuned
sparse-row crossover.

BREAKING CHANGE: `-ffp-contract=off` is now set project-wide. Without it,
`-march=x86-64-v3` and up fuse `a*b+c` into an FMA and the energy moves by 1-2
ULP (all evolved terms stay bit-identical), which in a fat binary would mean one
wheel answering differently depending on the host CPU. Existing `-march=native`
builds change in the last bits once, and a stored golden baseline needs
re-seeding. In exchange a source build, a wheel and all four tiers are
byte-comparable, and `just diff-baseline-variants` is the gate on it.

BREAKING CHANGE: `monoprop_VARIANT` and `monoprop_VARIANT_FLAGS` are gone from
the public `monoprop/Variants.h`. They were function-multiversioning scaffolding,
never used, and superseded by whole-library tiering. `variant()` now returns the
tier id (or `native`/`default`) rather than always `default`.

Assisted-by: ClaudeCode:claude-opus-5
The floor is appended to CMAKE_CXX_FLAGS, which is also where CXXFLAGS lands, so
the configure summary was attributing our flags to the user's environment.

Assisted-by: ClaudeCode:claude-opus-5
The reason on record -- that a per-function dispatch seam would suppress the
inlining the tiers depend on -- is wrong: `__attribute__((flatten))` answers it,
and clones built that way do vectorize at their own ISA (measured: ymm at
arch=x86-64-v3, zmm at arch=x86-64-v4, header templates inlined). The location is
favourable too, with 91% of the project's vectorized loops in one TU behind a
single non-template caller.

Replace it with the two reasons that hold. `avx512vpopcntdq` is not a valid ISA
name in a `target` attribute and `arch=` takes one name from a closed list, while
an `arch=<named core>` clone resolves on CPU identity rather than features, so it
would skip every non-Intel part with a vector popcount -- and that feature is 100%
of the top tier's measured value. And flattening build_layer's
with_algebra x with_store x with_kernel_width fan-out four times took one TU from
16.7 s to over 20 minutes at ~100 GB of compiler memory, against 16 GB runners.

Assisted-by: ClaudeCode:claude-opus-5
Two errors in the previous note. avx512vpopcntdq IS expressible -- a comma
separates options in a plain `target` attribute, and only separates clones in
`target_clones`, which is where that error came from. And the real obstacle is
narrower and more general than a missing flag name: GCC will not inline across an
`arch` mismatch, so a targeted wrapper around the engine compiles to a jmp with
`flatten` having no effect, and `#pragma GCC target` does not capture templates
defined outside its region. Header-resident code is widened by its TU's command
line or not at all.

Also record that collapsing the width axis does not rescue flatten (OOM at 24.5 GB
against 740 MB for the same code compiled normally), and that the duplication is
reducible a different way: 91% of the vectorized loops and 51% of the engine's
.text are in one TU.

Assisted-by: ClaudeCode:claude-opus-5
…at rejects it

Tier one translation unit instead of the whole library: put build_layer's
instantiation tree in cpp/monoprop/detail/evolution/TieredLayerBuild.cpp,
compile that once per ISA tier, compile the other nine at the baseline, and
dispatch through a function pointer at MonomialPropagator::build_evolve_result_.
Selected with monoprop_FAT_BINARY_MODE=narrow-seam; whole-library stays the
default and the only shippable mode.

The arithmetic holds. 1.96 MB of payload against 5.00 MB, 84 s of engine
compile against 197 s, and with the top tier's object first on the link line
the seam delivers 87% of the whole-library win (2089 ms against 2054 ms
tiered and 2338 ms baseline, on the 120-mode Hubbard model).

The mechanism does not. build_layer is a template -- its auto parameters make
it one -- and so is every fused_find_and_collect<A, W> under it, which makes
them weak COMDAT symbols whose mangled names carry no tier: 242 of 249 weak
symbols are byte-identical between the v1 and v4+vpopcntdq objects. The linker
keeps one definition per name, so the four tiers collapse into whichever the
link line lists first and the dispatch selects between four entry points that
all tail into the same code. Pinning each tier in turn gives 2327.7 / 2340.2 /
2318.9 ms -- flat, and level with a fully-baseline build; relinking the same
objects in reverse order makes every pin run at 2089 ms.

Nothing else notices. The module imports, each tier reports its own identity,
every tier returns bit-identical numbers, the Python and C++ suites pass and
diff-baseline-variants is clean -- the tiers were never meant to differ in
their answers and now do not differ in their instructions either. So the gate
is a build-time symbol check, tools/check-tier-symbols.py, and the mode carries
a CMake warning rather than a footnote: the surviving set is "the first
definition of each name", not "the first tier", which is why the module still
holds 29 zmm instructions in a build that dispatches to the baseline.

Also measured, as the reference line the seam is compared against: the tiers
are worth 12.1% (Hubbard) and 10.9% (kicked-Ising) end to end, v1 to
v4+vpopcntdq, with every step outside the run-to-run spread.

Assisted-by: ClaudeCode:claude-opus-5
… works

monoprop_FAT_BINARY_MODE=tier-dso. Same seam as narrow-seam -- the same tiered
translation unit, the same TierDispatch.cpp, the same generated predicate table,
one extension module -- with each tier's copy linked into its own
monoprop/lib/libmonoprop-tier-<id>.so instead of all four into one module.

That is the whole fix. COMDAT deduplication is per link, so build_layer's weak
template instantiations now collapse inside a tier rather than across the four,
and the run-time dispatch finally has something to choose between. Pinning each
tier (one thread, partitions off, two samples, medians):

                      Hubbard 120-mode      kicked-Ising 127-qubit
  x86-64-v1              2325.6 ms                412.5 ms
  x86-64-v2              2150.4 ms                367.6 ms
  x86-64-v3              2118.2 ms                372.9 ms
  x86-64-v4-vpopcntdq    2054.2 ms                362.4 ms

Whole-library reaches 2054.0 and 365.2 from 2337.9 and 410.0, so this is not the
87% the relink experiment predicted but all of it: the nine translation units
left at the baseline contribute nothing measurable. narrow-seam was flat at
~2330 and ~412 whatever the pin.

Two findings from finally having every rung timed. v2 is the single biggest step
-- 64% of the Hubbard win, 90% of the Pauli one -- because the v1 tier holds zero
popcnt instructions and the v2 tier holds 459: POPCNT arrived with the psABI's
second level, so at -march=x86-64 every std::popcount in the engine compiles to
the SWAR bit-twiddle. It is scalar, which is why the static vectorization census
that chose the tier list ranked this step near the bottom. And v3 buys nothing on
the Pauli model, reproduced in both samples.

Payload 3.08 MB against whole-library's 5.01 MB, and the ISA floor is now
structural rather than argued: _core.so and libmonoprop.so census at zero on
every counter, where narrow-seam shipped a module holding 29 zmm instructions of
unproven reachability.

The cost in source is a five-symbol ABI, because a shared object has to resolve
its references. The tiered TU's only out-of-line dependencies on the rest of the
engine are mpi::rank, mpi::size, mpi::alltoall_counts,
detail::build_layer_storage_unified and validate_only_rotate_len_k; they carry
monoprop_TIER_ABI and the three entry points carry monoprop_TIER_ENTRY
(cpp/monoprop/detail/TierAbi.h), against the -fvisibility=hidden the engine
already had. Three hazards checked and none bites: a hidden-visibility
NonEncodableCoefficient thrown from inside build_layer still matches its catch
outside (GCC does not mark the typeinfo name unique, so libstdc++ string-compares
it -- verified directly); config::get()'s Settings and the ev_fn inline variables
are reachable only from MonomialPropagator.cpp and so are never duplicated; and
cached_even_bits' thread_local is duplicated but is a pure function of the width.

tools/check-tier-symbols.py now picks its checks from the tree's CMakeCache
rather than from which artefacts are on disk -- a mode switch leaves the previous
shape's outputs in place, so file presence identified the last mode that ran, not
the one that built the module. Under tier-dso it checks the properties that make
the shape sound: no tier exports a monoprop symbol another exports, none exports
one weakly, every monoprop symbol a tier imports is exported by the module that
loads it, and the baseline tier and shared module hold nothing above the floor.
It also counts popcnt now, without which a v1-plus-v2 ladder would read as
collapsed. Its prints became sys.stdout.write, the convention under tools/, which
is what the ruff hook had been failing on.

whole-library stays the default. narrow-seam is kept for the measurement that
rejected it, still warns, and its gate still fails by design.

Verified in all three shapes: tier-dso 630 Python + 560 C++ + byte-identical
diff-baseline-variants + 629 per tier under test-variants; whole-library 631 +
560 + byte-identical; plain -march=native byte-identical against golden.

Assisted-by: ClaudeCode:claude-opus-5
…er by vector width

One switch, `monoprop_ENABLE_TIERED_DSO`, replaces `monoprop_ENABLE_ARCH_FLAGS`,
`monoprop_ENABLE_FAT_BINARY` and `monoprop_FAT_BINARY_MODE`. It defaults ON wherever
it can work -- x86-64, GCC or Clang, outside Debug -- so a plain `uv sync` now
produces the shape the wheels ship, and there is no config setting for a later
`uv run` to lose. `-march=native` is no longer a default anywhere;
`monoprop_ARCH_MARCH` asks for one explicitly and is rejected alongside the tiers
rather than silently ignored.

The old default rested on an assumption that no longer holds. Measured on this Zen 4
host, three samples of five rounds, single thread: `-march=native` gives 2056.3 ms on
the 120-mode Hubbard model against the tiered build's 2058.7, and *loses* the
127-qubit kicked-Ising one at 363.2 against 359.9. So the reason to build single-ISA
is build time, not speed.

The reason it loses is the new tier. The top tier is now a pair, `-vw256` and
`-vw512`: identical `-march`, identical `__builtin_cpu_supports` requirements,
differing only in `-mprefer-vector-width`. GCC otherwise takes that from the `-mtune`
tables, so the shipped vector width was a consequence of `monoprop_FAT_MTUNE` -- a
core picked for its instruction schedule -- and would have flipped had that ever been
pointed at an Intel server core. And it is not a question CPUID can answer: what it
turns on is how wide the datapath behind the registers is and what the core charges
in clock for using it, and there is no bit for either.

So the discriminator is a core-name table, `monoprop_FAT_NARROW_VECTOR_CORES`, read
through `__builtin_cpu_is` and validated at configure time. `znver4` is on it because
it is measured -- 1.1% to the narrow tier on the Pauli model with the two ranges
disjoint, which is a correction to GCC, whose znver4 tuning resolves to 512. Ice Lake
through Rocket Lake are on it for their ~175 MHz frequency penalty; Zen 5 and
Sapphire Rapids onwards are off it on the mechanism (full-width datapath, penalty
gone) and neither could be timed here.

Two consequences worth naming. A tier now carries two predicates, `runnable`
(features only, what gates a `monoprop_VARIANT` pin) and `preferred` (plus the table,
what the selection and `supported_variants()` read); conflating them made the wide
tier unpinnable on exactly the machines worth comparing it on. And nothing but a
disassembly distinguishes the pair, so `tools/check-tier-symbols.py` asserts the
widths differ -- every test, number and symbol table agrees even if the flag stops
arriving.

Removed with the modes they served: the whole-library packaging and its
per-tier extension modules, `src/monoprop/bindings/isa.cpp` and the `monoprop._isa`
probe, `monoprop/_variants/`, and the narrow-seam experiment (the measurement that
rejected it is kept in the docs). `monoprop._bootstrap` becomes `monoprop._tiers`,
206 lines lighter, and only forwards two queries into the engine.

BREAKING CHANGE: `monoprop_ENABLE_ARCH_FLAGS`, `monoprop_ENABLE_FAT_BINARY` and
`monoprop_FAT_BINARY_MODE` are gone, and so is the `-march=native` default. The
`x86-64-v4-vpopcntdq` tier id is replaced by `x86-64-v4-vpopcntdq-vw256` and
`-vw512`, so a `monoprop_VARIANT` pin naming the old one is now refused. A default
build's sparse-row crossover is 256 rather than 768, which changes term order on
cases at or above 256 storage modes: a stored byte-wise baseline captured before this
needs recapturing.

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 25, 2026
@robertodr

This comment was marked as outdated.

@robertodr

Copy link
Copy Markdown
Member Author

🤖 AI text below 🤖

Hubbard benchmarks on the fat binary

Rebuilt 162ab2a as the tiered default (plain uv sync, monoprop_ENABLE_TIERED_DSO on) and re-ran the
#226 grid — sites 32 / 64 / 124,
at cutoff=6, lower_atol=1e-6 and cutoff=8, lower_atol=1e-5. Rows read <num_sites>_<cutoff>_<lower_atol>.

The loaded tier is x86-64-v4-vpopcntdq-vw256. This is a Zen 4 host, so znver4 is on
monoprop_FAT_NARROW_VECTOR_CORES and the 512-bit tier is absent from supported_variants():

available  ('x86-64-v4-vpopcntdq-vw512', 'x86-64-v4-vpopcntdq-vw256', 'x86-64-v3', 'x86-64-v2', 'x86-64-v1')
supported  ('x86-64-v4-vpopcntdq-vw256', 'x86-64-v3', 'x86-64-v2', 'x86-64-v1')

The headline: the dispatched tier is -march=native, within the noise. On the four configs where the
row backend agrees, fat is +0.51%, +0.47%, −0.14%, +0.44% on time and +15, +1, +76, +8 MiB on peak RSS
against a same-day native build. The 124-site rows differ for an unrelated reason — the row-store
crossover — isolated below.

just check-tier-symbols passes on the build: every tier is its own link and keeps its own code.

Read the first two columns for shape only

main and 16d8ad8 are the stored August/earlier runs from the #226 comment, not re-measured. This host
is 8–9% slower today than when they were taken
— the earlier comment already documented a 2–4% drift, and
it has grown. That is why the third column exists: a just build-native build measured today, in between the
two fat sweeps, which is the only column the fat one may be differenced against.

Time

Operation main 16d8ad8 native, today fat, today
32_6_6 52.871 s 58.699 s 63.438 s 63.761 s
32_8_5 15.661 s 15.896 s 16.986 s 17.065 s
64_6_6 61.750 s 64.559 s 70.275 s 70.178 s
64_8_5 16.498 s 17.070 s 18.558 s 18.640 s
124_6_6 81.586 s 80.690 s 89.150 s 79.818 s
124_8_5 19.680 s 20.457 s 22.335 s 22.212 s

Memory (peak RSS)

Operation main 16d8ad8 native, today fat, today
32_6_6 18617.14 MiB 17909.15 MiB 17881.08 MiB 17896.19 MiB
32_8_5 6700.80 MiB 6465.30 MiB 6466.69 MiB 6467.27 MiB
64_6_6 18929.92 MiB 18270.70 MiB 18329.44 MiB 18405.51 MiB
64_8_5 6766.88 MiB 6505.06 MiB 6506.05 MiB 6514.38 MiB
124_6_6 19570.04 MiB 19641.00 MiB 19624.71 MiB 20590.88 MiB
124_8_5 8200.38 MiB 8217.71 MiB 8227.89 MiB 7947.16 MiB

operator_memory_breakdown().total_bytes

Operation main 16d8ad8 native, today fat, today
32_6_6 12934.81 MiB 13645.47 MiB 13645.47 MiB 13645.47 MiB
32_8_5 5023.67 MiB 5277.32 MiB 5277.32 MiB 5277.32 MiB
64_6_6 12914.29 MiB 13619.96 MiB 13619.96 MiB 13619.96 MiB
64_8_5 5035.86 MiB 5302.48 MiB 5302.48 MiB 5302.48 MiB
124_6_6 15080.73 MiB 15814.41 MiB 15814.41 MiB 17705.96 MiB
124_8_5 5901.93 MiB 6169.08 MiB 6169.08 MiB 6755.74 MiB

operator_terms_bytes

Operation main 16d8ad8 native, today fat, today
32_6_6 2222.22 MiB 2222.22 MiB 2222.22 MiB 2222.22 MiB
32_8_5 881.70 MiB 881.70 MiB 881.70 MiB 881.70 MiB
64_6_6 2206.03 MiB 2206.03 MiB 2206.03 MiB 2206.03 MiB
64_8_5 891.50 MiB 891.50 MiB 891.50 MiB 891.50 MiB
124_6_6 4413.62 MiB 4413.62 MiB 4413.62 MiB 6305.18 MiB
124_8_5 1759.98 MiB 1759.98 MiB 1759.98 MiB 2346.64 MiB

Row backend resolved

Operation main 16d8ad8 native, today fat, today
32_6_6 (not recorded) dense dense dense
32_8_5 (not recorded) dense dense dense
64_6_6 (not recorded) dense dense dense
64_8_5 (not recorded) dense dense dense
124_6_6 (not recorded) dense dense sparse
124_8_5 (not recorded) dense dense sparse

fat against the same-day native control

Operation Time Peak RSS Backend
32_6_6 +0.51% +15.11 MiB same (dense)
32_8_5 +0.47% +0.59 MiB same (dense)
64_6_6 -0.14% +76.07 MiB same (dense)
64_8_5 +0.44% +8.32 MiB same (dense)
124_6_6 -10.47% +966.17 MiB differs (native dense, fat sparse)
124_8_5 -0.55% -280.74 MiB differs (native dense, fat sparse)

124 sites, isolating the row backend from the ISA tier

Operation Build Backend Time Peak RSS operator_terms_bytes
124_6_6 native (crossover 768) dense 89.150 s 19624.71 MiB 4413.62 MiB
124_6_6 fat (crossover 256) sparse 79.818 s 20590.88 MiB 6305.18 MiB
124_6_6 fat + monoprop_ROW_STORE=dense dense 87.518 s 19672.80 MiB 4413.62 MiB
124_8_5 native (crossover 768) dense 22.335 s 8227.89 MiB 1759.98 MiB
124_8_5 fat (crossover 256) sparse 22.212 s 7947.16 MiB 2346.64 MiB
124_8_5 fat + monoprop_ROW_STORE=dense dense 22.170 s 8269.75 MiB 1759.98 MiB

Spread

Operation native, today fat, today
32_6_6 2.78 s 2.84 s
32_8_5 0.60 s 0.58 s
64_6_6 1.62 s 1.41 s
64_8_5 0.59 s 0.53 s
124_6_6 2.30 s 2.67 s
124_8_5 0.45 s 0.63 s

Evolved term counts (identical across all four columns)

  • 32_6_6: 260,746,764 — identical
  • 32_8_5: 95,752,255 — identical
  • 64_6_6: 260,762,912 — identical
  • 64_8_5: 95,752,255 — identical
  • 124_6_6: 260,762,912 — identical
  • 124_8_5: 95,752,255 — identical

124 sites: the crossover, not the ISA

At 124 sites the operator is 248 logical / 256 storage modes, and the two builds resolve different row
backends
: a tiered build pins monoprop_SPARSE_ROW_MIN_MODES=256 across all tiers (so the accumulation
order cannot depend on the host CPU), while monoprop_ARCH_MARCH=native gets 768. So native runs dense
there and fat runs sparse. Pinning monoprop_ROW_STORE=dense on the fat build removes the confound:

  • The tier itself is native-equivalent there too: 87.518 s vs 89.150 s (−1.8%) at 124_6_6, 22.170 s vs
    22.335 s (−0.7%) at 124_8_5, with peak RSS inside 48 MiB and operator_terms_bytes byte-identical.
  • The sparse backend is a real trade at this width, and it is what the fat column's 124-site numbers
    actually show. At 124_6_6 it is −8.8% time (79.818 s vs the dense 87.518 s) for +1891.56 MiB of
    operator_terms_bytes and +918 MiB of peak RSS. At 124_8_5 it is time-neutral (22.212 s vs 22.170 s),
    costs +586.66 MiB of operator_terms_bytes, and yet lowers peak RSS by 322 MiB — the peak there is set
    by build transients, not by the operator.

Worth knowing, since every wheel from this PR onwards picks sparse at ≥256 storage modes where the previous
local-build default did not. It is not a regression introduced here — it is the documented consequence of
pinning one crossover across tiers — but the 6_6 row is the first measurement of what that pinning buys and
costs on a shipping model.

The tiering is numerically inert on this workload

Every column agrees on the evolved term count at every config, including the sparse-backend runs. (The
byte-wise gate across tiers is just diff-baseline-variants, which I did not re-run for this comment.)

Caveats

  • Time is the mean over --model-rounds 5; the per-column standard deviations are tabulated above (0.45–2.84 s),
    so a single row's sub-2% difference is not resolvable — the fat-vs-native claim rests on all four
    backend-matched rows agreeing.
  • Peak RSS is --model-rounds 1 throughout: at 5 rounds setup= overlaps two rounds' live memory.
  • Single rank, default thread count (64 logical / 64 physical), --hubbard-observable-site=18, same host as
    the refactor!: ♻️ drop non-type template parameter for number of modes #226 columns.
  • main's lower total_bytes is the accounting difference (matched_scratch_bytes) explained in the refactor!: ♻️ drop non-type template parameter for number of modes #226
    comment, not less memory. Where the backend matches, the fat column equals the 16d8ad8 column
    byte-for-byte in every operator_memory_breakdown() field.
Commands
# fat (tiered default)
uv sync --all-groups --all-extras --reinstall-package monoprop --no-cache -v

# native control
uv sync --all-groups --all-extras --reinstall-package monoprop --no-cache -v \
  --config-settings-package="monoprop:cmake.define.monoprop_ENABLE_TIERED_DSO=OFF" \
  --config-settings-package="monoprop:cmake.define.monoprop_ARCH_MARCH=native"

# one config; --model-rounds 5 for the time column, 1 for the memory column
monoprop_BENCH_LABEL="$label" monoprop_BENCH_RESULTS=benches/results \
  uv run --no-sync python -m pytest benches --benchmark-json="benches/results/time-$label.json" \
  -k "test_model[hubbard]" --hubbard-num-sites 124 --hubbard-cutoff 6 \
  --hubbard-lower-atol 1e-6 --hubbard-observable-site 18 --model-rounds 5

just build-native was not used for the control: it syncs only the test group, which prunes
pytest-benchmark and monoprop-bench-tools out of the environment and aborts the benchmark run at
conftest import. Worth a fix on that recipe.

@robertodr

Copy link
Copy Markdown
Member Author

re-hashed as #311

@robertodr robertodr closed this Aug 29, 2026
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.

Document usage of architecture dependent compiler flags

1 participant