From a5c77d8d472cc53585aeefe521f350c1180f72ab Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 29 Sep 2026 10:37:32 -0400 Subject: [PATCH 01/10] proof-cascade: never meter CPU work through a frozen IOReport CPU rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family's energy contract: "measured when the platform exposes it, estimated when it doesn't, and the provenance always says which ... A reading never silently upgrades its own provenance." proof-cascade's meter broke it on current macOS, and `proof --measure` then blamed the machine for what the platform did. Ground truth, macOS 27.0.1 (measured for PR #110 by dumping the channels): on an M5 Max the IOReport Energy Model CPU rails do not advance for an unprivileged reader while `GPU Energy` does; on an M3 Ultra every Energy Model rail is frozen. Re-observed through this crate: the probe added below matched `CPU Energy` on the M5 Max and `DIE_0_CPU Energy` / `DIE_1_CPU Energy` on the M3 Ultra, with a raw delta of 0 on every sample. The defect. `hardware_meter_available()` was true as soon as an IOReport subscription opened, and `power_over()` summed every Energy Model rail, returning `None` only when the SUM was zero. Instrumenting `bracket()` on main showed what that meant: - M5 Max: idle_w=0.345 W, busy_w=0.281 W with every spare core burning (then 0.315/0.291 and 0.308/0.295 on two more runs): the GPU rail alone. The marginal clamps to Some(0) and the CLI printed "signal stayed inside the idle noise (a loaded/throttled box)". A run in which the GPU rail drew more in the busy window than at idle would print that difference as the proof's energy, tagged HwShunt, "a real hardware reading". - M3 Ultra: idle_power=None (every rail frozen, sum 0), and the CLI printed the same "loaded/throttled box" misdiagnosis. Its two tests could not see it: one returned early without IOReport and asserted only inside `if let Some(w)`, the other asserted only an upper bound inside `if let Some(uj)`, so every None passed silently, on the M3 Ultra always. The fix, with jcp-energy's semantics (PR #110), copied in so the crate stays self-contained. `macos_ioreport::probe_cpu_rail` opens a subscription and spins ~25 ms on the calling thread before each re-sample, for up to `CPU_RAIL_LIVENESS_BUDGET` (1 s), deciding on the raw integer of the Energy Model channel named `CPU Energy` after stripping a `DIE__` prefix, never on the summed total that the live GPU rail corrupts: Live, Frozen or Absent. `ioreport_status()` probes once per process and caches the answer as the public `IoReportStatus` {Unavailable, CpuRailAbsent, CpuRailFrozen, Live}, so callers can say why. `available()`, and so `hardware_meter_available()`, is true only when Live; `power_over()` and `measure_marginal_uj()` return None otherwise, before any window runs. None keeps meaning "no hardware interface", so callers keep their ModelBased figure, and a live reading keeps its existing grade. proof-cli prints the status as the reason ("IOReport opens, but its CPU energy rail is frozen (it did not advance while this process burned CPU), so it cannot meter CPU work. The figures above stay ModelBased"), keeps the idle-noise message for a live interface whose busy window did not clear idle, and drops the stale `macmon` references. The sealed receipt is unchanged: it is built from the first CascadeResult, never from the --measure figure. One measurement the reference does not state: sampling every channel is slow. One IOReportCreateSamples call took 0.4-0.8 s on the M5 Max, mostly waiting (75 ms of 0.81 s on-CPU, timed on the thread clock), so the 1 s budget buys two or three samples over a 1.1-1.9 s window and the probe's spin is a small share of it. That cannot produce a false Live, since only the rail's own integer moving can, so it errs only toward declining. The docs say so, and the test oracle burns on a helper thread instead. Tests. The two silent tests are replaced and three oracle-based ones added. Every branch asserts something: - the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail, against an independent oracle: its own subscription, each CPU rail's ABSOLUTE value (no deltas), names matched by lowercase ends_with("cpu energy") in any group (a superset of the probe's rails), and a helper thread burning CPU for the whole 3x-budget window (3.54 s of user CPU for this test alone). It logs "ioreport liveness: status=.. oracle=.. selected=..", asserts a frozen rail is never selected and that power_over and measure_marginal_uj return None there, and on every branch that selection equals status == Live. - the_liveness_probe_never_reports_a_frozen_rail_as_live: a fresh probe and the oracle, the implication evaluated on every host, not only one claiming Live. - a_selected_ioreport_meter_meters_cpu_work: when selected, status and a fresh probe are Live and a multi-core burst meters Some(>0); when not, the status is not Live and the meter returns None. - power_over_declines_unless_the_cpu_rail_is_live and meter_is_total_or_nothing replace the silent pair: a declined interface must give None, a usable one a figure. ioreport_status_is_unavailable_off_macos covers other platforms. - proof-cli tests/measure.rs runs `proof --measure`: when it declines, the output gives the reason (the frozen rail when that is the status) and holds no "HwShunt" and no idle-noise blame; when it meters, the host's meter must be available. Observed with 16/16 proof-cascade lib tests passing on both chips, macOS 27.0.1: "ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false" on the M5 Max and on the M3 Ultra, and the CLI prints the frozen-rail reason on both. Falsified on the M5 Max, each restored with git checkout: - F1, probe always Live: the frozen-rail test fails with "IOReport meter available although its CPU rail did not move under 3s of CPU burn — it would bracket GPU-only energy, or zero, as the energy of CPU work" (status=Live oracle=CpuRailMoved(false) selected=true). The probe test fails, and so does the selected-meter test, with "a live CPU rail metered a multi-core CPU burst as Some(0)": main's bracket on GPU-only rails. - F2, main's ungated available() and power_over(): the frozen-rail test fails with the same message (status=CpuRailFrozen oracle=CpuRailMoved(false) selected=true), so main fails it today. - F3, main's proof-cli over this meter: tests/measure.rs fails with "neither a declined meter nor a measurement in the output". The proof-rs workspace passes, as does the proof-conformance suite (10/10); joule-code-release and joulecontract-proof pass. joule-code-remediate's verified_fix_end_to_end_ships_with_receipt fails with "assertion failed: report.total_uj() > 0", identically on unmodified origin/main; it meters through sandbox-meter, not this crate. clippy adds no warnings (two pre-existing ones remain). Off macOS, `cargo check --tests` passes for x86_64-unknown-linux-gnu; aarch64 Linux and Windows could not be checked here, because blake3's C build needs a target sysroot or MSVC. Not done here, deliberately: - The grade. The module header calls this the HwShunt backend and proof-cli tags a live figure `Energy::measured` (HwShunt), while the same header says a macOS Energy Model reading is ModelBased. Which grade IOReport earns is a family-wide decision. - Reporting the live GPU rail as an explicitly partial reading. - A rail that freezes after the cached probe; that needs a residency witness. - measure_marginal_uj's doc promises None "inside the idle noise", but the code returns Some(0) when busy <= idle (observed above), and no test covers noise. - power_over divides by the sleep `d` while its sample-to-sample window also holds a 0.4-0.8 s IOReportCreateSamples call, overstating a live reading. - The Linux RAPL path: its "world-readable" comment predates the 2020 root-only change, and wrapping_sub over the sum of per-zone counters turns one zone wrapping into an absurd figure. Co-Authored-By: Claude Opus 5.5 --- .../crates/proof-cascade/src/meter.rs | 479 +++++++++++++++++- proof/proof-rs/crates/proof-cli/src/main.rs | 41 +- .../crates/proof-cli/tests/measure.rs | 55 ++ 3 files changed, 547 insertions(+), 28 deletions(-) create mode 100644 proof/proof-rs/crates/proof-cli/tests/measure.rs diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index c9651474e..344a70db9 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -9,6 +9,14 @@ //! (Apple's Energy Model is a vendor model, so a macOS reading is `ModelBased`, //! not a true coulomb-shunt `HwShunt`; Linux RAPL is the shunt-grade path.) //! +//! **On Apple Silicon, IOReport counts as reachable only while its CPU energy +//! rail is live** ([`ioreport_status`](crate::meter::ioreport_status)). A +//! subscription can open while every CPU rail holds a frozen value: measured on +//! macOS 27.0.1, an M5 Max keeps only its GPU rail advancing and an M3 Ultra +//! advances none. Bracketing CPU work against idle on those rails brackets GPU +//! activity, or nothing, so the interface is declined, `None` is returned, and +//! the caller keeps its `ModelBased` figure. +//! //! It is a measurement with stated bounds — SoC/package power resolved to the //! marginal delta over an idle machine ÷ iterations — exactly the discipline //! the WAI sink meter uses, including running the burst on every core so the @@ -21,12 +29,18 @@ use std::time::Instant; /// Marginal energy of one `work()` call in microjoules, from a hardware /// interface (`HwShunt`). `None` if no interface is reachable or the signal -/// stays inside the idle noise. `work` runs on every spare core during the busy -/// window, so keep it pure + `Copy`. +/// stays inside the idle noise; on Apple Silicon "reachable" means IOReport +/// with a live CPU energy rail ([`hardware_meter_available`]), so a frozen rail +/// yields `None` before any window runs. `work` runs on every spare core during +/// the busy window, so keep it pure + `Copy`. pub fn measure_marginal_uj(work: F) -> Option where F: Fn() + Send + Sync + Copy + 'static, { + // An interface that opens but cannot see CPU work is declined up front. + if !hardware_meter_available() { + return None; + } let (idle_w, busy_w, window, runs) = bracket(work)?; let marginal_w = (busy_w - idle_w).max(0.0); if runs == 0 { @@ -35,7 +49,13 @@ where Some((marginal_w * window / runs as f64 * 1e6).round() as u64) } -/// Whether a hardware energy interface is reachable on this machine. +/// Whether a usable hardware energy interface is reachable on this machine: +/// RAPL `energy_uj` on Linux; on Apple Silicon, IOReport **with a live CPU +/// energy rail** — [`ioreport_status`] is [`IoReportStatus::Live`]. A +/// subscription that opens over a frozen CPU rail does not count. On macOS the +/// first call pays the liveness probe (opening a subscription, a first sample, +/// then `CPU_RAIL_LIVENESS_BUDGET` of re-sampling); the answer is cached for +/// the process. pub fn hardware_meter_available() -> bool { #[cfg(target_os = "macos")] { @@ -51,6 +71,56 @@ pub fn hardware_meter_available() -> bool { } } +/// Why the IOReport interface is or is not used on this host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoReportStatus { + /// No IOReport subscription could be opened (not macOS, not Apple Silicon, + /// or running under Rosetta). + Unavailable, + /// A subscription opened, but no `CPU Energy` rail exists in the Energy + /// Model group — an unfamiliar channel naming. Never used. + CpuRailAbsent, + /// The CPU energy rail exists but did not advance while CPU was burned. The + /// platform is not exposing live CPU energy to this process, so bracketing + /// CPU work on it would measure GPU-only energy, or nothing. Observed on + /// macOS 27.0.1 on both an M5 Max and an M3 Ultra. + CpuRailFrozen, + /// The CPU energy rail advances under load; IOReport is used. + Live, +} + +/// How long the liveness probe keeps re-sampling (spinning between samples) +/// before concluding the CPU rail is frozen. One sample of every channel took +/// 0.4–0.8 s on an M5 Max, so this buys two or three samples, each compared +/// with the first — a window of 1.1–1.9 s there. A live rail slower than that +/// fails safe: declined, and the caller keeps its `ModelBased` figure. Paid once +/// per process; see [`ioreport_status`]. +#[cfg(target_os = "macos")] +pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); + +/// This host's IOReport status, probed once per process and cached. +/// +/// On macOS, [`hardware_meter_available`] and [`measure_marginal_uj`] use +/// IOReport only when this is [`IoReportStatus::Live`]. Exposed so a caller can +/// say *why* an Apple Silicon host has no hardware reading instead of guessing. +/// Always `Unavailable` off macOS; it does not describe Linux RAPL. +pub fn ioreport_status() -> IoReportStatus { + #[cfg(target_os = "macos")] + { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(|| match macos_ioreport::probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET) { + None => IoReportStatus::Unavailable, + Some(macos_ioreport::CpuRail::Live) => IoReportStatus::Live, + Some(macos_ioreport::CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(macos_ioreport::CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + }) + } + #[cfg(not(target_os = "macos"))] + { + IoReportStatus::Unavailable + } +} + /// Run `work` on every spare core for a window, bracketed against idle, and /// return `(idle_w, busy_w, window_s, total_runs)`. The power readings come from /// the platform interface; `None` if none is reachable. @@ -102,11 +172,12 @@ fn busy_power() -> Option { // Apple Silicon power via the IOReport Energy Model C API — energy delta over a // timed window ÷ window = watts, the same shape as the Linux RAPL path. No sudo, // no external binary. Ported from jouleclaw-energy's validated `apple_ioreport`. +// Used only while the CPU energy rail is live: see `probe_cpu_rail`. #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] mod macos_ioreport { use std::os::raw::{c_int, c_long, c_void}; - use std::time::Duration; + use std::time::{Duration, Instant}; type CFTypeRef = *const c_void; type CFStringRef = *const c_void; @@ -186,24 +257,140 @@ mod macos_ioreport { Some((sub, subscribed)) } - /// `true` if an IOReport subscription opens (Apple Silicon, not Rosetta). + /// `true` only if an IOReport subscription opens (Apple Silicon, not + /// Rosetta) **and** its CPU energy rail is live — see + /// [`super::ioreport_status`], which probes once per process and caches. A + /// subscription alone is not enough: over a frozen CPU rail the summed total + /// moves only with the GPU, or not at all. pub fn available() -> bool { + super::ioreport_status() == super::IoReportStatus::Live + } + + /// Whether this host's CPU energy rail is live, frozen, or not present. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum CpuRail { + /// The CPU energy rail advanced while this thread burned CPU. + Live, + /// The rail exists but held its value across the whole burn. + Frozen, + /// No `CPU Energy` channel exists in the Energy Model group. + Absent, + } + + /// Open a subscription, burn CPU on this thread for up to `budget` — ~25 ms + /// of spin before each re-sample — and report whether the CPU energy rail + /// **itself** advanced. `None` if no subscription opens. + /// + /// The summed total [`power_over`] reads is not a liveness signal. Measured + /// on an M5 Max and an M3 Ultra, both on macOS 27.0.1: every CPU rail in the + /// Energy Model group (`CPU Energy`, `PCPU`, `MCPU*`, `PACC_*`, + /// `DIE_n_CPU Energy`) held the same cumulative value across minutes of full + /// CPU load, while on the M5 Max `GPU Energy` kept advancing. A total that + /// moves therefore proves only that something drew on screen. Liveness is + /// decided on the CPU rail alone. + /// + /// Sampling every channel is slow: on that M5 Max one sample took 0.4–0.8 s, + /// mostly waiting (one timed on the thread clock spent 75 ms of 0.81 s + /// on-CPU), so `budget` buys two or three samples and the spin is a small + /// share of the window. Each delta is taken against the + /// first sample, so an advance anywhere in the window counts. The spin + /// cannot manufacture `Live` — only the rail's own raw integer moving can — + /// so a short spin can only err toward declining, the safe side. + pub fn probe_cpu_rail(budget: Duration) -> Option { unsafe { - match open() { - Some((sub, ch)) => { - CFRelease(sub); - CFRelease(ch as CFTypeRef); - true - } - None => false, - } + let (sub, ch) = open()?; + let rail = probe_subscription(sub, ch, budget); + CFRelease(sub); + CFRelease(ch as CFTypeRef); + Some(rail) } } + unsafe fn probe_subscription( + sub: IOReportSubscriptionRef, + ch: CFMutableDictionaryRef, + budget: Duration, + ) -> CpuRail { + let s0 = IOReportCreateSamples(sub, ch, std::ptr::null()); + if s0.is_null() { + return CpuRail::Absent; + } + let started = Instant::now(); + let mut acc: u64 = 0; + let (mut saw_rail, mut advanced) = (false, false); + while !advanced && started.elapsed() < budget { + let tick = Instant::now(); + while tick.elapsed() < Duration::from_millis(25) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); + let s1 = IOReportCreateSamples(sub, ch, std::ptr::null()); + if s1.is_null() { + continue; + } + let delta = IOReportCreateSamplesDelta(s0, s1, std::ptr::null()); + CFRelease(s1); + if delta.is_null() { + continue; + } + let (found, moved) = cpu_rail_moved(delta); + CFRelease(delta); + saw_rail |= found; + advanced = moved; + } + CFRelease(s0); + match (saw_rail, advanced) { + (_, true) => CpuRail::Live, + (true, false) => CpuRail::Frozen, + (false, false) => CpuRail::Absent, + } + } + + /// In a delta sample: does a CPU energy channel exist in the Energy Model + /// group (`CPU Energy`, or `DIE__CPU Energy` on multi-die parts), and did + /// any of them advance? Decided on the raw integer, before unit conversion, + /// so a small advance in a fine unit cannot truncate to zero — and never on + /// the summed total, which the live GPU rail corrupts. + unsafe fn cpu_rail_moved(delta: CFDictionaryRef) -> (bool, bool) { + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + if key.is_null() { + return (false, false); + } + let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if arr.is_null() { + return (false, false); + } + let (mut found, mut moved) = (false, false); + for i in 0..CFArrayGetCount(arr) { + let item = CFArrayGetValueAtIndex(arr, i); + if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { + continue; + } + let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) else { + continue; + }; + if strip_die_prefix(&name).eq_ignore_ascii_case("CPU Energy") { + found = true; + moved |= IOReportSimpleGetIntegerValue(item, 0) > 0; + } + } + (found, moved) + } + /// Average SoC power (watts) over `d`: two Energy-Model samples bracketing a - /// sleep, energy delta ÷ window. `None` if IOReport is unavailable or the - /// signal is empty. + /// sleep, energy delta ÷ window. `None` if IOReport is unavailable, its CPU + /// energy rail is not live ([`available`]), or the signal is empty. pub fn power_over(d: Duration) -> Option { + // Declined before sampling: on a frozen CPU rail the summed delta below + // would be GPU energy, or zero, attributed to CPU work. + if !available() { + return None; + } unsafe { let (sub, ch) = open()?; let s0 = IOReportCreateSamples(sub, ch, std::ptr::null()); @@ -342,6 +529,17 @@ mod macos_ioreport { #[cfg(test)] mod tests { use super::*; + use crate::meter::{ + hardware_meter_available, ioreport_status, measure_marginal_uj, IoReportStatus, + CPU_RAIL_LIVENESS_BUDGET, + }; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + /// Real, cheap, pure CPU work for the metering tests. + fn cpu_work() { + std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); + } #[test] fn energy_to_uj_scales_by_unit() { @@ -355,13 +553,234 @@ mod macos_ioreport { assert!(!counts_once("DIE_0_PACC0_CPU0")); } + /// `power_over` yields watts only through a live CPU rail, and a + /// plausible figure when it does; a declined IOReport yields `None`. + /// Replaces a version that returned early without IOReport and asserted + /// only inside `if let Some`, so every `None` passed silently. #[test] - fn power_is_plausible_when_available() { - if !available() { - return; // not Apple Silicon + fn power_over_declines_unless_the_cpu_rail_is_live() { + let w = power_over(Duration::from_millis(300)); + if available() { + assert!( + matches!(w, Some(w) if w > 0.0 && w < 500.0), + "a live CPU rail gave implausible SoC watts: {w:?}" + ); + } else { + assert_eq!( + w, + None, + "power_over produced watts although IOReport is declined ({:?})", + ioreport_status() + ); } - if let Some(w) = power_over(Duration::from_millis(300)) { - assert!(w > 0.0 && w < 500.0, "implausible SoC watts: {w}"); + } + + /// What the independent oracle saw on this host. + #[derive(Debug, PartialEq)] + enum Oracle { + NoIoReport, + NoCpuRail, + CpuRailMoved(bool), + } + + /// An oracle that shares nothing with the liveness probe but the raw FFI. + /// It opens its own subscription (not through `open()`), reads each CPU + /// rail's ABSOLUTE cumulative value (no delta samples), matches names with + /// `ends_with("cpu energy")` in any group and any case rather than the + /// probe's Energy-Model-group die-prefix stripping — a superset of the + /// probe's rails — and burns CPU for the whole of `budget` on a helper + /// thread while it looks for any change. + fn oracle_cpu_rail(budget: Duration) -> Oracle { + unsafe { + let all = IOReportCopyAllChannels(0, 0); + if all.is_null() { + return Oracle::NoIoReport; + } + let mut subscribed: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription( + std::ptr::null(), + all, + &mut subscribed as *mut _, + 0, + std::ptr::null(), + ); + CFRelease(all as CFTypeRef); + if sub.is_null() || subscribed.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + return Oracle::NoIoReport; + } + let verdict = oracle_on(sub, subscribed, budget); + CFRelease(sub); + CFRelease(subscribed as CFTypeRef); + verdict + } + } + + unsafe fn oracle_on( + sub: IOReportSubscriptionRef, + ch: CFMutableDictionaryRef, + budget: Duration, + ) -> Oracle { + let Some(v0) = cpu_rail_absolutes(sub, ch) else { + return Oracle::NoIoReport; + }; + if v0.is_empty() { + return Oracle::NoCpuRail; + } + // The burn runs on its own thread for the WHOLE window. One sample of + // every channel takes 0.4–0.8 s on an M5 Max, so spinning only between + // samples, as the probe does, would burn a small share of `budget`. + let stop = Arc::new(AtomicBool::new(false)); + let burner = { + let stop = stop.clone(); + std::thread::spawn(move || { + let mut acc: u64 = 0; + while !stop.load(Ordering::Relaxed) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + }) + }; + let started = Instant::now(); + let mut moved = false; + while !moved && started.elapsed() < budget { + moved = cpu_rail_absolutes(sub, ch).is_some_and(|v| v.len() == v0.len() && v != v0); + } + stop.store(true, Ordering::Relaxed); + let _ = burner.join(); + Oracle::CpuRailMoved(moved) + } + + /// The absolute cumulative value of every channel whose name ends in + /// "cpu energy" (any case, any group), in channel order. + unsafe fn cpu_rail_absolutes( + sub: IOReportSubscriptionRef, + ch: CFMutableDictionaryRef, + ) -> Option> { + let smp = IOReportCreateSamples(sub, ch, std::ptr::null()); + if smp.is_null() { + return None; + } + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + let mut vals = Vec::new(); + if !key.is_null() { + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if !arr.is_null() { + for i in 0..CFArrayGetCount(arr) { + let it = CFArrayGetValueAtIndex(arr, i); + if it.is_null() { + continue; + } + let name = cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); + if name.to_ascii_lowercase().ends_with("cpu energy") { + vals.push(IOReportSimpleGetIntegerValue(it, 0) as i64); + } + } + } + } + CFRelease(smp); + Some(vals) + } + + /// The property that matters: the IOReport meter is **never** available + /// when its CPU rail is frozen, because then it would bracket GPU-only + /// energy (or zero) as the energy of CPU work. Deterministic on any host — + /// it asserts the meter's decision against what the rail actually did, and + /// every branch asserts something. + /// + /// The oracle gets three times the probe's budget, so a rail too slow for + /// the probe but moving within the oracle's window is the only case that + /// may be declined while live — and that fails safe: no hardware figure, + /// never an over-claimed one. + #[test] + fn the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail() { + let status = ioreport_status(); + let selected = hardware_meter_available(); + let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); + // Recorded so a CI log says which branch below was actually asserted. + eprintln!("ioreport liveness: status={status:?} oracle={oracle:?} selected={selected}"); + match oracle { + Oracle::NoIoReport => { + assert_eq!(status, IoReportStatus::Unavailable); + assert!(!selected, "a hardware meter was selected with no IOReport"); + } + Oracle::NoCpuRail => { + assert_eq!(status, IoReportStatus::CpuRailAbsent); + assert!(!selected, "selected an IOReport meter that has no CPU rail"); + } + Oracle::CpuRailMoved(false) => { + assert!( + !selected, + "IOReport meter available although its CPU rail did not move under {:?} of CPU \ + burn — it would bracket GPU-only energy, or zero, as the energy of CPU work", + CPU_RAIL_LIVENESS_BUDGET * 3 + ); + assert_eq!(status, IoReportStatus::CpuRailFrozen); + // Declined end to end: no figure comes out of a frozen rail. + assert_eq!(power_over(Duration::from_millis(200)), None, "watts read from a frozen CPU rail"); + assert_eq!(measure_marginal_uj(cpu_work), None, "energy metered from a frozen CPU rail"); + } + Oracle::CpuRailMoved(true) => { + // A live rail. The probe had a third of the oracle's time: if it + // saw the move the meter is selected, and if it did not, declining + // is the safe side. Either way it is the invariant below. + } + } + // On macOS IOReport is the only interface: selected exactly when Live. + assert_eq!( + selected, + status == IoReportStatus::Live, + "hardware_meter_available() disagrees with ioreport_status() = {status:?}" + ); + } + + /// The dangerous direction on its own: the probe never reports a live rail + /// the oracle cannot see move. The probe runs fresh here (not the cached + /// status) and the oracle runs unconditionally, so the implication is + /// evaluated on every host rather than only on one that claims Live. + #[test] + fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { + let probe = probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET); + let status = ioreport_status(); + let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); + eprintln!("ioreport probe: probe={probe:?} status={status:?} oracle={oracle:?}"); + let claimed_live = probe == Some(CpuRail::Live) || status == IoReportStatus::Live; + assert!( + !claimed_live || oracle == Oracle::CpuRailMoved(true), + "probe said Live (probe={probe:?}, status={status:?}) but the oracle saw {oracle:?}" + ); + } + + /// When the meter IS selected, a multi-core CPU burst meters as non-zero + /// energy through a rail that moves; when it is not, the status says why + /// and no figure comes out. No branch goes unasserted. + #[test] + fn a_selected_ioreport_meter_meters_cpu_work() { + if hardware_meter_available() { + assert_eq!(ioreport_status(), IoReportStatus::Live, "selected on a CPU rail that is not live"); + assert_eq!( + probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET), + Some(CpuRail::Live), + "the CPU rail stopped advancing after the meter was selected" + ); + let uj = measure_marginal_uj(cpu_work); + assert!( + matches!(uj, Some(u) if u > 0), + "a live CPU rail metered a multi-core CPU burst as {uj:?}" + ); + } else { + assert_ne!( + ioreport_status(), + IoReportStatus::Live, + "status Live but the IOReport meter was not selected" + ); + assert_eq!(measure_marginal_uj(cpu_work), None, "a declined IOReport meter produced a figure"); } } } @@ -416,15 +835,29 @@ fn busy_power() -> Option { mod tests { use super::*; + /// A figure exactly when a usable interface exists: `None` — never a number — + /// where none is (including a declined IOReport), and a plausible per-call + /// figure where one is. Replaces a version that asserted only inside + /// `if let Some`, so a `None` passed silently on every host. #[test] fn meter_is_total_or_nothing() { - // None on a machine with no interface (CI-safe); a positive, plausible - // marginal where one exists. let r = measure_marginal_uj(|| { std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); }); - if let Some(uj) = r { + if hardware_meter_available() { + let uj = r.expect("a usable hardware interface returned no reading"); assert!(uj < 100_000_000, "implausible per-call energy {uj} uj"); + } else { + assert_eq!(r, None, "no usable hardware interface ({:?}), yet a figure came out", ioreport_status()); } } + + /// IOReport is an Apple Silicon interface: off macOS the status says so. + #[cfg(not(target_os = "macos"))] + #[test] + fn ioreport_status_is_unavailable_off_macos() { + assert_eq!(ioreport_status(), IoReportStatus::Unavailable); + #[cfg(not(target_os = "linux"))] + assert!(!hardware_meter_available(), "no interface is implemented for this OS"); + } } diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index 65c570260..3e6a8d308 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -88,14 +88,17 @@ fn main() { ); // --measure: upgrade the headline from a wall-time model to a REAL hardware - // reading. Bracket the whole batch against idle on the platform's energy - // interface (macmon/IOReport on Apple Silicon, RAPL on Linux) and report the - // marginal joules with HwShunt provenance — the schema's strongest tier. + // reading. Bracket a real T0 proof against idle on the platform's energy + // interface (IOReport on Apple Silicon, used only while its CPU energy rail + // is live; RAPL on Linux) and report the marginal joules with HwShunt + // provenance — the schema's strongest tier. Where no interface is usable, + // print why: the figures above then stay ModelBased. if measure { use proof_cascade::meter::{hardware_meter_available, measure_marginal_uj}; if !hardware_meter_available() { println!( - "\n --measure: no hardware energy interface reachable here (need macmon on macOS\n or RAPL on Linux). The figures above stay ModelBased — honestly tagged." + "\n --measure: no hardware energy reading on this host: {}.\n The figures above stay ModelBased — honestly tagged.", + no_hardware_reading_reason() ); } else { // Meter a REAL, non-trivial T0 proof: validity of a 16-variable @@ -122,9 +125,15 @@ fn main() { ); println!(" the standard's headline (joules per verified proof) is now a real hardware reading, not a model."); } - _ => println!( + // The interface answered, and busy did not clear idle. + Some(_) => println!( " measured: signal stayed inside the idle noise (a loaded/throttled box) — run on a quiet machine." ), + // No reading at all: say why rather than blaming the machine. + None => println!( + " measured: no hardware reading: {}. The figures above stay ModelBased.", + no_hardware_reading_reason() + ), } } } @@ -163,6 +172,28 @@ fn main() { println!(" was not validated against intent — a verified proof of an unverified spec."); } +/// Why `--measure` has no hardware reading on this host. On Apple Silicon this +/// comes from the meter's cached IOReport status, so a frozen CPU energy rail is +/// named as such instead of being misread as a noisy machine. +fn no_hardware_reading_reason() -> &'static str { + use proof_cascade::meter::{ioreport_status, IoReportStatus}; + if cfg!(target_os = "macos") { + match ioreport_status() { + IoReportStatus::CpuRailFrozen => { + "IOReport opens, but its CPU energy rail is frozen (it did not advance while this \ + process burned CPU), so it cannot meter CPU work" + } + IoReportStatus::CpuRailAbsent => "IOReport opens, but exposes no CPU energy rail", + IoReportStatus::Unavailable => "IOReport is unavailable (it needs Apple Silicon, not Rosetta)", + IoReportStatus::Live => "IOReport's CPU energy rail is live, but the bracket returned no reading", + } + } else if cfg!(target_os = "linux") { + "no readable RAPL counter at /sys/class/powercap/intel-rapl:*/energy_uj" + } else { + "no hardware energy interface is supported on this OS" + } +} + fn truncate(s: &str, n: usize) -> String { if s.chars().count() <= n { s.to_string() diff --git a/proof/proof-rs/crates/proof-cli/tests/measure.rs b/proof/proof-rs/crates/proof-cli/tests/measure.rs new file mode 100644 index 000000000..7d9fc6474 --- /dev/null +++ b/proof/proof-rs/crates/proof-cli/tests/measure.rs @@ -0,0 +1,55 @@ +//! `proof --measure`, end to end: when the hardware meter declines, the output +//! names the reason and nothing in it claims a hardware reading; when it +//! meters, the host really has a usable interface. + +use std::process::Command; + +#[test] +fn measure_names_why_it_declined_and_claims_no_hardware_reading() { + let out = Command::new(env!("CARGO_BIN_EXE_proof")) + .arg("--measure") + .output() + .expect("the proof binary runs"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + out.status.success(), + "proof --measure exited {:?}\n{stdout}\n{}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); + let status = proof_cascade::meter::ioreport_status(); + let declined = stdout.contains("--measure: no hardware energy reading on this host: "); + // Recorded so a CI log says which branch below was actually asserted. + eprintln!("proof --measure: declined={declined} ioreport_status={status:?}"); + + if declined { + assert!( + !stdout.contains("HwShunt"), + "the meter declined, yet the output claims a hardware reading:\n{stdout}" + ); + assert!( + !stdout.contains("idle noise"), + "the meter declined, yet the output blames a noisy machine:\n{stdout}" + ); + assert!( + stdout.contains("The figures above stay ModelBased"), + "a declined meter must leave the ModelBased figures standing:\n{stdout}" + ); + if status == proof_cascade::meter::IoReportStatus::CpuRailFrozen { + assert!( + stdout.contains("its CPU energy rail is frozen"), + "a frozen IOReport CPU rail must be named as the reason:\n{stdout}" + ); + } + } else { + assert!( + stdout.contains("--measure: metering a real 16-variable T0 proof") && stdout.contains(" measured: "), + "neither a declined meter nor a measurement in the output:\n{stdout}" + ); + // A figure may only come from an interface this host really exposes. + assert!( + proof_cascade::meter::hardware_meter_available(), + "the CLI metered on a host whose meter declines here ({status:?}):\n{stdout}" + ); + } +} -- 2.54.0 (Apple Git-157) From 5d01888f20ee2367ddbce915514c85be59662a4c Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:30:09 -0400 Subject: [PATCH 02/10] proof-cascade: IOReport is live only when one CPU rail advances twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family's energy contract: "measured when the platform exposes it, estimated when it doesn't, and the provenance always says which ... A reading never silently upgrades its own provenance." a5c77d8d stopped this meter from bracketing a frozen CPU rail, but its probe had two defects of its own. - It called the rail Live on ONE advance. On macOS 27.0.1 the Energy Model CPU rails are not permanently frozen for an unprivileged reader: they are refreshed in rare batches. Both of an M3 Ultra's `DIE__CPU Energy` rails held for at least 22 minutes, then stepped +1,344,869 and +1,391,637 mJ in a single 100 ms sample and held again; an M5 Max's `CPU Energy` went from 480,083,160 to 541,893,638 mJ between ~10:30 and 10:47 but did not change in 13,614 samples over the next 25 minutes. A probe whose window straddles a refresh sees one jump, says Live, and caches that for the process; from then on `bracket()` would hand each later ~1.3 kJ refresh, whole, to whichever idle or busy window straddled it, tagged HwShunt. This false Live was seen at ~10:46 on the M5 Max by the jouleclaw-energy work. - It sampled an all-channel subscription (~11,900 channels on the M5 Max), so "spins ~25 ms before each re-sample" was not what happened: at the 0.4-0.8 s a5c77d8d measured per sample, the 1 s budget bought two or three samples, each compared with the first. The fix brings the probe onto the family liveness rule, the one jouleclaw-energy (b9d7e945) and eoc-meter (c6378629) implement, copied in so the crate stays self-contained: 1. The probe samples its OWN subscription to the "Energy Model" group only (IOReportCopyChannelsInGroup, then IOReportCreateSubscription). If the group cannot be subscribed, opening the all-channel subscription tells "no such group" (CpuRailAbsent) from "no IOReport" (Unavailable). `power_over()`'s own all-channel read path is unchanged. 2. Liveness is decided per CPU rail (the name, after stripping `DIE__`, equals "CPU Energy" case-insensitively), on the raw integer between CONSECUTIVE samples, never on a summed total: Live means the same rail advanced in at least two separate sample intervals. 3. It spins 25 ms on the calling thread before each sample, until Live, or until the 1 s budget has elapsed and at least two intervals were observed, with a hard cap of five budgets. 4. Live, Frozen, Absent or Unavailable (no sample could be taken), cached once per process as before; anything but Live declines the rung. 5. The rule is a pure struct, `liveness::RailWatch`, in a module compiled on every platform, so proof-rs's Linux CI job runs its tests too. The IOReport FFI only feeds it. The public API is unchanged: IoReportStatus and its variants, ioreport_status(), CPU_RAIL_LIVENESS_BUDGET, hardware_meter_available() and measure_marginal_uj(). Docs made false by the change are corrected: the module header, IoReportStatus::CpuRailFrozen / Live, CPU_RAIL_LIVENESS_BUDGET ("buys two or three samples, each compared with the first"), hardware_meter_available's probe cost, and probe_cpu_rail. proof-cli keeps its messages; the frozen-rail reason's parenthetical now says "it did not keep advancing" instead of "it did not advance", since a rail that stepped once is also frozen. tests/measure.rs is unchanged. Measured tonight (01:24-01:29) with a standalone timer: one IOReportCreateSamples on the Energy Model group (364 channels on the M5 Max, 645 on the M3 Ultra) takes 5.1-5.4 ms median on both chips, all on-CPU. One on every channel took ~106-111 ms on the M5 Max (75 ms on-CPU) and ~133 ms on the M3 Ultra, well under a5c77d8d's 0.4-0.8 s figure, so that cost varies. The docs give 0.1-0.8 s. The probe now takes 1.09-1.14 s for a Frozen verdict, over 29-32 intervals of ~35 ms, both chips; under a full-core burst from a concurrent test it saw 16. Tests. Seven rule tests, no hardware: a_single_jump_is_a_batch_refresh_not_a_ live_rail, one_jump_on_each_die_is_not_a_live_rail, both_dies_stepping_together_once_is_not_a_live_rail (the M3 Ultra's observed refresh), two_advances_of_the_same_rail_are_live, no_interval_is_no_verdict_and_no_cpu_rail_is_absent, the_probe_samples_until_live_or_long_enough_to_tell (budget, two-interval floor, five-budget cap, one advance does not stop it) and only_the_cpu_energy_aggregate_is_a_cpu_rail. The hardware oracle keeps its own all-channel subscription, ABSOLUTE values and the helper thread that burns CPU for the whole 3x-budget window. It now matches names with ends_with("CPU Energy"), as the family rule says, where it used a lowercase match, and reports CpuRailMoved(true) only when one rail's absolute value changed at two or more separate reads. The old oracle took any single change. It logs "ioreport oracle: N reads in T, changes per CPU rail [..]". The probe test now checks every outcome against the oracle, not only Live, requires a Frozen verdict to rest on at least two intervals unless the cap cut it short, and logs probe_took and intervals. Observed, proof-cascade lib tests, macOS 27.0.1: M5 Max: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false ioreport probe: probe=Some(Frozen) status=CpuRailFrozen oracle=CpuRailMoved(false) probe_took=1.093259666s intervals=31 test result: ok. 23 passed; 0 failed M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: changes per CPU rail [0, 0]) ioreport probe: probe=Some(Frozen) status=CpuRailFrozen oracle=CpuRailMoved(false) probe_took=1.137413416s intervals=32 test result: ok. 23 passed; 0 failed (16 before this commit.) Falsified on the M5 Max. Each mutation was applied to this commit, then restored with git checkout, with a clean tree after each: - F-rule, LIVE_RAIL_ADVANCES = 1: five of the seven rule tests fail, e.g. "one refresh counted as a live rail", "one advance is not yet live", "one interval, past budget: keep going", and one_jump_on_each_die "left: Some(Live) right: Some(Frozen)". - F1, the probe always says Live: the frozen-rail test fails with "IOReport meter available although its CPU rail did not keep moving under 3s of CPU burn — it would bracket GPU-only energy, zero, or a refresh as the energy of CPU work" (status=Live oracle=CpuRailMoved(false) selected=true). The probe test and the selected-meter test also fail ("a live CPU rail metered a multi-core CPU burst as Some(0)"). - F2, main's ungated available(): the frozen-rail test fails with the same message (status=CpuRailFrozen oracle=CpuRailMoved(false) selected=true), and so does the selected-meter test ("selected on a CPU rail that is not live"). - F-sum, liveness decided on the summed Energy Model total: the live GPU rail makes the probe say Live after 2 intervals in 167 ms. The frozen-rail test fails with the message above (status=Live selected=true), and so do the probe and selected-meter tests. The proof-rs workspace passes on the M5 Max (z3 present), as does the proof-conformance binary (10/10, "all checks passed"). `proof --measure` declines with the frozen-rail reason on both chips, with no "HwShunt" and no "idle noise" in its output. The run takes 1.24 s (M5 Max) and 1.17 s (M3 Ultra). Dependants: joule-code-release (6) and joulecontract-proof (6) pass on both. joule-code-remediate's verified_fix_end_to_end_ships_with_receipt fails with "assertion failed: report.total_uj() > 0" (7 passed, 1 failed on the M5 Max), the same as on a5c77d8d run from a git archive. On the M3 Ultra it is 6 passed, 2 failed: that test and the formal-template test described below. It meters through sandbox-meter; no dependant calls proof_cascade::meter. On the M3 Ultra the shipped binaries of proof-rs's lib/soundness tests pass. measure.rs, proof-conformance's pack.rs and remediate's formal-template test bake this checkout's absolute paths into the binary (CARGO_BIN_EXE_*, CARGO_MANIFEST_DIR), so they cannot run on another host. There, measure.rs's assertions were checked by hand against `proof --measure`. clippy --no-deps --tests on proof-cascade and proof-cli adds no warnings (the duplicated #[link] attribute and the b"..\0" literal are on a5c77d8d). clippy for x86_64-unknown-linux-gnu is clean, including the liveness module off macOS. Not done here, deliberately: - The group-only probe is shown by the logged interval count, not asserted. A count threshold would flake under load (16 intervals under a concurrent full-core burst). - power_over() still reads an all-channel subscription and divides by the sleep, not the sample-to-sample window. The grade, the partial GPU reading, a rail that stops tracking after the cached probe, and the RAPL notes all stand as a5c77d8d listed them. - proof/README.md still says the meter reads IOReport "via `macmon`", which has been stale since a5c77d8d. Co-Authored-By: Claude Opus 5.5 --- .../crates/proof-cascade/src/meter.rs | 596 ++++++++++++++---- proof/proof-rs/crates/proof-cli/src/main.rs | 4 +- 2 files changed, 476 insertions(+), 124 deletions(-) diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index 344a70db9..d14a9ba80 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -11,11 +11,16 @@ //! //! **On Apple Silicon, IOReport counts as reachable only while its CPU energy //! rail is live** ([`ioreport_status`](crate::meter::ioreport_status)). A -//! subscription can open while every CPU rail holds a frozen value: measured on -//! macOS 27.0.1, an M5 Max keeps only its GPU rail advancing and an M3 Ultra -//! advances none. Bracketing CPU work against idle on those rails brackets GPU -//! activity, or nothing, so the interface is declined, `None` is returned, and -//! the caller keeps its `ModelBased` figure. +//! subscription can open while no CPU rail tracks the work: measured on macOS +//! 27.0.1 for an unprivileged reader, the CPU rails of an M5 Max and an M3 Ultra +//! hold their value under minutes of full CPU load and move only in rare batch +//! refreshes (both M3 Ultra dies held for at least 22 minutes, then stepped by +//! ~1.3 kJ each within one 100 ms sample), while the M5 Max's GPU rail keeps +//! advancing. Bracketing CPU work against idle on those rails brackets GPU +//! activity, nothing, or a refresh. So the interface is used only when the same +//! CPU rail advances in at least two separate sample intervals while the probe +//! burns CPU; otherwise `None` is returned and the caller keeps its +//! `ModelBased` figure. //! //! It is a measurement with stated bounds — SoC/package power resolved to the //! marginal delta over an idle machine ÷ iterations — exactly the discipline @@ -53,9 +58,9 @@ where /// RAPL `energy_uj` on Linux; on Apple Silicon, IOReport **with a live CPU /// energy rail** — [`ioreport_status`] is [`IoReportStatus::Live`]. A /// subscription that opens over a frozen CPU rail does not count. On macOS the -/// first call pays the liveness probe (opening a subscription, a first sample, -/// then `CPU_RAIL_LIVENESS_BUDGET` of re-sampling); the answer is cached for -/// the process. +/// first call pays the liveness probe (its own Energy Model subscription, then +/// about `CPU_RAIL_LIVENESS_BUDGET` of spinning and re-sampling on the calling +/// thread); the answer is cached for the process. pub fn hardware_meter_available() -> bool { #[cfg(target_os = "macos")] { @@ -80,21 +85,27 @@ pub enum IoReportStatus { /// A subscription opened, but no `CPU Energy` rail exists in the Energy /// Model group — an unfamiliar channel naming. Never used. CpuRailAbsent, - /// The CPU energy rail exists but did not advance while CPU was burned. The - /// platform is not exposing live CPU energy to this process, so bracketing - /// CPU work on it would measure GPU-only energy, or nothing. Observed on - /// macOS 27.0.1 on both an M5 Max and an M3 Ultra. + /// A CPU energy rail exists but did not keep advancing while CPU was + /// burned: no CPU rail advanced in two separate sample intervals. A rail + /// that stepped once did not track the work either — that is a batch + /// refresh. The platform is not exposing live CPU energy to this process, + /// so bracketing CPU work on it would measure GPU-only energy, nothing, or + /// a refresh. Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. CpuRailFrozen, - /// The CPU energy rail advances under load; IOReport is used. + /// The same CPU energy rail advanced in at least two separate sample + /// intervals while the probe burned CPU; IOReport is used. Live, } -/// How long the liveness probe keeps re-sampling (spinning between samples) -/// before concluding the CPU rail is frozen. One sample of every channel took -/// 0.4–0.8 s on an M5 Max, so this buys two or three samples, each compared -/// with the first — a window of 1.1–1.9 s there. A live rail slower than that -/// fails safe: declined, and the caller keeps its `ModelBased` figure. Paid once -/// per process; see [`ioreport_status`]. +/// How long the liveness probe watches the CPU energy rail, spinning ~25 ms on +/// the calling thread before each re-sample, before concluding it is frozen. +/// The probe samples its own subscription to the Energy Model group alone, ~5 ms +/// a sample on an M5 Max and an M3 Ultra (every channel: 0.1–0.8 s on the M5 +/// Max, ~0.13 s on the M3 Ultra), so this buys about thirty intervals of ~35 ms, +/// where a live rail needs two. If fewer than two intervals were observed when +/// it runs out, the probe keeps sampling, up to five budgets. A live rail slower +/// than that fails safe: declined, and the caller keeps its `ModelBased` +/// figure. Paid once per process; see [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); @@ -110,9 +121,9 @@ pub fn ioreport_status() -> IoReportStatus { static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); *STATUS.get_or_init(|| match macos_ioreport::probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET) { None => IoReportStatus::Unavailable, - Some(macos_ioreport::CpuRail::Live) => IoReportStatus::Live, - Some(macos_ioreport::CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, - Some(macos_ioreport::CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + Some(liveness::CpuRail::Live) => IoReportStatus::Live, + Some(liveness::CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(liveness::CpuRail::Absent) => IoReportStatus::CpuRailAbsent, }) } #[cfg(not(target_os = "macos"))] @@ -157,6 +168,241 @@ where Some((idle_w, busy_w, window, runs)) } +// --------------------------------------------------------------------------- +// CPU-rail liveness: the decision, kept apart from the FFI so it is a pure, +// tested rule on every platform. Only the macOS probe uses it. +// --------------------------------------------------------------------------- +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod liveness { + use std::time::Duration; + + /// In how many separate sample intervals the **same** CPU rail must advance + /// to count as live. One is not enough: see [`RailWatch`]. + pub(super) const LIVE_RAIL_ADVANCES: u32 = 2; + + /// The probe's hard cap, in budgets, while it waits for enough intervals. + pub(super) const MAX_BUDGETS: u32 = 5; + + /// What the liveness probe saw of the CPU energy rail. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(super) enum CpuRail { + /// One CPU energy rail advanced in [`LIVE_RAIL_ADVANCES`] separate sample + /// intervals while the probing thread burned CPU. + Live, + /// A CPU energy rail exists, but none advanced in that many intervals: + /// it held its value, or moved in a single step (a batch refresh, not a + /// live reading). + Frozen, + /// No CPU energy channel exists in the Energy Model group. + Absent, + } + + /// The liveness rule, fed one sample interval at a time. + /// + /// A rail is live only when the **same** CPU rail advances in at least + /// [`LIVE_RAIL_ADVANCES`] separate sample intervals. One advance is not + /// enough, because on macOS 27.0.1 the CPU rails are not permanently frozen + /// for an unprivileged reader: they are refreshed in rare batches. An M3 + /// Ultra's `DIE_0_` / `DIE_1_CPU Energy` held for at least 22 minutes, then + /// stepped by +1,344,869 and +1,391,637 mJ in a single 100 ms sample and held + /// again; an M5 Max's `CPU Energy` did not change in 13,614 samples over 25 + /// minutes, yet had moved from 480,083,160 to 541,893,638 mJ earlier that + /// hour. A probe window that straddles a refresh sees one large jump, and a + /// rule that accepts it selects the meter for the life of the process, after + /// which every later ~1.3 kJ refresh lands whole in whichever bracket + /// straddles it. A live rail advances in nearly every interval (the M5 Max + /// GPU rail changes every ~28 ms). Counting per rail keeps two dies' separate + /// jumps from adding up to a false "live". + #[derive(Debug, Default)] + pub(super) struct RailWatch { + /// Sample intervals observed so far. + intervals: u32, + /// Each CPU rail seen, by channel name, with the number of intervals in + /// which its raw integer advanced. + rails: Vec<(String, u32)>, + } + + impl RailWatch { + /// Record one sample interval: every CPU rail in it, and whether its raw + /// integer advanced between the two consecutive samples. + pub(super) fn observe<'a>(&mut self, rails: impl IntoIterator) { + self.intervals += 1; + for (name, advanced) in rails { + match self.rails.iter_mut().find(|(n, _)| n == name) { + Some((_, count)) => *count += u32::from(advanced), + None => self.rails.push((name.to_string(), u32::from(advanced))), + } + } + } + + /// Sample intervals observed so far. + pub(super) fn intervals(&self) -> u32 { + self.intervals + } + + /// Has one CPU rail advanced in enough separate intervals to be live? + pub(super) fn is_live(&self) -> bool { + self.rails.iter().any(|(_, count)| *count >= LIVE_RAIL_ADVANCES) + } + + /// Keep sampling? Until a rail has proved live, or `budget` has elapsed + /// **and** a live rail could have shown [`LIVE_RAIL_ADVANCES`] advances — + /// one slow IOReport sample must not make a live rail look frozen — but + /// never past [`MAX_BUDGETS`] budgets. + pub(super) fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { + !self.is_live() + && (elapsed < budget + || (self.intervals < LIVE_RAIL_ADVANCES && elapsed < budget * MAX_BUDGETS)) + } + + /// The verdict, or `None` if not a single interval could be observed + /// (the caller reports IOReport as unavailable). + pub(super) fn verdict(&self) -> Option { + if self.intervals == 0 { + None + } else if self.is_live() { + Some(CpuRail::Live) + } else if self.rails.is_empty() { + Some(CpuRail::Absent) + } else { + Some(CpuRail::Frozen) + } + } + } + + /// Whether a channel is a CPU energy rail: its name, after stripping a + /// `DIE__` prefix, is `CPU Energy` (any case). Not the per-cluster or + /// per-core sub-rails (`PCPU`, `MCPU*`, `PACC_*`, `DIE_0_PACC0_CPU0`). + pub(super) fn is_cpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") + } + + /// Strip an optional `DIE__` prefix (multi-die parts like the M-series + /// Ultra report per-die aggregates). + pub(super) fn strip_die_prefix(name: &str) -> &str { + if let Some(rest) = name.strip_prefix("DIE_") { + let after = rest.trim_start_matches(|c: char| c.is_ascii_digit()); + if let Some(r) = after.strip_prefix('_') { + return r; + } + } + name + } + + #[cfg(test)] + mod tests { + use super::*; + + #[test] + fn only_the_cpu_energy_aggregate_is_a_cpu_rail() { + assert!(is_cpu_energy_rail("CPU Energy")); + assert!(is_cpu_energy_rail("cpu energy")); + assert!(is_cpu_energy_rail("DIE_0_CPU Energy")); + assert!(is_cpu_energy_rail("DIE_1_CPU Energy")); + assert!(is_cpu_energy_rail("DIE_12_CPU Energy")); + assert!(!is_cpu_energy_rail("GPU Energy")); + assert!(!is_cpu_energy_rail("DIE_0_GPU Energy")); + assert!(!is_cpu_energy_rail("ANE Energy")); + assert!(!is_cpu_energy_rail("PCPU")); + assert!(!is_cpu_energy_rail("MCPU0")); + assert!(!is_cpu_energy_rail("PACC_0")); + assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); + } + + /// A rail that moves in one step and then holds — a batch refresh caught + /// mid-probe — is not live, however large the step. + #[test] + fn a_single_jump_is_a_batch_refresh_not_a_live_rail() { + let mut w = RailWatch::default(); + for advanced in [false, true, false, false, false] { + w.observe([("CPU Energy", advanced)]); + } + assert!(!w.is_live(), "one refresh counted as a live rail"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + } + + /// Two dies each refreshed once, in different intervals, do not add up + /// to a live rail: liveness is counted per rail. + #[test] + fn one_jump_on_each_die_is_not_a_live_rail() { + let mut w = RailWatch::default(); + w.observe([("DIE_0_CPU Energy", true), ("DIE_1_CPU Energy", false)]); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + } + + /// The M3 Ultra's observed refresh: both dies step in the same interval, + /// once. Still one advance per rail, so still frozen. + #[test] + fn both_dies_stepping_together_once_is_not_a_live_rail() { + let mut w = RailWatch::default(); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); + w.observe([("DIE_0_CPU Energy", true), ("DIE_1_CPU Energy", true)]); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + } + + /// The same rail advancing in two separate intervals is live — on either + /// die — and not before. + #[test] + fn two_advances_of_the_same_rail_are_live() { + let mut w = RailWatch::default(); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + assert!(!w.is_live(), "one advance is not yet live"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + assert!(w.is_live()); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + } + + /// No observed interval at all is no verdict; intervals with no CPU + /// channel in them are `Absent`. + #[test] + fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { + let mut w = RailWatch::default(); + assert_eq!(w.verdict(), None); + w.observe([]); + w.observe([]); + assert_eq!(w.intervals(), 2); + assert_eq!(w.verdict(), Some(CpuRail::Absent)); + } + + /// The probe keeps sampling within its budget until a rail proves live; + /// past the budget only until a live rail could have advanced twice; and + /// never past five budgets. + #[test] + fn the_probe_samples_until_live_or_long_enough_to_tell() { + let budget = Duration::from_millis(1000); + let mut w = RailWatch::default(); + assert!(w.wants_more(Duration::ZERO, budget)); + assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); + // Slow samples: past the budget with fewer than two intervals, keep going. + assert!(w.wants_more(budget * 2, budget), "no interval yet, past budget: keep going"); + w.observe([("CPU Energy", false)]); + assert!(w.wants_more(budget * 2, budget), "one interval, past budget: keep going"); + assert!(w.wants_more(budget * 5 - Duration::from_millis(1), budget), "just under the cap"); + assert!(!w.wants_more(budget * 5, budget), "hard cap: five budgets"); + w.observe([("CPU Energy", false)]); + assert!(w.wants_more(budget / 2, budget), "two intervals, within budget: keep watching"); + assert!(!w.wants_more(budget, budget), "two intervals, budget spent: frozen"); + assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap with no interval"); + assert!(!RailWatch::default().wants_more(budget * 50, budget), "hard cap with no interval"); + + // One advance is not a reason to stop early. + let mut once = RailWatch::default(); + once.observe([("CPU Energy", true)]); + assert!(once.wants_more(Duration::ZERO, budget), "one advance: keep watching"); + + let mut live = RailWatch::default(); + live.observe([("CPU Energy", true)]); + live.observe([("CPU Energy", true)]); + assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); + } + } +} + // --------------------------------------------------------------------------- // Platform power readings (watts), averaged over a short window. // --------------------------------------------------------------------------- @@ -176,6 +422,7 @@ fn busy_power() -> Option { #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] mod macos_ioreport { + use super::liveness::{is_cpu_energy_rail, strip_die_prefix, CpuRail, RailWatch}; use std::os::raw::{c_int, c_long, c_void}; use std::time::{Duration, Instant}; @@ -189,6 +436,13 @@ mod macos_ioreport { unsafe extern "C" { fn IOReportCopyAllChannels(a: u64, b: u64) -> CFMutableDictionaryRef; + fn IOReportCopyChannelsInGroup( + group: CFStringRef, + subgroup: CFStringRef, + a: u64, + b: u64, + c: u64, + ) -> CFMutableDictionaryRef; fn IOReportCreateSubscription( allowed: *const c_void, desired_channels: CFMutableDictionaryRef, @@ -257,115 +511,165 @@ mod macos_ioreport { Some((sub, subscribed)) } + /// The liveness probe's own subscription: the Energy Model group alone + /// (`IOReportCopyChannelsInGroup`), not every channel. Its CPU rails are the + /// same counters, with the same absolute values, an all-channel subscription + /// reads, but a sample of the group (364 channels on an M5 Max, 645 on an M3 + /// Ultra) takes ~5 ms, where one of every channel (~11,900 and ~14,300) took + /// 0.1–0.8 s on the M5 Max and ~0.13 s on the M3 Ultra — so the probe really + /// re-samples after every ~25 ms of spin instead of spending its budget + /// waiting on IOReport. Caller must `CFRelease` both. + unsafe fn open_energy_model() -> Option<(IOReportSubscriptionRef, CFMutableDictionaryRef)> { + let group = CFStringCreateWithCString( + std::ptr::null_mut(), + c"Energy Model".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + if group.is_null() { + return None; + } + let desired = IOReportCopyChannelsInGroup(group, std::ptr::null(), 0, 0, 0); + CFRelease(group); + if desired.is_null() { + return None; + } + let mut subscribed: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription( + std::ptr::null(), + desired, + &mut subscribed as *mut _, + 0, + std::ptr::null(), + ); + CFRelease(desired as CFTypeRef); + if sub.is_null() || subscribed.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !subscribed.is_null() { + CFRelease(subscribed as CFTypeRef); + } + return None; + } + Some((sub, subscribed)) + } + /// `true` only if an IOReport subscription opens (Apple Silicon, not /// Rosetta) **and** its CPU energy rail is live — see /// [`super::ioreport_status`], which probes once per process and caches. A /// subscription alone is not enough: over a frozen CPU rail the summed total - /// moves only with the GPU, or not at all. + /// moves only with the GPU, with a batch refresh, or not at all. pub fn available() -> bool { super::ioreport_status() == super::IoReportStatus::Live } - /// Whether this host's CPU energy rail is live, frozen, or not present. - #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum CpuRail { - /// The CPU energy rail advanced while this thread burned CPU. - Live, - /// The rail exists but held its value across the whole burn. - Frozen, - /// No `CPU Energy` channel exists in the Energy Model group. - Absent, - } + /// CPU burned on the probing thread before each liveness re-sample. + const PROBE_TICK: Duration = Duration::from_millis(25); - /// Open a subscription, burn CPU on this thread for up to `budget` — ~25 ms - /// of spin before each re-sample — and report whether the CPU energy rail - /// **itself** advanced. `None` if no subscription opens. + /// Open the probe's own Energy Model subscription, burn CPU on this thread + /// — [`PROBE_TICK`] of spin before each re-sample — and report whether a CPU + /// energy rail **itself** keeps advancing, by the rule in [`RailWatch`]: + /// the same rail must advance in two separate sample intervals. `None` if + /// IOReport cannot open or sample. /// /// The summed total [`power_over`] reads is not a liveness signal. Measured /// on an M5 Max and an M3 Ultra, both on macOS 27.0.1: every CPU rail in the /// Energy Model group (`CPU Energy`, `PCPU`, `MCPU*`, `PACC_*`, - /// `DIE_n_CPU Energy`) held the same cumulative value across minutes of full - /// CPU load, while on the M5 Max `GPU Energy` kept advancing. A total that - /// moves therefore proves only that something drew on screen. Liveness is - /// decided on the CPU rail alone. + /// `DIE_n_CPU Energy`) held its cumulative value across minutes of full CPU + /// load, apart from rare batch refreshes, while on the M5 Max `GPU Energy` + /// kept advancing. A total that moves therefore proves only that something + /// drew on screen. Liveness is decided per CPU rail, on the raw integer + /// between consecutive samples. /// - /// Sampling every channel is slow: on that M5 Max one sample took 0.4–0.8 s, - /// mostly waiting (one timed on the thread clock spent 75 ms of 0.81 s - /// on-CPU), so `budget` buys two or three samples and the spin is a small - /// share of the window. Each delta is taken against the - /// first sample, so an advance anywhere in the window counts. The spin - /// cannot manufacture `Live` — only the rail's own raw integer moving can — - /// so a short spin can only err toward declining, the safe side. + /// Sampling runs until a rail proves live, or until `budget` has elapsed + /// and at least two intervals were observed, never past five budgets + /// ([`RailWatch::wants_more`]). The spin cannot manufacture `Live` — only the + /// rail's own raw integer advancing twice can — so a short spin errs only + /// toward declining, the safe side. pub fn probe_cpu_rail(budget: Duration) -> Option { + probe(budget).0 + } + + /// [`probe_cpu_rail`], and how many sample intervals it observed. + pub(super) fn probe(budget: Duration) -> (Option, u32) { unsafe { - let (sub, ch) = open()?; - let rail = probe_subscription(sub, ch, budget); + let Some((sub, ch)) = open_energy_model() else { + // No Energy Model subscription. Tell "no IOReport" from "no such + // group" by opening the all-channel one the meter reads. + let absent = open().map(|(sub, ch)| { + CFRelease(sub); + CFRelease(ch as CFTypeRef); + CpuRail::Absent + }); + return (absent, 0); + }; + let watch = watch_subscription(sub, ch, budget); CFRelease(sub); CFRelease(ch as CFTypeRef); - Some(rail) + (watch.verdict(), watch.intervals()) } } - unsafe fn probe_subscription( + /// Feed consecutive sample intervals of `sub` to a [`RailWatch`] while this + /// thread burns CPU, until it has a verdict. A watch with no interval means + /// the subscription could not be sampled. + unsafe fn watch_subscription( sub: IOReportSubscriptionRef, ch: CFMutableDictionaryRef, budget: Duration, - ) -> CpuRail { - let s0 = IOReportCreateSamples(sub, ch, std::ptr::null()); - if s0.is_null() { - return CpuRail::Absent; + ) -> RailWatch { + let mut watch = RailWatch::default(); + let mut prev = IOReportCreateSamples(sub, ch, std::ptr::null()); + if prev.is_null() { + return watch; } let started = Instant::now(); let mut acc: u64 = 0; - let (mut saw_rail, mut advanced) = (false, false); - while !advanced && started.elapsed() < budget { + while watch.wants_more(started.elapsed(), budget) { let tick = Instant::now(); - while tick.elapsed() < Duration::from_millis(25) { + while tick.elapsed() < PROBE_TICK { acc = acc.wrapping_add(1); } std::hint::black_box(acc); - let s1 = IOReportCreateSamples(sub, ch, std::ptr::null()); - if s1.is_null() { + let cur = IOReportCreateSamples(sub, ch, std::ptr::null()); + if cur.is_null() { continue; } - let delta = IOReportCreateSamplesDelta(s0, s1, std::ptr::null()); - CFRelease(s1); + let delta = IOReportCreateSamplesDelta(prev, cur, std::ptr::null()); if delta.is_null() { + CFRelease(cur); continue; } - let (found, moved) = cpu_rail_moved(delta); + let rails = cpu_rail_advances(delta); CFRelease(delta); - saw_rail |= found; - advanced = moved; - } - CFRelease(s0); - match (saw_rail, advanced) { - (_, true) => CpuRail::Live, - (true, false) => CpuRail::Frozen, - (false, false) => CpuRail::Absent, + CFRelease(prev); + prev = cur; + watch.observe(rails.iter().map(|(name, advanced)| (name.as_str(), *advanced))); } + CFRelease(prev); + watch } - /// In a delta sample: does a CPU energy channel exist in the Energy Model - /// group (`CPU Energy`, or `DIE__CPU Energy` on multi-die parts), and did - /// any of them advance? Decided on the raw integer, before unit conversion, - /// so a small advance in a fine unit cannot truncate to zero — and never on - /// the summed total, which the live GPU rail corrupts. - unsafe fn cpu_rail_moved(delta: CFDictionaryRef) -> (bool, bool) { + /// In a samples-delta dictionary (two consecutive samples): every CPU + /// energy rail in the Energy Model group ([`is_cpu_energy_rail`]), and + /// whether its raw integer advanced over that interval. Raw integers, before + /// unit conversion, so a small advance in a fine unit cannot truncate to + /// zero; per rail, never the summed total, which the live GPU rail corrupts. + unsafe fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { + let mut rails = Vec::new(); let key = CFStringCreateWithCString( std::ptr::null_mut(), c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); if key.is_null() { - return (false, false); + return rails; } let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); if arr.is_null() { - return (false, false); + return rails; } - let (mut found, mut moved) = (false, false); for i in 0..CFArrayGetCount(arr) { let item = CFArrayGetValueAtIndex(arr, i); if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { @@ -374,12 +678,12 @@ mod macos_ioreport { let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) else { continue; }; - if strip_die_prefix(&name).eq_ignore_ascii_case("CPU Energy") { - found = true; - moved |= IOReportSimpleGetIntegerValue(item, 0) > 0; + if is_cpu_energy_rail(&name) { + let advanced = IOReportSimpleGetIntegerValue(item, 0) > 0; + rails.push((name, advanced)); } } - (found, moved) + rails } /// Average SoC power (watts) over `d`: two Energy-Model samples bracketing a @@ -481,16 +785,6 @@ mod macos_ioreport { uj as u64 } - fn strip_die_prefix(name: &str) -> &str { - if let Some(rest) = name.strip_prefix("DIE_") { - let after = rest.trim_start_matches(|c: char| c.is_ascii_digit()); - if let Some(r) = after.strip_prefix('_') { - return r; - } - } - name - } - fn counts_once(name: &str) -> bool { let base = strip_die_prefix(name); if base.eq_ignore_ascii_case("CPU Energy") @@ -584,12 +878,20 @@ mod macos_ioreport { } /// An oracle that shares nothing with the liveness probe but the raw FFI. - /// It opens its own subscription (not through `open()`), reads each CPU - /// rail's ABSOLUTE cumulative value (no delta samples), matches names with - /// `ends_with("cpu energy")` in any group and any case rather than the - /// probe's Energy-Model-group die-prefix stripping — a superset of the - /// probe's rails — and burns CPU for the whole of `budget` on a helper - /// thread while it looks for any change. + /// It opens its own subscription over every channel (not the probe's + /// Energy Model one, and not through `open()`), reads each CPU rail's + /// ABSOLUTE cumulative value (no delta samples), matches names with + /// `ends_with("CPU Energy")` in any group rather than the probe's group + /// filter and die-prefix stripping, and burns CPU on a helper thread for + /// the whole of `budget` while it reads. + /// + /// `CpuRailMoved(true)` only if one rail's absolute value changed at two + /// or more separate reads. A live rail changes at nearly every read; a + /// batch-refreshed one at most once in a window this short, and one jump + /// is not a live reading, so a refresh landing in both the probe's and + /// the oracle's window cannot vouch for a false Live. Its own literal + /// threshold, not the implementation's constant, so a change to one + /// cannot silently move the other. fn oracle_cpu_rail(budget: Duration) -> Oracle { unsafe { let all = IOReportCopyAllChannels(0, 0); @@ -629,9 +931,10 @@ mod macos_ioreport { if v0.is_empty() { return Oracle::NoCpuRail; } - // The burn runs on its own thread for the WHOLE window. One sample of - // every channel takes 0.4–0.8 s on an M5 Max, so spinning only between - // samples, as the probe does, would burn a small share of `budget`. + // The burn runs on its own thread for the WHOLE window: this + // subscription samples every channel, 0.1–0.8 s a sample on an M5 + // Max, so spinning only between samples would burn a small share of + // `budget`. let stop = Arc::new(AtomicBool::new(false)); let burner = { let stop = stop.clone(); @@ -643,17 +946,35 @@ mod macos_ioreport { }) }; let started = Instant::now(); - let mut moved = false; + // Per rail: at how many reads its absolute value had changed since + // the read before. + let mut changes = vec![0u32; v0.len()]; + let (mut prev, mut reads, mut moved) = (v0, 0u32, false); while !moved && started.elapsed() < budget { - moved = cpu_rail_absolutes(sub, ch).is_some_and(|v| v.len() == v0.len() && v != v0); + let Some(cur) = cpu_rail_absolutes(sub, ch) else { + continue; + }; + if cur.len() != prev.len() { + continue; + } + reads += 1; + for (count, (now, before)) in changes.iter_mut().zip(cur.iter().zip(prev.iter())) { + *count += u32::from(now != before); + } + moved = changes.iter().any(|&c| c >= 2); + prev = cur; } stop.store(true, Ordering::Relaxed); let _ = burner.join(); + eprintln!( + "ioreport oracle: {reads} reads in {:?}, changes per CPU rail {changes:?}", + started.elapsed() + ); Oracle::CpuRailMoved(moved) } - /// The absolute cumulative value of every channel whose name ends in - /// "cpu energy" (any case, any group), in channel order. + /// The absolute cumulative value of every channel whose name ends with + /// `CPU Energy` (any group), in channel order. unsafe fn cpu_rail_absolutes( sub: IOReportSubscriptionRef, ch: CFMutableDictionaryRef, @@ -678,7 +999,7 @@ mod macos_ioreport { continue; } let name = cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); - if name.to_ascii_lowercase().ends_with("cpu energy") { + if name.ends_with("CPU Energy") { vals.push(IOReportSimpleGetIntegerValue(it, 0) as i64); } } @@ -690,9 +1011,9 @@ mod macos_ioreport { /// The property that matters: the IOReport meter is **never** available /// when its CPU rail is frozen, because then it would bracket GPU-only - /// energy (or zero) as the energy of CPU work. Deterministic on any host — - /// it asserts the meter's decision against what the rail actually did, and - /// every branch asserts something. + /// energy, zero, or a batch refresh as the energy of CPU work. + /// Deterministic on any host — it asserts the meter's decision against + /// what the rail actually did, and every branch asserts something. /// /// The oracle gets three times the probe's budget, so a rail too slow for /// the probe but moving within the oracle's window is the only case that @@ -717,8 +1038,9 @@ mod macos_ioreport { Oracle::CpuRailMoved(false) => { assert!( !selected, - "IOReport meter available although its CPU rail did not move under {:?} of CPU \ - burn — it would bracket GPU-only energy, or zero, as the energy of CPU work", + "IOReport meter available although its CPU rail did not keep moving under {:?} of \ + CPU burn — it would bracket GPU-only energy, zero, or a refresh as the energy of \ + CPU work", CPU_RAIL_LIVENESS_BUDGET * 3 ); assert_eq!(status, IoReportStatus::CpuRailFrozen); @@ -741,20 +1063,50 @@ mod macos_ioreport { } /// The dangerous direction on its own: the probe never reports a live rail - /// the oracle cannot see move. The probe runs fresh here (not the cached - /// status) and the oracle runs unconditionally, so the implication is - /// evaluated on every host rather than only on one that claims Live. + /// the oracle cannot see keep moving. The probe runs fresh here (not the + /// cached status) and the oracle runs unconditionally, so every outcome + /// is checked against it, not only Live. Logs how long the probe took and + /// how many sample intervals it saw, so a CI log shows the probe really + /// re-samples every ~25 ms of spin. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { - let probe = probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET); + let t = Instant::now(); + let (probe, intervals) = super::probe(CPU_RAIL_LIVENESS_BUDGET); + let probe_took = t.elapsed(); let status = ioreport_status(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); - eprintln!("ioreport probe: probe={probe:?} status={status:?} oracle={oracle:?}"); - let claimed_live = probe == Some(CpuRail::Live) || status == IoReportStatus::Live; - assert!( - !claimed_live || oracle == Oracle::CpuRailMoved(true), - "probe said Live (probe={probe:?}, status={status:?}) but the oracle saw {oracle:?}" + eprintln!( + "ioreport probe: probe={probe:?} status={status:?} oracle={oracle:?} \ + probe_took={probe_took:?} intervals={intervals}" ); + match (probe, status) { + (Some(CpuRail::Live), _) | (_, IoReportStatus::Live) => assert_eq!( + oracle, + Oracle::CpuRailMoved(true), + "probe said Live (probe={probe:?}, status={status:?}), the oracle did not see the CPU \ + rail keep moving" + ), + (Some(CpuRail::Frozen), _) => { + assert!( + matches!(oracle, Oracle::CpuRailMoved(_)), + "the probe saw a CPU rail, the oracle found none: {oracle:?}" + ); + // Frozen is a verdict over at least two intervals, unless the + // five-budget cap cut the probe short. + assert!( + intervals >= 2 || probe_took >= CPU_RAIL_LIVENESS_BUDGET * 5, + "Frozen after {intervals} interval(s) in {probe_took:?}" + ); + } + (Some(CpuRail::Absent), _) => { + assert_eq!(oracle, Oracle::NoCpuRail, "the probe found no CPU rail, the oracle found one") + } + (None, _) => assert_eq!( + oracle, + Oracle::NoIoReport, + "the probe could not open or sample IOReport, the oracle could" + ), + } } /// When the meter IS selected, a multi-core CPU burst meters as non-zero diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index 3e6a8d308..4986e2698 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -180,8 +180,8 @@ fn no_hardware_reading_reason() -> &'static str { if cfg!(target_os = "macos") { match ioreport_status() { IoReportStatus::CpuRailFrozen => { - "IOReport opens, but its CPU energy rail is frozen (it did not advance while this \ - process burned CPU), so it cannot meter CPU work" + "IOReport opens, but its CPU energy rail is frozen (it did not keep advancing while \ + this process burned CPU), so it cannot meter CPU work" } IoReportStatus::CpuRailAbsent => "IOReport opens, but exposes no CPU energy rail", IoReportStatus::Unavailable => "IOReport is unavailable (it needs Apple Silicon, not Rosetta)", -- 2.54.0 (Apple Git-157) From bedef9b08e6e011ef2e9767e35932fe34821552e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 07:58:43 -0400 Subject: [PATCH 03/10] proof-cascade: the liveness rule pairs consecutive samples itself, per rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family's energy contract: "measured when the platform exposes it, estimated when it doesn't, and the provenance always says which ... A reading never silently upgrades its own provenance." 5d01888f put this meter's probe on the family liveness rule. An adversarial review of it found two medium defects and several low ones. All of them are fixed here, and the family test design is applied. The two medium defects: - The rule keyed rails by channel name and counted every same-named entry in an interval. So two channels with the same name that stepped together in one batch refresh counted as one rail advancing twice, which gave Live. The M3 Ultra's Energy Model group already lists 264 of its 645 names twice (e.g. ECPUDTL00..22, once per die, with no DIE_ prefix). Its CPU aggregates are unique today, so neither measured host reached this. - The half of the rule that says "between CONSECUTIVE samples" lived only in the FFI loop, and no test pinned it. With `prev = cur` removed, so every sample was compared with the FIRST one, all 23 tests still passed. That is the a5c77d8d shape: one batch refresh in the window then reads as an advance in every later interval, and the rail looks Live. What holds now: - D1, a pure sample layer. `RailWatch::observe` takes each whole sample as (channel name, ABSOLUTE raw integer) pairs. It keeps the previous sample itself and compares each CPU rail with it. A rail is keyed by (name, ordinal among the same-named channels in that sample), so it advances at most once per interval. The FFI sampler (`energy_model_sample`) only extracts the pairs from one IOReportCreateSamples call. The probe no longer uses IOReportCreateSamplesDelta. - D2, a generic loop. `liveness::watch(sample, tick, now, budget)` is the probe loop. The real probe passes the FFI sampler, a 25 ms spin and Instant. A failed sample is not an interval: the next good one is compared with the last good one, and failures keep sampling up to the five-budget cap. It is tested with scripted samples and a scripted clock: (a) the M5 Max refresh [480083160, 480083160, 541893638, ...], with a GPU rail that moves every sample, is Frozen after 40 intervals; (b) the M3 Ultra's two dies stepping together once, among unprefixed per-die sub-rails listed twice, is Frozen; (c) two "CPU Energy" channels stepping together once is Frozen; (d) one rail advancing every sample is Live after exactly 2 intervals and 3 samples, and the loop stops there; (e) failing samples run to exactly 5 budgets (201 samples), with no verdict, which maps to Unavailable; (f) past the budget it keeps sampling with 1 interval and stops with 2 (Frozen at 1.6 s, 5 samples on a 400 ms clock); (g) one step on each die, in different intervals, is Frozen. - D3. The verdict-to-status mapping is a pure fn, `status_from`, with a test. - D4. The once-per-process cache is pinned: STATUS_PROBE_RUNS counts the runs behind ioreport_status(). A test calls it several times, and also hardware_meter_available(), available(), power_over() and measure_marginal_uj(). It asserts that exactly one probe ran and that a warm call returns within one tick. - D5, cadence. The cached probe keeps its interval count. The frozen-rail test prints "ioreport liveness: status=.. probe_intervals=N", and when the status is CpuRailFrozen it asserts N >= 10. The fresh-probe test asserts the same floor for Frozen, and for Absent when the group opened. The group-only probe sees 26-32 intervals. The all-channel one gives 2-3 on the M5 Max tonight and 5-6 on the M3 Ultra. - D6, the declined path. This meter's honest fallback is no figure at all. A test burns 80 ms of real CPU per call through measure_marginal_uj on a declined host and asserts three things: the result is None (not Some(0)), the work closure never ran (no bracket), and the call returned in under 80 ms. proof-cli's test asserts the sealed receipt stays ModelBased with a non-zero figure. Also fixed: - RAPL wrap. rapl_power_over took wrapping_sub over the SUM of the per-zone counters, but each zone wraps at its own max_energy_range_uj. One zone wrapping therefore gave ~1.8e19 uJ, which proof-cli would have printed as HwShunt. The pure `rapl::delta_uj` now sums per-zone deltas, each taken modulo that zone's range. It gives no figure if a zone that wrapped has an unreadable range, or if the zone sets differ. Tests cover no wrap, a wrap in one zone, and an unreadable range with and without a wrap. - A worker leak in bracket(). It returned at `busy_power()?` before stopping its workers, so a busy window with no reading left every core spinning for the rest of the process. The RAPL change adds such a None, so the workers are now stopped and joined first. `bracket_with` takes the power readers, and a test pins that the workers stop. - measure_marginal_uj's doc said None when "the signal stays inside the idle noise", but the code returns Some(0) there. The doc now says so, and that it is not a measured zero. The behaviour is unchanged. - Unavailable. Its doc and the CLI's reason now cover a subscription that opens but can never be sampled. They no longer blame Rosetta alone. - proof-cli prints the decision it made ("--measure: hardware meter available=.. · ioreport_status=.."). tests/measure.rs asserts against that line instead of running a second probe, which could disagree on a borderline rail. - strip_die_prefix now requires at least one digit and matches DIE_ in any case. - The oracle's doc now says it shares the CFString helper, not only the FFI. Claims in pushed messages and docs that were not true: - 5d01888f says "Live means the same rail advanced in at least two separate sample intervals" and "The IOReport FFI only feeds it". With same-named channels, "the same rail" was one name. The FFI also did the consecutive-sample pairing, the part no test reached. - 5d01888f says the rule is "the one jouleclaw-energy (b9d7e945) and eoc-meter (c6378629) implement". Its shape matched jouleclaw-energy's, which has the same duplicate-name defect. eoc-meter keys by channel index and computes steps inside its pure struct. - 5d01888f deferred asserting the group-only probe because "a count threshold would flake under load". A floor of 10 is asserted here. The green runs saw 26-32, and 15-16 under concurrent load. - The test comments said "so a CI log says/shows". No CI job on this branch runs proof-cascade's macOS tests: proof-rs builds on the linux runner, where the IOReport code is cfg'd out. They now say "a log of a macOS run". - a5c77d8d says it "drops the stale `macmon` references", but proof/README.md still said "IOReport via `macmon`". The README now describes the direct C API, used only while the CPU rail is live. Observed, macOS 27.0.1. proof-cascade lib went from 23 to 36 tests: M5 Max, 3 runs: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false probe_intervals=29/31/30 ioreport probe: probe=Some(Frozen) ... probe_took=1.097s/1.094s/ 1.102s intervals=28/31/29 declined path: available=false ioreport_status=CpuRailFrozen figure=None work_runs=0 took=250ns test result: ok. 36 passed; 0 failed (each run) M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false probe_intervals=32 ioreport probe: probe=Some(Frozen) ... probe_took=1.10663325s intervals=32 test result: ok. 36 passed; 0 failed The whole proof-rs workspace passes on the M5 Max (z3 present), and so does the proof-conformance binary ("all checks passed"). `proof --measure` declines on both chips with "available=false · ioreport_status=CpuRailFrozen" and the frozen-rail reason. Its receipt stays "provenance ModelBased" (167 uj on the M5 Max, 345 uj on the M3 Ultra, where z3 is absent). clippy --no-deps --tests shows the same 2 warnings as 5d01888f and none new, and is clean for x86_64-unknown-linux-gnu. Dependants: joulecontract-proof (6) and joule-code-release (6) pass on both chips. joule-code-remediate's verified_fix_end_to_end_ships_with_receipt fails with "assertion failed: report.total_uj() > 0". It fails identically with 5d01888f's meter.rs checked out; it meters through sandbox-meter. On the M3 Ultra, the formal-template test also fails, from a baked CARGO_MANIFEST_DIR path. Falsified on the M5 Max. Each mutation was applied, the tests run, then restored with git checkout, with a clean tree after each: - Survivors of the last review. Skip the OnceLock (M4): "the liveness probe ran more than once in this process left: 25 right: 1". Compare with the first sample (M7): 6 rule tests fail, e.g. the M5 Max refresh "Run { verdict: Some(Live), intervals: 3 ..}". Probe on the all-channel subscription (M8): "Frozen after only 2 interval(s) in 1.367s" and "the cached probe called the rail frozen after 3 intervals". On the M3 Ultra, M8 gives 5 and 6 intervals and fails the same two tests. - Key rails by name alone, or count every same-named entry: (c) fails, Live. - Sum advances across rails: (b), (c) and (g) fail. - One advance is live: 9 tests fail. - Stop at the budget with under 2 intervals: (e), (f) and the stopping-rule test fail. - Don't stop when Live: (d) fails, 40 intervals. - Give up on a failed sample: (e) and (f) fail. - Treat every channel as a CPU rail: the rule tests fail, and on hardware "probe=Some(Live) ... oracle=CpuRailMoved(false)". - Swap Frozen/Absent in status_from: the mapping test and the frozen-rail test fail. - A declined meter returns Some(0), or runs a bracket before declining: D6 fails. - bracket() returns early without stopping its workers: "workers kept running after the bracket returned". - RAPL wrapping_sub with no modulo, or an unreadable range taken as u64::MAX: the rapl tests fail, e.g. "left: Some(18446743811566228266) right: Some(5500)". - proof-cli meters regardless, or prints the wrong decision: measure.rs fails with "the CLI's output disagrees with the decision it printed". Left for David: - The CLI tags a live IOReport figure Energy::measured, i.e. HwShunt, while the module header says a macOS Energy Model reading is ModelBased. The comment now states this instead of endorsing it. - No macOS CI job runs this crate's tests yet. Co-Authored-By: Claude Opus 5.5 --- proof/README.md | 2 +- .../crates/proof-cascade/src/meter.rs | 896 ++++++++++++++---- proof/proof-rs/crates/proof-cli/src/main.rs | 35 +- .../crates/proof-cli/tests/measure.rs | 65 +- 4 files changed, 769 insertions(+), 229 deletions(-) diff --git a/proof/README.md b/proof/README.md index 74898b950..53355bd7d 100644 --- a/proof/README.md +++ b/proof/README.md @@ -37,7 +37,7 @@ proof/ - **`proof-schema`** — the content-addressed goal (`blake3(statement‖0x00‖context)`), the cheapest-first tier ladder, the honest energy model (`HwShunt`/`ModelBased`/`Estimator` with the coupling enforced), `SpecGrounding`, the verdict, and the `Solver` trait every backend implements. Canonical det-CBOR content addressing. - **`proof-solvers`** — two **real** rungs with measured energy: a propositional decider (T0) that parses a boolean goal and decides validity by truth-table in process, and `z3` (T1) — shell or kept **resident** across goals — that decides an SMT-LIB goal (assert the negation; `unsat` ⇒ valid). Each refuses rather than fakes, so the router escalates. The model rungs (T2/T3) are honest stand-ins behind the same trait. -- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport via `macmon` on Apple Silicon, RAPL on Linux) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. Returns nothing where no interface is reachable — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. +- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while its CPU energy rail is live; RAPL on Linux) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. Returns nothing where no interface is reachable — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. - **`proof-receipt`** — the `proof/1` JCR-1 receipt: canonical det-CBOR + COSE_Sign1/EdDSA, vendored byte-compatible with the family's cross-language verifiers; refuses to seal an unproved goal. - **`proof-conformance`** — C1–C5 + a negative pack, all checks real, published vectors in `conformance/v1/`. - **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline, the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`, where an energy interface is present). diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index d14a9ba80..378f5175a 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -33,11 +33,21 @@ use std::sync::Arc; use std::time::Instant; /// Marginal energy of one `work()` call in microjoules, from a hardware -/// interface (`HwShunt`). `None` if no interface is reachable or the signal -/// stays inside the idle noise; on Apple Silicon "reachable" means IOReport -/// with a live CPU energy rail ([`hardware_meter_available`]), so a frozen rail -/// yields `None` before any window runs. `work` runs on every spare core during -/// the busy window, so keep it pure + `Copy`. +/// interface (`HwShunt`). +/// +/// - `None` if no interface is reachable ([`hardware_meter_available`]; on +/// Apple Silicon that means IOReport with a live CPU energy rail, so a frozen +/// rail yields `None` before any window runs and `work` never runs), if the +/// interface gave no reading for the idle or the busy window, or if `work` +/// never completed a call in the busy window. The caller keeps its own +/// `ModelBased` figure. +/// - `Some(0)` if the interface answered but the busy window did not draw more +/// than the idle one: the signal stayed inside the idle noise. That is not a +/// measured zero-energy call, and a caller must not report it as one +/// (`proof --measure` says the signal stayed inside the idle noise). +/// +/// `work` runs on every spare core during the busy window, so keep it pure + +/// `Copy`. pub fn measure_marginal_uj(work: F) -> Option where F: Fn() + Send + Sync + Copy + 'static, @@ -68,7 +78,7 @@ pub fn hardware_meter_available() -> bool { } #[cfg(target_os = "linux")] { - rapl_energy_uj().is_some() + !rapl_zones().is_empty() } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { @@ -79,8 +89,10 @@ pub fn hardware_meter_available() -> bool { /// Why the IOReport interface is or is not used on this host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IoReportStatus { - /// No IOReport subscription could be opened (not macOS, not Apple Silicon, - /// or running under Rosetta). + /// IOReport could not be opened or sampled: no subscription could be + /// opened (not macOS, not Apple Silicon, or running under Rosetta), or the + /// Energy Model subscription opened but never yielded two consecutive + /// samples within the probe's five budgets. Unavailable, /// A subscription opened, but no `CPU Energy` rail exists in the Energy /// Model group — an unfamiliar channel naming. Never used. @@ -99,16 +111,21 @@ pub enum IoReportStatus { /// How long the liveness probe watches the CPU energy rail, spinning ~25 ms on /// the calling thread before each re-sample, before concluding it is frozen. -/// The probe samples its own subscription to the Energy Model group alone, ~5 ms -/// a sample on an M5 Max and an M3 Ultra (every channel: 0.1–0.8 s on the M5 -/// Max, ~0.13 s on the M3 Ultra), so this buys about thirty intervals of ~35 ms, -/// where a live rail needs two. If fewer than two intervals were observed when -/// it runs out, the probe keeps sampling, up to five budgets. A live rail slower -/// than that fails safe: declined, and the caller keeps its `ModelBased` -/// figure. Paid once per process; see [`ioreport_status`]. +/// The probe samples its own subscription to the Energy Model group alone, +/// ~5–12 ms a sample on an M5 Max and an M3 Ultra (every channel: 0.1–0.8 s on +/// the M5 Max, ~0.14 s on the M3 Ultra), so this buys about thirty intervals of +/// ~35 ms, where a live rail needs two. If fewer than two intervals were +/// observed when it runs out, the probe keeps sampling, up to five budgets. A +/// live rail slower than that fails safe: declined, and the caller keeps its +/// `ModelBased` figure. Paid once per process; see [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); +/// How many times this process has run the liveness probe behind +/// [`ioreport_status`] — once at most; a test pins that. +#[cfg(target_os = "macos")] +static STATUS_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// This host's IOReport status, probed once per process and cached. /// /// On macOS, [`hardware_meter_available`] and [`measure_marginal_uj`] use @@ -118,13 +135,7 @@ pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::f pub fn ioreport_status() -> IoReportStatus { #[cfg(target_os = "macos")] { - static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); - *STATUS.get_or_init(|| match macos_ioreport::probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET) { - None => IoReportStatus::Unavailable, - Some(liveness::CpuRail::Live) => IoReportStatus::Live, - Some(liveness::CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, - Some(liveness::CpuRail::Absent) => IoReportStatus::CpuRailAbsent, - }) + cached_probe().0 } #[cfg(not(target_os = "macos"))] { @@ -132,10 +143,50 @@ pub fn ioreport_status() -> IoReportStatus { } } +/// The once-per-process liveness probe behind [`ioreport_status`]: its status +/// and how many sample intervals it observed. +#[cfg(target_os = "macos")] +fn cached_probe() -> (IoReportStatus, u32) { + static STATUS: std::sync::OnceLock<(IoReportStatus, u32)> = std::sync::OnceLock::new(); + *STATUS.get_or_init(|| { + STATUS_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); + let (verdict, intervals) = macos_ioreport::probe(CPU_RAIL_LIVENESS_BUDGET); + (status_from(verdict), intervals) + }) +} + +/// The probe's verdict as the public status. `None` — IOReport could not be +/// opened, or opened but never yielded two consecutive samples — is +/// `Unavailable`. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn status_from(verdict: Option) -> IoReportStatus { + match verdict { + None => IoReportStatus::Unavailable, + Some(liveness::CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + Some(liveness::CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(liveness::CpuRail::Live) => IoReportStatus::Live, + } +} + /// Run `work` on every spare core for a window, bracketed against idle, and /// return `(idle_w, busy_w, window_s, total_runs)`. The power readings come from /// the platform interface; `None` if none is reachable. fn bracket(work: F) -> Option<(f64, f64, f64, u64)> +where + F: Fn() + Send + Sync + Copy + 'static, +{ + bracket_with(work, idle_power, busy_power) +} + +/// [`bracket`] with the idle and busy power readers passed in. The workers +/// are stopped and joined before the busy reading is looked at, so a busy +/// window with no reading returns `None` without leaving them spinning for +/// the rest of the process. +fn bracket_with( + work: F, + idle_power: impl FnOnce() -> Option, + busy_power: impl FnOnce() -> Option, +) -> Option<(f64, f64, f64, u64)> where F: Fn() + Send + Sync + Copy + 'static, { @@ -158,19 +209,20 @@ where .collect(); let t0 = Instant::now(); - let busy_w = busy_power()?; + let busy_w = busy_power(); let window = t0.elapsed().as_secs_f64(); let runs = iters.load(Ordering::Relaxed); stop.store(true, Ordering::Relaxed); for h in handles { let _ = h.join(); } - Some((idle_w, busy_w, window, runs)) + Some((idle_w, busy_w?, window, runs)) } // --------------------------------------------------------------------------- -// CPU-rail liveness: the decision, kept apart from the FFI so it is a pure, -// tested rule on every platform. Only the macOS probe uses it. +// CPU-rail liveness: the decision AND the sampling loop, kept apart from the +// FFI so both are pure, tested code on every platform. The macOS probe only +// supplies the samples, the spin and the clock. // --------------------------------------------------------------------------- #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod liveness { @@ -197,42 +249,77 @@ mod liveness { Absent, } - /// The liveness rule, fed one sample interval at a time. + /// One sample of the probe's subscription as the rule reads it: every + /// channel's name and its ABSOLUTE raw integer, in channel order. Channels + /// that are not CPU rails ride along and are ignored by the rule. + pub(super) type Sample = Vec<(String, i64)>; + + /// A CPU rail's identity from one sample to the next: its channel name, and + /// its ordinal among the channels of that sample with the same name. Names + /// alone are not unique: an M3 Ultra's Energy Model group lists 264 of its + /// 645 names twice (e.g. `ECPUDTL00`..`22`, once per die, with no `DIE_` + /// prefix). Its CPU aggregates are unique today, but two same-named rails + /// stepping together in one refresh must stay two rails that each advanced + /// once, not one rail that advanced twice. + type RailKey = (String, u32); + + /// The liveness rule, fed one whole sample at a time. /// /// A rail is live only when the **same** CPU rail advances in at least - /// [`LIVE_RAIL_ADVANCES`] separate sample intervals. One advance is not - /// enough, because on macOS 27.0.1 the CPU rails are not permanently frozen - /// for an unprivileged reader: they are refreshed in rare batches. An M3 - /// Ultra's `DIE_0_` / `DIE_1_CPU Energy` held for at least 22 minutes, then - /// stepped by +1,344,869 and +1,391,637 mJ in a single 100 ms sample and held - /// again; an M5 Max's `CPU Energy` did not change in 13,614 samples over 25 - /// minutes, yet had moved from 480,083,160 to 541,893,638 mJ earlier that - /// hour. A probe window that straddles a refresh sees one large jump, and a - /// rule that accepts it selects the meter for the life of the process, after - /// which every later ~1.3 kJ refresh lands whole in whichever bracket - /// straddles it. A live rail advances in nearly every interval (the M5 Max - /// GPU rail changes every ~28 ms). Counting per rail keeps two dies' separate - /// jumps from adding up to a false "live". + /// [`LIVE_RAIL_ADVANCES`] separate sample intervals, where an interval is + /// two CONSECUTIVE samples and an advance is the rail's raw integer being + /// larger than in the sample just before. The watch keeps the previous + /// sample itself, so the comparison cannot drift to a fixed baseline. One + /// advance is not enough, because on macOS 27.0.1 the CPU rails are not + /// permanently frozen for an unprivileged reader: they are refreshed in + /// rare batches. An M3 Ultra's `DIE_0_` / `DIE_1_CPU Energy` held for at + /// least 22 minutes, then stepped by +1,344,869 and +1,391,637 mJ in a + /// single 100 ms sample and held again; an M5 Max's `CPU Energy` did not + /// change in 13,614 samples over 25 minutes, yet had moved from 480,083,160 + /// to 541,893,638 mJ earlier that hour. A probe window that straddles a + /// refresh sees one large jump, and a rule that accepts it selects the meter + /// for the life of the process, after which every later ~1.3 kJ refresh + /// lands whole in whichever bracket straddles it. Compared with a fixed + /// baseline instead of the previous sample, that one jump would read as an + /// advance in every later interval. A live rail advances in nearly every + /// interval (the M5 Max GPU rail changes every ~28 ms). Counting per rail + /// keeps two dies' separate jumps from adding up to a false "live". #[derive(Debug, Default)] pub(super) struct RailWatch { - /// Sample intervals observed so far. + /// Sample intervals observed so far (successful samples after the first). intervals: u32, - /// Each CPU rail seen, by channel name, with the number of intervals in - /// which its raw integer advanced. - rails: Vec<(String, u32)>, + /// The CPU rails of the previous successful sample, with their raw + /// integers. `None` until the first sample. + prev: Option>, + /// Every CPU rail seen, with the number of intervals in which it advanced. + rails: Vec<(RailKey, u32)>, } impl RailWatch { - /// Record one sample interval: every CPU rail in it, and whether its raw - /// integer advanced between the two consecutive samples. - pub(super) fn observe<'a>(&mut self, rails: impl IntoIterator) { - self.intervals += 1; - for (name, advanced) in rails { - match self.rails.iter_mut().find(|(n, _)| n == name) { - Some((_, count)) => *count += u32::from(advanced), - None => self.rails.push((name.to_string(), u32::from(advanced))), + /// Fold in the next sample. The first one is the baseline; each later + /// one closes an interval against the sample just before it. A rail + /// advances at most once per interval, and only if it was in both + /// samples. + pub(super) fn observe(&mut self, sample: &[(String, i64)]) { + let cur = cpu_rails(sample); + for (key, _) in &cur { + if !self.rails.iter().any(|(k, _)| k == key) { + self.rails.push((key.clone(), 0)); } } + if let Some(prev) = self.prev.take() { + self.intervals += 1; + for (key, now) in &cur { + // Keys are unique within a sample, so this is one advance + // per rail per interval at most. + if prev.iter().any(|(k, before)| k == key && now > before) { + if let Some((_, n)) = self.rails.iter_mut().find(|(k, _)| k == key) { + *n += 1; + } + } + } + } + self.prev = Some(cur); } /// Sample intervals observed so far. @@ -270,6 +357,46 @@ mod liveness { } } + /// The CPU rails of one sample, keyed by name and ordinal among the + /// same-named channels, in channel order. + fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in sample { + if is_cpu_energy_rail(name) { + let ordinal = rails.iter().filter(|((n, _), _)| n == name).count() as u32; + rails.push(((name.clone(), ordinal), *raw)); + } + } + rails + } + + /// The probe loop, generic over where samples come from, how the thread + /// burns CPU between them, and what time it is, so the loop itself is + /// tested with scripted samples and a scripted clock. Takes a baseline + /// sample, then ticks and re-samples for as long as + /// [`RailWatch::wants_more`] says to. A failed sample (`None`) is not an + /// interval: the next successful one is compared with the last successful + /// one. Returns the verdict and the number of intervals observed. + pub(super) fn watch( + mut sample: impl FnMut() -> Option, + mut tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, + ) -> (Option, u32) { + let started = now(); + let mut w = RailWatch::default(); + if let Some(s) = sample() { + w.observe(&s); + } + while w.wants_more(now().saturating_sub(started), budget) { + tick(); + if let Some(s) = sample() { + w.observe(&s); + } + } + (w.verdict(), w.intervals()) + } + /// Whether a channel is a CPU energy rail: its name, after stripping a /// `DIE__` prefix, is `CPU Energy` (any case). Not the per-cluster or /// per-core sub-rails (`PCPU`, `MCPU*`, `PACC_*`, `DIE_0_PACC0_CPU0`). @@ -277,13 +404,19 @@ mod liveness { strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") } - /// Strip an optional `DIE__` prefix (multi-die parts like the M-series - /// Ultra report per-die aggregates). + /// Strip an optional `DIE__` prefix, `` one or more digits, `DIE_` + /// in any case (multi-die parts like the M-series Ultra report per-die + /// aggregates). pub(super) fn strip_die_prefix(name: &str) -> &str { - if let Some(rest) = name.strip_prefix("DIE_") { - let after = rest.trim_start_matches(|c: char| c.is_ascii_digit()); - if let Some(r) = after.strip_prefix('_') { - return r; + let b = name.as_bytes(); + if b.len() > 4 && b[..4].eq_ignore_ascii_case(b"DIE_") { + // The first four bytes are ASCII, so index 4 is a char boundary. + let rest = &name[4..]; + let digits = rest.bytes().take_while(u8::is_ascii_digit).count(); + if digits > 0 { + if let Some(r) = rest[digits..].strip_prefix('_') { + return r; + } } } name @@ -292,6 +425,43 @@ mod liveness { #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; + + const BUDGET: Duration = Duration::from_millis(1000); + const TICK: Duration = Duration::from_millis(25); + + fn sample(pairs: &[(&str, i64)]) -> Sample { + pairs.iter().map(|(n, v)| (n.to_string(), *v)).collect() + } + + /// What one scripted [`watch`] did. + #[derive(Debug)] + struct Run { + verdict: Option, + intervals: u32, + /// How many times it asked for a sample, the baseline included. + samples: u32, + /// The scripted clock when it stopped. + took: Duration, + } + + /// Run [`watch`] on scripted samples (`script(n)` is the n-th sample, + /// from 0) and a scripted clock that only the tick advances, by `tick`. + fn run(tick: Duration, mut script: impl FnMut(u32) -> Option>) -> Run { + let clock = Cell::new(Duration::ZERO); + let calls = Cell::new(0u32); + let (verdict, intervals) = watch( + || { + let n = calls.get(); + calls.set(n + 1); + script(n).map(|s| sample(&s)) + }, + || clock.set(clock.get() + tick), + || clock.get(), + BUDGET, + ); + Run { verdict, intervals, samples: calls.get(), took: clock.get() } + } #[test] fn only_the_cpu_energy_aggregate_is_a_cpu_rail() { @@ -300,6 +470,9 @@ mod liveness { assert!(is_cpu_energy_rail("DIE_0_CPU Energy")); assert!(is_cpu_energy_rail("DIE_1_CPU Energy")); assert!(is_cpu_energy_rail("DIE_12_CPU Energy")); + assert!(is_cpu_energy_rail("die_1_CPU Energy")); + assert!(!is_cpu_energy_rail("DIE__CPU Energy"), "DIE__ needs a digit"); + assert!(!is_cpu_energy_rail("DIE_x_CPU Energy")); assert!(!is_cpu_energy_rail("GPU Energy")); assert!(!is_cpu_energy_rail("DIE_0_GPU Energy")); assert!(!is_cpu_energy_rail("ANE Energy")); @@ -307,6 +480,7 @@ mod liveness { assert!(!is_cpu_energy_rail("MCPU0")); assert!(!is_cpu_energy_rail("PACC_0")); assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); + assert!(!is_cpu_energy_rail("ECPUDTL00")); } /// A rail that moves in one step and then holds — a batch refresh caught @@ -314,45 +488,27 @@ mod liveness { #[test] fn a_single_jump_is_a_batch_refresh_not_a_live_rail() { let mut w = RailWatch::default(); - for advanced in [false, true, false, false, false] { - w.observe([("CPU Energy", advanced)]); + for v in [100, 100, 1_444_969, 1_444_969, 1_444_969, 1_444_969] { + w.observe(&sample(&[("CPU Energy", v)])); } + assert_eq!(w.intervals(), 5); assert!(!w.is_live(), "one refresh counted as a live rail"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); } - /// Two dies each refreshed once, in different intervals, do not add up - /// to a live rail: liveness is counted per rail. - #[test] - fn one_jump_on_each_die_is_not_a_live_rail() { - let mut w = RailWatch::default(); - w.observe([("DIE_0_CPU Energy", true), ("DIE_1_CPU Energy", false)]); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); - assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - } - - /// The M3 Ultra's observed refresh: both dies step in the same interval, - /// once. Still one advance per rail, so still frozen. - #[test] - fn both_dies_stepping_together_once_is_not_a_live_rail() { - let mut w = RailWatch::default(); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); - w.observe([("DIE_0_CPU Energy", true), ("DIE_1_CPU Energy", true)]); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); - assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - } - /// The same rail advancing in two separate intervals is live — on either - /// die — and not before. + /// die — and not before; a rail missing from the previous sample did not + /// advance; a counter that goes down did not advance. #[test] fn two_advances_of_the_same_rail_are_live() { let mut w = RailWatch::default(); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 20)])); + w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 21)])); assert!(!w.is_live(), "one advance is not yet live"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", false)]); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + w.observe(&sample(&[("DIE_0_CPU Energy", 9), ("DIE_1_CPU Energy", 21), ("CPU Energy", 5)])); + assert!(!w.is_live(), "a decrease and a new rail are not advances"); + w.observe(&sample(&[("DIE_0_CPU Energy", 9), ("DIE_1_CPU Energy", 22), ("CPU Energy", 5)])); assert!(w.is_live()); assert_eq!(w.verdict(), Some(CpuRail::Live)); } @@ -363,15 +519,122 @@ mod liveness { fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { let mut w = RailWatch::default(); assert_eq!(w.verdict(), None); - w.observe([]); - w.observe([]); + w.observe(&sample(&[("CPU Energy", 1)])); + assert_eq!(w.verdict(), None, "a baseline alone is not an interval"); + let mut w = RailWatch::default(); + for n in 0..3 { + w.observe(&sample(&[("GPU Energy", n), ("PCPU", n)])); + } assert_eq!(w.intervals(), 2); assert_eq!(w.verdict(), Some(CpuRail::Absent)); } - /// The probe keeps sampling within its budget until a rail proves live; - /// past the budget only until a live rail could have advanced twice; and - /// never past five budgets. + /// (a) The M5 Max's refresh: `CPU Energy` holds at 480,083,160 mJ, + /// steps once to 541,893,638 and holds, while `GPU Energy` advances in + /// every sample. Frozen. Compared with the FIRST sample instead of the + /// previous one, every interval after the step would read as an advance. + #[test] + fn watch_m5_max_refresh_is_frozen() { + let r = run(TICK, |n| { + let cpu = if n < 2 { 480_083_160 } else { 541_893_638 }; + Some(vec![("CPU Energy", cpu), ("GPU Energy", 851_622_003 + 28_000_000 * i64::from(n))]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + // One interval per 25 ms tick until the 1 s budget is spent. + assert_eq!((r.intervals, r.samples, r.took), (40, 41, BUDGET), "{r:?}"); + } + + /// (b) The M3 Ultra's refresh: both dies step once, in the same sample, + /// among unprefixed per-die sub-rails listed once per die and a GPU + /// rail that moves now and then. Frozen. + #[test] + fn watch_m3_ultra_two_die_refresh_is_frozen() { + let r = run(TICK, |n| { + let (d0, d1) = if n < 7 { (9_494_336, 10_212_656) } else { (10_839_205, 11_604_293) }; + let n = i64::from(n); + Some(vec![ + ("ECPUDTL00", 5_000 + n), + ("DIE_0_CPU Energy", d0), + ("ECPUDTL00", 7_000 + n), + ("DIE_1_CPU Energy", d1), + ("GPU Energy", 14_809_813 * (n / 5)), + ]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + assert_eq!(r.intervals, 40, "{r:?}"); + } + + /// (c) Two channels both named `CPU Energy` stepping together, once, are + /// two rails that each advanced once — not one rail that advanced + /// twice. Frozen. + #[test] + fn watch_two_same_named_rails_stepping_together_once_is_frozen() { + let r = run(TICK, |n| { + let step = if n < 3 { 0 } else { 1_300_000 }; + Some(vec![("CPU Energy", 9_494_336 + step), ("CPU Energy", 10_212_656 + step)]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + assert_eq!(r.intervals, 40, "{r:?}"); + } + + /// (d) A rail that advances in every sample is Live after exactly two + /// intervals, and the watch stops sampling right there. + #[test] + fn watch_a_rail_advancing_every_sample_is_live_after_two_intervals() { + let r = run(TICK, |n| Some(vec![("CPU Energy", 1_000 + i64::from(n))])); + assert_eq!(r.verdict, Some(CpuRail::Live), "{r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (2, 3, TICK * 2), "{r:?}"); + } + + /// (e) Samples that keep failing are retried up to the five-budget cap + /// and give no verdict — IOReport unavailable — with or without a + /// baseline. + #[test] + fn watch_failing_samples_run_to_the_cap_with_no_verdict() { + let r = run(TICK, |_| None); + assert_eq!((r.verdict, r.intervals), (None, 0), "{r:?}"); + assert_eq!(r.took, BUDGET * MAX_BUDGETS, "{r:?}"); + assert_eq!(r.samples, 1 + (BUDGET * MAX_BUDGETS).as_millis() as u32 / 25, "{r:?}"); + assert_eq!(super::super::status_from(r.verdict), super::super::IoReportStatus::Unavailable); + + let r = run(TICK, |n| (n == 0).then(|| vec![("CPU Energy", 1)])); + assert_eq!((r.verdict, r.intervals, r.took), (None, 0, BUDGET * MAX_BUDGETS), "{r:?}"); + } + + /// (f) Past the budget, the watch stops once it has two intervals + /// (Frozen) and keeps sampling while it has fewer. Samples fail until + /// the clock is past the budget: at 1.2 s one interval → keep going; at + /// 1.6 s two → stop. + #[test] + fn watch_past_the_budget_stops_only_with_two_intervals() { + let r = run(Duration::from_millis(400), |n| (n == 0 || n >= 3).then(|| vec![("CPU Energy", 7)])); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (2, 5, Duration::from_millis(1600)), "{r:?}"); + + // A failed sample between two good ones is not an interval, and the + // next good one is compared with the last good one. + let r = run(TICK, |n| (n % 2 == 0).then(|| vec![("CPU Energy", 1_000 + i64::from(n))])); + assert_eq!(r.verdict, Some(CpuRail::Live), "{r:?}"); + assert_eq!((r.intervals, r.samples), (2, 5), "{r:?}"); + } + + /// (g) One step on each die, in different intervals, does not add up to + /// a live rail: liveness is per rail, not summed. + #[test] + fn watch_one_step_on_each_die_in_different_intervals_is_frozen() { + let r = run(TICK, |n| { + Some(vec![ + ("DIE_0_CPU Energy", 100 + if n < 3 { 0 } else { 50 }), + ("DIE_1_CPU Energy", 200 + if n < 9 { 0 } else { 50 }), + ]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + assert_eq!(r.intervals, 40, "{r:?}"); + } + + /// The stopping rule on its own: within the budget until a rail proves + /// live; past the budget only until a live rail could have advanced + /// twice; and never past five budgets. #[test] fn the_probe_samples_until_live_or_long_enough_to_tell() { let budget = Duration::from_millis(1000); @@ -380,11 +643,12 @@ mod liveness { assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); // Slow samples: past the budget with fewer than two intervals, keep going. assert!(w.wants_more(budget * 2, budget), "no interval yet, past budget: keep going"); - w.observe([("CPU Energy", false)]); + w.observe(&sample(&[("CPU Energy", 1)])); + w.observe(&sample(&[("CPU Energy", 1)])); assert!(w.wants_more(budget * 2, budget), "one interval, past budget: keep going"); assert!(w.wants_more(budget * 5 - Duration::from_millis(1), budget), "just under the cap"); assert!(!w.wants_more(budget * 5, budget), "hard cap: five budgets"); - w.observe([("CPU Energy", false)]); + w.observe(&sample(&[("CPU Energy", 1)])); assert!(w.wants_more(budget / 2, budget), "two intervals, within budget: keep watching"); assert!(!w.wants_more(budget, budget), "two intervals, budget spent: frozen"); assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap with no interval"); @@ -392,17 +656,125 @@ mod liveness { // One advance is not a reason to stop early. let mut once = RailWatch::default(); - once.observe([("CPU Energy", true)]); + once.observe(&sample(&[("CPU Energy", 1)])); + once.observe(&sample(&[("CPU Energy", 2)])); assert!(once.wants_more(Duration::ZERO, budget), "one advance: keep watching"); let mut live = RailWatch::default(); - live.observe([("CPU Energy", true)]); - live.observe([("CPU Energy", true)]); + for v in 1..=3 { + live.observe(&sample(&[("CPU Energy", v)])); + } assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); } } } +// --------------------------------------------------------------------------- +// Linux RAPL: the energy between two reads of the powercap zones, kept apart +// from the sysfs reads so it is a pure, tested function on every platform. +// --------------------------------------------------------------------------- +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +mod rapl { + /// One powercap zone (`/sys/class/powercap/intel-rapl:`), read once. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(super) struct Zone { + pub(super) index: u32, + /// The cumulative `energy_uj` counter. + pub(super) energy_uj: u64, + /// `max_energy_range_uj`, the value this zone's counter wraps at, if + /// it could be read. + pub(super) max_energy_range_uj: Option, + } + + /// One zone's energy between two reads, modulo its own wrap. A counter that + /// went down wrapped at `max_range`; without a readable (non-zero) range, or + /// with a first read above it, the delta is unknown and `None`. Undercounts + /// a wrap by at most one counter unit (the counter's last value before 0 is + /// `max_range` less one unit), and cannot see more than one wrap in a window + /// — minutes at package power, against this meter's ~1 s windows. + pub(super) fn zone_delta_uj(e0: u64, e1: u64, max_range: Option) -> Option { + if e1 >= e0 { + return Some(e1 - e0); + } + let max = max_range.filter(|&m| m > 0)?; + max.checked_sub(e0)?.checked_add(e1) + } + + /// The energy between two reads of the same zones: the sum of per-zone + /// deltas, each taken modulo that zone's own `max_energy_range_uj`. Each + /// zone wraps at its own range, so wrapping the SUM of the counters turns + /// one zone's wrap into ~1.8e19 uJ. `None` — no figure, never an invented + /// one — if the zone sets differ or a zone's delta is unknown. + pub(super) fn delta_uj(before: &[Zone], after: &[Zone]) -> Option { + if before.len() != after.len() { + return None; + } + let mut total = 0u64; + for (b, a) in before.iter().zip(after) { + if b.index != a.index { + return None; + } + let range = a.max_energy_range_uj.or(b.max_energy_range_uj); + total = total.checked_add(zone_delta_uj(b.energy_uj, a.energy_uj, range)?)?; + } + Some(total) + } + + #[cfg(test)] + mod tests { + use super::*; + + /// A package zone's range as the kernel reports it (2^32 units of + /// 61.035 uJ). + const RANGE: u64 = 262_143_328_850; + + fn zone(index: u32, energy_uj: u64, max: Option) -> Zone { + Zone { index, energy_uj, max_energy_range_uj: max } + } + + #[test] + fn no_wrap_is_the_plain_sum_of_zone_deltas() { + let before = [zone(0, 1_000, Some(RANGE)), zone(1, 5_000, Some(65_532_610_987))]; + let after = [zone(0, 3_000, Some(RANGE)), zone(1, 9_000, Some(65_532_610_987))]; + assert_eq!(delta_uj(&before, &after), Some(2_000 + 4_000)); + assert_eq!(delta_uj(&before, &before), Some(0)); + } + + /// One zone wraps at its own range while the other does not: its delta + /// is taken modulo ITS range. Wrapping the sum instead gives ~1.8e19. + #[test] + fn a_wrap_in_one_zone_is_taken_modulo_that_zones_range() { + let before = [zone(0, RANGE - 1_000, Some(RANGE)), zone(1, 5_000, Some(65_532_610_987))]; + let after = [zone(0, 500, Some(RANGE)), zone(1, 9_000, Some(65_532_610_987))]; + assert_eq!(delta_uj(&before, &after), Some(1_500 + 4_000)); + let summed = (500u64 + 9_000).wrapping_sub(RANGE - 1_000 + 5_000); + assert!(summed > 1e18 as u64, "the summed-wrap figure this replaces: {summed}"); + } + + /// A zone whose range cannot be read: fine while it does not wrap; once + /// it wraps its delta is unknown, and so is the total. + #[test] + fn a_zone_with_an_unreadable_range_gives_no_figure_only_when_it_wraps() { + let before = [zone(0, 1_000, None), zone(1, 5_000, Some(RANGE))]; + let after = [zone(0, 4_000, None), zone(1, 6_000, Some(RANGE))]; + assert_eq!(delta_uj(&before, &after), Some(3_000 + 1_000)); + let wrapped = [zone(0, 10, None), zone(1, 6_000, Some(RANGE))]; + assert_eq!(zone_delta_uj(1_000, 10, None), None, "a wrap with an unreadable range"); + assert_eq!(delta_uj(&before, &wrapped), None); + assert_eq!(zone_delta_uj(1_000, 10, Some(0)), None, "a zero range is unreadable"); + } + + #[test] + fn mismatched_or_inconsistent_reads_give_no_figure() { + let one = [zone(0, 1_000, Some(RANGE))]; + let two = [zone(0, 2_000, Some(RANGE)), zone(1, 5_000, Some(RANGE))]; + assert_eq!(delta_uj(&one, &two), None, "a zone appeared"); + assert_eq!(delta_uj(&one, &[zone(1, 2_000, Some(RANGE))]), None, "a different zone"); + assert_eq!(zone_delta_uj(RANGE + 5, 3, Some(RANGE)), None, "first read above the range"); + } + } +} + // --------------------------------------------------------------------------- // Platform power readings (watts), averaged over a short window. // --------------------------------------------------------------------------- @@ -418,11 +790,11 @@ fn busy_power() -> Option { // Apple Silicon power via the IOReport Energy Model C API — energy delta over a // timed window ÷ window = watts, the same shape as the Linux RAPL path. No sudo, // no external binary. Ported from jouleclaw-energy's validated `apple_ioreport`. -// Used only while the CPU energy rail is live: see `probe_cpu_rail`. +// Used only while the CPU energy rail is live: see `probe`. #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] mod macos_ioreport { - use super::liveness::{is_cpu_energy_rail, strip_die_prefix, CpuRail, RailWatch}; + use super::liveness::{strip_die_prefix, watch, CpuRail, Sample}; use std::os::raw::{c_int, c_long, c_void}; use std::time::{Duration, Instant}; @@ -568,9 +940,10 @@ mod macos_ioreport { /// Open the probe's own Energy Model subscription, burn CPU on this thread /// — [`PROBE_TICK`] of spin before each re-sample — and report whether a CPU - /// energy rail **itself** keeps advancing, by the rule in [`RailWatch`]: - /// the same rail must advance in two separate sample intervals. `None` if - /// IOReport cannot open or sample. + /// energy rail **itself** keeps advancing, by the rule in + /// [`super::liveness::RailWatch`]: the same rail must advance in two + /// separate intervals between consecutive samples. `None` if IOReport + /// cannot be opened, or opens but yields no two consecutive samples. /// /// The summed total [`power_over`] reads is not a liveness signal. Measured /// on an M5 Max and an M3 Ultra, both on macOS 27.0.1: every CPU rail in the @@ -583,14 +956,13 @@ mod macos_ioreport { /// /// Sampling runs until a rail proves live, or until `budget` has elapsed /// and at least two intervals were observed, never past five budgets - /// ([`RailWatch::wants_more`]). The spin cannot manufacture `Live` — only the - /// rail's own raw integer advancing twice can — so a short spin errs only - /// toward declining, the safe side. - pub fn probe_cpu_rail(budget: Duration) -> Option { - probe(budget).0 - } - - /// [`probe_cpu_rail`], and how many sample intervals it observed. + /// ([`super::liveness::watch`]). The spin cannot manufacture `Live` — only + /// the rail's own raw integer advancing twice can — so a short spin errs + /// only toward declining, the safe side. + /// + /// Returns the verdict and how many sample intervals it observed. The loop + /// is [`super::liveness::watch`]; this only supplies the IOReport samples, + /// the spin and the clock. pub(super) fn probe(budget: Duration) -> (Option, u32) { unsafe { let Some((sub, ch)) = open_energy_model() else { @@ -603,87 +975,55 @@ mod macos_ioreport { }); return (absent, 0); }; - let watch = watch_subscription(sub, ch, budget); + let origin = Instant::now(); + let watched = watch(|| energy_model_sample(sub, ch), spin_tick, || origin.elapsed(), budget); CFRelease(sub); CFRelease(ch as CFTypeRef); - (watch.verdict(), watch.intervals()) + watched } } - /// Feed consecutive sample intervals of `sub` to a [`RailWatch`] while this - /// thread burns CPU, until it has a verdict. A watch with no interval means - /// the subscription could not be sampled. - unsafe fn watch_subscription( - sub: IOReportSubscriptionRef, - ch: CFMutableDictionaryRef, - budget: Duration, - ) -> RailWatch { - let mut watch = RailWatch::default(); - let mut prev = IOReportCreateSamples(sub, ch, std::ptr::null()); - if prev.is_null() { - return watch; - } - let started = Instant::now(); + /// Burn [`PROBE_TICK`] of CPU on this thread. + fn spin_tick() { + let tick = Instant::now(); let mut acc: u64 = 0; - while watch.wants_more(started.elapsed(), budget) { - let tick = Instant::now(); - while tick.elapsed() < PROBE_TICK { - acc = acc.wrapping_add(1); - } - std::hint::black_box(acc); - let cur = IOReportCreateSamples(sub, ch, std::ptr::null()); - if cur.is_null() { - continue; - } - let delta = IOReportCreateSamplesDelta(prev, cur, std::ptr::null()); - if delta.is_null() { - CFRelease(cur); - continue; - } - let rails = cpu_rail_advances(delta); - CFRelease(delta); - CFRelease(prev); - prev = cur; - watch.observe(rails.iter().map(|(name, advanced)| (name.as_str(), *advanced))); + while tick.elapsed() < PROBE_TICK { + acc = std::hint::black_box(acc.wrapping_add(1)); } - CFRelease(prev); - watch } - /// In a samples-delta dictionary (two consecutive samples): every CPU - /// energy rail in the Energy Model group ([`is_cpu_energy_rail`]), and - /// whether its raw integer advanced over that interval. Raw integers, before + /// One sample of `sub` as the liveness rule reads it: every Energy Model + /// channel's name and ABSOLUTE raw integer, in channel order — raw, before /// unit conversion, so a small advance in a fine unit cannot truncate to - /// zero; per rail, never the summed total, which the live GPU rail corrupts. - unsafe fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { - let mut rails = Vec::new(); + /// zero. `None` if IOReport returns no sample. No decision is made here. + unsafe fn energy_model_sample(sub: IOReportSubscriptionRef, ch: CFMutableDictionaryRef) -> Option { + let smp = IOReportCreateSamples(sub, ch, std::ptr::null()); + if smp.is_null() { + return None; + } + let mut channels = Vec::new(); let key = CFStringCreateWithCString( std::ptr::null_mut(), c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); - if key.is_null() { - return rails; - } - let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; - CFRelease(key as CFTypeRef); - if arr.is_null() { - return rails; - } - for i in 0..CFArrayGetCount(arr) { - let item = CFArrayGetValueAtIndex(arr, i); - if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { - continue; - } - let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) else { - continue; - }; - if is_cpu_energy_rail(&name) { - let advanced = IOReportSimpleGetIntegerValue(item, 0) > 0; - rails.push((name, advanced)); + if !key.is_null() { + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if !arr.is_null() { + for i in 0..CFArrayGetCount(arr) { + let item = CFArrayGetValueAtIndex(arr, i); + if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { + continue; + } + if let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) { + channels.push((name, IOReportSimpleGetIntegerValue(item, 0) as i64)); + } + } } } - rails + CFRelease(smp); + Some(channels) } /// Average SoC power (watts) over `d`: two Energy-Model samples bracketing a @@ -824,17 +1164,51 @@ mod macos_ioreport { mod tests { use super::*; use crate::meter::{ - hardware_meter_available, ioreport_status, measure_marginal_uj, IoReportStatus, - CPU_RAIL_LIVENESS_BUDGET, + cached_probe, hardware_meter_available, ioreport_status, measure_marginal_uj, IoReportStatus, + CPU_RAIL_LIVENESS_BUDGET, STATUS_PROBE_RUNS, }; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; + /// The fewest sample intervals a probe on the Energy Model group alone + /// observes within one budget: it sees 27–32 in ~1.1 s on both chips, 16 + /// under a concurrent full-core burst. A probe that re-samples every + /// channel sees 2–8, so this floor pins both the group-only subscription + /// and the ~25 ms tick. The test's own literal, not an implementation + /// constant. + const MIN_PROBE_INTERVALS: u32 = 10; + /// Real, cheap, pure CPU work for the metering tests. fn cpu_work() { std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); } + /// The liveness probe behind `ioreport_status()` runs once per process, + /// however many callers ask and whatever path they take: a probe per + /// call would burn ~1 s on each caller's thread (inside `bracket()`, a + /// whole probe in the busy window) and could flip the verdict + /// mid-process. + #[test] + fn the_liveness_probe_runs_once_per_process() { + let first = ioreport_status(); + for _ in 0..3 { + assert_eq!(ioreport_status(), first, "the cached status changed"); + } + let _ = hardware_meter_available(); + let _ = available(); + let _ = power_over(Duration::from_millis(10)); + let _ = measure_marginal_uj(cpu_work); + let t = Instant::now(); + let _ = ioreport_status(); + let warm = t.elapsed(); + assert_eq!( + STATUS_PROBE_RUNS.load(Ordering::SeqCst), + 1, + "the liveness probe ran more than once in this process" + ); + assert!(warm < PROBE_TICK, "a cached ioreport_status() took {warm:?}"); + } + #[test] fn energy_to_uj_scales_by_unit() { assert_eq!(energy_to_uj(5, "mJ"), 5_000); @@ -877,10 +1251,12 @@ mod macos_ioreport { CpuRailMoved(bool), } - /// An oracle that shares nothing with the liveness probe but the raw FFI. - /// It opens its own subscription over every channel (not the probe's - /// Energy Model one, and not through `open()`), reads each CPU rail's - /// ABSOLUTE cumulative value (no delta samples), matches names with + /// An oracle that shares only the raw FFI declarations and the CFString + /// helper (`cfstring_to_string`) with the liveness probe. It opens its + /// own subscription over every channel (not the probe's Energy Model + /// one, and not through `open()`), reads each CPU rail's ABSOLUTE + /// cumulative value with its own comparison loop (not + /// `liveness::watch` or `RailWatch`), matches names with /// `ends_with("CPU Energy")` in any group rather than the probe's group /// filter and die-prefix stripping, and burns CPU on a helper thread for /// the whole of `budget` while it reads. @@ -1018,14 +1394,28 @@ mod macos_ioreport { /// The oracle gets three times the probe's budget, so a rail too slow for /// the probe but moving within the oracle's window is the only case that /// may be declined while live — and that fails safe: no hardware figure, - /// never an over-claimed one. + /// never an over-claimed one. The cached probe that made the decision + /// must also have re-sampled at the group-only cadence. #[test] fn the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail() { let status = ioreport_status(); + let (_, probe_intervals) = cached_probe(); let selected = hardware_meter_available(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); - // Recorded so a CI log says which branch below was actually asserted. - eprintln!("ioreport liveness: status={status:?} oracle={oracle:?} selected={selected}"); + // Recorded so a log of a macOS run (manual, or a CI job that runs + // this crate's lib tests with --nocapture) says which branch below + // was actually asserted. + eprintln!( + "ioreport liveness: status={status:?} oracle={oracle:?} selected={selected} \ + probe_intervals={probe_intervals}" + ); + if status == IoReportStatus::CpuRailFrozen { + assert!( + probe_intervals >= MIN_PROBE_INTERVALS, + "the cached probe called the rail frozen after {probe_intervals} intervals in one \ + budget; the group-only probe re-samples every ~25 ms (27-32 intervals)" + ); + } match oracle { Oracle::NoIoReport => { assert_eq!(status, IoReportStatus::Unavailable); @@ -1065,9 +1455,11 @@ mod macos_ioreport { /// The dangerous direction on its own: the probe never reports a live rail /// the oracle cannot see keep moving. The probe runs fresh here (not the /// cached status) and the oracle runs unconditionally, so every outcome - /// is checked against it, not only Live. Logs how long the probe took and - /// how many sample intervals it saw, so a CI log shows the probe really - /// re-samples every ~25 ms of spin. + /// is checked against it, not only Live. A verdict other than Live from + /// an Energy Model subscription that opened must rest on at least + /// [`MIN_PROBE_INTERVALS`] intervals: that pins the group-only + /// subscription and the ~25 ms tick. Logs how long the probe took and how + /// many sample intervals it saw. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { let t = Instant::now(); @@ -1091,15 +1483,21 @@ mod macos_ioreport { matches!(oracle, Oracle::CpuRailMoved(_)), "the probe saw a CPU rail, the oracle found none: {oracle:?}" ); - // Frozen is a verdict over at least two intervals, unless the - // five-budget cap cut the probe short. + // Cadence: the group-only probe re-samples every ~25 ms. assert!( - intervals >= 2 || probe_took >= CPU_RAIL_LIVENESS_BUDGET * 5, - "Frozen after {intervals} interval(s) in {probe_took:?}" + intervals >= MIN_PROBE_INTERVALS, + "Frozen after only {intervals} interval(s) in {probe_took:?}: the probe is not \ + re-sampling the Energy Model group every ~25 ms" ); } (Some(CpuRail::Absent), _) => { - assert_eq!(oracle, Oracle::NoCpuRail, "the probe found no CPU rail, the oracle found one") + assert_eq!(oracle, Oracle::NoCpuRail, "the probe found no CPU rail, the oracle found one"); + // 0 intervals: the Energy Model group could not be subscribed + // at all. Otherwise the same cadence as a Frozen verdict. + assert!( + intervals == 0 || intervals >= MIN_PROBE_INTERVALS, + "Absent after only {intervals} interval(s) in {probe_took:?}" + ); } (None, _) => assert_eq!( oracle, @@ -1117,7 +1515,7 @@ mod macos_ioreport { if hardware_meter_available() { assert_eq!(ioreport_status(), IoReportStatus::Live, "selected on a CPU rail that is not live"); assert_eq!( - probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET), + probe(CPU_RAIL_LIVENESS_BUDGET).0, Some(CpuRail::Live), "the CPU rail stopped advancing after the meter was selected" ); @@ -1150,28 +1548,36 @@ fn idle_power() -> Option { fn busy_power() -> Option { rapl_power_over(std::time::Duration::from_millis(1400)) } +/// Average power over `d` across the RAPL zones: per-zone energy deltas, each +/// modulo its own wrap ([`rapl::delta_uj`]), ÷ the window. `None` if no zone is +/// readable, or the delta is unknown. #[cfg(target_os = "linux")] fn rapl_power_over(d: std::time::Duration) -> Option { - let e0 = rapl_energy_uj()?; + let z0 = rapl_zones(); + if z0.is_empty() { + return None; + } let t = Instant::now(); std::thread::sleep(d); - let e1 = rapl_energy_uj()?; + let z1 = rapl_zones(); let dt = t.elapsed().as_secs_f64(); - Some(e1.wrapping_sub(e0) as f64 / 1e6 / dt) + Some(rapl::delta_uj(&z0, &z1)? as f64 / 1e6 / dt) } +/// Every readable RAPL zone (`intel-rapl:0`..`7`): its `energy_uj` counter and +/// the `max_energy_range_uj` it wraps at, when that is readable. #[cfg(target_os = "linux")] -fn rapl_energy_uj() -> Option { - let mut total = 0u64; - let mut found = false; - for i in 0..8 { - if let Ok(s) = std::fs::read_to_string(format!("/sys/class/powercap/intel-rapl:{i}/energy_uj")) { - if let Ok(v) = s.trim().parse::() { - total = total.wrapping_add(v); - found = true; - } - } - } - found.then_some(total) +fn rapl_zones() -> Vec { + let read = |path: String| std::fs::read_to_string(path).ok()?.trim().parse::().ok(); + (0..8u32) + .filter_map(|index| { + let base = format!("/sys/class/powercap/intel-rapl:{index}"); + Some(rapl::Zone { + index, + energy_uj: read(format!("{base}/energy_uj"))?, + max_energy_range_uj: read(format!("{base}/max_energy_range_uj")), + }) + }) + .collect() } #[cfg(not(any(target_os = "macos", target_os = "linux")))] @@ -1186,6 +1592,84 @@ fn busy_power() -> Option { #[cfg(test)] mod tests { use super::*; + use std::time::Duration; + + /// How many times [`eighty_ms_of_work`] has run in this process. + static WORK_CALLS: AtomicU64 = AtomicU64::new(0); + + /// Real CPU work: ~80 ms of it on the calling thread, per call. + fn eighty_ms_of_work() { + WORK_CALLS.fetch_add(1, Ordering::SeqCst); + let t = Instant::now(); + let mut acc: u64 = 0; + while t.elapsed() < Duration::from_millis(80) { + acc = std::hint::black_box(acc.wrapping_mul(31).wrapping_add(1)); + } + } + + /// A busy window with no reading gives no figure AND stops its workers: + /// they must not keep burning every core for the rest of the process. + #[test] + fn a_busy_window_with_no_reading_stops_its_workers() { + static CALLS: AtomicU64 = AtomicU64::new(0); + fn counted_work() { + CALLS.fetch_add(1, Ordering::SeqCst); + std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); + } + let r = bracket_with(counted_work, || Some(1.0), || { + std::thread::sleep(Duration::from_millis(30)); + None + }); + assert_eq!(r, None); + let after = CALLS.load(Ordering::SeqCst); + assert!(after > 0, "the busy window never ran the work"); + std::thread::sleep(Duration::from_millis(60)); + assert_eq!(CALLS.load(Ordering::SeqCst), after, "workers kept running after the bracket returned"); + } + + /// The probe's verdict maps one-to-one onto the public status; no verdict + /// (IOReport could not be opened or sampled) is `Unavailable`. + #[test] + fn the_verdict_maps_onto_the_status_one_to_one() { + use liveness::CpuRail; + assert_eq!(status_from(None), IoReportStatus::Unavailable); + assert_eq!(status_from(Some(CpuRail::Absent)), IoReportStatus::CpuRailAbsent); + assert_eq!(status_from(Some(CpuRail::Frozen)), IoReportStatus::CpuRailFrozen); + assert_eq!(status_from(Some(CpuRail::Live)), IoReportStatus::Live); + } + + /// The declined path meters real work honestly. This meter's fallback is no + /// figure at all: on a host whose interface is declined, real CPU work (a + /// direct call burns 80 ms) put through `measure_marginal_uj` gives `None` + /// — not `Some(0)`, not a floored figure — before any bracket runs, so the + /// work is never called and the caller keeps its own `ModelBased` figure. + /// Where an interface is usable, the bracket really runs the work. + #[test] + fn a_declined_meter_gives_real_work_no_figure_and_runs_no_bracket() { + let t = Instant::now(); + eighty_ms_of_work(); + let direct = t.elapsed(); + assert!(direct >= Duration::from_millis(80), "the work did not run: {direct:?}"); + let available = hardware_meter_available(); + let before = WORK_CALLS.load(Ordering::SeqCst); + let t = Instant::now(); + let r = measure_marginal_uj(eighty_ms_of_work); + let took = t.elapsed(); + let runs = WORK_CALLS.load(Ordering::SeqCst) - before; + eprintln!( + "declined path: available={available} ioreport_status={:?} figure={r:?} work_runs={runs} \ + took={took:?}", + ioreport_status() + ); + if available { + assert!(runs > 0, "a usable meter answered {r:?} without running the work"); + assert!(r.is_some(), "a usable hardware interface returned no reading"); + } else { + assert_eq!(r, None, "a declined meter gave real work a figure"); + assert_eq!(runs, 0, "a declined meter ran a bracket ({runs} calls of the work)"); + assert!(took < Duration::from_millis(80), "a declined meter took {took:?} to say None"); + } + } /// A figure exactly when a usable interface exists: `None` — never a number — /// where none is (including a declined IOReport), and a plausible per-call diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index 4986e2698..f3526dca1 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -87,17 +87,27 @@ fn main() { summary.uj_by_tier[0], summary.uj_by_tier[1], summary.uj_by_tier[2], summary.uj_by_tier[3], t3, ); - // --measure: upgrade the headline from a wall-time model to a REAL hardware - // reading. Bracket a real T0 proof against idle on the platform's energy - // interface (IOReport on Apple Silicon, used only while its CPU energy rail - // is live; RAPL on Linux) and report the marginal joules with HwShunt - // provenance — the schema's strongest tier. Where no interface is usable, - // print why: the figures above then stay ModelBased. + // --measure: try to put a hardware reading beside the wall-time model. + // Bracket a real T0 proof against idle on the platform's energy interface + // (IOReport on Apple Silicon, used only while its CPU energy rail is live; + // RAPL on Linux) and report the marginal joules. The figure is tagged + // `Energy::measured`, i.e. HwShunt, on both platforms. meter.rs's header + // notes that Apple's Energy Model is a vendor model; which tier a live + // IOReport reading earns is an open grade decision, not settled here. + // Where no interface is usable, print why: the figures above then stay + // ModelBased. if measure { - use proof_cascade::meter::{hardware_meter_available, measure_marginal_uj}; - if !hardware_meter_available() { + use proof_cascade::meter::{hardware_meter_available, ioreport_status, measure_marginal_uj}; + let available = hardware_meter_available(); + // The decision THIS process made, printed so tests/measure.rs checks it + // instead of re-probing in its own process, which could disagree. + println!( + "\n --measure: hardware meter available={available} · ioreport_status={:?}", + ioreport_status() + ); + if !available { println!( - "\n --measure: no hardware energy reading on this host: {}.\n The figures above stay ModelBased — honestly tagged.", + " --measure: no hardware energy reading on this host: {}.\n The figures above stay ModelBased — honestly tagged.", no_hardware_reading_reason() ); } else { @@ -107,7 +117,7 @@ fn main() { // and bracketed against idle. This is real proof work, not a spin. const HARD: &str = "(a|b|c|d|e|f|g|h|i|j|k|l|m|n|o|p) | (~a&~b&~c&~d&~e&~f&~g&~h&~i&~j&~k&~l&~m&~n&~o&~p)"; - println!("\n --measure: metering a real 16-variable T0 proof against idle on the energy interface…"); + println!(" --measure: metering a real 16-variable T0 proof against idle on the energy interface…"); let parsed = proof_solvers::prop::parse(HARD).expect("tautology parses"); assert_eq!(proof_solvers::prop::is_valid(&parsed), Some(true), "the probe goal must be valid"); let measured = measure_marginal_uj(|| { @@ -184,7 +194,10 @@ fn no_hardware_reading_reason() -> &'static str { this process burned CPU), so it cannot meter CPU work" } IoReportStatus::CpuRailAbsent => "IOReport opens, but exposes no CPU energy rail", - IoReportStatus::Unavailable => "IOReport is unavailable (it needs Apple Silicon, not Rosetta)", + IoReportStatus::Unavailable => { + "IOReport could not be opened, or opened but could not be sampled (opening it needs \ + Apple Silicon, not Rosetta)" + } IoReportStatus::Live => "IOReport's CPU energy rail is live, but the bracket returned no reading", } } else if cfg!(target_os = "linux") { diff --git a/proof/proof-rs/crates/proof-cli/tests/measure.rs b/proof/proof-rs/crates/proof-cli/tests/measure.rs index 7d9fc6474..847623818 100644 --- a/proof/proof-rs/crates/proof-cli/tests/measure.rs +++ b/proof/proof-rs/crates/proof-cli/tests/measure.rs @@ -1,9 +1,18 @@ //! `proof --measure`, end to end: when the hardware meter declines, the output -//! names the reason and nothing in it claims a hardware reading; when it -//! meters, the host really has a usable interface. +//! names the reason, nothing in it claims a hardware reading, and the cascade's +//! own ModelBased figures stand; when it meters, the host really has a usable +//! interface. Every check is against the decision the `proof` process printed, +//! never a second probe in this process: two independent ~1 s probes of a +//! borderline rail could disagree. use std::process::Command; +/// The value after `key=` on the first line that has it, up to the next space. +fn field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> { + let at = stdout.find(key)? + key.len(); + stdout[at..].split_whitespace().next() +} + #[test] fn measure_names_why_it_declined_and_claims_no_hardware_reading() { let out = Command::new(env!("CARGO_BIN_EXE_proof")) @@ -17,11 +26,40 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { out.status, String::from_utf8_lossy(&out.stderr) ); - let status = proof_cascade::meter::ioreport_status(); + let available = match field(&stdout, "--measure: hardware meter available=") { + Some("true") => true, + Some("false") => false, + other => panic!("proof --measure did not print its meter decision ({other:?}):\n{stdout}"), + }; + let status = field(&stdout, "ioreport_status=") + .unwrap_or_else(|| panic!("proof --measure did not print its IOReport status:\n{stdout}")); let declined = stdout.contains("--measure: no hardware energy reading on this host: "); - // Recorded so a CI log says which branch below was actually asserted. - eprintln!("proof --measure: declined={declined} ioreport_status={status:?}"); + // Recorded so a log says which branch below was actually asserted. + eprintln!("proof --measure: declined={declined} available={available} ioreport_status={status}"); + // The sealed receipt is the cascade's own figure for the first goal, on + // every branch: ModelBased and non-zero, never the --measure figure. + let receipt = stdout + .lines() + .find(|l| l.trim_start().starts_with("receipt: closed at")) + .unwrap_or_else(|| panic!("no sealed receipt in the output:\n{stdout}")); + let receipt_uj: u64 = receipt + .split(" · ") + .nth(1) + .and_then(|f| f.strip_suffix(" uj")) + .and_then(|n| n.parse().ok()) + .unwrap_or_else(|| panic!("no uj figure in the receipt line: {receipt}")); + assert!(receipt_uj > 0, "the receipt carries a zero figure for a proof that ran: {receipt}"); + assert!(receipt.contains("provenance ModelBased"), "the receipt's provenance changed: {receipt}"); + + assert_eq!(declined, !available, "the CLI's output disagrees with the decision it printed:\n{stdout}"); + if cfg!(target_os = "macos") { + assert_eq!( + available, + status == "Live", + "on macOS the meter is available exactly when IOReport is Live:\n{stdout}" + ); + } if declined { assert!( !stdout.contains("HwShunt"), @@ -31,25 +69,30 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { !stdout.contains("idle noise"), "the meter declined, yet the output blames a noisy machine:\n{stdout}" ); + assert!( + !stdout.contains("--measure: metering a real"), + "the meter declined, yet the CLI metered:\n{stdout}" + ); assert!( stdout.contains("The figures above stay ModelBased"), "a declined meter must leave the ModelBased figures standing:\n{stdout}" ); - if status == proof_cascade::meter::IoReportStatus::CpuRailFrozen { + if status == "CpuRailFrozen" { assert!( stdout.contains("its CPU energy rail is frozen"), "a frozen IOReport CPU rail must be named as the reason:\n{stdout}" ); } + if cfg!(target_os = "macos") && status == "Unavailable" { + assert!( + stdout.contains("could not be opened, or opened but could not be sampled"), + "an unavailable IOReport must be named as the reason:\n{stdout}" + ); + } } else { assert!( stdout.contains("--measure: metering a real 16-variable T0 proof") && stdout.contains(" measured: "), "neither a declined meter nor a measurement in the output:\n{stdout}" ); - // A figure may only come from an interface this host really exposes. - assert!( - proof_cascade::meter::hardware_meter_available(), - "the CLI metered on a host whose meter declines here ({status:?}):\n{stdout}" - ); } } -- 2.54.0 (Apple Git-157) From 79375a1c48e6acb570e5bce275abd7c874c1d22d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 08:50:36 -0400 Subject: [PATCH 04/10] proof-cascade: the probe burns thread CPU time, and tests pin how it samples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-verification of bedef9b0 left four mutations alive. All four are in how the probe samples, not in what it decides: - an advance was any change (`now != before`), so a decrease counted; - a rail missing from the previous sample counted as an advance; - spin_tick slept instead of burning; - the probe passed a no-op tick. It also found two docs that claimed more coverage than the tests had. two_advances_of_the_same_rail_are_live claimed to cover decreases and new rails. MIN_PROBE_INTERVALS claimed to pin the ~25 ms tick. This commit makes each of those fail a test, and applies the family's round-4 test design (R1-R8). R1, the tick. A tick now burns 25 ms of the probing thread's own CPU time, read with clock_gettime(CLOCK_THREAD_CPUTIME_ID), with a cap of 500 ms of wall time. Before, it spun for 25 ms of wall time, which gives the thread only a fraction of that in CPU on a loaded host. The tick is still passed into the generic loop, so the scripted tests keep their scripted ticks. The probe's record. probe() now returns a ProbeRun: the verdict, the interval count, whether the Energy Model subscription opened, and for every FFI sample call, its wall time and the thread CPU clock just before and after the call. The once-per-process cache keeps this record, so a test can check the run that actually made the decision. Two pins read it; neither is a wall-clock ratio: - R2, the probe burns CPU. In every interval, the thread CPU time between sample calls is at least PROBE_TICK/2. Host load cannot shorten a CPU-time burn. Only the 500 ms cap can, and the assertion message says so. A separate test checks that one tick burns at least PROBE_TICK. - R3, the probe samples the group alone. The median wall time of the probe's own sample calls is under 50 ms (the group takes ~6-12 ms, every channel 130-800 ms). This replaces the floor of at least 10 intervals. Under load the probe saw as few as 4 intervals (load average 92), which the old floor would have failed. Both hardware tests check R2 and R3, one on the cached probe and one on a fresh probe, and print the median. R4, the FFI value pin. A new test places one probe sample between two independent all-channel oracle reads, three times over. It matches each CPU rail by group, name and ordinal. Where the oracle saw the rail hold, the probe's value must be equal to it. Where the rail moved, the value must lie between the two reads. On both hosts every rail held and matched exactly: M5 Max `CPU Energy` 1,863,990,848; M3 Ultra `DIE_0_` 28,237,793 and `DIE_1_` 30,845,042. R5, oracle evidence. The oracle's window is extended, up to 3x, only while it has fewer than 2 reads. If it still has fewer than 2 at the end, the test fails. It never reports CpuRailMoved(false) from 0 or 1 reads. R6, the pure rules, one test each: - a decrease between consecutive samples is not an advance; - a rail that is new in a sample only sets its baseline (a new name, or a second channel with the same name); - an Energy Model subscription that cannot be opened, or never yields a sample, gives no verdict, which maps to Unavailable, never to Live or Absent. This goes through the new pure `liveness::probe_with`; - no verdict is Unavailable (`no_verdict_is_unavailable`). One behaviour changes. A host where IOReport opens but the Energy Model group cannot be subscribed used to report CpuRailAbsent; it now reports Unavailable. Both decline. The enum variants are unchanged, and the docs and proof-cli's reason strings now say which status means what. The sampler's FFI code now only reads each channel's (group, name, integer). Which channels are kept is decided by the pure `energy_model_sample_from`, and a test checks that each channel keeps its own value. R7, the cache. STATUS_PROBE_RUNS was already a process-wide static AtomicUsize. The cache test now releases 8 threads together through a Barrier. Each asks for the status and selects the meter: hardware_meter_available and available, and on a declined host also measure_marginal_uj and power_over. The test asserts that the probe ran exactly once. R8. the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail still prints "ioreport liveness: status=...". The line now also carries median_sample, min_interval_cpu and oracle_reads. Flake removals. The M5 Max is also the macOS CI runner and is often heavily loaded, so three timing assertions are gone: - the warm-cache `< 25 ms` check (the probe counter is the pin); - the declined path's `took < 80 ms` check (the work-call count is the pin, and the time is still printed); - the fixed 30 ms sleep in a_busy_window_with_no_reading_stops_its_workers (the test now waits until the workers have run). Falsified. Each mutation was applied, run, and restored with git checkout, with git status clean afterwards. Each one failed a test: - a decrease counts (`now != before`): "a decrease counted as an advance"; - a new rail counts: "a rail's first appearance counted as an advance"; - no group subscription maps to Live, or to Absent: "left: (Some(Live), 0)" / "(Some(Absent), 0)"; - status_from(None) is Live: 4 tests fail, including no_verdict_is_unavailable; - a per-thread cache: "probe runs in this process: 14"; skipping the OnceLock: 54; - the sampler returns constant 0: "held at 1863990848 across the oracle's reads, yet the probe's sampler read 0"; - the sampler gives each CPU rail the sum of all CPU rails, applied in the pure assembly: caught on the M5 Max. Applied at the FFI boundary: an equivalent mutant on the M5 Max, which has one CPU rail (44/44 pass). It is caught on the M3 Ultra: "held at 28237793 ... read 59082835"; - the sampler reads the GPU rail: 4 tests fail; - the tick sleeps: "one tick burned 10.75µs"; "interval 0 of 25 consumed 27.25µs"; - the tick is a no-op: "interval 0 of 114 consumed 15.792µs"; - the probe uses the all-channel subscription: median sample calls of 528 ms and 293 ms on the M5 Max, and 174 ms and 163 ms on the M3 Ultra; - the oracle is starved: "the oracle got only 0 read(s)". The round-3 wall-clock spin, as an extra check, is caught only by the one-tick test (24.70 ms < 25 ms), and narrowly. Runs on this tree: - M5 Max, lib tests 10x under 36 extra busy-loop processes (2 x 18 CPUs), load average 50-168: 10/10, 44 passed each. Median sample call 7.5-11.4 ms, minimum interval CPU 25.019 ms, 6-8 intervals. Every run printed "ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false". - M3 Ultra, once: 44 passed, median 8.7 ms, 29 intervals. - The proof-rs workspace is green, and the proof-conformance binary prints "all checks passed". joulecontract-proof passes 6/6 and joule-code-release 6/6. - joule-code-remediate fails verified_fix_end_to_end_ships_with_receipt. It fails the same way with bedef9b0's meter.rs, so the failure predates this change; that crate meters through sandbox-meter, not this crate. - clippy reports only the two pre-existing warnings. The x86_64 Linux build is compile-checked only (no Linux host). Not changed, for David: which provenance grade a live IOReport reading earns (the module header still says HwShunt), and adding this crate to a macOS CI job. Co-Authored-By: Claude Opus 5.5 --- .../crates/proof-cascade/src/meter.rs | 1044 +++++++++++++---- proof/proof-rs/crates/proof-cli/src/main.rs | 6 +- 2 files changed, 799 insertions(+), 251 deletions(-) diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index 378f5175a..d5771b739 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -69,8 +69,8 @@ where /// energy rail** — [`ioreport_status`] is [`IoReportStatus::Live`]. A /// subscription that opens over a frozen CPU rail does not count. On macOS the /// first call pays the liveness probe (its own Energy Model subscription, then -/// about `CPU_RAIL_LIVENESS_BUDGET` of spinning and re-sampling on the calling -/// thread); the answer is cached for the process. +/// about `CPU_RAIL_LIVENESS_BUDGET` of burning CPU time and re-sampling on the +/// calling thread); the answer is cached for the process. pub fn hardware_meter_available() -> bool { #[cfg(target_os = "macos")] { @@ -89,13 +89,15 @@ pub fn hardware_meter_available() -> bool { /// Why the IOReport interface is or is not used on this host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IoReportStatus { - /// IOReport could not be opened or sampled: no subscription could be - /// opened (not macOS, not Apple Silicon, or running under Rosetta), or the - /// Energy Model subscription opened but never yielded two consecutive - /// samples within the probe's five budgets. + /// IOReport could not be opened or sampled: no subscription to its Energy + /// Model group could be opened (not macOS, not Apple Silicon, running + /// under Rosetta, or no such group), or that subscription opened but never + /// yielded two consecutive samples within the probe's five budgets. Never + /// used. Unavailable, - /// A subscription opened, but no `CPU Energy` rail exists in the Energy - /// Model group — an unfamiliar channel naming. Never used. + /// The Energy Model subscription opened and was sampled, but no + /// `CPU Energy` rail exists in it — an unfamiliar channel naming. Never + /// used. CpuRailAbsent, /// A CPU energy rail exists but did not keep advancing while CPU was /// burned: no CPU rail advanced in two separate sample intervals. A rail @@ -109,20 +111,23 @@ pub enum IoReportStatus { Live, } -/// How long the liveness probe watches the CPU energy rail, spinning ~25 ms on -/// the calling thread before each re-sample, before concluding it is frozen. -/// The probe samples its own subscription to the Energy Model group alone, -/// ~5–12 ms a sample on an M5 Max and an M3 Ultra (every channel: 0.1–0.8 s on -/// the M5 Max, ~0.14 s on the M3 Ultra), so this buys about thirty intervals of -/// ~35 ms, where a live rail needs two. If fewer than two intervals were -/// observed when it runs out, the probe keeps sampling, up to five budgets. A -/// live rail slower than that fails safe: declined, and the caller keeps its -/// `ModelBased` figure. Paid once per process; see [`ioreport_status`]. +/// How long (wall time) the liveness probe watches the CPU energy rail before +/// concluding it is frozen. Before each re-sample the probe burns ~25 ms of +/// the calling thread's own CPU time (at most 500 ms of wall time per tick). +/// It samples its own subscription to the Energy Model group alone, ~5–12 ms a +/// sample on an M5 Max and an M3 Ultra (every channel: 0.1–0.8 s on the M5 +/// Max, ~0.14 s on the M3 Ultra), so on a quiet host this buys about thirty +/// intervals, and fewer on a loaded one, where a live rail needs two. If fewer +/// than two intervals were observed when it runs out, the probe keeps +/// sampling, up to five budgets. A live rail slower than that fails safe: +/// declined, and the caller keeps its `ModelBased` figure. Paid once per +/// process; see [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); /// How many times this process has run the liveness probe behind -/// [`ioreport_status`] — once at most; a test pins that. +/// [`ioreport_status`] — once at most. A process-wide static (not a +/// thread-local), so a test that asks from many threads at once can pin it. #[cfg(target_os = "macos")] static STATUS_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); @@ -144,20 +149,23 @@ pub fn ioreport_status() -> IoReportStatus { } /// The once-per-process liveness probe behind [`ioreport_status`]: its status -/// and how many sample intervals it observed. +/// and the record of the run that decided it (how many intervals, and every +/// sample call it made). One process-wide `OnceLock`: concurrent first callers +/// wait for the one probe instead of each running their own. #[cfg(target_os = "macos")] -fn cached_probe() -> (IoReportStatus, u32) { - static STATUS: std::sync::OnceLock<(IoReportStatus, u32)> = std::sync::OnceLock::new(); - *STATUS.get_or_init(|| { +fn cached_probe() -> &'static (IoReportStatus, macos_ioreport::ProbeRun) { + static STATUS: std::sync::OnceLock<(IoReportStatus, macos_ioreport::ProbeRun)> = std::sync::OnceLock::new(); + STATUS.get_or_init(|| { STATUS_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); - let (verdict, intervals) = macos_ioreport::probe(CPU_RAIL_LIVENESS_BUDGET); - (status_from(verdict), intervals) + let run = macos_ioreport::probe(CPU_RAIL_LIVENESS_BUDGET); + (status_from(run.verdict), run) }) } -/// The probe's verdict as the public status. `None` — IOReport could not be -/// opened, or opened but never yielded two consecutive samples — is -/// `Unavailable`. +/// The probe's verdict as the public status. `None` — the Energy Model +/// subscription could not be opened, or opened but never yielded two +/// consecutive samples — is `Unavailable`: never `Live`, and never +/// `CpuRailAbsent`, which needs a sampled subscription with no CPU rail in it. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] fn status_from(verdict: Option) -> IoReportStatus { match verdict { @@ -220,9 +228,11 @@ where } // --------------------------------------------------------------------------- -// CPU-rail liveness: the decision AND the sampling loop, kept apart from the -// FFI so both are pure, tested code on every platform. The macOS probe only -// supplies the samples, the spin and the clock. +// CPU-rail liveness: the decision, the sampling loop, what an unopenable +// subscription means, and which channels a sample keeps, kept apart from the +// FFI so all of it is pure, tested code on every platform. The macOS probe +// only supplies the subscription, the raw channel reads, the CPU burn and the +// clock. // --------------------------------------------------------------------------- #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod liveness { @@ -397,6 +407,40 @@ mod liveness { (w.verdict(), w.intervals()) } + /// The whole probe, generic over how its subscription is opened: open it, + /// then [`watch`] it. A subscription that cannot be opened gives no + /// verdict and zero intervals, and so does one that opens but never + /// yields a sample ([`watch`]); the caller maps no verdict to + /// `Unavailable`. Neither is `Absent`: that needs sampled intervals with no + /// CPU rail in them. The subscription is dropped (released) on return. + pub(super) fn probe_with( + open: impl FnOnce() -> Option, + mut sample: impl FnMut(&S) -> Option, + tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, + ) -> (Option, u32) { + let Some(sub) = open() else { + return (None, 0); + }; + watch(|| sample(&sub), tick, now, budget) + } + + /// One sample as the rule reads it, from the channels one IOReport sample + /// listed, each as (group, name, raw integer) the way the FFI read them + /// (`None` where a string could not be read). Keeps the channels of the + /// Energy Model group (any case) that have a name, in order, each with + /// ITS OWN raw integer. + pub(super) fn energy_model_sample_from( + channels: impl IntoIterator, Option, i64)>, + ) -> Sample { + channels + .into_iter() + .filter(|(group, _, _)| group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case("Energy Model"))) + .filter_map(|(_, name, raw)| Some((name?, raw))) + .collect() + } + /// Whether a channel is a CPU energy rail: its name, after stripping a /// `DIE__` prefix, is `CPU Energy` (any case). Not the per-cluster or /// per-core sub-rails (`PCPU`, `MCPU*`, `PACC_*`, `DIE_0_PACC0_CPU0`). @@ -496,9 +540,9 @@ mod liveness { assert_eq!(w.verdict(), Some(CpuRail::Frozen)); } - /// The same rail advancing in two separate intervals is live — on either - /// die — and not before; a rail missing from the previous sample did not - /// advance; a counter that goes down did not advance. + /// The same rail advancing in two separate intervals is live — here + /// the second die, with a hold between its two advances — and not + /// after the first. #[test] fn two_advances_of_the_same_rail_are_live() { let mut w = RailWatch::default(); @@ -506,13 +550,116 @@ mod liveness { w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 21)])); assert!(!w.is_live(), "one advance is not yet live"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - w.observe(&sample(&[("DIE_0_CPU Energy", 9), ("DIE_1_CPU Energy", 21), ("CPU Energy", 5)])); - assert!(!w.is_live(), "a decrease and a new rail are not advances"); - w.observe(&sample(&[("DIE_0_CPU Energy", 9), ("DIE_1_CPU Energy", 22), ("CPU Energy", 5)])); + w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 21)])); + assert!(!w.is_live(), "a hold is not an advance"); + w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 22)])); assert!(w.is_live()); assert_eq!(w.verdict(), Some(CpuRail::Live)); } + /// A rail that goes DOWN between consecutive samples — a reset or a + /// wrap — did not advance. The rail that drops here has already + /// advanced once, so counting the drop would make it live. + #[test] + fn a_decrease_between_consecutive_samples_is_not_an_advance() { + let mut w = RailWatch::default(); + for v in [20, 21, 3] { + w.observe(&sample(&[("DIE_0_CPU Energy", 100), ("DIE_1_CPU Energy", v)])); + } + assert_eq!(w.intervals(), 2); + assert!(!w.is_live(), "a decrease counted as an advance"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + // The same through the loop: advance, drop, hold, for the whole budget. + let r = run(TICK, |n| Some(vec![("CPU Energy", [20, 21, 3][n.min(2) as usize])])); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + assert_eq!(r.intervals, 40, "{r:?}"); + } + + /// A rail absent from the previous sample only sets its baseline: its + /// first appearance is not an advance, whatever its value. The rail + /// that appears here then advances once, so counting its appearance + /// would make it live — both for a new name and for a second channel + /// with a name already present. + #[test] + fn a_rail_new_in_this_sample_only_sets_its_baseline() { + let mut w = RailWatch::default(); + w.observe(&sample(&[("CPU Energy", 1)])); + w.observe(&sample(&[("CPU Energy", 1), ("DIE_1_CPU Energy", 5)])); + w.observe(&sample(&[("CPU Energy", 1), ("DIE_1_CPU Energy", 6)])); + assert_eq!(w.intervals(), 2); + assert!(!w.is_live(), "a rail's first appearance counted as an advance"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + + let mut w = RailWatch::default(); + w.observe(&sample(&[("CPU Energy", 1)])); + w.observe(&sample(&[("CPU Energy", 1), ("CPU Energy", 5)])); + w.observe(&sample(&[("CPU Energy", 1), ("CPU Energy", 6)])); + assert!(!w.is_live(), "a second same-named channel's first appearance counted as an advance"); + } + + /// The Energy Model subscription cannot be opened, or opens but never + /// yields a sample: no verdict, which the status function maps to + /// `Unavailable` — never `Live`, never `CpuRailAbsent`. A subscription + /// that cannot be opened is never sampled or ticked. + #[test] + fn a_group_subscription_that_cannot_be_opened_or_sampled_is_unavailable() { + use super::super::{status_from, IoReportStatus}; + let (samples, ticks, clock) = (Cell::new(0u32), Cell::new(0u32), Cell::new(Duration::ZERO)); + let tick = || { + ticks.set(ticks.get() + 1); + clock.set(clock.get() + TICK); + }; + + let unopened = probe_with( + || None::<()>, + |_| { + samples.set(samples.get() + 1); + Some(sample(&[("CPU Energy", 1)])) + }, + tick, + || clock.get(), + BUDGET, + ); + assert_eq!(unopened, (None, 0), "a subscription that cannot be opened gives no verdict"); + assert_eq!((samples.get(), ticks.get()), (0, 0), "an unopened subscription was sampled or ticked"); + + let unsampled = probe_with(|| Some(()), |_| None, tick, || clock.get(), BUDGET); + assert_eq!(unsampled, (None, 0), "a subscription that never yields a sample gives no verdict"); + + for (what, (verdict, _)) in [("cannot be opened", unopened), ("never yields a sample", unsampled)] { + let status = status_from(verdict); + assert_eq!(status, IoReportStatus::Unavailable, "a subscription that {what}"); + assert_ne!(status, IoReportStatus::Live, "a subscription that {what} is not Live"); + assert_ne!(status, IoReportStatus::CpuRailAbsent, "a subscription that {what} is not Absent"); + } + + // Opened and sampled, the verdict comes from the samples. + let sampled = probe_with(|| Some(()), |_| Some(sample(&[("CPU Energy", 1)])), tick, || clock.get(), BUDGET); + assert_eq!(sampled.0, Some(CpuRail::Frozen)); + } + + /// The sampler's assembly keeps each Energy Model channel with its OWN + /// raw integer, in order, and drops other groups and unnamed channels: + /// no rail is given another rail's value, a sum, or zero. + #[test] + fn the_sample_keeps_each_energy_model_channel_with_its_own_value() { + let em = || Some("Energy Model".to_string()); + let name = |n: &str| Some(n.to_string()); + let s = energy_model_sample_from(vec![ + (em(), name("DIE_0_CPU Energy"), 9_494_336), + (name("CPU Stats"), name("CPU Energy"), 1), + (name("energy model"), name("DIE_1_CPU Energy"), 10_212_656), + (em(), None, 5), + (None, name("GPU Energy"), 7), + (em(), name("GPU Energy"), 14_809_813), + ]); + assert_eq!( + s, + sample(&[("DIE_0_CPU Energy", 9_494_336), ("DIE_1_CPU Energy", 10_212_656), ("GPU Energy", 14_809_813)]), + "each Energy Model channel keeps its own raw integer" + ); + } + /// No observed interval at all is no verdict; intervals with no CPU /// channel in them are `Absent`. #[test] @@ -794,7 +941,7 @@ fn busy_power() -> Option { #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] mod macos_ioreport { - use super::liveness::{strip_die_prefix, watch, CpuRail, Sample}; + use super::liveness::{energy_model_sample_from, probe_with, strip_die_prefix, CpuRail, Sample}; use std::os::raw::{c_int, c_long, c_void}; use std::time::{Duration, Instant}; @@ -851,8 +998,19 @@ mod macos_ioreport { buffer_size: c_long, encoding: u32, ) -> bool; + fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; } + /// `struct timespec` (``): `time_t`, `long`. + #[repr(C)] + struct Timespec { + tv_sec: i64, + tv_nsec: c_long, + } + + /// `CLOCK_THREAD_CPUTIME_ID` (`<_time.h>`): the calling thread's CPU time. + const CLOCK_THREAD_CPUTIME_ID: c_int = 16; + #[link(name = "IOReport", kind = "dylib")] #[link(name = "IOKit", kind = "framework")] #[link(name = "CoreFoundation", kind = "framework")] @@ -889,8 +1047,9 @@ mod macos_ioreport { /// reads, but a sample of the group (364 channels on an M5 Max, 645 on an M3 /// Ultra) takes ~5 ms, where one of every channel (~11,900 and ~14,300) took /// 0.1–0.8 s on the M5 Max and ~0.13 s on the M3 Ultra — so the probe really - /// re-samples after every ~25 ms of spin instead of spending its budget - /// waiting on IOReport. Caller must `CFRelease` both. + /// re-samples after every ~25 ms of CPU burn instead of spending its budget + /// waiting on IOReport. Caller must `CFRelease` both (the probe holds them + /// in an `EnergyModelSub`, which does). unsafe fn open_energy_model() -> Option<(IOReportSubscriptionRef, CFMutableDictionaryRef)> { let group = CFStringCreateWithCString( std::ptr::null_mut(), @@ -935,15 +1094,108 @@ mod macos_ioreport { super::ioreport_status() == super::IoReportStatus::Live } - /// CPU burned on the probing thread before each liveness re-sample. - const PROBE_TICK: Duration = Duration::from_millis(25); + /// Thread CPU time the probe burns before each liveness re-sample. + pub(super) const PROBE_TICK: Duration = Duration::from_millis(25); + + /// The most wall time one tick may take to burn [`PROBE_TICK`] of CPU + /// time. Only a thread starved of the CPU for that long is cut short. + const TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// The probe's own Energy Model subscription, released on drop. + struct EnergyModelSub { + sub: IOReportSubscriptionRef, + ch: CFMutableDictionaryRef, + } + + impl EnergyModelSub { + /// Subscribe to the Energy Model group alone ([`open_energy_model`]). + fn open() -> Option { + // SAFETY: open_energy_model returns owned, non-null refs or None. + unsafe { open_energy_model() }.map(|(sub, ch)| Self { sub, ch }) + } + + /// One sample ([`energy_model_sample`]). + fn sample(&self) -> Option { + // SAFETY: sub and ch are live until drop. + unsafe { energy_model_sample(self.sub, self.ch) } + } + } + + impl Drop for EnergyModelSub { + fn drop(&mut self) { + // SAFETY: both were created by open_energy_model and are released once. + unsafe { + CFRelease(self.sub); + CFRelease(self.ch as CFTypeRef); + } + } + } + + /// One call of the probe's FFI sampler, as the probe timed it. + #[derive(Debug, Clone, Copy)] + pub(super) struct SampleCall { + /// Wall time the call took: `IOReportCreateSamples` plus reading the + /// channels out of it. + pub(super) wall: Duration, + /// This thread's CPU clock just before and just after the call. + pub(super) cpu_before: Option, + pub(super) cpu_after: Option, + /// Whether it returned a sample. + pub(super) ok: bool, + } + + /// What one run of the liveness probe did — its verdict, and a record of + /// every sample call it made — so tests can check how it sampled, not only + /// what it decided. Outside tests only the verdict is read. + #[derive(Debug, Clone, Default)] + #[cfg_attr(not(test), allow(dead_code))] + pub(super) struct ProbeRun { + pub(super) verdict: Option, + pub(super) intervals: u32, + /// Whether the Energy Model subscription opened. + pub(super) opened: bool, + /// Every sample call, in order, the baseline included. + pub(super) calls: Vec, + } + + #[cfg_attr(not(test), allow(dead_code))] + impl ProbeRun { + /// The median wall time of the sample calls (the upper one of an even + /// count), or `None` if there were none. + pub(super) fn median_sample_wall(&self) -> Option { + let mut walls: Vec = self.calls.iter().map(|c| c.wall).collect(); + walls.sort(); + walls.get(walls.len() / 2).copied() + } + + /// This thread's CPU time consumed in each interval between two + /// consecutive successful samples, outside the sample calls themselves: + /// from the end of one successful call to the start of the next. One + /// entry per interval; `None` for an interval where the CPU clock + /// could not be read. + pub(super) fn interval_cpu(&self) -> Vec> { + let mut out = Vec::new(); + let mut prev_end: Option> = None; + for c in self.calls.iter().filter(|c| c.ok) { + if let Some(end) = prev_end { + out.push(match (end, c.cpu_before) { + (Some(end), Some(start)) => start.checked_sub(end), + _ => None, + }); + } + prev_end = Some(c.cpu_after); + } + out + } + } /// Open the probe's own Energy Model subscription, burn CPU on this thread - /// — [`PROBE_TICK`] of spin before each re-sample — and report whether a CPU - /// energy rail **itself** keeps advancing, by the rule in + /// — [`PROBE_TICK`] of its CPU time before each re-sample — and report + /// whether a CPU energy rail **itself** keeps advancing, by the rule in /// [`super::liveness::RailWatch`]: the same rail must advance in two - /// separate intervals between consecutive samples. `None` if IOReport - /// cannot be opened, or opens but yields no two consecutive samples. + /// separate intervals between consecutive samples. No verdict if the Energy + /// Model subscription cannot be opened, or opens but yields no two + /// consecutive samples. /// /// The summed total [`power_over`] reads is not a liveness signal. Measured /// on an M5 Max and an M3 Ultra, both on macOS 27.0.1: every CPU rail in the @@ -956,46 +1208,83 @@ mod macos_ioreport { /// /// Sampling runs until a rail proves live, or until `budget` has elapsed /// and at least two intervals were observed, never past five budgets - /// ([`super::liveness::watch`]). The spin cannot manufacture `Live` — only - /// the rail's own raw integer advancing twice can — so a short spin errs + /// ([`super::liveness::watch`]). The burn cannot manufacture `Live` — only + /// the rail's own raw integer advancing twice can — so a short burn errs /// only toward declining, the safe side. /// - /// Returns the verdict and how many sample intervals it observed. The loop - /// is [`super::liveness::watch`]; this only supplies the IOReport samples, - /// the spin and the clock. - pub(super) fn probe(budget: Duration) -> (Option, u32) { - unsafe { - let Some((sub, ch)) = open_energy_model() else { - // No Energy Model subscription. Tell "no IOReport" from "no such - // group" by opening the all-channel one the meter reads. - let absent = open().map(|(sub, ch)| { - CFRelease(sub); - CFRelease(ch as CFTypeRef); - CpuRail::Absent - }); - return (absent, 0); - }; - let origin = Instant::now(); - let watched = watch(|| energy_model_sample(sub, ch), spin_tick, || origin.elapsed(), budget); - CFRelease(sub); - CFRelease(ch as CFTypeRef); - watched - } + /// The loop is [`super::liveness::probe_with`]; this only supplies the + /// subscription, the IOReport samples, the burn and the clock, and records + /// every sample call. + pub(super) fn probe(budget: Duration) -> ProbeRun { + let mut opened = false; + let mut calls = Vec::new(); + let origin = Instant::now(); + let (verdict, intervals) = probe_with( + || { + let sub = EnergyModelSub::open(); + opened = sub.is_some(); + sub + }, + |sub: &EnergyModelSub| { + let cpu_before = thread_cpu_time(); + let t = Instant::now(); + let s = sub.sample(); + let wall = t.elapsed(); + calls.push(SampleCall { wall, cpu_before, cpu_after: thread_cpu_time(), ok: s.is_some() }); + s + }, + spin_tick, + || origin.elapsed(), + budget, + ); + ProbeRun { verdict, intervals, opened, calls } } - /// Burn [`PROBE_TICK`] of CPU on this thread. + /// This thread's CPU time, from `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`; + /// `None` if the clock cannot be read. + pub(super) fn thread_cpu_time() -> Option { + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: ts is a valid, writable timespec. + let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + let (secs, nanos) = (u64::try_from(ts.tv_sec).ok()?, u32::try_from(ts.tv_nsec).ok()?); + (rc == 0 && nanos < 1_000_000_000).then(|| Duration::new(secs, nanos)) + } + + /// One probe tick: burn [`PROBE_TICK`] of this thread's CPU time — CPU + /// time, not wall time, so a loaded host that runs this thread only part + /// of the time still gets the full burn — giving up after + /// [`TICK_WALL_CAP`] of wall time. Without a readable thread CPU clock it + /// spins [`PROBE_TICK`] of wall time. fn spin_tick() { - let tick = Instant::now(); + burn_thread_cpu(PROBE_TICK, TICK_WALL_CAP); + } + + fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) { + let wall = Instant::now(); let mut acc: u64 = 0; - while tick.elapsed() < PROBE_TICK { - acc = std::hint::black_box(acc.wrapping_add(1)); + let Some(start) = thread_cpu_time() else { + while wall.elapsed() < cpu { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + return; + }; + while wall.elapsed() < wall_cap { + for _ in 0..4096 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + match thread_cpu_time() { + Some(now) if now.saturating_sub(start) < cpu => {} + _ => break, + } } } /// One sample of `sub` as the liveness rule reads it: every Energy Model /// channel's name and ABSOLUTE raw integer, in channel order — raw, before /// unit conversion, so a small advance in a fine unit cannot truncate to - /// zero. `None` if IOReport returns no sample. No decision is made here. + /// zero. `None` if IOReport returns no sample. The FFI only reads each + /// channel's group, name and integer; which channels are kept is + /// [`energy_model_sample_from`]. No decision is made here. unsafe fn energy_model_sample(sub: IOReportSubscriptionRef, ch: CFMutableDictionaryRef) -> Option { let smp = IOReportCreateSamples(sub, ch, std::ptr::null()); if smp.is_null() { @@ -1013,17 +1302,19 @@ mod macos_ioreport { if !arr.is_null() { for i in 0..CFArrayGetCount(arr) { let item = CFArrayGetValueAtIndex(arr, i); - if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { + if item.is_null() { continue; } - if let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) { - channels.push((name, IOReportSimpleGetIntegerValue(item, 0) as i64)); - } + channels.push(( + cfstring_to_string(IOReportChannelGetGroup(item)), + cfstring_to_string(IOReportChannelGetChannelName(item)), + IOReportSimpleGetIntegerValue(item, 0) as i64, + )); } } } CFRelease(smp); - Some(channels) + Some(energy_model_sample_from(channels)) } /// Average SoC power (watts) over `d`: two Energy-Model samples bracketing a @@ -1164,49 +1455,162 @@ mod macos_ioreport { mod tests { use super::*; use crate::meter::{ - cached_probe, hardware_meter_available, ioreport_status, measure_marginal_uj, IoReportStatus, - CPU_RAIL_LIVENESS_BUDGET, STATUS_PROBE_RUNS, + cached_probe, hardware_meter_available, ioreport_status, liveness::is_cpu_energy_rail, + measure_marginal_uj, IoReportStatus, CPU_RAIL_LIVENESS_BUDGET, STATUS_PROBE_RUNS, }; use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::Arc; + use std::sync::{Arc, Barrier}; - /// The fewest sample intervals a probe on the Energy Model group alone - /// observes within one budget: it sees 27–32 in ~1.1 s on both chips, 16 - /// under a concurrent full-core burst. A probe that re-samples every - /// channel sees 2–8, so this floor pins both the group-only subscription - /// and the ~25 ms tick. The test's own literal, not an implementation - /// constant. - const MIN_PROBE_INTERVALS: u32 = 10; + /// The group-only-subscription pin: the median wall time of the probe's + /// own sample calls stays under this. A sample of the Energy Model group + /// alone takes ~6–12 ms; one of every channel, 130–800 ms. The test's + /// own literal, not an implementation constant. + const MAX_MEDIAN_SAMPLE_WALL: Duration = Duration::from_millis(50); /// Real, cheap, pure CPU work for the metering tests. fn cpu_work() { std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); } - /// The liveness probe behind `ioreport_status()` runs once per process, - /// however many callers ask and whatever path they take: a probe per - /// call would burn ~1 s on each caller's thread (inside `bracket()`, a - /// whole probe in the busy window) and could flip the verdict - /// mid-process. + /// How a probe run sampled: the median wall time of its sample calls, + /// and the least thread CPU time any of its intervals consumed. + struct Shape { + median_sample: Option, + interval_cpu: Vec>, + } + + impl Shape { + fn of(run: &ProbeRun) -> Self { + Self { median_sample: run.median_sample_wall(), interval_cpu: run.interval_cpu() } + } + + fn min_interval_cpu(&self) -> Option { + self.interval_cpu.iter().flatten().min().copied() + } + + /// The two pins on how the probe sampled, for a run whose Energy + /// Model subscription opened: it sampled the group alone (the median + /// sample call took under 50 ms), and it burned CPU in every interval + /// (at least `PROBE_TICK / 2` of this thread's CPU time). Neither is + /// a wall-clock ratio. The burn is measured in thread CPU time and + /// the tick burns thread CPU time, so host load cannot shorten it. + fn assert_pins(&self, run: &ProbeRun, who: &str) { + if !run.opened { + assert_eq!(run.verdict, None, "{who}: no Energy Model subscription, yet a verdict"); + assert!(run.calls.is_empty(), "{who}: sampled a subscription that did not open"); + return; + } + let median = self + .median_sample + .unwrap_or_else(|| panic!("{who}: the Energy Model subscription opened, yet no sample call ran")); + assert!( + median < MAX_MEDIAN_SAMPLE_WALL, + "{who}: the median sample call took {median:?} (>= {MAX_MEDIAN_SAMPLE_WALL:?}) over {} calls: \ + the probe is not sampling the Energy Model group alone (~6-12 ms a sample; every \ + channel: 130-800 ms)", + run.calls.len() + ); + assert_eq!(self.interval_cpu.len(), run.intervals as usize, "{who}: one CPU figure per interval"); + for (i, cpu) in self.interval_cpu.iter().enumerate() { + let cpu = cpu.unwrap_or_else(|| panic!("{who}: interval {i}: this thread's CPU clock could not be read")); + assert!( + cpu >= PROBE_TICK / 2, + "{who}: interval {i} of {} consumed {cpu:?} of the probing thread's CPU time between \ + samples, under PROBE_TICK/2 = {:?}: the probe is not burning CPU before each \ + re-sample. The tick burns thread CPU time, so host load does not shorten it; only \ + its {TICK_WALL_CAP:?} wall-clock cap can, which takes this thread being starved of \ + the CPU for that long", + run.intervals, + PROBE_TICK / 2 + ); + } + } + } + + /// The record's arithmetic on its own: the median, and one CPU figure + /// per interval between consecutive SUCCESSFUL calls, outside the calls. + #[test] + fn the_probe_record_gives_the_median_call_and_one_cpu_figure_per_interval() { + let ms = Duration::from_millis; + let call = |wall, before, after, ok| SampleCall { + wall: ms(wall), + cpu_before: Some(ms(before)), + cpu_after: Some(ms(after)), + ok, + }; + let run = ProbeRun { + verdict: Some(CpuRail::Frozen), + intervals: 2, + opened: true, + calls: vec![ + call(9, 0, 5, true), + call(300, 31, 32, false), + call(7, 60, 64, true), + call(8, 90, 95, true), + ], + }; + assert_eq!(run.median_sample_wall(), Some(ms(9))); + assert_eq!(run.interval_cpu(), vec![Some(ms(55)), Some(ms(26))]); + assert_eq!(ProbeRun::default().median_sample_wall(), None); + assert!(ProbeRun::default().interval_cpu().is_empty()); + } + + /// The tick burns thread CPU time: one tick consumes at least + /// `PROBE_TICK` of this thread's CPU clock, however loaded the host. + /// Only the wall-clock cap can cut it short, which needs this thread + /// starved of the CPU for 500 ms. + #[test] + fn one_tick_burns_the_probe_tick_of_thread_cpu_time() { + let before = thread_cpu_time().expect("this thread's CPU clock reads"); + spin_tick(); + let burned = thread_cpu_time().expect("this thread's CPU clock reads") - before; + eprintln!("ioreport tick: burned {burned:?} of thread CPU time"); + assert!( + burned >= PROBE_TICK, + "one tick burned {burned:?} of thread CPU time, under PROBE_TICK = {PROBE_TICK:?}; only the \ + {TICK_WALL_CAP:?} wall-clock cap may cut it short" + ); + } + + /// The liveness probe behind `ioreport_status()` runs once per + /// PROCESS, however many threads ask at once and whatever path they + /// take: a probe per call or per thread would burn ~1 s on each caller's + /// thread (inside `bracket()`, a whole probe in the busy window) and + /// could flip the verdict mid-process. Eight threads released together + /// each ask for the status and select the meter the way a caller does + /// (this crate has no backend object: `hardware_meter_available()` is + /// the selection, and `measure_marginal_uj` / `power_over` its users). + /// The probe counter is a process-wide static, so it sees every thread. #[test] fn the_liveness_probe_runs_once_per_process() { - let first = ioreport_status(); - for _ in 0..3 { - assert_eq!(ioreport_status(), first, "the cached status changed"); - } - let _ = hardware_meter_available(); - let _ = available(); - let _ = power_over(Duration::from_millis(10)); - let _ = measure_marginal_uj(cpu_work); - let t = Instant::now(); - let _ = ioreport_status(); - let warm = t.elapsed(); + const THREADS: usize = 8; + let gate = Arc::new(Barrier::new(THREADS)); + let handles: Vec<_> = (0..THREADS) + .map(|_| { + let gate = gate.clone(); + std::thread::spawn(move || { + gate.wait(); + let status = ioreport_status(); + let selected = hardware_meter_available(); + let ioreport = available(); + if !selected { + assert_eq!(measure_marginal_uj(cpu_work), None, "a declined meter gave a figure"); + assert_eq!(power_over(Duration::from_millis(10)), None, "a declined meter gave watts"); + } + (status, selected, ioreport) + }) + }) + .collect(); + let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a probing thread panicked")).collect(); + let runs = STATUS_PROBE_RUNS.load(Ordering::SeqCst); + eprintln!("ioreport cache: {THREADS} threads saw {:?}; probe runs in this process: {runs}", seen[0]); + assert!(seen.iter().all(|s| *s == seen[0]), "the threads were given different answers: {seen:?}"); + assert_eq!(seen[0].1, seen[0].0 == IoReportStatus::Live, "selected exactly when Live: {:?}", seen[0]); + assert_eq!(seen[0].1, seen[0].2, "hardware_meter_available() and available() disagree"); assert_eq!( - STATUS_PROBE_RUNS.load(Ordering::SeqCst), - 1, - "the liveness probe ran more than once in this process" + runs, 1, + "the liveness probe ran more than once in this process ({THREADS} threads asked at once)" ); - assert!(warm < PROBE_TICK, "a cached ioreport_status() took {warm:?}"); } #[test] @@ -1251,15 +1655,110 @@ mod macos_ioreport { CpuRailMoved(bool), } + /// One oracle run: its verdict, how many reads it compared, and whether + /// any CPU rail it read sits in a group named `Energy Model`. + #[derive(Debug)] + struct OracleRun { + verdict: Oracle, + reads: u32, + energy_model_rail: bool, + } + + /// A channel whose name ends with `CPU Energy`, as the oracle read it. + #[derive(Debug, Clone)] + struct OracleRail { + group: String, + name: String, + value: i64, + } + + /// The oracle's own subscription, over every channel (not the probe's + /// Energy Model one, and not through `open()`), released on drop. + struct OracleSub { + sub: IOReportSubscriptionRef, + ch: CFMutableDictionaryRef, + } + + impl OracleSub { + fn open() -> Option { + unsafe { + let all = IOReportCopyAllChannels(0, 0); + if all.is_null() { + return None; + } + let mut ch: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription(std::ptr::null(), all, &mut ch as *mut _, 0, std::ptr::null()); + CFRelease(all as CFTypeRef); + if sub.is_null() || ch.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !ch.is_null() { + CFRelease(ch as CFTypeRef); + } + return None; + } + Some(Self { sub, ch }) + } + } + + /// The group, name and ABSOLUTE cumulative value of every channel + /// whose name ends with `CPU Energy` (any group), in channel order. + fn read(&self) -> Option> { + unsafe { + let smp = IOReportCreateSamples(self.sub, self.ch, std::ptr::null()); + if smp.is_null() { + return None; + } + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + let mut rails = Vec::new(); + if !key.is_null() { + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if !arr.is_null() { + for i in 0..CFArrayGetCount(arr) { + let it = CFArrayGetValueAtIndex(arr, i); + if it.is_null() { + continue; + } + let name = cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); + if name.ends_with("CPU Energy") { + rails.push(OracleRail { + group: cfstring_to_string(IOReportChannelGetGroup(it)).unwrap_or_default(), + name, + value: IOReportSimpleGetIntegerValue(it, 0) as i64, + }); + } + } + } + } + CFRelease(smp); + Some(rails) + } + } + } + + impl Drop for OracleSub { + fn drop(&mut self) { + unsafe { + CFRelease(self.sub); + CFRelease(self.ch as CFTypeRef); + } + } + } + /// An oracle that shares only the raw FFI declarations and the CFString /// helper (`cfstring_to_string`) with the liveness probe. It opens its - /// own subscription over every channel (not the probe's Energy Model - /// one, and not through `open()`), reads each CPU rail's ABSOLUTE - /// cumulative value with its own comparison loop (not + /// own subscription over every channel ([`OracleSub`]), reads each CPU + /// rail's ABSOLUTE cumulative value with its own comparison loop (not /// `liveness::watch` or `RailWatch`), matches names with /// `ends_with("CPU Energy")` in any group rather than the probe's group /// filter and die-prefix stripping, and burns CPU on a helper thread for - /// the whole of `budget` while it reads. + /// the whole of its window while it reads. /// /// `CpuRailMoved(true)` only if one rail's absolute value changed at two /// or more separate reads. A live rail changes at nearly every read; a @@ -1268,49 +1767,27 @@ mod macos_ioreport { /// the oracle's window cannot vouch for a false Live. Its own literal /// threshold, not the implementation's constant, so a change to one /// cannot silently move the other. - fn oracle_cpu_rail(budget: Duration) -> Oracle { - unsafe { - let all = IOReportCopyAllChannels(0, 0); - if all.is_null() { - return Oracle::NoIoReport; - } - let mut subscribed: CFMutableDictionaryRef = std::ptr::null_mut(); - let sub = IOReportCreateSubscription( - std::ptr::null(), - all, - &mut subscribed as *mut _, - 0, - std::ptr::null(), - ); - CFRelease(all as CFTypeRef); - if sub.is_null() || subscribed.is_null() { - if !sub.is_null() { - CFRelease(sub); - } - return Oracle::NoIoReport; - } - let verdict = oracle_on(sub, subscribed, budget); - CFRelease(sub); - CFRelease(subscribed as CFTypeRef); - verdict - } - } - - unsafe fn oracle_on( - sub: IOReportSubscriptionRef, - ch: CFMutableDictionaryRef, - budget: Duration, - ) -> Oracle { - let Some(v0) = cpu_rail_absolutes(sub, ch) else { - return Oracle::NoIoReport; + /// + /// Evidence: the window is `budget`, extended (to at most three + /// budgets) only while the oracle has fewer than two reads after its + /// baseline. Fewer than two at the end is a test FAILURE, never + /// `CpuRailMoved(false)`: one read cannot show a rail changing twice. + fn oracle_cpu_rail(budget: Duration) -> OracleRun { + let none = |verdict| OracleRun { verdict, reads: 0, energy_model_rail: false }; + let Some(sub) = OracleSub::open() else { + return none(Oracle::NoIoReport); + }; + let Some(v0) = sub.read() else { + return none(Oracle::NoIoReport); }; if v0.is_empty() { - return Oracle::NoCpuRail; + return none(Oracle::NoCpuRail); } + let energy_model_rail = v0.iter().any(|r| r.group.eq_ignore_ascii_case("Energy Model")); // The burn runs on its own thread for the WHOLE window: this // subscription samples every channel, 0.1–0.8 s a sample on an M5 // Max, so spinning only between samples would burn a small share of - // `budget`. + // the window. let stop = Arc::new(AtomicBool::new(false)); let burner = { let stop = stop.clone(); @@ -1326,8 +1803,11 @@ mod macos_ioreport { // the read before. let mut changes = vec![0u32; v0.len()]; let (mut prev, mut reads, mut moved) = (v0, 0u32, false); - while !moved && started.elapsed() < budget { - let Some(cur) = cpu_rail_absolutes(sub, ch) else { + while !moved + && (started.elapsed() < budget || reads < 2) + && started.elapsed() < budget * 3 + { + let Some(cur) = sub.read() else { continue; }; if cur.len() != prev.len() { @@ -1335,54 +1815,22 @@ mod macos_ioreport { } reads += 1; for (count, (now, before)) in changes.iter_mut().zip(cur.iter().zip(prev.iter())) { - *count += u32::from(now != before); + *count += u32::from(now.value != before.value); } moved = changes.iter().any(|&c| c >= 2); prev = cur; } stop.store(true, Ordering::Relaxed); let _ = burner.join(); - eprintln!( - "ioreport oracle: {reads} reads in {:?}, changes per CPU rail {changes:?}", - started.elapsed() + let took = started.elapsed(); + eprintln!("ioreport oracle: {reads} reads in {took:?}, changes per CPU rail {changes:?}"); + assert!( + reads >= 2, + "the oracle got only {reads} read(s) of every channel in {took:?} (window {budget:?}, extended \ + to at most 3x while short of 2 reads): too few to tell a frozen CPU rail from a live one, so \ + this run has no evidence either way" ); - Oracle::CpuRailMoved(moved) - } - - /// The absolute cumulative value of every channel whose name ends with - /// `CPU Energy` (any group), in channel order. - unsafe fn cpu_rail_absolutes( - sub: IOReportSubscriptionRef, - ch: CFMutableDictionaryRef, - ) -> Option> { - let smp = IOReportCreateSamples(sub, ch, std::ptr::null()); - if smp.is_null() { - return None; - } - let key = CFStringCreateWithCString( - std::ptr::null_mut(), - c"IOReportChannels".as_ptr() as *const _, - K_CFSTRING_ENCODING_UTF8, - ); - let mut vals = Vec::new(); - if !key.is_null() { - let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; - CFRelease(key as CFTypeRef); - if !arr.is_null() { - for i in 0..CFArrayGetCount(arr) { - let it = CFArrayGetValueAtIndex(arr, i); - if it.is_null() { - continue; - } - let name = cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); - if name.ends_with("CPU Energy") { - vals.push(IOReportSimpleGetIntegerValue(it, 0) as i64); - } - } - } - } - CFRelease(smp); - Some(vals) + OracleRun { verdict: Oracle::CpuRailMoved(moved), reads, energy_model_rail } } /// The property that matters: the IOReport meter is **never** available @@ -1395,34 +1843,41 @@ mod macos_ioreport { /// the probe but moving within the oracle's window is the only case that /// may be declined while live — and that fails safe: no hardware figure, /// never an over-claimed one. The cached probe that made the decision - /// must also have re-sampled at the group-only cadence. + /// must also have sampled the Energy Model group alone and burned CPU in + /// every interval ([`Shape::assert_pins`]). #[test] fn the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail() { let status = ioreport_status(); - let (_, probe_intervals) = cached_probe(); + let (_, run) = cached_probe(); let selected = hardware_meter_available(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); + let shape = Shape::of(run); // Recorded so a log of a macOS run (manual, or a CI job that runs // this crate's lib tests with --nocapture) says which branch below - // was actually asserted. + // was actually asserted, and what the probe's sampling looked like. eprintln!( - "ioreport liveness: status={status:?} oracle={oracle:?} selected={selected} \ - probe_intervals={probe_intervals}" + "ioreport liveness: status={status:?} oracle={:?} selected={selected} probe_intervals={} \ + opened={} median_sample={:?} min_interval_cpu={:?} oracle_reads={}", + oracle.verdict, + run.intervals, + run.opened, + shape.median_sample, + shape.min_interval_cpu(), + oracle.reads ); - if status == IoReportStatus::CpuRailFrozen { - assert!( - probe_intervals >= MIN_PROBE_INTERVALS, - "the cached probe called the rail frozen after {probe_intervals} intervals in one \ - budget; the group-only probe re-samples every ~25 ms (27-32 intervals)" - ); + shape.assert_pins(run, "the cached probe"); + if oracle.energy_model_rail { + assert!(run.opened, "the oracle read a CPU rail in the Energy Model group; the probe could not subscribe to it"); } - match oracle { + match oracle.verdict { Oracle::NoIoReport => { assert_eq!(status, IoReportStatus::Unavailable); assert!(!selected, "a hardware meter was selected with no IOReport"); } Oracle::NoCpuRail => { - assert_eq!(status, IoReportStatus::CpuRailAbsent); + let want = + if run.intervals > 0 { IoReportStatus::CpuRailAbsent } else { IoReportStatus::Unavailable }; + assert_eq!(status, want, "no CPU rail anywhere, probe intervals {}", run.intervals); assert!(!selected, "selected an IOReport meter that has no CPU rail"); } Oracle::CpuRailMoved(false) => { @@ -1433,7 +1888,11 @@ mod macos_ioreport { CPU work", CPU_RAIL_LIVENESS_BUDGET * 3 ); - assert_eq!(status, IoReportStatus::CpuRailFrozen); + // A probe that sampled the rail calls it frozen; one that + // could not sample at all says Unavailable. Both decline. + let want = + if run.intervals > 0 { IoReportStatus::CpuRailFrozen } else { IoReportStatus::Unavailable }; + assert_eq!(status, want, "probe intervals {}", run.intervals); // Declined end to end: no figure comes out of a frozen rail. assert_eq!(power_over(Duration::from_millis(200)), None, "watts read from a frozen CPU rail"); assert_eq!(measure_marginal_uj(cpu_work), None, "energy metered from a frozen CPU rail"); @@ -1455,55 +1914,130 @@ mod macos_ioreport { /// The dangerous direction on its own: the probe never reports a live rail /// the oracle cannot see keep moving. The probe runs fresh here (not the /// cached status) and the oracle runs unconditionally, so every outcome - /// is checked against it, not only Live. A verdict other than Live from - /// an Energy Model subscription that opened must rest on at least - /// [`MIN_PROBE_INTERVALS`] intervals: that pins the group-only - /// subscription and the ~25 ms tick. Logs how long the probe took and how - /// many sample intervals it saw. + /// is checked against it, not only Live. The fresh probe must also have + /// sampled the Energy Model group alone and burned CPU in every interval + /// ([`Shape::assert_pins`]). Logs how long the probe took, how many + /// intervals it saw, and its median sample cost. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { let t = Instant::now(); - let (probe, intervals) = super::probe(CPU_RAIL_LIVENESS_BUDGET); + let run = super::probe(CPU_RAIL_LIVENESS_BUDGET); let probe_took = t.elapsed(); let status = ioreport_status(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); + let shape = Shape::of(&run); eprintln!( - "ioreport probe: probe={probe:?} status={status:?} oracle={oracle:?} \ - probe_took={probe_took:?} intervals={intervals}" + "ioreport probe: probe={:?} status={status:?} oracle={:?} probe_took={probe_took:?} \ + intervals={} opened={} calls={} median_sample={:?} min_interval_cpu={:?} oracle_reads={}", + run.verdict, + oracle.verdict, + run.intervals, + run.opened, + run.calls.len(), + shape.median_sample, + shape.min_interval_cpu(), + oracle.reads ); - match (probe, status) { + shape.assert_pins(&run, "the fresh probe"); + if oracle.energy_model_rail { + assert!(run.opened, "the oracle read a CPU rail in the Energy Model group; the probe could not subscribe to it"); + } + match (run.verdict, status) { (Some(CpuRail::Live), _) | (_, IoReportStatus::Live) => assert_eq!( - oracle, + oracle.verdict, Oracle::CpuRailMoved(true), - "probe said Live (probe={probe:?}, status={status:?}), the oracle did not see the CPU \ - rail keep moving" + "probe said Live (probe={:?}, status={status:?}), the oracle did not see the CPU rail keep \ + moving", + run.verdict ), - (Some(CpuRail::Frozen), _) => { - assert!( - matches!(oracle, Oracle::CpuRailMoved(_)), - "the probe saw a CPU rail, the oracle found none: {oracle:?}" - ); - // Cadence: the group-only probe re-samples every ~25 ms. - assert!( - intervals >= MIN_PROBE_INTERVALS, - "Frozen after only {intervals} interval(s) in {probe_took:?}: the probe is not \ - re-sampling the Energy Model group every ~25 ms" - ); - } - (Some(CpuRail::Absent), _) => { - assert_eq!(oracle, Oracle::NoCpuRail, "the probe found no CPU rail, the oracle found one"); - // 0 intervals: the Energy Model group could not be subscribed - // at all. Otherwise the same cadence as a Frozen verdict. - assert!( - intervals == 0 || intervals >= MIN_PROBE_INTERVALS, - "Absent after only {intervals} interval(s) in {probe_took:?}" - ); - } - (None, _) => assert_eq!( - oracle, - Oracle::NoIoReport, - "the probe could not open or sample IOReport, the oracle could" + (Some(CpuRail::Frozen), _) => assert!( + matches!(oracle.verdict, Oracle::CpuRailMoved(_)), + "the probe saw a CPU rail, the oracle found none: {:?}", + oracle.verdict ), + (Some(CpuRail::Absent), _) => assert_eq!( + oracle.verdict, + Oracle::NoCpuRail, + "the probe found no CPU rail, the oracle found one" + ), + (None, _) => assert!( + !oracle.energy_model_rail, + "the probe could not open or sample the Energy Model group, yet the oracle read a CPU rail \ + in it: {:?}", + oracle.verdict + ), + } + } + + /// The FFI value pin: the probe's sampler reads each CPU rail's raw + /// integer exactly as the oracle's independent absolute read of the + /// same rail does. Each round brackets one probe sample between two + /// oracle reads. Where the oracle saw the rail hold across that bracket + /// (a frozen rail is constant), the probe's value must EQUAL it; where + /// it moved, the probe's value must lie between the two reads. This + /// catches a sampler that returns a constant, sums rails, or reads the + /// wrong rail. Rails are matched by group, name and ordinal among the + /// same-named channels. + #[test] + fn the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does() { + let Some(oracle) = OracleSub::open() else { + eprintln!("ioreport ffi pin: IOReport does not open on this host; nothing to compare"); + assert_ne!(ioreport_status(), IoReportStatus::Live, "Live with no IOReport"); + return; + }; + let energy_model = |rails: Vec| -> Vec<((String, u32), i64)> { + let mut keyed: Vec<((String, u32), i64)> = Vec::new(); + for r in rails.into_iter().filter(|r| r.group.eq_ignore_ascii_case("Energy Model")) { + let ordinal = keyed.iter().filter(|((n, _), _)| *n == r.name).count() as u32; + keyed.push(((r.name, ordinal), r.value)); + } + keyed + }; + let first = energy_model(oracle.read().expect("the oracle's subscription yields a sample")); + if first.is_empty() { + eprintln!("ioreport ffi pin: no CPU Energy channel in the Energy Model group; nothing to compare"); + assert_ne!(ioreport_status(), IoReportStatus::Live, "Live with no Energy Model CPU rail"); + return; + } + let probe = EnergyModelSub::open() + .expect("the oracle reads a CPU rail in the Energy Model group; the probe cannot subscribe to it"); + for round in 1..=3 { + let before = energy_model(oracle.read().expect("oracle read before the probe sample")); + let sample = probe.sample().expect("the probe's subscription yields a sample"); + let after = energy_model(oracle.read().expect("oracle read after the probe sample")); + let mut rails: Vec<((String, u32), i64)> = Vec::new(); + for (name, raw) in sample.into_iter().filter(|(n, _)| is_cpu_energy_rail(n)) { + let ordinal = rails.iter().filter(|((n, _), _)| *n == name).count() as u32; + rails.push(((name, ordinal), raw)); + } + assert!(!rails.is_empty(), "round {round}: the probe's sample has no CPU rail; the oracle reads {first:?}"); + let find = |reads: &[((String, u32), i64)], key: &(String, u32)| { + reads.iter().find(|(k, _)| k == key).map(|(_, v)| *v) + }; + for (key, probed) in &rails { + let (Some(a), Some(b)) = (find(&before, key), find(&after, key)) else { + panic!("round {round}: the probe reports CPU rail {key:?}, the oracle does not read it: {before:?}"); + }; + let held = a == b; + eprintln!( + "ioreport ffi pin: round={round} rail={}#{} oracle_before={a} probe={probed} \ + oracle_after={b} held={held}", + key.0, key.1 + ); + if held { + assert_eq!( + *probed, a, + "round {round}: rail {key:?} held at {a} across the oracle's reads, yet the probe's \ + sampler read {probed}" + ); + } else { + assert!( + a.min(b) <= *probed && *probed <= a.max(b), + "round {round}: rail {key:?} moved {a} -> {b} across the oracle's reads, yet the \ + probe's sampler read {probed}, outside that range" + ); + } + } } } @@ -1515,7 +2049,7 @@ mod macos_ioreport { if hardware_meter_available() { assert_eq!(ioreport_status(), IoReportStatus::Live, "selected on a CPU rail that is not live"); assert_eq!( - probe(CPU_RAIL_LIVENESS_BUDGET).0, + probe(CPU_RAIL_LIVENESS_BUDGET).verdict, Some(CpuRail::Live), "the CPU rail stopped advancing after the meter was selected" ); @@ -1616,17 +2150,30 @@ mod tests { CALLS.fetch_add(1, Ordering::SeqCst); std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); } + // The busy reading waits until the workers have really run (not a + // fixed sleep, which a loaded host could outlast), then gives none. let r = bracket_with(counted_work, || Some(1.0), || { - std::thread::sleep(Duration::from_millis(30)); + let t = Instant::now(); + while CALLS.load(Ordering::SeqCst) == 0 && t.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(1)); + } None }); assert_eq!(r, None); let after = CALLS.load(Ordering::SeqCst); - assert!(after > 0, "the busy window never ran the work"); + assert!(after > 0, "the busy window never ran the work in 10 s"); std::thread::sleep(Duration::from_millis(60)); assert_eq!(CALLS.load(Ordering::SeqCst), after, "workers kept running after the bracket returned"); } + /// No verdict — the probe's Energy Model subscription could not be opened, + /// or opened but never yielded two consecutive samples — is `Unavailable`: + /// never `Live`, never `CpuRailAbsent`. + #[test] + fn no_verdict_is_unavailable() { + assert_eq!(status_from(None), IoReportStatus::Unavailable); + } + /// The probe's verdict maps one-to-one onto the public status; no verdict /// (IOReport could not be opened or sampled) is `Unavailable`. #[test] @@ -1643,7 +2190,9 @@ mod tests { /// direct call burns 80 ms) put through `measure_marginal_uj` gives `None` /// — not `Some(0)`, not a floored figure — before any bracket runs, so the /// work is never called and the caller keeps its own `ModelBased` figure. - /// Where an interface is usable, the bracket really runs the work. + /// Where an interface is usable, the bracket really runs the work. How + /// long the call took is logged, not asserted: a loaded host can stall + /// any thread, and the work-call count is the pin. #[test] fn a_declined_meter_gives_real_work_no_figure_and_runs_no_bracket() { let t = Instant::now(); @@ -1667,7 +2216,6 @@ mod tests { } else { assert_eq!(r, None, "a declined meter gave real work a figure"); assert_eq!(runs, 0, "a declined meter ran a bracket ({runs} calls of the work)"); - assert!(took < Duration::from_millis(80), "a declined meter took {took:?} to say None"); } } diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index f3526dca1..e5590f710 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -193,10 +193,10 @@ fn no_hardware_reading_reason() -> &'static str { "IOReport opens, but its CPU energy rail is frozen (it did not keep advancing while \ this process burned CPU), so it cannot meter CPU work" } - IoReportStatus::CpuRailAbsent => "IOReport opens, but exposes no CPU energy rail", + IoReportStatus::CpuRailAbsent => "IOReport's Energy Model group opens, but has no CPU energy rail", IoReportStatus::Unavailable => { - "IOReport could not be opened, or opened but could not be sampled (opening it needs \ - Apple Silicon, not Rosetta)" + "IOReport's Energy Model group could not be opened, or opened but could not be sampled \ + (opening it needs Apple Silicon, not Rosetta)" } IoReportStatus::Live => "IOReport's CPU energy rail is live, but the bracket returned no reading", } -- 2.54.0 (Apple Git-157) From c085945c7a667a080aef952b9f154e61eeca6cc8 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 16:09:27 -0400 Subject: [PATCH 05/10] proof-cascade: one counted production probe, pinned on what it was handed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-verification of 79375a1c left the decision rules pinned but the production chain around them loose: a wall-clock pin that flaked whenever another process sampled IOReport, a run counter that counted cache initialisations, an unopened subscription that could be mapped straight to Live inside probe() or the cache, and no pin on the values between the FFI reader and the rule (a sum of the CPU rails there survived on the M3 Ultra). Those were the dangerous survivors. The fail-safe ones were a wrong clock id, a stale reader and a reader that drops rails. Each now fails a test. The chain. `host_status()` is the one production composition: it increments REAL_PROBE_RUNS, then returns the pure `status_of` of `probe_with(EnergyModelSub::open, EnergyModelSub::read, spin_tick, wall_clock, thread_cpu_time, budget)`, with no branch. It is the only production caller of `probe_with`. `ioreport_status()` caches it in one process-wide OnceLock. `hardware_meter_available()` is `admits(ioreport_status())`, and `power_over` and `measure_marginal_uj` select only through it. `admits` accepts Live alone. `status_of` reads the run's `opened` flag: a subscription that did not open is Unavailable whatever verdict the run carries. `macos_ioreport::probe`, `available`, `cached_probe` and `status_from` are gone. Once per process. The concurrent test moved to tests/ioreport_once.rs, so it is its process's first use of the meter. Eight threads released through a Barrier ask for the status, the selection and measure_marginal_uj. The counter must read 1, and each thread's warm calls must burn under 10 ms of its own CPU. power_over is private, so the lib test the_gated_entry_points_read_the_cached_status pins it, and the other two, the same way on the calling thread's CPU. Fault injection. #[cfg(test)] hooks inside the real opener and the real reader (`seam`, per thread) let unit tests run the real, uncached `host_status()`. With the group denied, it gives Unavailable with no read, no tick and no verdict. On scripted channel reads, with the real tick and clocks, a single batch jump, a constant rail, and one step on each die in different intervals each give CpuRailFrozen. A rail that advances at every read gives Live after two intervals. Each scripted run must have been handed exactly the script. Measured on the tests' own clock, it must also have burned at least 12.5 ms of CPU per tick. For the cache, tests/ioreport_unopenable.rs drives the whole chain in a process of its own after `simulate_no_energy_model_group()`, a doc-hidden hook that can only turn the meter off. Four threads see Unavailable, no selection and no figure, the work never runs, and the probe runs once. Group-only, structurally (F1). Every sample the probe read listed only Energy Model channels, exactly as many as the group has: 364 of 11,886 on the M5 Max, and 645 of 14,345 on the M3 Ultra. The median thread CPU of a read is under 50 ms; measured, 6-9 ms. The wall-clock median is logged, never asserted. No wall-clock bound or ratio remains in these tests. What the rule was handed (F4). The rule records, for every sample, the (name, ordinal) keys and raw integers of every CPU rail and every `GPU Energy` channel it was handed. A test runs `host_status()` uncached between two all-channel oracle reads: - the keys in every sample equal the oracle's Energy Model keys; - a rail that held was handed exactly the oracle's value; - a rail that moved was handed a value within the two reads. For freshness, an independent watcher reads the group on its own thread. Every GPU Energy rise it saw strictly between two probe reads must appear as two different GPU values in the record. The FFI pin also asserts the key sets and brackets the GPU rail. A raw-reader test alternates the group and the all-channel subscriptions, so a reader that is stale for the process fails whether or not anything moves. Clocks (F3). The tests bind their own clock_gettime(16). The production thread clock stays still across a 100 ms sleep, and while a helper thread burns 100 ms as this thread waits. It moves with this thread's own burn, and never by more than the tests' clock. Each production tick burns at least 25 ms of thread CPU by the tests' clock, unless it ran into the 500 ms wall cap, and never more CPU than wall time. The probe's wall clock is checked against Instant around a sleep. Counting CPU energy once, the rule from eoc-meter 7aa42418. counts_once, now pure and in `liveness`, summed per-cluster accumulators and CPU power managers beside the `CPU Energy` aggregate they add up to: - on the M5 Max, `PACC_*`, `MCPM*`, `PCPM` and their `_SRAM` rails; - on the M3 Ultra, `DIE_n_*_CPM` and their `_CPM_SRAM` rails. It now drops `PACC*`, `EACC*`, `MACC*` and `*CPM*`. A test pins this on both chips' real channel names. It only matters on a live host, so no reading here was affected. Also, `open()` now releases `subscribed` when the subscription is null. Falsified. Each mutation was applied, run and restored with git checkout. git status was clean after each. Every one failed a test: - compare with the first sample: 10 lib tests, e.g. "one refresh counted as a live rail"; - a decrease counts as an advance: "a decrease counted as an advance"; - no subscription -> Live inside host_status: "an unopenable Energy Model group left: Live". The CpuRailAbsent variant fails the same test; - no subscription -> Live inside the cache: the lib tests pass, and ioreport_unopenable fails ("no Energy Model group, yet Live"); - a single advance -> Live inside host_status's read closure: "one refresh counted as a live rail"; - the sum of the CPU rails in host_status's read closure: - scripted, on any host: "one step on each die added up to a live rail"; - on the M3 Ultra also "("DIE_0_CPU Energy", 0) held at 38945708 across the probe, yet it handed the rule 81077035"; - inside probe_with: 3 tests; - a no-op tick in host_status: "interval 0 of 92 consumed 27.584µs", and "host_status() ticked 2 times, yet this thread burned only 14.167µs". spin_tick as a no-op: "tick 0 burned 333ns"; - a per-thread cache: "the liveness probe ran 8 times"; - hardware_meter_available() probing uncached: - "hardware_meter_available() burned 1.007352584s of this thread's CPU"; - "ran 17 times"; - ioreport_unopenable: 9; - power_over probing uncached: "a declined power_over burned 730.585083ms"; - the reader returns constant 0: "held at 2588973125 ..., yet the probe read 0"; - stale readers: - process-wide at the FFI: "listed 364 channels for the group and 364 for every channel"; - process-wide in EnergyModelSub: the GPU bracket; - per probe in the closure: "GPU Energy rose ... yet both handed the rule the same GPU value", and the scripted tests; - the thread clock as CLOCK_MONOTONIC: "advanced 101.41ms while this thread slept 100 ms". As process CPU: 598.246ms; - admits() accepting CpuRailAbsent: "no CPU rail admitted the meter"; - the probe subscribing to every channel: "sample 0 listed 11886 channels, some outside the Energy Model group". Runs on this tree: - M5 Max: the lib tests 5x while a helper process sampled every IOReport channel in a tight loop. Sibling suites' IOReport readers were also running on the host, at load 16-23. 5/5 passed, 58 each; the median read was 6.1-8.8 ms of thread CPU, and the minimum interval 25.02-25.03 ms of thread CPU. The helper was killed on exit, and none remain. - M3 Ultra, once: 58 passed, with "status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false probe_intervals=26". The once-per-process and unopenable tests also pass there. - The proof-rs workspace is green, and the proof-conformance binary prints "all checks passed". - clippy reports only the two warnings that are also on origin/main. - x86_64 Linux is compile-checked only. Not done, deliberately: - The oracle's evidence floor, at least 2 reads of every channel within at most 3x its window, is the one hardware assertion still sensitive to IOReport contention. Under the helper plus the sibling readers it got 2, the edge. It fails loudly rather than passing without evidence. - GPU freshness is asserted only when the watcher saw GPU Energy rise between two probe reads, and the log says how many. With the GPU and CPU rails both idle, a reader made stale between the test hook and the FFI call, in EnergyModelSub, would survive. That is fail-safe: it can only decline. - power_over and measure_marginal_uj are pinned warm on a declined host only. On a live one they do real sampling work. - Which provenance grade a live IOReport reading earns, and a macOS CI job for this crate, are left for David. Co-Authored-By: Claude Opus 5.5 --- .../crates/proof-cascade/src/meter.rs | 2453 ++++++++++++----- .../proof-cascade/tests/ioreport_once.rs | 106 + .../tests/ioreport_unopenable.rs | 58 + 3 files changed, 1988 insertions(+), 629 deletions(-) create mode 100644 proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs create mode 100644 proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index d5771b739..6223bb29c 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -22,6 +22,18 @@ //! burns CPU; otherwise `None` is returned and the caller keeps its //! `ModelBased` figure. //! +//! The chain on macOS is short on purpose. `host_status` is one expression: the +//! generic probe loop (`liveness::probe_with`) on the real parts — the probe's +//! own Energy Model subscription, the thread-CPU tick and the clocks — and the +//! pure status of its run. [`ioreport_status`](crate::meter::ioreport_status) +//! caches it once per process, and every selection +//! ([`hardware_meter_available`](crate::meter::hardware_meter_available), and +//! through it [`measure_marginal_uj`](crate::meter::measure_marginal_uj) and +//! `power_over`) gates the cached status through the +//! pure `admits`, which admits only `Live`. The decisions all live in pure code +//! that the tests run on scripted fakes, and the tests also run `host_status` +//! itself, on the real FFI and on scripted channel reads. +//! //! It is a measurement with stated bounds — SoC/package power resolved to the //! marginal delta over an idle machine ÷ iterations — exactly the discipline //! the WAI sink meter uses, including running the burst on every core so the @@ -49,14 +61,30 @@ use std::time::Instant; /// `work` runs on every spare core during the busy window, so keep it pure + /// `Copy`. pub fn measure_marginal_uj(work: F) -> Option +where + F: Fn() + Send + Sync + Copy + 'static, +{ + measure_gated(hardware_meter_available(), work, idle_power, busy_power) +} + +/// [`measure_marginal_uj`] with the selection's answer and the power readers +/// passed in. The selection is taken first: an interface that is not +/// available (on Apple Silicon, one [`admits`] turns away) returns `None` +/// before any power is read or `work` runs. +fn measure_gated( + available: bool, + work: F, + idle_power: impl FnOnce() -> Option, + busy_power: impl FnOnce() -> Option, +) -> Option where F: Fn() + Send + Sync + Copy + 'static, { // An interface that opens but cannot see CPU work is declined up front. - if !hardware_meter_available() { + if !available { return None; } - let (idle_w, busy_w, window, runs) = bracket(work)?; + let (idle_w, busy_w, window, runs) = bracket_with(work, idle_power, busy_power)?; let marginal_w = (busy_w - idle_w).max(0.0); if runs == 0 { return None; @@ -67,14 +95,16 @@ where /// Whether a usable hardware energy interface is reachable on this machine: /// RAPL `energy_uj` on Linux; on Apple Silicon, IOReport **with a live CPU /// energy rail** — [`ioreport_status`] is [`IoReportStatus::Live`]. A -/// subscription that opens over a frozen CPU rail does not count. On macOS the -/// first call pays the liveness probe (its own Energy Model subscription, then -/// about `CPU_RAIL_LIVENESS_BUDGET` of burning CPU time and re-sampling on the -/// calling thread); the answer is cached for the process. +/// subscription that opens over a frozen CPU rail does not count. On macOS this +/// is the selection every metering path goes through: the cached status, gated +/// by `admits`. The first call in a process pays the liveness probe (its own +/// Energy Model subscription, then about `CPU_RAIL_LIVENESS_BUDGET` of burning +/// CPU time and re-sampling on the calling thread); the answer is cached for +/// the process. pub fn hardware_meter_available() -> bool { #[cfg(target_os = "macos")] { - macos_ioreport::available() + admits(ioreport_status()) } #[cfg(target_os = "linux")] { @@ -114,33 +144,121 @@ pub enum IoReportStatus { /// How long (wall time) the liveness probe watches the CPU energy rail before /// concluding it is frozen. Before each re-sample the probe burns ~25 ms of /// the calling thread's own CPU time (at most 500 ms of wall time per tick). -/// It samples its own subscription to the Energy Model group alone, ~5–12 ms a -/// sample on an M5 Max and an M3 Ultra (every channel: 0.1–0.8 s on the M5 -/// Max, ~0.14 s on the M3 Ultra), so on a quiet host this buys about thirty -/// intervals, and fewer on a loaded one, where a live rail needs two. If fewer -/// than two intervals were observed when it runs out, the probe keeps -/// sampling, up to five budgets. A live rail slower than that fails safe: -/// declined, and the caller keeps its `ModelBased` figure. Paid once per -/// process; see [`ioreport_status`]. +/// It samples its own subscription to the Energy Model group alone — 364 +/// channels on an M5 Max, 645 on an M3 Ultra, ~6–9 ms of thread CPU a sample, +/// where a sample of every channel (~11,900 / ~14,300) costs ~85–125 ms — so on +/// a quiet host this buys about thirty intervals, and fewer on a loaded one, +/// where a live rail needs two. If fewer than two intervals were observed when +/// it runs out, the probe keeps sampling, up to five budgets. A live rail +/// slower than that fails safe: declined, and the caller keeps its +/// `ModelBased` figure. Paid once per process; see [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); -/// How many times this process has run the liveness probe behind -/// [`ioreport_status`] — once at most. A process-wide static (not a +/// How many times this process has run the production liveness probe, +/// [`host_status`] — once at most. It counts runs, not cache lookups: the +/// counter is `host_status`'s first statement, and `host_status` is the only +/// production caller of the probe. A process-wide static (not a /// thread-local), so a test that asks from many threads at once can pin it. #[cfg(target_os = "macos")] -static STATUS_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static REAL_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many times this process has run the production IOReport liveness probe +/// (always 0 off macOS). Exposed for the once-per-process test in +/// `tests/ioreport_once.rs`; not part of the meter's interface. +#[doc(hidden)] +pub fn real_probe_runs() -> usize { + #[cfg(target_os = "macos")] + { + REAL_PROBE_RUNS.load(Ordering::SeqCst) + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + +/// Set by [`simulate_no_energy_model_group`]: the probe's opener then finds no +/// Energy Model group to subscribe to. +#[cfg(target_os = "macos")] +static NO_ENERGY_MODEL_GROUP: AtomicBool = AtomicBool::new(false); + +/// A test hook, not a setting: from now on this process's liveness probe +/// finds no Energy Model group to subscribe to, as on a host that has none. A +/// test in a process of its own (`tests/ioreport_unopenable.rs`) uses it to +/// drive the whole production chain — probe, status, cache, selection, +/// metering — down its unopenable path, which neither Mac that runs the tests +/// can reach otherwise: the status must be `Unavailable` and every meter must +/// decline. It takes effect only before the first use of [`ioreport_status`], +/// which caches its answer for the process. It can only turn the meter off. +/// A no-op off macOS. +#[doc(hidden)] +pub fn simulate_no_energy_model_group() { + #[cfg(target_os = "macos")] + NO_ENERGY_MODEL_GROUP.store(true, Ordering::SeqCst); +} + +/// The production liveness probe's answer, and the record of the run that +/// decided it (every read, its ticks, and what the rule was handed), so tests +/// check the run that really made the decision. +#[cfg(target_os = "macos")] +#[derive(Debug)] +struct HostStatus { + status: IoReportStatus, + #[cfg_attr(not(test), allow(dead_code))] + run: liveness::ProbeRun, +} + +#[cfg(target_os = "macos")] +impl HostStatus { + /// A run and its status: always [`status_of`] of that same run. + fn of(run: liveness::ProbeRun) -> Self { + Self { status: status_of(&run), run } + } +} + +/// THE production composition: the generic probe loop +/// ([`liveness::probe_with`]) on the real parts — the probe's own Energy Model +/// subscription and its reads, the thread-CPU tick, this process's wall clock +/// and the thread CPU clock — and the pure status of its run. One expression, +/// no branch: every decision is in `probe_with` and [`status_of`], which the +/// tests run on scripted fakes, and the tests also run this very function +/// (uncached), on the real FFI and on scripted channel reads. Counts itself +/// first; the only production caller of `probe_with`, reached in production +/// only through the cache ([`host`]). +#[cfg(target_os = "macos")] +fn host_status() -> HostStatus { + REAL_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); + HostStatus::of(liveness::probe_with( + macos_ioreport::EnergyModelSub::open, + macos_ioreport::EnergyModelSub::read, + macos_ioreport::spin_tick, + macos_ioreport::wall_clock, + macos_ioreport::thread_cpu_time, + CPU_RAIL_LIVENESS_BUDGET, + )) +} + +/// The once-per-process cache of [`host_status`]: one process-wide +/// `OnceLock`, so concurrent first callers wait for the one probe instead of +/// each running their own. +#[cfg(target_os = "macos")] +fn host() -> &'static HostStatus { + static HOST: std::sync::OnceLock = std::sync::OnceLock::new(); + HOST.get_or_init(host_status) +} /// This host's IOReport status, probed once per process and cached. /// /// On macOS, [`hardware_meter_available`] and [`measure_marginal_uj`] use -/// IOReport only when this is [`IoReportStatus::Live`]. Exposed so a caller can -/// say *why* an Apple Silicon host has no hardware reading instead of guessing. -/// Always `Unavailable` off macOS; it does not describe Linux RAPL. +/// IOReport only when `admits` this — only when it is +/// [`IoReportStatus::Live`]. Exposed so a caller can say *why* an Apple +/// Silicon host has no hardware reading instead of guessing. Always +/// `Unavailable` off macOS; it does not describe Linux RAPL. pub fn ioreport_status() -> IoReportStatus { #[cfg(target_os = "macos")] { - cached_probe().0 + host().status } #[cfg(not(target_os = "macos"))] { @@ -148,47 +266,33 @@ pub fn ioreport_status() -> IoReportStatus { } } -/// The once-per-process liveness probe behind [`ioreport_status`]: its status -/// and the record of the run that decided it (how many intervals, and every -/// sample call it made). One process-wide `OnceLock`: concurrent first callers -/// wait for the one probe instead of each running their own. -#[cfg(target_os = "macos")] -fn cached_probe() -> &'static (IoReportStatus, macos_ioreport::ProbeRun) { - static STATUS: std::sync::OnceLock<(IoReportStatus, macos_ioreport::ProbeRun)> = std::sync::OnceLock::new(); - STATUS.get_or_init(|| { - STATUS_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); - let run = macos_ioreport::probe(CPU_RAIL_LIVENESS_BUDGET); - (status_from(run.verdict), run) - }) -} - -/// The probe's verdict as the public status. `None` — the Energy Model -/// subscription could not be opened, or opened but never yielded two -/// consecutive samples — is `Unavailable`: never `Live`, and never -/// `CpuRailAbsent`, which needs a sampled subscription with no CPU rail in it. +/// The status a probe run earns: `Unavailable` when its Energy Model +/// subscription did not open, or opened but never yielded two consecutive +/// samples (no verdict) — never `Live`, and never `CpuRailAbsent`, which needs +/// a sampled subscription with no CPU rail in it; otherwise its verdict, one to +/// one. #[cfg_attr(not(target_os = "macos"), allow(dead_code))] -fn status_from(verdict: Option) -> IoReportStatus { - match verdict { - None => IoReportStatus::Unavailable, - Some(liveness::CpuRail::Absent) => IoReportStatus::CpuRailAbsent, - Some(liveness::CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, - Some(liveness::CpuRail::Live) => IoReportStatus::Live, +fn status_of(run: &liveness::ProbeRun) -> IoReportStatus { + match (run.opened, run.verdict) { + (false, _) | (true, None) => IoReportStatus::Unavailable, + (true, Some(liveness::CpuRail::Absent)) => IoReportStatus::CpuRailAbsent, + (true, Some(liveness::CpuRail::Frozen)) => IoReportStatus::CpuRailFrozen, + (true, Some(liveness::CpuRail::Live)) => IoReportStatus::Live, } } -/// Run `work` on every spare core for a window, bracketed against idle, and -/// return `(idle_w, busy_w, window_s, total_runs)`. The power readings come from -/// the platform interface; `None` if none is reachable. -fn bracket(work: F) -> Option<(f64, f64, f64, u64)> -where - F: Fn() + Send + Sync + Copy + 'static, -{ - bracket_with(work, idle_power, busy_power) +/// Whether a status lets the IOReport meter be used: only `Live`. Every +/// selection path on macOS gates through this, on the cached status. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn admits(status: IoReportStatus) -> bool { + matches!(status, IoReportStatus::Live) } -/// [`bracket`] with the idle and busy power readers passed in. The workers -/// are stopped and joined before the busy reading is looked at, so a busy -/// window with no reading returns `None` without leaving them spinning for +/// Run `work` on every spare core for a window, bracketed against idle, and +/// return `(idle_w, busy_w, window_s, total_runs)`. The power readings come +/// from `idle_power` and `busy_power`; `None` if either gives none. The +/// workers are stopped and joined before the busy reading is looked at, so a +/// busy window with no reading returns `None` without leaving them spinning for /// the rest of the process. fn bracket_with( work: F, @@ -228,11 +332,11 @@ where } // --------------------------------------------------------------------------- -// CPU-rail liveness: the decision, the sampling loop, what an unopenable -// subscription means, and which channels a sample keeps, kept apart from the -// FFI so all of it is pure, tested code on every platform. The macOS probe -// only supplies the subscription, the raw channel reads, the CPU burn and the -// clock. +// CPU-rail liveness: the decision, the probe loop and the record it keeps, +// what an unopenable subscription means, which channels a read keeps, and +// which rails the power reading sums — kept apart from the FFI so all of it is +// pure, tested code on every platform. The macOS probe only supplies the +// subscription, the raw channel reads, the CPU burn and the clocks. // --------------------------------------------------------------------------- #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod liveness { @@ -259,19 +363,77 @@ mod liveness { Absent, } - /// One sample of the probe's subscription as the rule reads it: every - /// channel's name and its ABSOLUTE raw integer, in channel order. Channels - /// that are not CPU rails ride along and are ignored by the rule. + /// One sample as the rule reads it: every Energy Model channel's name and + /// its ABSOLUTE raw integer, in channel order. Channels that are not CPU + /// rails ride along and are ignored by the rule. pub(super) type Sample = Vec<(String, i64)>; - /// A CPU rail's identity from one sample to the next: its channel name, and + /// One channel of an IOReport sample as the FFI read it: its group and its + /// name (`None` where the string could not be read), and its raw integer. + pub(super) type Channel = (Option, Option, i64); + + /// What one IOReport sample listed before the Energy Model channels were + /// picked out of it: how many channels, and whether every one of them is in + /// the Energy Model group. The probe subscribes to that group alone, so each + /// of its samples lists exactly the group's channels (364 on an M5 Max, 645 + /// on an M3 Ultra) and nothing else; a subscription to every channel lists + /// ~11,900 / ~14,300 of them, in 186 / 255 groups. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[cfg_attr(not(test), allow(dead_code))] + pub(super) struct Listed { + pub(super) channels: usize, + pub(super) group_only: bool, + } + + /// One read of the probe's subscription: the sample the rule is handed, and + /// what the IOReport sample it came from listed. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(super) struct Read { + pub(super) sample: Sample, + pub(super) listed: Listed, + } + + /// A read, from the channels one IOReport sample listed, each as (group, + /// name, raw integer) the way the FFI read them. The sample keeps the + /// channels of the Energy Model group (any case) that have a name, in + /// order, each with ITS OWN raw integer. `listed` counts every channel, of + /// any group, and says whether all of them are in the Energy Model group (a + /// channel whose group could not be read is not). + pub(super) fn read_from(channels: impl IntoIterator) -> Read { + let mut sample = Sample::new(); + let mut listed = Listed { channels: 0, group_only: true }; + for (group, name, raw) in channels { + let in_group = group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case("Energy Model")); + listed.channels += 1; + listed.group_only &= in_group; + if let (true, Some(name)) = (in_group, name) { + sample.push((name, raw)); + } + } + Read { sample, listed } + } + + /// A rail's identity from one sample to the next: its channel name, and /// its ordinal among the channels of that sample with the same name. Names /// alone are not unique: an M3 Ultra's Energy Model group lists 264 of its /// 645 names twice (e.g. `ECPUDTL00`..`22`, once per die, with no `DIE_` /// prefix). Its CPU aggregates are unique today, but two same-named rails /// stepping together in one refresh must stay two rails that each advanced /// once, not one rail that advanced twice. - type RailKey = (String, u32); + pub(super) type RailKey = (String, u32); + + /// What the rule was handed in one successful sample, keyed as the rule + /// keys it: every CPU rail, and every `GPU Energy` channel, each with its + /// raw integer. The probe keeps one per successful sample, in order, so a + /// test can compare the values the real probe decided on with an + /// independent reading, and check that its reads were fresh (the GPU rail + /// moves where the CPU rails are frozen). + #[derive(Debug, Clone, Default, PartialEq, Eq)] + #[cfg_attr(not(test), allow(dead_code))] + pub(super) struct Seen { + pub(super) cpu: Vec<(RailKey, i64)>, + pub(super) gpu: Vec<(RailKey, i64)>, + } /// The liveness rule, fed one whole sample at a time. /// @@ -303,6 +465,8 @@ mod liveness { prev: Option>, /// Every CPU rail seen, with the number of intervals in which it advanced. rails: Vec<(RailKey, u32)>, + /// What each successful sample handed the rule, in order. + seen: Vec, } impl RailWatch { @@ -311,7 +475,8 @@ mod liveness { /// advances at most once per interval, and only if it was in both /// samples. pub(super) fn observe(&mut self, sample: &[(String, i64)]) { - let cur = cpu_rails(sample); + let cur = keyed(sample, is_cpu_energy_rail); + self.seen.push(Seen { cpu: cur.clone(), gpu: keyed(sample, is_gpu_energy_rail) }); for (key, _) in &cur { if !self.rails.iter().any(|(k, _)| k == key) { self.rails.push((key.clone(), 0)); @@ -367,12 +532,12 @@ mod liveness { } } - /// The CPU rails of one sample, keyed by name and ordinal among the - /// same-named channels, in channel order. - fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + /// The channels of one sample that `pick` accepts, keyed by name and + /// ordinal among the same-named channels, in channel order. + fn keyed(sample: &[(String, i64)], pick: fn(&str) -> bool) -> Vec<(RailKey, i64)> { let mut rails: Vec<(RailKey, i64)> = Vec::new(); for (name, raw) in sample { - if is_cpu_energy_rail(name) { + if pick(name) { let ordinal = rails.iter().filter(|((n, _), _)| n == name).count() as u32; rails.push(((name.clone(), ordinal), *raw)); } @@ -380,65 +545,129 @@ mod liveness { rails } - /// The probe loop, generic over where samples come from, how the thread - /// burns CPU between them, and what time it is, so the loop itself is - /// tested with scripted samples and a scripted clock. Takes a baseline - /// sample, then ticks and re-samples for as long as - /// [`RailWatch::wants_more`] says to. A failed sample (`None`) is not an - /// interval: the next successful one is compared with the last successful - /// one. Returns the verdict and the number of intervals observed. - pub(super) fn watch( - mut sample: impl FnMut() -> Option, - mut tick: impl FnMut(), - now: impl Fn() -> Duration, - budget: Duration, - ) -> (Option, u32) { - let started = now(); - let mut w = RailWatch::default(); - if let Some(s) = sample() { - w.observe(&s); - } - while w.wants_more(now().saturating_sub(started), budget) { - tick(); - if let Some(s) = sample() { - w.observe(&s); - } - } - (w.verdict(), w.intervals()) + /// One read of the probe's subscription, as the probe recorded it. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + #[cfg_attr(not(test), allow(dead_code))] + pub(super) struct SampleCall { + /// The probe's wall clock when the read began, and when it returned. + pub(super) start: Duration, + pub(super) end: Duration, + /// The probing thread's CPU clock just before and just after the read; + /// `None` where it could not be read. + pub(super) cpu_before: Option, + pub(super) cpu_after: Option, + /// What the IOReport sample listed, for a read that returned one; + /// `None` for a read that returned no sample. + pub(super) listed: Option, } - /// The whole probe, generic over how its subscription is opened: open it, - /// then [`watch`] it. A subscription that cannot be opened gives no - /// verdict and zero intervals, and so does one that opens but never - /// yields a sample ([`watch`]); the caller maps no verdict to - /// `Unavailable`. Neither is `Absent`: that needs sampled intervals with no - /// CPU rail in them. The subscription is dropped (released) on return. + /// What one run of the liveness probe did: its verdict, and a record of how + /// it got there — every read, every tick, and what each successful read + /// handed the rule — so tests check how the real probe sampled and what it + /// decided on, not only what it decided. Outside tests only `opened` and + /// the verdict are read. + #[derive(Debug, Clone, Default)] + #[cfg_attr(not(test), allow(dead_code))] + pub(super) struct ProbeRun { + pub(super) verdict: Option, + pub(super) intervals: u32, + /// Whether the subscription opened. + pub(super) opened: bool, + /// How many times the probe ticked (burned CPU before a re-read). + pub(super) ticks: u32, + /// Every read, in order, the baseline included. + pub(super) calls: Vec, + /// What each successful read handed the rule, in order: one per + /// `calls` entry whose `listed` is `Some`. + pub(super) seen: Vec, + } + + #[cfg_attr(not(test), allow(dead_code))] + impl ProbeRun { + /// The probing thread's CPU time consumed in each interval between two + /// consecutive successful reads, outside the reads themselves: from the + /// end of one successful read to the start of the next. One entry per + /// interval; `None` for an interval where the CPU clock could not be + /// read. + pub(super) fn interval_cpu(&self) -> Vec> { + let mut out = Vec::new(); + let mut prev_end: Option> = None; + for c in self.calls.iter().filter(|c| c.listed.is_some()) { + if let Some(end) = prev_end { + out.push(match (end, c.cpu_before) { + (Some(end), Some(start)) => start.checked_sub(end), + _ => None, + }); + } + prev_end = Some(c.cpu_after); + } + out + } + + /// The median thread CPU time one read took (the upper one of an even + /// count), over the reads whose CPU clock could be read; `None` if + /// there are none. + pub(super) fn median_read_cpu(&self) -> Option { + let mut cpu: Vec = + self.calls.iter().filter_map(|c| c.cpu_after?.checked_sub(c.cpu_before?)).collect(); + cpu.sort(); + cpu.get(cpu.len() / 2).copied() + } + + /// The median wall time one read took; logged, never asserted. + pub(super) fn median_read_wall(&self) -> Option { + let mut wall: Vec = self.calls.iter().map(|c| c.end.saturating_sub(c.start)).collect(); + wall.sort(); + wall.get(wall.len() / 2).copied() + } + } + + /// The whole probe, generic over its parts — how the subscription is opened + /// and read, how the thread burns CPU between reads, and its wall and CPU + /// clocks — so this very loop, and the record it keeps, run on scripted + /// fakes in the tests. `host_status` runs it on the real parts. + /// + /// Opens the subscription, takes a baseline read, then ticks and re-reads + /// for as long as [`RailWatch::wants_more`] says to. A failed read (`None`) + /// is not an interval: the next successful one is compared with the last + /// successful one. A subscription that cannot be opened is never read or + /// ticked and gives no verdict; so does one that opens but never yields two + /// consecutive samples. Neither is `Absent`: that needs sampled intervals + /// with no CPU rail in them. The subscription is dropped (released) on + /// return. pub(super) fn probe_with( open: impl FnOnce() -> Option, - mut sample: impl FnMut(&S) -> Option, - tick: impl FnMut(), - now: impl Fn() -> Duration, + mut read: impl FnMut(&S) -> Option, + mut tick: impl FnMut(), + wall: impl Fn() -> Duration, + cpu: impl Fn() -> Option, budget: Duration, - ) -> (Option, u32) { + ) -> ProbeRun { + let mut run = ProbeRun::default(); let Some(sub) = open() else { - return (None, 0); + return run; }; - watch(|| sample(&sub), tick, now, budget) - } - - /// One sample as the rule reads it, from the channels one IOReport sample - /// listed, each as (group, name, raw integer) the way the FFI read them - /// (`None` where a string could not be read). Keeps the channels of the - /// Energy Model group (any case) that have a name, in order, each with - /// ITS OWN raw integer. - pub(super) fn energy_model_sample_from( - channels: impl IntoIterator, Option, i64)>, - ) -> Sample { - channels - .into_iter() - .filter(|(group, _, _)| group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case("Energy Model"))) - .filter_map(|(_, name, raw)| Some((name?, raw))) - .collect() + run.opened = true; + let started = wall(); + let mut watch = RailWatch::default(); + loop { + let (start, cpu_before) = (wall(), cpu()); + let got = read(&sub); + let (cpu_after, end) = (cpu(), wall()); + run.calls.push(SampleCall { start, end, cpu_before, cpu_after, listed: got.as_ref().map(|r| r.listed) }); + if let Some(got) = got { + watch.observe(&got.sample); + } + if !watch.wants_more(wall().saturating_sub(started), budget) { + break; + } + tick(); + run.ticks += 1; + } + run.verdict = watch.verdict(); + run.intervals = watch.intervals(); + run.seen = watch.seen; + run } /// Whether a channel is a CPU energy rail: its name, after stripping a @@ -448,6 +677,44 @@ mod liveness { strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") } + /// Whether a channel is the GPU energy aggregate: `GPU Energy` (any case, + /// after an optional `DIE__` prefix). Not a liveness signal — the probe + /// records it only so tests can see its reads were fresh. + pub(super) fn is_gpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("GPU Energy") + } + + /// Whether the power reading sums this Energy Model rail, so that energy is + /// counted once. The aggregates `CPU Energy`, `GPU Energy` and `ANE Energy` + /// (optionally `DIE__`-prefixed) are summed; their summands are not. A + /// summand is any other rail whose name says CPU, GPU or ANE, and also the + /// CPU summands whose names do not: per-cluster accumulators (`PACC*`, + /// `EACC*`, `MACC*`) and CPU power managers (`*CPM*`, with their `_SRAM` + /// rails). From absolute samples of the Energy Model group on each chip: on + /// an M5 Max, `CPU Energy` = `MCPU0` + `MCPU1` + `PCPU`, and `PCPU` = + /// `PACC_0..5` + `PCPU0..5_SRAM` + `PCPM` + `PCPM_SRAM`; on an M3 Ultra, + /// each `DIE__CPU Energy` = its `EACC_CPU` + `PACC0_CPU` + `PACC1_CPU`, + /// each the sum of its cores, their SRAM and its `_CPM` + `_CPM_SRAM` + /// rails (all to within 1-8 mJ). Summing `PACC_*` or `*_CPM` beside + /// `CPU Energy` would count that CPU energy twice. Neither chip has an + /// `ANE Energy` aggregate, and `ANE0` (`ANE0_0`/`ANE0_1`) is dropped as a + /// summand, so ANE energy is not summed at all. + pub(super) fn counts_once(name: &str) -> bool { + let base = strip_die_prefix(name); + if base.eq_ignore_ascii_case("CPU Energy") + || base.eq_ignore_ascii_case("GPU Energy") + || base.eq_ignore_ascii_case("ANE Energy") + { + return true; + } + let u = base.to_ascii_uppercase(); + if u.contains("CPU") || u.contains("GPU") || u.contains("ANE") { + return false; + } + let cpu_summand = ["PACC", "EACC", "MACC"].iter().any(|p| u.starts_with(p)) || u.contains("CPM"); + !cpu_summand + } + /// Strip an optional `DIE__` prefix, `` one or more digits, `DIE_` /// in any case (multi-die parts like the M-series Ultra report per-die /// aggregates). @@ -467,7 +734,7 @@ mod liveness { } #[cfg(test)] - mod tests { + pub(super) mod tests { use super::*; use std::cell::Cell; @@ -478,33 +745,55 @@ mod liveness { pairs.iter().map(|(n, v)| (n.to_string(), *v)).collect() } - /// What one scripted [`watch`] did. + /// A read of a subscription to the Energy Model group alone, listing + /// exactly these channels. + pub(in super::super) fn read_of(pairs: &[(&str, i64)]) -> Read { + Read { sample: sample(pairs), listed: Listed { channels: pairs.len(), group_only: true } } + } + + /// The (key, raw) values the rule keys out of `pairs` with `pick`: the + /// test's own keying, by name and ordinal. + pub(in super::super) fn expect_keyed(pairs: &[(&str, i64)], pick: impl Fn(&str) -> bool) -> Vec<(RailKey, i64)> { + let mut out: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in pairs.iter().filter(|(n, _)| pick(n)) { + let ordinal = out.iter().filter(|((n, _), _)| n == name).count() as u32; + out.push(((name.to_string(), ordinal), *raw)); + } + out + } + + /// What one scripted [`probe_with`] did. #[derive(Debug)] struct Run { verdict: Option, intervals: u32, - /// How many times it asked for a sample, the baseline included. + /// How many times it asked for a read, the baseline included. samples: u32, /// The scripted clock when it stopped. took: Duration, + run: ProbeRun, } - /// Run [`watch`] on scripted samples (`script(n)` is the n-th sample, - /// from 0) and a scripted clock that only the tick advances, by `tick`. + /// Run [`probe_with`] on a subscription that opens, scripted reads + /// (`script(n)` is the n-th read, from 0), and a scripted clock that only + /// the tick advances, by `tick`. fn run(tick: Duration, mut script: impl FnMut(u32) -> Option>) -> Run { let clock = Cell::new(Duration::ZERO); let calls = Cell::new(0u32); - let (verdict, intervals) = watch( - || { + let run = probe_with( + || Some(()), + |_: &()| { let n = calls.get(); calls.set(n + 1); - script(n).map(|s| sample(&s)) + script(n).map(|s| read_of(&s)) }, || clock.set(clock.get() + tick), || clock.get(), + || None, BUDGET, ); - Run { verdict, intervals, samples: calls.get(), took: clock.get() } + assert_eq!(run.calls.len(), calls.get() as usize, "one record per read"); + Run { verdict: run.verdict, intervals: run.intervals, samples: calls.get(), took: clock.get(), run } } #[test] @@ -525,6 +814,11 @@ mod liveness { assert!(!is_cpu_energy_rail("PACC_0")); assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); assert!(!is_cpu_energy_rail("ECPUDTL00")); + assert!(is_gpu_energy_rail("GPU Energy")); + assert!(is_gpu_energy_rail("DIE_1_GPU Energy")); + assert!(!is_gpu_energy_rail("GPU0")); + assert!(!is_gpu_energy_rail("GPU0_0")); + assert!(!is_gpu_energy_rail("CPU Energy")); } /// A rail that moves in one step and then holds — a batch refresh caught @@ -597,55 +891,16 @@ mod liveness { assert!(!w.is_live(), "a second same-named channel's first appearance counted as an advance"); } - /// The Energy Model subscription cannot be opened, or opens but never - /// yields a sample: no verdict, which the status function maps to - /// `Unavailable` — never `Live`, never `CpuRailAbsent`. A subscription - /// that cannot be opened is never sampled or ticked. + /// A read keeps each Energy Model channel with its OWN raw integer, in + /// order, and drops other groups and unnamed channels — no rail is + /// given another rail's value, a sum, or zero — and it says what the + /// IOReport sample listed: every channel, of any group, and whether all + /// of them are in the Energy Model group. #[test] - fn a_group_subscription_that_cannot_be_opened_or_sampled_is_unavailable() { - use super::super::{status_from, IoReportStatus}; - let (samples, ticks, clock) = (Cell::new(0u32), Cell::new(0u32), Cell::new(Duration::ZERO)); - let tick = || { - ticks.set(ticks.get() + 1); - clock.set(clock.get() + TICK); - }; - - let unopened = probe_with( - || None::<()>, - |_| { - samples.set(samples.get() + 1); - Some(sample(&[("CPU Energy", 1)])) - }, - tick, - || clock.get(), - BUDGET, - ); - assert_eq!(unopened, (None, 0), "a subscription that cannot be opened gives no verdict"); - assert_eq!((samples.get(), ticks.get()), (0, 0), "an unopened subscription was sampled or ticked"); - - let unsampled = probe_with(|| Some(()), |_| None, tick, || clock.get(), BUDGET); - assert_eq!(unsampled, (None, 0), "a subscription that never yields a sample gives no verdict"); - - for (what, (verdict, _)) in [("cannot be opened", unopened), ("never yields a sample", unsampled)] { - let status = status_from(verdict); - assert_eq!(status, IoReportStatus::Unavailable, "a subscription that {what}"); - assert_ne!(status, IoReportStatus::Live, "a subscription that {what} is not Live"); - assert_ne!(status, IoReportStatus::CpuRailAbsent, "a subscription that {what} is not Absent"); - } - - // Opened and sampled, the verdict comes from the samples. - let sampled = probe_with(|| Some(()), |_| Some(sample(&[("CPU Energy", 1)])), tick, || clock.get(), BUDGET); - assert_eq!(sampled.0, Some(CpuRail::Frozen)); - } - - /// The sampler's assembly keeps each Energy Model channel with its OWN - /// raw integer, in order, and drops other groups and unnamed channels: - /// no rail is given another rail's value, a sum, or zero. - #[test] - fn the_sample_keeps_each_energy_model_channel_with_its_own_value() { + fn a_read_keeps_each_energy_model_channel_with_its_own_value_and_says_what_was_listed() { let em = || Some("Energy Model".to_string()); let name = |n: &str| Some(n.to_string()); - let s = energy_model_sample_from(vec![ + let r = read_from(vec![ (em(), name("DIE_0_CPU Energy"), 9_494_336), (name("CPU Stats"), name("CPU Energy"), 1), (name("energy model"), name("DIE_1_CPU Energy"), 10_212_656), @@ -654,10 +909,19 @@ mod liveness { (em(), name("GPU Energy"), 14_809_813), ]); assert_eq!( - s, + r.sample, sample(&[("DIE_0_CPU Energy", 9_494_336), ("DIE_1_CPU Energy", 10_212_656), ("GPU Energy", 14_809_813)]), "each Energy Model channel keeps its own raw integer" ); + assert_eq!(r.listed, Listed { channels: 6, group_only: false }, "a channel of another group was listed"); + + let r = read_from(vec![(em(), name("CPU Energy"), 3), (em(), None, 4), (name("ENERGY MODEL"), name("GPU0"), 5)]); + assert_eq!(r.listed, Listed { channels: 3, group_only: true }, "every channel is in the group"); + assert_eq!(r.sample, sample(&[("CPU Energy", 3), ("GPU0", 5)])); + + let r = read_from(vec![(em(), name("CPU Energy"), 3), (None, name("GPU Energy"), 4)]); + assert_eq!(r.listed, Listed { channels: 2, group_only: false }, "an unreadable group is not the group"); + assert_eq!(read_from(Vec::new()).listed, Listed { channels: 0, group_only: true }); } /// No observed interval at all is no verdict; intervals with no CPU @@ -689,6 +953,7 @@ mod liveness { assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); // One interval per 25 ms tick until the 1 s budget is spent. assert_eq!((r.intervals, r.samples, r.took), (40, 41, BUDGET), "{r:?}"); + assert_eq!(r.run.ticks, 40, "one tick before every re-read"); } /// (b) The M3 Ultra's refresh: both dies step once, in the same sample, @@ -742,7 +1007,7 @@ mod liveness { assert_eq!((r.verdict, r.intervals), (None, 0), "{r:?}"); assert_eq!(r.took, BUDGET * MAX_BUDGETS, "{r:?}"); assert_eq!(r.samples, 1 + (BUDGET * MAX_BUDGETS).as_millis() as u32 / 25, "{r:?}"); - assert_eq!(super::super::status_from(r.verdict), super::super::IoReportStatus::Unavailable); + assert!(r.run.seen.is_empty() && r.run.calls.iter().all(|c| c.listed.is_none()), "{r:?}"); let r = run(TICK, |n| (n == 0).then(|| vec![("CPU Energy", 1)])); assert_eq!((r.verdict, r.intervals, r.took), (None, 0, BUDGET * MAX_BUDGETS), "{r:?}"); @@ -766,17 +1031,28 @@ mod liveness { } /// (g) One step on each die, in different intervals, does not add up to - /// a live rail: liveness is per rail, not summed. + /// a live rail: liveness is per rail, not summed. Through the whole + /// loop, so a sum placed anywhere between the read and the rule reads + /// as two advances and fails here; and the record shows each die's own + /// values, as read. #[test] fn watch_one_step_on_each_die_in_different_intervals_is_frozen() { - let r = run(TICK, |n| { - Some(vec![ + let script = |n: u32| { + vec![ ("DIE_0_CPU Energy", 100 + if n < 3 { 0 } else { 50 }), ("DIE_1_CPU Energy", 200 + if n < 9 { 0 } else { 50 }), - ]) - }); + ("GPU Energy", 1_000 + i64::from(n)), + ] + }; + let r = run(TICK, |n| Some(script(n))); assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); assert_eq!(r.intervals, 40, "{r:?}"); + assert_eq!(r.run.seen.len(), 41, "one record per successful read"); + for (n, seen) in r.run.seen.iter().enumerate() { + let s = script(n as u32); + assert_eq!(seen.cpu, expect_keyed(&s, |c| c.ends_with("CPU Energy")), "read {n}: what the rule was handed"); + assert_eq!(seen.gpu, expect_keyed(&s, |c| c == "GPU Energy"), "read {n}: the GPU rail as read"); + } } /// The stopping rule on its own: within the budget until a rail proves @@ -813,6 +1089,204 @@ mod liveness { } assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); } + + /// The record keeps, for every read, the wall and CPU clocks around it + /// and what its sample listed; for every successful read, what the + /// rule was handed. A failed read records its clocks and no sample. + /// Each re-read is preceded by exactly one tick. + #[test] + fn the_probe_records_every_read_and_what_the_rule_was_handed() { + let clock = Cell::new(Duration::ZERO); + let cpu = Cell::new(Duration::ZERO); + let n = Cell::new(0u32); + let run = probe_with( + || Some(()), + |_: &()| { + let k = n.get(); + n.set(k + 1); + clock.set(clock.get() + Duration::from_millis(8)); + cpu.set(cpu.get() + Duration::from_millis(7)); + // The third read fails; the rail advances at reads 1 and 3. + (k != 2).then(|| read_of(&[("CPU Energy", [5, 6, 0, 7][k as usize]), ("GPU Energy", i64::from(k))])) + }, + || { + clock.set(clock.get() + TICK); + cpu.set(cpu.get() + TICK); + }, + || clock.get(), + || Some(cpu.get()), + BUDGET, + ); + let ms = Duration::from_millis; + assert_eq!(run.verdict, Some(CpuRail::Live)); + assert_eq!((run.intervals, run.ticks, run.calls.len()), (2, 3, 4)); + let listed = Some(Listed { channels: 2, group_only: true }); + assert_eq!( + run.calls, + vec![ + SampleCall { start: ms(0), end: ms(8), cpu_before: Some(ms(0)), cpu_after: Some(ms(7)), listed }, + SampleCall { start: ms(33), end: ms(41), cpu_before: Some(ms(32)), cpu_after: Some(ms(39)), listed }, + SampleCall { + start: ms(66), + end: ms(74), + cpu_before: Some(ms(64)), + cpu_after: Some(ms(71)), + listed: None, + }, + SampleCall { start: ms(99), end: ms(107), cpu_before: Some(ms(96)), cpu_after: Some(ms(103)), listed }, + ] + ); + let key = |name: &str| (name.to_string(), 0u32); + assert_eq!( + run.seen, + vec![ + Seen { cpu: vec![(key("CPU Energy"), 5)], gpu: vec![(key("GPU Energy"), 0)] }, + Seen { cpu: vec![(key("CPU Energy"), 6)], gpu: vec![(key("GPU Energy"), 1)] }, + Seen { cpu: vec![(key("CPU Energy"), 7)], gpu: vec![(key("GPU Energy"), 3)] }, + ] + ); + // Between successful reads, outside the reads: 32 - 7, and 96 - 39 + // (the failed read's 7 ms and a second tick lie in between). + assert_eq!(run.interval_cpu(), vec![Some(ms(25)), Some(ms(57))]); + assert_eq!(run.median_read_cpu(), Some(ms(7))); + assert_eq!(run.median_read_wall(), Some(ms(8))); + assert_eq!(ProbeRun::default().median_read_cpu(), None); + assert!(ProbeRun::default().interval_cpu().is_empty()); + } + + /// The power reading sums each aggregate once and none of its summands, + /// on the real Energy Model channel names of both chips (macOS 27.0.1). + /// Every channel whose name does not say CPU, GPU or ANE is listed for + /// both chips — so a rule that admits one more CPU summand, or drops a + /// domain, fails here — beside samples of the rails that do. + #[test] + fn counts_once_sums_each_aggregate_once_on_both_chips() { + // M5 Max: summed. `CPU Energy`, `GPU Energy`, and every domain that + // is no aggregate's summand. + let m5_summed = [ + "CPU Energy", + "GPU Energy", + "AFR0", + "AMCC0", + "AVE0", + "DCS0", + "DISP0", + "DISPEXT0", + "DRAM0", + "FAB0", + "ISP0", + "MSR0", + "PCIe Port 0 Energy", + "PCIe Port 1 Energy", + "apciec0 Energy", + "apciec1 Energy", + "apciec2 Energy", + ]; + // M5 Max: summands of `CPU Energy`. `CPU Energy` = `MCPU0` + `MCPU1` + // + `PCPU`; `MCPUn` = its cores + their SRAM + `MCPMn` + + // `MCPMn_SRAM`; `PCPU` = `PACC_0..5` + `PCPU0..5_SRAM` + `PCPM` + + // `PCPM_SRAM`. The first twelve are every summand whose name does + // not say CPU. + let m5_summands = [ + "PACC_0", + "PACC_1", + "PACC_2", + "PACC_3", + "PACC_4", + "PACC_5", + "MCPM0", + "MCPM1", + "PCPM", + "MCPM0_SRAM", + "MCPM1_SRAM", + "PCPM_SRAM", + "MCPU0", + "MCPU1", + "PCPU", + "MCPU0_3", + "MCPU1_5_SRAM", + "PCPU2_SRAM", + "MCPU0DTL0e", + "PCPUDTL513", + "GPU0", + "ANE0", + ]; + // M3 Ultra: summed. + let m3_summed = [ + "DIE_0_CPU Energy", + "DIE_1_CPU Energy", + "GPU Energy", + "AMCC0_0", + "AMCC0_1", + "AVE0_0", + "AVE0_1", + "DCS0_0", + "DCS0_1", + "DISP0_0", + "DISP0_1", + "DISPEXT0_0", + "DISPEXT0_1", + "DRAM0_0", + "DRAM0_1", + "ISP0_0", + "ISP0_1", + "MSR0_0", + "MSR0_1", + "PCIe Port 0 Energy", + "PCIe Port 1 Energy", + "PCIe Port 2 Energy", + "PCIe Port 3 Energy", + "apciec0 Energy", + "apciec1 Energy", + "apciec2 Energy", + "apciec3 Energy", + "apciec4 Energy", + "apciec5 Energy", + ]; + // M3 Ultra: summands. `DIE_n_CPU Energy` = its `EACC_CPU` + + // `PACC0_CPU` + `PACC1_CPU`, each = its cores + their SRAM + its + // `_CPM` + `_CPM_SRAM`. The first twelve are every summand whose name + // does not say CPU. + let m3_summands = [ + "DIE_0_EACC_CPM", + "DIE_0_EACC_CPM_SRAM", + "DIE_0_PACC0_CPM", + "DIE_0_PACC0_CPM_SRAM", + "DIE_0_PACC1_CPM", + "DIE_0_PACC1_CPM_SRAM", + "DIE_1_EACC_CPM", + "DIE_1_EACC_CPM_SRAM", + "DIE_1_PACC0_CPM", + "DIE_1_PACC0_CPM_SRAM", + "DIE_1_PACC1_CPM", + "DIE_1_PACC1_CPM_SRAM", + "DIE_0_EACC_CPU", + "DIE_1_PACC0_CPU", + "DIE_0_PACC1_CPU", + "DIE_0_PACC1_CPU3", + "DIE_1_EACC_CPU2_SRAM", + "ECPUDTL00", + "GPU0_0", + "GPU CS0_0", + "GPU SRAM0_0", + "GPU CS SRAM0_0", + "ANE0_0", + "ANE0_1", + ]; + for name in m5_summed.iter().chain(&m3_summed) { + assert!(counts_once(name), "{name} is not summed"); + } + for name in m5_summands { + assert!(!counts_once(name), "{name}, a summand of an aggregate on an M5 Max, is summed beside it"); + } + for name in m3_summands { + assert!(!counts_once(name), "{name}, a summand of an aggregate on an M3 Ultra, is summed beside it"); + } + // The same rule for the aggregate the chips do not list, and for a + // per-cluster accumulator of a kind neither lists. + assert!(counts_once("ANE Energy")); + assert!(!counts_once("MACC_0")); + } } } @@ -937,11 +1411,11 @@ fn busy_power() -> Option { // Apple Silicon power via the IOReport Energy Model C API — energy delta over a // timed window ÷ window = watts, the same shape as the Linux RAPL path. No sudo, // no external binary. Ported from jouleclaw-energy's validated `apple_ioreport`. -// Used only while the CPU energy rail is live: see `probe`. +// Used only while the CPU energy rail is live: see `host_status`. #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] mod macos_ioreport { - use super::liveness::{energy_model_sample_from, probe_with, strip_die_prefix, CpuRail, Sample}; + use super::liveness::{counts_once, read_from, Channel, Read}; use std::os::raw::{c_int, c_long, c_void}; use std::time::{Duration, Instant}; @@ -1036,6 +1510,9 @@ mod macos_ioreport { if !sub.is_null() { CFRelease(sub); } + if !subscribed.is_null() { + CFRelease(subscribed as CFTypeRef); + } return None; } Some((sub, subscribed)) @@ -1045,11 +1522,11 @@ mod macos_ioreport { /// (`IOReportCopyChannelsInGroup`), not every channel. Its CPU rails are the /// same counters, with the same absolute values, an all-channel subscription /// reads, but a sample of the group (364 channels on an M5 Max, 645 on an M3 - /// Ultra) takes ~5 ms, where one of every channel (~11,900 and ~14,300) took - /// 0.1–0.8 s on the M5 Max and ~0.13 s on the M3 Ultra — so the probe really - /// re-samples after every ~25 ms of CPU burn instead of spending its budget - /// waiting on IOReport. Caller must `CFRelease` both (the probe holds them - /// in an `EnergyModelSub`, which does). + /// Ultra) costs ~6–9 ms of thread CPU, where one of every channel (~11,900 + /// and ~14,300) costs ~85–125 ms — so the probe really re-samples after + /// every ~25 ms of CPU burn instead of spending its budget in IOReport. + /// Caller must `CFRelease` both (the probe holds them in an + /// `EnergyModelSub`, which does). unsafe fn open_energy_model() -> Option<(IOReportSubscriptionRef, CFMutableDictionaryRef)> { let group = CFStringCreateWithCString( std::ptr::null_mut(), @@ -1085,15 +1562,6 @@ mod macos_ioreport { Some((sub, subscribed)) } - /// `true` only if an IOReport subscription opens (Apple Silicon, not - /// Rosetta) **and** its CPU energy rail is live — see - /// [`super::ioreport_status`], which probes once per process and caches. A - /// subscription alone is not enough: over a frozen CPU rail the summed total - /// moves only with the GPU, with a batch refresh, or not at all. - pub fn available() -> bool { - super::ioreport_status() == super::IoReportStatus::Live - } - /// Thread CPU time the probe burns before each liveness re-sample. pub(super) const PROBE_TICK: Duration = Duration::from_millis(25); @@ -1101,143 +1569,75 @@ mod macos_ioreport { /// time. Only a thread starved of the CPU for that long is cut short. const TICK_WALL_CAP: Duration = Duration::from_millis(500); - /// The probe's own Energy Model subscription, released on drop. - struct EnergyModelSub { + /// The probe's own Energy Model subscription, released on drop: the real + /// opener and reader `host_status` hands the probe loop. + pub(super) struct EnergyModelSub { sub: IOReportSubscriptionRef, ch: CFMutableDictionaryRef, } impl EnergyModelSub { /// Subscribe to the Energy Model group alone ([`open_energy_model`]). - fn open() -> Option { + /// `None` when there is no such group to subscribe to — or when a test + /// has made this process ([`super::simulate_no_energy_model_group`]) or, + /// in this crate's own tests, this thread (`seam`) behave as if there + /// were none. + pub(super) fn open() -> Option { + if super::NO_ENERGY_MODEL_GROUP.load(std::sync::atomic::Ordering::SeqCst) { + return None; + } + #[cfg(test)] + if let Some(opens) = seam::opens() { + return opens.then_some(Self { sub: std::ptr::null(), ch: std::ptr::null_mut() }); + } // SAFETY: open_energy_model returns owned, non-null refs or None. unsafe { open_energy_model() }.map(|(sub, ch)| Self { sub, ch }) } - /// One sample ([`energy_model_sample`]). - fn sample(&self) -> Option { - // SAFETY: sub and ch are live until drop. - unsafe { energy_model_sample(self.sub, self.ch) } + /// One read: the channels one IOReport sample of this subscription + /// listed ([`energy_model_channels`]), turned by the pure [`read_from`] + /// into the sample the rule is handed and what was listed. `None` if + /// IOReport returned no sample. + pub(super) fn read(&self) -> Option { + self.channels().map(read_from) + } + + /// The raw channels of one sample; in this crate's own tests, a + /// scripted list instead when this thread has one (`seam`). + fn channels(&self) -> Option> { + #[cfg(test)] + if let Some(scripted) = seam::scripted() { + return scripted; + } + if self.sub.is_null() { + return None; + } + // SAFETY: sub and ch are live, non-null refs until drop. + unsafe { energy_model_channels(self.sub, self.ch) } } } impl Drop for EnergyModelSub { fn drop(&mut self) { - // SAFETY: both were created by open_energy_model and are released once. + // SAFETY: both were created by open_energy_model and are released + // once; a test's scripted subscription holds neither. unsafe { - CFRelease(self.sub); - CFRelease(self.ch as CFTypeRef); - } - } - } - - /// One call of the probe's FFI sampler, as the probe timed it. - #[derive(Debug, Clone, Copy)] - pub(super) struct SampleCall { - /// Wall time the call took: `IOReportCreateSamples` plus reading the - /// channels out of it. - pub(super) wall: Duration, - /// This thread's CPU clock just before and just after the call. - pub(super) cpu_before: Option, - pub(super) cpu_after: Option, - /// Whether it returned a sample. - pub(super) ok: bool, - } - - /// What one run of the liveness probe did — its verdict, and a record of - /// every sample call it made — so tests can check how it sampled, not only - /// what it decided. Outside tests only the verdict is read. - #[derive(Debug, Clone, Default)] - #[cfg_attr(not(test), allow(dead_code))] - pub(super) struct ProbeRun { - pub(super) verdict: Option, - pub(super) intervals: u32, - /// Whether the Energy Model subscription opened. - pub(super) opened: bool, - /// Every sample call, in order, the baseline included. - pub(super) calls: Vec, - } - - #[cfg_attr(not(test), allow(dead_code))] - impl ProbeRun { - /// The median wall time of the sample calls (the upper one of an even - /// count), or `None` if there were none. - pub(super) fn median_sample_wall(&self) -> Option { - let mut walls: Vec = self.calls.iter().map(|c| c.wall).collect(); - walls.sort(); - walls.get(walls.len() / 2).copied() - } - - /// This thread's CPU time consumed in each interval between two - /// consecutive successful samples, outside the sample calls themselves: - /// from the end of one successful call to the start of the next. One - /// entry per interval; `None` for an interval where the CPU clock - /// could not be read. - pub(super) fn interval_cpu(&self) -> Vec> { - let mut out = Vec::new(); - let mut prev_end: Option> = None; - for c in self.calls.iter().filter(|c| c.ok) { - if let Some(end) = prev_end { - out.push(match (end, c.cpu_before) { - (Some(end), Some(start)) => start.checked_sub(end), - _ => None, - }); + if !self.sub.is_null() { + CFRelease(self.sub); + } + if !self.ch.is_null() { + CFRelease(self.ch as CFTypeRef); } - prev_end = Some(c.cpu_after); } - out } } - /// Open the probe's own Energy Model subscription, burn CPU on this thread - /// — [`PROBE_TICK`] of its CPU time before each re-sample — and report - /// whether a CPU energy rail **itself** keeps advancing, by the rule in - /// [`super::liveness::RailWatch`]: the same rail must advance in two - /// separate intervals between consecutive samples. No verdict if the Energy - /// Model subscription cannot be opened, or opens but yields no two - /// consecutive samples. - /// - /// The summed total [`power_over`] reads is not a liveness signal. Measured - /// on an M5 Max and an M3 Ultra, both on macOS 27.0.1: every CPU rail in the - /// Energy Model group (`CPU Energy`, `PCPU`, `MCPU*`, `PACC_*`, - /// `DIE_n_CPU Energy`) held its cumulative value across minutes of full CPU - /// load, apart from rare batch refreshes, while on the M5 Max `GPU Energy` - /// kept advancing. A total that moves therefore proves only that something - /// drew on screen. Liveness is decided per CPU rail, on the raw integer - /// between consecutive samples. - /// - /// Sampling runs until a rail proves live, or until `budget` has elapsed - /// and at least two intervals were observed, never past five budgets - /// ([`super::liveness::watch`]). The burn cannot manufacture `Live` — only - /// the rail's own raw integer advancing twice can — so a short burn errs - /// only toward declining, the safe side. - /// - /// The loop is [`super::liveness::probe_with`]; this only supplies the - /// subscription, the IOReport samples, the burn and the clock, and records - /// every sample call. - pub(super) fn probe(budget: Duration) -> ProbeRun { - let mut opened = false; - let mut calls = Vec::new(); - let origin = Instant::now(); - let (verdict, intervals) = probe_with( - || { - let sub = EnergyModelSub::open(); - opened = sub.is_some(); - sub - }, - |sub: &EnergyModelSub| { - let cpu_before = thread_cpu_time(); - let t = Instant::now(); - let s = sub.sample(); - let wall = t.elapsed(); - calls.push(SampleCall { wall, cpu_before, cpu_after: thread_cpu_time(), ok: s.is_some() }); - s - }, - spin_tick, - || origin.elapsed(), - budget, - ); - ProbeRun { verdict, intervals, opened, calls } + /// Wall time since this process first asked: the probe's wall clock. One + /// process-wide epoch, so a test can place its own reads on the same + /// timeline as the probe's. + pub(super) fn wall_clock() -> Duration { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + EPOCH.get_or_init(Instant::now).elapsed() } /// This thread's CPU time, from `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`; @@ -1254,8 +1654,10 @@ mod macos_ioreport { /// time, not wall time, so a loaded host that runs this thread only part /// of the time still gets the full burn — giving up after /// [`TICK_WALL_CAP`] of wall time. Without a readable thread CPU clock it - /// spins [`PROBE_TICK`] of wall time. - fn spin_tick() { + /// spins [`PROBE_TICK`] of wall time. The burn cannot manufacture `Live` + /// — only a CPU rail's own raw integer advancing twice can — so a short + /// burn errs only toward declining, the safe side. + pub(super) fn spin_tick() { burn_thread_cpu(PROBE_TICK, TICK_WALL_CAP); } @@ -1279,13 +1681,12 @@ mod macos_ioreport { } } - /// One sample of `sub` as the liveness rule reads it: every Energy Model - /// channel's name and ABSOLUTE raw integer, in channel order — raw, before - /// unit conversion, so a small advance in a fine unit cannot truncate to - /// zero. `None` if IOReport returns no sample. The FFI only reads each - /// channel's group, name and integer; which channels are kept is - /// [`energy_model_sample_from`]. No decision is made here. - unsafe fn energy_model_sample(sub: IOReportSubscriptionRef, ch: CFMutableDictionaryRef) -> Option { + /// Every channel one sample of `sub` listed, as (group, name, ABSOLUTE raw + /// integer), in channel order — raw, before unit conversion, so a small + /// advance in a fine unit cannot truncate to zero. `None` if IOReport + /// returns no sample. The FFI only reads; which channels count, and how, + /// is pure code ([`read_from`] and the rule). No decision is made here. + unsafe fn energy_model_channels(sub: IOReportSubscriptionRef, ch: CFMutableDictionaryRef) -> Option> { let smp = IOReportCreateSamples(sub, ch, std::ptr::null()); if smp.is_null() { return None; @@ -1314,16 +1715,17 @@ mod macos_ioreport { } } CFRelease(smp); - Some(energy_model_sample_from(channels)) + Some(channels) } /// Average SoC power (watts) over `d`: two Energy-Model samples bracketing a /// sleep, energy delta ÷ window. `None` if IOReport is unavailable, its CPU - /// energy rail is not live ([`available`]), or the signal is empty. + /// energy rail is not live (the selection, [`super::hardware_meter_available`], + /// declines), or the signal is empty. pub fn power_over(d: Duration) -> Option { // Declined before sampling: on a frozen CPU rail the summed delta below // would be GPU energy, or zero, attributed to CPU work. - if !available() { + if !super::hardware_meter_available() { return None; } unsafe { @@ -1362,6 +1764,8 @@ mod macos_ioreport { } } + /// The energy in one delta sample: every Energy Model rail that + /// [`counts_once`] admits, converted from its unit to microjoules. unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> u64 { let key = CFStringCreateWithCString( std::ptr::null_mut(), @@ -1416,21 +1820,6 @@ mod macos_ioreport { uj as u64 } - fn counts_once(name: &str) -> bool { - let base = strip_die_prefix(name); - if base.eq_ignore_ascii_case("CPU Energy") - || base.eq_ignore_ascii_case("GPU Energy") - || base.eq_ignore_ascii_case("ANE Energy") - { - return true; - } - let l = name.to_ascii_lowercase(); - if l.contains("cpu") || l.contains("gpu") || l.contains("ane") { - return false; - } - true - } - unsafe fn cfstring_to_string(s: CFStringRef) -> Option { if s.is_null() { return None; @@ -1451,220 +1840,222 @@ mod macos_ioreport { cfstring_to_string(s).map(|x| x.eq_ignore_ascii_case(target)).unwrap_or(false) } + /// This crate's own tests' stand-ins for the probe's two FFI calls, per + /// thread, so a test can run the REAL production composition + /// (`host_status`) on a group that cannot be opened, or on scripted channel + /// reads, with the real tick and clocks. Compiled into this crate's own + /// tests only; on every thread the default is the real FFI. + #[cfg(test)] + pub(super) mod seam { + use super::Channel; + use std::cell::RefCell; + + /// How the probe's opener and reads behave on this thread. + pub(in super::super) enum Mode { + /// The Energy Model group cannot be opened. + Deny, + /// The group opens (no real subscription), and each read lists the + /// script's next channels; `None` from the script is a failed read. + Script(Box Option>>), + } + + thread_local! { + static MODE: RefCell> = const { RefCell::new(None) }; + } + + /// `None`: the real opener. `Some(false)`: cannot open. `Some(true)`: + /// a scripted subscription opens. + pub(super) fn opens() -> Option { + MODE.with(|m| match &*m.borrow() { + None => None, + Some(Mode::Deny) => Some(false), + Some(Mode::Script(_)) => Some(true), + }) + } + + /// The next scripted read on this thread, or `None` for the real FFI. + pub(super) fn scripted() -> Option>> { + MODE.with(|m| match &mut *m.borrow_mut() { + Some(Mode::Script(next)) => Some(next()), + _ => None, + }) + } + + /// Run `f` with this thread's opener and reads in `mode`, then return + /// to the real FFI, also when `f` panics. + pub(in super::super) fn with(mode: Mode, f: impl FnOnce() -> T) -> T { + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + MODE.with(|m| *m.borrow_mut() = None); + } + } + MODE.with(|m| *m.borrow_mut() = Some(mode)); + let _restore = Restore; + f() + } + } + #[cfg(test)] mod tests { use super::*; + use crate::meter::liveness::tests::expect_keyed; + use crate::meter::liveness::{CpuRail, Listed, ProbeRun, RailKey}; use crate::meter::{ - cached_probe, hardware_meter_available, ioreport_status, liveness::is_cpu_energy_rail, - measure_marginal_uj, IoReportStatus, CPU_RAIL_LIVENESS_BUDGET, STATUS_PROBE_RUNS, + admits, hardware_meter_available, host, host_status, ioreport_status, measure_marginal_uj, status_of, + HostStatus, IoReportStatus, CPU_RAIL_LIVENESS_BUDGET, }; use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc, Barrier}; + use std::sync::Arc; - /// The group-only-subscription pin: the median wall time of the probe's - /// own sample calls stays under this. A sample of the Energy Model group - /// alone takes ~6–12 ms; one of every channel, 130–800 ms. The test's - /// own literal, not an implementation constant. - const MAX_MEDIAN_SAMPLE_WALL: Duration = Duration::from_millis(50); + /// The secondary group-only pin: the median thread CPU time of the + /// probe's reads stays under this. A read of the Energy Model group + /// alone costs ~6–9 ms of thread CPU; one of every channel, ~85–125 ms. + /// Thread CPU, not wall time: another process sampling IOReport makes a + /// read wait, not burn. The test's own literal. + const MAX_MEDIAN_READ_CPU: Duration = Duration::from_millis(50); + + /// The probe's burn, as the tests see it: at least this much of the + /// probing thread's CPU in every interval, and per tick at least + /// [`TICK_CPU`] unless the tick ran into [`TICK_WALL`]. The tests' own + /// literals, not the implementation's constants. + const TICK_CPU: Duration = Duration::from_millis(25); + const TICK_WALL: Duration = Duration::from_millis(500); /// Real, cheap, pure CPU work for the metering tests. fn cpu_work() { std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); } - /// How a probe run sampled: the median wall time of its sample calls, - /// and the least thread CPU time any of its intervals consumed. - struct Shape { - median_sample: Option, - interval_cpu: Vec>, + // ── The tests' own thread CPU clock ────────────────────────────────── + + /// `CLOCK_THREAD_CPUTIME_ID` in macOS's ``: 16. Declared here, + /// apart from the production constant, and read through the tests' own + /// binding, so a wrong production clock cannot hide behind the same + /// wrong value in the tests. + const TEST_THREAD_CPU_CLOCK: c_int = 16; + + #[repr(C)] + struct TestTimespec { + tv_sec: i64, + tv_nsec: c_long, } - impl Shape { - fn of(run: &ProbeRun) -> Self { - Self { median_sample: run.median_sample_wall(), interval_cpu: run.interval_cpu() } - } + #[allow(clashing_extern_declarations)] + unsafe extern "C" { + #[link_name = "clock_gettime"] + fn test_clock_gettime(clock_id: c_int, tp: *mut TestTimespec) -> c_int; + } - fn min_interval_cpu(&self) -> Option { - self.interval_cpu.iter().flatten().min().copied() - } + /// This thread's CPU time, read by the tests themselves. + fn test_thread_cpu() -> Duration { + let mut ts = TestTimespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: ts is a valid, writable timespec. + let rc = unsafe { test_clock_gettime(TEST_THREAD_CPU_CLOCK, &mut ts) }; + assert_eq!(rc, 0, "the tests' own thread CPU clock does not read"); + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) + } - /// The two pins on how the probe sampled, for a run whose Energy - /// Model subscription opened: it sampled the group alone (the median - /// sample call took under 50 ms), and it burned CPU in every interval - /// (at least `PROBE_TICK / 2` of this thread's CPU time). Neither is - /// a wall-clock ratio. The burn is measured in thread CPU time and - /// the tick burns thread CPU time, so host load cannot shorten it. - fn assert_pins(&self, run: &ProbeRun, who: &str) { - if !run.opened { - assert_eq!(run.verdict, None, "{who}: no Energy Model subscription, yet a verdict"); - assert!(run.calls.is_empty(), "{who}: sampled a subscription that did not open"); - return; + /// Burn at least `d` of this thread's CPU, by the tests' own clock. + fn burn(d: Duration) -> Duration { + let start = test_thread_cpu(); + let mut acc: u64 = 0; + loop { + for _ in 0..4096 { + acc = std::hint::black_box(acc.wrapping_add(1)); } - let median = self - .median_sample - .unwrap_or_else(|| panic!("{who}: the Energy Model subscription opened, yet no sample call ran")); - assert!( - median < MAX_MEDIAN_SAMPLE_WALL, - "{who}: the median sample call took {median:?} (>= {MAX_MEDIAN_SAMPLE_WALL:?}) over {} calls: \ - the probe is not sampling the Energy Model group alone (~6-12 ms a sample; every \ - channel: 130-800 ms)", - run.calls.len() - ); - assert_eq!(self.interval_cpu.len(), run.intervals as usize, "{who}: one CPU figure per interval"); - for (i, cpu) in self.interval_cpu.iter().enumerate() { - let cpu = cpu.unwrap_or_else(|| panic!("{who}: interval {i}: this thread's CPU clock could not be read")); - assert!( - cpu >= PROBE_TICK / 2, - "{who}: interval {i} of {} consumed {cpu:?} of the probing thread's CPU time between \ - samples, under PROBE_TICK/2 = {:?}: the probe is not burning CPU before each \ - re-sample. The tick burns thread CPU time, so host load does not shorten it; only \ - its {TICK_WALL_CAP:?} wall-clock cap can, which takes this thread being starved of \ - the CPU for that long", - run.intervals, - PROBE_TICK / 2 - ); + let spent = test_thread_cpu() - start; + if spent >= d { + return spent; } } } - /// The record's arithmetic on its own: the median, and one CPU figure - /// per interval between consecutive SUCCESSFUL calls, outside the calls. + /// F3, clock identity: the production thread CPU clock is THIS thread's + /// CPU time, checked against the tests' own reading of it. It does not + /// move while the thread sleeps (so it is no wall clock), nor while + /// another thread of this process burns CPU and this one sleeps (so it + /// is not process CPU), and it moves with this thread's own burn. #[test] - fn the_probe_record_gives_the_median_call_and_one_cpu_figure_per_interval() { - let ms = Duration::from_millis; - let call = |wall, before, after, ok| SampleCall { - wall: ms(wall), - cpu_before: Some(ms(before)), - cpu_after: Some(ms(after)), - ok, - }; - let run = ProbeRun { - verdict: Some(CpuRail::Frozen), - intervals: 2, - opened: true, - calls: vec![ - call(9, 0, 5, true), - call(300, 31, 32, false), - call(7, 60, 64, true), - call(8, 90, 95, true), - ], - }; - assert_eq!(run.median_sample_wall(), Some(ms(9))); - assert_eq!(run.interval_cpu(), vec![Some(ms(55)), Some(ms(26))]); - assert_eq!(ProbeRun::default().median_sample_wall(), None); - assert!(ProbeRun::default().interval_cpu().is_empty()); - } + fn the_thread_cpu_clock_is_this_threads_cpu_time() { + let prod = || thread_cpu_time().expect("the production thread CPU clock reads"); + let p0 = prod(); + std::thread::sleep(Duration::from_millis(100)); + let slept = prod() - p0; - /// The tick burns thread CPU time: one tick consumes at least - /// `PROBE_TICK` of this thread's CPU clock, however loaded the host. - /// Only the wall-clock cap can cut it short, which needs this thread - /// starved of the CPU for 500 ms. - #[test] - fn one_tick_burns_the_probe_tick_of_thread_cpu_time() { - let before = thread_cpu_time().expect("this thread's CPU clock reads"); - spin_tick(); - let burned = thread_cpu_time().expect("this thread's CPU clock reads") - before; - eprintln!("ioreport tick: burned {burned:?} of thread CPU time"); + let p0 = prod(); + let helper = std::thread::spawn(|| burn(Duration::from_millis(100))); + let helper_burned = helper.join().expect("the burning helper panicked"); + let while_helper_burned = prod() - p0; + + let (t0, p0) = (test_thread_cpu(), prod()); + burn(Duration::from_millis(30)); + let (p1, t1) = (prod(), test_thread_cpu()); + let (own_prod, own_test) = (p1 - p0, t1 - t0); + eprintln!( + "ioreport clock: slept 100ms -> {slept:?}; helper burned {helper_burned:?} -> {while_helper_burned:?}; \ + own burn {own_test:?} (tests' clock) -> {own_prod:?} (production clock)" + ); assert!( - burned >= PROBE_TICK, - "one tick burned {burned:?} of thread CPU time, under PROBE_TICK = {PROBE_TICK:?}; only the \ - {TICK_WALL_CAP:?} wall-clock cap may cut it short" + slept < Duration::from_millis(5), + "the production thread CPU clock advanced {slept:?} while this thread slept 100 ms: it is not this \ + thread's CPU time (a wall clock, or the process's CPU while other threads ran)" + ); + assert!(helper_burned >= Duration::from_millis(100), "the helper burned only {helper_burned:?}"); + assert!( + while_helper_burned < Duration::from_millis(5), + "the production thread CPU clock advanced {while_helper_burned:?} while another thread burned \ + {helper_burned:?} and this one slept: it counts the process's CPU, not this thread's" + ); + assert!( + own_prod >= Duration::from_millis(25) && own_prod <= own_test, + "this thread burned {own_test:?} by the tests' clock; the production clock moved {own_prod:?}" ); } - /// The liveness probe behind `ioreport_status()` runs once per - /// PROCESS, however many threads ask at once and whatever path they - /// take: a probe per call or per thread would burn ~1 s on each caller's - /// thread (inside `bracket()`, a whole probe in the busy window) and - /// could flip the verdict mid-process. Eight threads released together - /// each ask for the status and select the meter the way a caller does - /// (this crate has no backend object: `hardware_meter_available()` is - /// the selection, and `measure_marginal_uj` / `power_over` its users). - /// The probe counter is a process-wide static, so it sees every thread. + /// The probe's wall clock is this process's monotonic clock: across a + /// 50 ms sleep it advances at least 50 ms, and no more than `Instant` + /// does around it. A stopped, scaled or skewed clock would change how + /// long the probe watches, and where the freshness pin places the + /// watcher's reads. (No bound on the host: a sleep never returns early, + /// and the nested reading can never exceed the outer one.) #[test] - fn the_liveness_probe_runs_once_per_process() { - const THREADS: usize = 8; - let gate = Arc::new(Barrier::new(THREADS)); - let handles: Vec<_> = (0..THREADS) - .map(|_| { - let gate = gate.clone(); - std::thread::spawn(move || { - gate.wait(); - let status = ioreport_status(); - let selected = hardware_meter_available(); - let ioreport = available(); - if !selected { - assert_eq!(measure_marginal_uj(cpu_work), None, "a declined meter gave a figure"); - assert_eq!(power_over(Duration::from_millis(10)), None, "a declined meter gave watts"); - } - (status, selected, ioreport) - }) - }) - .collect(); - let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a probing thread panicked")).collect(); - let runs = STATUS_PROBE_RUNS.load(Ordering::SeqCst); - eprintln!("ioreport cache: {THREADS} threads saw {:?}; probe runs in this process: {runs}", seen[0]); - assert!(seen.iter().all(|s| *s == seen[0]), "the threads were given different answers: {seen:?}"); - assert_eq!(seen[0].1, seen[0].0 == IoReportStatus::Live, "selected exactly when Live: {:?}", seen[0]); - assert_eq!(seen[0].1, seen[0].2, "hardware_meter_available() and available() disagree"); - assert_eq!( - runs, 1, - "the liveness probe ran more than once in this process ({THREADS} threads asked at once)" + fn the_probe_wall_clock_is_the_monotonic_clock() { + let i0 = Instant::now(); + let w0 = wall_clock(); + std::thread::sleep(Duration::from_millis(50)); + let w1 = wall_clock(); + let i1 = Instant::now(); + let (w, i) = (w1 - w0, i1 - i0); + assert!( + w >= Duration::from_millis(50) && w <= i, + "across a 50 ms sleep the probe's wall clock advanced {w:?}, Instant {i:?} around it" ); } + /// R1, the tick, on the tests' own clock: each production tick + /// consumes at least 25 ms of this thread's CPU time, unless it ran + /// into its 500 ms wall-clock cap — and never more CPU than wall time. #[test] - fn energy_to_uj_scales_by_unit() { - assert_eq!(energy_to_uj(5, "mJ"), 5_000); - assert_eq!(energy_to_uj(5_000, "nJ"), 5); - } - - #[test] - fn counts_once_drops_subrails() { - assert!(counts_once("CPU Energy")); - assert!(!counts_once("DIE_0_PACC0_CPU0")); - } - - /// `power_over` yields watts only through a live CPU rail, and a - /// plausible figure when it does; a declined IOReport yields `None`. - /// Replaces a version that returned early without IOReport and asserted - /// only inside `if let Some`, so every `None` passed silently. - #[test] - fn power_over_declines_unless_the_cpu_rail_is_live() { - let w = power_over(Duration::from_millis(300)); - if available() { + fn each_tick_burns_the_probe_tick_of_thread_cpu_time() { + for i in 0..5 { + let (w0, c0) = (Instant::now(), test_thread_cpu()); + spin_tick(); + let (c1, w1) = (test_thread_cpu(), Instant::now()); + let (cpu, wall) = (c1 - c0, w1 - w0); + eprintln!("ioreport tick: tick {i} burned {cpu:?} of thread CPU time in {wall:?}"); assert!( - matches!(w, Some(w) if w > 0.0 && w < 500.0), - "a live CPU rail gave implausible SoC watts: {w:?}" - ); - } else { - assert_eq!( - w, - None, - "power_over produced watts although IOReport is declined ({:?})", - ioreport_status() + cpu >= TICK_CPU || wall >= TICK_WALL, + "tick {i} burned {cpu:?} of thread CPU (tests' clock) in {wall:?}: under {TICK_CPU:?}, and it \ + did not run into the {TICK_WALL:?} wall cap" ); + assert!(cpu <= wall, "tick {i}: {cpu:?} of thread CPU in {wall:?} of wall time"); } } - /// What the independent oracle saw on this host. - #[derive(Debug, PartialEq)] - enum Oracle { - NoIoReport, - NoCpuRail, - CpuRailMoved(bool), - } + // ── The oracle: an independent read of every channel ────────────── - /// One oracle run: its verdict, how many reads it compared, and whether - /// any CPU rail it read sits in a group named `Energy Model`. - #[derive(Debug)] - struct OracleRun { - verdict: Oracle, - reads: u32, - energy_model_rail: bool, - } - - /// A channel whose name ends with `CPU Energy`, as the oracle read it. + /// A channel the oracle read: its group, name and ABSOLUTE raw integer. #[derive(Debug, Clone)] struct OracleRail { group: String, @@ -1672,6 +2063,51 @@ mod macos_ioreport { value: i64, } + /// One read of every channel by the oracle. + #[derive(Debug)] + struct OracleRead { + /// How many channels the sample listed, and how many of them are in + /// the Energy Model group. + group: Group, + /// Every channel whose name ends with `CPU Energy`, any group. + cpu: Vec, + /// Every Energy Model channel named `GPU Energy`. + gpu: Vec, + } + + /// How many channels the Energy Model group has, and how many there are + /// in all, as the oracle counted them in one all-channel sample. + #[derive(Debug, Clone, Copy)] + struct Group { + energy_model: usize, + all: usize, + } + + impl OracleRead { + /// The Energy Model CPU rails, keyed by name and ordinal: the + /// oracle's own keying. + fn cpu_keyed(&self) -> Vec<(RailKey, i64)> { + em_keyed(&self.cpu) + } + + fn gpu_keyed(&self) -> Vec<(RailKey, i64)> { + em_keyed(&self.gpu) + } + } + + fn em_keyed(rails: &[OracleRail]) -> Vec<(RailKey, i64)> { + let mut keyed: Vec<(RailKey, i64)> = Vec::new(); + for r in rails.iter().filter(|r| r.group.eq_ignore_ascii_case("Energy Model")) { + let ordinal = keyed.iter().filter(|((n, _), _)| *n == r.name).count() as u32; + keyed.push(((r.name.clone(), ordinal), r.value)); + } + keyed + } + + fn keys(values: &[(RailKey, i64)]) -> Vec { + values.iter().map(|(k, _)| k.clone()).collect() + } + /// The oracle's own subscription, over every channel (not the probe's /// Energy Model one, and not through `open()`), released on drop. struct OracleSub { @@ -1702,9 +2138,10 @@ mod macos_ioreport { } } - /// The group, name and ABSOLUTE cumulative value of every channel - /// whose name ends with `CPU Energy` (any group), in channel order. - fn read(&self) -> Option> { + /// One read of every channel: the channel counts, and the ABSOLUTE + /// cumulative values of the CPU and GPU energy channels, in channel + /// order. + fn read(&self) -> Option { unsafe { let smp = IOReportCreateSamples(self.sub, self.ch, std::ptr::null()); if smp.is_null() { @@ -1715,7 +2152,7 @@ mod macos_ioreport { c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); - let mut rails = Vec::new(); + let mut read = OracleRead { group: Group { energy_model: 0, all: 0 }, cpu: Vec::new(), gpu: Vec::new() }; if !key.is_null() { let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); @@ -1725,19 +2162,26 @@ mod macos_ioreport { if it.is_null() { continue; } + read.group.all += 1; + let group = cfstring_to_string(IOReportChannelGetGroup(it)).unwrap_or_default(); let name = cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); + let em = group.eq_ignore_ascii_case("Energy Model"); + read.group.energy_model += usize::from(em); + let rail = |group: String, name: String| OracleRail { + group, + name, + value: IOReportSimpleGetIntegerValue(it, 0) as i64, + }; if name.ends_with("CPU Energy") { - rails.push(OracleRail { - group: cfstring_to_string(IOReportChannelGetGroup(it)).unwrap_or_default(), - name, - value: IOReportSimpleGetIntegerValue(it, 0) as i64, - }); + read.cpu.push(rail(group, name)); + } else if em && name == "GPU Energy" { + read.gpu.push(rail(group, name)); } } } } CFRelease(smp); - Some(rails) + Some(read) } } } @@ -1751,11 +2195,30 @@ mod macos_ioreport { } } + /// What the independent oracle saw on this host. + #[derive(Debug, PartialEq)] + enum Oracle { + NoIoReport, + NoCpuRail, + CpuRailMoved(bool), + } + + /// One oracle run: its verdict, how many reads it compared, whether any + /// CPU rail it read sits in a group named `Energy Model`, and the + /// channel counts of its first read. + #[derive(Debug)] + struct OracleRun { + verdict: Oracle, + reads: u32, + energy_model_rail: bool, + group: Option, + } + /// An oracle that shares only the raw FFI declarations and the CFString /// helper (`cfstring_to_string`) with the liveness probe. It opens its /// own subscription over every channel ([`OracleSub`]), reads each CPU /// rail's ABSOLUTE cumulative value with its own comparison loop (not - /// `liveness::watch` or `RailWatch`), matches names with + /// `liveness::probe_with` or `RailWatch`), matches names with /// `ends_with("CPU Energy")` in any group rather than the probe's group /// filter and die-prefix stripping, and burns CPU on a helper thread for /// the whole of its window while it reads. @@ -1773,15 +2236,16 @@ mod macos_ioreport { /// baseline. Fewer than two at the end is a test FAILURE, never /// `CpuRailMoved(false)`: one read cannot show a rail changing twice. fn oracle_cpu_rail(budget: Duration) -> OracleRun { - let none = |verdict| OracleRun { verdict, reads: 0, energy_model_rail: false }; + let none = |verdict, group| OracleRun { verdict, reads: 0, energy_model_rail: false, group }; let Some(sub) = OracleSub::open() else { - return none(Oracle::NoIoReport); + return none(Oracle::NoIoReport, None); }; - let Some(v0) = sub.read() else { - return none(Oracle::NoIoReport); + let Some(first) = sub.read() else { + return none(Oracle::NoIoReport, None); }; + let (group, v0) = (first.group, first.cpu); if v0.is_empty() { - return none(Oracle::NoCpuRail); + return none(Oracle::NoCpuRail, Some(group)); } let energy_model_rail = v0.iter().any(|r| r.group.eq_ignore_ascii_case("Energy Model")); // The burn runs on its own thread for the WHOLE window: this @@ -1807,7 +2271,7 @@ mod macos_ioreport { && (started.elapsed() < budget || reads < 2) && started.elapsed() < budget * 3 { - let Some(cur) = sub.read() else { + let Some(cur) = sub.read().map(|r| r.cpu) else { continue; }; if cur.len() != prev.len() { @@ -1830,7 +2294,111 @@ mod macos_ioreport { to at most 3x while short of 2 reads): too few to tell a frozen CPU rail from a live one, so \ this run has no evidence either way" ); - OracleRun { verdict: Oracle::CpuRailMoved(moved), reads, energy_model_rail } + OracleRun { verdict: Oracle::CpuRailMoved(moved), reads, energy_model_rail, group: Some(group) } + } + + // ── Pins on how a real probe run sampled ───────────────────────────── + + /// F1 and R2, on one real probe run whose subscription opened (and, for + /// one that did not: no verdict, no read, no tick). F1: every sample it + /// read listed the Energy Model group alone — every channel in that + /// group, as many as the group has, far fewer than every channel — and + /// the median thread CPU of its reads stays under 50 ms. R2: every + /// interval burned at least `PROBE_TICK / 2` of the probing thread's + /// CPU between reads. No wall-clock bound or ratio: another process + /// sampling IOReport makes this probe wait, never changes what it + /// listed or what it burned. Returns the structural line to log. + fn assert_sampling(run: &ProbeRun, who: &str, group: &Group) -> String { + if !run.opened { + assert_eq!(run.verdict, None, "{who}: no Energy Model subscription, yet a verdict"); + assert!(run.calls.is_empty(), "{who}: read a subscription that did not open"); + assert_eq!(run.ticks, 0, "{who}: ticked on a subscription that did not open"); + return format!("ioreport structure: who={who} opened=false reads=0 ticks=0"); + } + assert!(!run.calls.is_empty(), "{who}: the subscription opened, yet no read ran"); + assert_eq!(run.ticks as usize, run.calls.len() - 1, "{who}: not one tick before every re-read"); + let listed: Vec = run.calls.iter().filter_map(|c| c.listed).collect(); + assert!(!listed.is_empty(), "{who}: none of its {} reads returned a sample", run.calls.len()); + for (i, l) in listed.iter().enumerate() { + assert!( + l.group_only, + "{who}: sample {i} listed {} channels, some outside the Energy Model group: the probe's \ + subscription is not the group alone", + l.channels + ); + assert_eq!( + l.channels, group.energy_model, + "{who}: sample {i} listed {} channels; the Energy Model group has {} (every channel: {})", + l.channels, group.energy_model, group.all + ); + assert!(l.channels < group.all, "{who}: sample {i} listed {} channels of {}", l.channels, group.all); + } + let median = run + .median_read_cpu() + .unwrap_or_else(|| panic!("{who}: the thread CPU clock could not be read around any read")); + assert!( + median < MAX_MEDIAN_READ_CPU, + "{who}: the median read cost {median:?} of thread CPU (>= {MAX_MEDIAN_READ_CPU:?}) over {} reads: \ + the probe is not sampling the Energy Model group alone (~6-9 ms a read; every channel: ~85-125 ms)", + run.calls.len() + ); + let cpu = run.interval_cpu(); + assert_eq!(cpu.len(), run.intervals as usize, "{who}: one CPU figure per interval"); + for (i, c) in cpu.iter().enumerate() { + let c = c.unwrap_or_else(|| panic!("{who}: interval {i}: this thread's CPU clock could not be read")); + assert!( + c >= TICK_CPU / 2, + "{who}: interval {i} of {} consumed {c:?} of the probing thread's CPU time between reads, under \ + {:?}: the probe is not burning CPU before each re-read. The tick burns thread CPU time, so host \ + load does not shorten it; only its {TICK_WALL:?} wall-clock cap can, which takes this thread \ + being starved of the CPU for that long", + run.intervals, + TICK_CPU / 2 + ); + } + format!( + "ioreport structure: who={who} reads={} sampled={} group_only=true channels={} energy_model_group={} \ + all_channels={} median_read_cpu={median:?} median_read_wall={:?} min_interval_cpu={:?} ticks={}", + run.calls.len(), + listed.len(), + listed[0].channels, + group.energy_model, + group.all, + run.median_read_wall(), + cpu.iter().flatten().min(), + run.ticks + ) + } + + /// The tests' own reading of a probe record, with their own loop (not + /// `RailWatch`): the intervals between consecutive recorded samples, + /// and whether one CPU rail rose in two of them. The run's verdict must + /// agree with it. + fn assert_verdict_matches_record(run: &ProbeRun, who: &str) { + assert_eq!(run.seen.len(), run.calls.iter().filter(|c| c.listed.is_some()).count(), "{who}: one record per sample"); + let mut rises: Vec<(RailKey, u32)> = Vec::new(); + for pair in run.seen.windows(2) { + for (key, now) in &pair[1].cpu { + let rose = pair[0].cpu.iter().any(|(k, before)| k == key && now > before); + match rises.iter_mut().find(|(k, _)| k == key) { + Some((_, n)) => *n += u32::from(rose), + None => rises.push((key.clone(), u32::from(rose))), + } + } + } + let intervals = run.seen.len().saturating_sub(1); + let live = rises.iter().any(|(_, n)| *n >= 2); + assert_eq!(run.intervals as usize, intervals, "{who}: intervals, against its own record"); + if intervals > 0 { + assert_eq!( + run.verdict == Some(CpuRail::Live), + live, + "{who}: verdict {:?}, yet by its own record the CPU rails rose {rises:?}", + run.verdict + ); + } else { + assert_eq!(run.verdict, None, "{who}: a verdict with no interval"); + } } /// The property that matters: the IOReport meter is **never** available @@ -1842,30 +2410,36 @@ mod macos_ioreport { /// The oracle gets three times the probe's budget, so a rail too slow for /// the probe but moving within the oracle's window is the only case that /// may be declined while live — and that fails safe: no hardware figure, - /// never an over-claimed one. The cached probe that made the decision - /// must also have sampled the Energy Model group alone and burned CPU in - /// every interval ([`Shape::assert_pins`]). + /// never an over-claimed one. The cached run that made the decision must + /// also have sampled the Energy Model group alone and burned CPU in + /// every interval ([`assert_sampling`]), its status must be the status + /// of that run, and its verdict must agree with its own record. #[test] fn the_ioreport_meter_is_never_available_on_a_frozen_cpu_rail() { let status = ioreport_status(); - let (_, run) = cached_probe(); + let cached = host(); + let run = &cached.run; let selected = hardware_meter_available(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); - let shape = Shape::of(run); // Recorded so a log of a macOS run (manual, or a CI job that runs // this crate's lib tests with --nocapture) says which branch below // was actually asserted, and what the probe's sampling looked like. eprintln!( "ioreport liveness: status={status:?} oracle={:?} selected={selected} probe_intervals={} \ - opened={} median_sample={:?} min_interval_cpu={:?} oracle_reads={}", + opened={} median_read_cpu={:?} min_interval_cpu={:?} oracle_reads={}", oracle.verdict, run.intervals, run.opened, - shape.median_sample, - shape.min_interval_cpu(), + run.median_read_cpu(), + run.interval_cpu().iter().flatten().min(), oracle.reads ); - shape.assert_pins(run, "the cached probe"); + assert_eq!(status, cached.status, "ioreport_status() is not the cached status"); + assert_eq!(cached.status, status_of(run), "the cached status is not the status of the cached run"); + assert_eq!(selected, admits(status), "the selection is not admits(the cached status)"); + let group = oracle.group.unwrap_or(Group { energy_model: 0, all: 0 }); + eprintln!("{}", assert_sampling(run, "the cached probe", &group)); + assert_verdict_matches_record(run, "the cached probe"); if oracle.energy_model_rail { assert!(run.opened, "the oracle read a CPU rail in the Energy Model group; the probe could not subscribe to it"); } @@ -1898,9 +2472,18 @@ mod macos_ioreport { assert_eq!(measure_marginal_uj(cpu_work), None, "energy metered from a frozen CPU rail"); } Oracle::CpuRailMoved(true) => { - // A live rail. The probe had a third of the oracle's time: if it - // saw the move the meter is selected, and if it did not, declining - // is the safe side. Either way it is the invariant below. + // A live rail. The probe had a third of the oracle's time: + // selected if its own record shows a CPU rail rising in two + // intervals (checked above), declined — the safe side — if + // not. It sampled a rail either way. + if oracle.energy_model_rail { + assert!( + matches!(status, IoReportStatus::Live | IoReportStatus::CpuRailFrozen), + "the oracle saw an Energy Model CPU rail keep moving; the probe said {status:?}" + ); + } else { + assert_ne!(status, IoReportStatus::Live, "Live with no CPU rail in the Energy Model group"); + } } } // On macOS IOReport is the only interface: selected exactly when Live. @@ -1912,33 +2495,37 @@ mod macos_ioreport { } /// The dangerous direction on its own: the probe never reports a live rail - /// the oracle cannot see keep moving. The probe runs fresh here (not the - /// cached status) and the oracle runs unconditionally, so every outcome - /// is checked against it, not only Live. The fresh probe must also have - /// sampled the Energy Model group alone and burned CPU in every interval - /// ([`Shape::assert_pins`]). Logs how long the probe took, how many - /// intervals it saw, and its median sample cost. + /// the oracle cannot see keep moving. The REAL production composition + /// runs fresh here (`host_status()`, uncached), and the oracle runs + /// unconditionally, so every outcome is checked against it, not only + /// Live. The fresh run must also pass [`assert_sampling`] and agree with + /// its own record. Logs how long the probe took, how many intervals it + /// saw, and its median read cost. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { let t = Instant::now(); - let run = super::probe(CPU_RAIL_LIVENESS_BUDGET); + let fresh = host_status(); let probe_took = t.elapsed(); + let run = &fresh.run; let status = ioreport_status(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); - let shape = Shape::of(&run); eprintln!( - "ioreport probe: probe={:?} status={status:?} oracle={:?} probe_took={probe_took:?} \ - intervals={} opened={} calls={} median_sample={:?} min_interval_cpu={:?} oracle_reads={}", + "ioreport probe: probe={:?} fresh_status={:?} status={status:?} oracle={:?} probe_took={probe_took:?} \ + intervals={} opened={} reads={} median_read_cpu={:?} min_interval_cpu={:?} oracle_reads={}", run.verdict, + fresh.status, oracle.verdict, run.intervals, run.opened, run.calls.len(), - shape.median_sample, - shape.min_interval_cpu(), + run.median_read_cpu(), + run.interval_cpu().iter().flatten().min(), oracle.reads ); - shape.assert_pins(&run, "the fresh probe"); + assert_eq!(fresh.status, status_of(run), "host_status()'s status is not the status of its run"); + let group = oracle.group.unwrap_or(Group { energy_model: 0, all: 0 }); + eprintln!("{}", assert_sampling(run, "the fresh probe", &group)); + assert_verdict_matches_record(run, "the fresh probe"); if oracle.energy_model_rail { assert!(run.opened, "the oracle read a CPU rail in the Energy Model group; the probe could not subscribe to it"); } @@ -1969,15 +2556,16 @@ mod macos_ioreport { } } - /// The FFI value pin: the probe's sampler reads each CPU rail's raw + /// The FFI value pin: the probe's reader reads each CPU rail's raw /// integer exactly as the oracle's independent absolute read of the - /// same rail does. Each round brackets one probe sample between two - /// oracle reads. Where the oracle saw the rail hold across that bracket - /// (a frozen rail is constant), the probe's value must EQUAL it; where - /// it moved, the probe's value must lie between the two reads. This - /// catches a sampler that returns a constant, sums rails, or reads the - /// wrong rail. Rails are matched by group, name and ordinal among the - /// same-named channels. + /// same rail does, and lists the Energy Model group alone. Each round + /// brackets one read between two oracle reads. The CPU and GPU keys the + /// read hands the rule must be the oracle's Energy Model keys. Where the + /// oracle saw a rail hold across the bracket, the read must EQUAL it; + /// where it moved (the GPU rail usually does), the read must lie + /// between the two oracle reads — so a constant, a stale first read, a + /// sum of rails, a dropped rail or the wrong rail fails here, wherever + /// the rail it touches is distinguishable on this host. #[test] fn the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does() { let Some(oracle) = OracleSub::open() else { @@ -1985,16 +2573,8 @@ mod macos_ioreport { assert_ne!(ioreport_status(), IoReportStatus::Live, "Live with no IOReport"); return; }; - let energy_model = |rails: Vec| -> Vec<((String, u32), i64)> { - let mut keyed: Vec<((String, u32), i64)> = Vec::new(); - for r in rails.into_iter().filter(|r| r.group.eq_ignore_ascii_case("Energy Model")) { - let ordinal = keyed.iter().filter(|((n, _), _)| *n == r.name).count() as u32; - keyed.push(((r.name, ordinal), r.value)); - } - keyed - }; - let first = energy_model(oracle.read().expect("the oracle's subscription yields a sample")); - if first.is_empty() { + let first = oracle.read().expect("the oracle's subscription yields a sample"); + if first.cpu_keyed().is_empty() { eprintln!("ioreport ffi pin: no CPU Energy channel in the Energy Model group; nothing to compare"); assert_ne!(ioreport_status(), IoReportStatus::Live, "Live with no Energy Model CPU rail"); return; @@ -2002,45 +2582,510 @@ mod macos_ioreport { let probe = EnergyModelSub::open() .expect("the oracle reads a CPU rail in the Energy Model group; the probe cannot subscribe to it"); for round in 1..=3 { - let before = energy_model(oracle.read().expect("oracle read before the probe sample")); - let sample = probe.sample().expect("the probe's subscription yields a sample"); - let after = energy_model(oracle.read().expect("oracle read after the probe sample")); - let mut rails: Vec<((String, u32), i64)> = Vec::new(); - for (name, raw) in sample.into_iter().filter(|(n, _)| is_cpu_energy_rail(n)) { - let ordinal = rails.iter().filter(|((n, _), _)| *n == name).count() as u32; - rails.push(((name, ordinal), raw)); - } - assert!(!rails.is_empty(), "round {round}: the probe's sample has no CPU rail; the oracle reads {first:?}"); - let find = |reads: &[((String, u32), i64)], key: &(String, u32)| { - reads.iter().find(|(k, _)| k == key).map(|(_, v)| *v) - }; - for (key, probed) in &rails { - let (Some(a), Some(b)) = (find(&before, key), find(&after, key)) else { - panic!("round {round}: the probe reports CPU rail {key:?}, the oracle does not read it: {before:?}"); - }; - let held = a == b; - eprintln!( - "ioreport ffi pin: round={round} rail={}#{} oracle_before={a} probe={probed} \ - oracle_after={b} held={held}", - key.0, key.1 - ); - if held { - assert_eq!( - *probed, a, - "round {round}: rail {key:?} held at {a} across the oracle's reads, yet the probe's \ - sampler read {probed}" - ); - } else { - assert!( - a.min(b) <= *probed && *probed <= a.max(b), - "round {round}: rail {key:?} moved {a} -> {b} across the oracle's reads, yet the \ - probe's sampler read {probed}, outside that range" + let before = oracle.read().expect("oracle read before the probe's read"); + let read = probe.read().expect("the probe's subscription yields a sample"); + let after = oracle.read().expect("oracle read after the probe's read"); + assert!(read.listed.group_only, "round {round}: the probe's read listed channels outside the group"); + assert_eq!( + read.listed.channels, before.group.energy_model, + "round {round}: the probe's read listed {} channels; the Energy Model group has {}", + read.listed.channels, before.group.energy_model + ); + let cpu = expect_keyed(&read.sample.iter().map(|(n, v)| (n.as_str(), *v)).collect::>(), |n| { + n.ends_with("CPU Energy") + }); + let gpu = expect_keyed(&read.sample.iter().map(|(n, v)| (n.as_str(), *v)).collect::>(), |n| { + n == "GPU Energy" + }); + assert_eq!(keys(&cpu), keys(&before.cpu_keyed()), "round {round}: the CPU rails read, against the oracle's"); + assert_eq!(keys(&gpu), keys(&before.gpu_keyed()), "round {round}: the GPU rails read, against the oracle's"); + for (what, probed, (b, a)) in + [("cpu", &cpu, (before.cpu_keyed(), after.cpu_keyed())), ("gpu", &gpu, (before.gpu_keyed(), after.gpu_keyed()))] + { + for ((key, value), ((_, lo), (_, hi))) in probed.iter().zip(b.iter().zip(a.iter())) { + let held = lo == hi; + eprintln!( + "ioreport ffi pin: round={round} {what}={}#{} oracle_before={lo} probe={value} \ + oracle_after={hi} held={held}", + key.0, key.1 ); + if held { + assert_eq!( + value, lo, + "round {round}: {key:?} held at {lo} across the oracle's reads, yet the probe read {value}" + ); + } else { + assert!( + lo.min(hi) <= value && value <= lo.max(hi), + "round {round}: {key:?} moved {lo} -> {hi} across the oracle's reads, yet the probe \ + read {value}, outside that range" + ); + } } } } } + /// The raw reader reads the subscription it is handed, every time. + /// Handed the probe's own Energy Model subscription and an all-channel + /// one (`open()`) in turn, it lists the group's channels, then every + /// channel, then the group's again — as many as the oracle counts in + /// each. A reader that hands back an earlier read (stale for the + /// process) fails here whether or not any channel moves, which the GPU + /// freshness pin cannot promise on a host whose GPU is idle. + #[test] + fn the_raw_reader_reads_the_subscription_it_is_given_every_time() { + let Some(oracle) = OracleSub::open() else { + eprintln!("ioreport raw reader: IOReport does not open on this host; nothing to compare"); + assert_ne!(ioreport_status(), IoReportStatus::Live, "Live with no IOReport"); + return; + }; + let group = oracle.read().expect("the oracle's subscription yields a sample").group; + let probe = EnergyModelSub::open().expect("the oracle reads IOReport; the probe's group subscription does not open"); + // SAFETY: open returns owned, non-null refs, released below. + let (sub, ch) = unsafe { open() }.expect("the probe's group opens, a subscription to every channel does not"); + let mut listed = Vec::new(); + for _ in 0..3 { + // SAFETY: both subscriptions are live until released below. + let g = unsafe { energy_model_channels(probe.sub, probe.ch) }.expect("the group subscription yields a sample"); + let a = unsafe { energy_model_channels(sub, ch) }.expect("the all-channel subscription yields a sample"); + listed.push((g.len(), a.len())); + } + // SAFETY: released once, here. + unsafe { + CFRelease(sub); + CFRelease(ch as CFTypeRef); + } + eprintln!( + "ioreport raw reader: (group, every channel) listed {listed:?}; the oracle counts ({}, {})", + group.energy_model, group.all + ); + for (i, (g, a)) in listed.iter().enumerate() { + assert_eq!( + (*g, *a), + (group.energy_model, group.all), + "read {i}: the raw reader listed {g} channels for the group and {a} for every channel; the oracle \ + counts {} and {}: it did not read the subscription it was handed", + group.energy_model, + group.all + ); + } + } + + /// A GPU Energy watcher for the freshness pin: a subscription of its + /// own to the Energy Model group, opened here with the raw FFI (not the + /// probe's code), read back to back on a thread of its own. Each read is + /// placed on the probe's timeline (`wall_clock`), so a change it saw can + /// be placed strictly between two of the probe's reads. + struct GpuWatch { + stop: Arc, + handle: std::thread::JoinHandle>, + } + + /// One read by the watcher: when it began and returned, on the probe's + /// clock, and the `GPU Energy` values, in channel order. + #[derive(Debug, Clone)] + struct GpuRead { + start: Duration, + end: Duration, + gpu: Vec, + } + + impl GpuWatch { + fn start() -> Self { + let stop = Arc::new(AtomicBool::new(false)); + let handle = { + let stop = stop.clone(); + std::thread::spawn(move || unsafe { + let group = CFStringCreateWithCString( + std::ptr::null_mut(), + c"Energy Model".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + assert!(!group.is_null(), "the watcher's group name"); + let desired = IOReportCopyChannelsInGroup(group, std::ptr::null(), 0, 0, 0); + CFRelease(group); + assert!(!desired.is_null(), "the watcher cannot list the Energy Model group"); + let mut ch: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription(std::ptr::null(), desired, &mut ch as *mut _, 0, std::ptr::null()); + CFRelease(desired as CFTypeRef); + assert!(!sub.is_null() && !ch.is_null(), "the watcher cannot subscribe to the Energy Model group"); + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + let mut reads = Vec::new(); + while !stop.load(Ordering::SeqCst) { + let start = wall_clock(); + let smp = IOReportCreateSamples(sub, ch, std::ptr::null()); + let end = wall_clock(); + if smp.is_null() { + continue; + } + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + let mut gpu = Vec::new(); + if !arr.is_null() { + for i in 0..CFArrayGetCount(arr) { + let it = CFArrayGetValueAtIndex(arr, i); + if !it.is_null() + && cfstring_to_string(IOReportChannelGetChannelName(it)).as_deref() == Some("GPU Energy") + { + gpu.push(IOReportSimpleGetIntegerValue(it, 0) as i64); + } + } + } + CFRelease(smp); + reads.push(GpuRead { start, end, gpu }); + } + CFRelease(key); + CFRelease(sub); + CFRelease(ch as CFTypeRef); + reads + }) + }; + Self { stop, handle } + } + + fn stop(self) -> Vec { + self.stop.store(true, Ordering::SeqCst); + self.handle.join().expect("the GPU watcher panicked") + } + } + + /// F4, what the REAL production composition hands the rule. The very + /// function the cache runs, `host_status()`, runs fresh (uncached) + /// between two all-channel oracle reads, while a GPU watcher reads the + /// group on its own thread. For every sample the run handed the rule: + /// its CPU and GPU keys are the oracle's Energy Model keys; a rail the + /// oracle saw hold was handed exactly the oracle's value, and a rail + /// that moved a value within the oracle's two reads. Freshness: for + /// every rise of GPU Energy the watcher saw strictly between two of the + /// probe's reads, the GPU values handed to the rule for those two reads + /// must differ — a sampler that returns a first read forever (per probe + /// or per process) fails here. Asserted whenever the watcher saw such a + /// rise — on an M5 Max usually every ~28 ms, but an idle GPU holds, and + /// on an M3 Ultra it moves only sporadically — and the log line says how + /// many were checked, so a run whose GPU held says so. A reader that is + /// stale for the whole process is also caught without any moving + /// channel ([`the_raw_reader_reads_the_subscription_it_is_given_every_time`]). + /// The run must also pass [`assert_sampling`]. + #[test] + fn the_production_probe_hands_the_rule_what_the_oracle_reads() { + let Some(oracle) = OracleSub::open() else { + eprintln!("ioreport freshness: IOReport does not open on this host; nothing to compare"); + assert_ne!(ioreport_status(), IoReportStatus::Live, "Live with no IOReport"); + return; + }; + let before = oracle.read().expect("the oracle's read before the probe"); + let watch = GpuWatch::start(); + let fresh = host_status(); + let watched = watch.stop(); + let after = oracle.read().expect("the oracle's read after the probe"); + let run = &fresh.run; + assert_eq!(fresh.status, status_of(run), "host_status()'s status is not the status of its run"); + eprintln!("{}", assert_sampling(run, "host_status()", &before.group)); + assert_verdict_matches_record(run, "host_status()"); + if before.cpu_keyed().is_empty() { + assert_ne!(fresh.status, IoReportStatus::Live, "Live with no Energy Model CPU rail"); + } + let (cpu_keys, gpu_keys) = (keys(&before.cpu_keyed()), keys(&before.gpu_keyed())); + for (k, s) in run.seen.iter().enumerate() { + assert_eq!(keys(&s.cpu), cpu_keys, "sample {k}: the CPU rails handed to the rule, against the oracle's"); + assert_eq!(keys(&s.gpu), gpu_keys, "sample {k}: the GPU rails handed to the rule, against the oracle's"); + } + for (what, bracket) in [ + ("cpu", before.cpu_keyed().into_iter().zip(after.cpu_keyed()).collect::>()), + ("gpu", before.gpu_keyed().into_iter().zip(after.gpu_keyed()).collect::>()), + ] { + for ((key, lo), (_, hi)) in &bracket { + let handed: Vec = run + .seen + .iter() + .map(|s| { + let rails = if what == "cpu" { &s.cpu } else { &s.gpu }; + rails.iter().find(|(k, _)| k == key).map(|(_, v)| *v).unwrap_or(i64::MIN) + }) + .collect(); + let (min, max) = (handed.iter().min(), handed.iter().max()); + eprintln!( + "ioreport freshness: {what}={}#{} oracle_before={lo} oracle_after={hi} handed={} values in \ + [{min:?}, {max:?}]", + key.0, + key.1, + handed.len() + ); + for (k, v) in handed.iter().enumerate() { + if lo == hi { + assert_eq!(v, lo, "sample {k}: {key:?} held at {lo} across the probe, yet it handed the rule {v}"); + } else { + assert!( + lo.min(hi) <= v && v <= lo.max(hi), + "sample {k}: {key:?} moved {lo} -> {hi} across the probe, yet it handed the rule {v}" + ); + } + } + } + } + // Freshness, on the watcher's rises of GPU Energy. + let sampled: Vec<(Duration, Duration, Vec)> = run + .calls + .iter() + .filter(|c| c.listed.is_some()) + .zip(&run.seen) + .map(|(c, s)| (c.start, c.end, s.gpu.iter().map(|(_, v)| *v).collect())) + .collect(); + let (mut rises, mut checked) = (0usize, 0usize); + for pair in watched.windows(2) { + let (x, y) = (&pair[0], &pair[1]); + let rose = x.gpu.len() == y.gpu.len() + && x.gpu.iter().zip(&y.gpu).all(|(a, b)| b >= a) + && x.gpu != y.gpu; + if !rose { + continue; + } + rises += 1; + // The last probe read that returned before the watcher's first + // read began, and the first that began after its second returned. + let i = sampled.iter().rposition(|(_, end, _)| *end <= x.start); + let j = sampled.iter().position(|(start, _, _)| *start >= y.end); + let (Some(i), Some(j)) = (i, j) else { + continue; + }; + checked += 1; + assert_ne!( + sampled[i].2, sampled[j].2, + "GPU Energy rose {:?} -> {:?} between the probe's reads {i} and {j} (the watcher read it at \ + {:?} and {:?}), yet both handed the rule the same GPU value: the probe's reads are stale", + x.gpu, y.gpu, x.start, y.end + ); + } + eprintln!( + "ioreport freshness: watcher_reads={} gpu_rises={rises} checked_between_probe_reads={checked} \ + probe_samples={}{}", + watched.len(), + sampled.len(), + if checked == 0 { " (not asserted: the watcher saw GPU Energy hold across the probe's reads)" } else { "" } + ); + } + + // ── The production composition on scripted reads ────────────────── + + /// Channels as the probe's own subscription lists them: all in the + /// Energy Model group. + fn em_channels(pairs: &[(&str, i64)]) -> Vec { + pairs.iter().map(|(n, v)| (Some("Energy Model".to_string()), Some(n.to_string()), *v)).collect() + } + + /// The REAL production composition, `host_status()`, run on this thread + /// with every channel read scripted — read `n` lists `script(n)` — and + /// the real tick and clocks. Anything between the raw channel read and + /// the rule runs as in production. Checks, on the tests' own thread CPU + /// clock, that the run's real ticks burned this thread's CPU: at least + /// `TICK_CPU / 2` per tick over the whole call, the bound + /// [`assert_sampling`] puts on each interval through the production + /// clock. + fn host_status_scripted(script: fn(u32) -> Vec<(&'static str, i64)>) -> HostStatus { + let mut n = 0u32; + let next = move || { + let channels = em_channels(&script(n)); + n += 1; + Some(channels) + }; + let c0 = test_thread_cpu(); + let hs = seam::with(seam::Mode::Script(Box::new(next)), host_status); + let burned = test_thread_cpu() - c0; + assert!(hs.run.ticks > 0, "a scripted run that never ticked: {:?}", hs.run); + assert!( + burned >= TICK_CPU / 2 * hs.run.ticks, + "host_status() ticked {} times, yet this thread burned only {burned:?} of CPU (tests' clock) in the \ + whole call: under {:?} a tick", + hs.run.ticks, + TICK_CPU / 2 + ); + hs + } + + /// Every sample the scripted run handed the rule is the script's, as + /// the tests key it: no rail summed, zeroed, dropped, stale or shifted. + fn assert_handed_the_script(run: &ProbeRun, script: fn(u32) -> Vec<(&'static str, i64)>) { + assert_eq!(run.seen.len(), run.calls.len(), "every scripted read succeeds"); + for (n, seen) in run.seen.iter().enumerate() { + let s = script(n as u32); + assert_eq!(seen.cpu, expect_keyed(&s, |c| c.ends_with("CPU Energy")), "read {n}: the CPU rails handed to the rule"); + assert_eq!(seen.gpu, expect_keyed(&s, |c| c == "GPU Energy"), "read {n}: the GPU rail handed to the rule"); + } + } + + fn scripted_group(script: fn(u32) -> Vec<(&'static str, i64)>) -> Group { + Group { energy_model: script(0).len(), all: usize::MAX } + } + + /// A single batch jump of the M5 Max's `CPU Energy`, beside a GPU rail + /// that moves every read. + fn m5_refresh(n: u32) -> Vec<(&'static str, i64)> { + let cpu = if n < 3 { 480_083_160 } else { 541_893_638 }; + vec![("MCPU0", 7), ("CPU Energy", cpu), ("PCPU", 9), ("GPU Energy", 851_622_003 + 28_000_000 * i64::from(n))] + } + + /// A CPU rail that holds at one value, as both Macs' rails do between + /// refreshes, beside a GPU rail that moves every read. + fn constant(n: u32) -> Vec<(&'static str, i64)> { + vec![("CPU Energy", 2_588_973_125), ("GPU Energy", 126_266_724_972_887 + i64::from(n))] + } + + /// One CPU rail advancing at every read. + fn advancing(n: u32) -> Vec<(&'static str, i64)> { + vec![("CPU Energy", 1_000 + i64::from(n)), ("GPU Energy", 5)] + } + + /// An M3 Ultra's two dies, each stepping once in a different interval, + /// among a per-die sub-rail listed once per die and a moving GPU rail. + fn two_dies(n: u32) -> Vec<(&'static str, i64)> { + vec![ + ("ECPUDTL00", 5_000), + ("DIE_0_CPU Energy", 9_494_336 + if n < 3 { 0 } else { 1_344_869 }), + ("ECPUDTL00", 7_000), + ("DIE_1_CPU Energy", 10_212_656 + if n < 9 { 0 } else { 1_391_637 }), + ("GPU Energy", 14_809_813 + i64::from(n)), + ] + } + + /// `host_status()` on an Energy Model group that cannot be opened: no + /// read, no tick, no verdict, `Unavailable` — never `Live`, never + /// `CpuRailAbsent` — and the meter is not admitted. + #[test] + fn host_status_on_an_unopenable_group_is_unavailable_and_never_reads_or_ticks() { + let hs = seam::with(seam::Mode::Deny, host_status); + eprintln!("ioreport scripted: unopenable -> status={:?} run={:?}", hs.status, hs.run); + assert!(!hs.run.opened, "an unopenable group opened"); + assert!(hs.run.calls.is_empty(), "read a subscription that did not open"); + assert_eq!(hs.run.ticks, 0, "ticked on a subscription that did not open"); + assert_eq!(hs.run.verdict, None, "a verdict with no subscription"); + assert_eq!(hs.status, IoReportStatus::Unavailable, "an unopenable Energy Model group"); + assert!(!admits(hs.status), "the meter admitted with no subscription"); + } + + /// `host_status()` on a single batch jump of the CPU rail: Frozen, + /// declined, the real tick burned in every interval, and the rule was + /// handed exactly what was read. + #[test] + fn host_status_calls_a_single_batch_jump_frozen() { + let hs = host_status_scripted(m5_refresh); + let line = assert_sampling(&hs.run, "scripted single jump", &scripted_group(m5_refresh)); + eprintln!("ioreport scripted: single jump -> status={:?}; {line}", hs.status); + assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "one refresh counted as a live rail"); + assert_eq!(hs.status, IoReportStatus::CpuRailFrozen); + assert!(!admits(hs.status), "a batch refresh admitted the meter"); + assert_handed_the_script(&hs.run, m5_refresh); + } + + /// `host_status()` on a CPU rail that never moves: Frozen for the whole + /// budget, declined, the real tick burned in every interval, and the + /// rule was handed exactly what was read. + #[test] + fn host_status_calls_a_constant_rail_frozen() { + let hs = host_status_scripted(constant); + let line = assert_sampling(&hs.run, "scripted constant", &scripted_group(constant)); + eprintln!("ioreport scripted: constant -> status={:?}; {line}", hs.status); + assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "a rail that never moved"); + assert_eq!(hs.status, IoReportStatus::CpuRailFrozen); + assert!(!admits(hs.status), "a constant rail admitted the meter"); + assert!(hs.run.intervals >= 2, "frozen on {} interval(s)", hs.run.intervals); + assert_handed_the_script(&hs.run, constant); + } + + /// `host_status()` on a CPU rail that advances at every read: Live after + /// exactly two intervals, and admitted. + #[test] + fn host_status_calls_two_advances_of_one_rail_live() { + let hs = host_status_scripted(advancing); + eprintln!("ioreport scripted: two advances -> status={:?} reads={} ticks={}", hs.status, hs.run.calls.len(), hs.run.ticks); + assert_eq!(hs.run.verdict, Some(CpuRail::Live), "a rail advancing at every read"); + assert_eq!((hs.run.intervals, hs.run.calls.len(), hs.run.ticks), (2, 3, 2), "Live after two intervals, at once"); + assert_eq!(hs.status, IoReportStatus::Live); + assert!(admits(hs.status), "a live rail not admitted"); + assert_handed_the_script(&hs.run, advancing); + let cpu = hs.run.interval_cpu(); + assert!(cpu.iter().all(|c| c.is_some_and(|c| c >= TICK_CPU / 2)), "the real tick did not burn: {cpu:?}"); + } + + /// `host_status()` on two dies that each step once, in different + /// intervals: Frozen. A sum of the CPU rails placed anywhere between the + /// raw read and the rule reads as one rail rising twice and fails here, + /// on any host; so does any rail handed a value other than its own. + #[test] + fn host_status_calls_one_step_on_each_die_in_different_intervals_frozen() { + let hs = host_status_scripted(two_dies); + let line = assert_sampling(&hs.run, "scripted two dies", &scripted_group(two_dies)); + eprintln!("ioreport scripted: two dies -> status={:?}; {line}", hs.status); + assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "one step on each die added up to a live rail"); + assert_eq!(hs.status, IoReportStatus::CpuRailFrozen); + assert_handed_the_script(&hs.run, two_dies); + } + + #[test] + fn energy_to_uj_scales_by_unit() { + assert_eq!(energy_to_uj(5, "mJ"), 5_000); + assert_eq!(energy_to_uj(5_000, "nJ"), 5); + } + + /// `power_over` yields watts only through a live CPU rail, and a + /// plausible figure when it does; a declined IOReport yields `None`. + #[test] + fn power_over_declines_unless_the_cpu_rail_is_live() { + let w = power_over(Duration::from_millis(300)); + if hardware_meter_available() { + assert!( + matches!(w, Some(w) if w > 0.0 && w < 500.0), + "a live CPU rail gave implausible SoC watts: {w:?}" + ); + } else { + assert_eq!( + w, + None, + "power_over produced watts although IOReport is declined ({:?})", + ioreport_status() + ); + } + } + + /// Every gated entry point takes its status from the cache. Once this + /// thread has the status, the selection costs it under 10 ms of its own + /// CPU (tests' clock), and on a declined host so do `power_over` and + /// `measure_marginal_uj`, which must give no figure. Re-running the + /// probe by any route costs at least three reads of the Energy Model + /// group and two 25 ms ticks of this thread's CPU, and on a frozen rail + /// about a second of it. Thread CPU, not wall time, so host load and + /// other IOReport readers cannot fail it. On a live host `power_over` + /// and `measure_marginal_uj` do real sampling work, so there only the + /// selection is timed and the status must be `Live`. + #[test] + fn the_gated_entry_points_read_the_cached_status() { + const WARM: Duration = Duration::from_millis(10); + let status = ioreport_status(); + let c0 = test_thread_cpu(); + let selected = hardware_meter_available(); + let selection = test_thread_cpu() - c0; + eprintln!("ioreport warm: status={status:?} selected={selected} selection burned {selection:?}"); + assert!(selection < WARM, "hardware_meter_available() burned {selection:?} of this thread's CPU after the status was cached"); + assert_eq!(selected, admits(status), "the selection is not admits(the cached status)"); + if selected { + assert_eq!(status, IoReportStatus::Live, "selected on {status:?}"); + return; + } + let c0 = test_thread_cpu(); + let watts = power_over(Duration::from_millis(200)); + let power = test_thread_cpu() - c0; + let c0 = test_thread_cpu(); + let figure = measure_marginal_uj(cpu_work); + let metering = test_thread_cpu() - c0; + eprintln!("ioreport warm: declined power_over={watts:?} burned {power:?}; measure_marginal_uj={figure:?} burned {metering:?}"); + assert_eq!((watts, figure), (None, None), "a declined meter ({status:?}) gave a figure"); + assert!(power < WARM, "a declined power_over burned {power:?} of this thread's CPU: it probed again"); + assert!(metering < WARM, "a declined measure_marginal_uj burned {metering:?} of this thread's CPU: it probed again"); + } + /// When the meter IS selected, a multi-core CPU burst meters as non-zero /// energy through a rail that moves; when it is not, the status says why /// and no figure comes out. No branch goes unasserted. @@ -2049,8 +3094,8 @@ mod macos_ioreport { if hardware_meter_available() { assert_eq!(ioreport_status(), IoReportStatus::Live, "selected on a CPU rail that is not live"); assert_eq!( - probe(CPU_RAIL_LIVENESS_BUDGET).verdict, - Some(CpuRail::Live), + host_status().status, + IoReportStatus::Live, "the CPU rail stopped advancing after the meter was selected" ); let uj = measure_marginal_uj(cpu_work); @@ -2126,6 +3171,9 @@ fn busy_power() -> Option { #[cfg(test)] mod tests { use super::*; + use liveness::tests::read_of; + use liveness::{probe_with, CpuRail, ProbeRun}; + use std::cell::Cell; use std::time::Duration; /// How many times [`eighty_ms_of_work`] has run in this process. @@ -2166,23 +3214,169 @@ mod tests { assert_eq!(CALLS.load(Ordering::SeqCst), after, "workers kept running after the bracket returned"); } - /// No verdict — the probe's Energy Model subscription could not be opened, - /// or opened but never yielded two consecutive samples — is `Unavailable`: - /// never `Live`, never `CpuRailAbsent`. - #[test] - fn no_verdict_is_unavailable() { - assert_eq!(status_from(None), IoReportStatus::Unavailable); + /// A run as the probe loop leaves it, for the pure mapping. + fn run_of(opened: bool, verdict: Option) -> ProbeRun { + ProbeRun { opened, verdict, ..ProbeRun::default() } } - /// The probe's verdict maps one-to-one onto the public status; no verdict - /// (IOReport could not be opened or sampled) is `Unavailable`. + /// No verdict — the probe's Energy Model subscription could not be opened, + /// or opened but never yielded two consecutive samples — is `Unavailable`: + /// never `Live`, never `CpuRailAbsent`. A run whose subscription did not + /// open is `Unavailable` whatever verdict it carries. + #[test] + fn no_verdict_is_unavailable() { + assert_eq!(status_of(&run_of(true, None)), IoReportStatus::Unavailable); + for verdict in [None, Some(CpuRail::Live), Some(CpuRail::Frozen), Some(CpuRail::Absent)] { + assert_eq!( + status_of(&run_of(false, verdict)), + IoReportStatus::Unavailable, + "a subscription that did not open, verdict {verdict:?}" + ); + } + } + + /// An opened run's verdict maps one-to-one onto the public status. #[test] fn the_verdict_maps_onto_the_status_one_to_one() { - use liveness::CpuRail; - assert_eq!(status_from(None), IoReportStatus::Unavailable); - assert_eq!(status_from(Some(CpuRail::Absent)), IoReportStatus::CpuRailAbsent); - assert_eq!(status_from(Some(CpuRail::Frozen)), IoReportStatus::CpuRailFrozen); - assert_eq!(status_from(Some(CpuRail::Live)), IoReportStatus::Live); + assert_eq!(status_of(&run_of(true, Some(CpuRail::Absent))), IoReportStatus::CpuRailAbsent); + assert_eq!(status_of(&run_of(true, Some(CpuRail::Frozen))), IoReportStatus::CpuRailFrozen); + assert_eq!(status_of(&run_of(true, Some(CpuRail::Live))), IoReportStatus::Live); + } + + /// Only a live CPU rail admits the IOReport meter. + #[test] + fn only_live_is_admitted() { + assert!(admits(IoReportStatus::Live), "a live CPU rail must admit the meter"); + assert!(!admits(IoReportStatus::CpuRailFrozen), "a frozen CPU rail admitted the meter"); + assert!(!admits(IoReportStatus::CpuRailAbsent), "no CPU rail admitted the meter"); + assert!(!admits(IoReportStatus::Unavailable), "no IOReport admitted the meter"); + } + + /// The metering path with the status injected: it meters only when + /// [`admits`] the status. Declined, it returns `None` without reading power + /// or running the work; admitted, it brackets the work between the two + /// readings. + #[test] + fn only_a_live_status_meters_and_a_declined_one_never_runs_work() { + static CALLS: AtomicU64 = AtomicU64::new(0); + fn counted_work() { + CALLS.fetch_add(1, Ordering::SeqCst); + std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); + } + for status in [ + IoReportStatus::Unavailable, + IoReportStatus::CpuRailAbsent, + IoReportStatus::CpuRailFrozen, + IoReportStatus::Live, + ] { + let before = CALLS.load(Ordering::SeqCst); + let readings = Cell::new(0u32); + let r = measure_gated( + admits(status), + counted_work, + || { + readings.set(readings.get() + 1); + Some(1.0) + }, + || { + readings.set(readings.get() + 1); + // A worker counts a run when its call RETURNS, so wait until + // more calls have started than there can be workers (at most + // 12): one worker has then started a second call, so its + // first has been counted. + let t = Instant::now(); + while CALLS.load(Ordering::SeqCst) - before <= 12 && t.elapsed() < Duration::from_secs(10) { + std::thread::sleep(Duration::from_millis(1)); + } + Some(3.0) + }, + ); + let ran = CALLS.load(Ordering::SeqCst) - before; + if status == IoReportStatus::Live { + assert!(r.is_some() && ran > 0 && readings.get() == 2, "{status:?}: {r:?}, {ran} runs, {} readings", readings.get()); + } else { + assert_eq!((r, ran, readings.get()), (None, 0, 0), "{status:?} metered"); + } + } + } + + /// Read `n` of a scripted run: its channels, or `None` for a failed read. + type Script = fn(u32) -> Option>; + + /// The production composition — `status_of(&probe_with(open, read, tick, + /// wall, cpu, budget))`, as `host_status` composes it — on fakes: an opener + /// that opens or not, scripted reads, a tick that advances a scripted wall + /// clock and CPU clock by 25 ms and counts itself. Returns the status, the + /// run, and how many reads and ticks the fakes saw. + fn composed(opens: bool, script: Script) -> (IoReportStatus, ProbeRun, u32, u32) { + let (clock, cpu) = (Cell::new(Duration::ZERO), Cell::new(Duration::ZERO)); + let (reads, ticks) = (Cell::new(0u32), Cell::new(0u32)); + let run = probe_with( + || opens.then_some(()), + |_: &()| { + let n = reads.get(); + reads.set(n + 1); + script(n).map(|s| read_of(&s)) + }, + || { + ticks.set(ticks.get() + 1); + clock.set(clock.get() + Duration::from_millis(25)); + cpu.set(cpu.get() + Duration::from_millis(25)); + }, + || clock.get(), + || Some(cpu.get()), + Duration::from_millis(1000), + ); + (status_of(&run), run, reads.get(), ticks.get()) + } + + /// The composition on an Energy Model group that cannot be opened: no + /// read, no tick, no verdict, `Unavailable`. + #[test] + fn the_composition_on_an_unopenable_group_reads_nothing_and_is_unavailable() { + let (status, run, reads, ticks) = composed(false, |_| Some(vec![("CPU Energy", 1)])); + assert_eq!((reads, ticks), (0, 0), "an unopened subscription was read or ticked"); + assert!(!run.opened && run.calls.is_empty() && run.ticks == 0 && run.verdict.is_none(), "{run:?}"); + assert_eq!(status, IoReportStatus::Unavailable); + assert!(!admits(status)); + } + + /// The composition on a single batch jump: Frozen, declined. + #[test] + fn the_composition_on_a_single_batch_jump_is_frozen() { + let (status, run, reads, ticks) = composed(true, |n| { + Some(vec![("CPU Energy", if n < 4 { 480_083_160 } else { 541_893_638 }), ("GPU Energy", i64::from(n))]) + }); + assert_eq!(run.verdict, Some(CpuRail::Frozen), "{run:?}"); + assert_eq!(status, IoReportStatus::CpuRailFrozen); + assert!(!admits(status)); + assert_eq!((reads, ticks, run.intervals), (41, 40, 40)); + } + + /// The composition on a rail that advances at every read: Live after two + /// intervals, admitted. + #[test] + fn the_composition_on_two_advances_is_live() { + let (status, run, reads, ticks) = composed(true, |n| Some(vec![("CPU Energy", 1_000 + i64::from(n))])); + assert_eq!(run.verdict, Some(CpuRail::Live), "{run:?}"); + assert_eq!(status, IoReportStatus::Live); + assert!(admits(status)); + assert_eq!((reads, ticks, run.intervals), (3, 2, 2)); + } + + /// The composition on two dies that each step once, in different + /// intervals: Frozen — liveness is per rail, and nothing between the read + /// and the rule sums them. + #[test] + fn the_composition_on_one_step_per_die_is_frozen() { + let (status, run, _, _) = composed(true, |n| { + Some(vec![ + ("DIE_0_CPU Energy", 100 + if n < 3 { 0 } else { 50 }), + ("DIE_1_CPU Energy", 200 + if n < 9 { 0 } else { 50 }), + ]) + }); + assert_eq!(run.verdict, Some(CpuRail::Frozen), "{run:?}"); + assert_eq!(status, IoReportStatus::CpuRailFrozen); } /// The declined path meters real work honestly. This meter's fallback is no @@ -2241,6 +3435,7 @@ mod tests { #[test] fn ioreport_status_is_unavailable_off_macos() { assert_eq!(ioreport_status(), IoReportStatus::Unavailable); + assert_eq!(real_probe_runs(), 0); #[cfg(not(target_os = "linux"))] assert!(!hardware_meter_available(), "no interface is implemented for this OS"); } diff --git a/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs b/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs new file mode 100644 index 000000000..3a97a7d26 --- /dev/null +++ b/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs @@ -0,0 +1,106 @@ +//! The IOReport liveness probe behind `ioreport_status()` runs once per +//! PROCESS, however many threads ask at once, through the status, the +//! selection and the metering entry point: a probe per call or per thread +//! would burn ~1 s on each caller's thread (inside `measure_marginal_uj`'s +//! bracket, a whole probe in the busy window) and could flip the verdict +//! mid-process. (`power_over` is private; the lib test +//! `the_gated_entry_points_read_the_cached_status` pins it the same way, on +//! the calling thread's CPU.) +//! +//! This file holds this one test, so that it runs in a process of its own and +//! IS the first use of the meter in that process. The counter it reads, +//! `real_probe_runs()`, counts runs of the production probe (`host_status`, +//! whose first statement it is), not lookups of the cache in front of it. + +use proof_cascade::meter::{ + hardware_meter_available, ioreport_status, measure_marginal_uj, real_probe_runs, IoReportStatus, +}; +use std::sync::{Arc, Barrier}; +use std::time::Duration; + +/// Real, cheap, pure CPU work. +fn cpu_work() { + std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); +} + +/// This thread's CPU time, read through this test's own binding of +/// `clock_gettime(CLOCK_THREAD_CPUTIME_ID)` (16 on macOS). +#[cfg(target_os = "macos")] +fn thread_cpu() -> Duration { + #[repr(C)] + struct Timespec { + tv_sec: i64, + tv_nsec: std::os::raw::c_long, + } + unsafe extern "C" { + fn clock_gettime(clock_id: std::os::raw::c_int, tp: *mut Timespec) -> std::os::raw::c_int; + } + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: ts is a valid, writable timespec. + assert_eq!(unsafe { clock_gettime(16, &mut ts) }, 0, "the thread CPU clock does not read"); + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) +} + +#[cfg(not(target_os = "macos"))] +fn thread_cpu() -> Duration { + Duration::ZERO +} + +/// Eight threads, released together, each ask for the status and then select +/// the meter the way a caller does (`hardware_meter_available()` is the +/// selection; `measure_marginal_uj` selects and, declined, gives no figure). +/// The probe must have run exactly once for all of them. Once a thread has its +/// status, its selection calls must be warm: together they may not burn 10 ms +/// of that thread's CPU, where re-running the probe by any route costs at +/// least three reads of the Energy Model group (~7 ms of thread CPU each) and +/// normally ~1 s of burn. +#[test] +fn the_liveness_probe_runs_once_per_process() { + assert_eq!(real_probe_runs(), 0, "the meter was used in this process before this test: this is not its first use"); + const THREADS: usize = 8; + let gate = Arc::new(Barrier::new(THREADS)); + let handles: Vec<_> = (0..THREADS) + .map(|_| { + let gate = gate.clone(); + std::thread::spawn(move || { + gate.wait(); + let status = ioreport_status(); + let c0 = thread_cpu(); + let selected = hardware_meter_available(); + let again = ioreport_status(); + let figure = if selected { None } else { measure_marginal_uj(cpu_work) }; + let warm = thread_cpu() - c0; + (status, again, selected, figure, warm) + }) + }) + .collect(); + let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a probing thread panicked")).collect(); + let runs = real_probe_runs(); + let warmest = seen.iter().map(|s| s.4).max().unwrap_or_default(); + eprintln!( + "ioreport cache: {THREADS} threads saw (status={:?}, selected={}); real probe runs in this process: {runs}; \ + warm selection calls burned at most {warmest:?} of a thread's CPU", + seen[0].0, seen[0].2 + ); + for (i, (status, again, selected, figure, _)) in seen.iter().enumerate() { + assert_eq!((*status, *again), (seen[0].0, seen[0].0), "thread {i} was given a different status"); + assert_eq!(*selected, seen[0].2, "thread {i} made a different selection"); + if !selected { + assert_eq!(*figure, None, "thread {i}: a declined meter gave a figure"); + } + } + if cfg!(target_os = "macos") { + assert_eq!(seen[0].2, seen[0].0 == IoReportStatus::Live, "on macOS the meter is selected exactly when Live"); + assert_eq!(runs, 1, "the liveness probe ran {runs} times in this process ({THREADS} threads asked at once)"); + for (i, s) in seen.iter().enumerate() { + assert!( + s.4 < Duration::from_millis(10), + "thread {i}: its warm selection calls burned {:?} of its CPU: something probed again, uncached", + s.4 + ); + } + } else { + assert_eq!(seen[0].0, IoReportStatus::Unavailable, "IOReport off macOS"); + assert_eq!(runs, 0, "a liveness probe ran off macOS"); + } +} diff --git a/proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs b/proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs new file mode 100644 index 000000000..829a0ec88 --- /dev/null +++ b/proof/proof-rs/crates/proof-cascade/tests/ioreport_unopenable.rs @@ -0,0 +1,58 @@ +//! The unopenable path of the whole production chain — probe, status, cache, +//! selection, metering — on any host. Neither Mac that runs these tests lacks +//! an Energy Model group, so `simulate_no_energy_model_group()` makes this +//! process's probe find none before the meter's first use. This file holds +//! this one test so that it runs in a process of its own and the hook cannot +//! reach any other test. + +use proof_cascade::meter::{ + hardware_meter_available, ioreport_status, measure_marginal_uj, real_probe_runs, simulate_no_energy_model_group, + IoReportStatus, +}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Barrier}; + +/// How many times [`counted_work`] has run in this process. +static WORK_CALLS: AtomicU64 = AtomicU64::new(0); + +fn counted_work() { + WORK_CALLS.fetch_add(1, Ordering::SeqCst); + std::hint::black_box((0..4000u64).fold(0u64, |a, b| a.wrapping_mul(31).wrapping_add(b))); +} + +/// With no Energy Model group to subscribe to, the status is `Unavailable` — +/// never `Live`, never `CpuRailAbsent` — for every thread that asks, the meter +/// is never selected, metering gives no figure and never runs the work, and the +/// probe still ran exactly once. +#[test] +fn a_host_with_no_energy_model_group_is_unavailable_and_every_meter_declines() { + assert_eq!(real_probe_runs(), 0, "the meter was used in this process before this test: this is not its first use"); + simulate_no_energy_model_group(); + const THREADS: usize = 4; + let gate = Arc::new(Barrier::new(THREADS)); + let handles: Vec<_> = (0..THREADS) + .map(|_| { + let gate = gate.clone(); + std::thread::spawn(move || { + gate.wait(); + let status = ioreport_status(); + let selected = hardware_meter_available(); + let figure = if cfg!(target_os = "macos") { measure_marginal_uj(counted_work) } else { None }; + (status, selected, figure) + }) + }) + .collect(); + let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a thread panicked")).collect(); + let runs = real_probe_runs(); + let work = WORK_CALLS.load(Ordering::SeqCst); + eprintln!("ioreport unopenable: {THREADS} threads saw {seen:?}; real probe runs: {runs}; work calls: {work}"); + for (i, (status, selected, figure)) in seen.iter().enumerate() { + assert_eq!(*status, IoReportStatus::Unavailable, "thread {i}: no Energy Model group, yet {status:?}"); + if cfg!(target_os = "macos") { + assert!(!selected, "thread {i}: the meter was selected with no Energy Model group"); + assert_eq!(*figure, None, "thread {i}: a figure came out with no Energy Model group"); + } + } + assert_eq!(work, 0, "the work ran {work} times with no usable meter"); + assert_eq!(runs, usize::from(cfg!(target_os = "macos")), "the liveness probe's runs in this process"); +} -- 2.54.0 (Apple Git-157) From 906ed1a60f2cdbc5986aea1a29489f475fe976ac Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 18:45:41 -0400 Subject: [PATCH 06/10] proof-cascade: a rail is live only if it keeps advancing, never on a two-step refresh The liveness rule said Live once the same CPU rail advanced in two separate sample intervals. That was meant to stop a batch refresh (one step, then a hold) from vouching for a frozen rail. But a refresh is not always one step. On an M5 Max running macOS 27.0.1, the jouleclaw-energy verifier ran a group-only watcher that sampled the Energy Model group every ~16 ms. It saw `CPU Energy` go 2,643,843,378 -> 2,705,607,863 (+61,764,485), then 64 ms later -> 2,705,611,298 (+3,435). `MCPU0`, `MCPU1` and `PCPU` each stepped twice with it. The probe's intervals are ~31 ms, so a probe window that holds such a refresh sees the rail advance in two intervals and says Live. That answer is cached for the process. There, two real probes said Live while the oracle saw no sustained movement, at the time of a refresh. Refreshes come ~30 minutes apart, so this is rare. But it is exactly the failure the rule exists to prevent: the meter is selected over a frozen rail, and every later refresh lands whole in some bracket. The rule, the same in all five crates that carry it. Live iff, after some interval, the same CPU rail (name + ordinal): - advanced in at least MIN_LIVE_ADVANCES = 5 separate intervals; - advanced in at least 80% of all intervals seen so far (advances * 5 >= intervals * 4); - was watched for at least MIN_LIVE_WINDOW = 300 ms of wall clock since the first sample. The rule is checked after each interval. Live is declared, and the probe stops, the first time all three hold. The pure rule now takes each sample's wall-clock reading. The probe passes the read's `end`, and the tests script the clock. Sampling goes on until Live, or until the 1 s budget has passed and at least 5 intervals were seen. The hard cap stays at 5 budgets. On a quiet M5 Max a live rail is now called live after ~10 intervals (~310 ms), not 2. Unchanged: per-rail keys; consecutive samples; a decrease or a first appearance is not an advance; one advance per rail per interval; the group-only subscription; the thread-CPU tick; the pure status mapping; the OnceLock cache; the F9 fault hooks; proof-cli's messages; the RAPL per-zone wrap code; and the "ioreport liveness: status=" log line. The oracle. It called a rail moving after 2 changes, so a two-step refresh in its window could vouch for a false Live. Now it calls a rail moving only if the rail changed at 5 or more of its reads and at 80% or more of them. Its count moves into an `OracleTally` that the tests feed scripted reads. It still extends its window while it has fewer than 5 reads, but the cap is now 8 budgets (was 3). Under contention it needed up to 6.8 s of the old 9 s cap to get 5 reads. Too few reads is still a failure, never a "not moved". Tests. Those marked (*) fail under the old rule. - (*) The observed two-step refresh, with MCPU0/MCPU1/PCPU stepping beside it. Frozen after the whole budget: in the pure loop, in the composition, and in the real `host_status()` on scripted reads (CpuRailFrozen). - (*) A refresh in 3, 4 or 5 steps within its first 150 ms, back to back or spread out: Frozen. - (*) The M3 Ultra shape, where DIE_0 and DIE_1 each step twice within 64 ms: Frozen. - (*) Two advances of one rail over 300 ms: not live. Five advances in six intervals over 600 ms: live. - A rail that advances every 31 ms interval is Live at the tenth (310 ms). It is not Live after 5 advances (155 ms) or 9 (279 ms), and it stays Live. - A rail missing one interval in five is Live at exactly 80% (8 of 10). One missing two in five is never Live. One that missed two of its first five is not yet Live at intervals 7-9, where the count and window are already met, and is Live at the tenth. - On a loaded host (100 ms intervals), Live comes at the fifth interval (500 ms), not the third (300 ms). - The probe keeps sampling past the budget while it has fewer than 5 intervals and stops at 5. It stops at the cap with 3. - The oracle's bar, on scripted reads: a one- or two-step refresh is never "moved". Scripts whose meaning changed were rewritten so they still catch what they were written for: - "Two advances -> Live" became a sustained-advance script. It must be Live after 5 or more intervals and 300 ms or more of the real clock, at the first interval where the tests' own reading of the record says so. - The one-step-per-die scripts became two dies that advance in alternate intervals, so a sum of the rails advances every interval and fails. - The decrease and first-appearance tests now come after four advances over 400-500 ms. - The record test pins which wall reading the rule gets. `assert_verdict_matches_record` recomputes the new rule with the tests' own literals, and checks that a Live run stopped at the first interval that met it. Mutations. Each was a temporary edit, restored with git checkout; the tree was clean after each. - The old rule (2 advances, no share check, no window): 21 lib tests fail, including every (*) test, e.g. "a two-step refresh counted as a live rail: Run { verdict: Some(Live), intervals: 4, ...". - MIN_LIVE_ADVANCES = 2: 7 fail. For one, the loaded host is "Run { verdict: Some(Live), intervals: 3, samples: 4, took: 300ms". - Share check removed: 6 fail, e.g. "a rail advancing in 60% of intervals counted as live" and "a refresh in steps [1, 2, 3, 4, 5] counted as live: Run { verdict: Some(Live), intervals: 10, ...". - Window check removed: 8 fail, e.g. "a refresh in steps [1, 2, 3, 4, 5] counted as live: Run { verdict: Some(Live), intervals: 5, samples: 6, took: 150ms" and "Live after 127.469542ms of window". - Oracle back to ">= 2 changes": "two steps in consecutive reads: the oracle called a refresh moving". Runs. - proof-cascade tests, 3 times, beside an all-channel IOReport sampler (150-570 ms a sample on this loaded host): 68 + 1 + 1 passed each time, e.g. "status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false probe_intervals=29 ... oracle_reads=5". The sampler was killed after each run. - M3 Ultra (macOS 27.0.1): the lib test binary was built here, run once, then deleted. 68 passed, with "status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false probe_intervals=27 ... oracle_reads=20". - The proof-rs workspace is green, and proof-conformance prints "all checks passed". clippy --no-deps shows no new warnings; the two in proof-cascade were already there. Not done here: - The Live branch is tested only on scripted rails. Neither Mac exposes a live CPU rail to an unprivileged reader, so a real Live has not been seen. - The other four crates that carry this rule get it in their own commits. Co-Authored-By: Claude Opus 5.5 --- .../crates/proof-cascade/src/meter.rs | 936 +++++++++++++----- .../proof-cascade/tests/ioreport_once.rs | 4 +- 2 files changed, 688 insertions(+), 252 deletions(-) diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index 6223bb29c..51b7c40e2 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -15,11 +15,14 @@ //! 27.0.1 for an unprivileged reader, the CPU rails of an M5 Max and an M3 Ultra //! hold their value under minutes of full CPU load and move only in rare batch //! refreshes (both M3 Ultra dies held for at least 22 minutes, then stepped by -//! ~1.3 kJ each within one 100 ms sample), while the M5 Max's GPU rail keeps -//! advancing. Bracketing CPU work against idle on those rails brackets GPU -//! activity, nothing, or a refresh. So the interface is used only when the same -//! CPU rail advances in at least two separate sample intervals while the probe -//! burns CPU; otherwise `None` is returned and the caller keeps its +//! ~1.3 kJ each within one 100 ms sample, and an M5 Max's `CPU Energy` was seen +//! to publish one refresh in two steps 64 ms apart), while the M5 Max's GPU +//! rail keeps advancing. Bracketing CPU work against idle on those rails +//! brackets GPU activity, nothing, or a refresh. So the interface is used only +//! when the same CPU rail keeps advancing while the probe burns CPU — in at +//! least five separate sample intervals, in at least 80% of the intervals +//! observed, over at least 300 ms of wall time — which no refresh observed so +//! far does; otherwise `None` is returned and the caller keeps its //! `ModelBased` figure. //! //! The chain on macOS is short on purpose. `host_status` is one expression: the @@ -130,14 +133,17 @@ pub enum IoReportStatus { /// used. CpuRailAbsent, /// A CPU energy rail exists but did not keep advancing while CPU was - /// burned: no CPU rail advanced in two separate sample intervals. A rail - /// that stepped once did not track the work either — that is a batch - /// refresh. The platform is not exposing live CPU energy to this process, - /// so bracketing CPU work on it would measure GPU-only energy, nothing, or - /// a refresh. Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. + /// burned: no CPU rail advanced in five separate sample intervals that + /// were at least 80% of those observed, over at least 300 ms. A rail that + /// stepped once, or in a few steps close together, did not track the work + /// either — that is a batch refresh. The platform is not exposing live CPU + /// energy to this process, so bracketing CPU work on it would measure + /// GPU-only energy, nothing, or a refresh. Observed on macOS 27.0.1 on + /// both an M5 Max and an M3 Ultra. CpuRailFrozen, - /// The same CPU energy rail advanced in at least two separate sample - /// intervals while the probe burned CPU; IOReport is used. + /// The same CPU energy rail kept advancing while the probe burned CPU: in + /// at least five separate sample intervals, in at least 80% of the + /// intervals observed, over at least 300 ms of wall time. IOReport is used. Live, } @@ -148,10 +154,12 @@ pub enum IoReportStatus { /// channels on an M5 Max, 645 on an M3 Ultra, ~6–9 ms of thread CPU a sample, /// where a sample of every channel (~11,900 / ~14,300) costs ~85–125 ms — so on /// a quiet host this buys about thirty intervals, and fewer on a loaded one, -/// where a live rail needs two. If fewer than two intervals were observed when -/// it runs out, the probe keeps sampling, up to five budgets. A live rail -/// slower than that fails safe: declined, and the caller keeps its -/// `ModelBased` figure. Paid once per process; see [`ioreport_status`]. +/// where a live rail needs five, spanning at least 300 ms; a live rail on a +/// quiet host is called live after about ten. If fewer than five intervals +/// were observed when it runs out, the probe keeps sampling, up to five +/// budgets. A live rail slower than that fails safe: declined, and the caller +/// keeps its `ModelBased` figure. Paid once per process; see +/// [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); @@ -343,21 +351,35 @@ mod liveness { use std::time::Duration; /// In how many separate sample intervals the **same** CPU rail must advance - /// to count as live. One is not enough: see [`RailWatch`]. - pub(super) const LIVE_RAIL_ADVANCES: u32 = 2; + /// to count as live. Two is not enough — a batch refresh can be published + /// in two steps — and neither is any count on its own: see [`RailWatch`]. + pub(super) const MIN_LIVE_ADVANCES: u32 = 5; + + /// How much wall time, from the first sample, the observation must span + /// before a rail can count as live: a refresh published in steps over a + /// few tens of milliseconds cannot fill it. + pub(super) const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300); /// The probe's hard cap, in budgets, while it waits for enough intervals. pub(super) const MAX_BUDGETS: u32 = 5; + /// Whether `advances` advancing intervals of one rail are at least 80% of + /// the `intervals` observed so far. + fn sustained(advances: u32, intervals: u32) -> bool { + u64::from(advances) * 5 >= u64::from(intervals) * 4 + } + /// What the liveness probe saw of the CPU energy rail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum CpuRail { - /// One CPU energy rail advanced in [`LIVE_RAIL_ADVANCES`] separate sample - /// intervals while the probing thread burned CPU. + /// One CPU energy rail kept advancing while the probing thread burned + /// CPU: in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, + /// in at least 80% of the intervals observed, over at least + /// [`MIN_LIVE_WINDOW`] of wall time. Live, - /// A CPU energy rail exists, but none advanced in that many intervals: - /// it held its value, or moved in a single step (a batch refresh, not a - /// live reading). + /// A CPU energy rail exists, but none kept advancing like that: it held + /// its value, or moved in one or a few steps close together (a batch + /// refresh, not a live reading). Frozen, /// No CPU energy channel exists in the Energy Model group. Absent, @@ -418,8 +440,10 @@ mod liveness { /// alone are not unique: an M3 Ultra's Energy Model group lists 264 of its /// 645 names twice (e.g. `ECPUDTL00`..`22`, once per die, with no `DIE_` /// prefix). Its CPU aggregates are unique today, but two same-named rails - /// stepping together in one refresh must stay two rails that each advanced - /// once, not one rail that advanced twice. + /// must stay two rails, each compared only with its own previous value: + /// keyed by name alone, one would be compared with the other, and a larger + /// value held beside a smaller one would read as an advance in every + /// interval. pub(super) type RailKey = (String, u32); /// What the rule was handed in one successful sample, keyed as the rule @@ -435,46 +459,76 @@ mod liveness { pub(super) gpu: Vec<(RailKey, i64)>, } - /// The liveness rule, fed one whole sample at a time. + /// The liveness rule, fed one whole sample at a time, with the wall-clock + /// reading at which it was taken. /// - /// A rail is live only when the **same** CPU rail advances in at least - /// [`LIVE_RAIL_ADVANCES`] separate sample intervals, where an interval is - /// two CONSECUTIVE samples and an advance is the rail's raw integer being + /// A rail is live only when the **same** CPU rail keeps advancing: after + /// some interval, all three of these hold at once — + /// + /// - it advanced in at least [`MIN_LIVE_ADVANCES`] separate intervals; + /// - those advancing intervals are at least 80% of all the intervals + /// observed so far; + /// - the observation so far spans at least [`MIN_LIVE_WINDOW`] of wall + /// time, from the first sample to this one. + /// + /// The rule is evaluated after every interval, and the rail is live from + /// the first interval at which all three hold. An interval is two + /// CONSECUTIVE samples and an advance is the rail's raw integer being /// larger than in the sample just before. The watch keeps the previous - /// sample itself, so the comparison cannot drift to a fixed baseline. One - /// advance is not enough, because on macOS 27.0.1 the CPU rails are not - /// permanently frozen for an unprivileged reader: they are refreshed in - /// rare batches. An M3 Ultra's `DIE_0_` / `DIE_1_CPU Energy` held for at - /// least 22 minutes, then stepped by +1,344,869 and +1,391,637 mJ in a - /// single 100 ms sample and held again; an M5 Max's `CPU Energy` did not - /// change in 13,614 samples over 25 minutes, yet had moved from 480,083,160 - /// to 541,893,638 mJ earlier that hour. A probe window that straddles a - /// refresh sees one large jump, and a rule that accepts it selects the meter - /// for the life of the process, after which every later ~1.3 kJ refresh - /// lands whole in whichever bracket straddles it. Compared with a fixed - /// baseline instead of the previous sample, that one jump would read as an - /// advance in every later interval. A live rail advances in nearly every - /// interval (the M5 Max GPU rail changes every ~28 ms). Counting per rail - /// keeps two dies' separate jumps from adding up to a false "live". + /// sample itself, so the comparison cannot drift to a fixed baseline. + /// + /// Why so much: on macOS 27.0.1 the CPU rails are not permanently frozen + /// for an unprivileged reader; they are refreshed in rare batches. An M3 + /// Ultra's `DIE_0_` / `DIE_1_CPU Energy` held for at least 22 minutes, then + /// stepped by +1,344,869 and +1,391,637 mJ in a single 100 ms sample and + /// held again; an M5 Max's `CPU Energy` did not change in 13,614 samples + /// over 25 minutes, yet had moved from 480,083,160 to 541,893,638 mJ earlier + /// that hour. And a refresh is not always one step: a watcher sampling the + /// Energy Model group every ~16 ms on an M5 Max saw `CPU Energy` go + /// 2,643,843,378 -> 2,705,607,863 (+61,764,485) and then, 64 ms later, + /// -> 2,705,611,298 (+3,435), with `MCPU0`, `MCPU1` and `PCPU` each stepping + /// twice too. The probe's intervals are ~31 ms, so a window holding such a + /// refresh sees the same rail advance in two separate intervals — which a + /// "two advances" rule called live, selecting the meter for the life of the + /// process, after which every later refresh lands whole in whichever + /// bracket straddles it. A live rail advances in nearly every interval + /// (the M5 Max GPU rail changes every ~28 ms), for as long as it is + /// watched; a refresh advances in a few intervals close together and then + /// holds. So the rule asks for many advances (a few steps are not enough), + /// in nearly every interval (steps followed by a hold fall under 80%), over + /// a window a refresh does not fill (to pass, it would have to keep + /// stepping in four of every five intervals for 300 ms — which is what a + /// live rail does). Compared with a fixed baseline instead of the previous + /// sample, one jump would read as an advance in every later interval. + /// Counting per rail keeps two dies' separate steps from adding up to a + /// false "live". #[derive(Debug, Default)] pub(super) struct RailWatch { /// Sample intervals observed so far (successful samples after the first). intervals: u32, + /// The wall-clock reading of the first successful sample: where the + /// observation window starts. `None` until the first sample. + first_at: Option, /// The CPU rails of the previous successful sample, with their raw /// integers. `None` until the first sample. prev: Option>, /// Every CPU rail seen, with the number of intervals in which it advanced. rails: Vec<(RailKey, u32)>, + /// Whether a rail has met the rule after some interval. Once set, it + /// stays set: the rule declares Live as soon as all three conditions + /// hold, and the probe stops sampling there. + live: bool, /// What each successful sample handed the rule, in order. seen: Vec, } impl RailWatch { - /// Fold in the next sample. The first one is the baseline; each later - /// one closes an interval against the sample just before it. A rail - /// advances at most once per interval, and only if it was in both - /// samples. - pub(super) fn observe(&mut self, sample: &[(String, i64)]) { + /// Fold in the next sample, taken at wall-clock reading `at`. The first + /// one is the baseline and starts the window; each later one closes an + /// interval against the sample just before it, and then the rule is + /// evaluated. A rail advances at most once per interval, and only if it + /// was in both samples. + pub(super) fn observe(&mut self, sample: &[(String, i64)], at: Duration) { let cur = keyed(sample, is_cpu_energy_rail); self.seen.push(Seen { cpu: cur.clone(), gpu: keyed(sample, is_gpu_energy_rail) }); for (key, _) in &cur { @@ -482,6 +536,7 @@ mod liveness { self.rails.push((key.clone(), 0)); } } + let first_at = *self.first_at.get_or_insert(at); if let Some(prev) = self.prev.take() { self.intervals += 1; for (key, now) in &cur { @@ -493,6 +548,13 @@ mod liveness { } } } + let window = at.saturating_sub(first_at); + self.live = self.live + || (window >= MIN_LIVE_WINDOW + && self + .rails + .iter() + .any(|(_, n)| *n >= MIN_LIVE_ADVANCES && sustained(*n, self.intervals))); } self.prev = Some(cur); } @@ -502,19 +564,20 @@ mod liveness { self.intervals } - /// Has one CPU rail advanced in enough separate intervals to be live? + /// Has one CPU rail kept advancing — met the rule after some interval? pub(super) fn is_live(&self) -> bool { - self.rails.iter().any(|(_, count)| *count >= LIVE_RAIL_ADVANCES) + self.live } /// Keep sampling? Until a rail has proved live, or `budget` has elapsed - /// **and** a live rail could have shown [`LIVE_RAIL_ADVANCES`] advances — - /// one slow IOReport sample must not make a live rail look frozen — but - /// never past [`MAX_BUDGETS`] budgets. + /// **and** at least [`MIN_LIVE_ADVANCES`] intervals were observed, so a + /// live rail could have shown that many advances — slow IOReport + /// samples must not make a live rail look frozen — but never past + /// [`MAX_BUDGETS`] budgets. pub(super) fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { !self.is_live() && (elapsed < budget - || (self.intervals < LIVE_RAIL_ADVANCES && elapsed < budget * MAX_BUDGETS)) + || (self.intervals < MIN_LIVE_ADVANCES && elapsed < budget * MAX_BUDGETS)) } /// The verdict, or `None` if not a single interval could be observed @@ -628,9 +691,11 @@ mod liveness { /// fakes in the tests. `host_status` runs it on the real parts. /// /// Opens the subscription, takes a baseline read, then ticks and re-reads - /// for as long as [`RailWatch::wants_more`] says to. A failed read (`None`) - /// is not an interval: the next successful one is compared with the last - /// successful one. A subscription that cannot be opened is never read or + /// for as long as [`RailWatch::wants_more`] says to. Each successful read + /// is handed to the rule with the wall-clock reading taken when it + /// returned (its record's `end`). A failed read (`None`) is not an + /// interval: the next successful one is compared with the last successful + /// one. A subscription that cannot be opened is never read or /// ticked and gives no verdict; so does one that opens but never yields two /// consecutive samples. Neither is `Absent`: that needs sampled intervals /// with no CPU rail in them. The subscription is dropped (released) on @@ -656,7 +721,7 @@ mod liveness { let (cpu_after, end) = (cpu(), wall()); run.calls.push(SampleCall { start, end, cpu_before, cpu_after, listed: got.as_ref().map(|r| r.listed) }); if let Some(got) = got { - watch.observe(&got.sample); + watch.observe(&got.sample, end); } if !watch.wants_more(wall().saturating_sub(started), budget) { break; @@ -740,11 +805,37 @@ mod liveness { const BUDGET: Duration = Duration::from_millis(1000); const TICK: Duration = Duration::from_millis(25); + /// The probe's interval on a quiet M5 Max: one ~25 ms tick and a + /// ~6 ms read of the Energy Model group. + const QUIET: Duration = Duration::from_millis(31); + /// An interval on a loaded host. + const LOADED: Duration = Duration::from_millis(100); + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } fn sample(pairs: &[(&str, i64)]) -> Sample { pairs.iter().map(|(n, v)| (n.to_string(), *v)).collect() } + /// Feed `samples` to a fresh watch, sample `i` at wall time `i * every`, + /// and return the watch and whether it was live after each sample. + fn watch(every: Duration, samples: &[Vec<(&str, i64)>]) -> (RailWatch, Vec) { + let mut w = RailWatch::default(); + let mut live = Vec::new(); + for (i, s) in samples.iter().enumerate() { + w.observe(&sample(s), every * i as u32); + live.push(w.is_live()); + } + (w, live) + } + + /// The first sample after which the watch was live, if any. + fn first_live(live: &[bool]) -> Option { + live.iter().position(|l| *l) + } + /// A read of a subscription to the Energy Model group alone, listing /// exactly these channels. pub(in super::super) fn read_of(pairs: &[(&str, i64)]) -> Read { @@ -825,70 +916,69 @@ mod liveness { /// mid-probe — is not live, however large the step. #[test] fn a_single_jump_is_a_batch_refresh_not_a_live_rail() { - let mut w = RailWatch::default(); - for v in [100, 100, 1_444_969, 1_444_969, 1_444_969, 1_444_969] { - w.observe(&sample(&[("CPU Energy", v)])); - } + let script: Vec<_> = + [100, 100, 1_444_969, 1_444_969, 1_444_969, 1_444_969].iter().map(|v| vec![("CPU Energy", *v)]).collect(); + let (w, live) = watch(LOADED, &script); assert_eq!(w.intervals(), 5); - assert!(!w.is_live(), "one refresh counted as a live rail"); + assert_eq!(first_live(&live), None, "one refresh counted as a live rail"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); } - /// The same rail advancing in two separate intervals is live — here - /// the second die, with a hold between its two advances — and not - /// after the first. + /// Two advances of the same rail — here the second die, with a hold + /// between them — are not live, however far apart; the same rail + /// advancing in five of six intervals over 600 ms is, and not before. #[test] - fn two_advances_of_the_same_rail_are_live() { - let mut w = RailWatch::default(); - w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 20)])); - w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 21)])); - assert!(!w.is_live(), "one advance is not yet live"); - assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 21)])); - assert!(!w.is_live(), "a hold is not an advance"); - w.observe(&sample(&[("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", 22)])); - assert!(w.is_live()); + fn two_advances_of_the_same_rail_are_not_live_and_sustained_ones_are() { + let script: Vec<_> = [20, 21, 21, 22, 23, 24, 25, 26] + .iter() + .map(|v| vec![("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", *v)]) + .collect(); + let (w, live) = watch(LOADED, &script[..4]); + assert_eq!((w.intervals(), w.verdict()), (3, Some(CpuRail::Frozen)), "two advances over 300 ms: {live:?}"); + let (w, live) = watch(LOADED, &script); + // Advances in intervals 1, 3, 4, 5, 6: the fifth closes at sample + // 6 (600 ms), where 5 of 6 intervals is over 80%. + assert_eq!(first_live(&live), Some(6), "{live:?}"); assert_eq!(w.verdict(), Some(CpuRail::Live)); } /// A rail that goes DOWN between consecutive samples — a reset or a /// wrap — did not advance. The rail that drops here has already - /// advanced once, so counting the drop would make it live. + /// advanced in four intervals over 400 ms, so counting the drop would + /// make it live. #[test] fn a_decrease_between_consecutive_samples_is_not_an_advance() { - let mut w = RailWatch::default(); - for v in [20, 21, 3] { - w.observe(&sample(&[("DIE_0_CPU Energy", 100), ("DIE_1_CPU Energy", v)])); - } - assert_eq!(w.intervals(), 2); - assert!(!w.is_live(), "a decrease counted as an advance"); + let values = [20, 21, 22, 23, 24, 3]; + let script: Vec<_> = + values.iter().map(|v| vec![("DIE_0_CPU Energy", 100), ("DIE_1_CPU Energy", *v)]).collect(); + let (w, live) = watch(LOADED, &script); + assert_eq!(w.intervals(), 5); + assert_eq!(first_live(&live), None, "a decrease counted as an advance"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - // The same through the loop: advance, drop, hold, for the whole budget. - let r = run(TICK, |n| Some(vec![("CPU Energy", [20, 21, 3][n.min(2) as usize])])); + // The same through the loop: four advances, a drop, then a hold + // for the whole budget. + let r = run(LOADED, |n| Some(vec![("CPU Energy", values[n.min(5) as usize])])); assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); - assert_eq!(r.intervals, 40, "{r:?}"); + assert_eq!((r.intervals, r.took), (10, BUDGET), "{r:?}"); } /// A rail absent from the previous sample only sets its baseline: its /// first appearance is not an advance, whatever its value. The rail - /// that appears here then advances once, so counting its appearance - /// would make it live — both for a new name and for a second channel - /// with a name already present. + /// that appears here then advances in four intervals over 500 ms, so + /// counting its appearance would make it live — both for a new name and + /// for a second channel with a name already present. #[test] fn a_rail_new_in_this_sample_only_sets_its_baseline() { - let mut w = RailWatch::default(); - w.observe(&sample(&[("CPU Energy", 1)])); - w.observe(&sample(&[("CPU Energy", 1), ("DIE_1_CPU Energy", 5)])); - w.observe(&sample(&[("CPU Energy", 1), ("DIE_1_CPU Energy", 6)])); - assert_eq!(w.intervals(), 2); - assert!(!w.is_live(), "a rail's first appearance counted as an advance"); - assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - - let mut w = RailWatch::default(); - w.observe(&sample(&[("CPU Energy", 1)])); - w.observe(&sample(&[("CPU Energy", 1), ("CPU Energy", 5)])); - w.observe(&sample(&[("CPU Energy", 1), ("CPU Energy", 6)])); - assert!(!w.is_live(), "a second same-named channel's first appearance counted as an advance"); + for name in ["DIE_1_CPU Energy", "CPU Energy"] { + let mut script = vec![vec![("CPU Energy", 1)]]; + for v in 5..=9 { + script.push(vec![("CPU Energy", 1), (name, v)]); + } + let (w, live) = watch(LOADED, &script); + assert_eq!(w.intervals(), 5); + assert_eq!(first_live(&live), None, "{name}: a rail's first appearance counted as an advance"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + } } /// A read keeps each Energy Model channel with its OWN raw integer, in @@ -930,14 +1020,12 @@ mod liveness { fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { let mut w = RailWatch::default(); assert_eq!(w.verdict(), None); - w.observe(&sample(&[("CPU Energy", 1)])); + w.observe(&sample(&[("CPU Energy", 1)]), BUDGET); assert_eq!(w.verdict(), None, "a baseline alone is not an interval"); - let mut w = RailWatch::default(); - for n in 0..3 { - w.observe(&sample(&[("GPU Energy", n), ("PCPU", n)])); - } - assert_eq!(w.intervals(), 2); - assert_eq!(w.verdict(), Some(CpuRail::Absent)); + let script: Vec<_> = (0..8).map(|n| vec![("GPU Energy", n), ("PCPU", n)]).collect(); + let (w, _) = watch(LOADED, &script); + assert_eq!(w.intervals(), 7); + assert_eq!(w.verdict(), Some(CpuRail::Absent), "a moving GPU rail and a CPU sub-rail are no CPU rail"); } /// (a) The M5 Max's refresh: `CPU Energy` holds at 480,083,160 mJ, @@ -976,9 +1064,10 @@ mod liveness { assert_eq!(r.intervals, 40, "{r:?}"); } - /// (c) Two channels both named `CPU Energy` stepping together, once, are - /// two rails that each advanced once — not one rail that advanced - /// twice. Frozen. + /// (c) Two channels both named `CPU Energy`, holding at different + /// values and stepping together once, are two rails, each compared with + /// its own previous value: keyed by name alone, the larger would read + /// as an advance over the smaller in every interval. Frozen. #[test] fn watch_two_same_named_rails_stepping_together_once_is_frozen() { let r = run(TICK, |n| { @@ -989,13 +1078,150 @@ mod liveness { assert_eq!(r.intervals, 40, "{r:?}"); } - /// (d) A rail that advances in every sample is Live after exactly two - /// intervals, and the watch stops sampling right there. + /// (d) A rail that advances in every interval, at the quiet host's + /// ~31 ms, is Live — but not before five advances AND 300 ms: it has + /// five advances at 155 ms and is still not live until the tenth + /// interval closes at 310 ms, where the watch stops sampling. Once + /// declared, Live stays: a later hold does not undo it. #[test] - fn watch_a_rail_advancing_every_sample_is_live_after_two_intervals() { - let r = run(TICK, |n| Some(vec![("CPU Energy", 1_000 + i64::from(n))])); + fn watch_a_rail_advancing_every_interval_is_live_only_after_five_advances_and_300_ms() { + let r = run(QUIET, |n| Some(vec![("CPU Energy", 1_000 + i64::from(n)), ("GPU Energy", 5)])); assert_eq!(r.verdict, Some(CpuRail::Live), "{r:?}"); - assert_eq!((r.intervals, r.samples, r.took), (2, 3, TICK * 2), "{r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (10, 11, QUIET * 10), "{r:?}"); + + // Eleven advances, then a hold long enough (from the 14th interval) + // to take the share under 80%. + let script: Vec<_> = (0..20).map(|n| vec![("CPU Energy", 1_000 + n.min(11))]).collect(); + let (w, live) = watch(QUIET, &script); + assert!(!live[5], "five advances in 155 ms counted as live"); + assert!(!live[9], "nine advances in 279 ms counted as live"); + assert_eq!(first_live(&live), Some(10), "{live:?}"); + assert!(live[10..].iter().all(|l| *l), "Live was undone by a later hold: {live:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + } + + /// (*) The refresh observed on an M5 Max (macOS 27.0.1), as the probe + /// sees it at ~31 ms an interval: `CPU Energy` steps +61,764,485 and, + /// one interval later, +3,435, then holds — and `MCPU0`, `MCPU1` and + /// `PCPU` step twice with it, while the GPU rail keeps moving. Two + /// advancing intervals of the same rail, which the "two advances" rule + /// called Live. Frozen, after the whole budget. + #[test] + fn watch_the_observed_two_step_refresh_is_frozen() { + const A: i64 = 2_643_843_378; + let step = |n: u32, first: i64, second: i64| match n { + 0..=2 => 0, + 3 => first, + _ => first + second, + }; + let r = run(QUIET, |n| { + Some(vec![ + ("MCPU0", 91_000_000 + step(n, 20_000_000, 1_000)), + ("MCPU1", 92_000_000 + step(n, 21_000_000, 1_100)), + ("PCPU", 400_000_000 + step(n, 20_764_485, 1_335)), + ("CPU Energy", A + step(n, 61_764_485, 3_435)), + ("GPU Energy", 851_622_003 + 28_000_000 * i64::from(n)), + ]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "a two-step refresh counted as a live rail: {r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (33, 34, QUIET * 33), "{r:?}"); + // The rule was handed the refresh exactly as observed. + let cpu: Vec = r.run.seen.iter().map(|s| s.cpu[0].1).collect(); + assert_eq!(&cpu[..6], &[A, A, A, 2_705_607_863, 2_705_611_298, 2_705_611_298]); + } + + /// (*) A refresh published in three, four or five steps within its + /// first 150 ms (30 ms intervals) — consecutive, or spread out — then + /// holding: Frozen. Five steps even meet the count, but never with + /// 300 ms of window at 80%. + #[test] + fn watch_a_refresh_in_three_to_five_steps_at_the_start_is_frozen() { + const EVERY: Duration = Duration::from_millis(30); + let patterns: [&[u32]; 5] = [&[1, 2, 3], &[1, 2, 3, 4], &[1, 2, 3, 4, 5], &[1, 3, 5], &[2, 3, 4, 5]]; + for steps in patterns { + let r = run(EVERY, move |n| { + let stepped = steps.iter().filter(|&&s| s <= n).count() as i64; + Some(vec![("CPU Energy", 2_643_843_378 + 1_000_000 * stepped), ("GPU Energy", i64::from(n))]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "a refresh in steps {steps:?} counted as live: {r:?}"); + assert_eq!(r.intervals, 34, "steps {steps:?}: {r:?}"); + } + } + + /// (*) The same two-step refresh in the M3 Ultra's shape (the two + /// steps were seen on an M5 Max; the M3 Ultra's dies were seen + /// stepping once, in one sample): `DIE_0_CPU Energy` and + /// `DIE_1_CPU Energy` each step twice within 64 ms (in consecutive + /// intervals, and two intervals apart), among unprefixed per-die + /// sub-rails listed once per die and a GPU rail that moves now and + /// then. Frozen. + #[test] + fn watch_m3_ultra_two_dies_each_stepping_twice_is_frozen() { + let r = run(QUIET, |n| { + let d0 = 9_494_336 + if n >= 3 { 1_344_000 } else { 0 } + if n >= 4 { 869 } else { 0 }; + let d1 = 10_212_656 + if n >= 3 { 1_391_000 } else { 0 } + if n >= 5 { 637 } else { 0 }; + let n = i64::from(n); + Some(vec![ + ("ECPUDTL00", 5_000 + n), + ("DIE_0_CPU Energy", d0), + ("ECPUDTL00", 7_000 + n), + ("DIE_1_CPU Energy", d1), + ("GPU Energy", 14_809_813 * (n / 5)), + ]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + assert_eq!(r.intervals, 33, "{r:?}"); + } + + /// A live rail that misses one interval in every five advances in 80% + /// of them: Live, at the tenth interval (310 ms), where it has 8 of 10 + /// — exactly 80%. One that misses two in every five (60%) is never + /// Live, over the whole budget. + #[test] + fn watch_a_live_rail_missing_one_interval_in_five_is_live_and_two_in_five_is_not() { + let misses_one = |n: u32| { + let advances = n - n / 5; // no advance in intervals 5, 10, 15, ... + Some(vec![("CPU Energy", 1_000 + i64::from(advances))]) + }; + let r = run(QUIET, misses_one); + assert_eq!(r.verdict, Some(CpuRail::Live), "{r:?}"); + assert_eq!((r.intervals, r.took), (10, QUIET * 10), "{r:?}"); + + let misses_two = |n: u32| { + let advances = n - n / 5 - (n + 2) / 5; // none in intervals 3, 5, 8, 10, ... + Some(vec![("CPU Energy", 1_000 + i64::from(advances))]) + }; + let r = run(QUIET, misses_two); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "a rail advancing in 60% of intervals counted as live: {r:?}"); + assert_eq!(r.intervals, 33, "{r:?}"); + } + + /// A rail that missed two of its first five intervals is not yet Live + /// — even at intervals 7, 8 and 9 of a loaded host, where it has five, + /// six and seven advances and 700–900 ms of window — until its share + /// of advancing intervals reaches 80%, at the tenth (8 of 10). + #[test] + fn watch_a_rail_missing_two_of_its_first_five_intervals_is_not_yet_live() { + // No advance in intervals 2 and 4, then one in every interval. + let values = [0, 1, 1, 2, 2, 3, 4, 5, 6, 7, 8, 9]; + let script: Vec<_> = values.iter().map(|v| vec![("CPU Energy", 1_000 + *v)]).collect(); + let (w, live) = watch(LOADED, &script); + assert_eq!(&live[..10], &[false; 10], "Live below 80% of intervals: {live:?}"); + assert_eq!(first_live(&live), Some(10), "{live:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + } + + /// A live rail on a loaded host, sampled every 100 ms, has 300 ms of + /// window after three intervals but is Live only at the fifth advance + /// (500 ms), where the probe stops. + #[test] + fn watch_a_live_rail_on_a_loaded_host_is_live_after_five_intervals() { + let r = run(LOADED, |n| Some(vec![("CPU Energy", 1_000 + i64::from(n))])); + assert_eq!(r.verdict, Some(CpuRail::Live), "{r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (5, 6, LOADED * 5), "{r:?}"); + let script: Vec<_> = (0..6).map(|n| vec![("CPU Energy", 1_000 + n)]).collect(); + let (_, live) = watch(LOADED, &script); + assert_eq!(live, [false, false, false, false, false, true], "Live before five advances"); } /// (e) Samples that keep failing are retried up to the five-budget cap @@ -1013,34 +1239,48 @@ mod liveness { assert_eq!((r.verdict, r.intervals, r.took), (None, 0, BUDGET * MAX_BUDGETS), "{r:?}"); } - /// (f) Past the budget, the watch stops once it has two intervals - /// (Frozen) and keeps sampling while it has fewer. Samples fail until - /// the clock is past the budget: at 1.2 s one interval → keep going; at - /// 1.6 s two → stop. + /// (f) Past the budget, the watch keeps sampling while it has fewer + /// than five intervals and stops once it has five (Frozen). Samples + /// fail until the clock is past the budget: at 1.2 s one interval, and + /// on through 2.4 s (four) → keep going; at 2.8 s five → stop. #[test] - fn watch_past_the_budget_stops_only_with_two_intervals() { - let r = run(Duration::from_millis(400), |n| (n == 0 || n >= 3).then(|| vec![("CPU Energy", 7)])); + fn watch_past_the_budget_stops_only_with_five_intervals() { + let r = run(ms(400), |n| (n == 0 || n >= 3).then(|| vec![("CPU Energy", 7)])); assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); - assert_eq!((r.intervals, r.samples, r.took), (2, 5, Duration::from_millis(1600)), "{r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (5, 8, ms(2800)), "{r:?}"); // A failed sample between two good ones is not an interval, and the - // next good one is compared with the last good one. + // next good one is compared with the last good one: a rail read + // every 50 ms is Live at the sixth interval (300 ms). let r = run(TICK, |n| (n % 2 == 0).then(|| vec![("CPU Energy", 1_000 + i64::from(n))])); assert_eq!(r.verdict, Some(CpuRail::Live), "{r:?}"); - assert_eq!((r.intervals, r.samples), (2, 5), "{r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (6, 13, ms(300)), "{r:?}"); } - /// (g) One step on each die, in different intervals, does not add up to - /// a live rail: liveness is per rail, not summed. Through the whole - /// loop, so a sum placed anywhere between the read and the rule reads - /// as two advances and fails here; and the record shows each die's own - /// values, as read. + /// (f') The cap: a watch that cannot get five intervals stops at five + /// budgets, Frozen — even when its rail advanced in every interval it + /// did get, since three advances are not a live rail. #[test] - fn watch_one_step_on_each_die_in_different_intervals_is_frozen() { + fn watch_short_of_five_intervals_stops_at_the_cap() { + // Only every fourth read succeeds: reads at 0, 1.6, 3.2 and 4.8 s, + // three intervals; the next read, at 5.2 s, fails and the cap ends it. + let r = run(ms(400), |n| (n % 4 == 0).then(|| vec![("CPU Energy", 1_000 + i64::from(n))])); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "{r:?}"); + assert_eq!((r.intervals, r.samples, r.took), (3, 14, ms(5200)), "{r:?}"); + assert!(r.took >= BUDGET * MAX_BUDGETS, "stopped before the cap: {r:?}"); + } + + /// (g) Two dies advancing in alternate intervals — each in half of + /// them — do not add up to a live rail: liveness is per rail, not + /// summed. Through the whole loop, so a sum placed anywhere between the + /// read and the rule advances in every interval, reads as live, and + /// fails here; and the record shows each die's own values, as read. + #[test] + fn watch_two_dies_advancing_in_alternate_intervals_is_frozen() { let script = |n: u32| { vec![ - ("DIE_0_CPU Energy", 100 + if n < 3 { 0 } else { 50 }), - ("DIE_1_CPU Energy", 200 + if n < 9 { 0 } else { 50 }), + ("DIE_0_CPU Energy", 100 + 50 * i64::from(n.div_ceil(2))), + ("DIE_1_CPU Energy", 200 + 50 * i64::from(n / 2)), ("GPU Energy", 1_000 + i64::from(n)), ] }; @@ -1056,44 +1296,49 @@ mod liveness { } /// The stopping rule on its own: within the budget until a rail proves - /// live; past the budget only until a live rail could have advanced - /// twice; and never past five budgets. + /// live; past the budget only until a live rail could have advanced in + /// five intervals; and never past five budgets. #[test] fn the_probe_samples_until_live_or_long_enough_to_tell() { let budget = Duration::from_millis(1000); let mut w = RailWatch::default(); assert!(w.wants_more(Duration::ZERO, budget)); assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); - // Slow samples: past the budget with fewer than two intervals, keep going. + // Slow samples: past the budget with fewer than five intervals, keep going. assert!(w.wants_more(budget * 2, budget), "no interval yet, past budget: keep going"); - w.observe(&sample(&[("CPU Energy", 1)])); - w.observe(&sample(&[("CPU Energy", 1)])); - assert!(w.wants_more(budget * 2, budget), "one interval, past budget: keep going"); - assert!(w.wants_more(budget * 5 - Duration::from_millis(1), budget), "just under the cap"); + for i in 0..5 { + w.observe(&sample(&[("CPU Energy", 1)]), ms(400) * i); + assert!(w.wants_more(budget * 2, budget), "{} interval(s), past budget: keep going", w.intervals()); + } + assert_eq!(w.intervals(), 4); + assert!(w.wants_more(budget * 5 - ms(1), budget), "four intervals, just under the cap"); assert!(!w.wants_more(budget * 5, budget), "hard cap: five budgets"); - w.observe(&sample(&[("CPU Energy", 1)])); - assert!(w.wants_more(budget / 2, budget), "two intervals, within budget: keep watching"); - assert!(!w.wants_more(budget, budget), "two intervals, budget spent: frozen"); + w.observe(&sample(&[("CPU Energy", 1)]), ms(2000)); + assert!(w.wants_more(budget / 2, budget), "five intervals, within budget: keep watching"); + assert!(!w.wants_more(budget, budget), "five intervals, budget spent: frozen"); assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap with no interval"); assert!(!RailWatch::default().wants_more(budget * 50, budget), "hard cap with no interval"); - // One advance is not a reason to stop early. - let mut once = RailWatch::default(); - once.observe(&sample(&[("CPU Energy", 1)])); - once.observe(&sample(&[("CPU Energy", 2)])); - assert!(once.wants_more(Duration::ZERO, budget), "one advance: keep watching"); + // Advances short of the rule are not a reason to stop early: two, + // or five in 155 ms. + let (two, _) = watch(QUIET, &[vec![("CPU Energy", 1)], vec![("CPU Energy", 2)], vec![("CPU Energy", 3)]]); + assert!(two.wants_more(Duration::ZERO, budget), "two advances: keep watching"); + let script: Vec<_> = (1..=6).map(|v| vec![("CPU Energy", v)]).collect(); + let (five, _) = watch(QUIET, &script); + assert!(five.wants_more(ms(155), budget), "five advances in 155 ms: keep watching"); - let mut live = RailWatch::default(); - for v in 1..=3 { - live.observe(&sample(&[("CPU Energy", v)])); - } - assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); + let script: Vec<_> = (1..=11).map(|v| vec![("CPU Energy", v)]).collect(); + let (live, _) = watch(QUIET, &script); + assert!(!live.wants_more(ms(310), budget), "live: stop at once"); } /// The record keeps, for every read, the wall and CPU clocks around it /// and what its sample listed; for every successful read, what the /// rule was handed. A failed read records its clocks and no sample. - /// Each re-read is preceded by exactly one tick. + /// Each re-read is preceded by exactly one tick. The rule is handed + /// each successful read's `end`: the window runs from the first read's + /// end (8 ms), so the rail, advancing at every successful read, is + /// Live at read 10 (330 ms of window), not read 9 (297 ms). #[test] fn the_probe_records_every_read_and_what_the_rule_was_handed() { let clock = Cell::new(Duration::ZERO); @@ -1104,10 +1349,10 @@ mod liveness { |_: &()| { let k = n.get(); n.set(k + 1); - clock.set(clock.get() + Duration::from_millis(8)); - cpu.set(cpu.get() + Duration::from_millis(7)); - // The third read fails; the rail advances at reads 1 and 3. - (k != 2).then(|| read_of(&[("CPU Energy", [5, 6, 0, 7][k as usize]), ("GPU Energy", i64::from(k))])) + clock.set(clock.get() + ms(8)); + cpu.set(cpu.get() + ms(7)); + // The third read fails; the rail advances at every other read. + (k != 2).then(|| read_of(&[("CPU Energy", 5 + i64::from(k)), ("GPU Energy", i64::from(k))])) }, || { clock.set(clock.get() + TICK); @@ -1117,13 +1362,12 @@ mod liveness { || Some(cpu.get()), BUDGET, ); - let ms = Duration::from_millis; assert_eq!(run.verdict, Some(CpuRail::Live)); - assert_eq!((run.intervals, run.ticks, run.calls.len()), (2, 3, 4)); + assert_eq!((run.intervals, run.ticks, run.calls.len()), (9, 10, 11)); let listed = Some(Listed { channels: 2, group_only: true }); assert_eq!( - run.calls, - vec![ + &run.calls[..4], + &[ SampleCall { start: ms(0), end: ms(8), cpu_before: Some(ms(0)), cpu_after: Some(ms(7)), listed }, SampleCall { start: ms(33), end: ms(41), cpu_before: Some(ms(32)), cpu_after: Some(ms(39)), listed }, SampleCall { @@ -1136,18 +1380,32 @@ mod liveness { SampleCall { start: ms(99), end: ms(107), cpu_before: Some(ms(96)), cpu_after: Some(ms(103)), listed }, ] ); + for (k, call) in run.calls.iter().enumerate() { + let k = k as u64; + assert_eq!( + *call, + SampleCall { + start: ms(33 * k), + end: ms(33 * k + 8), + cpu_before: Some(ms(32 * k)), + cpu_after: Some(ms(32 * k + 7)), + listed: (k != 2).then_some(Listed { channels: 2, group_only: true }), + }, + "read {k}" + ); + } let key = |name: &str| (name.to_string(), 0u32); - assert_eq!( - run.seen, - vec![ - Seen { cpu: vec![(key("CPU Energy"), 5)], gpu: vec![(key("GPU Energy"), 0)] }, - Seen { cpu: vec![(key("CPU Energy"), 6)], gpu: vec![(key("GPU Energy"), 1)] }, - Seen { cpu: vec![(key("CPU Energy"), 7)], gpu: vec![(key("GPU Energy"), 3)] }, - ] - ); - // Between successful reads, outside the reads: 32 - 7, and 96 - 39 - // (the failed read's 7 ms and a second tick lie in between). - assert_eq!(run.interval_cpu(), vec![Some(ms(25)), Some(ms(57))]); + let seen: Vec = [0i64, 1, 3, 4, 5, 6, 7, 8, 9, 10] + .iter() + .map(|k| Seen { cpu: vec![(key("CPU Energy"), 5 + k)], gpu: vec![(key("GPU Energy"), *k)] }) + .collect(); + assert_eq!(run.seen, seen); + // Between successful reads, outside the reads: 32 - 7, then 96 - 39 + // (the failed read's 7 ms and a second tick lie in between), then + // 25 ms each. + let mut cpu = vec![Some(ms(25)), Some(ms(57))]; + cpu.extend([Some(ms(25)); 7]); + assert_eq!(run.interval_cpu(), cpu); assert_eq!(run.median_read_cpu(), Some(ms(7))); assert_eq!(run.median_read_wall(), Some(ms(8))); assert_eq!(ProbeRun::default().median_read_cpu(), None); @@ -1655,8 +1913,8 @@ mod macos_ioreport { /// of the time still gets the full burn — giving up after /// [`TICK_WALL_CAP`] of wall time. Without a readable thread CPU clock it /// spins [`PROBE_TICK`] of wall time. The burn cannot manufacture `Live` - /// — only a CPU rail's own raw integer advancing twice can — so a short - /// burn errs only toward declining, the safe side. + /// — only a CPU rail's own raw integer that keeps advancing can — so a + /// short burn errs only toward declining, the safe side. pub(super) fn spin_tick() { burn_thread_cpu(PROBE_TICK, TICK_WALL_CAP); } @@ -2203,6 +2461,92 @@ mod macos_ioreport { CpuRailMoved(bool), } + /// The oracle's bar: a CPU rail it calls moving changed at no fewer + /// than this many of its reads (and at 80% of them). The tests' own + /// literal. + const ORACLE_MIN_CHANGES: u32 = 5; + + /// The most the oracle extends its window, in budgets, while short of + /// [`ORACLE_MIN_CHANGES`] reads. Its reads of every channel slow down + /// under IOReport contention (with an all-channel sampler running + /// beside the tests on a loaded M5 Max it needed 6 s for 5 of them), + /// and a floor of 5 reads needs room for 2.5 times the reads a floor of + /// 2 did under a cap of 3 budgets. Costs nothing while reads are fast: + /// the window is extended only while reads are too few. + const ORACLE_MAX_BUDGETS: u32 = 8; + + /// The oracle's count, kept apart from its reads so the tests can feed + /// it scripted ones: how many reads it compared with the read before, + /// and per CPU rail (in channel order) at how many of them the rail's + /// absolute value had changed. + #[derive(Debug)] + struct OracleTally { + reads: u32, + changes: Vec, + } + + impl OracleTally { + fn new(rails: usize) -> Self { + Self { reads: 0, changes: vec![0; rails] } + } + + /// One more read, `now`, against the read before it, `before`: the + /// rails' absolute values, in channel order. + fn add(&mut self, before: &[i64], now: &[i64]) { + self.reads += 1; + for (count, (now, before)) in self.changes.iter_mut().zip(now.iter().zip(before)) { + *count += u32::from(now != before); + } + } + + /// The oracle's verdict so far: one rail changed at no fewer than + /// [`ORACLE_MIN_CHANGES`] reads, and at at least 80% of them. + fn moved(&self) -> bool { + self.changes.iter().any(|&c| c >= ORACLE_MIN_CHANGES && c * 5 >= self.reads * 4) + } + } + + /// The oracle's bar on scripted reads, so a change to it is caught on + /// any host. A refresh, however it is published — one step, or the two + /// steps 64 ms apart observed on an M5 Max, which the oracle's reads of + /// every channel (0.1–0.8 s apart) see in one read or two — is never + /// "moved", however many reads follow; nor is a rail that changed at + /// four reads of four. A rail changing at every read is, from its fifth + /// read; one changing at 80% of them is, and at 70% is not. + #[test] + fn the_oracle_never_calls_a_refresh_moving() { + let tally = |reads: &[i64]| { + let mut t = OracleTally::new(1); + let mut moved = Vec::new(); + for pair in reads.windows(2) { + t.add(&pair[..1], &pair[1..]); + moved.push(t.moved()); + } + (t, moved) + }; + const A: i64 = 2_643_843_378; + let (b, c) = (A + 61_764_485, A + 61_764_485 + 3_435); + for (what, reads) in [ + ("one step", vec![A, A, c, c, c, c, c, c, c, c]), + ("two steps in consecutive reads", vec![A, A, b, c, c, c, c, c, c, c]), + ("two steps at the first two reads", vec![A, b, c, c, c, c, c]), + ("two steps, just two reads", vec![A, b, c]), + ] { + let (t, moved) = tally(&reads); + assert!(!moved.iter().any(|m| *m), "{what}: the oracle called a refresh moving: {t:?} {moved:?}"); + } + let (t, moved) = tally(&[1, 2, 3, 4, 5]); + assert!(!t.moved(), "four changes of four reads: {t:?}"); + assert_eq!(moved, [false; 4]); + let (t, _) = tally(&[1, 2, 3, 4, 5, 6]); + assert!(t.moved(), "a rail changing at every read, five reads: {t:?}"); + // 8 changes of 10 reads (80%), and 7 of 10 (70%). + let (t, _) = tally(&[1, 2, 3, 4, 5, 5, 6, 7, 8, 9, 9]); + assert!(t.moved(), "80% of reads: {t:?}"); + let (t, _) = tally(&[1, 2, 3, 3, 4, 5, 5, 6, 7, 7, 8]); + assert!(!t.moved(), "70% of reads: {t:?}"); + } + /// One oracle run: its verdict, how many reads it compared, whether any /// CPU rail it read sits in a group named `Energy Model`, and the /// channel counts of its first read. @@ -2223,18 +2567,24 @@ mod macos_ioreport { /// filter and die-prefix stripping, and burns CPU on a helper thread for /// the whole of its window while it reads. /// - /// `CpuRailMoved(true)` only if one rail's absolute value changed at two - /// or more separate reads. A live rail changes at nearly every read; a - /// batch-refreshed one at most once in a window this short, and one jump - /// is not a live reading, so a refresh landing in both the probe's and - /// the oracle's window cannot vouch for a false Live. Its own literal - /// threshold, not the implementation's constant, so a change to one - /// cannot silently move the other. + /// `CpuRailMoved(true)` only if one rail's absolute value changed at + /// [`ORACLE_MIN_CHANGES`] or more of its reads AND at at least 80% of + /// them. A live rail changes at nearly every read (the oracle's reads + /// of every channel are 0.1–0.8 s apart); a batch refresh changes a + /// rail at one read, or two when it is published in two steps (the M5 + /// Max's `CPU Energy`: two steps 64 ms apart), and then holds. So a + /// refresh landing in both the probe's and the oracle's window cannot + /// vouch for a false Live: it gives the oracle too few changes, and + /// too small a share of its reads. Its own literals, not the + /// implementation's constants, so a change to one cannot silently move + /// the other. /// - /// Evidence: the window is `budget`, extended (to at most three - /// budgets) only while the oracle has fewer than two reads after its - /// baseline. Fewer than two at the end is a test FAILURE, never - /// `CpuRailMoved(false)`: one read cannot show a rail changing twice. + /// Evidence: the window is `budget`, extended (to at most + /// [`ORACLE_MAX_BUDGETS`] budgets) only while the oracle has fewer than + /// [`ORACLE_MIN_CHANGES`] reads after its baseline — as many as a live + /// rail needs to meet the bar. Fewer at the end is a test FAILURE, + /// never `CpuRailMoved(false)`: too few reads cannot show a rail + /// keeping on moving. fn oracle_cpu_rail(budget: Duration) -> OracleRun { let none = |verdict, group| OracleRun { verdict, reads: 0, energy_model_rail: false, group }; let Some(sub) = OracleSub::open() else { @@ -2263,36 +2613,32 @@ mod macos_ioreport { }) }; let started = Instant::now(); - // Per rail: at how many reads its absolute value had changed since - // the read before. - let mut changes = vec![0u32; v0.len()]; - let (mut prev, mut reads, mut moved) = (v0, 0u32, false); - while !moved - && (started.elapsed() < budget || reads < 2) - && started.elapsed() < budget * 3 + let values = |rails: &[OracleRail]| rails.iter().map(|r| r.value).collect::>(); + let mut tally = OracleTally::new(v0.len()); + let mut prev = values(&v0); + while !tally.moved() + && (started.elapsed() < budget || tally.reads < ORACLE_MIN_CHANGES) + && started.elapsed() < budget * ORACLE_MAX_BUDGETS { - let Some(cur) = sub.read().map(|r| r.cpu) else { + let Some(cur) = sub.read().map(|r| values(&r.cpu)) else { continue; }; if cur.len() != prev.len() { continue; } - reads += 1; - for (count, (now, before)) in changes.iter_mut().zip(cur.iter().zip(prev.iter())) { - *count += u32::from(now.value != before.value); - } - moved = changes.iter().any(|&c| c >= 2); + tally.add(&prev, &cur); prev = cur; } stop.store(true, Ordering::Relaxed); let _ = burner.join(); let took = started.elapsed(); - eprintln!("ioreport oracle: {reads} reads in {took:?}, changes per CPU rail {changes:?}"); + let (reads, moved) = (tally.reads, tally.moved()); + eprintln!("ioreport oracle: {reads} reads in {took:?}, changes per CPU rail {:?}", tally.changes); assert!( - reads >= 2, + reads >= ORACLE_MIN_CHANGES, "the oracle got only {reads} read(s) of every channel in {took:?} (window {budget:?}, extended \ - to at most 3x while short of 2 reads): too few to tell a frozen CPU rail from a live one, so \ - this run has no evidence either way" + to at most {ORACLE_MAX_BUDGETS}x while short of {ORACLE_MIN_CHANGES} reads): too few for a live \ + CPU rail to show {ORACLE_MIN_CHANGES} changes, so this run has no evidence either way" ); OracleRun { verdict: Oracle::CpuRailMoved(moved), reads, energy_model_rail, group: Some(group) } } @@ -2370,14 +2716,20 @@ mod macos_ioreport { ) } - /// The tests' own reading of a probe record, with their own loop (not - /// `RailWatch`): the intervals between consecutive recorded samples, - /// and whether one CPU rail rose in two of them. The run's verdict must - /// agree with it. + /// The tests' own reading of a probe record, with their own loop and + /// their own literals (not `RailWatch` or its constants): the intervals + /// between consecutive recorded samples, each placed at the wall time + /// its closing read returned, and the first interval, if any, after + /// which one CPU rail had risen in at least 5 intervals, in at least + /// 80% of those so far, over at least 300 ms from the first sample. + /// The run's verdict must agree with it, and a Live run must have + /// stopped at that very interval. fn assert_verdict_matches_record(run: &ProbeRun, who: &str) { - assert_eq!(run.seen.len(), run.calls.iter().filter(|c| c.listed.is_some()).count(), "{who}: one record per sample"); + let at: Vec = run.calls.iter().filter(|c| c.listed.is_some()).map(|c| c.end).collect(); + assert_eq!(run.seen.len(), at.len(), "{who}: one record per sample"); let mut rises: Vec<(RailKey, u32)> = Vec::new(); - for pair in run.seen.windows(2) { + let mut first_live = None; + for (i, pair) in run.seen.windows(2).enumerate() { for (key, now) in &pair[1].cpu { let rose = pair[0].cpu.iter().any(|(k, before)| k == key && now > before); match rises.iter_mut().find(|(k, _)| k == key) { @@ -2385,17 +2737,29 @@ mod macos_ioreport { None => rises.push((key.clone(), u32::from(rose))), } } + let intervals = i as u32 + 1; + let window = at[i + 1].saturating_sub(at[0]); + if first_live.is_none() + && window >= Duration::from_millis(300) + && rises.iter().any(|(_, n)| *n >= 5 && *n * 5 >= intervals * 4) + { + first_live = Some(intervals); + } } let intervals = run.seen.len().saturating_sub(1); - let live = rises.iter().any(|(_, n)| *n >= 2); assert_eq!(run.intervals as usize, intervals, "{who}: intervals, against its own record"); if intervals > 0 { assert_eq!( run.verdict == Some(CpuRail::Live), - live, - "{who}: verdict {:?}, yet by its own record the CPU rails rose {rises:?}", - run.verdict + first_live.is_some(), + "{who}: verdict {:?}, yet by its own record the CPU rails rose {rises:?} in {intervals} intervals \ + over {:?}", + run.verdict, + at[intervals].saturating_sub(at[0]) ); + if let Some(n) = first_live { + assert_eq!(n as usize, intervals, "{who}: Live by interval {n}, yet it sampled on to {intervals}"); + } } else { assert_eq!(run.verdict, None, "{who}: a verdict with no interval"); } @@ -2473,8 +2837,8 @@ mod macos_ioreport { } Oracle::CpuRailMoved(true) => { // A live rail. The probe had a third of the oracle's time: - // selected if its own record shows a CPU rail rising in two - // intervals (checked above), declined — the safe side — if + // selected if its own record shows a CPU rail that kept + // rising (checked above), declined — the safe side — if // not. It sampled a rail either way. if oracle.energy_model_rail { assert!( @@ -2934,19 +3298,39 @@ mod macos_ioreport { vec![("CPU Energy", 2_588_973_125), ("GPU Energy", 126_266_724_972_887 + i64::from(n))] } - /// One CPU rail advancing at every read. + /// One CPU rail advancing at every read, for as long as it is read. fn advancing(n: u32) -> Vec<(&'static str, i64)> { vec![("CPU Energy", 1_000 + i64::from(n)), ("GPU Energy", 5)] } - /// An M3 Ultra's two dies, each stepping once in a different interval, - /// among a per-die sub-rail listed once per die and a moving GPU rail. + /// The refresh observed on an M5 Max (macOS 27.0.1), published in two + /// steps: `CPU Energy` +61,764,485 at read 3, +3,435 at read 4, then + /// held; `MCPU0`, `MCPU1` and `PCPU` step twice with it, and the GPU + /// rail moves every read. + fn two_step_refresh(n: u32) -> Vec<(&'static str, i64)> { + let step = |first: i64, second: i64| match n { + 0..=2 => 0, + 3 => first, + _ => first + second, + }; + vec![ + ("MCPU0", 91_000_000 + step(20_000_000, 1_000)), + ("MCPU1", 92_000_000 + step(21_000_000, 1_100)), + ("PCPU", 400_000_000 + step(20_764_485, 1_335)), + ("CPU Energy", 2_643_843_378 + step(61_764_485, 3_435)), + ("GPU Energy", 851_622_003 + 28_000_000 * i64::from(n)), + ] + } + + /// An M3 Ultra's two dies advancing in alternate intervals — each in + /// half of them, together in every one — among a per-die sub-rail + /// listed once per die and a moving GPU rail. fn two_dies(n: u32) -> Vec<(&'static str, i64)> { vec![ ("ECPUDTL00", 5_000), - ("DIE_0_CPU Energy", 9_494_336 + if n < 3 { 0 } else { 1_344_869 }), + ("DIE_0_CPU Energy", 9_494_336 + 1_344 * i64::from(n.div_ceil(2))), ("ECPUDTL00", 7_000), - ("DIE_1_CPU Energy", 10_212_656 + if n < 9 { 0 } else { 1_391_637 }), + ("DIE_1_CPU Energy", 10_212_656 + 1_391 * i64::from(n / 2)), ("GPU Energy", 14_809_813 + i64::from(n)), ] } @@ -2991,37 +3375,70 @@ mod macos_ioreport { assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "a rail that never moved"); assert_eq!(hs.status, IoReportStatus::CpuRailFrozen); assert!(!admits(hs.status), "a constant rail admitted the meter"); - assert!(hs.run.intervals >= 2, "frozen on {} interval(s)", hs.run.intervals); + assert!(hs.run.intervals >= 5, "frozen on {} interval(s)", hs.run.intervals); assert_handed_the_script(&hs.run, constant); + assert_verdict_matches_record(&hs.run, "scripted constant"); } - /// `host_status()` on a CPU rail that advances at every read: Live after - /// exactly two intervals, and admitted. + /// `host_status()` on a CPU rail that keeps advancing, at every read: + /// Live, admitted — and only once the rail has advanced in at least + /// five intervals over at least 300 ms of the real wall clock (the + /// tests' own literals), at the first interval where that holds, where + /// it stops at once. #[test] - fn host_status_calls_two_advances_of_one_rail_live() { + fn host_status_calls_a_sustained_advance_of_one_rail_live() { let hs = host_status_scripted(advancing); - eprintln!("ioreport scripted: two advances -> status={:?} reads={} ticks={}", hs.status, hs.run.calls.len(), hs.run.ticks); - assert_eq!(hs.run.verdict, Some(CpuRail::Live), "a rail advancing at every read"); - assert_eq!((hs.run.intervals, hs.run.calls.len(), hs.run.ticks), (2, 3, 2), "Live after two intervals, at once"); + let run = &hs.run; + let window = run.calls.last().map(|c| c.end).unwrap_or_default().saturating_sub(run.calls[0].end); + let line = assert_sampling(run, "scripted sustained advance", &scripted_group(advancing)); + eprintln!( + "ioreport scripted: sustained advance -> status={:?} intervals={} window={window:?}; {line}", + hs.status, run.intervals + ); + assert_eq!(run.verdict, Some(CpuRail::Live), "a rail advancing at every read"); assert_eq!(hs.status, IoReportStatus::Live); assert!(admits(hs.status), "a live rail not admitted"); - assert_handed_the_script(&hs.run, advancing); - let cpu = hs.run.interval_cpu(); + assert!(run.intervals >= 5, "Live after {} interval(s)", run.intervals); + assert!(window >= Duration::from_millis(300), "Live after {window:?} of window"); + assert_eq!((run.calls.len(), run.ticks as usize), (run.intervals as usize + 1, run.intervals as usize)); + assert_verdict_matches_record(run, "scripted sustained advance"); + assert_handed_the_script(run, advancing); + let cpu = run.interval_cpu(); assert!(cpu.iter().all(|c| c.is_some_and(|c| c >= TICK_CPU / 2)), "the real tick did not burn: {cpu:?}"); } - /// `host_status()` on two dies that each step once, in different - /// intervals: Frozen. A sum of the CPU rails placed anywhere between the - /// raw read and the rule reads as one rail rising twice and fails here, - /// on any host; so does any rail handed a value other than its own. + /// `host_status()` on the two-step refresh observed on an M5 Max: the + /// same rail advances in two consecutive intervals and then holds. + /// `CpuRailFrozen`, declined, after the whole budget, with the real + /// tick burned in every interval and the rule handed exactly what was + /// read. The "two advances" rule called this Live. #[test] - fn host_status_calls_one_step_on_each_die_in_different_intervals_frozen() { + fn host_status_calls_a_two_step_refresh_frozen() { + let hs = host_status_scripted(two_step_refresh); + let line = assert_sampling(&hs.run, "scripted two-step refresh", &scripted_group(two_step_refresh)); + eprintln!("ioreport scripted: two-step refresh -> status={:?}; {line}", hs.status); + assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "a two-step refresh counted as a live rail"); + assert_eq!(hs.status, IoReportStatus::CpuRailFrozen); + assert!(!admits(hs.status), "a two-step refresh admitted the meter"); + assert!(hs.run.intervals >= 5, "frozen on {} interval(s)", hs.run.intervals); + assert_handed_the_script(&hs.run, two_step_refresh); + assert_verdict_matches_record(&hs.run, "scripted two-step refresh"); + } + + /// `host_status()` on two dies advancing in alternate intervals: each + /// die in half of them, so Frozen. A sum of the CPU rails placed + /// anywhere between the raw read and the rule advances in every + /// interval, reads as live and fails here, on any host; so does any + /// rail handed a value other than its own. + #[test] + fn host_status_calls_two_dies_advancing_in_alternate_intervals_frozen() { let hs = host_status_scripted(two_dies); let line = assert_sampling(&hs.run, "scripted two dies", &scripted_group(two_dies)); eprintln!("ioreport scripted: two dies -> status={:?}; {line}", hs.status); - assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "one step on each die added up to a live rail"); + assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "two dies' alternate advances added up to a live rail"); assert_eq!(hs.status, IoReportStatus::CpuRailFrozen); assert_handed_the_script(&hs.run, two_dies); + assert_verdict_matches_record(&hs.run, "scripted two dies"); } #[test] @@ -3054,9 +3471,9 @@ mod macos_ioreport { /// thread has the status, the selection costs it under 10 ms of its own /// CPU (tests' clock), and on a declined host so do `power_over` and /// `measure_marginal_uj`, which must give no figure. Re-running the - /// probe by any route costs at least three reads of the Energy Model - /// group and two 25 ms ticks of this thread's CPU, and on a frozen rail - /// about a second of it. Thread CPU, not wall time, so host load and + /// probe by any route costs at least six reads of the Energy Model + /// group and five 25 ms ticks of this thread's CPU, and on a frozen + /// rail about a second of it. Thread CPU, not wall time, so host load and /// other IOReport readers cannot fail it. On a live host `power_over` /// and `measure_marginal_uj` do real sampling work, so there only the /// selection is timed and the status must be `Live`. @@ -3353,26 +3770,45 @@ mod tests { assert_eq!((reads, ticks, run.intervals), (41, 40, 40)); } - /// The composition on a rail that advances at every read: Live after two - /// intervals, admitted. + /// The composition on a rail that advances at every read: Live once it + /// has advanced in five intervals over 300 ms — at 25 ms an interval, the + /// twelfth — and admitted. #[test] - fn the_composition_on_two_advances_is_live() { + fn the_composition_on_a_sustained_advance_is_live() { let (status, run, reads, ticks) = composed(true, |n| Some(vec![("CPU Energy", 1_000 + i64::from(n))])); assert_eq!(run.verdict, Some(CpuRail::Live), "{run:?}"); assert_eq!(status, IoReportStatus::Live); assert!(admits(status)); - assert_eq!((reads, ticks, run.intervals), (3, 2, 2)); + assert_eq!((reads, ticks, run.intervals), (13, 12, 12)); } - /// The composition on two dies that each step once, in different - /// intervals: Frozen — liveness is per rail, and nothing between the read - /// and the rule sums them. + /// The composition on the observed two-step refresh: Frozen, declined, + /// after the whole budget. #[test] - fn the_composition_on_one_step_per_die_is_frozen() { + fn the_composition_on_a_two_step_refresh_is_frozen() { + let (status, run, reads, ticks) = composed(true, |n| { + let step = match n { + 0..=2 => 0, + 3 => 61_764_485, + _ => 61_764_485 + 3_435, + }; + Some(vec![("CPU Energy", 2_643_843_378 + step), ("GPU Energy", i64::from(n))]) + }); + assert_eq!(run.verdict, Some(CpuRail::Frozen), "{run:?}"); + assert_eq!(status, IoReportStatus::CpuRailFrozen); + assert!(!admits(status)); + assert_eq!((reads, ticks, run.intervals), (41, 40, 40)); + } + + /// The composition on two dies advancing in alternate intervals: Frozen — + /// liveness is per rail, and nothing between the read and the rule sums + /// them (their sum advances in every interval). + #[test] + fn the_composition_on_two_dies_advancing_in_alternate_intervals_is_frozen() { let (status, run, _, _) = composed(true, |n| { Some(vec![ - ("DIE_0_CPU Energy", 100 + if n < 3 { 0 } else { 50 }), - ("DIE_1_CPU Energy", 200 + if n < 9 { 0 } else { 50 }), + ("DIE_0_CPU Energy", 100 + 50 * i64::from(n.div_ceil(2))), + ("DIE_1_CPU Energy", 200 + 50 * i64::from(n / 2)), ]) }); assert_eq!(run.verdict, Some(CpuRail::Frozen), "{run:?}"); diff --git a/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs b/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs index 3a97a7d26..f159810ed 100644 --- a/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs +++ b/proof/proof-rs/crates/proof-cascade/tests/ioreport_once.rs @@ -52,8 +52,8 @@ fn thread_cpu() -> Duration { /// The probe must have run exactly once for all of them. Once a thread has its /// status, its selection calls must be warm: together they may not burn 10 ms /// of that thread's CPU, where re-running the probe by any route costs at -/// least three reads of the Energy Model group (~7 ms of thread CPU each) and -/// normally ~1 s of burn. +/// least six reads of the Energy Model group (~7 ms of thread CPU each) and +/// five 25 ms ticks, and normally ~1 s of burn. #[test] fn the_liveness_probe_runs_once_per_process() { assert_eq!(real_probe_runs(), 0, "the meter was used in this process before this test: this is not its first use"); -- 2.54.0 (Apple Git-157) From a96c6154c498d89b1d423cfc93fbf990105dc855 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 20:01:24 -0400 Subject: [PATCH 07/10] proof: a live vendor counter's reading is HwShunt, and the docs say so The owner settled the grade question this branch left open: a vendor energy counter (RAPL, NVML, Apple IOReport) gives a real reading; what it lacks is independent provenance, not accuracy. So a figure read from a counter that is live for the domain it claims is HwShunt. proof-cli already tagged a --measure figure that way, but the crate still said elsewhere that a macOS reading was ModelBased, and the CLI's own comment called the grade "an open grade decision, not settled here". This commit makes the specification, the schema and the docs agree with the code, and adds the liveness requirement where the grades are defined. No behaviour changes; one test is tightened. What changed: - SPEC.md section 5 and proof-schema's Provenance::HwShunt doc. HwShunt now names vendor energy counters next to a shunt, a power meter and the SMC. Both carry the family's liveness MUST word for word: a vendor counter's reading MAY be graded as a hardware reading only while the counter is demonstrably live for the domain the figure claims, observed advancing under load (for Apple IOReport, the CPU energy rail itself, in at least 5 sample intervals, in at least 4 of every 5, over at least 300 ms, while the probing thread burns CPU). A counter that is present but not live, frozen or republished in lumps, MUST NOT produce a hardware-graded figure. The spec adds that the fallback here is ModelBased or Estimator. Energy::measured's doc points at the requirement. No conformance vector is touched. - proof-cascade meter.rs. The module header said "a macOS reading is ModelBased, not a true coulomb-shunt HwShunt; Linux RAPL is the shunt-grade path". It now says a figure from this meter is HwShunt on both platforms, states the liveness condition, and says what each platform does about it. The docs on measure_marginal_uj and power_over say the same, and power_over's doc gives the probe's thresholds. The Linux section is new and honest about a gap: there is no liveness probe for RAPL. It is selected whenever a zone is readable, a frozen counter gives Some(0), which is not a figure, and nothing tells a counter that tracks the load from one that drops a lump into the busy window. - meter.rs's RAPL comment said energy_uj is "world-readable on most distros". It has not been since late 2020: kernel commit 949dd0104c49 ("powercap: restrict energy meter to root access", 5.10, backported to stable) made it root-only as the CVE-2020-8694 / PLATYPUS mitigation. An unprivileged process usually reads no zone, so the meter declines unless it runs as root or read access has been granted. - proof-cli main.rs. The module doc called T0 and T1 "both real with measured energy". Their energy is proof-solvers' wall time x a ~15 W power model, tagged ModelBased. It also said "T2/T3 reference stand-ins", but the CLI runs resident_z3_ladder, which has no T2/T3 rung. Those three goals come back unproved, as `proof` prints ("proved 6/9 ... T2=0 T3=0"). Both are fixed, and the --measure comment now states the grade. - proof/README.md. It said `--measure` gives an HwShunt reading "where an energy interface is present". It now says where the meter produces one, which on Apple Silicon needs a CPU rail seen to be live. It adds that on the Macs measured so far (macOS 27.0.1, M5 Max and M3 Ultra) --measure declines and every figure stays ModelBased, and that RAPL is root-only on Linux. It no longer says the solver rungs have "measured energy". It no longer says clippy is warning-free: that holds for the Linux target, but on macOS proof-cascade's IOReport FFI has two lints. (The "via macmon" text was already gone in bedef9b0.) - proof-cli tests/measure.rs. bedef9b0 had already moved this test off a second probe in the test process and onto the decision the CLI prints. But the printed status was tied to the CLI's behaviour for only two of its four values. A CLI that printed ioreport_status=CpuRailAbsent while acting on CpuRailFrozen passed: run against the unmodified test at 906ed1a6, it gave "ok. 1 passed". Now every IOReport reason in the output must belong to the printed status. A declined meter must print its own status's reason. The status must be one of the four. Off macOS it must be Unavailable, with no IOReport reason. Verified on the M5 Max (macOS 27.0.1, z3 present). The whole proof-rs workspace passes: proof-cascade 68, ioreport_once 1, ioreport_unopenable 1, measure 1, conformance pack 4 + soundness 5 + lib 1, receipt 8, schema 6, solvers 11. proof-conformance prints "all checks passed". The measure test passed 3 runs, each "declined=true available=false ioreport_status=CpuRailFrozen". clippy --no-deps --tests shows only the two existing proof-cascade lints, meter.rs's duplicated #[link] attribute and its hand-built nul-terminated string. It is clean for x86_64-unknown-linux-gnu, which type-checks the test's off-macOS branch but does not run it. rustdoc reports no warnings. Falsified, each mutation restored from a saved copy and its hash checked afterwards: - the CLI prints IoReportStatus::CpuRailAbsent in place of the status it used: "the CLI printed ioreport_status=CpuRailAbsent but gave the reason for CpuRailFrozen"; - prints Unavailable: "... printed ioreport_status=Unavailable but gave the reason for CpuRailFrozen"; - prints Live: "on macOS the meter is available exactly when IOReport is Live"; - prints !available: "the CLI's output disagrees with the decision it printed"; - the frozen reason is replaced by a generic one: "the meter declined with ioreport_status=CpuRailFrozen, but not for that status's reason". Not done here: - a RAPL liveness probe. Under the new MUST, the Linux path's only evidence is the bracket. Co-Authored-By: Claude Opus 5.5 --- proof/README.md | 14 +++-- proof/SPEC.md | 18 +++++- .../crates/proof-cascade/src/meter.rs | 46 ++++++++++++--- proof/proof-rs/crates/proof-cli/src/main.rs | 25 ++++---- .../crates/proof-cli/tests/measure.rs | 59 +++++++++++++++---- proof/proof-rs/crates/proof-schema/src/lib.rs | 19 +++++- 6 files changed, 140 insertions(+), 41 deletions(-) diff --git a/proof/README.md b/proof/README.md index 53355bd7d..813249cc6 100644 --- a/proof/README.md +++ b/proof/README.md @@ -36,15 +36,17 @@ proof/ ### The reference implementation (`proof-rs/`) - **`proof-schema`** — the content-addressed goal (`blake3(statement‖0x00‖context)`), the cheapest-first tier ladder, the honest energy model (`HwShunt`/`ModelBased`/`Estimator` with the coupling enforced), `SpecGrounding`, the verdict, and the `Solver` trait every backend implements. Canonical det-CBOR content addressing. -- **`proof-solvers`** — two **real** rungs with measured energy: a propositional decider (T0) that parses a boolean goal and decides validity by truth-table in process, and `z3` (T1) — shell or kept **resident** across goals — that decides an SMT-LIB goal (assert the negation; `unsat` ⇒ valid). Each refuses rather than fakes, so the router escalates. The model rungs (T2/T3) are honest stand-ins behind the same trait. -- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while its CPU energy rail is live; RAPL on Linux) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. Returns nothing where no interface is reachable — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. +- **`proof-solvers`** — two **real** rungs, their energy modeled from measured wall time (`ModelBased`, a ~15 W power model — not a hardware reading): a propositional decider (T0) that parses a boolean goal and decides validity by truth-table in process, and `z3` (T1) — shell or kept **resident** across goals — that decides an SMT-LIB goal (assert the negation; `unsat` ⇒ valid). Each refuses rather than fakes, so the router escalates. The model rungs (T2/T3) are honest stand-ins behind the same trait. +- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while its CPU energy rail is live; RAPL on Linux) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. A vendor counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5): on Apple Silicon the meter checks that before it brackets anything; on Linux it has no separate liveness probe yet, and its module doc says what the bracket alone does and does not show. Returns nothing where no interface is reachable or, on Apple Silicon, the CPU rail is not live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. - **`proof-receipt`** — the `proof/1` JCR-1 receipt: canonical det-CBOR + COSE_Sign1/EdDSA, vendored byte-compatible with the family's cross-language verifiers; refuses to seal an unproved goal. - **`proof-conformance`** — C1–C5 + a negative pack, all checks real, published vectors in `conformance/v1/`. -- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline, the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`, where an energy interface is present). +- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline, the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; on Apple Silicon that needs a CPU energy rail the meter has seen to be live. On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. -`cargo test --workspace` green; `cargo clippy --workspace --all-targets` -warning-free. The two bottom rungs decide for real with measured energy; the -model rungs are honestly marked as stand-ins until inference backends are wired. +`cargo test --workspace` green; `cargo clippy --workspace --all-targets` is +warning-free for Linux, and on macOS reports two lints in proof-cascade's +IOReport FFI. The two bottom rungs decide for real, with energy modeled from +wall time; the model rungs are honestly marked as stand-ins until inference +backends are wired. ## Honesty the standard records diff --git a/proof/SPEC.md b/proof/SPEC.md index d95bcfda0..80c5de00c 100644 --- a/proof/SPEC.md +++ b/proof/SPEC.md @@ -86,8 +86,10 @@ The normative requirement is that the figure is *reported and honestly tagged* Every reading is integer microjoules with a provenance tag: -- `HwShunt` — read from a hardware energy interface (RAPL/NVML/SMC). REQUIRES a - present measured figure. +- `HwShunt` — read from a hardware energy interface: a shunt or power meter, the + SMC, or a vendor energy counter (RAPL, NVML, Apple IOReport, ...). REQUIRES a + present measured figure. A vendor counter's reading is a hardware reading, + subject to the liveness requirement below. - `ModelBased` — computed from a calibrated per-operation model (e.g. measured wall time × a power model). A measured figure MAY be absent. - `Estimator` — a coarse constant. A measured figure MUST be absent. @@ -95,6 +97,18 @@ Every reading is integer microjoules with a provenance tag: A conforming implementation MUST enforce this coupling: `HwShunt` without a measured figure, or `Estimator` with one, is invalid. +**Liveness.** A reading from a vendor energy counter (RAPL, NVML, Apple +IOReport, ...) MAY be graded as a hardware reading only while the counter is +demonstrably live for the domain the figure claims: the implementation MUST +have observed the counter covering that domain advance under load (for Apple +IOReport: the CPU energy rail itself advancing in at least 5 sample intervals, +in at least 4 of every 5, over at least 300 ms while the probing thread burns +CPU). A counter that is present but not live - frozen, or republished in +lumps - MUST NOT produce a hardware-graded figure; the implementation falls +back to its estimator or reports the work unmetered, and SHOULD record why. In +this standard a hardware-graded figure is `HwShunt`; the fallback is +`ModelBased` or `Estimator`. + Batch accounting reports, against a **frontier-only baseline** (every goal costed at T3), the total joules, the savings, and the energy attributed to each *closing* tier. The per-tier breakdown is REQUIRED so that Amdahl behaviour — diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index 51b7c40e2..9f8254989 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -6,8 +6,17 @@ //! microjoules of one `work` call. Returns `None` where no interface is //! reachable — the caller keeps the honestly-tagged `ModelBased` reading rather //! than inventing a number. No elevated privileges and no external binaries. -//! (Apple's Energy Model is a vendor model, so a macOS reading is `ModelBased`, -//! not a true coulomb-shunt `HwShunt`; Linux RAPL is the shunt-grade path.) +//! +//! **A figure from this meter is a hardware reading, `HwShunt`, on both +//! platforms.** IOReport's Energy Model and RAPL are vendor energy counters, +//! not a shunt on the supply, and a vendor counter's reading is a real +//! reading: what it lacks is independent provenance, not accuracy. The grade +//! holds only while the counter is demonstrably live for the domain the figure +//! claims (SPEC §5): the implementation must have observed the counter covering +//! that domain advance under load. A counter that is present but not live — +//! frozen, or republished in lumps — must not produce a hardware-graded figure. +//! On Apple Silicon this meter returns `None` for such a counter and the +//! caller keeps its own estimate; on Linux it checks less (below). //! //! **On Apple Silicon, IOReport counts as reachable only while its CPU energy //! rail is live** ([`ioreport_status`](crate::meter::ioreport_status)). A @@ -25,6 +34,16 @@ //! far does; otherwise `None` is returned and the caller keeps its //! `ModelBased` figure. //! +//! **On Linux there is no such probe.** RAPL is selected whenever a zone's +//! `energy_uj` is readable by this process (root-only on current kernels, so +//! an unprivileged run usually finds none and gets `None`). The only liveness +//! evidence this path gathers is the bracket itself: a frozen counter gives +//! `Some(0)`, which is not a figure, and a non-zero figure means the zones' +//! counters advanced faster across the busy window than across the idle one. +//! That does not tell a counter that tracks the load from one that published +//! a lump inside the busy window; a multi-interval probe like the macOS one +//! has not been built for RAPL. +//! //! The chain on macOS is short on purpose. `host_status` is one expression: the //! generic probe loop (`liveness::probe_with`) on the real parts — the probe's //! own Energy Model subscription, the thread-CPU tick and the clocks — and the @@ -48,7 +67,10 @@ use std::sync::Arc; use std::time::Instant; /// Marginal energy of one `work()` call in microjoules, from a hardware -/// interface (`HwShunt`). +/// interface. A non-zero figure is a hardware reading, `HwShunt`, on both +/// platforms: on Apple Silicon it is only ever produced through a CPU energy +/// rail the liveness probe saw keep advancing under load; on Linux, from RAPL +/// with no probe beyond the bracket itself (see the module doc). /// /// - `None` if no interface is reachable ([`hardware_meter_available`]; on /// Apple Silicon that means IOReport with a live CPU energy rail, so a frozen @@ -1977,9 +1999,13 @@ mod macos_ioreport { } /// Average SoC power (watts) over `d`: two Energy-Model samples bracketing a - /// sleep, energy delta ÷ window. `None` if IOReport is unavailable, its CPU - /// energy rail is not live (the selection, [`super::hardware_meter_available`], - /// declines), or the signal is empty. + /// sleep, energy delta ÷ window. A hardware reading — the bracket built on + /// it is graded `HwShunt` — and so produced only while the CPU energy rail + /// is live: [`super::ioreport_status`] is `Live`, meaning the probe saw that + /// rail advance in at least five sample intervals, in at least 80% of them, + /// over at least 300 ms, while it burned CPU. `None` if IOReport is + /// unavailable, its CPU energy rail is not live (the selection, + /// [`super::hardware_meter_available`], declines), or the signal is empty. pub fn power_over(d: Duration) -> Option { // Declined before sampling: on a frozen CPU rail the summed delta below // would be GPU energy, or zero, attributed to CPU work. @@ -3533,8 +3559,12 @@ mod macos_ioreport { } // --------------------------------------------------------------------------- -// Linux — RAPL package energy counter (world-readable on most distros). -// Power = energy delta over a timed window. +// Linux — RAPL package energy counter. Power = energy delta over a timed +// window. `energy_uj` is root-only on mainline kernels since late 2020 +// (commit 949dd0104c49, "powercap: restrict energy meter to root access", in +// 5.10 and backported to stable: the CVE-2020-8694 / PLATYPUS mitigation). An +// unprivileged process therefore usually reads no zone, and the meter declines +// unless it runs as root or an administrator has granted read access. // --------------------------------------------------------------------------- #[cfg(target_os = "linux")] fn idle_power() -> Option { diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index e5590f710..881152b94 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -1,11 +1,14 @@ //! `proof` — a headless demonstration of the energy-optimized cascade. //! -//! Runs a proof workload through the cheapest-sufficient ladder (T0 propositional -//! decider + T1 resident `z3`, both real with measured energy; T2/T3 reference -//! stand-ins) and reports, per goal, the tier that closed it and the joules it -//! cost; then the headline — joules spent versus a frontier-only run — the -//! Amdahl breakdown by tier, the cache flywheel, and a sealed + verified JCR-1 -//! receipt. +//! Runs a proof workload through the cheapest-sufficient ladder — the T0 +//! propositional decider and T1 resident `z3`, both real deciders whose energy +//! is modeled, not measured: each attempt's wall time × proof-solvers' ~15 W +//! power model, tagged `ModelBased`. There is no T2/T3 rung, so goals that need +//! one come back unproved. It reports, per goal, the tier that closed it and +//! the joules it cost; then the headline — joules spent versus a frontier-only +//! run — the Amdahl breakdown by tier, the cache flywheel, and a sealed + +//! verified JCR-1 receipt. Only `--measure` attempts a hardware reading, and on +//! Apple Silicon only through a CPU energy rail the meter has seen to be live. use ed25519_dalek::SigningKey; use proof_schema::{GoalKind, ProofGoal}; @@ -91,11 +94,11 @@ fn main() { // Bracket a real T0 proof against idle on the platform's energy interface // (IOReport on Apple Silicon, used only while its CPU energy rail is live; // RAPL on Linux) and report the marginal joules. The figure is tagged - // `Energy::measured`, i.e. HwShunt, on both platforms. meter.rs's header - // notes that Apple's Energy Model is a vendor model; which tier a live - // IOReport reading earns is an open grade decision, not settled here. - // Where no interface is usable, print why: the figures above then stay - // ModelBased. + // `Energy::measured`, i.e. HwShunt, on both platforms: a vendor energy + // counter's reading is a hardware reading while the counter is live for + // the domain it claims (SPEC §5), which on Apple Silicon the meter checks + // before it brackets anything. Where no interface is usable, print why: + // the figures above then stay ModelBased. if measure { use proof_cascade::meter::{hardware_meter_available, ioreport_status, measure_marginal_uj}; let available = hardware_meter_available(); diff --git a/proof/proof-rs/crates/proof-cli/tests/measure.rs b/proof/proof-rs/crates/proof-cli/tests/measure.rs index 847623818..f3fd57428 100644 --- a/proof/proof-rs/crates/proof-cli/tests/measure.rs +++ b/proof/proof-rs/crates/proof-cli/tests/measure.rs @@ -4,6 +4,12 @@ //! interface. Every check is against the decision the `proof` process printed, //! never a second probe in this process: two independent ~1 s probes of a //! borderline rail could disagree. +//! +//! The printed status is itself checked against what the process did with it. +//! The CLI's reason for declining comes from the status it acted on, so on +//! macOS every IOReport reason in the output must be the one for the printed +//! status, and a declined meter must print that reason: a CLI that prints one +//! status and acts on another fails here, whichever status either one is. use std::process::Command; @@ -13,6 +19,16 @@ fn field<'a>(stdout: &'a str, key: &str) -> Option<&'a str> { stdout[at..].split_whitespace().next() } +/// Every `IoReportStatus` the CLI can print, each with the reason it gives +/// when that status is why there is no hardware reading (`Live`'s is for a +/// bracket that returned nothing). Each reason names exactly one status. +const IOREPORT_REASONS: [(&str, &str); 4] = [ + ("CpuRailFrozen", "its CPU energy rail is frozen"), + ("CpuRailAbsent", "has no CPU energy rail"), + ("Unavailable", "could not be opened, or opened but could not be sampled"), + ("Live", "CPU energy rail is live, but the bracket returned no reading"), +]; + #[test] fn measure_names_why_it_declined_and_claims_no_hardware_reading() { let out = Command::new(env!("CARGO_BIN_EXE_proof")) @@ -33,6 +49,10 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { }; let status = field(&stdout, "ioreport_status=") .unwrap_or_else(|| panic!("proof --measure did not print its IOReport status:\n{stdout}")); + assert!( + IOREPORT_REASONS.iter().any(|&(s, _)| s == status), + "proof --measure printed an unknown IOReport status {status:?}:\n{stdout}" + ); let declined = stdout.contains("--measure: no hardware energy reading on this host: "); // Recorded so a log says which branch below was actually asserted. eprintln!("proof --measure: declined={declined} available={available} ioreport_status={status}"); @@ -59,6 +79,33 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { status == "Live", "on macOS the meter is available exactly when IOReport is Live:\n{stdout}" ); + // The reason is derived from the status the process acted on: it must + // be the printed status's own, and no other status's. + for (reason_status, reason) in IOREPORT_REASONS { + if stdout.contains(reason) { + assert_eq!( + reason_status, status, + "the CLI printed ioreport_status={status} but gave the reason for {reason_status}:\n{stdout}" + ); + } + } + if declined { + let (_, reason) = IOREPORT_REASONS.iter().find(|&&(s, _)| s == status).expect("a known status"); + assert!( + stdout.contains(reason), + "the meter declined with ioreport_status={status}, but not for that status's reason ({reason:?}):\n{stdout}" + ); + } + } else { + // Off macOS there is no IOReport: the status is always Unavailable, + // and no IOReport reason is ever given. + assert_eq!(status, "Unavailable", "an IOReport status other than Unavailable off macOS:\n{stdout}"); + for (reason_status, reason) in IOREPORT_REASONS { + assert!( + !stdout.contains(reason), + "an IOReport reason ({reason_status}) off macOS:\n{stdout}" + ); + } } if declined { assert!( @@ -77,18 +124,6 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { stdout.contains("The figures above stay ModelBased"), "a declined meter must leave the ModelBased figures standing:\n{stdout}" ); - if status == "CpuRailFrozen" { - assert!( - stdout.contains("its CPU energy rail is frozen"), - "a frozen IOReport CPU rail must be named as the reason:\n{stdout}" - ); - } - if cfg!(target_os = "macos") && status == "Unavailable" { - assert!( - stdout.contains("could not be opened, or opened but could not be sampled"), - "an unavailable IOReport must be named as the reason:\n{stdout}" - ); - } } else { assert!( stdout.contains("--measure: metering a real 16-variable T0 proof") && stdout.contains(" measured: "), diff --git a/proof/proof-rs/crates/proof-schema/src/lib.rs b/proof/proof-rs/crates/proof-schema/src/lib.rs index 3ce2b4702..cb4eebf3c 100644 --- a/proof/proof-rs/crates/proof-schema/src/lib.rs +++ b/proof/proof-rs/crates/proof-schema/src/lib.rs @@ -101,8 +101,22 @@ impl Tier { /// How honest an energy reading is (SPEC §5) — the family's provenance model. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Provenance { - /// Read from a hardware energy interface (RAPL/NVML/SMC/shunt). Requires a + /// Read from a hardware energy interface: a shunt or power meter, the SMC, + /// or a vendor energy counter (RAPL, NVML, Apple IOReport, ...). Requires a /// present `measured_uj`. + /// + /// A vendor counter's reading is a hardware reading; what it lacks is + /// independent provenance, not accuracy. It is subject to the liveness + /// requirement of SPEC §5: a reading from a vendor energy counter (RAPL, + /// NVML, Apple IOReport, ...) MAY be graded as a hardware reading only + /// while the counter is demonstrably live for the domain the figure + /// claims: the implementation MUST have observed the counter covering that + /// domain advance under load (for Apple IOReport: the CPU energy rail + /// itself advancing in at least 5 sample intervals, in at least 4 of every + /// 5, over at least 300 ms while the probing thread burns CPU). A counter + /// that is present but not live - frozen, or republished in lumps - MUST + /// NOT produce a hardware-graded figure; the implementation falls back to + /// its estimator or reports the work unmetered, and SHOULD record why. HwShunt, /// Computed from a calibrated per-operation model (e.g. wall time × a power /// model). `measured_uj` may be absent. @@ -150,7 +164,8 @@ impl Energy { provenance: Provenance::Estimator, } } - /// A true hardware reading. + /// A true hardware reading. For a vendor energy counter, only while it is + /// live for the domain the figure claims ([`Provenance::HwShunt`]). pub fn measured(uj: u64) -> Self { Energy { measured_uj: Some(uj), -- 2.54.0 (Apple Git-157) From 9334ec0c216f6d6a2c96434f92f9856f7adb4562 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 20:39:39 -0400 Subject: [PATCH 08/10] proof-cascade: RAPL is a hardware reading only while every package counter keeps advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §5 says a vendor energy counter is a hardware reading only while it is demonstrably live for the domain the figure claims. The macOS path enforces that for IOReport's CPU rail; the Linux path did not. `hardware_meter_available` selected RAPL whenever any `intel-rapl:/energy_uj` was readable, and the module doc admitted it: "On Linux there is no such probe." A counter that holds, or republishes in a lump inside the busy window, would have been bracketed and graded HwShunt. RAPL now goes through the same probe loop and the same sustained-advance rule as IOReport: while the probe burns 25 ms of its own thread's CPU before each re-read, a counter is live only if it advanced in at least five sample intervals, in at least 80% of the intervals observed, over at least 300 ms. The domain is the package: the rule watches the zones whose `name` is `package-`, and because the figure sums all of them, EVERY package counter must meet it (IOReport needs one CPU rail of either die; RAPL needs each counter it adds up). A decrease (a wrap) is not an advance. `rapl_status()` is probed once per process behind a `OnceLock` and cached; the selection and `rapl_power_over` gate on it through `admits_counter`, which admits only `Live`, so a counter that is readable but not live yields `None` before any window runs and the work never runs. The new public `CounterStatus` (`Unavailable`, `Absent`, `Frozen`, `Live`) says why, and `proof --measure` prints `rapl_status=` beside `ioreport_status=` and gives the RAPL reason on Linux. The figure itself changes in one place: it sums the package zones only. Before, every readable top-level zone was summed, and on machines that expose `psys` (the platform zone, which covers the package) that counted the package energy twice. `psys` is neither summed nor probed now. To share the machinery rather than copy it: the thread CPU clock, the wall clock and the burn tick move out of the macOS-only IOReport module into a `probe_clock` module built on macOS and Linux (CLOCK_THREAD_CPUTIME_ID is 16 on macOS, 3 on Linux). `RailWatch` takes a `Watched` — which channels, and one or every one — whose default is the IOReport CPU rails with "any one", so the IOReport rule and every IOReport test are unchanged. `probe_with` is now `probe_watching(CPU_RAILS, ..)`. The RAPL composition, `rapl_probe_with`, is generic over its zone reader, so the tests run that very composition on scripted zones on every platform. Tests (all pure and scripted, run here on macOS): - a package counter advancing at every read is Live at the twelfth 25 ms interval, and the rule was handed each value as read; - constant, one-step and two-step counters are Frozen after the whole budget; - one package advancing and one holding is Frozen — the same reads under the any-one-rail rule are Live, which pins what `every` decides — both advancing is Live, and two packages advancing in alternate intervals are Frozen; - a moving `psys` beside a holding package is Frozen, and zones with no package are Absent; a wrap that then holds is Frozen; - no readable zone is Unavailable with no read or tick, and zones readable only at the open give no verdict after the five-budget cap; - the status maps one to one and only `Live` meters: declined statuses run no bracket and no work; - the package name and the read: `package-` only, each package counter with its own value, `psys` left out; - `tests/rapl_once.rs`: eight threads asking at once see one status, and on Linux the production probe ran once (off Linux: Unavailable, zero runs). Falsified, each restored through git before the next: RAPL's `every` set to "any" fails the every-package test; reading `psys` into the sample fails the read and psys tests; watching IOReport's CPU rails instead of the package counters fails five tests; mapping Frozen to Live fails five; admitting every status fails four. `cargo test --workspace` and the proof-conformance binary pass on macOS (M5 Max, macOS 27.0.1). The Linux path is compile-checked only: `cargo check --workspace --tests` and `cargo clippy --workspace --all-targets` for x86_64-unknown-linux-gnu are clean. Not run on Linux here, so the real sysfs reader and the Linux-only gate (`hardware_meter_available` and `rapl_power_over` gating on the cached status) have no runtime test; a mutation of that gate cannot be caught on macOS. Co-Authored-By: Claude Opus 5.5 --- proof/README.md | 4 +- .../crates/proof-cascade/src/meter.rs | 736 +++++++++++++++--- .../crates/proof-cascade/tests/rapl_once.rs | 41 + proof/proof-rs/crates/proof-cli/src/main.rs | 43 +- .../crates/proof-cli/tests/measure.rs | 40 +- 5 files changed, 723 insertions(+), 141 deletions(-) create mode 100644 proof/proof-rs/crates/proof-cascade/tests/rapl_once.rs diff --git a/proof/README.md b/proof/README.md index 813249cc6..48d65cd0c 100644 --- a/proof/README.md +++ b/proof/README.md @@ -37,10 +37,10 @@ proof/ - **`proof-schema`** — the content-addressed goal (`blake3(statement‖0x00‖context)`), the cheapest-first tier ladder, the honest energy model (`HwShunt`/`ModelBased`/`Estimator` with the coupling enforced), `SpecGrounding`, the verdict, and the `Solver` trait every backend implements. Canonical det-CBOR content addressing. - **`proof-solvers`** — two **real** rungs, their energy modeled from measured wall time (`ModelBased`, a ~15 W power model — not a hardware reading): a propositional decider (T0) that parses a boolean goal and decides validity by truth-table in process, and `z3` (T1) — shell or kept **resident** across goals — that decides an SMT-LIB goal (assert the negation; `unsat` ⇒ valid). Each refuses rather than fakes, so the router escalates. The model rungs (T2/T3) are honest stand-ins behind the same trait. -- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while its CPU energy rail is live; RAPL on Linux) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. A vendor counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5): on Apple Silicon the meter checks that before it brackets anything; on Linux it has no separate liveness probe yet, and its module doc says what the bracket alone does and does not show. Returns nothing where no interface is reachable or, on Apple Silicon, the CPU rail is not live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. +- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while its CPU energy rail is live; RAPL's package counters on Linux, used only while every one is live) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. A vendor counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5), and the meter checks that before it brackets anything, by one rule on both platforms (the counter keeps advancing in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms while the probe burns CPU): on Apple Silicon for IOReport's CPU energy rail, on Linux for every RAPL package counter the figure sums (`psys` is neither summed nor probed). Returns nothing where no interface is reachable or its counter is not live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. - **`proof-receipt`** — the `proof/1` JCR-1 receipt: canonical det-CBOR + COSE_Sign1/EdDSA, vendored byte-compatible with the family's cross-language verifiers; refuses to seal an unproved goal. - **`proof-conformance`** — C1–C5 + a negative pack, all checks real, published vectors in `conformance/v1/`. -- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline, the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; on Apple Silicon that needs a CPU energy rail the meter has seen to be live. On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. +- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline, the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; that needs a counter the meter has seen to be live (on Apple Silicon IOReport's CPU energy rail, on Linux every RAPL package counter). On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. `cargo test --workspace` green; `cargo clippy --workspace --all-targets` is warning-free for Linux, and on macOS reports two lints in proof-cascade's diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index 9f8254989..73f5de8c6 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -15,8 +15,8 @@ //! claims (SPEC §5): the implementation must have observed the counter covering //! that domain advance under load. A counter that is present but not live — //! frozen, or republished in lumps — must not produce a hardware-graded figure. -//! On Apple Silicon this meter returns `None` for such a counter and the -//! caller keeps its own estimate; on Linux it checks less (below). +//! On both platforms this meter returns `None` for such a counter and the +//! caller keeps its own estimate. //! //! **On Apple Silicon, IOReport counts as reachable only while its CPU energy //! rail is live** ([`ioreport_status`](crate::meter::ioreport_status)). A @@ -34,15 +34,18 @@ //! far does; otherwise `None` is returned and the caller keeps its //! `ModelBased` figure. //! -//! **On Linux there is no such probe.** RAPL is selected whenever a zone's -//! `energy_uj` is readable by this process (root-only on current kernels, so -//! an unprivileged run usually finds none and gets `None`). The only liveness -//! evidence this path gathers is the bracket itself: a frozen counter gives -//! `Some(0)`, which is not a figure, and a non-zero figure means the zones' -//! counters advanced faster across the busy window than across the idle one. -//! That does not tell a counter that tracks the load from one that published -//! a lump inside the busy window; a multi-interval probe like the macOS one -//! has not been built for RAPL. +//! **On Linux, RAPL counts as reachable only while every package counter is +//! live** ([`rapl_status`](crate::meter::rapl_status)). The same probe loop and +//! the same rule run over the `energy_uj` counters of the RAPL zones named +//! `package-`, while the probe burns CPU; RAPL is used only when EVERY +//! package counter keeps advancing — in at least five separate sample +//! intervals, in at least 80% of the intervals observed, over at least 300 ms +//! — because the figure sums all of them. A counter that holds, steps in a +//! lump, or goes down (a wrap) in an interval did not advance in it. The +//! figure is the package zones' energy alone: a `psys` zone covers the +//! package too, so summing it beside one would count that energy twice, and +//! its liveness is not probed. `energy_uj` is root-only on current kernels, +//! so an unprivileged run usually reads no zone: `Unavailable`, and `None`. //! //! The chain on macOS is short on purpose. `host_status` is one expression: the //! generic probe loop (`liveness::probe_with`) on the real parts — the probe's @@ -54,7 +57,11 @@ //! `power_over`) gates the cached status through the //! pure `admits`, which admits only `Live`. The decisions all live in pure code //! that the tests run on scripted fakes, and the tests also run `host_status` -//! itself, on the real FFI and on scripted channel reads. +//! itself, on the real FFI and on scripted channel reads. The Linux chain has +//! the same shape: `rapl_host_status` is the probe loop on the sysfs zones, +//! the tick and the clocks (`rapl_probe_with`, which the tests run on +//! scripted zones on every platform), cached once per process by +//! [`rapl_status`](crate::meter::rapl_status) and gated by `admits_counter`. //! //! It is a measurement with stated bounds — SoC/package power resolved to the //! marginal delta over an idle machine ÷ iterations — exactly the discipline @@ -68,13 +75,14 @@ use std::time::Instant; /// Marginal energy of one `work()` call in microjoules, from a hardware /// interface. A non-zero figure is a hardware reading, `HwShunt`, on both -/// platforms: on Apple Silicon it is only ever produced through a CPU energy -/// rail the liveness probe saw keep advancing under load; on Linux, from RAPL -/// with no probe beyond the bracket itself (see the module doc). +/// platforms, and is only ever produced through counters the liveness probe +/// saw keep advancing under load: on Apple Silicon a CPU energy rail, on +/// Linux every RAPL package counter (see the module doc). /// -/// - `None` if no interface is reachable ([`hardware_meter_available`]; on -/// Apple Silicon that means IOReport with a live CPU energy rail, so a frozen -/// rail yields `None` before any window runs and `work` never runs), if the +/// - `None` if no interface is reachable ([`hardware_meter_available`]: on +/// Apple Silicon, IOReport with a live CPU energy rail; on Linux, RAPL with +/// every package counter live — so a counter that is not live yields `None` +/// before any window runs and `work` never runs), if the /// interface gave no reading for the idle or the busy window, or if `work` /// never completed a call in the busy window. The caller keeps its own /// `ModelBased` figure. @@ -118,12 +126,13 @@ where } /// Whether a usable hardware energy interface is reachable on this machine: -/// RAPL `energy_uj` on Linux; on Apple Silicon, IOReport **with a live CPU +/// on Linux, RAPL **with every package counter live** — [`rapl_status`] is +/// [`CounterStatus::Live`]; on Apple Silicon, IOReport **with a live CPU /// energy rail** — [`ioreport_status`] is [`IoReportStatus::Live`]. A -/// subscription that opens over a frozen CPU rail does not count. On macOS this -/// is the selection every metering path goes through: the cached status, gated -/// by `admits`. The first call in a process pays the liveness probe (its own -/// Energy Model subscription, then about `CPU_RAIL_LIVENESS_BUDGET` of burning +/// readable counter that is not live does not count. This is the selection +/// every metering path goes through: the cached status, gated by `admits` / +/// `admits_counter`. The first call in a process pays the liveness probe (on +/// macOS its own Energy Model subscription; then about a second of burning /// CPU time and re-sampling on the calling thread); the answer is cached for /// the process. pub fn hardware_meter_available() -> bool { @@ -133,7 +142,7 @@ pub fn hardware_meter_available() -> bool { } #[cfg(target_os = "linux")] { - !rapl_zones().is_empty() + admits_counter(rapl_status()) } #[cfg(not(any(target_os = "macos", target_os = "linux")))] { @@ -262,9 +271,9 @@ fn host_status() -> HostStatus { HostStatus::of(liveness::probe_with( macos_ioreport::EnergyModelSub::open, macos_ioreport::EnergyModelSub::read, - macos_ioreport::spin_tick, - macos_ioreport::wall_clock, - macos_ioreport::thread_cpu_time, + probe_clock::spin_tick, + probe_clock::wall_clock, + probe_clock::thread_cpu_time, CPU_RAIL_LIVENESS_BUDGET, )) } @@ -318,6 +327,141 @@ fn admits(status: IoReportStatus) -> bool { matches!(status, IoReportStatus::Live) } +/// Whether a vendor energy counter other than IOReport's — RAPL's package +/// counters on Linux ([`rapl_status`]) — is live for the domain a figure from +/// it would claim (SPEC §5). Decided by the same probe loop and the same +/// sustained-advance rule as [`IoReportStatus`], once per process. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CounterStatus { + /// The counter could not be read: no such interface on this platform, no + /// permission to read it (RAPL's `energy_uj` is root-only on current + /// kernels), or never two consecutive reads within the probe's five + /// budgets. Never used. + Unavailable, + /// The interface was read, but holds no counter for the domain a figure + /// would claim (RAPL: zones, but no `package-` zone). Never used. + Absent, + /// The counter did not keep advancing while the probe burned CPU — for + /// RAPL, at least one package counter did not: it held, or moved in a + /// few steps close together. A figure from it would not be a live + /// reading. Never used. + Frozen, + /// The counter kept advancing while the probe burned CPU — for RAPL, + /// every package counter did: in at least five separate sample + /// intervals, in at least 80% of the intervals observed, over at least + /// 300 ms of wall time. Used. + Live, +} + +/// The status a probe run of a [`CounterStatus`] source earns: `Unavailable` +/// when nothing could be opened, or nothing was read twice in a row (no +/// verdict); otherwise its verdict, one to one. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn counter_status_of(run: &liveness::ProbeRun) -> CounterStatus { + match (run.opened, run.verdict) { + (false, _) | (true, None) => CounterStatus::Unavailable, + (true, Some(liveness::CpuRail::Absent)) => CounterStatus::Absent, + (true, Some(liveness::CpuRail::Frozen)) => CounterStatus::Frozen, + (true, Some(liveness::CpuRail::Live)) => CounterStatus::Live, + } +} + +/// Whether a [`CounterStatus`] lets its source meter: only `Live`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn admits_counter(status: CounterStatus) -> bool { + matches!(status, CounterStatus::Live) +} + +/// How long (wall time) the RAPL liveness probe watches the package counters +/// before concluding one is frozen: the IOReport probe's budget, ticks and +/// caps. RAPL counters update about every millisecond, so on a quiet host a +/// live set is called live at the tenth or so ~25 ms interval. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +const RAPL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); + +/// The RAPL liveness probe on its parts: `zones` reads every top-level +/// powercap zone (sysfs, on Linux), and the tick and clocks are as for +/// IOReport. It opens when a read finds any zone, and hands the rule each +/// read's package counters ([`rapl::read_of`]), every one of which must keep +/// advancing ([`rapl::PACKAGES`]). A read that finds no zone is a failed read. +/// Generic, so the tests run this very composition on scripted zones on every +/// platform. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn rapl_probe_with( + zones: impl Fn() -> Vec, + tick: impl FnMut(), + wall: impl Fn() -> std::time::Duration, + cpu: impl Fn() -> Option, + budget: std::time::Duration, +) -> liveness::ProbeRun { + liveness::probe_watching( + rapl::PACKAGES, + || (!zones().is_empty()).then_some(()), + |_: &()| { + let z = zones(); + (!z.is_empty()).then(|| rapl::read_of(&z)) + }, + tick, + wall, + cpu, + budget, + ) +} + +/// How many times this process has run the production RAPL liveness probe — +/// once at most (always 0 off Linux). Exposed for the once-per-process test in +/// `tests/rapl_once.rs`; not part of the meter's interface. +#[doc(hidden)] +pub fn real_rapl_probe_runs() -> usize { + #[cfg(target_os = "linux")] + { + REAL_RAPL_PROBE_RUNS.load(Ordering::SeqCst) + } + #[cfg(not(target_os = "linux"))] + { + 0 + } +} + +#[cfg(target_os = "linux")] +static REAL_RAPL_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// THE production RAPL composition: [`rapl_probe_with`] on the real sysfs +/// zones, the thread-CPU tick and the clocks, and the status of its run. +/// Counts itself first; reached in production only through the cache in +/// [`rapl_status`]. +#[cfg(target_os = "linux")] +fn rapl_host_status() -> CounterStatus { + REAL_RAPL_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); + counter_status_of(&rapl_probe_with( + rapl_zones, + probe_clock::spin_tick, + probe_clock::wall_clock, + probe_clock::thread_cpu_time, + RAPL_LIVENESS_BUDGET, + )) +} + +/// This host's RAPL status, probed once per process and cached: whether every +/// RAPL package counter (`intel-rapl:` zones named `package-`) kept +/// advancing while the probe burned CPU. On Linux, [`hardware_meter_available`] +/// and [`measure_marginal_uj`] use RAPL only when this is +/// [`CounterStatus::Live`]. The first call in a process pays the probe (about +/// `RAPL_LIVENESS_BUDGET` of burning CPU and re-reading sysfs on the calling +/// thread when the counters are readable; nothing when they are not). Always +/// `Unavailable` off Linux. +pub fn rapl_status() -> CounterStatus { + #[cfg(target_os = "linux")] + { + static RAPL: std::sync::OnceLock = std::sync::OnceLock::new(); + *RAPL.get_or_init(rapl_host_status) + } + #[cfg(not(target_os = "linux"))] + { + CounterStatus::Unavailable + } +} + /// Run `work` on every spare core for a window, bracketed against idle, and /// return `(idle_w, busy_w, window_s, total_runs)`. The power readings come /// from `idle_power` and `busy_power`; `None` if either gives none. The @@ -362,11 +506,12 @@ where } // --------------------------------------------------------------------------- -// CPU-rail liveness: the decision, the probe loop and the record it keeps, +// Counter liveness: the decision, the probe loop and the record it keeps, // what an unopenable subscription means, which channels a read keeps, and // which rails the power reading sums — kept apart from the FFI so all of it is // pure, tested code on every platform. The macOS probe only supplies the -// subscription, the raw channel reads, the CPU burn and the clocks. +// subscription, the raw channel reads, the CPU burn and the clocks; the Linux +// RAPL probe, the sysfs zone reads and the same burn and clocks. // --------------------------------------------------------------------------- #[cfg_attr(not(target_os = "macos"), allow(dead_code))] mod liveness { @@ -481,8 +626,28 @@ mod liveness { pub(super) gpu: Vec<(RailKey, i64)>, } + /// Which channels of a sample a [`RailWatch`] watches, and whether one of + /// them or every one must keep advancing for the source to be live. + #[derive(Debug, Clone, Copy)] + pub(super) struct Watched { + /// Whether a channel is one of the watched counters. + pub(super) pick: fn(&str) -> bool, + /// `false`: live once ONE watched counter meets the rule (IOReport: a + /// figure is built on a CPU rail of either die). `true`: live only + /// once EVERY watched counter seen meets it, and at least one was seen + /// (RAPL: the figure sums every package counter, so each must be live). + pub(super) every: bool, + } + + /// The IOReport watch: the CPU energy rails (`CPU Energy`, `DIE__CPU + /// Energy`), any one of which may prove the interface live. + pub(super) const CPU_RAILS: Watched = Watched { pick: is_cpu_energy_rail, every: false }; + /// The liveness rule, fed one whole sample at a time, with the wall-clock - /// reading at which it was taken. + /// reading at which it was taken. It watches the channels its [`Watched`] + /// picks — by default, IOReport's CPU energy rails, any one of which may + /// prove the interface live ([`CPU_RAILS`]) — and the same rule decides + /// RAPL's package counters, each of which must ([`super::rapl::PACKAGES`]). /// /// A rail is live only when the **same** CPU rail keeps advancing: after /// some interval, all three of these hold at once — @@ -524,8 +689,11 @@ mod liveness { /// sample, one jump would read as an advance in every later interval. /// Counting per rail keeps two dies' separate steps from adding up to a /// false "live". - #[derive(Debug, Default)] + #[derive(Debug)] pub(super) struct RailWatch { + /// Which channels are watched, and whether one or every one must meet + /// the rule. + watched: Watched, /// Sample intervals observed so far (successful samples after the first). intervals: u32, /// The wall-clock reading of the first successful sample: where the @@ -544,14 +712,26 @@ mod liveness { seen: Vec, } + impl Default for RailWatch { + /// A watch of IOReport's CPU energy rails ([`CPU_RAILS`]). + fn default() -> Self { + Self::new(CPU_RAILS) + } + } + impl RailWatch { + /// A fresh watch of the channels `watched` picks. + pub(super) fn new(watched: Watched) -> Self { + Self { watched, intervals: 0, first_at: None, prev: None, rails: Vec::new(), live: false, seen: Vec::new() } + } + /// Fold in the next sample, taken at wall-clock reading `at`. The first /// one is the baseline and starts the window; each later one closes an /// interval against the sample just before it, and then the rule is /// evaluated. A rail advances at most once per interval, and only if it /// was in both samples. pub(super) fn observe(&mut self, sample: &[(String, i64)], at: Duration) { - let cur = keyed(sample, is_cpu_energy_rail); + let cur = keyed(sample, self.watched.pick); self.seen.push(Seen { cpu: cur.clone(), gpu: keyed(sample, is_gpu_energy_rail) }); for (key, _) in &cur { if !self.rails.iter().any(|(k, _)| k == key) { @@ -571,12 +751,13 @@ mod liveness { } } let window = at.saturating_sub(first_at); - self.live = self.live - || (window >= MIN_LIVE_WINDOW - && self - .rails - .iter() - .any(|(_, n)| *n >= MIN_LIVE_ADVANCES && sustained(*n, self.intervals))); + let meets = |(_, n): &(RailKey, u32)| *n >= MIN_LIVE_ADVANCES && sustained(*n, self.intervals); + let met = if self.watched.every { + !self.rails.is_empty() && self.rails.iter().all(meets) + } else { + self.rails.iter().any(meets) + }; + self.live = self.live || (window >= MIN_LIVE_WINDOW && met); } self.prev = Some(cur); } @@ -723,6 +904,20 @@ mod liveness { /// with no CPU rail in them. The subscription is dropped (released) on /// return. pub(super) fn probe_with( + open: impl FnOnce() -> Option, + read: impl FnMut(&S) -> Option, + tick: impl FnMut(), + wall: impl Fn() -> Duration, + cpu: impl Fn() -> Option, + budget: Duration, + ) -> ProbeRun { + probe_watching(CPU_RAILS, open, read, tick, wall, cpu, budget) + } + + /// [`probe_with`], watching the channels `watched` picks: the same loop, + /// record and rule for every source this meter probes. + pub(super) fn probe_watching( + watched: Watched, open: impl FnOnce() -> Option, mut read: impl FnMut(&S) -> Option, mut tick: impl FnMut(), @@ -736,7 +931,7 @@ mod liveness { }; run.opened = true; let started = wall(); - let mut watch = RailWatch::default(); + let mut watch = RailWatch::new(watched); loop { let (start, cpu_before) = (wall(), cpu()); let got = read(&sub); @@ -1571,15 +1766,108 @@ mod liveness { } // --------------------------------------------------------------------------- -// Linux RAPL: the energy between two reads of the powercap zones, kept apart -// from the sysfs reads so it is a pure, tested function on every platform. +// The probes' clocks and their CPU burn, shared by every liveness probe on the +// platforms that have one (IOReport on macOS, RAPL on Linux). +// --------------------------------------------------------------------------- +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[allow(unsafe_code)] +mod probe_clock { + use std::os::raw::{c_int, c_long}; + use std::time::{Duration, Instant}; + + unsafe extern "C" { + fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; + } + + /// `struct timespec`: `time_t` (a `long` on both platforms), `long`. + #[repr(C)] + struct Timespec { + tv_sec: c_long, + tv_nsec: c_long, + } + + /// `CLOCK_THREAD_CPUTIME_ID`, the calling thread's CPU time: 16 in macOS's + /// `<_time.h>`, 3 in Linux's ``. + #[cfg(target_os = "macos")] + const CLOCK_THREAD_CPUTIME_ID: c_int = 16; + #[cfg(target_os = "linux")] + const CLOCK_THREAD_CPUTIME_ID: c_int = 3; + + /// Thread CPU time a probe burns before each liveness re-sample. + pub(super) const PROBE_TICK: Duration = Duration::from_millis(25); + + /// The most wall time one tick may take to burn [`PROBE_TICK`] of CPU + /// time. Only a thread starved of the CPU for that long is cut short. + const TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// Wall time since this process first asked: the probes' wall clock. One + /// process-wide epoch, so a test can place its own reads on the same + /// timeline as a probe's. + pub(super) fn wall_clock() -> Duration { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + EPOCH.get_or_init(Instant::now).elapsed() + } + + /// This thread's CPU time, from `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`; + /// `None` if the clock cannot be read. + pub(super) fn thread_cpu_time() -> Option { + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: ts is a valid, writable timespec. + let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + let (secs, nanos) = (u64::try_from(ts.tv_sec).ok()?, u32::try_from(ts.tv_nsec).ok()?); + (rc == 0 && nanos < 1_000_000_000).then(|| Duration::new(secs, nanos)) + } + + /// One probe tick: burn [`PROBE_TICK`] of this thread's CPU time — CPU + /// time, not wall time, so a loaded host that runs this thread only part + /// of the time still gets the full burn — giving up after + /// [`TICK_WALL_CAP`] of wall time. Without a readable thread CPU clock it + /// spins [`PROBE_TICK`] of wall time. The burn cannot manufacture `Live` + /// — only a counter's own raw integer that keeps advancing can — so a + /// short burn errs only toward declining, the safe side. + pub(super) fn spin_tick() { + burn_thread_cpu(PROBE_TICK, TICK_WALL_CAP); + } + + fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) { + let wall = Instant::now(); + let mut acc: u64 = 0; + let Some(start) = thread_cpu_time() else { + while wall.elapsed() < cpu { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + return; + }; + while wall.elapsed() < wall_cap { + for _ in 0..4096 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + match thread_cpu_time() { + Some(now) if now.saturating_sub(start) < cpu => {} + _ => break, + } + } + } +} + +// --------------------------------------------------------------------------- +// Linux RAPL: the energy between two reads of the powercap zones, and what +// the liveness rule is handed from one read, kept apart from the sysfs reads +// so they are pure, tested functions on every platform. // --------------------------------------------------------------------------- #[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod rapl { + use super::liveness::{Listed, Read, Sample, Watched}; + /// One powercap zone (`/sys/class/powercap/intel-rapl:`), read once. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) struct Zone { pub(super) index: u32, + /// Whether the zone is a package (its `name` is `package-`), not a + /// platform (`psys`) or other top-level zone. Only package zones are + /// summed, and only their liveness is probed: `psys` covers the + /// package too, so summing it beside one counts that energy twice. + pub(super) package: bool, /// The cumulative `energy_uj` counter. pub(super) energy_uj: u64, /// `max_energy_range_uj`, the value this zone's counter wraps at, if @@ -1587,6 +1875,49 @@ mod rapl { pub(super) max_energy_range_uj: Option, } + /// Whether a top-level zone's `name` file names a package: `package-`, + /// `` one or more digits. + pub(super) fn is_package_name(name: &str) -> bool { + name.trim().strip_prefix("package-").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + } + + /// The package zones of one read, in zone order: the zones whose energy a + /// RAPL figure sums. + pub(super) fn packages(zones: &[Zone]) -> Vec { + zones.iter().filter(|z| z.package).copied().collect() + } + + /// The name a package zone's counter has in the sample the liveness rule + /// is handed: its sysfs zone, `intel-rapl:`. + pub(super) fn rail_name(index: u32) -> String { + format!("intel-rapl:{index}") + } + + /// Whether a sample channel is a RAPL package counter: the only channels + /// [`read_of`] puts in a sample. + pub(super) fn is_package_rail(name: &str) -> bool { + name.strip_prefix("intel-rapl:").is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + } + + /// The liveness rule over RAPL: live only when EVERY package counter + /// keeps advancing (the figure sums all of them), by the same + /// sustained-advance rule as IOReport's CPU rail. + pub(super) const PACKAGES: Watched = Watched { pick: is_package_rail, every: true }; + + /// What one read of the zones hands the liveness rule: each PACKAGE + /// zone's raw `energy_uj`, under its sysfs name, in zone order. Other + /// zones are left out — they are neither summed nor probed — so a read + /// with zones but no package hands the rule an empty sample (no rail: + /// `Absent`). `listed` counts the counters handed over. + pub(super) fn read_of(zones: &[Zone]) -> Read { + let sample: Sample = packages(zones) + .iter() + .map(|z| (rail_name(z.index), i64::try_from(z.energy_uj).unwrap_or(i64::MAX))) + .collect(); + let listed = Listed { channels: sample.len(), group_only: true }; + Read { sample, listed } + } + /// One zone's energy between two reads, modulo its own wrap. A counter that /// went down wrapped at `max_range`; without a readable (non-zero) range, or /// with a first read above it, the delta is unknown and `None`. Undercounts @@ -1630,7 +1961,7 @@ mod rapl { const RANGE: u64 = 262_143_328_850; fn zone(index: u32, energy_uj: u64, max: Option) -> Zone { - Zone { index, energy_uj, max_energy_range_uj: max } + Zone { index, package: true, energy_uj, max_energy_range_uj: max } } #[test] @@ -1665,6 +1996,35 @@ mod rapl { assert_eq!(zone_delta_uj(1_000, 10, Some(0)), None, "a zero range is unreadable"); } + /// A package zone is named `package-` (the sysfs file ends in a + /// newline); `psys`, `dram`, `core` and malformed names are not. + #[test] + fn only_a_package_name_is_a_package() { + for name in ["package-0", "package-1\n", "package-12"] { + assert!(is_package_name(name), "{name:?}"); + } + for name in ["psys", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", ""] { + assert!(!is_package_name(name), "{name:?}"); + } + assert!(is_package_rail("intel-rapl:0") && is_package_rail("intel-rapl:12")); + assert!(!is_package_rail("intel-rapl:") && !is_package_rail("intel-rapl:0:1") && !is_package_rail("CPU Energy")); + } + + /// A read hands the rule each PACKAGE zone's own raw counter, under + /// its sysfs zone, in zone order; a `psys` zone is left out, so a read + /// with no package hands over an empty sample. + #[test] + fn a_read_hands_the_rule_each_package_counter_as_read() { + let psys = Zone { index: 1, package: false, energy_uj: 9_999_999, max_energy_range_uj: Some(RANGE) }; + let zones = [zone(0, 1_000, Some(RANGE)), psys, zone(2, 7_000, Some(RANGE))]; + let r = read_of(&zones); + assert_eq!(r.sample, vec![("intel-rapl:0".to_string(), 1_000), ("intel-rapl:2".to_string(), 7_000)]); + assert_eq!(r.listed, Listed { channels: 2, group_only: true }); + assert_eq!(packages(&zones), vec![zones[0], zones[2]], "only package zones are summed"); + let none = read_of(&[psys]); + assert!(none.sample.is_empty() && none.listed.channels == 0, "{none:?}"); + } + #[test] fn mismatched_or_inconsistent_reads_give_no_figure() { let one = [zone(0, 1_000, Some(RANGE))]; @@ -1697,7 +2057,7 @@ fn busy_power() -> Option { mod macos_ioreport { use super::liveness::{counts_once, read_from, Channel, Read}; use std::os::raw::{c_int, c_long, c_void}; - use std::time::{Duration, Instant}; + use std::time::Duration; type CFTypeRef = *const c_void; type CFStringRef = *const c_void; @@ -1752,19 +2112,8 @@ mod macos_ioreport { buffer_size: c_long, encoding: u32, ) -> bool; - fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; } - /// `struct timespec` (``): `time_t`, `long`. - #[repr(C)] - struct Timespec { - tv_sec: i64, - tv_nsec: c_long, - } - - /// `CLOCK_THREAD_CPUTIME_ID` (`<_time.h>`): the calling thread's CPU time. - const CLOCK_THREAD_CPUTIME_ID: c_int = 16; - #[link(name = "IOReport", kind = "dylib")] #[link(name = "IOKit", kind = "framework")] #[link(name = "CoreFoundation", kind = "framework")] @@ -1842,13 +2191,6 @@ mod macos_ioreport { Some((sub, subscribed)) } - /// Thread CPU time the probe burns before each liveness re-sample. - pub(super) const PROBE_TICK: Duration = Duration::from_millis(25); - - /// The most wall time one tick may take to burn [`PROBE_TICK`] of CPU - /// time. Only a thread starved of the CPU for that long is cut short. - const TICK_WALL_CAP: Duration = Duration::from_millis(500); - /// The probe's own Energy Model subscription, released on drop: the real /// opener and reader `host_status` hands the probe loop. pub(super) struct EnergyModelSub { @@ -1912,55 +2254,6 @@ mod macos_ioreport { } } - /// Wall time since this process first asked: the probe's wall clock. One - /// process-wide epoch, so a test can place its own reads on the same - /// timeline as the probe's. - pub(super) fn wall_clock() -> Duration { - static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); - EPOCH.get_or_init(Instant::now).elapsed() - } - - /// This thread's CPU time, from `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`; - /// `None` if the clock cannot be read. - pub(super) fn thread_cpu_time() -> Option { - let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; - // SAFETY: ts is a valid, writable timespec. - let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; - let (secs, nanos) = (u64::try_from(ts.tv_sec).ok()?, u32::try_from(ts.tv_nsec).ok()?); - (rc == 0 && nanos < 1_000_000_000).then(|| Duration::new(secs, nanos)) - } - - /// One probe tick: burn [`PROBE_TICK`] of this thread's CPU time — CPU - /// time, not wall time, so a loaded host that runs this thread only part - /// of the time still gets the full burn — giving up after - /// [`TICK_WALL_CAP`] of wall time. Without a readable thread CPU clock it - /// spins [`PROBE_TICK`] of wall time. The burn cannot manufacture `Live` - /// — only a CPU rail's own raw integer that keeps advancing can — so a - /// short burn errs only toward declining, the safe side. - pub(super) fn spin_tick() { - burn_thread_cpu(PROBE_TICK, TICK_WALL_CAP); - } - - fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) { - let wall = Instant::now(); - let mut acc: u64 = 0; - let Some(start) = thread_cpu_time() else { - while wall.elapsed() < cpu { - acc = std::hint::black_box(acc.wrapping_add(1)); - } - return; - }; - while wall.elapsed() < wall_cap { - for _ in 0..4096 { - acc = std::hint::black_box(acc.wrapping_add(1)); - } - match thread_cpu_time() { - Some(now) if now.saturating_sub(start) < cpu => {} - _ => break, - } - } - } - /// Every channel one sample of `sub` listed, as (group, name, ABSOLUTE raw /// integer), in channel order — raw, before unit conversion, so a small /// advance in a fine unit cannot truncate to zero. `None` if IOReport @@ -2184,6 +2477,8 @@ mod macos_ioreport { mod tests { use super::*; use crate::meter::liveness::tests::expect_keyed; + use crate::meter::probe_clock::{spin_tick, thread_cpu_time, wall_clock}; + use std::time::Instant; use crate::meter::liveness::{CpuRail, Listed, ProbeRun, RailKey}; use crate::meter::{ admits, hardware_meter_available, host, host_status, ioreport_status, measure_marginal_uj, status_of, @@ -3559,10 +3854,11 @@ mod macos_ioreport { } // --------------------------------------------------------------------------- -// Linux — RAPL package energy counter. Power = energy delta over a timed -// window. `energy_uj` is root-only on mainline kernels since late 2020 -// (commit 949dd0104c49, "powercap: restrict energy meter to root access", in -// 5.10 and backported to stable: the CVE-2020-8694 / PLATYPUS mitigation). An +// Linux — RAPL package energy counters. Power = energy delta over a timed +// window, used only while every package counter is live (`rapl_status`). +// `energy_uj` is root-only on mainline kernels since late 2020 (commit +// 949dd0104c49, "powercap: restrict energy meter to root access", in 5.10 and +// backported to stable: the CVE-2020-8694 / PLATYPUS mitigation). An // unprivileged process therefore usually reads no zone, and the meter declines // unless it runs as root or an administrator has granted read access. // --------------------------------------------------------------------------- @@ -3574,31 +3870,43 @@ fn idle_power() -> Option { fn busy_power() -> Option { rapl_power_over(std::time::Duration::from_millis(1400)) } -/// Average power over `d` across the RAPL zones: per-zone energy deltas, each -/// modulo its own wrap ([`rapl::delta_uj`]), ÷ the window. `None` if no zone is -/// readable, or the delta is unknown. +/// Average package power over `d`: the RAPL package zones' energy deltas, +/// each modulo its own wrap ([`rapl::delta_uj`]), ÷ the window. A hardware +/// reading — the bracket built on it is graded `HwShunt` — and so produced +/// only while every package counter is live ([`rapl_status`] is `Live`). +/// `None` if RAPL is declined, no package zone is readable, or the delta is +/// unknown. Other top-level zones (`psys`) are not summed: `psys` covers the +/// package too, and its liveness is not probed. #[cfg(target_os = "linux")] fn rapl_power_over(d: std::time::Duration) -> Option { - let z0 = rapl_zones(); + // Declined before reading: a counter that is readable but not live must + // not produce a hardware-graded figure. + if !hardware_meter_available() { + return None; + } + let z0 = rapl::packages(&rapl_zones()); if z0.is_empty() { return None; } let t = Instant::now(); std::thread::sleep(d); - let z1 = rapl_zones(); + let z1 = rapl::packages(&rapl_zones()); let dt = t.elapsed().as_secs_f64(); Some(rapl::delta_uj(&z0, &z1)? as f64 / 1e6 / dt) } -/// Every readable RAPL zone (`intel-rapl:0`..`7`): its `energy_uj` counter and -/// the `max_energy_range_uj` it wraps at, when that is readable. +/// Every readable top-level RAPL zone (`intel-rapl:0`..`7`): whether it is a +/// package (its `name`), its `energy_uj` counter, and the +/// `max_energy_range_uj` it wraps at, when that is readable. #[cfg(target_os = "linux")] fn rapl_zones() -> Vec { - let read = |path: String| std::fs::read_to_string(path).ok()?.trim().parse::().ok(); + let text = |path: String| std::fs::read_to_string(path).ok(); + let read = |path: String| text(path)?.trim().parse::().ok(); (0..8u32) .filter_map(|index| { let base = format!("/sys/class/powercap/intel-rapl:{index}"); Some(rapl::Zone { index, + package: text(format!("{base}/name")).is_some_and(|n| rapl::is_package_name(&n)), energy_uj: read(format!("{base}/energy_uj"))?, max_energy_range_uj: read(format!("{base}/max_energy_range_uj")), }) @@ -3845,6 +4153,190 @@ mod tests { assert_eq!(status, IoReportStatus::CpuRailFrozen); } + // ── RAPL: the production composition on scripted zones ──────────────── + + /// Read `n` of a scripted RAPL run: every top-level zone it lists, as + /// (index, is a package, `energy_uj`). + type ZoneScript = fn(u32) -> Vec<(u32, bool, u64)>; + + /// [`rapl_probe_with`] — the composition `rapl_host_status` runs — on + /// scripted zones, with a tick that advances a scripted wall clock and CPU + /// clock by 25 ms and counts itself. Returns the status, the run, and how + /// many times the zones were read and the probe ticked. + fn rapl_composed(script: ZoneScript) -> (CounterStatus, ProbeRun, u32, u32) { + let (clock, cpu) = (Cell::new(Duration::ZERO), Cell::new(Duration::ZERO)); + let (reads, ticks) = (Cell::new(0u32), Cell::new(0u32)); + let run = rapl_probe_with( + || { + let n = reads.get(); + reads.set(n + 1); + script(n) + .into_iter() + .map(|(index, package, energy_uj)| rapl::Zone { + index, + package, + energy_uj, + max_energy_range_uj: Some(262_143_328_850), + }) + .collect() + }, + || { + ticks.set(ticks.get() + 1); + clock.set(clock.get() + Duration::from_millis(25)); + cpu.set(cpu.get() + Duration::from_millis(25)); + }, + || clock.get(), + || Some(cpu.get()), + RAPL_LIVENESS_BUDGET, + ); + (counter_status_of(&run), run, reads.get(), ticks.get()) + } + + /// A package counter that advances at every read is Live once it has + /// advanced in five intervals over 300 ms — at 25 ms an interval, the + /// twelfth — and admitted. The open is one read of the zones; the probe + /// then reads 13 times. + #[test] + fn rapl_a_package_counter_advancing_at_every_read_is_live() { + let (status, run, reads, ticks) = rapl_composed(|n| vec![(0, true, 5_000 + 900 * u64::from(n))]); + assert_eq!(run.verdict, Some(CpuRail::Live), "{run:?}"); + assert_eq!(status, CounterStatus::Live); + assert!(admits_counter(status)); + assert_eq!((reads, ticks, run.intervals), (1 + 13, 12, 12)); + let handed: Vec = run.seen.iter().map(|s| s.cpu[0].1).collect(); + assert_eq!(handed, (1..=13).map(|n| 5_000 + 900 * n).collect::>(), "the counter as read"); + } + + /// A package counter that holds, or steps once and holds, is Frozen after + /// the whole budget and declined: the rule RAPL had none of before. + #[test] + fn rapl_a_constant_or_once_stepping_package_counter_is_frozen() { + for (what, script) in [ + ("constant", (|_| vec![(0, true, 42_000_000)]) as ZoneScript), + ("one step", |n| vec![(0, true, if n < 6 { 42_000_000 } else { 43_344_869 })]), + ("two steps", |n| vec![(0, true, 42_000_000 + if n >= 6 { 1_000_000 } else { 0 } + if n >= 8 { 3_435 } else { 0 })]), + ] { + let (status, run, reads, ticks) = rapl_composed(script); + assert_eq!(status, CounterStatus::Frozen, "{what}: {run:?}"); + assert!(!admits_counter(status), "{what}"); + assert_eq!((reads, ticks, run.intervals), (1 + 41, 40, 40), "{what}"); + } + } + + /// The figure sums every package counter, so EVERY one must keep + /// advancing: with one advancing and the other holding, RAPL is Frozen — + /// where IOReport's one-rail rule would call the same counters live — and + /// with both advancing it is Live. Two packages advancing in alternate + /// intervals (each in half of them) are not. + #[test] + fn rapl_every_package_counter_must_keep_advancing() { + let one_holds: ZoneScript = |n| vec![(0, true, 1_000 + u64::from(n)), (1, true, 77)]; + let (status, run, _, _) = rapl_composed(one_holds); + assert_eq!(status, CounterStatus::Frozen, "one package counter held: {run:?}"); + // The same reads under the any-one-rail rule would be Live: this is + // what `every` decides. + let mut any = liveness::RailWatch::new(liveness::Watched { pick: rapl::is_package_rail, every: false }); + for (n, s) in run.seen.iter().enumerate() { + let sample: Vec<(String, i64)> = s.cpu.iter().map(|((name, _), v)| (name.clone(), *v)).collect(); + any.observe(&sample, Duration::from_millis(25) * n as u32); + } + assert_eq!(any.verdict(), Some(CpuRail::Live), "the any-one-rail rule on the same reads"); + + let both: ZoneScript = |n| vec![(0, true, 1_000 + u64::from(n)), (1, true, 9_000 + 2 * u64::from(n))]; + let (status, run, reads, _) = rapl_composed(both); + assert_eq!(status, CounterStatus::Live, "{run:?}"); + assert_eq!(reads, 1 + 13); + + let alternate: ZoneScript = + |n| vec![(0, true, 100 + 50 * u64::from(n.div_ceil(2))), (1, true, 200 + 50 * u64::from(n / 2))]; + let (status, run, _, _) = rapl_composed(alternate); + assert_eq!(status, CounterStatus::Frozen, "alternate advances added up to live: {run:?}"); + } + + /// `psys` is neither summed nor probed: a moving `psys` beside a holding + /// package is Frozen, and zones with no package at all are Absent. + #[test] + fn rapl_psys_is_not_a_package_counter() { + let (status, run, _, _) = rapl_composed(|n| vec![(0, true, 5_000), (1, false, 8_000 + 1_000 * u64::from(n))]); + assert_eq!(status, CounterStatus::Frozen, "a moving psys made the package live: {run:?}"); + let (status, run, _, _) = rapl_composed(|n| vec![(1, false, 8_000 + 1_000 * u64::from(n))]); + assert_eq!(status, CounterStatus::Absent, "{run:?}"); + assert!(!admits_counter(status)); + } + + /// A package counter that wraps (goes down) did not advance in that + /// interval: one that wraps and then holds is Frozen, whatever came + /// before the wrap within the first 300 ms. + #[test] + fn rapl_a_wrap_is_not_an_advance() { + let (status, run, _, _) = + rapl_composed(|n| vec![(0, true, if n < 5 { 262_143_328_000 + 100 * u64::from(n) } else { 10 })]); + assert_eq!(status, CounterStatus::Frozen, "{run:?}"); + } + + /// No readable zone — `energy_uj` is root-only on current kernels — is + /// `Unavailable`: the open reads the zones once, and the probe never + /// reads or ticks. Zones that are readable at the open and never after + /// give no verdict: `Unavailable`, after the five-budget cap. + #[test] + fn rapl_unreadable_zones_are_unavailable() { + let (status, run, reads, ticks) = rapl_composed(|_| Vec::new()); + assert_eq!(status, CounterStatus::Unavailable); + assert!(!run.opened && run.calls.is_empty(), "{run:?}"); + assert_eq!((reads, ticks), (1, 0)); + assert!(!admits_counter(status)); + + let (status, run, _, ticks) = rapl_composed(|n| if n == 0 { vec![(0, true, 1)] } else { Vec::new() }); + assert_eq!(status, CounterStatus::Unavailable, "{run:?}"); + assert!(run.opened && run.verdict.is_none()); + assert_eq!(ticks, 200, "five budgets of 25 ms ticks"); + } + + /// The counter status maps one to one from an opened run's verdict; a + /// run that did not open, or gave no verdict, is `Unavailable`; and only + /// `Live` admits the source, so a declined RAPL never runs the work. + #[test] + fn rapl_only_a_live_status_meters() { + assert_eq!(counter_status_of(&run_of(true, Some(CpuRail::Live))), CounterStatus::Live); + assert_eq!(counter_status_of(&run_of(true, Some(CpuRail::Frozen))), CounterStatus::Frozen); + assert_eq!(counter_status_of(&run_of(true, Some(CpuRail::Absent))), CounterStatus::Absent); + assert_eq!(counter_status_of(&run_of(true, None)), CounterStatus::Unavailable); + for verdict in [None, Some(CpuRail::Live), Some(CpuRail::Frozen), Some(CpuRail::Absent)] { + assert_eq!(counter_status_of(&run_of(false, verdict)), CounterStatus::Unavailable, "{verdict:?}"); + } + static CALLS: AtomicU64 = AtomicU64::new(0); + fn counted_work() { + CALLS.fetch_add(1, Ordering::SeqCst); + } + for status in [CounterStatus::Unavailable, CounterStatus::Absent, CounterStatus::Frozen] { + assert!(!admits_counter(status), "{status:?} admitted"); + let readings = Cell::new(0u32); + let r = measure_gated( + admits_counter(status), + counted_work, + || { + readings.set(readings.get() + 1); + Some(1.0) + }, + || { + readings.set(readings.get() + 1); + Some(3.0) + }, + ); + assert_eq!((r, CALLS.load(Ordering::SeqCst), readings.get()), (None, 0, 0), "{status:?} metered"); + } + assert!(admits_counter(CounterStatus::Live)); + } + + /// RAPL is a Linux interface: off Linux its status says so, and no RAPL + /// probe ever runs. + #[cfg(not(target_os = "linux"))] + #[test] + fn rapl_status_is_unavailable_off_linux() { + assert_eq!(rapl_status(), CounterStatus::Unavailable); + assert_eq!(real_rapl_probe_runs(), 0); + } + /// The declined path meters real work honestly. This meter's fallback is no /// figure at all: on a host whose interface is declined, real CPU work (a /// direct call burns 80 ms) put through `measure_marginal_uj` gives `None` diff --git a/proof/proof-rs/crates/proof-cascade/tests/rapl_once.rs b/proof/proof-rs/crates/proof-cascade/tests/rapl_once.rs new file mode 100644 index 000000000..3e924257c --- /dev/null +++ b/proof/proof-rs/crates/proof-cascade/tests/rapl_once.rs @@ -0,0 +1,41 @@ +//! The RAPL liveness probe behind `rapl_status()` runs once per PROCESS, +//! however many threads ask at once: a probe per call would burn about a +//! second of CPU on each caller's thread where the counters are readable, and +//! could flip the verdict mid-process. +//! +//! This file holds this one test, so that it runs in a process of its own and +//! IS the first use of the RAPL meter in that process. The counter it reads, +//! `real_rapl_probe_runs()`, counts runs of the production probe +//! (`rapl_host_status`, whose first statement it is), not lookups of the cache. +//! Off Linux there is no RAPL: the status is `Unavailable` and no probe runs. + +use proof_cascade::meter::{rapl_status, real_rapl_probe_runs, CounterStatus}; +use std::sync::{Arc, Barrier}; + +#[test] +fn the_rapl_liveness_probe_runs_once_per_process() { + assert_eq!(real_rapl_probe_runs(), 0, "the RAPL meter was used in this process before this test"); + const THREADS: usize = 8; + let gate = Arc::new(Barrier::new(THREADS)); + let handles: Vec<_> = (0..THREADS) + .map(|_| { + let gate = gate.clone(); + std::thread::spawn(move || { + gate.wait(); + (rapl_status(), rapl_status()) + }) + }) + .collect(); + let seen: Vec<_> = handles.into_iter().map(|h| h.join().expect("a probing thread panicked")).collect(); + let runs = real_rapl_probe_runs(); + eprintln!("rapl cache: {THREADS} threads saw {:?}; real RAPL probe runs in this process: {runs}", seen[0].0); + for (i, (status, again)) in seen.iter().enumerate() { + assert_eq!((*status, *again), (seen[0].0, seen[0].0), "thread {i} was given a different status"); + } + if cfg!(target_os = "linux") { + assert_eq!(runs, 1, "the RAPL liveness probe ran {runs} times in this process ({THREADS} threads asked at once)"); + } else { + assert_eq!(seen[0].0, CounterStatus::Unavailable, "RAPL off Linux"); + assert_eq!(runs, 0, "a RAPL liveness probe ran off Linux"); + } +} diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index 881152b94..cee5fdfca 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -7,8 +7,9 @@ //! one come back unproved. It reports, per goal, the tier that closed it and //! the joules it cost; then the headline — joules spent versus a frontier-only //! run — the Amdahl breakdown by tier, the cache flywheel, and a sealed + -//! verified JCR-1 receipt. Only `--measure` attempts a hardware reading, and on -//! Apple Silicon only through a CPU energy rail the meter has seen to be live. +//! verified JCR-1 receipt. Only `--measure` attempts a hardware reading, and +//! only through a counter the meter has seen to be live (on Apple Silicon +//! IOReport's CPU energy rail, on Linux every RAPL package counter). use ed25519_dalek::SigningKey; use proof_schema::{GoalKind, ProofGoal}; @@ -93,20 +94,21 @@ fn main() { // --measure: try to put a hardware reading beside the wall-time model. // Bracket a real T0 proof against idle on the platform's energy interface // (IOReport on Apple Silicon, used only while its CPU energy rail is live; - // RAPL on Linux) and report the marginal joules. The figure is tagged - // `Energy::measured`, i.e. HwShunt, on both platforms: a vendor energy - // counter's reading is a hardware reading while the counter is live for - // the domain it claims (SPEC §5), which on Apple Silicon the meter checks - // before it brackets anything. Where no interface is usable, print why: - // the figures above then stay ModelBased. + // RAPL on Linux, used only while every package counter is live) and report + // the marginal joules. The figure is tagged `Energy::measured`, i.e. + // HwShunt, on both platforms: a vendor energy counter's reading is a + // hardware reading while the counter is live for the domain it claims + // (SPEC §5), which the meter checks before it brackets anything. Where no + // interface is usable, print why: the figures above then stay ModelBased. if measure { - use proof_cascade::meter::{hardware_meter_available, ioreport_status, measure_marginal_uj}; + use proof_cascade::meter::{hardware_meter_available, ioreport_status, measure_marginal_uj, rapl_status}; let available = hardware_meter_available(); // The decision THIS process made, printed so tests/measure.rs checks it // instead of re-probing in its own process, which could disagree. println!( - "\n --measure: hardware meter available={available} · ioreport_status={:?}", - ioreport_status() + "\n --measure: hardware meter available={available} · ioreport_status={:?} · rapl_status={:?}", + ioreport_status(), + rapl_status() ); if !available { println!( @@ -185,11 +187,12 @@ fn main() { println!(" was not validated against intent — a verified proof of an unverified spec."); } -/// Why `--measure` has no hardware reading on this host. On Apple Silicon this -/// comes from the meter's cached IOReport status, so a frozen CPU energy rail is -/// named as such instead of being misread as a noisy machine. +/// Why `--measure` has no hardware reading on this host. It comes from the +/// meter's cached status — IOReport's on Apple Silicon, RAPL's on Linux — so a +/// counter that is not live is named as such instead of being misread as a +/// noisy machine. fn no_hardware_reading_reason() -> &'static str { - use proof_cascade::meter::{ioreport_status, IoReportStatus}; + use proof_cascade::meter::{ioreport_status, rapl_status, CounterStatus, IoReportStatus}; if cfg!(target_os = "macos") { match ioreport_status() { IoReportStatus::CpuRailFrozen => { @@ -204,7 +207,15 @@ fn no_hardware_reading_reason() -> &'static str { IoReportStatus::Live => "IOReport's CPU energy rail is live, but the bracket returned no reading", } } else if cfg!(target_os = "linux") { - "no readable RAPL counter at /sys/class/powercap/intel-rapl:*/energy_uj" + match rapl_status() { + CounterStatus::Unavailable => "no readable RAPL counter at /sys/class/powercap/intel-rapl:*/energy_uj", + CounterStatus::Absent => "RAPL zones are readable, but none of them is a package zone", + CounterStatus::Frozen => { + "a RAPL package counter is readable but did not keep advancing while this process burned CPU, \ + so it cannot meter CPU work" + } + CounterStatus::Live => "RAPL's package counters are live, but the bracket returned no reading", + } } else { "no hardware energy interface is supported on this OS" } diff --git a/proof/proof-rs/crates/proof-cli/tests/measure.rs b/proof/proof-rs/crates/proof-cli/tests/measure.rs index f3fd57428..ce0282d3a 100644 --- a/proof/proof-rs/crates/proof-cli/tests/measure.rs +++ b/proof/proof-rs/crates/proof-cli/tests/measure.rs @@ -29,6 +29,15 @@ const IOREPORT_REASONS: [(&str, &str); 4] = [ ("Live", "CPU energy rail is live, but the bracket returned no reading"), ]; +/// Every RAPL `CounterStatus` the CLI can print, each with the reason it +/// gives on Linux when that status is why there is no hardware reading. +const RAPL_REASONS: [(&str, &str); 4] = [ + ("Unavailable", "no readable RAPL counter"), + ("Absent", "none of them is a package zone"), + ("Frozen", "a RAPL package counter is readable but did not keep advancing"), + ("Live", "RAPL's package counters are live, but the bracket returned no reading"), +]; + #[test] fn measure_names_why_it_declined_and_claims_no_hardware_reading() { let out = Command::new(env!("CARGO_BIN_EXE_proof")) @@ -53,9 +62,15 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { IOREPORT_REASONS.iter().any(|&(s, _)| s == status), "proof --measure printed an unknown IOReport status {status:?}:\n{stdout}" ); + let rapl = field(&stdout, "rapl_status=") + .unwrap_or_else(|| panic!("proof --measure did not print its RAPL status:\n{stdout}")); + assert!( + RAPL_REASONS.iter().any(|&(s, _)| s == rapl), + "proof --measure printed an unknown RAPL status {rapl:?}:\n{stdout}" + ); let declined = stdout.contains("--measure: no hardware energy reading on this host: "); // Recorded so a log says which branch below was actually asserted. - eprintln!("proof --measure: declined={declined} available={available} ioreport_status={status}"); + eprintln!("proof --measure: declined={declined} available={available} ioreport_status={status} rapl_status={rapl}"); // The sealed receipt is the cascade's own figure for the first goal, on // every branch: ModelBased and non-zero, never the --measure figure. @@ -73,6 +88,29 @@ fn measure_names_why_it_declined_and_claims_no_hardware_reading() { assert!(receipt.contains("provenance ModelBased"), "the receipt's provenance changed: {receipt}"); assert_eq!(declined, !available, "the CLI's output disagrees with the decision it printed:\n{stdout}"); + // RAPL is a Linux interface: elsewhere it is Unavailable and no RAPL + // reason is ever given. On Linux the meter is available exactly when RAPL + // is Live, and a declined meter gives the printed RAPL status's reason. + if cfg!(target_os = "linux") { + assert_eq!(available, rapl == "Live", "on Linux the meter is available exactly when RAPL is Live:\n{stdout}"); + for (reason_status, reason) in RAPL_REASONS { + if stdout.contains(reason) { + assert_eq!( + reason_status, rapl, + "the CLI printed rapl_status={rapl} but gave the reason for {reason_status}:\n{stdout}" + ); + } + } + if declined { + let (_, reason) = RAPL_REASONS.iter().find(|&&(s, _)| s == rapl).expect("a known status"); + assert!(stdout.contains(reason), "declined with rapl_status={rapl}, but not for its reason:\n{stdout}"); + } + } else { + assert_eq!(rapl, "Unavailable", "a RAPL status other than Unavailable off Linux:\n{stdout}"); + for (reason_status, reason) in RAPL_REASONS { + assert!(!stdout.contains(reason), "a RAPL reason ({reason_status}) off Linux:\n{stdout}"); + } + } if cfg!(target_os = "macos") { assert_eq!( available, -- 2.54.0 (Apple Git-157) From 4551c5f9a4d8659739d216b375df3460903f6dc4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 20:40:11 -0400 Subject: [PATCH 09/10] proof-cli: "the same verified output" charges the baseline only for the goals proved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headline said, on one run, "energy 375387 uj vs 450000000 uj frontier-only → 1198.762× less for the same verified output". The 450 J baseline charged a frontier model for all nine goals; the cascade proved six. The three it did not prove produced nothing, so the comparison was not for the same output: it credited the cascade with 150 J of frontier work it never replaced. `EnergySummary` gains `proved_baseline_uj`, the frontier-only baseline summed over the proved goals alone, and `proved_ratio_milli()`, that baseline over EVERY joule the cascade spent, the goals it failed on included, in thousandths rounded down (`None` when nothing was spent; `Some(0)` when nothing was proved). Both sides are charged conservatively: the frontier run only for the output it would have to match, the cascade for its failures too. The existing `baseline_uj` and `energy_ratio_milli()` are unchanged (the conformance check C3 and the receipt wire use them), and their docs now say they charge every goal. The CLI now prints, verbatim from one run (the T1 figures are wall-time models of a resident z3 and vary several-fold between runs): proved 6/9 · tiers T0=4 T1=2 T2=0 T3=0 energy 58892 uj for all 9 goals vs 300000000 uj frontier-only for the 6 proved → 5094.070× less for the same verified output (frontier-only for all 9 goals: 450000000 uj, 7641.105× — but 3 of them were not proved here, so not the same output) The all-goals figure stays, with its goal count, and is never called the same output; with nothing proved, or nothing spent, the CLI says so instead of printing a ratio. Tests: - `the_same_output_ratio_charges_the_baseline_only_for_proved_goals` (cascade): three goals, two proved, with the tests' own literals (T0 50 uJ, T1 5 mJ, T3 50 J): the same-output baseline is 2 × 50 J, the ratio is exactly 100,000,000,000 / 10,150 = 9,852,216 thousandths, below the all-goals ratio; a cache hit is a proved goal for zero joules; nothing spent gives no ratio, nothing proved gives zero. - `tests/headline.rs` (CLI): runs `proof` and checks the printed figures against the per-goal rows of the same run — the total is the sum of the rows, the same-output baseline is (proved rows) × 50 J, the all-goals one is (rows) × 50 J with the unproved count, both ratios are the printed baselines × 1000 ÷ the total, rounded down, and "same verified output" is claimed exactly once, on the proved-goals line. Falsified: printing the all-goals baseline and ratio on the same-output line fails the CLI test ("the same-output baseline is 6 x 50 J"); charging the proved baseline for every goal fails both tests. Each restored through git. Co-Authored-By: Claude Opus 5.5 --- proof/README.md | 2 +- .../proof-rs/crates/proof-cascade/src/lib.rs | 72 ++++++++++++++- proof/proof-rs/crates/proof-cli/src/main.rs | 51 +++++++--- .../crates/proof-cli/tests/headline.rs | 92 +++++++++++++++++++ 4 files changed, 202 insertions(+), 15 deletions(-) create mode 100644 proof/proof-rs/crates/proof-cli/tests/headline.rs diff --git a/proof/README.md b/proof/README.md index 48d65cd0c..708342ba9 100644 --- a/proof/README.md +++ b/proof/README.md @@ -40,7 +40,7 @@ proof/ - **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while its CPU energy rail is live; RAPL's package counters on Linux, used only while every one is live) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. A vendor counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5), and the meter checks that before it brackets anything, by one rule on both platforms (the counter keeps advancing in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms while the probe burns CPU): on Apple Silicon for IOReport's CPU energy rail, on Linux for every RAPL package counter the figure sums (`psys` is neither summed nor probed). Returns nothing where no interface is reachable or its counter is not live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. - **`proof-receipt`** — the `proof/1` JCR-1 receipt: canonical det-CBOR + COSE_Sign1/EdDSA, vendored byte-compatible with the family's cross-language verifiers; refuses to seal an unproved goal. - **`proof-conformance`** — C1–C5 + a negative pack, all checks real, published vectors in `conformance/v1/`. -- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline, the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; that needs a counter the meter has seen to be live (on Apple Silicon IOReport's CPU energy rail, on Linux every RAPL package counter). On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. +- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline — like for like, charged only for the goals the cascade proved, with the all-goals figure and its goal count beside it — the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; that needs a counter the meter has seen to be live (on Apple Silicon IOReport's CPU energy rail, on Linux every RAPL package counter). On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. `cargo test --workspace` green; `cargo clippy --workspace --all-targets` is warning-free for Linux, and on macOS reports two lints in proof-cascade's diff --git a/proof/proof-rs/crates/proof-cascade/src/lib.rs b/proof/proof-rs/crates/proof-cascade/src/lib.rs index 585fc767c..be72394ab 100644 --- a/proof/proof-rs/crates/proof-cascade/src/lib.rs +++ b/proof/proof-rs/crates/proof-cascade/src/lib.rs @@ -388,7 +388,16 @@ pub struct EnergySummary { pub from_cache: u64, pub total_uj: u64, pub wasted_uj: u64, + /// The frontier-only baseline over EVERY goal recorded, proved or not + /// (SPEC §5). pub baseline_uj: u64, + /// The frontier-only baseline over the PROVED goals alone: what a + /// frontier-only run is charged for the same verified output. A goal the + /// cascade did not prove produced nothing to compare, so it is not + /// charged here (it is in `baseline_uj`). A comparison offered as "for the + /// same verified output" is this against `total_uj` + /// ([`EnergySummary::proved_ratio_milli`]), never `baseline_uj` against it. + pub proved_baseline_uj: u64, /// Count of goals closed at each tier, indexed by `Tier::LADDER` order. pub closed_by_tier: [u64; 4], /// Joules attributed to each closing tier (Amdahl visibility). @@ -418,6 +427,7 @@ impl EnergySummary { self.baseline_uj = self.baseline_uj.saturating_add(r.baseline_uj); if r.proved { self.proved += 1; + self.proved_baseline_uj = self.proved_baseline_uj.saturating_add(r.baseline_uj); } if r.from_cache { self.from_cache += 1; @@ -437,14 +447,26 @@ impl EnergySummary { self.baseline_uj.saturating_sub(self.total_uj) } - /// `baseline / total`, in thousandths (so 9703 = 9.703×). Saturates if no - /// energy was spent. + /// `baseline / total`, in thousandths (so 9703 = 9.703×), with the + /// baseline charged for EVERY goal recorded — including goals the cascade + /// did not prove, so it is not a comparison for the same output; see + /// [`EnergySummary::proved_ratio_milli`]. Saturates if no energy was spent. pub fn energy_ratio_milli(&self) -> u64 { if self.total_uj == 0 { return u64::MAX; } self.baseline_uj.saturating_mul(1000) / self.total_uj } + + /// The like-for-like ratio, for the same verified output: the + /// frontier-only baseline charged only for the goals the cascade proved + /// ([`EnergySummary::proved_baseline_uj`]) ÷ every joule the cascade + /// spent, including those it spent on goals it did not prove — in + /// thousandths, rounded down. `None` if the cascade spent nothing (there + /// is no ratio to state); `Some(0)` if it proved nothing. + pub fn proved_ratio_milli(&self) -> Option { + (self.total_uj > 0).then(|| self.proved_baseline_uj.saturating_mul(1000) / self.total_uj) + } } #[cfg(test)] @@ -681,6 +703,52 @@ mod tests { assert_eq!(restored.len(), cache.len()); } + /// The same-output comparison charges the frontier-only baseline only for + /// the goals the cascade proved, against everything the cascade spent — + /// including the joules it spent failing on the goal it could not prove. + /// The all-goals ratio, which charges the baseline for the unproved goal + /// too, is larger, and is not a comparison for the same output. + #[test] + fn the_same_output_ratio_charges_the_baseline_only_for_proved_goals() { + // T0 closes Trivial, T1 Decidable; nothing closes Hard. + let r = Router::new(vec![ + Box::new(Gate { tier: Tier::Automation, closes: GoalKind::Trivial }), + Box::new(Gate { tier: Tier::Smt, closes: GoalKind::Decidable }), + ]); + let mut cache = ProofCache::new(); + let mut s = EnergySummary::default(); + for g in [ + ProofGoal::new("x=x", "c", GoalKind::Trivial), + ProofGoal::new("dec", "c", GoalKind::Decidable), + ProofGoal::new("hard", "c", GoalKind::Hard), + ] { + s.record(&r.prove(&g, &mut cache)); + } + // The tests' own literals: T0 = 50 uJ, T1 = 5_000 uJ, T3 = 50 J. + let total = 50 + (50 + 5_000) + (50 + 5_000); + assert_eq!((s.goals, s.proved, s.total_uj), (3, 2, total)); + assert_eq!(s.baseline_uj, 3 * 50_000_000, "the all-goals baseline charges the unproved goal too"); + assert_eq!(s.proved_baseline_uj, 2 * 50_000_000, "the same-output baseline charges the 2 proved goals only"); + assert_eq!(s.proved_ratio_milli(), Some(100_000_000_000 / total), "{s:?}"); + assert_eq!(s.proved_ratio_milli(), Some(9_852_216)); + assert_eq!(s.energy_ratio_milli(), 150_000_000_000 / total); + assert!(s.proved_ratio_milli().unwrap() < s.energy_ratio_milli()); + + // A cache hit is a proved goal served for nothing: charged to both + // baselines, adding no joules. + s.record(&r.prove(&ProofGoal::new("x=x", "c", GoalKind::Trivial), &mut cache)); + assert_eq!((s.proved_baseline_uj, s.total_uj), (3 * 50_000_000, total)); + + // Nothing spent: no ratio to state. Nothing proved: zero. + let mut free = EnergySummary::default(); + assert_eq!(free.proved_ratio_milli(), None); + free.record(&Router::new(Vec::new()).prove(&ProofGoal::new("hard", "c", GoalKind::Hard), &mut ProofCache::new())); + assert_eq!((free.proved, free.total_uj, free.proved_ratio_milli()), (0, 0, None)); + let mut none_proved = EnergySummary::default(); + none_proved.record(&r.prove(&ProofGoal::new("hard2", "c", GoalKind::Hard), &mut cache)); + assert_eq!((none_proved.proved_baseline_uj, none_proved.proved_ratio_milli()), (0, Some(0))); + } + #[test] fn summary_accounts_baseline_and_tiers() { let r = Router::new(ladder()); diff --git a/proof/proof-rs/crates/proof-cli/src/main.rs b/proof/proof-rs/crates/proof-cli/src/main.rs index cee5fdfca..7ada2899c 100644 --- a/proof/proof-rs/crates/proof-cli/src/main.rs +++ b/proof/proof-rs/crates/proof-cli/src/main.rs @@ -6,10 +6,12 @@ //! power model, tagged `ModelBased`. There is no T2/T3 rung, so goals that need //! one come back unproved. It reports, per goal, the tier that closed it and //! the joules it cost; then the headline — joules spent versus a frontier-only -//! run — the Amdahl breakdown by tier, the cache flywheel, and a sealed + -//! verified JCR-1 receipt. Only `--measure` attempts a hardware reading, and -//! only through a counter the meter has seen to be live (on Apple Silicon -//! IOReport's CPU energy rail, on Linux every RAPL package counter). +//! run charged for the goals the cascade proved, the same verified output, +//! with the all-goals figure and its goal count beside it — the Amdahl +//! breakdown by tier, the cache flywheel, and a sealed + verified JCR-1 +//! receipt. Only `--measure` attempts a hardware reading, and only through a +//! counter the meter has seen to be live (on Apple Silicon IOReport's CPU +//! energy rail, on Linux every RAPL package counter). use ed25519_dalek::SigningKey; use proof_schema::{GoalKind, ProofGoal}; @@ -65,7 +67,6 @@ fn main() { } } - let ratio = summary.energy_ratio_milli(); println!( "\n proved {}/{} · tiers T0={} T1={} T2={} T3={}", summary.proved, @@ -75,13 +76,39 @@ fn main() { summary.closed_by_tier[2], summary.closed_by_tier[3], ); - println!( - " energy {} uj vs {} uj frontier-only → {}.{:03}× less for the same verified output", - summary.total_uj, - summary.baseline_uj, - ratio / 1000, - ratio % 1000, - ); + // Like for like: the same verified output is the goals the cascade + // proved, so the frontier-only baseline is charged for those alone, and + // the cascade for every joule it spent — the goals it failed on included. + // The all-goals baseline also charges goals nothing proved here, so it is + // printed with its goal count and never called the same output. + let unproved = summary.goals - summary.proved; + match summary.proved_ratio_milli() { + Some(ratio) if summary.proved > 0 => println!( + " energy {} uj for all {} goals vs {} uj frontier-only for the {} proved → {}.{:03}× less for the same verified output", + summary.total_uj, + summary.goals, + summary.proved_baseline_uj, + summary.proved, + ratio / 1000, + ratio % 1000, + ), + Some(_) => println!( + " energy {} uj for all {} goals, none proved: no verified output to compare with a frontier-only run", + summary.total_uj, summary.goals, + ), + None => println!(" energy 0 uj: the cascade spent nothing, so there is no ratio to state"), + } + if unproved > 0 && summary.total_uj > 0 { + let all = summary.energy_ratio_milli(); + println!( + " (frontier-only for all {} goals: {} uj, {}.{:03}× — but {} of them were not proved here, so not the same output)", + summary.goals, + summary.baseline_uj, + all / 1000, + all % 1000, + unproved, + ); + } let t3 = summary.uj_by_tier[3] .checked_mul(100) .and_then(|n| n.checked_div(summary.total_uj.max(1))) diff --git a/proof/proof-rs/crates/proof-cli/tests/headline.rs b/proof/proof-rs/crates/proof-cli/tests/headline.rs new file mode 100644 index 000000000..1d65e7e66 --- /dev/null +++ b/proof/proof-rs/crates/proof-cli/tests/headline.rs @@ -0,0 +1,92 @@ +//! The `proof` headline is like for like. "For the same verified output" must +//! compare the cascade's whole spend with a frontier-only run charged ONLY for +//! the goals the cascade proved; the all-goals baseline, which also charges +//! goals nothing proved here, is printed with its goal count and never called +//! the same output. Every figure is checked against the per-goal rows the same +//! run printed, with the tests' own arithmetic: the frontier-only cost of a +//! goal is 50 J (50,000,000 uJ, T3's nominal cost), and a ratio is printed in +//! thousandths, rounded down. + +use std::process::Command; + +/// T3's nominal cost, the frontier-only charge per goal: the tests' own literal. +const FRONTIER_UJ: u64 = 50_000_000; + +/// The text between `before` and `after` on `line`. +fn between<'a>(line: &'a str, before: &str, after: &str) -> &'a str { + let at = line.find(before).unwrap_or_else(|| panic!("no {before:?} in {line:?}")) + before.len(); + let rest = &line[at..]; + &rest[..rest.find(after).unwrap_or_else(|| panic!("no {after:?} after {before:?} in {line:?}"))] +} + +fn num(s: &str) -> u64 { + s.trim().parse().unwrap_or_else(|_| panic!("not a number: {s:?}")) +} + +/// A ratio as printed: `.<3 digits>`, back to thousandths. +fn milli(s: &str) -> u64 { + let (int, frac) = s.split_once('.').unwrap_or_else(|| panic!("not a ratio: {s:?}")); + assert_eq!(frac.len(), 3, "a ratio not in thousandths: {s:?}"); + num(int) * 1000 + num(frac) +} + +#[test] +fn the_same_output_headline_charges_the_baseline_only_for_proved_goals() { + let out = Command::new(env!("CARGO_BIN_EXE_proof")).output().expect("the proof binary runs"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!(out.status.success(), "proof exited {:?}\n{stdout}", out.status); + + // The per-goal rows: tier (or `unproved`), then its joules. + let rows: Vec<(bool, u64)> = stdout + .lines() + .filter_map(|l| { + let mut f = l.split_whitespace(); + let tier = f.next()?; + if !(tier.starts_with("T0:") || tier.starts_with("T1:") || tier.starts_with("T2:") || tier.starts_with("T3:") || tier == "unproved") { + return None; + } + let uj = f.next()?.parse::().ok()?; + (f.next() == Some("uj")).then_some((tier != "unproved", uj)) + }) + .collect(); + let goals = rows.len() as u64; + let proved = rows.iter().filter(|(p, _)| *p).count() as u64; + let spent: u64 = rows.iter().map(|(_, uj)| uj).sum(); + assert!(goals > 0 && proved > 0 && spent > 0, "no per-goal rows parsed:\n{stdout}"); + + let tally = stdout.lines().find(|l| l.trim_start().starts_with("proved ")).expect("a proved tally"); + assert_eq!(between(tally, "proved ", " ·"), format!("{proved}/{goals}"), "the tally, against the rows"); + + let same: Vec<&str> = stdout.lines().filter(|l| l.contains("same verified output")).collect(); + assert_eq!(same.len(), 1, "\"same verified output\" must be claimed exactly once:\n{stdout}"); + let head = same[0]; + let total = num(between(head, "energy ", " uj for all")); + let head_goals = num(between(head, " uj for all ", " goals")); + let baseline = num(between(head, " vs ", " uj frontier-only for the ")); + let baseline_goals = num(between(head, " uj frontier-only for the ", " proved")); + let ratio = milli(between(head, " → ", "× less for the same verified output")); + eprintln!( + "proof headline: rows={goals} proved={proved} spent={spent} | printed total={total} baseline={baseline} for \ + {baseline_goals} proved, ratio={ratio}/1000" + ); + assert_eq!(total, spent, "the cascade's figure is not the sum of the rows it printed:\n{stdout}"); + assert_eq!(head_goals, goals, "the cascade's figure must cover every goal it ran:\n{stdout}"); + assert_eq!(baseline_goals, proved, "the same-output baseline is charged for the proved goals:\n{stdout}"); + assert_eq!(baseline, proved * FRONTIER_UJ, "the same-output baseline is {proved} x 50 J:\n{stdout}"); + assert_eq!(ratio, baseline * 1000 / total, "the same-output ratio, in thousandths rounded down:\n{stdout}"); + + let unproved = goals - proved; + let all = stdout.lines().find(|l| l.contains("frontier-only for all ")); + if unproved > 0 { + let all = all.unwrap_or_else(|| panic!("{unproved} goals unproved, but no all-goals figure:\n{stdout}")); + assert!(!all.contains("same verified output"), "the all-goals figure claims the same output: {all}"); + assert_eq!(num(between(all, "frontier-only for all ", " goals:")), goals); + let all_baseline = num(between(all, " goals: ", " uj, ")); + assert_eq!(all_baseline, goals * FRONTIER_UJ, "the all-goals baseline is {goals} x 50 J: {all}"); + assert_eq!(milli(between(all, " uj, ", "× — but")), all_baseline * 1000 / total, "{all}"); + assert_eq!(num(between(all, "× — but ", " of them were not proved")), unproved, "{all}"); + assert!(all_baseline > baseline, "the all-goals baseline must exceed the same-output one: {all}"); + } else { + assert!(all.is_none(), "every goal was proved, yet a separate all-goals figure:\n{stdout}"); + } +} -- 2.54.0 (Apple Git-157) From 0503e69b326449050444d5866f7368b8e06944c4 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 21:32:21 -0400 Subject: [PATCH 10/10] proof-cascade: IOReport is a hardware reading only while every CPU rail it sums keeps advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IOReport watch, `CPU_RAILS`, was "any one": Live as soon as ONE CPU rail met the sustained-advance rule. The power reading sums every CPU rail (`counts_once`), and on a multi-die part that is two rails, `DIE_0_CPU Energy` and `DIE_1_CPU Energy` on an M3 Ultra. So one die live beside a frozen one would have admitted the meter and bracketed one die's energy as the whole CPU's, as `HwShunt`. RAPL was already held to every package counter it sums (9334ec0c); IOReport now is too. The rule: Live only when every CPU rail present in the samples advanced in at least five intervals, in at least 80% of those observed, over at least 300 ms; Frozen otherwise; Absent with no CPU rail. A rail is present if it is in any sample, the first included (the watch already registered every sample's rails; a test now pins it), and is judged over every interval observed. With every source on the same rule there is nothing for `Watched.every` to choose, so the field is gone: a `Watched` is which channels to watch, and the watch needs every one of them. Two test-side checks encoded the old rule and now encode this one: the oracle behind the real-IOReport tests says "moved" only when every CPU rail it reads changed at five reads or more and at 80% of them, and `assert_verdict_matches_record`, the tests' own reading of a probe record, calls a run Live only when every CPU rail seen, the first sample's included, rose in at least 5 intervals at 80% over 300 ms. The oracle reads exactly the probe's rails on both hosts: one on this M5 Max, the two dies on the M3 Ultra. Docs: the meter module doc, `IoReportStatus`, `CpuRail`, `Watched`, `CPU_RAILS`, `RailWatch` and `PACKAGES`; SPEC §5's liveness clause (for IOReport, every CPU rail the figure sums, one per die, must advance on its own); the proof README. Tests, all scripted (macOS here). The rule: `watch_two_dies_that_both_keep_advancing_is_live` (live at the tenth interval, or at the fifteenth when one die misses its first three, either die), `watch_one_die_that_keeps_advancing_beside_a_frozen_one_is_frozen` (the other die held, stepped once, stepped five times, or advanced three intervals in five, either die; and twin `CPU Energy` channels), `watch_one_die_alone_that_keeps_advancing_is_live`, and `watch_a_die_in_only_some_samples_is_a_rail_the_watch_needs`. The whole probe loop: `the_probe_needs_every_die_to_keep_advancing`. The real `host_status()` on scripted reads: `host_status_calls_two_dies_that_keep_advancing_live` and `host_status_calls_one_live_die_beside_a_frozen_one_frozen`. The single-rail tests are unchanged. Two existing tests encoded "any one": `two_advances_of_the_same_rail_are_not_live_and_sustained_ones_are` held DIE_0 constant and expected Live from DIE_1 alone, so DIE_0 now advances at every interval and the verdict is still DIE_1's; and `rapl_every_package_counter_must_keep_advancing` drops its contrast with the any-one-rail watch, which no longer exists. proof-cascade: 86 lib tests and the three integration files pass on this M5 Max. The same lib test binary passes 86/86 on the M3 Ultra (macOS 27.0.1), where the real probe gave `CpuRailFrozen` over 25 and 26 intervals and the oracle saw 0 changes on both dies in 19 reads. `cargo test --workspace` passes (126 tests) and the proof-conformance binary passes all 10 checks. Clippy adds nothing for aarch64-apple-darwin (two warnings in the IOReport FFI predate this) and is clean for x86_64-unknown-linux-gnu. Falsified, each restored from git and the suite re-run green: the rule back to any one rail fails the one-live-die-beside-a-frozen-one tests (rule, loop and `host_status`), the two-live-dies test, the some-samples test, the updated two-advances test and the RAPL every-package test; registering a rail only once an interval has closed fails `watch_a_die_in_only_some_samples_is_a_rail_the_watch_needs`. Co-Authored-By: Claude Opus 5.5 --- proof/README.md | 4 +- proof/SPEC.md | 16 +- .../crates/proof-cascade/src/meter.rs | 411 ++++++++++++++---- 3 files changed, 337 insertions(+), 94 deletions(-) diff --git a/proof/README.md b/proof/README.md index 708342ba9..08b0bdd78 100644 --- a/proof/README.md +++ b/proof/README.md @@ -37,10 +37,10 @@ proof/ - **`proof-schema`** — the content-addressed goal (`blake3(statement‖0x00‖context)`), the cheapest-first tier ladder, the honest energy model (`HwShunt`/`ModelBased`/`Estimator` with the coupling enforced), `SpecGrounding`, the verdict, and the `Solver` trait every backend implements. Canonical det-CBOR content addressing. - **`proof-solvers`** — two **real** rungs, their energy modeled from measured wall time (`ModelBased`, a ~15 W power model — not a hardware reading): a propositional decider (T0) that parses a boolean goal and decides validity by truth-table in process, and `z3` (T1) — shell or kept **resident** across goals — that decides an SMT-LIB goal (assert the negation; `unsat` ⇒ valid). Each refuses rather than fakes, so the router escalates. The model rungs (T2/T3) are honest stand-ins behind the same trait. -- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while its CPU energy rail is live; RAPL's package counters on Linux, used only while every one is live) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. A vendor counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5), and the meter checks that before it brackets anything, by one rule on both platforms (the counter keeps advancing in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms while the probe burns CPU): on Apple Silicon for IOReport's CPU energy rail, on Linux for every RAPL package counter the figure sums (`psys` is neither summed nor probed). Returns nothing where no interface is reachable or its counter is not live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. +- **`proof-cascade`** — the escalation router (every joule accounted, `wasted_uj` disclosed), the content-addressed proof cache (the energy flywheel, durable as line-delimited JSON), batch energy accounting against a frontier-only baseline with the per-tier Amdahl breakdown, and a **real hardware energy meter** (`meter` — the `HwShunt` backend): it brackets a real proof, run on every core, against an idle baseline using the platform's energy interface (IOReport's Energy Model, read directly through its C API on Apple Silicon and used only while every CPU energy rail is live, one per die on a multi-die part; RAPL's package counters on Linux, used only while every one is live) and returns measured microjoules, so the headline `joules per verified proof` can be a real reading, not only a model. A vendor counter's reading is graded `HwShunt` only while the counter is live for the domain it claims ([`SPEC.md`](SPEC.md) §5), and the meter checks that before it brackets anything, by one rule on both platforms (the counter keeps advancing in at least 5 sample intervals, at least 4 of every 5, over at least 300 ms while the probe burns CPU): on Apple Silicon for every IOReport CPU energy rail the figure sums (`CPU Energy`, or each die's `DIE__CPU Energy`), on Linux for every RAPL package counter the figure sums (`psys` is neither summed nor probed). Returns nothing where no interface is reachable or its counter is not live — the caller keeps the honestly-tagged `ModelBased` figure rather than inventing one. - **`proof-receipt`** — the `proof/1` JCR-1 receipt: canonical det-CBOR + COSE_Sign1/EdDSA, vendored byte-compatible with the family's cross-language verifiers; refuses to seal an unproved goal. - **`proof-conformance`** — C1–C5 + a negative pack, all checks real, published vectors in `conformance/v1/`. -- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline — like for like, charged only for the goals the cascade proved, with the all-goals figure and its goal count beside it — the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; that needs a counter the meter has seen to be live (on Apple Silicon IOReport's CPU energy rail, on Linux every RAPL package counter). On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. +- **`proof-cli`** — the `proof` reference demo: a proof workload reporting joules-per-proof against a frontier-only baseline — like for like, charged only for the goals the cascade proved, with the all-goals figure and its goal count beside it — the Amdahl breakdown, the cache flywheel, and a sealed + verified receipt. `proof --measure` adds a **real hardware reading** of a non-trivial T0 proof (`HwShunt`) where the meter produces one; that needs a counter the meter has seen to be live (on Apple Silicon every IOReport CPU energy rail, on Linux every RAPL package counter). On the Macs measured so far (macOS 27.0.1, an M5 Max and an M3 Ultra) IOReport's CPU energy rail is frozen for an unprivileged reader, so `--measure` declines there, says why, and leaves every figure `ModelBased`; on Linux, RAPL's `energy_uj` is root-only on current kernels. `cargo test --workspace` green; `cargo clippy --workspace --all-targets` is warning-free for Linux, and on macOS reports two lints in proof-cascade's diff --git a/proof/SPEC.md b/proof/SPEC.md index 80c5de00c..a48379529 100644 --- a/proof/SPEC.md +++ b/proof/SPEC.md @@ -101,13 +101,15 @@ measured figure, or `Estimator` with one, is invalid. IOReport, ...) MAY be graded as a hardware reading only while the counter is demonstrably live for the domain the figure claims: the implementation MUST have observed the counter covering that domain advance under load (for Apple -IOReport: the CPU energy rail itself advancing in at least 5 sample intervals, -in at least 4 of every 5, over at least 300 ms while the probing thread burns -CPU). A counter that is present but not live - frozen, or republished in -lumps - MUST NOT produce a hardware-graded figure; the implementation falls -back to its estimator or reports the work unmetered, and SHOULD record why. In -this standard a hardware-graded figure is `HwShunt`; the fallback is -`ModelBased` or `Estimator`. +IOReport: every CPU energy rail the figure sums - the rail itself, one per die +on a multi-die part - advancing on its own in at least 5 sample intervals, in +at least 4 of every 5, over at least 300 ms while the probing thread burns CPU; +one rail that held leaves the whole figure not live). A counter that is +present but not live - frozen, or republished in lumps - MUST NOT produce a +hardware-graded figure; the implementation falls back to its estimator or +reports the work unmetered, and SHOULD record why. In this standard a +hardware-graded figure is `HwShunt`; the fallback is `ModelBased` or +`Estimator`. Batch accounting reports, against a **frontier-only baseline** (every goal costed at T3), the total joules, the savings, and the energy attributed to each diff --git a/proof/proof-rs/crates/proof-cascade/src/meter.rs b/proof/proof-rs/crates/proof-cascade/src/meter.rs index 73f5de8c6..a7ac8e22b 100644 --- a/proof/proof-rs/crates/proof-cascade/src/meter.rs +++ b/proof/proof-rs/crates/proof-cascade/src/meter.rs @@ -18,8 +18,8 @@ //! On both platforms this meter returns `None` for such a counter and the //! caller keeps its own estimate. //! -//! **On Apple Silicon, IOReport counts as reachable only while its CPU energy -//! rail is live** ([`ioreport_status`](crate::meter::ioreport_status)). A +//! **On Apple Silicon, IOReport counts as reachable only while every CPU +//! energy rail is live** ([`ioreport_status`](crate::meter::ioreport_status)). A //! subscription can open while no CPU rail tracks the work: measured on macOS //! 27.0.1 for an unprivileged reader, the CPU rails of an M5 Max and an M3 Ultra //! hold their value under minutes of full CPU load and move only in rare batch @@ -28,11 +28,14 @@ //! to publish one refresh in two steps 64 ms apart), while the M5 Max's GPU //! rail keeps advancing. Bracketing CPU work against idle on those rails //! brackets GPU activity, nothing, or a refresh. So the interface is used only -//! when the same CPU rail keeps advancing while the probe burns CPU — in at -//! least five separate sample intervals, in at least 80% of the intervals -//! observed, over at least 300 ms of wall time — which no refresh observed so -//! far does; otherwise `None` is returned and the caller keeps its -//! `ModelBased` figure. +//! when every CPU rail — `CPU Energy`, or each die's `DIE__CPU Energy` on a +//! multi-die part such as the M3 Ultra — keeps advancing on its own while the +//! probe burns CPU — in at least five separate sample intervals, in at least +//! 80% of the intervals observed, over at least 300 ms of wall time — which no +//! refresh observed so far does; otherwise `None` is returned and the caller +//! keeps its `ModelBased` figure. Every rail, not one: the power reading sums +//! every CPU rail, so a die whose rail held is a die the figure would claim +//! without reading it. //! //! **On Linux, RAPL counts as reachable only while every package counter is //! live** ([`rapl_status`](crate::meter::rapl_status)). The same probe loop and @@ -163,18 +166,20 @@ pub enum IoReportStatus { /// `CPU Energy` rail exists in it — an unfamiliar channel naming. Never /// used. CpuRailAbsent, - /// A CPU energy rail exists but did not keep advancing while CPU was - /// burned: no CPU rail advanced in five separate sample intervals that - /// were at least 80% of those observed, over at least 300 ms. A rail that - /// stepped once, or in a few steps close together, did not track the work - /// either — that is a batch refresh. The platform is not exposing live CPU - /// energy to this process, so bracketing CPU work on it would measure - /// GPU-only energy, nothing, or a refresh. Observed on macOS 27.0.1 on - /// both an M5 Max and an M3 Ultra. + /// A CPU energy rail exists, but not every one kept advancing while CPU + /// was burned: at least one CPU rail (on a multi-die part, one die's) did + /// not advance in five separate sample intervals that were at least 80% of + /// those observed, over at least 300 ms. A rail that stepped once, or in a + /// few steps close together, did not track the work either — that is a + /// batch refresh. The platform is not exposing live energy for the whole + /// CPU to this process, so bracketing CPU work on it would measure + /// GPU-only energy, nothing, a refresh, or some dies' energy as the whole + /// CPU's. Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. CpuRailFrozen, - /// The same CPU energy rail kept advancing while the probe burned CPU: in - /// at least five separate sample intervals, in at least 80% of the - /// intervals observed, over at least 300 ms of wall time. IOReport is used. + /// Every CPU energy rail kept advancing, each one on its own, while the + /// probe burned CPU: in at least five separate sample intervals, in at + /// least 80% of the intervals observed, over at least 300 ms of wall time. + /// IOReport is used. Live, } @@ -517,8 +522,8 @@ where mod liveness { use std::time::Duration; - /// In how many separate sample intervals the **same** CPU rail must advance - /// to count as live. Two is not enough — a batch refresh can be published + /// In how many separate sample intervals each watched rail must advance, on + /// its own, to count as live. Two is not enough — a batch refresh can be published /// in two steps — and neither is any count on its own: see [`RailWatch`]. pub(super) const MIN_LIVE_ADVANCES: u32 = 5; @@ -539,14 +544,14 @@ mod liveness { /// What the liveness probe saw of the CPU energy rail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(super) enum CpuRail { - /// One CPU energy rail kept advancing while the probing thread burned - /// CPU: in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, - /// in at least 80% of the intervals observed, over at least - /// [`MIN_LIVE_WINDOW`] of wall time. + /// Every CPU energy rail kept advancing, each one on its own, while + /// the probing thread burned CPU: in at least [`MIN_LIVE_ADVANCES`] + /// separate sample intervals, in at least 80% of the intervals + /// observed, over at least [`MIN_LIVE_WINDOW`] of wall time. Live, - /// A CPU energy rail exists, but none kept advancing like that: it held - /// its value, or moved in one or a few steps close together (a batch - /// refresh, not a live reading). + /// A CPU energy rail exists, but not every one kept advancing like + /// that: at least one held its value, or moved in one or a few steps + /// close together (a batch refresh, not a live reading). Frozen, /// No CPU energy channel exists in the Energy Model group. Absent, @@ -626,31 +631,31 @@ mod liveness { pub(super) gpu: Vec<(RailKey, i64)>, } - /// Which channels of a sample a [`RailWatch`] watches, and whether one of - /// them or every one must keep advancing for the source to be live. + /// Which channels of a sample a [`RailWatch`] watches. EVERY one of them + /// seen must keep advancing for the source to be live: each source's + /// figure sums every counter it watches (IOReport's CPU rails, one per die + /// on a multi-die part; RAPL's package counters), and a counter that held + /// is a domain that figure would claim without reading it. #[derive(Debug, Clone, Copy)] pub(super) struct Watched { /// Whether a channel is one of the watched counters. pub(super) pick: fn(&str) -> bool, - /// `false`: live once ONE watched counter meets the rule (IOReport: a - /// figure is built on a CPU rail of either die). `true`: live only - /// once EVERY watched counter seen meets it, and at least one was seen - /// (RAPL: the figure sums every package counter, so each must be live). - pub(super) every: bool, } /// The IOReport watch: the CPU energy rails (`CPU Energy`, `DIE__CPU - /// Energy`), any one of which may prove the interface live. - pub(super) const CPU_RAILS: Watched = Watched { pick: is_cpu_energy_rail, every: false }; + /// Energy`), every one of which must keep advancing: the power reading + /// sums them all. + pub(super) const CPU_RAILS: Watched = Watched { pick: is_cpu_energy_rail }; /// The liveness rule, fed one whole sample at a time, with the wall-clock /// reading at which it was taken. It watches the channels its [`Watched`] - /// picks — by default, IOReport's CPU energy rails, any one of which may - /// prove the interface live ([`CPU_RAILS`]) — and the same rule decides - /// RAPL's package counters, each of which must ([`super::rapl::PACKAGES`]). + /// picks — by default IOReport's CPU energy rails ([`CPU_RAILS`]); the + /// same rule decides RAPL's package counters ([`super::rapl::PACKAGES`]). /// - /// A rail is live only when the **same** CPU rail keeps advancing: after - /// some interval, all three of these hold at once — + /// The source is live only when EVERY watched rail keeps advancing, each + /// one on its own, and at least one was seen: after some interval, all + /// three of these hold at once for every rail seen in any sample, the + /// first included — /// /// - it advanced in at least [`MIN_LIVE_ADVANCES`] separate intervals; /// - those advancing intervals are at least 80% of all the intervals @@ -658,8 +663,14 @@ mod liveness { /// - the observation so far spans at least [`MIN_LIVE_WINDOW`] of wall /// time, from the first sample to this one. /// - /// The rule is evaluated after every interval, and the rail is live from - /// the first interval at which all three hold. An interval is two + /// Every rail, because the figure sums every rail it watches: on an M3 + /// Ultra `DIE_0_CPU Energy` and `DIE_1_CPU Energy` both go into the CPU + /// figure, so one die's live rail cannot vouch for a die that held. A rail + /// missing from some samples is still a rail: it advanced in none of the + /// intervals it was missing from. + /// + /// The rule is evaluated after every interval, and the source is live from + /// the first interval at which all three hold for every rail. An interval is two /// CONSECUTIVE samples and an advance is the rail's raw integer being /// larger than in the sample just before. The watch keeps the previous /// sample itself, so the comparison cannot drift to a fixed baseline. @@ -691,8 +702,7 @@ mod liveness { /// false "live". #[derive(Debug)] pub(super) struct RailWatch { - /// Which channels are watched, and whether one or every one must meet - /// the rule. + /// Which channels are watched. watched: Watched, /// Sample intervals observed so far (successful samples after the first). intervals: u32, @@ -702,11 +712,12 @@ mod liveness { /// The CPU rails of the previous successful sample, with their raw /// integers. `None` until the first sample. prev: Option>, - /// Every CPU rail seen, with the number of intervals in which it advanced. + /// Every watched rail seen in any sample, the first included, with the + /// number of intervals in which it advanced. rails: Vec<(RailKey, u32)>, - /// Whether a rail has met the rule after some interval. Once set, it - /// stays set: the rule declares Live as soon as all three conditions - /// hold, and the probe stops sampling there. + /// Whether every rail seen has met the rule after some interval. Once + /// set, it stays set: the rule declares Live as soon as all three + /// conditions hold for every rail, and the probe stops sampling there. live: bool, /// What each successful sample handed the rule, in order. seen: Vec, @@ -752,11 +763,7 @@ mod liveness { } let window = at.saturating_sub(first_at); let meets = |(_, n): &(RailKey, u32)| *n >= MIN_LIVE_ADVANCES && sustained(*n, self.intervals); - let met = if self.watched.every { - !self.rails.is_empty() && self.rails.iter().all(meets) - } else { - self.rails.iter().any(meets) - }; + let met = !self.rails.is_empty() && self.rails.iter().all(meets); self.live = self.live || (window >= MIN_LIVE_WINDOW && met); } self.prev = Some(cur); @@ -767,12 +774,13 @@ mod liveness { self.intervals } - /// Has one CPU rail kept advancing — met the rule after some interval? + /// Has every watched rail kept advancing — met the rule after some + /// interval? pub(super) fn is_live(&self) -> bool { self.live } - /// Keep sampling? Until a rail has proved live, or `budget` has elapsed + /// Keep sampling? Until the rails have proved live, or `budget` has elapsed /// **and** at least [`MIN_LIVE_ADVANCES`] intervals were observed, so a /// live rail could have shown that many advances — slow IOReport /// samples must not make a live rail look frozen — but never past @@ -1144,11 +1152,14 @@ mod liveness { /// Two advances of the same rail — here the second die, with a hold /// between them — are not live, however far apart; the same rail /// advancing in five of six intervals over 600 ms is, and not before. + /// The first die advances at every interval, so the verdict is the + /// second die's: every die must keep advancing. #[test] fn two_advances_of_the_same_rail_are_not_live_and_sustained_ones_are() { let script: Vec<_> = [20, 21, 21, 22, 23, 24, 25, 26] .iter() - .map(|v| vec![("DIE_0_CPU Energy", 10), ("DIE_1_CPU Energy", *v)]) + .enumerate() + .map(|(i, v)| vec![("DIE_0_CPU Energy", 10 + i as i64), ("DIE_1_CPU Energy", *v)]) .collect(); let (w, live) = watch(LOADED, &script[..4]); assert_eq!((w.intervals(), w.verdict()), (3, Some(CpuRail::Frozen)), "two advances over 300 ms: {live:?}"); @@ -1512,6 +1523,167 @@ mod liveness { } } + // ── Every CPU rail: the power reading sums each die's rail ────────── + + /// The two CPU rails of an M3 Ultra. + const DIES: (&str, &str) = ("DIE_0_CPU Energy", "DIE_1_CPU Energy"); + + /// A rail's values over 32 intervals: +7 in interval `i` (from 1) + /// exactly when `advances(i)`, held otherwise. + fn rail_where(advances: impl Fn(usize) -> bool) -> Vec { + let mut v = vec![1_000_000i64]; + for i in 1..=32 { + let last = v[i - 1]; + v.push(if advances(i) { last + 7 } else { last }); + } + v + } + + /// The two dies at these values, sample by sample, beside a GPU rail + /// that moves at every sample. + fn dies(v0: &[i64], v1: &[i64]) -> Vec> { + assert_eq!(v0.len(), v1.len()); + (0..v0.len()).map(|i| vec![(DIES.0, v0[i]), (DIES.1, v1[i]), ("GPU Energy", i as i64)]).collect() + } + + /// (a) Both dies keep advancing: Live, at the interval where the + /// slower die is, never before, whichever die that is. Both at every + /// interval: the tenth, where the window reaches 300 ms at ~31 ms an + /// interval, as for one rail. One missing an interval in five: the + /// tenth too (eight of ten). One missing its first three: the + /// fifteenth (twelve of fifteen), though the other alone was ready at + /// the tenth. + #[test] + fn watch_two_dies_that_both_keep_advancing_is_live() { + let every = rail_where(|_| true); + for (what, slower, at) in [ + ("at every interval", rail_where(|_| true), 10), + ("missing one interval in five", rail_where(|i| i % 5 != 0), 10), + ("missing its first three intervals", rail_where(|i| i > 3), 15), + ] { + for samples in [dies(&every, &slower), dies(&slower, &every)] { + let (w, live) = watch(QUIET, &samples); + assert_eq!(first_live(&live), Some(at), "one die {what}: {w:?}"); + assert!(live[at..].iter().all(|l| *l), "one die {what}: Live was undone: {live:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Live), "one die {what}"); + assert_eq!(w.rails.len(), 2, "two dies, two rails: {w:?}"); + } + } + } + + /// (b) One die keeps advancing at every interval while the other does + /// not: Frozen, and never Live at any interval, whichever die it is. + /// The other held for the whole budget (its energy is in the CPU + /// figure, unread), stepped once, stepped five times in a refresh, or + /// advanced in three intervals of five. The one-rail rule called each + /// of these Live at the tenth interval. The same for two channels both + /// named `CPU Energy`. + #[test] + fn watch_one_die_that_keeps_advancing_beside_a_frozen_one_is_frozen() { + let every = rail_where(|_| true); + for (what, other) in [ + ("held", rail_where(|_| false)), + ("stepped once", rail_where(|i| i == 4)), + ("stepped five times in a refresh", rail_where(|i| i <= 5)), + ("advanced in three intervals of five", rail_where(|i| i % 5 != 0 && i % 5 != 4)), + ] { + for samples in [dies(&every, &other), dies(&other, &every)] { + let (w, live) = watch(QUIET, &samples); + assert_eq!(first_live(&live), None, "the other die {what}: {w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "the other die {what}"); + assert!(w.rails.iter().any(|(_, n)| *n == 32), "one die advanced in every interval: {w:?}"); + } + } + let held = rail_where(|_| false); + let twins: Vec> = + every.iter().zip(&held).map(|(a, b)| vec![("CPU Energy", *a), ("CPU Energy", *b)]).collect(); + let (w, live) = watch(QUIET, &twins); + assert_eq!(first_live(&live), None, "twin channels, one held: {w:?}"); + assert_eq!(w.rails, [(("CPU Energy".to_string(), 0), 32), (("CPU Energy".to_string(), 1), 0)]); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + } + + /// (c) One die that keeps advancing, and no other die in any sample: + /// a part whose CPU figure sums that one rail. Live at the tenth + /// interval, exactly as `CPU Energy` alone is. + #[test] + fn watch_one_die_alone_that_keeps_advancing_is_live() { + let samples: Vec> = + rail_where(|_| true).iter().enumerate().map(|(i, v)| vec![(DIES.0, *v), ("GPU Energy", i as i64)]).collect(); + let (w, live) = watch(QUIET, &samples); + assert_eq!(first_live(&live), Some(10), "{w:?}"); + assert_eq!(w.rails, [((DIES.0.to_string(), 0), 32)]); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + } + + /// A die in only some of the samples is a rail the source needs, + /// judged over every interval: it advanced in none of those it was + /// missing from. DIE_1 in the first sample alone, in the first five + /// alone, or from the third on and holding: Frozen, never Live. DIE_1 + /// from the fifth sample on, advancing at every interval after: Live + /// only once its advances are 80% of every interval, at the twentieth + /// (sixteen of twenty), though DIE_0 was ready at the tenth. + #[test] + fn watch_a_die_in_only_some_samples_is_a_rail_the_watch_needs() { + let v = rail_where(|_| true); + let held = 9_742_153; + let script = |die1: &dyn Fn(usize) -> Option| -> Vec> { + (0..=32) + .map(|i| { + let mut s = vec![(DIES.0, v[i])]; + if let Some(x) = die1(i) { + s.push((DIES.1, x)); + } + s + }) + .collect() + }; + for (what, samples) in [ + ("in the first sample alone", script(&|i| (i == 0).then_some(held))), + ("in the first five samples alone", script(&|i| (i < 5).then(|| v[i]))), + ("from the third sample on, holding", script(&|i| (i >= 2).then_some(held))), + ] { + let (w, live) = watch(QUIET, &samples); + assert_eq!(first_live(&live), None, "DIE_1 {what}: {w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "DIE_1 {what}"); + } + let (w, _) = watch(QUIET, &script(&|i| (i == 0).then_some(held))); + assert_eq!( + w.rails, + [((DIES.0.to_string(), 0), 32), ((DIES.1.to_string(), 0), 0)], + "the first sample's rails are rails" + ); + let (w, live) = watch(QUIET, &script(&|i| (i >= 4).then(|| v[i]))); + assert_eq!(first_live(&live), Some(20), "DIE_1 from the fifth sample on, advancing: {w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + } + + /// (a), (b) and (c) through the whole probe loop, with a GPU rail + /// moving at every read. Both dies advancing at every read: Live at + /// the tenth interval, where the probe stops. DIE_0 advancing and + /// DIE_1 held: Frozen, after the whole budget. DIE_0 alone, advancing: + /// Live at the tenth. + #[test] + fn the_probe_needs_every_die_to_keep_advancing() { + let r = run(QUIET, |n| { + let n = i64::from(n); + Some(vec![(DIES.0, 100 + n), (DIES.1, 900 + 2 * n), ("GPU Energy", n)]) + }); + assert_eq!(r.verdict, Some(CpuRail::Live), "both dies: {r:?}"); + assert_eq!((r.intervals, r.samples), (10, 11), "both dies: {r:?}"); + + let r = run(QUIET, |n| { + let n = i64::from(n); + Some(vec![(DIES.0, 100 + n), (DIES.1, 9_742_153), ("GPU Energy", n)]) + }); + assert_eq!(r.verdict, Some(CpuRail::Frozen), "DIE_1 held: {r:?}"); + assert!(r.took >= BUDGET, "DIE_1 held: stopped at {:?}, before the budget", r.took); + + let r = run(QUIET, |n| Some(vec![(DIES.0, 100 + i64::from(n)), ("GPU Energy", i64::from(n))])); + assert_eq!(r.verdict, Some(CpuRail::Live), "DIE_0 alone: {r:?}"); + assert_eq!((r.intervals, r.samples), (10, 11), "DIE_0 alone: {r:?}"); + } + /// The stopping rule on its own: within the budget until a rail proves /// live; past the budget only until a live rail could have advanced in /// five intervals; and never past five budgets. @@ -1901,8 +2073,8 @@ mod rapl { /// The liveness rule over RAPL: live only when EVERY package counter /// keeps advancing (the figure sums all of them), by the same - /// sustained-advance rule as IOReport's CPU rail. - pub(super) const PACKAGES: Watched = Watched { pick: is_package_rail, every: true }; + /// sustained-advance rule as IOReport's CPU rails. + pub(super) const PACKAGES: Watched = Watched { pick: is_package_rail }; /// What one read of the zones hands the liveness rule: each PACKAGE /// zone's raw `energy_uj`, under its sysfs name, in zone order. Other @@ -2820,10 +2992,13 @@ mod macos_ioreport { } } - /// The oracle's verdict so far: one rail changed at no fewer than - /// [`ORACLE_MIN_CHANGES`] reads, and at at least 80% of them. + /// The oracle's verdict so far: EVERY CPU rail it reads, and at + /// least one, changed at no fewer than [`ORACLE_MIN_CHANGES`] + /// reads, and at at least 80% of them — what a Live status claims. + /// One die that keeps moving does not vouch for one that held. fn moved(&self) -> bool { - self.changes.iter().any(|&c| c >= ORACLE_MIN_CHANGES && c * 5 >= self.reads * 4) + !self.changes.is_empty() + && self.changes.iter().all(|&c| c >= ORACLE_MIN_CHANGES && c * 5 >= self.reads * 4) } } @@ -2866,6 +3041,19 @@ mod macos_ioreport { assert!(t.moved(), "80% of reads: {t:?}"); let (t, _) = tally(&[1, 2, 3, 3, 4, 5, 5, 6, 7, 7, 8]); assert!(!t.moved(), "70% of reads: {t:?}"); + // Two dies: moved only when both kept changing. + let dies = |d0: &[i64], d1: &[i64]| { + let mut t = OracleTally::new(2); + for i in 1..d0.len() { + t.add(&[d0[i - 1], d1[i - 1]], &[d0[i], d1[i]]); + } + t + }; + let (moving, held) = ([1, 2, 3, 4, 5, 6], [9, 9, 9, 9, 9, 9]); + assert!(dies(&moving, &moving).moved(), "both dies changing at every read"); + assert!(!dies(&moving, &held).moved(), "one die changing at every read, the other held"); + assert!(!dies(&held, &moving).moved(), "the other die held"); + assert!(!OracleTally::new(0).moved(), "no rail"); } /// One oracle run: its verdict, how many reads it compared, whether any @@ -2888,9 +3076,9 @@ mod macos_ioreport { /// filter and die-prefix stripping, and burns CPU on a helper thread for /// the whole of its window while it reads. /// - /// `CpuRailMoved(true)` only if one rail's absolute value changed at - /// [`ORACLE_MIN_CHANGES`] or more of its reads AND at at least 80% of - /// them. A live rail changes at nearly every read (the oracle's reads + /// `CpuRailMoved(true)` only if every CPU rail's absolute value changed + /// at [`ORACLE_MIN_CHANGES`] or more of its reads AND at at least 80% + /// of them. A live rail changes at nearly every read (the oracle's reads /// of every channel are 0.1–0.8 s apart); a batch refresh changes a /// rail at one read, or two when it is published in two steps (the M5 /// Max's `CPU Energy`: two steps 64 ms apart), and then holds. So a @@ -3041,14 +3229,16 @@ mod macos_ioreport { /// their own literals (not `RailWatch` or its constants): the intervals /// between consecutive recorded samples, each placed at the wall time /// its closing read returned, and the first interval, if any, after - /// which one CPU rail had risen in at least 5 intervals, in at least - /// 80% of those so far, over at least 300 ms from the first sample. + /// which EVERY CPU rail seen in any sample (the first included) had + /// risen in at least 5 intervals, in at least 80% of those so far, + /// over at least 300 ms from the first sample. /// The run's verdict must agree with it, and a Live run must have /// stopped at that very interval. fn assert_verdict_matches_record(run: &ProbeRun, who: &str) { let at: Vec = run.calls.iter().filter(|c| c.listed.is_some()).map(|c| c.end).collect(); assert_eq!(run.seen.len(), at.len(), "{who}: one record per sample"); - let mut rises: Vec<(RailKey, u32)> = Vec::new(); + let mut rises: Vec<(RailKey, u32)> = + run.seen.first().map(|s| s.cpu.iter().map(|(k, _)| (k.clone(), 0)).collect()).unwrap_or_default(); let mut first_live = None; for (i, pair) in run.seen.windows(2).enumerate() { for (key, now) in &pair[1].cpu { @@ -3062,7 +3252,8 @@ mod macos_ioreport { let window = at[i + 1].saturating_sub(at[0]); if first_live.is_none() && window >= Duration::from_millis(300) - && rises.iter().any(|(_, n)| *n >= 5 && *n * 5 >= intervals * 4) + && !rises.is_empty() + && rises.iter().all(|(_, n)| *n >= 5 && *n * 5 >= intervals * 4) { first_live = Some(intervals); } @@ -3142,7 +3333,7 @@ mod macos_ioreport { Oracle::CpuRailMoved(false) => { assert!( !selected, - "IOReport meter available although its CPU rail did not keep moving under {:?} of \ + "IOReport meter available although not every CPU rail kept moving under {:?} of \ CPU burn — it would bracket GPU-only energy, zero, or a refresh as the energy of \ CPU work", CPU_RAIL_LIVENESS_BUDGET * 3 @@ -3157,14 +3348,14 @@ mod macos_ioreport { assert_eq!(measure_marginal_uj(cpu_work), None, "energy metered from a frozen CPU rail"); } Oracle::CpuRailMoved(true) => { - // A live rail. The probe had a third of the oracle's time: - // selected if its own record shows a CPU rail that kept + // Live rails. The probe had a third of the oracle's time: + // selected if its own record shows every CPU rail kept // rising (checked above), declined — the safe side — if // not. It sampled a rail either way. if oracle.energy_model_rail { assert!( matches!(status, IoReportStatus::Live | IoReportStatus::CpuRailFrozen), - "the oracle saw an Energy Model CPU rail keep moving; the probe said {status:?}" + "the oracle saw every Energy Model CPU rail keep moving; the probe said {status:?}" ); } else { assert_ne!(status, IoReportStatus::Live, "Live with no CPU rail in the Energy Model group"); @@ -3218,7 +3409,7 @@ mod macos_ioreport { (Some(CpuRail::Live), _) | (_, IoReportStatus::Live) => assert_eq!( oracle.verdict, Oracle::CpuRailMoved(true), - "probe said Live (probe={:?}, status={status:?}), the oracle did not see the CPU rail keep \ + "probe said Live (probe={:?}, status={status:?}), the oracle did not see every CPU rail keep \ moving", run.verdict ), @@ -3656,6 +3847,30 @@ mod macos_ioreport { ] } + /// An M3 Ultra's two dies both advancing at every read, among a + /// per-die sub-rail listed once per die and a moving GPU rail. + fn two_live_dies(n: u32) -> Vec<(&'static str, i64)> { + vec![ + ("ECPUDTL00", 5_000), + ("DIE_0_CPU Energy", 9_494_336 + 1_344 * i64::from(n)), + ("ECPUDTL00", 7_000), + ("DIE_1_CPU Energy", 10_212_656 + 1_391 * i64::from(n)), + ("GPU Energy", 14_809_813 + i64::from(n)), + ] + } + + /// An M3 Ultra's `DIE_0_CPU Energy` advancing at every read while + /// `DIE_1_CPU Energy` holds, beside a moving GPU rail. + fn one_live_die_one_held(n: u32) -> Vec<(&'static str, i64)> { + vec![ + ("ECPUDTL00", 5_000), + ("DIE_0_CPU Energy", 9_494_336 + 1_344 * i64::from(n)), + ("ECPUDTL00", 7_000), + ("DIE_1_CPU Energy", 10_212_656), + ("GPU Energy", 14_809_813 + i64::from(n)), + ] + } + /// `host_status()` on an Energy Model group that cannot be opened: no /// read, no tick, no verdict, `Unavailable` — never `Live`, never /// `CpuRailAbsent` — and the meter is not admitted. @@ -3762,6 +3977,40 @@ mod macos_ioreport { assert_verdict_matches_record(&hs.run, "scripted two dies"); } + /// (a) `host_status()` on two dies that both advance at every read: + /// Live, admitted, at the first interval where both have risen in at + /// least five intervals over at least 300 ms of the real wall clock. + #[test] + fn host_status_calls_two_dies_that_keep_advancing_live() { + let hs = host_status_scripted(two_live_dies); + let line = assert_sampling(&hs.run, "scripted two live dies", &scripted_group(two_live_dies)); + eprintln!("ioreport scripted: two live dies -> status={:?} intervals={}; {line}", hs.status, hs.run.intervals); + assert_eq!(hs.run.verdict, Some(CpuRail::Live), "two dies that kept advancing"); + assert_eq!(hs.status, IoReportStatus::Live); + assert!(admits(hs.status), "two live dies not admitted"); + assert!(hs.run.intervals >= 5, "Live after {} interval(s)", hs.run.intervals); + assert_handed_the_script(&hs.run, two_live_dies); + assert_verdict_matches_record(&hs.run, "scripted two live dies"); + } + + /// (b) `host_status()` on `DIE_0_CPU Energy` advancing at every read + /// while `DIE_1_CPU Energy` holds: `CpuRailFrozen`, declined, after + /// the whole budget. The power reading sums both dies, so a Live here + /// would bracket one die's energy as the whole CPU's. The one-rail + /// rule called this Live within ~300 ms. + #[test] + fn host_status_calls_one_live_die_beside_a_frozen_one_frozen() { + let hs = host_status_scripted(one_live_die_one_held); + let line = assert_sampling(&hs.run, "scripted one live die", &scripted_group(one_live_die_one_held)); + eprintln!("ioreport scripted: one live die, one held -> status={:?}; {line}", hs.status); + assert_eq!(hs.run.verdict, Some(CpuRail::Frozen), "one live die vouched for a held one"); + assert_eq!(hs.status, IoReportStatus::CpuRailFrozen); + assert!(!admits(hs.status), "one live die beside a held one admitted the meter"); + assert!(hs.run.intervals >= 5, "frozen on {} interval(s)", hs.run.intervals); + assert_handed_the_script(&hs.run, one_live_die_one_held); + assert_verdict_matches_record(&hs.run, "scripted one live die"); + } + #[test] fn energy_to_uj_scales_by_unit() { assert_eq!(energy_to_uj(5, "mJ"), 5_000); @@ -4224,23 +4473,15 @@ mod tests { } /// The figure sums every package counter, so EVERY one must keep - /// advancing: with one advancing and the other holding, RAPL is Frozen — - /// where IOReport's one-rail rule would call the same counters live — and - /// with both advancing it is Live. Two packages advancing in alternate + /// advancing: with one advancing and the other holding, RAPL is Frozen, + /// and with both advancing it is Live. Two packages advancing in alternate /// intervals (each in half of them) are not. #[test] fn rapl_every_package_counter_must_keep_advancing() { let one_holds: ZoneScript = |n| vec![(0, true, 1_000 + u64::from(n)), (1, true, 77)]; let (status, run, _, _) = rapl_composed(one_holds); assert_eq!(status, CounterStatus::Frozen, "one package counter held: {run:?}"); - // The same reads under the any-one-rail rule would be Live: this is - // what `every` decides. - let mut any = liveness::RailWatch::new(liveness::Watched { pick: rapl::is_package_rail, every: false }); - for (n, s) in run.seen.iter().enumerate() { - let sample: Vec<(String, i64)> = s.cpu.iter().map(|((name, _), v)| (name.clone(), *v)).collect(); - any.observe(&sample, Duration::from_millis(25) * n as u32); - } - assert_eq!(any.verdict(), Some(CpuRail::Live), "the any-one-rail rule on the same reads"); + assert_eq!(run.intervals, 40, "watched for the whole budget: {run:?}"); let both: ZoneScript = |n| vec![(0, true, 1_000 + u64::from(n)), (1, true, 9_000 + 2 * u64::from(n))]; let (status, run, reads, _) = rapl_composed(both); -- 2.54.0 (Apple Git-157)