quantum-meter: wai-quantum's RunMeter over this process's CPU and GPU counters #232

Merged
dcharlot merged 2 commits from wai/quantum-run-meter into main 2026-10-13 06:37:59 -04:00
6 changed files with 2336 additions and 0 deletions

View file

@ -23,6 +23,12 @@ name: wai-quantum WebGPU member
# the four pinned digests, reproduce every vector and refuse every reject file with
# its class. It is not a required status until it has been green three runs in a row.
#
# The meter (wai/tools/quantum-meter) is wai-quantum's RunMeter over a process's own
# CPU and GPU energy counters. On the Metal host, where those counters exist, it meters
# a member's runs and the CPU reference's over windows they resolve, and its test must
# print what it read; on the software Vulkan host, where they do not, every window must
# come out unmetered.
#
# There is no windows job while the windows runner is offline: a job queued on an
# offline runner never settles. Add it back from the comment at the end when the
# runner has survived a reboot.
@ -38,6 +44,11 @@ on:
- 'wai/wai-quantum/**'
- 'wai/quantum-conformance/**'
- 'wai/tools/quantum-wgsl-check/**'
# The meter, and the counters it reads.
- 'wai/tools/quantum-meter/**'
- 'jouleclaw/jouleclaw-rs/Cargo.toml'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-core/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**'
- 'wai/wai-rs/src/bin/wai_quantum_vectors.rs'
# The GPU jobs build wai-rs with the forwarded feature, --locked.
- 'wai/wai-rs/Cargo.toml'
@ -57,6 +68,11 @@ on:
- 'wai/wai-quantum/**'
- 'wai/quantum-conformance/**'
- 'wai/tools/quantum-wgsl-check/**'
# The meter, and the counters it reads.
- 'wai/tools/quantum-meter/**'
- 'jouleclaw/jouleclaw-rs/Cargo.toml'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-core/**'
- 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**'
- 'wai/wai-rs/src/bin/wai_quantum_vectors.rs'
# The GPU jobs build wai-rs with the forwarded feature, --locked.
- 'wai/wai-rs/Cargo.toml'
@ -175,6 +191,18 @@ jobs:
- name: The member's tests
shell: bash
run: scripts/wai-quantum-webgpu-member.sh
# No per-process energy counter exists here: the meter must find none, and a
# run of the member through it must be unmetered.
- name: The meter, where its counters do not exist
shell: bash
working-directory: wai/tools/quantum-meter
run: |
set -euo pipefail
cargo test --locked -- --nocapture 2>&1 | tee "$RUNNER_TEMP/meter.log"
grep -q '^test a_members_runs_are_metered_over_a_window_the_counters_resolve \.\.\. ok$' "$RUNNER_TEMP/meter.log" \
|| { echo "::error::the meter's device test did not run"; exit 1; }
grep -q '^quantum-meter: the counters are not live here' "$RUNNER_TEMP/meter.log" \
|| { echo "::error::the meter's device test found no adapter, so it checked nothing"; exit 1; }
member-metal:
name: member · Metal (macos)
@ -195,6 +223,23 @@ jobs:
shell: bash
working-directory: wai/wai-rs
run: nice -n 10 cargo run --locked --no-default-features --features quantum_fabric_webgpu --bin wai_quantum_vectors -- verify ../quantum-conformance --webgpu
# The counters exist here. The device test prints a `quantum-meter hardware:` line
# for each window it read: a figure, or why another process's GPU use left it
# none. The tool then calibrates both members on one circuit and, where both
# windows earn a figure, places it.
- name: The meter on this process's counters
shell: bash
working-directory: wai/tools/quantum-meter
run: |
set -euo pipefail
nice -n 10 cargo test --locked --release -- --nocapture 2>&1 | tee "$RUNNER_TEMP/meter.log"
grep -q '^test a_members_runs_are_metered_over_a_window_the_counters_resolve \.\.\. ok$' "$RUNNER_TEMP/meter.log" \
|| { echo "::error::the meter's device test did not run"; exit 1; }
grep -q '^quantum-meter hardware: ' "$RUNNER_TEMP/meter.log" \
|| { echo "::error::the meter read no window: its counters are not live on this host"; exit 1; }
nice -n 10 cargo run --locked --release -- --counter-ppm 50000 2>&1 | tee "$RUNNER_TEMP/meter-tool.log"
grep -q '^quantum-meter: local/cpu-' "$RUNNER_TEMP/meter-tool.log" \
|| { echo "::error::the tool ran no window on the CPU reference"; exit 1; }
member-hw:
name: member · discrete GPU, Vulkan (jetson-hub)

1411
wai/tools/quantum-meter/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
# A meter for wai-quantum's fabric: its `RunMeter` over this process's own CPU and GPU
# energy counters, a calibration over windows long enough for those counters to resolve,
# and a tool that calibrates the CPU reference and a WebGPU member with it.
#
# Deliberately a SEPARATE crate with its own lockfile: a meter belongs with the energy
# source it reads, so the counters are a dependency here only, and never compile in
# wai-quantum's own builds or tests.
[package]
name = "quantum-meter"
version = "0.0.0"
edition = "2024"
rust-version = "1.97"
license = "Apache-2.0"
publish = false
[dependencies]
wai-quantum = { path = "../../wai-quantum", default-features = false, features = ["quantum_fabric_webgpu"] }
# `ioreport` is the per-process counters' feature. They exist on macOS on Apple Silicon;
# elsewhere the crate builds, the counters are not live, and every run is unmetered.
jouleclaw-energy = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", default-features = false, features = ["ioreport"] }

View file

@ -0,0 +1,496 @@
//! [`RunMeter`] over this process's own energy counters, and a calibration
//! over windows those counters can resolve.
//!
//! **The source.** `jouleclaw-energy`'s span meter: this process's CPU energy
//! (the kernel's per-process count) plus its GPU energy (the GPU rail, for a
//! span over which this process was all but the GPU's only user, or an exact
//! zero where it spent no GPU time). Both are readings of counters the
//! hardware keeps about itself, so a figure is labelled
//! [`EnergyClass::OnChipCounter`]. The domain is this process's CPU cores and
//! the GPU: what a run of a fabric member in this process spends on both, and
//! so the same domain for the CPU reference (GPU part zero) and for an
//! accelerator member (the device's work, and the host's submissions, waits
//! and copies). Figures from this meter on two members can be compared.
//! Memory, and any other rail, is outside it.
//!
//! **When there is no figure.** A window is unmetered, never estimated, when:
//!
//! - the counters are not live in this process ([`ProcessRunMeter::is_live`]):
//! off macOS on Apple Silicon, always;
//! - the GPU counter put no figure on the span: another process used the GPU
//! over it, a GPU client came or went, the rail was not current or did not
//! advance. The CPU figure alone would leave the GPU's energy out, which is
//! not zero;
//! - the window is shorter than a counter can resolve ([`least_window`]): one
//! second for a span with GPU time, a millisecond for one without;
//! - the figure is zero, or its label does not reach wai-quantum's resolution
//! floor (`check_label`, applied by the fabric to every reading).
//!
//! **The declared uncertainty** ([`label`]) is the sum of three terms, each
//! relative to the figure:
//!
//! - what the caller declares for the counters themselves
//! ([`Declared::counter_ppm`]). The platform publishes no accuracy for
//! either counter, so this is the operator's own evaluation and this crate
//! supplies no default;
//! - the ends of the window. A CPU thread's count is folded when the thread
//! leaves its core, at the latest when its quantum ends, so a read can miss
//! up to a quantum (10 ms) of one thread at each end ([`CPU_EDGE`]). A GPU
//! snapshot reads the rail on a republish, so it can be off by about one
//! republish interval (30 ms) at each end ([`GPU_EDGE`]). Either is taken
//! as that much of the window at the window's mean power: `edge / window`;
//! - the GPU's other users, for a span with GPU time: the rail is apportioned
//! by GPU time, and the counter gives a figure only where this process held
//! at least [`SOLE_USER_PERCENT`] of it, so the rest is at most
//! `100 − SOLE_USER_PERCENT` percent.
//!
//! So a window's length is part of its label: three seconds of GPU work
//! declares 3% for its ends and its share, a tenth of a second would declare
//! 61%, and is refused before that as shorter than the rail resolves.
//!
//! **Calibrating.** The fabric's own calibrations open one window per corpus
//! circuit, and a corpus circuit runs for microseconds: every such window is
//! refused here. [`span_of_runs`] is the calibration this meter supports: one
//! window around as many runs of a circuit as fill a stated time, giving the
//! runs' total operations and one reading, which
//! [`EnergyCalibration::add`](wai_quantum::quantum_fabric::EnergyCalibration::add)
//! takes as it takes a single run's.
use jouleclaw_energy::process_gpu::SOLE_USER_PERCENT;
use jouleclaw_energy::span::{ProcessSpanMeter, SpanSource, SpanStart, SpanUses};
use std::time::{Duration, Instant};
use wai_quantum::quantum_energy::{CounterFiltering, EnergyClass, Uncertainty};
use wai_quantum::quantum_fabric::RunMeter;
/// What a CPU reading can miss over the two ends of a window: up to one
/// scheduler quantum (10 ms) of one thread at each.
pub const CPU_EDGE: Duration = Duration::from_millis(20);
/// What a GPU reading can be off by over the two ends of a window: about one
/// republish interval of the rail (30 ms) at each.
pub const GPU_EDGE: Duration = Duration::from_millis(60);
/// The least window for a span with no GPU time: the CPU counter's own. It
/// moves about every half millisecond under load, and a window under a
/// millisecond may straddle no update at all.
pub const MIN_CPU_WINDOW: Duration = Duration::from_millis(1);
/// The least window for a span with GPU time: the GPU counter's own, where a
/// republish interval at each end is a few percent of the figure.
pub const MIN_GPU_WINDOW: Duration = Duration::from_secs(1);
/// How long the GPU counter's liveness probe load runs each time the probe
/// calls it ([`probe_load`]). The probe needs a few tens of milliseconds of
/// this process's GPU work between its samples, and one run of a member can
/// be far less: an 18-qubit run took about 2 ms on an M4 Max, and a host with
/// a larger GPU takes less. A single run as the load passed the probe on one
/// Mac and failed it, `Frozen`, on another whose GPU counter is live.
pub const PROBE_LOAD: Duration = Duration::from_millis(50);
/// A load for [`ProcessRunMeter::new`] from one run of a member: `run`
/// repeated until [`PROBE_LOAD`] has passed, once at least, each time the
/// probe calls it. `run` must wait for its GPU work, as a member's run does.
pub fn probe_load(mut run: impl FnMut()) -> impl FnMut() {
move || {
let began = Instant::now();
loop {
run();
if began.elapsed() >= PROBE_LOAD {
break;
}
}
}
}
/// What the operator declares about the counters.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Declared {
/// The standard relative uncertainty of the counters' own readings, in
/// parts per million: the operator's evaluation, since the platform
/// publishes none. Not zero.
pub counter_ppm: u32,
}
/// One window as the counters read it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Span {
/// The window, from the start readings to the call that ended it.
pub wall: Duration,
/// This process's CPU energy over it, µJ.
pub cpu_uj: u64,
/// This process's GPU energy over it, µJ: zero where it spent no GPU time.
pub gpu_uj: u64,
/// This process's GPU time over it, ns.
pub gpu_ns: u64,
}
impl Span {
/// The figure: CPU and GPU together, µJ.
pub fn joules_micro(&self) -> Option<u64> {
self.cpu_uj.checked_add(self.gpu_uj)
}
}
/// Why a window has no figure.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Refused {
/// This process's counters are not live.
NotLive(String),
/// The GPU counter put no figure on the span, and why.
NoGpuFigure(String),
/// The CPU counter put no figure on the span, and why.
NoCpuFigure(String),
/// A counter failed.
Counter(String),
/// The window is shorter than a counter resolves.
TooShort { window: Duration, least: Duration },
/// The figure is zero.
Zero,
/// The declared uncertainty is zero, or with the window's terms no longer
/// fits its field.
Uncertainty,
}
impl std::fmt::Display for Refused {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Refused::NotLive(why) => write!(f, "this process's counters are not live: {why}"),
Refused::NoGpuFigure(why) => write!(f, "the GPU counter put no figure on the window: {why}"),
Refused::NoCpuFigure(why) => write!(f, "the CPU counter put no figure on the window: {why}"),
Refused::Counter(why) => write!(f, "a counter failed: {why}"),
Refused::TooShort { window, least } => write!(f, "a window of {window:?} is shorter than the {least:?} its counters resolve"),
Refused::Zero => write!(f, "the window's figure is zero"),
Refused::Uncertainty => write!(f, "no uncertainty can be declared for the window"),
}
}
}
/// The least window a span is labelled over: [`MIN_GPU_WINDOW`] with GPU
/// time, [`MIN_CPU_WINDOW`] without.
pub fn least_window(span: &Span) -> Duration {
if span.gpu_ns > 0 { MIN_GPU_WINDOW } else { MIN_CPU_WINDOW }
}
/// `part / whole` in parts per million, rounded up.
fn ppm_of(part: Duration, whole: Duration) -> Option<u32> {
let whole = whole.as_nanos();
if whole == 0 {
return None;
}
u32::try_from((part.as_nanos() * 1_000_000).div_ceil(whole)).ok()
}
/// A span's figure and its label, or why it has none: the rule of the
/// [module docs](self), apart from the counters, so it is tested on spans
/// written by hand.
pub fn label(span: &Span, declared: Declared) -> Result<(u64, EnergyClass), Refused> {
let least = least_window(span);
if span.wall < least {
return Err(Refused::TooShort { window: span.wall, least });
}
let joules_micro = span.joules_micro().ok_or_else(|| Refused::Counter("the CPU and GPU figures together overflow".to_string()))?;
if joules_micro == 0 {
return Err(Refused::Zero);
}
if declared.counter_ppm == 0 {
return Err(Refused::Uncertainty);
}
let with_gpu = span.gpu_ns > 0;
let ends = ppm_of(if with_gpu { GPU_EDGE } else { CPU_EDGE }, span.wall).ok_or(Refused::Uncertainty)?;
let share = if with_gpu { (100 - SOLE_USER_PERCENT as u32) * 10_000 } else { 0 };
let relative_ppm = declared.counter_ppm.checked_add(ends).and_then(|p| p.checked_add(share)).ok_or(Refused::Uncertainty)?;
let window_us = u64::try_from(span.wall.as_micros()).map_err(|_| Refused::Uncertainty)?;
// Whether the platform filters these counters is not something a process
// can read, so the only honest value is that it is unknown.
Ok((joules_micro, EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm, window_us }, filtering: CounterFiltering::Unknown }))
}
/// [`RunMeter`] over this process's CPU and GPU energy counters: see the
/// [module docs](self).
pub struct ProcessRunMeter {
meter: ProcessSpanMeter,
declared: Declared,
open: Option<SpanStart>,
last: Option<Result<Span, Refused>>,
}
impl ProcessRunMeter {
/// The meter. The first one in a process probes the counters: about
/// 0.3 s of this thread's CPU, and `gpu_load`, which must put a few tens
/// of milliseconds of work on the GPU from this process and wait for it
/// each time it is called: [`probe_load`] around a run of the member to
/// be metered. The answer is kept for the life of the process, so a first
/// meter whose `gpu_load` does too little GPU work leaves every later one
/// without a GPU counter.
pub fn new(gpu_load: impl FnMut(), declared: Declared) -> Self {
ProcessRunMeter { meter: ProcessSpanMeter::new(gpu_load), declared, open: None, last: None }
}
/// Whether both counters are live in this process. When they are not,
/// [`RunMeter::start`] fails and every run is unmetered.
pub fn is_live(&self) -> bool {
self.meter.is_metered()
}
/// The GPU counter's own account of whether it is live.
pub fn gpu_liveness(&self) -> String {
format!("{:?}", self.meter.gpu().liveness())
}
/// The last window closed: its readings, or why it had none. A span that
/// was read can still have no figure ([`label`]); `stop`'s return says.
pub fn last(&self) -> Option<&Result<Span, Refused>> {
self.last.as_ref()
}
}
impl RunMeter for ProcessRunMeter {
fn start(&mut self) -> Result<(), String> {
if !self.is_live() {
let why = Refused::NotLive(format!("GPU counter {}", self.gpu_liveness()));
let text = why.to_string();
self.last = Some(Err(why));
return Err(text);
}
self.open = Some(self.meter.start());
Ok(())
}
fn stop(&mut self) -> Result<Option<(u64, EnergyClass)>, String> {
let start = self.open.take().ok_or_else(|| "the meter was stopped without a start".to_string())?;
// The window is the work's own: the end readings can wait up to a
// second for the GPU rail to republish, and that wait is not the run.
let wall = start.at().elapsed();
let span = match self.meter.finish(start, SpanUses::CpuGpu) {
Ok(energy) => match energy.source {
SpanSource::Process { cpu_uj, gpu } => Ok(Span { wall, cpu_uj, gpu_uj: gpu.uj, gpu_ns: gpu.self_ns }),
SpanSource::CpuOnly { why, .. } => Err(Refused::NoGpuFigure(why)),
SpanSource::Fallback { why, .. } => Err(Refused::NoCpuFigure(why)),
},
Err(e) => Err(Refused::Counter(e.to_string())),
};
let reading = match &span {
Ok(s) => label(s, self.declared).ok(),
Err(_) => None,
};
self.last = Some(span);
Ok(reading)
}
}
/// One window of a meter around repeated runs.
#[derive(Clone, Debug, PartialEq)]
pub struct Runs {
/// How many runs the window held.
pub runs: u64,
/// The operations they performed, together.
pub ops: u64,
/// The window's reading: zero and `None` when the meter gave none.
pub joules_micro: u64,
pub class: Option<EnergyClass>,
/// From just after the meter started to just before it was stopped.
pub wall: Duration,
}
/// One window of `meter` around runs of `run`, repeated until `at_least` has
/// passed (once at least). `run` performs one run and returns the operations
/// it performed (`pair_updates` of its circuit). The meter is started before
/// the first run and stopped after the last; a run that fails stops it and
/// discards the window.
///
/// The reading is as the meter gave it. It is the caller's to hold to
/// `check_label` before use, which
/// [`EnergyCalibration::add`](wai_quantum::quantum_fabric::EnergyCalibration::add)
/// does.
pub fn span_of_runs(meter: &mut dyn RunMeter, at_least: Duration, mut run: impl FnMut() -> Result<u64, String>) -> Result<Runs, String> {
meter.start()?;
let began = Instant::now();
let (mut runs, mut ops) = (0u64, 0u64);
loop {
match run() {
Ok(n) => {
runs += 1;
ops = ops.saturating_add(n);
}
Err(e) => {
let _ = meter.stop();
return Err(e);
}
}
if began.elapsed() >= at_least {
break;
}
}
let wall = began.elapsed();
let (joules_micro, class) = match meter.stop()? {
Some((joules_micro, class)) => (joules_micro, Some(class)),
None => (0, None),
};
Ok(Runs { runs, ops, joules_micro, class, wall })
}
#[cfg(test)]
mod tests {
use super::*;
use wai_quantum::quantum_energy::check_label;
use wai_quantum::quantum_fabric::EnergyCalibration;
const DECLARED: Declared = Declared { counter_ppm: 50_000 };
fn uncertainty(class: &EnergyClass) -> Uncertainty {
class.uncertainty().expect("an on-chip counter declares an uncertainty")
}
#[test]
fn a_cpu_span_is_labelled_with_the_counter_and_its_ends() {
let span = Span { wall: Duration::from_secs(3), cpu_uj: 4_200_000, gpu_uj: 0, gpu_ns: 0 };
let (joules_micro, class) = label(&span, DECLARED).expect("a figure");
assert_eq!(joules_micro, 4_200_000);
assert!(matches!(class, EnergyClass::OnChipCounter { filtering: CounterFiltering::Unknown, .. }), "{class:?}");
// 50 000 declared, and 20 ms of 3 s: 6 666.6…, rounded up.
assert_eq!(uncertainty(&class), Uncertainty { relative_ppm: 50_000 + 6_667, window_us: 3_000_000 });
assert_eq!(check_label(joules_micro, &class), Ok(()));
}
#[test]
fn a_gpu_span_adds_the_rails_ends_and_the_other_users_share() {
let span = Span { wall: Duration::from_secs(3), cpu_uj: 1_000_000, gpu_uj: 9_000_000, gpu_ns: 2_500_000_000 };
let (joules_micro, class) = label(&span, DECLARED).expect("a figure");
assert_eq!(joules_micro, 10_000_000, "CPU and GPU together");
// 50 000 declared, 60 ms of 3 s, and the 1% other clients may hold.
assert_eq!(uncertainty(&class), Uncertainty { relative_ppm: 50_000 + 20_000 + 10_000, window_us: 3_000_000 });
}
#[test]
fn a_window_shorter_than_its_counters_resolve_has_no_figure() {
let gpu = Span { wall: Duration::from_millis(999), cpu_uj: 100_000, gpu_uj: 900_000, gpu_ns: 800_000_000 };
assert_eq!(label(&gpu, DECLARED), Err(Refused::TooShort { window: Duration::from_millis(999), least: MIN_GPU_WINDOW }));
assert!(label(&Span { wall: MIN_GPU_WINDOW, ..gpu }, DECLARED).is_ok(), "the least window itself is labelled");
// The same window with no GPU time is long enough for the CPU counter.
let cpu = Span { gpu_uj: 0, gpu_ns: 0, ..gpu };
assert!(label(&cpu, DECLARED).is_ok());
let brief = Span { wall: Duration::from_micros(999), ..cpu };
assert_eq!(label(&brief, DECLARED), Err(Refused::TooShort { window: Duration::from_micros(999), least: MIN_CPU_WINDOW }));
}
#[test]
fn the_shorter_the_window_the_wider_the_label() {
let at = |ms| {
let span = Span { wall: Duration::from_millis(ms), cpu_uj: 1_000_000, gpu_uj: 1_000_000, gpu_ns: 1 };
uncertainty(&label(&span, DECLARED).expect("a figure").1).relative_ppm
};
assert_eq!(at(1_000), 50_000 + 60_000 + 10_000);
assert_eq!(at(10_000), 50_000 + 6_000 + 10_000);
assert!(at(1_000) > at(2_000) && at(2_000) > at(10_000));
}
#[test]
fn a_zero_figure_or_a_zero_declaration_has_no_label() {
let span = Span { wall: Duration::from_secs(2), cpu_uj: 0, gpu_uj: 0, gpu_ns: 0 };
assert_eq!(label(&span, DECLARED), Err(Refused::Zero));
let spent = Span { cpu_uj: 4, ..span };
assert_eq!(label(&spent, Declared { counter_ppm: 0 }), Err(Refused::Uncertainty));
// A figure is labelled here and can still be under the fabric's
// resolution floor, which the fabric applies to every reading: 4 µJ at
// 6% (5% declared, 1% for the ends of two seconds) is 240 000 µJ·ppm,
// under 288 676; 5 µJ reaches it.
let (joules_micro, class) = label(&spent, DECLARED).expect("labelled");
assert_eq!(uncertainty(&class).relative_ppm, 60_000);
assert!(check_label(joules_micro, &class).is_err(), "4 µJ at 6% is below the floor");
let (joules_micro, class) = label(&Span { cpu_uj: 5, ..span }, DECLARED).expect("labelled");
assert_eq!(check_label(joules_micro, &class), Ok(()), "5 µJ at 6% reaches it");
}
#[test]
fn an_uncertainty_that_does_not_fit_its_field_is_refused() {
let span = Span { wall: Duration::from_secs(2), cpu_uj: 1_000, gpu_uj: 0, gpu_ns: 0 };
assert_eq!(label(&span, Declared { counter_ppm: u32::MAX }), Err(Refused::Uncertainty));
assert_eq!(Span { cpu_uj: u64::MAX, gpu_uj: 1, ..span }.joules_micro(), None);
assert!(matches!(label(&Span { cpu_uj: u64::MAX, gpu_uj: 1, ..span }, DECLARED), Err(Refused::Counter(_))));
}
/// A meter that reads what it is told to, and counts its calls.
struct Scripted {
reading: Option<(u64, EnergyClass)>,
fail_start: bool,
starts: u32,
stops: u32,
}
impl Scripted {
fn giving(reading: Option<(u64, EnergyClass)>) -> Scripted {
Scripted { reading, fail_start: false, starts: 0, stops: 0 }
}
}
impl RunMeter for Scripted {
fn start(&mut self) -> Result<(), String> {
self.starts += 1;
if self.fail_start { Err("no counter".to_string()) } else { Ok(()) }
}
fn stop(&mut self) -> Result<Option<(u64, EnergyClass)>, String> {
self.stops += 1;
Ok(self.reading.clone())
}
}
fn a_class() -> EnergyClass {
label(&Span { wall: Duration::from_secs(3), cpu_uj: 1, gpu_uj: 0, gpu_ns: 0 }, DECLARED).expect("a figure").1
}
#[test]
fn one_window_holds_every_run_and_their_operations() {
let mut meter = Scripted::giving(Some((3_000_000, a_class())));
let mut calls = 0u64;
let runs = span_of_runs(&mut meter, Duration::from_millis(30), || {
calls += 1;
std::thread::sleep(Duration::from_millis(4));
Ok(1_000)
})
.expect("a window");
assert_eq!((meter.starts, meter.stops), (1, 1), "one window, whatever the runs");
assert!(runs.runs >= 2 && runs.runs == calls, "{runs:?}");
assert_eq!(runs.ops, 1_000 * runs.runs);
assert!(runs.wall >= Duration::from_millis(30), "{runs:?}");
assert_eq!((runs.joules_micro, runs.class.clone()), (3_000_000, Some(a_class())));
// The window is one run of a calibration, over all its operations.
let mut calibration = EnergyCalibration::new();
calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref());
let (nanojoules, _) = calibration.finish().expect("a figure");
assert_eq!(nanojoules, (3_000_000u64 * 1_000).div_ceil(runs.ops));
}
#[test]
fn a_window_runs_once_at_least_and_an_unmetered_one_reads_nothing() {
let mut meter = Scripted::giving(None);
let runs = span_of_runs(&mut meter, Duration::ZERO, || Ok(7)).expect("a window");
assert_eq!((runs.runs, runs.ops, runs.joules_micro, runs.class.clone()), (1, 7, 0, None));
// It leaves a calibration without a figure, as any unmetered run does.
let mut calibration = EnergyCalibration::new();
calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref());
assert_eq!(calibration.finish(), None);
}
#[test]
fn a_run_that_fails_closes_the_window_and_a_meter_that_fails_to_start_runs_nothing() {
let mut meter = Scripted::giving(Some((1, a_class())));
let mut calls = 0;
let failed = span_of_runs(&mut meter, Duration::from_secs(60), || {
calls += 1;
if calls == 2 { Err("the device was lost".to_string()) } else { Ok(1) }
});
assert_eq!(failed, Err("the device was lost".to_string()));
assert_eq!((meter.starts, meter.stops), (1, 1), "the window is closed, its reading discarded");
let mut dead = Scripted { fail_start: true, ..Scripted::giving(None) };
let mut ran = false;
let none = span_of_runs(&mut dead, Duration::ZERO, || {
ran = true;
Ok(1)
});
assert_eq!(none, Err("no counter".to_string()));
assert!(!ran, "no run without a window");
assert_eq!((dead.starts, dead.stops), (1, 0));
}
}

View file

@ -0,0 +1,215 @@
//! Calibrates the fabric's CPU reference and a WebGPU member with this
//! process's own energy counters, and places a circuit by the figures.
//!
//! quantum-meter --counter-ppm <n> [--qubits <n>] [--seconds <s>] [--gateset 1|2] [--expect <adapter key>]
//!
//! `--counter-ppm` is the operator's evaluation of the counters' own relative
//! uncertainty, in parts per million. The platform publishes none, so there
//! is no default.
//!
//! One circuit on `--qubits` qubits (20) is run by each member, again and
//! again inside one window of `--seconds` seconds (3) at least: a window long
//! enough for the counters to resolve, where a single run is not. Each
//! member's line gives the window's readings and the figure they earn, in
//! nanojoules per operation (one pair of amplitudes under one gate), or says
//! why the window has none. With a figure on both, the last line is the member
//! the fabric places that circuit on.
//!
//! Exit 0 when it ran, with or without figures; 1 when a member could not be
//! found or run; 2 for a bad argument.
use quantum_meter::{Declared, ProcessRunMeter, Refused, Runs, Span, label, probe_load, span_of_runs};
use std::process::ExitCode;
use std::time::Duration;
use wai_quantum::quantum::{BaseGate, Circuit, Gate, Gateset};
use wai_quantum::quantum_energy::EnergyClass;
use wai_quantum::quantum_fabric::{EnergyCalibration, FABRIC_DEFAULT, Member, Parity, REFERENCE, apply_kernel, detect_local, pair_updates, place_for};
use wai_quantum::quantum_fabric_webgpu::blocking;
struct Args {
declared: Declared,
qubits: u8,
at_least: Duration,
gateset: Gateset,
expect: Option<String>,
}
fn usage() -> ExitCode {
eprintln!("usage: quantum-meter --counter-ppm <n> [--qubits <n>] [--seconds <s>] [--gateset 1|2] [--expect <adapter key>]");
ExitCode::from(2)
}
fn args() -> Option<Args> {
let (mut ppm, mut qubits, mut seconds, mut gateset, mut expect) = (None, 20u8, 3u64, Gateset::V2, None);
let mut it = std::env::args().skip(1);
while let Some(flag) = it.next() {
let value = it.next()?;
match flag.as_str() {
"--counter-ppm" => ppm = Some(value.parse::<u32>().ok().filter(|&p| p > 0)?),
"--qubits" => qubits = value.parse::<u8>().ok().filter(|n| (2..=24).contains(n))?,
"--seconds" => seconds = value.parse::<u64>().ok().filter(|s| (1..=600).contains(s))?,
"--gateset" => {
gateset = match value.as_str() {
"1" => Gateset::V1,
"2" => Gateset::V2,
_ => return None,
}
}
"--expect" => expect = Some(value),
_ => return None,
}
}
Some(Args { declared: Declared { counter_ppm: ppm? }, qubits, at_least: Duration::from_secs(seconds), gateset, expect })
}
/// The circuit every window runs: on each qubit a Hadamard, then a chain of
/// controlled X, then T and a dyadic phase on each, then a Hadamard on each.
/// Valid in either gateset; `5n − 1` gates.
fn circuit(n: u8, gs: Gateset) -> Circuit {
let mut c = Circuit::with_gateset(n, gs);
let one = |base, target, param| Gate { base, controls: Vec::new(), target, param };
(0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0)));
(0..n - 1).for_each(|q| c.ops.push(Gate { base: BaseGate::X, controls: vec![q], target: q + 1, param: 0 }));
for q in 0..n {
c.ops.push(one(BaseGate::T, q, 0));
c.ops.push(one(BaseGate::P, q, 1 + u16::from(q % 6)));
}
(0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0)));
c
}
/// One member's window as a line, and the figure it earns.
fn report(member: &str, kernel: &str, runs: &Runs, last: Option<&Result<Span, Refused>>, declared: Declared) -> Option<(u64, EnergyClass)> {
let mut calibration = EnergyCalibration::new();
calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref());
let figure = calibration.finish();
let read = match last {
Some(Ok(s)) => format!("cpu={} uJ gpu={} uJ gpu_time={} ms", s.cpu_uj, s.gpu_uj, s.gpu_ns / 1_000_000),
_ => "no readings".to_string(),
};
let earned = match (&figure, last) {
(Some((nanojoules, class)), _) => {
let ppm = class.uncertainty().map_or(0, |u| u.relative_ppm);
format!("{nanojoules} nJ/op, {}, {ppm} ppm", class.label())
}
(None, Some(Err(why))) => format!("unmetered: {why}"),
// Read, and still no figure: the labelling rule says why, or the
// fabric's resolution floor refused the label.
(None, Some(Ok(s))) => match label(s, declared) {
Err(why) => format!("unmetered: {why}"),
Ok(_) => "unmetered: the reading is below the resolution floor".to_string(),
},
(None, None) => "unmetered".to_string(),
};
println!("quantum-meter: {member} kernel={kernel} runs={} ops={} wall={} ms {read} -> {earned}", runs.runs, runs.ops, runs.wall.as_millis());
figure
}
fn main() -> ExitCode {
let Some(args) = args() else { return usage() };
let kernel = apply_kernel(args.gateset);
let wide = circuit(args.qubits, args.gateset);
let ops = pair_updates(&wide);
println!("quantum-meter: circuit qubits={} gates={} ops={ops} kernel={kernel}", wide.n_qubits, wide.ops.len());
let discovery = match blocking::discover() {
Ok(d) => d,
Err(e) => {
eprintln!("quantum-meter: discovery failed: {e:?}");
return ExitCode::FAILURE;
}
};
for r in &discovery.refused {
println!("quantum-meter: adapter {} ({}) is not a member: {:?}", r.key, r.adapter, r.error);
}
let found = match &args.expect {
Some(key) => discovery.found.iter().find(|m| m.key() == key),
None => discovery.found.first(),
};
let Some(gpu) = found else {
eprintln!("quantum-meter: no WebGPU member{}", args.expect.as_ref().map(|k| format!(" with key {k}")).unwrap_or_default());
return ExitCode::FAILURE;
};
println!("quantum-meter: adapter {} member={} holds {} qubits", gpu.key(), gpu.id(), gpu.max_qubits());
// The first meter in a process probes the GPU counter with this load: a
// run of the member it will meter.
let mut meter = ProcessRunMeter::new(
probe_load(|| {
let _ = blocking::simulate_from(gpu, &wide, 0);
}),
args.declared,
);
println!("quantum-meter: counters live={} gpu={}", meter.is_live(), meter.gpu_liveness());
let mut gpu_member = match blocking::member(gpu, FABRIC_DEFAULT) {
Ok(m) => m,
Err(e) => {
eprintln!("quantum-meter: {} could not be certified: {e:?}", gpu.id());
return ExitCode::FAILURE;
}
};
let mut cpu_member = detect_local();
// An unmetered window still runs, so each member is exercised either way.
let window = |meter: &mut ProcessRunMeter, run: &mut dyn FnMut() -> Result<u64, String>| match span_of_runs(meter, args.at_least, &mut *run) {
Ok(runs) => Ok(runs),
// The meter would not start: run once without it.
Err(_) if !meter.is_live() => run().map(|ops| Runs { runs: 1, ops, joules_micro: 0, class: None, wall: Duration::ZERO }),
Err(e) => Err(e),
};
let gpu_runs = window(&mut meter, &mut || {
let state = blocking::simulate_from(gpu, &wide, 0).map_err(|e| format!("{e:?}"))?;
std::hint::black_box(state);
Ok(ops)
});
let gpu_runs = match gpu_runs {
Ok(r) => r,
Err(e) => {
eprintln!("quantum-meter: {} failed its run: {e}", gpu.id());
return ExitCode::FAILURE;
}
};
let gpu_figure = report(&gpu_member.id, kernel, &gpu_runs, meter.last(), args.declared);
let cpu_runs = window(&mut meter, &mut || {
let state = wide.simulate_from(0).map_err(|e| format!("{e:?}"))?;
std::hint::black_box(state);
Ok(ops)
});
let cpu_runs = match cpu_runs {
Ok(r) => r,
Err(e) => {
eprintln!("quantum-meter: the CPU reference failed its run: {e}");
return ExitCode::FAILURE;
}
};
let cpu_figure = report(&cpu_member.id, kernel, &cpu_runs, meter.last(), args.declared);
// Figures go on both members or on neither: a figure on one alone would
// place it whatever it costs.
let (Some(gpu_figure), Some(cpu_figure)) = (gpu_figure, cpu_figure) else {
println!("quantum-meter: no placement by energy: a figure is needed on both members");
return ExitCode::SUCCESS;
};
let set = |member: &mut Member, implementation: &str, figure: &(u64, EnergyClass)| {
member.kernels.iter_mut().filter(|k| k.kernel == kernel && k.implementation == implementation).for_each(|k| k.energy = Some(figure.clone()));
};
set(&mut gpu_member, gpu.implementation(), &gpu_figure);
set(&mut cpu_member, REFERENCE, &cpu_figure);
let members = [cpu_member, gpu_member];
let predict = |_: &Member, k: &wai_quantum::quantum_fabric::KernelEntry| k.energy.as_ref().map(|(nanojoules, _)| nanojoules.saturating_mul(ops).saturating_mul(1_000));
match place_for(&members, kernel, &Parity::BitExact, predict) {
Some((member, entry)) => println!(
"quantum-meter: placed {} ({}) for {} qubits: {} nJ/op against {} nJ/op on the other member",
member.id,
entry.implementation,
wide.n_qubits,
entry.energy.as_ref().map_or(0, |e| e.0),
if member.id == members[0].id { gpu_figure.0 } else { cpu_figure.0 }
),
None => println!("quantum-meter: no member is certified for {kernel}"),
}
ExitCode::SUCCESS
}

View file

@ -0,0 +1,149 @@
//! The meter on this host's own counters and GPU, where it has them: a WebGPU
//! member's runs and the CPU reference's, each in a window long enough to
//! resolve, and one run that is not.
//!
//! This is its own test binary because the first meter in a process probes
//! the GPU counter with the load it is given, once for the process's life.
//!
//! On a host with no WebGPU adapter, or where the counters are not live, the
//! test says so and checks what holds there: every window is unmetered. On a
//! host where they are live it prints `quantum-meter hardware:` lines, which a
//! CI job that expects live counters looks for.
//!
//! A GPU window can lose its figure to another process using the GPU over it,
//! which a shared host cannot prevent; the test then requires the refusal to
//! say so, and no figure.
use quantum_meter::{Declared, MIN_GPU_WINDOW, ProcessRunMeter, Refused, label, probe_load, span_of_runs};
use std::time::Duration;
use wai_quantum::quantum::{BaseGate, Circuit, Gate, Gateset};
use wai_quantum::quantum_energy::{EnergyClass, check_label};
use wai_quantum::quantum_fabric::{EnergyCalibration, RunMeter, pair_updates};
use wai_quantum::quantum_fabric_webgpu::blocking;
const DECLARED: Declared = Declared { counter_ppm: 50_000 };
/// Hadamards, a chain of controlled X, phases, Hadamards: `5n − 1` gates.
fn circuit(n: u8) -> Circuit {
let mut c = Circuit::with_gateset(n, Gateset::V2);
let one = |base, target, param| Gate { base, controls: Vec::new(), target, param };
(0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0)));
(0..n - 1).for_each(|q| c.ops.push(Gate { base: BaseGate::X, controls: vec![q], target: q + 1, param: 0 }));
for q in 0..n {
c.ops.push(one(BaseGate::T, q, 0));
c.ops.push(one(BaseGate::P, q, 1 + u16::from(q % 6)));
}
(0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0)));
c
}
#[test]
fn a_members_runs_are_metered_over_a_window_the_counters_resolve() {
let discovery = blocking::discover().expect("discovery resolves natively");
let Some(gpu) = discovery.found.first() else {
eprintln!("quantum-meter: no WebGPU adapter on this host ({} refused)", discovery.refused.len());
return;
};
let wide = circuit(gpu.max_qubits().min(18));
let ops = pair_updates(&wide);
let mut meter = ProcessRunMeter::new(
probe_load(|| {
let _ = blocking::simulate_from(gpu, &wide, 0);
}),
DECLARED,
);
let on_gpu = || blocking::simulate_from(gpu, &wide, 0).map(|_| ops).map_err(|e| format!("{e:?}"));
let on_cpu = || wide.simulate_from(0).map(|_| ops).map_err(|e| format!("{e:?}"));
if !meter.is_live() {
eprintln!("quantum-meter: the counters are not live here (GPU counter {})", meter.gpu_liveness());
// No window opens, and a run through the member is unmetered.
assert!(span_of_runs(&mut meter, Duration::ZERO, on_gpu).is_err());
assert!(matches!(meter.last(), Some(Err(Refused::NotLive(_)))), "{:?}", meter.last());
let (_, joules_micro, class) = blocking::simulate_metered(gpu, &wide, 0, &mut meter).expect("the run itself");
assert_eq!((joules_micro, class), (0, None));
return;
}
// The member's runs, two seconds of them.
let runs = span_of_runs(&mut meter, MIN_GPU_WINDOW * 2, on_gpu).expect("the member runs");
assert!(runs.runs >= 1 && runs.ops == ops * runs.runs, "{runs:?}");
match meter.last().expect("a window was closed") {
Ok(span) => {
assert!(span.gpu_ns > 0, "the member's runs spent GPU time: {span:?}");
assert!(span.cpu_uj > 0, "and the host's side of them CPU energy: {span:?}");
let (joules_micro, class) = label(span, DECLARED).expect("two seconds of GPU work is labelled");
assert_eq!((runs.joules_micro, runs.class.clone()), (joules_micro, Some(class.clone())), "stop returned the span's label");
assert_eq!(joules_micro, span.cpu_uj + span.gpu_uj);
assert!(matches!(class, EnergyClass::OnChipCounter { .. }), "{class:?}");
assert_eq!(check_label(joules_micro, &class), Ok(()));
let mut calibration = EnergyCalibration::new();
calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref());
let (nanojoules, _) = calibration.finish().expect("a figure");
eprintln!(
"quantum-meter hardware: {} on {} qubits: {} runs in {:?}, cpu {} uJ + gpu {} uJ over {} ms of GPU time -> {nanojoules} nJ/op, {} ppm",
gpu.id(),
wide.n_qubits,
runs.runs,
runs.wall,
span.cpu_uj,
span.gpu_uj,
span.gpu_ns / 1_000_000,
class.uncertainty().expect("declared").relative_ppm
);
}
// The GPU was not this process's alone over the window, or its rail
// gave no reading: no figure, and the reason kept.
Err(why @ (Refused::NoGpuFigure(_) | Refused::Counter(_))) => {
assert_eq!((runs.joules_micro, runs.class.clone()), (0, None), "{why}");
eprintln!("quantum-meter hardware: {} window unmetered: {why}", gpu.id());
}
Err(other) => panic!("a two-second window of the member's runs was refused: {other}"),
}
// The CPU reference's runs, in the same domain: no GPU time, so the
// figure is the CPU count alone.
let runs = span_of_runs(&mut meter, Duration::from_secs(1), on_cpu).expect("the reference runs");
match meter.last().expect("a window was closed") {
Ok(span) => {
assert_eq!((span.gpu_ns, span.gpu_uj), (0, 0), "the reference spends no GPU time: {span:?}");
assert!(span.cpu_uj > 0, "{span:?}");
assert_eq!(runs.joules_micro, span.cpu_uj);
let class = runs.class.clone().expect("a second of CPU work is labelled");
assert_eq!(check_label(runs.joules_micro, &class), Ok(()));
let mut calibration = EnergyCalibration::new();
calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref());
let (nanojoules, _) = calibration.finish().expect("a figure");
eprintln!(
"quantum-meter hardware: the CPU reference on {} qubits: {} runs in {:?}, cpu {} uJ -> {nanojoules} nJ/op, {} ppm",
wide.n_qubits,
runs.runs,
runs.wall,
span.cpu_uj,
class.uncertainty().expect("declared").relative_ppm
);
}
// Another process's GPU client came or went over the window: the GPU
// counter then gives no figure, a zero included.
Err(why @ (Refused::NoGpuFigure(_) | Refused::Counter(_))) => {
assert_eq!((runs.joules_micro, runs.class.clone()), (0, None), "{why}");
eprintln!("quantum-meter hardware: the CPU reference's window unmetered: {why}");
}
Err(other) => panic!("a one-second window of the reference's runs was refused: {other}"),
}
// One run of the member, in the window the fabric opens around it: far
// under a second, so it is unmetered, never read at a resolution the rail
// does not have.
let small = circuit(6);
let (_, joules_micro, class) = blocking::simulate_metered(gpu, &small, 0, &mut meter).expect("the run itself");
assert_eq!((joules_micro, class), (0, None), "a run of {:?}", meter.last());
match meter.last().expect("the run's window was closed") {
Ok(span) => assert!(matches!(label(span, DECLARED), Err(Refused::TooShort { .. })), "{span:?}"),
Err(Refused::NoGpuFigure(_) | Refused::Counter(_)) => {}
Err(other) => panic!("{other}"),
}
// Every start was matched by a stop: the meter starts again.
assert!(meter.start().is_ok());
assert!(meter.stop().is_ok());
}