Bitpack symplectic Pauli operators - #204
Conversation
Merging this PR will improve performance by ×2.2
|
No-op commit to re-trigger the Benchmarks workflow on PR #204. The prior run compared BASE and HEAD across different runner environments (CodSpeed flagged this), producing implausible ±3x swings on the jordan_wigner decode cases while the identical bravyi_kitaev/jkmn cases moved the other way. Re-running to get a consistent-environment comparison. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
Store the X and Z blocks of SymplecticOperator, SymplecticOperatorView and SymplecticMatrix as bitpacked integers (one bit per qubit) via the bitvec crate instead of dense bool arrays. This reduces memory ~8x and turns the symplectic product, Pauli weight, and Clifford conjugation into word-level bit operations. - Add a Block newtype over BitVec<u64, Lsb0> with the bit ops the symplectic layer needs (and/or popcounts, in-place XOR, iter_ones, lexicographic Ord) plus bool-array interop helpers. - Make SymplecticMatrix's fields private behind an accessor/mutator API (row_x, row_z, ipowers, x_bools, z_bools, view_row, select_rows, set_x, set_z, set_ipower). Update all core consumers (TernaryTree, MajoranaEncoding, SymplecticHamiltonian, anneal, topphatt) to use it. - Keep the Python/JSON boundary unchanged: symplectic data still crosses as the concatenated [x_block | z_block] boolean array with a uint8 ipowers vector, converting to/from Block at the edge. All Rust tests (144 lib + 52 doctests) and Python tests (336) pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
Benchmarks MajoranaEncoding::encode(&MajoranaSparse) -> QubitHamiltonian, the symplectic-product hot path (view_row + mul_assign_view), for JW encodings across mode counts and one-/two-body Hamiltonians. Enables apples-to-apples comparison of the symplectic representation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
and_count_ones, or_count_ones and xor_assign now operate directly on the underlying u64 storage words instead of iter_ones()/BitSlice iteration. This makes the symplectic product inner loop (mul_assign_view) run as native word ops, closing and reversing the encode regression measured at n = 8-16: encode is now ~1.05x-1.43x faster than the bool-array baseline (was 5-27% slower), and decode is ~1.35x faster, while memory stays ~8x smaller. Sound because every Block keeps its unused high bits zero (documented as an invariant), so AND/OR/XOR over the final partial word never counts stray bits. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
No-op commit to re-trigger the Benchmarks workflow on PR #204. The prior run compared BASE and HEAD across different runner environments (CodSpeed flagged this), producing implausible ±3x swings on the jordan_wigner decode cases while the identical bravyi_kitaev/jkmn cases moved the other way. Re-running to get a consistent-environment comparison. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
decode_zbasis_ensemble computed the annihilation parity as z.iter_ones().filter(|q| state[q]).count() against a dense bool state row. For dense operators (Jordan-Wigner / parity have Z-strings spanning every qubit) that iterates every set bit with a random bool-array index, which regressed ~3x vs the old fused sequential scan at 128 qubits (CodSpeed flagged decode[128-jordan_wigner] at -67%). Pack each ensemble state into a bitpacked Block once, reused across the mode loop, and compute parity via a word-level popcount of (z & state) plus a word-level XOR state update. Measured on JW at 128 qubits, 1000 states: batch decode 49.9ms -> 3.96ms (now 3.9x faster than the bool-array baseline's 15.5ms). Correctness covered by the existing decode roundtrip tests and ensemble proptests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
try_encode applied each Majorana operator to the state via the Mul<ZBasisState> path, whose parity used and_count_bools / iter_ones against a dense bool state - the same small-n regression the decode path had (CodSpeed: hartree_fock_state[32-jordan_wigner] -14.6%, [32-parity] -12.8%). Carry the working state as a bitpacked Block and compute each operator's parity as a word-level popcount of (z & state), with a word-level XOR for the shared X flip. The transform is exact: the i^k phase factors are unit-norm (so the running unit-norm coefficient needs no rescale), and the one combining branch renormalises to unit norm exactly as ZBasisState::new does (its combined value is always non-zero there). Covered by the existing Hartree-Fock roundtrip tests and proptests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
Benchmarks SymplecticHamiltonian::clone() and clone+apply_clifford_chain, the hot path of clifford_heuristic_optimisation (called once per simulated-annealing cost-function evaluation, and transitively by randomised_subsystem_descent). Diagnostic only, no source changes: measured against the pre-bitpacking baseline (2d44397), clone() is ~16-76x slower depending on term count (15.9k rows: 18.5us -> 1.40ms; 251k rows: 1.51ms -> 23.6ms), because SymplecticMatrix now stores one heap-allocated Block per row instead of two contiguous Array2<bool> buffers, turning clone into O(rows) small allocations instead of a couple of memcpys. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
Back each of SymplecticMatrix's X and Z blocks with a single contiguous
Vec<u64>, rows padded to whole u64 words (words_per_row = n_qubits.div_ceil(64)),
instead of a Vec of per-row heap-allocated blocks. Cloning the matrix is now a
pair of contiguous copies rather than one allocation per operator row, which
removes a large regression in SymplecticHamiltonian::clone that showed up in the
Clifford-heuristic and randomised-subsystem-descent annealers (clone runs once
per cost evaluation inside their simulated-annealing loops).
symplectic_bits.rs gains a borrowed BlockRef<'a> { words, n_bits } view (Copy)
alongside the owned Block; SymplecticOperatorView and the matrix row accessors
hand out BlockRefs over sub-slices of the contiguous buffer. Padding bits above
n_qubits are kept zero by every constructor and mutator, so word-level
AND/OR/XOR/popcount/iter_ones need no masking. Public method signatures are
preserved, so accessor-only consumers are unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
35cbced to
6588e30
Compare
Pack ZBasisState and ZBasisEnsemble (and the encoding vacuum state) into the same u64-word Block used by the symplectic layer, instead of dense Array1<bool> / Array2<bool>. Applying a Pauli operator to a state (SymplecticOperator * ZBasisState) and decoding now run as whole-word AND/XOR/popcount rather than iterating individual set-bit indices via Block::and_count_bools / xor_into_bools, which are removed. The occupation is still reachable as a boolean array through ZBasisState::state_bools(), and the Python boundary (vacuum state, Hartree-Fock state, decoded occupations) is unchanged. With the state layer packed, the last two bitvec uses in symplectic_bits.rs -- set-bit iteration and the lexicographic block ordering -- are reimplemented directly on the u64 storage words (relying on the existing padding-zero invariant), so the bitvec dependency is dropped from ferrmion-core. The whole symplectic/state layer is now plain u64 internally with bool only at the Python edge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
9ddbebc to
f160ea3
Compare
Follows up the rename to DenseBlock/DenseIndex by finishing the cleanup: - DenseIndex is now the packed storage word (one usize lane by default), carrying the word-level primitives (get/set/toggle, and/or/xor, count_ones, iter_ones, cmp_bits, words_for) that were previously inlined raw-u64 ops. The Clifford kernels, SymplecticMatrix packing/addressing, and block bit access all route through it, so the earlier raw >> 6 / & 63 / 1u64 << .. arithmetic is gone. This also fixes the build: words_for was left callable only as an associated fn. - DenseBlock is now generic over its backing: DenseBlock<Vec<DenseIndex>> (owned) and DenseBlock<&[DenseIndex]> (borrowed, Copy). The borrowed form replaces DenseBlockRef, which is removed. SymplecticMatrix keeps its single contiguous Vec<DenseIndex> buffer, with rows exposed as borrowed DenseBlocks over sub-slices, so clone stays a contiguous copy. Binary ops take a borrow of DenseBlock (&DenseBlock<T>) rather than a separate view type. Public method signatures and the Python/bool boundary are unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qe4s3qCaXxKJ6BeafNaBzx
41cb712 to
88ad111
Compare
|
Check out this pull request on See visual diffs & provide feedback on Jupyter Notebooks. Powered by ReviewNB |
6505fc1 to
28731ff
Compare
Finish migrating SymplecticMatrix to bitpacked DenseBlock storage and make Clifford conjugation operate on whole machine words rather than one bit per operator row. - Rework SymplecticMatrixTranspose to hold a qubit-major transpose of the X/Z blocks (via a new word-level DenseBlock::transpose using a blocked 64x64 bit transpose) so H/S/CNOT and hamming_weights become whole-word AND/OR/XOR and column-run copies; results are written back on drop. - Restore SymplecticMatrix::x_bools/z_bools, fix the identity() row over-allocation, and update stale doc examples to from_arrays. - Add a multi-word (>64 qubit/operator) Clifford-chain test that checks the word-level kernels against a per-bit reference, plus a DenseBlock::transpose round-trip/oracle test. Fix latent DenseBlock bugs that only surfaced once the crate compiled: - multi-word term/index addressing in get_index/set_index/set_term and the From<Array2<bool>> conversion, and the word offset in DenseIndex::iter_ones - to_bool_matrix now maps each term to a row - SymplecticOperator::to_pauli_string keeps the stored i-power - ZBasisState::adjoint again reverses qubit order; reindex sizes its block right Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sr1igxaqbCZgjLDGTpUi3
Proptest records discovered failing seeds under the crate manifest dir; check them in (as the project already does for the root-level files) so the saved cases re-run for everyone. All currently pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sr1igxaqbCZgjLDGTpUi3
The index reversal was mistakenly carried over from FermionProduct, which does need to reverse indices; a Z-basis state's adjoint keeps the same occupation. Remove the reversal and update the test expectation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sr1igxaqbCZgjLDGTpUi3
9ca2731 to
bf9c8fe
Compare
bf9c8fe to
3285cc1
Compare
Symplectic operators were previously built around a
Array<bool>. This update moves them over to a bitpackedDenseIndextype to improve performance.