Skip to content

feat(statevector): sample on Apple GPUs through Metal - #290

Merged
rafaelha merged 42 commits into
mainfrom
feat/metal-statevector
Nov 12, 2026
Merged

rafaelha merged 42 commits into
mainfrom
feat/metal-statevector

Conversation

@rafaelha

@rafaelha rafaelha commented Nov 10, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #284. On an Apple-silicon Mac, strategy="active_statevector" now samples on the GPU through Metal whenever every component has at most 11 active qubits. A plain install includes it: Tsim depends on pyobjc-framework-Metal (about 7 MB) on Apple silicon only, so other platforms install nothing new.

What users get

  • Automatic selection: no new argument.
    • The samplers fall back to the CPU, with a warning, when a component is wider than 11 qubits, the noise is beyond the GPU tables' limits, the GPU is out of memory, a kernel does not compile, or the process was forked after its parent used the GPU. A GPU whose SIMD lanes do not follow thread order (checked by a probe kernel) is not used at all.
    • The repr says where a sampler samples (samples on metal), and so does sampler.stats.device.
  • Opt-out: TSIM_DEVICE=cpu keeps the samplers on the CPU, in double precision. Tsim reads it each time it compiles a sampler.
  • Precision and seeds: the GPU computes outcome probabilities in float32 and never draws an outcome below about 1e-11. A seed's GPU samples differ from the CPU's and depend on batch_size. The install page, the docstrings, the user skill, AGENTS.md and the CHANGELOG say so.

How it works

  • Kernels: the noise sampler and every component's tape run as Metal kernels that PyObjC compiles at runtime. Metal caches them across processes.
    • The tapes come from feat(statevector): add the host code that GPU backends share #284's shared generator, with a Metal dialect: no goto, SIMD-group shuffles, and amplitudes in arrays of 32. Metal moves an array of 64 to the stack, which costs 1.8×.
    • The rarer outcome is drawn against its own share, in float-float, since Metal has no double.
    • Noise below p ≈ 1e-12 per segment takes a 48-bit fine path.
  • Rows: postselection, row packing and the boolean unpack all run on the GPU. Buffers live in unified memory, so nothing is copied, and freed output buffers are reused.
  • Wide rows: rows over 47 words keep their noise words in device memory.

Benchmark

Every tsim-bench circuit that Metal runs (179 circuits with k ≤ 11, from 28 papers), Metal against one CPU core of the same Mac:
tsim-dev bench --suite corpus --max-width 11 --side .:device=gpu --side .:device=cpu,threads=1 at 58471a37, which includes the harness fix from #292 (each device calibrates its own call size). Apple M4 Pro, 20-core GPU, macOS 27.2. Detector sampler, bit-packed, each circuit postselected as its paper does; the rate counts requested shots, kept or not.

  • All 179 run on the GPU, none failed. Speedup over one core: median 43×, geometric mean 39×, range 0.81–835×. 12 circuits are above 200×, 61 at 50–200×, 80 at 10–50×, 22 at 1–10×, and only qv_q10 is below 1×.
circuit k kept Metal 1 CPU core Metal ÷ 1 core
end2end_d5_dsurface15_p0.001 (cultivation d=5, end to end) 6 0.052 313M 1.37M 229×
end2end_d3_dsurface15_p0.001 4 0.54 163M 1.65M 99×
inject_cultivate_d3_p0.001 4 0.69 1.98G 36.9M 54×
inject_cultivate_d5_p0.001 6 0.14 748M 20.0M 37×
cult_growing_T_d5_to_d13_p0.001 6 0.12 351M 1.99M 176×
gauging_cultivation_d7_T_p0.001 8 0.00023 435M 4.22M 103×
distillation_color_d5_Z 3 0.080 2.55G 25.0M 102×
bb144_cfg369_inj1_T_p0.001 (qLDPC injection) 1 0.070 1.32G 8.54M 155×
hook_inject_T_d15_pd5_p0.001 1 0.67 62.4M 1.76M 35×
surface_d7_r7 0 – 757M 21.9M 35×
coherent_d3_r3 4 – 1.16G 26.6M 44×
coherent_d5_r5 11 – 467k 3.0k 156×
MSC_circuit_d7_p0.0005 8 – 31.4M 56.1k 560×
stability_d3_X_t20_alpha1.0_p0.001 3 – 133M 203k 656×
bh40-T (Magna) 3 0.88 757M 920k 823×
distillation_15to1_T_d25_p0.001 4 – 606k 66.8k 9.1×
rotated-init-x-injection-double-HXY_d1=3_p=0.001 8 0.62 64.1M 12.4M 5.2×
coherent_d3_r1 0 – 8.19G 2.17G 3.8×
tricycle_6_3_2 9 0.92 17.5M 5.16M 3.4×
star_d=11 2 0.00095 3.58M 1.36M 2.6×
tricycle_9_3_2 11 0.87 757k 378k 2.0×
qv_q10 10 – 11.8M 14.6M 0.81×
  • Largest gains come from circuits whose cost is mostly noise sampling and postselection (the Magna bh*-T distillation, stability experiments, end-to-end cultivation): those parallelize across the GPU.
  • Smallest gains come from wide components without much noise (qv_q10, tricycle_*, k = 9–11), where every shot is limited by the GPU's arithmetic, and where the CPU shares trajectories between shots that draw the same outcomes. A component of k = 9 runs at about 58 ns per shot, close to the M4 Pro's FMA and shuffle throughput.
  • Compile: Metal adds about 0.1–0.5 s per process: about 0.1 s for PyObjC and the device, plus building the GPU sampler.
  • The machine had light other load (3–4 busy cores) during the run. Each ratio is the mean of 3–5 interleaved rounds; the CPU column is Metal's rate divided by it.
