Skip to content

Share state vectors between shots with trajectory_sharing=True - #198

Open
rafaelha wants to merge 8 commits into
statevector-samplerfrom
trajectory-sharing
Open

rafaelha wants to merge 8 commits into
statevector-samplerfrom
trajectory-sharing

Conversation

@rafaelha

Copy link
Copy Markdown
Collaborator

Summary

Stacked on #196 (base statevector-sampler). This PR adds an experimental trajectory_sharing=True option to compile_sampler and compile_detector_sampler for strategy="statevector", along with the investigation behind it.

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.

Also in this PR:

  • investigation.md (at the branch root) covers:

    • how much sharing the circuits allow;
    • whether Tsim's representation helps: yes, mostly through the classical/quantum split. Postselecting on the direct detectors before any quantum simulation multiplies the sharing 34× to 195× on cultivation;
    • the static-shape techniques;
    • the speedups.

    Its numbers were measured on an older statevector-sampler base (883b125).

  • scripts/trajectory/: sharing statistics, stage breakdowns and a sharing benchmark.

  • scripts/clifft_comparison/: a Tsim/Clifft comparison harness on the clifft-bench workloads and SymFT's d=7 circuit. run_all.sh waits for an idle machine, then times Clifft, Tsim on all threads and Tsim on one thread. report.py writes a speed table and compares acceptance and observable rates with Clifft's using a two-proportion z-test.

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:

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

  • pytest: 1569 passed
  • pyright src test: 0 errors
  • 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

rafaelha and others added 8 commits October 13, 2026 11:40
Investigation prototype behind an experimental trajectory_sharing option
of compile_sampler / compile_detector_sampler (statevector strategy only).

Shots that agree on the f-bit part of every rotation sign (and, with
postselection, of every postselected sign) share one lane with a shot
count and a range in a key-sorted order. Lanes split binomially at
measurements, and a lane whose postselected record is 1 moves to a dead
table without amplitudes. Tables have static capacities: the loop runs
in segments, and the host doubles a full table or shrinks the lane table
to the live lanes between segments. Grouping sorts on the host (numpy's
radix sort is an order of magnitude faster than XLA's on CPU), with
random tie-breaking so that every shot's outputs stay independent.

The measurement kernels are split into weight and projection helpers so
that both executors share them. Without the option, sample_program is
called exactly as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sharing_stats.py counts distinct histories and states per action on the
clifft-bench components, breakdown.py splits a benchmark call into its
stages, shared_breakdown.py times the shared executor's stages, and
benchmark.py compares one lane per shot with shared trajectories.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Pruning now XORs the detector reference bits into the postselection
  test, so use_detector_reference_sample no longer kills the shots the
  user keeps.
- Rare outcomes: whether any shot of a lane sees the rarer outcome is
  decided with choose_outcome's 2^-64 resolution, then the first such
  shot and the rest are drawn exactly. jax.random.binomial alone cut
  that chance to a float32 grid, so a lone shot never saw an outcome of
  probability below about 1e-7.
- Every lane slot draws from its own key, so table sizes, resumes and
  segments no longer change the samples (now tested bitwise).
- Pruned shots report False after their pruning record.
- The dead table holds one entry per shot and never grows; the sticky
  lane capacity lives in a Sharing object owned by the sampler; the
  unused device sort is gone; sharing_stats is bounded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The exact rare-outcome draw now runs its first-hit and binomial draws
only when some lane hit, and reconstruct finds each shot's lane with a
scatter and a running maximum in place of sorting the one-per-shot dead
table. The trajectory benchmark script is renamed benchmark_sharing.py
so that it no longer shadows scripts/statevector/benchmark.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/trajectory/results.md: how much sharing the clifft-bench
components allow, where a sample call spends its time, how the prototype
handles static shapes, the review fixes, and the speedups of the shared
executor and end to end (78x on coherent_d5_r5, 2x to 3x on the
cultivation and coherent d3 circuits, none on distillation).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
investigation.md replaces scripts/trajectory/results.md and adds a
section on whether Tsim's representation helps trajectory sharing,
measured with two new sharing_stats.py options (--mode measurements for
raw records, --noiseless) and a new structure.py (noise compression,
direct outputs and tape structure per workload).

The answer: yes, mostly through the classical/quantum split of the ZX
reduction. Postselecting on the direct detectors before any quantum
simulation multiplies the sharing by 34x to 195x on the cultivation
circuits and removes all noise-driven branching on coherent_d3_r3, and
random measurement outcomes become classical bits (noiseless
cultivation: 2 trajectories in 16,384 shots in both sampling modes).
Sampling detectors rather than records matters mainly because it makes
that postselection possible.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The simplified sampler reads every output off the final symbols instead of
writing records at measurement or Record actions, so the shared executor
can no longer prune at the action that writes a postselected record.

- Prune each postselected output at the action that sets its last branch
  bit, or at the first measurement for an output of f-bits only, and key
  lanes on the f-parts of the postselected output rows.
- Mark a pruned shot's outputs False when they need a branch bit set after
  its pruning action (`ready` replaces `slot_action`).
- Keep the weight and projection helpers the shared kernels need in
  shared.py, so execute.py stays as upstream.
- Port sharing_stats.py and structure.py to output rows, and the pruning
  test to the new `ready` order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
run_all.sh waits for an idle machine (summed %CPU, since macOS's load
average sits near 5 on an idle desktop), then times Clifft, Tsim on all
threads and Tsim on one thread with one lane per shot and with trajectory
sharing. report.py merges the JSON lines into a speed table and checks
acceptance and observable rates of Tsim against Clifft by a two-proportion
z-test. Workloads follow clifft-bench's manifest; SymFT's d=7 circuit runs
with and without postselection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 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-14T01:31:34.326145Z de01bc1 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

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-198/

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

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de01bc1f41

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +690 to +691
sticky = sharing.lane_capacity if segment >= num_actions else 0
capacity = max(_capacity(2 * groups), sticky)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Size shared tables within the sampler's memory budget

When grouping keys are mostly unique, groups approaches the batch size and this allocation reserves the next power of two above twice that many lanes—between 2× and 4× the batch. However, _estimate_batch_size() still budgets memory for only one statevector lane per shot using half the available memory. For wide, low-sharing components, an automatically selected batch can therefore allocate roughly all to twice the available memory and fail with an OOM even though the non-sharing executor fits; account for this capacity multiplier when selecting the batch size or grow from a smaller initial table.

Useful? React with 👍 / 👎.

Comment thread src/tsim/sampler.py
Comment on lines 613 to +616
def _dispatch(f_batch: np.ndarray, indices: list[int], n_valid: int) -> None:
self._key, subkey = jax.random.split(self._key)
jax_out = np.asarray(
sample_program(self._program, jnp.asarray(f_batch), subkey)
sample_program(self._program, jnp.asarray(f_batch), subkey, **sharing)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve zeroed fields after reference postprocessing

When a non-direct masked detector prunes a shared lane and either reference-sample option is enabled, this call returns partially written rows but no per-row prune status. _sample_batches_with_postselection therefore does not add these rows to was_discarded, and its later detector/observable reference XORs reintroduce set bits into fields that reconstruct deliberately cleared after pruning. Discarded rows can consequently report spurious later detections or observables instead of False; propagate the shared executor's discarded-row mask or exclude those rows from reference postprocessing.

Useful? React with 👍 / 👎.

@github-actions

Copy link
Copy Markdown
Contributor

☂️ Code Coverage

current status: ✅

Overall Coverage

Statements Covered Coverage Threshold Status
3736 3601 96% 0% 🟢

New Files

File Coverage Status
src/tsim/statevector/shared.py 99% 🟢
TOTAL 99% 🟢

Modified Files

File Coverage Status
src/tsim/circuit.py 92% 🟢
src/tsim/sampler.py 93% 🟢
TOTAL 93% 🟢

updated for commit: de01bc1 by action🐍

This branch has not been deployed

No deployments
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