diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index c916b76c2..de128ba15 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -565,8 +565,9 @@ jobs: done # The fabric's groundwork for accelerator members (certification one case at # a time, the fabric-v1 corpus and its pins, the 32-bit-lane words and their - # range guard, the program a member runs in every planner configuration, the - # WGSL kernels' host layouts, member ids and mutants), each by name: a module + # range guard, run meters, metered calibration, placement for a run and the + # fabric's fallback, the program a member runs in every planner configuration, + # the WGSL kernels' host layouts, member ids and mutants), each by name: a module # filter is satisfied by any one test of the module, so a test dropped or # compiled out on wasm32 would otherwise go unnoticed. for t in quantum_fabric::tests::certification_is_certify \ @@ -582,6 +583,13 @@ jobs: quantum_fabric::tests::the_fabric_v1_corpus_of_each_gateset_is_pinned \ quantum_fabric::tests::fabric_v1_covers_every_dispatch_shape \ quantum_fabric::tests::gate_words_are_the_table \ + quantum_fabric::tests::a_meter_reading_is_labelled_or_dropped \ + quantum_fabric::tests::a_placement_figure_is_a_measurement_or_nothing \ + quantum_fabric::tests::figures_go_on_both_members_or_neither \ + quantum_fabric::tests::place_for_sends_small_runs_to_the_cpu_and_large_ones_to_the_gpu \ + quantum_fabric::tests::the_fabric_falls_back_and_names_the_member_that_produced_the_result \ + quantum_fabric::tests::a_metered_fabric_run_seals_its_reading \ + quantum_fabric::tests::a_calibration_gives_the_cpu_reference_its_figure \ quantum_fabric::plan::tests::execute_is_the_native_simulator \ quantum_fabric::plan::tests::every_configuration_is_the_native_simulator \ quantum_fabric::plan::tests::long_circuits_tile_as_they_run_gate_by_gate \ diff --git a/scripts/wai-quantum-webgpu-member.sh b/scripts/wai-quantum-webgpu-member.sh index c408c34c5..c650de5aa 100755 --- a/scripts/wai-quantum-webgpu-member.sh +++ b/scripts/wai-quantum-webgpu-member.sh @@ -2,13 +2,14 @@ # # wai-quantum-webgpu-member.sh # -# Runs the WebGPU member's device tests and fails unless (1) all 13 of them ran +# Runs the WebGPU member's device tests and fails unless (1) all 14 of them ran # and passed, (2) the adapters found are exactly WAI_QUANTUM_WEBGPU_EXPECT, (3) each # declared adapter earned one bit-exact certificate per pinned corpus digest under # each implementation (the per-gate kernel, wgsl-lanes32/, and tile runs, -# wgsl-lanes32-tile/), and (4) each declared adapter killed every shader mutant of -# both. These checks are what make a filtered, skipped or adapter-less run fail -# instead of pass. +# wgsl-lanes32-tile/), (4) each declared adapter killed every shader mutant of +# both, and (5) each declared adapter ran metered and on the fabric, falling back +# and producing as placed. These checks are what make a filtered, skipped or +# adapter-less run fail instead of pass. # # WAI_QUANTUM_WEBGPU_EXPECT= [WAI_QUANTUM_WEBGPU_WIDE=1] \ # scripts/wai-quantum-webgpu-member.sh @@ -23,8 +24,8 @@ export WAI_QUANTUM_WEBGPU_REPORT="$dir/webgpu-member.report" cargo test --locked --features quantum_fabric_webgpu --test webgpu_member \ -- --nocapture --test-threads=1 2>&1 | tee "$log" -grep -q "test result: ok. 13 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out" "$log" \ - || { echo "::error::the member's 13 tests did not all run and pass"; exit 1; } +grep -q "test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out" "$log" \ + || { echo "::error::the member's 14 tests did not all run and pass"; exit 1; } grep -qxF "webgpu adapters: as declared ($WAI_QUANTUM_WEBGPU_EXPECT)" "$WAI_QUANTUM_WEBGPU_REPORT" \ || { echo "::error::the adapters found are not the declared set"; exit 1; } @@ -48,6 +49,8 @@ for key in $(tr ',' '\n' <<<"$WAI_QUANTUM_WEBGPU_EXPECT" | sort -u); do [ "$n" -eq "$want" ] || { echo "::error::$key: a mutant survived, or the mutant test did not run"; exit 1; } n=$(grep -cxF "webgpu tile mutants: key=$key killed 3 of 3" "$WAI_QUANTUM_WEBGPU_REPORT" || true) [ "$n" -eq "$want" ] || { echo "::error::$key: a tile mutant survived, or the mutant test did not run"; exit 1; } + n=$(grep -cxF "webgpu fabric: key=$key ok" "$WAI_QUANTUM_WEBGPU_REPORT" || true) + [ "$n" -eq "$want" ] || { echo "::error::$key: the metered and fabric test did not run for it"; exit 1; } if [ "${WAI_QUANTUM_WEBGPU_WIDE:-0}" = 1 ] && [ "${key%%-*}" = gpu ]; then grep -qE "^webgpu wide: key=$key n=2[56] matches native$" "$WAI_QUANTUM_WEBGPU_REPORT" \ || { echo "::error::$key: no wide run at the member's full size"; exit 1; } diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 668b5ead3..bc5858dcf 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,70 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### wai-quantum 0.4.3: metered runs, placement by run, and the fabric wrapper + +Additive. The default features, `full`, both implementations of the WebGPU member, and +every existing pin and digest are unchanged; runs, receipts and members stay unmetered +unless a caller supplies a meter. + +- **Run meters.** `quantum_fabric::RunMeter`: `start` just before a run's first + submission, `stop` after its last readback, returning the microjoules in the window and + their `EnergyClass`, or none when the window cannot be attributed to the run alone. The + trait is member-neutral, and no implementation ships in the crate. A reading reaches a + result only when `check_label` accepts it; otherwise the run is unmetered (zero, no + class). Every start is matched by one stop, a refused run's reading is discarded, and a + meter that cannot start is never stopped. +- **The WebGPU member, metered.** `WebGpuMember::simulate_metered(c, start, meter)` + returns the state with the accepted reading. Its window runs from the first submission + to the last readback: the device's work, and the host's waits and copies between them; + planning, buffer creation, the upload's staging and the conversion to a state vector + are outside it, and the member's `detail` says so. A circuit refused + before any submission opens no window. `member_metered(corpus, meter)` certifies as + `member` does and gives each entry the figure its runs earn; + `blocking::{simulate_metered, member_metered}`. +- **Placement figures from metered calibrations only.** `EnergyCalibration` (`new`, + `add`, `runs`, `finish`) turns metered runs into a `KernelEntry::energy` figure: the + total energy over the operations performed (`pair_updates`: one pair of amplitudes under + one gate), in nanojoules rounded up, with the runs' class, whose uncertainty is the + largest of the runs' plus what the rounding adds, over their windows together. An + unmetered run, a refused label, a second class, or an uncertainty that no longer fits + its field once the rounding is added leaves no figure. + `detect_local_metered(corpus, meter)` gives the CPU reference its figures. Because + `place` ranks a known figure before none, figures go on the CPU reference and on the + members placement compares it with, or on none. `place` is unchanged; `place_for` and + `Fabric` hold the rule themselves, and read figures on only some candidates as figures + on none. +- **Placement for a run.** `place_for(members, kernel, required, predict_pj)` places by + the least predicted picojoules when every candidate has a prediction, ties falling as + in `place`. Otherwise predictions are not used, and the order is `place`'s, except that + figures on only some candidates count as figures on none. Under synthetic models with a + fixed cost per GPU run, a 3-qubit circuit stays on the CPU and a 24-qubit one goes to + the GPU. +- **The fabric wrapper.** `Fabric` holds members with their `Runner`s (`CpuReference`, + and `WebGpuMember` for its own implementation) and optional meters (`add`, + `add_metered`, `set_model`, `order`, `run`). `run` places the circuit, falls back down + the placement order when a member refuses with `MemberError::{TooWide, OutOfRange, + Memory, Timeout, Device}` (`falls_back`), stops at an invalid circuit or a defect, and + seals the `FabricReceipt` with the member and implementation that produced the result + and its accepted reading. The refusals (`Refused`) are listed beside the receipt in + `FabricRun`, or in `FabricError`; the receipt's signed bytes are unchanged. + `WebGpuError` converts into `MemberError`. A runner states the member it is + (`Runner::member_id`), and a run that reaches a member paired with a runner of another + id is refused there as a defect, so a receipt cannot name a member that did not produce + the result. `ready(future)` gives the output of a future that is ready on its first + poll, which is how a native caller drives `run` without an executor; `&R` is a `Runner` + and `&mut M` a `RunMeter`. +- **Tests.** CPU-side tests, native and under wasm32-wasip2 (each required by name in the + `quantum-wasip2` job), cover the readings, the calibration, figures on both members or + neither, `place_for`, the fallback, and metered fabric receipts. The device test + `g13_metered_runs_and_the_fabric` runs the member under a test meter, calibrates it on + random-v1 with the pinned digests, and runs the fabric with the member held to three + qubits: a wider circuit falls back to the CPU reference, a narrower one is produced on + the member and metered. It runs the tile implementation the same way, whose receipt + names it, and requires a member paired with another member's runner, or with the other + implementation's, to be refused. The member script requires it, and a fabric line per + declared adapter. + ### wai-quantum 0.4.2: tile runs, and the WebGPU member in a browser Additive. The default features, `full`, the per-gate implementation and every existing pin diff --git a/wai/tools/quantum-wgsl-check/Cargo.lock b/wai/tools/quantum-wgsl-check/Cargo.lock index 9e381235d..92290ce3b 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.4.2" +version = "0.4.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index f86bdffa4..7e2ff5f8e 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -698,7 +698,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.4.2" +version = "0.4.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum-serve/Cargo.lock b/wai/wai-quantum-serve/Cargo.lock index 8855b1a3a..13028d600 100644 --- a/wai/wai-quantum-serve/Cargo.lock +++ b/wai/wai-quantum-serve/Cargo.lock @@ -589,7 +589,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.4.2" +version = "0.4.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.lock b/wai/wai-quantum/Cargo.lock index 0f6af4536..893115472 100644 --- a/wai/wai-quantum/Cargo.lock +++ b/wai/wai-quantum/Cargo.lock @@ -995,7 +995,7 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wai-quantum" -version = "0.4.2" +version = "0.4.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-quantum/Cargo.toml b/wai/wai-quantum/Cargo.toml index 24aaaddf9..aacf5cfdf 100644 --- a/wai/wai-quantum/Cargo.toml +++ b/wai/wai-quantum/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wai-quantum" -version = "0.4.2" +version = "0.4.3" edition = "2024" rust-version = "1.97" license = "Apache-2.0" diff --git a/wai/wai-quantum/src/quantum_fabric.rs b/wai/wai-quantum/src/quantum_fabric.rs index 507a15814..df9e6a5db 100644 --- a/wai/wai-quantum/src/quantum_fabric.rs +++ b/wai/wai-quantum/src/quantum_fabric.rs @@ -24,10 +24,22 @@ //! circuits so that every dispatch shape a member uses is exercised. //! - **Placement** ([`place`]): of the detected members whose certified //! parity meets what the caller requires, the one that spends the fewest -//! joules per unit of work. +//! joules per unit of work. [`place_for`] places one run by the energy a +//! caller's model predicts for it, so that a small circuit can stay on the +//! CPU and a large one go to an accelerator. +//! - **Energy** ([`RunMeter`], [`EnergyCalibration`]): a run is unmetered +//! unless a caller brackets it with a meter, which this crate does not +//! ship, and a reading reaches a result only with a label the crate's +//! energy rules accept. A placement figure comes only from a metered +//! calibration and carries its class. //! - **Receipt** ([`FabricReceipt`]): a signed statement that a result was //! computed by a kernel on a member, binding the kernel's certificate, the //! joules, and how the joules were acquired. +//! - **Running** ([`Fabric`]): members with their runners. A circuit runs on +//! the placed member and, when that member is too small, out of range, out +//! of memory, out of time or failed, on the next in placement order; the +//! receipt names the member that produced the result, and the refusals are +//! listed beside it. //! //! Numerics decide how much parity a fabric can earn. Most accelerators carry //! no 64-bit float, and some no 64-bit integer. The gate kernel of the @@ -94,6 +106,32 @@ //! - **Receipts verify, refuse tampering, refuse an uncertified kernel, and //! refuse a joule figure without its acquisition class.** Moving bytes //! between the named fields changes what is signed. +//! - **A meter's reading is labelled or dropped.** A run carries a reading +//! only when the label check accepts it; a reading below the resolution +//! floor, without a figure, without its uncertainty, unattributed or failed +//! leaves the run unmetered. Every start of a meter is matched by one stop, +//! a refused run's reading is discarded, and a meter that cannot start is +//! never stopped. +//! - **A placement figure is a measurement or nothing.** A calibration rounds +//! up, carries the runs' class with the uncertainty the rounding adds, and +//! earns no figure if any run was unmetered or the runs' classes differ. +//! - **Figures go on both members or on neither.** With synthetic figures, +//! when both the GPU and the CPU reference carry figures the GPU is placed +//! only if its figure is lower. [`place`] ranks a known figure before none, +//! so there a figure on one member alone places it, which is why the rule +//! exists; [`place_for`] and [`Fabric`] hold the rule themselves, and read +//! figures on only some candidates as figures on none. Under synthetic +//! picojoule models with a fixed cost per run, [`place_for`] sends a +//! 3-qubit circuit to the CPU and a 24-qubit one to the GPU. +//! - **A receipt's member is the runner's.** [`Fabric`] refuses a member +//! paired with a runner that is another member, so a receipt cannot name a +//! member that did not produce the result. +//! - **The fabric falls back only where another member can help.** Members +//! that refuse for size, range, memory, time or a failed device hand the +//! circuit down the placement order; an invalid circuit or a defect stops +//! at once. The receipt names the member that produced the result, +//! verifies, and carries its meter's reading when the label check accepts +//! it. //! //! # Honest boundaries //! @@ -115,10 +153,16 @@ //! (feature `quantum_fabric_webgpu`) runs them on real hardware. //! - **Only the gate kernel is in lanes.** Sampling, probability weights and //! fidelities in the core use 128-bit sums and are not ported. +//! - **Energy is the caller's.** No meter ships in this crate; without one, +//! receipts carry zero joules and no class, and members carry no figure. +//! A meter's window is what the member states it to be, and attributing it +//! to one run is the meter's to judge. -use crate::quantum::{Circuit, FRAC, Gateset, MAX_QUBITS, ONE, QuantumError}; -use crate::quantum_energy::{EnergyClass, check_label}; +use crate::quantum::{Circuit, FRAC, Gateset, MAX_QUBITS, ONE, QuantumError, StateVector}; +use crate::quantum_energy::{EnergyClass, Uncertainty, check_label}; use crate::quantum_ops::content_hash; +use core::future::Future; +use core::pin::Pin; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde_json::{Map, Value, json}; @@ -358,7 +402,15 @@ pub struct KernelEntry { /// Which implementation: [`REFERENCE`], or the name of the port. pub implementation: String, pub certificate: Certificate, - /// Nanojoules per operation, and how that figure was acquired. + /// Nanojoules per operation (one pair of amplitudes under one gate, + /// [`pair_updates`]), rounded up, and how that figure was acquired. `None` + /// unless a metered calibration earned it ([`EnergyCalibration`], + /// [`detect_local_metered`], or an accelerator member's own metered + /// calibration); nothing in this crate estimates one. [`place`] ranks a + /// known figure before none, so figures go on the CPU reference and on the + /// members placement compares it with, or on none of them: there, a figure + /// on one member alone places that member whatever it costs. [`place_for`] + /// and [`Fabric`] read figures on only some candidates as figures on none. pub energy: Option<(u64, EnergyClass)>, } @@ -454,17 +506,313 @@ fn certified_for(member: &Member, entry: &KernelEntry, kernel: &str) -> bool { /// nanojoules per operation. One with a known figure comes before one /// without; ties fall to the member id, then to the reference implementation, /// then to the implementation's name, so placement is the same on every run. +/// +/// Because a known figure comes before none, energy figures go on every +/// member placement compares, or on none ([`KernelEntry::energy`]). pub fn place<'a>(members: &'a [Member], kernel: &str, required: &Parity) -> Option<(&'a Member, &'a KernelEntry)> { - let key = |m: &Member, k: &KernelEntry| (k.energy.is_none(), k.energy.as_ref().map_or(0, |e| e.0), m.id.clone(), k.implementation != REFERENCE, k.implementation.clone()); - let mut best: Option<(&Member, &KernelEntry)> = None; - for m in members.iter().filter(|m| m.presence == Presence::Detected) { - for k in m.kernels.iter().filter(|k| certified_for(m, k, kernel) && k.certificate.parity.meets(required)) { - if best.is_none_or(|(bm, bk)| key(m, k) < key(bm, bk)) { - best = Some((m, k)); + ranked(members, kernel, required).first().map(|&(i, j)| (&members[i], &members[i].kernels[j])) +} + +/// Placement for one run, by its predicted energy: of the candidates +/// [`place`] considers, the one whose `predict_pj` is least, ties falling as +/// they fall in [`place`]. `predict_pj` gives the picojoules this run would +/// cost on a member's entry, typically from the entry's figure, the run's +/// [`pair_updates`] and the member's own cost per run, so that a small circuit +/// can stay on the CPU while a large one goes to an accelerator. When any +/// candidate has no prediction, predictions are not used: a prediction for +/// some candidates says nothing about the others. The order is then +/// [`place`]'s, except that figures on only some of the candidates count as +/// figures on none, where [`place`] would put a figured candidate first +/// whatever it costs: the tie order alone decides (member id, then the +/// reference implementation, then the implementation's name). +pub fn place_for<'a>(members: &'a [Member], kernel: &str, required: &Parity, predict_pj: impl Fn(&Member, &KernelEntry) -> Option) -> Option<(&'a Member, &'a KernelEntry)> { + ranked_for(members, kernel, required, &predict_pj).first().map(|&(i, j)| (&members[i], &members[i].kernels[j])) +} + +/// Every candidate for `kernel` (a detected member, an entry whose +/// certificate is its own and whose parity meets `required`) as indices +/// `(member, entry)`, in [`place`]'s order: the first is what it places, and +/// equal keys keep the order of `members`. +fn ranked(members: &[Member], kernel: &str, required: &Parity) -> Vec<(usize, usize)> { + let mut order: Vec<(usize, usize)> = Vec::new(); + for (i, m) in members.iter().enumerate().filter(|(_, m)| m.presence == Presence::Detected) { + for (j, k) in m.kernels.iter().enumerate() { + if certified_for(m, k, kernel) && k.certificate.parity.meets(required) { + order.push((i, j)); } } } - best + let key = |&(i, j): &(usize, usize)| { + let (m, k) = (&members[i], &members[i].kernels[j]); + (k.energy.is_none(), k.energy.as_ref().map_or(0, |e| e.0), m.id.as_str(), k.implementation != REFERENCE, k.implementation.as_str()) + }; + order.sort_by(|a, b| key(a).cmp(&key(b))); + order +} + +/// [`ranked`], with figures on only some of the candidates counted as figures +/// on none: the candidates are then in the tie order alone. Where every +/// candidate has a figure, or none has, this is [`ranked`]. +fn ranked_evenly(members: &[Member], kernel: &str, required: &Parity) -> Vec<(usize, usize)> { + let mut order = ranked(members, kernel, required); + let figured = order.iter().filter(|&&(i, j)| members[i].kernels[j].energy.is_some()).count(); + if figured != 0 && figured != order.len() { + let key = |&(i, j): &(usize, usize)| { + let (m, k) = (&members[i], &members[i].kernels[j]); + (m.id.as_str(), k.implementation != REFERENCE, k.implementation.as_str()) + }; + order.sort_by(|a, b| key(a).cmp(&key(b))); + } + order +} + +/// [`ranked_evenly`], reordered by `predict_pj` when every candidate has a +/// prediction; equal predictions keep its order. +fn ranked_for(members: &[Member], kernel: &str, required: &Parity, predict_pj: &dyn Fn(&Member, &KernelEntry) -> Option) -> Vec<(usize, usize)> { + let order = ranked_evenly(members, kernel, required); + let predicted: Option> = order.iter().map(|&(i, j)| predict_pj(&members[i], &members[i].kernels[j])).collect(); + match predicted { + Some(pj) => { + let mut by_energy: Vec<(u64, (usize, usize))> = pj.into_iter().zip(order).collect(); + by_energy.sort_by_key(|(pj, _)| *pj); + by_energy.into_iter().map(|(_, at)| at).collect() + } + None => order, + } +} + +/// The operations a run of `c` performs, the unit energy figures are given +/// in: one pair of amplitudes under one gate, so `2^(n−1)` per gate on `n` +/// qubits, controlled or not. Saturates at `u64::MAX`. +pub fn pair_updates(c: &Circuit) -> u64 { + if c.n_qubits == 0 { + return 0; + } + let per_gate = 1u64.checked_shl(u32::from(c.n_qubits) - 1).unwrap_or(u64::MAX); + (c.ops.len() as u64).saturating_mul(per_gate) +} + +// --------------------------------------------------------------------------- +// Energy +// --------------------------------------------------------------------------- + +/// A source of the energy one run spends, bracketing the run: `start` just +/// before its first submission of work, `stop` after its last readback. What +/// the window holds is the member's to state (an accelerator member's runs +/// from its first submission to its last readback, so it holds the device's +/// work and the host's waits and copies between them). This crate ships no +/// implementation: a meter belongs +/// with the energy source it reads, and without one every run is unmetered. +pub trait RunMeter { + /// Just before the first submission. + fn start(&mut self) -> Result<(), String>; + + /// After the last readback: the microjoules in the window and how they + /// were acquired, or `None` when the window cannot be attributed to this + /// run alone. + fn stop(&mut self) -> Result, String>; +} + +impl RunMeter for &mut M { + fn start(&mut self) -> Result<(), String> { + (**self).start() + } + + fn stop(&mut self) -> Result, String> { + (**self).stop() + } +} + +/// The meter of an unmetered run: it reads nothing. +pub(crate) struct Unmetered; + +impl RunMeter for Unmetered { + fn start(&mut self) -> Result<(), String> { + Ok(()) + } + + fn stop(&mut self) -> Result, String> { + Ok(None) + } +} + +/// A meter's reading as a run's result carries it: the figure and its class +/// when [`check_label`] accepts them, otherwise zero and no class. A meter +/// that failed, or could not attribute its window to the run, leaves the run +/// unmetered; nothing is estimated in its place. +fn accepted(reading: Result, String>) -> (u64, Option) { + match reading { + Ok(Some((joules_micro, class))) if check_label(joules_micro, &class).is_ok() => (joules_micro, Some(class)), + _ => (0, None), + } +} + +/// One run's metering window. [`Window::open`] starts the meter once, just +/// before the run's first submission; [`Window::close`] stops it after the +/// last readback and returns the accepted reading. A run that ends early (a +/// refusal, an error) drops the window, which stops the meter and discards +/// its reading, so every start is matched by one stop. A meter that fails to +/// start is never stopped, and the run is unmetered. +pub(crate) struct Window<'m, M: RunMeter + ?Sized> { + meter: &'m mut M, + tried: bool, + open: bool, +} + +impl<'m, M: RunMeter + ?Sized> Window<'m, M> { + pub(crate) fn new(meter: &'m mut M) -> Self { + Window { meter, tried: false, open: false } + } + + /// Starts the meter, the first time only. + pub(crate) fn open(&mut self) { + if !self.tried { + self.tried = true; + self.open = self.meter.start().is_ok(); + } + } + + /// Stops the meter: the reading, or zero and no class for a window that + /// never opened or a reading [`check_label`] refuses. + pub(crate) fn close(mut self) -> (u64, Option) { + if !self.open { + return (0, None); + } + self.open = false; + accepted(self.meter.stop()) + } +} + +impl Drop for Window<'_, M> { + fn drop(&mut self) { + if self.open { + let _ = self.meter.stop(); + } + } +} + +/// A placement figure from metered runs ([`KernelEntry::energy`]): the +/// energy of each run as its meter read it, over the operations it performed +/// ([`pair_updates`]). The figure is the total energy over the total +/// operations, in nanojoules per operation rounded up, with the runs' class. +/// Every run must be metered, every reading accepted by [`check_label`], and +/// every class the same (one kind, and one counter filtering state or one +/// calibration reference); otherwise there is no figure, never an estimate. +/// +/// For a class with a declared uncertainty, the figure's is the largest +/// relative uncertainty among the runs (a sum's relative uncertainty is at +/// most its parts' largest, however they are correlated), plus the most the +/// rounding up to a whole nanojoule adds when the division is not exact, over +/// the runs' windows together. +#[derive(Clone, Debug, Default)] +pub struct EnergyCalibration { + joules_micro: u64, + ops: u64, + runs: u64, + class: Option, + spoiled: bool, +} + +impl EnergyCalibration { + pub fn new() -> EnergyCalibration { + EnergyCalibration::default() + } + + /// Folds in one run: the operations it performed, and its meter's + /// reading (zero and `None` for a run that was not metered, which leaves + /// the calibration without a figure). + pub fn add(&mut self, ops: u64, joules_micro: u64, class: Option<&EnergyClass>) { + if self.spoiled { + return; + } + let Some(class) = class.filter(|c| check_label(joules_micro, c).is_ok()) else { + self.spoiled = true; + return; + }; + let merged = match &self.class { + None => Some(class.clone()), + Some(seen) => one_class(seen, class), + }; + match (merged, self.joules_micro.checked_add(joules_micro), self.ops.checked_add(ops)) { + (Some(c), Some(j), Some(o)) => { + self.class = Some(c); + self.joules_micro = j; + self.ops = o; + self.runs += 1; + } + _ => self.spoiled = true, + } + } + + /// The runs folded in so far. + pub fn runs(&self) -> u64 { + self.runs + } + + /// Nanojoules per operation, rounded up, and their class; `None` unless + /// every run was metered with an accepted reading of one class and at + /// least one operation was performed, and `None` too when the uncertainty + /// with the rounding added no longer fits its field. + pub fn finish(&self) -> Option<(u64, EnergyClass)> { + if self.spoiled || self.ops == 0 { + return None; + } + let class = self.class.clone()?; + let nanojoules = u128::from(self.joules_micro) * 1000; + let ops = u128::from(self.ops); + let figure = u64::try_from(nanojoules.div_ceil(ops)).ok()?; + // The rounding adds less than one nanojoule: under 10^6 / figure ppm. + let rounding = if nanojoules % ops == 0 { 0 } else { 1_000_000u64.div_ceil(figure) }; + let widen = |u: Uncertainty| Some(Uncertainty { relative_ppm: u32::try_from(u64::from(u.relative_ppm) + rounding).ok()?, ..u }); + let class = match class { + EnergyClass::OnChipCounter { uncertainty, filtering } => EnergyClass::OnChipCounter { uncertainty: widen(uncertainty)?, filtering }, + EnergyClass::CalibratedInstrument { uncertainty, calibration_ref } => EnergyClass::CalibratedInstrument { uncertainty: widen(uncertainty)?, calibration_ref }, + other => other, + }; + Some((figure, class)) + } +} + +/// Two runs' classes as one, if they are one class: the same kind, and the +/// same counter filtering or calibration reference. The relative uncertainty +/// is the larger, the window the two together. +fn one_class(a: &EnergyClass, b: &EnergyClass) -> Option { + let wider = |u: &Uncertainty, v: &Uncertainty| Uncertainty { relative_ppm: u.relative_ppm.max(v.relative_ppm), window_us: u.window_us.saturating_add(v.window_us) }; + match (a, b) { + (EnergyClass::ModelBased, EnergyClass::ModelBased) => Some(EnergyClass::ModelBased), + (EnergyClass::Estimator, EnergyClass::Estimator) => Some(EnergyClass::Estimator), + (EnergyClass::OnChipCounter { uncertainty: u, filtering: f }, EnergyClass::OnChipCounter { uncertainty: v, filtering: g }) if f == g => { + Some(EnergyClass::OnChipCounter { uncertainty: wider(u, v), filtering: *f }) + } + (EnergyClass::CalibratedInstrument { uncertainty: u, calibration_ref: r }, EnergyClass::CalibratedInstrument { uncertainty: v, calibration_ref: s }) if r == s => { + Some(EnergyClass::CalibratedInstrument { uncertainty: wider(u, v), calibration_ref: r.clone() }) + } + _ => None, + } +} + +/// [`detect_local`], with each reference entry's energy figure from a +/// metered calibration: every circuit of `corpus` in the entry's gateset, run +/// by the reference from `|0…0⟩` inside its own window of `meter` +/// ([`EnergyCalibration`]). An entry whose calibration earns no figure keeps +/// none. Give the accelerator members placement compares this one with +/// figures from their own metered calibrations, or give figures to none. +pub fn detect_local_metered(corpus: Corpus, meter: &mut dyn RunMeter) -> Member { + let mut local = detect_local(); + for entry in &mut local.kernels { + let Some(gs) = Gateset::ALL.into_iter().find(|&gs| apply_kernel(gs) == entry.kernel) else { continue }; + let mut calibration = EnergyCalibration::new(); + for c in corpus.circuits(gs) { + let mut window = Window::new(&mut *meter); + window.open(); + let ran = std::hint::black_box(c.simulate_from(0)); + let (joules_micro, class) = window.close(); + let class = if ran.is_ok() { class } else { None }; + calibration.add(pair_updates(&c), joules_micro, class.as_ref()); + } + entry.energy = calibration.finish(); + } + local } // --------------------------------------------------------------------------- @@ -940,6 +1288,276 @@ impl FabricReceipt { } } +// --------------------------------------------------------------------------- +// Running on the fabric +// --------------------------------------------------------------------------- + +/// Why a member returned no result, as the fabric reads it. +#[derive(Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum MemberError { + /// More qubits than the member holds. + TooWide { n_qubits: u8, max: u8 }, + /// The reference's result leaves the member's number range at this op. + OutOfRange { gate: u32 }, + /// Out of memory on the member or its host. + Memory(String), + /// A wait on the member passed its time limit. + Timeout, + /// The member's device failed or was lost. + Device(String), + /// What the CPU reference refuses: no member runs the circuit. + Circuit(QuantumError), + /// Anything else: a defect, or an implementation the runner does not + /// have. Never a reason to try another member. + Other(String), +} + +impl MemberError { + /// Whether another member may still produce the result: this one is too + /// small for the circuit, out of range, out of memory, out of time or + /// failed. An invalid circuit or a defect is the same on every member. + pub fn falls_back(&self) -> bool { + matches!(self, MemberError::TooWide { .. } | MemberError::OutOfRange { .. } | MemberError::Memory(_) | MemberError::Timeout | MemberError::Device(_)) + } +} + +impl std::fmt::Display for MemberError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MemberError::TooWide { n_qubits, max } => write!(f, "{n_qubits} qubits; the member holds at most {max}"), + MemberError::OutOfRange { gate } => write!(f, "the result of op {gate} leaves the member's range"), + MemberError::Memory(e) => write!(f, "out of memory: {e}"), + MemberError::Timeout => write!(f, "a wait on the member passed its time limit"), + MemberError::Device(e) => write!(f, "device: {e}"), + MemberError::Circuit(e) => write!(f, "{e}"), + MemberError::Other(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for MemberError {} + +/// What a [`Runner`] returns: the state, and the microjoules and class its +/// meter read (zero and `None` when unmetered). +pub type RunFuture<'r> = Pin), MemberError>> + 'r>>; + +/// What runs circuits on one member, for [`Fabric`]. +pub trait Runner { + /// The id of the member this runner is. [`Fabric`] refuses a pairing with + /// a [`Member`] of another id, so that a receipt cannot name a member that + /// did not produce the result. + fn member_id(&self) -> String; + + /// `c` from the basis state `start` with `implementation`, inside a window + /// of `meter` ([`RunMeter`]): the state, equal to `c.simulate_from(start)`, + /// and the reading, zero and `None` unless [`check_label`] accepts it. An + /// implementation the runner does not have is [`MemberError::Other`]. + fn run<'r>(&'r self, implementation: &'r str, c: &'r Circuit, start: usize, meter: &'r mut dyn RunMeter) -> RunFuture<'r>; +} + +/// The CPU reference as a [`Runner`]: [`Circuit::simulate_from`], for the +/// [`REFERENCE`] implementation of [`detect_local`]'s member. Its window is +/// the call. +#[derive(Clone, Copy, Debug, Default)] +pub struct CpuReference; + +impl Runner for &R { + fn member_id(&self) -> String { + (**self).member_id() + } + + fn run<'r>(&'r self, implementation: &'r str, c: &'r Circuit, start: usize, meter: &'r mut dyn RunMeter) -> RunFuture<'r> { + (**self).run(implementation, c, start, meter) + } +} + +impl Runner for CpuReference { + fn member_id(&self) -> String { + detect_local().id + } + + fn run<'r>(&'r self, implementation: &'r str, c: &'r Circuit, start: usize, meter: &'r mut dyn RunMeter) -> RunFuture<'r> { + Box::pin(async move { + if implementation != REFERENCE { + return Err(MemberError::Other(format!("the CPU reference does not run implementation {implementation:?}"))); + } + let mut window = Window::new(meter); + window.open(); + let state = c.simulate_from(start).map_err(MemberError::Circuit)?; + let (joules_micro, class) = window.close(); + Ok((state, joules_micro, class)) + }) + } +} + +/// A member that did not produce the result, and why. +#[derive(Debug, PartialEq, Eq)] +pub struct Refused { + pub member_id: String, + pub implementation: String, + pub error: MemberError, +} + +/// A result from [`Fabric::run`]. +#[derive(Debug)] +pub struct FabricRun { + pub state: StateVector, + /// Sealed over the state's hash, naming the member and implementation + /// that produced it, with the joules its meter read. + pub receipt: FabricReceipt, + /// The members tried before it, in placement order, each with why it + /// refused. Beside the receipt, not inside it. + pub refusals: Vec, +} + +/// Why [`Fabric::run`] returned no result: every member tried, in placement +/// order, with why it refused. Empty when no member could be placed; it ends +/// with a refusal that does not fall back ([`MemberError::falls_back`]) when +/// the fabric stopped there. +#[derive(Debug, PartialEq, Eq)] +pub struct FabricError { + pub refusals: Vec, +} + +impl std::fmt::Display for FabricError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.refusals.is_empty() { + return write!(f, "no member offers a certified kernel that meets the requirement"); + } + write!(f, "no member produced the result:")?; + for r in &self.refusals { + write!(f, " {} ({}): {};", r.member_id, r.implementation, r.error)?; + } + Ok(()) + } +} + +impl std::error::Error for FabricError {} + +/// The output of a future that is ready on its first poll, and `None` when it +/// is not. Natively, every future a runner of this crate returns is ready at +/// once, so this drives [`Fabric::run`] without an executor; in a browser the +/// same futures are awaited. +pub fn ready(future: F) -> Option { + let mut future = core::pin::pin!(future); + match future.as_mut().poll(&mut core::task::Context::from_waker(core::task::Waker::noop())) { + core::task::Poll::Ready(output) => Some(output), + core::task::Poll::Pending => None, + } +} + +/// A model of what a run costs: picojoules for `c` on a member's entry, or +/// `None` where the model has no figure ([`place_for`]). +type Model<'a> = Box Option + 'a>; + +/// Members with their runners, and optionally their meters: runs a circuit on +/// the member placement chooses ([`place_for`]'s order: by a model's +/// predictions where it predicts every candidate, and otherwise as [`place`] +/// with figures on only some candidates counted as figures on none), and when +/// that member is too small, out of range, out of memory, out of time or +/// failed ([`MemberError::falls_back`]), on the next in placement order. The +/// receipt is sealed with the member that produced the result, and the +/// refusals before it are listed beside the receipt. An invalid circuit or a +/// defect stops the run at once. +/// +/// Each member is paired with a runner for the implementations its entries +/// name, and the runner must be that member ([`Runner::member_id`]): a run +/// that reaches a pairing whose ids differ is refused there as a defect, and +/// nothing is sealed. Natively, every future a runner of this crate returns +/// is already resolved on its first poll, so a run resolves in one poll too +/// ([`ready`]). +#[derive(Default)] +pub struct Fabric<'a> { + members: Vec, + runners: Vec<(Box, Option>)>, + model: Option>, +} + +impl<'a> Fabric<'a> { + pub fn new() -> Fabric<'a> { + Fabric::default() + } + + /// A member, run by `runner`, unmetered. + pub fn add(&mut self, member: Member, runner: impl Runner + 'a) -> &mut Self { + self.members.push(member); + self.runners.push((Box::new(runner), None)); + self + } + + /// A member, run by `runner`, each run inside a window of `meter`. + pub fn add_metered(&mut self, member: Member, runner: impl Runner + 'a, meter: impl RunMeter + 'a) -> &mut Self { + self.members.push(member); + self.runners.push((Box::new(runner), Some(Box::new(meter)))); + self + } + + /// Places each run by `model`'s predicted picojoules ([`place_for`]): + /// when it predicts every candidate, the least; otherwise as [`place`]. + pub fn set_model(&mut self, model: impl Fn(&Member, &KernelEntry, &Circuit) -> Option + 'a) -> &mut Self { + self.model = Some(Box::new(model)); + self + } + + /// The members, in the order they were added. + pub fn members(&self) -> &[Member] { + &self.members + } + + fn ranked(&self, c: &Circuit, required: &Parity) -> Vec<(usize, usize)> { + let kernel = apply_kernel(c.gateset); + match &self.model { + Some(model) => ranked_for(&self.members, kernel, required, &|m: &Member, k: &KernelEntry| model(m, k, c)), + None => ranked_evenly(&self.members, kernel, required), + } + } + + /// The order [`Self::run`] tries members in for `c`: the first is the + /// placed one. + pub fn order(&self, c: &Circuit, required: &Parity) -> Vec<(&Member, &KernelEntry)> { + self.ranked(c, required).into_iter().map(|(i, j)| (&self.members[i], &self.members[i].kernels[j])).collect() + } + + /// `c` from the basis state `start` on the placed member for its + /// gateset's kernel, falling back down the placement order, and a receipt + /// signed by `signer` that names the member that produced the state. + pub async fn run(&mut self, c: &Circuit, start: usize, required: &Parity, signer: &SigningKey, signer_id: &str) -> Result { + let mut refusals = Vec::new(); + for (i, j) in self.ranked(c, required) { + let member = &self.members[i]; + let entry = &member.kernels[j]; + let (runner, meter) = &mut self.runners[i]; + let runner_is = runner.member_id(); + if runner_is != member.id { + let error = MemberError::Other(format!("member {} is paired with a runner that is member {runner_is}", member.id)); + refusals.push(Refused { member_id: member.id.clone(), implementation: entry.implementation.clone(), error }); + break; + } + let mut unmetered = Unmetered; + let meter: &mut dyn RunMeter = match meter { + Some(m) => m.as_mut(), + None => &mut unmetered, + }; + match runner.run(&entry.implementation, c, start, meter).await { + Ok((state, joules_micro, class)) => { + let (joules_micro, class) = accepted(Ok(class.map(|cl| (joules_micro, cl)))); + let receipt = FabricReceipt::seal(signer, signer_id, state.statevector_hash(), member, entry, joules_micro, class); + return Ok(FabricRun { state, receipt, refusals }); + } + Err(error) => { + let stop = !error.falls_back(); + refusals.push(Refused { member_id: member.id.clone(), implementation: entry.implementation.clone(), error }); + if stop { + break; + } + } + } + } + Err(FabricError { refusals }) + } +} + // --------------------------------------------------------------------------- // The gate kernel in 32-bit lanes // --------------------------------------------------------------------------- @@ -2050,4 +2668,465 @@ mod tests { c.validate().unwrap(); assert_eq!(lanes::gate_words(&c.ops[0], Gateset::V1).ctrl, 1); } + + // ----------------------------------------------------------------------- + // Energy, placement for a run, and the fabric + // ----------------------------------------------------------------------- + + /// A future's output, which must be ready on its first poll. + fn now(f: F) -> F::Output { + ready(f).expect("pending after one poll") + } + + fn counter(relative_ppm: u32, window_us: u64) -> EnergyClass { + EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm, window_us }, filtering: CounterFiltering::Off } + } + + /// A meter for tests: what each end returns, and how often each was + /// called. + struct TestMeter { + start: Result<(), String>, + stop: Result, String>, + starts: u32, + stops: u32, + } + + impl TestMeter { + fn reads(stop: Result, String>) -> TestMeter { + TestMeter { start: Ok(()), stop, starts: 0, stops: 0 } + } + } + + impl RunMeter for TestMeter { + fn start(&mut self) -> Result<(), String> { + self.starts += 1; + self.start.clone() + } + + fn stop(&mut self) -> Result, String> { + self.stops += 1; + self.stop.clone() + } + } + + #[test] + fn a_meter_reading_is_labelled_or_dropped() { + let c = Circuit::qft(3); + let accepted = counter(50_000, 1_000); + let mut m = TestMeter::reads(Ok(Some((1234, accepted.clone())))); + let (state, joules, class) = now(CpuReference.run(REFERENCE, &c, 0, &mut m)).unwrap(); + assert_eq!(state, c.simulate().unwrap()); + assert_eq!((joules, class, m.starts, m.stops), (1234, Some(accepted.clone()), 1, 1)); + // Below the resolution floor, no figure, no uncertainty, no window, + // unattributed, failed: unmetered, and the meter stopped once. + for stop in [Ok(Some((1, counter(1_000, 1_000)))), Ok(Some((0, EnergyClass::ModelBased))), Ok(Some((1234, counter(0, 1_000)))), Ok(Some((1234, counter(50_000, 0)))), Ok(None), Err("busy".to_string())] { + let mut m = TestMeter::reads(stop.clone()); + let (state, joules, class) = now(CpuReference.run(REFERENCE, &c, 5, &mut m)).unwrap(); + assert_eq!(state, c.simulate_from(5).unwrap()); + assert_eq!((joules, class, m.starts, m.stops), (0, None, 1, 1), "{stop:?}"); + } + // A meter that cannot start is never stopped. + let mut m = TestMeter { start: Err("no counter".into()), ..TestMeter::reads(Ok(Some((1234, accepted.clone())))) }; + let (_, joules, class) = now(CpuReference.run(REFERENCE, &c, 0, &mut m)).unwrap(); + assert_eq!((joules, class, m.starts, m.stops), (0, None, 1, 0)); + // A refused run stops the meter it started and keeps no reading. + let mut m = TestMeter::reads(Ok(Some((1234, accepted.clone())))); + let bad = &refused_circuits()[3]; + assert_eq!(now(CpuReference.run(REFERENCE, bad, 0, &mut m)).unwrap_err(), MemberError::Circuit(bad.simulate_from(0).unwrap_err())); + assert_eq!((m.starts, m.stops), (1, 1)); + // Another implementation is not the reference's, and opens no window. + assert!(matches!(now(CpuReference.run("lanes32", &c, 0, &mut m)), Err(MemberError::Other(_)))); + assert_eq!((m.starts, m.stops), (1, 1)); + } + + #[test] + fn a_placement_figure_is_a_measurement_or_nothing() { + // 30 µJ over 4000 operations: 7.5 nJ, rounded up to 8. The class keeps + // the larger uncertainty, adds the rounding's 10^6 / 8 ppm, and holds + // over both windows. + let mut cal = EnergyCalibration::new(); + cal.add(3_000, 10, Some(&counter(50_000, 400))); + cal.add(1_000, 20, Some(&counter(100_000, 600))); + assert_eq!(cal.runs(), 2); + assert_eq!(cal.finish(), Some((8, counter(225_000, 1_000)))); + // An exact division adds nothing for rounding. + let mut exact = EnergyCalibration::new(); + exact.add(1_000, 10, Some(&counter(50_000, 5))); + assert_eq!(exact.finish(), Some((10, counter(50_000, 5)))); + // A modelled figure stays modelled. + let mut modelled = EnergyCalibration::new(); + modelled.add(10, 7, Some(&EnergyClass::ModelBased)); + assert_eq!(modelled.finish(), Some((700, EnergyClass::ModelBased))); + // One instrument, or none. + let instrument = |r: &str| EnergyClass::CalibratedInstrument { uncertainty: Uncertainty { relative_ppm: 10_000, window_us: 7 }, calibration_ref: r.into() }; + let mut one = EnergyCalibration::new(); + one.add(100, 50, Some(&instrument("cert-1"))); + one.add(100, 50, Some(&instrument("cert-1"))); + assert_eq!(one.finish(), Some((500, EnergyClass::CalibratedInstrument { uncertainty: Uncertainty { relative_ppm: 10_000, window_us: 14 }, calibration_ref: "cert-1".into() }))); + // No figure: an unmetered run among metered ones, a refused label, + // classes that differ, no run, no operation. + let spoiled = |runs: &[(u64, u64, Option)]| { + let mut cal = EnergyCalibration::new(); + for (ops, joules, class) in runs { + cal.add(*ops, *joules, class.as_ref()); + } + cal.finish() + }; + let good = (100, 10, Some(counter(50_000, 1))); + let on = EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm: 50_000, window_us: 1 }, filtering: CounterFiltering::On }; + for runs in [ + vec![good.clone(), (100, 0, None)], + vec![(100, 0, None), good.clone()], + vec![good.clone(), (100, 1, Some(counter(1_000, 1)))], + vec![good.clone(), (100, 10, Some(on))], + vec![good.clone(), (100, 10, Some(EnergyClass::ModelBased))], + vec![(100, 50, Some(instrument("cert-1"))), (100, 50, Some(instrument("cert-2")))], + vec![], + vec![(0, 10, Some(counter(50_000, 1)))], + ] { + assert_eq!(spoiled(&runs), None, "{runs:?}"); + } + // An uncertainty that no longer fits its field once the rounding is + // added earns no figure: 1 µJ over 3 operations is 334 nJ, rounded up, + // which adds 2995 ppm. One that still fits is stated in full. + let widened = |ppm: u32| { + let mut cal = EnergyCalibration::new(); + cal.add(3, 1, Some(&counter(ppm, 1))); + assert_eq!(cal.runs(), 1, "the reading itself is accepted"); + cal.finish() + }; + assert_eq!(widened(u32::MAX - 2995), Some((334, counter(u32::MAX, 1)))); + assert_eq!(widened(u32::MAX - 2994), None); + assert_eq!(widened(u32::MAX), None); + // The operations of a run: 2^(n−1) pairs per gate, controlled or not. + let mut c = Circuit::new(4); + c.ops = vec![Gate { base: BaseGate::H, controls: vec![], target: 0, param: 0 }, Gate { base: BaseGate::X, controls: vec![0, 1], target: 3, param: 0 }]; + assert_eq!(pair_updates(&c), 16); + assert_eq!(pair_updates(&Circuit::new(0)), 0); + c.n_qubits = 255; + assert_eq!(pair_updates(&c), u64::MAX); + } + + /// A synthetic accelerator member with a certificate of its own for each + /// gateset's kernel, and `nj` nanojoules per operation on each entry. + fn accelerator(id: &str, implementation: &str, nj: Option) -> Member { + let kernels = Gateset::ALL + .into_iter() + .map(|gs| { + let kernel = apply_kernel(gs); + let certificate = Certificate { kernel: kernel.into(), implementation: implementation.into(), member: id.into(), generator: FABRIC_DEFAULT.generator(gs), parity: Parity::BitExact, cases: 224, corpus: [7; 32] }; + KernelEntry { kernel: kernel.into(), implementation: implementation.into(), certificate, energy: nj.map(|e| (e, counter(50_000, 1_000))) } + }) + .collect(); + Member { id: id.into(), class: FabricClass::Gpu, presence: Presence::Detected, kernels, detail: String::new() } + } + + /// The CPU reference with `nj` nanojoules per operation on each entry. + fn local_with(nj: Option) -> Member { + let mut m = detect_local(); + m.kernels.iter_mut().for_each(|k| k.energy = nj.map(|e| (e, counter(50_000, 1_000)))); + m + } + + const GPU: &str = "local/gpu-test-0123456789ab"; + + #[test] + fn figures_go_on_both_members_or_neither() { + let cpu = detect_local(); + for gs in Gateset::ALL { + let kernel = apply_kernel(gs); + let placed = |members: &[Member]| place(members, kernel, &Parity::BitExact).map(|(m, _)| m.id.clone()).unwrap(); + // Neither carries a figure: the CPU reference. + assert_eq!(placed(&[local_with(None), accelerator(GPU, "port", None)]), cpu.id); + // Both do: the GPU only when its figure is lower. + assert_eq!(placed(&[local_with(Some(10)), accelerator(GPU, "port", Some(3))]), GPU); + assert_eq!(placed(&[local_with(Some(10)), accelerator(GPU, "port", Some(30))]), cpu.id); + assert_eq!(placed(&[local_with(Some(10)), accelerator(GPU, "port", Some(10))]), cpu.id); + // A figure on one member alone places it, whatever it costs: + // which is why figures go on both or on neither. + assert_eq!(placed(&[local_with(None), accelerator(GPU, "port", Some(u64::MAX))]), GPU); + assert_eq!(placed(&[local_with(Some(u64::MAX)), accelerator(GPU, "port", None)]), cpu.id); + // place_for and the fabric hold the rule themselves: figures on + // one member alone count as figures on none, so the tie order + // places the CPU reference, whichever member carries the figure. + for (on_cpu, on_gpu) in [(None, Some(1)), (None, Some(u64::MAX)), (Some(1), None), (Some(u64::MAX), None)] { + let ms = [local_with(on_cpu), accelerator(GPU, "port", on_gpu)]; + assert_eq!(place_for(&ms, kernel, &Parity::BitExact, |_, _| None).unwrap().0.id, cpu.id, "{on_cpu:?} {on_gpu:?}"); + let mut fabric = Fabric::new(); + fabric.add(ms[0].clone(), CpuReference).add(ms[1].clone(), Port(GPU)); + assert_eq!(fabric.order(&Circuit::with_gateset(3, gs), &Parity::BitExact)[0].0.id, cpu.id, "{on_cpu:?} {on_gpu:?}"); + } + // Figures on both, or on neither, are place's order there too. + for (on_cpu, on_gpu) in [(None, None), (Some(10), Some(3)), (Some(10), Some(30)), (Some(10), Some(10))] { + let ms = [local_with(on_cpu), accelerator(GPU, "port", on_gpu)]; + assert_eq!(place_for(&ms, kernel, &Parity::BitExact, |_, _| None).unwrap().0.id, placed(&ms), "{on_cpu:?} {on_gpu:?}"); + } + } + } + + /// Picojoules for `c`: the entry's figure per operation, and for a GPU a + /// fixed 20 µJ per run. + fn model(m: &Member, k: &KernelEntry, c: &Circuit) -> Option { + let (nj, _) = k.energy.as_ref()?; + let fixed = if m.class == FabricClass::Gpu { 20_000_000 } else { 0 }; + Some(nj.saturating_mul(1000).saturating_mul(pair_updates(c)).saturating_add(fixed)) + } + + #[test] + fn place_for_sends_small_runs_to_the_cpu_and_large_ones_to_the_gpu() { + let members = [local_with(Some(10)), accelerator(GPU, "port", Some(1))]; + let cpu = members[0].id.clone(); + for gs in Gateset::ALL { + let kernel = apply_kernel(gs); + let circuit = |n: u8| { + let mut c = Circuit::with_gateset(n, gs); + c.ops = (0..10).map(|k| Gate { base: BaseGate::H, controls: vec![], target: k % n, param: 0 }).collect(); + c + }; + let (small, large) = (circuit(3), circuit(24)); + // 3 qubits: 40 operations, 0.4 µJ on the CPU, 20.04 µJ on the GPU. + // 24 qubits: 10·2^23 operations, 839 mJ on the CPU, 83.9 mJ on the GPU. + assert_eq!((model(&members[0], &members[0].kernels[0], &small), model(&members[1], &members[1].kernels[0], &small)), (Some(400_000), Some(20_040_000))); + assert_eq!((model(&members[0], &members[0].kernels[0], &large), model(&members[1], &members[1].kernels[0], &large)), (Some(838_860_800_000), Some(83_906_080_000))); + let placed_for = |c: &Circuit| place_for(&members, kernel, &Parity::BitExact, |m, k| model(m, k, c)).map(|(m, _)| m.id.clone()).unwrap(); + assert_eq!(placed_for(&small), cpu); + assert_eq!(placed_for(&large), GPU); + // By figures alone, place sends both to the GPU. + assert_eq!(place(&members, kernel, &Parity::BitExact).unwrap().0.id, GPU); + // A missing prediction for any candidate: predictions are not + // used. With figures on both members that is place's choice, the + // GPU by its figure, even where the one prediction given would say + // otherwise; with a figure on the GPU alone the figure is not used + // either, and the tie order places the CPU reference, where place + // would place the GPU. + let unfigured = [local_with(None), accelerator(GPU, "port", Some(1))]; + for c in [&small, &large] { + for missing in [FabricClass::Gpu, FabricClass::Cpu] { + let unpredicted = |ms: &[Member]| place_for(ms, kernel, &Parity::BitExact, |m, k| if m.class == missing { None } else { model(m, k, c) }).map(|(m, k)| (m.id.clone(), k.implementation.clone())); + let by_place = |ms: &[Member]| place(ms, kernel, &Parity::BitExact).map(|(m, k)| (m.id.clone(), k.implementation.clone())); + assert_eq!(unpredicted(&members), by_place(&members), "{missing:?} unpredicted, n = {}", c.n_qubits); + assert_eq!(unpredicted(&members).unwrap().0, GPU); + assert_eq!(unpredicted(&unfigured).unwrap().0, cpu, "{missing:?} unpredicted, n = {}", c.n_qubits); + assert_eq!(by_place(&unfigured).unwrap().0, GPU); + } + } + // Equal predictions keep place's order: the CPU reference by id. + let bare = [local_with(None), accelerator(GPU, "port", None)]; + assert_eq!(place_for(&bare, kernel, &Parity::BitExact, |_, _| Some(5)).unwrap().0.id, cpu); + // Only placeable candidates are predicted: not a member that was + // not detected, nor an entry whose certificate is another member's, + // though each carries the lowest figure. + let mut undetected = accelerator("local/gpu-test-3333333333ab", "port", Some(1)); + undetected.presence = Presence::Declared; + let mut foreign = accelerator("local/gpu-test-4444444444ab", "port", Some(1)); + foreign.kernels.iter_mut().for_each(|k| k.certificate.member = GPU.into()); + let listed = [local_with(Some(10)), undetected, foreign]; + let chosen = place_for(&listed, kernel, &Parity::BitExact, |m, _| { + assert_eq!(m.id, cpu, "a candidate that cannot be placed was predicted"); + Some(1) + }); + assert_eq!(chosen.unwrap().0.id, cpu); + assert!(place_for(&[], kernel, &Parity::BitExact, |_, _| Some(1)).is_none()); + } + } + + /// A runner for the member of the given id that always refuses with the + /// error `make` builds. + struct Refuses(&'static str, fn() -> MemberError); + + impl Runner for Refuses { + fn member_id(&self) -> String { + self.0.into() + } + + fn run<'r>(&'r self, _: &'r str, _: &'r Circuit, _: usize, _: &'r mut dyn RunMeter) -> RunFuture<'r> { + let error = (self.1)(); + Box::pin(async move { Err(error) }) + } + } + + /// A runner for the member of the given id, an accelerator whose + /// implementation `"port"` produces the reference's state. + struct Port(&'static str); + + impl Runner for Port { + fn member_id(&self) -> String { + self.0.into() + } + + fn run<'r>(&'r self, implementation: &'r str, c: &'r Circuit, start: usize, meter: &'r mut dyn RunMeter) -> RunFuture<'r> { + Box::pin(async move { + assert_eq!(implementation, "port"); + let mut window = Window::new(meter); + window.open(); + let state = c.simulate_from(start).map_err(MemberError::Circuit)?; + let (joules, class) = window.close(); + Ok((state, joules, class)) + }) + } + } + + #[test] + fn the_fabric_falls_back_and_names_the_member_that_produced_the_result() { + let signer = SigningKey::from_bytes(&[7; 32]); + let cpu = local_with(Some(10)); + let c = Circuit::qft(4); + let native = c.simulate_from(3).unwrap(); + let falls_back: [fn() -> MemberError; 5] = [ + || MemberError::TooWide { n_qubits: 4, max: 3 }, + || MemberError::OutOfRange { gate: 2 }, + || MemberError::Memory("full".into()), + || MemberError::Timeout, + || MemberError::Device("lost".into()), + ]; + for refusal in falls_back { + assert!(refusal().falls_back()); + // The refusing member is placed first (1 nJ against 10), and the + // CPU reference produces the result. + let mut fabric = Fabric::new(); + fabric.add(cpu.clone(), CpuReference).add(accelerator(GPU, "port", Some(1)), Refuses(GPU, refusal)); + assert_eq!(fabric.order(&c, &Parity::BitExact).iter().map(|(m, _)| m.id.as_str()).collect::>(), [GPU, cpu.id.as_str()]); + let out = now(fabric.run(&c, 3, &Parity::BitExact, &signer, "fabric-test")).unwrap(); + assert_eq!(out.state, native); + assert_eq!((out.receipt.member_id.as_str(), out.receipt.implementation.as_str()), (cpu.id.as_str(), REFERENCE)); + assert_eq!(out.receipt.result_hash, native.statevector_hash()); + assert!(out.receipt.verify()); + assert!(out.receipt.certificate_matches(&Certificate::reference(apply_kernel(c.gateset), &cpu.id))); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (0, None)); + assert_eq!(out.refusals, [Refused { member_id: GPU.into(), implementation: "port".into(), error: refusal() }]); + } + // An invalid circuit, or a defect, stops at once: the CPU is not tried. + let stops: [fn() -> MemberError; 2] = [|| MemberError::Circuit(QuantumError::Invalid("bad".into())), || MemberError::Other("defect".into())]; + for refusal in stops { + assert!(!refusal().falls_back()); + let mut fabric = Fabric::new(); + fabric.add(cpu.clone(), CpuReference).add(accelerator(GPU, "port", Some(1)), Refuses(GPU, refusal)); + let e = now(fabric.run(&c, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap_err(); + assert_eq!(e.refusals, [Refused { member_id: GPU.into(), implementation: "port".into(), error: refusal() }]); + } + // A member that produces it after one that refuses: the receipt names + // the producer and binds its certificate. + let port = accelerator("local/gpu-test-1111111111ab", "port", Some(2)); + let mut fabric = Fabric::new(); + fabric.add(cpu.clone(), CpuReference).add(port.clone(), Port("local/gpu-test-1111111111ab")).add(accelerator(GPU, "port", Some(1)), Refuses(GPU, || MemberError::Timeout)); + let out = now(fabric.run(&c, 3, &Parity::BitExact, &signer, "fabric-test")).unwrap(); + assert_eq!((out.receipt.member_id.as_str(), out.receipt.implementation.as_str(), &out.state), (port.id.as_str(), "port", &native)); + assert!(out.receipt.verify()); + let entry = port.kernels.iter().find(|k| k.kernel == apply_kernel(c.gateset)).unwrap(); + assert!(out.receipt.certificate_matches(&entry.certificate)); + assert_eq!(out.refusals, [Refused { member_id: GPU.into(), implementation: "port".into(), error: MemberError::Timeout }]); + // Every member refuses: each, in order. + let mut fabric = Fabric::new(); + fabric.add(accelerator(GPU, "port", None), Refuses(GPU, || MemberError::Device("lost".into()))).add(accelerator("local/gpu-test-2222222222ab", "port", None), Refuses("local/gpu-test-2222222222ab", || MemberError::TooWide { n_qubits: 4, max: 2 })); + let e = now(fabric.run(&c, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap_err(); + let tried: Vec<&str> = e.refusals.iter().map(|r| r.member_id.as_str()).collect(); + assert_eq!(tried, [GPU, "local/gpu-test-2222222222ab"]); + // A member paired with a runner that is another member: refused there + // as a defect, nothing sealed, and no other member tried, though the + // runner would have produced the state. The CPU reference paired with + // an accelerator's member is refused the same way. + let other = "local/gpu-test-1111111111ab"; + let mut fabric = Fabric::new(); + fabric.add(cpu.clone(), CpuReference).add(accelerator(GPU, "port", Some(1)), Port(other)); + let e = now(fabric.run(&c, 3, &Parity::BitExact, &signer, "fabric-test")).unwrap_err(); + assert_eq!(e.refusals.len(), 1); + assert_eq!((e.refusals[0].member_id.as_str(), e.refusals[0].implementation.as_str()), (GPU, "port")); + assert!(matches!(&e.refusals[0].error, MemberError::Other(why) if why.contains(GPU) && why.contains(other)), "{:?}", e.refusals[0].error); + assert!(!e.refusals[0].error.falls_back()); + let mut fabric = Fabric::new(); + fabric.add(accelerator(GPU, REFERENCE, None), CpuReference); + let e = now(fabric.run(&c, 3, &Parity::BitExact, &signer, "fabric-test")).unwrap_err(); + assert!(matches!(&e.refusals[..], [Refused { member_id, error: MemberError::Other(why), .. }] if member_id == GPU && why.contains(&cpu.id)), "{:?}", e.refusals); + // A borrowed runner is the runner it borrows. + let port = Port(GPU); + let mut fabric = Fabric::new(); + fabric.add(accelerator(GPU, "port", None), &port); + assert_eq!(now(fabric.run(&c, 3, &Parity::BitExact, &signer, "fabric-test")).unwrap().receipt.member_id, GPU); + // No member at all; an invalid circuit on the CPU reference. + assert_eq!(now(Fabric::new().run(&c, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap_err(), FabricError { refusals: vec![] }); + let mut fabric = Fabric::new(); + fabric.add(detect_local(), CpuReference); + let bad = &refused_circuits()[1]; + let e = now(fabric.run(bad, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap_err(); + assert_eq!(e.refusals.len(), 1); + assert_eq!(e.refusals[0].error, MemberError::Circuit(bad.simulate_from(0).unwrap_err())); + } + + #[test] + fn a_metered_fabric_run_seals_its_reading() { + let signer = SigningKey::from_bytes(&[7; 32]); + let c = Circuit::qft(5); + let reading = counter(50_000, 1_000); + // The CPU reference behind a meter: the receipt carries the reading + // with its label, and verifies, through JSON too. + let mut fabric = Fabric::new(); + fabric.add_metered(detect_local(), CpuReference, TestMeter::reads(Ok(Some((1234, reading.clone()))))); + let out = now(fabric.run(&c, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap(); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (1234, Some(&reading))); + assert!(out.receipt.verify()); + assert!(FabricReceipt::from_json(&out.receipt.to_json()).unwrap().verify()); + // A reading the label check refuses is dropped: an unmetered receipt. + let mut fabric = Fabric::new(); + fabric.add_metered(detect_local(), CpuReference, TestMeter::reads(Ok(Some((1, counter(1_000, 1_000)))))); + let out = now(fabric.run(&c, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap(); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (0, None)); + assert!(out.receipt.verify()); + // A runner that hands back a reading as it is given: without its + // label, with a label the check refuses (below the resolution floor), + // or with a label and no figure. The fabric seals none of them, and + // seals one the check accepts. + struct Hands(u64, Option); + impl Runner for Hands { + fn member_id(&self) -> String { + detect_local().id + } + + fn run<'r>(&'r self, _: &'r str, c: &'r Circuit, start: usize, _: &'r mut dyn RunMeter) -> RunFuture<'r> { + Box::pin(async move { Ok((c.simulate_from(start).map_err(MemberError::Circuit)?, self.0, self.1.clone())) }) + } + } + for (joules, class) in [(1234, None), (1, Some(counter(1_000, 1_000))), (0, Some(reading.clone()))] { + assert!(class.as_ref().is_none_or(|cl| check_label(joules, cl).is_err())); + let mut fabric = Fabric::new(); + fabric.add(detect_local(), Hands(joules, class)); + let out = now(fabric.run(&c, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap(); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (0, None), "{joules}"); + assert!(out.receipt.verify()); + } + let mut fabric = Fabric::new(); + fabric.add(detect_local(), Hands(1234, Some(reading.clone()))); + let out = now(fabric.run(&c, 0, &Parity::BitExact, &signer, "fabric-test")).unwrap(); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (1234, Some(&reading))); + assert!(out.receipt.verify()); + // Under a size-aware model the fabric's order is place_for's. + let mut fabric = Fabric::new(); + fabric.add(local_with(Some(10)), CpuReference).add(accelerator(GPU, "port", Some(1)), Port(GPU)).set_model(model); + let first = |fabric: &Fabric, n: u8| fabric.order(&Circuit::qft(n), &Parity::BitExact)[0].0.id.clone(); + assert_eq!(first(&fabric, 3), detect_local().id); + assert_eq!(first(&fabric, 20), GPU); + } + + #[test] + fn a_calibration_gives_the_cpu_reference_its_figure() { + let corpus = Corpus::Random { seed: 7, cases: 20 }; + let reading = counter(50_000, 1_000); + let mut m = TestMeter::reads(Ok(Some((1000, reading.clone())))); + let local = detect_local_metered(corpus, &mut m); + assert_eq!((m.starts, m.stops), (40, 40)); + for (entry, gs) in local.kernels.iter().zip(Gateset::ALL) { + assert_eq!(entry.kernel, apply_kernel(gs)); + let ops: u64 = corpus.circuits(gs).iter().map(pair_updates).sum(); + let mut expect = EnergyCalibration::new(); + for c in corpus.circuits(gs) { + expect.add(pair_updates(&c), 1000, Some(&reading)); + } + assert_eq!(entry.energy, expect.finish()); + assert!(entry.energy.is_some()); + let (nj, _) = entry.energy.clone().unwrap(); + assert_eq!(u128::from(nj), (20u128 * 1000 * 1000).div_ceil(u128::from(ops))); + } + // Unmetered: no figure. + let mut none = TestMeter::reads(Ok(None)); + assert!(detect_local_metered(corpus, &mut none).kernels.iter().all(|k| k.energy.is_none())); + } } diff --git a/wai/wai-quantum/src/quantum_fabric_webgpu.rs b/wai/wai-quantum/src/quantum_fabric_webgpu.rs index 54bf85cf4..e1246b334 100644 --- a/wai/wai-quantum/src/quantum_fabric_webgpu.rs +++ b/wai/wai-quantum/src/quantum_fabric_webgpu.rs @@ -39,9 +39,24 @@ //! - **Placement and receipts** go through //! [`place`](crate::quantum_fabric::place) and //! [`FabricReceipt`](crate::quantum_fabric::FabricReceipt) unchanged. A member -//! carries no energy figure, so beside the CPU reference, whose id sorts -//! first, the reference is placed; a caller who wants the GPU places over the -//! GPU member alone. +//! carries no energy figure unless a metered calibration earned one +//! ([`WebGpuMember::member_metered`]), so beside the CPU reference, whose id +//! sorts first, the reference is placed; a caller who wants the GPU places +//! over the GPU member alone, or gives both members figures from metered +//! calibrations +//! ([`detect_local_metered`](crate::quantum_fabric::detect_local_metered)) +//! and places by them, or by a model of each run +//! ([`place_for`](crate::quantum_fabric::place_for)). +//! - **Metering** ([`WebGpuMember::simulate_metered`]): a caller's +//! [`RunMeter`] brackets a run from its first submission to its last +//! readback, so the window covers the device's work; planning, buffer +//! creation, the upload's staging and the conversion to a state vector are +//! outside it. The reading comes back only when the crate's label check +//! accepts it. +//! - **On the fabric** ([`Fabric`](crate::quantum_fabric::Fabric)): a member +//! is a [`Runner`] for its own implementation, and its refusals for size, +//! range, memory, time and the device hand a circuit down the placement +//! order. //! //! Every runtime entry point is an `async fn`, and the crate brings no //! executor. Natively, every future the GPU interface returns is already @@ -69,14 +84,19 @@ //! - **A software adapter is class `Cpu`.** Its id begins `local/swgpu-`, which //! sorts after every `local/cpu-`, so it never displaces the CPU //! reference by its name. -//! - **Unmetered.** Receipts over a member's runs carry no joules and no -//! acquisition class. +//! - **Unmetered by default.** Without a meter, receipts over a member's runs +//! carry no joules and no acquisition class, and the member no energy +//! figure. No meter ships in this crate; one belongs with the energy source +//! it reads, and attributing a window to one run (the process being the +//! device's only client, for a per-process counter) is the meter's to +//! judge. use crate::quantum::{Amp, Circuit, Gateset, MAX_QUBITS, ONE, QuantumError, StateVector}; +use crate::quantum_energy::EnergyClass; use crate::quantum_fabric::lanes::{self, GateWords, Lane}; use crate::quantum_fabric::plan::{self, Config, LaneRun, PlannedGate, Program, Step}; use crate::quantum_fabric::wgsl::{self, AdapterIdentity}; -use crate::quantum_fabric::{Certificate, Certification, Corpus, FabricClass, KernelEntry, Member, Presence, apply_kernel}; +use crate::quantum_fabric::{Certificate, Certification, Corpus, EnergyCalibration, FabricClass, KernelEntry, Member, MemberError, Presence, RunFuture, RunMeter, Runner, Unmetered, Window, apply_kernel, pair_updates}; use core::future::Future; use core::pin::Pin; use core::task::{Context, Poll, Waker}; @@ -366,7 +386,7 @@ async fn open(adapter: wgpu::Adapter) -> Result { identity.max_buffer = granted.max_buffer_size; let max_qubits = qubits_for(identity.max_storage_binding, identity.max_buffer); let detail = format!( - "{backend}; adapter {:?}; {}; driver {:?} {:?}; vendor {:#x}, device {:#x}; translator {}; storage binding {} bytes, buffer {} bytes; up to {max_qubits} qubits; probe passed in this process", + "{backend}; adapter {:?}; {}; driver {:?} {:?}; vendor {:#x}, device {:#x}; translator {}; storage binding {} bytes, buffer {} bytes; up to {max_qubits} qubits; a metered run's window runs from its first submission to its last readback (the device's work, and the host's waits and copies between them); probe passed in this process", identity.name, identity.device_type, identity.driver, identity.driver_info, identity.vendor, identity.device, identity.translator, identity.max_storage_binding, identity.max_buffer, ); let member = WebGpuMember { @@ -625,7 +645,8 @@ impl WebGpuMember { self.backend } - /// What discovery found: adapter, driver, translator, granted limits. Not + /// What discovery found: adapter, driver, translator, granted limits, and + /// what a metered run's window covers ([`Self::simulate_metered`]). Not /// signed. pub fn detail(&self) -> &str { &self.detail @@ -730,7 +751,7 @@ impl WebGpuMember { } let planned: Vec = gates.iter().enumerate().map(|(k, w)| PlannedGate::new(*w, u32::try_from(k).unwrap_or(u32::MAX))).collect(); let p = plan::schedule(n as u8, 0, planned, &self.config); - self.run(n as u8, Init::Lanes(state), &p.gates, &p.steps).await + Ok(self.run(n as u8, Init::Lanes(state), &p.gates, &p.steps, &mut Unmetered).await?.0) } /// A planned program ([`plan::compile`], [`plan::compile_with`], @@ -739,9 +760,15 @@ impl WebGpuMember { /// implementation ([`Self::tiled`]); a member without it, or a step it /// cannot lay out, is refused as [`WebGpuError::Internal`]. pub async fn run_program(&self, p: &Program) -> Result { + Ok(self.run_program_metered(p, &mut Unmetered).await?.0) + } + + /// [`Self::run_program`] inside a window of `meter`, with its accepted + /// reading (zero and `None` when nothing was submitted). + async fn run_program_metered(&self, p: &Program, meter: &mut M) -> Result<(LaneRun, (u64, Option)), WebGpuError> { let n = p.n_qubits; if n == 0 { - return Ok(LaneRun { amps: vec![[ONE as i32, 0]], out_of_range: None }); + return Ok((LaneRun { amps: vec![[ONE as i32, 0]], out_of_range: None }, (0, None))); } if n > self.max_qubits { return Err(WebGpuError::TooWide { n_qubits: n, max: self.max_qubits }); @@ -749,7 +776,7 @@ impl WebGpuMember { if n > MAX_QUBITS || p.start >= 1usize << n { return Err(WebGpuError::Internal(2)); } - self.run(n, Init::Basis(p.start), &p.gates, &p.steps).await + self.run(n, Init::Basis(p.start), &p.gates, &p.steps, meter).await } /// Equal to `c.simulate_from(start)`, or a typed refusal: what the CPU @@ -757,18 +784,35 @@ impl WebGpuMember { /// member holds, or a result that leaves 32 bits. Planned with /// [`Self::config`]. pub async fn simulate_from(&self, c: &Circuit, start: usize) -> Result { + Ok(self.simulate_with(c, start, &mut Unmetered).await?.0) + } + + /// [`Self::simulate_from`] inside a window of `meter`: the state, and the + /// microjoules and class the meter read, which are zero and `None` unless + /// the crate's label check accepts them (`quantum_energy::check_label`). + /// The window opens just before the run's first submission and closes + /// after its last readback, so it covers the device's work and the host's + /// waits on it; planning, buffer creation, the upload's staging and the + /// conversion to a state vector are outside it. A circuit refused before + /// any submission opens no window; a run refused after it opened stops + /// the meter and discards the reading. + pub async fn simulate_metered(&self, c: &Circuit, start: usize, meter: &mut dyn RunMeter) -> Result<(StateVector, u64, Option), WebGpuError> { + self.simulate_with(c, start, meter).await + } + + async fn simulate_with(&self, c: &Circuit, start: usize, meter: &mut M) -> Result<(StateVector, u64, Option), WebGpuError> { let p = plan::compile_with(c, start, &self.config).map_err(WebGpuError::Circuit)?; if p.n_qubits > self.max_qubits { return Err(WebGpuError::TooWide { n_qubits: p.n_qubits, max: self.max_qubits }); } - let run = self.run_program(&p).await?; + let (run, (joules_micro, class)) = self.run_program_metered(&p, meter).await?; if let Some(gate) = run.out_of_range { return Err(WebGpuError::OutOfRange { gate }); } let mut amps = Vec::new(); amps.try_reserve_exact(run.amps.len()).map_err(|e| WebGpuError::Memory(e.to_string()))?; amps.extend(run.amps.iter().map(|l| Amp { re: i64::from(l[0]), im: i64::from(l[1]) })); - Ok(StateVector { n_qubits: p.n_qubits, amps }) + Ok((StateVector { n_qubits: p.n_qubits, amps }, joules_micro, class)) } /// [`Self::simulate_from`] the all-zeros state. @@ -782,30 +826,61 @@ impl WebGpuMember { /// range report inside a corpus is an error: its circuits are valid and /// within the horizon. pub async fn certify(&self, gs: Gateset, corpus: Corpus) -> Result { + Ok(self.certify_metered(gs, corpus, &mut Unmetered).await?.0) + } + + /// [`Self::certify`], each run inside its own window of `meter`, and the + /// figure those readings earn ([`EnergyCalibration`]). + async fn certify_metered(&self, gs: Gateset, corpus: Corpus, meter: &mut M) -> Result<(Certificate, Option<(u64, EnergyClass)>), WebGpuError> { let mut cert = Certification::new(); + let mut calibration = EnergyCalibration::new(); for c in corpus.circuits(gs) { let reference = lanes::reference_output(&c).map_err(WebGpuError::Circuit)?; - let run = self.run_program(&plan::compile_with(&c, 0, &self.config).map_err(WebGpuError::Circuit)?).await?; + let (run, (joules_micro, class)) = self.run_program_metered(&plan::compile_with(&c, 0, &self.config).map_err(WebGpuError::Circuit)?, &mut *meter).await?; if let Some(gate) = run.out_of_range { return Err(WebGpuError::OutOfRange { gate }); } cert.add(&lanes::circuit_bytes(&c), &reference, &lanes::output(&run.amps)); + calibration.add(pair_updates(&c), joules_micro, class.as_ref()); } - Ok(cert.finish(apply_kernel(gs), &self.implementation, &self.id, &corpus.generator(gs))) + Ok((cert.finish(apply_kernel(gs), &self.implementation, &self.id, &corpus.generator(gs)), calibration.finish())) } /// A fabric [`Member`]: present as [`Presence::Detected`], with one entry /// per gateset, each certified in this call on `corpus`, and no energy /// figure. pub async fn member(&self, corpus: Corpus) -> Result { + self.member_with(corpus, &mut Unmetered).await + } + + /// [`Self::member`], each corpus run inside its own window of `meter` + /// ([`Self::simulate_metered`]): every entry also carries the energy + /// figure its runs earn ([`EnergyCalibration`]: nanojoules per operation, + /// rounded up, with the readings' class), or none if any run went + /// unmetered. The certificates are the ones [`Self::member`] earns. Place + /// this member beside a CPU reference that carries figures of its own + /// ([`detect_local_metered`](crate::quantum_fabric::detect_local_metered)), + /// or give figures to neither. + pub async fn member_metered(&self, corpus: Corpus, meter: &mut dyn RunMeter) -> Result { + self.member_with(corpus, meter).await + } + + async fn member_with(&self, corpus: Corpus, meter: &mut M) -> Result { let mut kernels = Vec::new(); for gs in Gateset::ALL { - let certificate = self.certify(gs, corpus).await?; - kernels.push(KernelEntry { kernel: apply_kernel(gs).into(), implementation: self.implementation.clone(), certificate, energy: None }); + let (certificate, energy) = self.certify_metered(gs, corpus, &mut *meter).await?; + kernels.push(KernelEntry { kernel: apply_kernel(gs).into(), implementation: self.implementation.clone(), certificate, energy }); } Ok(Member { id: self.id.clone(), class: self.class, presence: Presence::Detected, kernels, detail: self.detail.clone() }) } + /// The same member holding at most `max_qubits` qubits (no more than it + /// holds), so that tests can make it refuse a circuit as too wide. + #[doc(hidden)] + pub fn with_max_qubits(&self, max_qubits: u8) -> WebGpuMember { + WebGpuMember { max_qubits: max_qubits.min(self.max_qubits), ..self.clone() } + } + /// The same member with another cap on workgroups along x, so that tests /// can force multi-row and ragged grids at small sizes. #[doc(hidden)] @@ -947,7 +1022,11 @@ impl WebGpuMember { /// without the tile kernel, a grid past the per-dimension limit) is /// refused as [`WebGpuError::Internal`] before anything runs; the kernels /// refuse the rest with report bit 4. - async fn run(&self, n: u8, init: Init<'_>, gates: &[PlannedGate], steps: &[Step]) -> Result { + /// + /// `meter`'s window opens just before the first submission and closes + /// after the last readback; the accepted reading comes back beside the + /// lanes. A run that ends early stops the meter and discards the reading. + async fn run(&self, n: u8, init: Init<'_>, gates: &[PlannedGate], steps: &[Step], meter: &mut M) -> Result<(LaneRun, (u64, Option)), WebGpuError> { self.alive()?; let len = 1usize << n; let plane = 4u64 << n; @@ -1061,6 +1140,7 @@ impl WebGpuMember { }; scopes.close().await?; upload?; + let mut window = Window::new(meter); // The steps, a chunk per submission, each waited on natively before // the next, so that POLL_TIMEOUT bounds one chunk's GPU time. A chunk @@ -1154,14 +1234,18 @@ impl WebGpuMember { } } } - let _index = self.queue.submit([encoder.finish()]); + let finished = encoder.finish(); + window.open(); + let _index = self.queue.submit([finished]); drop((gate_records, run_records, tile_records)); scopes.close().await?; #[cfg(not(target_arch = "wasm32"))] wait(&self.device, &self.sink, _index)?; } - // The report: a host defect ends the run here. + // The report: a host defect ends the run here. A program without + // steps submits nothing before it, so its window opens here. + window.open(); let mut words = [0u32; 2]; self.read(&report, 8, &report_staging, |_, b| { for (k, w) in b.chunks_exact(4).take(2).enumerate() { @@ -1186,9 +1270,47 @@ impl WebGpuMember { }) .await?; } + let reading = window.close(); self.settled()?; let out_of_range = (words[0] & 1 != 0).then_some(words[1]); - Ok(LaneRun { amps, out_of_range }) + Ok((LaneRun { amps, out_of_range }, reading)) + } +} + +/// How the fabric reads a member's refusal: size, range, memory, time and the +/// device fall back to the next member; the circuit's own refusal is the CPU +/// reference's; anything else is a defect. +impl From for MemberError { + fn from(e: WebGpuError) -> MemberError { + match e { + WebGpuError::TooWide { n_qubits, max } => MemberError::TooWide { n_qubits, max }, + WebGpuError::OutOfRange { gate } => MemberError::OutOfRange { gate }, + WebGpuError::Memory(why) => MemberError::Memory(why), + WebGpuError::Timeout => MemberError::Timeout, + WebGpuError::Device(why) => MemberError::Device(why), + WebGpuError::Circuit(e) => MemberError::Circuit(e), + other => MemberError::Other(other.to_string()), + } + } +} + +/// The member on the fabric ([`Fabric`](crate::quantum_fabric::Fabric)): its +/// own implementation, through [`WebGpuMember::simulate_metered`]. Pair it +/// with a [`Member`] whose entries name that implementation, such as +/// [`WebGpuMember::member`]'s; another implementation is +/// [`MemberError::Other`]. +impl Runner for WebGpuMember { + fn member_id(&self) -> String { + self.id.clone() + } + + fn run<'r>(&'r self, implementation: &'r str, c: &'r Circuit, start: usize, meter: &'r mut dyn RunMeter) -> RunFuture<'r> { + Box::pin(async move { + if implementation != self.implementation { + return Err(MemberError::Other(format!("member {} runs implementation {}, not {implementation:?}", self.id, self.implementation))); + } + Ok(self.simulate_with(c, start, meter).await?) + }) } } @@ -1197,7 +1319,8 @@ impl WebGpuMember { pub mod blocking { use super::{Discovery, WebGpuError, WebGpuMember}; use crate::quantum::{Circuit, Gateset, StateVector}; - use crate::quantum_fabric::{Certificate, Corpus, Member}; + use crate::quantum_energy::EnergyClass; + use crate::quantum_fabric::{Certificate, Corpus, Member, RunMeter}; use core::future::Future; use core::task::{Context, Poll, Waker}; @@ -1232,6 +1355,16 @@ pub mod blocking { pub fn member(m: &WebGpuMember, corpus: Corpus) -> Result { resolve(m.member(corpus))? } + + /// [`WebGpuMember::simulate_metered`]. + pub fn simulate_metered(m: &WebGpuMember, c: &Circuit, start: usize, meter: &mut dyn RunMeter) -> Result<(StateVector, u64, Option), WebGpuError> { + resolve(m.simulate_metered(c, start, meter))? + } + + /// [`WebGpuMember::member_metered`]. + pub fn member_metered(m: &WebGpuMember, corpus: Corpus, meter: &mut dyn RunMeter) -> Result { + resolve(m.member_metered(corpus, meter))? + } } #[cfg(all(test, not(target_arch = "wasm32")))] diff --git a/wai/wai-quantum/tests/webgpu_member.rs b/wai/wai-quantum/tests/webgpu_member.rs index 743dcb91f..dec145ffa 100644 --- a/wai/wai-quantum/tests/webgpu_member.rs +++ b/wai/wai-quantum/tests/webgpu_member.rs @@ -26,7 +26,10 @@ use wai_quantum::quantum::{BaseGate, Circuit, Gate, Gateset, MAX_QUBITS, Quantum use wai_quantum::quantum_fabric::lanes::{self, GateWords, Lane}; use wai_quantum::quantum_fabric::plan::{self, Config, PlannedGate, Program, Step}; use wai_quantum::quantum_fabric::wgsl::{self, Detector, MUTANTS, Mutant, TILE_MUTANTS}; -use wai_quantum::quantum_fabric::{Corpus, FABRIC_DEFAULT, FabricClass, FabricReceipt, Parity, Presence, REFERENCE, apply_kernel, detect_local, place}; +use wai_quantum::quantum_energy::{CounterFiltering, EnergyClass, Uncertainty}; +use wai_quantum::quantum_fabric::{ + Corpus, CpuReference, EnergyCalibration, FABRIC_DEFAULT, Fabric, FabricClass, FabricReceipt, Member, MemberError, Parity, Presence, REFERENCE, Refused, RunMeter, apply_kernel, detect_local, pair_updates, place, +}; use wai_quantum::quantum_fabric_webgpu::{Discovery, WebGpuError, WebGpuMember, blocking, probe_pairs}; /// The CPU's corpus digests: random-v1 (seed 7, 200 cases) and fabric-v1 @@ -801,3 +804,143 @@ fn g12_tiles_match_per_gate() { } } } + +/// A meter for these tests (the crate ships none): a fixed reading, and how +/// often each end was called. +struct FixedMeter { + reading: Option<(u64, EnergyClass)>, + starts: u32, + stops: u32, +} + +impl FixedMeter { + fn new(reading: Option<(u64, EnergyClass)>) -> FixedMeter { + FixedMeter { reading, starts: 0, stops: 0 } + } +} + +impl RunMeter for FixedMeter { + fn start(&mut self) -> Result<(), String> { + self.starts += 1; + Ok(()) + } + + fn stop(&mut self) -> Result, String> { + self.stops += 1; + Ok(self.reading.clone()) + } +} + +fn counter() -> EnergyClass { + EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm: 50_000, window_us: 1_000 }, filtering: CounterFiltering::Off } +} + +#[test] +fn g13_metered_runs_and_the_fabric() { + let signer = SigningKey::from_bytes(&[7; 32]); + let local = detect_local(); + for m in members() { + // A metered run: one window, and the reading only when its label is + // accepted. + let c = random_circuit(8, 60, 0x6d65_7465, Gateset::V2); + let native = c.simulate_from(3).unwrap(); + let mut meter = FixedMeter::new(Some((1234, counter()))); + let (state, joules, class) = blocking::simulate_metered(m, &c, 3, &mut meter).unwrap(); + assert_eq!(state, native, "{}", m.key()); + assert_eq!((joules, class, meter.starts, meter.stops), (1234, Some(counter()), 1, 1)); + // Under the resolution floor: unmetered, the meter stopped once. + let mut below = FixedMeter::new(Some((1, counter()))); + let (state, joules, class) = blocking::simulate_metered(m, &c, 3, &mut below).unwrap(); + assert_eq!((state == native, joules, class, below.starts, below.stops), (true, 0, None, 1, 1)); + // Nothing submitted, no window: refusals before the device, and the + // register of no qubits. + let mut idle = FixedMeter::new(Some((1234, counter()))); + assert!(matches!(blocking::simulate_metered(m, &refused_circuits()[0], 0, &mut idle), Err(WebGpuError::Circuit(_)))); + assert_eq!(blocking::simulate_metered(&m.with_max_qubits(3), &c, 0, &mut idle).unwrap_err(), WebGpuError::TooWide { n_qubits: 8, max: 3 }); + let (empty, joules, class) = blocking::simulate_metered(m, &Circuit::new(0), 0, &mut idle).unwrap(); + assert_eq!((empty, joules, class), (Circuit::new(0).simulate().unwrap(), 0, None)); + assert_eq!((idle.starts, idle.stops), (0, 0)); + + // A metered calibration: the certificates member() earns, and a figure + // from every run's reading. + let mut meter = FixedMeter::new(Some((1000, counter()))); + let metered = blocking::member_metered(m, RANDOM_V1, &mut meter).unwrap_or_else(|e| panic!("{}: {e}", m.key())); + assert_eq!((meter.starts, meter.stops), (400, 400)); + for ((entry, gs), (_, pin, _)) in metered.kernels.iter().zip(Gateset::ALL).zip(PINS) { + assert_eq!((entry.certificate.parity, hex(&entry.certificate.corpus)), (Parity::BitExact, pin.to_string()), "{} {gs:?}", m.key()); + let mut expect = EnergyCalibration::new(); + for c in RANDOM_V1.circuits(gs) { + expect.add(pair_updates(&c), 1000, Some(&counter())); + } + assert_eq!(entry.energy, expect.finish()); + assert!(entry.energy.as_ref().is_some_and(|(nj, class)| *nj > 0 && class.label() == "OnChipCounter")); + } + + // The fabric: the member first by synthetic figures (1 nJ per + // operation against the CPU reference's 10), holding three qubits. + let figured = |member: &Member, nj: u64| { + let mut member = member.clone(); + member.kernels.iter_mut().for_each(|k| k.energy = Some((nj, counter()))); + member + }; + let gpu = figured(&metered, 1); + let mut fabric = Fabric::new(); + fabric.add(figured(&local, 10), CpuReference).add_metered(gpu.clone(), m.with_max_qubits(3), FixedMeter::new(Some((1234, counter())))); + // Wider than it holds: it refuses, and the CPU reference produces the + // result. + let wide = random_circuit(6, 40, 0x7769_6465, Gateset::V1); + let out = blocking::resolve(fabric.run(&wide, 0, &Parity::BitExact, &signer, "local/test")).unwrap().unwrap_or_else(|e| panic!("{}: {e}", m.key())); + assert_eq!(out.state, wide.simulate().unwrap()); + assert_eq!((out.receipt.member_id.as_str(), out.receipt.implementation.as_str()), (local.id.as_str(), REFERENCE)); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (0, None)); + assert!(out.receipt.verify()); + assert_eq!(out.refusals, [Refused { member_id: m.id().into(), implementation: m.implementation().into(), error: MemberError::TooWide { n_qubits: 6, max: 3 } }]); + // Within it: the member produces the result, metered, and the receipt + // binds its certificate. + let narrow = random_circuit(3, 40, 0x6e61_7272, Gateset::V2); + let out = blocking::resolve(fabric.run(&narrow, 5, &Parity::BitExact, &signer, "local/test")).unwrap().unwrap_or_else(|e| panic!("{}: {e}", m.key())); + assert_eq!(out.state, narrow.simulate_from(5).unwrap()); + assert_eq!((out.receipt.member_id.as_str(), out.receipt.implementation.as_str()), (m.id(), m.implementation())); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (1234, Some(&counter()))); + assert!(out.receipt.verify()); + let entry = gpu.kernels.iter().find(|k| k.kernel == apply_kernel(Gateset::V2)).unwrap(); + assert!(out.receipt.certificate_matches(&entry.certificate)); + assert!(out.refusals.is_empty()); + + // The tile implementation on the same device: a metered run gives the + // native state inside one window, and on the fabric its receipt names + // the tile implementation and binds that implementation's certificate. + let tiled = run(m.tiled()); + assert_ne!(tiled.implementation(), m.implementation()); + let mut meter = FixedMeter::new(Some((1234, counter()))); + let (state, joules, class) = blocking::simulate_metered(&tiled, &c, 3, &mut meter).unwrap(); + assert_eq!(state, native, "{} tiled", m.key()); + assert_eq!((joules, class, meter.starts, meter.stops), (1234, Some(counter()), 1, 1)); + let tile_member = figured(&blocking::member(&tiled, RANDOM_V1).unwrap_or_else(|e| panic!("{}: {e}", m.key())), 1); + let mut fabric = Fabric::new(); + fabric.add(figured(&local, 10), CpuReference).add_metered(tile_member.clone(), tiled.clone(), FixedMeter::new(Some((1234, counter())))); + let out = blocking::resolve(fabric.run(&narrow, 5, &Parity::BitExact, &signer, "local/test")).unwrap().unwrap_or_else(|e| panic!("{}: {e}", m.key())); + assert_eq!(out.state, narrow.simulate_from(5).unwrap()); + assert_eq!((out.receipt.member_id.as_str(), out.receipt.implementation.as_str()), (tiled.id(), tiled.implementation())); + assert_eq!((out.receipt.joules_micro, out.receipt.energy_class.as_ref()), (1234, Some(&counter()))); + assert!(out.receipt.verify()); + let entry = tile_member.kernels.iter().find(|k| k.kernel == apply_kernel(Gateset::V2)).unwrap(); + assert!(out.receipt.certificate_matches(&entry.certificate)); + // The tile implementation's entries with the per-gate runner: the + // runner does not run that implementation, which stops the run, and + // nothing is sealed. + let mut fabric = Fabric::new(); + fabric.add(figured(&local, 10), CpuReference).add(tile_member.clone(), m.clone()); + let e = blocking::resolve(fabric.run(&narrow, 5, &Parity::BitExact, &signer, "local/test")).unwrap().unwrap_err(); + assert!(matches!(&e.refusals[..], [Refused { error: MemberError::Other(_), .. }]), "{} {:?}", m.key(), e.refusals); + // A member that is not the runner's: refused before anything runs. + let mut stranger = gpu.clone(); + stranger.id = "local/gpu-test-0123456789ab".into(); + stranger.kernels.iter_mut().for_each(|k| k.certificate.member = stranger.id.clone()); + let mut fabric = Fabric::new(); + fabric.add(figured(&local, 10), CpuReference).add(stranger.clone(), m.clone()); + let e = blocking::resolve(fabric.run(&narrow, 5, &Parity::BitExact, &signer, "local/test")).unwrap().unwrap_err(); + assert!(matches!(&e.refusals[..], [Refused { member_id, error: MemberError::Other(why), .. }] if *member_id == stranger.id && why.contains(m.id())), "{} {:?}", m.key(), e.refusals); + report(&format!("webgpu fabric: key={} ok", m.key())); + } +} diff --git a/wai/wai-rs/Cargo.lock b/wai/wai-rs/Cargo.lock index 369bd6d37..721e47a05 100644 --- a/wai/wai-rs/Cargo.lock +++ b/wai/wai-rs/Cargo.lock @@ -4459,7 +4459,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.4.2" +version = "0.4.3" dependencies = [ "blake3", "ed25519-dalek", diff --git a/wai/wai-web/Cargo.lock b/wai/wai-web/Cargo.lock index 39cd17aa5..9862ac0f3 100644 --- a/wai/wai-web/Cargo.lock +++ b/wai/wai-web/Cargo.lock @@ -1226,7 +1226,7 @@ dependencies = [ [[package]] name = "wai-quantum" -version = "0.4.2" +version = "0.4.3" dependencies = [ "blake3", "ed25519-dalek",