All 179 circuits
Paper Circuit k Kept Metal (shots/s) 1 CPU core (shots/s) Metal ÷ 1 core
1811.12926_quantum_volume qv_q10 10 - 11.8 M 14.6 M 0.81×
2302.12292_hook_injection hook_inject_T_d15_pd5_p0.001 1 0.667 62.4 M 1.76 M 35.4×
2302.12292_hook_injection hook_inject_T_d15_pd7_p0.001 1 0.45 66.3 M 1.7 M 39×
2302.12292_hook_injection li_inject_T_d15_pd5_p0.001 1 0.457 29.6 M 891 k 33.2×
2302.12292_hook_injection li_inject_T_d15_pd7_p0.001 1 0.196 30.3 M 853 k 35.5×
2302.12292_hook_injection pregrown_hook_inject_T_d15_pd5_p0.001 1 0.587 53.5 M 1.56 M 34.4×
2302.12292_hook_injection pregrown_hook_inject_T_d15_pd7_p0.001 1 0.377 57.6 M 1.56 M 36.9×
2302.12292_hook_injection zz_inject_T_d15_pd5_p0.001 1 0.531 26.5 M 830 k 31.9×
2302.12292_hook_injection zz_inject_T_d15_pd7_p0.001 1 0.269 27.3 M 832 k 32.8×
2406.17653_transversal_algorithmic_ft s_injection_T_d3_p0.001_pinj0.1 1 0.912 3.97 G 55.8 M 71.1×
2406.17653_transversal_algorithmic_ft s_injection_T_d5_p0.001_pinj0.1 1 0.912 2.45 G 45.7 M 53.6×
2406.17653_transversal_algorithmic_ft s_injection_T_d7_p0.001_pinj0.1 1 0.912 1.08 G 35.7 M 30.2×
2406.17653_transversal_algorithmic_ft s_injection_T_d9_p0.001_pinj0.1 1 0.912 711 M 26.4 M 27×
2409.07707_color_code_msd cult_growing_T_d3_to_d11_p0.001 4 0.644 296 M 3.63 M 81.5×
2409.07707_color_code_msd cult_growing_T_d3_to_d7_p0.001 4 0.644 845 M 11.5 M 73.3×
2409.07707_color_code_msd cult_growing_T_d5_to_d13_p0.001 6 0.12 351 M 1.99 M 176×
2409.07707_color_code_msd cult_growing_T_d5_to_d9_p0.001 6 0.12 584 M 5.42 M 108×
2409.17595_magic_state_cultivation end2end_d3_dsurface15_p0.001 4 0.539 163 M 1.65 M 98.6×
2409.17595_magic_state_cultivation end2end_d5_dsurface15_p0.001 6 0.0519 313 M 1.37 M 229×
2409.17595_magic_state_cultivation inject_cultivate_d3_p0.001 4 0.687 1.98 G 36.9 M 53.7×
2409.17595_magic_state_cultivation inject_cultivate_d5_p0.001 6 0.144 748 M 20 M 37.3×
2502.01743_surface_code_cultivation expanded-rotated-init-x-injection-double-HXY_d1=3_d2=11_p=0.001 8 0.648 46.9 M 2.9 M 16.1×
2502.01743_surface_code_cultivation expanded-rotated-injection-double-ghz-HXY_d1=3_d2=11_p=0.001 8 0.626 52.1 M 2.97 M 17.6×
2502.01743_surface_code_cultivation expanded-rotated-injection-syndrome-double-ghz-HXY_d1=3_d2=11_p=0.001 8 0.573 57.9 M 3.03 M 19.1×
2502.01743_surface_code_cultivation expanded-unrotated_H_injection-ghz_d1=3_d2=11_p=0.001 9 0.689 11.6 M 223 k 52.1×
2502.01743_surface_code_cultivation expanded-unrotated_H_injection-syndrome-ghz_d1=3_d2=11_p=0.001 9 0.538 14.6 M 380 k 38.4×
2502.01743_surface_code_cultivation rotated-init-x-injection-double-HXY_d1=3_p=0.001 8 0.624 64.1 M 12.4 M 5.15×
2502.01743_surface_code_cultivation rotated-init-x-injection-syndrome-double-ghz-HXY_d1=3_p=0.001 8 0.571 70.2 M 13.2 M 5.3×
2502.01743_surface_code_cultivation rotated-injection-double-ghz-HXY_d1=3_p=0.001 8 0.603 65.5 M 11.8 M 5.55×
2502.01743_surface_code_cultivation rotated-injection-ghz-HXY_d1=3_p=0.001 6 0.558 210 M 19.4 M 10.8×
2502.01743_surface_code_cultivation rotated-injection-syndrome-double-ghz-HXY_d1=3_p=0.001 8 0.552 73.2 M 13.2 M 5.54×
2502.01743_surface_code_cultivation unrotated_HXY_injection-ghz_d1=3_p=0.001 7 0.561 136 M 14.9 M 9.15×
2502.01743_surface_code_cultivation unrotated_HXY_injection-syndrome-ghz_d1=3_p=0.001 7 0.474 179 M 17.4 M 10.3×
2502.01743_surface_code_cultivation unrotated_H_injection-ghz_d1=3_p=0.001 9 0.548 17.8 M 646 k 27.6×
2502.01743_surface_code_cultivation unrotated_H_injection-syndrome-ghz_d1=3_p=0.001 9 0.463 24.2 M 1.4 M 17.3×
2503.18657_rp2_cultivation rp_3_T_cult 4 0.514 1.7 G 27 M 63.1×
2503.18657_rp2_cultivation rp_3_rp_5_T_cult 10 0.0945 44 M 7.97 M 5.52×
2503.18657_rp2_cultivation rp_3_sc_7_end2end_4_full_rds 4 0.554 962 M 13.9 M 69.2×
2503.18657_rp2_cultivation rp_3_rp_5_sc_11_end2end_6_full_rds 9 0.116 73.9 M 909 k 81.3×
2504.02935_erasure_injection hook_inject_T_d3_p0.001 1 0.902 4.35 G 83.7 M 52×
2504.02935_erasure_injection inject_cultivate_T_d3_p0.001 4 0.687 2.57 G 42.2 M 61×
2505.13587_correlated_decoding_distillation distillation_15to1_T_d5_p0.001 4 - 49.1 M 2.03 M 24.2×
2505.13587_correlated_decoding_distillation distillation_15to1_T_d9_p0.001 4 - 8.94 M 634 k 14.1×
2505.13587_correlated_decoding_distillation distillation_15to1_T_d13_p0.001 4 - 3.4 M 289 k 11.8×
2505.13587_correlated_decoding_distillation distillation_15to1_T_d17_p0.001 4 - 1.68 M 155 k 10.9×
2505.13587_correlated_decoding_distillation distillation_15to1_T_d21_p0.001 4 - 962 k 102 k 9.41×
2505.13587_correlated_decoding_distillation distillation_15to1_T_d25_p0.001 4 - 606 k 66.8 k 9.07×
2507.12511_unfolded_distillation unfolded_repetition_no_preselection_pz1e-3_rounds2 4 - 1.89 G 37.3 M 50.7×
2507.12511_unfolded_distillation unfolded_repetition_no_preselection_pz1e-3_rounds4 4 - 1.33 G 24.1 M 55.2×
2507.12511_unfolded_distillation unfolded_repetition_no_preselection_pz1e-3_rounds6 4 - 1.13 G 18.4 M 61.6×
2507.12511_unfolded_distillation unfolded_surface_no_bitflip_pz1e-3_rounds2 4 1 1.07 G 24.4 M 43.9×
2507.12511_unfolded_distillation unfolded_surface_no_bitflip_pz1e-3_rounds4 4 1 637 M 15.5 M 41.1×
2507.12511_unfolded_distillation unfolded_surface_no_bitflip_pz1e-3_rounds6 4 1 456 M 11.2 M 40.6×
2509.05212_fold_transversal_cultivation fold-d3_native_T_p0.001 6 0.75 219 M 32.7 M 6.69×
2509.05212_fold_transversal_cultivation fold-d5_native_T_p0.001 8 0.16 56.5 M 5.23 M 10.8×
2509.08658_few_clifford_terms d3_cultivation_T_p0.001 4 0.647 2.59 G 40.7 M 63.7×
2509.18294_transversal_star star_d=3 2 0.679 3.02 G 42.1 M 71.7×
2509.18294_transversal_star star_d=5 2 0.237 1.32 G 22 M 60×
2509.18294_transversal_star star_d=7 2 0.051 987 M 12.4 M 79.8×
2509.18294_transversal_star star_d=9 2 0.00791 761 M 6.77 M 112×
2509.18294_transversal_star star_d=11 2 0.000949 3.58 M 1.36 M 2.64×
2509.18294_transversal_star star_d=13 2 9.21e-05 2.34 M 830 k 2.82×
2509.18294_transversal_star star_d=15 2 7e-06 76.8 M 1.65 M 46.4×
2510.07269_transversal_dimension_jump tricycle_6_3_2 9 0.92 17.5 M 5.16 M 3.39×
2510.07269_transversal_dimension_jump tricycle_9_3_2 11 0.867 757 k 378 k 2×
2510.24615_lattice_surgery_cultivation inject-check-3_T 4 0.73 2.23 G 45 M 49.6×
2510.24615_lattice_surgery_cultivation lattice-surgery-complementary-gap-3-to-5-to-7_T 4 0.574 1.39 G 19.6 M 70.8×
2512.13908_google_cultivation_experiment fig2_cultivation_stim_X_T 4 1 5.04 G 99.8 M 50.5×
2512.13908_google_cultivation_experiment fig2_cultivation_stim_Z_T 4 1 5.04 G 103 M 48.7×
2512.13908_google_cultivation_experiment fig2_injection_stim_X_T 1 1 5.41 G 117 M 46.1×
2512.13908_google_cultivation_experiment fig2_injection_stim_Z_T 1 1 5.4 G 117 M 46.1×
2512.13908_google_cultivation_experiment fig2_simulation_stim_X_T 4 1 5.06 G 100 M 50.6×
2512.13908_google_cultivation_experiment fig2_simulation_stim_Z_T 4 1 5.07 G 105 M 48.4×
2512.13908_google_cultivation_experiment fig3_injection_stim_T 4 1 2.26 G 102 M 22.1×
2512.13908_google_cultivation_experiment fig3_simulation_stim_T 4 1 1.2 G 98.6 M 12.2×
2512.13908_google_cultivation_experiment fig3_simulation_zoom_stim_T 4 1 1.2 G 99.8 M 12×
2512.13908_google_cultivation_experiment fig3_with_idle_rounds_stim_T 4 1 1.2 G 100 M 11.9×
2512.13908_google_cultivation_experiment fig3_with_idle_rounds_zoom_stim_T 4 1 1.2 G 98.9 M 12.1×
2512.13908_google_cultivation_experiment fig3_without_idle_rounds_stim_T 4 1 3.27 G 103 M 31.9×
2512.13908_google_cultivation_experiment fig4_cultivation_N2_stim_X_T 4 1 2.45 G 95.2 M 25.7×
2512.13908_google_cultivation_experiment fig4_cultivation_N2_stim_Z_T 4 1 2.43 G 96 M 25.3×
2512.13908_google_cultivation_experiment fig4_cultivation_N3_stim_X_T 4 1 2.95 G 95.3 M 31×
2512.13908_google_cultivation_experiment fig4_cultivation_N3_stim_Z_T 4 1 3.05 G 96.2 M 31.7×
2512.13908_google_cultivation_experiment fig4_injection_N2_stim_X_T 1 1 4.65 G 117 M 39.7×
2512.13908_google_cultivation_experiment fig4_injection_N2_stim_Z_T 1 1 4.65 G 118 M 39.5×
2512.13908_google_cultivation_experiment fig4_simulation_injection_N2_stim_X_T 1 1 4.68 G 117 M 39.9×
2512.13908_google_cultivation_experiment fig4_simulation_injection_N2_stim_Z_T 1 1 4.67 G 117 M 39.9×
2512.23037_soft circuit_d3_p0.001 4 0.687 1.87 G 44.8 M 41.7×
2512.23037_soft circuit_d5_p0.001 6 0.144 976 M 14.1 M 69.1×
2603.05429_gauging_clifford_cultivation gauging_cultivation_d3_T_p0.001 4 0.461 1.89 G 32 M 59.1×
2603.05429_gauging_clifford_cultivation gauging_cultivation_d5_T_p0.001 6 0.0401 577 M 10 M 57.7×
2603.05429_gauging_clifford_cultivation gauging_cultivation_d7_T_p0.001 8 0.000225 435 M 4.22 M 103×
2604.01059_tsim distillation_color_d5_X 4 0.0797 2.73 G 31.9 M 85.6×
2604.01059_tsim distillation_steane_d3_X 4 0.353 3.13 G 43.6 M 71.7×
2604.01059_tsim distillation_unencoded_X 4 - 2.85 G 58.5 M 48.7×
2604.01059_tsim distillation_color_d5_Y 3 0.0874 2.78 G 32.7 M 84.9×
2604.01059_tsim distillation_steane_d3_Y 3 0.367 3.36 G 43.3 M 77.5×
2604.01059_tsim distillation_unencoded_Y 3 - 3.28 G 58.7 M 55.9×
2604.01059_tsim distillation_color_d5_Z 3 0.0798 2.55 G 25 M 102×
2604.01059_tsim distillation_steane_d3_Z 3 0.353 2.79 G 33 M 84.5×
2604.01059_tsim distillation_unencoded_Z 3 - 2.27 G 41.6 M 54.6×
2604.05126_qldpc_magic_state_injection bb144_cfg328_inj5_T_p0.001 1 0.069 1.23 G 7.79 M 158×
2604.05126_qldpc_magic_state_injection bb144_cfg342_inj3_T_p0.001 1 0.0687 1.3 G 8.19 M 159×
2604.05126_qldpc_magic_state_injection bb144_cfg369_inj1_T_p0.001 1 0.0703 1.32 G 8.54 M 155×
2604.05126_qldpc_magic_state_injection bb144_cfg422_inj2_T_p0.001 1 0.0687 1.32 G 8.21 M 161×
2604.05126_qldpc_magic_state_injection bb144_cfg491_inj4_T_p0.001 1 0.0684 1.31 G 8.11 M 162×
2604.05126_qldpc_magic_state_injection bb144_cfg88_inj6_T_p0.001 1 0.0721 1.29 G 7.98 M 162×
2604.27058_clifft coherent_d3_r1 0 - 8.19 G 2.17 G 3.78×
2604.27058_clifft coherent_d3_r3 4 - 1.16 G 26.6 M 43.5×
2604.27058_clifft coherent_d5_r1 0 - 7.79 G 857 M 9.09×
2604.27058_clifft coherent_d5_r5 11 - 467 k 3 k 156×
2604.27058_clifft cultivation_d3 4 0.687 2.04 G 41.8 M 48.8×
2604.27058_clifft cultivation_d5 6 0.144 750 M 20.3 M 37×
2604.27058_clifft distillation 3 0.0798 2.57 G 24.7 M 104×
2604.27058_clifft surface_d7_r7 0 - 757 M 21.9 M 34.5×
2607.28600_symft coherent_surface_d3_r1_p1e-3_rz0p02 0 - 8.19 G 2.16 G 3.8×
2607.28600_symft coherent_surface_d3_r3_p1e-3_rz0p02 4 - 1.17 G 27 M 43.3×
2607.28600_symft coherent_surface_d5_r1_p1e-3_rz0p02 0 - 7.78 G 851 M 9.14×
2607.28600_symft coherent_surface_d5_r5_p1e-3_rz0p02 11 - 466 k 3.04 k 154×
2607.28600_symft distillation 3 0.0798 2.56 G 24.9 M 103×
2607.28600_symft msc_d3_inject_cultivate_p1e-3 4 0.687 2.01 G 41.2 M 48.8×
2607.28600_symft msc_d5_inject_cultivate_p1e-3 6 0.144 767 M 20.3 M 37.9×
2607.28600_symft MSC_circuit_d7_p0.0005 8 - 31.4 M 56.1 k 560×
2607.28600_symft msc_proxy_d7_unverified_p1e-3 6 - 171 M 673 k 254×
2607.28600_symft pure_surface_d7_r7_p1e-3 0 - 758 M 21.9 M 34.5×
2607.28600_symft pure_surface_d9_r9_p1e-3 0 - 386 M 9.88 M 39.1×
2609.17706_chan_color_code_cultivation d3a6f2_inject_cultivate_T_p0.001 4 0.639 2.14 G 40.5 M 52.8×
2609.17706_chan_color_code_cultivation d3a6_inject_cultivate_T_p0.001 4 0.687 2.05 G 41.7 M 49.2×
2609.17706_chan_color_code_cultivation d5a19f13_inject_cultivate_T_p0.001 6 0.106 825 M 19.5 M 42.4×
2609.17706_chan_color_code_cultivation d5a19_inject_cultivate_T_p0.001 6 0.144 651 M 20.1 M 32.4×
2609.17706_chan_color_code_cultivation d5a19r4f13_inject_cultivate_T_p0.001 6 0.0811 909 M 17 M 53.5×
2609.17706_chan_color_code_cultivation d5a19r4_inject_cultivate_T_p0.001 6 0.111 808 M 19.2 M 42.1×
2609.18922_exact_ler_cultivation clifft_d3_p001 4 0.687 1.99 G 40.9 M 48.6×
2609.18922_exact_ler_cultivation soft_cultivation_d5_p0005 6 6.52e-05 868 M 6.16 M 141×
2610.03869_modular_neural_decoder stability_d3_X_t100_alpha0.5_p0.001 3 - 15.5 M 63.5 k 244×
2610.03869_modular_neural_decoder stability_d3_X_t20_alpha0.15_p0.001 3 - 145 M 2.17 M 66.8×
2610.03869_modular_neural_decoder stability_d3_X_t20_alpha0.5_p0.001 3 - 145 M 469 k 309×
2610.03869_modular_neural_decoder stability_d3_X_t20_alpha1.0_p0.001 3 - 133 M 203 k 656×
2610.06811_merlin cultivation_d3_t 4 0.687 1.88 G 41.9 M 44.9×
2610.06811_merlin cultivation_d5_t 6 0.144 750 M 20.4 M 36.8×
2610.06811_merlin distillation_15to1_p0.001 4 0.985 4.72 G 69.8 M 67.6×
2610.06811_merlin distillation_bh2_p0.001 4 0.986 4.74 G 73.8 M 64.2×
2610.06811_merlin distillation_bh4_p0.001 4 0.98 1.42 G 63 M 22.5×
2610.06811_merlin distillation_bh6_p0.001 4 0.974 2.13 G 70.3 M 30.3×
2610.06811_merlin distillation_bh8_p0.001 4 0.969 950 M 60.5 M 15.7×
2610.06811_merlin distillation_bh10_p0.001 4 0.963 1.17 G 68.5 M 17.1×
2610.06811_merlin distillation_bh12_p0.001 4 0.957 697 M 58.7 M 11.9×
2610.06811_merlin distillation_bh14_p0.001 4 0.951 790 M 67.5 M 11.7×
2610.06811_merlin distillation_bh16_p0.001 4 0.946 559 M 57.5 M 9.73×
2610.06811_merlin distillation_bh18_p0.001 4 0.94 657 M 65.4 M 10×
2610.06828_practical_cost_cultivation color_code_cultivation_f3_T 4 0.539 167 M 1.66 M 101×
2610.06828_practical_cost_cultivation color_code_cultivation_f5_T 6 0.0519 315 M 1.4 M 225×
2610.22315_magna bh2-T 3 0.986 3.92 G 62.2 M 63×
2610.22315_magna bh2-S 0 0.986 6.97 G 990 M 7.04×
2610.22315_magna bh4-T 3 0.98 3.29 G 52.3 M 62.9×
2610.22315_magna bh4-S 0 0.98 6.95 G 913 M 7.61×
2610.22315_magna bh6-T 3 0.974 2.85 G 44 M 64.8×
2610.22315_magna bh6-S 0 0.974 6.75 G 859 M 7.86×
2610.22315_magna bh10-T 3 0.963 2.21 G 26.2 M 84.5×
2610.22315_magna bh10-S 0 0.963 6.8 G 809 M 8.41×
2610.22315_magna bh20-T 3 0.935 1.32 G 1.75 M 756×
2610.22315_magna bh20-S 0 0.935 6.63 G 612 M 10.8×
2610.22315_magna bh40-T 3 0.882 757 M 920 k 823×
2610.22315_magna bh40-S 0 0.882 6.54 G 491 M 13.3×
2610.22315_magna bh60-T 3 0.833 507 M 609 k 832×
2610.22315_magna bh60-S 0 0.833 6.39 G 419 M 15.2×
2610.22315_magna bh80-T 3 0.788 379 M 454 k 835×
2610.22315_magna bh80-S 0 0.788 6.48 G 370 M 17.5×
2610.22315_magna bh100-T 3 0.747 290 M 358 k 809×
2610.22315_magna bh100-S 0 0.747 6.3 G 317 M 19.9×
2610.22315_magna color3 4 0.687 2.39 G 43.5 M 55×
2610.22315_magna color5 6 0.144 980 M 12.8 M 76.5×
2610.22315_magna rp2-d3 4 0.514 1.78 G 26.3 M 67.8×
2610.22315_magna rp2-d3-S 0 0.514 3.73 G 41 M 91×
2610.22315_magna rp2-d5 9 0.0945 130 M 10.1 M 12.8×
2610.22315_magna rp2-d5-S 0 0.0945 2.35 G 21.5 M 109×
2610.22315_magna dk 4 0.845 1.79 G 42 M 42.6×
2610.22315_magna wg-T 4 0.551 1.47 G 14.4 M 102×
2610.22315_magna wg-S 0 0.551 3.49 G 78.5 M 44.5×

