Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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"
12 changes: 7 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 @@ -318,10 +319,9 @@ macro_rules! create_interface {
///
/// 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`
/// environment variable to control the pool size.
/// is seeded with a scrambled `seed` plus `i`, so results are
/// reproducible, thread-count independent, and nearby seeds give
/// unrelated shots. Set `RAYON_NUM_THREADS` to control the pool size.
///
/// Returns the outcome codes (0/1/2 = zero/one/lost) as one flat,
/// shot-major `bytearray` plus its `(num_shots, n_measurements)` shape.
Expand All @@ -346,7 +346,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
3 changes: 2 additions & 1 deletion crates/ppvm-stim/src/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,8 @@ 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()`,
/// not plain `seed + i`) 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"
25 changes: 22 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,15 @@ 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 is scrambled first so nearby seeds give unrelated shots. Depends
/// only on `(base, index)`, so serial and parallel runs are identical.
#[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 +184,19 @@ mod tests {
);
}

#[test]
fn nearby_seeds_do_not_share_shots() {
// With `seed + i`, seed 8 was seed 7 shifted by one shot.
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
2 changes: 1 addition & 1 deletion docs/examples/stim_program.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,6 @@
# Multi-shot sampling. The two measurement outcomes are correlated on every shot.
shots = sample_stim(prog, n_qubits=2, num_shots=16, seed=0)
print(f"sampled {len(shots)} shots") # → sampled 16 shots
print("first shot:", shots[0]) # → first shot: [<MeasurementResult.ONE: 1>, <MeasurementResult.ONE: 1>]
print("first shot:", shots[0]) # → first shot: [<MeasurementResult.ZERO: 0>, <MeasurementResult.ZERO: 0>]
all_correlated = all(s[0] == s[1] for s in shots)
print("all shots correlated:", all_correlated) # → all shots correlated: True
4 changes: 2 additions & 2 deletions docs/examples/stim_sampling.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
patterns = Counter(tuple(int(r) for r in shot) for shot in shots)
for pattern, count in sorted(patterns.items()):
print(f"{pattern}: {count}")
# → (0, 0): 102
# → (1, 1): 98
# → (0, 0): 111
# → (1, 1): 89

# A GHZ state should only produce (0, 0) or (1, 1) — never (0, 1) or (1, 0).
correlated_fraction = sum(c for p, c in patterns.items() if p[0] == p[1]) / len(shots)
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
16 changes: 16 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,22 @@ def test_generalized_tableau_sample_classmethod_equivalent():
assert a == b


def test_sample_nearby_seeds_do_not_share_shots():
# With `seed + i`, seed 8 was seed 7 shifted by one shot.
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:]
# Chance collisions of 32-bit shots are rare (~2e-4).
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
4 changes: 3 additions & 1 deletion skills/ppvm-usage/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ 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 a scrambled base, not plain `seed + i`:
// `base = SmallRng::seed_from_u64(seed).random::<u64>()`, then
// `new_with_seed(.., base.wrapping_add(i as u64))`.
let shots = sample(&prog, 10_000, |_i| {
GeneralizedTableau::<_, usize, _>::new(n_qubits, 1e-10)
})?;
Expand Down
Loading