quantum-meter: wai-quantum's RunMeter over this process's CPU and GPU counters #232
6 changed files with 2336 additions and 0 deletions
45
.github/workflows/wai-quantum-webgpu.yml
vendored
45
.github/workflows/wai-quantum-webgpu.yml
vendored
|
|
@ -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
1411
wai/tools/quantum-meter/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
20
wai/tools/quantum-meter/Cargo.toml
Normal file
20
wai/tools/quantum-meter/Cargo.toml
Normal 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"] }
|
||||
496
wai/tools/quantum-meter/src/lib.rs
Normal file
496
wai/tools/quantum-meter/src/lib.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
215
wai/tools/quantum-meter/src/main.rs
Normal file
215
wai/tools/quantum-meter/src/main.rs
Normal 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
|
||||
}
|
||||
149
wai/tools/quantum-meter/tests/hardware.rs
Normal file
149
wai/tools/quantum-meter/tests/hardware.rs
Normal 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());
|
||||
}
|
||||
Loading…
Reference in a new issue