Testing

  • Gate: uv run tsim-dev check with a plain uv sync, no extra, on the M4 Pro: lint passes, 2757 tests pass, the Metal tests included. CI runs on Linux, where the Metal tests skip.
  • Generator: with the Metal dialect, feat(statevector): add the host code that GPU backends share #284's generator writes the same tapes as the Metal prototype (190 tapes compared), apart from the intended draw_one change.
  • Against the CPU: test_metal_sampler.py compares with the stabilizer-rank strategy and the CPU executor. It covers postselection, reference flips, column layouts, booleans against packed rows, deferred noise, wide rows, width-11 unrolled and looping tapes, and rare outcomes.
  • Fixes: a review found eight problems with sharing, interrupting, forking, pickling, limits and very large calls, and a second review found three more. A third review led to the SIMD-lane probe, the CPU fallback on a compile error, hashed noise streams per block, and a two-sided test of the 48-bit noise path. Each fix has a test, and I checked that each test fails without its fix, except the fork test, whose old behaviour is a hang, and the stream hashing, whose collisions are too rare to construct.

Not verified

  • Other Apple GPUs than the M4 Pro, a real out-of-memory condition, and Windows (the fork hook is guarded, but nothing ran there).
  • tsim-dev bench --suite full on the CPU path. The CPU path is unchanged unless a GPU is used; the quick A/B against origin/main without Metal was at 0.99–1.02×.

