Skip to content

Commit e53807c

Browse files
Roger-luoclaude
andcommitted
chore(autotune): improve skill + add SIMD experiment log (#52)
## Skill improvements (from 8-iteration SIMD experiment) - Add "profile before optimizing" as explicit first step - Add "always measure end-to-end impact" rule - New Common Pitfalls section: heap alloc in hot paths, match dispatch in inner loops, variable-address loops, compiler auto-vectorization, isolated vs end-to-end mismatch - Improve subagent prompt guidance: file preservation, anti-patterns - Add profiling step to workflow reference ## Experiment log Add docs/autotune/2026-04-11-simd-tableau-msd/ with full metric.toml and log.md from the batch Clifford gates experiment (8 iterations, 3 kept, 5 discarded, 65% total improvement on MSD benchmark). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 406bcbe commit e53807c

4 files changed

Lines changed: 94 additions & 2 deletions

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# Log for 2026-04-11-simd-tableau-msd
2+
3+
## Goal
4+
Implement ARM NEON SIMD optimizations (behind optional feature flag) to improve generalized
5+
tableau simulation of MSD on Apple M-series chips.
6+
7+
## Architecture Notes
8+
- MSD uses 85 qubits (5×17), `Byte8F64<2>` config (128-bit storage via `BitArray<[u64; 2]>`), `u128` index type
9+
- Tableau has 170 rows (2×85). Each Clifford gate iterates all 170 rows.
10+
- PauliWord uses `[u64; 2]` for xbits/zbits — maps perfectly to NEON `uint64x2_t` (128-bit)
11+
- Prior autotune achieved 32.3% improvement (181→122.5µs) via micro-optimizations
12+
- Key hot paths: PhasedPauliWord MulAssign (fused loop), Clifford gate inner loops, compute_decomposition
13+
14+
## SIMD Targets (ARM NEON)
15+
1. PhasedPauliWord MulAssign: fused loop over [u64; 2] with XOR, AND, OR, NOT, popcount → single NEON 128-bit ops
16+
2. Clifford gate inner loops: 170 rows × bit operations per row → potential for batched NEON
17+
3. compute_decomposition: anticommutes_at checks + Pauli multiplications
18+
19+
## Entries
20+
21+
## 2026-04-11
22+
- 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.
23+
- 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.
24+
- 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.
25+
- 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.
26+
- 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.
27+
- 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).
28+
- 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.
29+
- 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%.
30+
- 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.
31+
- 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.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
[[metric]]
2+
"commit" = "d2d5a2afbc8bf45d0db64869f563778a50a13b90"
3+
"status" = "keep"
4+
"description" = "Baseline measurement before SIMD optimizations"
5+
"msd_ns" = 123886.0
6+
"h_ns" = 101.0
7+
"sqrt_x_ns" = 103.0
8+
"sqrt_y_ns" = 129.0
9+
"cz_ns" = 149.0
10+
"t_ns" = 156.0
11+
"measure_gen_ns" = 398.0
12+
[[metric]]
13+
"commit" = "0b35a4e665614014c01004b8930dcb9e9c6a929c"
14+
"status" = "discard"
15+
"description" = "NEON MulAssign for [u64;2]: no measurable improvement, LLVM auto-vectorizes the 2-iteration loop"
16+
"msd_ns" = 123682.0
17+
[[metric]]
18+
"commit" = "7d8fd96780bacfb01f4b51c6e74a6a3c390408e6"
19+
"status" = "discard"
20+
"description" = "Circuit fusion with match dispatch: 3x SLOWER (370\u00b5s vs 126\u00b5s), dispatch overhead dominates"
21+
"msd_fused_ns" = 370465.0
22+
"msd_baseline_ns" = 126291.0
23+
[[metric]]
24+
"commit" = "d71ad473a1a26fa36c8d55e9a29ecc22d8e472e1"
25+
"status" = "discard"
26+
"description" = "Batch same-type gates: 1.66x slower per batch (variable addr0 prevents constant folding)"
27+
"sqrt_y_16x_individual_ns" = 2877.0
28+
"sqrt_y_16x_batch_ns" = 4766.0
29+
"msd_fused_ns" = 247804.0
30+
[[metric]]
31+
"commit" = "c48f1a8d2d08bb51fe75741841fabf760d471ce5"
32+
"status" = "keep"
33+
"description" = "Combined bitmask Clifford gates: 36% faster MSD (81.4\u00b5s vs 127.4\u00b5s)"
34+
"msd_fused_ns" = 81443.0
35+
"msd_baseline_ns" = 127360.0
36+
[[metric]]
37+
"commit" = "77008c6e06bf6f192385d210f851780b53d68d39"
38+
"status" = "keep"
39+
"description" = "CZ block pairs + combined bitmask: 70.4\u00b5s vs 126.9\u00b5s = 44.5% faster"
40+
"msd_fused_ns" = 70375.0
41+
"msd_baseline_ns" = 126868.0
42+
[[metric]]
43+
"commit" = "df9e1b89c807f3214d2552cff95da564e1258475"
44+
"status" = "keep"
45+
"description" = "Cross-word CZ block pairs: 63.4\u00b5s vs 70.4\u00b5s = 10% faster, 50% from baseline"
46+
"msd_fused_ns" = 63432.0
47+
"msd_baseline_ns" = 125925.0

