From b9d7e9450f1fcbe5d403e90647a14cfa3f3a22d5 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 29 Sep 2026 10:44:01 -0400 Subject: [PATCH 01/23] jouleclaw-energy: never use IOReport unless its CPU rail keeps moving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This crate's contract, in its own docs: every reading carries a Provenance "declaring how the value was obtained", and "implementations that overstate their honesty are non-conformant". The family contract (jcp-energy): "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." The IOReport counter broke both on current macOS. Ground truth, measured today by reading the channels, not by re-running tests: - M5 Max, macOS 27.0.1: `CPU Energy` (the only channel in any group whose name ends in "CPU Energy") held 480,083,160 mJ across a 3 s CPU burn while `GPU Energy` advanced ~892 mJ. M3 Ultra, same OS: `DIE_0_CPU Energy`, `DIE_1_CPU Energy` and `GPU Energy` all held. All are format 1 (Simple). - The CPU rail is not permanently frozen; it is batch-updated, tens of minutes apart. Watched for 25 minutes (10:51-11:16) through an Energy-Model-only subscription sampled every 100 ms and an all-channel one every 2 s: on the M3 Ultra both dies' CPU rails changed exactly once, in the same sample, at 11:09:05 in both subscriptions (+1,344,869 and +1,391,637 mJ); on the M5 Max the CPU rail did not change in 13,614 samples while `GPU Energy` changed in 13,125 of them. Yet the M5 Max rail read 541,893,638 mJ by 10:47, up from 480,083,160 at ~10:30. - A live rail looks nothing like that: the M5 Max GPU rail changed in 166 of 176 samples ~28 ms apart. - One IOReportCreateSamples over the counter's all-channel subscription costs ~0.35-0.7 s on the M5 Max (11,860 channels), ~0.135 s on the M3 Ultra (14,335 channels). The defect. `AppleIoReportCounter::new()` was available whenever a subscription opened, `detect_meter()` put it first (since b11af6ce), and `read()` summed every Energy Model rail, skipped non-positive deltas, and labelled the total SocTotal / ModelBased. On the M5 Max a CPU burn was metered as GPU energy — main's `measures_energy_after_cpu_burn` passes there only through the GPU rail. On the M3 Ultra nothing moves and the same test fails on main: "expected measured energy to advance after a CPU burn: before=0 after=0"; a consumer flooring that zero to the meter's resolution sealed it as a ModelBased reading. The fix enforces the contract where the counter is opened. `AppleIoReportCounter::new()` (so `Default`, `detect_meter()` and every direct caller) returns an available counter only when `ioreport_status()` is `Live`. The status is probed once per process and cached (OnceLock): open a subscription, burn CPU on the calling thread, re-sample after every 25 ms of burn for the 1 s budget, and decide on the raw integer of each CPU rail (`CPU Energy`, or `DIE__CPU Energy`) per consecutive sample interval — never on the summed total, because the live GPU rail is exactly what corrupts the total. Otherwise the counter is declined: `is_available()` is false, `status()` says why, and every `read()` returns `Err(EnergyError::NoCounter(SocTotal))` — never a number. `detect_meter()` then falls through to `apple::AppleSiliconMeter`, whose readings say `Estimator` end to end (CompositeMeter passes the reading through; nothing in this crate re-tags it). The probe costs ~1.9 s once per process on the M5 Max. New public API, all additive: `IoReportStatus` { Unavailable, CpuRailAbsent, CpuRailFrozen, Live }, `jouleclaw_energy::ioreport_status()` (compiled on every target; `Unavailable` without the backend, no probe), `AppleIoReportCounter::status()`, `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET`. One deliberate difference from the jcp-energy reference (dfec5d44): a single advance does not make a rail live. The same CPU rail must advance in at least two separate sample intervals (`RailWatch`). Because the rail is batch-updated, a probe whose window straddles an update sees one large jump — tens of minutes of the whole machine's CPU energy — and the reference's "advanced at all" rule calls that live and selects the counter for the rest of the process. That happened here: at ~10:46, while a falsification run re-probed without the cache, fresh probes under the reference rule reported the M5 Max rail Live and the oracle, a moment later, saw it hold. Counting per rail keeps two dies' separate jumps from adding up. A live rail passes in its first two intervals; the probe samples past the budget until it has two, up to five budgets, so one slow sample cannot make a live rail look frozen. Docs made false by the change are corrected in the files touched: the module and `new()` / `is_available()` docs; the `detect_meter()` comment that said the counter is unavailable only "if IOReport can't open"; the `apple` estimator's doc, which said IOReport was not yet wired and told a maintainer to re-tag the estimator ModelBased once it was — it is now the path that runs on macOS 27, so that instruction would relabel an estimate as a reading; and the `energy_probe` example, which printed "--- measured ---" over whatever it picked and now prints why IOReport was declined and "--- estimated ---" for an Estimator. No grade changes. Tests. `measures_energy_after_cpu_burn` — which asserted only that the summed total advanced, and returned early, passing, when IOReport was unavailable — is replaced. The new tests assert against an independent oracle: it opens its own subscription, reads each CPU rail's ABSOLUTE value (no delta samples), matches names with `ends_with("CPU Energy")` in any group, burns for three times the probe budget, and returns NoIoReport / NoCpuRail / CpuRailMoved(bool), true only if a rail's value changed at two or more separate reads, so one batch jump landing in both windows cannot vouch for a false Live. No test returns early without asserting. - the_ioreport_counter_is_never_used_on_a_frozen_cpu_rail: neither available nor selected by detect_meter when the oracle saw the rail hold; logs "ioreport liveness: status=.. oracle=.. selected=.. available=..". - the_liveness_probe_never_reports_a_frozen_rail_as_live: runs the probe uncached on a fresh subscription beside the cached status; asserts on every outcome, not only Live. - a_used_ioreport_counter_meters_cpu_work: when available, its own CPU rail is live and 200 ms of CPU meters non-zero ModelBased energy; when not, the status is not Live. - a_declined_counter_yields_no_reading_and_the_fallback_says_estimator. - the_liveness_status_is_probed_once_and_cached. - Rule tests with no hardware: a_single_jump_is_a_batch_update_not_a_live_rail, one_jump_on_each_die_is_not_a_live_rail, a_rail_that_keeps_advancing_is_live, no_cpu_rail_is_absent_and_no_interval_is_no_verdict, the_probe_samples_until_live_or_long_enough_to_tell, only_the_cpu_energy_aggregate_is_a_cpu_rail; and, where the backend is not built, without_the_ioreport_backend_the_status_is_unavailable. Observed with this commit, one test binary run on both machines: M5 Max: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false (oracle: changes per CPU rail [0]) test result: ok. 39 passed; 0 failed M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false (oracle: changes per CPU rail [0, 0], the DIE_0_/DIE_1_ path) test result: ok. 39 passed; 0 failed With `--features ioreport` 39 lib tests (main: 29); without it 24 (main: 23). Falsified on the M5 Max, each mutation applied to this commit and restored with git checkout: - F1, the probe always says Live: "the oracle did not see the CPU rail keep moving under 3s of CPU burn, yet status=Live" (and the probe test). - F2, main's ungated new(): "IOReport counter used although its CPU rail did not keep moving under 3s of CPU burn — it would report GPU-only energy, or zero, as the energy of CPU work (available=true, selected=true)". - F3, no per-process cache: "a cached status and a declined counter took 6.389182s: the liveness probe ran again". - F4, liveness decided on every Energy Model rail, as the total sees it: the GPU rail makes the M5 Max probe say Live; both liveness tests fail. - F5, the reference's single-advance rule: four rule tests fail, e.g. one_jump_on_each_die_is_not_a_live_rail "left: Some(Live) right: Some(Frozen)". - F6, advances counted across rails, not per rail: one_jump_on_each_die_is_not_a_live_rail fails. Dependants, per workspace with -p: mesh-meter and mesh-core with hw-meter-apple (2 and 3 passed), jouledesk-github (4), l3-jouleclaw-binding (2; 4 ignored, they need a model), jouleclaw-harness-econ (11), jouleclaw-loader-gguf with fabric-meter (357 passed, 14 ignored) — all pass. clippy -p jouleclaw-energy --no-deps --tests, with and without ioreport: no new warnings (the duplicated #[link] attribute, sum_energy_uj's b"..\0", ledger.rs's constant asserts and meter.rs's vec-init-then-push are on main). cargo check --tests --examples for x86_64- and aarch64-unknown-linux-gnu, with and without ioreport: clean. Not done here, deliberately: - The consumers (jouledesk-github, mesh-meter, l3-jouleclaw, loader-gguf's fabric_dispatch / fabric_calibrate / mpp_energy, harness-econ) are handled next, on this branch. On macOS 27 they now get no IOReport counter. - Reporting the live GPU rail as an explicitly partial reading instead of declining, and which grade IOReport readings carry, are David's decisions. - A per-read check for a rail that stops moving after selection needs a witness (CPU Stats residency); the platform-level freeze is caught here. - `counts_once` would sum `PACC_` sub-rails (no "cpu" in the name) on top of `CPU Energy`, a latent double count once the rail is live; fixing it needs a channel dump from a host whose CPU rail is live. - The counter subscribes to all ~12-14k channels, so every read() costs one 0.35-0.7 s sample on the M5 Max; an Energy-Model-only subscription samples in ~8 ms. Worth doing, but it changes what every read costs. - jcp-energy (the reference) and the sibling backends still accept a single advance as live. Co-Authored-By: Claude Opus 5.5 --- .../jouleclaw-energy/examples/energy_probe.rs | 26 +- .../crates/jouleclaw-energy/src/apple.rs | 22 +- .../jouleclaw-energy/src/apple_ioreport.rs | 673 +++++++++++++++++- .../crates/jouleclaw-energy/src/lib.rs | 59 ++ .../crates/jouleclaw-energy/src/meter.rs | 17 +- 5 files changed, 744 insertions(+), 53 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs index e35ce0e42..b8b079e56 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs @@ -1,9 +1,9 @@ //! Cross-platform energy probe — read the best available hardware counter on -//! this host and print a **measured** energy delta with its honest provenance. +//! this host and print its energy delta with its honest provenance. //! -//! The point of the probe: prove that on real hardware JouleClaw reports a -//! `HwShunt` / `ModelBased` reading, not an `Estimator` fallback. Build with the -//! feature that matches the host's counter: +//! The point of the probe: show whether on real hardware JouleClaw reports a +//! `HwShunt` / `ModelBased` reading or an `Estimator` fallback, and label the +//! figure accordingly. Build with the feature that matches the host's counter: //! //! # NVIDIA discrete GPU (Linux or Windows — loads libnvidia-ml / nvml.dll): //! cargo run -p jouleclaw-energy --release --example energy_probe --features nvml @@ -11,7 +11,8 @@ //! # Intel/AMD x86 on Linux (powercap sysfs RAPL): //! cargo run -p jouleclaw-energy --release --example energy_probe --features rapl //! -//! # Apple Silicon (load-avg estimator until IOReport is wired): +//! # Apple Silicon (IOReport Energy Model when its CPU rail is live, else the +//! # load-avg estimator, with the reason printed): //! cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport //! //! It samples the counter across a ~2 s window (driving a small CPU workload so @@ -52,12 +53,14 @@ fn pick_counter() -> Option<(&'static str, Box)> { } #[cfg(all(feature = "ioreport", target_os = "macos"))] { - // Prefer the real IOReport energy-model counter (ModelBased); fall back - // to the load-avg estimator only if IOReport will not open. + // Prefer the real IOReport energy-model counter (ModelBased). It is + // unavailable unless IOReport opens AND its CPU energy rail advances + // under load; say which before falling back to the load-avg estimator. let c = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); if c.is_available() { return Some(("apple-ioreport (Energy Model)", Box::new(c))); } + println!("ioreport : declined ({:?})", c.status()); let c = jouleclaw_energy::apple::AppleSiliconMeter::new(); if c.is_available() { return Some(("apple (load-avg estimator)", Box::new(c))); @@ -82,6 +85,7 @@ fn churn(deadline: Instant) -> u64 { } fn main() { + println!("== JouleClaw energy probe =="); let Some((label, counter)) = pick_counter() else { eprintln!( "no hardware energy counter available on this host/build.\n\ @@ -90,7 +94,6 @@ fn main() { std::process::exit(1); }; - println!("== JouleClaw energy probe =="); println!("counter : {label}"); println!("provenance : {}", counter.provenance()); println!("domain : {:?}", counter.domain()); @@ -128,7 +131,12 @@ fn main() { let elapsed = start.elapsed().as_secs_f64(); let delta_uj = after.uj.saturating_sub(before.uj); let joules = delta_uj as f64 / 1_000_000.0; - println!("--- measured ---"); + // An estimator's figure is not a measurement; say which one this is. + if after.provenance == jouleclaw_energy::Provenance::Estimator { + println!("--- estimated ---"); + } else { + println!("--- measured ---"); + } println!("window : {:.3} s", elapsed); println!( "energy : {} uj ({:.3} J) avg {:.2} W [{}]", diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs index 5d3423983..20bdbe8d6 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs @@ -1,10 +1,12 @@ //! Apple Silicon energy estimator — `Provenance::Estimator`. //! -//! **HONEST DISCLOSURE.** The production-quality path is IOReport's -//! `"Energy Model"` channel group (`PMP`/`Energy Model` keys), which -//! returns vendor-modelled package energy at ~1 mJ / ~10 ms. JouleClaw -//! has not yet wired the IOReport FFI; until then this backend uses -//! the system 1-minute load average, normalises it against +//! **HONEST DISCLOSURE.** The measured Apple path is IOReport's +//! `"Energy Model"` channel group, wired as +//! [`crate::apple_ioreport::AppleIoReportCounter`] (`ModelBased`). This +//! backend is the fallback wherever that counter is unavailable: IOReport +//! will not open, or its CPU energy rail does not advance under load (macOS +//! 27.0.1 on an M5 Max and an M3 Ultra; [`crate::ioreport_status`] says +//! which). It uses the system 1-minute load average, normalises it against //! `available_parallelism`, and interpolates between a fixed //! `idle_watts = 8 W` and `peak_watts = 40 W` band. //! @@ -14,9 +16,9 @@ //! ~100 ms (load-average is updated by the kernel at ~5 s but quoted //! at much higher frequency; the practical noise floor is ~100 ms). //! -//! When the IOReport bridge ships, this module should be replaced or -//! re-tagged as `Provenance::ModelBased`. **DO NOT change the tag in -//! place** — the breaker depends on it. +//! It is selected precisely when no live reading exists, so it stays +//! `Provenance::Estimator` on every host. **DO NOT change the tag** — +//! the breaker depends on it. use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -87,7 +89,7 @@ impl EnergyCounter for AppleSiliconMeter { } fn provenance(&self) -> Provenance { - // Until IOReport is wired this is unambiguously an Estimator. + // A load-average model: unambiguously an Estimator, on every host. Provenance::Estimator } @@ -151,7 +153,7 @@ mod tests { fn apple_meter_declares_estimator_provenance() { let m = AppleSiliconMeter::new(); // The honesty tag is what the breaker reads. It MUST be - // Estimator until IOReport ships. + // Estimator: this is what runs when IOReport has no live CPU rail. assert_eq!(m.provenance(), Provenance::Estimator); assert_eq!(m.domain(), EnergyDomain::SocTotal); assert_eq!(m.name(), "apple-silicon"); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index 8a8c31f1b..fef93cb88 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -10,7 +10,21 @@ //! utilisation — a real on-device reading, but not a coulomb-counter shunt — so //! it is tagged [`Provenance::ModelBased`], the honest tier between a hardware //! shunt and a static estimator. It replaces the load-avg -//! [`crate::apple::AppleSiliconMeter`] (`Estimator`) wherever IOReport opens. +//! [`crate::apple::AppleSiliconMeter`] (`Estimator`) wherever IOReport opens +//! **and its CPU energy rail is live**. +//! +//! **CPU-rail liveness.** An open subscription is not a working meter. On macOS +//! 27.0.1, for an unprivileged reader, every CPU rail in the Energy Model group +//! holds its cumulative value under minutes of full CPU load, moving only in +//! batch updates tens of minutes apart: on an M5 Max (`CPU Energy`, while +//! `GPU Energy` keeps advancing) and on an M3 Ultra (`DIE__CPU Energy`). +//! Summing the rails would then report GPU-only energy, or zero, as the energy +//! of CPU work, tagged `ModelBased`. So [`AppleIoReportCounter::new`] is +//! available only when the CPU rail itself keeps advancing, sample after +//! sample, while the probing thread burns CPU — probed once per process and +//! cached, see [`crate::ioreport_status`]. Otherwise the counter is unavailable +//! and yields no reading, [`crate::meter::detect_meter`] falls through to the +//! load-avg estimator, and that reading says `Estimator`. //! //! IOReport is private API. Apple has not deprecated it for M1+ in any released //! macOS; if the surface ever changes, [`AppleIoReportCounter::new`] returns an @@ -23,11 +37,11 @@ #![allow(non_camel_case_types)] use std::os::raw::{c_int, c_long, c_void}; -use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::{Mutex, OnceLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::meter::{EnergyMeter, EnergySource}; -use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance}; +use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, IoReportStatus, Provenance}; // ── Minimal CoreFoundation / IOReport FFI ─────────────────────────────────── // All CF handles are opaque pointers; we never dereference them in Rust. @@ -121,6 +135,7 @@ impl Drop for State { pub struct AppleIoReportCounter { state: Mutex>, available: bool, + status: IoReportStatus, } // The raw CF pointers are confined to `State` behind a `Mutex`, and IOReport @@ -136,19 +151,43 @@ impl Default for AppleIoReportCounter { impl AppleIoReportCounter { /// Open an IOReport subscription over all advertised channels (the energy - /// group is filtered at sample-walk time). Returns an unavailable counter if - /// IOReport cannot be opened (non-Apple-Silicon, Rosetta, or a future macOS - /// that changes the surface). + /// group is filtered at sample-walk time) — **only if** this host's CPU + /// energy rail is live, i.e. [`crate::ioreport_status`] is + /// [`IoReportStatus::Live`]. + /// + /// Otherwise returns an unavailable counter: `is_available()` is `false`, + /// [`Self::status`] says why, and every `read()` fails with + /// [`EnergyError::NoCounter`] — it never produces a number. That covers + /// IOReport not opening (non-Apple-Silicon, Rosetta, a future macOS that + /// changes the surface), no CPU energy rail in the Energy Model group, and + /// a CPU rail that does not advance under load (macOS 27.0.1 on an M5 Max + /// and an M3 Ultra), where the summed rails would pass GPU-only energy, or + /// zero, off as the energy of CPU work. + /// + /// The first call in a process runs the liveness probe on the calling + /// thread (see [`crate::ioreport_status`] for its cost); later calls reuse + /// the cached answer. pub fn new() -> Self { + let status = host_status(); + if status != IoReportStatus::Live { + return Self::declined(status); + } match Self::open() { Some(state) => Self { state: Mutex::new(Some(state)), available: true, + status, }, - None => Self { - state: Mutex::new(None), - available: false, - }, + None => Self::declined(IoReportStatus::Unavailable), + } + } + + /// A counter that meters nothing: no subscription, `read()` always fails. + fn declined(status: IoReportStatus) -> Self { + Self { + state: Mutex::new(None), + available: false, + status, } } @@ -186,10 +225,222 @@ impl AppleIoReportCounter { } } - /// Whether IOReport opened successfully. + /// Whether this counter meters: IOReport opened **and** the host's CPU + /// energy rail is live. When `false`, [`Self::status`] says why. pub fn is_available(&self) -> bool { self.available } + + /// Why this counter is, or is not, available. It is [`IoReportStatus::Live`] + /// exactly when [`Self::is_available`] is `true`. + pub fn status(&self) -> IoReportStatus { + self.status + } +} + +// ── CPU-rail liveness ─────────────────────────────────────────────────────── + +/// How long the liveness probe watches the CPU energy rail, burning CPU on its +/// thread between samples, before concluding the rail is frozen. A live Energy +/// Model rail moves many times a second (the M5 Max GPU rail changed in 166 of +/// 176 samples ~28 ms apart), so a second holds the two advancing intervals the +/// probe needs even at the ~0.5 s a full-subscription sample takes on an M5 Max. +/// A slower live rail fails safe — the counter is declined and the host +/// estimated, never over-claimed. Paid once per process, see +/// [`crate::ioreport_status`]. +pub const CPU_RAIL_LIVENESS_BUDGET: Duration = Duration::from_millis(1000); + +/// CPU burned on the probing thread before each liveness re-sample. +const PROBE_TICK: Duration = Duration::from_millis(25); + +/// In how many separate sample intervals one CPU rail must advance to count as +/// live. One is not enough: see [`RailWatch`]. +const LIVE_RAIL_ADVANCES: u32 = 2; + +/// What the liveness probe saw of the CPU energy rail. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CpuRail { + /// A CPU energy rail advanced, interval after interval, while the probing + /// thread burned CPU. + Live, + /// The rail exists but held its value across the whole burn, or moved in a + /// single step (a batch update, not a live reading). + Frozen, + /// No CPU energy channel exists in the Energy Model group. + Absent, +} + +/// The liveness decision, kept apart from the FFI so it can be tested on its own. +/// +/// A rail is live only when the **same** CPU rail advances in at least +/// [`LIVE_RAIL_ADVANCES`] separate sample intervals. A single advance is not +/// enough, because on macOS 27.0.1 the CPU rail is not permanently frozen: it +/// is batch-updated, tens of minutes apart. Watched for 25 minutes, an M3 +/// Ultra's `DIE_0_` / `DIE_1_CPU Energy` changed exactly once, both dies in +/// the same 100 ms sample (+1,344,869 and +1,391,637 mJ), and an M5 Max's +/// `CPU Energy` not at all in 13,614 samples — though it had moved from +/// 480,083,160 to 541,893,638 mJ earlier that hour, and a probe running at +/// that moment saw it move. A probe window that straddles an update sees one +/// large jump, minutes of the whole machine's CPU energy, not a live reading. +/// A live rail advances in nearly every interval: the M5 Max GPU rail changed +/// in 13,125 of those 13,614 samples, 100 ms apart. Counting per rail keeps +/// two dies' separate jumps from adding up to a false "live". +#[derive(Debug, Default)] +struct RailWatch { + /// Sample intervals observed so far. + intervals: u32, + /// Each CPU rail seen, by channel name, with the number of intervals in + /// which it advanced. + rails: Vec<(String, u32)>, +} + +impl RailWatch { + /// Record one sample interval: every CPU rail in it, and whether it advanced. + 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))), + } + } + } + + /// Has one CPU rail advanced in enough separate intervals to be live? + fn is_live(&self) -> bool { + self.rails.iter().any(|(_, count)| *count >= LIVE_RAIL_ADVANCES) + } + + /// Keep sampling? Until the rail has proved live or `budget` is spent — but + /// never stop before a live rail could have shown [`LIVE_RAIL_ADVANCES`] + /// advances (one slow IOReport sample must not make a live rail look + /// frozen), up to a hard cap of five budgets. + fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { + !self.is_live() + && (elapsed < budget || (self.intervals < LIVE_RAIL_ADVANCES && elapsed < budget * 5)) + } + + /// The verdict, or `None` if not a single interval could be observed. + 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) + } + } +} + +/// This host's IOReport status, probed once per process and cached. Backs +/// [`crate::ioreport_status`] and gates [`AppleIoReportCounter::new`]. +pub(crate) fn host_status() -> IoReportStatus { + static STATUS: OnceLock = OnceLock::new(); + *STATUS.get_or_init(|| { + let Some(state) = AppleIoReportCounter::open() else { + return IoReportStatus::Unavailable; + }; + match probe_cpu_rail(&state, CPU_RAIL_LIVENESS_BUDGET) { + None => IoReportStatus::Unavailable, + Some(CpuRail::Live) => IoReportStatus::Live, + Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + } + }) +} + +/// Burn CPU on this thread, re-sampling after every [`PROBE_TICK`] of burn, +/// and report whether the CPU energy rail **itself** is live by the rule in +/// [`RailWatch`]. `None` if the subscription yielded no usable sample. +/// +/// The summed total is not a liveness signal: on an M5 Max the GPU rail keeps +/// advancing while every CPU rail holds, so a total that moves proves only that +/// something drew on screen. Liveness is decided on the CPU rail alone, per +/// consecutive sample interval, from the probe's own samples, so no counter's +/// cumulative reading is touched. +/// +/// Each re-sample is one `IOReportCreateSamples` over the whole subscription, +/// which is slow: measured at ~0.35–0.7 s on an M5 Max (11,860 channels) and +/// ~0.135 s on an M3 Ultra (14,335 channels). So the probe compares a few times +/// within `budget` rather than every tick, and can overrun it by one sample. +fn probe_cpu_rail(state: &State, budget: Duration) -> Option { + unsafe { + let mut prev = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); + if prev.is_null() { + return None; + } + let started = Instant::now(); + let mut watch = RailWatch::default(); + 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(state.sub, state.channels, 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))); + } + CFRelease(prev); + watch.verdict() + } +} + +/// In a samples-delta dictionary: every CPU energy channel in the Energy Model +/// group (`CPU Energy`, or `DIE__CPU Energy` on multi-die parts), and +/// whether it advanced over that interval. Decided on the raw integer, before +/// unit conversion, so a small advance in a fine unit cannot truncate to zero. +fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { + let mut rails = Vec::new(); + unsafe { + 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)); + } + } + } + rails +} + +/// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally +/// `DIE__`-prefixed. +fn is_cpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") } impl EnergyCounter for AppleIoReportCounter { @@ -436,28 +687,392 @@ mod tests { } #[test] - fn measures_energy_after_cpu_burn() { - let c = AppleIoReportCounter::new(); - if !c.is_available() { - return; // not Apple Silicon — nothing to assert + 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 update caught + /// mid-probe — is not live, however large the step. + #[test] + fn a_single_jump_is_a_batch_update_not_a_live_rail() { + let mut w = RailWatch::default(); + for advanced in [false, true, false, false, false] { + w.observe([("CPU Energy", advanced)]); } - let _ = c.read(); // prime - let before = c.read().expect("baseline read"); - let start = std::time::Instant::now(); + assert!(!w.is_live()); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + } + + /// Two dies each updated once (at different moments) 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)); + } + + /// A rail that advances interval after interval is live, as soon as it has + /// done so twice — on either die. + #[test] + fn a_rail_that_keeps_advancing_is_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"); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + assert!(w.is_live()); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + } + + /// No CPU channel is `Absent`; no observed interval at all is no verdict + /// (the caller reports IOReport as unavailable). + #[test] + fn no_cpu_rail_is_absent_and_no_interval_is_no_verdict() { + let mut w = RailWatch::default(); + assert_eq!(w.verdict(), None); + w.observe([]); + w.observe([]); + assert_eq!(w.verdict(), Some(CpuRail::Absent)); + } + + /// The probe keeps sampling within its budget until the rail proves live, + /// and past the budget until a live rail could have advanced twice — but + /// 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)); + // Slow samples: past the budget with fewer than two intervals, keep going. + assert!(w.wants_more(budget * 2, budget)); + w.observe([("CPU Energy", false)]); + assert!(w.wants_more(budget * 2, budget)); + w.observe([("CPU Energy", false)]); + assert!(!w.wants_more(budget * 2, budget), "two intervals in and past budget: frozen"); + assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); + assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap"); + 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"); + } + + /// Spin this thread for `d`. + fn burn(d: Duration) { + let started = Instant::now(); let mut acc: u64 = 0; - while start.elapsed() < std::time::Duration::from_millis(200) { + while started.elapsed() < d { acc = acc.wrapping_add(1); } std::hint::black_box(acc); - let _ = c.read(); - let after = c.read().expect("work read"); - // 200 ms of CPU burn clears the mJ quantum comfortably. - assert!( - after.uj > before.uj, - "expected measured energy to advance after a CPU burn: before={} after={}", - before.uj, - after.uj + } + + /// What the independent oracle saw on this host. + #[derive(Debug, PartialEq)] + enum Oracle { + NoIoReport, + NoCpuRail, + CpuRailMoved(bool), + } + + /// The ABSOLUTE cumulative value of every channel whose name ends with + /// `CPU Energy`, in any group, from one plain sample (no delta). + fn oracle_read(sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef) -> Option> { + unsafe { + let smp = IOReportCreateSamples(sub, chans, 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) + } + } + + /// An oracle that shares nothing with the liveness probe but the raw FFI + /// declarations: it opens its own subscription (not `open()`), reads each + /// CPU rail's ABSOLUTE cumulative value (no delta samples), matches names + /// with `ends_with("CPU Energy")` in any group (not the implementation's + /// group filter and die-prefix stripping), and burns CPU for `budget`, + /// reading as it goes. + /// + /// `CpuRailMoved(true)` means some rail kept moving: its value changed at + /// two or more separate reads. A live rail changes at nearly every read; a + /// batch-updated one at most once in a window this short, and one jump is + /// not a live reading. 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 chans: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription(std::ptr::null(), all, &mut chans, 0, std::ptr::null()); + CFRelease(all as CFTypeRef); + if sub.is_null() || chans.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !chans.is_null() { + CFRelease(chans as CFTypeRef); + } + return Oracle::NoIoReport; + } + let verdict = 'watch: { + let Some(mut prev) = oracle_read(sub, chans) else { + break 'watch Oracle::NoIoReport; + }; + if prev.is_empty() { + break 'watch Oracle::NoCpuRail; + } + // Per rail: at how many reads its absolute value had changed. + let mut changes = vec![0u32; prev.len()]; + let mut reads = 0u32; + let started = Instant::now(); + while started.elapsed() < budget { + burn(Duration::from_millis(50)); + let Some(cur) = oracle_read(sub, chans) else { + continue; + }; + reads += 1; + for (count, (now, before)) in changes.iter_mut().zip(cur.iter().zip(prev.iter())) { + *count += u32::from(now != before); + } + if changes.iter().any(|&c| c >= 2) { + break 'watch Oracle::CpuRailMoved(true); + } + prev = cur; + } + eprintln!("ioreport oracle: {reads} reads over {budget:?}, changes per CPU rail {changes:?}"); + Oracle::CpuRailMoved(false) + }; + CFRelease(sub); + CFRelease(chans as CFTypeRef); + verdict + } + } + + /// The property that matters: the counter is never available — so + /// `detect_meter` never selects it — when its CPU rail is frozen, because + /// then it would report GPU-only energy, or zero, as the energy of CPU + /// work. It asserts the counter's decision against what the rail actually + /// did, on whatever host runs it: every branch asserts, none returns early. + /// + /// 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 in + /// which declining is not proven right — and that one fails safe: + /// estimated, never over-claimed. A single batch update landing in both + /// windows does not rescue a probe that called it live: the oracle counts + /// it as one jump, not a moving rail. + #[test] + fn the_ioreport_counter_is_never_used_on_a_frozen_cpu_rail() { + let status = crate::ioreport_status(); + let counter = AppleIoReportCounter::new(); + let available = counter.is_available(); + let selected = crate::meter::detect_meter().active_name() == "apple-ioreport"; + 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} available={available}" ); - assert_eq!(after.provenance, Provenance::ModelBased); + match oracle { + Oracle::NoIoReport => { + assert_eq!(status, IoReportStatus::Unavailable, "IOReport cannot open, yet status={status:?}"); + assert!(!available && !selected, "IOReport cannot open, yet the counter was used"); + } + Oracle::NoCpuRail => { + assert_eq!(status, IoReportStatus::CpuRailAbsent, "no CPU rail exists, yet status={status:?}"); + assert!(!available && !selected, "used an IOReport counter that has no CPU rail"); + } + Oracle::CpuRailMoved(false) => { + assert_eq!( + status, + IoReportStatus::CpuRailFrozen, + "the oracle did not see the CPU rail keep moving under {:?} of CPU burn, yet status={status:?}", + CPU_RAIL_LIVENESS_BUDGET * 3 + ); + assert!( + !available && !selected, + "IOReport counter used although its CPU rail did not keep moving under {:?} of CPU burn \ + — it would report GPU-only energy, or zero, as the energy of CPU work \ + (available={available}, selected={selected})", + CPU_RAIL_LIVENESS_BUDGET * 3 + ); + } + Oracle::CpuRailMoved(true) => { + // A live rail. The probe had a third of the oracle's time: if it + // saw the move the counter must be used; if not, declining is the + // safe side, but then it must decline everywhere. + if status == IoReportStatus::Live { + assert!( + available && selected, + "status Live but the counter was not used (available={available}, selected={selected})" + ); + } else { + assert!(!available && !selected, "status {status:?} but the counter was used"); + } + } + } + // The counter is available exactly when its own status says Live, and a + // declined counter names the host status it was declined on. + assert_eq!(available, counter.status() == IoReportStatus::Live); + if status != IoReportStatus::Live { + assert_eq!(counter.status(), status); + } + } + + /// The dangerous direction on its own: the probe never calls a rail Live + /// that the oracle cannot see move. Runs the probe itself, uncached, on a + /// fresh subscription, beside the cached status the counter is gated on, + /// and asserts on every outcome, not only on Live. + #[test] + fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { + let t = Instant::now(); + let probed = AppleIoReportCounter::open().map(|s| probe_cpu_rail(&s, CPU_RAIL_LIVENESS_BUDGET)); + let probe_took = t.elapsed(); + let cached = crate::ioreport_status(); + let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); + eprintln!("ioreport probe: probed={probed:?} cached={cached:?} oracle={oracle:?} probe_took={probe_took:?}"); + match (probed, cached) { + (Some(Some(CpuRail::Live)), _) | (_, IoReportStatus::Live) => assert_eq!( + oracle, + Oracle::CpuRailMoved(true), + "probe said Live (probed={probed:?}, cached={cached:?}), the oracle did not see the CPU rail keep moving" + ), + (Some(Some(CpuRail::Frozen)), _) => assert!( + matches!(oracle, Oracle::CpuRailMoved(_)), + "the probe saw a CPU rail, the oracle found none: {oracle:?}" + ), + (Some(Some(CpuRail::Absent)), _) => assert_eq!( + oracle, + Oracle::NoCpuRail, + "the probe found no CPU rail, the oracle found one" + ), + (Some(None) | None, _) => assert_eq!( + oracle, + Oracle::NoIoReport, + "the probe could not open or sample IOReport, the oracle could" + ), + } + } + + /// The probe runs once per process: after the first answer, the status and + /// a declined counter come back without probing again (a re-probe costs at + /// least one IOReport sample plus up to a full budget of burn). + #[test] + fn the_liveness_status_is_probed_once_and_cached() { + let first = crate::ioreport_status(); + let t = Instant::now(); + let again = crate::ioreport_status(); + let counter = AppleIoReportCounter::new(); + let took = t.elapsed(); + assert_eq!(first, again, "the cached status changed between calls"); + if counter.is_available() { + assert_eq!(first, IoReportStatus::Live); + assert_eq!(counter.status(), IoReportStatus::Live); + } else { + assert_eq!(counter.status(), first); + assert!( + took < CPU_RAIL_LIVENESS_BUDGET / 5, + "a cached status and a declined counter took {took:?}: the liveness probe ran again" + ); + } + } + + /// When the counter IS used, a CPU burn is metered through a CPU rail that + /// moves — so the reading is energy, not a zero or a GPU-only figure that + /// merely happened to advance. When it is not used, the host status must + /// say why: never `Live`. + #[test] + fn a_used_ioreport_counter_meters_cpu_work() { + let counter = AppleIoReportCounter::new(); + if counter.is_available() { + let rail = { + let guard = counter.state.lock().expect("state lock"); + let state = guard.as_ref().expect("an available counter holds a subscription"); + probe_cpu_rail(state, CPU_RAIL_LIVENESS_BUDGET) + }; + assert_eq!(rail, Some(CpuRail::Live), "an available counter whose own CPU rail does not move"); + let _ = counter.read(); // prime + let before = counter.read().expect("baseline read"); + burn(Duration::from_millis(200)); + let after = counter.read().expect("work read"); + assert!( + after.uj > before.uj, + "a live CPU rail metered 200 ms of CPU work as zero: before={} after={}", + before.uj, + after.uj + ); + assert_eq!(after.provenance, Provenance::ModelBased); + } else { + assert_ne!(counter.status(), IoReportStatus::Live, "a declined counter reports status Live"); + assert_ne!( + crate::ioreport_status(), + IoReportStatus::Live, + "host status Live but the counter declined" + ); + } + } + + /// A declined counter yields no number at all, and the fallback that runs + /// in its place carries its own grade end to end: `detect_meter` hands out + /// the load-average estimator, and its readings say `Estimator` — never a + /// `ModelBased` figure from a frozen rail. + #[test] + fn a_declined_counter_yields_no_reading_and_the_fallback_says_estimator() { + let counter = AppleIoReportCounter::new(); + let composite = crate::meter::detect_meter(); + let active = composite.active().expect("detect_meter always has a meter"); + let reading = composite.read().expect("detect_meter always reads"); + if counter.is_available() { + assert_eq!(composite.active_name(), "apple-ioreport"); + assert_eq!(active.provenance(), Provenance::ModelBased); + assert_eq!(reading.provenance, Provenance::ModelBased); + } else { + assert!( + matches!(counter.read(), Err(EnergyError::NoCounter(EnergyDomain::SocTotal))), + "a declined IOReport counter must not produce a reading" + ); + // The load-average estimator on Apple Silicon; the static one elsewhere. + let fallback = if cfg!(target_arch = "aarch64") { "apple-silicon" } else { "estimation" }; + assert_eq!(composite.active_name(), fallback); + assert_eq!(active.provenance(), Provenance::Estimator); + assert_eq!(reading.provenance, Provenance::Estimator); + } } } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs index 007e0ce9b..4478d45ca 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs @@ -82,6 +82,56 @@ impl fmt::Display for Provenance { } } +/// Why the Apple Silicon IOReport counter (`apple_ioreport::AppleIoReportCounter`, +/// built with the `ioreport` feature on macOS) is, or is not, usable on this host. +/// +/// The counter is available — and [`meter::detect_meter`] selects it — only when +/// this is [`IoReportStatus::Live`]. Under any other status the host is metered +/// by the next rung instead (on Apple Silicon, the load-average estimator, whose +/// readings say [`Provenance::Estimator`]); this says why. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoReportStatus { + /// IOReport is not usable here: this build has no IOReport backend (not + /// macOS, or built without the `ioreport` feature), or no subscription + /// could be opened or sampled (not Apple Silicon, Rosetta, a future macOS + /// that changes the private surface). + Unavailable, + /// A subscription opened, but no `CPU Energy` rail (nor `DIE__CPU Energy`) + /// exists in the `Energy Model` group — an unfamiliar channel naming. The + /// counter is never used. + CpuRailAbsent, + /// The CPU energy rail exists but does not advance like a live reading while + /// this process burns CPU: it held its value, or moved in a single step (a + /// rare batch update). The platform is not exposing live CPU energy to this + /// process, so the summed rails would report GPU-only energy, or zero, as + /// the energy of CPU work. Observed on macOS 27.0.1 on an M5 Max and an + /// M3 Ultra. + CpuRailFrozen, + /// The CPU energy rail keeps advancing under load, sample after sample; + /// the counter is usable. + Live, +} + +/// This host's [`IoReportStatus`], probed once per process and cached. +/// +/// On macOS with the `ioreport` feature, the first call opens an IOReport +/// subscription and burns CPU on the calling thread while it watches the CPU +/// energy rail: about `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET` plus the +/// IOReport samples it takes (about 1.9 s in all on an M5 Max, measured). Every +/// later call — and every `AppleIoReportCounter::new()` and +/// [`meter::detect_meter`] — reuses that answer. Anywhere else it returns +/// [`IoReportStatus::Unavailable`] without probing. +pub fn ioreport_status() -> IoReportStatus { + #[cfg(all(feature = "ioreport", target_os = "macos"))] + { + apple_ioreport::host_status() + } + #[cfg(not(all(feature = "ioreport", target_os = "macos")))] + { + IoReportStatus::Unavailable + } +} + /// Hardware energy domain a counter reports on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -238,6 +288,15 @@ mod tests { assert_eq!(counter.resolution_uj(), 1); } + /// A build without the IOReport backend (not macOS, or no `ioreport` + /// feature) never claims it: the status is `Unavailable`, with no probe. + /// The macOS + `ioreport` side is asserted in `apple_ioreport`'s tests. + #[test] + #[cfg(not(all(feature = "ioreport", target_os = "macos")))] + fn without_the_ioreport_backend_the_status_is_unavailable() { + assert_eq!(ioreport_status(), IoReportStatus::Unavailable); + } + #[test] fn ranking_provenance_by_honesty() { // The breaker MUST treat HwShunt as the most honest source. diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs index e5daee962..178df4b3d 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs @@ -241,17 +241,24 @@ impl EnergyMeter for EstimationMeter { /// Detect the best meter for this platform. Always returns *something* /// — at minimum the [`EstimationMeter`] fallback. +/// +/// With the `ioreport` feature on macOS, the IOReport counter is selected only +/// when [`crate::ioreport_status`] is `Live`; the first call in a process runs +/// that liveness probe on the calling thread. pub fn detect_meter() -> CompositeMeter { let mut meters: Vec> = Vec::new(); #[cfg(all(feature = "ioreport", target_os = "macos"))] { // Prefer the real IOReport energy counter (ModelBased — the same on-die - // counters powermetrics/Activity Monitor read). It reports - // `is_available() == false` if IOReport can't open (non-Apple-Silicon, - // Rosetta, a future macOS), in which case `CompositeMeter` falls through - // to the load-average estimator below. Without this the crate shipped a - // real meter that `detect_meter` never selected. + // counters powermetrics/Activity Monitor read), but only where it can + // meter CPU work. It reports `is_available() == false` unless IOReport + // opens AND its CPU energy rail advances under load: a subscription can + // open while every CPU rail is frozen (macOS 27.0.1 on an M5 Max and an + // M3 Ultra), and the summed rails then move only with the GPU, or not at + // all. `crate::ioreport_status()` says which case this host is in. When + // it is unavailable, `CompositeMeter` falls through to the load-average + // estimator below, whose readings say `Provenance::Estimator`. meters.push(Box::new(crate::apple_ioreport::AppleIoReportCounter::new())); meters.push(Box::new(crate::apple::AppleSiliconMeter::new())); } -- 2.54.0 (Apple Git-157) From 1a477bc8d31e0228d3ef8f7ee07cff7fb9859159 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:31:15 -0400 Subject: [PATCH 02/23] jouleclaw-energy: the liveness probe samples the Energy Model group alone The family liveness rule says the probe re-samples the CPU energy rail after every ~25 ms of CPU burn on the probing thread. In this crate that was not true. The probe (b9d7e945) sampled the counter's own subscription, which holds every IOReport channel, and one IOReportCreateSamples over every channel costs 0.12-0.8 s on the M5 Max (11,885 channels; 0.4-0.8 s yesterday, 117-144 ms today) and ~0.15 s on the M3 Ultra (14,342). So the "1 s of burn, polled every 25 ms" probe spent much of its second waiting inside IOReport, compared the rail a handful of times, and overran its budget by up to a sample. It still declined correctly on both machines; it just was not the probe it said it was. The fix. The probe now opens its own subscription to the Energy Model group (IOReportCopyChannelsInGroup("Energy Model") then IOReportCreateSubscription), samples that, and drops it. The CPU rails in it are the same channels the counter reads, at the same absolute values. If the group cannot be subscribed, the probe opens the counter's all-channel subscription only to tell Unavailable (IOReport does not open) from CpuRailAbsent (no such group), as eoc-meter does (c6378629). The counter's own read path is unchanged: it still subscribes to every channel and filters the group when it sums. The rule itself (RailWatch: the same rail advancing in two separate intervals) is unchanged. Measured with this commit, by the new test, from what the probe itself sampled: M5 Max: 364 channels per sample, 0 outside the group (all-channel: 11,885); one sample 5.9 ms (all-channel 117 ms); 33 intervals in 1.08 s. M3 Ultra: 645 channels per sample, 0 outside the group (all-channel: 14,342); one sample 8.6 ms (all-channel 146 ms); 32 intervals in 1.10 s. The uncached probe (open + 1 s of burn) took 1.10 s on the M5 Max and 1.13 s on the M3 Ultra; on the all-channel subscription it took 1.32 s on the M5 Max this morning, 1.9 s when the core commit measured it. To test what the probe really sampled rather than a helper beside it, the sampling loop now returns a ProbeRun: the RailWatch, the channels per sample, and how many were outside the Energy Model group. host_status caches probe_status, which is probe(budget).0. Tests. - the_liveness_probe_subscribes_to_the_energy_model_group_only (new): runs the same probe host_status runs, and asserts every channel it sampled was in the Energy Model group, that it sampled fewer channels than one all-channel sample holds, and that the group's CPU rails are exactly those in the counter's subscription. Where the group cannot be subscribed, the status must say CpuRailAbsent or Unavailable, matching whether IOReport opens. It asserts on channels, not timings; the timings are printed. - the_liveness_probe_never_reports_a_frozen_rail_as_live now runs the real uncached probe_status instead of a probe on the all-channel subscription. 40 passed on both machines, both "status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false". Falsified, each mutation applied to this commit and restored with git checkout: - FP1, the probe back on the counter's all-channel subscription (probe() calling open()): "the probe sampled 11521 channels outside the Energy Model group (11885 per sample)"; it managed 6 intervals in 1.45 s. - FP2, the group subscription built from IOReportCopyAllChannels: the same failure, 11521 of 11885. - FP3, the family rule's threshold set to 1 (LIVE_RAIL_ADVANCES = 1): four rule tests fail, e.g. a_single_jump_is_a_batch_update_not_a_live_rail ("assertion failed: !w.is_live()") and a_rail_that_keeps_advancing_is_live ("one advance is not yet live"). Not done here, deliberately: - The counter's own read() still samples every channel (0.1-0.8 s per read on the M5 Max). It is declined on both machines, so nothing pays that today; changing what a live counter's read costs is a separate change. - The oracle in the tests still samples every channel, deliberately: it shares no subscription with the probe. Co-Authored-By: Claude Opus 5.5 --- .../jouleclaw-energy/src/apple_ioreport.rs | 288 +++++++++++++++--- .../crates/jouleclaw-energy/src/lib.rs | 7 +- 2 files changed, 248 insertions(+), 47 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index fef93cb88..bc2b9dbf6 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -56,6 +56,13 @@ const K_CFSTRING_ENCODING_UTF8: u32 = 0x0800_0100; 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, @@ -225,6 +232,53 @@ impl AppleIoReportCounter { } } + /// A subscription to the `Energy Model` group alone, for the liveness probe. + /// Its CPU rails are the same counters the counter's own all-channel + /// subscription ([`Self::open`]) reads, at the same absolute values, but one + /// sample of this group takes ~6 ms, against 0.4–0.8 s for every channel on + /// an M5 Max (~11,860 channels) and ~0.14 s on an M3 Ultra. On every channel + /// the probe would spend most of its budget inside IOReport rather than + /// burning CPU between samples. `None` if the group cannot be subscribed. + fn open_energy_model() -> Option { + unsafe { + 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() { + return None; + } + if subscribed.is_null() { + CFRelease(sub); + return None; + } + Some(State { + sub, + channels: subscribed, + prior_sample: None, + cumulative_uj: 0, + }) + } + } + /// Whether this counter meters: IOReport opened **and** the host's CPU /// energy rail is live. When `false`, [`Self::status`] says why. pub fn is_available(&self) -> bool { @@ -243,11 +297,11 @@ impl AppleIoReportCounter { /// How long the liveness probe watches the CPU energy rail, burning CPU on its /// thread between samples, before concluding the rail is frozen. A live Energy /// Model rail moves many times a second (the M5 Max GPU rail changed in 166 of -/// 176 samples ~28 ms apart), so a second holds the two advancing intervals the -/// probe needs even at the ~0.5 s a full-subscription sample takes on an M5 Max. -/// A slower live rail fails safe — the counter is declined and the host -/// estimated, never over-claimed. Paid once per process, see -/// [`crate::ioreport_status`]. +/// 176 samples ~28 ms apart), and the probe samples the `Energy Model` group +/// alone every [`PROBE_TICK`] of burn, so a second holds dozens of intervals +/// where it needs two advancing ones. A slower live rail fails safe — the +/// counter is declined and the host estimated, never over-claimed. Paid once +/// per process, see [`crate::ioreport_status`]. pub const CPU_RAIL_LIVENESS_BUDGET: Duration = Duration::from_millis(1000); /// CPU burned on the probing thread before each liveness re-sample. @@ -338,22 +392,54 @@ impl RailWatch { /// [`crate::ioreport_status`] and gates [`AppleIoReportCounter::new`]. pub(crate) fn host_status() -> IoReportStatus { static STATUS: OnceLock = OnceLock::new(); - *STATUS.get_or_init(|| { - let Some(state) = AppleIoReportCounter::open() else { - return IoReportStatus::Unavailable; - }; - match probe_cpu_rail(&state, CPU_RAIL_LIVENESS_BUDGET) { + *STATUS.get_or_init(|| probe_status(CPU_RAIL_LIVENESS_BUDGET)) +} + +/// Probe this host's CPU energy rail now, uncached. Backs [`host_status`]. +fn probe_status(budget: Duration) -> IoReportStatus { + probe(budget).0 +} + +/// What one run of the liveness probe sampled, beside the rule's view of it. +#[derive(Debug, Default)] +struct ProbeRun { + /// The liveness rule, fed every sample interval the probe compared. + watch: RailWatch, + /// Channels in the largest sample the probe compared. + channels: usize, + /// How many channels of any one sample were outside the `Energy Model` group. + outside_group: usize, +} + +/// Probe this host's CPU energy rail now, uncached, on the probe's own +/// subscription to the `Energy Model` group +/// ([`AppleIoReportCounter::open_energy_model`]), which is dropped after: the +/// status, and what the probe sampled to decide it (`None` when the group could +/// not be subscribed). +fn probe(budget: Duration) -> (IoReportStatus, Option) { + let Some(state) = AppleIoReportCounter::open_energy_model() else { + // No Energy Model subscription. Tell "no IOReport" from "no such group" + // by opening the subscription the counter itself would use. + let status = match AppleIoReportCounter::open() { + Some(_) => IoReportStatus::CpuRailAbsent, None => IoReportStatus::Unavailable, - Some(CpuRail::Live) => IoReportStatus::Live, - Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, - Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, - } - }) + }; + return (status, None); + }; + let run = probe_cpu_rail(&state, budget); + let status = match run.watch.verdict() { + None => IoReportStatus::Unavailable, + Some(CpuRail::Live) => IoReportStatus::Live, + Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + }; + (status, Some(run)) } /// Burn CPU on this thread, re-sampling after every [`PROBE_TICK`] of burn, -/// and report whether the CPU energy rail **itself** is live by the rule in -/// [`RailWatch`]. `None` if the subscription yielded no usable sample. +/// and watch whether the CPU energy rail **itself** is live by the rule in +/// [`RailWatch`]; its verdict is `None` if the subscription yielded no usable +/// sample. /// /// The summed total is not a liveness signal: on an M5 Max the GPU rail keeps /// advancing while every CPU rail holds, so a total that moves proves only that @@ -361,20 +447,22 @@ pub(crate) fn host_status() -> IoReportStatus { /// consecutive sample interval, from the probe's own samples, so no counter's /// cumulative reading is touched. /// -/// Each re-sample is one `IOReportCreateSamples` over the whole subscription, -/// which is slow: measured at ~0.35–0.7 s on an M5 Max (11,860 channels) and -/// ~0.135 s on an M3 Ultra (14,335 channels). So the probe compares a few times -/// within `budget` rather than every tick, and can overrun it by one sample. -fn probe_cpu_rail(state: &State, budget: Duration) -> Option { +/// Each re-sample is one `IOReportCreateSamples` over `state`'s subscription. +/// On the probe's `Energy Model` subscription that costs a few milliseconds, so +/// the thread spends most of `budget` burning CPU and the rail is compared every +/// ~[`PROBE_TICK`]; on an all-channel subscription one sample takes 0.14–0.8 s +/// and the probe would compare only a handful of times, overrunning `budget` by +/// up to one sample. +fn probe_cpu_rail(state: &State, budget: Duration) -> ProbeRun { + let mut run = ProbeRun::default(); unsafe { let mut prev = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); if prev.is_null() { - return None; + return run; } let started = Instant::now(); - let mut watch = RailWatch::default(); let mut acc: u64 = 0; - while watch.wants_more(started.elapsed(), budget) { + while run.watch.wants_more(started.elapsed(), budget) { let tick = Instant::now(); while tick.elapsed() < PROBE_TICK { acc = acc.wrapping_add(1); @@ -389,23 +477,28 @@ fn probe_cpu_rail(state: &State, budget: Duration) -> Option { CFRelease(cur); continue; } - let rails = cpu_rail_advances(delta); + let (rails, channels, outside_group) = cpu_rail_advances(delta); CFRelease(delta); CFRelease(prev); prev = cur; - watch.observe(rails.iter().map(|(name, advanced)| (name.as_str(), *advanced))); + run.channels = run.channels.max(channels); + run.outside_group = run.outside_group.max(outside_group); + run.watch.observe(rails.iter().map(|(name, advanced)| (name.as_str(), *advanced))); } CFRelease(prev); - watch.verdict() } + run } /// In a samples-delta dictionary: every CPU energy channel in the Energy Model /// group (`CPU Energy`, or `DIE__CPU Energy` on multi-die parts), and -/// whether it advanced over that interval. Decided on the raw integer, before -/// unit conversion, so a small advance in a fine unit cannot truncate to zero. -fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { +/// whether it advanced over that interval; then how many channels the sample +/// held, and how many of those were outside the Energy Model group. Decided on +/// the raw integer, before unit conversion, so a small advance in a fine unit +/// cannot truncate to zero. +fn cpu_rail_advances(delta: CFDictionaryRef) -> (Vec<(String, bool)>, usize, usize) { let mut rails = Vec::new(); + let (mut channels, mut outside_group) = (0, 0); unsafe { let key = CFStringCreateWithCString( std::ptr::null_mut(), @@ -413,16 +506,21 @@ fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { K_CFSTRING_ENCODING_UTF8, ); if key.is_null() { - return rails; + return (rails, channels, outside_group); } let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); if arr.is_null() { - return rails; + return (rails, channels, outside_group); } 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; + } + channels += 1; + if !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { + outside_group += 1; continue; } let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) else { @@ -434,7 +532,7 @@ fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { } } } - rails + (rails, channels, outside_group) } /// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally @@ -957,33 +1055,34 @@ mod tests { } /// The dangerous direction on its own: the probe never calls a rail Live - /// that the oracle cannot see move. Runs the probe itself, uncached, on a - /// fresh subscription, beside the cached status the counter is gated on, - /// and asserts on every outcome, not only on Live. + /// that the oracle cannot see move. Runs the probe itself, uncached, on its + /// own fresh `Energy Model` subscription exactly as [`host_status`] does, + /// beside the cached status the counter is gated on, and asserts on every + /// outcome, not only on Live. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { let t = Instant::now(); - let probed = AppleIoReportCounter::open().map(|s| probe_cpu_rail(&s, CPU_RAIL_LIVENESS_BUDGET)); + let probed = probe_status(CPU_RAIL_LIVENESS_BUDGET); let probe_took = t.elapsed(); let cached = crate::ioreport_status(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); eprintln!("ioreport probe: probed={probed:?} cached={cached:?} oracle={oracle:?} probe_took={probe_took:?}"); match (probed, cached) { - (Some(Some(CpuRail::Live)), _) | (_, IoReportStatus::Live) => assert_eq!( + (IoReportStatus::Live, _) | (_, IoReportStatus::Live) => assert_eq!( oracle, Oracle::CpuRailMoved(true), "probe said Live (probed={probed:?}, cached={cached:?}), the oracle did not see the CPU rail keep moving" ), - (Some(Some(CpuRail::Frozen)), _) => assert!( + (IoReportStatus::CpuRailFrozen, _) => assert!( matches!(oracle, Oracle::CpuRailMoved(_)), "the probe saw a CPU rail, the oracle found none: {oracle:?}" ), - (Some(Some(CpuRail::Absent)), _) => assert_eq!( + (IoReportStatus::CpuRailAbsent, _) => assert_eq!( oracle, Oracle::NoCpuRail, "the probe found no CPU rail, the oracle found one" ), - (Some(None) | None, _) => assert_eq!( + (IoReportStatus::Unavailable, _) => assert_eq!( oracle, Oracle::NoIoReport, "the probe could not open or sample IOReport, the oracle could" @@ -991,6 +1090,107 @@ mod tests { } } + /// Every channel in one plain sample of `state`'s subscription, as + /// (group, name), and how long `IOReportCreateSamples` took. + fn sample_channels(state: &State) -> Option<(Vec<(String, String)>, Duration)> { + unsafe { + let t = Instant::now(); + let smp = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); + let took = t.elapsed(); + if smp.is_null() { + return None; + } + let mut chans = Vec::new(); + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + 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; + } + chans.push(( + cfstring_to_string(IOReportChannelGetGroup(it)).unwrap_or_default(), + cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(), + )); + } + } + } + CFRelease(smp); + Some((chans, took)) + } + } + + /// The probe samples its own subscription to the `Energy Model` group, not + /// every channel, so one sample costs milliseconds and it really re-samples + /// after every ~25 ms of burn. Asserted on what the probe [`host_status`] + /// runs actually sampled, not on timings: every channel in its samples is in + /// the `Energy Model` group, and there are fewer of them than one sample of + /// the counter's all-channel subscription holds. And the group's CPU rails + /// are exactly the ones the counter's subscription carries. Where the group + /// cannot be subscribed, the status must say why. + #[test] + fn the_liveness_probe_subscribes_to_the_energy_model_group_only() { + let t = Instant::now(); + let (status, run) = probe(CPU_RAIL_LIVENESS_BUDGET); + let probe_took = t.elapsed(); + let Some(run) = run else { + let why = if AppleIoReportCounter::open().is_some() { + IoReportStatus::CpuRailAbsent + } else { + IoReportStatus::Unavailable + }; + assert_eq!(status, why, "the probe had no Energy Model subscription, yet status={status:?}"); + return; + }; + let group = AppleIoReportCounter::open_energy_model().expect("the probe subscribed to the group once"); + let all = AppleIoReportCounter::open().expect("the Energy Model group opened, so every channel opens"); + let (group_chans, group_took) = sample_channels(&group).expect("sample the Energy Model group"); + let (all_chans, all_took) = sample_channels(&all).expect("sample every channel"); + eprintln!( + "ioreport probe subscription: status={status:?} after {} intervals in {probe_took:?}; \ + {} channels per sample, {} outside the Energy Model group (all-channel subscription: {}); \ + one sample {group_took:?} (all-channel {all_took:?})", + run.watch.intervals, + run.channels, + run.outside_group, + all_chans.len() + ); + assert!(run.watch.intervals > 0, "the probe compared no samples, yet status={status:?}"); + assert!(run.channels > 0, "the probe's samples held no channels"); + assert_eq!( + run.outside_group, 0, + "the probe sampled {} channels outside the Energy Model group ({} per sample)", + run.outside_group, run.channels + ); + assert!( + run.channels < all_chans.len(), + "the probe sampled {} channels, as many as the all-channel subscription ({})", + run.channels, + all_chans.len() + ); + let cpu_rails = |chans: &[(String, String)]| { + let mut rails: Vec = chans + .iter() + .filter(|(group, name)| group.eq_ignore_ascii_case("Energy Model") && name.ends_with("CPU Energy")) + .map(|(_, name)| name.clone()) + .collect(); + rails.sort(); + rails + }; + assert_eq!( + cpu_rails(&group_chans), + cpu_rails(&all_chans), + "the probe's group carries different CPU rails from the ones the counter reads" + ); + } + /// The probe runs once per process: after the first answer, the status and /// a declined counter come back without probing again (a re-probe costs at /// least one IOReport sample plus up to a full budget of burn). @@ -1025,7 +1225,7 @@ mod tests { let rail = { let guard = counter.state.lock().expect("state lock"); let state = guard.as_ref().expect("an available counter holds a subscription"); - probe_cpu_rail(state, CPU_RAIL_LIVENESS_BUDGET) + probe_cpu_rail(state, CPU_RAIL_LIVENESS_BUDGET).watch.verdict() }; assert_eq!(rail, Some(CpuRail::Live), "an available counter whose own CPU rail does not move"); let _ = counter.read(); // prime diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs index 4478d45ca..4356eb70f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs @@ -115,9 +115,10 @@ pub enum IoReportStatus { /// This host's [`IoReportStatus`], probed once per process and cached. /// /// On macOS with the `ioreport` feature, the first call opens an IOReport -/// subscription and burns CPU on the calling thread while it watches the CPU -/// energy rail: about `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET` plus the -/// IOReport samples it takes (about 1.9 s in all on an M5 Max, measured). Every +/// subscription to the `Energy Model` group and burns CPU on the calling thread +/// while it watches the CPU energy rail, re-sampling every ~25 ms: about +/// `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET` where the rail is frozen, less +/// where it is live (it stops as soon as the rail has advanced twice). Every /// later call — and every `AppleIoReportCounter::new()` and /// [`meter::detect_meter`] — reuses that answer. Anywhere else it returns /// [`IoReportStatus::Unavailable`] without probing. -- 2.54.0 (Apple Git-157) From 226a70cb646797acb00a0add5bc2ee967272d4d3 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:38:15 -0400 Subject: [PATCH 03/23] jouledesk-github: an estimate is sealed and posted as an estimate, and no zero is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family 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. jouledesk-github broke it in the ledger and in every reply it posts. The defect, on origin/main (54e89b56) and still after b9d7e945: - do_work read jouleclaw-energy's detect_meter() around the work and floored the delta to the meter's resolution, `(after - before).max(1000)`, falling back to that 1000 µJ when a read failed. A counter that read nothing, which is what the IOReport CPU rail does on macOS 27.0.1, came out as 1000 µJ of work. - seal() committed that figure through Teammate::tag, which seals every task's efficiency/1 as Energy::measured: HwShunt, a hardware reading, whatever metered it. - The GitHub and Slack replies said "measured via ``" regardless. Since b9d7e945 declines IOReport on both of our Macs, the meter that runs is the load-average estimator (apple-silicon), and the three together sealed an estimate as a hardware reading and posted "measured via `apple-silicon`, provenance `estimator`". The fix. - jouledesk: Teammate::tag_with_provenance(kind, intent, provenance) seals the task's energy at how it was obtained, using the schema's own constructors (Energy::measured / modeled / estimator). Teammate::tag is unchanged: it still seals HwShunt, and the signed conformance vector (examples/vector.rs, tests/pack.rs) is built through it. EnergyProvenance re-exports jcp_schema::Provenance so a binding need not depend on jcp-schema. - jouledesk-github seals the meter's figure at the meter's provenance, and the replies say "estimated by ``" for an estimate and "measured via" only for a reading, naming the meter and its provenance either way. - The floor is gone. The figure is the delta as read. And no zero becomes a figure: the delta counts only if both reads succeeded, agree about provenance, the counter did not go backwards, the window was at least the active meter's own min_window_ns (the estimator declares 100 ms, IOReport 10 ms), and it is non-zero, since no work runs on zero energy. Otherwise the work is not sealed and the reply says "not metered — ``: ; no energy figure was sealed". The same gate refuses a zero in jouleclaw-loader-gguf's tuner (next commit). Why the window gate: with the floor removed, the no-model fallback (a deterministic extract that takes microseconds) sealed "0.0 mJ (0 µJ) — estimated by `apple-silicon`" on the M5 Max. The estimator integrates at 1 ms steps, so such a window reads 0, or one whole millisecond of its watts when it straddles a tick. Neither is a figure for the work, and the estimator says so itself through its 100 ms minimum window. What a reply says now. A real mention with the default model (gemma-4-E2B, --dry-run, M5 Max): - **energy:** 134774.9 mJ (134774859 µJ) — estimated by `apple-silicon`, provenance `estimator` - **decision:** completed · ledger verified ✓ The no-model fallback (M5 Max; the M3 Ultra says "7 µs"): - **energy:** not metered — `apple-silicon`: the work took 71 µs, shorter than the 100 ms window it can resolve; no energy figure was sealed It used to seal 1000 µJ as HwShunt and post "measured via". Tests (jouledesk 18 + pack 4, jouledesk-github 12; all pass on the M5 Max; the jouledesk-github binary also on the M3 Ultra, 12 passed): - a_tags_energy_is_sealed_at_the_provenance_it_was_obtained_at (jouledesk): each provenance, read back through the verified efficiency/1 chain; plain tag still seals HwShunt. - a_figure_is_the_delta_as_read_never_floored: 250 µJ stays 250. - a_window_without_two_good_readings_has_no_figure: a failed read, a counter going backwards, readings that disagree about provenance. - a_window_the_meter_cannot_resolve_is_never_a_zero: a 200 µs window, with or without a step in it, and a zero delta over a second. - an_estimate_is_sealed_and_posted_as_an_estimate, a_reading_is_sealed_at_its_own_provenance, work_with_no_figure_is_neither_sealed_nor_given_one, and Slack's slack_posts_an_estimate_as_an_estimate: what is sealed, read back from the verified chain, and what each reply says. - On this host, not skippable: a_mention_answered_too_fast_to_meter_is_posted_ as_unmetered and the Slack equivalent run the whole handle() path and assert the reply names IOReport exactly when ioreport_status() is Live; on_this_host_work_the_meter_can_resolve_is_sealed_at_its_provenance burns CPU for the real meter's min window + 50 ms and asserts the figure is non-zero and sealed at that meter's provenance. Both machines: "ioreport=CpuRailFrozen sealed Estimator 6000000 µJ ... estimated by `apple-silicon`" (M3 Ultra: 1492895 µJ). The old handles_a_mention_and_seals_measured_energy asserted `measured_uj >= 1000` and "measured via": it held only because of the floor. Falsified, each mutation applied to this commit and restored with git checkout: - FJ1, the floor back (`uj.max(1_000)`): a_figure_is_the_delta_as_read_never_ floored fails. - FJ2, no min-window gate: three tests fail; the fallback's window then reads "it registered no energy across 11 µs of work", so the zero gate still holds it back. - FJ3, a zero delta accepted as a figure: a_window_the_meter_cannot_resolve_is_ never_a_zero fails. - FJ4, seal through plain tag (every figure HwShunt): three tests fail; the on-host one sealed "HwShunt 5379036 µJ" for apple-silicon's estimate. - FJ5, an estimate posted "measured via": three tests fail, GitHub and Slack. - FJ6, tag_with_provenance sealing Energy::measured for an estimator: a_tags_energy_is_sealed_at_the_provenance_it_was_obtained_at fails. clippy --no-deps --tests on jouledesk and jouledesk-github: nothing new (the unused libwebrtc patch warning is the workspace's). jcp-efficiency is a new dev-dependency of jouledesk-github, already in the graph through jouledesk. Not done here, deliberately: - Work::measured_uj and Handled::measured_uj are public Rust fields that now carry an estimate when provenance says `estimator` (documented). The sealed efficiency/1 is right (measured_uj: None for an estimate); renaming the Rust fields is an API question. - Teammate::tag still seals a skill's declared cost as a HwShunt reading. The signed jouledesk vector is built through it, so changing it means regenerating a signed vector. Co-Authored-By: Claude Opus 5.5 --- jouledesk/jouledesk-rs/Cargo.lock | 1 + .../crates/jouledesk-github/Cargo.toml | 7 +- .../crates/jouledesk-github/src/lib.rs | 394 ++++++++++++++++-- .../crates/jouledesk-github/src/main.rs | 22 +- .../crates/jouledesk-github/src/slack.rs | 49 ++- .../jouledesk-rs/crates/jouledesk/src/lib.rs | 58 ++- 6 files changed, 466 insertions(+), 65 deletions(-) diff --git a/jouledesk/jouledesk-rs/Cargo.lock b/jouledesk/jouledesk-rs/Cargo.lock index 56cb012ea..5876b5edc 100644 --- a/jouledesk/jouledesk-rs/Cargo.lock +++ b/jouledesk/jouledesk-rs/Cargo.lock @@ -1262,6 +1262,7 @@ dependencies = [ name = "jouledesk-github" version = "0.1.0" dependencies = [ + "jcp-efficiency", "jouleclaw-energy", "jouledesk", "serde_json", diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml b/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml index a45cf9ae3..80a623aa4 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/Cargo.toml @@ -17,8 +17,13 @@ path = "src/main.rs" [dependencies] jouledesk = { path = "../jouledesk" } -# Real, measured energy for the work — Apple-Silicon IOReport on this box. +# The host's best energy meter: Apple-Silicon IOReport where its CPU rail is live, +# else the load-average estimator (sealed and posted as an estimate). jouleclaw-energy = { path = "../../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", default-features = false, features = ["ioreport"] } serde_json = "1" ureq = { version = "2", features = ["json"] } tiny_http = "0.12" + +[dev-dependencies] +# Tests read the sealed efficiency/1 receipt back to check its provenance. +jcp-efficiency = { path = "../../../../jcp/jcp-rs/crates/jcp-efficiency" } diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs index 6b3adabbc..85e19c756 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs @@ -2,13 +2,20 @@ //! //! The chat/forge transforms in [`jouledesk::surface`] turn an event into a reply body //! offline. This crate closes the loop: it takes a real `@joule` mention, **does the -//! work with a real model under a real energy meter**, **seals a verifiable -//! `efficiency/1` at the measured cost**, and **posts the reply back through the GitHub -//! API**. So the joules in the receipt are measured on the machine that did the work, -//! not asserted — and a human can actually tag `@joule` and get an accountable answer. +//! work with a real model under the best energy meter this host exposes**, **seals a +//! verifiable `efficiency/1` at that meter's figure and provenance**, and **posts the +//! reply back through the GitHub API**. The joules in the receipt come from the machine +//! that did the work, not a constant: measured where the host exposes a live counter, +//! estimated — and sealed and posted as an estimate — where it does not (Apple Silicon +//! whose IOReport CPU rail does not advance, for one). A human can actually tag `@joule` +//! and get an accountable answer. +use std::time::{Duration, Instant}; + +use jouleclaw_energy::meter::CompositeMeter; +use jouleclaw_energy::{EnergyReading, Provenance}; use jouledesk::surface::{parse_event, Surface}; -use jouledesk::{Skill, SkillOutcome, TagOutcome, Teammate}; +use jouledesk::{EnergyProvenance, Skill, SkillOutcome, TagOutcome, Teammate}; use serde_json::{json, Value}; /// The Slack binding — the same core ([`do_work`] + [`seal`]) on a second surface. @@ -18,40 +25,137 @@ pub mod slack; const DEFAULT_MODEL: &str = "/Volumes/macos_4TB_external_1/neuronexus_ai/model_library/gemma-4-E2B-q4_k_m.gguf"; -/// The work product: a real answer and the **real energy it cost**, measured on this -/// machine (not a constant). +/// The work product: a real answer and the energy this machine's meter puts on it (not +/// a constant) — a reading or an estimate, as `provenance` says. pub struct Work { pub answer: String, + /// The meter's figure for the work, in µJ: the delta between its readings before + /// and after, as read, with no floor. A measurement only when `provenance` says so. + /// `0` and meaningless when `provenance` is `unmetered`; such work is never sealed. pub measured_uj: u64, + /// The provenance of the meter's readings (`hw_shunt`, `model_based`, `estimator`), + /// or `unmetered` when the meter produced no figure for the work. pub provenance: String, + /// The meter that actually ran (`apple-ioreport`, `apple-silicon`, `estimation`…). pub meter: String, pub model: String, + /// Why the meter produced no figure for the work, when `provenance` is `unmetered`. + pub unmetered: Option, } -/// Do the actual work for a mention — generate an answer with a real model, with a real -/// hardware energy counter read around it so the cost is measured. +/// `Work::provenance` for work whose meter produced no figure for it. +const UNMETERED: &str = "unmetered"; + +/// Do the actual work for a mention — generate an answer with a real model, with the +/// host's best energy meter read around it: measured where the host exposes a live +/// counter, estimated where it does not, and the provenance says which. pub fn do_work(question: &str) -> Work { let meter = jouleclaw_energy::meter::detect_meter(); - let before = meter.read().ok().map(|r| r.uj); - let (answer, model) = match run_model(question) { + let ((answer, model), figure) = metered(&meter, || match run_model(question) { Some(a) => (a, model_label()), None => (fallback_answer(question), "deterministic-extract".to_string()), + }); + let (measured_uj, provenance, unmetered) = match figure { + Ok((uj, provenance)) => (uj, provenance.to_string(), None), + Err(why) => (0, UNMETERED.to_string(), Some(why)), }; - let after = meter.read().ok().map(|r| r.uj); - let res = meter.active().map(|m| m.resolution_uj()).unwrap_or(1000); - let provenance = meter - .active() - .map(|m| m.provenance().to_string()) - .unwrap_or_else(|| "estimator".into()); - // A cumulative counter: the work's cost is the delta, floored to what the meter can - // resolve so we never seal below the counter's honest resolution. - let measured_uj = match (before, after) { - (Some(a), Some(b)) if b >= a => (b - a).max(res), - _ => res, - }; - Work { answer, measured_uj, provenance, meter: meter.active_name().to_string(), model } + Work { answer, measured_uj, provenance, meter: meter.active_name().to_string(), model, unmetered } } +/// Run `work` under `meter`: its result, and the meter's figure for it (see +/// [`window_energy`]) or why there is none. +fn metered(meter: &CompositeMeter, work: impl FnOnce() -> T) -> (T, Result<(u64, Provenance), String>) { + let min_window = Duration::from_nanos(meter.active().map_or(u64::MAX, |m| m.min_window_ns())); + let started = Instant::now(); + let before = meter.read().ok(); + let out = work(); + let after = meter.read().ok(); + let figure = window_energy(before.as_ref(), after.as_ref(), started.elapsed(), min_window); + (out, figure) +} + +/// What a window of work cost, from a cumulative meter's readings before and after it: +/// the delta, with the provenance those readings carry — or why there is no honest +/// figure: a read failed, the two readings disagree about provenance, the counter went +/// backwards, the window was shorter than the meter says it can resolve +/// (`min_window_ns`), or the meter registered no energy at all across the work. The +/// delta is never floored to the meter's resolution (a floor fabricates energy the meter +/// did not report), and nothing becomes a zero: no work runs on zero energy, so a zero +/// means the meter did not see the work. +fn window_energy( + before: Option<&EnergyReading>, + after: Option<&EnergyReading>, + window: Duration, + min_window: Duration, +) -> Result<(u64, Provenance), String> { + let (Some(before), Some(after)) = (before, after) else { + return Err("a read of it failed".to_string()); + }; + if before.provenance != after.provenance { + return Err(format!( + "its readings disagree about provenance ({} then {})", + before.provenance, after.provenance + )); + } + let Some(uj) = after.uj.checked_sub(before.uj) else { + return Err(format!("its cumulative reading went backwards ({} µJ, then {} µJ)", before.uj, after.uj)); + }; + if window < min_window { + return Err(format!( + "the work took {}, shorter than the {} window it can resolve", + span(window), + span(min_window) + )); + } + if uj == 0 { + return Err(format!("it registered no energy across {} of work", span(window))); + } + Ok((uj, after.provenance)) +} + +/// A duration as a reply states it: µs below a millisecond, else ms. +fn span(d: Duration) -> String { + if d < Duration::from_millis(1) { + format!("{} µs", d.as_micros()) + } else if d.subsec_nanos().is_multiple_of(1_000_000) { + format!("{} ms", d.as_millis()) + } else { + format!("{:.1} ms", d.as_secs_f64() * 1e3) + } +} + +/// The provenance `work`'s energy is sealed at: its meter's, as the ledger schema names +/// it. `None` for work with no figure, which has nothing to seal. +fn ledger_provenance(work: &Work) -> Option { + [Provenance::HwShunt, Provenance::ModelBased, Provenance::Estimator] + .into_iter() + .find(|p| p.to_string() == work.provenance) + .map(|p| match p { + Provenance::HwShunt => EnergyProvenance::HwShunt, + Provenance::ModelBased => EnergyProvenance::ModelBased, + Provenance::Estimator => EnergyProvenance::Estimator, + }) +} + +/// How `work`'s energy figure was obtained, as a reply states it: a reading was +/// "measured via" its meter, an estimate "estimated by" it. `None` for work with no +/// figure, which a reply never shows. +fn obtained(work: &Work) -> Option<&'static str> { + match ledger_provenance(work)? { + EnergyProvenance::Estimator => Some("estimated by"), + EnergyProvenance::HwShunt | EnergyProvenance::ModelBased => Some("measured via"), + } +} + +/// A reply's energy line for work the meter put no figure on, saying why. +fn unmetered_line(work: &Work) -> String { + let why = work.unmetered.as_deref().unwrap_or("it produced no figure"); + format!("not metered — `{}`: {why}; no energy figure was sealed", work.meter) +} + +/// The outcome of work that was not sealed because its meter put no figure on it. +const NOT_SEALED: &str = "not sealed (no energy figure)"; + fn model_label() -> String { std::env::var("JOULEDESK_MODEL") .unwrap_or_else(|_| DEFAULT_MODEL.into()) @@ -93,18 +197,19 @@ fn fallback_answer(question: &str) -> String { } /// The result of handling one mention: who to address, the reply body to post, and the -/// accountable facts (sealed decision + measured energy + verification). +/// accountable facts (sealed decision + metered energy + verification). pub struct Handled { pub who: String, pub body: String, pub outcome: String, + /// [`Work::measured_uj`]: `0` and meaningless when the work was not sealed. pub measured_uj: u64, pub verified: bool, } /// Handle one GitHub issue-comment event (`{ "comment": {…} }`): parse the mention, do -/// the work under the meter, seal it through the teammate loop at the **measured** cost, -/// and compose the reply body. `None` if the comment does not address `handle`. +/// the work under the meter, seal it through the teammate loop at the meter's figure and +/// provenance, and compose the reply body. `None` if the comment does not address `handle`. pub fn handle(event: &Value, handle: &str) -> Option { let mention = parse_event(Surface::GitHub, event)?; if !mention.text.contains(handle) { @@ -114,24 +219,21 @@ pub fn handle(event: &Value, handle: &str) -> Option { let question = if question.is_empty() { "Please help with this thread.".into() } else { question }; - // real work + real measured energy → seal at the measured cost → compose. + // real work + metered energy → seal at the meter's figure and provenance → compose. let work = do_work(&question); let (outcome_str, verified) = seal(&work, &question); let body = compose_github(&mention.who, &work, &outcome_str, verified); Some(Handled { who: mention.who, body, outcome: outcome_str, measured_uj: work.measured_uj, verified }) } -/// Seal the work through the teammate loop at the **measured** cost — shared by every -/// surface binding. Returns (outcome, ledger-verified). +/// Seal the work through the teammate loop at the meter's figure, labelled with the +/// meter's provenance — shared by every surface binding. Returns (outcome, +/// ledger-verified). Work its meter put no figure on is not sealed: there is no figure to +/// commit, and none is invented. pub fn seal(work: &Work, intent: &str) -> (String, bool) { - let mut t = Teammate::new(); - let cost = work.measured_uj; - let kind = "answer"; - let _ = t.grant_skill( - Skill::new("responder", &[kind], cost, move |_| SkillOutcome::done(cost)), - u64::MAX, - ); - let outcome = t.tag(kind, intent); + let Some((t, outcome)) = seal_work(work, intent) else { + return (NOT_SEALED.to_string(), false); + }; let verified = t.verify_ledger().is_ok(); let outcome_str = match &outcome { TagOutcome::Done => "completed".to_string(), @@ -141,6 +243,22 @@ pub fn seal(work: &Work, intent: &str) -> (String, bool) { (outcome_str, verified) } +/// The teammate that sealed `work`, and the tag's outcome: the work's figure committed as +/// the skill's cost, its `efficiency/1` sealed at the meter's provenance. `None` for work +/// its meter put no figure on. +fn seal_work(work: &Work, intent: &str) -> Option<(Teammate, TagOutcome)> { + let provenance = ledger_provenance(work)?; + let mut t = Teammate::new(); + let cost = work.measured_uj; + let kind = "answer"; + let _ = t.grant_skill( + Skill::new("responder", &[kind], cost, move |_| SkillOutcome::done(cost)), + u64::MAX, + ); + let outcome = t.tag_with_provenance(kind, intent, provenance); + Some((t, outcome)) +} + fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> String { let mj = work.measured_uj as f64 / 1000.0; let answer = &work.answer; @@ -149,11 +267,18 @@ fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> Stri let meter = &work.meter; let prov = &work.provenance; let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" }; + let (sealed, energy) = match obtained(work) { + Some(how) => ( + "sealed to a ledger anyone can verify", + format!("{mj:.1} mJ ({uj} µJ) — {how} `{meter}`, provenance `{prov}`"), + ), + None => ("nothing sealed", unmetered_line(work)), + }; format!( "🔷 **@joule** — {outcome}, @{who}.\n\n{answer}\n\n---\n\ - _Decided under a signed grant, worked on real hardware, sealed to a ledger anyone can verify._\n\ + _Decided under a signed grant, worked on real hardware, {sealed}._\n\ - **model:** `{model}`\n\ - - **energy:** {mj:.1} mJ ({uj} µJ) — measured via `{meter}`, provenance `{prov}`\n\ + - **energy:** {energy}\n\ - **decision:** {outcome} · ledger {verify}" ) } @@ -235,20 +360,197 @@ fn describe(e: ureq::Error) -> String { } #[cfg(test)] -mod tests { +pub(crate) mod tests { use super::*; + use jouleclaw_energy::{EnergyDomain, IoReportStatus}; + + fn reading(uj: u64, provenance: Provenance) -> EnergyReading { + EnergyReading { uj, timestamp_ns: 0, domain: EnergyDomain::SocTotal, provenance } + } + + const SECOND: Duration = Duration::from_secs(1); + /// The Apple estimator's declared minimum window. + const ESTIMATOR_WINDOW: Duration = Duration::from_millis(100); + + /// Work as `do_work` would produce it under a meter named `meter`. + pub(crate) fn work(uj: u64, provenance: &str, meter: &str) -> Work { + Work { + answer: "an answer".into(), + measured_uj: uj, + provenance: provenance.into(), + meter: meter.into(), + model: "a-model".into(), + unmetered: (provenance == UNMETERED) + .then(|| "the work took 200 µs, shorter than the 100 ms window it can resolve".into()), + } + } + + /// The provenance label and µJ sealed into `work`'s one `efficiency/1` receipt. + fn sealed(work: &Work) -> (String, u64) { + let (t, outcome) = seal_work(work, "a question").expect("work with a figure is sealed"); + assert_eq!(outcome, TagOutcome::Done); + let recs = jcp_efficiency::verify_chain(t.efficiency_ledger(), t.steward(), &t.steward_pubkey()) + .expect("the energy ledger verifies"); + assert_eq!(recs.len(), 1); + (recs[0].provenance.clone(), recs[0].energy_uj) + } #[test] - fn handles_a_mention_and_seals_measured_energy() { + fn a_figure_is_the_delta_as_read_never_floored() { + // Both Apple meters resolve 1000 µJ; a 250 µJ delta over a window the meter can + // resolve stays 250 — it is not raised to the resolution. + let est = Provenance::Estimator; + let fig = |b, a, p| window_energy(Some(&reading(b, p)), Some(&reading(a, p)), SECOND, ESTIMATOR_WINDOW); + assert_eq!(fig(5_000, 5_250, est), Ok((250, est))); + let mb = Provenance::ModelBased; + assert_eq!(fig(7, 80_007, mb), Ok((80_000, mb))); + } + + #[test] + fn a_window_without_two_good_readings_has_no_figure() { + let est = Provenance::Estimator; + let fig = |b: Option, a: Option| { + window_energy(b.as_ref(), a.as_ref(), SECOND, ESTIMATOR_WINDOW) + }; + let failed = Err("a read of it failed".to_string()); + assert_eq!(fig(None, Some(reading(5_000, est))), failed, "the first read failed"); + assert_eq!(fig(Some(reading(5_000, est)), None), failed, "the second read failed"); + assert_eq!(fig(None, None), failed); + let backwards = fig(Some(reading(5_000, est)), Some(reading(4_000, est))).unwrap_err(); + assert!(backwards.contains("went backwards"), "{backwards}"); + let disagree = fig(Some(reading(5_000, Provenance::ModelBased)), Some(reading(9_000, est))).unwrap_err(); + assert!(disagree.contains("disagree about provenance"), "{disagree}"); + } + + /// A window shorter than the meter can resolve has no figure, whatever the delta; + /// a meter that registered nothing has none either. Neither becomes a zero. + #[test] + fn a_window_the_meter_cannot_resolve_is_never_a_zero() { + let est = Provenance::Estimator; + let short = Duration::from_micros(200); + let why = window_energy(Some(&reading(5_000, est)), Some(&reading(5_000, est)), short, ESTIMATOR_WINDOW) + .unwrap_err(); + assert_eq!(why, "the work took 200 µs, shorter than the 100 ms window it can resolve"); + // A step landing in a window too short to resolve is not a figure for the work. + let why = window_energy(Some(&reading(5_000, est)), Some(&reading(20_000, est)), short, ESTIMATOR_WINDOW) + .unwrap_err(); + assert!(why.contains("shorter than the 100 ms window"), "{why}"); + let mb = Provenance::ModelBased; + let why = window_energy(Some(&reading(9, mb)), Some(&reading(9, mb)), SECOND, Duration::from_millis(10)) + .unwrap_err(); + assert_eq!(why, "it registered no energy across 1000 ms of work"); + assert_eq!(span(Duration::from_micros(1_500)), "1.5 ms"); + } + + #[test] + fn an_estimate_is_sealed_and_posted_as_an_estimate() { + let w = work(12_345, &Provenance::Estimator.to_string(), "apple-silicon"); + assert_eq!(sealed(&w), ("Estimator".to_string(), 12_345)); + let body = compose_github("dana", &w, "completed", true); + assert!( + body.contains("12.3 mJ (12345 µJ) — estimated by `apple-silicon`, provenance `estimator`"), + "{body}" + ); + assert!(!body.contains("measured"), "an estimate is never called a measurement: {body}"); + } + + #[test] + fn a_reading_is_sealed_at_its_own_provenance() { + let w = work(80_000, &Provenance::ModelBased.to_string(), "apple-ioreport"); + assert_eq!(sealed(&w), ("ModelBased".to_string(), 80_000)); + let body = compose_github("dana", &w, "completed", true); + assert!(body.contains("measured via `apple-ioreport`, provenance `model_based`"), "{body}"); + let w = work(80_000, &Provenance::HwShunt.to_string(), "rapl"); + assert_eq!(sealed(&w), ("HwShunt".to_string(), 80_000)); + } + + #[test] + fn work_with_no_figure_is_neither_sealed_nor_given_one() { + let w = work(0, UNMETERED, "apple-silicon"); + assert!(seal_work(&w, "a question").is_none(), "no figure, so nothing is committed"); + assert_eq!(seal(&w, "a question"), (NOT_SEALED.to_string(), false)); + let body = compose_github("dana", &w, NOT_SEALED, false); + assert!( + body.contains( + "not metered — `apple-silicon`: the work took 200 µs, shorter than the 100 ms window it \ + can resolve; no energy figure was sealed" + ), + "{body}" + ); + assert!(body.contains("nothing sealed") && !body.contains("mJ"), "{body}"); + } + + /// End to end on this host, through `handle`: the reply names the meter that + /// actually ran. With no model configured the work is a deterministic extract that + /// takes well under a millisecond, shorter than any meter here can resolve (the + /// estimator's 100 ms, IOReport's 10 ms), so the reply says it was not metered and + /// why, and nothing is sealed — where it used to seal the meter's resolution, + /// 1000 µJ, as a measurement. The meter is IOReport only where its CPU rail is live. + #[test] + fn a_mention_answered_too_fast_to_meter_is_posted_as_unmetered() { std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force the fallback (no subprocess) let event = json!({ "comment": { "user": { "login": "dana" }, "body": "@joule summarize the thread" } }); let h = handle(&event, "@joule").expect("addresses @joule"); - assert_eq!(h.who, "dana"); - assert!(h.verified, "the sealed ledger verifies"); - assert!(h.measured_uj >= 1000, "measured energy floored to the meter resolution"); - assert!(h.body.contains("@dana") && h.body.contains("measured via")); std::env::remove_var("JOULEDESK_MODEL"); + let status = jouleclaw_energy::ioreport_status(); + let energy = h.body.lines().find(|l| l.contains("**energy:**")).unwrap_or_default(); + println!("jouledesk-github on this host: ioreport={status:?} {energy} · outcome={}", h.outcome); + assert_eq!(h.who, "dana"); + assert_eq!((h.outcome.as_str(), h.verified, h.measured_uj), (NOT_SEALED, false, 0)); + assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}"); + assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body); + let ioreport = energy.contains("`apple-ioreport`"); + assert_eq!(ioreport, status == IoReportStatus::Live, "IOReport runs exactly when its CPU rail is live"); + } + + /// End to end on this host with the real meter, for work long enough to meter: the + /// meter's own figure, sealed and posted at the meter's own provenance. IOReport + /// (`model_based`) only where its CPU rail is live; elsewhere — macOS 27.0.1 on an + /// M5 Max and an M3 Ultra — an estimator, sealed `Estimator` and posted "estimated". + #[test] + fn on_this_host_work_the_meter_can_resolve_is_sealed_at_its_provenance() { + let status = jouleclaw_energy::ioreport_status(); + let meter = jouleclaw_energy::meter::detect_meter(); + let name = meter.active_name(); + let min_window = Duration::from_nanos(meter.active().expect("detect_meter has a meter").min_window_ns()); + let ((), figure) = metered(&meter, || { + let t = Instant::now(); + let mut x = 1u64; + while t.elapsed() < min_window + Duration::from_millis(50) { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + } + std::hint::black_box(x); + }); + let (uj, provenance) = figure.unwrap_or_else(|why| panic!("`{name}` put no figure on resolvable work: {why}")); + let w = Work { + answer: "an answer".into(), + measured_uj: uj, + provenance: provenance.to_string(), + meter: name.to_string(), + model: "a-model".into(), + unmetered: None, + }; + let (label, sealed_uj) = sealed(&w); + let body = compose_github("dana", &w, "completed", true); + let energy = body.lines().find(|l| l.contains("**energy:**")).unwrap_or_default(); + println!("jouledesk-github on this host: ioreport={status:?} sealed {label} {sealed_uj} µJ; {energy}"); + assert!(uj > 0 && sealed_uj == uj, "{uj} µJ read, {sealed_uj} µJ sealed"); + assert_eq!(name == "apple-ioreport", status == IoReportStatus::Live, "IOReport runs exactly when live"); + match provenance { + Provenance::Estimator => { + assert_eq!(label, "Estimator"); + assert!(energy.contains(&format!("estimated by `{name}`, provenance `estimator`")), "{energy}"); + assert!(!body.contains("measured"), "{body}"); + } + Provenance::ModelBased | Provenance::HwShunt => { + assert_ne!(label, "Estimator"); + assert!(energy.contains(&format!("measured via `{name}`")), "{energy}"); + } + } + if matches!(name, "apple-silicon" | "estimation") { + assert_eq!(provenance, Provenance::Estimator, "an estimator's figure is an estimate"); + } } #[test] diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/main.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/main.rs index fc6d91ce9..e37e79d8b 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/main.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/main.rs @@ -7,7 +7,7 @@ //! Env: `GITHUB_TOKEN` (for --reply / --serve), `JOULEDESK_MODEL` (a local GGUF), //! `JOULEDESK_HANDLE` (default `@joule`). -use jouledesk_github::{handle, GitHub}; +use jouledesk_github::{handle, GitHub, Handled}; use serde_json::{json, Value}; fn arg(a: &[String], k: &str) -> Option { @@ -17,6 +17,16 @@ fn has(a: &[String], k: &str) -> bool { a.iter().any(|x| x == k) } +/// A handled mention's energy for a log line: the sealed figure, or that there is none. +/// The reply body says whether the figure was measured or estimated, and by which meter. +fn energy(h: &Handled) -> String { + if h.verified { + format!("{} µJ", h.measured_uj) + } else { + format!("no sealed figure ({})", h.outcome) + } +} + fn main() { let a: Vec = std::env::args().skip(1).collect(); let handle_tok = arg(&a, "--handle") @@ -29,7 +39,7 @@ fn main() { let event = json!({ "comment": { "user": { "login": "you" }, "body": format!("{handle_tok} {q}") } }); match handle(&event, &handle_tok) { Some(h) => { - eprintln!("[measured {} µJ · verified={}]", h.measured_uj, h.verified); + eprintln!("[energy {} · verified={}]", energy(&h), h.verified); println!("{}", h.body); } None => eprintln!("no mention"), @@ -44,7 +54,7 @@ fn main() { "text": format!("<@UBOT> {q}") } }); match jouledesk_github::slack::handle(&event) { Some(h) => { - eprintln!("[measured {} µJ · verified={}]", h.measured_uj, h.verified); + eprintln!("[energy {} · verified={}]", energy(&h), h.verified); println!("{}", h.body); } None => eprintln!("no mention"), @@ -64,7 +74,7 @@ fn main() { let event = json!({ "event": { "user": "someone", "channel": channel, "text": format!("<@UBOT> {q}") } }); let h = jouledesk_github::slack::handle(&event).expect("mention"); match jouledesk_github::slack::Slack::new(token).post_message(&channel, &h.body) { - Ok(ts) => println!("posted to {channel} ts={ts} (measured {} µJ)", h.measured_uj), + Ok(ts) => println!("posted to {channel} ts={ts} (energy {})", energy(&h)), Err(e) => { eprintln!("slack post failed: {e}"); std::process::exit(1); @@ -95,7 +105,7 @@ fn main() { match gh.latest_mention(&repo, issue, &handle_tok, &bot) { Ok(Some(event)) => { let h = handle(&event, &handle_tok).expect("mention parses"); - eprintln!("→ {} (measured {} µJ, verified={})", h.outcome, h.measured_uj, h.verified); + eprintln!("→ {} (energy {}, verified={})", h.outcome, energy(&h), h.verified); match gh.post_comment(&repo, issue, &h.body) { Ok(id) => println!("posted reply comment id={id} to {repo}#{issue}"), Err(e) => { @@ -137,7 +147,7 @@ fn main() { if let Some(h) = handle(&event, &handle_tok) { if !repo.is_empty() && issue != 0 { match gh.post_comment(&repo, issue, &h.body) { - Ok(id) => eprintln!("auto-replied to {repo}#{issue} (comment {id}, {} µJ)", h.measured_uj), + Ok(id) => eprintln!("auto-replied to {repo}#{issue} (comment {id}, energy {})", energy(&h)), Err(e) => eprintln!("post failed: {e}"), } } diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs index 4b813e438..d4811e681 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs @@ -1,9 +1,9 @@ //! The Slack binding — the second surface, on the same core. //! //! An `app_mention` event comes in; `@joule` does the work with the same real model -//! under the same real energy meter, seals the same verifiable `efficiency/1` at the -//! measured cost, and posts the reply with `chat.postMessage`. Only the wire shape and -//! the delivery client differ from GitHub — the accountable core is identical. +//! under the same energy meter, seals the same verifiable `efficiency/1` at that meter's +//! figure and provenance, and posts the reply with `chat.postMessage`. Only the wire +//! shape and the delivery client differ from GitHub — the accountable core is identical. //! //! Live delivery needs a Slack **bot token** (`SLACK_BOT_TOKEN`) + a workspace app //! subscribed to `app_mention`; `--slack-dry-run` proves the payload here without one. @@ -48,7 +48,7 @@ fn strip_mentions(text: &str) -> String { } /// The reply in Slack `mrkdwn` (single `*` for bold, `<@id>` mentions) — same facts as -/// the GitHub reply: real answer + the sealed, measured attestation. +/// the GitHub reply: real answer + the sealed, metered attestation, at its provenance. fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> String { let mj = work.measured_uj as f64 / 1000.0; let answer = &work.answer; @@ -56,11 +56,18 @@ fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> Stri let meter = &work.meter; let prov = &work.provenance; let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" }; + let (sealed, energy) = match super::obtained(work) { + Some(how) => ( + "sealed to a verifiable ledger", + format!("{mj:.1} mJ {how} `{meter}` (provenance `{prov}`)"), + ), + None => ("nothing sealed", super::unmetered_line(work)), + }; format!( "🔷 *@joule* — {outcome}, <@{who}>.\n\n{answer}\n\n\ - _Signed grant · metered on real hardware · sealed to a verifiable ledger_\n\ + _Signed grant · worked on real hardware · {sealed}_\n\ • *model:* `{model}`\n\ - • *energy:* {mj:.1} mJ measured via `{meter}` (provenance `{prov}`)\n\ + • *energy:* {energy}\n\ • *decision:* {outcome} · ledger {verify}" ) } @@ -95,19 +102,39 @@ impl Slack { mod tests { use super::*; + /// The same end-to-end path as the GitHub binding's: the deterministic fallback is + /// too fast for any meter here to resolve, so the Slack reply says it was not + /// metered, and why, and seals nothing — never a figure, never "measured". #[test] - fn slack_app_mention_seals_and_composes_mrkdwn() { + fn slack_app_mention_composes_mrkdwn_and_never_claims_a_figure_it_lacks() { std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force fallback (no subprocess) let event = json!({ "event": { "type": "app_mention", "user": "U123", "channel": "C1", "text": "<@UBOT> summarize the thread" } }); let h = handle(&event).expect("addresses the app"); - assert_eq!(h.who, "U123"); - assert!(h.verified); - assert!(h.measured_uj >= 1000); - assert!(h.body.contains("<@U123>") && h.body.contains("measured via")); std::env::remove_var("JOULEDESK_MODEL"); + assert_eq!(h.who, "U123"); + assert!(h.body.contains("<@U123>"), "{}", h.body); + let energy = h.body.lines().find(|l| l.contains("• *energy:* ")).expect("an energy line"); + assert_eq!((h.outcome.as_str(), h.verified), (super::super::NOT_SEALED, false), "{energy}"); + assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}"); + assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body); + } + + #[test] + fn slack_posts_an_estimate_as_an_estimate() { + use jouleclaw_energy::Provenance; + let w = crate::tests::work(12_345, &Provenance::Estimator.to_string(), "apple-silicon"); + let body = compose("U123", &w, "completed", true); + assert!(body.contains("12.3 mJ estimated by `apple-silicon` (provenance `estimator`)"), "{body}"); + assert!(!body.contains("measured") && !body.contains("metered on"), "{body}"); + let w = crate::tests::work(80_000, &Provenance::ModelBased.to_string(), "apple-ioreport"); + let body = compose("U123", &w, "completed", true); + assert!(body.contains("80.0 mJ measured via `apple-ioreport` (provenance `model_based`)"), "{body}"); + let w = crate::tests::work(0, super::super::UNMETERED, "apple-ioreport"); + let body = compose("U123", &w, super::super::NOT_SEALED, false); + assert!(body.contains("not metered") && body.contains("nothing sealed") && !body.contains("mJ"), "{body}"); } #[test] diff --git a/jouledesk/jouledesk-rs/crates/jouledesk/src/lib.rs b/jouledesk/jouledesk-rs/crates/jouledesk/src/lib.rs index f7c88f26f..585cc11f6 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk/src/lib.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk/src/lib.rs @@ -20,6 +20,10 @@ use jcp_schema::{Did, Energy}; // jcp-orchestrate directly. pub use jcp_orchestrate::{HarnessOutcome as SkillOutcome, Task as SkillTask}; +// Re-exported so a binding can say how a tag's energy was obtained +// ([`Teammate::tag_with_provenance`]) without depending on jcp-schema directly. +pub use jcp_schema::Provenance as EnergyProvenance; + /// Host-surface bindings (§8): Slack, GitHub, GitLab, Discord, Matrix, and a neutral /// shape — parse an inbound event → tag → render the reply. pub mod surface; @@ -151,7 +155,25 @@ impl Teammate { /// A member tags `@joule` to delegate `intent` of a given `kind` (SPEC §4). The /// teammate authorises it against its grants **outside the model**, runs it, and /// seals the decision. Returns the outcome. + /// + /// The task's `efficiency/1` receipt carries its energy as a hardware reading + /// ([`EnergyProvenance::HwShunt`]). A binding whose figure came from anything + /// else — an energy model, an estimator — uses [`Self::tag_with_provenance`]. pub fn tag(&mut self, kind: &str, intent: &str) -> TagOutcome { + self.tag_with_provenance(kind, intent, EnergyProvenance::HwShunt) + } + + /// [`Self::tag`], sealing the task's `efficiency/1` energy at `provenance`: how + /// the skill's committed cost was actually obtained. A binding that meters the + /// work passes its meter's provenance, so a figure from an energy model is + /// sealed as modeled and an estimate as an estimate, never as a hardware + /// reading (SPEC §6: energy "at honest provenance"). + pub fn tag_with_provenance( + &mut self, + kind: &str, + intent: &str, + provenance: EnergyProvenance, + ) -> TagOutcome { self.n += 1; let id = format!("tag-{}", self.n); self.orch.submit(Task::new(id.clone(), kind, intent.as_bytes().to_vec())); @@ -175,13 +197,18 @@ impl Teammate { .map(|c| c.cost_uj) .unwrap_or(0); let completed = matches!(outcome, TagOutcome::Done); + let energy = match provenance { + EnergyProvenance::HwShunt => Energy::measured(cost), + EnergyProvenance::ModelBased => Energy::modeled(cost), + EnergyProvenance::Estimator => Energy::estimator(cost), + }; self.eff.append(&Efficiency { task: id, completed, verdict: if completed { "done".into() } else { "failed".into() }, tokens_in: 0, tokens_out: 0, - energy: Energy::measured(cost), + energy, wall_ns: 0, value_uusd: None, energy_attestation: None, @@ -357,6 +384,35 @@ mod tests { assert_eq!(t.verify_ledger(), Ok(1), "only the real mention dispatched"); } + /// The provenance label sealed into the one `efficiency/1` receipt of a + /// teammate that ran one tag, read back through the verified chain. + fn sealed_provenance(t: &Teammate) -> String { + let recs = jcp_efficiency::verify_chain(t.efficiency_ledger(), t.steward(), &t.steward_pubkey()) + .expect("the energy ledger verifies under the steward key"); + assert_eq!(recs.len(), 1, "one efficiency/1 receipt per task that ran"); + assert_eq!(recs[0].energy_uj, 100_000, "the committed cost, as sealed"); + recs[0].provenance.clone() + } + + #[test] + fn a_tags_energy_is_sealed_at_the_provenance_it_was_obtained_at() { + for (provenance, label) in [ + (EnergyProvenance::Estimator, "Estimator"), + (EnergyProvenance::ModelBased, "ModelBased"), + (EnergyProvenance::HwShunt, "HwShunt"), + ] { + let mut t = Teammate::new(); + t.grant_skill(summarizer(), 1_000_000).unwrap(); + assert_eq!(t.tag_with_provenance("summarize", "the Q3 thread", provenance), TagOutcome::Done); + assert_eq!(sealed_provenance(&t), label, "an energy figure obtained as {provenance:?}"); + } + // Plain `tag` is unchanged: it seals the committed cost as a hardware reading. + let mut t = Teammate::new(); + t.grant_skill(summarizer(), 1_000_000).unwrap(); + assert_eq!(t.tag("summarize", "the Q3 thread"), TagOutcome::Done); + assert_eq!(sealed_provenance(&t), "HwShunt"); + } + #[test] fn efficiency_accounting_rolls_up_completed_and_wasted() { let mut t = Teammate::new(); -- 2.54.0 (Apple Git-157) From c0813bcfb72fabeb1db93f6b18fda7c90619f8a9 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:48:37 -0400 Subject: [PATCH 04/23] jouleclaw-loader-gguf: a meter that did not measure records nothing and writes nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family contract: measured when the platform exposes it, estimated when it doesn't, and the provenance always says which. This crate's energy paths price work as marginal draw, `ΔE_busy − P_idle·Δt`, on the IOReport counter, and three of them turned a counter that measured nothing into a figure that is then treated as measured. The defect (b9d7e945, unchanged since origin/main 54e89b56): - fabric_dispatch::tune_shape_live read the counter with `.unwrap_or(0)` / `.unwrap_or(e0)`, clamped the marginal with `.max(0.0)` and truncated the per-op figure to u64. On a CPU rail that does not advance (macOS 27.0.1, both our Macs) every backend came out at 0 µJ per op, joules_minimal picked one, and TuningProfile::record kept it: a recorded 0 beats every later measurement, so the shape was pinned to that backend for the process. - examples/fabric_calibrate wrote ~/.jouleclaw/fabric-calibration.json, which jouleclaw-fabric loads as Measured placement costs, with idle `(e1 - e0) / t` (0 on a frozen rail) and each member's marginal clamped to `.max(0.0)`: zeros written as measurements. - examples/mpp_energy overlaid its matmul2d figure into the same file with an unclamped marginal (negative on a frozen rail), printed a CPU/GPU ratio of 0/0 = NaN, and ignored whether its CPU matmul ran at all. b9d7e945 now declines the counter on those hosts, so today these paths stop at `is_available()`. That covers today's hosts. A counter that is available but does not see a window would still have written zeros. The fix. - New module marginal_energy, pure arithmetic over raw readings, which the three paths share: idle_draw_uw refuses an idle window that did not advance (an SoC draws power doing nothing, so a counter that holds is not metering), marginal_uj refuses a busy window at or below idle and is never clamped to zero, per_op_uj refuses a per-op cost that would round to 0, energy_ratio is None unless both figures are positive and finite, and refusal() renders what an example prints before it exits non-zero. - tune_shape_live: a failed read, an idle baseline that did not advance, or either backend's window drawing nothing above idle voids the tune (None, nothing recorded), through the new tune_measured: an unmeasured backend neither wins nor loses against a measured one. - TuningProfile::record (and so record_global and tune_and_record) never records a zero-joule cost; tune_and_record returns None for one. - fabric_calibrate and mpp_energy refuse to write, exit 1, and print the file, the reason and jouleclaw_energy::ioreport_status() when the counter is unavailable, the idle baseline did not advance, or any marginal is <= 0. They refuse before any write, so no partial file. mpp_energy's ratio is n/a unless both runs were measured, and its CPU run now asserts the matmul ran, as its GPU run already did (a refused shape would meter an empty loop, and that run is one of the refusal gates). - energy_autotune (example) says why a shape was not tuned, with the status. Observed, M5 Max and M3 Ultra (macOS 27.0.1), examples run with HOME set to a fresh directory and JOULECLAW_FABRIC_CALIBRATION unset: refusing to write /.jouleclaw/fabric-calibration.json: the energy counter is not available on this host (apple-ioreport declined) IOReport status: CpuRailFrozen; nothing was written (its figures are loaded as measurements) exit 1 for fabric_calibrate and mpp_energy on both, no file under the temp HOME. The real ~/.jouleclaw/fabric-calibration.json kept its 2026-06-11 11:02:14 mtime and SHA-256 (792b00e6...) on the M5 Max, and its mtime on the M3 Ultra. energy_autotune: "not tuned: the meter produced no figure (IOReport CpuRailFrozen)", global profile empty. Tests. Default features, what CI's jouleclaw-rs job runs: 294 lib tests pass (the marginal_energy and energy_autotune tests below are not feature-gated). With `--features "rayon fabric-meter"`: 302 pass on the M5 Max and, from the same binary, on the M3 Ultra. - a_counter_that_holds_at_idle_has_no_idle_baseline, a_busy_window_at_or_below_ idle_is_refused_never_clamped_to_zero, a_per_op_cost_never_rounds_to_zero, the_cpu_gpu_ratio_is_undefined_unless_both_are_measured, a_refusal_names_the_file_the_reason_and_the_ioreport_status, a_refusal_says_why. - a_zero_joule_cost_is_never_recorded (incl. through record_global), tune_and_record_records_nothing_when_the_winner_drew_zero_joules, an_unmeasured_candidate_voids_the_tune. - a_live_tune_records_only_what_the_meter_measured (fabric-meter, on this host): calls tune_shape_live itself and asserts it records exactly what it returns, never a zero, and None where ioreport_status() is not Live. Both machines: "ioreport=CpuRailFrozen tuned=None recorded=None". Falsified, each mutation applied to this commit and restored with git checkout: - FL1, record accepts a zero: "the meter did not see the work: nothing to record". - FL2, a held idle counter as a 0 W baseline: "left: Ok(0.0) right: Err(IdleDidNotAdvance { secs: 3.0 })". - FL3, the marginal clamped to zero again: "left: Ok(0.0) right: Err(NoMarginal { marginal_uj: 0.0 })". - FL4, the raw ratio: "left: Some(NaN) right: None". - FL5, an unmeasured backend taken as 0 µJ: "left: Some(Cost { backend: Cpu, uj: 0, ns: 0 }) right: None". - FL6, tune_shape_live returning and recording a zero cost when the counter is declined: the host test fails, "a live tune records exactly what it returns", tuned=Some(Cost { uj: 0 .. }) recorded=None (the record gate dropped it; the test caught the returned zero). - FL7, the refusal without the IOReport status, and FL8, a per-op cost rounding to 0 accepted: their tests fail. clippy --no-deps --tests on the crate (with and without fabric-meter, plus the three energy examples): no warning in the lines this commit adds. The crate does not pass clippy on its own: two deny-level lints elsewhere (clippy::erasing_op at gemma4.rs:562 and :582, clippy::approx_constant in a lib test) stop it before the tests and examples, so they were checked with those two allowed. The pre-existing warnings in the touched files (mpp_energy's double_parens, fabric_calibrate's chunks_exact_mut, fabric_dispatch's too_many_arguments) are unchanged, and mpp_energy's unused-Result warning is gone. `cargo check --tests --examples` for aarch64-unknown-linux-gnu (CI's jouleclaw-rs target, with +fp16): clean. Not done here, deliberately: - jouleclaw-fabric loads every calibration row as Measured, and the file carries no date, OS or provenance. A file written on a live counter is still loaded at that grade on a host where the counter is now declined. - TuningProfile and the autotune/1 receipt carry no provenance field, so a sealed tuning profile cannot say how its joules were obtained. Both are wire or format changes. Co-Authored-By: Claude Opus 5.5 --- .../examples/energy_autotune.rs | 14 +- .../examples/fabric_calibrate.rs | 49 ++- .../examples/mpp_energy.rs | 71 +++-- .../src/energy_autotune.rs | 94 +++++- .../src/fabric_dispatch.rs | 63 +++- .../crates/jouleclaw-loader-gguf/src/lib.rs | 1 + .../src/marginal_energy.rs | 278 ++++++++++++++++++ 7 files changed, 522 insertions(+), 48 deletions(-) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/energy_autotune.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/energy_autotune.rs index d9e1b363d..43c4f8ff0 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/energy_autotune.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/energy_autotune.rs @@ -9,7 +9,9 @@ //! global profile — executes regardless of box load; only the *accuracy* of the //! µJ figures needs a quiet machine (the marginal-energy subtraction assumes a //! stable idle draw). A backend the platform can't run for a shape reports -//! `u64::MAX` and simply never wins. +//! `u64::MAX` and simply never wins. A shape the meter cannot measure — the IOReport +//! counter unavailable (its CPU energy rail not advancing, among other reasons), or a +//! window that draws nothing above idle — is left untuned: nothing is recorded. use jouleclaw_loader_gguf::energy_autotune; use jouleclaw_loader_gguf::fabric_dispatch; @@ -39,14 +41,20 @@ fn main() { cost.uj, cost.ns ), - None => println!(" m={m:<4} n={n} k={k} → meter unavailable (need IOReport)"), + None => println!( + " m={m:<4} n={n} k={k} → not tuned: the meter produced no figure (IOReport {:?})", + jouleclaw_energy::ioreport_status() + ), } } println!("\nglobal tuning profile (the dispatcher's lookup):"); let snapshot = energy_autotune::global_snapshot(); if snapshot.is_empty() { - println!(" (empty — no shape was tuned; is the IOReport meter present?)"); + println!( + " (empty — no shape was measured; IOReport {:?})", + jouleclaw_energy::ioreport_status() + ); } for (key, cost) in snapshot { println!( diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs index 24955ba71..15d7bde0f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/fabric_calibrate.rs @@ -15,14 +15,36 @@ //! dataset records. Run this on a quiet machine or the //! numbers are someone else's workload. //! +//! It writes nothing, and exits non-zero saying why, unless +//! every figure is a measurement: the IOReport counter must +//! be available (on Apple Silicon, its CPU energy rail must +//! advance — `jouleclaw_energy::ioreport_status`), the idle +//! baseline must advance, and every member's marginal +//! energy must be positive (`marginal_energy`). A zero, +//! clamped or not, would be loaded as a measured placement +//! cost. +//! //! ```bash //! cargo run --release --features "rayon fabric-meter" \ //! --example fabric_calibrate //! ``` +use jouleclaw_energy::apple_ioreport::AppleIoReportCounter; use jouleclaw_energy::EnergyCounter; +use jouleclaw_loader_gguf::marginal_energy::{idle_draw_uw, marginal_uj, refusal, NotMeasured}; use std::time::Instant; +/// Write nothing: say why, with the IOReport status, on stderr and exit non-zero. +fn refuse(path: &str, why: impl std::fmt::Display) -> ! { + eprintln!("{}", refusal(path, why, jouleclaw_energy::ioreport_status())); + std::process::exit(1); +} + +/// One cumulative reading, µJ. +fn read_uj(counter: &AppleIoReportCounter) -> Result { + counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string())) +} + struct Shape { label: &'static str, m: usize, @@ -38,19 +60,22 @@ fn main() { Shape { label: "prefill-batch (m=16)", m: 16, n: 4096, k: 2304 }, ]; - let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); + let home = std::env::var("HOME").unwrap_or_default(); + let dir = format!("{home}/.jouleclaw"); + let path = format!("{dir}/fabric-calibration.json"); + + let counter = AppleIoReportCounter::new(); if !counter.is_available() { - eprintln!("IOReport unavailable on this host — nothing honest to write."); - std::process::exit(1); + refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into())); } // Idle baseline: 3 s of nothing. let idle_uw = { - let e0 = counter.read().expect("read").uj; + let e0 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e)); let t0 = Instant::now(); std::thread::sleep(std::time::Duration::from_secs(3)); - let e1 = counter.read().expect("read").uj; - (e1 - e0) as f64 / t0.elapsed().as_secs_f64() + let e1 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e)); + idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).unwrap_or_else(|e| refuse(&path, e)) }; println!("idle draw: {:.2} W (machine-wide SoC)", idle_uw / 1e6); @@ -108,16 +133,17 @@ fn main() { run(&mut out); // Measure ≥ 2 s of work. let mut iters = 0u64; - let e0 = counter.read().expect("read").uj; + let e0 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e)); let t0 = Instant::now(); while t0.elapsed().as_secs_f64() < 2.0 { run(&mut out); iters += 1; } let dt = t0.elapsed().as_secs_f64(); - let e1 = counter.read().expect("read").uj; - let marginal_uj = ((e1 - e0) as f64 - idle_uw * dt).max(0.0); - let uj_per_op = marginal_uj / iters as f64; + let e1 = read_uj(&counter).unwrap_or_else(|e| refuse(&path, e)); + let marginal = marginal_uj(e0, e1, dt, idle_uw) + .unwrap_or_else(|e| refuse(&path, format!("{member} on {}: {e}", shape.label))); + let uj_per_op = marginal / iters as f64; let uj_per_gflop = uj_per_op / gflop; println!( "{member:>18}: {iters:>5} ops in {dt:.2}s → {:.0} µJ/op, {:.0} µJ/GFLOP ({:.1} ms/op)", @@ -145,10 +171,7 @@ fn main() { latest.insert(e.member_id.clone(), e); } let out_entries: Vec<_> = latest.into_values().collect(); - let home = std::env::var("HOME").unwrap_or_default(); - let dir = format!("{home}/.jouleclaw"); std::fs::create_dir_all(&dir).expect("mkdir"); - let path = format!("{dir}/fabric-calibration.json"); std::fs::write(&path, serde_json::to_vec_pretty(&out_entries).unwrap()).expect("write"); println!("\nwrote {path} — Fabric::discover() now places on measured figures."); } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs index 197a37406..5378484a9 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/examples/mpp_energy.rs @@ -2,22 +2,39 @@ //! threaded-NEON CPU path, via the IOReport SoC energy counter. //! Run on a QUIET box: marginal energy = ΔE_busy − P_idle·Δt. //! `cargo run --release --features "rayon fabric fabric-meter" --example mpp_energy` +//! +//! It writes nothing, and exits non-zero saying why, unless both figures are +//! measurements: the IOReport counter available (its CPU energy rail live — +//! `jouleclaw_energy::ioreport_status`), an idle baseline that advances, and a +//! positive marginal for the GPU **and** the CPU run. A CPU run that draws nothing +//! above idle means the meter cannot see CPU work, so its SoC figure for the GPU run +//! is not a whole-SoC figure either. #[cfg(all(feature = "fabric", feature = "fabric-meter"))] fn main() { use jouleclaw_energy::EnergyCounter; + use jouleclaw_loader_gguf::marginal_energy::{energy_ratio, idle_draw_uw, marginal_uj, refusal, NotMeasured}; use std::time::{Duration, Instant}; fn f32_to_bf16(v: f32) -> u16 { let b = v.to_bits(); (((b + (((b >> 16) & 1) + 0x7fff)) >> 16)) as u16 } + /// Write nothing: say why, with the IOReport status, on stderr and exit non-zero. + fn refuse(path: &str, why: impl std::fmt::Display) -> ! { + eprintln!("{}", refusal(path, why, jouleclaw_energy::ioreport_status())); + std::process::exit(1); + } + let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| { + format!("{}/.jouleclaw/fabric-calibration.json", std::env::var("HOME").unwrap_or_default()) + }); let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); if !counter.is_available() { - eprintln!("IOReport unavailable"); std::process::exit(1); + refuse(&path, NotMeasured::CounterUnavailable("apple-ioreport declined".into())); } + let read_uj = || counter.read().map(|r| r.uj).map_err(|e| NotMeasured::ReadFailed(e.to_string())); let idle_uw = { - let e0 = counter.read().unwrap().uj; + let e0 = read_uj().unwrap_or_else(|e| refuse(&path, e)); let t0 = Instant::now(); std::thread::sleep(Duration::from_secs(3)); - let e1 = counter.read().unwrap().uj; - (e1 - e0) as f64 / t0.elapsed().as_secs_f64() + let e1 = read_uj().unwrap_or_else(|e| refuse(&path, e)); + idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).unwrap_or_else(|e| refuse(&path, e)) }; println!("idle draw: {:.2} W (machine-wide SoC)", idle_uw / 1e6); @@ -29,10 +46,11 @@ fn main() { .collect(); let gflop = 2.0 * m as f64 * n as f64 * k as f64 / 1e9; - // run a workload for ~`secs` and return (µJ/GFLOP, GFLOP/s). - let measure = |secs: f64, mut run: Box| -> (f64, f64) { + // run a workload for ~`secs` and return (µJ/GFLOP, GFLOP/s), or why the meter + // produced no figure for it. + let measure = |secs: f64, mut run: Box| -> Result<(f64, f64), NotMeasured> { run(); // warm - let e0 = counter.read().unwrap().uj; + let e0 = read_uj()?; let t0 = Instant::now(); let mut ops = 0u64; while t0.elapsed().as_secs_f64() < secs { @@ -40,16 +58,16 @@ fn main() { ops += 1; } let dt = t0.elapsed().as_secs_f64(); - let e1 = counter.read().unwrap().uj; + let e1 = read_uj()?; let total_gf = gflop * ops as f64; - let marginal_uj = (e1 - e0) as f64 - idle_uw * dt; - (marginal_uj / total_gf, total_gf / dt) + let marginal = marginal_uj(e0, e1, dt, idle_uw)?; + Ok((marginal / total_gf, total_gf / dt)) }; let mut out = vec![0f32; m * n]; let optr = out.as_mut_ptr(); let (a_p, w_p) = (&a, &w); - let (gpu_ujpgf, gpu_gfps) = measure(3.0, Box::new(move || { + let gpu = measure(3.0, Box::new(move || { let o = unsafe { std::slice::from_raw_parts_mut(optr, m * n) }; let ok = jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_mpp_bt(a_p, w_p, m, n, k, o); assert!(ok, "mpp_bt fell back — set JOULECLAW_FABRIC=1 on a Metal-4 box"); @@ -57,23 +75,38 @@ fn main() { let mut out2 = vec![0f32; m * n]; let o2ptr = out2.as_mut_ptr(); - let (cpu_ujpgf, cpu_gfps) = measure(3.0, Box::new(move || { + let cpu = measure(3.0, Box::new(move || { let o = unsafe { std::slice::from_raw_parts_mut(o2ptr, m * n) }; - jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_threaded(a_p, w_p, &[], m, n, k, o); + // Assert it ran, as the GPU run does: a refused shape would meter an empty loop. + jouleclaw_loader_gguf::matmul_bf16::matmul_bf16_threaded(a_p, w_p, &[], m, n, k, o) + .expect("threaded-NEON matmul refused this shape"); })); println!("shape {m}x{n}x{k}"); - println!(" matmul2d (metal-gpu): {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s", gpu_ujpgf, gpu_gfps); - println!(" threaded-NEON (cpu) : {:>9.0} µJ/GFLOP {:>7.1} GFLOP/s", cpu_ujpgf, cpu_gfps); - println!(" energy ratio (CPU/GPU per GFLOP): {:.2}×", cpu_ujpgf / gpu_ujpgf); + for (label, r) in [("matmul2d (metal-gpu)", &gpu), ("threaded-NEON (cpu) ", &cpu)] { + match r { + Ok((ujpgf, gfps)) => println!(" {label}: {ujpgf:>9.0} µJ/GFLOP {gfps:>7.1} GFLOP/s"), + Err(e) => println!(" {label}: not measured — {e}"), + } + } + let ratio = match (&gpu, &cpu) { + (Ok((gpu_ujpgf, _)), Ok((cpu_ujpgf, _))) => energy_ratio(*cpu_ujpgf, *gpu_ujpgf), + _ => None, + }; + match ratio { + Some(ratio) => println!(" energy ratio (CPU/GPU per GFLOP): {ratio:.2}×"), + None => println!(" energy ratio (CPU/GPU per GFLOP): n/a — both runs must be measured"), + } + let gpu_ujpgf = match (gpu, cpu) { + (Ok((gpu_ujpgf, _)), Ok(_)) => gpu_ujpgf, + (Err(e), _) => refuse(&path, format!("the matmul2d (metal-gpu) run: {e}")), + (_, Err(e)) => refuse(&path, format!("the threaded-NEON (cpu) run: {e}")), + }; // Overlay the measured matmul2d µJ/GFLOP into this box's // calibration file under local/metal-mpp, so the placer + // ledger price the prefill path at its TRUE cost (not the // borrowed MPSMatrix metal-gpu figure). Merge, don't clobber. - let path = std::env::var("JOULECLAW_FABRIC_CALIBRATION").unwrap_or_else(|_| { - format!("{}/.jouleclaw/fabric-calibration.json", std::env::var("HOME").unwrap_or_default()) - }); let mut entries: Vec = std::fs::read(&path) .ok() .and_then(|b| serde_json::from_slice(&b).ok()) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/energy_autotune.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/energy_autotune.rs index cebfed612..95511a4af 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/energy_autotune.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/energy_autotune.rs @@ -119,6 +119,32 @@ pub fn tune_shape( joules_minimal(&costs) } +/// [`tune_shape`] over a meter that can fail to measure a window: `metered(backend)` +/// returns `None` when the meter did not register that backend's work (a read failed, +/// the idle baseline did not advance, no energy above idle). One unmeasured window voids +/// the whole tune — `None`, nothing to record — because ranking a measured backend +/// against an unmeasured one decides by a missing figure. A backend that cannot run +/// the shape reports `Some((u64::MAX, u64::MAX))` and, as in [`tune_shape`], never wins. +#[cfg(any(test, feature = "fabric-meter"))] +pub(crate) fn tune_measured( + candidates: &[Backend], + reps: u32, + mut metered: impl FnMut(Backend) -> Option<(u64, u64)>, +) -> Option { + let reps = reps.max(1); + let mut costs = Vec::with_capacity(candidates.len()); + for &backend in candidates { + let (mut uj_sum, mut ns_sum) = (0u64, 0u64); + for _ in 0..reps { + let (uj, ns) = metered(backend)?; + uj_sum = uj_sum.saturating_add(uj); + ns_sum = ns_sum.saturating_add(ns); + } + costs.push(Cost { backend, uj: uj_sum / reps as u64, ns: ns_sum / reps as u64 }); + } + joules_minimal(&costs) +} + /// A learned, deterministic map from op-shape to the backend that runs it in the /// fewest joules. Deterministic (a `BTreeMap`) so a persisted or sealed profile is /// byte-stable. The dispatcher consults it; a shape with no entry falls back to @@ -137,7 +163,14 @@ impl TuningProfile { /// Record a measurement for `key`, keeping whichever is joules-minimal — so a /// later, noisier measurement can never regress a shape's decision. + /// + /// A zero-joule cost is never recorded. Nothing runs on zero energy, so it means + /// the meter did not see the work, not that the work was free; recorded, it would + /// beat every later measurement and pin the shape to that backend for good. pub fn record(&mut self, key: ShapeKey, cost: Cost) { + if cost.uj == 0 { + return; + } match self.chosen.get(&key) { Some(current) if current.uj <= cost.uj => {} _ => { @@ -146,7 +179,9 @@ impl TuningProfile { } } - /// Tune `key` across `candidates` and record the winner; returns it. + /// Tune `key` across `candidates` and record the winner; returns it. `None`, with + /// nothing recorded, when the joules-minimal candidate drew zero joules (see + /// [`Self::record`]). pub fn tune_and_record( &mut self, key: ShapeKey, @@ -155,6 +190,9 @@ impl TuningProfile { metered: impl FnMut(Backend) -> (u64, u64), ) -> Option { let cost = tune_shape(candidates, reps, metered)?; + if cost.uj == 0 { + return None; + } self.record(key, cost); Some(cost) } @@ -343,6 +381,60 @@ mod tests { assert_eq!(p.cost(key).unwrap().uj, 600); } + #[test] + fn a_zero_joule_cost_is_never_recorded() { + let key = ShapeKey::new(1, 32, 32); + let mut p = TuningProfile::new(); + p.record(key, Cost { backend: Backend::Cpu, uj: 0, ns: 100 }); + assert!(p.is_empty(), "the meter did not see the work: nothing to record"); + // Nor does a zero displace a measured decision… + p.record(key, Cost { backend: Backend::Gpu, uj: 500, ns: 100 }); + p.record(key, Cost { backend: Backend::Cpu, uj: 0, ns: 50 }); + assert_eq!(p.cost(key), Some(Cost { backend: Backend::Gpu, uj: 500, ns: 100 })); + // …while a real, lower measurement still wins the shape. + p.record(key, Cost { backend: Backend::Cpu, uj: 300, ns: 900 }); + assert_eq!(p.choose(key), Some(Backend::Cpu)); + // The process-global profile goes through the same gate. Unique dims. + let key = ShapeKey::new(1, 999, 333); + record_global(key, Cost { backend: Backend::Cpu, uj: 0, ns: 5 }); + assert_eq!(decide_global(key), None); + } + + #[test] + fn tune_and_record_records_nothing_when_the_winner_drew_zero_joules() { + let key = ShapeKey::new(1, 48, 48); + let mut p = TuningProfile::new(); + let chosen = p.tune_and_record(key, &[Backend::Gpu, Backend::Cpu], 1, |b| match b { + Backend::Gpu => (500, 1_000), + Backend::Cpu => (0, 1_500), // the CPU's window never registered on the meter + }); + assert_eq!(chosen, None); + assert!(p.is_empty()); + } + + #[test] + fn an_unmeasured_candidate_voids_the_tune() { + // The meter saw the GPU's window but not the CPU's: comparing them would decide + // by a missing figure, so there is no decision at all. + let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 1, |b| match b { + Backend::Gpu => Some((500, 1_000)), + Backend::Cpu => None, + }); + assert_eq!(tuned, None); + // Every window measured: the ordinary joules-minimal tune, averaged over reps. + let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 2, |b| match b { + Backend::Gpu => Some((500, 1_000)), + Backend::Cpu => Some((300, 1_500)), + }); + assert_eq!(tuned, Some(Cost { backend: Backend::Cpu, uj: 300, ns: 1_500 })); + // A backend that cannot run the shape still never wins, even over reps. + let tuned = tune_measured(&[Backend::Gpu, Backend::Cpu], 2, |b| match b { + Backend::Gpu => Some((u64::MAX, u64::MAX)), + Backend::Cpu => Some((300, 1_500)), + }); + assert_eq!(tuned.map(|c| c.backend), Some(Backend::Cpu)); + } + #[test] fn global_profile_records_and_is_consulted() { // Odd, unique dims so parallel tests never collide on the shared global. diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/fabric_dispatch.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/fabric_dispatch.rs index b2c5625fa..f60c78887 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/fabric_dispatch.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/fabric_dispatch.rs @@ -430,7 +430,13 @@ mod enabled { /// shape; a warmup pass tunes each shape a model uses, then the run is launched /// with `JOULECLAW_ENERGY_AUTOTUNE=1`. Energy is honest marginal draw /// (`ΔE − P_idle·Δt`), averaged over a `window_secs` loop past the counter's - /// update window. Returns the winning cost, or `None` if the meter is absent. + /// update window. Returns the winning cost, or `None` — recording nothing — when + /// the meter cannot measure: the IOReport counter is unavailable (including an + /// Apple Silicon host whose CPU energy rail does not advance, see + /// `jouleclaw_energy::ioreport_status`), a read fails, the idle baseline does not + /// advance, or either backend's window draws no energy above idle + /// ([`crate::marginal_energy`]). An unmeasured backend never wins, or loses, a + /// tune-off against a measured one. #[cfg(feature = "fabric-meter")] pub fn tune_shape_live( a: &[f32], @@ -440,7 +446,8 @@ mod enabled { k: usize, window_secs: f64, ) -> Option { - use crate::energy_autotune::{record_global, tune_shape, Backend, Cost, ShapeKey}; + use crate::energy_autotune::{record_global, tune_measured, Backend, Cost, ShapeKey}; + use crate::marginal_energy::{idle_draw_uw, marginal_uj, per_op_uj}; use jouleclaw_energy::EnergyCounter; use std::time::{Duration, Instant}; @@ -448,27 +455,28 @@ mod enabled { if !counter.is_available() { return None; } + let read = || counter.read().ok().map(|r| r.uj); // Idle baseline, so we charge each kernel its *marginal* energy, not the // whole SoC's — the same honest figure the mpp_energy example reports. let idle_uw = { - let e0 = counter.read().ok()?.uj; + let e0 = read()?; let t0 = Instant::now(); std::thread::sleep(Duration::from_millis(300)); - let e1 = counter.read().ok()?.uj; - e1.saturating_sub(e0) as f64 / t0.elapsed().as_secs_f64().max(1e-9) + let e1 = read()?; + idle_draw_uw(e0, e1, t0.elapsed().as_secs_f64()).ok()? }; let mut out = vec![0f32; m * n]; - let mut measure = |backend: Backend| -> (u64, u64) { + let measure = |backend: Backend| -> Option<(u64, u64)> { let run = |out: &mut [f32]| match backend { Backend::Cpu => matmul_bf16_amx(a, w, &[], m, n, k, out), Backend::Gpu => matmul_bf16_gpu(a, w, &[], m, n, k, out), }; // A backend that refuses this shape can never win the tune-off. if !run(&mut out) { - return (u64::MAX, u64::MAX); + return Some((u64::MAX, u64::MAX)); } - let e0 = counter.read().map(|r| r.uj).unwrap_or(0); + let e0 = read()?; let t0 = Instant::now(); let mut ops = 0u64; while t0.elapsed().as_secs_f64() < window_secs { @@ -476,13 +484,13 @@ mod enabled { ops += 1; } let dt = t0.elapsed().as_secs_f64(); - let e1 = counter.read().map(|r| r.uj).unwrap_or(e0); + let e1 = read()?; let ops = ops.max(1); - let marginal = (e1.saturating_sub(e0) as f64 - idle_uw * dt).max(0.0); - ((marginal / ops as f64) as u64, (dt / ops as f64 * 1e9) as u64) + let uj = marginal_uj(e0, e1, dt, idle_uw).and_then(|mj| per_op_uj(mj, ops)).ok()?; + Some((uj, (dt / ops as f64 * 1e9) as u64)) }; - let cost: Cost = tune_shape(&[Backend::Cpu, Backend::Gpu], 1, &mut measure)?; + let cost: Cost = tune_measured(&[Backend::Cpu, Backend::Gpu], 1, measure)?; record_global(ShapeKey::new(m, n, k), cost); Some(cost) } @@ -1379,6 +1387,37 @@ pub fn matmul_bf16_auto( #[cfg(all(test, feature = "fabric", target_os = "macos", target_arch = "aarch64"))] mod tests { + /// On this host, with the IOReport meter (`fabric-meter`): a live tune records a + /// shape only when the meter measured every backend's window, never a zero, and + /// what it returns is exactly what it recorded. Where the counter is declined — + /// macOS 27.0.1 on an M5 Max and an M3 Ultra, whose CPU energy rail does not + /// advance — it returns `None` and the global profile gains nothing for the shape. + /// It used to fall to `(ΔE − P_idle·Δt).max(0) = 0` µJ per op on a frozen rail and + /// record that zero, which then beat every later measurement. + #[cfg(feature = "fabric-meter")] + #[test] + fn a_live_tune_records_only_what_the_meter_measured() { + use crate::energy_autotune::{global_snapshot, ShapeKey}; + let status = jouleclaw_energy::ioreport_status(); + // Odd, unique dims so parallel tests never collide on the shared global profile. + let (m, n, k) = (1usize, 48usize, 80usize); + let a = vec![0.25f32; m * k]; + let w: Vec = (0..n * k) + .flat_map(|i| crate::matmul_bf16::f32_to_bf16_rne(((i % 7) as f32 - 3.0) * 0.01).to_le_bytes()) + .collect(); + let tuned = super::tune_shape_live(&a, &w, m, n, k, 0.05); + let key = ShapeKey::new(m, n, k); + let recorded = global_snapshot().into_iter().find(|(s, _)| *s == key).map(|(_, c)| c); + eprintln!("tune_shape_live on this host: ioreport={status:?} tuned={tuned:?} recorded={recorded:?}"); + assert_eq!(tuned, recorded, "a live tune records exactly what it returns"); + if let Some(cost) = recorded { + assert!(cost.uj > 0 && cost.uj != u64::MAX, "recorded {cost:?}"); + } + if status != jouleclaw_energy::IoReportStatus::Live { + assert_eq!(tuned, None, "the IOReport counter is declined ({status:?}), so nothing is measured"); + } + } + /// The attribution ledger records every dispatch with /// the member that actually ran it and the op's GFLOPs. #[test] diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs index 904034778..6e6e06229 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs @@ -38,6 +38,7 @@ pub mod mmap_advise; pub mod cpu_prefetch; pub mod energy_autotune; +pub mod marginal_energy; pub mod huge_page; pub mod io_uring_prefetch; pub mod tier_store; diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs new file mode 100644 index 000000000..8f1dd593d --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/marginal_energy.rs @@ -0,0 +1,278 @@ +//! Marginal energy on a machine-wide cumulative energy counter, and when there is none. +//! +//! The live autotuner ([`crate::fabric_dispatch`]'s `tune_shape_live`) and the +//! calibration examples (`fabric_calibrate`, `mpp_energy`) price work the same way: +//! sample the counter across an idle window for the idle draw, then across a busy +//! window, and charge the work `ΔE_busy − P_idle·Δt`. That is a figure only if the +//! counter metered both windows. A counter that does not advance at idle is not +//! metering this machine (an SoC draws power doing nothing), and a busy window that +//! draws nothing above idle means the meter did not see the work. Either way there is +//! no figure, and nothing may be recorded from it: a zero, or a negative clamped to +//! zero, would be written down as a measurement the meter never made. +//! +//! Pure arithmetic over raw readings, so the refusals are unit-tested without hardware. + +use std::fmt; + +/// Why a marginal-energy measurement has no figure. +#[derive(Debug, Clone, PartialEq)] +pub enum NotMeasured { + /// The energy counter is not available on this host; the payload says why. + CounterUnavailable(String), + /// A read of the counter failed. + ReadFailed(String), + /// The cumulative counter went backwards across a window. + CounterWentBackwards { + /// Reading at the start of the window, µJ. + before_uj: u64, + /// Reading at the end of the window, µJ. + after_uj: u64, + }, + /// A window with no duration, or no operations in it. + EmptyWindow, + /// The counter did not advance across the idle window. + IdleDidNotAdvance { + /// Length of the idle window, seconds. + secs: f64, + }, + /// The busy window drew no energy above the idle draw. + NoMarginal { + /// `ΔE_busy − P_idle·Δt`, µJ — zero or negative. + marginal_uj: f64, + }, + /// The marginal energy per operation is below one microjoule, so it rounds to 0. + BelowResolution { + /// Marginal energy of the whole window, µJ. + marginal_uj: f64, + /// Operations in the window. + ops: u64, + }, +} + +impl fmt::Display for NotMeasured { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CounterUnavailable(why) => write!( + f, + "the energy counter is not available on this host ({why})" + ), + Self::ReadFailed(e) => write!(f, "a read of the energy counter failed: {e}"), + Self::CounterWentBackwards { + before_uj, + after_uj, + } => { + write!( + f, + "the cumulative counter went backwards ({before_uj} µJ, then {after_uj} µJ)" + ) + } + Self::EmptyWindow => { + f.write_str("the measurement window has no duration or no operations") + } + Self::IdleDidNotAdvance { secs } => write!( + f, + "the counter did not advance across {secs:.2} s of idle, so it is not metering this machine" + ), + Self::NoMarginal { marginal_uj } => write!( + f, + "the busy window drew {marginal_uj:.0} µJ above the idle draw: the meter did not see the work" + ), + Self::BelowResolution { marginal_uj, ops } => { + write!(f, "{marginal_uj:.0} µJ over {ops} ops is below 1 µJ per op") + } + } + } +} + +impl std::error::Error for NotMeasured {} + +/// The energy a window drew, from the counter's reading at its start and end. +fn window_uj(before_uj: u64, after_uj: u64, secs: f64) -> Result { + if !(secs.is_finite() && secs > 0.0) { + return Err(NotMeasured::EmptyWindow); + } + after_uj + .checked_sub(before_uj) + .ok_or(NotMeasured::CounterWentBackwards { + before_uj, + after_uj, + }) +} + +/// The idle draw in µW, from the counter's readings before and after `secs` of idle. +/// Refused when the counter did not advance. +pub fn idle_draw_uw(before_uj: u64, after_uj: u64, secs: f64) -> Result { + match window_uj(before_uj, after_uj, secs)? { + 0 => Err(NotMeasured::IdleDidNotAdvance { secs }), + delta => Ok(delta as f64 / secs), + } +} + +/// The marginal energy of a busy window, `ΔE − idle_uw·secs`, in µJ. Refused unless it +/// is positive: never clamped to zero. +pub fn marginal_uj( + before_uj: u64, + after_uj: u64, + secs: f64, + idle_uw: f64, +) -> Result { + let delta = window_uj(before_uj, after_uj, secs)?; + let marginal = delta as f64 - idle_uw * secs; + if marginal.is_finite() && marginal > 0.0 { + Ok(marginal) + } else { + Err(NotMeasured::NoMarginal { + marginal_uj: marginal, + }) + } +} + +/// Whole microjoules per operation of a window's marginal energy. Refused when it would +/// round to zero, so a per-op cost is never recorded as 0. +pub fn per_op_uj(marginal_uj: f64, ops: u64) -> Result { + if ops == 0 { + return Err(NotMeasured::EmptyWindow); + } + let per_op = marginal_uj / ops as f64; + if !(per_op.is_finite() && per_op > 0.0) { + return Err(NotMeasured::NoMarginal { marginal_uj }); + } + match per_op as u64 { + 0 => Err(NotMeasured::BelowResolution { marginal_uj, ops }), + uj => Ok(uj), + } +} + +/// The ratio of two marginal-energy figures, or `None` unless both are positive and +/// finite: a ratio against an unmeasured figure is undefined, not `NaN` or `inf`. +pub fn energy_ratio(numerator: f64, denominator: f64) -> Option { + let measured = |x: f64| x.is_finite() && x > 0.0; + (measured(numerator) && measured(denominator)).then(|| numerator / denominator) +} + +/// What a calibration example prints before it exits non-zero having written nothing: +/// the file it refused to write, why the figure it needed is not a measurement, and +/// the IOReport status (`jouleclaw_energy::ioreport_status`), which says whether the +/// counter is metering this host at all and, if not, why. Every figure in that file is +/// loaded as a measurement, so a refusal must leave it untouched. +pub fn refusal(path: &str, why: impl fmt::Display, ioreport_status: impl fmt::Debug) -> String { + format!( + "refusing to write {path}: {why}\nIOReport status: {ioreport_status:?}; nothing was written \ + (its figures are loaded as measurements)" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_counter_that_holds_at_idle_has_no_idle_baseline() { + // A frozen rail reads the same before and after: not metering, so no baseline. + assert_eq!( + idle_draw_uw(480_083_160, 480_083_160, 3.0), + Err(NotMeasured::IdleDidNotAdvance { secs: 3.0 }) + ); + assert_eq!( + idle_draw_uw(2_000, 1_000, 3.0), + Err(NotMeasured::CounterWentBackwards { + before_uj: 2_000, + after_uj: 1_000 + }) + ); + assert_eq!(idle_draw_uw(0, 5, 0.0), Err(NotMeasured::EmptyWindow)); + // A live counter: 6 J over 3 s is a 2 W idle draw. + assert_eq!(idle_draw_uw(1_000, 6_001_000, 3.0), Ok(2_000_000.0)); + } + + #[test] + fn a_busy_window_at_or_below_idle_is_refused_never_clamped_to_zero() { + let idle_uw = 2_000_000.0; // 2 W + // 10 J over 2 s at a 2 W idle: 6 J of marginal energy. + assert_eq!(marginal_uj(0, 10_000_000, 2.0, idle_uw), Ok(6_000_000.0)); + // Exactly idle, below idle, and a counter that did not move at all: no figure. + assert_eq!( + marginal_uj(0, 4_000_000, 2.0, idle_uw), + Err(NotMeasured::NoMarginal { marginal_uj: 0.0 }) + ); + assert_eq!( + marginal_uj(0, 3_000_000, 2.0, idle_uw), + Err(NotMeasured::NoMarginal { + marginal_uj: -1_000_000.0 + }) + ); + assert_eq!( + marginal_uj(7, 7, 2.0, idle_uw), + Err(NotMeasured::NoMarginal { + marginal_uj: -4_000_000.0 + }) + ); + assert!(matches!( + marginal_uj(0, 10_000_000, 2.0, f64::NAN), + Err(NotMeasured::NoMarginal { .. }) + )); + assert_eq!( + marginal_uj(9, 8, 2.0, idle_uw), + Err(NotMeasured::CounterWentBackwards { + before_uj: 9, + after_uj: 8 + }) + ); + } + + #[test] + fn a_per_op_cost_never_rounds_to_zero() { + assert_eq!(per_op_uj(6_000_000.0, 1_000), Ok(6_000)); + assert_eq!( + per_op_uj(500.0, 1_000), + Err(NotMeasured::BelowResolution { + marginal_uj: 500.0, + ops: 1_000 + }) + ); + assert_eq!(per_op_uj(6_000_000.0, 0), Err(NotMeasured::EmptyWindow)); + assert_eq!( + per_op_uj(0.0, 10), + Err(NotMeasured::NoMarginal { marginal_uj: 0.0 }) + ); + } + + #[test] + fn the_cpu_gpu_ratio_is_undefined_unless_both_are_measured() { + assert_eq!(energy_ratio(6.0, 3.0), Some(2.0)); + // Frozen rails left both marginals at zero: the old ratio was 0/0 = NaN. + assert_eq!(energy_ratio(0.0, 0.0), None); + assert_eq!(energy_ratio(5.0, 0.0), None, "was +inf"); + assert_eq!(energy_ratio(-1.0, 3.0), None); + assert_eq!(energy_ratio(f64::NAN, 3.0), None); + } + + #[test] + fn a_refusal_names_the_file_the_reason_and_the_ioreport_status() { + #[derive(Debug)] + #[allow(dead_code)] + enum Status { + CpuRailFrozen, + } + let msg = refusal( + "/tmp/x/fabric-calibration.json", + NotMeasured::IdleDidNotAdvance { secs: 3.0 }, + Status::CpuRailFrozen, + ); + assert!(msg.starts_with("refusing to write /tmp/x/fabric-calibration.json: "), "{msg}"); + assert!(msg.contains("did not advance across 3.00 s of idle"), "{msg}"); + assert!(msg.contains("IOReport status: CpuRailFrozen"), "{msg}"); + assert!(msg.contains("nothing was written"), "{msg}"); + } + + #[test] + fn a_refusal_says_why() { + let why = NotMeasured::CounterUnavailable("IOReport: CpuRailFrozen".into()).to_string(); + assert!( + why.contains("not available") && why.contains("CpuRailFrozen"), + "{why}" + ); + let why = NotMeasured::NoMarginal { marginal_uj: -12.0 }.to_string(); + assert!(why.contains("did not see the work"), "{why}"); + } +} -- 2.54.0 (Apple Git-157) From 060a858620f613933cd463d1ac5c7c7f40ad14d6 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:50:44 -0400 Subject: [PATCH 05/23] jouleclaw-harness-econ: the local-model examples say which meter ran and never zero-fill a read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family contract: measured when the platform exposes it, estimated when it doesn't, and the provenance always says which. The three examples that meter a real local model's decode with jouleclaw-energy's detect_meter() broke it in their output, and turned a failed read into a figure. The defect (b9d7e945, unchanged since origin/main 54e89b56): - local_model_econ, swebench_local_coder and swebench_local_suite read the meter with `.unwrap_or(0)` before the decode and `.unwrap_or(e0)` after. A failed first read billed the dispatch the meter's whole cumulative value; a failed second read billed it zero. - local_model_econ printed "{J} J measured" per dispatch and "(Estimator, energy-only $)" for the average; swebench_local_coder printed "{J} J measured"; swebench_local_suite said "Energy is real inference measured on this machine". Those labels were fixed strings, not the meter's provenance. Since b9d7e945 the meter on both of our Macs is the load-average estimator, so every "measured" is an estimate called a measurement. Where IOReport's CPU rail is live, the hard-coded "Estimator" would understate a reading. - The docs: Cargo.toml said the `ioreport` dev-feature "selects the Apple-Silicon load-derived energy estimator" and that the IOReport meter "lives in a separate repo's sandbox-meter"; the examples said energy "is the honest Provenance::Estimator figure". It is IOReport where its CPU rail is live, else the estimator. The fix. - Two small, documented functions in the library, so the rule is tested where CI runs (tests inside examples/ never run under `cargo test --workspace`): window_joules(before, after) gives the delta and the readings' provenance, or why there is no figure: a failed read, readings that disagree about provenance, a counter that went backwards, or a zero (no decode runs on zero energy). joules_obtained(provenance) is "measured" for a reading and "estimated" for an estimate. The crate already depended on jouleclaw-energy for Provenance; no dependency is added. - The three examples meter every dispatch through window_joules. They stop with the reason if the meter put no figure on a dispatch, since the report exists to print that figure. They print each dispatch's joules with joules_obtained, the meter that ran and its provenance. local_model_econ's average states the dispatches' own provenance. - The false doc strings are corrected: Cargo.toml, the three examples' module docs, and swebench_local_suite's closing line. Observed, M5 Max, `cargo run --release -p jouleclaw-harness-econ --example local_model_econ` with its real Nemotron-3-Nano-4B snapshot: dispatch 1: 6 tok in 0.84s (7.1 tok/s), 33.720 J estimated by apple-silicon (estimator) dispatch 2: 6 tok in 0.98s (6.1 tok/s), 39.320 J estimated by apple-silicon (estimator) dispatch 3: 6 tok in 0.85s (7.1 tok/s), 33.840 J estimated by apple-silicon (estimator) avg real-model dispatch : 35.627 J (Estimator, energy-only $) The report's own energy_provenance ("(Estimator)") is derived from the tier, not the meter, and is unchanged. Tests: 14 lib tests pass (M5 Max), 3 new, none feature- or host-gated: a_window_is_the_delta_at_its_readings_provenance, a_window_without_two_good_readings_is_never_zero (a failed read on either side, backwards, disagreeing provenance, a zero), an_estimate_is_never_reported_as_ measured. The WIP version of this change had put the label test inside examples/local_model_econ.rs, where `cargo test --workspace` compiles it but never runs it. Falsified, each mutation applied to this commit and restored with git checkout: - FH1, a failed first read taken as a 0 µJ reading (the old unwrap_or(0)): a_window_without_two_good_readings_is_never_zero fails. - FH2, a zero window accepted as a figure: the same test fails. - FH3, an estimate called "measured": an_estimate_is_never_reported_as_measured fails. clippy --no-deps --tests --examples: no warning in the lines this commit touches; swebench_local_suite's unread `text` field and empty-format-literal warnings are pre-existing. `cargo check --tests --examples` for aarch64-unknown-linux-gnu (+fp16, CI's target): clean. Not done here, deliberately: - EconReport::energy_provenance comes from the tier (skill/cache hits are HwShunt, model tiers Estimator), not from any meter. A skill hit's joules are a constant (SKILL_JOULES), so a report whose rows are all skill hits says HwShunt for a figure nothing measured. That is not an IOReport consumer; it needs its own change. - meter_probe (example) prints the meter and provenance it gets and is not wrong; it does not print ioreport_status(). Co-Authored-By: Claude Opus 5.5 --- .../crates/jouleclaw-harness-econ/Cargo.toml | 6 +- .../examples/local_model_econ.rs | 72 +++++++++++------ .../examples/swebench_local_coder.rs | 34 +++++--- .../examples/swebench_local_suite.rs | 43 ++++++++--- .../crates/jouleclaw-harness-econ/src/lib.rs | 77 ++++++++++++++++++- 5 files changed, 181 insertions(+), 51 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/Cargo.toml index 5e7453359..00355bc41 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/Cargo.toml @@ -21,7 +21,7 @@ thiserror = { workspace = true } # only want the economics types don't pull in the inference substrate. [dev-dependencies] jouleclaw-loader-gguf = { path = "../jouleclaw-loader-gguf", features = ["rayon"] } -# `ioreport` selects the Apple-Silicon load-derived energy estimator on macOS -# (honest Provenance::Estimator — the hardware-shunt IOReport meter lives in a -# separate repo's sandbox-meter and is intentionally NOT cross-dep'd here). +# `ioreport` gives `detect_meter` the Apple-Silicon IOReport energy-model counter +# (Provenance::ModelBased), used only where its CPU energy rail advances under +# load, else the load-derived estimator (honest Provenance::Estimator). jouleclaw-energy = { path = "../jouleclaw-energy", features = ["ioreport"] } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs index 09b32d0f3..dd89a33dc 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs @@ -1,21 +1,22 @@ -//! Real local-model dispatch, real (Estimator-grade) measured joules, in the -//! econ report — and the cheapest-sufficient energy collapse, measured. +//! Real local-model dispatch, metered joules at the meter's honest provenance, in +//! the econ report — and the cheapest-sufficient energy collapse. //! //! Wires the pure-Rust `jouleclaw-loader-gguf` inference substrate as a cascade //! [`Tier`]: it loads a real GGUF/safetensors model, greedily decodes an answer, //! and meters the energy of that decode window via `jouleclaw-energy`'s //! auto-detected meter. A recurring task is run several rounds with //! skill-compilation on: the first few rounds pay the **real model** (real -//! decode time, real load-derived joules), then the class compiles into a +//! decode time, real metered joules), then the class compiles into a //! deterministic skill and the rest resolve at ~0 — the cheapest-sufficient -//! thesis in measured energy. +//! thesis in metered energy. //! -//! HONEST ENERGY: on Apple Silicon the auto-detected meter is a load-average × -//! power-band ESTIMATOR (`Provenance::Estimator`), not a hardware shunt (the -//! shunt-grade IOReport meter lives in a separate repo and is deliberately not -//! cross-dep'd into this public crate). Decode wall-time and token counts are -//! exact measurements; the joules are an honest load-derived estimate over that -//! window. A LOCAL model is priced energy-only (no per-call API dollars). +//! HONEST ENERGY: the auto-detected meter is `jouleclaw-energy`'s. On Apple Silicon +//! that is the IOReport energy-model counter (`Provenance::ModelBased`) only where +//! its CPU energy rail advances under load; elsewhere — macOS 27.0.1 on an M5 Max +//! and an M3 Ultra — it is a load-average × power-band ESTIMATOR +//! (`Provenance::Estimator`). Each dispatch prints which meter ran and whether its +//! joules were measured or estimated. Decode wall-time and token counts are exact +//! measurements. A LOCAL model is priced energy-only (no per-call API dollars). //! //! Run (defaults to a Nemotron-3-Nano-4B snapshot if present): //! ```text @@ -34,7 +35,8 @@ use jouleclaw_cascade::{ VerificationStatus, }; use jouleclaw_energy::meter::detect_meter; -use jouleclaw_harness_econ::{EconHarness, PriceModel, Task}; +use jouleclaw_energy::Provenance; +use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task}; use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer; use jouleclaw_loader_gguf::nemotron_h::{NemotronHConfig, NemotronHModel}; use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file; @@ -42,12 +44,15 @@ use jouleclaw_skill::{ConstantSkillInducer, SkillTier}; const DEFAULT_SNAPSHOT: &str = "/Volumes/macos_4TB_external_1/hf_hub/models--nvidia--NVIDIA-Nemotron-3-Nano-4B-BF16/snapshots/dfaf35de3e30f1867dd8dbc38a7fc9fb52d3914f"; -/// One real model dispatch: wall-time, tokens, and metered joules. +/// One real model dispatch: wall-time, tokens, and metered joules — with the meter +/// that metered them and the provenance of its readings. #[derive(Clone, Copy)] struct DispatchStat { secs: f64, tokens: usize, joules: f64, + meter: &'static str, + provenance: Provenance, } /// A cascade tier backed by a real local model. Greedy-decodes an answer and @@ -89,9 +94,12 @@ impl Tier for LocalModelTier { std::iter::once(self.bos).chain(self.tok.encode(&prompt)).collect(); // Fresh meter per dispatch: its first read sets the baseline, the second - // yields the energy integrated over the decode window. + // yields the energy integrated over the decode window. A window the meter + // put no figure on stops the run (`window_joules`): a failed read is not a + // zero-energy reading, and a figure the meter did not produce is never + // reported. let meter = detect_meter(); - let e0 = meter.read().map(|r| r.uj).unwrap_or(0); + let e0 = meter.read(); let t = Instant::now(); let mut state = self.model.new_state(); @@ -114,15 +122,18 @@ impl Tier for LocalModelTier { } let secs = t.elapsed().as_secs_f64(); - let e1 = meter.read().map(|r| r.uj).unwrap_or(e0); - let joules = e1.saturating_sub(e0) as f64 / 1e6; + let (joules, provenance) = window_joules(e0, meter.read()) + .unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name())); let text = self.tok.decode(&generated); self.calls.fetch_add(1, Ordering::SeqCst); - self.log - .lock() - .unwrap() - .push(DispatchStat { secs, tokens: generated.len(), joules }); + self.log.lock().unwrap().push(DispatchStat { + secs, + tokens: generated.len(), + joules, + meter: meter.active_name(), + provenance, + }); Ok(Answer { output: AnswerOutput::Text(text), @@ -208,18 +219,27 @@ fn main() { println!("\nreal model dispatches: {} (rest served by the compiled skill)", stats.len()); for (i, s) in stats.iter().enumerate() { println!( - " dispatch {}: {} tok in {:.2}s ({:.1} tok/s), {:.3} J measured", + " dispatch {}: {} tok in {:.2}s ({:.1} tok/s), {:.3} J {} by {} ({})", i + 1, s.tokens, s.secs, if s.secs > 0.0 { s.tokens as f64 / s.secs } else { 0.0 }, - s.joules + s.joules, + joules_obtained(s.provenance), + s.meter, + s.provenance ); } let model_j: f64 = stats.iter().map(|s| s.joules).sum(); let model_n = stats.len().max(1); + // The dispatches' own provenance, not an assumed one. + let provenance = match stats.first().map(|s| s.provenance) { + Some(p) if stats.iter().all(|s| s.provenance == p) => format!("{p:?}"), + Some(_) => "mixed provenance".to_string(), + None => "no dispatch".to_string(), + }; println!( - "\navg real-model dispatch : {:.3} J (Estimator, energy-only $)", + "\navg real-model dispatch : {:.3} J ({provenance}, energy-only $)", model_j / model_n as f64 ); println!( @@ -235,7 +255,9 @@ fn main() { ); println!("dollars per verified fix : ${:.8}", report.usd_per_verified_fix); println!( - "\nThe energy axis is real inference measured on this machine; the curve bends\n\ - because the recurring class compiled into a skill and stopped paying the model." + "\nThe energy axis is real inference on this machine, at each dispatch's meter and\n\ + provenance above; the curve bends because the recurring class compiled into a\n\ + skill and stopped paying the model." ); } + diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs index 3bffb58db..87130387e 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs @@ -1,15 +1,18 @@ //! The genuine number, end to end: a real instruction-tuned **code model** //! (Granite-4.0-h, pure-Rust loader-gguf) generates a fix, which is applied as //! the whole corrected file and judged by a **real `python3` test run** -//! ([`WholeFileValidator`]), with the decode energy measured. Output is the econ +//! ([`WholeFileValidator`]), with the decode energy metered. Output is the econ //! report's joules-and-$ per VERIFIED fix on an operationally-verified fix. //! //! The local model emits the corrected file (the protocol that works for local //! models — they fumble unified-diff syntax), wrapped in Granite's chat //! template. Verification is operational: the patch is written and `python3` -//! actually imports + asserts. Energy is the honest `Provenance::Estimator` -//! load-derived figure (decode time + tokens are exact); a local model is -//! priced energy-only. +//! actually imports + asserts. Energy is `jouleclaw-energy`'s auto-detected +//! meter at its own provenance: the IOReport energy model (`ModelBased`) only +//! where its CPU energy rail advances under load, else the load-derived +//! estimator (`Estimator`), as on macOS 27.0.1 on an M5 Max and an M3 Ultra; the +//! report says which (decode time + tokens are exact). A local model is priced +//! energy-only. //! //! Run (defaults to the granite-4.0-h-micro snapshot if present): //! ```text @@ -29,9 +32,10 @@ use jouleclaw_cascade::{ VerificationStatus, }; use jouleclaw_energy::meter::detect_meter; +use jouleclaw_energy::Provenance; use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator}; use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance}; -use jouleclaw_harness_econ::{EconHarness, PriceModel, Task}; +use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task}; use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer; use jouleclaw_loader_gguf::granite_h::{GraniteHConfig, GraniteHModel}; use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file; @@ -43,6 +47,9 @@ struct DispatchStat { secs: f64, tokens: usize, joules: f64, + /// The meter that metered the decode, and the provenance of its readings. + meter: &'static str, + provenance: Provenance, text: String, } @@ -89,8 +96,10 @@ impl Tier for LocalCoderTier { }; let ids = self.chat_prompt(&user); + // A window the meter put no figure on stops the run (`window_joules`): a + // failed read is not a zero-energy reading. let meter = detect_meter(); - let e0 = meter.read().map(|r| r.uj).unwrap_or(0); + let e0 = meter.read(); let t = Instant::now(); let mut state = self.model.new_state(); @@ -117,8 +126,8 @@ impl Tier for LocalCoderTier { } let secs = t.elapsed().as_secs_f64(); - let e1 = meter.read().map(|r| r.uj).unwrap_or(e0); - let joules = e1.saturating_sub(e0) as f64 / 1e6; + let (joules, provenance) = window_joules(e0, meter.read()) + .unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name())); let text = self.tok.decode(&generated); self.calls.fetch_add(1, Ordering::SeqCst); @@ -126,6 +135,8 @@ impl Tier for LocalCoderTier { secs, tokens: generated.len(), joules, + meter: meter.active_name(), + provenance, text: text.clone(), }); @@ -255,11 +266,14 @@ fn main() { let stats = log.lock().unwrap(); for s in stats.iter() { println!( - "model: {} tok in {:.1}s ({:.1} tok/s), {:.3} J measured", + "model: {} tok in {:.1}s ({:.1} tok/s), {:.3} J {} by {} ({})", s.tokens, s.secs, if s.secs > 0.0 { s.tokens as f64 / s.secs } else { 0.0 }, - s.joules + s.joules, + joules_obtained(s.provenance), + s.meter, + s.provenance ); println!("--- model output ---\n{}\n--------------------", s.text.trim()); } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs index 2e1111932..77c54fa71 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs @@ -2,14 +2,17 @@ //! code model. Five distinct bug classes (wrong-operator, off-by-one, //! empty-guard, boundary-comparison, identity-vs-abs) — each a real Python file //! the Granite coder must fix as a whole-file replacement, judged by a real -//! `python3` test ([`WholeFileValidator`]), with the decode energy measured. +//! `python3` test ([`WholeFileValidator`]), with the decode energy metered. //! //! This is the multi-instance generalisation of `swebench_local_coder`: instead -//! of one fix it reports a measured curve across bug types — which classes a 4B +//! of one fix it reports a metered curve across bug types — which classes a 4B //! local model resolves, and the joules/$ each verified fix actually cost. The -//! denominator is fixes that PASS their test, never attempts. Energy is the -//! honest `Provenance::Estimator` figure (decode time + tokens exact); a local -//! model is priced energy-only. +//! denominator is fixes that PASS their test, never attempts. Energy is +//! `jouleclaw-energy`'s auto-detected meter at its own provenance: the IOReport +//! energy model (`ModelBased`) only where its CPU energy rail advances under +//! load, else the load-derived estimator (`Estimator`), as on macOS 27.0.1 on an +//! M5 Max and an M3 Ultra; the report says which (decode time + tokens exact). A +//! local model is priced energy-only. //! //! Run (defaults to the granite-4.0-h-micro snapshot if present): //! ```text @@ -30,9 +33,10 @@ use jouleclaw_cascade::{ VerificationStatus, }; use jouleclaw_energy::meter::detect_meter; +use jouleclaw_energy::Provenance; use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator}; use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance}; -use jouleclaw_harness_econ::{EconHarness, PriceModel, Task}; +use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task}; use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer; use jouleclaw_loader_gguf::granite_h::{GraniteHConfig, GraniteHModel}; use jouleclaw_loader_gguf::safetensors_reader::read_safetensors_file; @@ -86,6 +90,9 @@ struct DispatchStat { secs: f64, tokens: usize, joules: f64, + /// The meter that metered the decode, and the provenance of its readings. + meter: &'static str, + provenance: Provenance, text: String, } @@ -121,8 +128,10 @@ impl Tier for LocalCoderTier { ); let ids = self.tok.encode(&prompt); + // A window the meter put no figure on stops the run (`window_joules`): a + // failed read is not a zero-energy reading. let meter = detect_meter(); - let e0 = meter.read().map(|r| r.uj).unwrap_or(0); + let e0 = meter.read(); let t = Instant::now(); let mut state = self.model.new_state(); @@ -145,10 +154,17 @@ impl Tier for LocalCoderTier { } let secs = t.elapsed().as_secs_f64(); - let e1 = meter.read().map(|r| r.uj).unwrap_or(e0); - let joules = e1.saturating_sub(e0) as f64 / 1e6; + let (joules, provenance) = window_joules(e0, meter.read()) + .unwrap_or_else(|why| panic!("no energy figure for this dispatch from `{}`: {why}", meter.active_name())); let text = self.tok.decode(&generated); - self.log.lock().unwrap().push(DispatchStat { secs, tokens: generated.len(), joules, text: text.clone() }); + self.log.lock().unwrap().push(DispatchStat { + secs, + tokens: generated.len(), + joules, + meter: meter.active_name(), + provenance, + text: text.clone(), + }); Ok(Answer { output: AnswerOutput::Text(text), @@ -310,6 +326,9 @@ fn main() { "totals: {toks} tok in {secs:.1}s ({:.1} tok/s), {model_j:.3} J spent", if secs > 0.0 { toks as f64 / secs } else { 0.0 } ); + for s in stats.iter() { + println!(" {:.3} J {} by {} ({})", s.joules, joules_obtained(s.provenance), s.meter, s.provenance); + } if report.verified > 0 { println!( "joules per VERIFIED fix : {:.3} J ({:?})", @@ -321,8 +340,8 @@ fn main() { } println!( "\nThe denominator is fixes that PASS their test, not attempts. Energy is real\n\ - inference measured on this machine; swap a stronger model tier to raise the\n\ - resolve rate and lower the joules-per-verified-fix." + inference on this machine, at each dispatch's meter and provenance above; swap a\n\ + stronger model tier to raise the resolve rate and lower the joules-per-verified-fix." ); drop(stats); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/src/lib.rs index fb54e78f8..e6dd37446 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/src/lib.rs @@ -55,7 +55,7 @@ use jouleclaw_cascade::{ Answer, AnswerError, ExecutionTrace, L4ModelId, Query, QueryInput, Runtime, Tier, TierEstimate, TierId, VerificationStatus, }; -use jouleclaw_energy::Provenance; +use jouleclaw_energy::{EnergyReading, Provenance}; use jouleclaw_skill::{InMemorySkillStore, SharedSkills, SkillInducer, SkillStore}; use serde::{Deserialize, Serialize}; @@ -515,12 +515,87 @@ fn worst_provenance(a: Provenance, b: Provenance) -> Provenance { if rank(a) <= rank(b) { a } else { b } } +/// The joules a meter put on one window of work — a model dispatch, say — from its +/// cumulative readings before and after it, with the provenance those readings carry. +/// Or why there is no figure: a read failed, the readings disagree about provenance, +/// the counter went backwards, or it registered nothing at all (no work runs on zero +/// energy, so a zero means the meter did not see the work). A failed read is never +/// taken as zero, which would bill the window nothing, or bill it the counter's whole +/// history. +pub fn window_joules( + before: Result, + after: Result, +) -> Result<(f64, Provenance), String> { + let before = before.map_err(|e| format!("the meter read failed before the window: {e}"))?; + let after = after.map_err(|e| format!("the meter read failed after the window: {e}"))?; + if before.provenance != after.provenance { + return Err(format!( + "the meter's readings disagree about provenance ({} then {})", + before.provenance, after.provenance + )); + } + match after.uj.checked_sub(before.uj) { + None => Err(format!("the counter went backwards ({} µJ, then {} µJ)", before.uj, after.uj)), + Some(0) => Err("the meter registered no energy across the window".to_string()), + Some(uj) => Ok((uj as f64 / 1e6, after.provenance)), + } +} + +/// How a report says a metered figure at `provenance` was obtained: `"measured"` for a +/// reading (a hardware shunt, or an on-device energy model), `"estimated"` for an +/// estimate. The local-model examples print each dispatch's joules through it, beside +/// the meter that ran, so an estimate — what `jouleclaw_energy::meter::detect_meter` +/// yields on Apple Silicon whose IOReport CPU rail does not advance — is never called a +/// measurement. +pub fn joules_obtained(provenance: Provenance) -> &'static str { + match provenance { + Provenance::HwShunt | Provenance::ModelBased => "measured", + Provenance::Estimator => "estimated", + } +} + #[cfg(test)] mod tests { use super::*; use jouleclaw_cascade::{Cascade, ContextRef, JouleBudget, QualityFloor}; use jouleclaw_skill::{ConstantSkillInducer, SkillTier, SKILL_JOULES}; + fn reading(uj: u64, provenance: Provenance) -> Result { + Ok(EnergyReading { uj, timestamp_ns: 0, domain: jouleclaw_energy::EnergyDomain::SocTotal, provenance }) + } + + #[test] + fn a_window_is_the_delta_at_its_readings_provenance() { + let est = Provenance::Estimator; + assert_eq!(window_joules(reading(1_000_000, est), reading(3_500_000, est)), Ok((2.5, est))); + let mb = Provenance::ModelBased; + assert_eq!(window_joules(reading(7, mb), reading(250_007, mb)), Ok((0.25, mb))); + } + + #[test] + fn a_window_without_two_good_readings_is_never_zero() { + let est = Provenance::Estimator; + let failed: Result = Err("no joule counter available".into()); + let why = window_joules(failed.clone(), reading(9_000_000, est)).unwrap_err(); + assert!(why.contains("read failed before the window"), "{why}"); + let why = window_joules(reading(9_000_000, est), failed).unwrap_err(); + assert!(why.contains("read failed after the window"), "{why}"); + let why = window_joules(reading(5, est), reading(4, est)).unwrap_err(); + assert!(why.contains("went backwards"), "{why}"); + let why = window_joules(reading(5, Provenance::ModelBased), reading(9, est)).unwrap_err(); + assert!(why.contains("disagree about provenance"), "{why}"); + let why = window_joules(reading(541_893, Provenance::ModelBased), reading(541_893, Provenance::ModelBased)) + .unwrap_err(); + assert!(why.contains("registered no energy"), "{why}"); + } + + #[test] + fn an_estimate_is_never_reported_as_measured() { + assert_eq!(joules_obtained(Provenance::Estimator), "estimated"); + assert_eq!(joules_obtained(Provenance::ModelBased), "measured"); + assert_eq!(joules_obtained(Provenance::HwShunt), "measured"); + } + fn q(text: &str) -> Query { Query { input: QueryInput::Text(text.to_string()), -- 2.54.0 (Apple Git-157) From 66efc6c5699a3f0b55af0bbe4c396f5348e89842 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:54:39 -0400 Subject: [PATCH 06/23] mesh-meter: a declined or failed family meter is estimated, never a zero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family contract: measured when the platform exposes it, estimated when it doesn't, and the provenance always says which. mesh-meter's HwMeter bridges jouleclaw-energy's detect_meter() into Mesh's Meter/Session, and seals each span's Energy into the mesh/1 receipt. What was asked: confirm that on a host where b9d7e945 declines the IOReport counter, the bridge takes the no-hardware path and not a zero ModelBased reading. It does, as long as the composite reads. detect_meter() puts the Apple load estimator behind the declined counter, and every span says Estimator (on both Macs: "meter=apple-silicon energy=Energy { measured_uj: None, estimated_uj: 2000000, provenance: Estimator }"). What the check found. The bridge turned any failed read into `Sample { uj: 0, provenance: Estimator }`, and a Session's delta is `end - start` at the end sample's provenance: - An unavailable counter with nothing behind it (CompositeMeter over the IOReport counter alone, declined) reads Err every time. Every span came out `estimated_uj: 0`: a zero, billed as an estimate. - A span whose opening read failed and whose closing read succeeded was billed the counter's whole cumulative value at the counter's provenance (with a RAPL-sized history, 1,000,000,001,000 µJ tagged ModelBased in the test). - A span whose closing read failed was billed 0. The fix. When a read of the backend fails, the bridge takes this crate's no-hardware path from then on: EstimatorMeter, tagged Estimator, continuing from the backend's last good reading, so a span across the failure is billed an estimate of its own length. It never bills zero, never the counter's history, and never runs backwards. The fallback is sticky: once a read has failed, the backend is not trusted again in this meter. Mixing its readings back in would bill whichever span straddled the switch the difference between two unrelated counters. `active_name()`/`name()` report "estimator" once it has fallen back. The module and HwMeter docs now say the bridge can resolve to an estimator (on Apple Silicon whose CPU rail does not advance, it does). mesh-core, the only consumer (its demo): `--features hw-meter-apple` (and -rapl, which the README documents, and -nvml) built the backend but did not turn on mesh-core's own `hw-meter` feature, which is what the demo checks, so the demo ran Mesh's 15 W EstimatorMeter and never the family meter. Each platform feature now implies hw-meter, and a compile_error! stops a build where one is on without it. `cargo run -p mesh-core --example demo --features hw-meter-apple` now prints "meter: apple-silicon" and seals "energy_provenance": "Estimator" (it printed "meter: estimator" before). Tests (mesh-meter: 2 without features, 8 with hw-meter, 10 with hw-meter-apple, M5 Max; the hw-meter-apple binary also on the M3 Ultra, 10 passed; mesh-core 3/3 with hw-meter-apple): - an_unavailable_counter_takes_the_no_hardware_path_never_a_modelbased_zero: a backend that is not available and whose read would claim a ModelBased zero. - the_real_ioreport_counter_when_declined_takes_the_no_hardware_path (hw-meter-apple, macOS): the real AppleIoReportCounter bridged alone; available exactly when ioreport_status() is Live, and otherwise an Estimator span, no measured figure, non-zero. Both machines: "ioreport= CpuRailFrozen available=false meter=estimator energy=Energy { measured_uj: None, estimated_uj: 305126 .. }" (M3 Ultra: 450329). - on_this_host_the_bridge_is_as_honest_as_the_family_meter (hw-meter-apple): detect() on this host; IOReport only when Live, else Estimator. - a_read_that_fails_mid_span_is_estimated_never_zero, a_span_that_opens_on_a_failed_read_is_not_billed_the_counters_history, a_backend_that_never_reads_is_estimated, once_a_read_fails_the_bridge_estimates_and_never_runs_backwards, a_backend_that_reads_passes_through_unchanged: a scripted backend. These run only with hw-meter; CI's mesh-rs job builds without features. Falsified, each mutation applied to this commit and restored with git checkout: - FM1, main's snapshot (a failed read is `uj: 0`, Estimator): 6 tests fail, among them the real declined counter ("meter=none .. estimated_uj: 0") and the history case ("estimated_uj: 1000000001000, provenance: ModelBased"). - FM2, the backend trusted again after a failure: 2 fail, e.g. "the backend is not trusted again" (left: ModelBased). - FM3, hw-meter-apple no longer implying hw-meter: mesh-core does not compile ("a hw-meter-* feature must enable mesh-core's `hw-meter` feature"). clippy --no-deps --tests --examples on mesh-meter and mesh-core, with no features, hw-meter, hw-meter-apple and hw-meter-rapl: clean. Not done here, deliberately: - The mesh-rs CI job runs `cargo test --workspace` with no features, so none of the hw tests above run in CI. Adding a feature leg means editing .github/workflows. - mesh-core's module doc still says Session makes the figure "measured, not self-reported"; with the default EstimatorMeter it is an estimate, as the receipt's provenance says. That predates this branch. Co-Authored-By: Claude Opus 5.5 --- mesh/mesh-rs/crates/mesh-core/Cargo.toml | 10 +- mesh/mesh-rs/crates/mesh-core/src/lib.rs | 9 + mesh/mesh-rs/crates/mesh-meter/src/lib.rs | 342 ++++++++++++++++++++-- 3 files changed, 335 insertions(+), 26 deletions(-) diff --git a/mesh/mesh-rs/crates/mesh-core/Cargo.toml b/mesh/mesh-rs/crates/mesh-core/Cargo.toml index 1a8d8c7aa..c6653f6e1 100644 --- a/mesh/mesh-rs/crates/mesh-core/Cargo.toml +++ b/mesh/mesh-rs/crates/mesh-core/Cargo.toml @@ -24,11 +24,13 @@ serde_json = { workspace = true } [features] default = [] -# Forwarded to mesh-meter: use the family's hardware-backed meter. +# Forwarded to mesh-meter: use the family's hardware-backed meter. Each platform +# feature implies `hw-meter`, which is what the demo checks: without it +# `--features hw-meter-rapl` built the backend and then used the estimator. hw-meter = ["mesh-meter/hw-meter"] -hw-meter-rapl = ["mesh-meter/hw-meter-rapl"] -hw-meter-nvml = ["mesh-meter/hw-meter-nvml"] -hw-meter-apple = ["mesh-meter/hw-meter-apple"] +hw-meter-rapl = ["hw-meter", "mesh-meter/hw-meter-rapl"] +hw-meter-nvml = ["hw-meter", "mesh-meter/hw-meter-nvml"] +hw-meter-apple = ["hw-meter", "mesh-meter/hw-meter-apple"] [lints] workspace = true diff --git a/mesh/mesh-rs/crates/mesh-core/src/lib.rs b/mesh/mesh-rs/crates/mesh-core/src/lib.rs index f6bc6d6b5..86e2960bc 100644 --- a/mesh/mesh-rs/crates/mesh-core/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-core/src/lib.rs @@ -15,6 +15,15 @@ #![forbid(unsafe_code)] +// A platform meter feature must select the family meter, not only build its +// backend: callers (the demo among them) gate on `hw-meter`, and without it the +// documented `--features hw-meter-rapl` built RAPL and then ran the estimator. +#[cfg(all( + any(feature = "hw-meter-rapl", feature = "hw-meter-nvml", feature = "hw-meter-apple"), + not(feature = "hw-meter") +))] +compile_error!("a hw-meter-* feature must enable mesh-core's `hw-meter` feature"); + use std::collections::HashMap; use ed25519_dalek::SigningKey; diff --git a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs index b0120fa6a..0f2077743 100644 --- a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs @@ -11,8 +11,11 @@ //! `Estimator` provenance. The default so the foundation is OS-neutral. //! * [`ManualMeter`] — caller-driven, for tests and replaying recorded spans. //! -//! With the `hw-meter` feature, [`HwMeter`] bridges the family's hardware meter -//! (RAPL / NVML / Apple Silicon) so a span on real silicon yields `HwShunt`. +//! With the `hw-meter` feature, [`HwMeter`] bridges the family's meter (RAPL / +//! NVML / Apple Silicon IOReport, else the family's estimator), so a span carries +//! the provenance of whatever actually metered it: `HwShunt` from a hardware +//! counter, `ModelBased` from Apple's energy model, `Estimator` where the host +//! exposes neither. #![forbid(unsafe_code)] @@ -197,31 +200,74 @@ impl Meter for SteppingMeter { #[cfg(feature = "hw-meter")] mod hw { - use super::{Meter, Sample}; + use std::sync::{Mutex, MutexGuard}; + + use super::{EstimatorMeter, Meter, Sample}; use jouleclaw_energy::meter::{detect_meter, CompositeMeter}; use jouleclaw_energy::Provenance as JpProvenance; use mesh_schema::Provenance; - /// Bridges the family's hardware-backed [`CompositeMeter`] (RAPL / NVML / - /// Apple Silicon, with an estimator fallback) into the Mesh [`Meter`] - /// interface. The active backend's provenance is mapped through unchanged, - /// so honesty is preserved across the bridge. + /// [`EstimatorMeter`]'s name: what [`HwMeter`] reports once it has fallen back. + const FALLBACK_NAME: &str = "estimator"; + + /// Bridges the family's [`CompositeMeter`] (RAPL / NVML / Apple Silicon + /// IOReport where the host exposes a live counter, else the family's + /// estimator) into the Mesh [`Meter`] interface. The active backend's + /// provenance is mapped through unchanged, so honesty is preserved across the + /// bridge. On Apple Silicon whose IOReport CPU energy rail does not advance + /// (macOS 27.0.1 on an M5 Max and an M3 Ultra), the active backend is the load + /// estimator and spans say `Estimator`. + /// + /// If the backend fails a read, the bridge takes this crate's no-hardware path + /// from then on: an [`EstimatorMeter`], continuing from the last good reading, + /// tagged `Estimator`. A failed read is never a zero reading, which would bill a + /// span nothing, or bill it the counter's whole history. pub struct HwMeter { inner: CompositeMeter, + fallback: EstimatorMeter, + state: Mutex, + } + + /// Where the fallback estimate picks up. + #[derive(Default)] + struct Fallback { + /// A read of the backend has failed; every snapshot since is an estimate. + failed: bool, + /// The backend's last good reading, µJ. + last_uj: u64, + /// The fallback estimator's cumulative figure at that reading, µJ. + fallback_at_last_uj: u64, } impl HwMeter { /// Detect and wrap the best meter for this platform. pub fn detect() -> Self { + Self::bridging(detect_meter()) + } + + fn bridging(inner: CompositeMeter) -> Self { Self { - inner: detect_meter(), + inner, + fallback: EstimatorMeter::default(), + state: Mutex::new(Fallback::default()), } } - /// The active backend's short name (`"rapl"`, `"nvml"`, `"estimation"`, - /// `"none"`). + /// The active backend's short name (`"rapl"`, `"nvml"`, `"apple-ioreport"`, + /// `"apple-silicon"`, `"estimation"`, `"none"`), or `"estimator"` once a + /// read has failed and the bridge estimates. pub fn active_name(&self) -> &'static str { - self.inner.active_name() + if self.state().failed { + FALLBACK_NAME + } else { + self.inner.active_name() + } + } + + fn state(&self) -> MutexGuard<'_, Fallback> { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) } } @@ -235,21 +281,273 @@ mod hw { impl Meter for HwMeter { fn name(&self) -> &str { - self.inner.active_name() + self.active_name() } fn snapshot(&self) -> Sample { - match self.inner.read() { - Ok(r) => Sample { - uj: r.uj, - provenance: map_prov(r.provenance), - }, - // No meter available: report a zero estimator reading rather - // than panic; the resulting delta is honestly tagged Estimator. - Err(_) => Sample { + let mut state = self.state(); + if !state.failed { + match self.inner.read() { + Ok(r) => { + state.last_uj = r.uj; + state.fallback_at_last_uj = self.fallback.snapshot().uj; + return Sample { + uj: r.uj, + provenance: map_prov(r.provenance), + }; + } + Err(_) => state.failed = true, + } + } + // The backend could not be read: estimate from here on, continuing + // from its last good reading, so a span across the failure is billed an + // estimate of its own length — never zero, never the counter's history. + let since = self + .fallback + .snapshot() + .uj + .saturating_sub(state.fallback_at_last_uj); + Sample { + uj: state.last_uj.saturating_add(since), + provenance: Provenance::Estimator, + } + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::Session; + use jouleclaw_energy::meter::EnergyMeter; + use jouleclaw_energy::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading}; + use std::sync::atomic::{AtomicU64, Ordering}; + use std::time::Duration; + + /// A cumulative counter with a long history: 1 MJ, in µJ. + const BASE: u64 = 1_000_000_000_000; + /// µJ per read. + const STEP: u64 = 1_000; + + /// A backend whose read number `n` (from 0) fails when `fails(n)`; a good + /// read returns `BASE + n·STEP` µJ, `ModelBased`. + struct Flaky { + reads: AtomicU64, + fails: fn(u64) -> bool, + } + + impl EnergyCounter for Flaky { + fn domain(&self) -> EnergyDomain { + EnergyDomain::SocTotal + } + fn provenance(&self) -> JpProvenance { + JpProvenance::ModelBased + } + fn resolution_uj(&self) -> u64 { + 1 + } + fn min_window_ns(&self) -> u64 { + 1 + } + fn read(&self) -> Result { + let n = self.reads.fetch_add(1, Ordering::SeqCst); + if (self.fails)(n) { + return Err(EnergyError::Platform("sample failed".into())); + } + Ok(EnergyReading { + uj: BASE + n * STEP, + timestamp_ns: 0, + domain: EnergyDomain::SocTotal, + provenance: JpProvenance::ModelBased, + }) + } + } + + impl EnergyMeter for Flaky { + fn name(&self) -> &'static str { + "flaky" + } + fn is_available(&self) -> bool { + true + } + } + + /// A backend that is not available, as the IOReport counter is on a host whose + /// CPU energy rail does not advance. Its read would claim a `ModelBased` zero; + /// the bridge must never get there. + struct Declined; + + impl EnergyCounter for Declined { + fn domain(&self) -> EnergyDomain { + EnergyDomain::SocTotal + } + fn provenance(&self) -> JpProvenance { + JpProvenance::ModelBased + } + fn resolution_uj(&self) -> u64 { + 1 + } + fn min_window_ns(&self) -> u64 { + 1 + } + fn read(&self) -> Result { + Ok(EnergyReading { uj: 0, - provenance: Provenance::Estimator, - }, + timestamp_ns: 0, + domain: EnergyDomain::SocTotal, + provenance: JpProvenance::ModelBased, + }) + } + } + + impl EnergyMeter for Declined { + fn name(&self) -> &'static str { + "declined" + } + fn is_available(&self) -> bool { + false + } + } + + fn bridge(fails: fn(u64) -> bool) -> HwMeter { + let flaky = Flaky { + reads: AtomicU64::new(0), + fails, + }; + HwMeter::bridging(CompositeMeter::new(vec![Box::new(flaky)])) + } + + /// A 20 ms span on `m`. + fn span(m: &HwMeter) -> mesh_schema::Energy { + let s = Session::start(m); + std::thread::sleep(Duration::from_millis(20)); + s.stop() + } + + #[test] + fn a_read_that_fails_mid_span_is_estimated_never_zero() { + let m = bridge(|n| n >= 1); // the span's closing read fails + let e = span(&m); + assert_eq!(e.provenance, Provenance::Estimator, "{e:?}"); + assert_eq!(e.measured_uj, None); + assert!(e.estimated_uj > 0, "a 20 ms span is not free: {e:?}"); + assert!(e.check_provenance().is_ok()); + assert_eq!(m.name(), EstimatorMeter::default().name()); + } + + #[test] + fn a_span_that_opens_on_a_failed_read_is_not_billed_the_counters_history() { + let m = bridge(|n| n == 0); // only the opening read fails; the backend recovers + let e = span(&m); + assert_eq!(e.provenance, Provenance::Estimator, "{e:?}"); + assert!(e.estimated_uj > 0 && e.estimated_uj < BASE, "{e:?}"); + } + + #[test] + fn a_backend_that_never_reads_is_estimated() { + let e = span(&bridge(|_| true)); + assert_eq!(e.provenance, Provenance::Estimator, "{e:?}"); + assert!(e.estimated_uj > 0, "{e:?}"); + } + + #[test] + fn once_a_read_fails_the_bridge_estimates_and_never_runs_backwards() { + let m = bridge(|n| n == 1); + let good = m.snapshot(); + assert_eq!((good.uj, good.provenance), (BASE, Provenance::ModelBased)); + let mut prev = good.uj; + for _ in 0..3 { + std::thread::sleep(Duration::from_millis(2)); + let s = m.snapshot(); + assert_eq!( + s.provenance, + Provenance::Estimator, + "the backend is not trusted again" + ); + assert!(s.uj >= prev, "{} then {}", prev, s.uj); + prev = s.uj; + } + } + + /// An unavailable counter takes this crate's no-hardware path: spans say + /// `Estimator`, carry no measured figure and are not free — never the + /// `ModelBased` zero the declined counter would have read. + #[test] + fn an_unavailable_counter_takes_the_no_hardware_path_never_a_modelbased_zero() { + let m = HwMeter::bridging(CompositeMeter::new(vec![Box::new(Declined)])); + let e = span(&m); + assert_eq!(e.provenance, Provenance::Estimator, "{e:?}"); + assert_eq!(e.measured_uj, None); + assert!(e.estimated_uj > 0, "a 20 ms span is not free: {e:?}"); + assert_eq!(m.name(), EstimatorMeter::default().name()); + } + + /// The same with the real IOReport counter, bridged alone (no estimator behind + /// it in the composite): where its CPU rail is not live (macOS 27.0.1 on an + /// M5 Max and an M3 Ultra) it is unavailable, and the bridge estimates. + #[cfg(all(feature = "hw-meter-apple", target_os = "macos"))] + #[test] + fn the_real_ioreport_counter_when_declined_takes_the_no_hardware_path() { + let status = jouleclaw_energy::ioreport_status(); + let counter = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); + let available = counter.is_available(); + let m = HwMeter::bridging(CompositeMeter::new(vec![Box::new(counter)])); + let e = span(&m); + println!("mesh-meter, IOReport bridged alone: ioreport={status:?} available={available} meter={} energy={e:?}", m.name()); + assert_eq!(available, status == jouleclaw_energy::IoReportStatus::Live); + if available { + assert_eq!((m.name(), e.provenance), ("apple-ioreport", Provenance::ModelBased)); + } else { + assert_eq!(m.name(), EstimatorMeter::default().name()); + assert_eq!((e.provenance, e.measured_uj), (Provenance::Estimator, None)); + assert!(e.estimated_uj > 0, "{e:?}"); + } + } + + #[test] + fn a_backend_that_reads_passes_through_unchanged() { + let m = bridge(|_| false); + let e = span(&m); + assert_eq!( + (e.provenance, e.estimated_uj), + (Provenance::ModelBased, STEP) + ); + assert_eq!(m.name(), "flaky"); + } + + /// This host, with `hw-meter-apple`: the bridge says what the family meter + /// says. IOReport only where its CPU rail is live; elsewhere (macOS 27.0.1 on + /// an M5 Max and an M3 Ultra) the estimator, whose spans say `Estimator`. + #[cfg(feature = "hw-meter-apple")] + #[test] + fn on_this_host_the_bridge_is_as_honest_as_the_family_meter() { + let status = jouleclaw_energy::ioreport_status(); + let m = HwMeter::detect(); + let s = Session::start(&m); + let t = std::time::Instant::now(); + let mut x = 1u64; + while t.elapsed() < Duration::from_millis(50) { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + } + std::hint::black_box(x); + let e = s.stop(); + println!( + "mesh-meter on this host: ioreport={status:?} meter={} energy={e:?}", + m.name() + ); + if status == jouleclaw_energy::IoReportStatus::Live { + assert_eq!(m.name(), "apple-ioreport"); + assert_eq!(e.provenance, Provenance::ModelBased); + } else { + assert_ne!( + m.name(), + "apple-ioreport", + "IOReport is used only when its CPU rail is live" + ); + } + if matches!(m.name(), "apple-silicon" | "estimation") { + assert_eq!(e.provenance, Provenance::Estimator); + assert_eq!(e.measured_uj, None); + assert!(e.estimated_uj > 0, "{e:?}"); } } } -- 2.54.0 (Apple Git-157) From e4b200748cb295d57d4563b7361b171436454a6d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:57:20 -0400 Subject: [PATCH 07/23] l3-jouleclaw: the receipt says which meter ran and why, and an estimate is never "host-metered" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family contract: measured when the platform exposes it, estimated when it doesn't, and the provenance always says which. The L3 binding's `run` binary meters the host across a model inference and puts the figure on a Joule Code receipt. The defect (b9d7e945, unchanged since origin/main 54e89b56): - It printed every counter figure that was not HwShunt as "(, host-metered)". Since b9d7e945 declines IOReport on both of our Macs, the counter it gets is the load-average estimator, so the printout read "(Estimator, host-metered)": an estimate presented as metered. - The comment said the estimator runs "only if IOReport will not open". It now also runs when IOReport opens but its CPU rail does not advance, which is the case on both machines, and nothing said which. - The delta was `a.saturating_sub(b)` with the after-reading's provenance: a counter going backwards gave a 0, and so did one that registered nothing. A before/after pair with different provenance was labelled with the second. The fix. - receipt_energy() takes the counter's delta at its provenance only if both reads succeeded, agree about provenance, and the delta is positive (no inference runs on zero energy). Otherwise the receipt carries the token-count estimate, which is what it already did without a counter. HwShunt carries measured_uj; every other provenance carries none, as the receipt schema requires. - The printout adds "meter : (IOReport: )", and the energy line names how the figure was obtained: "measured by " (HwShunt), "host-metered by (energy model)" (ModelBased), "estimated by from host load" (Estimator), or "estimated from token counts". - host_counter returns the meter as an EnergyMeter so the printout can name it, and its comment says when IOReport is used. Observed, M5 Max, a real model (`JOULE_CODE_L3_ENGINE=llama`, gemma-4-E2B q4_k_m): meter : apple-silicon (IOReport: CpuRailFrozen) tokens : in=280 out=255 energy : 180.091 J (Estimator, estimated by apple-silicon from host load) Tests (the `run` bin, 4; the lib's 2 and the 4 model-gated ignored tests are unchanged). CI's joule-code-conformance job runs `cargo test` here on its Linux runner, where there is no counter: - a_counter_reading_keeps_its_provenance, an_estimate_is_never_printed_as_metered. - without_two_readings_the_receipt_is_the_token_estimate: missing reads, a counter going backwards, one that registered nothing (a held ModelBased rail included), readings that disagree about provenance. - on_this_host_the_estimator_fallback_runs_unless_ioreport_is_live: IOReport only where Live; on Apple Silicon otherwise the estimator meters a 20 ms burn (non-zero, Estimator, "estimated by apple-silicon from host load"); with no counter (Linux) the receipt is exactly the token estimate. M5 Max and, from the same binary, M3 Ultra: "ioreport=CpuRailFrozen meter=apple-silicon energy=Energy { measured_uj: None, estimated_uj: 670069, provenance: Estimator }" (M3 Ultra: 198232). Falsified, each mutation applied to this commit and restored with git checkout: - FX1, an estimate printed "host-metered by ": 2 tests fail ("left: host-metered by apple-silicon"). - FX2, a zero delta taken as the figure: fails on "Some((400, Estimator)) → Some((400, Estimator))". - FX3, disagreeing provenance ignored: fails on "Some((100, ModelBased)) → Some((400, Estimator))". - FX4, IOReport used although declined: "IOReport is used only when its CPU rail is live". clippy --no-deps --tests --bins: nothing in src/bin/run.rs (the lib's one warning predates this). `cargo check --tests --bins --target x86_64-unknown-linux-gnu`: clean. Co-Authored-By: Claude Opus 5.5 --- .../examples/l3-jouleclaw/src/bin/run.rs | 189 ++++++++++++++---- 1 file changed, 153 insertions(+), 36 deletions(-) diff --git a/joule-code/examples/l3-jouleclaw/src/bin/run.rs b/joule-code/examples/l3-jouleclaw/src/bin/run.rs index 2d685d3ab..83e386952 100644 --- a/joule-code/examples/l3-jouleclaw/src/bin/run.rs +++ b/joule-code/examples/l3-jouleclaw/src/bin/run.rs @@ -7,12 +7,16 @@ //! By default it runs the model **in-process on JouleClaw's own from-scratch GGUF //! tensor runtime** (`jouleclaw-runtime`); set `JOULE_CODE_L3_ENGINE=llama` to use //! the `llama.cpp` shell-out backend instead. Either way it content-addresses the -//! result as a `CodingOp` and meters the **host** energy spent during the -//! inference with JouleClaw's energy counter — so the receipt's joule cost is a -//! real local measurement (honestly tagged with the counter's provenance), not a -//! datacenter token estimate. +//! result as a `CodingOp` and puts the **host** energy spent during the inference +//! on the receipt, from JouleClaw's best counter on this host and tagged with that +//! counter's provenance: a measurement where the host exposes a live counter +//! (IOReport's energy model on Apple Silicon whose CPU rail advances), an estimate +//! from host load where it does not (Apple Silicon whose IOReport CPU rail is +//! frozen, as on macOS 27.0.1), and the token-count estimate where there is no +//! counter at all. The printed receipt says which. -use jouleclaw_energy::{EnergyCounter, Provenance as JcProvenance}; +use jouleclaw_energy::meter::EnergyMeter; +use jouleclaw_energy::Provenance as JcProvenance; use jouleclaw_llm::{LlmBackend, LlmRequest}; use joule_code_cargo::{Anchor, CodingOp, Edit}; @@ -23,11 +27,13 @@ use l3_jouleclaw_binding::llama_backend::LlamaCompletionBackend; use l3_jouleclaw_binding::runtime_backend::JouleclawRuntimeBackend; /// The host's best energy counter, or None where unavailable (e.g. CI/Linux). -fn host_counter() -> Option> { +fn host_counter() -> Option> { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { - // Prefer the real IOReport energy-model counter (ModelBased); the - // load-avg estimator is only a last resort if IOReport will not open. + // The IOReport energy-model counter (ModelBased) where it can meter CPU + // work: it is unavailable unless IOReport opens AND its CPU energy rail + // advances under load (`jouleclaw_energy::ioreport_status()` says why not). + // Otherwise the load-average estimator (Estimator). let ioreport = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); if ioreport.is_available() { return Some(Box::new(ioreport)); @@ -40,6 +46,39 @@ fn host_counter() -> Option> { None } +/// The receipt energy for an inference: the host counter's delta across it, with +/// the counter's provenance, or — without a counter, or without two readings of +/// it — the token-count estimate. `true` when the figure came from the counter. +/// A counter that went backwards, one that registered nothing across the +/// inference (no inference runs on zero energy), or readings that disagree about +/// provenance give no counter figure: never a zero. +fn receipt_energy( + before: Option<(u64, JcProvenance)>, + after: Option<(u64, JcProvenance)>, + input_tokens: u64, + output_tokens: u64, +) -> (Energy, bool) { + if let (Some((b, before_prov)), Some((a, prov))) = (before, after) { + if let Some(uj) = a.checked_sub(b).filter(|uj| *uj > 0 && before_prov == prov) { + let prov = map_provenance(prov); + let measured_uj = (prov == Provenance::HwShunt).then_some(uj); + return (Energy { measured_uj, estimated_uj: uj, provenance: prov }, true); + } + } + (Energy::from_tokens(input_tokens, output_tokens), false) +} + +/// How the receipt's energy was obtained, for the printout: by which counter, or +/// from token counts. An estimate is never called metered. +fn energy_note(energy: &Energy, counter: &str, from_counter: bool) -> String { + match (from_counter, energy.provenance) { + (false, _) => "estimated from token counts".to_string(), + (true, Provenance::HwShunt) => format!("measured by {counter}"), + (true, Provenance::ModelBased) => format!("host-metered by {counter} (energy model)"), + (true, Provenance::Estimator) => format!("estimated by {counter} from host load"), + } +} + fn map_provenance(p: JcProvenance) -> Provenance { match p { JcProvenance::HwShunt => Provenance::HwShunt, @@ -77,10 +116,11 @@ fn main() { }, }; let counter = host_counter(); + let counter_name = counter.as_ref().map_or("none", |c| c.name()); // Meter host energy across the inference. Prime the counter once so the // first-sample init delta is excluded from the measured window. - let read = |c: &Option>| { + let read = |c: &Option>| { c.as_ref() .and_then(|c| c.read().ok()) .map(|r| (r.uj, r.provenance)) @@ -113,27 +153,14 @@ fn main() { }; let op_id = op.op_id().expect("op_id"); - // Receipt energy: real measured host energy if a counter was available, - // else the token-based estimate (honest fallback). - let energy = match (before, after) { - (Some((b, _)), Some((a, prov))) => { - let uj = a.saturating_sub(b); - let prov = map_provenance(prov); - match prov { - Provenance::HwShunt => Energy { - measured_uj: Some(uj), - estimated_uj: uj, - provenance: prov, - }, - _ => Energy { - measured_uj: None, - estimated_uj: uj, - provenance: prov, - }, - } - } - _ => Energy::from_tokens(response.input_tokens as u64, response.output_tokens as u64), - }; + // Receipt energy: the host counter's figure at its provenance, else the + // token-based estimate (honest fallback). + let (energy, from_counter) = receipt_energy( + before, + after, + response.input_tokens as u64, + response.output_tokens as u64, + ); let receipt = Receipt { op_id: op_id.clone(), tier: Tier::L3, @@ -151,18 +178,108 @@ fn main() { println!("op_id : {op_id}"); println!("edits : {} (GENERATED.rs)", op.edits.len()); println!("tier : {}", receipt.tier); + println!( + "meter : {counter_name} (IOReport: {:?})", + jouleclaw_energy::ioreport_status() + ); println!( "tokens : in={} out={}", response.input_tokens, response.output_tokens ); println!( - "energy : {:.3} J ({:?}{})", + "energy : {:.3} J ({:?}, {})", receipt.energy.estimated_uj as f64 / 1_000_000.0, receipt.energy.provenance, - if receipt.energy.measured_uj.is_some() { - ", measured" - } else { - ", host-metered" - } + energy_note(&receipt.energy, counter_name, from_counter) ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_counter_reading_keeps_its_provenance() { + let (e, from_counter) = + receipt_energy(Some((100, JcProvenance::HwShunt)), Some((400, JcProvenance::HwShunt)), 7, 9); + assert!(from_counter); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(300), 300, Provenance::HwShunt)); + for (jc, p) in [(JcProvenance::ModelBased, Provenance::ModelBased), (JcProvenance::Estimator, Provenance::Estimator)] { + let (e, from_counter) = receipt_energy(Some((100, jc)), Some((400, jc)), 7, 9); + assert!(from_counter); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 300, p)); + assert!(e.check_provenance().is_ok()); + } + } + + #[test] + fn without_two_readings_the_receipt_is_the_token_estimate() { + let tokens = Energy::from_tokens(7, 9); + let est = JcProvenance::Estimator; + for (before, after) in [ + (None, None), + (None, Some((400, est))), + (Some((100, est)), None), + (Some((400, est)), Some((100, est))), // went backwards + (Some((400, est)), Some((400, est))), // registered nothing + (Some((9, JcProvenance::ModelBased)), Some((9, JcProvenance::ModelBased))), // a held rail + (Some((100, JcProvenance::ModelBased)), Some((400, est))), // disagree + ] { + let (e, from_counter) = receipt_energy(before, after, 7, 9); + assert!(!from_counter, "{before:?} → {after:?}"); + assert_eq!(e, tokens); + } + } + + #[test] + fn an_estimate_is_never_printed_as_metered() { + let est = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::Estimator }; + assert_eq!(energy_note(&est, "apple-silicon", true), "estimated by apple-silicon from host load"); + assert_eq!(energy_note(&est, "none", false), "estimated from token counts"); + let modeled = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::ModelBased }; + assert_eq!(energy_note(&modeled, "apple-ioreport", true), "host-metered by apple-ioreport (energy model)"); + let measured = Energy { measured_uj: Some(5), estimated_uj: 5, provenance: Provenance::HwShunt }; + assert_eq!(energy_note(&measured, "rapl", true), "measured by rapl"); + } + + /// On this host: the IOReport counter is used only where its CPU rail is live. + /// Elsewhere on Apple Silicon (macOS 27.0.1 on an M5 Max and an M3 Ultra) the + /// load estimator runs and meters the burn, and the receipt says Estimator; with + /// no counter at all (CI's Linux runner), the token estimate does. + #[test] + fn on_this_host_the_estimator_fallback_runs_unless_ioreport_is_live() { + let status = jouleclaw_energy::ioreport_status(); + let counter = host_counter(); + let name = counter.as_ref().map_or("none", |c| c.name()); + let read = || counter.as_ref().and_then(|c| c.read().ok()).map(|r| (r.uj, r.provenance)); + let _ = read(); // prime, as main does + let before = read(); + let t = std::time::Instant::now(); + let mut x = 1u64; + while t.elapsed() < std::time::Duration::from_millis(20) { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + } + std::hint::black_box(x); + let after = read(); + let (e, from_counter) = receipt_energy(before, after, 7, 9); + let note = energy_note(&e, name, from_counter); + println!("l3 on this host: ioreport={status:?} meter={name} energy={e:?} ({note})"); + if status == jouleclaw_energy::IoReportStatus::Live { + assert_eq!(name, "apple-ioreport"); + assert_eq!(e.provenance, Provenance::ModelBased); + } else { + assert_ne!(name, "apple-ioreport", "IOReport is used only when its CPU rail is live"); + assert_eq!(e.provenance, Provenance::Estimator); + assert_eq!(e.measured_uj, None); + assert!(note.starts_with("estimated"), "{note}"); + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert_eq!(name, "apple-silicon"); + assert!(from_counter && e.estimated_uj > 0, "the estimator metered 20 ms of burn: {e:?}"); + assert_eq!(note, "estimated by apple-silicon from host load"); + } else { + assert_eq!((name, from_counter), ("none", false)); + assert_eq!(e, Energy::from_tokens(7, 9)); + } + } + } +} -- 2.54.0 (Apple Git-157) From 073fb7d81ba63990b600a1fc194e0e07977ecc1e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 08:01:34 -0400 Subject: [PATCH 08/23] jouleclaw-energy: each probe sample is compared with the one before it, rail by rail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The family 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. On macOS 27.0.1 the IOReport CPU rails are refreshed in rare batches, so this crate's liveness probe declines the counter unless one CPU rail steps in two separate intervals. The rule was right. What an independent verification of 1a477bc8 found is that the parts around it could be broken without any test noticing. What was wrong: - Only the pure RailWatch was tested. The loop that pairs samples, where the original defect lived, and the FFI mapping that feeds it were not. Two mutations passed all 40 tests on both Macs: the real probe loop breaking after its first interval (probe_took fell to 138 ms), and every CPU rail collapsed into one synthetic "any rail advanced" rail at the FFI mapping. On a multi-die host whose dies refresh in different intervals, the second reports Live, which is the over-claim the rule exists to prevent. The family's shared mutation, comparing every sample with the first one, would have passed as well, because nothing tested the pairing. - RailWatch keyed rails by name. The M3 Ultra's Energy Model group has 645 channels and 264 duplicated names. The CPU aggregates are unique there today, but two same-named rails stepping in one refresh would have counted as two advances of one rail. - The test oracle ran its CPU burn on the calling thread, in 50 ms slices between blocking all-channel reads of 0.1-0.8 s each. The window was under load 13-18% of the time on the M5 Max and 27% on the M3 Ultra. Two claims were false. Its doc said it "burns CPU for `budget`, reading as it goes". b9d7e945's message said the oracle "burns for three times the probe budget". Both are true now, of a helper thread. b9d7e945 also said "Counting per rail keeps two dies' separate jumps from adding up". That held only for rails with different names. The fix follows the family test design. - D1, the sample layer. RailWatch::observe takes a whole sample as (channel name, absolute raw integer) and keeps the CPU rails: `CPU Energy`, optionally `DIE__`-prefixed. It keys each rail by (name, ordinal among same-named CPU rails in that sample) and compares it with the PREVIOUS sample. A rail can advance at most once per interval, and only by going up. The FFI side, sample_energy_model, takes one plain IOReportCreateSamples (absolute values; the probe no longer uses delta samples) and extracts (name, raw) for every Energy Model channel. It decides nothing. A sample with no channel array is a failed sample, not a sample with no CPU rail. - D2, the loop. It is generic: watch(sample, tick, now, budget). It takes a baseline, then ticks and samples again while RailWatch::wants_more, and skips failed samples; the next good sample is compared with the last good one. The real probe passes the FFI sampler, a 25 ms spin and Instant. Behaviour change: a failed first sample used to end the probe at once with no verdict. Failed samples are now retried up to the five-budget cap, so a host whose Energy Model group subscribes but never samples pays up to 5 s, once per process, before Unavailable. - D3: status_of maps a verdict to an IoReportStatus. - D4: the real probe counts its runs in a per-thread counter (PROBE_RUNS). Tests that probe on purpose, in parallel, do not disturb it. - The oracle's burn runs on a helper thread. It starts before the oracle's subscription opens and stops after its last read. The calling thread only reads, back to back. The oracle still has its own all-channel subscription, absolute values, ends_with("CPU Energy") in any group, and its own ">= 2 changes" threshold. Tests (50 with --features ioreport, was 40; 24 without, unchanged): - Scripted, no hardware, through watch() with a scripted sampler and clock: (a) the M5 Max refresh 480,083,160 -> 541,893,638 mJ, beside a GPU rail moving every sample, is Frozen; (b) the M3 Ultra's two dies stepping in one sample, beside duplicated ECPUDTL00 names, is Frozen; (c) two channels named `CPU Energy` stepping together once is Frozen; (d) a rail stepping every sample is Live after exactly 2 intervals, with sampling stopped at 3 samples and 2 ticks; (e) samples that always fail run to the 5-budget cap with no verdict (Unavailable), and a failed baseline followed by good samples still gets a verdict; (f) at 600 ms a sample, the probe keeps going past the budget with one interval and stops at two, Frozen; at 3 s a sample it stops at the cap with one; (g) DIE_0 and DIE_1 stepping once each, in different intervals, is Frozen. - Rule tests now feed samples. New: two_same_named_rails_stepping_once_are_ not_a_live_rail, only_a_cpu_rail_going_up_is_an_advance, and each_verdict_maps_to_its_status (D3). - D4, the_liveness_status_is_probed_once_and_cached: three ioreport_status() calls, new(), default() and detect_meter() ran the real probe at most once on the test's thread. It replaces a timing check. - D5, the_liveness_probe_never_reports_a_frozen_rail_as_live: wherever the probe could sample and found no live rail, it ran its whole budget and compared at least 10 intervals. It prints the count. - the_liveness_probe_subscribes_to_the_energy_model_group_only also asserts: - the rails the rule watched, as (name, ordinal), are exactly the group's CPU rails, with the test's own name matching; - unless the verdict is Live, at least 2 intervals and the whole budget. - D6: a_declined_counter_yields_no_reading_and_the_fallback_says_estimator now also runs 150 ms of CPU work through detect_meter(). The figure must be above one quantum of the meter, with Estimator provenance on both readings. Observed. M5 Max (macOS 27.0.1), three runs, 50 passed each: ioreport probe subscription: status=CpuRailFrozen after 30 intervals in 1.096744083s; 364 channels per sample, 0 outside the Energy Model group (all-channel subscription: 11885); one sample 18.000625ms (all-channel 449.545167ms); rails watched [("CPU Energy", 0)] ioreport oracle: 8 reads over a 3s window, CPU burned on a helper thread for 3.085035917s, changes per CPU rail [0] -> CpuRailMoved(false) ioreport probe: probed=CpuRailFrozen cached=CpuRailFrozen oracle=CpuRailMoved(false) intervals=Some(30) probe_took=1.087961791s ioreport declined (CpuRailFrozen): 150.009083ms of CPU work metered by `apple-silicon` as 6000000 µJ, provenance estimator then estimator ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false Intervals were 28-30 across the three runs. The oracle got 3-8 reads, since an all-channel sample took 0.45-1.07 s at the time. M3 Ultra (same test binary, run by scp, then deleted), 50 passed: ... after 32 intervals in 1.100072167s; 645 channels per sample, 0 outside ...; rails watched [("DIE_0_CPU Energy", 0), ("DIE_1_CPU Energy", 0)] ioreport oracle: 17 reads over a 3s window, CPU burned on a helper thread for 3.031725917s, changes per CPU rail [0, 0] -> CpuRailMoved(false) ioreport declined (CpuRailFrozen): 150.007459ms of CPU work metered by `apple-silicon` as 1452465 µJ, provenance estimator then estimator In every run the cache test saw 0 probe runs on its own thread, because another test's thread filled the cache. It pins "at most once"; the D4 mutation below shows 6. Falsified on the final code, each mutation applied, run, and restored with git checkout, with a clean tree after: - Compare every sample with the first (prev.clone() + get_or_insert). 7 fail, e.g. "one refresh counted as a live rail: RailWatch { intervals: 5, ... rails: [(("CPU Energy", 0), 4)] }". - The round-2 survivor M7, the loop stopping after its first interval. 5 fail, including both hardware probe tests: "the probe decided CpuRailFrozen on 1 intervals in ...: it did not re-sample the Energy Model group every ~25ms". - The real probe's clock running 10x fast. 2 fail: "the probe decided CpuRailFrozen on 3 intervals in 225.471167ms". - The real probe given a tenth of its budget. The same 2 fail. - The round-2 survivor M8, the FFI collapsing the CPU rails into a synthetic "total". 3 fail on both Macs: "the probe's rule did not watch the group's CPU rails one by one" and "the probe found no CPU rail, the oracle found one". - The same collapse into one rail named `CPU Energy`. It fails on the M3 Ultra ("rails watched [("CPU Energy", 0)]" / "did not watch the group's CPU rails one by one"). It passes on the M5 Max, where the group has one CPU rail, so the collapse changes nothing there. - The rule keying every CPU rail as one. 5 fail, (b), (c) and (g) among them. - Keying by name alone (ordinal 0). (c) and the same-named rule test fail ("left: Some(Live) right: Some(Frozen)"). - The GPU rail counted as a CPU rail. 8 fail. - A failed sample ending the probe. (e) fails: "gave up at 37ms after 2 samples, before the 5s cap". - Stopping at the budget with fewer than 2 intervals. 3 fail, (f) among them. - Frozen mapped to Live. 4 fail, D3 among them. - No per-process cache. D4 fails: "the real liveness probe ran 6 times: the status is not cached". - PROBE_TICK at 250 ms. D5 fails: "the probe decided CpuRailFrozen on 4 intervals", and (a). - The probe on the all-channel subscription. The group test fails ("11521 channels outside the Energy Model group") and so does D5 ("on 2 intervals in 1.569546042s"). - The estimator apportioning whole seconds only. D6 fails: "150.009209ms of CPU work metered as 0 µJ by `apple-silicon`: a zero or a floor, not a figure". One mutation I wrote was wrong and proved nothing. It moved the real probe's clock a budget ahead, but watch() takes its own origin from that clock, so a constant offset cancels. It passed, and it was replaced by the 10x clock. clippy --no-deps --tests, with and without ioreport: nothing new. The collapsible-if my first draft added is fixed. What remains was there before: the duplicated #[link], sum_energy_uj's b"..\0", ledger.rs's constant asserts and meter.rs's vec-init-then-push. cargo check --tests --examples for x86_64- and aarch64-unknown-linux-gnu, with and without ioreport: clean. Dependants, re-run against this commit on the M5 Max: - jouleclaw-loader-gguf --features "rayon fabric-meter": 302 passed, 2 ignored ("tune_shape_live on this host: ioreport=CpuRailFrozen tuned=None recorded=None"). - jouleclaw-harness-econ: 14 passed. - mesh-meter --features hw-meter-apple: 10 passed. mesh-core --features hw-meter-apple: 3 passed. - l3-jouleclaw: 2 + 4 passed ("estimated by apple-silicon from host load"). - jouledesk + jouledesk-github: 18 + 4 + 12 passed. - The energy_probe example prints "ioreport : declined (CpuRailFrozen)" and "--- estimated ---". ~/.jouleclaw/fabric-calibration.json is untouched: Jun 11 11:02, sha256 792b00e6...b488. Not done here: - No CI job runs this crate with --features ioreport. The workflows are outside this branch; see the report. - The counter's own read() still samples every channel. It is declined on both Macs. Co-Authored-By: Claude Opus 5.5 --- .../jouleclaw-energy/src/apple_ioreport.rs | 748 +++++++++++++----- .../crates/jouleclaw-energy/src/lib.rs | 3 +- 2 files changed, 574 insertions(+), 177 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index bc2b9dbf6..388953991 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -20,11 +20,12 @@ //! `GPU Energy` keeps advancing) and on an M3 Ultra (`DIE__CPU Energy`). //! Summing the rails would then report GPU-only energy, or zero, as the energy //! of CPU work, tagged `ModelBased`. So [`AppleIoReportCounter::new`] is -//! available only when the CPU rail itself keeps advancing, sample after -//! sample, while the probing thread burns CPU — probed once per process and -//! cached, see [`crate::ioreport_status`]. Otherwise the counter is unavailable -//! and yields no reading, [`crate::meter::detect_meter`] falls through to the -//! load-avg estimator, and that reading says `Estimator`. +//! available only when one CPU rail itself keeps advancing — between +//! consecutive samples, in at least two separate intervals — while the probing +//! thread burns CPU; probed once per process and cached, see +//! [`crate::ioreport_status`]. Otherwise the counter is unavailable and yields +//! no reading, [`crate::meter::detect_meter`] falls through to the load-avg +//! estimator, and that reading says `Estimator`. //! //! IOReport is private API. Apple has not deprecated it for M1+ in any released //! macOS; if the surface ever changes, [`AppleIoReportCounter::new`] returns an @@ -311,6 +312,10 @@ const PROBE_TICK: Duration = Duration::from_millis(25); /// live. One is not enough: see [`RailWatch`]. const LIVE_RAIL_ADVANCES: u32 = 2; +/// The probe never runs longer than this many budgets, however slow or failing +/// its samples are. +const PROBE_CAP_BUDGETS: u32 = 5; + /// What the liveness probe saw of the CPU energy rail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CpuRail { @@ -320,11 +325,31 @@ enum CpuRail { /// The rail exists but held its value across the whole burn, or moved in a /// single step (a batch update, not a live reading). Frozen, - /// No CPU energy channel exists in the Energy Model group. + /// No CPU energy channel exists in the `Energy Model` group. Absent, } -/// The liveness decision, kept apart from the FFI so it can be tested on its own. +/// One CPU rail within a sample: its channel name, and its ordinal among the +/// CPU rails of that name in the same sample. A name alone is not a key: the M3 +/// Ultra's Energy Model group carries 264 duplicated channel names (per-die +/// sub-rails with no `DIE__` prefix), and two same-named rails stepping in +/// one refresh must count as one step each, not as two steps of one rail. +type RailKey = (String, u32); + +/// The CPU rails of one sample, keyed, at their absolute raw values: every +/// channel that [`is_cpu_energy_rail`], in sample order. +fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in sample.iter().filter(|(name, _)| is_cpu_energy_rail(name)) { + let ordinal = rails.iter().filter(|((n, _), _)| n == name).count() as u32; + rails.push(((name.clone(), ordinal), *raw)); + } + rails +} + +/// The liveness decision, kept apart from the FFI so it can be tested on its +/// own. It is fed whole samples — each channel's name and ABSOLUTE raw integer +/// value — and compares each sample's CPU rails with the previous sample's. /// /// A rail is live only when the **same** CPU rail advances in at least /// [`LIVE_RAIL_ADVANCES`] separate sample intervals. A single advance is not @@ -337,27 +362,42 @@ enum CpuRail { /// that moment saw it move. A probe window that straddles an update sees one /// large jump, minutes of the whole machine's CPU energy, not a live reading. /// A live rail advances in nearly every interval: the M5 Max GPU rail changed -/// in 13,125 of those 13,614 samples, 100 ms apart. Counting per rail keeps -/// two dies' separate jumps from adding up to a false "live". +/// in 13,125 of those 13,614 samples, 100 ms apart. +/// +/// So each interval is one pair of consecutive samples, and each rail +/// ([`RailKey`]) advances at most once in it. Comparing every sample with the +/// first one instead would count one refresh again at every later sample. +/// Counting per rail keeps two dies' separate jumps, or two same-named rails +/// stepping in one refresh, from adding up to a false "live". #[derive(Debug, Default)] struct RailWatch { - /// Sample intervals observed so far. + /// Sample intervals observed so far: pairs of consecutive samples compared. intervals: u32, - /// Each CPU rail seen, by channel name, with the number of intervals in - /// which it advanced. - rails: Vec<(String, u32)>, + /// The previous sample's CPU rails, at their absolute raw values; `None` + /// before the first sample. + prev: Option>, + /// Each CPU rail seen in an interval, 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 it advanced. - 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: every channel in it, as (name, absolute raw + /// value). Its CPU rails are compared with the previous sample's; the first + /// sample is only the baseline for the next. + fn observe(&mut self, sample: &[(String, i64)]) { + let cur = cpu_rails(sample); + if let Some(prev) = self.prev.take() { + self.intervals += 1; + for (key, now) in &cur { + let advanced = prev.iter().any(|(k, before)| k == key && now > before); + match self.rails.iter_mut().find(|(k, _)| k == key) { + Some((_, count)) => *count += u32::from(advanced), + None => self.rails.push((key.clone(), u32::from(advanced))), + } } } + self.prev = Some(cur); } /// Has one CPU rail advanced in enough separate intervals to be live? @@ -368,10 +408,11 @@ impl RailWatch { /// Keep sampling? Until the rail has proved live or `budget` is spent — but /// never stop before a live rail could have shown [`LIVE_RAIL_ADVANCES`] /// advances (one slow IOReport sample must not make a live rail look - /// frozen), up to a hard cap of five budgets. + /// frozen), up to a hard cap of [`PROBE_CAP_BUDGETS`] budgets. fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { !self.is_live() - && (elapsed < budget || (self.intervals < LIVE_RAIL_ADVANCES && elapsed < budget * 5)) + && (elapsed < budget + || (self.intervals < LIVE_RAIL_ADVANCES && elapsed < budget * PROBE_CAP_BUDGETS)) } /// The verdict, or `None` if not a single interval could be observed. @@ -388,6 +429,45 @@ impl RailWatch { } } +/// The liveness probe's loop, over any sampler, tick and clock: take a +/// baseline sample, then `tick` and sample again for as long as +/// [`RailWatch::wants_more`] says, feeding every sample that came back to the +/// rule. A failed sample (`None`) is skipped, and the next good one is compared +/// with the last good one; the clock still runs, so a sampler that keeps +/// failing ends at the cap with no verdict. `now` is the time since any fixed +/// origin. The real probe ([`probe_cpu_rail`]) passes the IOReport sampler, a +/// [`PROBE_TICK`] CPU spin and [`Instant`]; the tests pass scripts. +fn watch( + mut sample: impl FnMut() -> Option>, + mut tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, +) -> RailWatch { + 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 +} + +/// The status a probe verdict maps to; no verdict at all means IOReport could +/// not be sampled. +fn status_of(verdict: Option) -> IoReportStatus { + match verdict { + None => IoReportStatus::Unavailable, + Some(CpuRail::Live) => IoReportStatus::Live, + Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + } +} + /// This host's IOReport status, probed once per process and cached. Backs /// [`crate::ioreport_status`] and gates [`AppleIoReportCounter::new`]. pub(crate) fn host_status() -> IoReportStatus { @@ -400,14 +480,25 @@ fn probe_status(budget: Duration) -> IoReportStatus { probe(budget).0 } +thread_local! { + /// How many times [`probe`] — the real, uncached probe — has run on this + /// thread. The tests read it to pin the once-per-process cache: counted per + /// thread, so tests that probe on purpose, in parallel, do not disturb it. + static PROBE_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + /// What one run of the liveness probe sampled, beside the rule's view of it. +/// The channel counts are there for the tests, which assert on what the real +/// probe sampled. #[derive(Debug, Default)] struct ProbeRun { - /// The liveness rule, fed every sample interval the probe compared. + /// The liveness rule, fed every sample the probe took. watch: RailWatch, - /// Channels in the largest sample the probe compared. + /// Channels in the largest sample the probe took. + #[cfg_attr(not(test), allow(dead_code))] channels: usize, /// How many channels of any one sample were outside the `Energy Model` group. + #[cfg_attr(not(test), allow(dead_code))] outside_group: usize, } @@ -417,6 +508,7 @@ struct ProbeRun { /// status, and what the probe sampled to decide it (`None` when the group could /// not be subscribed). fn probe(budget: Duration) -> (IoReportStatus, Option) { + PROBE_RUNS.with(|runs| runs.set(runs.get() + 1)); let Some(state) = AppleIoReportCounter::open_energy_model() else { // No Energy Model subscription. Tell "no IOReport" from "no such group" // by opening the subscription the counter itself would use. @@ -427,112 +519,107 @@ fn probe(budget: Duration) -> (IoReportStatus, Option) { return (status, None); }; let run = probe_cpu_rail(&state, budget); - let status = match run.watch.verdict() { - None => IoReportStatus::Unavailable, - Some(CpuRail::Live) => IoReportStatus::Live, - Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, - Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, - }; - (status, Some(run)) + (status_of(run.watch.verdict()), Some(run)) } -/// Burn CPU on this thread, re-sampling after every [`PROBE_TICK`] of burn, -/// and watch whether the CPU energy rail **itself** is live by the rule in -/// [`RailWatch`]; its verdict is `None` if the subscription yielded no usable -/// sample. +/// Run [`watch`] over `state`'s subscription: burn CPU on this thread for +/// [`PROBE_TICK`] between samples, and decide by the rule in [`RailWatch`] +/// whether the CPU energy rail **itself** is live. /// /// The summed total is not a liveness signal: on an M5 Max the GPU rail keeps /// advancing while every CPU rail holds, so a total that moves proves only that -/// something drew on screen. Liveness is decided on the CPU rail alone, per -/// consecutive sample interval, from the probe's own samples, so no counter's -/// cumulative reading is touched. +/// something drew on screen. Liveness is decided on the CPU rails alone, from +/// the probe's own samples, so no counter's cumulative reading is touched. /// -/// Each re-sample is one `IOReportCreateSamples` over `state`'s subscription. -/// On the probe's `Energy Model` subscription that costs a few milliseconds, so -/// the thread spends most of `budget` burning CPU and the rail is compared every +/// Each sample is one `IOReportCreateSamples` over `state`'s subscription. On +/// the probe's `Energy Model` subscription that costs a few milliseconds, so +/// the thread spends most of `budget` burning CPU and compares the rail every /// ~[`PROBE_TICK`]; on an all-channel subscription one sample takes 0.14–0.8 s -/// and the probe would compare only a handful of times, overrunning `budget` by -/// up to one sample. +/// and the probe would compare only a handful of times. fn probe_cpu_rail(state: &State, budget: Duration) -> ProbeRun { - let mut run = ProbeRun::default(); - unsafe { - let mut prev = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); - if prev.is_null() { - return run; - } - let started = Instant::now(); - let mut acc: u64 = 0; - while run.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(state.sub, state.channels, 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, channels, outside_group) = cpu_rail_advances(delta); - CFRelease(delta); - CFRelease(prev); - prev = cur; - run.channels = run.channels.max(channels); - run.outside_group = run.outside_group.max(outside_group); - run.watch.observe(rails.iter().map(|(name, advanced)| (name.as_str(), *advanced))); - } - CFRelease(prev); - } - run + let (mut channels, mut outside_group) = (0, 0); + let started = Instant::now(); + let watch = watch( + || { + let s = sample_energy_model(state)?; + channels = channels.max(s.channels); + outside_group = outside_group.max(s.outside_group); + Some(s.values) + }, + || spin(PROBE_TICK), + || started.elapsed(), + budget, + ); + ProbeRun { watch, channels, outside_group } } -/// In a samples-delta dictionary: every CPU energy channel in the Energy Model -/// group (`CPU Energy`, or `DIE__CPU Energy` on multi-die parts), and -/// whether it advanced over that interval; then how many channels the sample -/// held, and how many of those were outside the Energy Model group. Decided on -/// the raw integer, before unit conversion, so a small advance in a fine unit -/// cannot truncate to zero. -fn cpu_rail_advances(delta: CFDictionaryRef) -> (Vec<(String, bool)>, usize, usize) { - let mut rails = Vec::new(); - let (mut channels, mut outside_group) = (0, 0); +/// Spin this thread for `d`. +fn spin(d: Duration) { + let started = Instant::now(); + let mut acc: u64 = 0; + while started.elapsed() < d { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); +} + +/// One plain sample, as the liveness probe reads it. +struct EnergyModelSample { + /// Every `Energy Model` channel in the sample, in sample order, as (name, + /// absolute raw integer value). Raw integers, before unit conversion, so a + /// small advance in a fine unit cannot truncate to zero. + values: Vec<(String, i64)>, + /// How many channels the sample held. + channels: usize, + /// How many of those were outside the `Energy Model` group. + outside_group: usize, +} + +/// Take one plain sample of `state`'s subscription (absolute values, no +/// delta) and extract every `Energy Model` channel's (name, raw value) from it. +/// `None` if IOReport returned no sample, or one without a channel array: a +/// failed sample, never a sample with no CPU rail in it. Deciding anything is +/// left to [`RailWatch`]. +fn sample_energy_model(state: &State) -> Option { + let mut out = EnergyModelSample { values: Vec::new(), channels: 0, outside_group: 0 }; unsafe { + let smp = IOReportCreateSamples(state.sub, state.channels, 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, ); - if key.is_null() { - return (rails, channels, outside_group); - } - let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; - CFRelease(key as CFTypeRef); + let arr = if key.is_null() { + std::ptr::null() + } else { + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + arr + }; if arr.is_null() { - return (rails, channels, outside_group); + CFRelease(smp); + return None; } for i in 0..CFArrayGetCount(arr) { let item = CFArrayGetValueAtIndex(arr, i); if item.is_null() { continue; } - channels += 1; + out.channels += 1; if !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { - outside_group += 1; + out.outside_group += 1; 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 let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) { + out.values.push((name, IOReportSimpleGetIntegerValue(item, 0) as i64)); } } + CFRelease(smp); } - (rails, channels, outside_group) + Some(out) } /// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally @@ -753,6 +840,9 @@ fn cfstring_eq_ic(s: CFStringRef, target: &str) -> bool { #[cfg(test)] mod tests { use super::*; + use std::cell::Cell; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; #[test] fn energy_to_uj_scales_by_unit() { @@ -800,27 +890,48 @@ mod tests { assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); } + /// A sample as the probe's FFI extracts it: (channel name, absolute raw value). + fn smp(chans: &[(&str, i64)]) -> Vec<(String, i64)> { + chans.iter().map(|(name, raw)| (name.to_string(), *raw)).collect() + } + /// A rail that moves in one step and then holds — a batch update caught - /// mid-probe — is not live, however large the step. + /// mid-probe — is not live, however large the step. The M5 Max's own + /// refresh, as absolute values. #[test] fn a_single_jump_is_a_batch_update_not_a_live_rail() { let mut w = RailWatch::default(); - for advanced in [false, true, false, false, false] { - w.observe([("CPU Energy", advanced)]); + for raw in [480_083_160, 480_083_160, 541_893_638, 541_893_638, 541_893_638, 541_893_638] { + w.observe(&smp(&[("CPU Energy", raw)])); } - assert!(!w.is_live()); + assert_eq!(w.intervals, 5); + assert!(!w.is_live(), "one refresh counted as a live rail: {w:?}"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); } - /// Two dies each updated once (at different moments) do not add up to a + /// Two dies each refreshed once (at different moments) 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)); + w.observe(&smp(&[("DIE_0_CPU Energy", 100), ("DIE_1_CPU Energy", 200)])); + w.observe(&smp(&[("DIE_0_CPU Energy", 150), ("DIE_1_CPU Energy", 200)])); + w.observe(&smp(&[("DIE_0_CPU Energy", 150), ("DIE_1_CPU Energy", 250)])); + w.observe(&smp(&[("DIE_0_CPU Energy", 150), ("DIE_1_CPU Energy", 250)])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + } + + /// Two rails of the same name, stepping together in one refresh, are one + /// step each — not two steps of one rail. + #[test] + fn two_same_named_rails_stepping_once_are_not_a_live_rail() { + let mut w = RailWatch::default(); + w.observe(&smp(&[("CPU Energy", 1_000), ("CPU Energy", 5_000)])); + w.observe(&smp(&[("CPU Energy", 2_000), ("CPU Energy", 6_000)])); + w.observe(&smp(&[("CPU Energy", 2_000), ("CPU Energy", 6_000)])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + let keys: Vec<&RailKey> = w.rails.iter().map(|(key, _)| key).collect(); + assert_eq!(keys, [&("CPU Energy".to_string(), 0), &("CPU Energy".to_string(), 1)]); } /// A rail that advances interval after interval is live, as soon as it has @@ -828,24 +939,53 @@ mod tests { #[test] fn a_rail_that_keeps_advancing_is_live() { let mut w = RailWatch::default(); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + w.observe(&smp(&[("DIE_0_CPU Energy", 5), ("DIE_1_CPU Energy", 10)])); + w.observe(&smp(&[("DIE_0_CPU Energy", 5), ("DIE_1_CPU Energy", 11)])); assert!(!w.is_live(), "one advance is not yet live"); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + w.observe(&smp(&[("DIE_0_CPU Energy", 5), ("DIE_1_CPU Energy", 12)])); assert!(w.is_live()); assert_eq!(w.verdict(), Some(CpuRail::Live)); } + /// Only a CPU energy aggregate decides, and only when it goes up: a GPU rail + /// and CPU sub-rails moving in every sample, and a CPU rail going down (a + /// reset), leave the CPU rail frozen. + #[test] + fn only_a_cpu_rail_going_up_is_an_advance() { + let mut w = RailWatch::default(); + for i in 0..10i64 { + w.observe(&smp(&[ + ("CPU Energy", 541_893_638 - i), + ("GPU Energy", 1_000 * i), + ("PACC_0", i), + ("ECPUDTL00", i), + ])); + } + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + } + /// No CPU channel is `Absent`; no observed interval at all is no verdict /// (the caller reports IOReport as unavailable). #[test] fn no_cpu_rail_is_absent_and_no_interval_is_no_verdict() { let mut w = RailWatch::default(); assert_eq!(w.verdict(), None); - w.observe([]); - w.observe([]); + w.observe(&smp(&[("GPU Energy", 1)])); + assert_eq!(w.verdict(), None, "a baseline alone is not an interval"); + w.observe(&smp(&[("GPU Energy", 2)])); assert_eq!(w.verdict(), Some(CpuRail::Absent)); } + /// Every verdict maps to the status that says it, and no verdict to + /// `Unavailable`. + #[test] + fn each_verdict_maps_to_its_status() { + assert_eq!(status_of(None), IoReportStatus::Unavailable); + assert_eq!(status_of(Some(CpuRail::Absent)), IoReportStatus::CpuRailAbsent); + assert_eq!(status_of(Some(CpuRail::Frozen)), IoReportStatus::CpuRailFrozen); + assert_eq!(status_of(Some(CpuRail::Live)), IoReportStatus::Live); + } + /// The probe keeps sampling within its budget until the rail proves live, /// and past the budget until a live rail could have advanced twice — but /// never past five budgets. @@ -856,26 +996,176 @@ mod tests { assert!(w.wants_more(Duration::ZERO, budget)); // Slow samples: past the budget with fewer than two intervals, keep going. assert!(w.wants_more(budget * 2, budget)); - w.observe([("CPU Energy", false)]); + w.observe(&smp(&[("CPU Energy", 7)])); + w.observe(&smp(&[("CPU Energy", 7)])); assert!(w.wants_more(budget * 2, budget)); - w.observe([("CPU Energy", false)]); + w.observe(&smp(&[("CPU Energy", 7)])); assert!(!w.wants_more(budget * 2, budget), "two intervals in and past budget: frozen"); assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap"); let mut live = RailWatch::default(); - live.observe([("CPU Energy", true)]); - live.observe([("CPU Energy", true)]); + for raw in [1, 2, 3] { + live.observe(&smp(&[("CPU Energy", raw)])); + } assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); } - /// Spin this thread for `d`. - fn burn(d: Duration) { - let started = Instant::now(); - let mut acc: u64 = 0; - while started.elapsed() < d { - acc = acc.wrapping_add(1); - } - std::hint::black_box(acc); + /// Run [`watch`] over a script: `sample(i)` is the i-th sample (`None` is a + /// failed read), and the scripted clock advances `per_sample` per sample and + /// [`PROBE_TICK`] per tick. Returns the rule's state, how many samples and + /// ticks the loop took, and the clock when it stopped. + fn scripted( + sample: impl Fn(usize) -> Option>, + per_sample: Duration, + budget: Duration, + ) -> (RailWatch, usize, usize, Duration) { + let clock = Cell::new(Duration::ZERO); + let (samples, ticks) = (Cell::new(0usize), Cell::new(0usize)); + let w = watch( + || { + let i = samples.get(); + assert!(i < 100_000, "the probe loop never stopped"); + samples.set(i + 1); + clock.set(clock.get() + per_sample); + sample(i) + }, + || { + ticks.set(ticks.get() + 1); + clock.set(clock.get() + PROBE_TICK); + }, + || clock.get(), + budget, + ); + (w, samples.get(), ticks.get(), clock.get()) + } + + /// One Energy Model sample takes about this long on both Macs. + const GROUP_SAMPLE: Duration = Duration::from_millis(6); + + /// (a) The M5 Max's refresh, in the probe's own loop, beside a GPU rail + /// that moves in every sample: one step, then held — frozen. A loop that + /// compared every sample with the first would see that step again at every + /// later sample and call it live. + #[test] + fn watch_an_m5_max_refresh_is_frozen() { + let budget = CPU_RAIL_LIVENESS_BUDGET; + let (w, samples, _, clock) = scripted( + |i| { + Some(smp(&[ + ("CPU Energy", if i < 2 { 480_083_160 } else { 541_893_638 }), + ("GPU Energy", 851_622 * i as i64), + ])) + }, + GROUP_SAMPLE, + budget, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + assert_eq!(w.intervals as usize, samples - 1, "every sample after the baseline is an interval"); + assert!(w.intervals >= 10 && clock >= budget, "{} intervals, stopped at {clock:?}", w.intervals); + } + + /// (b) The M3 Ultra's refresh: both dies step once, in the same sample, + /// beside per-die sub-rails whose names repeat without a `DIE_` prefix. + #[test] + fn watch_an_m3_ultra_two_die_refresh_is_frozen() { + let (w, _, _, _) = scripted( + |i| { + let refreshed = i >= 7; + Some(smp(&[ + ("DIE_0_CPU Energy", 9_494_336 + if refreshed { 1_344_869 } else { 0 }), + ("ECPUDTL00", 3 * i as i64), + ("DIE_1_CPU Energy", 10_212_656 + if refreshed { 1_391_637 } else { 0 }), + ("ECPUDTL00", 5 * i as i64), + ("GPU Energy", 14_809 * i as i64), + ])) + }, + GROUP_SAMPLE, + CPU_RAIL_LIVENESS_BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + let names: Vec<&str> = w.rails.iter().map(|((name, _), _)| name.as_str()).collect(); + assert_eq!(names, ["DIE_0_CPU Energy", "DIE_1_CPU Energy"]); + } + + /// (c) Two channels both named `CPU Energy`, stepping together once. + #[test] + fn watch_two_same_named_rails_stepping_together_once_is_frozen() { + let (w, _, _, _) = scripted( + |i| { + let raw = if i >= 4 { 2_000 } else { 1_000 }; + Some(smp(&[("CPU Energy", raw), ("CPU Energy", raw + 5)])) + }, + GROUP_SAMPLE, + CPU_RAIL_LIVENESS_BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + } + + /// (d) A rail stepping in every sample is live after exactly two intervals, + /// and the probe stops sampling there. + #[test] + fn watch_a_rail_stepping_every_sample_is_live_after_two_intervals() { + let (w, samples, ticks, _) = scripted( + |i| Some(smp(&[("CPU Energy", 1_000 + i as i64)])), + GROUP_SAMPLE, + CPU_RAIL_LIVENESS_BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + assert_eq!((w.intervals, samples, ticks), (2, 3, 2), "a baseline, two intervals, then stop"); + } + + /// (e) Samples that keep failing are retried until the five-budget cap and + /// give no verdict, which is `Unavailable`. A failed baseline does not end + /// the probe either: once samples come back, they are decided on. + #[test] + fn watch_failed_samples_keep_sampling_to_the_cap() { + let budget = CPU_RAIL_LIVENESS_BUDGET; + let cap = budget * PROBE_CAP_BUDGETS; + let (w, samples, _, clock) = scripted(|_| None, GROUP_SAMPLE, budget); + assert_eq!(w.verdict(), None); + assert_eq!(status_of(w.verdict()), IoReportStatus::Unavailable); + assert!(clock >= cap, "gave up at {clock:?} after {samples} samples, before the {cap:?} cap"); + assert!(clock < cap + PROBE_TICK + GROUP_SAMPLE, "ran past the cap: {clock:?}"); + let (w, _, _, _) = scripted(|i| (i >= 10).then(|| smp(&[("CPU Energy", 7)])), GROUP_SAMPLE, budget); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + } + + /// (f) Slow samples: past the budget with two intervals it stops, frozen; + /// past the budget with fewer than two it keeps sampling — up to the cap. + #[test] + fn watch_stops_past_the_budget_only_after_two_intervals() { + let budget = CPU_RAIL_LIVENESS_BUDGET; + let held = |_| Some(smp(&[("CPU Energy", 7)])); + // 600 ms a sample: the baseline lands at 0.6 s, the first interval at + // 1.225 s (past the budget, one interval: keep going), the second at 1.85 s. + let (w, samples, _, clock) = scripted(held, Duration::from_millis(600), budget); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + assert_eq!( + (w.intervals, samples), + (2, 3), + "stopped at {clock:?}: past the budget it must sample until two intervals, then stop" + ); + // 3 s a sample: the first interval lands past the cap, so the probe ends + // there with one. + let (w, samples, _, _) = scripted(held, Duration::from_secs(3), budget); + assert_eq!((w.verdict(), w.intervals, samples), (Some(CpuRail::Frozen), 1, 2)); + } + + /// (g) `DIE_0` steps once and `DIE_1` steps once, in different intervals: + /// two rails with one step each, not one rail with two. + #[test] + fn watch_one_step_on_each_die_in_different_intervals_is_frozen() { + let (w, _, _, _) = scripted( + |i| { + Some(smp(&[ + ("DIE_0_CPU Energy", if i >= 3 { 150 } else { 100 }), + ("DIE_1_CPU Energy", if i >= 9 { 250 } else { 200 }), + ])) + }, + GROUP_SAMPLE, + CPU_RAIL_LIVENESS_BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); } /// What the independent oracle saw on this host. @@ -922,22 +1212,56 @@ mod tests { } /// An oracle that shares nothing with the liveness probe but the raw FFI - /// declarations: it opens its own subscription (not `open()`), reads each - /// CPU rail's ABSOLUTE cumulative value (no delta samples), matches names - /// with `ends_with("CPU Energy")` in any group (not the implementation's - /// group filter and die-prefix stripping), and burns CPU for `budget`, - /// reading as it goes. + /// declarations: it opens its own subscription over every channel (not + /// `open()`, not the probe's group subscription), reads each CPU rail's + /// ABSOLUTE cumulative value (no delta samples), and matches names with + /// `ends_with("CPU Energy")` in any group (not the implementation's group + /// filter and die-prefix stripping). /// - /// `CpuRailMoved(true)` means some rail kept moving: its value changed at - /// two or more separate reads. A live rail changes at nearly every read; a - /// batch-updated one at most once in a window this short, and one jump is - /// not a live reading. 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 { + /// The load is a helper thread that spins from before the subscription + /// opens until the last read, for the whole `window`, so the window is under + /// CPU load even while a read blocks (one all-channel sample takes 0.1–0.8 s + /// on an M5 Max). The calling thread only reads, back to back, until + /// `window` has passed. + /// + /// `CpuRailMoved(true)` means some rail kept moving: its value changed + /// between consecutive reads at two or more separate reads. A live rail + /// changes at nearly every read; a batch-updated one at most once in a + /// window this short, and one jump is not a live reading. Its own literal + /// threshold, not the implementation's constant, so a change to one cannot + /// silently move the other. + fn oracle_cpu_rail(window: Duration) -> Oracle { + let stop = Arc::new(AtomicBool::new(false)); + let burner = { + let stop = Arc::clone(&stop); + std::thread::spawn(move || { + let started = Instant::now(); + let mut acc: u64 = 0; + while !stop.load(Ordering::Relaxed) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); + started.elapsed() + }) + }; + let (verdict, reads, changes) = oracle_watch(window); + stop.store(true, Ordering::Relaxed); + let burned = burner.join().expect("the oracle's burn thread"); + eprintln!( + "ioreport oracle: {reads} reads over a {window:?} window, CPU burned on a helper thread for \ + {burned:?}, changes per CPU rail {changes:?} -> {verdict:?}" + ); + verdict + } + + /// The oracle's reads, on the calling thread: the verdict, how many reads + /// after the first, and at how many of them each CPU rail had changed. + fn oracle_watch(window: Duration) -> (Oracle, u32, Vec) { + let started = Instant::now(); unsafe { let all = IOReportCopyAllChannels(0, 0); if all.is_null() { - return Oracle::NoIoReport; + return (Oracle::NoIoReport, 0, Vec::new()); } let mut chans: CFMutableDictionaryRef = std::ptr::null_mut(); let sub = IOReportCreateSubscription(std::ptr::null(), all, &mut chans, 0, std::ptr::null()); @@ -949,8 +1273,10 @@ mod tests { if !chans.is_null() { CFRelease(chans as CFTypeRef); } - return Oracle::NoIoReport; + return (Oracle::NoIoReport, 0, Vec::new()); } + let mut changes = Vec::new(); + let mut reads = 0u32; let verdict = 'watch: { let Some(mut prev) = oracle_read(sub, chans) else { break 'watch Oracle::NoIoReport; @@ -959,11 +1285,8 @@ mod tests { break 'watch Oracle::NoCpuRail; } // Per rail: at how many reads its absolute value had changed. - let mut changes = vec![0u32; prev.len()]; - let mut reads = 0u32; - let started = Instant::now(); - while started.elapsed() < budget { - burn(Duration::from_millis(50)); + changes = vec![0u32; prev.len()]; + while started.elapsed() < window { let Some(cur) = oracle_read(sub, chans) else { continue; }; @@ -976,12 +1299,11 @@ mod tests { } prev = cur; } - eprintln!("ioreport oracle: {reads} reads over {budget:?}, changes per CPU rail {changes:?}"); Oracle::CpuRailMoved(false) }; CFRelease(sub); CFRelease(chans as CFTypeRef); - verdict + (verdict, reads, changes) } } @@ -1059,14 +1381,38 @@ mod tests { /// own fresh `Energy Model` subscription exactly as [`host_status`] does, /// beside the cached status the counter is gated on, and asserts on every /// outcome, not only on Live. + /// + /// And its cadence: wherever the probe could sample and did not find a live + /// rail, it ran for its whole budget and compared at least 10 intervals — + /// what a group-only subscription re-sampled every ~25 ms gives (about 30 + /// on both Macs), and what an all-channel probe (2–8 intervals a second) or + /// one that stops early cannot. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { let t = Instant::now(); - let probed = probe_status(CPU_RAIL_LIVENESS_BUDGET); + let (probed, run) = probe(CPU_RAIL_LIVENESS_BUDGET); let probe_took = t.elapsed(); let cached = crate::ioreport_status(); let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); - eprintln!("ioreport probe: probed={probed:?} cached={cached:?} oracle={oracle:?} probe_took={probe_took:?}"); + let intervals = run.as_ref().map(|r| r.watch.intervals); + eprintln!( + "ioreport probe: probed={probed:?} cached={cached:?} oracle={oracle:?} \ + intervals={intervals:?} probe_took={probe_took:?}" + ); + if let Some(run) = &run + && matches!(run.watch.verdict(), Some(CpuRail::Frozen | CpuRail::Absent)) + { + assert!( + run.watch.intervals >= 10, + "the probe decided {probed:?} on {} intervals in {probe_took:?}: it did not re-sample \ + the Energy Model group every ~{PROBE_TICK:?}", + run.watch.intervals + ); + assert!( + probe_took >= CPU_RAIL_LIVENESS_BUDGET, + "the probe decided {probed:?} after {probe_took:?}, before its {CPU_RAIL_LIVENESS_BUDGET:?} budget" + ); + } match (probed, cached) { (IoReportStatus::Live, _) | (_, IoReportStatus::Live) => assert_eq!( oracle, @@ -1132,9 +1478,13 @@ mod tests { /// after every ~25 ms of burn. Asserted on what the probe [`host_status`] /// runs actually sampled, not on timings: every channel in its samples is in /// the `Energy Model` group, and there are fewer of them than one sample of - /// the counter's all-channel subscription holds. And the group's CPU rails - /// are exactly the ones the counter's subscription carries. Where the group - /// cannot be subscribed, the status must say why. + /// the counter's all-channel subscription holds. The group's CPU rails are + /// exactly the ones the counter's subscription carries, and exactly the + /// rails the probe's rule watched, one by one (`DIE_0_` and `DIE_1_` on an + /// M3 Ultra): the FFI hands the rule every rail, never a merged one. And + /// unless the rail proved live, the probe sampled for its whole budget, at + /// least two intervals. Where the group cannot be subscribed, the status + /// must say why. #[test] fn the_liveness_probe_subscribes_to_the_energy_model_group_only() { let t = Instant::now(); @@ -1153,16 +1503,16 @@ mod tests { let all = AppleIoReportCounter::open().expect("the Energy Model group opened, so every channel opens"); let (group_chans, group_took) = sample_channels(&group).expect("sample the Energy Model group"); let (all_chans, all_took) = sample_channels(&all).expect("sample every channel"); + let watched: Vec = run.watch.rails.iter().map(|(key, _)| key.clone()).collect(); eprintln!( "ioreport probe subscription: status={status:?} after {} intervals in {probe_took:?}; \ {} channels per sample, {} outside the Energy Model group (all-channel subscription: {}); \ - one sample {group_took:?} (all-channel {all_took:?})", + one sample {group_took:?} (all-channel {all_took:?}); rails watched {watched:?}", run.watch.intervals, run.channels, run.outside_group, all_chans.len() ); - assert!(run.watch.intervals > 0, "the probe compared no samples, yet status={status:?}"); assert!(run.channels > 0, "the probe's samples held no channels"); assert_eq!( run.outside_group, 0, @@ -1175,42 +1525,61 @@ mod tests { run.channels, all_chans.len() ); + if status != IoReportStatus::Live { + assert!( + run.watch.intervals >= LIVE_RAIL_ADVANCES && probe_took >= CPU_RAIL_LIVENESS_BUDGET, + "the probe stopped at {status:?} after {} intervals in {probe_took:?}: short of two intervals \ + or of its {CPU_RAIL_LIVENESS_BUDGET:?} budget", + run.watch.intervals + ); + } let cpu_rails = |chans: &[(String, String)]| { - let mut rails: Vec = chans - .iter() - .filter(|(group, name)| group.eq_ignore_ascii_case("Energy Model") && name.ends_with("CPU Energy")) - .map(|(_, name)| name.clone()) - .collect(); + let mut rails: Vec = Vec::new(); + for (group, name) in chans { + if group.eq_ignore_ascii_case("Energy Model") && name.ends_with("CPU Energy") { + let nth = rails.iter().filter(|(n, _)| n == name).count() as u32; + rails.push((name.clone(), nth)); + } + } rails.sort(); rails }; + let group_rails = cpu_rails(&group_chans); assert_eq!( - cpu_rails(&group_chans), + group_rails, cpu_rails(&all_chans), "the probe's group carries different CPU rails from the ones the counter reads" ); + let mut watched = watched; + watched.sort(); + assert_eq!(watched, group_rails, "the probe's rule did not watch the group's CPU rails one by one"); } - /// The probe runs once per process: after the first answer, the status and - /// a declined counter come back without probing again (a re-probe costs at - /// least one IOReport sample plus up to a full budget of burn). + /// The probe runs once per process. Three status calls, two counters and a + /// `detect_meter` ran the real probe at most once on this thread — once if + /// this thread filled the cache, never if another test's did. Without the + /// cache each of them would probe again. #[test] fn the_liveness_status_is_probed_once_and_cached() { + let runs = || PROBE_RUNS.with(|runs| runs.get()); + let before = runs(); let first = crate::ioreport_status(); - let t = Instant::now(); let again = crate::ioreport_status(); let counter = AppleIoReportCounter::new(); - let took = t.elapsed(); - assert_eq!(first, again, "the cached status changed between calls"); - if counter.is_available() { - assert_eq!(first, IoReportStatus::Live); - assert_eq!(counter.status(), IoReportStatus::Live); - } else { - assert_eq!(counter.status(), first); - assert!( - took < CPU_RAIL_LIVENESS_BUDGET / 5, - "a cached status and a declined counter took {took:?}: the liveness probe ran again" - ); + let other = AppleIoReportCounter::default(); + let meter = crate::meter::detect_meter(); + let third = crate::ioreport_status(); + let probed = runs() - before; + eprintln!( + "ioreport cache: status={first:?}; the real probe ran {probed} time(s) on this thread for three \ + status calls, two counters and detect_meter (active: {})", + meter.active_name() + ); + assert!(probed <= 1, "the real liveness probe ran {probed} times: the status is not cached"); + assert_eq!((first, again, third), (first, first, first), "the cached status changed between calls"); + for c in [&counter, &other] { + assert_eq!(c.status(), first); + assert_eq!(c.is_available(), first == IoReportStatus::Live); } } @@ -1230,7 +1599,7 @@ mod tests { assert_eq!(rail, Some(CpuRail::Live), "an available counter whose own CPU rail does not move"); let _ = counter.read(); // prime let before = counter.read().expect("baseline read"); - burn(Duration::from_millis(200)); + spin(Duration::from_millis(200)); let after = counter.read().expect("work read"); assert!( after.uj > before.uj, @@ -1252,7 +1621,10 @@ mod tests { /// A declined counter yields no number at all, and the fallback that runs /// in its place carries its own grade end to end: `detect_meter` hands out /// the load-average estimator, and its readings say `Estimator` — never a - /// `ModelBased` figure from a frozen rail. + /// `ModelBased` figure from a frozen rail. And it meters work that ran: + /// 150 ms of CPU work, read through `detect_meter`, is a figure larger than + /// one quantum of the meter, at the fallback's grade — not a zero, and not + /// a floor. #[test] fn a_declined_counter_yields_no_reading_and_the_fallback_says_estimator() { let counter = AppleIoReportCounter::new(); @@ -1273,6 +1645,30 @@ mod tests { assert_eq!(composite.active_name(), fallback); assert_eq!(active.provenance(), Provenance::Estimator); assert_eq!(reading.provenance, Provenance::Estimator); + + let work = Duration::from_millis(150).max(Duration::from_nanos(active.min_window_ns())); + let before = composite.read().expect("the fallback reads"); + let t = Instant::now(); + spin(work); + let after = composite.read().expect("the fallback reads"); + let took = t.elapsed(); + let uj = after.uj.checked_sub(before.uj).expect("the fallback's reading never runs backwards"); + eprintln!( + "ioreport declined ({:?}): {took:?} of CPU work metered by `{fallback}` as {uj} µJ, \ + provenance {} then {}", + counter.status(), + before.provenance, + after.provenance + ); + assert_eq!( + (before.provenance, after.provenance), + (Provenance::Estimator, Provenance::Estimator), + "the fallback's figure must carry the fallback's grade" + ); + assert!( + uj > active.resolution_uj(), + "{took:?} of CPU work metered as {uj} µJ by `{fallback}`: a zero or a floor, not a figure" + ); } } } diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs index 4356eb70f..a35b023e2 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs @@ -118,7 +118,8 @@ pub enum IoReportStatus { /// subscription to the `Energy Model` group and burns CPU on the calling thread /// while it watches the CPU energy rail, re-sampling every ~25 ms: about /// `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET` where the rail is frozen, less -/// where it is live (it stops as soon as the rail has advanced twice). Every +/// where it is live (it stops as soon as one rail has advanced in two +/// intervals), and up to five budgets where samples are slow or fail. Every /// later call — and every `AppleIoReportCounter::new()` and /// [`meter::detect_meter`] — reuses that answer. Anywhere else it returns /// [`IoReportStatus::Unavailable`] without probing. -- 2.54.0 (Apple Git-157) From c8890926ef55008e4d3c6aaa907c96282a7d950e Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 08:01:34 -0400 Subject: [PATCH 09/23] jouledesk-github: no unit test reads the model setting, and an unmetered reply names no grant or ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 226a70cb reported "jouledesk-github 12" passing on the M5 Max. That held on some runs only. Two defects in that commit's own change: 1. A test race on a process-global. The two on-host handle() tests, GitHub and Slack, each ran set_var("JOULEDESK_MODEL", "/nonexistent.gguf"), then handle(), then remove_var, and cargo runs them in parallel. When one removed the variable before the other's run_model read it, run_model fell back to DEFAULT_MODEL. This Mac has that model file, and llama-completion is on its PATH, so a unit test ran gemma-4-E2B for 2-3 s. The work was then metered and sealed, and the test's "not sealed" assertion failed. The same binary at e4b20074, run 20 times here: pass=15 fail=5, e.g. left: ("completed", true, 70845000) right: ("not sealed (no energy figure)", false, 0) and on the Slack side left: ("completed", true) right: ("not sealed (no energy figure)", false) The race was on main too, but main's loose assertions passed either way. This machine is also the macos CI runner for jouledesk-rs. The M3 Ultra has no model file, so it never showed there. 2. The reply for work with no energy figure still said "_Decided under a signed grant, ..._" and "ledger UNVERIFIED ✗"; Slack said "_Signed grant · ..._". seal_work returns before any Teammate, grant or ledger exists, so no grant decided anything and there was no ledger to be unverified. The fix. - The work path takes the model explicitly. do_work_with(question, model) and handle_with(event, handle, model), plus slack::handle_with(event, model), are pub(crate). run_model and model_label take the path they are given. The public do_work, handle and slack::handle keep their signatures. They read JOULEDESK_MODEL, else DEFAULT_MODEL, once at the entry point and pass it down. The model that runs is now also the model the reply names; before, run_model and model_label each read the variable separately. - Under cfg(test), configured_model() panics. Every unit test passes a model path, NO_MODEL = "/nonexistent.gguf", so none can read the process-global or reach the default model and spawn llama-completion. No test calls set_var or remove_var any more. - A reply for work with no figure now says it was not decided under a grant and has no ledger entry: _Worked on real hardware; not metered, so no grant decided it and nothing was sealed._ - **energy:** not metered — `apple-silicon`: the work took 121 µs, shorter than the 100 ms window it can resolve; no energy figure was sealed - **decision:** not sealed (no energy figure) · no ledger entry In Slack: _Worked on real hardware · not metered · no grant decision · nothing sealed_ … • *decision:* not sealed (no energy figure) · no ledger entry A sealed reply is unchanged. From a real --dry-run with the default model on the M5 Max: _Decided under a signed grant, worked on real hardware, sealed to a ledger anyone can verify._ - **energy:** 99956.5 mJ (99956487 µJ) — estimated by `apple-silicon`, provenance `estimator` - **decision:** completed · ledger verified ✓ Tests. assert_unmetered_reply: no "signed grant", no "UNVERIFIED" or "ledger verified", and "no ledger entry". It is applied to the composed GitHub and Slack replies and to both on-host handle() paths. Those paths also assert the reply names `deterministic-extract`. The sealed-reply tests now assert the grant clause and "ledger verified ✓". After, on the M5 Max: - The same test binary 20 times with JOULEDESK_MODEL unset: pass=20 fail=0, every run 12 passed in 1.22-1.27 s. - 5 more times with JOULEDESK_MODEL set to the real gemma model: pass=5 fail=0, 1.22-1.24 s. No run spent seconds in a model. M3 Ultra: 12 passed. jouledesk: 18 + 4 passed. clippy --no-deps --tests on jouledesk and jouledesk-github: nothing new; the unused libwebrtc patch warning is the workspace's. Falsified, each mutation applied, run, and restored with git checkout, with a clean tree after: - handle_with ignores its model and calls configured_model(): a_mention_answered_too_fast_to_meter_is_posted_as_unmetered fails with "a unit test reached the configured model (/Volumes/.../gemma-4-E2B-q4_k_m.gguf unless JOULEDESK_MODEL); pass a model path". - The GitHub unmetered reply says "Decided under a signed grant" and "ledger {verify}" again: 2 tests fail ("no grant decided this work: ..."). - The same in Slack ("Signed grant · ..."): 2 tests fail. Not changed: Work::measured_uj and Handled::measured_uj keep their names, although they carry an estimate when provenance says `estimator`. Renaming them is an API decision. Co-Authored-By: Claude Opus 5.5 --- .../crates/jouledesk-github/src/lib.rs | 96 ++++++++++++++----- .../crates/jouledesk-github/src/slack.rs | 35 +++++-- 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs index 85e19c756..7653e3617 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs @@ -25,6 +25,22 @@ pub mod slack; const DEFAULT_MODEL: &str = "/Volumes/macos_4TB_external_1/neuronexus_ai/model_library/gemma-4-E2B-q4_k_m.gguf"; +/// The model the public entry points ([`do_work`], [`handle`], [`slack::handle`]) run: +/// `JOULEDESK_MODEL`, else [`DEFAULT_MODEL`]. Read once, at the entry point, and passed +/// down, so the model that runs is the model the reply names. +#[cfg(not(test))] +fn configured_model() -> String { + std::env::var("JOULEDESK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()) +} + +/// Unit tests pass the model path themselves. None may read `JOULEDESK_MODEL` — a +/// process-global that tests running in parallel would race on — or reach the default +/// model, which runs a real model through `llama-completion` on a host that has it. +#[cfg(test)] +fn configured_model() -> String { + panic!("a unit test reached the configured model ({DEFAULT_MODEL} unless JOULEDESK_MODEL); pass a model path") +} + /// The work product: a real answer and the energy this machine's meter puts on it (not /// a constant) — a reading or an estimate, as `provenance` says. pub struct Work { @@ -50,9 +66,15 @@ const UNMETERED: &str = "unmetered"; /// host's best energy meter read around it: measured where the host exposes a live /// counter, estimated where it does not, and the provenance says which. pub fn do_work(question: &str) -> Work { + do_work_with(question, &configured_model()) +} + +/// [`do_work`] with the model given as the path to a local GGUF; a path that does not +/// exist answers with the deterministic fallback, and runs no subprocess. +pub(crate) fn do_work_with(question: &str, model: &str) -> Work { let meter = jouleclaw_energy::meter::detect_meter(); - let ((answer, model), figure) = metered(&meter, || match run_model(question) { - Some(a) => (a, model_label()), + let ((answer, model), figure) = metered(&meter, || match run_model(question, model) { + Some(a) => (a, model_label(model)), None => (fallback_answer(question), "deterministic-extract".to_string()), }); let (measured_uj, provenance, unmetered) = match figure { @@ -156,27 +178,23 @@ fn unmetered_line(work: &Work) -> String { /// The outcome of work that was not sealed because its meter put no figure on it. const NOT_SEALED: &str = "not sealed (no energy figure)"; -fn model_label() -> String { - std::env::var("JOULEDESK_MODEL") - .unwrap_or_else(|_| DEFAULT_MODEL.into()) - .rsplit('/') - .next() - .unwrap_or("model") - .to_string() +/// The model a reply names: the file name of its path. +fn model_label(model: &str) -> String { + model.rsplit('/').next().unwrap_or("model").to_string() } -/// Generate an answer with a local model via `llama-completion` (the reference model -/// hook — swap for Mesh / JouleClaw in production). `None` if no model is configured. -fn run_model(question: &str) -> Option { - let model = std::env::var("JOULEDESK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()); - if !std::path::Path::new(&model).exists() { +/// Generate an answer with the local model at `model` via `llama-completion` (the +/// reference model hook — swap for Mesh / JouleClaw in production). `None` if there is +/// no model file there. +fn run_model(question: &str, model: &str) -> Option { + if !std::path::Path::new(model).exists() { return None; } let bin = std::env::var("JOULEDESK_LLAMA").unwrap_or_else(|_| "llama-completion".into()); let prompt = format!("user\n{question}\nmodel\n"); let out = std::process::Command::new(&bin) .args([ - "-m", &model, "-p", &prompt, "-n", "160", "--temp", "0.2", "-r", "", + "-m", model, "-p", &prompt, "-n", "160", "--temp", "0.2", "-r", "", "--no-display-prompt", ]) .output() @@ -211,6 +229,11 @@ pub struct Handled { /// the work under the meter, seal it through the teammate loop at the meter's figure and /// provenance, and compose the reply body. `None` if the comment does not address `handle`. pub fn handle(event: &Value, handle: &str) -> Option { + handle_with(event, handle, &configured_model()) +} + +/// [`handle`] with the model given as the path to a local GGUF (see [`do_work_with`]). +pub(crate) fn handle_with(event: &Value, handle: &str, model: &str) -> Option { let mention = parse_event(Surface::GitHub, event)?; if !mention.text.contains(handle) { return None; @@ -220,7 +243,7 @@ pub fn handle(event: &Value, handle: &str) -> Option { if question.is_empty() { "Please help with this thread.".into() } else { question }; // real work + metered energy → seal at the meter's figure and provenance → compose. - let work = do_work(&question); + let work = do_work_with(&question, model); let (outcome_str, verified) = seal(&work, &question); let body = compose_github(&mention.who, &work, &outcome_str, verified); Some(Handled { who: mention.who, body, outcome: outcome_str, measured_uj: work.measured_uj, verified }) @@ -267,19 +290,26 @@ fn compose_github(who: &str, work: &Work, outcome: &str, verified: bool) -> Stri let meter = &work.meter; let prov = &work.provenance; let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" }; - let (sealed, energy) = match obtained(work) { + // Work with no figure was never put to a grant and never reached a ledger: say so, + // rather than name a grant decision or a ledger that does not exist. + let (how_done, energy, ledger) = match obtained(work) { Some(how) => ( - "sealed to a ledger anyone can verify", + "Decided under a signed grant, worked on real hardware, sealed to a ledger anyone can verify", format!("{mj:.1} mJ ({uj} µJ) — {how} `{meter}`, provenance `{prov}`"), + format!("ledger {verify}"), + ), + None => ( + "Worked on real hardware; not metered, so no grant decided it and nothing was sealed", + unmetered_line(work), + "no ledger entry".to_string(), ), - None => ("nothing sealed", unmetered_line(work)), }; format!( "🔷 **@joule** — {outcome}, @{who}.\n\n{answer}\n\n---\n\ - _Decided under a signed grant, worked on real hardware, {sealed}._\n\ + _{how_done}._\n\ - **model:** `{model}`\n\ - **energy:** {energy}\n\ - - **decision:** {outcome} · ledger {verify}" + - **decision:** {outcome} · {ledger}" ) } @@ -369,6 +399,9 @@ pub(crate) mod tests { } const SECOND: Duration = Duration::from_secs(1); + /// A model path with no file behind it: the work is the deterministic fallback and + /// no subprocess runs. Tests pass it explicitly; none reads `JOULEDESK_MODEL`. + pub(crate) const NO_MODEL: &str = "/nonexistent.gguf"; /// The Apple estimator's declared minimum window. const ESTIMATOR_WINDOW: Duration = Duration::from_millis(100); @@ -452,6 +485,8 @@ pub(crate) mod tests { "{body}" ); assert!(!body.contains("measured"), "an estimate is never called a measurement: {body}"); + assert!(body.contains("_Decided under a signed grant, worked on real hardware, sealed to a ledger"), "{body}"); + assert!(body.contains("- **decision:** completed · ledger verified ✓"), "{body}"); } #[test] @@ -477,7 +512,16 @@ pub(crate) mod tests { ), "{body}" ); - assert!(body.contains("nothing sealed") && !body.contains("mJ"), "{body}"); + assert!(body.contains("nothing was sealed") && !body.contains("mJ"), "{body}"); + assert_unmetered_reply(&body); + } + + /// A reply for work with no figure names no grant decision and no ledger: neither + /// exists, because such work is never put to a grant or sealed. + pub(crate) fn assert_unmetered_reply(body: &str) { + assert!(!body.to_lowercase().contains("signed grant"), "no grant decided this work: {body}"); + assert!(!body.contains("UNVERIFIED") && !body.contains("ledger verified"), "there is no ledger: {body}"); + assert!(body.contains("no ledger entry"), "{body}"); } /// End to end on this host, through `handle`: the reply names the meter that @@ -488,11 +532,9 @@ pub(crate) mod tests { /// 1000 µJ, as a measurement. The meter is IOReport only where its CPU rail is live. #[test] fn a_mention_answered_too_fast_to_meter_is_posted_as_unmetered() { - std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force the fallback (no subprocess) let event = json!({ "comment": { "user": { "login": "dana" }, "body": "@joule summarize the thread" } }); - let h = handle(&event, "@joule").expect("addresses @joule"); - std::env::remove_var("JOULEDESK_MODEL"); + let h = handle_with(&event, "@joule", NO_MODEL).expect("addresses @joule"); let status = jouleclaw_energy::ioreport_status(); let energy = h.body.lines().find(|l| l.contains("**energy:**")).unwrap_or_default(); println!("jouledesk-github on this host: ioreport={status:?} {energy} · outcome={}", h.outcome); @@ -500,6 +542,8 @@ pub(crate) mod tests { assert_eq!((h.outcome.as_str(), h.verified, h.measured_uj), (NOT_SEALED, false, 0)); assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}"); assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body); + assert!(h.body.contains("- **model:** `deterministic-extract`"), "{}", h.body); + assert_unmetered_reply(&h.body); let ioreport = energy.contains("`apple-ioreport`"); assert_eq!(ioreport, status == IoReportStatus::Live, "IOReport runs exactly when its CPU rail is live"); } @@ -556,6 +600,6 @@ pub(crate) mod tests { #[test] fn ignores_comments_that_dont_address_the_handle() { let event = json!({ "comment": { "user": { "login": "x" }, "body": "just chatting" } }); - assert!(handle(&event, "@joule").is_none()); + assert!(handle_with(&event, "@joule", NO_MODEL).is_none()); } } diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs index d4811e681..6246a5fda 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/slack.rs @@ -10,12 +10,18 @@ use serde_json::{json, Value}; -use super::{do_work, seal, Handled}; +use super::{do_work_with, seal, Handled}; /// Handle one Slack `app_mention` event: `{ "event": { "user", "text", "channel" } }`. /// Strips the bot mention, does the work under the meter, seals, and composes the reply /// in Slack `mrkdwn`. `None` if there is no addressable text. pub fn handle(event: &Value) -> Option { + handle_with(event, &super::configured_model()) +} + +/// [`handle`] with the model given as the path to a local GGUF (see +/// [`super::do_work_with`]). +pub(crate) fn handle_with(event: &Value, model: &str) -> Option { let ev = &event["event"]; let who = ev["user"].as_str()?.to_string(); let question = strip_mentions(ev["text"].as_str()?); @@ -23,7 +29,7 @@ pub fn handle(event: &Value) -> Option { return None; } - let work = do_work(&question); + let work = do_work_with(&question, model); let (outcome, verified) = seal(&work, &question); let body = compose(&who, &work, &outcome, verified); Some(Handled { who, body, outcome, measured_uj: work.measured_uj, verified }) @@ -56,19 +62,25 @@ fn compose(who: &str, work: &super::Work, outcome: &str, verified: bool) -> Stri let meter = &work.meter; let prov = &work.provenance; let verify = if verified { "verified ✓" } else { "UNVERIFIED ✗" }; - let (sealed, energy) = match super::obtained(work) { + // As on GitHub: work with no figure names no grant decision and no ledger. + let (how_done, energy, ledger) = match super::obtained(work) { Some(how) => ( - "sealed to a verifiable ledger", + "Signed grant · worked on real hardware · sealed to a verifiable ledger", format!("{mj:.1} mJ {how} `{meter}` (provenance `{prov}`)"), + format!("ledger {verify}"), + ), + None => ( + "Worked on real hardware · not metered · no grant decision · nothing sealed", + super::unmetered_line(work), + "no ledger entry".to_string(), ), - None => ("nothing sealed", super::unmetered_line(work)), }; format!( "🔷 *@joule* — {outcome}, <@{who}>.\n\n{answer}\n\n\ - _Signed grant · worked on real hardware · {sealed}_\n\ + _{how_done}_\n\ • *model:* `{model}`\n\ • *energy:* {energy}\n\ - • *decision:* {outcome} · ledger {verify}" + • *decision:* {outcome} · {ledger}" ) } @@ -107,19 +119,19 @@ mod tests { /// metered, and why, and seals nothing — never a figure, never "measured". #[test] fn slack_app_mention_composes_mrkdwn_and_never_claims_a_figure_it_lacks() { - std::env::set_var("JOULEDESK_MODEL", "/nonexistent.gguf"); // force fallback (no subprocess) let event = json!({ "event": { "type": "app_mention", "user": "U123", "channel": "C1", "text": "<@UBOT> summarize the thread" } }); - let h = handle(&event).expect("addresses the app"); - std::env::remove_var("JOULEDESK_MODEL"); + let h = handle_with(&event, crate::tests::NO_MODEL).expect("addresses the app"); assert_eq!(h.who, "U123"); assert!(h.body.contains("<@U123>"), "{}", h.body); let energy = h.body.lines().find(|l| l.contains("• *energy:* ")).expect("an energy line"); assert_eq!((h.outcome.as_str(), h.verified), (super::super::NOT_SEALED, false), "{energy}"); assert!(energy.contains("not metered — `") && energy.contains("shorter than the"), "{energy}"); assert!(!h.body.contains("measured") && !h.body.contains(" mJ"), "{}", h.body); + assert!(h.body.contains("• *model:* `deterministic-extract`"), "{}", h.body); + crate::tests::assert_unmetered_reply(&h.body); } #[test] @@ -129,12 +141,15 @@ mod tests { let body = compose("U123", &w, "completed", true); assert!(body.contains("12.3 mJ estimated by `apple-silicon` (provenance `estimator`)"), "{body}"); assert!(!body.contains("measured") && !body.contains("metered on"), "{body}"); + assert!(body.contains("_Signed grant · worked on real hardware · sealed to a verifiable ledger_"), "{body}"); + assert!(body.contains("• *decision:* completed · ledger verified ✓"), "{body}"); let w = crate::tests::work(80_000, &Provenance::ModelBased.to_string(), "apple-ioreport"); let body = compose("U123", &w, "completed", true); assert!(body.contains("80.0 mJ measured via `apple-ioreport` (provenance `model_based`)"), "{body}"); let w = crate::tests::work(0, super::super::UNMETERED, "apple-ioreport"); let body = compose("U123", &w, super::super::NOT_SEALED, false); assert!(body.contains("not metered") && body.contains("nothing sealed") && !body.contains("mJ"), "{body}"); + crate::tests::assert_unmetered_reply(&body); } #[test] -- 2.54.0 (Apple Git-157) From 7d7e766b4a1d5bad241eb1ec5005312123b97378 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 08:48:07 -0400 Subject: [PATCH 10/23] jouleclaw-energy: the liveness probe burns its own CPU time, and its tests hold on a loaded host The hardware tests for the CPU-rail liveness probe run in CI on the macOS runner, which is this M5 Max, and it is often heavily loaded: load averages of 24-104 have been seen. Round 3 left tests that only held on a quiet host, a cache that was pinned per thread rather than per process, and rail values nobody checked. This commit fixes those. The probe's tick - The tick now burns 25 ms of the probing thread's own CPU time, measured on CLOCK_THREAD_CPUTIME_ID. It gives up after 500 ms of wall time. - The old tick spun on the wall clock. On a loaded host the scheduler gave it only part of each 25 ms. With 36 extra spinners on this Mac, that old tick left intervals with only 8.6-11.8 ms of thread CPU. - The tick is still passed into watch(), so the scripted tests keep their scripted ticks. What the hardware tests assert now - The probe burns CPU. In every interval it counts, the probing thread must use at least half a tick (12.5 ms) of CPU time. Only the 500 ms wall cap could break that, and the assertion message says so. The probe records its thread CPU at each sample for this check. - The probe subscribes to the Energy Model group only. The median wall time of the probe's own sample calls must be under 50 ms. A group sample takes about 6-12 ms and an all-channel sample 130-800 ms. The probe records each call's duration, and the test prints the median. - The interval-count assertions are gone: ">= 10 intervals" and ">= 2 intervals plus the full budget". What stays is "a frozen or absent verdict took at least the 1 s budget". It is a lower bound, and load can only make the probe slower. No hardware test asserts a ratio of CPU time to wall time. - New test: the_probe_sampler_reads_each_cpu_rail_at_its_own_value. It checks the values the sampler reads, against an oracle on the oracle's own all-channel subscription. - The oracle reads every Energy Model CPU rail by name, once before and once after one probe sample. - If the oracle saw a rail hold, the probe's value must equal it. If the rail moved, the probe's value must lie between the two reads. - Before this, a sampler that returned constant 0, or gave each CPU rail the sum of all CPU rails, passed every test. The oracle - It keeps reading past its 3 s window, up to 4 windows, until it has at least 2 reads after its baseline. - With fewer reads it fails the test, with the read count and the reason. It never returns CpuRailMoved(false) on 0 or 1 reads. - The "ioreport liveness:" line now ends with oracle_reads=N. The test that prints it keeps its name. - Under load the oracle needed the extension: one run got 2 reads in 3.76 s. The pure rule: each case has its own test - A rail that goes down between two samples (a reset or wrap) has not advanced. - A rail missing from the previous sample only sets its baseline. - No verdict maps to Unavailable. - A probe that cannot subscribe to the Energy Model group, or whose subscription never returns a sample, maps to Unavailable through status_of_run(). It is never Live and never CpuRailAbsent. That last rule changes behaviour. A host where the group cannot be subscribed but the all-channel subscription opens used to report CpuRailAbsent. It now reports Unavailable, because the probe saw nothing. IoReportStatus's documentation says this. The enum and its variants are unchanged. The once-per-process cache - A process-wide AtomicUsize, HOST_PROBE_RUNS (built only for tests), counts the runs of the real host probe. The thread-local counter is removed. - the_liveness_status_is_probed_once_and_cached now starts 6 threads through a barrier. Each calls ioreport_status() twice, builds two counters and calls detect_meter. The test asserts the host probe ran exactly once in the process. - This corrects 073fb7d8, which said the cache was pinned "once per process". Its thread-local counter only pinned "once on this thread": a per-thread cache passed all 50 tests. Evidence - M5 Max (macOS 27.0.1), clean test binary, 10 runs under 36 background spinners (2 x hw.ncpu). Load average went from 34.8 to 142.8 during the runs. The spinners were killed afterwards, and none was left. - Result: 10/10, "test result: ok. 55 passed". - Every run: "ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false oracle_reads=3..7". - Group probe: 5-10 intervals, median sample cost 7.16-10.24 ms. - Least thread CPU in any interval: 31.72-32.39 ms. - Oracle: 2-8 reads after the baseline. - M3 Ultra (27.0.1), the same binary: 55 passed. - Median sample cost 6.75 ms over 33 calls. - Rails DIE_0_CPU Energy and DIE_1_CPU Energy. - Sampler values equal to the oracle's: 28237793 and 30845042. - Oracle: 16 reads. Mutations. Each was a temporary edit, restored with git checkout, and git status was clean afterwards. - A decrease counts as an advance: 2 tests fail, "a fall counted as an advance". - A rail new in a sample counts as an advance: 1 fails, "a new rail counted as an advance". - No group subscription maps to Live: 1 fails. - Per-thread cache: 1 fails, "the host liveness probe ran 13 times in this process". With no cache at all, 41 times. - The sampler returns constant 0: 1 fails, "held at 1863990848 ... but the probe's sampler read 0". - Each CPU rail gets the sum of all CPU rails: - On the M3 Ultra it fails, "("DIE_0_CPU Energy", 0) held at 28237793 ... but the probe's sampler read 59082835". - On the M5 Max it is an equivalent mutant, because that group has one CPU rail. Summing every channel named *CPU* into it does fail there: "read 5567863491". - The tick sleeps: fails, "interval 0 of 29 burned only 6.086583ms". A no-op tick fails the same way: "interval 0 of 114 ... 8.550958ms". - The probe on the all-channel subscription: fails, "median sample call took 538.447416ms". - The oracle concludes after 1 read: 2 tests fail with the read-count message. - The mutations whose old assertions were removed are still caught: - watch() stopping after 1 interval fails 4 tests. - A 10x-fast probe clock fails, "decided CpuRailFrozen after 211.462541ms, before its 1s budget". - PROBE_TICK = 250 ms fails the scripted test (a). - The round-3 wall-clock tick passes on a quiet host. Under the same load it fails 3/3 on the new CPU pin. Other checks - No features: 24 passed. - Linux x86_64 and aarch64, with and without ioreport: cargo check passes. - Clippy: no new warnings. - Consumers: - jouledesk-github: 20/20. - jouledesk: 18 + 4. - loader-gguf with rayon and fabric-meter: 302 passed. Runs with only fabric-meter hit a tier_store temp-file race (NotFound) twice in 12 runs, in a file this branch does not touch; the 10 other runs passed. - harness-econ: 14. - mesh-meter with hw-meter-apple: 10. mesh-core: 3. - l3-jouleclaw: 2 + 4. Not done here - The counter's own read() still samples every channel. It is declined on both Macs today. - No CI job runs these tests: .github/workflows is off-limits to this branch. Co-Authored-By: Claude Opus 5.5 --- .../jouleclaw-energy/src/apple_ioreport.rs | 707 ++++++++++++------ .../crates/jouleclaw-energy/src/lib.rs | 18 +- 2 files changed, 510 insertions(+), 215 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index 388953991..28dedb4d8 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -22,10 +22,10 @@ //! of CPU work, tagged `ModelBased`. So [`AppleIoReportCounter::new`] is //! available only when one CPU rail itself keeps advancing — between //! consecutive samples, in at least two separate intervals — while the probing -//! thread burns CPU; probed once per process and cached, see -//! [`crate::ioreport_status`]. Otherwise the counter is unavailable and yields -//! no reading, [`crate::meter::detect_meter`] falls through to the load-avg -//! estimator, and that reading says `Estimator`. +//! thread burns 25 ms of its own CPU time between samples; probed once per +//! process and cached, see [`crate::ioreport_status`]. Otherwise the counter is +//! unavailable and yields no reading, [`crate::meter::detect_meter`] falls +//! through to the load-avg estimator, and that reading says `Estimator`. //! //! IOReport is private API. Apple has not deprecated it for M1+ in any released //! macOS; if the surface ever changes, [`AppleIoReportCounter::new`] returns an @@ -295,19 +295,29 @@ impl AppleIoReportCounter { // ── CPU-rail liveness ─────────────────────────────────────────────────────── -/// How long the liveness probe watches the CPU energy rail, burning CPU on its -/// thread between samples, before concluding the rail is frozen. A live Energy -/// Model rail moves many times a second (the M5 Max GPU rail changed in 166 of -/// 176 samples ~28 ms apart), and the probe samples the `Energy Model` group -/// alone every [`PROBE_TICK`] of burn, so a second holds dozens of intervals -/// where it needs two advancing ones. A slower live rail fails safe — the -/// counter is declined and the host estimated, never over-claimed. Paid once -/// per process, see [`crate::ioreport_status`]. +/// How long (wall clock) the liveness probe watches the CPU energy rail, +/// burning CPU on its thread between samples, before concluding the rail is +/// frozen. A live Energy Model rail moves many times a second (the M5 Max GPU +/// rail changed in 166 of 176 samples ~28 ms apart), and the probe samples the +/// `Energy Model` group alone after every [`PROBE_TICK`] of its own CPU time, +/// so an unloaded second holds dozens of intervals where it needs two advancing +/// ones; a loaded host gives fewer intervals, each still under a full tick of +/// burn. A slower live rail fails safe — the counter is declined and the host +/// estimated, never over-claimed. Paid once per process, see +/// [`crate::ioreport_status`]. pub const CPU_RAIL_LIVENESS_BUDGET: Duration = Duration::from_millis(1000); -/// CPU burned on the probing thread before each liveness re-sample. +/// Thread CPU time the probing thread burns before each liveness re-sample, +/// measured on its own `CLOCK_THREAD_CPUTIME_ID` clock, so a host whose other +/// work deschedules the probe still sees the probe's CPU load in every +/// interval. const PROBE_TICK: Duration = Duration::from_millis(25); +/// The most wall-clock time one [`PROBE_TICK`] may take. Only a host so loaded +/// that this thread gets less than a tick of CPU in this long cuts a tick +/// short; the probe's wall-clock budget and cap bound the probe either way. +const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); + /// In how many separate sample intervals one CPU rail must advance to count as /// live. One is not enough: see [`RailWatch`]. const LIVE_RAIL_ADVANCES: u32 = 2; @@ -436,7 +446,8 @@ impl RailWatch { /// with the last good one; the clock still runs, so a sampler that keeps /// failing ends at the cap with no verdict. `now` is the time since any fixed /// origin. The real probe ([`probe_cpu_rail`]) passes the IOReport sampler, a -/// [`PROBE_TICK`] CPU spin and [`Instant`]; the tests pass scripts. +/// burn of [`PROBE_TICK`] of thread CPU time ([`burn_thread_cpu`]) and +/// [`Instant`]; the tests pass scripts. fn watch( mut sample: impl FnMut() -> Option>, mut tick: impl FnMut(), @@ -468,6 +479,15 @@ fn status_of(verdict: Option) -> IoReportStatus { } } +/// The status one probe run maps to: `None` when the probe could not subscribe +/// to the `Energy Model` group at all. That, like a subscription that never +/// yields a sample (no verdict), is [`IoReportStatus::Unavailable`]: the probe +/// saw nothing, so it can say neither that a CPU rail is live nor that none +/// exists. +fn status_of_run(watch: Option<&RailWatch>) -> IoReportStatus { + status_of(watch.and_then(RailWatch::verdict)) +} + /// This host's IOReport status, probed once per process and cached. Backs /// [`crate::ioreport_status`] and gates [`AppleIoReportCounter::new`]. pub(crate) fn host_status() -> IoReportStatus { @@ -475,21 +495,23 @@ pub(crate) fn host_status() -> IoReportStatus { *STATUS.get_or_init(|| probe_status(CPU_RAIL_LIVENESS_BUDGET)) } +/// How many times [`probe_status`] — the real probe behind the cached host +/// status — has run in this process, on any thread. The tests pin the +/// once-per-process cache on it. Only the host path counts: the tests that +/// call [`probe`] directly, uncached and in parallel, do not touch it. +#[cfg(test)] +static HOST_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + /// Probe this host's CPU energy rail now, uncached. Backs [`host_status`]. fn probe_status(budget: Duration) -> IoReportStatus { + #[cfg(test)] + HOST_PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); probe(budget).0 } -thread_local! { - /// How many times [`probe`] — the real, uncached probe — has run on this - /// thread. The tests read it to pin the once-per-process cache: counted per - /// thread, so tests that probe on purpose, in parallel, do not disturb it. - static PROBE_RUNS: std::cell::Cell = const { std::cell::Cell::new(0) }; -} - /// What one run of the liveness probe sampled, beside the rule's view of it. -/// The channel counts are there for the tests, which assert on what the real -/// probe sampled. +/// Everything but the rule is there for the tests, which assert on what the +/// real probe sampled and what it cost. #[derive(Debug, Default)] struct ProbeRun { /// The liveness rule, fed every sample the probe took. @@ -500,30 +522,29 @@ struct ProbeRun { /// How many channels of any one sample were outside the `Energy Model` group. #[cfg_attr(not(test), allow(dead_code))] outside_group: usize, + /// The wall-clock duration of every sample call the probe made, failed + /// ones included, in order. + #[cfg_attr(not(test), allow(dead_code))] + sample_costs: Vec, + /// The probing thread's CPU time consumed in each interval the rule + /// counted (between one good sample and the next), in order: one entry per + /// [`RailWatch::intervals`] while the thread CPU clock reads. + #[cfg_attr(not(test), allow(dead_code))] + interval_cpu: Vec, } /// Probe this host's CPU energy rail now, uncached, on the probe's own /// subscription to the `Energy Model` group /// ([`AppleIoReportCounter::open_energy_model`]), which is dropped after: the /// status, and what the probe sampled to decide it (`None` when the group could -/// not be subscribed). +/// not be subscribed, which [`status_of_run`] maps to `Unavailable`). fn probe(budget: Duration) -> (IoReportStatus, Option) { - PROBE_RUNS.with(|runs| runs.set(runs.get() + 1)); - let Some(state) = AppleIoReportCounter::open_energy_model() else { - // No Energy Model subscription. Tell "no IOReport" from "no such group" - // by opening the subscription the counter itself would use. - let status = match AppleIoReportCounter::open() { - Some(_) => IoReportStatus::CpuRailAbsent, - None => IoReportStatus::Unavailable, - }; - return (status, None); - }; - let run = probe_cpu_rail(&state, budget); - (status_of(run.watch.verdict()), Some(run)) + let run = AppleIoReportCounter::open_energy_model().map(|state| probe_cpu_rail(&state, budget)); + (status_of_run(run.as_ref().map(|r| &r.watch)), run) } -/// Run [`watch`] over `state`'s subscription: burn CPU on this thread for -/// [`PROBE_TICK`] between samples, and decide by the rule in [`RailWatch`] +/// Run [`watch`] over `state`'s subscription: burn [`PROBE_TICK`] of this +/// thread's CPU time between samples, and decide by the rule in [`RailWatch`] /// whether the CPU energy rail **itself** is live. /// /// The summed total is not a liveness signal: on an M5 Max the GPU rail keeps @@ -533,32 +554,69 @@ fn probe(budget: Duration) -> (IoReportStatus, Option) { /// /// Each sample is one `IOReportCreateSamples` over `state`'s subscription. On /// the probe's `Energy Model` subscription that costs a few milliseconds, so -/// the thread spends most of `budget` burning CPU and compares the rail every -/// ~[`PROBE_TICK`]; on an all-channel subscription one sample takes 0.14–0.8 s -/// and the probe would compare only a handful of times. +/// the thread spends most of `budget` burning CPU and compares the rail after +/// every [`PROBE_TICK`] of burn; on an all-channel subscription one sample +/// takes 0.13–0.8 s and the probe would compare only a handful of times. fn probe_cpu_rail(state: &State, budget: Duration) -> ProbeRun { let (mut channels, mut outside_group) = (0, 0); + let (mut sample_costs, mut interval_cpu) = (Vec::new(), Vec::new()); + let mut cpu_at_last_sample: Option = None; let started = Instant::now(); let watch = watch( || { - let s = sample_energy_model(state)?; + let t = Instant::now(); + let s = sample_energy_model(state); + sample_costs.push(t.elapsed()); + let s = s?; + let cpu = thread_cpu_time(); + if let (Some(before), Some(now)) = (cpu_at_last_sample, cpu) { + interval_cpu.push(now.saturating_sub(before)); + } + cpu_at_last_sample = cpu; channels = channels.max(s.channels); outside_group = outside_group.max(s.outside_group); Some(s.values) }, - || spin(PROBE_TICK), + || burn_thread_cpu(PROBE_TICK, PROBE_TICK_WALL_CAP), || started.elapsed(), budget, ); - ProbeRun { watch, channels, outside_group } + ProbeRun { watch, channels, outside_group, sample_costs, interval_cpu } } -/// Spin this thread for `d`. -fn spin(d: Duration) { - let started = Instant::now(); +/// This thread's CPU time so far (`CLOCK_THREAD_CPUTIME_ID`: user and system +/// time the thread actually ran, not time it waited to be scheduled). `None` +/// if the clock cannot be read. +fn thread_cpu_time() -> Option { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: `ts` is a valid, writable timespec for the duration of the call. + let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + (rc == 0 && ts.tv_sec >= 0 && ts.tv_nsec >= 0) + .then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)) +} + +/// Burn `cpu` of this thread's own CPU time — until its thread CPU clock has +/// advanced by `cpu`, not until the wall clock has — or stop after `wall_cap` +/// of wall-clock time, whichever comes first. A wall-clock spin on a loaded +/// host burns only the share of its time the scheduler grants it; this burns +/// the whole amount unless the thread gets less than `cpu` of CPU in +/// `wall_cap`. If the thread CPU clock cannot be read, it spins for `cpu` of +/// wall-clock time instead. +fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) { + let wall = Instant::now(); + let start = thread_cpu_time(); let mut acc: u64 = 0; - while started.elapsed() < d { - acc = acc.wrapping_add(1); + loop { + for _ in 0..1024 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + let burned = match (start, thread_cpu_time()) { + (Some(start), Some(now)) => now.saturating_sub(start), + _ => wall.elapsed(), + }; + if burned >= cpu || wall.elapsed() >= wall_cap { + break; + } } std::hint::black_box(acc); } @@ -976,16 +1034,82 @@ mod tests { assert_eq!(w.verdict(), Some(CpuRail::Absent)); } - /// Every verdict maps to the status that says it, and no verdict to - /// `Unavailable`. + /// A CPU rail that goes DOWN between consecutive samples — a counter reset + /// or wrap — has not advanced. A rail that falls in every interval is + /// frozen with no advance counted, and a reset followed by one rise is one + /// advance, not two. + #[test] + fn a_cpu_rail_going_down_is_not_an_advance() { + let mut w = RailWatch::default(); + for raw in [900, 800, 700, 600, 500] { + w.observe(&smp(&[("CPU Energy", raw)])); + } + assert_eq!(w.intervals, 4); + assert_eq!(w.rails, [(("CPU Energy".to_string(), 0), 0)], "a fall counted as an advance: {w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + + let mut w = RailWatch::default(); + for raw in [i64::MAX, i64::MAX, 0, 0, 10] { + w.observe(&smp(&[("CPU Energy", raw)])); + } + assert_eq!(w.rails, [(("CPU Energy".to_string(), 0), 1)], "a wrap counted as an advance: {w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + } + + /// A CPU rail missing from the previous sample has nothing to be compared + /// with: its first sample only sets its baseline. A rail that appears and + /// then rises once has advanced once, not twice — whether it appears in a + /// sample that had no CPU rail, or beside a die already watched. + #[test] + fn a_rail_new_in_a_sample_only_sets_its_baseline() { + let mut w = RailWatch::default(); + w.observe(&smp(&[("GPU Energy", 1)])); + w.observe(&smp(&[("GPU Energy", 2), ("CPU Energy", 500)])); + assert_eq!(w.rails, [(("CPU Energy".to_string(), 0), 0)], "a new rail counted as an advance: {w:?}"); + w.observe(&smp(&[("GPU Energy", 3), ("CPU Energy", 600)])); + assert_eq!(w.rails, [(("CPU Energy".to_string(), 0), 1)], "{w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one rise after its baseline is not live: {w:?}"); + + let mut w = RailWatch::default(); + w.observe(&smp(&[("DIE_0_CPU Energy", 7)])); + w.observe(&smp(&[("DIE_0_CPU Energy", 7), ("DIE_1_CPU Energy", 40)])); + w.observe(&smp(&[("DIE_0_CPU Energy", 7), ("DIE_1_CPU Energy", 41)])); + w.observe(&smp(&[("DIE_0_CPU Energy", 7), ("DIE_1_CPU Energy", 41)])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a die new in a sample counted as an advance: {w:?}"); + } + + /// No verdict at all — not one interval observed — is `Unavailable`. + #[test] + fn no_verdict_is_unavailable() { + assert_eq!(status_of(None), IoReportStatus::Unavailable); + } + + /// Every verdict maps to the status that says it. #[test] fn each_verdict_maps_to_its_status() { - assert_eq!(status_of(None), IoReportStatus::Unavailable); assert_eq!(status_of(Some(CpuRail::Absent)), IoReportStatus::CpuRailAbsent); assert_eq!(status_of(Some(CpuRail::Frozen)), IoReportStatus::CpuRailFrozen); assert_eq!(status_of(Some(CpuRail::Live)), IoReportStatus::Live); } + /// A probe that could not subscribe to the `Energy Model` group, and one + /// whose subscription never yielded a sample, saw nothing: both are + /// `Unavailable` — never `Live`, and never `CpuRailAbsent`, which claims + /// the group was sampled and held no CPU rail. + #[test] + fn no_energy_model_subscription_or_no_sample_is_unavailable() { + assert_eq!(status_of_run(None), IoReportStatus::Unavailable, "no Energy Model subscription"); + let (never, samples, _, _) = scripted(|_| None, GROUP_SAMPLE, CPU_RAIL_LIVENESS_BUDGET); + assert!(samples > 1, "the scripted probe tried {samples} samples"); + assert_eq!(status_of_run(Some(&never)), IoReportStatus::Unavailable, "a subscription with no sample"); + let (one, _, _, _) = scripted(|i| (i == 0).then(|| smp(&[("GPU Energy", 1)])), GROUP_SAMPLE, CPU_RAIL_LIVENESS_BUDGET); + assert_eq!( + status_of_run(Some(&one)), + IoReportStatus::Unavailable, + "a baseline and no second sample is no interval: {one:?}" + ); + } + /// The probe keeps sampling within its budget until the rail proves live, /// and past the budget until a live rail could have advanced twice — but /// never past five budgets. @@ -1176,53 +1300,108 @@ mod tests { CpuRailMoved(bool), } - /// The ABSOLUTE cumulative value of every channel whose name ends with - /// `CPU Energy`, in any group, from one plain sample (no delta). - fn oracle_read(sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef) -> Option> { - unsafe { - let smp = IOReportCreateSamples(sub, chans, std::ptr::null()); - if smp.is_null() { - return None; + /// The oracle's own IOReport subscription, over every channel: not + /// `open()`, not the probe's group subscription, only the raw FFI + /// declarations. Released on drop. + struct OracleSub { + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + } + + impl OracleSub { + fn open() -> Option { + unsafe { + let all = IOReportCopyAllChannels(0, 0); + if all.is_null() { + return None; + } + let mut chans: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription(std::ptr::null(), all, &mut chans, 0, std::ptr::null()); + CFRelease(all as CFTypeRef); + let oracle = Self { sub, chans }; + (!sub.is_null() && !chans.is_null()).then_some(oracle) } - 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); + } + + /// Every channel of one plain sample (no delta), as (group, name, + /// ABSOLUTE raw value), in sample order. + fn read(&self) -> Option> { + unsafe { + let smp = IOReportCreateSamples(self.sub, self.chans, 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; + } + vals.push(( + cfstring_to_string(IOReportChannelGetGroup(it)).unwrap_or_default(), + cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(), + IOReportSimpleGetIntegerValue(it, 0) as i64, + )); } } } + CFRelease(smp); + Some(vals) } - CFRelease(smp); - Some(vals) + } + + /// The value of every channel whose name ends with `CPU Energy`, in any + /// group, from one read. + fn read_cpu_rails(&self) -> Option> { + let vals = self.read()?; + Some(vals.into_iter().filter(|(_, name, _)| name.ends_with("CPU Energy")).map(|(_, _, v)| v).collect()) } } + impl Drop for OracleSub { + fn drop(&mut self) { + unsafe { + if !self.sub.is_null() { + CFRelease(self.sub); + } + if !self.chans.is_null() { + CFRelease(self.chans as CFTypeRef); + } + } + } + } + + /// The oracle needs at least this many reads after its baseline before it + /// says anything about a rail it found: two changes are its bar for a + /// moving rail, and fewer reads than that cannot tell a live rail from a + /// held one. + const ORACLE_MIN_READS: u32 = 2; + + /// How far past its window the oracle keeps reading, as a multiple of the + /// window, when a loaded host has not yet given it [`ORACLE_MIN_READS`]. + const ORACLE_WINDOW_CAP: u32 = 4; + /// An oracle that shares nothing with the liveness probe but the raw FFI - /// declarations: it opens its own subscription over every channel (not - /// `open()`, not the probe's group subscription), reads each CPU rail's - /// ABSOLUTE cumulative value (no delta samples), and matches names with - /// `ends_with("CPU Energy")` in any group (not the implementation's group - /// filter and die-prefix stripping). + /// declarations: it opens its own subscription over every channel + /// ([`OracleSub`]), reads each CPU rail's ABSOLUTE cumulative value (no + /// delta samples), and matches names with `ends_with("CPU Energy")` in any + /// group (not the implementation's group filter and die-prefix stripping). /// /// The load is a helper thread that spins from before the subscription - /// opens until the last read, for the whole `window`, so the window is under - /// CPU load even while a read blocks (one all-channel sample takes 0.1–0.8 s - /// on an M5 Max). The calling thread only reads, back to back, until - /// `window` has passed. + /// opens until the last read, so the window is under CPU load even while a + /// read blocks (one all-channel sample takes 0.1–2 s on an M5 Max). The + /// calling thread only reads, back to back, until `window` has passed — and + /// on past it, up to [`ORACLE_WINDOW_CAP`] windows, until it has + /// [`ORACLE_MIN_READS`] reads after the first. /// /// `CpuRailMoved(true)` means some rail kept moving: its value changed /// between consecutive reads at two or more separate reads. A live rail @@ -1230,7 +1409,12 @@ mod tests { /// window this short, and one jump is not a live reading. Its own literal /// threshold, not the implementation's constant, so a change to one cannot /// silently move the other. - fn oracle_cpu_rail(window: Duration) -> Oracle { + /// + /// Fewer than [`ORACLE_MIN_READS`] reads after the baseline is no evidence + /// that a rail held: the calling test FAILS here, with the count, rather + /// than get `CpuRailMoved(false)` out of 0 or 1 reads. Returns the verdict + /// and the read count. + fn oracle_cpu_rail(window: Duration) -> (Oracle, u32) { let stop = Arc::new(AtomicBool::new(false)); let burner = { let stop = Arc::clone(&stop); @@ -1244,67 +1428,59 @@ mod tests { started.elapsed() }) }; + let started = Instant::now(); let (verdict, reads, changes) = oracle_watch(window); + let took = started.elapsed(); stop.store(true, Ordering::Relaxed); let burned = burner.join().expect("the oracle's burn thread"); eprintln!( - "ioreport oracle: {reads} reads over a {window:?} window, CPU burned on a helper thread for \ - {burned:?}, changes per CPU rail {changes:?} -> {verdict:?}" + "ioreport oracle: {reads} reads after the baseline in {took:?} (window {window:?}), CPU burned on a \ + helper thread for {burned:?}, changes per CPU rail {changes:?} -> {verdict:?}" ); - verdict + assert!( + !matches!(verdict, Oracle::CpuRailMoved(_)) || reads >= ORACLE_MIN_READS, + "the oracle found a CPU rail but read it only {reads} time(s) after its baseline in {took:?} (window \ + {window:?}, extended up to {:?}): too few reads to say whether the rail moved or held — no evidence \ + either way, so no verdict. The host is too loaded to sample every channel in time.", + window * ORACLE_WINDOW_CAP + ); + (verdict, reads) } /// The oracle's reads, on the calling thread: the verdict, how many reads /// after the first, and at how many of them each CPU rail had changed. fn oracle_watch(window: Duration) -> (Oracle, u32, Vec) { let started = Instant::now(); - unsafe { - let all = IOReportCopyAllChannels(0, 0); - if all.is_null() { - return (Oracle::NoIoReport, 0, Vec::new()); - } - let mut chans: CFMutableDictionaryRef = std::ptr::null_mut(); - let sub = IOReportCreateSubscription(std::ptr::null(), all, &mut chans, 0, std::ptr::null()); - CFRelease(all as CFTypeRef); - if sub.is_null() || chans.is_null() { - if !sub.is_null() { - CFRelease(sub); - } - if !chans.is_null() { - CFRelease(chans as CFTypeRef); - } - return (Oracle::NoIoReport, 0, Vec::new()); - } - let mut changes = Vec::new(); - let mut reads = 0u32; - let verdict = 'watch: { - let Some(mut prev) = oracle_read(sub, chans) else { - break 'watch Oracle::NoIoReport; - }; - if prev.is_empty() { - break 'watch Oracle::NoCpuRail; - } - // Per rail: at how many reads its absolute value had changed. - changes = vec![0u32; prev.len()]; - while started.elapsed() < window { - let Some(cur) = oracle_read(sub, chans) else { - continue; - }; - reads += 1; - for (count, (now, before)) in changes.iter_mut().zip(cur.iter().zip(prev.iter())) { - *count += u32::from(now != before); - } - if changes.iter().any(|&c| c >= 2) { - break 'watch Oracle::CpuRailMoved(true); - } - prev = cur; - } - Oracle::CpuRailMoved(false) - }; - CFRelease(sub); - CFRelease(chans as CFTypeRef); - (verdict, reads, changes) + let Some(oracle) = OracleSub::open() else { + return (Oracle::NoIoReport, 0, Vec::new()); + }; + let Some(mut prev) = oracle.read_cpu_rails() else { + return (Oracle::NoIoReport, 0, Vec::new()); + }; + if prev.is_empty() { + return (Oracle::NoCpuRail, 0, Vec::new()); } + // Per rail: at how many reads its absolute value had changed. + let mut changes = vec![0u32; prev.len()]; + let mut reads = 0u32; + let reading = |reads: u32| { + let elapsed = started.elapsed(); + elapsed < window || (reads < ORACLE_MIN_READS && elapsed < window * ORACLE_WINDOW_CAP) + }; + while reading(reads) { + let Some(cur) = oracle.read_cpu_rails() else { + continue; + }; + reads += 1; + for (count, (now, before)) in changes.iter_mut().zip(cur.iter().zip(prev.iter())) { + *count += u32::from(now != before); + } + if changes.iter().any(|&c| c >= 2) { + return (Oracle::CpuRailMoved(true), reads, changes); + } + prev = cur; + } + (Oracle::CpuRailMoved(false), reads, changes) } /// The property that matters: the counter is never available — so @@ -1325,10 +1501,12 @@ mod tests { let counter = AppleIoReportCounter::new(); let available = counter.is_available(); let selected = crate::meter::detect_meter().active_name() == "apple-ioreport"; - let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); - // Recorded so a CI log says which branch below was actually asserted. + let (oracle, oracle_reads) = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); + // Recorded so a CI log says which branch below was actually asserted, + // and on how many oracle reads. eprintln!( - "ioreport liveness: status={status:?} oracle={oracle:?} selected={selected} available={available}" + "ioreport liveness: status={status:?} oracle={oracle:?} selected={selected} available={available} \ + oracle_reads={oracle_reads}" ); match oracle { Oracle::NoIoReport => { @@ -1382,32 +1560,53 @@ mod tests { /// beside the cached status the counter is gated on, and asserts on every /// outcome, not only on Live. /// - /// And its cadence: wherever the probe could sample and did not find a live - /// rail, it ran for its whole budget and compared at least 10 intervals — - /// what a group-only subscription re-sampled every ~25 ms gives (about 30 - /// on both Macs), and what an all-channel probe (2–8 intervals a second) or - /// one that stops early cannot. + /// And that the probe burns CPU while it watches: in every interval it + /// counted, the probing thread consumed at least half a [`PROBE_TICK`] of + /// its own CPU time. The tick burns thread CPU time, not wall time, so this + /// holds however loaded the host is; only the tick's wall-clock cap can + /// cut it short. A tick that sleeps, or does nothing, leaves an interval + /// with only the sample call's few milliseconds. Wherever the probe could + /// sample and found no live rail, it also watched for its whole budget. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { let t = Instant::now(); let (probed, run) = probe(CPU_RAIL_LIVENESS_BUDGET); let probe_took = t.elapsed(); let cached = crate::ioreport_status(); - let oracle = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); + let (oracle, _) = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); let intervals = run.as_ref().map(|r| r.watch.intervals); + let cpu = run.as_ref().map(|r| { + let mut cpu = r.interval_cpu.clone(); + cpu.sort(); + (cpu.first().copied(), median(&cpu)) + }); eprintln!( - "ioreport probe: probed={probed:?} cached={cached:?} oracle={oracle:?} \ - intervals={intervals:?} probe_took={probe_took:?}" + "ioreport probe: probed={probed:?} cached={cached:?} oracle={oracle:?} intervals={intervals:?} \ + probe_took={probe_took:?} thread CPU per interval (least, median)={cpu:?}" ); + if let Some(run) = &run + && run.watch.verdict().is_some() + { + assert_eq!( + run.interval_cpu.len(), + run.watch.intervals as usize, + "the probe did not read this thread's CPU clock at every sample it counted" + ); + for (i, cpu) in run.interval_cpu.iter().enumerate() { + assert!( + *cpu >= PROBE_TICK / 2, + "interval {i} of {} burned only {cpu:?} of the probing thread's CPU time, under half a \ + {PROBE_TICK:?} tick: the probe did not burn CPU between samples. The tick burns thread CPU \ + time, so host load cannot cause this; only its {PROBE_TICK_WALL_CAP:?} wall-clock cap can, on \ + a host that gave this thread under {:?} of CPU in {PROBE_TICK_WALL_CAP:?}", + run.watch.intervals, + PROBE_TICK / 2 + ); + } + } if let Some(run) = &run && matches!(run.watch.verdict(), Some(CpuRail::Frozen | CpuRail::Absent)) { - assert!( - run.watch.intervals >= 10, - "the probe decided {probed:?} on {} intervals in {probe_took:?}: it did not re-sample \ - the Energy Model group every ~{PROBE_TICK:?}", - run.watch.intervals - ); assert!( probe_took >= CPU_RAIL_LIVENESS_BUDGET, "the probe decided {probed:?} after {probe_took:?}, before its {CPU_RAIL_LIVENESS_BUDGET:?} budget" @@ -1428,14 +1627,26 @@ mod tests { Oracle::NoCpuRail, "the probe found no CPU rail, the oracle found one" ), - (IoReportStatus::Unavailable, _) => assert_eq!( - oracle, - Oracle::NoIoReport, - "the probe could not open or sample IOReport, the oracle could" + (IoReportStatus::Unavailable, _) => assert!( + oracle == Oracle::NoIoReport + || AppleIoReportCounter::open_energy_model().and_then(|s| sample_energy_model(&s)).is_none(), + "the probe said Unavailable, yet the oracle read IOReport ({oracle:?}) and the Energy Model group \ + subscribes and samples" ), } } + /// The median of durations sorted ascending: the middle one, or the mean + /// of the middle two. `None` for none. + fn median(sorted: &[Duration]) -> Option { + let n = sorted.len(); + match n { + 0 => None, + _ if n % 2 == 1 => Some(sorted[n / 2]), + _ => Some((sorted[n / 2 - 1] + sorted[n / 2]) / 2), + } + } + /// Every channel in one plain sample of `state`'s subscription, as /// (group, name), and how long `IOReportCreateSamples` took. fn sample_channels(state: &State) -> Option<(Vec<(String, String)>, Duration)> { @@ -1475,28 +1686,28 @@ mod tests { /// The probe samples its own subscription to the `Energy Model` group, not /// every channel, so one sample costs milliseconds and it really re-samples - /// after every ~25 ms of burn. Asserted on what the probe [`host_status`] - /// runs actually sampled, not on timings: every channel in its samples is in - /// the `Energy Model` group, and there are fewer of them than one sample of - /// the counter's all-channel subscription holds. The group's CPU rails are - /// exactly the ones the counter's subscription carries, and exactly the - /// rails the probe's rule watched, one by one (`DIE_0_` and `DIE_1_` on an - /// M3 Ultra): the FFI hands the rule every rail, never a merged one. And - /// unless the rail proved live, the probe sampled for its whole budget, at - /// least two intervals. Where the group cannot be subscribed, the status - /// must say why. + /// after every tick of burn. Pinned on the probe's own sample calls: their + /// MEDIAN wall-clock duration is under 50 ms (one group sample takes + /// ~6–12 ms, one all-channel sample 130–800 ms; a median shrugs off the + /// odd sample a loaded host stalls). And on what the probe actually + /// sampled: every channel in its samples is in the `Energy Model` group, + /// and there are fewer of them than one sample of the counter's + /// all-channel subscription holds. The group's CPU rails are exactly the + /// ones the counter's subscription carries, and exactly the rails the + /// probe's rule watched, one by one (`DIE_0_` and `DIE_1_` on an M3 + /// Ultra): the FFI hands the rule every rail, never a merged one. Where + /// the group cannot be subscribed, the status is `Unavailable`. #[test] fn the_liveness_probe_subscribes_to_the_energy_model_group_only() { let t = Instant::now(); let (status, run) = probe(CPU_RAIL_LIVENESS_BUDGET); let probe_took = t.elapsed(); let Some(run) = run else { - let why = if AppleIoReportCounter::open().is_some() { - IoReportStatus::CpuRailAbsent - } else { - IoReportStatus::Unavailable - }; - assert_eq!(status, why, "the probe had no Energy Model subscription, yet status={status:?}"); + assert_eq!( + status, + IoReportStatus::Unavailable, + "the probe had no Energy Model subscription, yet status={status:?}" + ); return; }; let group = AppleIoReportCounter::open_energy_model().expect("the probe subscribed to the group once"); @@ -1504,14 +1715,27 @@ mod tests { let (group_chans, group_took) = sample_channels(&group).expect("sample the Energy Model group"); let (all_chans, all_took) = sample_channels(&all).expect("sample every channel"); let watched: Vec = run.watch.rails.iter().map(|(key, _)| key.clone()).collect(); + let mut costs = run.sample_costs.clone(); + costs.sort(); + let median_cost = median(&costs).expect("the probe made at least one sample call"); eprintln!( "ioreport probe subscription: status={status:?} after {} intervals in {probe_took:?}; \ {} channels per sample, {} outside the Energy Model group (all-channel subscription: {}); \ - one sample {group_took:?} (all-channel {all_took:?}); rails watched {watched:?}", + median sample cost {median_cost:?} over {} sample calls (least {:?}, most {:?}); \ + one test sample {group_took:?} (all-channel {all_took:?}); rails watched {watched:?}", run.watch.intervals, run.channels, run.outside_group, - all_chans.len() + all_chans.len(), + costs.len(), + costs.first(), + costs.last() + ); + assert!( + median_cost < Duration::from_millis(50), + "the probe's median sample call took {median_cost:?} over {} calls: not the Energy Model group alone \ + (one group sample takes ~6-12 ms, one all-channel sample 130-800 ms)", + costs.len() ); assert!(run.channels > 0, "the probe's samples held no channels"); assert_eq!( @@ -1525,14 +1749,6 @@ mod tests { run.channels, all_chans.len() ); - if status != IoReportStatus::Live { - assert!( - run.watch.intervals >= LIVE_RAIL_ADVANCES && probe_took >= CPU_RAIL_LIVENESS_BUDGET, - "the probe stopped at {status:?} after {} intervals in {probe_took:?}: short of two intervals \ - or of its {CPU_RAIL_LIVENESS_BUDGET:?} budget", - run.watch.intervals - ); - } let cpu_rails = |chans: &[(String, String)]| { let mut rails: Vec = Vec::new(); for (group, name) in chans { @@ -1555,31 +1771,106 @@ mod tests { assert_eq!(watched, group_rails, "the probe's rule did not watch the group's CPU rails one by one"); } - /// The probe runs once per process. Three status calls, two counters and a - /// `detect_meter` ran the real probe at most once on this thread — once if - /// this thread filled the cache, never if another test's did. Without the - /// cache each of them would probe again. + /// The probe sampler reads each CPU rail at that rail's own value. The + /// oracle reads every `Energy Model` CPU rail, by name, on its own + /// all-channel subscription just before and just after one sample of the + /// probe's sampler ([`sample_energy_model`] on the probe's group + /// subscription). A rail the oracle saw hold across those two reads — a + /// frozen rail is constant — must carry exactly that value in the probe's + /// sample; a rail that moved between them must lie within them. So a + /// sampler that returns a constant, sums rails into each other or reads + /// the wrong rail fails here even on a host whose rails are frozen, where + /// the liveness rule alone cannot tell. + #[test] + fn the_probe_sampler_reads_each_cpu_rail_at_its_own_value() { + /// `Energy Model` CPU rails, keyed the test's own way — (name, its + /// ordinal among same-named rails) — sorted by key. + fn keyed<'a>(chans: impl Iterator) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in chans.filter(|(name, _)| name.ends_with("CPU Energy")) { + let nth = rails.iter().filter(|((n, _), _)| n == name).count() as u32; + rails.push(((name.to_string(), nth), raw)); + } + rails.sort(); + rails + } + let status = crate::ioreport_status(); + let Some(oracle) = OracleSub::open() else { + assert_eq!(status, IoReportStatus::Unavailable, "IOReport cannot open, yet status={status:?}"); + return; + }; + let Some(group) = AppleIoReportCounter::open_energy_model() else { + assert_eq!(status, IoReportStatus::Unavailable, "no Energy Model subscription, yet status={status:?}"); + return; + }; + let group_rails = |read: Vec<(String, String, i64)>| { + let in_group = read.iter().filter(|(g, _, _)| g.eq_ignore_ascii_case("Energy Model")); + keyed(in_group.map(|(_, name, raw)| (name.as_str(), *raw))) + }; + let before = group_rails(oracle.read().expect("the oracle reads every channel")); + let sample = sample_energy_model(&group).expect("the probe's sampler reads the Energy Model group"); + let after = group_rails(oracle.read().expect("the oracle reads every channel")); + let probe = keyed(sample.values.iter().map(|(name, raw)| (name.as_str(), *raw))); + eprintln!("ioreport sampler values: probe {probe:?}; oracle before {before:?}, after {after:?}"); + + let keys = |rails: &[(RailKey, i64)]| rails.iter().map(|(k, _)| k.clone()).collect::>(); + assert_eq!(keys(&before), keys(&after), "the oracle's CPU rails changed between two reads"); + assert_eq!(keys(&probe), keys(&before), "the probe's sampler and the oracle see different CPU rails"); + if before.is_empty() { + assert_eq!(status, IoReportStatus::CpuRailAbsent, "no CPU rail in the group, yet status={status:?}"); + } + for ((key, p), ((_, b), (_, a))) in probe.iter().zip(before.iter().zip(after.iter())) { + if b == a { + assert_eq!( + p, b, + "{key:?} held at {b} across the oracle's reads, but the probe's sampler read {p}" + ); + } else { + assert!( + (*b.min(a)..=*b.max(a)).contains(p), + "{key:?} moved from {b} to {a} across the oracle's reads, but the probe's sampler read {p}, \ + outside them" + ); + } + } + } + + /// The probe runs once per process, not once per thread. Several threads, + /// released together, each call the status twice and build two counters + /// and a `detect_meter`, while other tests do the same: the real host + /// probe has run exactly once in this whole process. A cache per thread + /// would probe on each of these threads; no cache, on every call. #[test] fn the_liveness_status_is_probed_once_and_cached() { - let runs = || PROBE_RUNS.with(|runs| runs.get()); - let before = runs(); - let first = crate::ioreport_status(); - let again = crate::ioreport_status(); - let counter = AppleIoReportCounter::new(); - let other = AppleIoReportCounter::default(); - let meter = crate::meter::detect_meter(); - let third = crate::ioreport_status(); - let probed = runs() - before; + const THREADS: usize = 6; + let start = Arc::new(std::sync::Barrier::new(THREADS)); + let workers: Vec<_> = (0..THREADS) + .map(|_| { + let start = Arc::clone(&start); + std::thread::spawn(move || { + start.wait(); + let first = crate::ioreport_status(); + let counter = AppleIoReportCounter::new(); + let other = AppleIoReportCounter::default(); + let meter = crate::meter::detect_meter(); + let again = crate::ioreport_status(); + let statuses = [first, again, counter.status(), other.status()]; + (statuses, counter.is_available(), meter.active_name()) + }) + }) + .collect(); + let seen: Vec<_> = workers.into_iter().map(|w| w.join().expect("a status thread panicked")).collect(); + let runs = HOST_PROBE_RUNS.load(Ordering::SeqCst); + let status = crate::ioreport_status(); eprintln!( - "ioreport cache: status={first:?}; the real probe ran {probed} time(s) on this thread for three \ - status calls, two counters and detect_meter (active: {})", - meter.active_name() + "ioreport cache: status={status:?}; {THREADS} threads at once, each with two status calls, two \ + counters and detect_meter (active: {}): the host probe ran {runs} time(s) in this process", + seen[0].2 ); - assert!(probed <= 1, "the real liveness probe ran {probed} times: the status is not cached"); - assert_eq!((first, again, third), (first, first, first), "the cached status changed between calls"); - for c in [&counter, &other] { - assert_eq!(c.status(), first); - assert_eq!(c.is_available(), first == IoReportStatus::Live); + assert_eq!(runs, 1, "the host liveness probe ran {runs} times in this process: not once per process"); + for (statuses, available, _) in &seen { + assert_eq!(*statuses, [status; 4], "a thread saw a different status"); + assert_eq!(*available, status == IoReportStatus::Live); } } @@ -1599,7 +1890,7 @@ mod tests { assert_eq!(rail, Some(CpuRail::Live), "an available counter whose own CPU rail does not move"); let _ = counter.read(); // prime let before = counter.read().expect("baseline read"); - spin(Duration::from_millis(200)); + burn_thread_cpu(Duration::from_millis(200), Duration::from_secs(5)); let after = counter.read().expect("work read"); assert!( after.uj > before.uj, @@ -1649,7 +1940,7 @@ mod tests { let work = Duration::from_millis(150).max(Duration::from_nanos(active.min_window_ns())); let before = composite.read().expect("the fallback reads"); let t = Instant::now(); - spin(work); + burn_thread_cpu(work, Duration::from_secs(5)); let after = composite.read().expect("the fallback reads"); let took = t.elapsed(); let uj = after.uj.checked_sub(before.uj).expect("the fallback's reading never runs backwards"); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs index a35b023e2..bb718c523 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs @@ -92,13 +92,15 @@ impl fmt::Display for Provenance { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IoReportStatus { /// IOReport is not usable here: this build has no IOReport backend (not - /// macOS, or built without the `ioreport` feature), or no subscription - /// could be opened or sampled (not Apple Silicon, Rosetta, a future macOS - /// that changes the private surface). + /// macOS, or built without the `ioreport` feature), or the probe could not + /// subscribe to the `Energy Model` group or never got a sample of it (not + /// Apple Silicon, Rosetta, a future macOS that changes the private + /// surface). The probe saw nothing, so it claims neither a live rail nor a + /// missing one. Unavailable, - /// A subscription opened, but no `CPU Energy` rail (nor `DIE__CPU Energy`) - /// exists in the `Energy Model` group — an unfamiliar channel naming. The - /// counter is never used. + /// The `Energy Model` group was subscribed and sampled, but no `CPU Energy` + /// rail (nor `DIE__CPU Energy`) exists in it — an unfamiliar channel + /// naming. The counter is never used. CpuRailAbsent, /// The CPU energy rail exists but does not advance like a live reading while /// this process burns CPU: it held its value, or moved in a single step (a @@ -116,7 +118,9 @@ pub enum IoReportStatus { /// /// On macOS with the `ioreport` feature, the first call opens an IOReport /// subscription to the `Energy Model` group and burns CPU on the calling thread -/// while it watches the CPU energy rail, re-sampling every ~25 ms: about +/// while it watches the CPU energy rail, re-sampling after every 25 ms of the +/// thread's own CPU time (a tick gives up after 500 ms of wall time on a host +/// too loaded to grant it that): about /// `apple_ioreport::CPU_RAIL_LIVENESS_BUDGET` where the rail is frozen, less /// where it is live (it stops as soon as one rail has advanced in two /// intervals), and up to five budgets where samples are slow or fail. Every -- 2.54.0 (Apple Git-157) From 01170e38ca09501938cfeda45f102ae6750d701d Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 15:20:34 -0400 Subject: [PATCH 11/23] jouleclaw-energy: the probe's own record pins what it burns and samples, and only the cache runs it Round 4 left one medium finding and four mutations that no test caught. This commit closes all five. The hardware tests run in CI on this M5 Max, which is often heavily loaded, so every new pin reads the probe's own record or a count. None of them is a wall-clock ratio. The once-per-process pin counts the real probe - PROBE_RUNS (test builds only) is now incremented inside probe(), the function that opens the Energy Model subscription and samples it. Round 4 counted in probe_status, the wrapper that the cache called. So a counter constructor that called probe() directly, uncached, passed all 55 tests (O6). - probe_status is gone; host_status caches probe() itself. - Tests that run the probe on purpose, uncached, call the same probe() inside deliberately(). That is a test-only scope which marks one call, on one thread, as not the host's run. It exists only in test builds, so no production path can use it and still build. - the_liveness_status_is_probed_once_and_cached still releases 6 threads through a barrier. Each thread calls ioreport_status() twice, builds two counters and calls detect_meter. The real probe must have run exactly once in the process. A constructor that probes uncached now fails it with "the real liveness probe ran 26 times". The tick is pinned from the probe's own record - probe_cpu_rail records each tick: the probing thread's CPU time, on its own CLOCK_THREAD_CPUTIME_ID, and the wall time. Every tick must show a whole PROBE_TICK (25 ms) of thread CPU or have hit the 500 ms wall cap, and there must be one tick before every re-sample. - Round 4 pinned only the CPU used per interval, from the end of one sample to the end of the next. That includes the sample call's own 6-8 ms, so a tick that burned 25% or 30% of PROBE_TICK passed under load (O1, O1b). Both now fail at any load, for example: "tick 0 of 66 burned 6.251291ms ... a tick burns 25ms of thread CPU time". - The record is checked against the tests' own thread clock, which is Mach thread_info, not clock_gettime. The thread must have used at least as much CPU as the probe says its ticks and its counted intervals used. A probe whose "thread CPU" clock was really the wall clock passes this only when the thread was almost never off the CPU. Under load it fails: "its ticks used 804.630251ms ... the thread used only 566.975ms". The no-subscription path goes through the pure mapping, and a test runs it - probe() is now probe_with(open, probe_on), given the real opener and the real probe. probe_with maps "nothing opened" through status_of_run to Unavailable, the same as a probe that saw no interval. - A test-only switch, NO_ENERGY_MODEL_GROUP, makes open_energy_model return None on one thread. a_probe_that_cannot_subscribe_to_the_group_is_unavailable runs the real probe() that way, on a host whose group opens, and asserts Unavailable with nothing probed. It also calls probe_with with an opener that opens nothing, and with a subscription that never returns a sample. - Round 4's O2 (probe() maps a failed subscription straight to CpuRailAbsent) passed on both Macs. It now fails with "the real probe with no Energy Model subscription ... left: CpuRailAbsent". The same bypass returning Live fails too, placed in probe() or in probe_with. The sampler is pinned to read afresh - New test: the_probe_samples_afresh_across_its_window. While the real probe runs, an oracle thread reads the Energy Model group every 10 ms and records when each read ran. It uses its own subscription, opened with the raw FFI, not open_energy_model. The probe records each sample call's start, end and values (test builds only). - For every probe sample with an oracle read wholly before it and one wholly after it: a GPU Energy rail that held must read the oracle's value, and one that moved must read between the two oracle reads. - If the oracle saw a GPU Energy rail change between two reads strictly inside the probe's window, the probe's own samples must show it changing too. - Fewer than two oracle reads inside the window is no evidence, and fails. - the_probe_sampler_reads_each_cpu_rail_at_its_own_value now holds GPU Energy to the same bracketing rule as the CPU rails, over two rounds on one subscription. - Round 4's O3 (the sampler returns its first sample's values forever) passed on both Macs. It now fails. So do a sampler that is stale per probe run and one that is stale per subscription. Example: "probe sample 1: ("GPU Energy", 0) moved 123056146417043 -> 123056172426143 across the oracle's reads either side of it, but the probe's sample read 123056142245337, outside them: not a fresh sample". - What this test can show depends on the GPU: - The CPU rails are frozen on both Macs, so they never can. - On this M5 Max, GPU Energy changes every ~28 ms. It moved inside the window in every run here. - An M3 Ultra's GPU moves only sporadically: in 113 of 11,716 100 ms samples over 25 minutes one day, and in about a fifth of 30 ms intervals today. Today the M3 Ultra caught all three stale samplers. - In a window where the GPU does not move, the test prints "no GPU rail moved inside the window: this run cannot tell a fresh sampler from a stale one", and a stale sampler would pass that run. Docs: the budget's doc no longer says "each still under a full tick of burn", and the all-channel sample cost is now quoted as 0.1-1 s. Evidence, on this exact tree - M5 Max, macOS 27.0.1, 10 runs under 36 background spinners (2 x hw.ncpu), 1-minute load average 23-57: 10/10 "test result: ok. 57 passed". All spinners were killed afterwards and none was left. Every run showed: - "ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false", with oracle_reads=8-13. - "the real probe ran 1 time(s)". - Probe: 7-12 intervals, median sample cost 6.98-7.79 ms, least tick 25.0005 ms of thread CPU, least interval 31.34 ms. - Freshness: the oracle saw GPU Energy change 43-52 times inside the window, and the probe's own samples changed in every interval (6-13 changes). - M5 Max, 5 more runs under 72 spinners (4 x hw.ncpu), load up to 75: 5/5. - M3 Ultra (10.10.10.2), the same binary: 57 passed. DIE_0 and DIE_1 read equal to the oracle (35656018 and 38872869). GPU Energy moved 9 times inside the probe's window, and the probe's samples 9 times. - 31 mutations. Each was applied, built, run, and restored with git checkout, with git status clean after each one. - These fail at least one test: baseline never moves; same-named rails counted under one key; a decrease, or a first appearance, counted as an advance; no subscription mapped to Live (in status_of_run, in probe_with, in probe); no subscription mapped to CpuRailAbsent (in probe_with, in probe); per-thread cache; no cache; constructor probing uncached; constant-0 sampler; three stale samplers; tick sleeps; tick is a no-op (the burn call, or the whole tick); tick burns 30% or 25%; tick burns on a helper thread; all-channel subscription; liveness decided on the sum of every Energy Model rail, or on the GPU rail; advances summed over rails; a declined counter reading 0; the fallback metering 0. - Two pass on a quiet M5 Max, and each is caught where it differs. The first gives each CPU rail the sum of all CPU rails. It is equivalent on the M5 Max's single rail, and on the M3 Ultra it fails: "DIE_0_CPU Energy held at 35656018 ... but the probe's sampler read 74528887". The second makes the wall clock the probe's thread clock. It agrees with the thread clock on a quiet host, and fails 3/3 under 36 spinners. - 24 no-feature tests pass. cargo check --tests --examples passes for aarch64-apple-darwin, x86_64-apple-darwin, aarch64-unknown-linux-gnu and x86_64-unknown-linux-gnu, with and without ioreport. clippy --no-deps shows only the warnings that were already there. Not done here - A path that re-implements probe(), by calling probe_with or probe_cpu_rail directly, is not counted. - No CI job runs these ioreport tests; .github/workflows is outside this branch. Co-Authored-By: Claude Opus 5.5 --- .../jouleclaw-energy/src/apple_ioreport.rs | 610 +++++++++++++++--- 1 file changed, 525 insertions(+), 85 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index 28dedb4d8..4b2790342 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -236,11 +236,17 @@ impl AppleIoReportCounter { /// A subscription to the `Energy Model` group alone, for the liveness probe. /// Its CPU rails are the same counters the counter's own all-channel /// subscription ([`Self::open`]) reads, at the same absolute values, but one - /// sample of this group takes ~6 ms, against 0.4–0.8 s for every channel on - /// an M5 Max (~11,860 channels) and ~0.14 s on an M3 Ultra. On every channel - /// the probe would spend most of its budget inside IOReport rather than - /// burning CPU between samples. `None` if the group cannot be subscribed. + /// sample of this group takes ~6 ms, against 0.1–1 s for every channel on + /// an M5 Max (~11,860 channels) and ~0.14–0.26 s on an M3 Ultra. On every + /// channel the probe would spend most of its budget inside IOReport rather + /// than burning CPU between samples. `None` if the group cannot be + /// subscribed — and, in test builds, on a thread where a test has made it + /// unsubscribable ([`NO_ENERGY_MODEL_GROUP`]). fn open_energy_model() -> Option { + #[cfg(test)] + if NO_ENERGY_MODEL_GROUP.with(std::cell::Cell::get) { + return None; + } unsafe { let group = CFStringCreateWithCString( std::ptr::null_mut(), @@ -301,10 +307,10 @@ impl AppleIoReportCounter { /// rail changed in 166 of 176 samples ~28 ms apart), and the probe samples the /// `Energy Model` group alone after every [`PROBE_TICK`] of its own CPU time, /// so an unloaded second holds dozens of intervals where it needs two advancing -/// ones; a loaded host gives fewer intervals, each still under a full tick of -/// burn. A slower live rail fails safe — the counter is declined and the host -/// estimated, never over-claimed. Paid once per process, see -/// [`crate::ioreport_status`]. +/// ones; a loaded host gives fewer intervals, each of them still after a whole +/// tick of the probing thread's CPU time. A slower live rail fails safe — the +/// counter is declined and the host estimated, never over-claimed. Paid once +/// per process, see [`crate::ioreport_status`]. pub const CPU_RAIL_LIVENESS_BUDGET: Duration = Duration::from_millis(1000); /// Thread CPU time the probing thread burns before each liveness re-sample, @@ -492,21 +498,31 @@ fn status_of_run(watch: Option<&RailWatch>) -> IoReportStatus { /// [`crate::ioreport_status`] and gates [`AppleIoReportCounter::new`]. pub(crate) fn host_status() -> IoReportStatus { static STATUS: OnceLock = OnceLock::new(); - *STATUS.get_or_init(|| probe_status(CPU_RAIL_LIVENESS_BUDGET)) + *STATUS.get_or_init(|| probe(CPU_RAIL_LIVENESS_BUDGET).0) } -/// How many times [`probe_status`] — the real probe behind the cached host -/// status — has run in this process, on any thread. The tests pin the -/// once-per-process cache on it. Only the host path counts: the tests that -/// call [`probe`] directly, uncached and in parallel, do not touch it. +/// How many times the real probe, [`probe`], has run in this process, on any +/// thread. It is counted inside [`probe`] itself, not in the cache in front of +/// it, so any path that probes instead of asking [`host_status`] — a counter +/// constructor, a meter selection — is counted as well. The tests pin it at +/// exactly one. A run a test makes on purpose, on its own thread, is left out +/// (see [`DELIBERATE_PROBE`]): it is that test's run, not the host's. #[cfg(test)] -static HOST_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); +static PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); -/// Probe this host's CPU energy rail now, uncached. Backs [`host_status`]. -fn probe_status(budget: Duration) -> IoReportStatus { - #[cfg(test)] - HOST_PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - probe(budget).0 +#[cfg(test)] +thread_local! { + /// Set on one thread, for the length of one call, by a test that runs the + /// real probe on purpose (uncached, to look at what it sampled); + /// [`PROBE_RUNS`] leaves that run out. It exists in test builds only, so + /// no production path can set it and still build. + static DELIBERATE_PROBE: std::cell::Cell = const { std::cell::Cell::new(false) }; + + /// Set on one thread by a test to make the `Energy Model` group + /// unsubscribable there ([`AppleIoReportCounter::open_energy_model`] + /// returns `None`), so the real [`probe`] runs down its no-subscription + /// path on a host whose group opens. + static NO_ENERGY_MODEL_GROUP: std::cell::Cell = const { std::cell::Cell::new(false) }; } /// What one run of the liveness probe sampled, beside the rule's view of it. @@ -531,15 +547,66 @@ struct ProbeRun { /// [`RailWatch::intervals`] while the thread CPU clock reads. #[cfg_attr(not(test), allow(dead_code))] interval_cpu: Vec, + /// Every tick the probe burned between two sample calls, in order, as the + /// probe itself measured it on its own thread. + #[cfg_attr(not(test), allow(dead_code))] + ticks: Vec, + /// Test builds only: every sample call, in order — when it ran and what + /// it handed the rule — so a test can hold the probe's own samples to an + /// independent reader of the same rails. + #[cfg(test)] + samples: Vec, +} + +/// One tick of the real probe, as the probe measured it. +#[cfg_attr(not(test), allow(dead_code))] +#[derive(Debug, Clone, Copy)] +struct Tick { + /// The probing thread's CPU time over the tick, by [`thread_cpu_time`]; + /// `None` if that clock could not be read. + cpu: Option, + /// Wall-clock time over the tick. + wall: Duration, +} + +/// One sample call of the real probe (test builds only). +#[cfg(test)] +#[derive(Debug)] +struct SampleCall { + /// Just before the FFI sample call. + start: Instant, + /// Just after it returned. + end: Instant, + /// What it handed the rule: every `Energy Model` channel, as (name, + /// absolute raw value); `None` for a failed sample. + values: Option>, } /// Probe this host's CPU energy rail now, uncached, on the probe's own /// subscription to the `Energy Model` group /// ([`AppleIoReportCounter::open_energy_model`]), which is dropped after: the /// status, and what the probe sampled to decide it (`None` when the group could -/// not be subscribed, which [`status_of_run`] maps to `Unavailable`). +/// not be subscribed, which [`status_of_run`] maps to `Unavailable`). This is +/// the real probe: [`host_status`] caches it, and [`PROBE_RUNS`] counts it. fn probe(budget: Duration) -> (IoReportStatus, Option) { - let run = AppleIoReportCounter::open_energy_model().map(|state| probe_cpu_rail(&state, budget)); + #[cfg(test)] + if !DELIBERATE_PROBE.with(std::cell::Cell::get) { + PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } + probe_with(AppleIoReportCounter::open_energy_model, |state| probe_cpu_rail(state, budget)) +} + +/// The probe's decision, over any way of opening its subscription and of +/// probing it, so the paths where nothing opens, or nothing is ever sampled, +/// run in tests too. `open` gives `None` when the `Energy Model` group cannot +/// be subscribed: then nothing is probed and, like a probe that saw no +/// interval, the status is whatever the pure [`status_of_run`] says — +/// `Unavailable`. The subscription is dropped once probed. +fn probe_with( + open: impl FnOnce() -> Option, + probe_on: impl FnOnce(&S) -> ProbeRun, +) -> (IoReportStatus, Option) { + let run = open().map(|subscription| probe_on(&subscription)); (status_of_run(run.as_ref().map(|r| &r.watch)), run) } @@ -556,17 +623,25 @@ fn probe(budget: Duration) -> (IoReportStatus, Option) { /// the probe's `Energy Model` subscription that costs a few milliseconds, so /// the thread spends most of `budget` burning CPU and compares the rail after /// every [`PROBE_TICK`] of burn; on an all-channel subscription one sample -/// takes 0.13–0.8 s and the probe would compare only a handful of times. +/// takes 0.1–1 s and the probe would compare only a handful of times. +/// +/// The probe keeps its own record of what each sample call cost and what each +/// tick burned ([`ProbeRun`]), for the tests; nothing in it feeds the rule. fn probe_cpu_rail(state: &State, budget: Duration) -> ProbeRun { let (mut channels, mut outside_group) = (0, 0); - let (mut sample_costs, mut interval_cpu) = (Vec::new(), Vec::new()); + let (mut sample_costs, mut interval_cpu, mut ticks) = (Vec::new(), Vec::new(), Vec::new()); + #[cfg(test)] + let mut samples = Vec::new(); let mut cpu_at_last_sample: Option = None; let started = Instant::now(); let watch = watch( || { let t = Instant::now(); let s = sample_energy_model(state); - sample_costs.push(t.elapsed()); + let end = Instant::now(); + sample_costs.push(end.duration_since(t)); + #[cfg(test)] + samples.push(SampleCall { start: t, end, values: s.as_ref().map(|s| s.values.clone()) }); let s = s?; let cpu = thread_cpu_time(); if let (Some(before), Some(now)) = (cpu_at_last_sample, cpu) { @@ -577,11 +652,25 @@ fn probe_cpu_rail(state: &State, budget: Duration) -> ProbeRun { outside_group = outside_group.max(s.outside_group); Some(s.values) }, - || burn_thread_cpu(PROBE_TICK, PROBE_TICK_WALL_CAP), + || { + let (cpu, wall) = (thread_cpu_time(), Instant::now()); + burn_thread_cpu(PROBE_TICK, PROBE_TICK_WALL_CAP); + let cpu = cpu.zip(thread_cpu_time()).map(|(before, after)| after.saturating_sub(before)); + ticks.push(Tick { cpu, wall: wall.elapsed() }); + }, || started.elapsed(), budget, ); - ProbeRun { watch, channels, outside_group, sample_costs, interval_cpu } + ProbeRun { + watch, + channels, + outside_group, + sample_costs, + interval_cpu, + ticks, + #[cfg(test)] + samples, + } } /// This thread's CPU time so far (`CLOCK_THREAD_CPUTIME_ID`: user and system @@ -1110,6 +1199,59 @@ mod tests { ); } + /// Run the real probe on purpose, on this thread: [`PROBE_RUNS`] leaves + /// the run out, since it is this test's, not the host's. + fn deliberately(probe: impl FnOnce() -> T) -> T { + struct Done; + impl Drop for Done { + fn drop(&mut self) { + DELIBERATE_PROBE.with(|d| d.set(false)); + } + } + DELIBERATE_PROBE.with(|d| d.set(true)); + let _done = Done; + probe() + } + + /// Run `f` on this thread as if the `Energy Model` group could not be + /// subscribed: [`AppleIoReportCounter::open_energy_model`] gives `None`. + fn without_the_energy_model_group(f: impl FnOnce() -> T) -> T { + struct Done; + impl Drop for Done { + fn drop(&mut self) { + NO_ENERGY_MODEL_GROUP.with(|g| g.set(false)); + } + } + NO_ENERGY_MODEL_GROUP.with(|g| g.set(true)); + let _done = Done; + f() + } + + /// The probe's own wiring of the no-subscription case, not just the pure + /// rule: the REAL [`probe`], run where the `Energy Model` group cannot be + /// subscribed, probes nothing and reports `Unavailable` — never + /// `CpuRailAbsent`, which says the group was sampled and held no CPU rail, + /// and never `Live`. So does [`probe_with`], which it runs through, for an + /// opener that opens nothing and for a subscription that never yields a + /// sample. Every host runs this, including one whose group opens. + #[test] + fn a_probe_that_cannot_subscribe_to_the_group_is_unavailable() { + let (status, run) = without_the_energy_model_group(|| deliberately(|| probe(CPU_RAIL_LIVENESS_BUDGET))); + eprintln!("ioreport probe without the Energy Model group: status={status:?}, run={run:?}"); + assert!(run.is_none(), "the probe probed a subscription although the group could not be subscribed"); + assert_eq!(status, IoReportStatus::Unavailable, "the real probe with no Energy Model subscription"); + + let (status, run) = probe_with(|| None::<()>, |_| unreachable!("nothing opened, so nothing is probed")); + assert_eq!((status, run.is_none()), (IoReportStatus::Unavailable, true), "probe_with: nothing opened"); + + let (status, run) = probe_with( + || Some(()), + |_| ProbeRun { watch: scripted(|_| None, GROUP_SAMPLE, CPU_RAIL_LIVENESS_BUDGET).0, ..ProbeRun::default() }, + ); + assert_eq!(status, IoReportStatus::Unavailable, "probe_with: a subscription that never yields a sample"); + assert_eq!(run.map(|r| r.watch.intervals), Some(0), "probe_with must hand back what it probed"); + } + /// The probe keeps sampling within its budget until the rail proves live, /// and past the budget until a live rail could have advanced twice — but /// never past five budgets. @@ -1323,6 +1465,33 @@ mod tests { } } + /// The oracle's own subscription to the `Energy Model` group alone, + /// from the raw FFI declarations (not `open_energy_model`): one read + /// takes milliseconds where one of every channel takes 0.1–2 s, so + /// its reads can fall between the probe's samples. + fn open_energy_model_group() -> Option { + unsafe { + 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 chans: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription(std::ptr::null(), desired, &mut chans, 0, std::ptr::null()); + CFRelease(desired as CFTypeRef); + let oracle = Self { sub, chans }; + (!sub.is_null() && !chans.is_null()).then_some(oracle) + } + } + /// Every channel of one plain sample (no delta), as (group, name, /// ABSOLUTE raw value), in sample order. fn read(&self) -> Option> { @@ -1554,36 +1723,117 @@ mod tests { } } + /// This thread's CPU time by the tests' own clock — Mach `thread_info` + /// (`THREAD_BASIC_INFO`, microseconds), not the probe's + /// `clock_gettime(CLOCK_THREAD_CPUTIME_ID)` — so a probe whose CPU clock + /// were not this thread's CPU time cannot vouch for itself. + fn test_thread_cpu() -> Duration { + let mut info: libc::thread_basic_info = unsafe { std::mem::zeroed() }; + let mut count = libc::THREAD_BASIC_INFO_COUNT; + // SAFETY: `info` is a writable `thread_basic_info` and `count` holds + // its size in `integer_t`s, as `thread_info` requires; the port comes + // from this thread's own pthread and needs no release. + let rc = unsafe { + libc::thread_info( + libc::pthread_mach_thread_np(libc::pthread_self()), + libc::THREAD_BASIC_INFO as libc::thread_flavor_t, + &mut info as *mut libc::thread_basic_info as libc::thread_info_t, + &mut count, + ) + }; + assert_eq!(rc, libc::KERN_SUCCESS, "thread_info(THREAD_BASIC_INFO) failed"); + let us = |t: libc::time_value_t| t.seconds as u64 * 1_000_000 + t.microseconds as u64; + Duration::from_micros(us(info.user_time) + us(info.system_time)) + } + + /// How far the tests' microsecond thread clock may fall short of the + /// probe's nanosecond one over one probe: two truncated readings, with a + /// wide margin. A tick is 25 ms. + const CLOCK_SLACK: Duration = Duration::from_millis(1); + /// The dangerous direction on its own: the probe never calls a rail Live /// that the oracle cannot see move. Runs the probe itself, uncached, on its /// own fresh `Energy Model` subscription exactly as [`host_status`] does, /// beside the cached status the counter is gated on, and asserts on every /// outcome, not only on Live. /// - /// And that the probe burns CPU while it watches: in every interval it - /// counted, the probing thread consumed at least half a [`PROBE_TICK`] of - /// its own CPU time. The tick burns thread CPU time, not wall time, so this - /// holds however loaded the host is; only the tick's wall-clock cap can - /// cut it short. A tick that sleeps, or does nothing, leaves an interval - /// with only the sample call's few milliseconds. Wherever the probe could - /// sample and found no live rail, it also watched for its whole budget. + /// And that the probe burns CPU while it watches, pinned three ways, none + /// of them a wall-clock ratio (this host is also a heavily loaded CI + /// runner): + /// - From the probe's own record of each tick: every tick burned a whole + /// [`PROBE_TICK`] of the probing thread's CPU time, or ran into its + /// [`PROBE_TICK_WALL_CAP`], and a tick came before every re-sample. A + /// tick that sleeps, does nothing, or burns a fraction of a tick fails + /// here at any load. + /// - In every interval the rule counted, the probing thread consumed at + /// least half a [`PROBE_TICK`] of its own CPU time. + /// - By the tests' own thread clock ([`test_thread_cpu`]), the thread used + /// at least what the probe's record says its ticks, and its counted + /// intervals, used: the probe's CPU clock is this thread's CPU time, not + /// wall time or another thread's. (A wall clock passes this only where + /// the thread was hardly ever off the CPU, i.e. where the two agree.) + /// + /// Wherever the probe could sample and found no live rail, it also watched + /// for its whole budget. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { + let cpu_before = test_thread_cpu(); let t = Instant::now(); - let (probed, run) = probe(CPU_RAIL_LIVENESS_BUDGET); + let (probed, run) = deliberately(|| probe(CPU_RAIL_LIVENESS_BUDGET)); let probe_took = t.elapsed(); + let thread_cpu = test_thread_cpu().saturating_sub(cpu_before); let cached = crate::ioreport_status(); let (oracle, _) = oracle_cpu_rail(CPU_RAIL_LIVENESS_BUDGET * 3); let intervals = run.as_ref().map(|r| r.watch.intervals); - let cpu = run.as_ref().map(|r| { - let mut cpu = r.interval_cpu.clone(); - cpu.sort(); - (cpu.first().copied(), median(&cpu)) - }); + let least_and_median = |mut v: Vec| { + v.sort(); + (v.first().copied(), median(&v)) + }; + let cpu = run.as_ref().map(|r| least_and_median(r.interval_cpu.clone())); + let tick_cpu = run.as_ref().map(|r| least_and_median(r.ticks.iter().filter_map(|k| k.cpu).collect())); + let ticked: Duration = run.as_ref().map_or(Duration::ZERO, |r| r.ticks.iter().filter_map(|k| k.cpu).sum()); + let in_intervals: Duration = run.as_ref().map_or(Duration::ZERO, |r| r.interval_cpu.iter().sum()); eprintln!( "ioreport probe: probed={probed:?} cached={cached:?} oracle={oracle:?} intervals={intervals:?} \ - probe_took={probe_took:?} thread CPU per interval (least, median)={cpu:?}" + probe_took={probe_took:?} thread CPU per interval (least, median)={cpu:?} ticks={:?} thread CPU per \ + tick (least, median)={tick_cpu:?}; by the probe's clock the ticks used {ticked:?} and the counted \ + intervals {in_intervals:?}, by the tests' clock the thread used {thread_cpu:?} over the whole probe", + run.as_ref().map(|r| r.ticks.len()) ); + if let Some(run) = &run { + assert_eq!( + run.ticks.len() + 1, + run.sample_costs.len(), + "{} ticks between {} sample calls: the probe must burn a tick before every re-sample", + run.ticks.len(), + run.sample_costs.len() + ); + for (i, tick) in run.ticks.iter().enumerate() { + let cpu = tick.cpu.unwrap_or_else(|| panic!("tick {i}: the probe could not read its thread CPU clock")); + assert!( + cpu >= PROBE_TICK || tick.wall >= PROBE_TICK_WALL_CAP, + "tick {i} of {} burned {cpu:?} of the probing thread's CPU time in {:?} of wall time: a tick \ + burns {PROBE_TICK:?} of thread CPU time, and only its {PROBE_TICK_WALL_CAP:?} wall-clock cap \ + may cut that short", + run.ticks.len(), + tick.wall + ); + } + // Ticks and counted intervals are disjoint stretches of this one + // call, so an honest thread clock can never put more CPU time in + // them than the whole call used. The intervals cover all but the + // baseline sample, so a clock that is not this thread's CPU time + // shows here as soon as the thread lost the CPU for more than one + // sample call's worth. + for (what, recorded) in [("ticks", ticked), ("counted intervals", in_intervals)] { + assert!( + thread_cpu + CLOCK_SLACK >= recorded, + "by the probe's own CPU clock its {what} used {recorded:?} of this thread's CPU time, but by the \ + tests' clock the thread used only {thread_cpu:?} over the whole probe: the probe's CPU clock is \ + not this thread's CPU time" + ); + } + } if let Some(run) = &run && run.watch.verdict().is_some() { @@ -1688,7 +1938,7 @@ mod tests { /// every channel, so one sample costs milliseconds and it really re-samples /// after every tick of burn. Pinned on the probe's own sample calls: their /// MEDIAN wall-clock duration is under 50 ms (one group sample takes - /// ~6–12 ms, one all-channel sample 130–800 ms; a median shrugs off the + /// ~6–12 ms, one all-channel sample 0.1–1 s; a median shrugs off the /// odd sample a loaded host stalls). And on what the probe actually /// sampled: every channel in its samples is in the `Energy Model` group, /// and there are fewer of them than one sample of the counter's @@ -1700,7 +1950,7 @@ mod tests { #[test] fn the_liveness_probe_subscribes_to_the_energy_model_group_only() { let t = Instant::now(); - let (status, run) = probe(CPU_RAIL_LIVENESS_BUDGET); + let (status, run) = deliberately(|| probe(CPU_RAIL_LIVENESS_BUDGET)); let probe_took = t.elapsed(); let Some(run) = run else { assert_eq!( @@ -1734,7 +1984,7 @@ mod tests { assert!( median_cost < Duration::from_millis(50), "the probe's median sample call took {median_cost:?} over {} calls: not the Energy Model group alone \ - (one group sample takes ~6-12 ms, one all-channel sample 130-800 ms)", + (one group sample takes ~6-12 ms, one all-channel sample 0.1-1 s)", costs.len() ); assert!(run.channels > 0, "the probe's samples held no channels"); @@ -1771,23 +2021,29 @@ mod tests { assert_eq!(watched, group_rails, "the probe's rule did not watch the group's CPU rails one by one"); } - /// The probe sampler reads each CPU rail at that rail's own value. The - /// oracle reads every `Energy Model` CPU rail, by name, on its own + /// The probe sampler reads each rail at that rail's own value. The oracle + /// reads every `Energy Model` CPU and GPU rail, by name, on its own /// all-channel subscription just before and just after one sample of the /// probe's sampler ([`sample_energy_model`] on the probe's group - /// subscription). A rail the oracle saw hold across those two reads — a - /// frozen rail is constant — must carry exactly that value in the probe's - /// sample; a rail that moved between them must lie within them. So a - /// sampler that returns a constant, sums rails into each other or reads - /// the wrong rail fails here even on a host whose rails are frozen, where - /// the liveness rule alone cannot tell. + /// subscription), twice over on the same subscription. A rail the oracle + /// saw hold across those two reads — a frozen rail is constant — must + /// carry exactly that value in the probe's sample; a rail that moved + /// between them must lie within them. So a sampler that returns a + /// constant, sums rails into each other or reads the wrong rail fails here + /// even on a host whose CPU rails are frozen, where the liveness rule + /// alone cannot tell. `GPU Energy` is held to the same rule: where the GPU + /// draws it moves between the oracle's reads (on an M5 Max it changes + /// every ~28 ms, and one all-channel read takes 0.1–1 s), so a sample + /// that is not taken afresh — the subscription's first values handed back + /// again in the second round — falls outside them. #[test] fn the_probe_sampler_reads_each_cpu_rail_at_its_own_value() { - /// `Energy Model` CPU rails, keyed the test's own way — (name, its - /// ordinal among same-named rails) — sorted by key. + /// `Energy Model` CPU and GPU rails, keyed the test's own way — (name, + /// its ordinal among same-named rails) — sorted by key. fn keyed<'a>(chans: impl Iterator) -> Vec<(RailKey, i64)> { let mut rails: Vec<(RailKey, i64)> = Vec::new(); - for (name, raw) in chans.filter(|(name, _)| name.ends_with("CPU Energy")) { + let rail = |name: &str| name.ends_with("CPU Energy") || name.ends_with("GPU Energy"); + for (name, raw) in chans.filter(|(name, _)| rail(name)) { let nth = rails.iter().filter(|((n, _), _)| n == name).count() as u32; rails.push(((name.to_string(), nth), raw)); } @@ -1807,39 +2063,223 @@ mod tests { let in_group = read.iter().filter(|(g, _, _)| g.eq_ignore_ascii_case("Energy Model")); keyed(in_group.map(|(_, name, raw)| (name.as_str(), *raw))) }; - let before = group_rails(oracle.read().expect("the oracle reads every channel")); - let sample = sample_energy_model(&group).expect("the probe's sampler reads the Energy Model group"); - let after = group_rails(oracle.read().expect("the oracle reads every channel")); - let probe = keyed(sample.values.iter().map(|(name, raw)| (name.as_str(), *raw))); - eprintln!("ioreport sampler values: probe {probe:?}; oracle before {before:?}, after {after:?}"); - let keys = |rails: &[(RailKey, i64)]| rails.iter().map(|(k, _)| k.clone()).collect::>(); - assert_eq!(keys(&before), keys(&after), "the oracle's CPU rails changed between two reads"); - assert_eq!(keys(&probe), keys(&before), "the probe's sampler and the oracle see different CPU rails"); - if before.is_empty() { - assert_eq!(status, IoReportStatus::CpuRailAbsent, "no CPU rail in the group, yet status={status:?}"); - } - for ((key, p), ((_, b), (_, a))) in probe.iter().zip(before.iter().zip(after.iter())) { - if b == a { - assert_eq!( - p, b, - "{key:?} held at {b} across the oracle's reads, but the probe's sampler read {p}" - ); - } else { - assert!( - (*b.min(a)..=*b.max(a)).contains(p), - "{key:?} moved from {b} to {a} across the oracle's reads, but the probe's sampler read {p}, \ - outside them" - ); + let mut seen = Vec::new(); + for round in 0..2 { + let before = group_rails(oracle.read().expect("the oracle reads every channel")); + let sample = sample_energy_model(&group).expect("the probe's sampler reads the Energy Model group"); + let after = group_rails(oracle.read().expect("the oracle reads every channel")); + let probe = keyed(sample.values.iter().map(|(name, raw)| (name.as_str(), *raw))); + + assert_eq!(keys(&before), keys(&after), "round {round}: the oracle's rails changed between two reads"); + assert_eq!(keys(&probe), keys(&before), "round {round}: the probe's sampler and the oracle see different rails"); + if !before.iter().any(|((name, _), _)| name.ends_with("CPU Energy")) { + assert_eq!(status, IoReportStatus::CpuRailAbsent, "no CPU rail in the group, yet status={status:?}"); + } + for ((key, p), ((_, b), (_, a))) in probe.iter().zip(before.iter().zip(after.iter())) { + if b == a { + assert_eq!( + p, b, + "round {round}: {key:?} held at {b} across the oracle's reads, but the probe's sampler read {p}" + ); + } else { + assert!( + (*b.min(a)..=*b.max(a)).contains(p), + "round {round}: {key:?} moved from {b} to {a} across the oracle's reads, but the probe's \ + sampler read {p}, outside them" + ); + } + let how = if b == a { "held: equal" } else { "moved: within" }; + seen.push(format!("{}#{} r{round}: {b} | {p} | {a} ({how})", key.0, key.1)); } } + eprintln!("ioreport sampler values: oracle before | probe | oracle after, per rail: {seen:?}"); } - /// The probe runs once per process, not once per thread. Several threads, - /// released together, each call the status twice and build two counters - /// and a `detect_meter`, while other tests do the same: the real host - /// probe has run exactly once in this whole process. A cache per thread - /// would probe on each of these threads; no cache, on every call. + /// How long the freshness oracle waits between its reads of the group. + const ORACLE_PACE: Duration = Duration::from_millis(10); + + /// The probe takes every one of its samples afresh. While the REAL probe + /// runs, an oracle thread reads the `Energy Model` group on its own + /// subscription ([`OracleSub::open_energy_model_group`]) every + /// [`ORACLE_PACE`], from before the probe's first sample until after its + /// last, and notes when each read ran. Then, against the probe's own + /// record of its samples ([`ProbeRun`]): + /// - every probe sample with an oracle read wholly before it and one + /// wholly after it holds each `GPU Energy` rail at the oracle's value + /// if the rail held across them, and within them if it moved; + /// - where the oracle saw a `GPU Energy` rail change between two of its + /// reads strictly inside the probe's window, the probe's own samples + /// show that rail changing too. A cumulative counter read at the first + /// and last probe sample must differ if it changed between two reads + /// taken after the one and before the other, so a probe whose sampler + /// hands back stale values fails here. + /// + /// Fewer than two oracle reads inside the probe's window is no evidence + /// either way, and fails. The CPU rails cannot show freshness on a host + /// where they are frozen; `GPU Energy` can wherever the GPU draws, as it + /// does in nearly every 100 ms on the M5 Max this runs on in CI. An M3 + /// Ultra's GPU moves only sporadically (in 113 of 11,716 100 ms samples + /// over 25 minutes; in about a fifth of 30 ms intervals on another day): + /// there, a window in which it did not move shows nothing about freshness, + /// only that the probe's values equal the oracle's. The printed line says + /// which case ran. + #[test] + fn the_probe_samples_afresh_across_its_window() { + /// One read of the oracle's: when it ran, and each `GPU Energy` rail. + struct Read { + start: Instant, + end: Instant, + gpu: Vec<(RailKey, i64)>, + } + /// The `GPU Energy` rails among (name, raw value) channels of the + /// `Energy Model` group, keyed the test's own way, sorted by key. + fn gpu_rails<'a>(chans: impl Iterator) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in chans.filter(|(name, _)| name.ends_with("GPU Energy")) { + let nth = rails.iter().filter(|((n, _), _)| n == name).count() as u32; + rails.push(((name.to_string(), nth), raw)); + } + rails.sort(); + rails + } + let stop = Arc::new(AtomicBool::new(false)); + let (reading_tx, reading_rx) = std::sync::mpsc::channel::(); + let reader = { + let stop = Arc::clone(&stop); + std::thread::spawn(move || { + let Some(oracle) = OracleSub::open_energy_model_group() else { + let _ = reading_tx.send(false); + return Vec::new(); + }; + let mut reads: Vec = Vec::new(); + loop { + // Read once more after the probe has returned, so its last + // sample has an oracle read after it. + let last = stop.load(Ordering::SeqCst); + let start = Instant::now(); + let read = oracle.read(); + let end = Instant::now(); + if let Some(chans) = read { + let in_group = chans.iter().filter(|(g, _, _)| g.eq_ignore_ascii_case("Energy Model")); + let gpu = gpu_rails(in_group.map(|(_, name, raw)| (name.as_str(), *raw))); + reads.push(Read { start, end, gpu }); + if reads.len() == 1 { + let _ = reading_tx.send(true); + } + } + if last { + return reads; + } + std::thread::sleep(ORACLE_PACE); + } + }) + }; + let reading = reading_rx.recv_timeout(Duration::from_secs(10)); + let (status, run) = deliberately(|| probe(CPU_RAIL_LIVENESS_BUDGET)); + stop.store(true, Ordering::SeqCst); + let reads = reader.join().expect("the oracle's reader thread"); + let reading = reading.unwrap_or_else(|e| { + panic!("the oracle subscribed to the Energy Model group but got no read of it in 10 s: {e:?}") + }); + let Some(run) = run else { + eprintln!("ioreport sampler freshness: no Energy Model subscription (status={status:?}); oracle reading={reading}"); + assert_eq!(status, IoReportStatus::Unavailable, "the probe had no subscription, yet status={status:?}"); + assert!(!reading, "the oracle subscribed to the Energy Model group and read it; the probe could not"); + return; + }; + assert!(reading, "the probe subscribed to the Energy Model group; the oracle could not"); + let samples: Vec<(&SampleCall, Vec<(RailKey, i64)>)> = run + .samples + .iter() + .filter_map(|s| s.values.as_ref().map(|v| (s, gpu_rails(v.iter().map(|(n, raw)| (n.as_str(), *raw)))))) + .collect(); + let (Some((first, _)), Some((last, _))) = (samples.first(), samples.last()) else { + panic!("the probe subscribed but none of its {} sample calls returned a sample", run.samples.len()); + }; + let (first, last) = (*first, *last); + let inside: Vec<&Read> = reads.iter().filter(|r| r.start >= first.end && r.end <= last.start).collect(); + let window = last.start.saturating_duration_since(first.end); + assert!( + inside.len() >= 2, + "the oracle read the Energy Model group {} time(s) strictly inside the probe's {window:?} window ({} reads \ + in all): too few to see whether a rail moved there — no evidence either way", + inside.len(), + reads.len() + ); + let keys = |rails: &[(RailKey, i64)]| rails.iter().map(|(k, _)| k.clone()).collect::>(); + let gpu_keys = keys(&reads[0].gpu); + for r in &reads { + assert_eq!(keys(&r.gpu), gpu_keys, "the oracle's GPU rails changed between its reads"); + } + for (i, (_, rails)) in samples.iter().enumerate() { + assert_eq!(keys(rails), gpu_keys, "probe sample {i}: the probe and the oracle see different GPU rails"); + } + + let mut bracketed = 0; + for (i, (s, rails)) in samples.iter().enumerate() { + let before = reads.iter().rev().find(|r| r.end <= s.start); + let after = reads.iter().find(|r| r.start >= s.end); + let (Some(b), Some(a)) = (before, after) else { continue }; + bracketed += 1; + for (((key, p), (_, bv)), (_, av)) in rails.iter().zip(&b.gpu).zip(&a.gpu) { + if bv == av { + assert_eq!( + p, bv, + "probe sample {i}: {key:?} held at {bv} across the oracle's reads either side of it, but the \ + probe's sample read {p}" + ); + } else { + assert!( + (*bv.min(av)..=*bv.max(av)).contains(p), + "probe sample {i}: {key:?} moved {bv} -> {av} across the oracle's reads either side of it, \ + but the probe's sample read {p}, outside them: not a fresh sample" + ); + } + } + } + assert!(bracketed >= 2, "only {bracketed} probe samples had an oracle read on each side"); + + let mut per_rail = Vec::new(); + for (k, key) in gpu_keys.iter().enumerate() { + let oracle_changes = inside.windows(2).filter(|w| w[0].gpu[k].1 != w[1].gpu[k].1).count(); + let probe_changes = samples.windows(2).filter(|w| w[0].1[k].1 != w[1].1[k].1).count(); + if oracle_changes > 0 { + assert!( + probe_changes > 0, + "{key:?} changed {oracle_changes} time(s) between the oracle's reads inside the probe's {window:?} \ + window, yet all {} of the probe's samples read {}: the probe's sampler is not reading afresh", + samples.len(), + samples[0].1[k].1 + ); + } + per_rail.push((key.clone(), oracle_changes, probe_changes)); + } + let verdict = if per_rail.iter().any(|(_, oracle_changes, _)| *oracle_changes > 0) { + "a GPU rail moved inside the window: the probe's samples are fresh" + } else if gpu_keys.is_empty() { + "no GPU Energy rail on this host: this run cannot tell a fresh sampler from a stale one" + } else { + "no GPU rail moved inside the window: this run cannot tell a fresh sampler from a stale one" + }; + eprintln!( + "ioreport sampler freshness: status={status:?}; {} probe samples over {window:?}; the oracle read the group \ + {} times, {} strictly inside that window; {bracketed} probe samples bracketed; per GPU rail (changes \ + the oracle saw inside, changes in the probe's samples): {per_rail:?} -> {verdict}", + samples.len(), + reads.len(), + inside.len() + ); + } + + /// The probe runs once per process, not once per thread, and nothing but + /// the cache runs it. Several threads, released together, each call the + /// status twice and build two counters and a `detect_meter`, while other + /// tests do the same: the real probe ([`probe`], counted inside itself by + /// [`PROBE_RUNS`]) has run exactly once in this whole process. A cache per + /// thread would probe on each of these threads; no cache, on every call; + /// a counter constructor or meter selection that probed instead of asking + /// the cache, on every counter built. #[test] fn the_liveness_status_is_probed_once_and_cached() { const THREADS: usize = 6; @@ -1860,14 +2300,14 @@ mod tests { }) .collect(); let seen: Vec<_> = workers.into_iter().map(|w| w.join().expect("a status thread panicked")).collect(); - let runs = HOST_PROBE_RUNS.load(Ordering::SeqCst); + let runs = PROBE_RUNS.load(Ordering::SeqCst); let status = crate::ioreport_status(); eprintln!( "ioreport cache: status={status:?}; {THREADS} threads at once, each with two status calls, two \ - counters and detect_meter (active: {}): the host probe ran {runs} time(s) in this process", + counters and detect_meter (active: {}): the real probe ran {runs} time(s) in this process", seen[0].2 ); - assert_eq!(runs, 1, "the host liveness probe ran {runs} times in this process: not once per process"); + assert_eq!(runs, 1, "the real liveness probe ran {runs} times in this process: not once per process"); for (statuses, available, _) in &seen { assert_eq!(*statuses, [status; 4], "a thread saw a different status"); assert_eq!(*available, status == IoReportStatus::Live); -- 2.54.0 (Apple Git-157) From 0c2a43bbd5ef2f0da36f11e8dbb203c708f5c8d1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 15:37:18 -0400 Subject: [PATCH 12/23] jouleclaw-loader-gguf: each test temp file gets a name no other test in the process can pick The tier_store tests sometimes failed with "tier_store.rs:738:50: called `Result::unwrap()` on an `Err` value: Io(Os { code: 2, kind: NotFound, ... })". write_synth_gguf named its file jouleclaw-tier-store--.gguf. On macOS SystemTime ticks in microseconds, and the harness starts these tests together on parallel threads, so two tests could pick the same name. They then shared one file, and the first to finish deleted it before the other had read it. Each such helper now takes a number from its own process-wide AtomicU64 instead of the time, giving --. The crate's mellum, falcon_h1 and hybrid_lm tests already name their files this way. Four more helpers used the same timestamp naming with no per-test tag, and several tests call each of them at once, so they get the same fix: - lib.rs prefetch_policy_tests::write_synth_gguf (8 tests) - gpu_buffer.rs aligned_temp_mmap (5 tests; it truncates and maps the file) - mmap_advise.rs tempfile_compat (2 tests) - moe.rs build_synthetic_store_with_experts (2 tests) None of the four failed in the runs below, but they had the same race. Only #[cfg(test)] code changes. Evidence, on this M5 Max: - Before, on the parent tree (3b260e0a): the lib test binary built with --features fabric-meter, run 30 times, passed 27 and failed 3. All three failures were at tier_store.rs:738:50 with NotFound, in pin_hot_changes_tier_to_hot, demote_to_cold_returns_to_cold and out_of_bounds_errors. - After, the same build and the same 30 runs: 30/30, 299 passed each time. - `cargo test -p jouleclaw-loader-gguf --lib --features fabric-meter`, 12 consecutive runs: 12/12, "test result: ok. 299 passed; 0 failed; 2 ignored". - With --features "rayon fabric-meter": 3/3, 302 passed. - clippy --no-deps --tests --features fabric-meter: no warning on or next to any changed line. It exits 101 on three error-level lints that were already there, in files this commit does not touch: ssm.rs:406 (approx_constant) and gemma4.rs:562 and :582 ("this operation will always return zero"). Co-Authored-By: Claude Opus 5.5 --- .../crates/jouleclaw-loader-gguf/src/gpu_buffer.rs | 14 ++++++-------- .../crates/jouleclaw-loader-gguf/src/lib.rs | 14 ++++++-------- .../jouleclaw-loader-gguf/src/mmap_advise.rs | 14 ++++++-------- .../crates/jouleclaw-loader-gguf/src/moe.rs | 11 ++++++----- .../crates/jouleclaw-loader-gguf/src/tier_store.rs | 14 ++++++-------- 5 files changed, 30 insertions(+), 37 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/gpu_buffer.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/gpu_buffer.rs index 8ee1a3df3..4b584e1b2 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/gpu_buffer.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/gpu_buffer.rs @@ -181,16 +181,14 @@ mod tests { use std::io::Write; fn aligned_temp_mmap(len: usize) -> memmap2::Mmap { + // A name unique within this process. A timestamp is not: it ticks in + // microseconds on macOS, and parallel tests get here at once, so two + // tests could get one file and truncate or delete it under each other. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let dir = std::env::temp_dir(); let pid = std::process::id(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = dir.join(format!( - "jouleclaw-gpu-overlay-{}-{}.bin", - pid, nanos - )); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = dir.join(format!("jouleclaw-gpu-overlay-{pid}-{seq}.bin")); let mut f = std::fs::OpenOptions::new() .read(true) .write(true) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs index 6e6e06229..53c584c52 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/lib.rs @@ -1370,17 +1370,15 @@ mod prefetch_policy_tests { use std::io::Write; fn write_synth_gguf() -> std::path::PathBuf { + // A name unique within this process. A timestamp is not: it ticks in + // microseconds on macOS, and parallel tests get here at once, so two + // tests could get one file and truncate or delete it under each other. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let bytes = synthesize_llama_gguf(&SyntheticConfig::default()); let dir = std::env::temp_dir(); let pid = std::process::id(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = dir.join(format!( - "jouleclaw-prefetch-test-{}-{}.gguf", - pid, nanos - )); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = dir.join(format!("jouleclaw-prefetch-test-{pid}-{seq}.gguf")); let mut f = std::fs::File::create(&path).unwrap(); f.write_all(&bytes).unwrap(); f.sync_all().unwrap(); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/mmap_advise.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/mmap_advise.rs index cf9187ed1..38836240e 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/mmap_advise.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/mmap_advise.rs @@ -253,16 +253,14 @@ mod tests { fn tempfile_compat() -> std::fs::File { // Minimal tempfile shim — keep dep surface tiny. + // A name unique within this process. A timestamp is not: it ticks in + // microseconds on macOS, and parallel tests get here at once, so two + // tests could get one file and truncate or delete it under each other. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let dir = std::env::temp_dir(); let pid = std::process::id(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = dir.join(format!( - "jouleclaw-mmap-advise-{}-{}.bin", - pid, nanos - )); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = dir.join(format!("jouleclaw-mmap-advise-{pid}-{seq}.bin")); std::fs::OpenOptions::new() .read(true) .write(true) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/moe.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/moe.rs index 21ae85e39..e280e5c36 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/moe.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/moe.rs @@ -467,12 +467,13 @@ mod tests { // promote/demote helpers exercise the lookup path. let bytes = synthesize_llama_gguf(&SyntheticConfig::default()); let dir = std::env::temp_dir(); + // A name unique within this process. A timestamp is not: it ticks in + // microseconds on macOS, and parallel tests get here at once, so two + // tests could get one file and truncate or delete it under each other. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let pid = std::process::id(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = dir.join(format!("jouleclaw-moe-test-{pid}-{nanos}.gguf")); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = dir.join(format!("jouleclaw-moe-test-{pid}-{seq}.gguf")); std::fs::write(&path, &bytes).unwrap(); let model = crate::read_gguf_file(&path).unwrap(); let data_offset = match &model.buf { diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/tier_store.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/tier_store.rs index e935855ce..34ed8d008 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/tier_store.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf/src/tier_store.rs @@ -716,17 +716,15 @@ mod tests { use std::io::Write; fn write_synth_gguf() -> PathBuf { + // A name unique within this process. A timestamp is not: it ticks in + // microseconds on macOS, and parallel tests get here at once, so two + // tests could get one file and truncate or delete it under each other. + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let bytes = synthesize_llama_gguf(&SyntheticConfig::default()); let dir = std::env::temp_dir(); let pid = std::process::id(); - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos(); - let path = dir.join(format!( - "jouleclaw-tier-store-{}-{}.gguf", - pid, nanos - )); + let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = dir.join(format!("jouleclaw-tier-store-{pid}-{seq}.gguf")); let mut f = std::fs::File::create(&path).unwrap(); f.write_all(&bytes).unwrap(); f.sync_all().unwrap(); -- 2.54.0 (Apple Git-157) From c445d15028cdeebef3908f6af225a6a8429569ee Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 17:56:00 -0400 Subject: [PATCH 13/23] jouleclaw-energy: one tested composition is the host status, and only Live opens a counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5 left two dangerous survivors. Tests only ever saw CpuRailFrozen, the one status either Mac produces, so these two passed 57/57 on both Macs: - OWN1: a counter that declined only a frozen rail. - OWN4: a host cache that took "no subscription" for a live rail. It also left four fail-safe survivors: - a constructor that re-implemented the probe, which went uncounted; - a zero or stale transform between the probe's record and the rule; - a tick that always ran to its wall cap; - a latent flake on a host whose rail is live. In the sibling crates, a wall-time median pin also failed on correct code under concurrent all-channel IOReport sampling. This commit closes all of these. The production chain - probe_host_status() is the one production status. It bumps REAL_RUNS, then returns status_of(probe_with(open_probe_sampler, probe_tick, , CPU_RAIL_LIVENESS_BUDGET)). It has no branch of its own. - probe_with opens a sampler and watches it. If nothing opened, or no interval was observed, it returns None, which status_of maps to Unavailable. - crate::ioreport_status() now holds the process-wide OnceLock. It is the only production caller of probe_host_status. - AppleIoReportCounter::new() is new_given(ioreport_status()). new_given declines, before opening anything, unless admits(status) says yes. admits is a pure, exhaustive match that is true for Live alone. - GROUP_OPENS counts every call of the real group opener. Only the probe opens the group. So a constructor that re-implements the probe around the cache shows up there, even though it bypasses REAL_RUNS. Both counters are read through #[doc(hidden)] ioreport_probe_runs() and ioreport_group_opens(). Every status is tested - admits: Live admits; Unavailable, CpuRailAbsent and CpuRailFrozen do not. - new_given with Unavailable, CpuRailAbsent or CpuRailFrozen: - opens no subscription of any kind and samples nothing; - is unavailable and names the status; - read() returns NoCounter. - new_given(Live) opens the all-channel subscription once and probes nothing. - OWN1 now fails: "a counter given Unavailable opened a subscription or sampled: Record { group_opens: 0, all_channel_opens: 1, ... }". - The once-per-process pin now lives in its own integration test, tests/ioreport_probe_once_per_process.rs, so it runs in a process of its own. A barrier releases 9 threads: - a third ask for the status first; - a third build a counter first; - a third call detect_meter first. REAL_RUNS and GROUP_OPENS must both end at 1. Without the ioreport backend the test asserts that nothing probed. - In the lib test process the uncached status now runs on purpose, so the unit test there only checks that every thread sees the one cached status. Fault injection inside the production code - A test-only, thread-local seam sits in four places: - inside the real group opener; - inside the all-channel opener; - inside the FFI leaf of the real sampler, sample_raw, now split from the pure energy_model_channels filter; - at the top of RailWatch::observe. - A test can script whether the group opens and what each sample holds. Everything between the leaf and the rule is production code, including the real 25 ms thread-CPU tick and the real clock. - The real, uncached probe_host_status runs under these scripts: - The group does not open: Unavailable, one open call, zero sample calls. OWN4 now fails: "the production status with no Energy Model subscription". - The M5 Max's own single refresh: CpuRailFrozen. - A rail advancing twice: Live, after exactly 3 sample calls. - A constant rail with one failed sample: CpuRailFrozen. The failed sample is kept from the rule, and the verdict comes only after the whole budget. - Two dies stepping in different intervals: CpuRailFrozen. The script also has same-named sub-rails and an out-of-group channel named "CPU Energy" that moves in every sample. Each die reaches the rule at its own value. - No CPU rail: CpuRailAbsent. - Every scripted run also checks two things: - The rule was handed exactly the Energy Model channels of each sample that came back. - Each tick between two sample calls burned at least 25 ms and under 50 ms of this thread's CPU on the tests' clock, unless it hit the 500 ms wall cap. What the rule is handed - The record is now taken at the rule's input (observe), not beside the sampler. A zero or stale transform anywhere between the FFI leaf and the rule now shows up in it. - The freshness test runs the real uncached composition while an oracle reads the group on its own subscription. It checks: - the rule's input equals what the sampler read; - its CPU and GPU rails are the oracle's; - each bracketed sample equals the oracle where the rail held, and lies between the oracle's reads where it moved; - a rail the oracle saw change inside the window also changes in what the rule was handed. - On a host whose rail is live, the probe stops after two intervals (~60 ms), and one such window often holds too few oracle reads. So the test runs the composition up to 8 times, until one run has evidence and a moving rail. - No evidence in 8 runs fails. - Evidence but no moving rail passes, and the test prints that such runs cannot tell a fresh sampler from a stale one. Cost and clocks, with no wall-clock bound - The group-only pin is structural. Every channel of every sample the real probe took is in the Energy Model group. Each sample holds exactly as many channels as the oracle's own group subscription: 364 on the M5 Max, 645 on the M3 Ultra. - The cost pin is the median THREAD CPU per sample call, under 50 ms on the tests' clock. The wall-time median is gone. - No hardware test asserts a wall-clock bound or ratio. The one wall-clock check left is a lower bound in a scripted test: a Frozen verdict comes only after the whole budget. - The tests read their own CLOCK_THREAD_CPUTIME_ID, the literal 16 from the SDK's <_time.h>. Against it, the production clock: - does not advance over a 200 ms sleep (10.5 µs); - does not advance while a helper thread burns 200 ms (57 µs); - advances by what the tests' clock saw over this thread's own burn (100.001 ms for 100.0008 ms). - Each production tick burns at least 25 ms and under 50 ms of this thread's CPU (unless it hits its 500 ms cap), and no more CPU than wall time. - The probe's own record of its ticks and sample costs is gone: ProbeRun, Tick, DELIBERATE_PROBE, NO_ENERGY_MODEL_GROUP and PROBE_RUNS. The tests now measure the ticks themselves, between sample calls. counts_once - Ported from eoc-meter 7aa42418. Beside the CPU aggregates, counts_once now also drops: - per-cluster accumulators (PACC*, EACC*, MACC*); - CPU power managers (*CPM*), with their _SRAM rails. It matches on the die-stripped name. The test pins the real channel names of both chips. Evidence (this commit's tree; M5 Max, macOS 27.0.1, hw.ncpu=18) - Tests with --features ioreport: - lib: "test result: ok. 66 passed; 0 failed" (57 before); - ioreport_probe_once_per_process: 1 passed, "real probe runs=1, Energy Model group opens=1"; - vendor_energy_conformance: 1 passed. - Tests without the feature: lib 24, integration 1, conformance 1. - Every probe status line said "ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false available=false". - Load runs, on the lib test binary built from this tree (sha256 df3f59b0…): - 5 runs while a helper process sampled IOReport over all ~11,900 channels in a tight loop (~185 ms a sample): 5/5, each "66 passed". Median sample-call thread CPU 6.95-8.65 ms, median tick 25.071-25.079 ms. - 5 runs under 18 busy-loop spinners (1 x ncpu, load1 14.2 -> 27.3): 5/5. Median sample-call thread CPU 6.59-6.82 ms. - Every helper was killed; kill -0 and pgrep found 0 left. - A light group-only watcher of mine was also running during these runs. - M3 Ultra (10.10.10.2, 27.0.1), the same binaries, run with --nocapture and then deleted: - lib "test result: ok. 66 passed; 0 failed", and the once test "real probe runs=1, Energy Model group opens=1". - 645 channels per sample, equal to the group; median 7.10 ms; DIE_0 and DIE_1 keyed apart. - GPU Energy changed 6 times inside the window and 7 times in what the rule was handed: fresh. - Simulated live host (a temporary edit makes is_live true after two intervals; restored with git checkout), freshness and group-only tests only: - 10/10 unloaded, 10/10 under the all-channel helper. - Under the helper, the median thread CPU over the 3 sample calls was 6.1-8.3 ms; round 5's wall-time median was 100-202 ms there. - Every ~57 ms window held 3-4 oracle reads. - Where no rail moved in any of the 8 windows, the test said it cannot tell fresh from stale: 4 of 10 unloaded, 6 of 10 under the helper. In this simulation the CPU rail is really frozen. On a genuinely live host the rail itself moves in nearly every interval. - Mutations: 22, each an exact-string edit of this tree, run with cargo test --no-fail-fast (lib and integration), then restored with git checkout (clean after each). All 22 failed: - new_given declines only CpuRailFrozen: "a counter given Unavailable opened a subscription or sampled". - no subscription -> Live inside probe_host_status: "the production status with no Energy Model subscription". - a single advance -> Live inside the production closure: "one batch refresh counted as a live rail" and "two dies' separate steps ... made a live rail". - the rule compares with the first sample: 11 tests fail. - a decrease counts as an advance: "a fall counted as an advance". - a no-op tick: "production tick 0 burned 416ns ...". - a tick that always runs to its wall cap: "production tick 0 burned 455.483167ms ... a tick stops once it has burned 25ms". - a per-thread cache: "the real probe ran 10 time(s) and asked for the Energy Model group 10 time(s)". - a constructor that probes uncached: "ran 19 time(s) and asked ... 19". - a constructor that re-implements the probe through probe_with: "ran 1 time(s) and asked for the Energy Model group 19 time(s)". - a zero or stale transform in the production sampler closure: "the rule was not handed exactly the Energy Model channels of each sample". - a zero transform inside watch() before observe: 7 tests fail. - zeroing inside the rule after the record: 6 tests fail. - the sampler reads constant 0: "("CPU Energy", 0) held at 2643843378 across the oracle's reads either side of it, but the rule was handed 0". - the thread clock is CLOCK_MONOTONIC: "this thread slept 200ms, and the production thread clock advanced 210.015ms". - admits accepts CpuRailAbsent: "CpuRailAbsent admitted the counter". - counts_once re-admits PACC_*: "PACC_0, a summand of CPU Energy on an M5 Max, is summed beside it". - extras: - CLOCK_PROCESS_CPUTIME_ID: "advanced 1.767591s". - the probe on the all-channel subscription: 9 lib tests and the integration test fail. - each CPU rail handed to the rule as the sum of all CPU rails: fails on this one-rail M5 Max through the two-die script. - a tick of 30%: "burned 7.50375ms". - Consumers, all passing: - jouledesk-github: 10/10, 12 passed each. - jouleclaw-loader-gguf with fabric-meter: 299 passed, 2 ignored. - jouleclaw-harness-econ: 14. - mesh-meter with hw-meter-apple: 10. - l3-jouleclaw: 2 + 4, with 2 + 2 ignored. - energy_probe prints "ioreport : declined (CpuRailFrozen)". - ~/.jouleclaw/fabric-calibration.json is unchanged (2026-06-11 11:02:14). - cargo check --all-targets, with and without ioreport, on x86_64/aarch64-unknown-linux-gnu and x86_64/aarch64-apple-darwin: 0 warnings. - clippy --no-deps --all-targets --features ioreport shows only warnings that were already there: - the duplicated link attribute, now apple_ioreport.rs:112; - the b"IOReportChannels\0" literal in sum_energy_uj, now :823; - ledger.rs:631-634. - rustdoc --document-private-items shows only its three earlier warnings. Found, not fixed here: one batch refresh is two advances, so the rule can say Live on this frozen host - During the first mutation pass, the per-thread-cache mutant ran about a dozen real probes in one lib test process. Two of them, on two test threads, said Live, and the oracle tests failed: - "probe said Live (probed=CpuRailFrozen, cached=Live), the oracle did not see the CPU rail keep moving"; - "left: [CpuRailFrozen, ...] right: [Live, ...]". - CPU Energy moved in one batch refresh, 2,588,973,125 -> 2,643,843,378, between 17:54 and 17:59:38. That mutant ran from 17:58:24 to 17:58:34. - On this chip CPU Energy is the sum MCPU0 + MCPU1 + PCPU: 311,045,470 + 313,039,186 + 2,019,758,721 = 2,643,843,377, against 2,643,843,378. - A watcher sampled the Energy Model group every ~16 ms: 20 minutes with no refresh, then the next one caught one. The refresh landed as two steps of every cluster and of CPU Energy, 64 ms apart: t=1076.463s CPU Energy 2643843378 -> 2705607863 (+61764485) sample#56065 t=1076.528s CPU Energy 2705607863 -> 2705611298 (+3435) sample#56068 (MCPU0 +9470641 then +788, MCPU1 +9747542 then +844, PCPU +42546301 then +1802.) - The probe's intervals are ~31 ms: a 25 ms tick plus a ~6 ms sample. A probe whose window holds such a refresh sees the same rail advance in two distinct intervals, which is Live under the current rule. - A probe lands on a refresh rarely: its window is 1 s, and the two refreshes seen here came about 30-34 minutes apart. When it does, production caches Live for the whole process. The counter would then sum batch-refreshed CPU rails, plus the GPU, tagged ModelBased: the over-claim this branch exists to stop. - The same mutant on the final tree left the lib tests at 66/66, with no refresh during that run. - The rule itself (the same rail advancing in two distinct intervals) is the cross-crate rule every sibling crate shares, so it is left unchanged here. One direction would demand what a live rail shows and a refresh cannot: a rail that advances in most intervals over a minimum count, for example at least 5 intervals with 80% of them advancing. The M5 Max GPU rail changed in 166 of 176 samples ~28 ms apart. A refresh gives at most 2 or 3 advancing intervals in a row. Not done here - Changing the liveness rule, per the finding above. It needs one decision across jouleclaw-energy, eoc-meter, proof-cascade, sandbox-meter and jcp-energy. - The counter's own read() still samples every channel. It is declined on both Macs. - No CI job runs these ioreport tests: .github/workflows is off-limits to this branch. Co-Authored-By: Claude Opus 5.5 --- .../jouleclaw-energy/src/apple_ioreport.rs | 1766 ++++++++++------- .../crates/jouleclaw-energy/src/lib.rs | 42 +- .../tests/ioreport_probe_once_per_process.rs | 105 + 3 files changed, 1194 insertions(+), 719 deletions(-) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/ioreport_probe_once_per_process.rs diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index 4b2790342..cfac6f729 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -38,7 +38,8 @@ #![allow(non_camel_case_types)] use std::os::raw::{c_int, c_long, c_void}; -use std::sync::{Mutex, OnceLock}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::meter::{EnergyMeter, EnergySource}; @@ -123,6 +124,21 @@ struct State { cumulative_uj: u64, } +#[cfg(test)] +impl State { + /// A subscription with no IOReport object behind it, for an opener a test + /// has scripted ([`seam`]): every sample of it comes from the script, and + /// dropping it releases nothing. + fn inert() -> Self { + Self { + sub: std::ptr::null(), + channels: std::ptr::null_mut(), + prior_sample: None, + cumulative_uj: 0, + } + } +} + impl Drop for State { fn drop(&mut self) { unsafe { @@ -174,10 +190,20 @@ impl AppleIoReportCounter { /// /// The first call in a process runs the liveness probe on the calling /// thread (see [`crate::ioreport_status`] for its cost); later calls reuse - /// the cached answer. + /// the cached answer. The status comes from that cache and nowhere else; + /// the gate on it is `new_given`, which the tests drive with every status. pub fn new() -> Self { - let status = host_status(); - if status != IoReportStatus::Live { + Self::new_given(crate::ioreport_status()) + } + + /// The counter for a given host IOReport `status`. Unless the status + /// [`admits`] a counter (it is `Live`), it is declined before anything is + /// opened. Otherwise the all-channel subscription is opened, and if that + /// fails the counter is declined as `Unavailable`. Production passes the + /// cached [`crate::ioreport_status`] ([`Self::new`]); the tests pass every + /// status. + fn new_given(status: IoReportStatus) -> Self { + if !admits(status) { return Self::declined(status); } match Self::open() { @@ -200,6 +226,8 @@ impl AppleIoReportCounter { } fn open() -> Option { + #[cfg(test)] + seam::all_channel_open(); unsafe { let all = IOReportCopyAllChannels(0, 0); if all.is_null() { @@ -240,13 +268,15 @@ impl AppleIoReportCounter { /// an M5 Max (~11,860 channels) and ~0.14–0.26 s on an M3 Ultra. On every /// channel the probe would spend most of its budget inside IOReport rather /// than burning CPU between samples. `None` if the group cannot be - /// subscribed — and, in test builds, on a thread where a test has made it - /// unsubscribable ([`NO_ENERGY_MODEL_GROUP`]). + /// subscribed. Every call is counted in [`GROUP_OPENS`]. In test builds, a + /// thread can script whether it opens (the test-only `seam`); a scripted + /// open is not counted there, since it asks IOReport for nothing. fn open_energy_model() -> Option { #[cfg(test)] - if NO_ENERGY_MODEL_GROUP.with(std::cell::Cell::get) { - return None; + if let Some(opens) = seam::group_open() { + return opens.then(State::inert); } + GROUP_OPENS.fetch_add(1, Ordering::SeqCst); unsafe { let group = CFStringCreateWithCString( std::ptr::null_mut(), @@ -299,6 +329,17 @@ impl AppleIoReportCounter { } } +/// Whether a host IOReport status lets the counter meter: `Live`, and nothing +/// else. `Unavailable` saw nothing. `CpuRailAbsent` has no CPU rail to meter. +/// `CpuRailFrozen` would pass GPU-only energy, or zero, off as the energy of +/// CPU work. The match is exhaustive, so a new status has to be decided here. +fn admits(status: IoReportStatus) -> bool { + match status { + IoReportStatus::Live => true, + IoReportStatus::Unavailable | IoReportStatus::CpuRailAbsent | IoReportStatus::CpuRailFrozen => false, + } +} + // ── CPU-rail liveness ─────────────────────────────────────────────────────── /// How long (wall clock) the liveness probe watches the CPU energy rail, @@ -400,8 +441,13 @@ struct RailWatch { impl RailWatch { /// Fold in the next sample: every channel in it, as (name, absolute raw /// value). Its CPU rails are compared with the previous sample's; the first - /// sample is only the baseline for the next. + /// sample is only the baseline for the next. In test builds, a thread that + /// records (the test-only `seam`) keeps every sample exactly as it arrives + /// here: what the rule was handed, not what some earlier step meant to + /// hand it. fn observe(&mut self, sample: &[(String, i64)]) { + #[cfg(test)] + seam::fed(sample); let cur = cpu_rails(sample); if let Some(prev) = self.prev.take() { self.intervals += 1; @@ -451,9 +497,9 @@ impl RailWatch { /// rule. A failed sample (`None`) is skipped, and the next good one is compared /// with the last good one; the clock still runs, so a sampler that keeps /// failing ends at the cap with no verdict. `now` is the time since any fixed -/// origin. The real probe ([`probe_cpu_rail`]) passes the IOReport sampler, a -/// burn of [`PROBE_TICK`] of thread CPU time ([`burn_thread_cpu`]) and -/// [`Instant`]; the tests pass scripts. +/// origin. The real probe ([`probe_host_status`], through [`probe_with`]) +/// passes the `Energy Model` group sampler, a burn of [`PROBE_TICK`] of thread +/// CPU time ([`probe_tick`]) and [`Instant`]; the tests pass scripts. fn watch( mut sample: impl FnMut() -> Option>, mut tick: impl FnMut(), @@ -475,7 +521,9 @@ fn watch( } /// The status a probe verdict maps to; no verdict at all means IOReport could -/// not be sampled. +/// not be sampled — the probe could not subscribe to the `Energy Model` group, +/// or its subscription never gave it one interval. The probe saw nothing then, +/// so it can say neither that a CPU rail is live nor that none exists. fn status_of(verdict: Option) -> IoReportStatus { match verdict { None => IoReportStatus::Unavailable, @@ -485,192 +533,78 @@ fn status_of(verdict: Option) -> IoReportStatus { } } -/// The status one probe run maps to: `None` when the probe could not subscribe -/// to the `Energy Model` group at all. That, like a subscription that never -/// yields a sample (no verdict), is [`IoReportStatus::Unavailable`]: the probe -/// saw nothing, so it can say neither that a CPU rail is live nor that none -/// exists. -fn status_of_run(watch: Option<&RailWatch>) -> IoReportStatus { - status_of(watch.and_then(RailWatch::verdict)) -} +/// How many times this process has run the real liveness probe +/// ([`probe_host_status`]), on any thread: bumped first thing in every run. +/// Read through [`crate::ioreport_probe_runs`] by the test that pins +/// [`crate::ioreport_status`] to one run per process, from a process of its +/// own. +pub(crate) static REAL_RUNS: AtomicUsize = AtomicUsize::new(0); -/// This host's IOReport status, probed once per process and cached. Backs -/// [`crate::ioreport_status`] and gates [`AppleIoReportCounter::new`]. -pub(crate) fn host_status() -> IoReportStatus { - static STATUS: OnceLock = OnceLock::new(); - *STATUS.get_or_init(|| probe(CPU_RAIL_LIVENESS_BUDGET).0) -} +/// How many times this process has asked IOReport for a subscription to the +/// `Energy Model` group ([`AppleIoReportCounter::open_energy_model`]), on any +/// thread. In production only the probe asks, once a run, so this equals +/// [`REAL_RUNS`] — unless some path re-implements the probe around the cache +/// (a constructor that opens and watches the group itself, say), which the +/// same once-per-process test then sees. Read through +/// [`crate::ioreport_group_opens`]. +pub(crate) static GROUP_OPENS: AtomicUsize = AtomicUsize::new(0); -/// How many times the real probe, [`probe`], has run in this process, on any -/// thread. It is counted inside [`probe`] itself, not in the cache in front of -/// it, so any path that probes instead of asking [`host_status`] — a counter -/// constructor, a meter selection — is counted as well. The tests pin it at -/// exactly one. A run a test makes on purpose, on its own thread, is left out -/// (see [`DELIBERATE_PROBE`]): it is that test's run, not the host's. -#[cfg(test)] -static PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); - -#[cfg(test)] -thread_local! { - /// Set on one thread, for the length of one call, by a test that runs the - /// real probe on purpose (uncached, to look at what it sampled); - /// [`PROBE_RUNS`] leaves that run out. It exists in test builds only, so - /// no production path can set it and still build. - static DELIBERATE_PROBE: std::cell::Cell = const { std::cell::Cell::new(false) }; - - /// Set on one thread by a test to make the `Energy Model` group - /// unsubscribable there ([`AppleIoReportCounter::open_energy_model`] - /// returns `None`), so the real [`probe`] runs down its no-subscription - /// path on a host whose group opens. - static NO_ENERGY_MODEL_GROUP: std::cell::Cell = const { std::cell::Cell::new(false) }; -} - -/// What one run of the liveness probe sampled, beside the rule's view of it. -/// Everything but the rule is there for the tests, which assert on what the -/// real probe sampled and what it cost. -#[derive(Debug, Default)] -struct ProbeRun { - /// The liveness rule, fed every sample the probe took. - watch: RailWatch, - /// Channels in the largest sample the probe took. - #[cfg_attr(not(test), allow(dead_code))] - channels: usize, - /// How many channels of any one sample were outside the `Energy Model` group. - #[cfg_attr(not(test), allow(dead_code))] - outside_group: usize, - /// The wall-clock duration of every sample call the probe made, failed - /// ones included, in order. - #[cfg_attr(not(test), allow(dead_code))] - sample_costs: Vec, - /// The probing thread's CPU time consumed in each interval the rule - /// counted (between one good sample and the next), in order: one entry per - /// [`RailWatch::intervals`] while the thread CPU clock reads. - #[cfg_attr(not(test), allow(dead_code))] - interval_cpu: Vec, - /// Every tick the probe burned between two sample calls, in order, as the - /// probe itself measured it on its own thread. - #[cfg_attr(not(test), allow(dead_code))] - ticks: Vec, - /// Test builds only: every sample call, in order — when it ran and what - /// it handed the rule — so a test can hold the probe's own samples to an - /// independent reader of the same rails. - #[cfg(test)] - samples: Vec, -} - -/// One tick of the real probe, as the probe measured it. -#[cfg_attr(not(test), allow(dead_code))] -#[derive(Debug, Clone, Copy)] -struct Tick { - /// The probing thread's CPU time over the tick, by [`thread_cpu_time`]; - /// `None` if that clock could not be read. - cpu: Option, - /// Wall-clock time over the tick. - wall: Duration, -} - -/// One sample call of the real probe (test builds only). -#[cfg(test)] -#[derive(Debug)] -struct SampleCall { - /// Just before the FFI sample call. - start: Instant, - /// Just after it returned. - end: Instant, - /// What it handed the rule: every `Energy Model` channel, as (name, - /// absolute raw value); `None` for a failed sample. - values: Option>, -} - -/// Probe this host's CPU energy rail now, uncached, on the probe's own -/// subscription to the `Energy Model` group -/// ([`AppleIoReportCounter::open_energy_model`]), which is dropped after: the -/// status, and what the probe sampled to decide it (`None` when the group could -/// not be subscribed, which [`status_of_run`] maps to `Unavailable`). This is -/// the real probe: [`host_status`] caches it, and [`PROBE_RUNS`] counts it. -fn probe(budget: Duration) -> (IoReportStatus, Option) { - #[cfg(test)] - if !DELIBERATE_PROBE.with(std::cell::Cell::get) { - PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - } - probe_with(AppleIoReportCounter::open_energy_model, |state| probe_cpu_rail(state, budget)) -} - -/// The probe's decision, over any way of opening its subscription and of -/// probing it, so the paths where nothing opens, or nothing is ever sampled, -/// run in tests too. `open` gives `None` when the `Energy Model` group cannot -/// be subscribed: then nothing is probed and, like a probe that saw no -/// interval, the status is whatever the pure [`status_of_run`] says — -/// `Unavailable`. The subscription is dropped once probed. -fn probe_with( - open: impl FnOnce() -> Option, - probe_on: impl FnOnce(&S) -> ProbeRun, -) -> (IoReportStatus, Option) { - let run = open().map(|subscription| probe_on(&subscription)); - (status_of_run(run.as_ref().map(|r| &r.watch)), run) -} - -/// Run [`watch`] over `state`'s subscription: burn [`PROBE_TICK`] of this -/// thread's CPU time between samples, and decide by the rule in [`RailWatch`] -/// whether the CPU energy rail **itself** is live. +/// This host's IOReport status from one real, uncached run of the liveness +/// probe: count the run, then map ([`status_of`]) the verdict of +/// [`probe_with`] over the real opener ([`open_probe_sampler`]: the `Energy +/// Model` group subscription and its sampler), the real tick ([`probe_tick`]: +/// a burn of this thread's CPU time), the monotonic clock and +/// [`CPU_RAIL_LIVENESS_BUDGET`]. /// -/// The summed total is not a liveness signal: on an M5 Max the GPU rail keeps -/// advancing while every CPU rail holds, so a total that moves proves only that -/// something drew on screen. Liveness is decided on the CPU rails alone, from -/// the probe's own samples, so no counter's cumulative reading is touched. +/// One composition with no branch of its own, so the path the tests drive — +/// with IOReport scripted underneath it, or real — is the path the counter is +/// gated on. Its one production caller is the cache in +/// [`crate::ioreport_status`]; call that, not this. +pub(crate) fn probe_host_status() -> IoReportStatus { + REAL_RUNS.fetch_add(1, Ordering::SeqCst); + let epoch = Instant::now(); + status_of(probe_with(open_probe_sampler, probe_tick, move || epoch.elapsed(), CPU_RAIL_LIVENESS_BUDGET)) +} + +/// The probe over any opener, tick and clock: open a sampler, [`watch`] the +/// CPU rails with it for `budget`, and give the rule's verdict. `None` — +/// which [`status_of`] maps to `Unavailable` — when nothing opened (then +/// nothing is sampled or ticked) or when not one interval was observed. The +/// sampler, and the subscription it owns, is dropped once watched. Its one +/// production caller is [`probe_host_status`]; the tests pass scripts. +fn probe_with( + open: impl FnOnce() -> Option, + tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, +) -> Option +where + F: FnMut() -> Option>, +{ + open().and_then(|sample| watch(sample, tick, now, budget).verdict()) +} + +/// The real opener: a subscription to the `Energy Model` group alone +/// ([`AppleIoReportCounter::open_energy_model`]), and a sampler over it +/// ([`sample_energy_model`]) that owns it and releases it when dropped. `None` +/// if the group cannot be subscribed. /// -/// Each sample is one `IOReportCreateSamples` over `state`'s subscription. On -/// the probe's `Energy Model` subscription that costs a few milliseconds, so -/// the thread spends most of `budget` burning CPU and compares the rail after -/// every [`PROBE_TICK`] of burn; on an all-channel subscription one sample -/// takes 0.1–1 s and the probe would compare only a handful of times. -/// -/// The probe keeps its own record of what each sample call cost and what each -/// tick burned ([`ProbeRun`]), for the tests; nothing in it feeds the rule. -fn probe_cpu_rail(state: &State, budget: Duration) -> ProbeRun { - let (mut channels, mut outside_group) = (0, 0); - let (mut sample_costs, mut interval_cpu, mut ticks) = (Vec::new(), Vec::new(), Vec::new()); - #[cfg(test)] - let mut samples = Vec::new(); - let mut cpu_at_last_sample: Option = None; - let started = Instant::now(); - let watch = watch( - || { - let t = Instant::now(); - let s = sample_energy_model(state); - let end = Instant::now(); - sample_costs.push(end.duration_since(t)); - #[cfg(test)] - samples.push(SampleCall { start: t, end, values: s.as_ref().map(|s| s.values.clone()) }); - let s = s?; - let cpu = thread_cpu_time(); - if let (Some(before), Some(now)) = (cpu_at_last_sample, cpu) { - interval_cpu.push(now.saturating_sub(before)); - } - cpu_at_last_sample = cpu; - channels = channels.max(s.channels); - outside_group = outside_group.max(s.outside_group); - Some(s.values) - }, - || { - let (cpu, wall) = (thread_cpu_time(), Instant::now()); - burn_thread_cpu(PROBE_TICK, PROBE_TICK_WALL_CAP); - let cpu = cpu.zip(thread_cpu_time()).map(|(before, after)| after.saturating_sub(before)); - ticks.push(Tick { cpu, wall: wall.elapsed() }); - }, - || started.elapsed(), - budget, - ); - ProbeRun { - watch, - channels, - outside_group, - sample_costs, - interval_cpu, - ticks, - #[cfg(test)] - samples, - } +/// The probe reads the group alone because one sample of it takes a few +/// milliseconds of this thread's CPU, against 0.1–1 s for every channel on an +/// M5 Max: on every channel the probe would spend its budget inside IOReport +/// rather than burning CPU between samples. The summed total is not a liveness +/// signal either: on an M5 Max the GPU rail keeps advancing while every CPU +/// rail holds, so liveness is decided on the CPU rails alone ([`RailWatch`]), +/// from the probe's own samples, and no counter's cumulative reading is +/// touched. +fn open_probe_sampler() -> Option Option>> { + AppleIoReportCounter::open_energy_model().map(|state| move || sample_energy_model(&state)) +} + +/// The real tick: burn [`PROBE_TICK`] of this thread's CPU time, or stop at +/// [`PROBE_TICK_WALL_CAP`] of wall time on a host too loaded to grant it. +fn probe_tick() { + burn_thread_cpu(PROBE_TICK, PROBE_TICK_WALL_CAP); } /// This thread's CPU time so far (`CLOCK_THREAD_CPUTIME_ID`: user and system @@ -710,25 +644,51 @@ fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) { std::hint::black_box(acc); } -/// One plain sample, as the liveness probe reads it. -struct EnergyModelSample { - /// Every `Energy Model` channel in the sample, in sample order, as (name, - /// absolute raw integer value). Raw integers, before unit conversion, so a - /// small advance in a fine unit cannot truncate to zero. - values: Vec<(String, i64)>, - /// How many channels the sample held. - channels: usize, - /// How many of those were outside the `Energy Model` group. - outside_group: usize, +/// One plain sample of `state`'s subscription (absolute values, no delta): +/// every `Energy Model` channel in it, in sample order, as (name, absolute raw +/// integer value) — raw integers, before unit conversion, so a small advance +/// in a fine unit cannot truncate to zero. `None` if IOReport returned no +/// sample, or one without a channel array: a failed sample, never a sample +/// with no CPU rail in it. Deciding anything is left to [`RailWatch`]. +fn sample_energy_model(state: &State) -> Option> { + sample_raw(state).map(energy_model_channels) } -/// Take one plain sample of `state`'s subscription (absolute values, no -/// delta) and extract every `Energy Model` channel's (name, raw value) from it. -/// `None` if IOReport returned no sample, or one without a channel array: a -/// failed sample, never a sample with no CPU rail in it. Deciding anything is -/// left to [`RailWatch`]. -fn sample_energy_model(state: &State) -> Option { - let mut out = EnergyModelSample { values: Vec::new(), channels: 0, outside_group: 0 }; +/// The `Energy Model` channels of one raw sample, in sample order, as (name, +/// raw value): what the probe hands the rule. A channel whose name could not +/// be read is left out. +fn energy_model_channels(raw: Vec<(String, String, i64)>) -> Vec<(String, i64)> { + raw.into_iter() + .filter(|(group, name, _)| group.eq_ignore_ascii_case("Energy Model") && !name.is_empty()) + .map(|(_, name, value)| (name, value)) + .collect() +} + +/// The FFI leaf of every probe sample: one `IOReportCreateSamples` over +/// `state`'s subscription, and every channel in it as (group, name, absolute +/// raw value), in sample order, unfiltered. A group or name that cannot be +/// read is empty. In test builds a thread can script what this returns, and +/// record every call with its cost on the tests' own clock (the test-only +/// `seam`). +fn sample_raw(state: &State) -> Option> { + #[cfg(test)] + let started = seam::Stamp::now(); + #[cfg(test)] + if let Some(scripted) = seam::scripted_sample() { + seam::sampled(started, &scripted); + return scripted; + } + let raw = read_channels(state); + #[cfg(test)] + seam::sampled(started, &raw); + raw +} + +/// [`sample_raw`]'s IOReport calls. +fn read_channels(state: &State) -> Option> { + // SAFETY: `state` holds a live subscription and its channel dictionary + // (released only when `state` drops). Every CF object created here is + // released on every path; the ones got from it are borrowed. unsafe { let smp = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); if smp.is_null() { @@ -750,23 +710,22 @@ fn sample_energy_model(state: &State) -> Option { CFRelease(smp); return None; } - for i in 0..CFArrayGetCount(arr) { + let count = CFArrayGetCount(arr); + let mut out = Vec::with_capacity(usize::try_from(count).unwrap_or(0)); + for i in 0..count { let item = CFArrayGetValueAtIndex(arr, i); if item.is_null() { continue; } - out.channels += 1; - if !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { - out.outside_group += 1; - continue; - } - if let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) { - out.values.push((name, IOReportSimpleGetIntegerValue(item, 0) as i64)); - } + out.push(( + cfstring_to_string(IOReportChannelGetGroup(item)).unwrap_or_default(), + cfstring_to_string(IOReportChannelGetChannelName(item)).unwrap_or_default(), + IOReportSimpleGetIntegerValue(item, 0) as i64, + )); } CFRelease(smp); + Some(out) } - Some(out) } /// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally @@ -931,10 +890,20 @@ fn strip_die_prefix(name: &str) -> &str { name } -/// Whether a channel should be summed: the canonical per-domain aggregates -/// (`CPU/GPU/ANE Energy`, optionally `DIE__`-prefixed), DRAM controller -/// rails, and independent SoC rails — but **not** the per-core / per-cluster -/// sub-rails of CPU/GPU/ANE, which would double-count the aggregate. +/// Whether the counter sums this 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). In one absolute sample +/// 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` rails (all to within 1-8 mJ). +/// Summing `PACC_*` or `*_CPM` beside `CPU Energy` would count that CPU energy +/// twice. DRAM controller rails and the other independent SoC rails (AMCC, +/// DCS, display, ISP, AVE, MSR, FAB, PCIe) are no aggregate's summand and are +/// summed. fn counts_once(name: &str) -> bool { let base = strip_die_prefix(name); if base.eq_ignore_ascii_case("CPU Energy") @@ -943,12 +912,12 @@ fn counts_once(name: &str) -> bool { { return true; } - let l = name.to_ascii_lowercase(); - if l.contains("cpu") || l.contains("gpu") || l.contains("ane") { - return false; // sub-rail of an aggregated domain + let u = base.to_ascii_uppercase(); + if u.contains("CPU") || u.contains("GPU") || u.contains("ANE") { + return false; // a summand of an aggregated domain } - // DRAM controller rails and independent rails sum cleanly. - true + let cpu_summand = ["PACC", "EACC", "MACC"].iter().any(|p| u.starts_with(p)) || u.contains("CPM"); + !cpu_summand } /// Read a CFString into an owned Rust String (UTF-8, ≤255 bytes). @@ -984,11 +953,194 @@ fn cfstring_eq_ic(s: CFStringRef, target: &str) -> bool { .unwrap_or(false) } +/// Test-only fault injection and recording, INSIDE the real group opener +/// ([`AppleIoReportCounter::open_energy_model`]), the all-channel opener +/// ([`AppleIoReportCounter::open`]), the real sampler's FFI leaf +/// ([`sample_raw`]) and the rule's input ([`RailWatch::observe`]). +/// +/// A test installs it on its own thread for the length of one call — usually +/// the real, uncached [`probe_host_status`] — and gets back what happened on +/// that thread: every open, every sample call (what it returned, and its start +/// and end on the tests' own thread CPU clock and a monotonic one), and every +/// sample the rule was handed. With a script, the group subscription and its +/// samples are the script's; everything between them and the rule — the +/// production closures, the tick, the clock, the loop, the mapping — is the +/// production code. Every other thread, and every build that ships, reads the +/// real IOReport. A thread with a script installed must never ask for the +/// cached status: the cache would keep the scripted answer for the whole +/// process. +#[cfg(test)] +mod seam { + use std::cell::RefCell; + use std::time::{Duration, Instant}; + + /// `CLOCK_THREAD_CPUTIME_ID` as the macOS SDK defines it (`<_time.h>`: + /// `_CLOCK_THREAD_CPUTIME_ID = 16`), written out by the tests themselves, + /// so that their clock shares nothing with the one the production tick + /// reads. + pub(super) const TEST_CLOCK_THREAD_CPUTIME_ID: libc::clockid_t = 16; + + /// This thread's CPU time, on the tests' own clock. + pub(super) fn test_thread_cpu() -> Duration { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: `ts` is a valid, writable timespec for the duration of the call. + let rc = unsafe { libc::clock_gettime(TEST_CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + assert_eq!(rc, 0, "the tests' own thread CPU clock did not read"); + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) + } + + /// A moment on this thread: its CPU time by the tests' own clock, and a + /// monotonic reading. + #[derive(Debug, Clone, Copy)] + pub(super) struct Stamp { + pub(super) cpu: Duration, + pub(super) at: Instant, + } + + impl Stamp { + pub(super) fn now() -> Self { + let at = Instant::now(); + Self { cpu: test_thread_cpu(), at } + } + } + + /// One sample as the FFI leaf returns it: every channel, as (group, name, + /// absolute raw value). + pub(super) type Raw = Vec<(String, String, i64)>; + + /// One call of the sampler's FFI leaf. + #[derive(Debug)] + pub(super) struct SampleCall { + pub(super) start: Stamp, + pub(super) end: Stamp, + /// What it returned; `None` for a failed sample. + pub(super) raw: Option, + } + + /// What happened on the thread while the seam was installed. + #[derive(Debug, Default)] + pub(super) struct Record { + /// Calls of the `Energy Model` group opener. + pub(super) group_opens: usize, + /// Calls of the all-channel opener (the counter's own subscription). + pub(super) all_channel_opens: usize, + /// Every call of the sampler's FFI leaf, in order. + pub(super) calls: Vec, + /// Every sample the liveness rule was handed, in order, exactly as it + /// arrived there. + pub(super) fed: Vec>, + } + + type Script = Box Option>; + + struct Seam { + /// `Some(opens)`: the group opener is scripted, and opens or not. + /// `None`: the real one. + opens: Option, + /// The i-th sample of a scripted subscription. + script: Option