🤖 Generated with Claude Code

rafaelha and others added 2 commits November 10, 2026 20:17
The CUDA and Metal backends of the statevector executor share their host
side: `statevector/gpu/` holds the noise kernels' tables (`noise`), a tape
generator that writes a component's tape in either language through a small
`Dialect` (`tape`), and the packing of returned rows (`rows`).

Two changes to the statevector plans come with it. `shared.common_prefix`
returns the leading actions of a tape that every shot runs alike, which a GPU
backend runs once. `plan_component` also plans each component with its
measurements deferred (`schedule(defer=True)`) and keeps that plan when it is
no wider and leaves less shot-dependent work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rk model

- noise_tables raises when an outcome flips more than 255 words (the record's
  8-bit count would wrap) and when the events alone do not fit shared_bytes
  without shared words (MIN_EVENTS is now a named constant).
- Dialect rejects an amplitude_chunk that is not a positive power of two, which
  would declare fewer chunk arrays than the tape indexes.
- _shot_work ends the shared prefix where shared.common_prefix does: an unread
  dormant measurement is a fair coin and no longer counts as shot work.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

☂️ Code Coverage

current status: ✅

Overall Coverage

Statements Covered Coverage Threshold Status
6216 5194 84% 0% 🟢

New Files

File Coverage Status
src/tsim/statevector/metal/_init_.py 39% 🟢
src/tsim/statevector/metal/codegen.py 100% 🟢
src/tsim/statevector/metal/kernels.py 69% 🟢
src/tsim/statevector/metal/noise.py 68% 🟢
src/tsim/statevector/metal/runtime.py 40% 🟢
src/tsim/statevector/metal/sampler.py 31% 🟢
TOTAL 58% 🟢

