Skip to content

Add strategy="active_statevector" for sampling on a dense state vector - #203

Merged
rafaelha merged 30 commits into
mainfrom
extraction-free-statevector
Oct 20, 2026
Merged

rafaelha merged 30 commits into
mainfrom
extraction-free-statevector

Conversation

@rafaelha

@rafaelha rafaelha commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds strategy="active_statevector" to compile_sampler and compile_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.

CompiledStateProbs computes probabilities, so it keeps "cat5" as its default. The strategy type is now one literal, SamplingStrategy = Literal["active_statevector", "cat5", "bss", "cutting"]. zxparam's DecompositionStrategy appears only where tsim calls zxparam's decomposition. compile_program takes strategy explicitly, because the valid default depends on mode.

Pipeline (src/tsim/statevector/)

  1. pbc.py reads 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.
  2. schedule.py reorders 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.
  3. plan.py plans the schedule on a fixed width W, tracking a Clifford frame.
  4. program.py lowers the plan to an action tape. execute.py runs the tape in JAX as a fori_loop over a lax.switch of five kernels, on lanes × 2^W amplitudes.

Reading the diagram (pbc.py)

  1. Graph state. The quantum spiders and their Hadamard edges form a graph state. A spider with phase π/2·q + 2θ rotates its qubit by exp(-iθ Z_v), then measures m_v = S^-q X S^q (a signed X or Y).
  2. Classical part. Every classical spider is eliminated over GF(2) (_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 raises ValueError.
  3. Folding the postselections. A sampler cannot postselect, so the postselections are moved to the start, where they act on the graph state. This requires every rotation to commute with them. The product rotation lemma lets each axis Z_v become Z_v K_T, its product with graph-state stabilizers. Here T may only hold unrotated spiders or spiders rotated later. The order and the sets T come 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.
  4. Frame. 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.
  5. Outputs and compaction. Each output is one measured Pauli: the product of its outcomes' Paulis, times X on fresh lines for its fair coins. Lines that no op flips are dropped. Paulis that commute with every op are measured and forgotten first. They change no outcome and keep the width down.

Memory stays linear in the size of the diagram, apart from stim's tableau of the folded graph state.

Sampler

  • One pipeline: detector and measurement sampling share the same path.
  • Precision: amplitudes are complex64 by default, and complex128 with jax_enable_x64.
  • Randomness: all randomness goes through one 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 a count, and run(start, stop) can resume a tape, so trajectory sharing (Share state vectors between shots with trajectory_sharing=True #198) can build on this.
  • Batch size: the automatic batch size is capped at 2^16 lanes.
  • Unsupported cases raise a ValueError that points to the stabilizer-rank strategies:
    • mode="joint";
    • components wider than 30;
    • components the reader cannot handle: an X spider, a non-Pauli classical phase, a classical part that is not stochastic, or no flow. None of these occurred for circuit-derived graphs in testing.

Active width

circuit Clifft, no passes Clifft, default passes Clifft + ActiveWidthSchedulePass Tsim via extraction (#196), detectors / measurements Tsim, this PR, detectors / measurements
distillation 5 5 3 3 / 3 3 / 3
cultivation d=3 4 4 4 5 / 5 4 / 4
coherent d=3, r=3 8 5 4 5 / 4 4 / 4
cultivation d=5 10 10 10 6 / 6 6 / 6
cultivation d=5, end-to-end 10 10 10 6 / 7 6 / 6
coherent d=5, r=5 24 13 13 12 / 15 11 / 14
  • Clifft columns: peak_active_width from Clifft 0.11.0, compiled with hir_passes=None, with the default passes, and with the default passes plus the opt-in ActiveWidthSchedulePass.
  • Tsim columns: the largest width over the statevector components of compile_detector_sampler and compile_sampler.
  • Circuits: every row except end-to-end is a clifft-bench circuit. The end-to-end circuit is convert_s_to_t.make_d5_end2end(0.001) from unitaryfoundation/clifft-paper.
  • Compile time: from the prepared sampling graph to the plan, for all non-direct components, on one core. It is at most 0.02 s on every circuit except coherent d=5, r=5, which takes 0.24 s (0.21 s via extraction).

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

  • Unit tests (test/unit/statevector):
    • The ops read from every test component are compared with stabilizer-rank probabilities over the error-bit patterns, in both detector and measurement mode.
    • The later stages (schedule, plan, executor) are each compared with the previous stage and with the stabilizer-rank path.
    • Structural tests check that commutant measurements commute with every op and that every line is flipped by some op. Each refusal also has a test.
  • Integration tests compare samples with the stabilizer-rank samplers on hand-built circuits: T interference, GHZ with T gates, arbitrary rotations, injection checks, mid-circuit feed-forward, and circuits with several components.
  • Exact fuzz against the extraction route (outside the repo): 5,000 random circuits, 12,124 components (2,758 with a quantum part). The gate set covers non-Clifford rotations, 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.
  • Sampling against the extraction route: all six circuits in the table agree within the spread between two seeds of the extraction-route sampler (largest |z| over marginals and pairwise correlations).
  • Against Clifft: on the extraction route, acceptance agreed with Clifft 0.10.0 within 5 standard errors, and logical-error rates agreed with Clifft 0.10.0, Clifft 0.11.0 and SymFT. The programs here give the same distributions (fuzz and sampling checks above).
  • More circuits reach the same width as extraction: quantum volume q10/q20, STAR d=5…11, MSD 5, MSC 3, and surface d=7, r=7.
  • CI: pytest (1524 tests on Python 3.10–3.13), pyright, black, isort and ruff pass on the branch head.

Known limitations

  • Fixed width: every action touches all 2^W amplitudes. Exact per-step widths would do 1.5–3.8× less array work on the d=3 and d=5 circuits.
  • Width on small random circuits: compared with extraction, the width differs in both directions. It is wider in 4.7% of quantum components (by 1, almost always) and narrower in 4.3%. Structured circuits are equal or better.
  • Reading speed on large non-QEC components: for example, 1.6 s against 0.1 s for extraction on quantum volume q20 (3,400 spiders, 3,056 rotations, width 20). There, sampling cost dominates by far.

🤖 Generated with Claude Code

rafaelha and others added 22 commits October 13, 2026 11:40
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>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T15:45:14.348838Z 01dd227 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://queracomputing.github.io/tsim/pr-preview/pr-203/

Built to branch gh-pages at 2026-10-19 19:29 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

@github-actions

github-actions Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

☂️ Code Coverage

current status: ✅

Overall Coverage

Statements Covered Coverage Threshold Status
3579 3434 96% 0% 🟢

New Files

File Coverage Status
src/tsim/statevector/_init_.py 100% 🟢
src/tsim/statevector/execute.py 100% 🟢
src/tsim/statevector/pbc.py 96% 🟢
src/tsim/statevector/plan.py 99% 🟢
src/tsim/statevector/program.py 100% 🟢
src/tsim/statevector/schedule.py 99% 🟢
TOTAL 99% 🟢

Modified Files

File Coverage Status
src/tsim/_init_.py 100% 🟢
src/tsim/circuit.py 92% 🟢
src/tsim/compile/pipeline.py 100% 🟢
src/tsim/core/types.py 100% 🟢
src/tsim/sampler.py 93% 🟢
TOTAL 97% 🟢

updated for commit: 5e899e6 by action🐍

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>
@rafaelha
rafaelha changed the base branch from statevector-sampler to main October 19, 2026 18:03
@rafaelha rafaelha changed the title Read statevector programs straight off the reduced ZX diagram Add strategy="active_statevector" for sampling on a dense state vector Oct 19, 2026
rafaelha and others added 2 commits October 19, 2026 14:14
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rafaelha and others added 3 commits October 19, 2026 14:29
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
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rafaelha
rafaelha merged commit 7dbc46c into main Oct 20, 2026
9 checks passed
@rafaelha
rafaelha deleted the extraction-free-statevector branch October 20, 2026 15:02
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant