Skip to content

Gather postselected shots in parallel and stop finishing discarded ones - #259

Merged
rafaelha merged 3 commits into
mainfrom
perf/postselect-filter
Nov 4, 2026
Merged

rafaelha merged 3 commits into
mainfrom
perf/postselect-filter

Conversation

@rafaelha

@rafaelha rafaelha commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Fixes the postselection slowdown that #252, #222 and #223 brought to strategy="active_statevector" before 1.0.0. Samples are bit-for-bit those of main.

#252 made CompiledDetectorSampler.sample(postselection_mask=...) return only the shots that pass. The statevector path found them with one NumPy pass over every shot's words after sampling, on one thread. It also still cleared (#222) and finished (#223) the discarded shots, though nothing reads them any more. Against main at 5af2731 (before these PRs), on B850 (Ryzen 9 9900X), the postselected cells fell to:

cell, postselected 1 thread 10 threads
surface_d7_r7 0.74x 0.25x
cultivation_d5 0.80x 0.53x
coherent_d3_r1 0.17x 0.19x
coherent_d5_r1 0.31x 0.23x
coherent_d3_r3 0.85x

Changes

  • Survivors from alive. Each chunk's _run_chunk already knows which shots passed: the screen of postselected direct detectors, then each component's pruning. It now hands that back, compacts its survivors in place on its own thread (_compact), and a numba _join puts the chunks together. _shrink gives the rest of the buffer back, so a view of a few survivors doesn't keep a whole call's words alive. ShotPlan.rejected is now a bool.
  • Programs without components (surface, coherent r=1). These postselect direct detectors only, all of them in the screen. ChannelSampler.sample_screened_words screens each noise block inside the noise kernel, while the block is still in cache. A parallel _copy_kept then copies the survivors out of scratch words that the sampler reuses (at most 32 MiB). Fresh scratch memory each call cost about 0.4 ms of page faults per 2 MB on these 0.2 ms calls. Both loops have branchless paths for 1-word rows and for blocks that keep most of their shots.
  • Discarded shots are no longer finished. _prune drops the dying lanes' shots from live and alive, without _finish_shot's pattern XOR, coin draws and hidden clearing. _screen's &= kept and the pruned_kept clearing are gone too. This is the follow-up Return only postselected shots from CompiledDetectorSampler.sample #252 listed. Return only postselected shots from CompiledDetectorSampler.sample #252 kept the pattern XOR because its filter read the words; this filter reads alive.

Benchmarks

near-clifford-benchmarks harness on B850, sharing on, bit-packed. Shot data is the simulator's call alone. "Counting included" adds the harness's counting, which for 5af2731 includes dropping the rejected shots itself.

cell, postselected threads shot data vs 5af2731 counting included
surface_d7_r7 1 / 10 0.97x / 0.94x 1.11x / 1.10x
distillation_d3 1 / 10 1.04x / 1.44x 1.11x / 1.50x
distillation_d5 1 / 10 1.04x / 1.35x 1.06x / 1.38x
cultivation_d3 1 / 10 1.06x / 1.37x 1.16x / 1.44x
cultivation_d5 1 / 10 1.04x / 1.19x 1.09x / 1.22x
cultivation_d5_end2end 1 / 10 1.01x / 1.05x 1.01x / 1.05x
cultivation_d7 1 / 10 1.09x / 1.14x 1.11x / 1.16x
coherent_d3_r3 1 / 10 0.99x / 1.69x 1.06x / 1.78x
coherent_d5_r5 1 / 10 1.03x / 1.00x 1.03x / 1.00x
coherent_d3_r1 1 / 10 0.50x / 0.57x 1.05x / 0.74x
coherent_d5_r1 1 / 10 0.69x / 0.61x 1.34x / 0.86x

On the two K=0 coherent r=1 circuits, a call is 0.1–0.2 ms of noise sampling. Returning only the survivors costs one extra read and write of the call's words there. 5af2731 skipped that work and left it to the caller, so the shot-data rate can't match. Counting included, these cells are at 0.74–1.34x of 5af2731. Unmasked calls take the same paths as before.