Modified Files

File Coverage Status
src/tsim/circuit.py 95% 🟢
src/tsim/sampler.py 95% 🟢
src/tsim/statevector/gpu/tape.py 99% 🟢
src/tsim/statevector/shots.py 79% 🟢
TOTAL 92% 🟢

updated for commit: 02e24ac by action🐍

Below p ~ 2^-24, a kernel's 24-bit geometric skip reaches only some trials of a
segment, so a group of many such channels fired them at biased per-channel
rates (0.93 to 1.17 of their rates at p = 1e-9 on 3200 channels; the total
rate was right). Such groups are now cut into segments of fewer than 2^-13
expected faults, which the kernels draw from 48 bits (their fine path).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rafaelha
rafaelha force-pushed the feat/metal-statevector branch from fccbace to 335c1d8 Compare November 10, 2026 19:51
@rafaelha rafaelha mentioned this pull request Nov 10, 2026
rafaelha and others added 20 commits November 11, 2026 03:56
Both dialects are internal constants, so a ValueError and its tests guard
nothing a user can reach.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both backends return every output, in order, so each packed word is a whole
shot word and the piece tables only encoded the identity.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both GPU backends built the read mask of a postselected batch, chose the
deferred channels and estimated the screen's pass rate with the same code;
gpu.noise now has split, screen_pass_rate and words_fit (the shared-memory
fit test that Metal repeated with literals).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both GPU backends wrote the same component calls, seeds, word stores and
postselection hits around the tape functions, and repeated MAX_WIDTH, the
threads per shot, the deviation tolerance and the layout check. gpu.tape now
has tape_kernel, threads_per_shot, deviation and supports.

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>
- Calls on one sampler run one at a time (they share its batch buffers), and
  the buffer pool is locked.
