Repository navigation
Batch versions of all clifford gates - #116
Conversation
There was a problem hiding this comment.
Pull request overview
This PR addresses issue #99 by promoting batched Clifford operations to first-class traits, filling out the batched gate set, and updating the STIM executor to dispatch through batched gate APIs (with added benchmarks and a Python demo).
Changes:
- Introduces
CliffordBatch/CliffordExtensionsBatchtraits (with default loop implementations) and re-exports them in the runtime traits module. - Implements/extends batched gate implementations in
ppvm-tableau(including removing the prior fixed 8-word mask limit viaSmallVec) and adds coverage/benches. - Switches STIM execution to use batched single-qubit gate calls and batched pair-gate calls.
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| ppvm-python/demo/stim_demo.py | Adds a small demo for loading/running STIM programs and sampling results. |
| crates/ppvm-tableau/src/gates/clifford.rs | Adds batched implementations for the full Clifford set, uses SmallVec masks to remove the previous hard cap, and expands tests. |
| crates/ppvm-tableau/Cargo.toml | Adds smallvec dependency and registers new benchmark targets. |
| crates/ppvm-tableau/benches/clifford_batch.rs | Adds baseline and batched-vs-loop benchmarks across Clifford/extension gates. |
| crates/ppvm-tableau/benches/clifford_crossover.rs | Adds a “crossover” benchmark to find batch-size break-even points. |
| crates/ppvm-stim/src/executor.rs | Updates dispatch to use batched gate APIs; introduces per-instruction pair collection for 2-qubit gates. |
| crates/ppvm-runtime/src/traits/mod.rs | Re-exports the new batch traits. |
| crates/ppvm-runtime/src/traits/clifford.rs | Defines CliffordBatch and CliffordExtensionsBatch traits with default loop bodies. |
| Cargo.lock | Records the added smallvec dependency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| GateName::X => tab.x_batch(targets), | ||
| GateName::Y => tab.y_batch(targets), | ||
| GateName::Z => tab.z_batch(targets), | ||
| GateName::H | GateName::HXZ => tab.h_batch(targets), | ||
| GateName::S | GateName::SqrtZ => tab.s_batch(targets), | ||
| GateName::SDag | GateName::SqrtZDag => tab.s_adj_batch(targets), | ||
| GateName::SqrtX => tab.sqrt_x_batch(targets), | ||
| GateName::SqrtXDag => tab.sqrt_x_adj_batch(targets), | ||
| GateName::SqrtY => tab.sqrt_y_batch(targets), | ||
| GateName::SqrtYDag => tab.sqrt_y_adj_batch(targets), |
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cnot_batch(&pairs); |
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cy_batch(&pairs); |
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cz_batch(&pairs); |
|
| GateName::CX | GateName::ZCX | GateName::CNot => { | ||
| targets.chunks_exact(2).for_each(|p| tab.cnot(p[0], p[1])); | ||
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cnot_batch(&pairs); |
| GateName::CY | GateName::ZCY => { | ||
| targets.chunks_exact(2).for_each(|p| tab.cy(p[0], p[1])); | ||
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cy_batch(&pairs); |
| GateName::CZ | GateName::ZCZ => { | ||
| targets.chunks_exact(2).for_each(|p| tab.cz(p[0], p[1])); | ||
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cz_batch(&pairs); |
| /// Batched Clifford gates: apply the same gate to many qubits in one call. | ||
| /// | ||
| /// Default implementations loop over the corresponding single-qubit | ||
| /// (or single-pair) method on [`Clifford`]. Types like the stabilizer | ||
| /// `Tableau` override the defaults with a fused inner-loop or bitmask | ||
| /// implementation; types without a faster path inherit the loop and | ||
| /// still benefit from the trait's uniform API. |
| fn build_masks(&self, indices: &[usize]) -> Option<(MaskBuf<T>, usize)> { | ||
| if self.data.is_empty() { | ||
| return None; | ||
| } | ||
| let n_words = self.data[0].word.xbits.data.as_raw_slice().len(); | ||
| debug_assert!(n_words <= 8, "Storage exceeds 8 words"); | ||
| let bits_per_word = std::mem::size_of::<<T::Storage as BitView>::Store>() * 8; | ||
| let one = <T::Storage as BitView>::Store::one(); | ||
| let zero = <T::Storage as BitView>::Store::zero(); | ||
| let mut masks = [zero; 8]; | ||
| let mut masks: MaskBuf<T> = smallvec![zero; n_words]; |
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| const N: usize = 128; | ||
| const BATCH_SIZES: &[usize] = &[1, 2, 4, 8]; |
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cnot_batch(&pairs); |
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cy_batch(&pairs); |
| let pairs: Vec<(usize, usize)> = | ||
| targets.chunks_exact(2).map(|p| (p[0], p[1])).collect(); | ||
| tab.cz_batch(&pairs); |
| GateName::CX | GateName::ZCX | GateName::CNot => { | ||
| targets.chunks_exact(2).for_each(|p| tab.cnot(p[0], p[1])); | ||
| let mut pairs: Vec<(usize, usize)> = Vec::with_capacity(targets.len() / 2); | ||
| pairs.extend(targets.chunks_exact(2).map(|p| (p[0], p[1]))); | ||
| tab.cnot_batch(&pairs); | ||
| } | ||
| GateName::CY | GateName::ZCY => { | ||
| targets.chunks_exact(2).for_each(|p| tab.cy(p[0], p[1])); | ||
| let mut pairs: Vec<(usize, usize)> = Vec::with_capacity(targets.len() / 2); | ||
| pairs.extend(targets.chunks_exact(2).map(|p| (p[0], p[1]))); | ||
| tab.cy_batch(&pairs); | ||
| } | ||
| GateName::CZ | GateName::ZCZ => { | ||
| targets.chunks_exact(2).for_each(|p| tab.cz(p[0], p[1])); | ||
| let mut pairs: Vec<(usize, usize)> = Vec::with_capacity(targets.len() / 2); | ||
| pairs.extend(targets.chunks_exact(2).map(|p| (p[0], p[1]))); | ||
| tab.cz_batch(&pairs); |
| //! Crossover bench: at fixed tableau size, sweep the *batch size* `k` and | ||
| //! compare a flat loop of `k` single-gate calls against the batched call. | ||
| //! | ||
| //! Goal is to find the smallest `k` at which the bitmask / fused batch pays | ||
| //! off versus the loop. The fixed tableau size is 128 (two `u64` words, so | ||
| //! batches up to `k = 64` stay in one storage word; `k > 64` spans both). | ||
| //! |
# Conflicts: # Cargo.lock # crates/ppvm-tableau/Cargo.toml
Benchmark: batched vs. non-batched (loop) Clifford gatesMeasuring what this PR actually buys, on top of the latest Environment: Apple M4 Pro (14 cores), TL;DR
End-to-end:
|
| Executor | per shot | 12 shots | speedup |
|---|---|---|---|
| loop (non-batch / pre-PR dispatch) | 1.22 s | 14.67 s | 1.00× |
| batch (this PR) | 0.59 s | 7.1 s | 2.05× |
Measured by surgically reverting only the Clifford-gate arms of
execute_validatedto the pre-PR single-gate loops; everything else (parsing, resets, measurement, noise sampling) is identical. Both numbers were stable across two runs.
Single-qubit gates — Tableau<Byte8F64<2>>, n=128 (L1-resident)
| Gate | Targets | loop | batch | speedup |
|---|---|---|---|---|
x |
all 128 | 11.23 µs | 590 ns | 19.0× |
y |
all 128 | 13.29 µs | 644 ns | 20.6× |
z |
all 128 | 11.00 µs | 590 ns | 18.6× |
h |
all 128 | 21.49 µs | 630 ns | 34.1× |
s |
all 128 | 18.36 µs | 667 ns | 27.5× |
s_adj |
all 128 | 19.91 µs | 679 ns | 29.3× |
sqrt_x |
all 128 | 18.51 µs | 591 ns | 31.3× |
sqrt_x_adj |
all 128 | 18.34 µs | 475 ns | 38.6× |
sqrt_y |
all 128 | 19.86 µs | 623 ns | 31.9× |
sqrt_y_adj |
all 128 | 19.88 µs | 625 ns | 31.8× |
For the "every-other" pattern (64 targets) the speedups are ~11–25× (same gates, fewer qubits per word).
Two-qubit gates
| Gate | Config | Targets | loop | batch | speedup |
|---|---|---|---|---|---|
cnot |
n=128 (L1) | 64 pairs | 15.98 µs | 36.41 µs | 0.4× (regress) |
cz |
n=128 (L1) | 64 pairs | 15.78 µs | 44.21 µs | 0.4× (regress) |
cy |
n=128 (L1) | 64 pairs | 17.84 µs | 55.51 µs | 0.3× (regress) |
cnot |
n=2048 (out of L1) | 1024 pairs | 9.03 ms | 6.85 ms | 1.3× |
cz |
n=2048 (out of L1) | 1024 pairs | 8.35 ms | 7.60 ms | 1.1× |
cy |
n=2048 (out of L1) | 1024 pairs | 9.68 ms | 11.48 ms | 0.8× (regress) |
The raw-word two-qubit batch only beats the loop once the tableau no longer fits in cache; cy doesn't reach break-even even at 2048 qubits. Worth a look before relying on cy_batch in hot paths.
Crossover — smallest batch size k where batch overtakes loop (n=128)
| Gate | k=1 | k=2 | k=4 | k=8 |
|---|---|---|---|---|
h |
0.95× | 1.52× | 2.61× | 4.83× |
x |
0.75× | 0.93× | 1.46× | 2.51× |
s |
0.75× | 1.07× | 1.76× | 3.19× |
Batch cost is roughly flat in k (one fused pass per word), so it wins from k≈2 (h, s) or k≈4 (x); at k=1 there's a small fixed mask-building overhead.
Takeaway: the PR delivers a solid, real ~2× end-to-end speedup on a realistic surface-code STIM workload, driven almost entirely by single-qubit batching. The two-qubit batch paths are the weak spot — net-neutral-to-negative except for large tableaux — and cy_batch in particular may be worth revisiting.
Numbers reproducible with cargo bench -p ppvm-tableau --bench clifford_batch / --bench clifford_crossover and cargo run --release --example surface_d30 -p ppvm-stim.
Closes #99.
Makes batched versions proper traits and completes the implemented set. Also uses batched gates for STIM.