From 852cd42e807560e324033440ff1c79c6b1401d65 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 12 Oct 2026 05:08:11 -0400 Subject: [PATCH 1/3] wai-quantum fabric: run meters, metered calibration, place_for, and the Fabric wrapper Stage 6 of the WebGPU member plan (energy, and placement that can choose the GPU). - quantum_fabric: the member-neutral RunMeter trait (no meter ships in the crate); a reading reaches a result only when check_label accepts it, every start is matched by one stop, and a refused run's reading is discarded. EnergyCalibration turns metered runs into a KernelEntry figure (nJ per pair-update, rounded up, the runs' class with the rounding folded into its uncertainty), or no figure at all; detect_local_metered gives the CPU reference its figure. place_for places one run by a caller's picojoule model when every candidate has a prediction, else as place. Fabric runs a circuit on the placed member, falls back down the placement order on TooWide, OutOfRange, Memory, Timeout or Device, and seals the receipt with the member that produced the result, the refusals beside it. - quantum_fabric_webgpu: simulate_metered brackets a run from its first submission to its last readback (device work only; detail says so), member_metered certifies and calibrates in one pass, the member is a Runner for its own implementation, and WebGpuError maps onto MemberError. - Tests: CPU-side tests with synthetic meters, figures, models and refusing members; device test g13 (metered runs, a metered calibration, and the fabric falling back from a member held to three qubits); the member script expects 14 tests and a fabric line per declared adapter. Co-Authored-By: Claude Opus 5.5 --- scripts/wai-quantum-webgpu-member.sh | 15 +- wai/wai-quantum/src/quantum_fabric.rs | 933 ++++++++++++++++++- wai/wai-quantum/src/quantum_fabric_webgpu.rs | 173 +++- wai/wai-quantum/tests/webgpu_member.rs | 110 ++- 4 files changed, 1191 insertions(+), 40 deletions(-) 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/wai-quantum/src/quantum_fabric.rs b/wai/wai-quantum/src/quantum_fabric.rs index 507a15814..56a39ccc6 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,27 @@ //! - **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, +//! the GPU is placed only when both it and the CPU reference carry figures +//! and the GPU's is lower; a figure on one member alone places it, which is +//! why the rule exists. 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, and is [`place`] when any candidate lacks a prediction. +//! - **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 +148,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 +397,14 @@ 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: a figure on one + /// member alone places that member whatever it costs. pub energy: Option<(u64, EnergyClass)>, } @@ -454,17 +500,280 @@ 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 this is [`place`]: a prediction for some +/// candidates says nothing about the others. +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`], reordered by `predict_pj` when every candidate has a +/// prediction; equal predictions keep [`place`]'s order. +fn ranked_for(members: &[Member], kernel: &str, required: &Parity, predict_pj: &dyn Fn(&Member, &KernelEntry) -> Option) -> Vec<(usize, usize)> { + let order = ranked(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 covers +/// its device work only). 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>; +} + +/// 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. + 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| Uncertainty { relative_ppm: u32::try_from(u64::from(u.relative_ppm) + rounding).unwrap_or(u32::MAX), ..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 +1249,234 @@ 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 { + /// `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 CpuReference { + 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 {} + +/// 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`], or [`place_for`] under a model), +/// 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. Natively, every future a runner of this crate returns is already +/// resolved on its first poll, so a run resolves in one poll too. +#[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(&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 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 +2587,378 @@ 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 { + let mut f = core::pin::pin!(f); + match f.as_mut().poll(&mut core::task::Context::from_waker(core::task::Waker::noop())) { + core::task::Poll::Ready(v) => v, + core::task::Poll::Pending => panic!("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:?}"); + } + // 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); + } + } + + /// 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: place's choice. + let unfigured = [local_with(None), accelerator(GPU, "port", Some(1))]; + for ms in [&members[..], &unfigured[..]] { + for c in [&small, &large] { + let a = place_for(ms, kernel, &Parity::BitExact, |m, k| if m.class == FabricClass::Gpu { None } else { model(m, k, c) }); + let b = place(ms, kernel, &Parity::BitExact); + assert_eq!(a.map(|(m, k)| (&m.id, &k.implementation)), b.map(|(m, k)| (&m.id, &k.implementation))); + } + } + // 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. + assert!(place_for(&[], kernel, &Parity::BitExact, |_, _| Some(1)).is_none()); + } + } + + /// A runner that always refuses with the error `make` builds. + struct Refuses(fn() -> MemberError); + + impl Runner for Refuses { + fn run<'r>(&'r self, _: &'r str, _: &'r Circuit, _: usize, _: &'r mut dyn RunMeter) -> RunFuture<'r> { + let error = (self.0)(); + Box::pin(async move { Err(error) }) + } + } + + /// A runner for an accelerator's implementation `"port"` that produces the + /// reference's state. + struct Port; + + impl Runner for Port { + 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(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(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).add(accelerator(GPU, "port", Some(1)), Refuses(|| 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(|| MemberError::Device("lost".into()))).add(accelerator("local/gpu-test-2222222222ab", "port", None), Refuses(|| 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"]); + // 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 without its label: the fabric + // seals none. + struct Unlabelled; + impl Runner for Unlabelled { + 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)?, 1234, None)) }) + } + } + let mut fabric = Fabric::new(); + fabric.add(detect_local(), Unlabelled); + 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()); + // 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).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..7d021958d 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 covers device work only, from its first submission to its last readback; 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,43 @@ 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 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 +1315,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 +1351,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..64375b1ed 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,108 @@ 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()); + report(&format!("webgpu fabric: key={} ok", m.key())); + } +} -- 2.54.0 (Apple Git-157) From b3dae20921949aba96e31480ad5e7308d169ee7d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 12 Oct 2026 05:25:03 -0400 Subject: [PATCH 2/3] wai-quantum 0.4.3: metered runs, placement by run, and the fabric wrapper Additive release of Stage 6. The CHANGELOG gains a 0.4.3 section above 0.4.2; the version changes in Cargo.toml and in the version line of the six lockfiles that resolve wai-quantum. The quantum-wasip2 job requires each new fabric test by name. The place_for test now also drops the CPU's prediction, so a missing prediction read as zero would place the CPU where place places the GPU, and fail. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/wai-conformance.yml | 12 +++++- wai/CHANGELOG.md | 52 +++++++++++++++++++++++++ wai/tools/quantum-wgsl-check/Cargo.lock | 2 +- wai/wai-pack/Cargo.lock | 2 +- wai/wai-quantum-serve/Cargo.lock | 2 +- wai/wai-quantum/Cargo.lock | 2 +- wai/wai-quantum/Cargo.toml | 2 +- wai/wai-quantum/src/quantum_fabric.rs | 13 +++++-- wai/wai-rs/Cargo.lock | 2 +- wai/wai-web/Cargo.lock | 2 +- 10 files changed, 78 insertions(+), 13 deletions(-) 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/wai/CHANGELOG.md b/wai/CHANGELOG.md index 668b5ead3..d9325e9e2 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,58 @@ 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 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, 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 or a second class 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; the documentation says so and a test + with synthetic figures holds it. +- **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`, and is `place` otherwise. 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`. +- **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. 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 56a39ccc6..244edf9ae 100644 --- a/wai/wai-quantum/src/quantum_fabric.rs +++ b/wai/wai-quantum/src/quantum_fabric.rs @@ -2789,13 +2789,18 @@ mod tests { 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: place's choice. + // A missing prediction for any candidate: place's choice, which is + // the GPU by its figure even where the CPU's prediction alone, or + // the GPU's, would say otherwise. let unfigured = [local_with(None), accelerator(GPU, "port", Some(1))]; for ms in [&members[..], &unfigured[..]] { for c in [&small, &large] { - let a = place_for(ms, kernel, &Parity::BitExact, |m, k| if m.class == FabricClass::Gpu { None } else { model(m, k, c) }); - let b = place(ms, kernel, &Parity::BitExact); - assert_eq!(a.map(|(m, k)| (&m.id, &k.implementation)), b.map(|(m, k)| (&m.id, &k.implementation))); + for missing in [FabricClass::Gpu, FabricClass::Cpu] { + let a = place_for(ms, kernel, &Parity::BitExact, |m, k| if m.class == missing { None } else { model(m, k, c) }); + let b = place(ms, kernel, &Parity::BitExact); + assert_eq!(a.map(|(m, k)| (&m.id, &k.implementation)), b.map(|(m, k)| (&m.id, &k.implementation)), "{missing:?} unpredicted, n = {}", c.n_qubits); + assert_eq!(a.unwrap().0.id, GPU); + } } } // Equal predictions keep place's order: the CPU reference by id. 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", -- 2.54.0 (Apple Git-157) From 1665baee8f87c32573cbf7de6d408a2075956a8e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 12 Oct 2026 15:38:07 -0400 Subject: [PATCH 3/3] wai-quantum fabric: the review of the energy stage, fixed An independent review found no wrong figure, pairing or fallback in the arithmetic; it found four things the code did not hold. - A receipt's member was whatever Member the caller paired with a runner: nothing compared them, so a member paired with another adapter's runner gave a signed, verifying receipt for work it did not do. A Runner now states the member it is (member_id), and Fabric::run refuses a pairing whose ids differ as a defect, before anything runs. - "Figures on both members or on neither" was documented, not held: a calibration that earns no figure on one member (its readings under the resolution floor, say) left the other placed first whatever it cost. place is unchanged; place_for and Fabric now read figures on only some candidates as figures on none. - Tests: the assertion that only placeable candidates are predicted held for any implementation, and is now a closure that panics on an unplaceable one over a list that holds two; a runner that hands back a refused label, or a label with no figure, is sealed as unmetered; the device test runs the tile implementation metered and on the fabric. - The member's detail said a window "covers device work only"; it also holds the host's waits and copies between submissions, and says so. Also: a calibration whose uncertainty no longer fits its field once the rounding is added earns no figure rather than a capped one; ready() drives Fabric::run natively without an executor; &R is a Runner and &mut M a RunMeter. Co-Authored-By: Claude Opus 5.5 --- wai/CHANGELOG.md | 34 ++- wai/wai-quantum/src/quantum_fabric.rs | 287 +++++++++++++++---- wai/wai-quantum/src/quantum_fabric_webgpu.rs | 6 +- wai/wai-quantum/tests/webgpu_member.rs | 35 +++ 4 files changed, 288 insertions(+), 74 deletions(-) diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index d9325e9e2..bc5858dcf 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -20,9 +20,10 @@ unless a caller supplies a meter. 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 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, and the member's `detail` says so. A circuit refused + 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}`. @@ -31,15 +32,19 @@ unless a caller supplies a meter. 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 or a second class leaves no figure. + 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; the documentation says so and a test - with synthetic figures holds it. + 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`, and is `place` otherwise. 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. + 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 @@ -48,15 +53,22 @@ unless a caller supplies a meter. 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`. + `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. The member script requires it, and a fabric line per declared - adapter. + 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 diff --git a/wai/wai-quantum/src/quantum_fabric.rs b/wai/wai-quantum/src/quantum_fabric.rs index 244edf9ae..df9e6a5db 100644 --- a/wai/wai-quantum/src/quantum_fabric.rs +++ b/wai/wai-quantum/src/quantum_fabric.rs @@ -116,11 +116,16 @@ //! 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, -//! the GPU is placed only when both it and the CPU reference carry figures -//! and the GPU's is lower; a figure on one member alone places it, which is -//! why the rule exists. 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, and is [`place`] when any candidate lacks a prediction. +//! 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 @@ -403,8 +408,9 @@ pub struct KernelEntry { /// [`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: a figure on one - /// member alone places that member whatever it costs. + /// 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)>, } @@ -513,8 +519,12 @@ pub fn place<'a>(members: &'a [Member], kernel: &str, required: &Parity) -> Opti /// 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 this is [`place`]: a prediction for some -/// candidates says nothing about the others. +/// 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])) } @@ -540,10 +550,26 @@ fn ranked(members: &[Member], kernel: &str, required: &Parity) -> Vec<(usize, us order } -/// [`ranked`], reordered by `predict_pj` when every candidate has a -/// prediction; equal predictions keep [`place`]'s 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(members, kernel, required); + 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) => { @@ -572,8 +598,10 @@ pub fn pair_updates(c: &Circuit) -> u64 { /// 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 covers -/// its device work only). This crate ships no implementation: a meter belongs +/// 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. @@ -585,6 +613,16 @@ pub trait RunMeter { 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; @@ -713,7 +751,8 @@ impl EnergyCalibration { /// 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. + /// 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; @@ -724,10 +763,10 @@ impl EnergyCalibration { 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| Uncertainty { relative_ppm: u32::try_from(u64::from(u.relative_ppm) + rounding).unwrap_or(u32::MAX), ..u }; + 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 }, + 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)) @@ -1305,6 +1344,11 @@ pub type RunFuture<'r> = Pin 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 @@ -1318,7 +1362,21 @@ pub trait Runner { #[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 { @@ -1377,21 +1435,38 @@ impl std::fmt::Display for FabricError { 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`], or [`place_for`] under a model), -/// 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. +/// 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. Natively, every future a runner of this crate returns is already -/// resolved on its first poll, so a run resolves in one poll too. +/// 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, @@ -1434,7 +1509,7 @@ impl<'a> Fabric<'a> { 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(&self.members, kernel, required), + None => ranked_evenly(&self.members, kernel, required), } } @@ -1453,6 +1528,12 @@ impl<'a> Fabric<'a> { 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(), @@ -2594,11 +2675,7 @@ mod tests { /// A future's output, which must be ready on its first poll. fn now(f: F) -> F::Output { - let mut f = core::pin::pin!(f); - match f.as_mut().poll(&mut core::task::Context::from_waker(core::task::Waker::noop())) { - core::task::Poll::Ready(v) => v, - core::task::Poll::Pending => panic!("pending after one poll"), - } + ready(f).expect("pending after one poll") } fn counter(relative_ppm: u32, window_us: u64) -> EnergyClass { @@ -2709,6 +2786,18 @@ mod tests { ] { 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 }]; @@ -2757,6 +2846,21 @@ mod tests { // 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:?}"); + } } } @@ -2789,43 +2893,67 @@ mod tests { 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: place's choice, which is - // the GPU by its figure even where the CPU's prediction alone, or - // the GPU's, would say otherwise. + // 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 ms in [&members[..], &unfigured[..]] { - for c in [&small, &large] { - for missing in [FabricClass::Gpu, FabricClass::Cpu] { - let a = place_for(ms, kernel, &Parity::BitExact, |m, k| if m.class == missing { None } else { model(m, k, c) }); - let b = place(ms, kernel, &Parity::BitExact); - assert_eq!(a.map(|(m, k)| (&m.id, &k.implementation)), b.map(|(m, k)| (&m.id, &k.implementation)), "{missing:?} unpredicted, n = {}", c.n_qubits); - assert_eq!(a.unwrap().0.id, GPU); - } + 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. + // 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 that always refuses with the error `make` builds. - struct Refuses(fn() -> MemberError); + /// 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.0)(); + let error = (self.1)(); Box::pin(async move { Err(error) }) } } - /// A runner for an accelerator's implementation `"port"` that produces the - /// reference's state. - struct Port; + /// 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"); @@ -2856,7 +2984,7 @@ mod tests { // 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(refusal)); + 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); @@ -2872,7 +3000,7 @@ mod tests { 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(refusal)); + 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() }]); } @@ -2880,7 +3008,7 @@ mod tests { // 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).add(accelerator(GPU, "port", Some(1)), Refuses(|| MemberError::Timeout)); + 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()); @@ -2889,10 +3017,31 @@ mod tests { 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(|| MemberError::Device("lost".into()))).add(accelerator("local/gpu-test-2222222222ab", "port", None), Refuses(|| MemberError::TooWide { n_qubits: 4, max: 2 })); + 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(); @@ -2922,22 +3071,36 @@ mod tests { 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 without its label: the fabric - // seals none. - struct Unlabelled; - impl Runner for Unlabelled { + // 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)?, 1234, None)) }) + 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(), Unlabelled); + 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()), (0, None)); + 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).set_model(model); + 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); diff --git a/wai/wai-quantum/src/quantum_fabric_webgpu.rs b/wai/wai-quantum/src/quantum_fabric_webgpu.rs index 7d021958d..e1246b334 100644 --- a/wai/wai-quantum/src/quantum_fabric_webgpu.rs +++ b/wai/wai-quantum/src/quantum_fabric_webgpu.rs @@ -386,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; a metered run's window covers device work only, from its first submission to its last readback; 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 { @@ -1300,6 +1300,10 @@ impl From for MemberError { /// [`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 { diff --git a/wai/wai-quantum/tests/webgpu_member.rs b/wai/wai-quantum/tests/webgpu_member.rs index 64375b1ed..dec145ffa 100644 --- a/wai/wai-quantum/tests/webgpu_member.rs +++ b/wai/wai-quantum/tests/webgpu_member.rs @@ -906,6 +906,41 @@ fn g13_metered_runs_and_the_fabric() { 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())); } } -- 2.54.0 (Apple Git-157)