Repository navigation
Conversation
|
e1d3453 to
73656a2
Compare
☂️ Code Coverage
Overall Coverage
New Files
Modified Files
|
73656a2 to
ab4a19d
Compare
rafaelha
left a comment
There was a problem hiding this comment.
Review of the CUDA backend. The first three comments change sampled statistics relative to device="cpu"; the next two are memory-scaling failures; the last is test coverage.
| while (true) {{ | ||
| state += GOLDEN; | ||
| u64 r = mix64(state); | ||
| float skip = lk < -3.0e38f ? 0.f : floorf(__logf(unit(r)) / lk); |
There was a problem hiding this comment.
Geometric skip is biased for small fire probabilities. unit(r) is a 24-bit uniform and __logf is the fast approximation, so the per-segment fire probability has a floor of 2^-24 (u = 1.0 always gives skip 0). The CPU next_skip is exact down to 2^-64.
Measured on an RTX 5090 over all 2^24 unit() values, 256-trial segment, one channel per block:
| p | P(fire) / exact |
|---|---|
| 1e-8 | 1.02x |
| 1e-10 | 4.7x |
| 1e-12 | 233x |
A circuit with many negligible-but-nonzero channels (e.g. 1000 idle channels at p=1e-12) gets ~230x the CPU fault rate, with no warning. __logf is never positive for any unit() output, so there is no negative-skip / out-of-bounds issue, only the bias. Suggest a 53-bit uniform with log/log1p in double, or at least a 32-bit uniform on (0,1).
| self._buffer("values", (shots, no), np.float32) if expectations else None | ||
| ) | ||
| counts.fill(0) | ||
| seeds = cp.asarray( |
There was a problem hiding this comment.
Samples depend on batch_size. Component seeds and the noise seed are drawn from self._rng once per device batch, and the noise stream is keyed by blockIdx within the batch. So compile_sampler(seed=1, device="cuda").sample(2**23) and .sample(2**23, batch_size=2**22) return different samples.
This contradicts the active_statevector docstring in sampler.py ("the sampled outcomes of a seed do not depend on the batch size") and ShotSampler.sample's batch_size doc. Either key seeds/noise by the global shot index, or document that device="cuda" is exempt.
| ) | ||
| self._num_detectors = num_detectors | ||
| self._observables = np.arange(num_detectors, layout.num_outputs) | ||
| self._rng = np.random.default_rng([seed, 2]) |
There was a problem hiding this comment.
RNG stream collides with the reference sample. shots.py uses default_rng([seed, 2]) for _reference_seeds. Here the same stream is used, and both the host reference() and the first _run_batch start with integers(0, 2**64, size=num_components), so they get identical component seeds. draw(seed, 0, ...) then matches the CPU _draw for shot 0.
With use_detector_reference_sample=True, shot 0 of the first batch reproduces the noiseless reference whenever noise misses the component, so its detectors are deterministically 0 after the XOR. Use a distinct stream, e.g. [seed, 3].
| """Return the shot words of ``shots`` shots and the largest norm deviation.""" | ||
| module = self._module(mask) | ||
| layout = self.layout | ||
| out = np.empty((shots, layout.num_words), dtype=np.uint64) |
There was a problem hiding this comment.
Whole-call host allocation. _sample_words allocates (shots, num_words) uint64 up front, including key words, and copies every word back per batch. ShotSampler.sample on CPU splits into _part_shots() parts bounded by memory_budget() and keeps only output columns.
E.g. compile_detector_sampler(device="cuda").sample(10**9) on a 12-word layout tries to allocate 96 GB and raises MemoryError before any kernel runs; the same call on CPU succeeds. The gather kernel (compiled but never launched) looks like it was meant for this: gather only output_words per batch and size the host array accordingly.
| MAX_WIDTH = 11 | ||
| """Widest component the CUDA executor runs: 32 threads of 64 amplitudes.""" | ||
|
|
||
| BATCH_SHOTS = 1 << 22 |
There was a problem hiding this comment.
Batch size ignores device memory and word count. A layout with 150 shot words (the noise kernel admits up to ~176) needs 2^22 × 150 × 8 B ≈ 5 GB for words alone, plus queues and the float32 values buffer. On an 8–12 GB GPU this hits OutOfMemoryError unless the user guesses a smaller batch_size. Suggest deriving the default from cp.cuda.Device().mem_info and layout.num_words, analogous to memory_budget() on CPU.
| from tsim.statevector.cuda import available | ||
| from tsim.statevector.program import StatevectorComponent | ||
|
|
||
| pytestmark = pytest.mark.skipif(not available(), reason="needs CuPy and a CUDA GPU") |
There was a problem hiding this comment.
Module-wide skip hides device-independent tests in CI. test_sample_postselected_returns_only_passing_shots[cpu], test_expectations_need_the_cuda_device, and the device="tpu" / strategy="cat5" ValueError checks in test_cuda_needs_the_statevector_strategy don't need CuPy, but CI (no GPU) skips them. A regression in the CPU branch of sample_postselected would go unnoticed. Suggest moving these to test/unit/test_sampler.py or applying the skip only to the CUDA cases.
compile_sampler and compile_detector_sampler take device="cuda": the noise sampler and every statevector component run as CUDA kernels that CuPy compiles with NVRTC, in float32, for components of up to 11 active qubits. - Noise: a block of shots keeps its words in shared memory; its threads walk each fire-probability group's pooled trials with geometric skips, record the faults, then apply them from one 32-byte record per outcome. The block screens its shots and queues the survivors. - Tapes: each component's tape is generated as straight-line code over amplitudes in registers, with up to 32 threads per shot and warp shuffles for partner amplitudes; long tapes loop instead, so they compile in seconds. Only the screened survivors run it, and outcomes are drawn as the CPU executor's, by seed, shot and action. - CompiledDetectorSampler.sample_postselected returns only the shots that pass postselection, bit-packed and copied once to pinned memory, and with observable_expectations=True each observable's expectation on the shot's state before the measurement that settles it. Install with the new gpu extra (cupy-cuda13x[ctk]). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The tape compared 24 bits of a draw with p1 * 2^24, so a draw of 0 took any outcome of positive probability: 1 in 1.7e7 of the times float32 rounding left a certain outcome's other branch a weight of ~1e-14. On RP2 d=5 cultivation that is 4.5e-5 garbage branches per shot, on Gidney's d=5 circuits ~6e-7. An outcome whose weight is at most 1e-11 of the total is now impossible (rounding leaves at most ~1e-12; genuine outcome probabilities, down to 1e-10 for small-angle rotations, stay above), and the others are drawn against the full 64-bit draw, as the CPU executor draws them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An observable's expectation is taken before the measurement that settles it, so it ignores postselection on detectors settled at or after that measurement in the same component, and its average over the accepted shots is biased. Such observables now return their sampled ±1, which is unbiased; the others keep the exact expectation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
211fed3 to
840be38
Compare
compile_samplerandcompile_detector_samplertakedevice="cuda"(withstrategy="active_statevector"). With it, the noise sampler and every statevector component run as CUDA kernels. CuPy compiles them with NVRTC, in float32, for components of up to 11 active qubits.What's in it
CompiledDetectorSampler.sample_postselected(shots, *, postselection_mask, observable_expectations, ...). This is the only new public method. It returns only the shots that pass postselection, bit-packed, so the GPU copies only those rows, once, into pinned memory.shotscounts every shot, so the acceptance rate islen(rows) / shots. It also works on the CPU. Withobservable_expectations=True(CUDA only), it also returns each observable's expectation of (−1)^obs on the shot's state before the measurement that settles it, as Clifft'sEXP_VALdoes. That expectation ignores postselection that comes later, so when a postselected detector of the same component is tested at or after that measurement, the shot's sampled ±1 is returned instead. Either way, the mean over the returned shots is an unbiased estimate of the observable's expectation.sampleandsample(..., postselection_mask=...). Both keep their contract on the GPU and return every row.gpuextra (cupy-cuda13x[ctk]), which needs CUDA 13 (driver 580 or newer).runtime.pypreloads the pip NVRTC 13, because CuPy otherwise loads the first NVRTC on the library path.Not supported on the GPU:
ValueError).The stabilizer-rank strategies and the JAX
cuda12/cuda13extras are unchanged.Performance (RTX 5090)
Measured with near-clifford-benchmarks: 2^22 shots per call, excluding compilation and warmup, with the device-to-host copy of the returned rows included. Rates are in shots/s.
Testing
test/integration/test_cuda_sampler.pyhas 45 tests, which skip without CuPy and a GPU. They compare detector, measurement and postselected samples against the stabilizer-rank strategy and the CPU executor. They also cover reference samples, programs without statevector components, expectations (including their fallback under later postselection), widths 8, 9 and 11 (8, 16 and 32 threads per shot), the width limit, and small outcome probabilities drawn at their rate (p = 1e-10 at 2^28 shots).test_kernels.py::test_the_calling_thread_keeps_parallel_kernels_after_run_tasks, which fails the same way without this PR under numba's TBB threading layer (fixed separately onfix/thread-spread-test).CI has no GPU, so the CUDA tests only run locally.
🤖 Generated with Claude Code