Testing

  • Bit-for-bit equality with main (aa9d6a2): 880 configurations, at 1 and 10 threads, and again with the part size forced to 4096 (the multi-part path).
    • 11 circuits: the benchmark circuits, a constant-detector circuit and a small T component.
    • Masks: all, none, half, one, zero.
    • Bool and bit-packed output, four call sizes including 0 and 1, batch_size, the reference sample, and appended and separate observables.
    • A sampler reused across calls of shrinking and growing sizes (scratch reuse).
  • Updated tests: two test_shared.py tests read discards off discarded rows' contents.
    • test_pruning_discards_exactly_the_postselected_failures now reads alive.
    • test_pruned_shots_report_false_after_their_pruning_action is replaced by test_pruning_keeps_exactly_the_shots_an_unpruned_run_accepts, an exact check: with the same seed, pruning keeps exactly the shots that an unpruned run accepts, with identical rows.
  • New unit tests for _copy_kept (every branch, serial and parallel), _join, _compact, scratch reuse across shrinking and growing calls, and pickling a sampler after a screened call (as on main; the scratch lock is left out).
  • Suite: uv run pytest: 2465 passed. test_kernels.py::test_the_calling_thread_keeps_parallel_kernels_after_run_tasks fails on this machine on unmodified main too, and was deselected. pre-commit run --all-files, including pyright, is clean.

🤖 Generated with Claude Code

#252 made CompiledDetectorSampler.sample return only the shots that pass
postselection. On the statevector path it found them with one NumPy pass
over every shot's words after sampling, on one thread, and still cleared
and finished discarded shots as #222 and #223 did. On B850 against main
5af2731 (before #252), postselected cells fell to 0.25x (surface d=7, 10
threads), 0.53x (cultivation d=5) and 0.17-0.31x (coherent r=1).

