diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index baafb9a41..5bed6ab66 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,29 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.6.8: threads only where they pay, and a cut-rank search that recounts only what moved + +Additive. No result changes a bit: every kernel path gives the same bits at any thread count, +and every pinned digest is unchanged. + +- **State-vector passes** (`quantum_sv`): a gate, a slice pass or a fixed-chunk sum now spawns + at most one thread per `2¹⁸` amplitudes. Each pass spawns its threads afresh, and below + that size a thread costs more than its share. On a shared 32-core host, a gate from 14 to + 18 qubits ran 5 to 14 times slower with a thread per core than on one thread. A count that a + test forces is still taken as it is, so the split paths stay tested at small sizes. The + ignored `gate_timings` test prints the times. +- **The cut-rank search** (`ProductMeasured::anneal_order`) moves one qubit at a time. Seven + moves in eight go at most four places, and the rest anywhere. Only the cuts between the + two places are recounted, so a move costs about a ninth of a full recount. On the 70-qubit + graph-state circuit, 300 000 moves reach a 29-bit widest cut in about six seconds. In + tests, every move's recounted cuts are checked against a full recount. +- **The service's `cutrank` method:** + - The planner makes one short search, enough to bound the job's memory and work. + - The run searches up to 300 000 moves from each of four seeds, on threads of their own, + with the planner's order among the candidates. + - It keeps the cheapest order whose widest cut is no wider than the planner's, so a job + stays inside the memory it was admitted with. + ### wai-quantum 0.6.7: tensor networks that split controlled gates and fix projections Additive. `Network::amplitude` and `Network::simplify` are unchanged, so every pinned digest is too. diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock index fed8f1640..6eec16195 100644 --- a/wai/tools/quantum-meter/Cargo.lock +++ b/wai/tools/quantum-meter/Cargo.lock @@ -1017,7 +1017,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index 7e3f6e99a..db830d95f 100644 --- a/wai/tools/quantum-wgsl-check/Cargo.lock +++ b/wai/tools/quantum-wgsl-check/Cargo.lock @@ -599,7 +599,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index b8b830905..df27f67e6 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -713,7 +713,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-qpu/Cargo.lock b/wai/wai-quantum-qpu/Cargo.lock index faa6c29d8..4e000db03 100644 --- a/wai/wai-quantum-qpu/Cargo.lock +++ b/wai/wai-quantum-qpu/Cargo.lock @@ -422,7 +422,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index c30a27701..6a9270c14 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -599,7 +599,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/Cargo.lock b/wai/wai-quantum-service/Cargo.lock index 3831bab18..2b283d594 100644 --- a/wai/wai-quantum-service/Cargo.lock +++ b/wai/wai-quantum-service/Cargo.lock @@ -857,7 +857,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-service/README.md b/wai/wai-quantum-service/README.md index 053a6c406..531274049 100644 --- a/wai/wai-quantum-service/README.md +++ b/wai/wai-quantum-service/README.md @@ -50,7 +50,7 @@ How the methods run: - **statevector** groups consecutive gates into passes over the state. Each pass applies its gates tile by tile, in a buffer that fits a core's cache. The grouping never changes a bit of the result. - **cutrank** takes a Clifford circuit followed, on each qubit, by one-qubit gates at any angles (a qubit's gates from its first non-Clifford one on act on that qubit alone). - It writes the Clifford part's state as a graph state with local Cliffords, and sums the amplitude one qubit at a time along an order of the qubits, in a table of `2^cut` entries. Here `cut` is the state's cut-rank, its entanglement in bits, across the cut the sum has reached. - - The planner searches for an order with narrower cuts. The cost is set by the widest cut, `max_cut` in the result, not by the number of qubits. + - The planner makes one short search for an order with narrower cuts, enough to bound the job's memory and work. The run searches longer, from several seeds on threads of their own, and keeps the cheapest order that is no wider than the planner's. The cost is set by the widest cut, `max_cut` in the result, not by the number of qubits. - Counts are exact samples, drawn qubit by qubit from the table's conditional probabilities. - Amplitudes are exact up to one phase shared by all of the job's amplitudes, because a stabilizer tableau does not fix the state's global phase; the result's `global_phase` says so. - The table is held in double precision when it fits the job's memory, and in single precision otherwise (accuracy class `single`). diff --git a/wai/wai-quantum-service/src/catalog.rs b/wai/wai-quantum-service/src/catalog.rs index b1ea0cc62..4e03e9fc3 100644 --- a/wai/wai-quantum-service/src/catalog.rs +++ b/wai/wai-quantum-service/src/catalog.rs @@ -62,9 +62,13 @@ pub const CUTRANK_MAX_QUBITS: u32 = 4096; pub const CUTRANK_MAX_CUT: usize = 34; /// The table entries a `cutrank` job's passes may touch: about ten minutes. pub const CUTRANK_MAX_WORK: u64 = 4_000_000_000_000; -/// The word operations the planner spends searching for an order of the -/// qubits with narrower cuts: about a second. -const CUTRANK_ANNEAL_OPS: u64 = 2_000_000_000; +/// The word operations spent searching for an order of the qubits with +/// narrower cuts: the planner's one search, about a second, and each of the +/// run's searches. +const CUTRANK_PLAN_OPS: u64 = 1_000_000_000; +const CUTRANK_RUN_OPS: u64 = 8_000_000_000; +/// Searches the run makes, from different seeds, on threads of their own. +const CUTRANK_RUN_SEEDS: u64 = 4; const MIB: u64 = 1 << 20; const GIB: u64 = 1 << 30; @@ -275,9 +279,12 @@ pub fn mps_work(r: &Request, chi: u64) -> u64 { } /// A `cutrank` job's state: the program's Clifford part as a graph form with -/// its closing one-qubit gates, summed along an order found by a seeded -/// search. The planner and the run build it alike, so they agree on the order. -pub fn product_measured(r: &Request) -> Result { +/// its closing one-qubit gates, summed along an order found by seeded +/// searches. The planner makes one short search, enough to bound the job's +/// memory and work; the run (`thorough`) makes several longer ones and keeps +/// the cheapest order, which is never costlier than the planner's. Both are +/// deterministic. +pub fn product_measured(r: &Request, thorough: bool) -> Result { use wai_quantum::quantum_cutrank::ProductMeasured; use wai_quantum::quantum_tableau::Tableau; let n = r.program.n_qubits; @@ -287,16 +294,41 @@ pub fn product_measured(r: &Request) -> Result> = std::thread::scope(|sc| { + let handles: Vec<_> = (1..=CUTRANK_RUN_SEEDS) + .map(|seed| { + let pm = ± + sc.spawn(move || pm.anneal_order(m, seed)) + }) + .collect(); + handles.into_iter().map(|h| h.join().expect("a search")).collect() + }); + let mut best = quick; + for o in &found { + let cand = pm.clone().with_order(o).map_err(|e| e.to_string())?; + if cand.max_cut() <= best.max_cut() && cand.work() < best.work() { + best = cand; + } + } + Ok(best) } /// The memory a `cutrank` job takes: its largest table with a margin, the @@ -464,7 +496,7 @@ fn try_method(method: &str, r: &Request, dyadic: &Result Result { } "cutrank" => { use wai_quantum::quantum_cutrank::Precision; - // The same order the planner found, and the precision its memory allows. - let pm = catalog::product_measured(&r)?; + // An order at least as cheap as the planner's (searched longer, from + // several seeds), and the precision the admitted memory allows. + let pm = catalog::product_measured(&r, true)?; let precision = if plan.memory_bytes >= catalog::cutrank_bytes(&pm, Precision::Double) { Precision::Double } else { Precision::Single }; let cut = pm.max_cut(); out["accuracy"] = json!({ diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 8519ce6d9..cfe101006 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -1079,7 +1079,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "block2", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 9e7a14a9a..de6a36666 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/wai/wai-quantum/README.md b/wai/wai-quantum/README.md index 01c4a4a2e..f60808a86 100644 --- a/wai/wai-quantum/README.md +++ b/wai/wai-quantum/README.md @@ -457,13 +457,15 @@ entries the passes touch. ```sh cargo run --release --example cutrank_qasm --features quantum_cutrank -- circuit.qasm cuts -cargo run --release --example cutrank_qasm --features quantum_cutrank -- circuit.qasm order 20000 3 > order.txt +cargo run --release --example cutrank_qasm --features quantum_cutrank -- circuit.qasm order 300000 1 > order.txt CUTRANK_ORDER=order.txt CUTRANK_PRECISION=single \ cargo run --release --example cutrank_qasm --features quantum_cutrank -- circuit.qasm sample 1000 7 ``` -`order` anneals for the least work and prints the order it found; `sample` -prints each exact sample with `2^n · p`. +`order` anneals for the least work and prints the order it found. A move of +the search takes one qubit to another place, and only the cuts between the two +places are recounted, so 300 000 moves on the 70-qubit circuit below take a few +seconds. `sample` prints each exact sample with `2^n · p`. A 70-qubit graph-state sampling circuit has a nearest-neighbour Clifford bulk, then a non-Clifford basis on every qubit. With an annealed order, every cut is diff --git a/wai/wai-quantum/src/quantum_cutrank.rs b/wai/wai-quantum/src/quantum_cutrank.rs index 7505df12f..8627392f8 100644 --- a/wai/wai-quantum/src/quantum_cutrank.rs +++ b/wai/wai-quantum/src/quantum_cutrank.rs @@ -185,6 +185,32 @@ impl ProductMeasured { cuts } + /// The cut-rank between the qubits `members` and the rest: the rank over + /// GF(2) of the graph's edges between the two sides. + fn set_rank(&self, members: &[usize]) -> usize { + let words = self.n.div_ceil(64); + let mut ahead = vec![u64::MAX; words]; + if !self.n.is_multiple_of(64) { + ahead[words - 1] = (1u64 << (self.n % 64)) - 1; + } + for &v in members { + ahead[v / 64] &= !(1u64 << (v % 64)); + } + let mut basis: Vec> = Vec::new(); + for &v in members { + let mut x: Vec = self.adjacency[v].iter().zip(&ahead).map(|(a, b)| a & b).collect(); + for b in &basis { + if get(&x, pivot(b)) { + x.iter_mut().zip(b).for_each(|(a, b)| *a ^= b); + } + } + if x.iter().any(|&w| w != 0) { + basis.push(x); + } + } + basis.len() + } + /// The work of an order: `Σ_k 2^{cut_k}`, the table entries its passes /// touch, up to a small factor. pub fn work(&self) -> f64 { @@ -192,33 +218,46 @@ impl ProductMeasured { } /// An order with less work, by simulated annealing from the current one: - /// `moves` random swaps and moves of one qubit, seeded, accepted by the - /// Metropolis rule on the work's logarithm as the temperature falls. The - /// cheapest order seen is returned; it is never worse than the current. + /// `moves` moves of one qubit, seeded, accepted by the Metropolis rule on + /// the work's logarithm as the temperature falls. Seven moves in eight go + /// at most four places, the rest anywhere. Moving a qubit from place `i` + /// to place `j` changes only the sets summed after places between them, so + /// only those cuts are recounted. The cheapest order seen is returned; it + /// is never worse than the current. pub fn anneal_order(&self, moves: usize, seed: u64) -> Vec { let n = self.n; let mut s = seed; - let cost = |o: &[usize]| self.cut_ranks_of(o).iter().map(|&k| (k as f64).exp2()).sum::(); + let work = |c: &[usize]| c.iter().map(|&k| (k as f64).exp2()).sum::(); let mut order = self.order.clone(); - let mut cur = self.work(); + let mut cuts = self.cuts.clone(); + let mut cur = work(&cuts); let (mut best, mut best_order) = (cur, order.clone()); if n < 2 { return best_order; } for it in 0..moves { let temp = 0.3 * (1.0 - it as f64 / moves as f64) + 1e-3; - let (i, j) = ((splitmix(&mut s) % n as u64) as usize, (splitmix(&mut s) % n as u64) as usize); - let mut cand = order.clone(); - if splitmix(&mut s) & 1 == 0 { - cand.swap(i, j); - } else { - let v = cand.remove(i); - cand.insert(j, v); + let i = (splitmix(&mut s) % n as u64) as usize; + let reach = if splitmix(&mut s).is_multiple_of(8) { n - 1 } else { 4.min(n - 1) }; + let d = 1 + (splitmix(&mut s) % reach as u64) as usize; + let j = if splitmix(&mut s) & 1 == 0 { i.saturating_sub(d) } else { (i + d).min(n - 1) }; + if i == j { + continue; } - let c = cost(&cand); + let mut cand = order.clone(); + let v = cand.remove(i); + cand.insert(j, v); + let mut cand_cuts = cuts.clone(); + for k in i.min(j)..i.max(j) { + cand_cuts[k] = self.set_rank(&cand[..=k]); + } + #[cfg(test)] + assert_eq!(cand_cuts, self.cut_ranks_of(&cand), "the recounted cuts are the whole order's"); + let c = work(&cand_cuts); // The acceptance draw only for a worse order. if c <= cur || ((cur.log2() - c.log2()) / temp).exp() > (splitmix(&mut s) >> 11) as f64 / (1u64 << 53) as f64 { order = cand; + cuts = cand_cuts; cur = c; if c < best { best = c; diff --git a/wai/wai-quantum/src/quantum_sv.rs b/wai/wai-quantum/src/quantum_sv.rs index 9693c801a..148865f54 100644 --- a/wai/wai-quantum/src/quantum_sv.rs +++ b/wai/wai-quantum/src/quantum_sv.rs @@ -96,7 +96,7 @@ type SliceFn<'a> = dyn Fn(&mut [f64], &mut [f64], usize) + Sync + 'a; fn par_slices(re: &mut [f64], im: &mut [f64], unit: usize, f: &SliceFn) { #[cfg(not(target_arch = "wasm32"))] { - let threads = available_threads(); + let threads = threads_for(re.len()); let units = re.len() / unit; if threads > 1 && units >= threads && re.len() >= 1 << 14 { let chunk = units.div_ceil(threads) * unit; @@ -298,6 +298,24 @@ pub(crate) fn on_one_thread(f: impl FnOnce() -> R) -> R { r } +/// Amplitudes a thread must have to itself before a pass is split. Each pass +/// spawns its threads afresh, and below this a thread's start costs more than +/// its share of the work: a one-qubit gate on `2¹⁸` amplitudes takes a few +/// hundred microseconds on one core, about what spawning a full machine's +/// threads costs. +const PER_THREAD: usize = 1 << 18; + +/// The threads worth spawning for a pass over `len` amplitudes: what the +/// machine has, but at most one per [`PER_THREAD`] amplitudes. A count a test +/// forces is taken as it is, so the split paths stay tested at small sizes. +fn threads_for(len: usize) -> usize { + #[cfg(test)] + if let Some(t) = FORCE_THREADS.with(|f| f.get()) { + return t; + } + available_threads().min((len / PER_THREAD).max(1)) +} + fn available_threads() -> usize { #[cfg(test)] if let Some(t) = FORCE_THREADS.with(|f| f.get()) { @@ -777,7 +795,7 @@ impl StateVector { let offsets: Vec = (0..d) .map(|local| bitpos.iter().enumerate().fold(0usize, |o, (i, &p)| o | (((local >> (k - 1 - i)) & 1) << p))) .collect(); - let threads = available_threads(); + let threads = threads_for(len); if len / block >= threads || threads == 1 || len < 1 << 14 { let f = |re: &mut [f64], im: &mut [f64], _off: usize| kernel_block(re, im, m, &offsets, &sorted); par_slices(&mut self.re, &mut self.im, block, &f); @@ -915,7 +933,7 @@ impl StateVector { }; #[cfg(not(target_arch = "wasm32"))] let parts: Vec = { - let threads = available_threads().min(chunks); + let threads = threads_for(len).min(chunks); if threads > 1 && len >= 1 << 14 { let size = chunks.div_ceil(threads); std::thread::scope(|sc| { @@ -1261,6 +1279,29 @@ mod tests { } } + /// Gate application time by size, one thread against the default: + /// `cargo test --release --features quantum_sv --lib gate_timings -- --ignored --nocapture`. + #[test] + #[ignore] + fn gate_timings() { + for n in [12u32, 14, 16, 18, 20, 22] { + let gates = random_circuit(n, 4, 3); + let time = |threads: Option| { + FORCE_THREADS.with(|f| f.set(threads)); + let mut sv = StateVector::zero(n).unwrap(); + let t = std::time::Instant::now(); + for g in &gates { + sv.apply(g).unwrap(); + } + let e = t.elapsed(); + FORCE_THREADS.with(|f| f.set(None)); + e.as_secs_f64() * 1e6 / gates.len() as f64 + }; + let forced: Vec = [1usize, 2, 4, 8, 16, 32].iter().map(|&t| format!("{t}: {:.0}", time(Some(t)))).collect(); + eprintln!("{n} qubits, {} gates: µs per gate by threads {}; default {:.0}", gates.len(), forced.join(", "), time(None)); + } + } + /// One thread takes the block kernels everywhere (as on a target without /// threads); many threads take the split paths for the top qubits. The /// amplitudes and reductions must be the same bits either way. diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 74029b271..ae1093f44 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4310,7 +4310,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 12170cf93..21070fae1 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1241,7 +1241,7 @@ version = "0.1.0" [[package]] name = "wai-quantum" -version = "0.6.7" +version = "0.6.8" dependencies = [ "blake3", "ed25519-dalek",