Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/ppvm-python-native/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ pyo3 = { version = "0.29.0", features = [
"multiple-pymethods",
"abi3-py310",
] }
rand = "0.10.3"
15 changes: 10 additions & 5 deletions crates/ppvm-python-native/src/interface_tableau.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use paste::paste;
use ppvm_tableau::prelude::*;
use pyo3::prelude::*;
use pyo3::types::{PyByteArray, PyComplex, PyDict};
use rand::{RngExt, SeedableRng, rngs::SmallRng};

pub(crate) fn measurement_to_u8(m: Option<bool>) -> u8 {
match m {
Expand Down Expand Up @@ -317,10 +318,12 @@ macro_rules! create_interface {
/// Multi-shot sampling: builds a fresh tableau per shot.
///
/// Shots run in parallel on rayon's global thread pool (GIL
/// released), falling back to serial for small batches. Shot `i`
/// is seeded with `seed.wrapping_add(i)` when `seed` is given
/// (wrapping mod 2⁶⁴), so results are reproducible and
/// independent of the thread count; set the `RAYON_NUM_THREADS`
/// released), falling back to serial for small batches. When
/// `seed` is given, it is first scrambled into a base seed
/// (`SmallRng::seed_from_u64(seed).random::<u64>()`) and shot `i`
/// is seeded with `base.wrapping_add(i)`. Results are reproducible
/// and independent of the thread count, and nearby seeds (`s`,
/// `s + 1`, ...) give unrelated shots. Set the `RAYON_NUM_THREADS`
/// environment variable to control the pool size.
///
/// Returns the outcome codes (0/1/2 = zero/one/lost) as one flat,
Expand All @@ -346,7 +349,9 @@ macro_rules! create_interface {
Some(s) => GeneralizedTableau::<$type, $indexType>::new_with_seed(
n_qubits,
min_abs_coeff,
s.wrapping_add(i as u64),
SmallRng::seed_from_u64(s)
.random::<u64>()
.wrapping_add(i as u64),
),
None => GeneralizedTableau::<$type, $indexType>::new(
n_qubits,
Expand Down
4 changes: 3 additions & 1 deletion crates/ppvm-stim/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ where
/// `make_tableau(i)`.
///
/// The shot index lets callers derive a deterministic per-shot seed (e.g.
/// `seed + i`) so results are independent of evaluation order — the same
/// `base.wrapping_add(i)` with `base = SmallRng::seed_from_u64(seed).random()`;
/// don't use `seed + i` directly, or calls with seeds `s` and `s + 1` share
/// almost all shots) so results are independent of evaluation order — the same
/// factory then yields identical results from `sample_parallel` (when the `rayon` feature is enabled).
pub fn sample_serial<T, I, C, F>(
program: &ExtendedProgram,
Expand Down
1 change: 1 addition & 0 deletions crates/ppvm-vihaco/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ vihaco-parser = "0.4.1"
vihaco-parser-derive = "0.4.1"
vihaco-circuit-isa = { version = "0.1.0", path = "../vihaco-circuit-isa" }
ppvm-pauli-sum = { version = "0.1.0", path = "../ppvm-pauli-sum" }
rand = "0.10.3"
30 changes: 27 additions & 3 deletions crates/ppvm-vihaco/src/shots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use crate::PPVMModule;
use crate::composite::PPVM;
use crate::measurements::MeasurementResult;
use rand::{RngExt, SeedableRng, rngs::SmallRng};

/// One shot's full output: the measurement record and the trace-instruction
/// record. Either may be empty depending on what the program emits.
Expand All @@ -27,10 +28,18 @@ pub const PARALLEL_SHOT_THRESHOLD: usize = 128;

/// Per-shot seed derived from the base seed and the shot index, so every shot
/// gets a distinct RNG stream (a shared seed would make all shots identical).
/// Depends only on `(base, index)`, so serial and parallel runs are bit-for-bit
/// identical for a given seed regardless of thread count.
/// The base seed is scrambled through a `SmallRng` before the index is added,
/// so nearby base seeds (`s`, `s + 1`, ...) give unrelated shots rather than
/// the same shots shifted by one (issue #228). Depends only on

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

comments are maybe a little verbose, e.g. adding issue #228 adds little extra info.

/// `(base, index)`, so serial and parallel runs are bit-for-bit identical for
/// a given seed regardless of thread count.
#[inline]
fn shot_seed(base: Option<u64>, index: usize) -> Option<u64> {
base.map(|b| b.wrapping_add(index as u64))
base.map(|b| {
SmallRng::seed_from_u64(b)
.random::<u64>()
.wrapping_add(index as u64)
})
}

/// Run a single shot on a fresh machine and return both records.
Expand Down Expand Up @@ -178,6 +187,21 @@ mod tests {
);
}

#[test]
fn nearby_seeds_do_not_share_shots() {
// Regression test for #228: with `seed + i` per-shot seeds, the run
// with seed 8 was the run with seed 7 shifted by one shot. Each shot is
// one random bit, so a chance match of 127 bits is ~2^-127.
let m = module(RANDOM);
let a = run_shots_serial(&m, 128, Some(7)).unwrap();
let b = run_shots_serial(&m, 128, Some(8)).unwrap();
assert_ne!(
a[1..],
b[..127],
"seed 8 reproduced seed 7's shots shifted by one"
);
}

#[cfg(feature = "rayon")]
#[test]
fn serial_and_parallel_match_for_same_seed() {
Expand Down
7 changes: 4 additions & 3 deletions ppvm-python/src/ppvm/generalized_tableau.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,9 +439,10 @@ def sample(

Shots run in parallel across CPU cores (the GIL is released during
sampling), with a serial fallback for small batches. When ``seed`` is
given (it must fit in an unsigned 64-bit integer), shot ``i`` uses
``(seed + i) % 2**64`` (wrapping ``u64`` arithmetic), so results are
reproducible and independent of the number of threads. Set the
given (it must fit in an unsigned 64-bit integer), each shot's seed is
derived deterministically from ``seed`` and the shot index, so results
are reproducible and independent of the number of threads, and nearby
seeds (``seed`` and ``seed + 1``) give unrelated shots. Set the
``RAYON_NUM_THREADS`` environment variable before the first call to
control the pool size (it defaults to the number of logical cores).

Expand Down
18 changes: 18 additions & 0 deletions ppvm-python/test/generalized_tableau/test_stim.py
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,24 @@ def test_generalized_tableau_sample_classmethod_equivalent():
assert a == b


def test_sample_nearby_seeds_do_not_share_shots():
# Regression test for #228: with `seed + i` per-shot seeds, the batch for
# seed 8 was the batch for seed 7 shifted by one shot (999 of 1000 shared).
n = 32
qubits = " ".join(map(str, range(n)))
prog = StimProgram.parse(f"H {qubits}\nM {qubits}")

def shots(seed):
res = sample_stim(prog, n_qubits=n, num_shots=1000, seed=seed)
return [tuple(int(v) for v in shot) for shot in res]

a, b = shots(7), shots(8)
assert b[:-1] != a[1:]
# 32 random bits per shot: a chance collision between the two batches is
# ~1000^2 / 2^32 ≈ 2e-4, so allow a handful.
assert len(set(a) & set(b)) < 5


def test_sample_stim_zero_shots_returns_empty():
prog = StimProgram.parse("X 0\nM 0")
assert sample_stim(prog, n_qubits=1, num_shots=0) == []
Expand Down
5 changes: 4 additions & 1 deletion skills/ppvm-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,10 @@ let prog = parse_extended(stim_src)?;

// Multi-shot: pass a factory closure to `sample` — it reuses the parsed
// program. The closure receives the shot index `i`; derive a per-shot seed
// from it (e.g. `new_with_seed(.., base.wrapping_add(i as u64))`) for reproducible runs.
// from it for reproducible runs. Scramble the user seed first
// (`let base = SmallRng::seed_from_u64(seed).random::<u64>();`), then use
// `new_with_seed(.., base.wrapping_add(i as u64))`. Plain `seed + i` makes calls
// with seeds `s` and `s + 1` share almost all their shots.
let shots = sample(&prog, 10_000, |_i| {
GeneralizedTableau::<_, usize, _>::new(n_qubits, 1e-10)
})?;
Expand Down
Loading