- Each chunk now returns whether its shots passed (the screen and every
  component's pruning, i.e. `alive`) and compacts its survivors in place
  on its own thread; a numba pass joins the chunks.
- A program without components screens each noise block inside the noise
  kernel, while it is in cache, and copies the survivors in parallel out
  of scratch words the sampler reuses (at most 32 MiB).
- Discarded shots are no longer finished: _prune drops their lanes and
  clears `alive` without drawing coins or writing outputs, and the
  `kept`/`pruned_kept` clearing is gone. Nothing reads them any more.

Samples are bit-for-bit those of main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 4, 2026 02:44
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 4, 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-11-04T02:46:22.416795Z a528e29 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 Nov 4, 2026 •

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.2

QR code for preview link

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

Built to branch gh-pages at 2026-11-04 07:57 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Postselection with zero or one survivor can leave bit-packed outputs referencing the original allocation, retaining discarded shot data and component keys instead of releasing them.

1 open issue
What changed in this PR

This PR addresses postselection slowdowns in the active_statevector backend, following the change to return only accepted shots.

Changes:

  • Gathers surviving shots through chunk-local compaction and compiled kernels instead of a single NumPy filtering pass.
  • Screens direct-only programs inside noise blocks and reuses scratch memory.
  • Stops finishing discarded shots and updates pruning tests to check survivor identity and sample equality.
File Description
test/​unit/​statevector/​test_shared.py Checks pruning through alive and compares survivors with unpruned samples.
test/​helpers/​statevector.py Exposes survivor flags in sampling statistics.
src/​tsim/​statevector/​shots.py Adds survivor gathering, buffer shrinking, and direct-only scratch reuse.
src/​tsim/​statevector/​shared.py Removes output completion and clearing for pruned shots.
src/​tsim/​noise/​channels.py Adds per-block screening and survivor counts.
CHANGELOG.md Documents the postselection optimizations.

Review effort: Balanced

Comment thread src/tsim/statevector/shots.py Resolved
- Take and return the scratch words under a lock, so that two threads on
  a free-threaded build cannot share them, and pickle a ShotSampler
  without the lock and the scratch, as main pickles.
- Keep at most 16 MiB of scratch per sampler, noted in the changelog.
- Warn in _shrink's docstring that no other view of the words may remain.
- Unit tests for _copy_kept's branches, _join, _compact, scratch reuse
  across shrinking and growing calls, and pickling after a screened call.
- run_lanes' docstring and the end-of-run NOTE no longer mention clearing
  pruned shots' outputs or _finish_shot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 4, 2026 02:54
@rafaelha

rafaelha commented Nov 4, 2026

Copy link
Copy Markdown
Collaborator Author

Review (independent agent pass) and follow-ups

The reviewer found no bug that changes samples. Byte-identical output with main was re-checked independently over 679 configurations, in three modes: 10 threads, 1 thread, and the multi-part path. The configurations cover circuits with and without components, more than 64 outputs, key words beyond the output words, masks from none to all, the reference sample, batch_size, and reused samplers of shrinking and growing sizes. In addition, 4 Python threads sharing one sampler never returned a row with a masked bit set.

It also confirmed that alive is exactly "every masked detector reads 0" by construction:

  • every postselected output is tested once, at ready or in prune_first;
  • lane_program makes the measurements such outputs read lane-splitting, so they never take a coin;
  • direct postselected detectors are all in screen.

Addressed in a9af140:

  • The scratch hand-off now takes a lock. The old swap was atomic only under the GIL, and 3.13t/3.14t builds would have let two threads share the buffer. Since a lock doesn't pickle, ShotSampler now defines __getstate__/__setstate__ and pickles as on main.
  • Scratch is capped at 16 MiB per sampler, and the changelog says so.
  • _shrink documents that it skips numpy's reference check.
  • Stale run_lanes docstring and NOTE fixed.
  • Unit tests for _copy_kept (every branch), _join, _compact, scratch reuse and pickling.

🤖 Generated with Claude Code

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Zero- or one-survivor bit-packed results can retain the full original shot/key allocation, while the packed postselection test checks padding bits instead of masked detectors due to incorrect bit order.

2 open issues

Review effort: Balanced

Comment thread test/unit/statevector/test_shots.py Resolved
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

☂️ Code Coverage

current status: ✅

Overall Coverage

Statements Covered Coverage Threshold Status
4703 4127 88% 0% 🟢

New Files

No new covered files...

Modified Files

File Coverage Status
src/tsim/noise/channels.py 87% 🟢
src/tsim/statevector/shared.py 36% 🟢
src/tsim/statevector/shots.py 79% 🟢
TOTAL 67% 🟢

updated for commit: c99612a by action🐍

…ittle-endian

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings November 4, 2026 07:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

No active review issues block approval of the postselection performance changes.

No open issues 🎉

2 resolved since last review

Review effort: Balanced

@rafaelha
rafaelha merged commit 5c29af6 into main Nov 4, 2026
10 checks passed
@rafaelha
rafaelha deleted the perf/postselect-filter branch November 4, 2026 08:04
rafaelha added a commit that referenced this pull request Nov 4, 2026
With strategy="active_statevector", the samplers run on a CUDA GPU when CuPy
sees one and every component fits in 11 active qubits, as the stabilizer-rank
strategies do with JAX; otherwise on the CPU. This drops device="cuda" and
CompiledDetectorSampler.sample_postselected: sample keeps main's columns and
packing, and with a postselection_mask copies only the passing shots off the
GPU. Observable expectations stay available through the private
sample_accepted.

Also follow main's seeding (#263) and ShotPlan (#259), read the multiprocessor
count of the current device, and keep the rest of the test suite on the CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rafaelha added a commit that referenced this pull request Nov 5, 2026
With strategy="active_statevector", the samplers run on a CUDA GPU when CuPy
sees one and every component fits in 11 active qubits, as the stabilizer-rank
strategies do with JAX; otherwise on the CPU. This drops device="cuda" and
CompiledDetectorSampler.sample_postselected: sample keeps main's columns and
packing, and with a postselection_mask copies only the passing shots off the
GPU. Observable expectations stay available through the private
sample_accepted.

Also follow main's seeding (#263) and ShotPlan (#259), read the multiprocessor
count of the current device, and keep the rest of the test suite on the CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rafaelha added a commit that referenced this pull request Nov 5, 2026
With strategy="active_statevector", the samplers run on a CUDA GPU when CuPy
sees one and every component fits in 11 active qubits, as the stabilizer-rank
strategies do with JAX; otherwise on the CPU. This drops device="cuda" and
CompiledDetectorSampler.sample_postselected: sample keeps main's columns and
packing, and with a postselection_mask copies only the passing shots off the
GPU. Observable expectations stay available through the private
sample_accepted.

Also follow main's seeding (#263) and ShotPlan (#259), read the multiprocessor
count of the current device, and keep the rest of the test suite on the CPU.

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.

2 participants