Repository navigation
chore(autotune): improve skill with SIMD experiment lessons #52
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # Log for 2026-04-11-simd-tableau-msd | ||
|
|
||
| ## Goal | ||
| Implement ARM NEON SIMD optimizations (behind optional feature flag) to improve generalized | ||
| tableau simulation of MSD on Apple M-series chips. | ||
|
|
||
| ## Architecture Notes | ||
| - MSD uses 85 qubits (5×17), `Byte8F64<2>` config (128-bit storage via `BitArray<[u64; 2]>`), `u128` index type | ||
| - Tableau has 170 rows (2×85). Each Clifford gate iterates all 170 rows. | ||
| - PauliWord uses `[u64; 2]` for xbits/zbits — maps perfectly to NEON `uint64x2_t` (128-bit) | ||
| - Prior autotune achieved 32.3% improvement (181→122.5µs) via micro-optimizations | ||
| - Key hot paths: PhasedPauliWord MulAssign (fused loop), Clifford gate inner loops, compute_decomposition | ||
|
|
||
| ## SIMD Targets (ARM NEON) | ||
| 1. PhasedPauliWord MulAssign: fused loop over [u64; 2] with XOR, AND, OR, NOT, popcount → single NEON 128-bit ops | ||
| 2. Clifford gate inner loops: 170 rows × bit operations per row → potential for batched NEON | ||
| 3. compute_decomposition: anticommutes_at checks + Pauli multiplications | ||
|
|
||
| ## Entries | ||
|
|
||
| ## 2026-04-11 | ||
| - neon-mulassign (discard): Replaced scalar loop in PhasedPauliWord MulAssign<&Self> with ARM NEON uint64x2_t intrinsics for [u64;2] storage. No measurable impact (123.7µs vs 123.9µs baseline). LLVM already auto-vectorizes the 2-element loop effectively — the explicit NEON intrinsics provide no benefit over compiler-generated code. For [u64;2], the loop body is only 2 iterations; the NEON version does 1 pass but with the same number of logical operations. Finding: explicit NEON for small fixed-size loops over u64 is redundant on aarch64 — the compiler generates equivalent SIMD already. | ||
| - circuit-fusion-match (discard): Fused all ~680 Clifford gates into a single loop with match-based dispatch. 3x SLOWER (370µs vs 126µs). The match dispatch adds ~115K indirect branches across all rows. Branch predictor works perfectly for the original tight per-gate loops (same branch taken 170 times) but poorly for the fused version with 13-variant enum dispatch. Finding: dynamic dispatch via match in the inner loop is catastrophic. Need batch same-type gates (no dispatch) or compile-time specialization. | ||
| - batch-same-type (discard): Batch same-type gates (sqrt_y_batch, cz_batch) iterating over indices per row. 1.66x slower than individual calls for 16x sqrt_y (4766ns vs 2877ns). MSD fused: 248µs vs 130µs baseline. Variable addr0 in inner loop prevents compiler from hoisting word_idx/bit_mask computation and constant-folding the bit access. Finding: with AoS layout, per-gate tight loops with constant target are optimal — batching makes each application slower. But combined bitmask approach (merge all same-word gates into single mask op) should work since it reduces to O(1) per word per row. | ||
| - combined-bitmask (KEEP): Merged all same-word, same-type single-qubit Clifford gates into combined bitmask operations. For sqrt_y/adj/sqrt_x/adj, compute combined_mask from all qubit indices, then: swap/XOR bits within mask, use popcount parity for phase. Reduces 16x170 iterations to 170 iterations with O(1) work per word per row. Key optimizations: (1) stack-allocated [u64; 8] mask array instead of Vec, (2) fast-path in GeneralizedTableau skipping loss filter when no qubits lost, (3) individual CZ calls (CZ batch was slower due to variable-address penalty), (4) inline const arrays in benchmark (no Vec allocations). Result: MSD 81.4µs vs 127.4µs baseline = 36% improvement. Combined with prior autotune: 181µs → 81.4µs = 55% total improvement. | ||
| - cz-block-pairs (KEEP): For CZ pairs with constant qubit offset and same u64 word, replace N individual CZ calls with a single word-level shift+XOR+popcount per row. 17 CZ pairs reduced from 3738ns to 829ns = 4.5x faster. Combined with bitmask batches: MSD 70.4µs vs 126.9µs baseline = 44.5% improvement. Total from 181µs = 61.1% improvement. | ||
| - cz-independent-pairs (discard, not committed): Implemented branchless CZ for independent same-word pairs with per-pair conditionals and deferred z_delta accumulation. No measurable improvement (69.7µs vs 70.4µs — noise). The per-pair conditional branches in the inner loop have similar cost to PhasedPauliWord delegation. The compiler already generates near-optimal code for individual CZ (~1ns per row). Only constant-offset CZ can be significantly optimized (via cz_block_pairs). | ||
| - PHASE SUMMARY: After 6 iterations (2 kept, 4 discarded), MSD improved from 126.9µs to 70.4µs (44.5%). From original 181µs: 61.1% total. Remaining breakdown: encoding CZ 30.5% (near-optimal), measurement+T 47.6% (sequential/data-dependent), between-block CZ 10.7% (block_pairs), encoding sqrt 11.2% (bitmask batch). Further micro-optimizations on Clifford gates unlikely to yield >1%. Structural changes needed for the next level: measurement path optimization (compute_decomposition, coefficient manipulation), or algorithmic changes to reduce measurement cost. | ||
| - cz-block-pairs-cross-word (KEEP): Generalized CZ block pairs to handle cross-word cases where controls are in word 0 and targets in word 1. Replaces 38 individual cross-word CZ calls with 4 batch calls. MSD 63.4µs vs 70.4µs = 10% improvement. From baseline: 63.4µs vs 125.9µs = 49.6%. From original 181µs = 65.0%. | ||
| - cz-pairs-branchless (discard, not committed): Direct word-level CZ for independent same-word pairs with per-pair bit extraction and conditional z_delta accumulation. 35% faster in isolation (4.0µs vs 6.2µs for 24 CZ), but <1% end-to-end improvement (62.0µs vs 62.9µs) — absolute savings (~2µs) too small relative to total. cz-lookup-table also discarded — PEXT-like bit extraction loop on ARM negates lookup table benefit. | ||
| - ESCALATION: After 8 iterations (3 kept, 5 discarded), micro-optimization well is dry. Remaining 63µs: encoding CZ ~22µs (near-optimal), measurement ~34µs (sequential data-dependent), between-block ~7µs (already optimized). Consecutive failures on encoding CZ (iterations 6, 8) confirm diminishing returns on Clifford gate optimization. Next phase should target measurement path or algorithmic improvements. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| [[metric]] | ||
| "commit" = "d2d5a2afbc8bf45d0db64869f563778a50a13b90" | ||
| "status" = "keep" | ||
| "description" = "Baseline measurement before SIMD optimizations" | ||
| "msd_ns" = 123886.0 | ||
| "h_ns" = 101.0 | ||
| "sqrt_x_ns" = 103.0 | ||
| "sqrt_y_ns" = 129.0 | ||
| "cz_ns" = 149.0 | ||
| "t_ns" = 156.0 | ||
| "measure_gen_ns" = 398.0 | ||
|
|
||
| [[metric]] | ||
| "commit" = "0b35a4e665614014c01004b8930dcb9e9c6a929c" | ||
| "status" = "discard" | ||
| "description" = "NEON MulAssign for [u64;2]: no measurable improvement, LLVM auto-vectorizes the 2-iteration loop" | ||
| "msd_ns" = 123682.0 | ||
|
|
||
| [[metric]] | ||
| "commit" = "7d8fd96780bacfb01f4b51c6e74a6a3c390408e6" | ||
| "status" = "discard" | ||
| "description" = "Circuit fusion with match dispatch: 3x SLOWER (370\u00b5s vs 126\u00b5s), dispatch overhead dominates" | ||
| "msd_fused_ns" = 370465.0 | ||
| "msd_baseline_ns" = 126291.0 | ||
|
|
||
| [[metric]] | ||
| "commit" = "d71ad473a1a26fa36c8d55e9a29ecc22d8e472e1" | ||
| "status" = "discard" | ||
| "description" = "Batch same-type gates: 1.66x slower per batch (variable addr0 prevents constant folding)" | ||
| "sqrt_y_16x_individual_ns" = 2877.0 | ||
| "sqrt_y_16x_batch_ns" = 4766.0 | ||
| "msd_fused_ns" = 247804.0 | ||
|
|
||
| [[metric]] | ||
| "commit" = "c48f1a8d2d08bb51fe75741841fabf760d471ce5" | ||
| "status" = "keep" | ||
| "description" = "Combined bitmask Clifford gates: 36% faster MSD (81.4\u00b5s vs 127.4\u00b5s)" | ||
| "msd_fused_ns" = 81443.0 | ||
| "msd_baseline_ns" = 127360.0 | ||
|
|
||
| [[metric]] | ||
| "commit" = "77008c6e06bf6f192385d210f851780b53d68d39" | ||
| "status" = "keep" | ||
| "description" = "CZ block pairs + combined bitmask: 70.4\u00b5s vs 126.9\u00b5s = 44.5% faster" | ||
| "msd_fused_ns" = 70375.0 | ||
| "msd_baseline_ns" = 126868.0 | ||
|
|
||
| [[metric]] | ||
| "commit" = "df9e1b89c807f3214d2552cff95da564e1258475" | ||
| "status" = "keep" | ||
| "description" = "Cross-word CZ block pairs: 63.4\u00b5s vs 70.4\u00b5s = 10% faster, 50% from baseline" | ||
| "msd_fused_ns" = 63432.0 | ||
| "msd_baseline_ns" = 125925.0 | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -9,11 +9,13 @@ Use this skill only for a narrow, measurable performance target. If the request | |||||
|
|
||||||
| ## Core Rules | ||||||
|
|
||||||
| - **Profile before optimizing.** Before the first iteration, instrument or profile the target benchmark to get a time breakdown by section/function. Guessing at bottlenecks wastes iterations — the actual hotspot is often not what you expect. A quick ad-hoc timing binary (using `std::time::Instant` or equivalent) that measures sections of the benchmark is often more useful than a full profiling tool. | ||||||
| - Prefer targeted microbenchmarks and profiling over full benchmark suites. | ||||||
| - Once the loop starts, continue autonomously until interrupted unless a hard blocker requires human input. | ||||||
| - Restrict implementation subagents to crate source directories and the active `docs/autotune/` experiment area. | ||||||
| - Record every attempt with `keep`, `discard`, or `crash`. | ||||||
| - Keep ledger updates in a separate commit from code changes so discarded results survive code reverts. | ||||||
| - **Always measure end-to-end impact**, not just isolated microbenchmark improvements. An optimization that is 35% faster in isolation may be <1% end-to-end if it targets a small fraction of total runtime. Before committing to an approach, estimate the absolute time savings relative to the total benchmark. | ||||||
|
|
||||||
| ## Escalation Strategy | ||||||
|
|
||||||
|
|
@@ -74,7 +76,8 @@ When dispatching the implementation subagent, the prompt must be self-contained | |||||
| - What to optimize and why (the hypothesis). | ||||||
| - Which files are in scope (crate `src/` directories only). | ||||||
| - What NOT to touch (benchmarks, tests, docs beyond `docs/autotune/`). | ||||||
| - Relevant findings from previous iterations (copy from `log.md`). | ||||||
| - **Explicit list of files the subagent must NOT overwrite** if the host has already modified them (e.g., a benchmark file with optimized calls). Subagents lack context about prior iterations and will rewrite files from scratch if not told to preserve them. When a benchmark or test file has been tuned across iterations, either exclude it from the subagent's scope entirely or paste the current version into the prompt. | ||||||
| - Relevant findings from previous iterations (copy from `log.md`). Include **anti-patterns** — things proven not to work — so the subagent doesn't repeat them. | ||||||
| - The specific benchmark command that will be used to measure (so the subagent understands the target). | ||||||
| - Instruction to commit all changes before finishing. | ||||||
|
|
||||||
|
|
@@ -83,6 +86,16 @@ When dispatching the implementation subagent, the prompt must be self-contained | |||||
| - Fix obvious trivial failures (typo, missing import) and rerun. | ||||||
| - Mark fundamentally broken ideas as `crash` and continue. | ||||||
|
|
||||||
| ## Common Pitfalls | ||||||
|
|
||||||
| These patterns have caused wasted iterations in past experiments. Check for them before committing to an approach. | ||||||
|
|
||||||
| - **Heap allocation in hot paths.** A `Vec::collect()` inside a per-row loop or a per-call filter can easily cost 50-100ns per allocation. Over thousands of calls, this dominates. Prefer stack-allocated fixed-size arrays (`[T; N]`) and fast-path checks that skip work entirely (e.g., skip loss-filter allocation when no qubits are lost). | ||||||
| - **Dynamic dispatch in inner loops.** A `match` on an enum inside a tight per-row loop destroys branch prediction. In one experiment, fusing 680 gate calls into a single loop with match dispatch was **3x slower** than 680 separate tight loops. The branch predictor handles identical branches perfectly (same gate applied 170 times) but chokes on alternating match arms. If you need fusion, use typed batch methods (one method per gate type) rather than enum dispatch. | ||||||
|
||||||
| - **Dynamic dispatch in inner loops.** A `match` on an enum inside a tight per-row loop destroys branch prediction. In one experiment, fusing 680 gate calls into a single loop with match dispatch was **3x slower** than 680 separate tight loops. The branch predictor handles identical branches perfectly (same gate applied 170 times) but chokes on alternating match arms. If you need fusion, use typed batch methods (one method per gate type) rather than enum dispatch. | |
| - **Enum/match dispatch in inner loops.** A `match` on an enum inside a tight per-row loop introduces branchy control flow that can be hard for the branch predictor to learn. In one experiment, fusing 680 gate calls into a single loop with enum/match dispatch was **3x slower** than 680 separate tight loops. The branch predictor handles identical branches perfectly (same gate applied 170 times) but chokes on alternating match arms. If you need fusion, use typed batch methods (one method per gate type) rather than enum dispatch. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
skills/autotune/scripts/record_result.pywrites a blank line between[[metric]]entries (seeskills/autotune/scripts/test_autotune_scripts.py, expected output includes\n\nafter each entry). This newmetric.tomlplaces entries back-to-back with no separating blank line, which diverges from the repo’s generated format and makes the ledger harder to scan / more conflict-prone. Consider reformatting to match the script output (add a blank line between each[[metric]]block).There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot apply changes based on this feedback
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added blank lines between each
[[metric]]entry inmetric.tomlso the format matches the output produced byrecord_result.py(each entry ends with\n\n). Commit: 7a26efd.