- A failed or interrupted call waits for the batches it committed, through
  further interrupts, and drops the batch buffers, so the next call does not
  return its rows.
- A sampler used in a process forked after Metal's setup samples on the CPU,
  with a warning, instead of hanging.
- Samplers pickle: the CPU sampler and the inputs, with the GPU sampler
  rebuilt on load (or the CPU one where Metal is missing).
- Noise beyond the GPU tables' limits, and running out of GPU memory for
  programs without components, fall back to the CPU with a warning.
- Calls of more than 2^31 shots run in parts (the GPU counters are 32-bit).
- `SamplerStats.device` says where a sampler samples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… samples

- On Apple silicon, Tsim depends on pyobjc-framework-Metal (about 7 MB), so
  the plain install samples on the GPU; the `metal` extra is gone.
- The samplers' repr says where they sample (`samples on metal`).
- The docs, the user skill and AGENTS.md say that the GPU computes outcome
  probabilities in float32, that its seeds depend on `batch_size`, and that
  `TSIM_DEVICE=cpu` keeps the samplers on the CPU.
- Review fixes: `os.register_at_fork` is guarded (Windows has none), and
  batches of wide rows stay below 512 MB, so the kernels' 32-bit offsets do
  not wrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sampler always returns every output in order, so noise_kept's pack_rows