‎skills/autotune/SKILL.md‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,13 @@ Use this skill only for a narrow, measurable performance target. If the request
99

1010
## Core Rules
1111

12+
- **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.
1213
- Prefer targeted microbenchmarks and profiling over full benchmark suites.
1314
- Once the loop starts, continue autonomously until interrupted unless a hard blocker requires human input.
1415
- Restrict implementation subagents to crate source directories and the active `docs/autotune/` experiment area.
1516
- Record every attempt with `keep`, `discard`, or `crash`.
1617
- Keep ledger updates in a separate commit from code changes so discarded results survive code reverts.
18+
- **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.
1719

1820
## Escalation Strategy
1921

@@ -74,7 +76,8 @@ When dispatching the implementation subagent, the prompt must be self-contained
7476
- What to optimize and why (the hypothesis).
7577
- Which files are in scope (crate `src/` directories only).
7678
- What NOT to touch (benchmarks, tests, docs beyond `docs/autotune/`).
77-
- Relevant findings from previous iterations (copy from `log.md`).
79+
- **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.
80+
- Relevant findings from previous iterations (copy from `log.md`). Include **anti-patterns** — things proven not to work — so the subagent doesn't repeat them.
7881
- The specific benchmark command that will be used to measure (so the subagent understands the target).
7982
- Instruction to commit all changes before finishing.
8083

@@ -83,6 +86,16 @@ When dispatching the implementation subagent, the prompt must be self-contained
8386
- Fix obvious trivial failures (typo, missing import) and rerun.
8487
- Mark fundamentally broken ideas as `crash` and continue.
8588

89+
## Common Pitfalls
90+
91+
These patterns have caused wasted iterations in past experiments. Check for them before committing to an approach.
92+
93+
- **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).
94+
- **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.
95+
- **Variable-address inner loops.** When a loop iterates over different qubit indices per row (e.g., `for &addr in indices { bits[addr] ... }`), the compiler cannot hoist index computation or constant-fold bit masks. This was 1.7x slower than individual constant-target calls. The fix: **combined bitmask** — merge all same-word targets into a single mask, then apply one O(1) operation per word per row.
96+
- **Compiler auto-vectorization on aarch64.** LLVM already generates NEON instructions for small fixed-size loops over `[u64; 2]`. Explicit NEON intrinsics via `std::arch::aarch64` provided zero benefit. Before writing intrinsics, check the generated assembly — the compiler may already be doing what you want.
97+
- **Isolated vs. end-to-end mismatch.** An A/B test showing 35% improvement on a subsystem means nothing if that subsystem is 3% of total runtime. Always estimate: `absolute_savings_µs / total_benchmark_µs > 2%` before investing in implementation.
98+
8699
## Recording Results
87100

88101
Use `scripts/record_result.py` to append benchmark data to `metric.toml`.

‎skills/autotune/references/autotune-workflow.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
# Autotune Workflow
22

3-
## 1. Validate the target metric
3+
## 1. Validate the target metric and profile
44

55
- Ensure the target metric is numeric and available from a focused benchmark.
66
- If no good target exists, inspect benchmarks or profile and propose one.
77
- Identify the exact benchmark command (e.g., `cargo bench -p crate --bench micro -- "gates/x"`).
8+
- **Profile the benchmark to get a time breakdown.** Before the first optimization iteration, measure how time is distributed across major code sections. Use an ad-hoc timing binary with section markers (e.g., `Instant::now()` around each phase), not just the overall benchmark number. This prevents wasting iterations on non-bottlenecks. Record the breakdown in `log.md` under "Architecture Notes". Re-profile after major gains to update the breakdown — the bottleneck shifts as you optimize.
89

910
## 2. Prepare the experiment
1011

0 commit comments

Comments
 (0)