Repository navigation
Add strategy="active_statevector" for sampling on a dense state vector - #203
Merged
Merged
Conversation
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A float32 uniform draw is a multiple of 2^-23, so any p1 below 2^-23 gave outcome 1 with probability 2^-23: deterministic outcomes, whose p1 rounds to ~1e-15 instead of 0, fired at ~1.2e-7 per measurement under complex64. Compare two uint32 draws with p1·2^32 instead, independent of the dtype. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
p1 = (nu - <P>)/(2 nu) cancels catastrophically (p1 ~ 1e-7 instead of ~1e-15 on eigenstates under complex64), and renormalizing by 1 - p1 loses the relative precision of a small p0. Sum each outcome's weight directly (|psi +- P psi|^2 / 4, or the even and odd parity halves) and renormalize by the selected weight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 1e-5 threshold was built for stabilizer-rank arithmetic. Under complex64 each rotation adds ~1e-7 relative drift, which is harmless because every probability is normalized by the measured weights, so warn above 1e-3 for complex64 and 1e-9 for complex128. Stabilizer-rank components keep 1e-5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The per-shot estimate counted only the amplitudes; peak memory was up to 2.7x higher, and the memory-derived batches (millions of lanes) were also slower. Count the f-params and records on host and device, cap the automatic batch at 2^16 lanes, and require one lane to fit in the same 50% budget that sizes batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hand-built ops reach a dormant measurement of Y (S_DAG on the pivot), a forgotten dormant measurement that no later op depends on, and a diagonal measurement on two active axes. Running a tape as [0, k) then [k, A) reproduces running [0, A) field by field. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All five acceptances still agree with Clifft 0.10.0 and no norm warning is printed. Write zero logical-error counts as upper bounds, and note that the previous logical-error section could not resolve the ~1e-7 sampling floor. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add benchmark_clifft.py, which runs Clifft on the five clifft-bench workloads the way clifft-bench does, and workloads.py, which both benchmark scripts share. benchmark.py now reports the cores kept busy in the timed window and takes --seed; NPROC=1 pins JAX to one thread. results.md compares Tsim with Clifft 0.11.0 on one thread and on all threads of the same machine, lists both libraries' active widths, and checks the logical-error rates against Clifft and SymFT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
zx_param is now zxparam: rename the imports in the statevector modules, their tests and the test helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Read every output off the final symbols instead of carrying records through the tape: an output is the measurement's sign, XOR its branch bit when the measurement is random. This removes the Record action, the RECORD kernel, slots, the scratch record column and the sign of measurement actions. - Dispatch on the component type in sample_component and give each component type its own norm-warning threshold, so sample_program is the loop it is on main. - Pick the per-component compiler once in compile_program and reuse its output indices and f-bit selection in ComponentLayout.of. - Share one anticommute helper across heisenberg, schedule and plan, and one elimination step between StabilizerGroup.rotate and measure. - Derive num_lines, num_f, num_words, num_outputs and num_branches instead of storing them; drop the unused dtype option. - Gather once in the diagonal measurement kernel and jit sample_statevector_component as a whole. - Plan on ops in schedule order and emit each Rotate in one place. - Share test fixtures, merge duplicated tests and drop subsumed ones. - Default the benchmark circuits to a clifft-bench checkout next to the repository instead of a personal path. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace circuit extraction and the Heisenberg rewrite of the extracted circuit with a reading of the component itself. The quantum spiders form a graph state, each with a Z rotation (the non-Clifford part of its phase) and a single-qubit X or Y measurement (the Clifford part). The classical spiders are eliminated over GF(2), which leaves every output as a parity of measurement outcomes, f-bits and fair coins, plus parities of outcomes that the diagram postselects. The postselections are moved to the start, where they act on the graph state, after rewriting each rotation axis Z_v as Z_v K_T with graph-state stabilizers (product rotation lemma). The order and the sets T are found with a backward greedy search over one incremental GF(2) basis, which is complete. A Clifford frame from the postselections and the commuting graph stabilizers maps the state to |0...0>; noise-dependent postselection targets become f-dependent signs. Lines no op flips are dropped, and Paulis that commute with every op are measured first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When no measurement or width-preserving rotation can run, simulate each pending measurement's unrun ancestors on a copy of the stabilizer group and run the one whose peak width is smallest, the earliest one on a tie, rather than the one with the fewest unrun rotations. On coherent d=5, r=5 this takes the width from 14 to 11. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ar time The column-by-column reduction rebuilt every equality for every pivot, which is quadratic: 0.9 s on the 3,400-spider component of quantum volume q20. Map the column priority to bit positions and eliminate by leading bits, then back-substitute. The reduced echelon form is unique for a column priority, so the result is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reader built an identity and an adjacency matrix over all quantum spiders, kept every graph-state stabilizer, and filled the op matrices over every graph-state line before dropping the idle ones. Build each stabilizer from its neighbour mask when a product needs it, find the flipped lines first and fill the op matrices for those only, and add the postselection targets from each op's own Z bits. The ops are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
Contributor
|
Contributor
☂️ Code Coverage
Overall Coverage
New Files
Modified Files
|
The dense state vector only spans a component's active qubits, so its cost grows as 2^W in the active width, not in the number of qubits. Say so in the strategy's name and docstrings, which also no longer describe components as extracted circuits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
heisenberg.py becomes pbc.py, HeisenbergOps becomes PauliOps and heisenberg_ops becomes read_pauli_ops. The ops are Pauli product rotations and measurements on |0…0⟩, and most of the module reads them off the ZX diagram, so the old name described neither. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
compile_sampler, compile_detector_sampler and the sampler classes now default to strategy="active_statevector". CompiledStateProbs computes probabilities, so it keeps "cat5". SamplingStrategy is one literal of all four strategies, and zxparam's DecompositionStrategy only types the call into zxparam's decomposition. compile_program takes the strategy explicitly, since its valid default depends on the mode. Tests that pin seeded sample counts of the stabilizer-rank sampler now ask for strategy="cat5". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The widening rule replayed every pending measurement's whole chain after each widening: 39.7 s against 0.68 s for the previous rule on a random 24-qubit, depth-150 component. A chain stops once it reaches the best peak so far, which cannot win since ties go to the earliest; the orders are unchanged and that component now schedules in 1.1 s. lane_bytes counts four amplitude arrays, as XLA's memory analysis of the executor shows, instead of three. read_pauli_ops converts each frame image once, and its NOTE says that frame line i holds the i-th independent postselection. The CHANGELOG lists the new strategy and default. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rafaelha
added this pull request to stack #204
October 19, 2026 19:24
choose_outcome took only p1, so in complex64 the probability of outcome 0 was 1 - p1: it rounded to 0 below about 6e-8 and moved in steps of 6e-8 above, 15-20% off at 1e-7, while outcome 1 kept its precision. It now takes p0 and p1, each the outcome's weight over the total, and draws the less likely one against the 64-bit fraction, which also drops the p1 = 1 case. The commutant selection gave every inserted X part the witness 1, so a part in the span of an odd number of chosen ones counted as independent and added a deterministic measurement. Each now gets its own witness. A dormant measurement clears its Pauli with one pass of controlled Paulis from the step's image, instead of a CX pass and two more frame images, and an active X measurement reuses the pivot's Z bit across the CZs. The plans are identical on the test components, the clifft-bench circuits and random circuits. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 tasks done
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 tasks done
rafaelha
added a commit
that referenced
this pull request
Oct 20, 2026
## Summary Stacked on #203 (base `extraction-free-statevector`). This PR adds an experimental `trajectory_sharing=True` option to `compile_sampler` and `compile_detector_sampler` for `strategy="active_statevector"`. Shots whose quantum trajectories agree can share one state vector. In the clifft-bench circuits, the only per-shot input to the amplitudes is the sign of a rotation, and that sign depends only on noise bits. Once the direct detectors are postselected, the surviving shots share a handful of trajectories. One state vector per shot does 700× to 12,000× more array work than needed. How `src/tsim/statevector/shared.py` works: - **Grouping:** shots that agree on the f-bit part of every rotation sign and every postselected output row share one lane. The lane carries a shot count and a range in a key-sorted order. Grouping sorts on the host, because numpy's radix sort is an order of magnitude faster than XLA's on CPU. Ties are broken randomly, so every shot's outputs stay independent. - **Splitting:** lanes split binomially at measurements. Whether any shot of a lane sees the rarer outcome is decided with `choose_outcome`'s 2^-64 resolution. The first such shot and the rest are then drawn exactly. - **Pruning:** a lane that fires a postselected output is dropped at the action that sets that output's last branch bit, with the detector reference bits XOR-ed in. For an output of f-bits only, the drop happens at the first measurement. coherent d5r5 needs pruning: without it, sharing cuts the work only 3×; with it, 617×. - **Static shapes:** lane tables have fixed capacities, and the loop exits when a table is full. - The host doubles the full table and resumes. - Wide components (W ≥ 8) run in segments, so the host can shrink the table to the live lanes. - Capacities are sticky, but every new capacity compiles again (about 1 s). - Every lane slot draws from its own key, so capacities, resumes and segments do not change the samples. Without the option, `sample_program` is called exactly as before. The investigation behind this PR and its benchmark harnesses (sharing statistics, stage breakdowns, and a Tsim/Clifft comparison on the clifft-bench workloads and SymFT's d=7 circuit) are kept out of the repository. ### Performance (same machine, Apple M4 Pro) | circuit | width, Tsim / Clifft | one lane per shot, 1 thread | shared, 1 thread | Clifft, 1 thread | one lane per shot, all threads | shared, all threads | Clifft, all threads | |---|---|---:|---:|---:|---:|---:|---:| | distillation | 3 / 5 | 2.83M | 3.27M | 2.89M | 3.14M | 3.28M | 25.7M | | cultivation d3 | 5 / 4 | 872k | 2.37M | 4.99M | 1.63M | 2.50M | 39.8M | | coherent d3r3 | 5 / 5 | 402k | 1.76M | 2.03M | 872k | 1.66M | 16.9M | | cultivation d5 | 6 / 10 | 439k | 1.19M | 148k | 750k | 1.25M | 1.36M | | coherent d5r5 | 12 / 13 | 633 | 42.5k | 3.8k | 2.1k | 103k | 34.5k | | SymFT d=7, postselected | 12 / 19 | 3.5k | 98.5k | 1.7k | 9.7k | 184k | 7.3k | | SymFT d=7, no postselection | 12 / 19 | 909 | 388 | 85 | 2.5k | 1.0k | 376 | | quantum volume, 10 qubits | 10 / 10 | 1.5k | 392k | 30.4k | 4.8k | 523k | 190k | How to read the table: - **Units:** attempted shots/s, counted before postselection. - **Source:** the local Tsim/Clifft comparison harness, with Clifft 0.11.0. "1 thread" means `NPROC=1`. - **Workloads:** the clifft-bench circuits are postselected on every detector, as clifft-bench does. - **When it was measured:** on this branch before the rebase onto #197. That rebase changed only the context of the patches, and #197 leaves graphs unchanged. What the numbers show: - **Largest gains:** wide components with little branching. Against one lane per shot, sharing is: - 50× faster on coherent d5r5 on all threads, and 67× on one thread; - 19× and 28× faster on SymFT's postselected d=7; - 109× and 253× faster on quantum volume with 10 qubits. - **Small circuits:** the shared executor itself is 5× to 12× faster than one lane per shot. But the classical per-shot work (noise sampling, direct detectors, survivor buffering) now dominates the call. The follow-up PR on `tsim-perf` addresses this. - **Without postselection:** shots branch on noise, and sharing is slower than one lane per shot on SymFT's d=7. Merging lanes that reach the same state would fix this, and it is still open. For example, cultivation d5 without postselection has 17,055 histories that end in only 4 states. ## How this was verified - **Unit tests** (`test/unit/statevector/test_shared.py`): - shared sampling matches the plan's exact output distribution; - pruning discards exactly the postselected failures; - pruned shots report False after their pruning action; - full tables grow and resume; - table capacities and segments leave the samples bitwise unchanged; - rare outcomes keep their probability, including an outcome below float32 resolution for a lone shot. - **Integration tests** (`test/integration/test_trajectory_sharing.py`): - shared detector and measurement sampling match one lane per shot; - pruning through the sampler keeps the accepted distribution, with and without the detector reference sample; - random noisy circuits. - **Harness check:** acceptance and observable rates agree with Clifft on all 12 workloads. The largest deviation is |z| = 2.3, on coherent d5r5 acceptance. - **Independent review:** found four problems, all fixed and tested in 4f64fba: - pruning with the detector reference sample; - the float32 resolution of rare outcomes; - determinism across resumes; - leftover values in pruned rows. ## Test plan - [x] `pytest`: 1569 passed - [x] `pyright src test`: 0 errors - [x] pre-commit hooks pass on the changed files Known limitations, left for follow-ups: - **Recompilation:** every new table capacity compiles again (about 1 s), and this can happen inside a sample call. - **Batch size and memory:** the estimates still assume one lane per shot. - **No state merging:** sampling without postselection gains little, or loses. - **Wide components:** lazy or prefix key splitting is untried. It could give about 10× more on coherent d5r5. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
rafaelha
added a commit
that referenced
this pull request
Oct 20, 2026
## Summary Stacked on #198 (base `trajectory-sharing`), which is stacked on #203. This PR moves `strategy="active_statevector"` off JAX and onto numba kernels on the CPU, and makes it use every core by default: - **No JAX in the statevector path.** - One numba function samples a whole batch with the GIL released, both with trajectory sharing and with one lane per shot (`share=False` skips the grouping). The JAX executor (`statevector/execute.py`) is deleted. - `StatevectorComponent` is a frozen dataclass of NumPy arrays, and batch seeds come from a NumPy Generator instead of JAX keys. - **Behavior change:** GPU users of `active_statevector` now sample on the host; the stabilizer-rank strategies keep JAX and the GPU. Amplitudes are complex128, independent of `jax_enable_x64`. - **Trajectory sharing is the default** for `active_statevector` (`trajectory_sharing=None` means True there and False otherwise). In the benchmarks below it is never slower than one lane per shot. - **Every thread has work.** - Without a `batch_size`, a call is split into one batch per numba thread whenever each batch gets at least 4096 shots. Smaller calls stay one batch, which the parallel build of the executor spreads over the threads. - With postselection, the survivors left after screening are split the same way. - An explicit `batch_size` keeps samples independent of the thread count. - **Noise sampling and setup** (from the earlier commits of this PR) run in numba kernels as well; `numba>=0.61` is a runtime dependency. ### Changes in detail - **Shared executor (`statevector/shared.py`).** - **`_execute`:** groups the shots (a SplitMix64 Fisher–Yates shuffle, then stable sorts on the key), runs the tape, and writes the outputs. Lanes live in arrays with one row per shot, which bounds them, so only the amplitudes grow. - **Small circuits:** before, 20–30% of a batch was GIL-held Python and NumPy, which capped batch-per-thread sampling at 3–5 busy cores. - **Wide components:** each action touches only the live lanes and the `2^extent` amplitudes it can reach. The extent comes from the plan, and the planner promotes onto the lowest free axis. - **Parallel build:** a loop is split into chunks across threads only once its work reaches `PARALLEL_WORK`. So a lone wide batch keeps parallel kernels, and small actions launch no threads. - **Kernels parallelize only explicit `prange` loops.** numba's `parallel=True` also turned `np.zeros`, `np.full` and slice fills into thread launches at every measurement. - **Randomness:** rare outcomes are drawn by geometric skips with a 64-bit uniform, so they stay exact below float32 resolution. Every stream starts from its seed's SplitMix64 output, so different components never share random numbers. The samples for a given seed change. - **Postselection:** postselected component detectors now prune lanes with sharing off as well. - **Noise sampling (`ChannelSampler.sample`):** a numba kernel walks each channel's geometric skips and flips only the f-variables of the outcome that fired. Blocks of 4096 shots run in parallel, each on its own SplitMix64 stream, so samples depend on the seed and the shot count but not on the thread count. The random stream differs from NumPy's. - **Direct detectors and postselection:** one parallel numba pass writes each direct detector into its result row and returns the discard mask. Survivors are buffered with one `np.concatenate` per chunk. - **Sampler setup on large circuits:** `unique_columns` and an indexed `absorb_subset_channels`. On the d=5 end-to-end cultivation circuit (463 qubits, 184,039 error bits), building the sampler takes 24 s instead of 101 s; 17 s of that is zxparam's `full_reduce`. The noise model is bitwise unchanged. - **`tsim.utils.kernels`:** - **Serial or parallel:** compiles every kernel serially and in parallel, and runs the parallel version only above a work threshold. - **Locking:** holds a lock around parallel calls, because the workqueue layer aborts on concurrent ones. - **Worker threads:** runs whole batches on a cached thread pool (`run_tasks`). - **Read-only installs:** compiles without a cache there. ### Performance (same machine, Apple M4 Pro, 14 cores) Choose a thread setting and sharing mode; all threads with sharing (the default) is open. <details open> <summary><b>All threads · trajectory sharing on (default)</b></summary> | circuit | Tsim width | Clifft k_max | base | this PR | Clifft | this PR / base | this PR / Clifft | |---|---:|---:|---:|---:|---:|---:|---:| | surface d7r7† | 0 | 0 | 1.59M | 30.7M | 37.8M | **19×** | 0.81× | | coherent d3r1† | 0 | 0 (4) | 93.6M | 103M | 682M | **1.1×** | 0.15× | | coherent d5r1† | 0 | 0 (12) | 66.3M | 90.5M | 447M | **1.4×** | 0.2× | | distillation | 3 | 3 (5) | 3.21M | 32.9M | 37.1M | **10×** | 0.89× | | cultivation d3 | 4 | 4 | 2.24M | 30.5M | 46.3M | **14×** | 0.66× | | coherent d3r3 | 4 | 4 (5) | 1.73M | 21.3M | 46.7M | **12×** | 0.46× | | cultivation d5 | 6 | 10 | 1.25M | 16M | 1.65M | **13×** | **9.7×** | | coherent d5r5 | 11 | 13 | 18.4k | 155k | 88.5k | **8.5×** | **1.8×** | | quantum volume, 10 qubits | 10 | 10 | 837k | 93M | 174k | **111×** | **535×** | | quantum volume, 20 qubits | 20 | 20 | 8.1 | 154 | 17 | **19×** | **9.1×** | | SymFT d=7, no postselection | 8 | 19 | 11.2k | 475k | 1.38k | **42×** | **344×** | | SymFT d=7, postselected | 8 | 19 | 423k | 6.99M | 38.7k | **17×** | **181×** | </details> <details> <summary><b>All threads · trajectory sharing off</b></summary> | circuit | Tsim width | Clifft k_max | base | this PR | Clifft | this PR / base | this PR / Clifft | |---|---:|---:|---:|---:|---:|---:|---:| | surface d7r7 | 0 | 0 | 1.59M | 30.7M | 37.8M | **19×** | 0.81× | | coherent d3r1 | 0 | 0 (4) | 93.6M | 103M | 682M | **1.1×** | 0.15× | | coherent d5r1 | 0 | 0 (12) | 66.3M | 90.5M | 447M | **1.4×** | 0.2× | | distillation | 3 | 3 (5) | 3.13M | 26.3M | 37.1M | **8.4×** | 0.71× | | cultivation d3 | 4 | 4 | 2.06M | 10.1M | 46.3M | **4.9×** | 0.22× | | coherent d3r3 | 4 | 4 (5) | 1.27M | 7.11M | 46.7M | **5.6×** | 0.15× | | cultivation d5 | 6 | 10 | 733k | 4.48M | 1.65M | **6.1×** | **2.7×** | | coherent d5r5 | 11 | 13 | 4.47k | 151k | 88.5k | **34×** | **1.7×** | | quantum volume, 10 qubits | 10 | 10 | 5.35k | 13.7k | 174k | **2.6×** | 0.079× | | quantum volume, 20 qubits | 20 | 20 | 0.99 | 1.8 | 17 | **1.9×** | 0.11× | | SymFT d=7, no postselection | 8 | 19 | 53.8k | 450k | 1.38k | **8.4×** | **326×** | | SymFT d=7, postselected | 8 | 19 | 130k | 2.97M | 38.7k | **23×** | **77×** | </details> <details> <summary><b>1 thread · trajectory sharing on (default)</b></summary> | circuit | Tsim width | Clifft k_max | base | this PR | Clifft | this PR / base | this PR / Clifft | |---|---:|---:|---:|---:|---:|---:|---:| | surface d7r7† | 0 | 0 | 1.54M | 11.2M | 6.18M | **7.3×** | **1.8×** | | coherent d3r1† | 0 | 0 (4) | 90.4M | 108M | 682M | **1.2×** | 0.16× | | coherent d5r1† | 0 | 0 (12) | 63.9M | 79.6M | 275M | **1.2×** | 0.29× | | distillation | 3 | 3 (5) | 3.16M | 5.98M | 3.45M | **1.9×** | **1.7×** | | cultivation d3 | 4 | 4 | 2.01M | 8.13M | 4.98M | **4.1×** | **1.6×** | | coherent d3r3 | 4 | 4 (5) | 1.65M | 5.91M | 5.11M | **3.6×** | **1.2×** | | cultivation d5 | 6 | 10 | 1.09M | 3.74M | 172k | **3.4×** | **22×** | | coherent d5r5 | 11 | 13 | 6.29k | 37.4k | 9.55k | **5.9×** | **3.9×** | | quantum volume, 10 qubits | 10 | 10 | 853k | 16.2M | 25.7k | **19×** | **629×** | | quantum volume, 20 qubits | 20 | 20 | 2.5 | 39 | 4.4 | **15×** | **8.8×** | | SymFT d=7, no postselection | 8 | 19 | 8.04k | 58.1k | 200 | **7.2×** | **291×** | | SymFT d=7, postselected | 8 | 19 | 398k | 1.7M | 7.18k | **4.3×** | **237×** | </details> <details> <summary><b>1 thread · trajectory sharing off</b></summary> | circuit | Tsim width | Clifft k_max | base | this PR | Clifft | this PR / base | this PR / Clifft | |---|---:|---:|---:|---:|---:|---:|---:| | surface d7r7 | 0 | 0 | 1.54M | 11.2M | 6.18M | **7.3×** | **1.8×** | | coherent d3r1 | 0 | 0 (4) | 90.4M | 108M | 682M | **1.2×** | 0.16× | | coherent d5r1 | 0 | 0 (12) | 63.9M | 79.6M | 275M | **1.2×** | 0.29× | | distillation | 3 | 3 (5) | 2.8M | 5.03M | 3.45M | **1.8×** | **1.5×** | | cultivation d3 | 4 | 4 | 1.3M | 1.63M | 4.98M | **1.3×** | 0.33× | | coherent d3r3 | 4 | 4 (5) | 669k | 1.13M | 5.11M | **1.7×** | 0.22× | | cultivation d5 | 6 | 10 | 432k | 788k | 172k | **1.8×** | **4.6×** | | coherent d5r5 | 11 | 13 | 1.36k | 21.7k | 9.55k | **16×** | **2.3×** | | quantum volume, 10 qubits | 10 | 10 | 1.66k | 1.45k | 25.7k | 0.87× | 0.056× | | quantum volume, 20 qubits | 20 | 20 | 0.33 | 0.26 | 4.4 | 0.78× | 0.059× | | SymFT d=7, no postselection | 8 | 19 | 23.8k | 57.2k | 200 | **2.4×** | **287×** | | SymFT d=7, postselected | 8 | 19 | 73.1k | 1.03M | 7.18k | **14×** | **144×** | </details> How to read the tables: - **Units:** attempted shots/s, counted before postselection. - **Source:** the harness on `clifft-benchmarks` (`benchmarks/clifft/comparison`), run on 2026-10-19. - **Configurations:** each Tsim column shows its best configuration from the harness calls. With all threads, that includes calls of 40 batches (for example `sample(2_621_440, batch_size=65_536)`). One thread means `NPROC=1 NUMBA_NUM_THREADS=1`. - **"base":** the same harness on `trajectory-sharing` (35db8b6). - **Clifft:** 0.11.0 with every pass, including the opt-in `ActiveWidthSchedulePass`. "Clifft k_max" is its peak active width, with the default passes' value in parentheses when it differs. - **†:** no statevector component, so sharing doesn't apply. With the default `batch_size=None` on all threads, sampling one call of the harness's size, against the base of this stack's previous tip (`ea4fc28`): | circuit (shots per call) | sharing on: before → now | sharing off: before → now | |---|---:|---:| | distillation (262,144) | 30.8M → 33.7M | 17.5M → 20.5M | | cultivation d3 (262,144) | 22.3M → 44.0M | 9.47M → 11.0M | | coherent d3r3 (262,144) | 17.4M → 31.9M | 4.52M → 8.63M | | cultivation d5 (65,536) | 7.74M → 9.24M | 450k → 1.87M | | coherent d5r5 (16,384) | 76.5k → 87.8k | 6.5k → 38.8k | | quantum volume, 10 qubits (65,536) | 24.7M → 37.3M | 9.4k → 13.4k | | SymFT d=7, postselected (16,384) | 2.76M → 4.18M | 76k → 1.83M | What the numbers show: - **Against base:** this PR is faster on every circuit, except one lane per shot on the two quantum-volume circuits on one thread (0.87× and 0.78×). There the deleted JAX executor was faster; with sharing, the default, the same circuits are 15–19× faster than base. - **Against Clifft with all passes:** - Tsim wins on every circuit with a non-trivial statevector component from cultivation d5 on: 1.8× to 535× on all threads with sharing. - On one thread with sharing, Tsim is also ahead on distillation, cultivation d3 and coherent d3r3. - Clifft is faster on the coherent r1 circuits, which `ActiveWidthSchedulePass` reduces to width 0, and with all threads on distillation, cultivation d3 and coherent d3r3. Clifft's `sample_survivors` returns counts only, while Tsim's harness builds the full detector arrays, which costs Tsim on the cheapest circuits. - **d=5 end-to-end cultivation**, postselected on the 107 cultivation-stage detectors, measured at af98f77, before the executor and batching commits: this PR 620k (all threads) and 206k (1 thread) shots/s; Clifft 0.11 542k and 70k. ## Changes from the stack review (7e13528) - **Pruning:** postselection prunes in the executor whenever a component detector is masked, also when no direct detector is. - **Reference samples:** reference bits are XOR-ed into the outputs before a pruned shot's later outputs are cleared, so those stay False. Outputs of f-bits only prune before the first action. - **Backends:** the sampler picks a JAX or a statevector backend once, instead of branching in every batching method. The statevector path creates no JAX keys and queries no JAX devices, and `CompiledProgram` holds NumPy arrays. - **Precision:** statevector amplitudes are complex128. - **Executor arguments:** the `Tape` and the `SharingLayout` are passed as named tuples instead of positional tuples, and `StatevectorComponent` holds its arrays in one `Tape`. - **Threads:** `run_tasks` uses one thread pool and a semaphore instead of a pool per thread count. - **Noise setup:** `unique_columns` accepts non-integer 0/1 matrices. ## How this was verified - **Executor tests:** - **Exact distribution:** sharing on and off; an outcome of probability 0 is never drawn. - **Pruning:** exact pruning distributions, and outputs set after a lane's pruning action read False. - **Rare outcomes:** binomial counts below float32 resolution. - **NaN poisoning:** the amplitudes the executor must not read hold NaN while a d5r3 surface code whose extent drops and rises is sampled. It matches a full-width run bitwise. - **Parallel and serial builds** give the same samples. - **Sampler tests:** - **Baselines:** sharing against `strategy="cat5"` on every test circuit and on random noisy circuits. - **Independence:** two disconnected components draw independent numbers per shot and per batch. A review found the first version gave them shifted copies of one SplitMix64 stream. - **Thread count:** samples with an explicit `batch_size` don't depend on it. - **Batching:** default batches give each thread one; postselection splits the survivors. - **Memory:** the memory fit applies with and without sharing. - **Noise sampling, direct detectors and setup:** as in the earlier commits (joint distributions across blocks, reproducibility, concurrent threads, `unique_columns` against `np.unique`, bitwise-identical noise models). - **Harness check:** acceptance and observable rates agree with Clifft on all 12 workloads (|z| ≤ 2.5). - **Review:** a review agent checked every step. Its fixes are the "Fix …", "Strengthen …" and "Polish …" commits. ## Test plan - [x] `pytest`: 1715 passed - [x] `pyright src test`: 0 errors Known limitations, left for follow-ups: - **Sharing on all threads** keeps 5–7 of 14 cores busy on small circuits. The rest is serial work around the batches: noise sampling, screening and concatenation. - **Memory-limited concurrency:** with complex128, the per-shot memory estimate of a statevector batch doubles, so on a machine with little free memory fewer batches run at once. With about 17 GiB free, quantum volume q10 runs 6 instead of 13 batches at once (114M instead of 154–163M shots/s), and postselected coherent d5r5 runs 1 instead of 2 (88k instead of 96–99k shots/s). The kernels themselves run as fast as with complex64. The tables above were measured with complex64. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
strategy="active_statevector"tocompile_samplerandcompile_detector_sampler, and makes it their default. Each component of the reduced sampling graph that is not a single error-basis bit is simulated on a dense state vector over its active qubits, in the style of Clifft. The cost grows as 2^W in the active width W, not in the number of qubits.The program is read straight off the reduced ZX diagram, without extracting a circuit. This replaces #196, which got the same kind of program through
zxparam.extract. The direct reading reaches the same or a lower width on every benchmark.CompiledStateProbscomputes probabilities, so it keeps"cat5"as its default. The strategy type is now one literal,SamplingStrategy = Literal["active_statevector", "cat5", "bss", "cutting"]. zxparam'sDecompositionStrategyappears only where tsim calls zxparam's decomposition.compile_programtakesstrategyexplicitly, because the valid default depends onmode.Pipeline (
src/tsim/statevector/)pbc.pyreads a component as a Pauli-based computation: Pauli rotations and Pauli measurements on|0…0⟩(see below). Noise only flips signs, so each op carries an affine GF(2) sign row over the error-basis bits, and commuting ops can be reordered freely.schedule.pyreorders commuting ops to keep W small. W = n − dim S, where S is the Pauli group that stabilizes every trajectory up to sign. Measurements and rotations that don't widen run eagerly. When something must widen, the scheduler simulates each pending measurement's unrun ancestors on a copy of the stabilizer group. It runs the measurement with the smallest peak width, the earliest on a tie.plan.pyplans the schedule on a fixed width W, tracking a Clifford frame.program.pylowers the plan to an action tape.execute.pyruns the tape in JAX as afori_loopover alax.switchof five kernels, on lanes × 2^W amplitudes.Reading the diagram (
pbc.py)π/2·q + 2θrotates its qubit byexp(-iθ Z_v), then measuresm_v = S^-q X S^q(a signed X or Y)._Relations). This leaves equalities between bits, plus a ±1 sign from π phases and Hadamard edges. Each output becomes a parity of outcomes, error bits and fair coins, and some parities of outcomes are postselected. A leftover sign raisesValueError.Z_vbecomeZ_v K_T, its product with graph-state stabilizers. HereTmay only hold unrotated spiders or spiders rotated later. The order and the setsTcome from a backward greedy search over one incremental GF(2) basis. As in gflow finding, the set of allowed spiders only grows, so the search never backtracks and fails only if no order exists.stim.Tableau.from_stabilizers([postselections] + [graph stabilizers that commute with all of them])maps the folded state to|0…0⟩. A noise-dependent postselection target becomes "post line k starts in|t_k⟩", which is one sign update for every op with a Z on that line. Dependent postselections are checked for consistency.Memory stays linear in the size of the diagram, apart from stim's tableau of the folded graph state.
Sampler
jax_enable_x64.choose_outcome(p0, p1, count, key). It draws the less likely outcome against a uniform 64-bit fraction, so both outcomes keep the relative precision of their probability, with 2^-64 resolution: under complex64, an outcome of probability 10⁻⁹ is drawn at that rate on either side. Lanes carry acount, andrun(start, stop)can resume a tape, so trajectory sharing (Share state vectors between shots with trajectory_sharing=True #198) can build on this.ValueErrorthat points to the stabilizer-rank strategies:mode="joint";Active width
ActiveWidthSchedulePasspeak_active_widthfrom Clifft 0.11.0, compiled withhir_passes=None, with the default passes, and with the default passes plus the opt-inActiveWidthSchedulePass.compile_detector_samplerandcompile_sampler.convert_s_to_t.make_d5_end2end(0.001)from unitaryfoundation/clifft-paper.Performance
Sampling cost scales as 2^W per action. On coherent d=5, r=5, the drop from 12 to 11 makes sampling about 1.7× faster than the extraction route (2,649 vs. 1,432–1,561 shots/s, same machine and settings).
The same-machine comparison with Clifft is from the extraction route (#196, measured 2026-10-10) and has not been rerun on this branch. There, Clifft is faster on four of the five clifft-bench circuits. Tsim is faster on cultivation d=5 on one thread (312k vs. 137k shots/s), where its width is 6 against Clifft's 10. On all threads, Tsim keeps 1.3–3.7 cores busy and Clifft 8–11. The stabilizer-rank strategies do not compile coherent d=3, r=3, cultivation d=5 or coherent d=5, r=5 within 120 s. With
strategy="active_statevector", every clifft-bench circuit compiles in under 1 s.Verification
test/unit/statevector):MPP,MX/MY/MXX/MZZ, resets, measurement noise, correlated errors, classical feed-forward,MPAD, detectors and observables. All agree to 6·10⁻¹⁶ at four error-bit patterns each, with no refusals.Known limitations
🤖 Generated with Claude Code