perf: 📦 build fat binary for multiple microarchitectures - #276
perf: 📦 build fat binary for multiple microarchitectures#276robertodr wants to merge 85 commits into
Conversation
Assisted-by: GitHub Copilot: gpt-5.6-sol (plan), claude-haiku-4.5/claude-sonnet-4.6 (execute)
with before-build we re-ran the same installation script before every build.
Assisted-by: GitHub Copilot, gpt-5.3-codex
Stage 0 of the NumModes-NTTP-removal plan: the regression instrument every later stage's "Verify" step relies on. - tools/capture-baseline.py: propagates every tests/data/*.msgpack fixture through MajoranaPropagator across a few cutoffs/cutoff_types, plus a couple of native PauliOperator smoke problems through PauliPropagator (Basis::Pauli coverage -- there is no Majorana -> Pauli operator converter in the public API to press a fermionic fixture into a qubit circuit, so this is hand-picked instead), and dumps term counts, the full (indices, coefficient) set in engine-native order, and the expectation value. Probe cutoffs are capped at 4 -- support cutoff grows combinatorially and 6 on the largest fixture (28 modes) produced 13.7M surviving terms. - justfile: `just capture-baseline [LABEL]` and `just diff-baseline [AGAINST]`. - .gitignore: exclude the capture output directory. Verified reproducible: two independent serial captures are byte-identical, and the three "exact" fixtures' captured energies agree with their fixture's actual_energy to ~1e-13. Assisted-by: ClaudeCode:claude-sonnet-5
Stage 1 of the NumModes-NTTP-removal plan: emit_term_products (the per-term hot path in fused_find_and_collect) always needs both the XORed monomial and popcount(mono & gen); today those come from two separate full-width passes over the same operands (operator^ then count_and()). Bitset::fused_xor computes both (plus popcount(result), unused here but exposed for a future caller) in one pass. Kept narrowly scoped to what's unconditionally needed every call: the arithmetic new_pop identity (mono_pop + gen_pop - 2*overlap) and the conditional cutoff_sums / monomial_hash call sites are untouched -- forcing either into the same always-run loop would trade a cheap O(1)/conditional computation for an unconditional one, which is not what this stage is after. This also keeps SplitmixHash itself untouched, per the plan's invariant. Verified bit-identical: `just diff-baseline` reports no differences, full C++ suite (198/198) and pytest (592 passed) still green. Benchmarked benches/bench_models.py's hubbard model at the W=1 regime this targets (`--hubbard-num-sites 8 --hubbard-trotter-steps 5 --hubbard-observable-site 3`, 12 samples each side): median 4.65ms before -> 3.95ms after (~15% faster), with the after distribution also markedly tighter (no outliers vs. two 3-4x outliers before). At the suite's default 120-mode/W=4 config the two are within noise of each other, as expected -- this stage's fusion only pays off in the small-W band; W=4 is already past where a runtime trip count would cost anything (see the plan's Stage 2). Assisted-by: ClaudeCode:claude-sonnet-5
…ayer Stage 2a (part 1/N) of the NumModes-NTTP-removal plan: the mechanical "deduce instead of spell" pass, scoped to cpp/monoprop/algebra/*.h plus the call sites it touches. No codegen change -- same instantiations, same compiled code -- just nobody spells the width where a MonomialLike argument already carries it. - core/Monomial.h: new MonomialLike concept (Bitset-shaped: static size(), num_words(), member count()/find_first()) recovering NumModes as decltype(mono)::size() / 2 where a value is still needed. - AlgebraCommon.h: bitset_to_indices, is_paired (2-arg and 1-arg monomial overloads), cutoff_sums, length_cutoff, support_cutoff. - MajoranaAlgebra.h: hermitian_coefficient, majorana_state_phase, interleave_phase, interleave_phase_mask, encode_coeff, decode_coeff, change_basis. - PauliAlgebra.h: pair_swap, pauli_y_count, pauli_anticommutes, pauli_rotation_sign, pauli_state_phase, make_pauli_gen_context (this one's return type still names NumModes explicitly -- it sizes PauliGenContext's fixed nz_words array, and that class stays templated until Stage 2c/2d). - Algebra.h: algebra_fold_generator, algebra_fold_needs_odd_correction, algebra_encode_coeff, algebra_decode_coeff, algebra_state_phase. Left explicit, deliberately: functions with no monomial-shaped argument to deduce from -- indices_to_bitset(_checked), initial_state_mask, monomial_from_selector, generate_paired_op, pauli_even_mask, is_paired's VecZ overload, is_fully_paired, with_algebra, algebra_score_state -- plus every class template (LengthCutoff/SupportCutoff/CutoffEvaluator, MajoranaAlgebra<N>/PauliAlgebra<N>, PauliGenContext<N>), which is 2c/2d's job, not 2a's. Verified bit-identical: `just diff-baseline` reports no differences, full C++ suite (198/198) and pytest (592 passed) green, clang-format clean. Assisted-by: ClaudeCode:claude-sonnet-5
…/evolution layer
Stage 2a (part 2/N) of the NumModes-NTTP-removal plan: continues the part-1
algebra-layer pass into TypeAliases.h, the operator layer, and the
evolution/layer_build cluster. No codegen change -- same instantiations,
same compiled code.
- TypeAliases.h: materialize_row/assign_row/row_popcount/for_each_row_position,
both overloads. The vector overload deduces via `std::vector<MonomialLike
auto>`; the detail::OperatorIndex overload via a new RowStoreLike concept
(value_type + row(i)) since OperatorIndex itself isn't Monomial-shaped.
- InvertedIndex.h: combine_columns_block. MPOperator.h: insert_absent_terms
(fully deduced, no constraint needed -- nothing in its body reads
NumModes), plus a stale forward-declaration of algebra_encode_coeff that
would otherwise silently resolve to an undefined, differently-templated
overload.
- Scan.h: build_even_parity_generator_columns, even_parity_scan_pass1,
emit_term_products, fused_find_and_collect (both keep their Algebra `A`
template parameter named -- it isn't deducible from any argument, exactly
as before).
- Resolve.h: insert_incoming_misses (fully deduced); resolve_incoming /
process_responses keep `Sink` named (referenced by name in the body and
return type; call sites already relied on deducing it from `sink`, only
the leading NumModes was ever spelled explicitly).
- Engine.h: append_inserted_endpoints, build_layer (the primary layer-build
entry point -- local_op/gen deduce, cutoff_fn stays plain auto, and a
local `num_modes` constant threads through to the still-templated
LayerBuildEngine/ContractSink/GraphSink and to Stage 2e's kWords/kQueryWords).
- CosineRecompute.h: the whole fold-cache/lazy-fold cluster except
generator_from_words (no monomial-shaped argument to deduce from).
- PartitionGroup.h: collect_on_all (map_partitions keeps NumModes named --
its R-default depends on it by name, same obstacle as Common.h's
query_read/QW).
Deliberately left untouched, and why:
- Common.h (kQueryWords, kQueryWordsFused, query_push, query_read,
query_phase, query_value, build_fused_query_value), MPIUtils.h (kWords,
append_monomial_words, read_monomial_from_words), Engine.h's
generator_words line, and CheckedCount.h -- the plan's own Stage 2e ("wire
format to a runtime stride") claims this cluster explicitly; converting it
piecemeal here would fight that stage rather than prepare for it.
- probe_incoming_queries, cutoff_function_basis_change, map_partitions --
each has a second template parameter whose default value expression
names NumModes directly; abbreviating the deducible parameter would leave
no name for that default to reference.
- core/Monomial.h's monomial_hash -- outside this stage's stated file list,
and adjacent to the hash/routing invariant (see Stage 1's commit); not
worth the risk for a spelling-only change with no callers left needing it.
Verified bit-identical: `just diff-baseline` reports no differences. Full
C++ suite green serial (198/198) and MPI-enabled build (206/206, including
the mpi-2 CTest case); pytest green serial (592 passed) and under mpiexec
at n=2 and n=4 (600/600 each). clang-format clean.
Assisted-by: ClaudeCode:claude-sonnet-5
Stage 2b of the NumModes-NTTP-removal plan. `Bitset` stops being
`Bitset<NumBits>` and carries its width as data; `Monomial<NumModes>` stops
being an alias for it and becomes a thin transitional wrapper so that no call
site outside this commit has to change yet.
Bitset (cpp/monoprop/Bitset.h, rewritten):
- Width lives in two uint32_t members (`nwords_`, `top_bits_`) instead of an
NTTP. `size()`/`num_words()` are runtime instance methods.
- Storage is inline-capacity-plus-spill: the first 8 words (`kInlineWords`,
which covers the whole shipped range -- monoprop_MAX_NUM_MODES=250 is 500
bits is 8 words) sit in a `std::array`; a wider bitset spills the *entire*
word array to a `std::vector`, so `data()`/`word(i)` stay a single
contiguous view whichever storage is live. Nothing in the shipped
configuration allocates.
- `detail::with_nwords(n, f)` replaces the `if constexpr (num_words() == 1)`
compile-time branches the NTTP used to permit: a `switch` over n in [0, 8]
handing `f` an `std::integral_constant`, so each arm keeps a compile-time
trip count and stays fully unrolled even though the width is now runtime
data. Every per-word method routes through it below the inline ceiling and
falls back to a plain loop above.
- `SplitmixHash` is no longer a template and its single-word fast path is a
runtime `if`. The hash *function* is untouched -- see the bit-identity check
below, which is what actually pins that.
- `FusedXor` is declared inside the class and defined after it: a non-template
class cannot hold a nested member of its own (still incomplete) type by
value, which the `Bitset<NumBits>` template happened to allow.
Monomial (cpp/monoprop/core/Monomial.h):
- `Monomial<NumModes>` is now an empty subclass of `Bitset` that feeds
2 * NumModes to the runtime constructor and shadows `size()`/`num_words()`
with `static constexpr` versions. That keeps every existing spelling working
unchanged -- default construction, and the array-sizing and
template-argument uses (`std::array<size_t, Monomial<N>::size()>`,
`kQueryWords<N>`, ...) that Stages 2c/2d/2f have not migrated yet.
- This is deliberately a shim, not the end state. It exists so 2b lands and
verifies on its own; the plan's "Monomial stops being an alias" wording is
reached when 2c/2d/2f remove the last compile-time-NumModes consumers, at
which point the wrapper is deleted rather than kept.
- Operators (`^`, `&`, `>>`, `fused_xor().result`, ...) are inherited and
return plain `Bitset` by value rather than being redefined to rewrap. Safe
because nothing recovers NumModes from an operator's result -- only from a
parameter or an explicitly Monomial-typed local -- but see the two bugs that
fell out of exactly this, below.
- `Monomial` needs its own `operator==(const Monomial&, uint64_t)` pair:
overload resolution will not chain Monomial's converting constructor with a
derived-to-base binding to reach the inherited
`Bitset::operator==(const Bitset&)`, so the `mono == 0b1010` literal
comparisons in the test suite stopped compiling on the constructor alone.
Fallout fixed (all of it the same root cause -- code that recovered a
compile-time width from a *qualified* `decltype(x)::size()` breaks the moment
`x` is a plain `Bitset`):
- `pair_swap`, `pauli_y_count` and `pauli_rotation_sign` took
`MonomialLike auto`, so a caller could always hand them a plain Bitset from
`a ^ b` -- and pauli_algebra_tests.cpp does. They now use instance calls
(`p.size()`, `p.num_words()`) throughout, against a new runtime-width
`pauli_even_mask(size_t num_modes)` sibling of the existing
`pauli_even_mask<NumModes>()`. `pair_swap`'s local result is
`Bitset result(p.size())` rather than a default-constructed `Mono`, which
would be zero-width if `Mono` resolved to the base.
- `even_bits`/`odd_bits` gain runtime-width overloads
(`even_bits<Ordering>(n)`) next to the existing compile-time ones; the
`detail::` helpers take `n` as an ordinary argument.
- `cutoff_sums`'s ternary needs an explicit upcast on the non-shifted branch:
one arm is `Monomial<N>`, the other is the plain `Bitset` from `mono >> k`,
and conversions exist in both directions, so the common type was ambiguous.
- Two `constexpr` mask locals demote to `const`: `Bitset` holds a vector and
is no longer a literal type. Both are loop-invariant hoists in code the
compiler can still fold; Stage 2's benchmark leg is where that gets measured
rather than asserted.
`MonomialLike` switches from qualified to instance calls, so plain `Bitset`
satisfies it too and not only the wrapper.
Verification:
- `just diff-baseline`: bit-identical to the golden capture (identical term
sets, coefficients and energies across all 9 fixtures / 34 records;
manifest sha256 matches). This is what pins SplitmixHash and the storage
rounding, per the plan's Stages 1-2 acceptance bar.
- ctest serial: 201/201 (198 before, plus three new spilled-width cases:
`bitset_trampoline_inline_spill_boundary` at n in {511, 512, 513, 576, 1024,
4096}, `bitset_spilled_copy_is_independent`, `bitset_spilled_find_chain` --
the heap-spill path is new capability, so it gets its own coverage).
- bitset_tests.cpp keeps `std::bitset<N>` as the compile-time oracle while
constructing the subject at runtime, so the differential tests still compare
against an independent implementation.
- pytest serial: 592 passed, 8 deselected.
- MPI build: ctest 209/209 (including the mpi-2 labelled run); pytest
--with-mpi 600 passed at both n=2 and n=4.
AGENTS.md's "Core abstractions" entry described Monomial<N> as Bitset<2*N>,
which this commit makes false, so it is updated here per the repo's
documentation policy. No user-facing API, path or workflow changes.
Unrelated pre-existing flake, noted so it is not mistaken for fallout from
this commit: in the MPI-enabled build, `ctest -j4` intermittently kills one
arbitrary test with SIGPIPE (a different test each run, each passing in
isolation and under -j1). It reproduces on the parent commit's MPI build too,
so it predates this work; the MPI verification above was therefore run at -j1.
Assisted-by: ClaudeCode:claude-opus-5
Stage 2b (83238fa) was bit-identical and green but regressed the benchmarks badly: 471 -> 686 ms on hubbard, 924 ms -> 1.56 s on Schrodinger build_graph, and 3546 -> 10551 MiB peak RSS on the same. Two causes, both introduced there. **Masks were rebuilt per term.** Stage 2b turned `even_bits`/`pauli_even_mask` into runtime helpers but skipped the "cached per propagator" half of the plan, so three masks that had been `constexpr` became a full object construction on every term -- including inside `pauli_rotation_sign`, the always_inline kernel that runs once per emitted rotation. Fixed by giving each one a home: - `cached_even_bits<Ordering>(n)` in `Utilities.h`: thread_local memo keyed on width. thread_local rather than shared because the scan runs concurrently on the partitions' pinned masters and a shared cache would need synchronisation on the hottest path in the library; the width only changes between propagators, so the miss branch is taken once per thread. - `PauliGenContext` carries `e_mask`, built once per layer in `make_pauli_gen_context` alongside the `nz_words` it already precomputed. - `cutoff_sums`' single-word branch spells the LSb0 pattern as a literal, which restores a constant expression -- a runtime-width Bitset is not a literal type, so `even_bits<...>().word(0)` could no longer fold. The runtime `pauli_even_mask(size_t)` overload added in 2b is deleted: it existed only so `MonomialLike auto` callers could avoid a qualified `Mono::size()`, and the cache subsumes it. **Per-term code built Bitset temporaries.** `cutoff_sums` and `is_paired` were written as `a & mask` / `(a >> 1) & mask` / `^` / `|` chains -- five and three temporaries per call. That was nearly free when a Bitset was an exactly-sized trivially copyable array; since 2b each is a full runtime-width construction. Both are now single word loops. `(word >> 1) & even_mask` equals `((bits >> 1) & mask).word(w)` because a full-width shift carries the low bit of word w+1 into bit 63, an odd position the even mask drops anyway -- the same within-word-pairs argument `pair_swap` and `pauli_uv` already rely on. Sums of popcounts are unchanged, hence bit-identity below. **Bitset is 96 -> 72 bytes.** The `std::vector` spill member cost 24 bytes on every monomial at every width; the inline words and the heap pointer are never both live, so they now share a union and Bitset owns its buffer directly (rule-of-five, with same-width copy assignment reusing the existing buffer -- the common case, and now allocation-free). Still not trivially copyable, and still sized for the widest supported bitset rather than its own width: that is inherent to owning bits inline at a runtime width, and the route out is Stage 6's arena, where a Bitset becomes a non-owning {pointer, width} view. Noted in the header and in AGENTS.md so per-term code is written accordingly. **`generate_paired_op` reserves.** It is the largest by-value monomial allocation in the library -- 11,017,633 entries at 128 modes / cutoff 6, i.e. the whole Schrodinger term count -- and grew geometrically, holding old and new buffers at the last reallocation. Reserving the exact `Sum_{k<=max_ones} C(n, k)` removes a multi-GiB transient. Measured (128 modes, cutoff 6; 83238fa -> here, against the pre-2b parent): | | parent | 83238fa | now | |---|---|---|---| | hubbard | 471 ms | +46% | +7% | | pauli | 88 ms | +21% | +8% | | Schrod. build_graph | 924 ms | +69% | +9% | | Schrod. inplace | 839 ms | +85% | +16% | | Schrod. build_graph RSS | 3546 MiB | +197% | +98% | | Schrod. inplace RSS | 5049 MiB | +137% | +66% | All Heisenberg memory and all Schrodinger steady-state memory (energy/gradient/pare) are back to parity or slightly better. Time figures are single-round and move a few percent between runs; the memory figures replicate to within 0.4%. Still outstanding: the Schrodinger *build* peak remains ~2x the parent. The 72-byte MonomialList accounts for only ~440 MiB of the ~3.5 GiB gap, so a transient is unaccounted for; attributing it needs a heap profile rather than source reading, and it is a build-time peak only. Recorded here rather than guessed at. Verification: `just diff-baseline` bit-identical (manifest sha256 matches); ctest 201/201 serial and 209/209 in the MPI build; pytest 592 passed serial and 600 passed under mpiexec at n=2 and n=4. Assisted-by: ClaudeCode:claude-opus-5
Building a propagator peaked at 7.0 GiB before this, against 974 MiB resident
once construction returned. RSS probes through the constructor put all of it in
one step, generate_paired_op, and showed why it was so much larger than the list
itself: every constructor phase ran *eight* times. A propagator with S
partitions is a facade over S single-partition propagators, each of which
generated the complete basis and then kept only the ~1/S share it owns, so S
full copies were live simultaneously.
At the benchmark's 128 modes / cutoff 6 the basis is Sum_{k<=4} C(128,k) =
11,017,633 monomials -- the entire term count -- so at 8 partitions that is
8 x 792 MiB. It also explains the memory half of the Bitset-width regression
exactly: 8 x 11.0M x (72 - 32) B = 3520 MiB predicted against 3489 MiB measured.
The redundancy predates the runtime-width Bitset; that change only scaled it.
`generate_paired_op` splits into three:
- `count_paired_op(max_ones, n)` -- the closed-form count, so a caller can size
storage without generating anything.
- `for_each_paired_op(max_ones, n, fn)` -- yields one monomial at a time. The
permutation loop is unchanged from the list version, so the yield order is the
list's order. That order is load-bearing: it fixes term indices, hence MPI
owner routing and float accumulation order.
- `generate_paired_op` -- now a thin wrapper over the two, for tests and any
caller that genuinely wants the whole list at once.
The Schrodinger arm of the constructor streams into the store through the same
insert-if-owned predicate the list loop used. Heisenberg keeps its list, which is
one entry per owned initial-operator term and already small; it also stops being
copied out of `local_heisenberg_terms` on the way in.
Measured (128 modes, cutoff 6; peak RSS, vs the pre-Stage-2b parent):
| | parent | before this | now |
|---|---|---|---|
| Schrod. build_graph | 3546 MiB | 7036 MiB | 1666 MiB (-53%) |
| Schrod. inplace | 5049 MiB | 8404 MiB | 2977 MiB (-41%) |
Construction now has no transient at all -- peak equals resident at 974 MiB,
where it was 7022 MiB -- and the whole-run peak is set by build_graph rather
than by construction. Heisenberg memory and Schrodinger steady-state are
unchanged. Time is within noise of the previous commit and still ~6-16% above
the parent; that residue is the width-sized non-trivially-copyable Bitset on
by-value paths like materialize_row, which Stage 6's arena addresses rather than
anything here. Timing figures are single-round and vary by up to ~30% run to
run, so treat the percentages as directional; the memory figures replicate to
within 0.4%.
Verification: `just diff-baseline` bit-identical (unchanged yield order is what
pins this); ctest 201/201 serial and 209/209 in the MPI build; pytest 592 passed
serial and 600 passed under mpiexec at n=2 and n=4.
Assisted-by: ClaudeCode:claude-opus-5
Stage 2c of the NumModes-NTTP-removal plan, first target. `InvertedIndex` takes its column count as a constructor argument instead of a template parameter: `kNumColumns = Monomial<NumModes>::size()` becomes `cols.size()`, exposed as `num_columns()`, and `std::array<Column, kNumColumns>` becomes a `std::vector` sized at construction. `rebuild`'s per-column `Counts` array follows it off the stack, which also drops a 4 KB stack frame at 256 modes. Result-neutral by construction, not just by test: the columns are XOR-folded and `combine_columns_block` documents that XOR associativity makes any block decomposition reproduce the full-width fold bit for bit, so column *storage* has no path to a result. The ascending-`set_rows` invariant that `lower_bound` depends on is untouched -- fill order is row order either way. `MPOperator::inverted_index()` now takes the width off the store rather than from `NumModes`, so there is one source of truth for it and it cannot drift from the monomials whose positions `rebuild()` scatters. `build_cos_callbacks` and `recompute_cos` keep their `NumModes` parameter -- they still need it for `LazyFold<NumModes>` and `Monomial<NumModes>` -- and only their `InvertedIndex` parameter type changes. `even_parity_scan_pass1` and `combine_columns_block` keep taking a deduced `auto`: the type is no longer a template, but the fold-cache tests bind those parameters to a stand-in with the same column accessors, so naming the type would narrow them for no gain. Breaking for C++ consumers of the installed headers: `InvertedIndex` is no longer a template and `kNumColumns` is no longer a `static constexpr`. Nothing in Python is affected. Verification: `just diff-baseline` bit-identical; ctest 201/201 serial and 209/209 in the MPI build; pytest 592 passed serial and 600 passed under mpiexec at n=2 and n=4. Assisted-by: ClaudeCode:claude-opus-5
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
This comment was marked as outdated.
This comment was marked as outdated.
|
🤖 AI text below 🤖 Hubbard benchmarks on the fat binaryRebuilt The loaded tier is The headline: the dispatched tier is
Read the first two columns for shape only
Time
Memory (peak RSS)
|
| 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 — identical32_8_5: 95,752,255 — identical64_6_6: 260,762,912 — identical64_8_5: 95,752,255 — identical124_6_6: 260,762,912 — identical124_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%) at124_8_5, with peak RSS inside 48 MiB andoperator_terms_bytesbyte-identical. - The sparse backend is a real trade at this width, and it is what the
fatcolumn's 124-site numbers
actually show. At124_6_6it is −8.8% time (79.818 s vs the dense 87.518 s) for +1891.56 MiB of
operator_terms_bytesand +918 MiB of peak RSS. At124_8_5it is time-neutral (22.212 s vs 22.170 s),
costs +586.66 MiB ofoperator_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 1throughout: at 5 roundssetup=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 lowertotal_bytesis 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, thefatcolumn equals the16d8ad8column
byte-for-byte in everyoperator_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 5just 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.
|
re-hashed as #311 |
Summary
Enabling autovectorization has been done with the
-march=nativeflag (monoprop_ENABLE_ARCH_FLAGS=ON) in local builds, but to make portable wheels, we have been compiling to the baseline of themanylinuxcontainer. For the various uses ofpopcountin this codebase that means effectively not using any CPU primitives (neitherPOPCNTnorVPOPCNTDQ).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-v1which is the baselinex86-64-v2which addsPOPCNTx86-64-v4-vpopcntdq-vw256AVX512 +VPOPCNTDQwith 256 bit vector widthx86-64-v4-vpopcntdq-vw512AVX512 +VPOPCNTDQwith 512 bit vector widthaarch64
WIP
Changes
-march=nativeChecklist
docs/,CONTRIBUTING.md) if neededCHANGELOG/ release notes updated if applicableAI/LLM disclosure
Important
By opening this PR I confirm that I have read CONTRIBUTING.md and I agree to the terms of the Contributor License Agreement.
Warning
If you're contributing on behalf of your employer, contact cla@algorithmiq.fi to arrange a Corporate CLA.
Footnotes
https://en.wikipedia.org/wiki/X86-64#Microarchitecture_levels ↩