copies output word k and clears the bits past the last output, instead of
reading a piece table per word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nothing read compile_seconds, gpu_seconds or _Slot.shots; the tape kernel
template never used OW or S, and NE always equalled NO; FAST_MATH was never
toggled. runtime.PROFILE stays: the benchmarking skill uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tape kernel's glue, the noise split, the screen's pass rate, the
shared-memory fit test, the deviation, the width limit and the fork warning
now come from tsim.statevector.gpu; the generated kernels and dispatches are
unchanged. codegen's tape_function and hex64 pass-throughs go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The kernels' counts buffer was addressed by bare byte offsets and indices,
and 256, 1024, 1 << 20 and stacklevel=5 were spelled out at each use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both noise kernels and noise_kept repeated the segment buffers, and both
noise kernels the same ballot, bitmap and queue block; those are now one
signature string and an enqueue function.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 170-line MSL template and the source assembly leave sampler.py, so
_module only plans, compiles and uploads. sample and direct_bools share one
fallback to the CPU (_or_cpu) for forked processes and GPU out-of-memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
_run_accepted took 10 parameters, _encode_pack 9 and _encode_batch 8, most
of them the same per call; they now take a _Call and the batch's own values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
tsim/sampler.py knew Metal's width limit, its fork state, its error types and
the no-words rule; metal.shot_sampler now decides, and sampler.py keeps only
the TSIM_DEVICE check and one call, so a second GPU backend adds one line
there. ShotSampler says device = "cpu" itself.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The detector, measurement and looping comparisons with cat5 are one test, as
are the postselection checks in threadgroup and device memory, and the two
out-of-memory fallbacks; the same cases run.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The changelog entry described the kernels, and three docstrings repeated a
seven-line paragraph; the install page keeps the details.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rafaelha
rafaelha force-pushed the feat/metal-statevector branch from b9b58be to 82d3ed1 Compare November 11, 2026 13:14
rafaelha and others added 3 commits November 11, 2026 09:56
The screen ran one thread per shot over every output word, so 32 of a
block's 256 threads walked 44 words in sequence on end-to-end
cultivation d=5. A thread per word and shot now clears the shot's flag,
and the constants go into the rows as they are written. The noise kernel
of end2end_d5_dsurface15 takes 2.27 instead of 2.95 ms per million
shots on an M4 Pro (1.2x the throughput).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
From 8 qubits on, a shot's threads hold at most 16 amplitudes instead
of 32: fewer registers per thread pay for the extra shuffles. On an M4
Pro tricycle_6_3_2 and unrotated_H_injection (9 qubits) run 1.4x, the
8-qubit circuits 1.1-1.2x faster; 7 qubits tie and 6 lose 10%, so
narrower tapes keep 32. CUDA keeps tape.threads_per_shot as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov deactivated this repository and now rejects every upload with
HTTP 400, which failed the 3.12 job and cancelled the others.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
rafaelha and others added 8 commits November 11, 2026 10:58
…e GPU

The kernels take a thread's lane and SIMD group from its index in its
threadgroup: a shot's threads exchange amplitudes by lane, and the scans
list shots in lane order. The device check only checked the SIMD width,
so a GPU that laid lanes out otherwise would have sampled wrongly
without an error. A probe kernel now records every thread's lane and
group in threadgroups of 128, 256 and 1024 threads (the kernels' sizes);
where they are out of order, Metal counts as unavailable and Tsim
samples on the CPU. check_simd_width becomes check_device.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A kernel compiles on a sampler's first call with each postselection
mask; a compile error raised out of sample(). Library now raises
CompileError, on which the sampler warns and samples on the CPU, as out
of GPU memory, and stays there: it neither recompiles nor warns on each
call, and its stats say cpu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A segment's stream was mix64(seed ^ bid * C ^ sg * GOLDEN): two (block,
segment) pairs with equal XORs shared their draws. The block's stream is
now hashed first, so streams of different blocks are unrelated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The test only bounded the faults from above, so it passed if the 48-bit
path never fired. Probabilities near 3e-10 keep the channels on that
path and give ~83 expected faults, which the test now checks both ways:
with the 48-bit path off, 24-bit skips add ~110 faults and it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Codecov accepts uploads again, so a failed upload fails the build again.

This reverts commit c7a825d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

rafaelha and others added 6 commits November 11, 2026 15:13
… executor

The looping tape path (UNROLL_BUDGET exceeded) was never generated in CI.
Compile each tape in a C++ dialect for one thread per shot with g++ and
check that looping and unrolled tapes (in each dialect variant) agree with
each other and with shared.run_lanes on random shot words. Also cover
rows.select's partial last byte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI has no Apple GPU, so the Metal tests do not run there and the executor's
lines always count as uncovered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A slot's keep threshold had 24 bits, so an outcome below about 1e-8 of its
channel rounded to zero and the GPU never drew it, e.g. the X branch of
HERALDED_PAULI_CHANNEL_1(0.1, 1e-10, 0, 0). Thresholds now have 40 bits (the
low 16 in the record's spare field), and a nonzero one never rounds to zero.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The GPU takes an outcome below 1e-11 of the total weight as float32 residue
and never draws it, which also dropped genuine branches of small-angle
rotations. A layout with a rotation branch below 1e-8 (R_Z by less than about
1e-4 pi) now stays on the CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A fault draws its slot and a 40-bit fraction from the low 40 bits of its draw
and compares the fraction with the 40-bit keep thresholds of the shared host
tables, so an outcome below about 1e-8 of its channel is no longer impossible.
The event keeps the fraction's high 24 bits; the low 16 only matter when those
tie with the threshold's (a chance of 2^-24) and come from a hash of the fault
then, so the noise kernel costs the same (tsim-dev bench --device gpu, quick
suite: no circuit slower than 5%).

A circuit with a rotation branch that float32 cannot resolve samples on the
CPU, with a warning that says so.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from feat/gpu-shared to main November 12, 2026 04:42
# Conflicts:
#	src/tsim/statevector/gpu/tape.py
@rafaelha
rafaelha marked this pull request as ready for review November 12, 2026 04:45
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

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

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-12T04:48:46.271243Z 8e4167f Draft marked ready
ℹ️ 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.

@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: 8e4167f7cf

ℹ️ 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 thread src/tsim/statevector/metal/sampler.py Outdated
def direct_bools(self, shots: int, flip: np.ndarray | None = None) -> np.ndarray:
"""Return every output bit of a program without components, as booleans."""
return self._or_cpu(
lambda: self.sample_accepted(shots, flip=flip, bools=True)[0],

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 Forward batch_size through direct boolean sampling

For a component-free sampler with bit_packed=False, the public fast path calls direct_bools, but this call to sample_accepted drops the user-supplied batch_size. When shots exceeds that requested size, boolean sampling therefore uses the default GPU batch partition while bit_packed=True honors the requested partition; because Metal draws a new stream per batch, two same-seeded calls with the same explicit batch_size can return different underlying bits solely due to output formatting, and the requested memory bound is also lost. Pass batch_size through this fast path.

AGENTS.md reference: AGENTS.md:L69-L72

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 02e24ac: direct_bools takes batch_size and the Metal sampler passes it to sample_accepted. A new test (test_direct_booleans_take_the_batch_size_as_packed_rows_do) fails without the fix.

A sampler without components returns booleans through direct_bools, which
dropped batch_size; on the GPU each batch draws its own stream, so booleans
and packed rows of one seed and batch size differed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rafaelha
rafaelha merged commit 020d9bb into main Nov 12, 2026
11 checks passed
@rafaelha
rafaelha deleted the feat/metal-statevector branch November 12, 2026 05:03
rafaelha added a commit that referenced this pull request Nov 12, 2026
CI ran only on `ubuntu-latest`, although the package claims to run on
any operating system, and the Metal backend (#290) was never installed
or imported in CI.

- Adds `macos-latest` (arm64, so `pyobjc-framework-Metal` is installed)
and `windows-latest` to the test matrix, for Python 3.14 only.
Version-specific failures already show up on the four Linux jobs.
- `fail-fast: false`, so one OS failing does not cancel the others.
- Steps run in `bash` on every OS (Git Bash on Windows) so the existing
commands work unchanged.
- On macOS, a step prints `runtime.unavailable()`. On the standard
runner it reports `the Metal executor needs an Apple GPU (Apple
silicon)` (the runner has an Apple Paravirtual device), so
`test/integration/test_metal_sampler.py` still skips there.

Fixes that Windows needed:
- `tesseract-decoder` has no Windows wheels; the dev dependency is now
limited to `sys_platform != 'win32'` (uv.lock updated). `test_skills.py`
already skips decoders it cannot import.
- `test/unit/test_dev_cli.py` skips on Windows: the `tsim-dev` CLI
imports `fcntl` and is POSIX-only.
- `test_skills.py` reads the skill's Markdown as UTF-8; Windows
defaulted to cp1252 and failed on non-ASCII characters.

Coverage upload is unchanged (Linux, 3.12). No user-visible change, so
no changelog entry.

🤖 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