From dfec5d44b0f5835efa349eb320e10ea919b33606 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Tue, 29 Sep 2026 10:08:12 -0400 Subject: [PATCH 01/10] jcp-energy: never select IOReport when its CPU rail is frozen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This crate's contract, in its own module doc: "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 rung broke that on current macOS, and the CI job meant to catch it could not. What IOReport actually returns on macOS 27.0.1, measured by dumping the channels rather than re-running the test: - On an M5 Max, every CPU rail in the Energy Model group (`CPU Energy`, `PCPU`, `MCPU*`, `PACC_*`) holds the same cumulative value across 200 ms, 1 s, 3 s and ~4 minutes of full CPU load. `CPU Energy` read 73,176,481 mJ before and after. `GPU Energy` stays live. - On an M3 Ultra, the same OS: all 645 Energy Model rails are frozen, CPU, DRAM and display included. - Every channel is IOReport format 1 (Simple), so the accessor is right, and subscribing to the Energy Model group alone instead of all channels does not unfreeze anything. No channel in any group exposes live CPU energy to an unprivileged reader; "CPU Stats" residency moves, but that is activity. So `detect()` selected IOReport the moment a subscription opened, and `measure()` accepted any `a > b` on the summed rails as the task's energy. On the M5 Max the GPU rail makes `a > b` true: GPU energy was reported as the energy of CPU work, as a whole-task ModelBased figure. On the M3 Ultra nothing moves, so it fell to time×power under the same tag, indistinguishable from a reading. The CI job `macOS energy backend (jcp-energy)` flaked for the same reason. Its test asserted the summed total advanced across a 200 ms CPU burn — satisfied by the GPU, not the CPU. It passed when something happened to draw on screen and failed when nothing did, which is why it failed overnight and "healed" by day. That flake has been blocking unrelated PRs. The fix enforces the contract where the rung is chosen. `detect()` selects IOReport only when `ioreport_status()` is `Live`: the CPU rail itself — `CPU Energy`, or `DIE__CPU Energy` on multi-die parts — must advance while this thread burns CPU, polled every ~25 ms for up to one second, probed once per process and cached. Liveness is decided on the raw integer before unit conversion, and never on the summed total, because the live GPU rail is exactly what corrupts the total. Otherwise the rung is declined and the time×power model estimates, as the contract requires. `IoReportStatus` (`Unavailable`, `CpuRailAbsent`, `CpuRailFrozen`, `Live`) is exposed so a caller or receipt can say why an Apple Silicon host is being estimated. The change is additive: `mod ioreport` is private and nothing outside the crate matches on `Meter`. Tests. The flaky test is replaced by three that assert against an independent oracle: it reads the CPU rail's ABSOLUTE cumulative value with no delta samples, matches names with `ends_with("CPU Energy")` rather than the implementation's die-prefix stripping, and burns for three times the probe's budget. There is no early return that passes without asserting — a host with no IOReport must report `Unavailable`, one with no CPU rail `CpuRailAbsent`. - `the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail` — the property that matters. It logs what it observed so a CI log shows which branch was asserted. - `the_liveness_probe_never_reports_a_frozen_rail_as_live` — the dangerous direction, asserted on its own. - `a_selected_ioreport_meter_meters_cpu_work` — when the rung IS selected, a CPU burn meters as non-zero energy. Observed on both machines: `status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false`, 10/10 passing — on the M3 Ultra via the `DIE_0_`/`DIE_1_` rails, which confirms the multi-die path. The CI job becomes deterministic: green while the meter correctly declines a frozen rail, red if it ever selects one. Falsified: a probe that always says Live fails the test (the oracle saw the rail hold); reverting `detect()` to select IOReport whenever it opens — main's behaviour — fails it with "IOReport selected although its CPU rail did not move", so main fails this test today. jcp-cli, jcp-conformance and jcp-intercept (every dependant of this crate) pass, as does the conformance suite's C9 energy-honesty check; clippy is clean on jcp-energy. jcp-gateway fails on main as well and does not depend on this crate — it meters through sandbox-meter's probe, which reads the same frozen rail. Not done here, deliberately: - A per-span check for a rail that freezes after `detect()` needs "CPU Stats" residency as a witness; the platform-level freeze observed here is caught at selection. - Reporting the live GPU rail as an explicitly partial reading, instead of declining, would need a coverage field on the intercept/1 wire — a format change, so a separate decision. - The same defect in jouleclaw-energy, eoc-meter, proof-cascade and sandbox-meter, and in the callers that seal a zero delta as a measurement. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 325 ++++++++++++++++++++++-- 1 file changed, 306 insertions(+), 19 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index eab3c3ff2..a4737298c 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -15,7 +15,12 @@ //! 3. **IOReport** (Apple Silicon) — real on-die SoC Energy Model counters via //! Apple's IOReport C API (no sudo, no external binary). A real reading but a //! vendor model, so `Provenance::ModelBased` — strictly better than the -//! wall-clock model below. +//! wall-clock model below. **Selected only when its CPU energy rail is live** +//! ([`ioreport_status`]): a subscription can open while every CPU rail holds a +//! frozen value, and then the summed total advances only through the GPU rail — +//! GPU energy reported as the energy of CPU work. When the CPU rail is frozen +//! the platform is not exposing CPU energy, so by the rule above this rung is +//! declined and the time×power model estimates instead. //! 4. **Time × power model** — wall-clock duration times a calibrated active //! power, `Provenance::ModelBased`. Always available. //! 5. **Constant estimator** — a fixed per-operation figure, @@ -135,6 +140,7 @@ impl std::fmt::Debug for Nvml { mod ioreport { use std::os::raw::{c_int, c_long, c_void}; use std::sync::Mutex; + use std::time::{Duration, Instant}; type CFTypeRef = *const c_void; type CFStringRef = *const c_void; @@ -289,6 +295,106 @@ mod ioreport { } } + /// Whether this host's CPU energy rail is live, frozen, or not present. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum CpuRail { + /// The CPU energy rail advanced while this thread burned CPU. + Live, + /// The rail exists but held its value across the whole burn. + Frozen, + /// No `CPU Energy` channel exists in the Energy Model group. + Absent, + } + + impl IoReport { + /// Burn CPU on this thread for up to `budget`, re-sampling every ~25 ms, + /// and report whether the CPU energy rail **itself** advanced. + /// + /// The summed total is not a liveness signal. Measured on an M5 Max and an + /// M3 Ultra, both on macOS 27.0.1: every CPU rail in the Energy Model group + /// (`CPU Energy`, `PCPU`, `MCPU*`, `PACC_*`, `DIE_n_CPU Energy`) held the + /// same cumulative value across minutes of full CPU load, while on the M5 + /// Max `GPU Energy` kept advancing. A total that moves therefore proves + /// only that something drew on screen. Liveness is decided on the CPU rail + /// alone. Reads its own samples, so the cumulative reading is untouched. + pub fn probe_cpu_rail(&self, budget: Duration) -> CpuRail { + let Ok(state) = self.state.lock() else { + return CpuRail::Absent; + }; + unsafe { + let s0 = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); + if s0.is_null() { + return CpuRail::Absent; + } + let started = Instant::now(); + let mut acc: u64 = 0; + let mut saw_rail = false; + let mut advanced = false; + while !advanced && started.elapsed() < budget { + let tick = Instant::now(); + while tick.elapsed() < Duration::from_millis(25) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); + let s1 = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); + if s1.is_null() { + continue; + } + let delta = IOReportCreateSamplesDelta(s0, s1, std::ptr::null()); + CFRelease(s1); + if delta.is_null() { + continue; + } + let (found, moved) = cpu_rail_moved(delta); + CFRelease(delta); + saw_rail |= found; + advanced = moved; + } + CFRelease(s0); + match (saw_rail, advanced) { + (_, true) => CpuRail::Live, + (true, false) => CpuRail::Frozen, + (false, false) => CpuRail::Absent, + } + } + } + } + + /// In a delta sample: does a CPU energy channel exist (`CPU Energy`, or + /// `DIE__CPU Energy` on multi-die parts), and did any of them advance? + /// Decided on the raw integer, before unit conversion, so a small advance in + /// a fine unit cannot truncate to zero. + unsafe fn cpu_rail_moved(delta: CFDictionaryRef) -> (bool, bool) { + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + if key.is_null() { + return (false, false); + } + let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if arr.is_null() { + return (false, false); + } + let (mut found, mut moved) = (false, false); + for i in 0..CFArrayGetCount(arr) { + let item = CFArrayGetValueAtIndex(arr, i); + if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { + continue; + } + let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) else { + continue; + }; + if strip_die_prefix(&name).eq_ignore_ascii_case("CPU Energy") { + found = true; + moved |= IOReportSimpleGetIntegerValue(item, 0) > 0; + } + } + (found, moved) + } + unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> u64 { let key = CFStringCreateWithCString( std::ptr::null_mut(), @@ -404,23 +510,200 @@ mod ioreport { assert!(!counts_once("DIE_0_PACC0_CPU0")); } - #[test] - fn cumulative_advances_after_cpu_burn() { - let Some(c) = IoReport::open() else { - return; // not Apple Silicon - }; - let _ = c.read_uj(); - let before = c.read_uj().unwrap(); - let start = std::time::Instant::now(); - let mut acc: u64 = 0; - while start.elapsed() < std::time::Duration::from_millis(200) { - acc = acc.wrapping_add(1); - } - std::hint::black_box(acc); - let _ = c.read_uj(); - let after = c.read_uj().unwrap(); - assert!(after > before, "energy must advance: before={before} after={after}"); + /// What the independent oracle saw on this host. + #[derive(Debug, PartialEq)] + enum Oracle { + NoIoReport, + NoCpuRail, + CpuRailMoved(bool), } + + /// An oracle that shares nothing with the liveness probe but the raw FFI: + /// it reads each CPU rail's ABSOLUTE cumulative value (no delta samples), + /// matches names with `ends_with("CPU Energy")` rather than the die-prefix + /// stripping the implementation uses, and burns CPU for `budget` looking + /// for any change at all. + fn oracle_cpu_rail(budget: Duration) -> Oracle { + let Some(io) = IoReport::open() else { + return Oracle::NoIoReport; + }; + let st = io.state.lock().unwrap(); + let read = || -> Option> { + unsafe { + let smp = IOReportCreateSamples(st.sub, st.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, + ); + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + let mut vals = Vec::new(); + if !arr.is_null() { + for i in 0..CFArrayGetCount(arr) { + let it = CFArrayGetValueAtIndex(arr, i); + 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) + } + }; + let Some(v0) = read() else { + return Oracle::NoIoReport; + }; + if v0.is_empty() { + return Oracle::NoCpuRail; + } + let started = Instant::now(); + let mut acc: u64 = 0; + while started.elapsed() < budget { + let tick = Instant::now(); + while tick.elapsed() < Duration::from_millis(50) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); + if read().is_some_and(|v| v != v0) { + return Oracle::CpuRailMoved(true); + } + } + Oracle::CpuRailMoved(false) + } + + /// The property that matters: IOReport is **never** selected when its CPU + /// rail is frozen, because then it would report GPU-only energy (or zero) + /// as the energy of CPU work. Deterministic on any host — it asserts the + /// meter's decision against what the rail actually did, and there is no + /// early return that could pass without checking anything. + /// + /// 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 left + /// unasserted — and that one fails safe: estimated, never over-claimed. + #[test] + fn the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail() { + let status = crate::ioreport_status(); + let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); + let oracle = oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3); + // Recorded so a CI log says which branch below was actually asserted. + eprintln!("ioreport liveness: status={status:?} oracle={oracle:?} selected={selected}"); + match oracle { + Oracle::NoIoReport => { + assert_eq!(status, crate::IoReportStatus::Unavailable); + assert!(!selected); + } + Oracle::NoCpuRail => { + assert_eq!(status, crate::IoReportStatus::CpuRailAbsent); + assert!(!selected, "selected an IOReport rung that has no CPU rail"); + } + Oracle::CpuRailMoved(false) => { + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen); + assert!( + !selected, + "IOReport selected although its CPU rail did not move under {:?} of CPU burn \ + — it would report GPU-only energy, or zero, as the energy of CPU work", + crate::CPU_RAIL_LIVENESS_BUDGET * 3 + ); + } + Oracle::CpuRailMoved(true) => { + // Live rail: the probe had 1/3 of the oracle's time. If it saw the + // move, the rung must be selected; if not, declining is the safe side. + if status == crate::IoReportStatus::Live { + assert!(selected, "liveness probe said live but detect() declined"); + } + } + } + } + + /// The converse: the probe never reports a live rail the oracle cannot see + /// move. A false Live is the dangerous direction, so it is asserted on its own. + #[test] + fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { + if crate::ioreport_status() == crate::IoReportStatus::Live { + assert_eq!( + oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3), + Oracle::CpuRailMoved(true), + "probe said Live, oracle saw the CPU rail hold its value" + ); + } + } + + /// When the rung is selected, a CPU burn is metered through a rail that + /// moves — so the reading is energy, not a zero or a GPU-only figure that + /// merely happened to advance. + #[test] + fn a_selected_ioreport_meter_meters_cpu_work() { + let meter = crate::Meter::detect(); + if let crate::Meter::IoReport(io) = &meter { + assert_eq!(io.probe_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET), CpuRail::Live); + let (_, e) = meter.measure(|| { + let t = Instant::now(); + let mut acc: u64 = 0; + while t.elapsed() < Duration::from_millis(200) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc) + }); + assert!(e.uj() > 0, "a live CPU rail metered 200 ms of CPU work as zero"); + } else { + assert_ne!(crate::ioreport_status(), crate::IoReportStatus::Live, + "status Live but detect() did not select the IOReport rung"); + } + } + } +} + +/// Why the IOReport rung was or was not selected on this host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoReportStatus { + /// No IOReport subscription could be opened (not macOS, or not Apple Silicon). + Unavailable, + /// A subscription opened, but no `CPU Energy` rail exists in the Energy Model + /// group — an unfamiliar channel naming. Never selected. + CpuRailAbsent, + /// The CPU energy rail exists but did not advance while CPU was burned. The + /// platform is not exposing live CPU energy to this process, so selecting the + /// rung would report GPU-only energy, or zero, as the energy of CPU work. + /// Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. + CpuRailFrozen, + /// The CPU energy rail advances under load; the rung is selected. + Live, +} + +/// How long the liveness probe burns CPU before concluding the CPU rail is +/// frozen. Where Energy Model rails are live they update within a few hundred +/// milliseconds (the M5 Max GPU rail moves inside every 200 ms window), so one +/// second is generous. A slower live rail fails safe — it is estimated, never +/// over-claimed. Paid once per process; see [`ioreport_status`]. +#[cfg(target_os = "macos")] +pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); + +/// This host's IOReport status, probed once per process and cached. +/// +/// [`Meter::detect`] selects the IOReport rung only when this is +/// [`IoReportStatus::Live`]. Exposed so a caller, or a receipt, can say *why* +/// an Apple Silicon host is being metered by the time×power model instead. +pub fn ioreport_status() -> IoReportStatus { + #[cfg(target_os = "macos")] + { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(|| match ioreport::IoReport::open() { + None => IoReportStatus::Unavailable, + Some(io) => match io.probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET) { + ioreport::CpuRail::Live => IoReportStatus::Live, + ioreport::CpuRail::Frozen => IoReportStatus::CpuRailFrozen, + ioreport::CpuRail::Absent => IoReportStatus::CpuRailAbsent, + }, + }) + } + #[cfg(not(target_os = "macos"))] + { + IoReportStatus::Unavailable } } @@ -454,9 +737,13 @@ impl Meter { if let Some(n) = Nvml::open() { return Meter::Nvml(Box::new(n)); } + // Opening a subscription is not enough: the CPU rail must be live, or + // the rung reports GPU-only energy (or zero) as the energy of CPU work. #[cfg(target_os = "macos")] - if let Some(io) = ioreport::IoReport::open() { - return Meter::IoReport(io); + if ioreport_status() == IoReportStatus::Live { + if let Some(io) = ioreport::IoReport::open() { + return Meter::IoReport(io); + } } Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS } } -- 2.54.0 (Apple Git-157) From 2518f0d70a716e05d87ebadedddd5bebe40f81fd Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 01:27:56 -0400 Subject: [PATCH 02/10] jcp-energy: IOReport is live only when one CPU rail keeps advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The liveness gate added in dfec5d44 enforces the contract at selection, but it had three defects. Each was measured on the M5 Max and the M3 Ultra (both macOS 27.0.1). 1. One advance counted as Live. For an unprivileged reader the CPU rails are not permanently frozen: they are refreshed in rare batches. On the M3 Ultra both dies held for at least 22 minutes, then stepped +1,344,869 and +1,391,637 mJ in a single 100 ms sample. On the M5 Max the rail went from 480,083,160 to 541,893,638 mJ in about 17 minutes, then did not change across 13,614 samples in a 25-minute watch. A probe whose window straddles a refresh sees one jump and calls the rail Live; this happened on the M5 Max at ~10:46 on 2026-09-29. The rung then stays selected for the life of the process, and each later ~1.3 kJ refresh lands whole in whichever metered span straddles it, tagged ModelBased. Both machines have been refreshed again since: today the M5 Max rail reads 1,308,503,606 mJ and the M3 Ultra's read 21,170,718 / 23,300,102 mJ. Under load they still do not move. 2. The probe sampled the counter's all-channel subscription. Its doc said "re-sampling every ~25 ms", but one IOReportCreateSamples over every channel took 130-160 ms on the M5 Max today (0.4-0.8 s on 2026-09-29) and ~135 ms on the M3 Ultra. Instrumented: the probe took 7 samples in ~1.09 s on the M5 Max and burned CPU for only 175 ms of that; on the M3 Ultra, 6 samples and 150 ms. It also compared every sample with the first one instead of with the previous one. 3. The oracle burned CPU on its sampling thread, between samples, so most of its "three times the budget" window was not under load. It burned for 0.90-0.95 s of ~3.1 s on the M5 Max and 0.80 s on the M3 Ultra with today's ~130 ms samples; with 0.4-0.8 s samples that falls to ~250 ms. It also returned CpuRailMoved(true) on any change in the absolute value, and a single refresh is a change. The fix ports the family liveness rule: RailWatch from jouleclaw-energy (b9d7e945) and the group-only probe subscription from eoc-meter (c6378629). The code is copied in, so the crate gains no dependency. - The probe opens its own subscription to the "Energy Model" group only (IOReportCopyChannelsInGroup, then IOReportCreateSubscription). One sample costs 6-12 ms on the M5 Max and ~6.6 ms on the M3 Ultra, and it returns the same absolute CPU values as the all-channel subscription. The probe now burns 25 ms, samples, and repeats, which gives 29-31 intervals in its one-second budget on both machines. The counter's own read path, over all channels, is unchanged. - Liveness is decided per CPU rail: a channel whose name, after any DIE__ prefix is stripped, equals "CPU Energy" (case-insensitive). The test is the raw integer delta between consecutive samples, never a summed total. Live requires the same rail to advance in at least two separate intervals (LIVE_RAIL_ADVANCES). One jump is Frozen, and so is one jump on each die in different intervals. - The probe keeps sampling until the rail is Live, or until the budget has elapsed and at least two intervals were observed. It never runs past five budgets. - The outcomes are unchanged: Live, CpuRailFrozen, CpuRailAbsent and Unavailable. Unavailable now also covers a subscription that yields no sample. The result is still cached once per process in a OnceLock, and any outcome except Live declines the rung. If the Energy Model group cannot be subscribed, opening the all-channel subscription tells CpuRailAbsent from Unavailable. - The rule itself is RailWatch, a pure struct with no FFI. The public API is unchanged: IoReportStatus and its variants, ioreport_status(), CPU_RAIL_LIVENESS_BUDGET (1 s), Meter, and the grades. One item is gone: IoReport::probe_cpu_rail, a public method on an unnameable type that dfec5d44 added and nothing outside the crate used. The probe is now a free function that takes its own subscription. Docs made false by the change are corrected: - the crate doc's IOReport rung, which now says what Live means and mentions the refresh; - the probe doc ("every ~25 ms" was only achievable on the group subscription); - the four IoReportStatus variant docs; - CPU_RAIL_LIVENESS_BUDGET, which now describes the intervals and the five-budget cap instead of "updates within a few hundred ms"; - the cost described in the ioreport_status() doc; - the detect() comment. Tests. There are now 17 lib tests on macOS, up from 10, all in mod ioreport::tests except the new off-macOS one. - Rule tests, which need no hardware: - a_single_jump_is_a_batch_refresh_not_a_live_rail, including the M3 Ultra refresh with both dies in one interval - one_jump_on_each_die_in_different_intervals_is_not_a_live_rail - two_advances_of_the_same_rail_are_live - no_interval_is_no_verdict_and_no_cpu_rail_is_absent - the_probe_samples_until_live_or_long_enough_to_tell, including the five-budget cap - only_the_cpu_energy_aggregate_is_a_cpu_rail - The oracle opens its own all-channel subscription and reads absolute values, matching names with ends_with("CPU Energy") in any group. A helper thread burns CPU for the whole 3 s window while the test thread reads. It reports CpuRailMoved(true) only if one rail's value changed at two or more separate reads, using its own literal threshold rather than the crate's constant. It also counts changes in GPU Energy, which shows its reads are fresh: 16-21 of 16-21 reads on the M5 Max, 17-18 of 19-20 on the M3 Ultra. - the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail keeps its name. On every oracle branch it asserts the selection first, then the status. It prints one "ioreport liveness: status=..." line that includes the oracle's reads and its changes per rail. - the_liveness_probe_never_reports_a_frozen_rail_as_live now runs the probe itself, uncached, on a fresh group subscription, beside the cached status. It asserts on every oracle outcome, not only on Live, and prints how long the probe took and how many intervals it saw. - the_liveness_status_is_probed_once_and_cached is new. - a_selected_ioreport_meter_meters_cpu_work now reads the counter directly, before and after a 200 ms burn. It used to go through Meter::measure, whose time x power fallback made e.uj() > 0 true whatever the counter did. - ioreport_is_unavailable_off_macos is new and runs only off macOS. Observed with the same test binary on both machines, three runs each: M5 Max: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 21 reads over 3s with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [21]) probe: took=1.10-1.11s intervals=29-31 test result: ok. 17 passed; 0 failed M3 Ultra: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 20 reads over 3s with a helper thread burning CPU; changes per CPU rail [0, 0], per GPU rail [17]), which exercises the DIE_0_/DIE_1_ path probe: took=1.08-1.09s intervals=31 test result: ok. 17 passed; 0 failed Falsified on the M5 Max. Each mutation was reverted with git checkout. - F-rule, LIVE_RAIL_ADVANCES = 1: four rule tests fail. For example, "one jump counted as a live rail: RailWatch { intervals: 5, rails: [("CPU Energy", 1)] }", "one jump per die counted as a live rail", "one interval cannot show two advances" and "one advance is not yet live". - F1, probe_status always returns Live: the frozen-rail test fails with "IOReport selected (status=Live) although its CPU rail did not keep moving under 3s of CPU burn — it would report GPU-only energy, zero, or a batch refresh as the energy of CPU work". The probe test and a_selected_ioreport_meter_meters_cpu_work fail too, the latter with "IOReport selected but a fresh probe says Some(Frozen)". - F2, detect() ungated as on main: the frozen-rail test fails with "IOReport selected (status=CpuRailFrozen) although ...". - F-sum, liveness decided on the summed Energy Model total: through the GPU rail the probe says Live after 2-3 intervals (0.16-0.18 s). The frozen-rail test and the probe test fail in both of two runs. The same happens on the M3 Ultra, where GPU Energy changed at 16-17 of 18 reads today. A variant that treats every Energy Model channel as a rail fails the same way. Dependants on the M5 Max all pass: jcp-cli (0 tests), jcp-conformance (1 lib test and 5 pack tests) and jcp-intercept (8). So does the conformance suite: "C9 energy provenance honesty" passes and it ends "all checks passed". On the M3 Ultra the jcp-conformance lib, jcp-cli and jcp-intercept test binaries and the jcp-conformance suite also pass (exit 0). The pack test was not run there: it reads the pack from its compile-time path and does not touch this crate. clippy -p jcp-energy --no-deps, lib and --tests, for aarch64-apple-darwin and x86_64-unknown-linux-gnu: no warnings. Not done here, deliberately: - The counter's own reads still sample every channel, 130 ms to 0.8 s a read on the M5 Max. A group-only subscription returns the same values in ~6-12 ms, but switching changes what every read costs, so it belongs in a separate change. - counts_once admits CPU sub-rails (PACC_*, MCPM*, ...) beside CPU Energy. That is a double count once the rail is live. - A rail that stops tracking after selection is not caught: the probe runs once per process. - Reporting the live GPU rail as an explicitly partial reading, and deciding which grade IOReport readings carry, are separate decisions. Both need a wire-format change. - No live CPU rail exists on these hosts, so every Live-branch assertion has been exercised only by mutation. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 828 +++++++++++++++++++----- 1 file changed, 653 insertions(+), 175 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index a4737298c..16eca5cbc 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -16,11 +16,14 @@ //! Apple's IOReport C API (no sudo, no external binary). A real reading but a //! vendor model, so `Provenance::ModelBased` — strictly better than the //! wall-clock model below. **Selected only when its CPU energy rail is live** -//! ([`ioreport_status`]): a subscription can open while every CPU rail holds a -//! frozen value, and then the summed total advances only through the GPU rail — -//! GPU energy reported as the energy of CPU work. When the CPU rail is frozen -//! the platform is not exposing CPU energy, so by the rule above this rung is -//! declined and the time×power model estimates instead. +//! ([`ioreport_status`]): a subscription can open while every CPU rail holds +//! its value, refreshed only in rare batches, and then the summed total +//! advances through the GPU rail — GPU energy reported as the energy of CPU +//! work — or by a whole refresh at once. Live means the same CPU rail advanced +//! in at least two separate sample intervals while the probe burned CPU; a +//! single advance is a batch refresh, not a live rail. Otherwise the platform +//! is not exposing CPU energy, so by the rule above this rung is declined and +//! the time×power model estimates instead. //! 4. **Time × power model** — wall-clock duration times a calibrated active //! power, `Provenance::ModelBased`. Always available. //! 5. **Constant estimator** — a fixed per-operation figure, @@ -142,6 +145,8 @@ mod ioreport { use std::sync::Mutex; use std::time::{Duration, Instant}; + use crate::IoReportStatus; + type CFTypeRef = *const c_void; type CFStringRef = *const c_void; type CFArrayRef = *const c_void; @@ -152,6 +157,13 @@ mod ioreport { unsafe extern "C" { fn IOReportCopyAllChannels(a: u64, b: u64) -> CFMutableDictionaryRef; + fn IOReportCopyChannelsInGroup( + group: CFStringRef, + subgroup: CFStringRef, + a: u64, + b: u64, + c: u64, + ) -> CFMutableDictionaryRef; fn IOReportCreateSubscription( allowed: *const c_void, desired_channels: CFMutableDictionaryRef, @@ -295,90 +307,239 @@ mod ioreport { } } - /// Whether this host's CPU energy rail is live, frozen, or not present. + // ── CPU-rail liveness ─────────────────────────────────────────────────── + + /// 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 the same CPU rail must advance + /// before it counts as live. One is not enough: see [`RailWatch`]. + const LIVE_RAIL_ADVANCES: u32 = 2; + + /// However slow IOReport is, the probe stops after this many budgets. + const PROBE_BUDGET_CAP: u32 = 5; + + /// What the liveness probe saw of the CPU energy rail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] - pub enum CpuRail { - /// The CPU energy rail advanced while this thread burned CPU. + enum CpuRail { + /// The same CPU energy rail advanced in at least [`LIVE_RAIL_ADVANCES`] + /// separate sample intervals while the probing thread burned CPU. Live, - /// The rail exists but held its value across the whole burn. + /// A CPU energy rail exists, but none advanced in that many intervals: + /// it held, or moved in a single step (a batch refresh). Frozen, /// No `CPU Energy` channel exists in the Energy Model group. Absent, } - impl IoReport { - /// Burn CPU on this thread for up to `budget`, re-sampling every ~25 ms, - /// and report whether the CPU energy rail **itself** advanced. - /// - /// The summed total is not a liveness signal. Measured on an M5 Max and an - /// M3 Ultra, both on macOS 27.0.1: every CPU rail in the Energy Model group - /// (`CPU Energy`, `PCPU`, `MCPU*`, `PACC_*`, `DIE_n_CPU Energy`) held the - /// same cumulative value across minutes of full CPU load, while on the M5 - /// Max `GPU Energy` kept advancing. A total that moves therefore proves - /// only that something drew on screen. Liveness is decided on the CPU rail - /// alone. Reads its own samples, so the cumulative reading is untouched. - pub fn probe_cpu_rail(&self, budget: Duration) -> CpuRail { - let Ok(state) = self.state.lock() else { - return CpuRail::Absent; - }; - unsafe { - let s0 = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); - if s0.is_null() { - return CpuRail::Absent; - } - let started = Instant::now(); - let mut acc: u64 = 0; - let mut saw_rail = false; - let mut advanced = false; - while !advanced && started.elapsed() < budget { - let tick = Instant::now(); - while tick.elapsed() < Duration::from_millis(25) { - acc = acc.wrapping_add(1); - } - std::hint::black_box(acc); - let s1 = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); - if s1.is_null() { - continue; - } - let delta = IOReportCreateSamplesDelta(s0, s1, std::ptr::null()); - CFRelease(s1); - if delta.is_null() { - continue; - } - let (found, moved) = cpu_rail_moved(delta); - CFRelease(delta); - saw_rail |= found; - advanced = moved; - } - CFRelease(s0); - match (saw_rail, advanced) { - (_, true) => CpuRail::Live, - (true, false) => CpuRail::Frozen, - (false, false) => CpuRail::Absent, + /// The liveness rule, 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. One advance is not + /// enough, because on macOS 27.0.1 an unprivileged reader's CPU rails are + /// not permanently frozen: they are refreshed in rare batches. On an M3 + /// Ultra both dies' rails held for at least 22 minutes, stepped +1,344,869 + /// and +1,391,637 mJ in one 100 ms sample, then held again; an M5 Max's + /// `CPU Energy` did not change in 13,614 samples over 25 minutes, yet had + /// moved from 480,083,160 to 541,893,638 mJ earlier that hour. A probe + /// whose window straddles a refresh sees one large jump. Called live, it + /// would select the rung for the life of the process, and every later + /// ~1.3 kJ refresh would be attributed whole to whichever metered span + /// straddled it. A live rail advances in nearly every interval (the M5 Max + /// GPU rail changes every ~28 ms), so it passes in its first two. Counting + /// per rail keeps one jump on each die 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 that + /// rail advanced over the interval. + 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 proves live, or until `budget` has + /// elapsed **and** enough intervals were observed for a live rail to + /// have shown [`LIVE_RAIL_ADVANCES`] advances (one slow IOReport sample + /// must not make a live rail look frozen) — and never past + /// [`PROBE_BUDGET_CAP`] budgets. + fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { + !self.is_live() + && elapsed < budget * PROBE_BUDGET_CAP + && (elapsed < budget || self.intervals < LIVE_RAIL_ADVANCES) + } + + /// 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) + } + } } - /// In a delta sample: does a CPU energy channel exist (`CPU Energy`, or - /// `DIE__CPU Energy` on multi-die parts), and did any of them advance? - /// Decided on the raw integer, before unit conversion, so a small advance in - /// a fine unit cannot truncate to zero. - unsafe fn cpu_rail_moved(delta: CFDictionaryRef) -> (bool, bool) { + /// The liveness probe's own subscription, to the Energy Model group only. + /// + /// The counter's subscription covers every channel (~11,860 on an M5 Max), + /// and one sample of it has been measured at 0.13–0.8 s on an M5 Max and + /// ~0.14 s on an M3 Ultra, mostly spent waiting rather than burning CPU. A + /// probe on it re-sampled every ~150 ms at best, not after every ~25 ms of + /// burn. The group alone samples in ~6–12 ms on both, and carries the same + /// absolute CPU rail values. + struct EnergyModelSubscription { + sub: IOReportSubscriptionRef, + channels: CFMutableDictionaryRef, + } + + impl Drop for EnergyModelSubscription { + fn drop(&mut self) { + unsafe { + CFRelease(self.sub); + CFRelease(self.channels as CFTypeRef); + } + } + } + + impl EnergyModelSubscription { + /// `None` when IOReport cannot open, or has no Energy Model group. + fn open() -> 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 channels: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription( + std::ptr::null(), + desired, + &mut channels as *mut _, + 0, + std::ptr::null(), + ); + CFRelease(desired as CFTypeRef); + if sub.is_null() || channels.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !channels.is_null() { + CFRelease(channels as CFTypeRef); + } + return None; + } + Some(Self { sub, channels }) + } + } + } + + /// Burn CPU on this thread, re-sampling the Energy Model group after every + /// [`PROBE_TICK`] of burn, and watch each CPU energy rail **itself**, per + /// consecutive sample interval, by the rule in [`RailWatch`]. A watch with + /// no interval means 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. The probe takes its own samples on + /// its own subscription, so no counter's cumulative reading is touched. + fn probe_cpu_rail(sub: &EnergyModelSubscription, budget: Duration) -> RailWatch { + let mut watch = RailWatch::default(); + unsafe { + let mut prev = IOReportCreateSamples(sub.sub, sub.channels, std::ptr::null()); + if prev.is_null() { + return watch; + } + let started = Instant::now(); + while watch.wants_more(started.elapsed(), budget) { + burn(PROBE_TICK); + let cur = IOReportCreateSamples(sub.sub, sub.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 + } + + /// This host's status from one probe on a fresh Energy Model subscription. + /// Backs the cached [`crate::ioreport_status`]. + pub(crate) fn probe_status(budget: Duration) -> IoReportStatus { + let Some(sub) = EnergyModelSubscription::open() else { + // No Energy Model subscription. Tell "no IOReport" from "no such + // group" by opening the subscription the counter itself uses. + return match IoReport::open() { + Some(_) => IoReportStatus::CpuRailAbsent, + None => IoReportStatus::Unavailable, + }; + }; + match probe_cpu_rail(&sub, budget).verdict() { + None => IoReportStatus::Unavailable, + Some(CpuRail::Live) => IoReportStatus::Live, + Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + } + } + + /// In a samples-delta dictionary: every CPU energy rail 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. + unsafe fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { + let mut rails = Vec::new(); let key = CFStringCreateWithCString( std::ptr::null_mut(), c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); if key.is_null() { - return (false, false); + return rails; } let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); if arr.is_null() { - return (false, false); + return rails; } - let (mut found, mut moved) = (false, false); for i in 0..CFArrayGetCount(arr) { let item = CFArrayGetValueAtIndex(arr, i); if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { @@ -387,12 +548,28 @@ mod ioreport { let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) else { continue; }; - if strip_die_prefix(&name).eq_ignore_ascii_case("CPU Energy") { - found = true; - moved |= IOReportSimpleGetIntegerValue(item, 0) > 0; + if is_cpu_energy_rail(&name) { + let advanced = IOReportSimpleGetIntegerValue(item, 0) > 0; + rails.push((name, advanced)); } } - (found, moved) + rails + } + + /// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally + /// `DIE__`-prefixed, compared case-insensitively. + fn is_cpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") + } + + /// 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); } unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> u64 { @@ -497,6 +674,7 @@ mod ioreport { #[cfg(test)] mod tests { use super::*; + use std::sync::atomic::{AtomicBool, Ordering}; #[test] fn energy_to_uj_scales_by_unit() { @@ -510,6 +688,114 @@ mod ioreport { assert!(!counts_once("DIE_0_PACC0_CPU0")); } + #[test] + fn only_the_cpu_energy_aggregate_is_a_cpu_rail() { + assert!(is_cpu_energy_rail("CPU Energy")); + assert!(is_cpu_energy_rail("cpu energy")); + assert!(is_cpu_energy_rail("DIE_0_CPU Energy")); + assert!(is_cpu_energy_rail("DIE_1_CPU Energy")); + assert!(is_cpu_energy_rail("DIE_12_CPU Energy")); + assert!(!is_cpu_energy_rail("GPU Energy")); + assert!(!is_cpu_energy_rail("DIE_0_GPU Energy")); + assert!(!is_cpu_energy_rail("ANE Energy")); + assert!(!is_cpu_energy_rail("PCPU")); + assert!(!is_cpu_energy_rail("MCPU0")); + assert!(!is_cpu_energy_rail("PACC_0")); + assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); + } + + // ── The liveness rule, without hardware ───────────────────────────── + + /// A rail that moves in one step and then holds is a batch refresh + /// caught mid-probe, not a live rail, however large the step — including + /// the refresh observed on an M3 Ultra, both dies in the same interval. + #[test] + fn a_single_jump_is_a_batch_refresh_not_a_live_rail() { + let mut w = RailWatch::default(); + for advanced in [false, true, false, false, false] { + w.observe([("CPU Energy", advanced)]); + } + assert!(!w.is_live(), "one jump counted as a live rail: {w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + + let mut w = RailWatch::default(); + for both in [false, false, true, false] { + w.observe([("DIE_0_CPU Energy", both), ("DIE_1_CPU Energy", both)]); + } + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one refresh of both dies counted as live: {w:?}"); + } + + /// Two dies each refreshed once, in different intervals, do not add up + /// to a live rail: advances are counted per rail. + #[test] + fn one_jump_on_each_die_in_different_intervals_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!(!w.is_live(), "one jump per die counted as a live rail: {w:?}"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + } + + /// The same rail advancing in two separate intervals is live — on either + /// die, and whether or not the intervals are consecutive. + #[test] + fn two_advances_of_the_same_rail_are_live() { + let mut w = RailWatch::default(); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + assert!(!w.is_live(), "one advance is not yet live"); + w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); + assert!(w.is_live()); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + + let mut w = RailWatch::default(); + for advanced in [true, false, false, true] { + w.observe([("CPU Energy", advanced)]); + } + assert_eq!(w.verdict(), Some(CpuRail::Live)); + } + + /// No interval observed at all is no verdict (the caller reports + /// IOReport as unavailable); intervals with no CPU rail are `Absent`. + #[test] + fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { + let mut w = RailWatch::default(); + assert_eq!(w.verdict(), None); + w.observe([]); + w.observe([]); + assert_eq!(w.verdict(), Some(CpuRail::Absent)); + } + + /// The probe keeps sampling within its budget until the rail proves + /// live; past the budget only until a live rail could have advanced + /// twice; and never past five budgets. + #[test] + fn the_probe_samples_until_live_or_long_enough_to_tell() { + let budget = Duration::from_millis(1000); + let mut w = RailWatch::default(); + assert!(w.wants_more(Duration::ZERO, budget), "nothing observed yet"); + // Slow samples: past the budget with fewer than two intervals, go on... + assert!(w.wants_more(budget * 2, budget), "no interval yet, past the budget"); + w.observe([("CPU Energy", false)]); + assert!(w.wants_more(budget * 2, budget), "one interval cannot show two advances"); + assert!(w.wants_more(budget * 5 - Duration::from_millis(1), budget)); + // ...but never past five budgets. + assert!(!w.wants_more(budget * 5, budget), "hard cap: five budgets"); + assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap with no interval"); + w.observe([("CPU Energy", false)]); + assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); + assert!(!w.wants_more(budget, budget), "budget spent, two intervals: stop"); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + // A rail that proves live stops the probe at once. + let mut live = RailWatch::default(); + live.observe([("CPU Energy", true)]); + assert!(live.wants_more(Duration::ZERO, budget), "one advance: not yet live, keep watching"); + live.observe([("CPU Energy", true)]); + assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); + } + + // ── Against the hardware ──────────────────────────────────────────── + /// What the independent oracle saw on this host. #[derive(Debug, PartialEq)] enum Oracle { @@ -518,141 +804,320 @@ mod ioreport { CpuRailMoved(bool), } - /// An oracle that shares nothing with the liveness probe but the raw FFI: - /// it reads each CPU rail's ABSOLUTE cumulative value (no delta samples), - /// matches names with `ends_with("CPU Energy")` rather than the die-prefix - /// stripping the implementation uses, and burns CPU for `budget` looking - /// for any change at all. - fn oracle_cpu_rail(budget: Duration) -> Oracle { - let Some(io) = IoReport::open() else { - return Oracle::NoIoReport; - }; - let st = io.state.lock().unwrap(); - let read = || -> Option> { - unsafe { - let smp = IOReportCreateSamples(st.sub, st.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, - ); + /// One oracle run: its verdict, and what it read to reach it. + #[derive(Debug)] + struct OracleRun { + verdict: Oracle, + /// Reads taken after the first. + reads: u32, + /// Per CPU rail, at how many reads its absolute value had changed. + cpu_changes: Vec, + /// The same for `GPU Energy`: evidence the reads are fresh samples. + gpu_changes: Vec, + } + + impl OracleRun { + fn bare(verdict: Oracle) -> Self { + Self { verdict, reads: 0, cpu_changes: Vec::new(), gpu_changes: Vec::new() } + } + } + + /// The ABSOLUTE cumulative values of every channel whose name ends with + /// `CPU Energy`, and of every one ending with `GPU Energy`, in any + /// group, from one plain sample (no delta). + fn oracle_read( + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + ) -> Option<(Vec, Vec)> { + 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 cpu, mut gpu) = (Vec::new(), Vec::new()); + if !key.is_null() { let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); - let mut vals = Vec::new(); 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); + cpu.push(IOReportSimpleGetIntegerValue(it, 0) as i64); + } else if name.ends_with("GPU Energy") { + gpu.push(IOReportSimpleGetIntegerValue(it, 0) as i64); } } } - CFRelease(smp); - Some(vals) } - }; - let Some(v0) = read() else { - return Oracle::NoIoReport; - }; - if v0.is_empty() { - return Oracle::NoCpuRail; + CFRelease(smp); + Some((cpu, gpu)) } - let started = Instant::now(); - let mut acc: u64 = 0; - while started.elapsed() < budget { - let tick = Instant::now(); - while tick.elapsed() < Duration::from_millis(50) { - acc = acc.wrapping_add(1); + } + + /// Read until `window` has elapsed since `started`, counting per rail at + /// how many reads the absolute value changed. Stops early once one CPU + /// rail has changed at two separate reads. + fn oracle_watch( + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + started: Instant, + window: Duration, + ) -> OracleRun { + let Some((mut cpu_prev, mut gpu_prev)) = oracle_read(sub, chans) else { + return OracleRun::bare(Oracle::NoIoReport); + }; + if cpu_prev.is_empty() { + return OracleRun::bare(Oracle::NoCpuRail); + } + let mut run = OracleRun { + verdict: Oracle::CpuRailMoved(false), + reads: 0, + cpu_changes: vec![0; cpu_prev.len()], + gpu_changes: vec![0; gpu_prev.len()], + }; + while started.elapsed() < window { + let Some((cpu, gpu)) = oracle_read(sub, chans) else { + continue; + }; + run.reads += 1; + for (n, (now, before)) in run.cpu_changes.iter_mut().zip(cpu.iter().zip(&cpu_prev)) { + *n += u32::from(now != before); } - std::hint::black_box(acc); - if read().is_some_and(|v| v != v0) { - return Oracle::CpuRailMoved(true); + for (n, (now, before)) in run.gpu_changes.iter_mut().zip(gpu.iter().zip(&gpu_prev)) { + *n += u32::from(now != before); + } + (cpu_prev, gpu_prev) = (cpu, gpu); + if run.cpu_changes.iter().any(|&n| n >= 2) { + run.verdict = Oracle::CpuRailMoved(true); + break; } } - Oracle::CpuRailMoved(false) + run + } + + /// An oracle that shares nothing with the liveness probe but the raw FFI + /// declarations: it opens its own subscription over every channel (not + /// the probe's Energy Model group), reads ABSOLUTE cumulative values (no + /// delta samples), matches names with `ends_with("CPU Energy")` in any + /// group (not the implementation's group filter and die-prefix + /// stripping), and keeps CPU busy on a HELPER thread for the whole + /// `window`, so the window is under load even while an all-channel sample + /// blocks this thread. + /// + /// `CpuRailMoved(true)` only if one rail's absolute value changed at two + /// or more separate reads. A live rail changes at nearly every read; a + /// batch-refreshed one at most once in a window this short, and one jump + /// is not a live reading. 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) -> OracleRun { + let (sub, chans) = unsafe { + let all = IOReportCopyAllChannels(0, 0); + if all.is_null() { + return OracleRun::bare(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 OracleRun::bare(Oracle::NoIoReport); + } + (sub, chans) + }; + let stop = AtomicBool::new(false); + let run = std::thread::scope(|s| { + s.spawn(|| { + let mut acc: u64 = 0; + while !stop.load(Ordering::Relaxed) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); + }); + let run = oracle_watch(sub, chans, Instant::now(), window); + stop.store(true, Ordering::Relaxed); + run + }); + unsafe { + CFRelease(sub); + CFRelease(chans as CFTypeRef); + } + run } /// The property that matters: IOReport is **never** selected when its CPU - /// rail is frozen, because then it would report GPU-only energy (or zero) - /// as the energy of CPU work. Deterministic on any host — it asserts the - /// meter's decision against what the rail actually did, and there is no - /// early return that could pass without checking anything. + /// rail is not live, because then it would report GPU-only energy, zero, + /// or a whole batch refresh as the energy of CPU work. Deterministic on + /// any host — it asserts the meter's decision against what the rail + /// actually did, and every branch asserts; 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 left - /// unasserted — and that one fails safe: estimated, never over-claimed. + /// 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 refresh landing in both + /// windows does not rescue a probe that called it live: the oracle counts + /// it as one change, not a moving rail. #[test] fn the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail() { let status = crate::ioreport_status(); let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); - let oracle = oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3); - // Recorded so a CI log says which branch below was actually asserted. - eprintln!("ioreport liveness: status={status:?} oracle={oracle:?} selected={selected}"); - match oracle { + let window = crate::CPU_RAIL_LIVENESS_BUDGET * 3; + let run = oracle_cpu_rail(window); + // One line, so a CI log says which branch below was actually asserted. + eprintln!( + "ioreport liveness: status={status:?} oracle={:?} selected={selected} \ + (oracle: {} reads over {window:?} with a helper thread burning CPU; \ + changes per CPU rail {:?}, per GPU rail {:?})", + run.verdict, run.reads, run.cpu_changes, run.gpu_changes + ); + match run.verdict { Oracle::NoIoReport => { + assert!(!selected, "IOReport selected (status={status:?}) but no subscription opens"); assert_eq!(status, crate::IoReportStatus::Unavailable); - assert!(!selected); } Oracle::NoCpuRail => { + assert!(!selected, "IOReport selected (status={status:?}) but it has no CPU rail"); assert_eq!(status, crate::IoReportStatus::CpuRailAbsent); - assert!(!selected, "selected an IOReport rung that has no CPU rail"); } Oracle::CpuRailMoved(false) => { - assert_eq!(status, crate::IoReportStatus::CpuRailFrozen); assert!( !selected, - "IOReport selected although its CPU rail did not move under {:?} of CPU burn \ - — it would report GPU-only energy, or zero, as the energy of CPU work", - crate::CPU_RAIL_LIVENESS_BUDGET * 3 + "IOReport selected (status={status:?}) although its CPU rail did not keep moving \ + under {window:?} of CPU burn — it would report GPU-only energy, zero, or a batch \ + refresh as the energy of CPU work" + ); + assert_eq!( + status, + crate::IoReportStatus::CpuRailFrozen, + "the oracle did not see the CPU rail keep moving under {window:?} of CPU burn" ); } Oracle::CpuRailMoved(true) => { - // Live rail: the probe had 1/3 of the oracle's time. If it saw the - // move, the rung must be selected; if not, declining is the safe side. - if status == crate::IoReportStatus::Live { - assert!(selected, "liveness probe said live but detect() declined"); - } + // A live rail. The probe had a third of the oracle's time: if it + // saw two advances the rung must be selected; if not, declining + // is the safe side. + assert!( + matches!(status, crate::IoReportStatus::Live | crate::IoReportStatus::CpuRailFrozen), + "the oracle saw a CPU rail keep moving but the status is {status:?}" + ); + assert_eq!(selected, status == crate::IoReportStatus::Live, "status={status:?}"); } } } - /// The converse: the probe never reports a live rail the oracle cannot see - /// move. A false Live is the dangerous direction, so it is asserted on its own. + /// The dangerous direction on its own: the probe never calls a rail Live + /// that the oracle cannot see keep moving. Runs the probe itself, + /// uncached, on a fresh Energy Model subscription, beside the cached + /// status `detect()` is gated on, and checks on every outcome that the + /// two agree with the oracle about what exists. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { - if crate::ioreport_status() == crate::IoReportStatus::Live { + let t = Instant::now(); + let watch = EnergyModelSubscription::open() + .map(|sub| probe_cpu_rail(&sub, crate::CPU_RAIL_LIVENESS_BUDGET)); + let took = t.elapsed(); + let probed = watch.as_ref().map(RailWatch::verdict); + let cached = crate::ioreport_status(); + let run = oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3); + eprintln!( + "ioreport probe: probed={probed:?} cached={cached:?} took={took:?} intervals={} \ + advances per CPU rail {:?}; oracle={:?}", + watch.as_ref().map_or(0, |w| w.intervals), + watch.as_ref().map(|w| w.rails.clone()).unwrap_or_default(), + run.verdict + ); + if probed == Some(Some(CpuRail::Live)) || cached == crate::IoReportStatus::Live { assert_eq!( - oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3), + run.verdict, Oracle::CpuRailMoved(true), - "probe said Live, oracle saw the CPU rail hold its value" + "the probe said Live (probed={probed:?}, cached={cached:?}), the oracle did not see \ + the CPU rail keep moving" ); } + match run.verdict { + Oracle::NoIoReport => { + assert!(matches!(probed, None | Some(None)), "the oracle could not open IOReport, the probe did"); + assert_eq!(cached, crate::IoReportStatus::Unavailable); + } + Oracle::NoCpuRail => assert!( + matches!(probed, None | Some(Some(CpuRail::Absent))) + && cached == crate::IoReportStatus::CpuRailAbsent, + "the oracle found no CPU rail, the probe reported {probed:?} (cached={cached:?})" + ), + Oracle::CpuRailMoved(false) => assert!( + probed == Some(Some(CpuRail::Frozen)) && cached == crate::IoReportStatus::CpuRailFrozen, + "the CPU rail did not keep moving under the oracle's burn, so the probe must say \ + Frozen: probed={probed:?}, cached={cached:?}" + ), + Oracle::CpuRailMoved(true) => assert!( + matches!(probed, Some(Some(CpuRail::Live | CpuRail::Frozen))), + "the oracle saw a CPU rail keep moving but the probe reported {probed:?}" + ), + } } - /// When the rung is selected, a CPU burn is metered through a rail that - /// moves — so the reading is energy, not a zero or a GPU-only figure that - /// merely happened to advance. + /// The probe runs once per process: after the first answer, the status + /// comes back without probing again (a re-probe is at least one budget + /// of CPU burn on a rail that is not live). + #[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 took = t.elapsed(); + assert_eq!(first, again, "the cached status changed between calls"); + assert!( + took < crate::CPU_RAIL_LIVENESS_BUDGET / 5, + "a cached status took {took:?}: the liveness probe ran again" + ); + } + + /// When the rung IS selected, its own CPU rail is live on a fresh probe + /// and a CPU burn advances the counter itself — a reading, not the + /// time×power fallback that `measure` degrades to on a zero delta. When + /// it is not selected, the status says why: never `Live`. #[test] fn a_selected_ioreport_meter_meters_cpu_work() { let meter = crate::Meter::detect(); if let crate::Meter::IoReport(io) = &meter { - assert_eq!(io.probe_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET), CpuRail::Live); - let (_, e) = meter.measure(|| { - let t = Instant::now(); - let mut acc: u64 = 0; - while t.elapsed() < Duration::from_millis(200) { - acc = acc.wrapping_add(1); - } - std::hint::black_box(acc) - }); - assert!(e.uj() > 0, "a live CPU rail metered 200 ms of CPU work as zero"); + assert_eq!( + crate::ioreport_status(), + crate::IoReportStatus::Live, + "detect() selected IOReport although the liveness status is not Live" + ); + let rail = EnergyModelSubscription::open() + .and_then(|sub| probe_cpu_rail(&sub, crate::CPU_RAIL_LIVENESS_BUDGET).verdict()); + assert_eq!(rail, Some(CpuRail::Live), "IOReport selected but a fresh probe says {rail:?}"); + let _ = io.read_uj(); // the first read only sets the baseline sample + let before = io.read_uj().expect("baseline read"); + burn(Duration::from_millis(200)); + let after = io.read_uj().expect("work read"); + assert!( + after > before, + "a live CPU rail metered 200 ms of CPU work as zero: before={before} after={after}" + ); + let (_, e) = meter.measure(|| burn(Duration::from_millis(200))); + assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased); } else { - assert_ne!(crate::ioreport_status(), crate::IoReportStatus::Live, - "status Live but detect() did not select the IOReport rung"); + assert_ne!( + crate::ioreport_status(), + crate::IoReportStatus::Live, + "status Live but detect() did not select the IOReport rung" + ); } } } @@ -661,45 +1126,51 @@ mod ioreport { /// Why the IOReport rung was or was not selected on this host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IoReportStatus { - /// No IOReport subscription could be opened (not macOS, or not Apple Silicon). + /// No IOReport subscription could be opened or sampled (not macOS, not Apple + /// Silicon, or Rosetta). Unavailable, /// A subscription opened, but no `CPU Energy` rail exists in the Energy Model - /// group — an unfamiliar channel naming. Never selected. + /// group (or there is no such group) — an unfamiliar channel naming. Never + /// selected. CpuRailAbsent, - /// The CPU energy rail exists but did not advance while CPU was burned. The - /// platform is not exposing live CPU energy to this process, so selecting the - /// rung would report GPU-only energy, or zero, as the energy of CPU work. - /// Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. + /// The CPU energy rail exists but did not keep advancing while CPU was + /// burned: no one CPU rail advanced in two separate sample intervals. It + /// held, or stepped once — a batch refresh, not a live reading. The platform + /// is not exposing live CPU energy to this process, so selecting the rung + /// would report GPU-only energy, zero, or a whole refresh as the energy of + /// CPU work. Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. CpuRailFrozen, - /// The CPU energy rail advances under load; the rung is selected. + /// The same CPU energy rail advanced in at least two separate sample + /// intervals under load; the rung is selected. Live, } -/// How long the liveness probe burns CPU before concluding the CPU rail is -/// frozen. Where Energy Model rails are live they update within a few hundred -/// milliseconds (the M5 Max GPU rail moves inside every 200 ms window), so one -/// second is generous. A slower live rail fails safe — it is estimated, never -/// over-claimed. Paid once per process; see [`ioreport_status`]. +/// How long the liveness probe burns CPU, on the calling thread, before +/// concluding the CPU rail is not live. The probe samples its own subscription +/// to the Energy Model group, ~6–12 ms a sample, after every ~25 ms of burn — +/// about 30 intervals in the budget — and the same CPU rail must advance in +/// two of them. A live Energy Model rail changes many times a second (the M5 +/// Max GPU rail every ~28 ms), so one second is generous. If samples are slow +/// enough that fewer than two intervals fit, the probe keeps sampling past the +/// budget until it has two, up to five budgets. A slower live rail fails safe +/// — it is estimated, never over-claimed. Paid once per process; see +/// [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); /// This host's IOReport status, probed once per process and cached. /// /// [`Meter::detect`] selects the IOReport rung only when this is -/// [`IoReportStatus::Live`]. Exposed so a caller, or a receipt, can say *why* -/// an Apple Silicon host is being metered by the time×power model instead. +/// [`IoReportStatus::Live`]. On macOS the first call runs the probe on the +/// calling thread: about one `CPU_RAIL_LIVENESS_BUDGET` of CPU burn when the +/// rail is not live (at most five, if IOReport samples are very slow), less +/// when it is. Exposed so a caller, or a receipt, can say *why* an Apple +/// Silicon host is being metered by the time×power model instead. pub fn ioreport_status() -> IoReportStatus { #[cfg(target_os = "macos")] { static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); - *STATUS.get_or_init(|| match ioreport::IoReport::open() { - None => IoReportStatus::Unavailable, - Some(io) => match io.probe_cpu_rail(CPU_RAIL_LIVENESS_BUDGET) { - ioreport::CpuRail::Live => IoReportStatus::Live, - ioreport::CpuRail::Frozen => IoReportStatus::CpuRailFrozen, - ioreport::CpuRail::Absent => IoReportStatus::CpuRailAbsent, - }, - }) + *STATUS.get_or_init(|| ioreport::probe_status(CPU_RAIL_LIVENESS_BUDGET)) } #[cfg(not(target_os = "macos"))] { @@ -738,7 +1209,8 @@ impl Meter { return Meter::Nvml(Box::new(n)); } // Opening a subscription is not enough: the CPU rail must be live, or - // the rung reports GPU-only energy (or zero) as the energy of CPU work. + // the rung reports GPU-only energy, zero, or a whole batch refresh as + // the energy of CPU work. #[cfg(target_os = "macos")] if ioreport_status() == IoReportStatus::Live { if let Some(io) = ioreport::IoReport::open() { @@ -878,6 +1350,12 @@ mod tests { assert_eq!(time_power_uj(u128::MAX, u64::MAX), u64::MAX); } + #[cfg(not(target_os = "macos"))] + #[test] + fn ioreport_is_unavailable_off_macos() { + assert_eq!(ioreport_status(), IoReportStatus::Unavailable); + } + #[test] fn rapl_wraparound_delta() { let r = Rapl { -- 2.54.0 (Apple Git-157) From 94e7cd3f249f0b7d8edaa32dd67b867d322abfd1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 08:00:08 -0400 Subject: [PATCH 03/10] jcp-energy: the liveness rule pairs consecutive samples, and tests pin each step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit corrects dfec5d44 and 2518f0d7 where they were wrong, and closes the gaps the round-2 review of 2518f0d7 found. The part of the probe where the original defect lived could not be tested: putting that defect back passed every test. Where the two earlier commits were wrong: - dfec5d44 says that on the M3 Ultra "all 645 Energy Model rails are frozen, CPU, DRAM and display included", and that there "nothing moves". Both are wrong. GPU Energy moves on the M3 Ultra, sporadically: 2518f0d7's oracle saw it change at 17-18 of 19-20 reads, the review at 18 of 19, and this commit's runs at 16-19 of 20. The CPU rails are not frozen for good either: they are refreshed in rare batches, as 2518f0d7 already said. - dfec5d44 deleted cumulative_advances_after_cpu_burn. The comment on the macOS job in .github/workflows/standards-build.yml still says that test catches read_uj() returning Some(0), the symptom seen on 2026-09-19. Neither commit said so, and from dfec5d44 until this commit nothing caught that mutation. The job also runs without --nocapture, so a passing log never shows the "ioreport liveness:" line. The workflow belongs to PR #102 and is not edited here. - 2518f0d7 fixed the probe comparing every sample with the first one, but nothing tested the fix: with the defect put back, all 17 tests passed. The pairing lived in the FFI loop, and the rule's tests took precomputed booleans. - 2518f0d7's cache test timed a second ioreport_status() against 200 ms. A re-probe on a live rail takes 145-155 ms, so on such a host it could not catch a missing cache. - 2518f0d7's oracle doc says it "shares nothing with the liveness probe but the raw FFI declarations". It used the crate's cfstring_to_string. 2518f0d7 also says the oracle's GPU Energy count "shows its reads are fresh"; that holds only while the GPU draws (the review saw 7 changes in 16-20 reads). - 2518f0d7 gives 130-160 ms for one all-channel sample on the M5 Max "today". That depends on load. At today's load averages of 17-27 on 18 cores the oracle got 2-11 all-channel reads in 3 s, and the review measured ~425 ms, so the 0.4-0.8 s figure from 2026-09-29 comes back under load. What changes: - The FFI only reads. A probe sample is one plain sample of the Energy Model group: (channel name, absolute raw integer) for every channel. RailWatch, which is pure, picks out the CPU rails, keys each by (name, ordinal among same-named channels in that sample) because the M3 Ultra's group repeats 264 of its 645 names, compares each sample with the one before it, and counts at most one advance per rail per interval. A failed sample is skipped; the next is compared with the last usable one. - The probe loop is watch(sample, tick, elapsed, budget), generic over the sampler, the tick and the clock. The real probe passes the FFI sampler, a 25 ms CPU spin and Instant; the tests pass scripted samples and a scripted clock. - Behaviour change: a first sample that fails no longer ends the probe. Like any failed sample it keeps the probe sampling, up to the five-budget cap; with no two usable samples the status is Unavailable. - status_of(verdict) is a pure mapping to IoReportStatus. detect() calls a private detect_with(status), so the gate can be driven with every status. A private counter, PROBE_RUNS, counts runs of the real probe. The public API is unchanged. - IoReport::open no longer leaks the subscribed-channels dictionary when the subscription comes back null. The path is implausible, but since 2518f0d7 probe_status reaches it. - The oracle converts CFStrings itself, so its doc is now true. Tests: 26 lib tests on macOS, up from 17. - Scripted samples through watch() with a scripted clock: - the M5 Max refresh [480083160, 480083160, 541893638, ...] beside a rising GPU rail: Frozen, the step counted once, after the whole budget; - the M3 Ultra two-die refresh in one sample, among duplicated ECPUDTL00 names: Frozen; - two same-named "CPU Energy" channels stepping together once: Frozen; - one rail rising by one raw unit at every sample: Live after exactly two intervals, three samples and two ticks; - every sample failing: sampling continues to the five-budget cap, no verdict, Unavailable; - past the budget: stops with Frozen once there are two intervals, samples again with only one; - DIE_0 and DIE_1 each stepping once, in different intervals: Frozen; - advances in non-consecutive intervals, and on one die only: Live; - failed samples between usable ones: the pairing goes on. - each_verdict_reports_its_status covers all four verdicts. only_a_live_status_selects_the_ioreport_rung drives detect_with with every status: Live selects IOReport wherever the counter opens; the others give TimePower at the default 15 W. - the_liveness_status_is_probed_once_and_cached counts instead of timing: after several status and detect() calls, PROBE_RUNS must equal 1. - the_liveness_probe_never_reports_a_frozen_rail_as_live also pins cadence and burn. When a fresh probe's verdict is not Live it must have seen at least 10 intervals (group-only: ~30; all-channel: 2-8). The probing thread must use at least half a 25 ms tick of CPU per interval (CLOCK_THREAD_CPUTIME_ID): burning, 23-34 ms; sleeping, 6-10 ms. Per interval, not against the wall clock: in one run under load averages above 20, the probe (unmutated there) spent only 36% of its wall time on CPU, because each IOReport sample waited longer while the burn still got its CPU. - a_selected_ioreport_meter_meters_cpu_work, in its declined branch, meters 100 ms of real CPU work with the detected meter and asserts uj() > 0, at least the time x power figure at the default 15 W, measured_uj None, provenance ModelBased and check_provenance(). - the_counter_advances_when_a_rail_it_sums_advanced is new. The counter takes a baseline; the oracle's own subscription reads GPU Energy (Energy Model group, absolute) until one channel rises by at least 10^6 raw units or 3 s pass; the counter reads again. If the GPU rose inside the counter's window the counter must have advanced. With the GPU idle only after >= base is asserted, and the printed line says which branch ran. - the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail keeps its name and its "ioreport liveness: status=" line. Falsified on the M5 Max against this tree. Each mutation was a temporary edit, restored with git checkout; git status was clean after each. Every mutation the review listed as surviving now fails: - M6, compare each sample with the first (dfec5d44's defect): 6 tests fail, e.g. "a batch refresh read as live: [("CPU Energy#0", 2)]". - M7, the probe on the all-channel subscription: "the probe saw 2 intervals in 1.689598583s: it is not re-sampling the Energy Model group after every 25ms tick". - M8, the detect() fallback TimePower { milliwatts: 0 }: "the declined meter metered 100ms of CPU work as 0 µJ" and "status Unavailable gave TimePower { milliwatts: 0 }". - M9, the probe sleeping instead of burning: "the probing thread used 207.32975ms of CPU over 27 intervals, 7.678879ms each, under half of each 25ms tick". - M10, no verdict mapped to Live: 2 tests fail, "left: Live, right: Unavailable". - M11, the gate selecting on CpuRailAbsent: "status CpuRailAbsent gave IoReport(IoReport)". - M13, the advance threshold raised to 1e9 raw units: 8 tests fail. - M14, read_uj() returning Some(0): "GPU Energy rose 18851614 raw inside the counter's window, but the counter read 0 -> 0 µJ". Also caught, on an intermediate tree with the same rule and loop: keying rails by name alone, no OnceLock (the probe ran 13 times), summing advances across rails, counting GPU Energy as a CPU rail, stopping at the budget with one interval, LIVE_RAIL_ADVANCES = 1, and Absent for rails that held. Observed on this tree: M5 Max, 3 runs, load averages 23-24: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 7-9 reads over 3s; changes per CPU rail [0], per GPU rail [7-9]); probe 1.00-1.04 s, 16-27 intervals, 31.4 ms thread CPU per interval; declined meter 100 ms -> ~1,500,010 µJ ModelBased, measured_uj None; test result: ok. 26 passed; 0 failed. M3 Ultra, the same test binary (sha256 55d69f84...): status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (20 reads; CPU rails [0, 0], GPU rail [17]); probe 1.03 s, 28 intervals, 33.7 ms per interval; test result: ok. 26 passed; 0 failed. Dependants on the M5 Max: jcp-cli (0 tests), jcp-conformance (1 lib, 5 pack) and jcp-intercept (8) pass, and the suite prints "✓ C9 energy provenance honesty" and "all checks passed". On the M3 Ultra the jcp-cli, jcp-conformance lib and jcp-intercept test binaries and the suite pass too. Under M8, C9 and the consumers still pass (jcp-cli prints "ModelBased backend → 0 µJ probe"); only this crate's tests catch it. clippy -p jcp-energy --no-deps, lib and --tests, is clean on aarch64-apple-darwin, x86_64-apple-darwin and x86_64-unknown-linux-gnu. Caveats: - M14 is caught only when a GPU Energy channel rises within 3 s. It did in every run today on both machines; on an idle headless host nothing can tell a constant reader from a true zero. - The two new hardware floors depend on load, the interval floor most: 12 and 16 intervals were seen at load averages of 20-27 on the 18-core M5 Max, which also hosts the macOS CI runner. - Keying by ordinal assumes IOReport lists same-named channels in the same order in every sample of one subscription. - The tests were not run on Linux (no docker); only clippy and check for x86_64-unknown-linux-gnu. - The workflow comment and --nocapture are left to PR #102; C9 is unchanged. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 854 ++++++++++++++++++------ 1 file changed, 658 insertions(+), 196 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 16eca5cbc..3859b1872 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -142,6 +142,7 @@ impl std::fmt::Debug for Nvml { #[allow(non_camel_case_types, unsafe_code)] mod ioreport { use std::os::raw::{c_int, c_long, c_void}; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Mutex; use std::time::{Duration, Instant}; @@ -270,6 +271,9 @@ mod ioreport { if !sub.is_null() { CFRelease(sub); } + if !subscribed.is_null() { + CFRelease(subscribed as CFTypeRef); + } return None; } Some(Self { @@ -316,9 +320,15 @@ mod ioreport { /// before it counts as live. One is not enough: see [`RailWatch`]. const LIVE_RAIL_ADVANCES: u32 = 2; - /// However slow IOReport is, the probe stops after this many budgets. + /// However slow IOReport is, or however many of its samples fail, the probe + /// stops after this many budgets. const PROBE_BUDGET_CAP: u32 = 5; + /// How many times the real probe, [`probe_status`], has run in this + /// process. [`crate::ioreport_status`] caches its answer, so this never + /// passes one; a test pins that by counting rather than by timing. + static PROBE_RUNS: AtomicUsize = AtomicUsize::new(0); + /// What the liveness probe saw of the CPU energy rail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CpuRail { @@ -332,7 +342,40 @@ mod ioreport { Absent, } - /// The liveness rule, kept apart from the FFI so it can be tested on its own. + /// One plain sample of the Energy Model group, as the FFI reads it: every + /// channel's name and its ABSOLUTE raw cumulative integer, in the order + /// IOReport lists them. The FFI compares nothing; [`RailWatch::observe`] + /// pairs each sample with the one before it. + type Sample = Vec<(String, i64)>; + + /// A CPU rail within one sample: its channel name, and its ordinal among + /// the channels of that name in the same sample. Names are not unique: an + /// M3 Ultra's Energy Model group has 645 channels and 264 duplicated names + /// (`ECPUDTL00`..`22` once per die, with no `DIE_` prefix). The CPU + /// aggregates are unique today on both chips, but keyed by name alone, two + /// same-named rails stepping together in one refresh would count as two + /// advances of one rail. + #[derive(Debug, Clone, PartialEq, Eq)] + struct RailKey { + name: String, + ordinal: u32, + } + + /// The CPU energy rails in one sample, each with its absolute raw value. + fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in sample { + if is_cpu_energy_rail(name) { + let ordinal = rails.iter().filter(|(k, _)| k.name == *name).count() as u32; + rails.push((RailKey { name: name.clone(), ordinal }, *raw)); + } + } + rails + } + + /// The liveness rule over plain samples, kept apart from the FFI so all of + /// it, including the pairing of each sample with the previous one, runs on + /// scripted samples in tests. /// /// A rail is live only when the **same** CPU rail advances in at least /// [`LIVE_RAIL_ADVANCES`] separate sample intervals. One advance is not @@ -348,26 +391,43 @@ mod ioreport { /// straddled it. A live rail advances in nearly every interval (the M5 Max /// GPU rail changes every ~28 ms), so it passes in its first two. Counting /// per rail keeps one jump on each die from adding up to a false Live. + /// + /// Each interval is a sample and the one **before** it. Compared with the + /// first sample instead, a rail that stepped once would show a rise in + /// every interval after the step, and one refresh would read as live. #[derive(Debug, Default)] struct RailWatch { - /// Sample intervals observed so far. + /// Sample intervals observed so far: pairs of consecutive usable samples. intervals: u32, - /// Each CPU rail seen, by channel name, with the number of intervals in - /// which it advanced. - rails: Vec<(String, u32)>, + /// The previous usable sample's CPU rails, which the next one is + /// compared with. + prev: Option>, + /// Every CPU rail seen, with the number of intervals in which it advanced. + rails: Vec<(RailKey, u32)>, } impl RailWatch { - /// Record one sample interval: every CPU rail in it, and whether that - /// rail advanced over the interval. - 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 usable sample. The first only sets the baseline. + /// Each later one closes an interval with the sample before it, and a + /// rail advanced over that interval when its raw value rose. A rail is + /// counted at most once per interval. + fn observe(&mut self, sample: &[(String, i64)]) { + let cur = cpu_rails(sample); + for (key, _) in &cur { + if !self.rails.iter().any(|(k, _)| k == key) { + self.rails.push((key.clone(), 0)); } } + if let Some(prev) = &self.prev { + self.intervals += 1; + for (key, now) in &cur { + let rose = prev.iter().any(|(k, before)| k == key && now > before); + if let Some((_, count)) = self.rails.iter_mut().find(|(k, _)| k == key) { + *count += u32::from(rose); + } + } + } + self.prev = Some(cur); } /// Has one CPU rail advanced in enough separate intervals to be live? @@ -400,13 +460,38 @@ mod ioreport { } } + /// The probe loop, generic over where samples come from, what happens + /// between them and the clock, so tests drive it with scripted samples and + /// a scripted clock exactly as [`probe_cpu_rail`] drives it with IOReport, + /// a CPU spin and [`Instant`]. Take a sample; stop when + /// [`RailWatch::wants_more`] says so; otherwise `tick` and sample again. A + /// sample that fails is skipped, so the next usable one is compared with + /// the last usable one. `elapsed` is the time since the probe started. + fn watch( + mut sample: impl FnMut() -> Option, + mut tick: impl FnMut(), + elapsed: impl Fn() -> Duration, + budget: Duration, + ) -> RailWatch { + let mut rail_watch = RailWatch::default(); + loop { + if let Some(s) = sample() { + rail_watch.observe(&s); + } + if !rail_watch.wants_more(elapsed(), budget) { + return rail_watch; + } + tick(); + } + } + /// The liveness probe's own subscription, to the Energy Model group only. /// /// The counter's subscription covers every channel (~11,860 on an M5 Max), /// and one sample of it has been measured at 0.13–0.8 s on an M5 Max and /// ~0.14 s on an M3 Ultra, mostly spent waiting rather than burning CPU. A /// probe on it re-sampled every ~150 ms at best, not after every ~25 ms of - /// burn. The group alone samples in ~6–12 ms on both, and carries the same + /// burn. The group alone samples in ~5–12 ms on both, and carries the same /// absolute CPU rail values. struct EnergyModelSubscription { sub: IOReportSubscriptionRef, @@ -460,50 +545,51 @@ mod ioreport { Some(Self { sub, channels }) } } + + /// One plain sample (no delta): every Energy Model channel's name and + /// absolute raw integer. `None` when IOReport yields no usable sample. + fn sample(&self) -> Option { + unsafe { + let sample = IOReportCreateSamples(self.sub, self.channels, std::ptr::null()); + if sample.is_null() { + return None; + } + let values = energy_model_values(sample); + CFRelease(sample); + values + } + } } /// Burn CPU on this thread, re-sampling the Energy Model group after every - /// [`PROBE_TICK`] of burn, and watch each CPU energy rail **itself**, per - /// consecutive sample interval, by the rule in [`RailWatch`]. A watch with - /// no interval means the subscription yielded no usable sample. + /// [`PROBE_TICK`] of burn, and watch each CPU energy rail **itself** by the + /// rule in [`RailWatch`]. A watch with no interval means the subscription + /// yielded fewer than two usable samples in [`PROBE_BUDGET_CAP`] budgets. /// /// 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. The probe takes its own samples on /// its own subscription, so no counter's cumulative reading is touched. fn probe_cpu_rail(sub: &EnergyModelSubscription, budget: Duration) -> RailWatch { - let mut watch = RailWatch::default(); - unsafe { - let mut prev = IOReportCreateSamples(sub.sub, sub.channels, std::ptr::null()); - if prev.is_null() { - return watch; - } - let started = Instant::now(); - while watch.wants_more(started.elapsed(), budget) { - burn(PROBE_TICK); - let cur = IOReportCreateSamples(sub.sub, sub.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); + let started = Instant::now(); + watch(|| sub.sample(), || burn(PROBE_TICK), || started.elapsed(), budget) + } + + /// The status a probe verdict reports. No verdict, because not one interval + /// could be observed, is `Unavailable`: never `Live`. + 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, } - watch } /// This host's status from one probe on a fresh Energy Model subscription. /// Backs the cached [`crate::ioreport_status`]. pub(crate) fn probe_status(budget: Duration) -> IoReportStatus { + PROBE_RUNS.fetch_add(1, Ordering::Relaxed); let Some(sub) = EnergyModelSubscription::open() else { // No Energy Model subscription. Tell "no IOReport" from "no such // group" by opening the subscription the counter itself uses. @@ -512,34 +598,28 @@ mod ioreport { None => IoReportStatus::Unavailable, }; }; - match probe_cpu_rail(&sub, budget).verdict() { - None => IoReportStatus::Unavailable, - Some(CpuRail::Live) => IoReportStatus::Live, - Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, - Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, - } + status_of(probe_cpu_rail(&sub, budget).verdict()) } - /// In a samples-delta dictionary: every CPU energy rail 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. - unsafe fn cpu_rail_advances(delta: CFDictionaryRef) -> Vec<(String, bool)> { - let mut rails = Vec::new(); + /// Every Energy Model channel in a plain sample, as `(name, absolute raw + /// integer)` in IOReport's order. Raw integers, before unit conversion, so + /// a small advance in a fine unit cannot truncate to zero. `None` when the + /// sample carries no channel array. + unsafe fn energy_model_values(sample: CFDictionaryRef) -> Option { let key = CFStringCreateWithCString( std::ptr::null_mut(), c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); if key.is_null() { - return rails; + return None; } - let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; + let arr = CFDictionaryGetValue(sample, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); if arr.is_null() { - return rails; + return None; } + let mut values = Vec::new(); for i in 0..CFArrayGetCount(arr) { let item = CFArrayGetValueAtIndex(arr, i); if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { @@ -548,12 +628,9 @@ mod ioreport { 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)); - } + values.push((name, IOReportSimpleGetIntegerValue(item, 0) as i64)); } - rails + Some(values) } /// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally @@ -674,7 +751,8 @@ mod ioreport { #[cfg(test)] mod tests { use super::*; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::cell::Cell; + use std::sync::atomic::AtomicBool; #[test] fn energy_to_uj_scales_by_unit() { @@ -704,98 +782,297 @@ mod ioreport { assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); } - // ── The liveness rule, without hardware ───────────────────────────── + // ── The liveness rule, on scripted samples and a scripted clock ───── - /// A rail that moves in one step and then holds is a batch refresh - /// caught mid-probe, not a live rail, however large the step — including - /// the refresh observed on an M3 Ultra, both dies in the same interval. - #[test] - fn a_single_jump_is_a_batch_refresh_not_a_live_rail() { - let mut w = RailWatch::default(); - for advanced in [false, true, false, false, false] { - w.observe([("CPU Energy", advanced)]); - } - assert!(!w.is_live(), "one jump counted as a live rail: {w:?}"); - assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + /// A probe budget, as in production. + const BUDGET: Duration = Duration::from_millis(1000); - let mut w = RailWatch::default(); - for both in [false, false, true, false] { - w.observe([("DIE_0_CPU Energy", both), ("DIE_1_CPU Energy", both)]); - } - assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one refresh of both dies counted as live: {w:?}"); + /// One scripted tick: a 25 ms spin plus a ~5 ms Energy Model sample. + const STEP: Duration = Duration::from_millis(30); + + /// An M5 Max-shaped sample: `CPU Energy` at `cpu`, beside a `GPU + /// Energy` that rises at every sample, as the M5 Max's does while + /// anything draws. A rule that counted the GPU rail would call every + /// host live. + fn m5(i: usize, cpu: i64) -> Sample { + vec![ + ("GPU Energy".to_string(), 112_249_654_932_804 + 1_000_000 * i as i64), + ("CPU Energy".to_string(), cpu), + ] } - /// Two dies each refreshed once, in different intervals, do not add up - /// to a live rail: advances are counted per rail. - #[test] - fn one_jump_on_each_die_in_different_intervals_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!(!w.is_live(), "one jump per die counted as a live rail: {w:?}"); - assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + /// One probe run through [`watch`] on a script: sample `i` is + /// `script(i)`, and each tick moves the clock on by `step`. Returns the + /// watch, how many samples were taken, and the clock when it stopped. + fn scripted( + mut script: impl FnMut(usize) -> Option, + step: Duration, + budget: Duration, + ) -> (RailWatch, usize, Duration) { + let clock = Cell::new(Duration::ZERO); + let mut taken = 0; + let rail_watch = watch( + || { + let s = script(taken); + taken += 1; + s + }, + || clock.set(clock.get() + step), + || clock.get(), + budget, + ); + (rail_watch, taken, clock.get()) } - /// The same rail advancing in two separate intervals is live — on either - /// die, and whether or not the intervals are consecutive. - #[test] - fn two_advances_of_the_same_rail_are_live() { - let mut w = RailWatch::default(); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); - assert!(!w.is_live(), "one advance is not yet live"); - w.observe([("DIE_0_CPU Energy", false), ("DIE_1_CPU Energy", true)]); - assert!(w.is_live()); - assert_eq!(w.verdict(), Some(CpuRail::Live)); - - let mut w = RailWatch::default(); - for advanced in [true, false, false, true] { - w.observe([("CPU Energy", advanced)]); - } - assert_eq!(w.verdict(), Some(CpuRail::Live)); + /// Advances per CPU rail, as `("name#ordinal", intervals advanced)`. + fn advances(w: &RailWatch) -> Vec<(String, u32)> { + w.rails.iter().map(|(k, n)| (format!("{}#{}", k.name, k.ordinal), *n)).collect() } - /// No interval observed at all is no verdict (the caller reports - /// IOReport as unavailable); intervals with no CPU rail are `Absent`. + fn rail(name: &str, n: u32) -> (String, u32) { + (name.to_string(), n) + } + + /// (a) The refresh seen on an M5 Max: `CPU Energy` steps once, from + /// 480,083,160 to 541,893,638 mJ, and holds, while `GPU Energy` rises + /// at every sample. Frozen, with the step counted once, after the whole + /// budget. Compared with the FIRST sample (dfec5d44's defect), the rail + /// would show a rise in every interval after the step and read Live. + #[test] + fn an_m5_max_batch_refresh_mid_probe_is_frozen() { + let (w, taken, stopped) = + scripted(|i| Some(m5(i, if i < 2 { 480_083_160 } else { 541_893_638 })), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a batch refresh read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1)]); + // The whole budget: at 30 ms a tick, the clock reaches 1 s at the 34th. + assert_eq!((w.intervals, taken, stopped), (34, 35, STEP * 34)); + } + + /// (b) The refresh seen on an M3 Ultra: both dies' `CPU Energy` step in + /// the same sample, +1,344,869 and +1,391,637 mJ, among a duplicated + /// unprefixed name (`ECPUDTL00`, once per die, rising) and a rising GPU + /// rail. Frozen: one advance per die, never summed into a Live. + #[test] + fn an_m3_ultra_two_die_refresh_in_one_sample_is_frozen() { + let (w, _, _) = scripted( + |i| { + let (d0, d1) = if i < 3 { (21_170_718, 23_300_102) } else { (22_515_587, 24_691_739) }; + Some(vec![ + ("ECPUDTL00".to_string(), 7_000 + i as i64), + ("DIE_0_CPU Energy".to_string(), d0), + ("ECPUDTL00".to_string(), 9_000 + i as i64), + ("DIE_1_CPU Energy".to_string(), d1), + ("GPU Energy".to_string(), 5_000_000 * i as i64), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one refresh of both dies read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + } + + /// (c) Two channels both named `CPU Energy`, stepping together once: + /// two rails, one advance each, Frozen. Keyed by name alone they would + /// be one rail, advancing twice in one interval. + #[test] + fn two_same_named_rails_stepping_together_once_are_frozen() { + let (w, _, _) = scripted( + |i| { + let step = if i < 2 { 0 } else { 1_300_000 }; + Some(vec![ + ("CPU Energy".to_string(), 21_170_718 + step), + ("CPU Energy".to_string(), 23_300_102 + step), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "two same-named rails read as one live rail: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1), rail("CPU Energy#1", 1)]); + } + + /// (d) A live rail: `CPU Energy` rising by the smallest possible step, + /// one raw unit, at every sample. Live after exactly two intervals, and + /// the probe stops there: three samples, two ticks. + #[test] + fn a_rail_advancing_at_every_sample_is_live_after_two_intervals() { + let (w, taken, stopped) = scripted(|i| Some(m5(i, 541_893_638 + i as i64)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "a rail rising at every sample: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 2)]); + assert_eq!((w.intervals, taken, stopped), (2, 3, STEP * 2), "a live rail must stop the probe at once"); + } + + /// The same rail advancing in two separate intervals is live, whether or + /// not the intervals are consecutive, and on either die. + #[test] + fn two_advances_of_one_rail_in_separate_intervals_are_live() { + let steps = |i: usize| match i { + 0 => 0, + 1..=3 => 1, + _ => 2, + }; + let (w, taken, _) = scripted(|i| Some(m5(i, 541_893_638 + steps(i))), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!((w.intervals, taken), (4, 5), "advances in intervals 1 and 4"); + let (w, _, _) = scripted( + |i| { + Some(vec![ + ("DIE_0_CPU Energy".to_string(), 21_170_718), + ("DIE_1_CPU Energy".to_string(), 23_300_102 + i as i64), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 2)]); + } + + /// (e) Every sample fails: the probe keeps sampling a tick apart up to + /// the five-budget cap, and gives no verdict, which reports Unavailable. + #[test] + fn failed_samples_keep_sampling_to_the_cap_and_give_no_verdict() { + let (w, taken, stopped) = scripted(|_| None, PROBE_TICK, BUDGET); + assert_eq!(w.verdict(), None); + assert_eq!(status_of(w.verdict()), crate::IoReportStatus::Unavailable); + assert_eq!(stopped, BUDGET * PROBE_BUDGET_CAP, "the probe stopped at {stopped:?}, not at the cap"); + // 5 s of 25 ms ticks, plus the first sample. + assert_eq!(taken, 201); + } + + /// A failed sample is skipped, not a reset: the next usable sample is + /// compared with the last usable one. + #[test] + fn a_failed_sample_is_skipped_and_the_pairing_goes_on() { + // Usable, failed, +2, failed, +2: two intervals, two advances. + let (w, taken, _) = scripted(|i| (i % 2 == 0).then(|| m5(i, 541_893_638 + i as i64)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!((w.intervals, taken), (2, 5)); + // A refresh between two failures is still one step. + let (w, _, _) = scripted( + |i| (i % 3 != 1).then(|| m5(i, if i < 4 { 480_083_160 } else { 541_893_638 })), + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1)]); + } + + /// (f) Past the budget, the probe stops once it has two intervals, with + /// the verdict Frozen, and not before: one slow sample must not make a + /// live rail look frozen. + #[test] + fn past_the_budget_the_probe_stops_only_with_two_intervals() { + let held = |i| Some(m5(i, 541_893_638)); + // 600 ms a tick: the budget runs out at the second interval. + let (w, taken, stopped) = scripted(held, Duration::from_millis(600), BUDGET); + assert_eq!( + (w.verdict(), w.intervals, taken, stopped), + (Some(CpuRail::Frozen), 2, 3, Duration::from_millis(1200)) + ); + // 1.5 s a tick: past the budget with ONE interval, so it samples again. + let (w, taken, stopped) = scripted(held, Duration::from_millis(1500), BUDGET); + assert_eq!( + (w.verdict(), w.intervals, taken, stopped), + (Some(CpuRail::Frozen), 2, 3, Duration::from_millis(3000)), + "past the budget with one interval, the probe must take one more sample" + ); + } + + /// (g) DIE_0 steps once and DIE_1 steps once, in different intervals: + /// Frozen. Advances are counted per rail, never summed across dies. + #[test] + fn one_step_on_each_die_in_different_intervals_is_frozen() { + let (w, _, _) = scripted( + |i| { + Some(vec![ + ("DIE_0_CPU Energy".to_string(), if i < 2 { 21_170_718 } else { 22_515_587 }), + ("DIE_1_CPU Energy".to_string(), if i < 5 { 23_300_102 } else { 24_691_739 }), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one step per die read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + } + + /// No interval at all is no verdict: a baseline alone is not one. + /// Intervals with no CPU rail are Absent, however much the GPU moves. #[test] fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { let mut w = RailWatch::default(); assert_eq!(w.verdict(), None); - w.observe([]); - w.observe([]); + w.observe(&[("GPU Energy".to_string(), 1)]); + assert_eq!(w.verdict(), None, "a baseline alone is not an interval"); + w.observe(&[("GPU Energy".to_string(), 2)]); + w.observe(&[("GPU Energy".to_string(), 3)]); assert_eq!(w.verdict(), Some(CpuRail::Absent)); } - /// The probe keeps sampling within its budget until the rail proves - /// live; past the budget only until a live rail could have advanced - /// twice; and never past five budgets. + /// The stopping rule on its own: within the budget keep watching until + /// the rail proves live; past it only until a live rail could have + /// advanced twice; never past five budgets. #[test] fn the_probe_samples_until_live_or_long_enough_to_tell() { - let budget = Duration::from_millis(1000); + let budget = BUDGET; + let held = m5(0, 541_893_638); let mut w = RailWatch::default(); assert!(w.wants_more(Duration::ZERO, budget), "nothing observed yet"); - // Slow samples: past the budget with fewer than two intervals, go on... assert!(w.wants_more(budget * 2, budget), "no interval yet, past the budget"); - w.observe([("CPU Energy", false)]); + w.observe(&held); + w.observe(&held); assert!(w.wants_more(budget * 2, budget), "one interval cannot show two advances"); assert!(w.wants_more(budget * 5 - Duration::from_millis(1), budget)); - // ...but never past five budgets. assert!(!w.wants_more(budget * 5, budget), "hard cap: five budgets"); assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap with no interval"); - w.observe([("CPU Energy", false)]); + w.observe(&held); assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); assert!(!w.wants_more(budget, budget), "budget spent, two intervals: stop"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); - // A rail that proves live stops the probe at once. let mut live = RailWatch::default(); - live.observe([("CPU Energy", true)]); + live.observe(&m5(0, 1)); + live.observe(&m5(1, 2)); assert!(live.wants_more(Duration::ZERO, budget), "one advance: not yet live, keep watching"); - live.observe([("CPU Energy", true)]); + live.observe(&m5(2, 3)); assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); } + /// Each verdict reports its status, and no verdict (not one interval + /// observed) is Unavailable, never Live. + #[test] + fn each_verdict_reports_its_status() { + use crate::IoReportStatus as S; + assert_eq!(status_of(None), S::Unavailable); + assert_eq!(status_of(Some(CpuRail::Absent)), S::CpuRailAbsent); + assert_eq!(status_of(Some(CpuRail::Frozen)), S::CpuRailFrozen); + assert_eq!(status_of(Some(CpuRail::Live)), S::Live); + } + // ── Against the hardware ──────────────────────────────────────────── + #[repr(C)] + struct Timespec { + tv_sec: i64, + tv_nsec: i64, + } + + /// macOS ``: `_CLOCK_THREAD_CPUTIME_ID`. + const CLOCK_THREAD_CPUTIME_ID: c_int = 16; + + unsafe extern "C" { + fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; + } + + /// CPU time this thread has used, user and system. + fn thread_cpu_time() -> Duration { + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + assert_eq!(rc, 0, "clock_gettime(CLOCK_THREAD_CPUTIME_ID) failed"); + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) + } + /// What the independent oracle saw on this host. #[derive(Debug, PartialEq)] enum Oracle { @@ -812,7 +1089,8 @@ mod ioreport { reads: u32, /// Per CPU rail, at how many reads its absolute value had changed. cpu_changes: Vec, - /// The same for `GPU Energy`: evidence the reads are fresh samples. + /// The same for `GPU Energy`. When the GPU draws, this shows the + /// reads are fresh samples; an idle GPU shows few changes or none. gpu_changes: Vec, } @@ -822,13 +1100,61 @@ mod ioreport { } } - /// The ABSOLUTE cumulative values of every channel whose name ends with - /// `CPU Energy`, and of every one ending with `GPU Energy`, in any - /// group, from one plain sample (no delta). - fn oracle_read( + /// The oracle's own CFString conversion, so it does not share the + /// implementation's `cfstring_to_string`. + fn oracle_string(s: CFStringRef) -> Option { + if s.is_null() { + return None; + } + let mut buf = [0u8; 512]; + let ok = unsafe { + CFStringGetCString(s, buf.as_mut_ptr() as *mut i8, buf.len() as c_long, K_CFSTRING_ENCODING_UTF8) + }; + if !ok { + return None; + } + let end = buf.iter().position(|&b| b == 0)?; + String::from_utf8(buf[..end].to_vec()).ok() + } + + /// The oracle's own all-channel subscription. `None` if IOReport does + /// not open. Released by [`oracle_unsubscribe`]. + fn oracle_subscribe() -> Option<(IOReportSubscriptionRef, CFMutableDictionaryRef)> { + 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); + if sub.is_null() || chans.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !chans.is_null() { + CFRelease(chans as CFTypeRef); + } + return None; + } + Some((sub, chans)) + } + } + + fn oracle_unsubscribe((sub, chans): (IOReportSubscriptionRef, CFMutableDictionaryRef)) { + unsafe { + CFRelease(sub); + CFRelease(chans as CFTypeRef); + } + } + + /// From one plain sample (no delta), every channel whose name `keep`s, + /// in any group: `(group, name, ABSOLUTE raw value)`. + fn oracle_channels( sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef, - ) -> Option<(Vec, Vec)> { + keep: impl Fn(&str) -> bool, + ) -> Option> { unsafe { let smp = IOReportCreateSamples(sub, chans, std::ptr::null()); if smp.is_null() { @@ -839,7 +1165,7 @@ mod ioreport { c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); - let (mut cpu, mut gpu) = (Vec::new(), Vec::new()); + let mut out = Vec::new(); if !key.is_null() { let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); @@ -849,20 +1175,28 @@ mod ioreport { if it.is_null() { continue; } - let name = cfstring_to_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); - if name.ends_with("CPU Energy") { - cpu.push(IOReportSimpleGetIntegerValue(it, 0) as i64); - } else if name.ends_with("GPU Energy") { - gpu.push(IOReportSimpleGetIntegerValue(it, 0) as i64); + let name = oracle_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); + if keep(&name) { + let group = oracle_string(IOReportChannelGetGroup(it)).unwrap_or_default(); + out.push((group, name, IOReportSimpleGetIntegerValue(it, 0) as i64)); } } } } CFRelease(smp); - Some((cpu, gpu)) + Some(out) } } + /// The ABSOLUTE cumulative values of every channel whose name ends with + /// `CPU Energy`, and of every one ending with `GPU Energy`, in any + /// group, from one plain sample. + fn oracle_read(sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef) -> Option<(Vec, Vec)> { + let chans = oracle_channels(sub, chans, |n| n.ends_with("CPU Energy") || n.ends_with("GPU Energy"))?; + let pick = |suffix: &str| chans.iter().filter(|(_, n, _)| n.ends_with(suffix)).map(|c| c.2).collect(); + Some((pick("CPU Energy"), pick("GPU Energy"))) + } + /// Read until `window` has elapsed since `started`, counting per rail at /// how many reads the absolute value changed. Stops early once one CPU /// rail has changed at two separate reads. @@ -906,8 +1240,9 @@ mod ioreport { /// An oracle that shares nothing with the liveness probe but the raw FFI /// declarations: it opens its own subscription over every channel (not - /// the probe's Energy Model group), reads ABSOLUTE cumulative values (no - /// delta samples), matches names with `ends_with("CPU Energy")` in any + /// the probe's Energy Model group), converts CFStrings itself, reads + /// ABSOLUTE cumulative values and compares each read with the one + /// before it itself, matches names with `ends_with("CPU Energy")` in any /// group (not the implementation's group filter and die-prefix /// stripping), and keeps CPU busy on a HELPER thread for the whole /// `window`, so the window is under load even while an all-channel sample @@ -920,24 +1255,8 @@ mod ioreport { /// implementation's constant, so a change to one cannot silently move /// the other. fn oracle_cpu_rail(window: Duration) -> OracleRun { - let (sub, chans) = unsafe { - let all = IOReportCopyAllChannels(0, 0); - if all.is_null() { - return OracleRun::bare(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 OracleRun::bare(Oracle::NoIoReport); - } - (sub, chans) + let Some((sub, chans)) = oracle_subscribe() else { + return OracleRun::bare(Oracle::NoIoReport); }; let stop = AtomicBool::new(false); let run = std::thread::scope(|s| { @@ -952,10 +1271,7 @@ mod ioreport { stop.store(true, Ordering::Relaxed); run }); - unsafe { - CFRelease(sub); - CFRelease(chans as CFTypeRef); - } + oracle_unsubscribe((sub, chans)); run } @@ -1024,20 +1340,33 @@ mod ioreport { /// uncached, on a fresh Energy Model subscription, beside the cached /// status `detect()` is gated on, and checks on every outcome that the /// two agree with the oracle about what exists. + /// + /// It also pins how the probe samples. When the probe runs its whole + /// budget (any verdict but Live) it must have seen at least 10 + /// intervals: a group-only sample every ~25 ms tick fits ~30 (as few as + /// 12 seen under load averages near 24 on 18 cores), while an + /// all-channel sample (0.13–0.8 s) fits 2–8. And the probing thread + /// must use at least half a tick of CPU per interval: burning between + /// samples it used 23–34 ms per interval, the whole tick plus the + /// sample; sleeping, 6–10 ms, the sample calls alone. Counted per + /// interval, not against the wall clock, because under load an IOReport + /// sample waits longer while the burn still gets its CPU. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { - let t = Instant::now(); - let watch = EnergyModelSubscription::open() - .map(|sub| probe_cpu_rail(&sub, crate::CPU_RAIL_LIVENESS_BUDGET)); - let took = t.elapsed(); + let sub = EnergyModelSubscription::open(); + let (cpu_before, t) = (thread_cpu_time(), Instant::now()); + let watch = sub.as_ref().map(|sub| probe_cpu_rail(sub, crate::CPU_RAIL_LIVENESS_BUDGET)); + let (took, cpu) = (t.elapsed(), thread_cpu_time() - cpu_before); + drop(sub); let probed = watch.as_ref().map(RailWatch::verdict); + let intervals = watch.as_ref().map_or(0, |w| w.intervals); let cached = crate::ioreport_status(); let run = oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3); eprintln!( - "ioreport probe: probed={probed:?} cached={cached:?} took={took:?} intervals={} \ - advances per CPU rail {:?}; oracle={:?}", - watch.as_ref().map_or(0, |w| w.intervals), - watch.as_ref().map(|w| w.rails.clone()).unwrap_or_default(), + "ioreport probe: probed={probed:?} cached={cached:?} took={took:?} thread_cpu={cpu:?} \ + ({:?} per interval) intervals={intervals} advances per CPU rail {:?}; oracle={:?}", + cpu.checked_div(intervals), + watch.as_ref().map(advances).unwrap_or_default(), run.verdict ); if probed == Some(Some(CpuRail::Live)) || cached == crate::IoReportStatus::Live { @@ -1068,28 +1397,51 @@ mod ioreport { "the oracle saw a CPU rail keep moving but the probe reported {probed:?}" ), } + if matches!(probed, Some(Some(CpuRail::Frozen | CpuRail::Absent))) { + assert!( + intervals >= 10, + "the probe saw {intervals} intervals in {took:?}: it is not re-sampling the Energy Model \ + group after every {PROBE_TICK:?} tick (an all-channel subscription fits 2-8)" + ); + } + if intervals > 0 { + let per_interval = cpu / intervals; + assert!( + per_interval >= PROBE_TICK / 2, + "the probing thread used {cpu:?} of CPU over {intervals} intervals, {per_interval:?} \ + each, under half of each {PROBE_TICK:?} tick: the probe must burn CPU between samples, \ + not wait" + ); + } } - /// The probe runs once per process: after the first answer, the status - /// comes back without probing again (a re-probe is at least one budget - /// of CPU burn on a rail that is not live). + /// The probe runs once per process, however many times the status is + /// asked for or the meter is built. Counted, not timed: a probe on a + /// live rail is over in tens of milliseconds, which a clock cannot tell + /// from a cache hit. #[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 took = t.elapsed(); - assert_eq!(first, again, "the cached status changed between calls"); - assert!( - took < crate::CPU_RAIL_LIVENESS_BUDGET / 5, - "a cached status took {took:?}: the liveness probe ran again" + for _ in 0..3 { + assert_eq!(crate::ioreport_status(), first, "the cached status changed between calls"); + let _ = crate::Meter::detect(); + } + assert_eq!( + PROBE_RUNS.load(Ordering::Relaxed), + 1, + "the liveness probe did not run exactly once in this process" ); } /// When the rung IS selected, its own CPU rail is live on a fresh probe /// and a CPU burn advances the counter itself — a reading, not the - /// time×power fallback that `measure` degrades to on a zero delta. When - /// it is not selected, the status says why: never `Live`. + /// time×power fallback that `measure` degrades to on a zero delta. + /// + /// When it is not, the status says why (never `Live`), and the meter + /// `detect()` returned still meters real work honestly: 100 ms of CPU + /// work is the time×power model's figure, at least 100 ms at the + /// documented default power, tagged `ModelBased` with no measurement. + /// Never a floored or zero figure for work that ran. #[test] fn a_selected_ioreport_meter_meters_cpu_work() { let meter = crate::Meter::detect(); @@ -1113,10 +1465,84 @@ mod ioreport { let (_, e) = meter.measure(|| burn(Duration::from_millis(200))); assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased); } else { - assert_ne!( - crate::ioreport_status(), - crate::IoReportStatus::Live, - "status Live but detect() did not select the IOReport rung" + let status = crate::ioreport_status(); + assert_ne!(status, crate::IoReportStatus::Live, "status Live but detect() did not select the IOReport rung"); + let work = Duration::from_millis(100); + let (_, e) = meter.measure(|| burn(work)); + let floor = crate::time_power_uj(work.as_nanos(), crate::DEFAULT_ACTIVE_MILLIWATTS); + eprintln!( + "declined meter: status={status:?} meter={meter:?} {work:?} of CPU work -> {} µJ, \ + provenance={:?}, measured_uj={:?}", + e.uj(), + e.provenance, + e.measured_uj + ); + e.check_provenance().expect("the declined meter's figure must be internally consistent"); + assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased, "the declined meter's figure is not ModelBased"); + assert!(e.measured_uj.is_none(), "the declined meter claimed a measurement: {:?}", e.measured_uj); + assert!(e.uj() > 0, "the declined meter metered {work:?} of CPU work as 0 µJ"); + assert!( + e.uj() >= floor, + "the declined meter metered {work:?} of CPU work as {} µJ, under the time×power model's \ + {floor} µJ at {} mW", + e.uj(), + crate::DEFAULT_ACTIVE_MILLIWATTS + ); + } + } + + /// The counter sums what its rails did. The symptom seen on 2026-09-19, + /// the counter open and reading 0 µJ across a whole run, is a reader + /// that returns a constant, and only a rail that moved can tell a + /// constant from a true zero. So: take the counter's baseline; then read + /// `GPU Energy` in the Energy Model group independently (the oracle's + /// subscription, absolute values) until one such channel rises by at + /// least 10⁶ raw units (≥ 1 µJ in every unit the counter converts), or + /// 3 s pass; then read the counter again. The counter's window contains + /// the independent one and the rail is cumulative, so if the GPU rail + /// rose, the counter must have. With the GPU idle nothing can tell a + /// constant from a true zero: that branch asserts only that the counter + /// never runs backwards, and the printed line says which branch ran. + #[test] + fn the_counter_advances_when_a_rail_it_sums_advanced() { + let Some(io) = IoReport::open() else { + let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); + eprintln!("ioreport counter: no counter opens on this host; selected={selected}"); + assert!(!selected, "detect() selected an IOReport counter that does not open"); + return; + }; + let oracle = oracle_subscribe().expect("the counter's all-channel subscription opened, the oracle's did not"); + let gpu = || { + oracle_channels(oracle.0, oracle.1, |n| n.ends_with("GPU Energy")).map(|chans| { + chans + .into_iter() + .filter(|(group, _, _)| group.eq_ignore_ascii_case("Energy Model")) + .map(|c| c.2) + .collect::>() + }) + }; + let base = io.read_uj().expect("the counter's baseline read"); + let g0 = gpu().expect("an independent read of GPU Energy"); + let started = Instant::now(); + let mut rose: i64 = 0; + while rose < 1_000_000 && started.elapsed() < Duration::from_secs(3) { + if let Some(g) = gpu() { + rose = g.iter().zip(&g0).map(|(now, before)| now - before).max().unwrap_or(0); + } + } + let window = started.elapsed(); + let after = io.read_uj().expect("the counter's second read"); + oracle_unsubscribe(oracle); + eprintln!( + "ioreport counter: GPU Energy (independent, absolute) rose {rose} raw over {window:?}; \ + the counter, over a window containing it, went {base} -> {after} µJ" + ); + assert!(after >= base, "the counter ran backwards: {base} -> {after} µJ"); + if rose >= 1_000_000 { + assert!( + after > base, + "GPU Energy rose {rose} raw inside the counter's window, but the counter read {base} -> \ + {after} µJ: it did not sum a rail it counts" ); } } @@ -1126,7 +1552,8 @@ mod ioreport { /// Why the IOReport rung was or was not selected on this host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IoReportStatus { - /// No IOReport subscription could be opened or sampled (not macOS, not Apple + /// No IOReport subscription could be opened, or the probe's subscription + /// gave fewer than two usable samples in five budgets (not macOS, not Apple /// Silicon, or Rosetta). Unavailable, /// A subscription opened, but no `CPU Energy` rail exists in the Energy Model @@ -1147,14 +1574,15 @@ pub enum IoReportStatus { /// How long the liveness probe burns CPU, on the calling thread, before /// concluding the CPU rail is not live. The probe samples its own subscription -/// to the Energy Model group, ~6–12 ms a sample, after every ~25 ms of burn — -/// about 30 intervals in the budget — and the same CPU rail must advance in -/// two of them. A live Energy Model rail changes many times a second (the M5 -/// Max GPU rail every ~28 ms), so one second is generous. If samples are slow -/// enough that fewer than two intervals fit, the probe keeps sampling past the -/// budget until it has two, up to five budgets. A slower live rail fails safe -/// — it is estimated, never over-claimed. Paid once per process; see -/// [`ioreport_status`]. +/// to the Energy Model group, ~5–12 ms a sample, after every ~25 ms of burn — +/// about 30 intervals in the budget, each a sample and the one before it — and +/// the same CPU rail must advance in two of them. A live Energy Model rail +/// changes many times a second (the M5 Max GPU rail every ~28 ms), so one +/// second is generous. A sample that fails is skipped. If samples are slow or +/// failing enough that fewer than two intervals fit, the probe keeps sampling +/// past the budget until it has two, up to five budgets. A slower live rail +/// fails safe — it is estimated, never over-claimed. Paid once per process; +/// see [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); @@ -1163,8 +1591,8 @@ pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::f /// [`Meter::detect`] selects the IOReport rung only when this is /// [`IoReportStatus::Live`]. On macOS the first call runs the probe on the /// calling thread: about one `CPU_RAIL_LIVENESS_BUDGET` of CPU burn when the -/// rail is not live (at most five, if IOReport samples are very slow), less -/// when it is. Exposed so a caller, or a receipt, can say *why* an Apple +/// rail is not live (at most five, if IOReport samples are very slow or keep +/// failing), less when it is. Exposed so a caller, or a receipt, can say *why* an Apple /// Silicon host is being metered by the time×power model instead. pub fn ioreport_status() -> IoReportStatus { #[cfg(target_os = "macos")] @@ -1201,6 +1629,14 @@ impl Meter { /// hardware interface exists the result is the time×power model — there is /// always an estimate, it just says so in its provenance. pub fn detect() -> Self { + Self::detect_with(ioreport_status) + } + + /// [`Meter::detect`] with the IOReport status supplied, so the gate can be + /// driven with every status on a host whose own status is one value. The + /// status is asked for only once RAPL and NVML are absent: the first ask + /// in a process runs the liveness probe. + fn detect_with(ioreport_status: impl FnOnce() -> IoReportStatus) -> Self { if let Some(r) = Rapl::open() { return Meter::Rapl(r); } @@ -1217,6 +1653,8 @@ impl Meter { return Meter::IoReport(io); } } + #[cfg(not(target_os = "macos"))] + let _ = ioreport_status; Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS } } @@ -1356,6 +1794,30 @@ mod tests { assert_eq!(ioreport_status(), IoReportStatus::Unavailable); } + /// `detect()` selects the IOReport rung on a `Live` status wherever the + /// counter opens, and on no other status: the others fall to the + /// time×power model at the documented default power. + #[cfg(target_os = "macos")] + #[test] + fn only_a_live_status_selects_the_ioreport_rung() { + use IoReportStatus as S; + for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { + let meter = Meter::detect_with(|| status); + assert!( + matches!(meter, Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS }), + "status {status:?} gave {meter:?}" + ); + } + let opens = ioreport::IoReport::open().is_some(); + let meter = Meter::detect_with(|| S::Live); + assert_eq!( + matches!(meter, Meter::IoReport(_)), + opens, + "a Live status gave {meter:?} where the counter {} open", + if opens { "does" } else { "does not" } + ); + } + #[test] fn rapl_wraparound_delta() { let r = Rapl { -- 2.54.0 (Apple Git-157) From 7105154289d85d59040797dc623be036dee68408 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 08:49:34 -0400 Subject: [PATCH 04/10] jcp-energy: the probe burns thread CPU, and its sampler answers to the oracle Round 3 left three mutations alive and one assertion whose margin shrank with load. This Mac is also the macOS CI runner, often at load averages of 24-104, so every pin on how the probe paces itself had to stop depending on wall time. This commit closes all four. What changes in the probe: - A tick now burns thread CPU time. It spins until the thread's own clock, clock_gettime(CLOCK_THREAD_CPUTIME_ID), has advanced 25 ms, with a 500 ms wall-clock cap. Before, a tick spun 25 ms of wall time, and on a loaded host that is a few milliseconds of CPU. Under 36 extra spinners, the old tick got a median of 3.2 ms of CPU in 48 ms. - The probe records the wall time of each of its own FFI sample calls, and the CPU and wall time of each tick. Tests read how it sampled and burned from that record, not by timing it from outside. - With no Energy Model subscription, the status is now Unavailable, through the same pure status_of as a probe that saw no interval. Before, a failed group open beside an openable counter said CpuRailAbsent, which claims a group was read and had no CPU rail. probe_with() is generic over the opener, so that path now runs in a test. Tests: - Group-only subscription: the MEDIAN wall time of the probe's own sample calls is under 50 ms. Group samples measured 5.7-12.8 ms; all-channel ones 142-714 ms. This replaces the >= 10-interval floor. No hardware test asserts a wall-clock ratio. - Burning: thread CPU per interval is at least 12.5 ms by the tests' own clock (clock_gettime_nsec_np); only the 500 ms wall cap could cut it short, and the message says so. From the probe's record, every tick burned 25 ms of thread CPU or hit the cap, and a tick came before every re-sample. - New: the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does. The oracle reads each Energy Model CPU and GPU rail absolutely, on its own all-channel subscription, before and after each of two probe samples. A rail it saw hold must read EQUAL in the probe's sample; a rail that moved must read between the two. This catches a sampler that returns constant 0 (round 3's X3), sums rails, or reads the wrong channel. - Pure-rule pins, one test each: a decrease is not an advance (X5); a rail missing from the previous sample only sets its baseline; no subscription, or no sample, is Unavailable (X4); no verdict is Unavailable. - Oracle evidence: fewer than two reads after the first is now a failure (Oracle::TooFewReads), never CpuRailMoved(false). The oracle extends its 3 s window up to 5x to get the two reads. - The cache pin is process-wide. Eight threads, released together by a barrier, ask for the status and build the meter. The static PROBE_RUNS counter must read exactly 1. - The IOReport hardware tests now share one lock. Side by side, one test's all-channel reads slowed another's group samples: max sample calls reached 132-279 ms, and 12 probes at once pushed the median to 112 ms. Serialised, the median is 6-13 ms. The suite takes ~12-16 s instead of ~5 s. - `the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail` still prints "ioreport liveness: status=". It now shows how long the oracle actually read. Evidence, on this exact tree: - M5 Max, 10 runs of the lib tests with 36 extra spinners (2 x hw.ncpu), at 1-minute load averages of 61-180: 31/31 in all 10. Median sample cost was 7.0-12.8 ms, thread CPU per interval 32.3-33.6 ms, CPU per tick 25.00 ms, and the oracle took 5-17 reads. Every run printed status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false. - M3 Ultra, same binary: 31/31. DIE_0 and DIE_1 read equal to the oracle (28237793 and 30845042), and the median sample cost was 5.9 ms. - Each mutation below was restored with git checkout, and each one fails a test: X3 constant-0 sampler, X4 no subscription -> Live, X5 decrease counts, a new rail counts, per-thread cache, sum of CPU-named rails, GPU value given as CPU, tick sleeps, tick is a no-op (both the burn call and the closure), tick burns wall time, probe on the all-channel subscription, no OnceLock, compare with the first sample, TimePower{0} when declined, None -> Live, and a coarse threshold. Summing the CPU Energy rails is the identity on the M5 Max's single rail, so it cannot fail there. On the M3 Ultra it fails: "held at 28237793 ... but the probe's sampler read 59082835". - jcp-cli, jcp-conformance (lib and pack) and jcp-intercept pass. So does the conformance suite, including "C9 energy provenance honesty", on both machines. clippy --no-deps is clean for aarch64-apple-darwin, x86_64-apple-darwin and x86_64-unknown-linux-gnu, with and without --tests. Not done here, deliberately: - C9 still meters `|| ()`, so a declined meter giving 0 passes it. That is a conformance-suite change. - The standards-build.yml comment is stale, and its job lacks --nocapture. PR #102 owns that workflow. - read_uj still samples every channel. counts_once admits CPU sub-rails that do not contain "cpu" (e.g. PACC_*) beside CPU Energy, a double count once a rail is live. The receipt carries no IoReportStatus (a wire decision). CPU_RAIL_LIVENESS_BUDGET is public on macOS only. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 587 +++++++++++++++++++----- 1 file changed, 483 insertions(+), 104 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 3859b1872..95851b835 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -313,9 +313,18 @@ mod ioreport { // ── CPU-rail liveness ─────────────────────────────────────────────────── - /// CPU burned on the probing thread before each liveness re-sample. + /// CPU time the probing thread burns, by its own CPU clock + /// ([`thread_cpu_time`]), before each liveness re-sample. Thread CPU time, + /// not wall time: on a loaded host a wall-clock spin of 25 ms can get a + /// few milliseconds of CPU, and the rail would be watched while barely + /// loaded. const PROBE_TICK: Duration = Duration::from_millis(25); + /// The most wall time one tick may take. A thread that cannot get + /// [`PROBE_TICK`] of CPU in this long (under 5% of a core) stops its tick + /// here, so a starved probe still ends. + const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); + /// In how many separate sample intervals the same CPU rail must advance /// before it counts as live. One is not enough: see [`RailWatch`]. const LIVE_RAIL_ADVANCES: u32 = 2; @@ -410,7 +419,11 @@ mod ioreport { /// Fold in the next usable sample. The first only sets the baseline. /// Each later one closes an interval with the sample before it, and a /// rail advanced over that interval when its raw value rose. A rail is - /// counted at most once per interval. + /// counted at most once per interval. A rail whose value FELL did not + /// advance: a cumulative counter that goes down was reset or wrapped, + /// and that is not energy spent. A rail missing from the previous + /// sample has nothing to be compared with, so this sample only sets its + /// baseline. fn observe(&mut self, sample: &[(String, i64)]) { let cur = cpu_rails(sample); for (key, _) in &cur { @@ -561,22 +574,72 @@ mod ioreport { } } + /// One tick of a real probe, as the probe itself saw it. Read only by the + /// tests that pin how the probe burns. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug, Clone, Copy)] + struct Tick { + /// This thread's CPU time over the tick ([`thread_cpu_time`]; zero if + /// the clock could not be read). + cpu: Duration, + /// Wall time over the tick. + wall: Duration, + } + + /// One real probe: the rule's watch, and what the probe's own FFI sample + /// calls and ticks cost, so a test can check how it sampled and burned + /// without timing it from outside. Only `watch` decides anything; the + /// records are read by those tests alone. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug)] + struct ProbeRun { + watch: RailWatch, + /// Wall time of each FFI sample call, in order, failed ones included. + sample_walls: Vec, + /// Each tick between two sample calls, in order. + ticks: Vec, + } + /// Burn CPU on this thread, re-sampling the Energy Model group after every - /// [`PROBE_TICK`] of burn, and watch each CPU energy rail **itself** by the - /// rule in [`RailWatch`]. A watch with no interval means the subscription - /// yielded fewer than two usable samples in [`PROBE_BUDGET_CAP`] budgets. + /// [`PROBE_TICK`] of this thread's CPU time, and watch each CPU energy rail + /// **itself** by the rule in [`RailWatch`]. A watch with no interval means + /// the subscription yielded fewer than two usable samples in + /// [`PROBE_BUDGET_CAP`] budgets. /// /// 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. The probe takes its own samples on /// its own subscription, so no counter's cumulative reading is touched. - fn probe_cpu_rail(sub: &EnergyModelSubscription, budget: Duration) -> RailWatch { + fn probe_cpu_rail(sub: &EnergyModelSubscription, budget: Duration) -> ProbeRun { let started = Instant::now(); - watch(|| sub.sample(), || burn(PROBE_TICK), || started.elapsed(), budget) + let mut sample_walls = Vec::new(); + let mut ticks = Vec::new(); + let watch = watch( + || { + let t = Instant::now(); + let s = sub.sample(); + sample_walls.push(t.elapsed()); + s + }, + || { + let (cpu, wall) = (thread_cpu_time(), Instant::now()); + burn(PROBE_TICK, PROBE_TICK_WALL_CAP); + let cpu = match (cpu, thread_cpu_time()) { + (Some(a), Some(b)) => b.saturating_sub(a), + _ => Duration::ZERO, + }; + ticks.push(Tick { cpu, wall: wall.elapsed() }); + }, + || started.elapsed(), + budget, + ); + ProbeRun { watch, sample_walls, ticks } } - /// The status a probe verdict reports. No verdict, because not one interval - /// could be observed, is `Unavailable`: never `Live`. + /// The status a probe verdict reports. No verdict — no Energy Model + /// subscription, or not one interval observed on it — is `Unavailable`: + /// never `Live`, and never `CpuRailAbsent`, which says a group was read + /// and had no CPU rail. fn status_of(verdict: Option) -> IoReportStatus { match verdict { None => IoReportStatus::Unavailable, @@ -586,19 +649,19 @@ mod ioreport { } } + /// The probe's whole decision, generic over how its subscription opens and + /// how it is probed, so the path with no subscription runs in tests too. + /// `open` gives `None` when IOReport does not open or has no Energy Model + /// group; then there is no verdict, and [`status_of`] says `Unavailable`. + fn probe_with(open: impl FnOnce() -> Option, probe: impl FnOnce(&S) -> RailWatch) -> IoReportStatus { + status_of(open().and_then(|sub| probe(&sub).verdict())) + } + /// This host's status from one probe on a fresh Energy Model subscription. /// Backs the cached [`crate::ioreport_status`]. pub(crate) fn probe_status(budget: Duration) -> IoReportStatus { PROBE_RUNS.fetch_add(1, Ordering::Relaxed); - let Some(sub) = EnergyModelSubscription::open() else { - // No Energy Model subscription. Tell "no IOReport" from "no such - // group" by opening the subscription the counter itself uses. - return match IoReport::open() { - Some(_) => IoReportStatus::CpuRailAbsent, - None => IoReportStatus::Unavailable, - }; - }; - status_of(probe_cpu_rail(&sub, budget).verdict()) + probe_with(EnergyModelSubscription::open, |sub| probe_cpu_rail(sub, budget).watch) } /// Every Energy Model channel in a plain sample, as `(name, absolute raw @@ -639,14 +702,50 @@ mod ioreport { strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") } - /// Spin this thread for `d`. - fn burn(d: Duration) { - let started = Instant::now(); + /// `struct timespec` on 64-bit macOS. + #[repr(C)] + struct Timespec { + tv_sec: c_long, + tv_nsec: c_long, + } + + /// macOS ``: `_CLOCK_THREAD_CPUTIME_ID`. + const CLOCK_THREAD_CPUTIME_ID: c_int = 16; + + unsafe extern "C" { + fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; + } + + /// CPU time this thread has used, user and system, by + /// `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`. `None` if the clock cannot be + /// read. + fn thread_cpu_time() -> Option { + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + (rc == 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)) + } + + /// Spin this thread until it has used `cpu` of CPU time by its own CPU + /// clock, or until `wall_cap` of wall time has passed, whichever comes + /// first. However loaded the host, the thread has then burned `cpu`, unless + /// it got under `cpu / wall_cap` of a core. With no readable thread clock + /// only the wall cap ends the spin: slower, never shorter. + fn burn(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..1_000 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + let used = match (start, thread_cpu_time()) { + (Some(a), Some(b)) => b.saturating_sub(a), + _ => Duration::ZERO, + }; + if used >= cpu || wall.elapsed() >= wall_cap { + return; + } } - std::hint::black_box(acc); } unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> u64 { @@ -1050,35 +1149,132 @@ mod ioreport { assert_eq!(status_of(Some(CpuRail::Live)), S::Live); } + /// No verdict is `Unavailable`: not `Live`, and not `CpuRailAbsent`. + #[test] + fn no_verdict_is_unavailable() { + assert_eq!(status_of(None), crate::IoReportStatus::Unavailable); + } + + /// A rail whose raw value FELL between consecutive samples did not + /// advance: a reset or a wrap is not energy spent. A rail falling at + /// every sample is Frozen with no advance; one real rise and one wrap + /// is one advance, not two. + #[test] + fn a_decrease_between_samples_is_not_an_advance() { + let (w, _, _) = scripted(|i| Some(m5(i, 541_893_638 - i as i64)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail falling at every sample read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 0)], "a fall counted as an advance"); + let wraps = |i: usize| match i { + 0 => 4_294_967_000, + 1 => 4_294_967_200, + _ => 150, + }; + let (w, _, _) = scripted(|i| Some(m5(i, wraps(i))), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rise and a wrap read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 1)], "a wrap counted as an advance"); + } + + /// A rail missing from the previous sample has nothing to be compared + /// with: the sample it appears in only sets its baseline. A `CPU + /// Energy` present in every other sample never advances, and a second + /// die that first shows up mid-probe and holds does not either. + #[test] + fn a_rail_missing_from_the_previous_sample_only_sets_its_baseline() { + let gpu_only = |i: usize| vec![("GPU Energy".to_string(), 1_000_000 * i as i64)]; + let (w, _, _) = + scripted(|i| Some(if i % 2 == 0 { m5(i, 541_893_638) } else { gpu_only(i) }), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail read as live for reappearing: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 0)], "a reappearing rail counted as an advance"); + let (w, _, _) = scripted( + |i| { + let mut s = vec![("DIE_0_CPU Energy".to_string(), 21_170_718)]; + if i >= 5 { + s.push(("DIE_1_CPU Energy".to_string(), 23_300_102)); + } + Some(s) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{:?}", advances(&w)); + assert_eq!( + advances(&w), + [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 0)], + "a rail new in a sample counted as an advance" + ); + } + + /// The probe's whole decision when there is no Energy Model + /// subscription (IOReport does not open, or has no such group), or + /// when the subscription opens and never yields a sample: no verdict, + /// so `Unavailable` — never `Live`, never `CpuRailAbsent`. + #[test] + fn no_energy_model_subscription_or_no_sample_is_unavailable() { + let no_group = probe_with(|| None::<()>, |_| unreachable!("no subscription opened, so nothing to probe")); + assert_eq!(no_group, crate::IoReportStatus::Unavailable, "no Energy Model subscription"); + let no_sample = probe_with(|| Some(()), |_| scripted(|_| None, PROBE_TICK, BUDGET).0); + assert_eq!(no_sample, crate::IoReportStatus::Unavailable, "a subscription that never samples"); + } + // ── Against the hardware ──────────────────────────────────────────── - #[repr(C)] - struct Timespec { - tv_sec: i64, - tv_nsec: i64, - } - - /// macOS ``: `_CLOCK_THREAD_CPUTIME_ID`. - const CLOCK_THREAD_CPUTIME_ID: c_int = 16; - unsafe extern "C" { - fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; + fn clock_gettime_nsec_np(clock_id: c_int) -> u64; } - /// CPU time this thread has used, user and system. - fn thread_cpu_time() -> Duration { - let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; - let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; - assert_eq!(rc, 0, "clock_gettime(CLOCK_THREAD_CPUTIME_ID) failed"); - Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) + /// CPU time this thread has used, read the tests' own way + /// (`clock_gettime_nsec_np`), not through the probe's + /// [`thread_cpu_time`]. + fn test_thread_cpu() -> Duration { + let ns = unsafe { clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID) }; + assert_ne!(ns, 0, "clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID) failed"); + Duration::from_nanos(ns) } + /// The median of some durations (the upper one of an even count). + fn median(of: &[Duration]) -> Option { + let mut v = of.to_vec(); + v.sort(); + v.get(v.len() / 2).copied() + } + + /// `cpu` of this thread's CPU time: real work to meter. It takes at + /// least `cpu` of wall time, since a thread cannot use CPU faster than + /// the clock runs. + fn cpu_work(cpu: Duration) { + burn(cpu, Duration::from_secs(30)); + } + + /// The group-only pin: a group sample takes ~5-12 ms, an all-channel + /// one 130-800 ms. + const GROUP_SAMPLE_MEDIAN_CEILING: Duration = Duration::from_millis(50); + /// What the independent oracle saw on this host. #[derive(Debug, PartialEq)] enum Oracle { NoIoReport, NoCpuRail, CpuRailMoved(bool), + /// Fewer than two reads after the first, even with the window + /// extended: too few for a moving rail to show two changes, so no + /// evidence either way. Every test fails on it. + TooFewReads, + } + + /// How many times its window the oracle may keep reading to get the + /// two reads it needs. + const ORACLE_WINDOW_CAP: u32 = 5; + + /// The failure for an oracle run with too little evidence. + fn too_few_reads(run: &OracleRun, window: Duration) -> ! { + panic!( + "oracle evidence: only {} reads after the first in {:?} (window {window:?}, extended up to \ + {ORACLE_WINDOW_CAP}x): fewer than 2 cannot show a rail changing twice, so this run can neither \ + clear nor convict the probe (the host is too loaded for an all-channel IOReport sample, or its \ + reads fail)", + run.reads, + run.took + ) } /// One oracle run: its verdict, and what it read to reach it. @@ -1087,6 +1283,9 @@ mod ioreport { verdict: Oracle, /// Reads taken after the first. reads: u32, + /// How long the oracle read for: its window, or longer if it had + /// to extend it to get two reads. + took: Duration, /// Per CPU rail, at how many reads its absolute value had changed. cpu_changes: Vec, /// The same for `GPU Energy`. When the GPU draws, this shows the @@ -1096,7 +1295,7 @@ mod ioreport { impl OracleRun { fn bare(verdict: Oracle) -> Self { - Self { verdict, reads: 0, cpu_changes: Vec::new(), gpu_changes: Vec::new() } + Self { verdict, reads: 0, took: Duration::ZERO, cpu_changes: Vec::new(), gpu_changes: Vec::new() } } } @@ -1199,7 +1398,10 @@ mod ioreport { /// Read until `window` has elapsed since `started`, counting per rail at /// how many reads the absolute value changed. Stops early once one CPU - /// rail has changed at two separate reads. + /// rail has changed at two separate reads. If `window` ends with fewer + /// than two reads after the first, keeps reading until it has two, up + /// to [`ORACLE_WINDOW_CAP`] windows, and says [`Oracle::TooFewReads`] + /// if it still has not: never `CpuRailMoved(false)` on 0-1 reads. fn oracle_watch( sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef, @@ -1215,10 +1417,11 @@ mod ioreport { let mut run = OracleRun { verdict: Oracle::CpuRailMoved(false), reads: 0, + took: Duration::ZERO, cpu_changes: vec![0; cpu_prev.len()], gpu_changes: vec![0; gpu_prev.len()], }; - while started.elapsed() < window { + while started.elapsed() < window || (run.reads < 2 && started.elapsed() < window * ORACLE_WINDOW_CAP) { let Some((cpu, gpu)) = oracle_read(sub, chans) else { continue; }; @@ -1235,6 +1438,10 @@ mod ioreport { break; } } + run.took = started.elapsed(); + if run.reads < 2 { + run.verdict = Oracle::TooFewReads; + } run } @@ -1286,9 +1493,11 @@ mod ioreport { /// which declining is not proven right — and that one fails safe: /// estimated, never over-claimed. A single batch refresh landing in both /// windows does not rescue a probe that called it live: the oracle counts - /// it as one change, not a moving rail. + /// it as one change, not a moving rail. An oracle with fewer than two + /// reads after the first is no evidence, and fails the test. #[test] fn the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail() { + let _hw = crate::ioreport_hw_lock(); let status = crate::ioreport_status(); let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); let window = crate::CPU_RAIL_LIVENESS_BUDGET * 3; @@ -1296,18 +1505,24 @@ mod ioreport { // One line, so a CI log says which branch below was actually asserted. eprintln!( "ioreport liveness: status={status:?} oracle={:?} selected={selected} \ - (oracle: {} reads over {window:?} with a helper thread burning CPU; \ + (oracle: {} reads over {:?} of a {window:?} window with a helper thread burning CPU; \ changes per CPU rail {:?}, per GPU rail {:?})", - run.verdict, run.reads, run.cpu_changes, run.gpu_changes + run.verdict, run.reads, run.took, run.cpu_changes, run.gpu_changes ); match run.verdict { + Oracle::TooFewReads => too_few_reads(&run, window), Oracle::NoIoReport => { assert!(!selected, "IOReport selected (status={status:?}) but no subscription opens"); assert_eq!(status, crate::IoReportStatus::Unavailable); } Oracle::NoCpuRail => { assert!(!selected, "IOReport selected (status={status:?}) but it has no CPU rail"); - assert_eq!(status, crate::IoReportStatus::CpuRailAbsent); + // Absent when the Energy Model group opened without one, + // Unavailable when there is no such group to open. + assert!( + matches!(status, crate::IoReportStatus::CpuRailAbsent | crate::IoReportStatus::Unavailable), + "the oracle found no CPU rail but the status is {status:?}" + ); } Oracle::CpuRailMoved(false) => { assert!( @@ -1341,33 +1556,53 @@ mod ioreport { /// status `detect()` is gated on, and checks on every outcome that the /// two agree with the oracle about what exists. /// - /// It also pins how the probe samples. When the probe runs its whole - /// budget (any verdict but Live) it must have seen at least 10 - /// intervals: a group-only sample every ~25 ms tick fits ~30 (as few as - /// 12 seen under load averages near 24 on 18 cores), while an - /// all-channel sample (0.13–0.8 s) fits 2–8. And the probing thread - /// must use at least half a tick of CPU per interval: burning between - /// samples it used 23–34 ms per interval, the whole tick plus the - /// sample; sleeping, 6–10 ms, the sample calls alone. Counted per - /// interval, not against the wall clock, because under load an IOReport - /// sample waits longer while the burn still gets its CPU. + /// It also pins how the probe samples and burns, without asserting any + /// wall-clock ratio, because this host is also a heavily loaded CI + /// runner: + /// - Group-only subscription: the MEDIAN wall time of the probe's own + /// FFI sample calls is under 50 ms. A group sample takes ~5-12 ms, an + /// all-channel one 130-800 ms. + /// - Burning: the probing thread used at least half a tick of CPU per + /// interval, by the tests' own thread clock. Each tick burns + /// [`PROBE_TICK`] of thread CPU time, so this holds at any load + /// unless the [`PROBE_TICK_WALL_CAP`] cut ticks short. And by the + /// probe's own record, every tick burned a whole [`PROBE_TICK`] or + /// ran into that cap, with one tick between every two samples. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { - let sub = EnergyModelSubscription::open(); - let (cpu_before, t) = (thread_cpu_time(), Instant::now()); - let watch = sub.as_ref().map(|sub| probe_cpu_rail(sub, crate::CPU_RAIL_LIVENESS_BUDGET)); - let (took, cpu) = (t.elapsed(), thread_cpu_time() - cpu_before); - drop(sub); - let probed = watch.as_ref().map(RailWatch::verdict); - let intervals = watch.as_ref().map_or(0, |w| w.intervals); + let _hw = crate::ioreport_hw_lock(); + // The cached probe first, so it is not burning and sampling beside + // the probe this test times. let cached = crate::ioreport_status(); - let run = oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3); + let sub = EnergyModelSubscription::open(); + let (cpu_before, t) = (test_thread_cpu(), Instant::now()); + let probe = sub.as_ref().map(|sub| probe_cpu_rail(sub, crate::CPU_RAIL_LIVENESS_BUDGET)); + let (took, cpu) = (t.elapsed(), test_thread_cpu() - cpu_before); + drop(sub); + let probed = probe.as_ref().map(|p| p.watch.verdict()); + let intervals = probe.as_ref().map_or(0, |p| p.watch.intervals); + let samples = probe.as_ref().map_or(&[][..], |p| &p.sample_walls[..]); + let ticks = probe.as_ref().map_or(&[][..], |p| &p.ticks[..]); + let sample_median = median(samples); + let tick_cpu: Vec = ticks.iter().map(|k| k.cpu).collect(); + let tick_wall: Vec = ticks.iter().map(|k| k.wall).collect(); + let window = crate::CPU_RAIL_LIVENESS_BUDGET * 3; + let run = oracle_cpu_rail(window); eprintln!( "ioreport probe: probed={probed:?} cached={cached:?} took={took:?} thread_cpu={cpu:?} \ - ({:?} per interval) intervals={intervals} advances per CPU rail {:?}; oracle={:?}", + ({:?} per interval) intervals={intervals} median sample cost={sample_median:?} over {} FFI \ + sample calls (max {:?}); ticks={} median tick cpu={:?} wall={:?}; advances per CPU rail {:?}; \ + oracle={:?} ({} reads over {:?})", cpu.checked_div(intervals), - watch.as_ref().map(advances).unwrap_or_default(), - run.verdict + samples.len(), + samples.iter().max(), + ticks.len(), + median(&tick_cpu), + median(&tick_wall), + probe.as_ref().map(|p| advances(&p.watch)).unwrap_or_default(), + run.verdict, + run.reads, + run.took ); if probed == Some(Some(CpuRail::Live)) || cached == crate::IoReportStatus::Live { assert_eq!( @@ -1378,13 +1613,17 @@ mod ioreport { ); } match run.verdict { + Oracle::TooFewReads => too_few_reads(&run, window), Oracle::NoIoReport => { assert!(matches!(probed, None | Some(None)), "the oracle could not open IOReport, the probe did"); assert_eq!(cached, crate::IoReportStatus::Unavailable); } Oracle::NoCpuRail => assert!( - matches!(probed, None | Some(Some(CpuRail::Absent))) - && cached == crate::IoReportStatus::CpuRailAbsent, + matches!( + (probed, cached), + (None, crate::IoReportStatus::Unavailable) + | (Some(Some(CpuRail::Absent)), crate::IoReportStatus::CpuRailAbsent) + ), "the oracle found no CPU rail, the probe reported {probed:?} (cached={cached:?})" ), Oracle::CpuRailMoved(false) => assert!( @@ -1397,11 +1636,30 @@ mod ioreport { "the oracle saw a CPU rail keep moving but the probe reported {probed:?}" ), } - if matches!(probed, Some(Some(CpuRail::Frozen | CpuRail::Absent))) { + if probe.is_some() { + let m = sample_median.expect("the probe made no FFI sample call"); assert!( - intervals >= 10, - "the probe saw {intervals} intervals in {took:?}: it is not re-sampling the Energy Model \ - group after every {PROBE_TICK:?} tick (an all-channel subscription fits 2-8)" + m < GROUP_SAMPLE_MEDIAN_CEILING, + "the median of the probe's {} FFI sample calls took {m:?}, not under \ + {GROUP_SAMPLE_MEDIAN_CEILING:?}: the probe is not sampling the Energy Model group alone (a \ + group sample takes ~5-12 ms, an all-channel one 130-800 ms)", + samples.len() + ); + assert_eq!( + ticks.len() + 1, + samples.len(), + "{} ticks between {} sample calls: the probe must tick once before every re-sample", + ticks.len(), + samples.len() + ); + } + for (i, k) in ticks.iter().enumerate() { + assert!( + k.cpu >= PROBE_TICK || k.wall >= PROBE_TICK_WALL_CAP, + "tick {i} used {:?} of this thread's CPU in {:?}: a tick burns {PROBE_TICK:?} of thread CPU \ + time, and only its {PROBE_TICK_WALL_CAP:?} wall cap may cut that short", + k.cpu, + k.wall ); } if intervals > 0 { @@ -1410,27 +1668,46 @@ mod ioreport { per_interval >= PROBE_TICK / 2, "the probing thread used {cpu:?} of CPU over {intervals} intervals, {per_interval:?} \ each, under half of each {PROBE_TICK:?} tick: the probe must burn CPU between samples, \ - not wait" + not wait. Each tick spins until the thread's own CPU clock has advanced {PROBE_TICK:?}, \ + so on any load only its {PROBE_TICK_WALL_CAP:?} wall cap could cut this short" ); } } - /// The probe runs once per process, however many times the status is - /// asked for or the meter is built. Counted, not timed: a probe on a - /// live rail is over in tens of milliseconds, which a clock cannot tell - /// from a cache hit. + /// The probe runs once per PROCESS, however many threads ask for the + /// status or build the meter at once, and however many times. Counted + /// by the process-wide [`PROBE_RUNS`], not timed: a probe on a live rail + /// is over in tens of milliseconds, which a clock cannot tell from a + /// cache hit. Eight threads released together by a barrier each ask + /// three times; a cache kept per thread would probe at least eight + /// times. #[test] fn the_liveness_status_is_probed_once_and_cached() { - let first = crate::ioreport_status(); - for _ in 0..3 { - assert_eq!(crate::ioreport_status(), first, "the cached status changed between calls"); - let _ = crate::Meter::detect(); - } - assert_eq!( - PROBE_RUNS.load(Ordering::Relaxed), - 1, - "the liveness probe did not run exactly once in this process" - ); + let _hw = crate::ioreport_hw_lock(); + const THREADS: usize = 8; + let barrier = std::sync::Barrier::new(THREADS); + let statuses: Vec = std::thread::scope(|s| { + let workers: Vec<_> = (0..THREADS) + .map(|_| { + s.spawn(|| { + barrier.wait(); + let first = crate::ioreport_status(); + let _ = crate::Meter::detect(); + for _ in 0..3 { + assert_eq!(crate::ioreport_status(), first, "the cached status changed between calls"); + let _ = crate::Meter::detect(); + } + first + }) + }) + .collect(); + workers.into_iter().map(|w| w.join().expect("a probing thread panicked")).collect() + }); + let runs = PROBE_RUNS.load(Ordering::SeqCst); + eprintln!("ioreport cache: {THREADS} threads saw {statuses:?}; probe runs in this process = {runs}"); + assert!(statuses.iter().all(|s| *s == statuses[0]), "threads saw different statuses: {statuses:?}"); + assert_eq!(statuses[0], crate::ioreport_status(), "the spawned threads and this one disagree"); + assert_eq!(runs, 1, "the liveness probe did not run exactly once in this process"); } /// When the rung IS selected, its own CPU rail is live on a fresh probe @@ -1444,6 +1721,7 @@ mod ioreport { /// Never a floored or zero figure for work that ran. #[test] fn a_selected_ioreport_meter_meters_cpu_work() { + let _hw = crate::ioreport_hw_lock(); let meter = crate::Meter::detect(); if let crate::Meter::IoReport(io) = &meter { assert_eq!( @@ -1452,23 +1730,23 @@ mod ioreport { "detect() selected IOReport although the liveness status is not Live" ); let rail = EnergyModelSubscription::open() - .and_then(|sub| probe_cpu_rail(&sub, crate::CPU_RAIL_LIVENESS_BUDGET).verdict()); + .and_then(|sub| probe_cpu_rail(&sub, crate::CPU_RAIL_LIVENESS_BUDGET).watch.verdict()); assert_eq!(rail, Some(CpuRail::Live), "IOReport selected but a fresh probe says {rail:?}"); let _ = io.read_uj(); // the first read only sets the baseline sample let before = io.read_uj().expect("baseline read"); - burn(Duration::from_millis(200)); + cpu_work(Duration::from_millis(200)); let after = io.read_uj().expect("work read"); assert!( after > before, "a live CPU rail metered 200 ms of CPU work as zero: before={before} after={after}" ); - let (_, e) = meter.measure(|| burn(Duration::from_millis(200))); + let (_, e) = meter.measure(|| cpu_work(Duration::from_millis(200))); assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased); } else { let status = crate::ioreport_status(); assert_ne!(status, crate::IoReportStatus::Live, "status Live but detect() did not select the IOReport rung"); let work = Duration::from_millis(100); - let (_, e) = meter.measure(|| burn(work)); + let (_, e) = meter.measure(|| cpu_work(work)); let floor = crate::time_power_uj(work.as_nanos(), crate::DEFAULT_ACTIVE_MILLIWATTS); eprintln!( "declined meter: status={status:?} meter={meter:?} {work:?} of CPU work -> {} µJ, \ @@ -1491,6 +1769,90 @@ mod ioreport { } } + /// Every channel whose name ends with `CPU Energy` or `GPU Energy`, + /// keyed by (name, ordinal among the same-named ones), in order: the + /// oracle's own matching, applied alike to its reads and the probe's. + fn keyed_rails(chans: impl IntoIterator) -> Vec<((String, u32), i64)> { + let mut out: Vec<((String, u32), i64)> = Vec::new(); + for (name, raw) in chans { + if name.ends_with("CPU Energy") || name.ends_with("GPU Energy") { + let ordinal = out.iter().filter(|((n, _), _)| *n == name).count() as u32; + out.push(((name, ordinal), raw)); + } + } + out + } + + /// The probe's own FFI sampler reads each CPU rail's true absolute + /// value. Around each of two probe samples, the oracle reads the same + /// rails of the Energy Model group absolutely on its own all-channel + /// subscription, once before and once after. A rail the oracle saw + /// hold across those two reads (a frozen rail is constant) must read + /// EQUAL in the probe's sample; one that moved must read within them. + /// So a sampler that returns a constant, gives a rail a sum of rails, or + /// reads the wrong channel fails, on a frozen host as on a live one. + /// `GPU Energy` is held to the same rule; where the GPU draws it moves, + /// so the probe's sample must also be fresh. And the rails the + /// implementation calls CPU rails are exactly the oracle's. + #[test] + fn the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does() { + let _hw = crate::ioreport_hw_lock(); + let Some(sub) = EnergyModelSubscription::open() else { + // No Energy Model subscription, so no sampler to check: the + // oracle must not find a CPU rail in that group either. + let found = oracle_subscribe().and_then(|o| { + let chans = oracle_channels(o.0, o.1, |n| n.ends_with("CPU Energy")); + oracle_unsubscribe(o); + chans + }); + eprintln!("ioreport sampler: no Energy Model subscription opens; the oracle read {found:?}"); + let in_group = found.unwrap_or_default().iter().filter(|(g, _, _)| g.eq_ignore_ascii_case("Energy Model")).count(); + assert_eq!(in_group, 0, "the oracle reads CPU rails in the Energy Model group, but the probe cannot subscribe to it"); + return; + }; + let oracle = oracle_subscribe().expect("the probe's Energy Model subscription opened, the oracle's all-channel one did not"); + let read = |when: &str| -> Vec<((String, u32), i64)> { + let chans = oracle_channels(oracle.0, oracle.1, |n| n.ends_with("CPU Energy") || n.ends_with("GPU Energy")) + .unwrap_or_else(|| panic!("the oracle's read {when} the probe's sample failed")); + keyed_rails(chans.into_iter().filter(|(g, _, _)| g.eq_ignore_ascii_case("Energy Model")).map(|(_, n, v)| (n, v))) + }; + let names = |v: &[((String, u32), i64)]| v.iter().map(|(k, _)| k.clone()).collect::>(); + let mut seen = Vec::new(); + for round in 0..2 { + let before = read("before"); + let sample = sub.sample().expect("the probe's sampler gave no sample"); + let after = read("after"); + let probe = keyed_rails(sample.iter().cloned()); + assert_eq!(names(&probe), names(&before), "the probe's sampler and the oracle read different rails"); + assert_eq!(names(&after), names(&before), "the oracle's rails changed between two of its reads"); + let oracle_cpu: Vec<(String, u32)> = + names(&before).into_iter().filter(|(n, _)| n.ends_with("CPU Energy")).collect(); + let implementation: Vec<(String, u32)> = + cpu_rails(&sample).into_iter().map(|(k, _)| (k.name, k.ordinal)).collect(); + assert_eq!(implementation, oracle_cpu, "the implementation's CPU rails are not the ones the oracle reads"); + for (((key, b), (_, p)), (_, a)) in before.iter().zip(&probe).zip(&after) { + let (b, p, a) = (*b, *p, *a); + let rail = format!("{}#{}", key.0, key.1); + if b == a { + assert_eq!( + p, b, + "{rail} held at {b} across the oracle's reads either side of the probe's sample, but the \ + probe's sampler read {p}" + ); + } else { + assert!( + (b.min(a)..=b.max(a)).contains(&p), + "{rail} moved {b} -> {a} across the oracle's reads either side of the probe's sample, but \ + the probe's sampler read {p}, outside them" + ); + } + seen.push(format!("{rail} r{round}: {b} | {p} | {a} ({})", if b == a { "held: equal" } else { "moved: within" })); + } + } + oracle_unsubscribe(oracle); + eprintln!("ioreport sampler: oracle before | probe | oracle after, per rail: {seen:?}"); + } + /// The counter sums what its rails did. The symptom seen on 2026-09-19, /// the counter open and reading 0 µJ across a whole run, is a reader /// that returns a constant, and only a rail that moved can tell a @@ -1505,6 +1867,7 @@ mod ioreport { /// never runs backwards, and the printed line says which branch ran. #[test] fn the_counter_advances_when_a_rail_it_sums_advanced() { + let _hw = crate::ioreport_hw_lock(); let Some(io) = IoReport::open() else { let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); eprintln!("ioreport counter: no counter opens on this host; selected={selected}"); @@ -1552,13 +1915,13 @@ mod ioreport { /// Why the IOReport rung was or was not selected on this host. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum IoReportStatus { - /// No IOReport subscription could be opened, or the probe's subscription - /// gave fewer than two usable samples in five budgets (not macOS, not Apple - /// Silicon, or Rosetta). + /// No subscription to the Energy Model group could be opened (IOReport does + /// not open — not macOS, not Apple Silicon, or Rosetta — or it has no such + /// group), or the subscription gave fewer than two usable samples in five + /// budgets. Unavailable, - /// A subscription opened, but no `CPU Energy` rail exists in the Energy Model - /// group (or there is no such group) — an unfamiliar channel naming. Never - /// selected. + /// The Energy Model group was sampled, but it has no `CPU Energy` rail — an + /// unfamiliar channel naming. Never selected. CpuRailAbsent, /// The CPU energy rail exists but did not keep advancing while CPU was /// burned: no one CPU rail advanced in two separate sample intervals. It @@ -1574,9 +1937,11 @@ pub enum IoReportStatus { /// How long the liveness probe burns CPU, on the calling thread, before /// concluding the CPU rail is not live. The probe samples its own subscription -/// to the Energy Model group, ~5–12 ms a sample, after every ~25 ms of burn — -/// about 30 intervals in the budget, each a sample and the one before it — and -/// the same CPU rail must advance in two of them. A live Energy Model rail +/// to the Energy Model group, ~5–12 ms a sample, after every 25 ms of the +/// calling thread's own CPU time (a tick that cannot get that in 500 ms of wall +/// time stops there) — about 30 intervals in the budget on an idle host, fewer +/// under load, each a sample and the one before it — and the same CPU rail +/// must advance in two of them. A live Energy Model rail /// changes many times a second (the M5 Max GPU rail every ~28 ms), so one /// second is generous. A sample that fails is skipped. If samples are slow or /// failing enough that fewer than two intervals fit, the probe keeps sampling @@ -1590,8 +1955,8 @@ pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::f /// /// [`Meter::detect`] selects the IOReport rung only when this is /// [`IoReportStatus::Live`]. On macOS the first call runs the probe on the -/// calling thread: about one `CPU_RAIL_LIVENESS_BUDGET` of CPU burn when the -/// rail is not live (at most five, if IOReport samples are very slow or keep +/// calling thread: about one `CPU_RAIL_LIVENESS_BUDGET` of wall time burning CPU +/// when the rail is not live (at most five, if IOReport samples are very slow or keep /// failing), less when it is. Exposed so a caller, or a receipt, can say *why* an Apple /// Silicon host is being metered by the time×power model instead. pub fn ioreport_status() -> IoReportStatus { @@ -1742,12 +2107,25 @@ fn time_power_uj(elapsed_ns: u128, milliwatts: u64) -> u64 { u64::try_from(elapsed_ns.saturating_mul(milliwatts as u128) / 1_000_000).unwrap_or(u64::MAX) } +/// Serialises the tests that use IOReport, or may run the liveness probe, on +/// this host. Otherwise the test harness runs them side by side, and one test's +/// all-channel reads and CPU burn slow another's IOReport samples: the probe's +/// sample times and the oracle's read count would measure the sibling tests, +/// not the host. A test that panics while holding it does not poison the rest. +#[cfg(all(test, target_os = "macos"))] +fn ioreport_hw_lock() -> std::sync::MutexGuard<'static, ()> { + static HW: std::sync::Mutex<()> = std::sync::Mutex::new(()); + HW.lock().unwrap_or_else(std::sync::PoisonError::into_inner) +} + #[cfg(test)] mod tests { use super::*; #[test] fn detect_always_yields_a_meter_with_honest_provenance() { + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); let meter = Meter::detect(); let (out, e) = meter.measure(|| 21 * 2); assert_eq!(out, 42); @@ -1800,6 +2178,7 @@ mod tests { #[cfg(target_os = "macos")] #[test] fn only_a_live_status_selects_the_ioreport_rung() { + let _hw = ioreport_hw_lock(); use IoReportStatus as S; for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { let meter = Meter::detect_with(|| status); -- 2.54.0 (Apple Git-157) From a0daa3da6246faa9bd08da9cc97b6bd4234f0295 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 16:17:28 -0400 Subject: [PATCH 05/10] jcp-energy: the production probe runs under test, and no pin reads wall time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 5's verifier found five problems. (1) The pin "median WALL time of the probe's sample calls < 50 ms" failed on correct code while another process sampled IOReport over all channels: a flake on this host, which is the macOS CI runner. (2) The glue between the tested parts was itself untested. probe_status() and the closure it handed probe_with() ran only against the hardware, where the subscription always opens and the CPU rail never moves, so a no-subscription -> Live branch, a single advance -> Live in that closure, and a no-op tick all survived, and PROBE_RUNS counted calls to the wrapper, not probe runs. (3) Nothing pinned which clock the probe reads as thread CPU. (4) A sampler that repeated a stale sample passed on frozen rails, where a stale value equals a fresh one. (5) Nothing pinned the values the rule is handed. What changes in the code: - One production chain, and every link is thin. ioreport_status() caches host_status() in a process-wide OnceLock. host_status() has no branch: it counts itself in HOST_PROBE_RUNS, then returns status_of(probe_with(the real opener, burn_tick, HostClocks, the budget)). It is the only production caller of probe_with. Meter::detect() is detect_with(ioreport_status), which selects the rung only when the pure admits(status) does, and admits() takes only Live. - probe_with() is generic over the opener, the sampler, the tick and the clocks, and returns a ProbeRun: the watch, and a record of every sample call (when it began, wall and thread CPU, and the keyed CPU and GPU Energy values handed to the rule) and of every tick (thread CPU and wall). The record is kept in every build; only tests read it. - The FFI read only reads: (group, name, raw) per channel. group_sample() does the rest, and tests run it. - Fault injection inside the real parts, in test builds only (#[cfg(test)], thread-local, undone by a guard): EnergyModelSubscription::open can be made to fail, and its FFI read can be passed through or replaced by a script. A tap keeps each ProbeRun. A dependant, or an integration test, links a library with none of this. - counts_once, the counter's filter, drops PACC*, EACC*, MACC* and *CPM* (with their _SRAM rails) beside the CPU aggregates: eoc-meter's rule from 7aa42418. On a live rail the counter would have counted that CPU energy twice. - Two #[doc(hidden)] counters, ioreport_probe_runs() and ioreport_energy_model_opens(), so an integration test can count. Tests (45: 44 in the library, 1 integration test): - No pin reads wall time. Group-only subscription, by structure: every sample the real probe took carried exactly the Energy Model group's channels as the oracle counts them on its own all-channel subscription (364 of 11,886-12,038 on the M5 Max, 645 of 14,345 on the M3 Ultra), none from another group. By cost: the median THREAD CPU of the probe's FFI sample calls, by the tests' own clock, is under 50 ms. Measured: 5.8-6.2 ms, with or without an all-channel sampler beside it. - The real host_status(), uncached, under fault injection. Its opener failing: Unavailable, with no read, no tick and no CPU burned. A scripted batch refresh: Frozen, one advance. A scripted constant rail: Frozen, no advance. A rail rising at every read: Live, after three reads and two ticks. Two dies stepping once each, in different intervals: Frozen. In each, between every two reads the thread burned 25 ms of its CPU, or the tick ran into the 500 ms wall cap, by the tests' own clocks, and no tick shows more CPU than wall time. - Clock identity, on the tests' own clock ids (their literals 16 and 12, read with clock_gettime_nsec_np). The probe's thread clock advances under 5 ms across a 100 ms sleep, and under 5 ms while a helper thread burns 100 ms and the process clock advances at least 100 ms. It tracks 30 ms of this thread's CPU to within 1 ms. - Values handed to the rule. The real host_status() runs, uncached, while an oracle on another thread reads back to back. Rails only count up. So for each sample handed to the rule, a rail the oracle saw hold must have been handed the oracle's value exactly, and a rail that moved a value between the oracle reads either side. The CPU and GPU key sets must equal the oracle's. Freshness is forced: the probe's second read is held until the oracle sees a rail differ from the first read (or has looked twice, and 3 s have passed). Every later read must then show the rise. In every run below, on both chips, the GPU rail gave that witness within 0.06-0.9 s, at the oracle's first or second read. Pure versions of the two-die case run through probe_with. - Once per process, counted: eight threads, released by a barrier, ask for the status and build the meter. HOST_PROBE_RUNS and the Energy Model opens must both be 1. tests/ioreport_probed_once.rs does this in a process of its own. The macOS CI job runs only `--lib`, which skips that file, so a lib test does it too: the_liveness_probe_runs_once_in_a_fresh_process re-runs its own binary, filtered to itself, with a marker in the environment, and fails if that process fails or ran no test. - admits() on all four statuses. detect_with() gives the time×power model on every status but Live, and the IOReport meter on Live wherever the counter opens. - counts_once_drops_subrails pins the rule on the real channel names of both chips. Its summed lists are exactly what counts_once admits in a dump of each chip's Energy Model group taken for this commit (17 of 364 channels on the M5 Max, 29 of 645 on the M3 Ultra). In that dump, CPU Energy = MCPU0 + MCPU1 + PCPU to 1 mJ, and DIE_n_CPU Energy = EACC + PACC0 + PACC1 to 1-2 mJ. - No early return passes by default. Where a test finds no Energy Model subscription, or no counter, the oracle must find none either. - Kept: every earlier pin, and the "ioreport liveness: status=" line. Found while verifying: the freshness test could fail on correct code. Its witness only waited 3 s. Under load one all-channel oracle read can take longer, and a witness seen after the probe's second read had been released was then asserted as a floor under it. It failed that way once, under an unrelated mutation. The witness now counts only if it was seen before the release, and the wait is counted in the oracle's reads. Evidence, on this tree less one type alias (see the last item): - Mutations. Each was a temporary edit, then the whole suite (library and integration tests), then git checkout; the tree was clean after each. All 17 fail at least one test: - every sample compared with the first: 10 tests ("a batch refresh read as live: [("CPU Energy#0", 2)]"); - a decrease counts as an advance: the pure decrease test; - no subscription -> Live inside host_status: host_status with its opener failing ("no Energy Model subscription"); - a single advance -> Live in host_status's composition: 2 host_status tests; the same inside probe_with's production sample closure (it fabricates later advances): 4 tests; - a no-op tick passed by host_status: 5 tests ("tick 0 used 9.209µs of this thread's CPU in 9.291µs ... a tick must burn 25ms of thread CPU"); - a per-thread cache: the lib fresh-process pin ("probe runs = 8") and tests/ioreport_probed_once.rs; - the sampler returns constant 0: both oracle tests ("CPU Energy#0 held at 2588973125 ... the probe's sampler read 0"); - a stale sampler, three ways: repeating the process's first sample (2 tests), each subscription address's first sample (2), and each live subscription's first sample, so only later reads are stale (1, the forced witness: "GPU Energy#0 moved 126278986003314 -> 126278989490261 ... probe read 1, but the probe handed the rule 126278982679214, outside them: a stale or made-up sample"); - the thread clock read as CLOCK_MONOTONIC: 6 tests; as CLOCK_PROCESS_CPUTIME_ID: the clock-identity test; - admits() accepts CpuRailAbsent: 2 tests; detect() gates only on CpuRailFrozen: "status Unavailable gave IoReport(IoReport)"; - the counter sums every Energy Model rail: counts_once_drops_subrails; the probe hands the rule the sum of every rail as its CPU rail: 10 tests, the hardware ones included. - Contention. The lib test binary ran 5 times while another process sampled IOReport over all channels in a tight loop (384 samples, median 144-313 ms each), at 1-minute load averages of 10.6-12.1: 44/44 passed in all 5. The probe's median thread CPU per sample call was 5.99-6.22 ms, its median tick 25.06 ms of thread CPU, 30-31 intervals. Every run printed status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false, and every run asserted staleness. The helper was killed by a trap; none remains. - M3 Ultra, macOS 27.0.1, the same binary once: 44/44. DIE_0 and DIE_1 read equal to the oracle (38945708 and 42131327 mJ), the median sample cost 5.8 ms of thread CPU, staleness was asserted through the GPU rail, and the fresh process counted probe runs = 1. - jcp-cli, jcp-conformance (lib and pack) and jcp-intercept pass. The conformance binary prints "all checks passed", C9 included. clippy -p jcp-energy --no-deps --tests is clean for aarch64-apple-darwin, x86_64-apple-darwin and x86_64-unknown-linux-gnu. The freshness test's new Witness struct drew one type_complexity warning. A type alias, added after the runs above, clears it; the suite passed again after it (44 + 1). Not done here, deliberately: - The macOS CI job's `cargo test --lib -p jcp-energy` still skips tests/ioreport_probed_once.rs; the lib pin covers the same ground there. The workflow is PR #102's. - C9 still meters `|| ()`, so a declined meter giving 0 passes it. That is a conformance-suite change. - read_uj still samples every channel. ANE energy is not summed on either chip: there is no `ANE Energy` aggregate, and counting its rails needs a whole-sample rule, which eoc-meter leaves for later too. The receipt carries no IoReportStatus (a wire decision). CPU_RAIL_LIVENESS_BUDGET is public on macOS only. - The same defects in jouleclaw-energy, proof-cascade and sandbox-meter. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 2284 ++++++++++++++--- .../jcp-energy/tests/ioreport_probed_once.rs | 68 + 2 files changed, 1955 insertions(+), 397 deletions(-) create mode 100644 jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 95851b835..aa629c407 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -312,6 +312,24 @@ mod ioreport { } // ── CPU-rail liveness ─────────────────────────────────────────────────── + // + // The production chain, outermost first, and what pins each link: + // + // - `crate::ioreport_status()` caches one status per process in a + // `OnceLock`. `tests/ioreport_probed_once.rs`, and the lib test + // `the_liveness_probe_runs_once_in_a_fresh_process`, run it cold, from + // eight threads at once, in a process of their own, and count real runs. + // - `host_status()` is the whole composition, with no branch: the real + // opener, tick and clocks handed to `probe_with`, and `status_of` on the + // verdict. The tests run this very function, with its subscription + // failing, with scripted samples, and against the hardware. + // - `probe_with()` is the loop and its record, generic over opener, + // sampler, tick and clocks. The tests drive it with fakes. + // - `watch()` and `RailWatch` are the rule, on scripted samples. + // `status_of()` is a pure mapping. + // - The real parts: `EnergyModelSubscription` (opener and FFI read), checked + // against an independent oracle, structurally and value by value; + // `burn_tick()` and `HostClocks`, checked on the tests' own clocks. /// CPU time the probing thread burns, by its own CPU clock /// ([`thread_cpu_time`]), before each liveness re-sample. Thread CPU time, @@ -333,10 +351,26 @@ mod ioreport { /// stops after this many budgets. const PROBE_BUDGET_CAP: u32 = 5; - /// How many times the real probe, [`probe_status`], has run in this - /// process. [`crate::ioreport_status`] caches its answer, so this never - /// passes one; a test pins that by counting rather than by timing. - static PROBE_RUNS: AtomicUsize = AtomicUsize::new(0); + /// How many times the real probe, [`host_status`], has run in this process. + /// It is the first thing `host_status` does. [`crate::ioreport_status`] + /// caches its answer, so this never passes one; a test pins that by + /// counting rather than by timing. + static HOST_PROBE_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// How many times this process has opened a subscription to the Energy + /// Model group: once per probe, whatever runs it. A path that probed + /// without going through [`host_status`] would still show here. + static ENERGY_MODEL_OPENS: AtomicUsize = AtomicUsize::new(0); + + /// [`HOST_PROBE_RUNS`], for `crate::ioreport_probe_runs`. + pub(crate) fn host_probe_runs() -> usize { + HOST_PROBE_RUNS.load(Ordering::SeqCst) + } + + /// [`ENERGY_MODEL_OPENS`], for `crate::ioreport_energy_model_opens`. + pub(crate) fn energy_model_opens() -> usize { + ENERGY_MODEL_OPENS.load(Ordering::SeqCst) + } /// What the liveness probe saw of the CPU energy rail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -351,15 +385,15 @@ mod ioreport { Absent, } - /// One plain sample of the Energy Model group, as the FFI reads it: every - /// channel's name and its ABSOLUTE raw cumulative integer, in the order - /// IOReport lists them. The FFI compares nothing; [`RailWatch::observe`] - /// pairs each sample with the one before it. + /// The Energy Model channels of one sample, as the rule is handed them: + /// every in-group channel's name and its ABSOLUTE raw cumulative integer, + /// in the order IOReport lists them. [`RailWatch::observe`] pairs each + /// sample with the one before it. type Sample = Vec<(String, i64)>; - /// A CPU rail within one sample: its channel name, and its ordinal among - /// the channels of that name in the same sample. Names are not unique: an - /// M3 Ultra's Energy Model group has 645 channels and 264 duplicated names + /// A rail within one sample: its channel name, and its ordinal among the + /// channels of that name in the same sample. Names are not unique: an M3 + /// Ultra's Energy Model group has 645 channels and 264 duplicated names /// (`ECPUDTL00`..`22` once per die, with no `DIE_` prefix). The CPU /// aggregates are unique today on both chips, but keyed by name alone, two /// same-named rails stepping together in one refresh would count as two @@ -370,11 +404,13 @@ mod ioreport { ordinal: u32, } - /// The CPU energy rails in one sample, each with its absolute raw value. - fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + /// The channels of one sample whose names `pick` accepts, each keyed by + /// [`RailKey`], with its absolute raw value. Item by item: no arithmetic + /// across channels. + fn rails_where(sample: &[(String, i64)], pick: fn(&str) -> bool) -> Vec<(RailKey, i64)> { let mut rails: Vec<(RailKey, i64)> = Vec::new(); for (name, raw) in sample { - if is_cpu_energy_rail(name) { + if pick(name) { let ordinal = rails.iter().filter(|(k, _)| k.name == *name).count() as u32; rails.push((RailKey { name: name.clone(), ordinal }, *raw)); } @@ -382,6 +418,18 @@ mod ioreport { rails } + /// The CPU energy rails in one sample, each with its absolute raw value. + fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + rails_where(sample, is_cpu_energy_rail) + } + + /// The `GPU Energy` rails in one sample. The rule never reads them. The + /// probe's record keeps them, because where the GPU draws they move, and a + /// test can then tell a fresh sample from a stale one. + fn gpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + rails_where(sample, is_gpu_energy_rail) + } + /// The liveness rule over plain samples, kept apart from the FFI so all of /// it, including the pairing of each sample with the previous one, runs on /// scripted samples in tests. @@ -404,7 +452,7 @@ mod ioreport { /// Each interval is a sample and the one **before** it. Compared with the /// first sample instead, a rail that stepped once would show a rise in /// every interval after the step, and one refresh would read as live. - #[derive(Debug, Default)] + #[derive(Debug, Default, Clone)] struct RailWatch { /// Sample intervals observed so far: pairs of consecutive usable samples. intervals: u32, @@ -473,13 +521,13 @@ mod ioreport { } } - /// The probe loop, generic over where samples come from, what happens + /// The rule's loop, generic over where samples come from, what happens /// between them and the clock, so tests drive it with scripted samples and - /// a scripted clock exactly as [`probe_cpu_rail`] drives it with IOReport, - /// a CPU spin and [`Instant`]. Take a sample; stop when - /// [`RailWatch::wants_more`] says so; otherwise `tick` and sample again. A - /// sample that fails is skipped, so the next usable one is compared with - /// the last usable one. `elapsed` is the time since the probe started. + /// a scripted clock exactly as [`probe_with`] drives it. Take a sample; + /// stop when [`RailWatch::wants_more`] says so; otherwise `tick` and sample + /// again. A sample that fails is skipped, so the next usable one is + /// compared with the last usable one. `elapsed` is the time since the + /// probe started. fn watch( mut sample: impl FnMut() -> Option, mut tick: impl FnMut(), @@ -498,14 +546,60 @@ mod ioreport { } } + /// One channel of one sample, as the FFI reads it, and nothing more: its + /// group, its name and its raw integer. Everything done with it after that + /// is [`group_sample`]'s, which tests can run. + #[derive(Debug, Clone)] + struct Item { + group: Option, + name: Option, + raw: i64, + } + + /// One sample of the probe's subscription: how many channels it carried, + /// how many of those were outside the Energy Model group, and the in-group + /// channels the rule is handed. + #[derive(Debug)] + struct GroupSample { + channels: usize, + outside_group: usize, + values: Sample, + } + + /// A [`GroupSample`] from one read's channels, one channel at a time. A + /// channel's value is its own raw integer; nothing is summed or carried + /// across channels. A channel outside the Energy Model group is counted + /// and not handed on. A channel with no name cannot be keyed, so it is not + /// handed on either. + fn group_sample(items: Vec) -> GroupSample { + let channels = items.len(); + let mut outside_group = 0; + let mut values = Vec::with_capacity(channels); + for item in items { + if !item.group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case("Energy Model")) { + outside_group += 1; + continue; + } + if let Some(name) = item.name { + values.push((name, item.raw)); + } + } + GroupSample { channels, outside_group, values } + } + + /// Where the probe's samples come from: the real subscription, or, in + /// tests, a script. + trait Sampler { + fn sample(&self) -> Option; + } + /// The liveness probe's own subscription, to the Energy Model group only. /// - /// The counter's subscription covers every channel (~11,860 on an M5 Max), - /// and one sample of it has been measured at 0.13–0.8 s on an M5 Max and - /// ~0.14 s on an M3 Ultra, mostly spent waiting rather than burning CPU. A - /// probe on it re-sampled every ~150 ms at best, not after every ~25 ms of - /// burn. The group alone samples in ~5–12 ms on both, and carries the same - /// absolute CPU rail values. + /// The counter's subscription covers every channel (~11,890 on an M5 Max, + /// ~14,350 on an M3 Ultra), and one sample of it takes 0.13-0.8 s of wall + /// time and ~0.1 s of CPU. The group alone (364 channels on an M5 Max, 645 + /// on an M3 Ultra) samples in ~5-12 ms and ~8 ms of CPU, and carries the + /// same absolute CPU rail values. struct EnergyModelSubscription { sub: IOReportSubscriptionRef, channels: CFMutableDictionaryRef, @@ -522,7 +616,14 @@ mod ioreport { impl EnergyModelSubscription { /// `None` when IOReport cannot open, or has no Energy Model group. + /// Counted in [`ENERGY_MODEL_OPENS`], opened or not. In this crate's + /// unit tests a test can make it fail (`tests::seam`); nothing else can. fn open() -> Option { + ENERGY_MODEL_OPENS.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] + if tests::seam::open_fails() { + return None; + } unsafe { let group = CFStringCreateWithCString( std::ptr::null_mut(), @@ -559,81 +660,210 @@ mod ioreport { } } - /// One plain sample (no delta): every Energy Model channel's name and - /// absolute raw integer. `None` when IOReport yields no usable sample. - fn sample(&self) -> Option { - unsafe { - let sample = IOReportCreateSamples(self.sub, self.channels, std::ptr::null()); - if sample.is_null() { - return None; - } - let values = energy_model_values(sample); - CFRelease(sample); - values + /// One read's channels. In this crate's unit tests a test can stand in + /// for the FFI read, passing it through or replacing it + /// (`tests::seam`); nothing else can. + fn items(&self) -> Option> { + let read = || unsafe { self.read_items() }; + #[cfg(test)] + if let Some(items) = tests::seam::sample(&read) { + return items; } + read() + } + + /// One plain sample (no delta) of this subscription, channel by + /// channel. `None` when IOReport yields no usable sample. + unsafe fn read_items(&self) -> Option> { + let sample = IOReportCreateSamples(self.sub, self.channels, std::ptr::null()); + if sample.is_null() { + return None; + } + let items = sample_items(sample); + CFRelease(sample); + items } } - /// One tick of a real probe, as the probe itself saw it. Read only by the - /// tests that pin how the probe burns. + impl Sampler for EnergyModelSubscription { + fn sample(&self) -> Option { + self.items().map(group_sample) + } + } + + /// Every channel of a plain sample, as `(group, name, raw integer)` in + /// IOReport's order. Raw integers, before unit conversion, so a small + /// advance in a fine unit cannot truncate to zero. `None` when the sample + /// carries no channel array. + unsafe fn sample_items(sample: CFDictionaryRef) -> Option> { + let key = CFStringCreateWithCString( + std::ptr::null_mut(), + c"IOReportChannels".as_ptr() as *const _, + K_CFSTRING_ENCODING_UTF8, + ); + if key.is_null() { + return None; + } + let arr = CFDictionaryGetValue(sample, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + if arr.is_null() { + return None; + } + let count = CFArrayGetCount(arr); + let mut items = Vec::with_capacity(count.max(0) as usize); + for i in 0..count { + let item = CFArrayGetValueAtIndex(arr, i); + if item.is_null() { + continue; + } + items.push(Item { + group: cfstring_to_string(IOReportChannelGetGroup(item)), + name: cfstring_to_string(IOReportChannelGetChannelName(item)), + raw: IOReportSimpleGetIntegerValue(item, 0) as i64, + }); + } + Some(items) + } + + /// The clocks the probe reads: wall time, for its budget and its record, + /// and this thread's CPU time, for its record. The tick reads the thread + /// clock for itself. + trait Clocks { + fn now(&self) -> Instant; + fn thread_cpu(&self) -> Option; + } + + /// The real clocks: [`Instant`] and [`thread_cpu_time`]. + struct HostClocks; + + impl Clocks for HostClocks { + fn now(&self) -> Instant { + Instant::now() + } + + fn thread_cpu(&self) -> Option { + thread_cpu_time() + } + } + + /// What one sample call handed the rule, and how big the sample was. Read + /// by the tests alone; see [`ProbeRun`]. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug, Clone)] + struct Taken { + /// Channels in the sample. + channels: usize, + /// How many of them were outside the Energy Model group. + outside_group: usize, + /// The CPU rails of the sample handed to the rule, keyed and valued as + /// the rule reads them ([`cpu_rails`]). + cpu_rails: Vec<(RailKey, i64)>, + /// The `GPU Energy` rails of the same sample. + gpu_rails: Vec<(RailKey, i64)>, + } + + /// One sample call of a probe run. + #[cfg_attr(not(test), allow(dead_code))] + #[derive(Debug, Clone)] + struct SampleCall { + /// When the call began, by the probe's wall clock. + at: Instant, + /// Its wall time. + wall: Duration, + /// This thread's CPU time over it; `None` if the clock could not be read. + cpu: Option, + /// What it handed the rule; `None` if it gave no sample. + taken: Option, + } + + /// One tick of a probe run, as the probe's clocks saw it. #[cfg_attr(not(test), allow(dead_code))] #[derive(Debug, Clone, Copy)] struct Tick { - /// This thread's CPU time over the tick ([`thread_cpu_time`]; zero if - /// the clock could not be read). - cpu: Duration, + /// This thread's CPU time over the tick; `None` if unreadable. + cpu: Option, /// Wall time over the tick. wall: Duration, } - /// One real probe: the rule's watch, and what the probe's own FFI sample - /// calls and ticks cost, so a test can check how it sampled and burned - /// without timing it from outside. Only `watch` decides anything; the - /// records are read by those tests alone. + /// One probe run: the rule's watch (`None` when no subscription opened), + /// and a record of every sample call and every tick, in order. Only the + /// watch decides anything. The record is kept in every build, so the probe + /// the tests read is the probe that ships, and only the tests read it. #[cfg_attr(not(test), allow(dead_code))] - #[derive(Debug)] + #[derive(Debug, Clone)] struct ProbeRun { - watch: RailWatch, - /// Wall time of each FFI sample call, in order, failed ones included. - sample_walls: Vec, - /// Each tick between two sample calls, in order. + /// When the run began, by the probe's wall clock. + started: Instant, + watch: Option, + samples: Vec, ticks: Vec, } - /// Burn CPU on this thread, re-sampling the Energy Model group after every - /// [`PROBE_TICK`] of this thread's CPU time, and watch each CPU energy rail - /// **itself** by the rule in [`RailWatch`]. A watch with no interval means - /// the subscription yielded fewer than two usable samples in - /// [`PROBE_BUDGET_CAP`] budgets. + impl ProbeRun { + /// The run's verdict: none when no subscription opened, or when not + /// one interval was observed on it. + fn verdict(&self) -> Option { + self.watch.as_ref().and_then(RailWatch::verdict) + } + } + + /// CPU time between two readings of a thread clock, if both were read. + fn cpu_between(from: Option, to: Option) -> Option { + Some(to?.saturating_sub(from?)) + } + + /// The probe: `open` a subscription and, if one opens, [`watch`] it. Take + /// a sample; until [`RailWatch::wants_more`] says stop, `tick` and sample + /// again. Every sample call and every tick is recorded by `clocks`. With no + /// subscription there is no sample, no tick and no verdict. /// - /// 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. The probe takes its own samples on - /// its own subscription, so no counter's cumulative reading is touched. - fn probe_cpu_rail(sub: &EnergyModelSubscription, budget: Duration) -> ProbeRun { - let started = Instant::now(); - let mut sample_walls = Vec::new(); + /// Generic over each part, so the tests drive this very loop with a + /// scripted subscription, tick and clocks. [`host_status`] passes the real + /// ones, and is its only production caller. Each record is taken from the + /// sample as it is handed to the rule, so what the tests read is what the + /// rule saw. + fn probe_with( + open: impl FnOnce() -> Option, + mut tick: impl FnMut(), + clocks: &impl Clocks, + budget: Duration, + ) -> ProbeRun { + let started = clocks.now(); + let mut samples = Vec::new(); let mut ticks = Vec::new(); - let watch = watch( - || { - let t = Instant::now(); - let s = sub.sample(); - sample_walls.push(t.elapsed()); - s - }, - || { - let (cpu, wall) = (thread_cpu_time(), Instant::now()); - burn(PROBE_TICK, PROBE_TICK_WALL_CAP); - let cpu = match (cpu, thread_cpu_time()) { - (Some(a), Some(b)) => b.saturating_sub(a), - _ => Duration::ZERO, - }; - ticks.push(Tick { cpu, wall: wall.elapsed() }); - }, - || started.elapsed(), - budget, - ); - ProbeRun { watch, sample_walls, ticks } + let rail_watch = open().map(|sub| { + watch( + || { + // Wall, then CPU; at the end CPU, then wall: the CPU + // interval sits inside the wall one. + let (at, cpu) = (clocks.now(), clocks.thread_cpu()); + let got = sub.sample(); + let cpu = cpu_between(cpu, clocks.thread_cpu()); + let wall = clocks.now().saturating_duration_since(at); + let taken = got.as_ref().map(|g| Taken { + channels: g.channels, + outside_group: g.outside_group, + cpu_rails: cpu_rails(&g.values), + gpu_rails: gpu_rails(&g.values), + }); + samples.push(SampleCall { at, wall, cpu, taken }); + got.map(|g| g.values) + }, + || { + let (at, cpu) = (clocks.now(), clocks.thread_cpu()); + tick(); + let cpu = cpu_between(cpu, clocks.thread_cpu()); + ticks.push(Tick { cpu, wall: clocks.now().saturating_duration_since(at) }); + }, + || clocks.now().saturating_duration_since(started), + budget, + ) + }); + let run = ProbeRun { started, watch: rail_watch, samples, ticks }; + #[cfg(test)] + tests::seam::deliver(&run); + run } /// The status a probe verdict reports. No verdict — no Energy Model @@ -649,51 +879,27 @@ mod ioreport { } } - /// The probe's whole decision, generic over how its subscription opens and - /// how it is probed, so the path with no subscription runs in tests too. - /// `open` gives `None` when IOReport does not open or has no Energy Model - /// group; then there is no verdict, and [`status_of`] says `Unavailable`. - fn probe_with(open: impl FnOnce() -> Option, probe: impl FnOnce(&S) -> RailWatch) -> IoReportStatus { - status_of(open().and_then(|sub| probe(&sub).verdict())) + /// The real probe's tick: spin until this thread has burned [`PROBE_TICK`] + /// of its own CPU time, or [`PROBE_TICK_WALL_CAP`] of wall time has passed. + fn burn_tick() { + burn(PROBE_TICK, PROBE_TICK_WALL_CAP); } - /// This host's status from one probe on a fresh Energy Model subscription. - /// Backs the cached [`crate::ioreport_status`]. - pub(crate) fn probe_status(budget: Duration) -> IoReportStatus { - PROBE_RUNS.fetch_add(1, Ordering::Relaxed); - probe_with(EnergyModelSubscription::open, |sub| probe_cpu_rail(sub, budget).watch) - } - - /// Every Energy Model channel in a plain sample, as `(name, absolute raw - /// integer)` in IOReport's order. Raw integers, before unit conversion, so - /// a small advance in a fine unit cannot truncate to zero. `None` when the - /// sample carries no channel array. - unsafe fn energy_model_values(sample: CFDictionaryRef) -> Option { - let key = CFStringCreateWithCString( - std::ptr::null_mut(), - c"IOReportChannels".as_ptr() as *const _, - K_CFSTRING_ENCODING_UTF8, - ); - if key.is_null() { - return None; - } - let arr = CFDictionaryGetValue(sample, key as *const _) as CFArrayRef; - CFRelease(key as CFTypeRef); - if arr.is_null() { - return None; - } - let mut values = Vec::new(); - 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; - }; - values.push((name, IOReportSimpleGetIntegerValue(item, 0) as i64)); - } - Some(values) + /// This host's liveness status: [`probe_with`] on the real opener + /// ([`EnergyModelSubscription::open`]), the real tick ([`burn_tick`]) and + /// the real clocks ([`HostClocks`]) for + /// [`crate::CPU_RAIL_LIVENESS_BUDGET`], mapped by [`status_of`]. It counts + /// itself, then composes, and has no branch of its own: every decision is + /// in `probe_with` and `status_of`. The tests run this function too, with + /// its subscription failing, with scripted samples, and against the + /// hardware. The only production caller of `probe_with`; its own only + /// caller is the cache in [`crate::ioreport_status`]. + pub(crate) fn host_status() -> IoReportStatus { + HOST_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); + status_of( + probe_with(EnergyModelSubscription::open, burn_tick, &HostClocks, crate::CPU_RAIL_LIVENESS_BUDGET) + .verdict(), + ) } /// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally @@ -702,6 +908,12 @@ mod ioreport { strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") } + /// Whether a channel is the GPU energy aggregate, named like + /// [`is_cpu_energy_rail`]'s. + fn is_gpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("GPU Energy") + } + /// `struct timespec` on 64-bit macOS. #[repr(C)] struct Timespec { @@ -718,7 +930,8 @@ mod ioreport { /// CPU time this thread has used, user and system, by /// `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`. `None` if the clock cannot be - /// read. + /// read. The one thread clock of the probe: [`burn`] and [`HostClocks`] + /// both read it. fn thread_cpu_time() -> Option { let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; @@ -812,6 +1025,19 @@ mod ioreport { name } + /// 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` is the sum of + /// its `EACC_CPU`, `PACC0_CPU` and `PACC1_CPU`, and each of those is the + /// sum of its cores, their SRAM and its `_CPM` rails (all to within 1-9 + /// mJ). Summing `PACC_*` or `*_CPM` beside `CPU Energy` would count that + /// CPU energy twice. The same rule as eoc-meter's (7aa42418). fn counts_once(name: &str) -> bool { let base = strip_die_prefix(name); if base.eq_ignore_ascii_case("CPU Energy") @@ -820,11 +1046,12 @@ mod ioreport { { return true; } - let l = name.to_ascii_lowercase(); - if l.contains("cpu") || l.contains("gpu") || l.contains("ane") { + let u = base.to_ascii_uppercase(); + if u.contains("CPU") || u.contains("GPU") || u.contains("ANE") { return false; } - true + let cpu_summand = ["PACC", "EACC", "MACC"].iter().any(|p| u.starts_with(p)) || u.contains("CPM"); + !cpu_summand } unsafe fn cfstring_to_string(s: CFStringRef) -> Option { @@ -850,19 +1077,180 @@ mod ioreport { #[cfg(test)] mod tests { use super::*; - use std::cell::Cell; + use std::cell::{Cell, RefCell}; + use std::rc::Rc; use std::sync::atomic::AtomicBool; + /// Seams through which the tests reach into the production chain. They + /// exist only in this crate's unit-test build: the library a dependant + /// or an integration test links has none of them. Each is + /// thread-local, so it touches only the test thread that sets it, and + /// each setter returns a guard that unsets it when dropped, on a panic + /// too. + pub(super) mod seam { + use super::super::{Item, ProbeRun}; + use std::cell::{Cell, RefCell}; + + /// Given the real FFI read, returns what the probe gets instead. + type StandIn = Box Option>) -> Option>>; + + thread_local! { + static OPEN_FAILS: Cell = const { Cell::new(false) }; + static STAND_IN: RefCell> = const { RefCell::new(None) }; + static TAPPED: RefCell>> = const { RefCell::new(None) }; + } + + /// Asked by `EnergyModelSubscription::open`: fail, as on a host + /// with no Energy Model group? + pub fn open_fails() -> bool { + OPEN_FAILS.with(Cell::get) + } + + /// Asked by `EnergyModelSubscription::items`: `None` when no + /// stand-in is set, or what the stand-in makes of the real read. + pub fn sample(read: &dyn Fn() -> Option>) -> Option>> { + STAND_IN.with(|s| s.borrow_mut().as_mut().map(|stand_in| stand_in(read))) + } + + /// Told by `probe_with` of every run it makes; kept while tapped. + pub fn deliver(run: &ProbeRun) { + TAPPED.with(|t| { + if let Some(runs) = t.borrow_mut().as_mut() { + runs.push(run.clone()); + } + }); + } + + /// While alive, every Energy Model subscription this thread opens fails. + pub struct FailingOpens; + + impl Drop for FailingOpens { + fn drop(&mut self) { + OPEN_FAILS.with(|f| f.set(false)); + } + } + + pub fn fail_opens() -> FailingOpens { + OPEN_FAILS.with(|f| f.set(true)); + FailingOpens + } + + /// While alive, every FFI read of this thread's Energy Model + /// subscriptions goes through the stand-in. + pub struct StandingIn; + + impl Drop for StandingIn { + fn drop(&mut self) { + STAND_IN.with(|s| *s.borrow_mut() = None); + } + } + + pub fn stand_in( + f: impl FnMut(&dyn Fn() -> Option>) -> Option> + 'static, + ) -> StandingIn { + STAND_IN.with(|s| *s.borrow_mut() = Some(Box::new(f))); + StandingIn + } + + /// While alive, keeps every run `probe_with` makes on this thread. + pub struct Tap; + + impl Tap { + pub fn runs(&self) -> Vec { + TAPPED.with(|t| t.borrow_mut().as_mut().map(std::mem::take).unwrap_or_default()) + } + + /// The one run `of` made, or a failure if it made none or several. + pub fn one_run(&self, of: &str) -> ProbeRun { + let mut runs = self.runs(); + assert_eq!(runs.len(), 1, "{of} made {} probe runs, not one", runs.len()); + runs.remove(0) + } + } + + impl Drop for Tap { + fn drop(&mut self) { + TAPPED.with(|t| *t.borrow_mut() = None); + } + } + + pub fn tap() -> Tap { + TAPPED.with(|t| *t.borrow_mut() = Some(Vec::new())); + Tap + } + } + #[test] fn energy_to_uj_scales_by_unit() { assert_eq!(energy_to_uj(5, "mJ"), 5_000); assert_eq!(energy_to_uj(5_000, "nJ"), 5); + assert_eq!(energy_to_uj(5_000_000, "pJ"), 5); + assert_eq!(energy_to_uj(5, "uJ"), 5); + assert_eq!(energy_to_uj(5, "µJ"), 5); + assert_eq!(energy_to_uj(2, "J"), 2_000_000); + assert_eq!(energy_to_uj(-5, "mJ"), 0, "a negative raw value is no energy"); } + /// The counter sums each aggregate once and none of its summands, on + /// the real Energy Model channel names of both chips. Checked on one + /// absolute sample of each, macOS 27.0.1: + /// - M5 Max: `CPU Energy` = `MCPU0` + `MCPU1` + `PCPU` (2,413,310,607 + /// vs 2,413,310,605 mJ); `PCPU` = `PACC_0..5` + `PCPU0..5_SRAM` + + /// `PCPM` + `PCPM_SRAM` (7 mJ apart); `MCPUn` = its six cores + their + /// SRAM + `MCPMn` + `MCPMn_SRAM` (5 mJ apart). + /// - M3 Ultra: `DIE_n_CPU Energy` = its `EACC_CPU` + `PACC0_CPU` + + /// `PACC1_CPU` (1-2 mJ apart), each = its cores + their SRAM + its + /// `_CPM` + `_CPM_SRAM` (4-9 mJ apart). + /// + /// Before, the counter also summed `PACC_*`, `MCPM*`, `PCPM` and their + /// `_SRAM` rails (M5 Max) and all twelve `_CPM` rails (M3 Ultra), beside + /// the aggregate they add up to. The summed lists below are everything + /// the counter sums on each chip; every other name there is a summand. #[test] fn counts_once_drops_subrails() { - assert!(counts_once("CPU Energy")); - assert!(!counts_once("DIE_0_PACC0_CPU0")); + let m5_max_summed = [ + "CPU Energy", "GPU Energy", "AFR0", "ISP0", "AVE0", "MSR0", "AMCC0", "DCS0", "DRAM0", "DISP0", + "DISPEXT0", "FAB0", "PCIe Port 0 Energy", "PCIe Port 1 Energy", "apciec0 Energy", "apciec1 Energy", + "apciec2 Energy", + ]; + let m3_ultra_summed = [ + "DIE_0_CPU Energy", "DIE_1_CPU Energy", "GPU Energy", "AMCC0_0", "AMCC0_1", "AVE0_0", "AVE0_1", + "DCS0_0", "DCS0_1", "DISP0_0", "DISP0_1", "DISPEXT0_0", "DISPEXT0_1", "DRAM0_0", "DRAM0_1", "ISP0_0", + "ISP0_1", "MSR0_0", "MSR0_1", "PCIe Port 0 Energy", "PCIe Port 1 Energy", "PCIe Port 2 Energy", + "PCIe Port 3 Energy", "apciec0 Energy", "apciec1 Energy", "apciec2 Energy", "apciec3 Energy", + "apciec4 Energy", "apciec5 Energy", + ]; + for name in m5_max_summed.iter().chain(&m3_ultra_summed) { + assert!(counts_once(name), "{name} is not summed"); + } + // M5 Max: the summands of `CPU Energy`, every family of them. + for name in [ + "MCPU0", "MCPU1", "PCPU", "MCPU0_0", "MCPU0_5", "MCPU1_3", "MCPU0_2_SRAM", "MCPU1_5_SRAM", + "PCPU0_SRAM", "PCPU5_SRAM", "PACC_0", "PACC_1", "PACC_2", "PACC_3", "PACC_4", "PACC_5", "MCPM0", + "MCPM1", "PCPM", "MCPM0_SRAM", "MCPM1_SRAM", "PCPM_SRAM", "MCPU0DTL00", "MCPU0DTL0e", "MCPU1DTL5e", + "PCPUDTL00", "PCPUDTL513", + ] { + assert!(!counts_once(name), "{name}, a summand of CPU Energy on an M5 Max, is summed beside it"); + } + // M3 Ultra: the summands of `DIE_n_CPU Energy`, every family of them. + for name in [ + "DIE_0_EACC_CPU", "DIE_1_EACC_CPU", "DIE_0_PACC0_CPU", "DIE_1_PACC1_CPU", "DIE_0_EACC_CPU3", + "DIE_1_PACC0_CPU5", "DIE_0_EACC_CPU0_SRAM", "DIE_1_PACC1_CPU4_SRAM", "DIE_0_EACC_CPM", + "DIE_1_EACC_CPM", "DIE_0_PACC0_CPM", "DIE_1_PACC1_CPM", "DIE_0_EACC_CPM_SRAM", + "DIE_1_PACC0_CPM_SRAM", "DIE_0_PACC1_CPM_SRAM", "ECPUDTL00", "PCPUDTL00", "PCPU1DTL00", + ] { + assert!(!counts_once(name), "{name}, a summand of DIE_n_CPU Energy on an M3 Ultra, is summed beside it"); + } + // The GPU's own rails, beside the `GPU Energy` aggregate. + for name in ["GPU0", "GPU0_0", "GPU CS0_0", "GPU SRAM0_0", "GPU CS SRAM0_0"] { + assert!(!counts_once(name), "{name} is summed beside GPU Energy"); + } + // Neither chip has an `ANE Energy` aggregate, and its rails are + // not summed either: ANE energy is not counted at all. Pinned as it + // is, so a change to it is a decision, not an accident. + for name in ["ANE0", "ANE0_0", "ANE0_1"] { + assert!(!counts_once(name), "{name} is summed"); + } } #[test] @@ -879,6 +1267,10 @@ mod ioreport { assert!(!is_cpu_energy_rail("MCPU0")); assert!(!is_cpu_energy_rail("PACC_0")); assert!(!is_cpu_energy_rail("DIE_0_PACC0_CPU0")); + assert!(is_gpu_energy_rail("GPU Energy")); + assert!(is_gpu_energy_rail("DIE_1_GPU Energy")); + assert!(!is_gpu_energy_rail("GPU0")); + assert!(!is_gpu_energy_rail("CPU Energy")); } // ── The liveness rule, on scripted samples and a scripted clock ───── @@ -1204,33 +1596,592 @@ mod ioreport { ); } - /// The probe's whole decision when there is no Energy Model - /// subscription (IOReport does not open, or has no such group), or - /// when the subscription opens and never yields a sample: no verdict, - /// so `Unavailable` — never `Live`, never `CpuRailAbsent`. - #[test] - fn no_energy_model_subscription_or_no_sample_is_unavailable() { - let no_group = probe_with(|| None::<()>, |_| unreachable!("no subscription opened, so nothing to probe")); - assert_eq!(no_group, crate::IoReportStatus::Unavailable, "no Energy Model subscription"); - let no_sample = probe_with(|| Some(()), |_| scripted(|_| None, PROBE_TICK, BUDGET).0); - assert_eq!(no_sample, crate::IoReportStatus::Unavailable, "a subscription that never samples"); + // ── probe_with, with a stand-in for every part ────────────────────── + + /// A channel of the Energy Model group, as the FFI would read it. + fn item(name: &str, raw: i64) -> Item { + Item { group: Some("Energy Model".to_string()), name: Some(name.to_string()), raw } } - // ── Against the hardware ──────────────────────────────────────────── + /// An M5 Max-shaped read: `GPU Energy` rising at every sample beside + /// `CPU Energy` at `cpu`. + fn m5_items(i: usize, cpu: i64) -> Vec { + vec![item("GPU Energy", m5_gpu(i)), item("CPU Energy", cpu)] + } + + fn m5_gpu(i: usize) -> i64 { + 112_249_654_932_804 + 1_000_000 * i as i64 + } + + /// The batch refresh seen on an M5 Max: `CPU Energy` steps once, after + /// the second sample, and holds. + fn m5_refresh(i: usize) -> i64 { + if i < 2 { 480_083_160 } else { 541_893_638 } + } + + /// An M3 Ultra-shaped read in which each die's `CPU Energy` steps once, + /// in different intervals: DIE_0 in the first (sample 0 to 1), DIE_1 + /// in the second (sample 1 to 2). Every probe takes at least three + /// samples, so both steps are seen however few samples a loaded host + /// allows. Beside them, a duplicated unprefixed name rising on both dies + /// and a rising GPU rail. Summed, the two dies would be one rail + /// advancing twice. + fn two_dies_stepping_apart(i: usize) -> Vec { + vec![ + item("ECPUDTL00", 7_000 + i as i64), + item("DIE_0_CPU Energy", die_0(i)), + item("ECPUDTL00", 9_000 + i as i64), + item("DIE_1_CPU Energy", die_1(i)), + item("GPU Energy", 5_000_000 * i as i64), + ] + } + + fn die_0(i: usize) -> i64 { + if i < 1 { 21_170_718 } else { 22_515_587 } + } + + fn die_1(i: usize) -> i64 { + if i < 2 { 23_300_102 } else { 24_691_739 } + } + + /// A scripted subscription: read `i` is `script(i)`, made into a + /// [`GroupSample`] by [`group_sample`] as the real subscription's are. + /// Its sample calls are counted in `calls`, which the test keeps. + struct Scripted { + script: F, + calls: Rc>, + } + + impl Option>> Sampler for Scripted { + fn sample(&self) -> Option { + let i = self.calls.get(); + self.calls.set(i + 1); + (self.script)(i).map(group_sample) + } + } + + /// The subscription type of an opener that never opens one. + type NoSubscription = Scripted Option>>; + + /// Scripted clocks: wall time and thread CPU time stand still, except + /// when [`fake_tick`] moves them. + struct FakeClocks { + base: Instant, + wall: Cell, + cpu: Cell, + } + + impl FakeClocks { + fn new() -> Self { + Self { base: Instant::now(), wall: Cell::new(Duration::ZERO), cpu: Cell::new(Duration::ZERO) } + } + } + + impl Clocks for FakeClocks { + fn now(&self) -> Instant { + self.base + self.wall.get() + } + + fn thread_cpu(&self) -> Option { + Some(self.cpu.get()) + } + } + + /// A scripted tick: [`STEP`] of wall time and [`PROBE_TICK`] of thread + /// CPU time pass on `clocks`, and `count` goes up. + fn fake_tick<'a>(clocks: &'a FakeClocks, count: &'a Cell) -> impl FnMut() + 'a { + move || { + clocks.wall.set(clocks.wall.get() + STEP); + clocks.cpu.set(clocks.cpu.get() + PROBE_TICK); + count.set(count.get() + 1); + } + } + + /// Rails as `("name#ordinal", raw)`. + fn named(rails: &[(RailKey, i64)]) -> Vec<(String, i64)> { + rails.iter().map(|(k, v)| (format!("{}#{}", k.name, k.ordinal), *v)).collect() + } + + /// [`probe_with`], the composition [`host_status`] runs, with an opener + /// that finds no subscription: the opener is asked once, nothing is + /// sampled, nothing ticks, and there is no verdict, which is + /// Unavailable. + #[test] + fn probe_with_no_subscription_samples_nothing_and_never_ticks() { + let (clocks, ticks, opened) = (FakeClocks::new(), Cell::new(0), Cell::new(0)); + let run = probe_with( + || { + opened.set(opened.get() + 1); + None:: + }, + fake_tick(&clocks, &ticks), + &clocks, + BUDGET, + ); + assert_eq!(opened.get(), 1, "the opener was asked {} times", opened.get()); + assert_eq!( + (run.samples.len(), ticks.get(), run.ticks.len()), + (0, 0, 0), + "sampled or ticked with no subscription" + ); + assert!(run.watch.is_none(), "watched a subscription that did not open"); + assert_eq!(run.verdict(), None); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::Unavailable); + } + + /// No subscription, or a subscription that never yields a sample: no + /// verdict, so `Unavailable`, never `Live`, never `CpuRailAbsent`. + #[test] + fn no_energy_model_subscription_or_no_sample_is_unavailable() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let no_group = probe_with(|| None::, fake_tick(&clocks, &ticks), &clocks, BUDGET); + assert_eq!(status_of(no_group.verdict()), crate::IoReportStatus::Unavailable, "no Energy Model subscription"); + let silent = Scripted { script: |_| None, calls: Rc::new(Cell::new(0)) }; + let no_sample = probe_with(|| Some(silent), fake_tick(&clocks, &ticks), &clocks, BUDGET); + assert_eq!( + status_of(no_sample.verdict()), + crate::IoReportStatus::Unavailable, + "a subscription that never samples" + ); + assert!(no_sample.samples.iter().all(|s| s.taken.is_none())); + } + + /// One batch refresh through [`probe_with`]: Frozen, after the whole + /// budget. The record holds, for every sample, exactly the CPU and GPU + /// values the script gave and the rule was handed, and every tick as + /// the clocks saw it. + #[test] + fn probe_with_one_batch_refresh_is_frozen() { + let (clocks, ticks, calls) = (FakeClocks::new(), Cell::new(0), Rc::new(Cell::new(0))); + let sub = Scripted { script: |i| Some(m5_items(i, m5_refresh(i))), calls: Rc::clone(&calls) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "a batch refresh read as live: {:?}", advances(w)); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!(advances(w), [rail("CPU Energy#0", 1)]); + // The whole budget: at 30 ms a tick, the clock reaches 1 s at the 34th. + assert_eq!((calls.get(), run.samples.len(), ticks.get(), run.ticks.len()), (35, 35, 34, 34)); + for (i, s) in run.samples.iter().enumerate() { + let t = s.taken.as_ref().expect("every scripted sample is usable"); + assert_eq!((t.channels, t.outside_group), (2, 0), "sample {i}"); + assert_eq!( + named(&t.cpu_rails), + [("CPU Energy#0".to_string(), m5_refresh(i))], + "sample {i}: the record is not what the script gave" + ); + assert_eq!(named(&t.gpu_rails), [("GPU Energy#0".to_string(), m5_gpu(i))], "sample {i}"); + assert_eq!(s.at, clocks.base + STEP * i as u32, "sample {i} recorded at the wrong time"); + } + assert!(run.ticks.iter().all(|k| k.cpu == Some(PROBE_TICK) && k.wall == STEP), "{:?}", run.ticks); + } + + /// A rail advancing at every sample, through [`probe_with`]: Live after + /// two intervals, with three samples and two ticks. + #[test] + fn probe_with_two_advances_is_live() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { script: |i| Some(m5_items(i, 541_893_638 + i as i64)), calls: Rc::new(Cell::new(0)) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + assert_eq!(run.verdict(), Some(CpuRail::Live), "{:?}", run.watch.as_ref().map(advances)); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::Live); + assert_eq!((run.samples.len(), ticks.get(), run.ticks.len()), (3, 2, 2)); + } + + /// Two dies each stepping once, in different intervals, through + /// [`probe_with`]: Frozen, one advance per die, and the record holds + /// each die's own value. A transform between the sampler and the rule + /// that summed the dies would see one rail advance twice, and say Live. + #[test] + fn probe_with_one_step_per_die_in_different_intervals_is_frozen() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { script: |i| Some(two_dies_stepping_apart(i)), calls: Rc::new(Cell::new(0)) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "one step per die read as live: {:?}", advances(w)); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + for (i, s) in run.samples.iter().enumerate() { + let t = s.taken.as_ref().expect("every scripted sample is usable"); + assert_eq!( + named(&t.cpu_rails), + [("DIE_0_CPU Energy#0".to_string(), die_0(i)), ("DIE_1_CPU Energy#0".to_string(), die_1(i))], + "sample {i}: the rule was not handed each die's own value" + ); + } + } + + /// [`group_sample`] counts every channel, counts the ones outside the + /// group, and hands on each in-group channel's own raw value, in order. + #[test] + fn group_sample_counts_channels_and_hands_on_each_rails_own_value() { + let other = |group: Option<&str>, name: Option<&str>, raw| Item { + group: group.map(str::to_string), + name: name.map(str::to_string), + raw, + }; + let g = group_sample(vec![ + item("DIE_0_CPU Energy", 5), + other(Some("CPU Stats"), Some("CPU Energy"), 99), + item("DIE_1_CPU Energy", 7), + other(None, Some("GPU Energy"), 1), + other(Some("energy model"), None, 3), + item("GPU Energy", 11), + ]); + assert_eq!((g.channels, g.outside_group), (6, 2)); + assert_eq!( + g.values, + [ + ("DIE_0_CPU Energy".to_string(), 5), + ("DIE_1_CPU Energy".to_string(), 7), + ("GPU Energy".to_string(), 11) + ] + ); + } + + // ── The tests' own clocks ─────────────────────────────────────────── + + /// Clock ids from macOS ``, the tests' own: not the + /// implementation's constant, so a wrong id there cannot move the + /// tests' clocks with it. + const TEST_CLOCK_PROCESS_CPUTIME_ID: c_int = 12; + const TEST_CLOCK_THREAD_CPUTIME_ID: c_int = 16; unsafe extern "C" { fn clock_gettime_nsec_np(clock_id: c_int) -> u64; } - /// CPU time this thread has used, read the tests' own way - /// (`clock_gettime_nsec_np`), not through the probe's - /// [`thread_cpu_time`]. - fn test_thread_cpu() -> Duration { - let ns = unsafe { clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID) }; - assert_ne!(ns, 0, "clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID) failed"); + /// A clock read the tests' own way (`clock_gettime_nsec_np`), not + /// through the probe's [`thread_cpu_time`]. + fn test_clock(id: c_int) -> Duration { + let ns = unsafe { clock_gettime_nsec_np(id) }; + assert_ne!(ns, 0, "clock_gettime_nsec_np({id}) failed"); Duration::from_nanos(ns) } + /// CPU time this thread has used, by the tests' own clock. + fn test_thread_cpu() -> Duration { + test_clock(TEST_CLOCK_THREAD_CPUTIME_ID) + } + + /// Spin until this thread has used `cpu` more CPU time, by the tests' + /// own clock: real work, owing nothing to the implementation's burn. + fn spin_cpu(cpu: Duration) { + let start = test_thread_cpu(); + let mut acc: u64 = 0; + while test_thread_cpu().saturating_sub(start) < cpu { + for _ in 0..1_000 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + } + } + + /// The CPU floor and wall cap the tests hold each tick to: their own + /// literals, not the implementation's constants. + const TICK_CPU_FLOOR: Duration = Duration::from_millis(25); + const TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// How far a CPU reading may pass the wall time around it: the + /// granularity of reading two clocks, nothing more. + const CLOCK_SLACK: Duration = Duration::from_millis(1); + + /// How far the probe's own record of a tick's CPU time may fall short + /// of the tests' measurement, which spans the tick and the probe's + /// bookkeeping between two reads. + const RECORD_SLACK: Duration = Duration::from_millis(2); + + /// A moment by the tests' own clocks: wall time and this thread's CPU + /// time. + #[derive(Debug, Clone, Copy)] + struct Stamp { + wall: Instant, + cpu: Duration, + } + + impl Stamp { + /// Wall, then CPU: where an interval begins whose CPU time must fit + /// inside its wall time. + fn opening() -> Self { + let wall = Instant::now(); + Self { wall, cpu: test_thread_cpu() } + } + + /// CPU, then wall: where such an interval ends. + fn closing() -> Self { + let cpu = test_thread_cpu(); + Self { wall: Instant::now(), cpu } + } + + /// (CPU, wall) from `self` to `later`. + fn until(self, later: Stamp) -> (Duration, Duration) { + (later.cpu.saturating_sub(self.cpu), later.wall.saturating_duration_since(self.wall)) + } + } + + /// Each tick between two FFI reads, by the tests' own clocks: the probe + /// burned [`TICK_CPU_FLOOR`] of this thread's CPU between every two + /// reads, unless the tick ran into [`TICK_WALL_CAP`] of wall time, and + /// no tick shows more CPU than wall time. `reads` are the (entered, + /// left) stamps of each read, in order. Returns each tick's (CPU, wall). + fn assert_ticks_burned(reads: &[(Stamp, Stamp)], what: &str) -> Vec<(Duration, Duration)> { + let ticks: Vec<(Duration, Duration)> = reads.windows(2).map(|w| w[0].1.until(w[1].0)).collect(); + for (k, &(cpu, wall)) in ticks.iter().enumerate() { + assert!( + cpu <= wall + CLOCK_SLACK, + "{what}: tick {k} shows {cpu:?} of this thread's CPU in {wall:?} of wall time, by the tests' clocks" + ); + assert!( + cpu >= TICK_CPU_FLOOR || wall >= TICK_WALL_CAP, + "{what}: tick {k} used {cpu:?} of this thread's CPU in {wall:?}, by the tests' own clock: a tick \ + must burn {TICK_CPU_FLOOR:?} of thread CPU, and only a {TICK_WALL_CAP:?} wall cap may cut that short" + ); + } + ticks + } + + /// The probe's thread clock ([`thread_cpu_time`], which [`burn`] and + /// [`HostClocks`] read) is this thread's own CPU time. Checked on the + /// tests' own clocks, read with their own clock ids: + /// - not wall time: across a 100 ms sleep it advances under 5 ms; + /// - not the process's CPU time: while a helper thread burns 100 ms of + /// CPU and this thread waits for it, it advances under 5 ms, though + /// the process clock advances at least 100 ms; + /// - it tracks this thread's CPU: across 30 ms of this thread's CPU by + /// the tests' clock, it advances the same, within 1 ms. + #[test] + fn the_probe_reads_this_threads_own_cpu_clock() { + let probe = || thread_cpu_time().expect("the probe's thread clock could not be read"); + let before = probe(); + std::thread::sleep(Duration::from_millis(100)); + let slept = probe().saturating_sub(before); + + let (before, process_before) = (probe(), test_clock(TEST_CLOCK_PROCESS_CPUTIME_ID)); + let helper = std::thread::scope(|s| { + s.spawn(|| { + let start = test_thread_cpu(); + spin_cpu(Duration::from_millis(100)); + test_thread_cpu().saturating_sub(start) + }) + .join() + .expect("the helper thread panicked") + }); + let waited = probe().saturating_sub(before); + let process = test_clock(TEST_CLOCK_PROCESS_CPUTIME_ID).saturating_sub(process_before); + + let (before, tests_before) = (probe(), test_thread_cpu()); + spin_cpu(Duration::from_millis(30)); + let (tracked, by_tests) = (probe().saturating_sub(before), test_thread_cpu().saturating_sub(tests_before)); + + eprintln!( + "ioreport thread clock: across a 100ms sleep it advanced {slept:?}; while a helper burned {helper:?} \ + (process clock +{process:?}) it advanced {waited:?}; across {by_tests:?} of this thread's CPU by \ + the tests' clock it advanced {tracked:?}" + ); + assert!( + slept < Duration::from_millis(5), + "the probe's thread clock advanced {slept:?} across a 100ms sleep of this thread: it counts time this \ + thread did not run (wall time, or other threads' CPU)" + ); + assert!(helper >= Duration::from_millis(100), "the helper burned only {helper:?}"); + assert!( + process >= Duration::from_millis(100), + "the process clock advanced only {process:?} while a helper burned {helper:?}, so this test cannot tell a \ + process clock from a thread clock" + ); + assert!( + waited < Duration::from_millis(5), + "the probe's thread clock advanced {waited:?} while this thread waited on a helper burning {helper:?}: it \ + reads the process's CPU time" + ); + assert!( + tracked.abs_diff(by_tests) <= Duration::from_millis(1), + "the probe's thread clock advanced {tracked:?} across {by_tests:?} of this thread's CPU by the tests' clock" + ); + } + + // ── host_status itself, through the seams ─────────────────────────── + + /// Runs [`host_status`] itself on this thread with every FFI read + /// replaced by `script(i)`. Everything else is real: the subscription it + /// opens, [`burn_tick`], [`HostClocks`] and the budget. Returns the + /// status, the run (from the tap), each read's (entered, left) stamps + /// by the tests' own clocks, and how long `host_status` took by the + /// tests' wall clock. + fn host_status_on_script( + script: impl Fn(usize) -> Option> + 'static, + ) -> (crate::IoReportStatus, ProbeRun, Vec<(Stamp, Stamp)>, Duration) { + let reads = Rc::new(RefCell::new(Vec::new())); + let (log, next) = (Rc::clone(&reads), Cell::new(0)); + let _script = seam::stand_in(move |_real_read| { + let entered = Stamp::closing(); + let i = next.get(); + next.set(i + 1); + let items = script(i); + log.borrow_mut().push((entered, Stamp::opening())); + items + }); + let tap = seam::tap(); + let started = Instant::now(); + let status = host_status(); + let took = started.elapsed(); + let run = tap.one_run("host_status"); + (status, run, reads.take(), took) + } + + /// No Energy Model subscription opened, so `host_status` never read the + /// script: the no-subscription case, which must be Unavailable with no + /// read. Not a pass by default: the oracle, on its own all-channel + /// subscription, must find no Energy Model channel either, or the + /// opener failed on a host that has the group. + fn no_energy_model_group_here(status: crate::IoReportStatus, reads: &[(Stamp, Stamp)]) { + let census = oracle_census(); + eprintln!( + "host_status: no Energy Model subscription opened; status={status:?}, {} reads; the oracle counts (in \ + that group, in all) = {census:?}", + reads.len() + ); + assert!( + census.is_none_or(|(group, _)| group == 0), + "no Energy Model subscription opened, but the oracle reads that group's channels (in it, in all) = \ + {census:?}: the opener failed on a host that has the group" + ); + assert_eq!(status, crate::IoReportStatus::Unavailable, "no Energy Model subscription opened"); + assert!(reads.is_empty(), "read {} samples with no subscription", reads.len()); + } + + /// `host_status` itself, with its Energy Model subscription failing: no + /// FFI read, no tick, no CPU burned, no verdict, so Unavailable (never + /// Live, never CpuRailAbsent). A branch put into `host_status` for this + /// case runs here. + #[test] + fn host_status_with_no_energy_model_subscription_is_unavailable() { + let _fail = seam::fail_opens(); + let reads = Rc::new(Cell::new(0)); + let counted = Rc::clone(&reads); + let _count = seam::stand_in(move |read| { + counted.set(counted.get() + 1); + read() + }); + let tap = seam::tap(); + let cpu = test_thread_cpu(); + let status = host_status(); + let cpu = test_thread_cpu().saturating_sub(cpu); + let runs = tap.runs(); + eprintln!( + "host_status, subscription failing: status={status:?}, {} probe runs, {} reads, {} ticks, {cpu:?} of CPU", + runs.len(), + reads.get(), + runs.iter().map(|r| r.ticks.len()).sum::() + ); + assert_eq!(status, crate::IoReportStatus::Unavailable, "no Energy Model subscription"); + assert_eq!(runs.len(), 1, "host_status made {} probe runs, not one: it decides through probe_with", runs.len()); + let run = &runs[0]; + assert!(run.watch.is_none(), "probe_with watched a subscription that did not open"); + assert_eq!((reads.get(), run.samples.len(), run.ticks.len()), (0, 0, 0), "read or ticked with no subscription"); + assert!(cpu < TICK_CPU_FLOOR, "host_status burned {cpu:?} of CPU with no subscription to watch"); + } + + /// `host_status` itself, with one batch refresh in place of its FFI + /// reads: Frozen, having burned a tick of CPU between every two reads, + /// by the tests' own clocks. The same refresh as (a), through every + /// production link but the FFI read. A rail that is not live keeps the + /// probe going for its whole budget, so `host_status` took at least + /// [`crate::CPU_RAIL_LIVENESS_BUDGET`]: a lower bound, which load can + /// only lengthen. + #[test] + fn host_status_on_a_scripted_batch_refresh_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, took) = host_status_on_script(|i| Some(m5_items(i, m5_refresh(i)))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + let ticks = assert_ticks_burned(&reads, "host_status on a scripted batch refresh"); + eprintln!( + "host_status, scripted batch refresh: status={status:?}, advances {:?}, {} reads, median tick CPU by the \ + tests' clock {:?}, took {took:?}", + advances(w), + reads.len(), + median(&ticks.iter().map(|t| t.0).collect::>()) + ); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "a batch refresh read as live: {:?}", advances(w)); + assert!( + took >= crate::CPU_RAIL_LIVENESS_BUDGET, + "host_status gave up on a rail that is not live after {took:?}, before its {:?} budget", + crate::CPU_RAIL_LIVENESS_BUDGET + ); + assert_eq!(advances(w), [rail("CPU Energy#0", 1)]); + assert_eq!(reads.len(), run.samples.len(), "the record and the reads disagree"); + for (i, s) in run.samples.iter().enumerate() { + let t = s.taken.as_ref().expect("every scripted sample is usable"); + assert_eq!(named(&t.cpu_rails), [("CPU Energy#0".to_string(), m5_refresh(i))], "sample {i}"); + assert_eq!(named(&t.gpu_rails), [("GPU Energy#0".to_string(), m5_gpu(i))], "sample {i}"); + } + } + + /// `host_status` itself, with a rail advancing at every read: Live after + /// two intervals, three reads and two ticks, each a tick of burned CPU. + #[test] + fn host_status_on_a_scripted_live_rail_is_live() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, _) = host_status_on_script(|i| Some(m5_items(i, 541_893_638 + i as i64))); + if run.watch.is_none() { + return no_energy_model_group_here(status, &reads); + } + assert_ticks_burned(&reads, "host_status on a scripted live rail"); + assert_eq!(status, crate::IoReportStatus::Live, "{:?}", run.watch.as_ref().map(advances)); + assert_eq!((reads.len(), run.samples.len(), run.ticks.len()), (3, 3, 2)); + } + + /// `host_status` itself, with a CPU rail that holds at every read, as + /// every CPU rail does on macOS 27.0.1 between batch refreshes, beside + /// a GPU rail that rises at every read: Frozen, with no advance, after + /// its whole budget, having burned a tick of CPU between every two + /// reads by the tests' own clocks, and ticked once before every + /// re-read. + #[test] + fn host_status_on_a_scripted_constant_rail_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, took) = host_status_on_script(|i| Some(m5_items(i, 541_893_638))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + let ticks = assert_ticks_burned(&reads, "host_status on a scripted constant rail"); + eprintln!( + "host_status, scripted constant rail: status={status:?}, advances {:?}, {} reads, {} ticks, median tick \ + CPU by the tests' clock {:?}, took {took:?}", + advances(w), + reads.len(), + run.ticks.len(), + median(&ticks.iter().map(|t| t.0).collect::>()) + ); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "a constant rail read as {status:?}: {:?}", advances(w)); + assert_eq!(advances(w), [rail("CPU Energy#0", 0)], "a constant rail counted as advancing"); + assert!( + took >= crate::CPU_RAIL_LIVENESS_BUDGET, + "host_status gave up on a constant rail after {took:?}, before its {:?} budget", + crate::CPU_RAIL_LIVENESS_BUDGET + ); + assert_eq!(reads.len(), run.samples.len(), "the record and the reads disagree"); + assert_eq!(run.ticks.len() + 1, run.samples.len(), "the probe must tick once before every re-read"); + assert!(w.intervals >= 2, "Frozen on {} intervals", w.intervals); + } + + /// `host_status` itself, with two dies each stepping once in different + /// intervals: Frozen, one advance per die. A transform anywhere between + /// the FFI read and the rule that summed the dies' rails would see one + /// rail advance twice and say Live. + #[test] + fn host_status_on_two_dies_stepping_apart_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, _) = host_status_on_script(|i| Some(two_dies_stepping_apart(i))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + assert_ticks_burned(&reads, "host_status on two dies stepping apart"); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "one step per die read as live: {:?}", advances(w)); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + } + + // ── Against the hardware ──────────────────────────────────────────── + /// The median of some durations (the upper one of an even count). fn median(of: &[Duration]) -> Option { let mut v = of.to_vec(); @@ -1238,16 +2189,18 @@ mod ioreport { v.get(v.len() / 2).copied() } - /// `cpu` of this thread's CPU time: real work to meter. It takes at - /// least `cpu` of wall time, since a thread cannot use CPU faster than - /// the clock runs. - fn cpu_work(cpu: Duration) { - burn(cpu, Duration::from_secs(30)); - } + /// The group-only pin by cost: a group sample takes ~8-9 ms of the + /// sampling thread's CPU however contended, an all-channel one ~100-125 + /// ms. + const GROUP_SAMPLE_CPU_CEILING: Duration = Duration::from_millis(50); - /// The group-only pin: a group sample takes ~5-12 ms, an all-channel - /// one 130-800 ms. - const GROUP_SAMPLE_MEDIAN_CEILING: Duration = Duration::from_millis(50); + /// How long the oracle may take to read beside the probe before the + /// test gives up on it. + const ORACLE_GIVE_UP: Duration = Duration::from_secs(60); + + /// How long the probe's second read is held for the oracle to see a + /// rail move after its first. + const WITNESS_WAIT: Duration = Duration::from_secs(3); /// What the independent oracle saw on this host. #[derive(Debug, PartialEq)] @@ -1347,13 +2300,22 @@ mod ioreport { } } + /// One channel of one oracle read. + #[derive(Debug, Clone)] + struct OracleChannel { + group: String, + name: String, + unit: String, + raw: i64, + } + /// From one plain sample (no delta), every channel whose name `keep`s, - /// in any group: `(group, name, ABSOLUTE raw value)`. + /// in any group, with its ABSOLUTE raw value. fn oracle_channels( sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef, keep: impl Fn(&str) -> bool, - ) -> Option> { + ) -> Option> { unsafe { let smp = IOReportCreateSamples(sub, chans, std::ptr::null()); if smp.is_null() { @@ -1376,8 +2338,12 @@ mod ioreport { } let name = oracle_string(IOReportChannelGetChannelName(it)).unwrap_or_default(); if keep(&name) { - let group = oracle_string(IOReportChannelGetGroup(it)).unwrap_or_default(); - out.push((group, name, IOReportSimpleGetIntegerValue(it, 0) as i64)); + out.push(OracleChannel { + group: oracle_string(IOReportChannelGetGroup(it)).unwrap_or_default(), + unit: oracle_string(IOReportChannelGetUnitLabel(it)).unwrap_or_default(), + name, + raw: IOReportSimpleGetIntegerValue(it, 0) as i64, + }); } } } @@ -1387,12 +2353,23 @@ mod ioreport { } } + /// How many channels the Energy Model group has, and how many there are + /// in all, by one oracle read on its own all-channel subscription. + fn oracle_census() -> Option<(usize, usize)> { + let (sub, chans) = oracle_subscribe()?; + let all = oracle_channels(sub, chans, |_| true); + oracle_unsubscribe((sub, chans)); + let all = all?; + Some((all.iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")).count(), all.len())) + } + /// The ABSOLUTE cumulative values of every channel whose name ends with /// `CPU Energy`, and of every one ending with `GPU Energy`, in any /// group, from one plain sample. fn oracle_read(sub: IOReportSubscriptionRef, chans: CFMutableDictionaryRef) -> Option<(Vec, Vec)> { let chans = oracle_channels(sub, chans, |n| n.ends_with("CPU Energy") || n.ends_with("GPU Energy"))?; - let pick = |suffix: &str| chans.iter().filter(|(_, n, _)| n.ends_with(suffix)).map(|c| c.2).collect(); + let pick = + |suffix: &str| chans.iter().filter(|c| c.name.ends_with(suffix)).map(|c| c.raw).collect(); Some((pick("CPU Energy"), pick("GPU Energy"))) } @@ -1482,6 +2459,32 @@ mod ioreport { run } + /// Every channel whose name ends with `CPU Energy` or `GPU Energy`, + /// keyed by (name, ordinal among the same-named ones), in order: the + /// oracle's own matching, applied alike to its reads and the probe's. + fn keyed_rails(chans: impl IntoIterator) -> Vec<((String, u32), i64)> { + let mut out: Vec<((String, u32), i64)> = Vec::new(); + for (name, raw) in chans { + if name.ends_with("CPU Energy") || name.ends_with("GPU Energy") { + let ordinal = out.iter().filter(|((n, _), _)| *n == name).count() as u32; + out.push(((name, ordinal), raw)); + } + } + out + } + + /// The Energy Model group's CPU and GPU rails in one oracle read, keyed + /// by [`keyed_rails`]. + fn oracle_group_rails( + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + ) -> Option> { + let chans = oracle_channels(sub, chans, |n| n.ends_with("CPU Energy") || n.ends_with("GPU Energy"))?; + Some(keyed_rails( + chans.into_iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")).map(|c| (c.name, c.raw)), + )) + } + /// The property that matters: IOReport is **never** selected when its CPU /// rail is not live, because then it would report GPU-only energy, zero, /// or a whole batch refresh as the energy of CPU work. Deterministic on @@ -1550,70 +2553,101 @@ mod ioreport { } } - /// The dangerous direction on its own: the probe never calls a rail Live - /// that the oracle cannot see keep moving. Runs the probe itself, - /// uncached, on a fresh Energy Model subscription, beside the cached - /// status `detect()` is gated on, and checks on every outcome that the - /// two agree with the oracle about what exists. + /// The dangerous direction on its own: the probe never calls a rail + /// Live that the oracle cannot see keep moving. It runs [`host_status`] + /// itself, uncached — the tap keeps its run, and a stand-in passes every + /// FFI read through unchanged, stamping it by the tests' own clocks — + /// beside the cached status `detect()` is gated on, and checks on every + /// outcome that the two agree with the oracle about what exists. /// - /// It also pins how the probe samples and burns, without asserting any - /// wall-clock ratio, because this host is also a heavily loaded CI - /// runner: - /// - Group-only subscription: the MEDIAN wall time of the probe's own - /// FFI sample calls is under 50 ms. A group sample takes ~5-12 ms, an - /// all-channel one 130-800 ms. - /// - Burning: the probing thread used at least half a tick of CPU per - /// interval, by the tests' own thread clock. Each tick burns - /// [`PROBE_TICK`] of thread CPU time, so this holds at any load - /// unless the [`PROBE_TICK_WALL_CAP`] cut ticks short. And by the - /// probe's own record, every tick burned a whole [`PROBE_TICK`] or - /// ran into that cap, with one tick between every two samples. + /// It also pins how the real probe samples and burns. Nothing here is + /// asserted on wall time or a ratio of wall times: this host is also a + /// loaded CI runner, where other processes' IOReport samples slow every + /// sample call. + /// - Group-only subscription, by structure: every sample the probe took + /// carried exactly the Energy Model group's channels, as the oracle + /// counts them on its own all-channel subscription, and none from + /// another group. An all-channel sample carries ~11,900 (M5 Max) or + /// ~14,300 (M3 Ultra). + /// - And by cost: the median THREAD CPU time of the probe's FFI sample + /// calls, by the tests' own clock, is under 50 ms. A group sample costs + /// ~8-9 ms of CPU however contended; an all-channel one ~100-125 ms. + /// - Burning, by the tests' own clocks: between every two FFI reads the + /// thread burned 25 ms of its CPU, unless that tick ran into a 500 ms + /// wall cap, and no tick shows more CPU than wall time. The probe's + /// own record agrees, tick by tick. Over the whole probe, at least + /// half a tick of CPU per interval, and a whole tick when no tick was + /// capped. #[test] fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { let _hw = crate::ioreport_hw_lock(); // The cached probe first, so it is not burning and sampling beside - // the probe this test times. + // the probe this test watches. let cached = crate::ioreport_status(); - let sub = EnergyModelSubscription::open(); + let reads = Rc::new(RefCell::new(Vec::<(Stamp, Stamp)>::new())); + let log = Rc::clone(&reads); + let pass_through = seam::stand_in(move |read| { + let entered = Stamp::closing(); + let items = read(); + log.borrow_mut().push((entered, Stamp::opening())); + items + }); + let tap = seam::tap(); let (cpu_before, t) = (test_thread_cpu(), Instant::now()); - let probe = sub.as_ref().map(|sub| probe_cpu_rail(sub, crate::CPU_RAIL_LIVENESS_BUDGET)); - let (took, cpu) = (t.elapsed(), test_thread_cpu() - cpu_before); - drop(sub); - let probed = probe.as_ref().map(|p| p.watch.verdict()); - let intervals = probe.as_ref().map_or(0, |p| p.watch.intervals); - let samples = probe.as_ref().map_or(&[][..], |p| &p.sample_walls[..]); - let ticks = probe.as_ref().map_or(&[][..], |p| &p.ticks[..]); - let sample_median = median(samples); - let tick_cpu: Vec = ticks.iter().map(|k| k.cpu).collect(); - let tick_wall: Vec = ticks.iter().map(|k| k.wall).collect(); + let status = host_status(); + let (took, cpu) = (t.elapsed(), test_thread_cpu().saturating_sub(cpu_before)); + let run = tap.one_run("host_status"); + drop((pass_through, tap)); + let reads = reads.take(); + assert_eq!(status, status_of(run.verdict()), "host_status said {status:?} of a run whose verdict is {:?}", run.verdict()); + let probed = run.watch.as_ref().map(RailWatch::verdict); + let intervals = run.watch.as_ref().map_or(0, |w| w.intervals); + let taken: Vec<&Taken> = run.samples.iter().filter_map(|s| s.taken.as_ref()).collect(); + let read_cpu: Vec = reads.iter().map(|&(entered, left)| entered.until(left).0).collect(); + let record_cpu: Vec = run.samples.iter().filter_map(|s| s.cpu).collect(); + let tick_cpu: Vec = reads.windows(2).map(|w| w[0].1.until(w[1].0).0).collect(); + let tick_wall: Vec = reads.windows(2).map(|w| w[0].1.until(w[1].0).1).collect(); + let record_gap = + run.ticks.iter().zip(&tick_cpu).filter_map(|(recorded, &by_tests)| Some(by_tests.abs_diff(recorded.cpu?))).max(); + let census = oracle_census(); let window = crate::CPU_RAIL_LIVENESS_BUDGET * 3; - let run = oracle_cpu_rail(window); + let oracle = oracle_cpu_rail(window); eprintln!( - "ioreport probe: probed={probed:?} cached={cached:?} took={took:?} thread_cpu={cpu:?} \ - ({:?} per interval) intervals={intervals} median sample cost={sample_median:?} over {} FFI \ - sample calls (max {:?}); ticks={} median tick cpu={:?} wall={:?}; advances per CPU rail {:?}; \ + "ioreport probe: probed={probed:?} cached={cached:?} took={took:?} thread_cpu={cpu:?} ({:?} per \ + interval) intervals={intervals}; {} FFI sample calls; ticks={} median tick cpu={:?} wall={:?} by the \ + tests' clocks (the probe's own record differs by at most {record_gap:?}); advances per CPU rail {:?}; \ oracle={:?} ({} reads over {:?})", cpu.checked_div(intervals), - samples.len(), - samples.iter().max(), - ticks.len(), + reads.len(), + run.ticks.len(), median(&tick_cpu), median(&tick_wall), - probe.as_ref().map(|p| advances(&p.watch)).unwrap_or_default(), - run.verdict, - run.reads, - run.took + run.watch.as_ref().map(advances).unwrap_or_default(), + oracle.verdict, + oracle.reads, + oracle.took + ); + eprintln!( + "ioreport structure: {} probe samples carried {:?} channels, {:?} of them outside the Energy Model \ + group; the oracle counts (in that group, in all) = {census:?}; thread CPU per FFI sample call: median \ + {:?} by the tests' clock (max {:?}), median {:?} by the probe's record", + taken.len(), + taken.iter().map(|t| t.channels).collect::>(), + taken.iter().map(|t| t.outside_group).collect::>(), + median(&read_cpu), + read_cpu.iter().max(), + median(&record_cpu) ); if probed == Some(Some(CpuRail::Live)) || cached == crate::IoReportStatus::Live { assert_eq!( - run.verdict, + oracle.verdict, Oracle::CpuRailMoved(true), "the probe said Live (probed={probed:?}, cached={cached:?}), the oracle did not see \ the CPU rail keep moving" ); } - match run.verdict { - Oracle::TooFewReads => too_few_reads(&run, window), + match oracle.verdict { + Oracle::TooFewReads => too_few_reads(&oracle, window), Oracle::NoIoReport => { assert!(matches!(probed, None | Some(None)), "the oracle could not open IOReport, the probe did"); assert_eq!(cached, crate::IoReportStatus::Unavailable); @@ -1636,151 +2670,386 @@ mod ioreport { "the oracle saw a CPU rail keep moving but the probe reported {probed:?}" ), } - if probe.is_some() { - let m = sample_median.expect("the probe made no FFI sample call"); + if run.watch.is_some() { + let (group, all) = census.expect("the oracle could not read the channels to count the Energy Model group"); + assert!(!taken.is_empty(), "the probe took no usable sample"); + for (i, t) in taken.iter().enumerate() { + assert_eq!( + t.outside_group, 0, + "probe sample {i} carried {} of its {} channels from outside the Energy Model group: the \ + probe is not subscribed to that group alone", + t.outside_group, t.channels + ); + assert_eq!( + t.channels, group, + "probe sample {i} carried {} channels; the Energy Model group has {group} (all groups: {all})", + t.channels + ); + } + assert!(group < all, "the Energy Model group ({group}) is not smaller than all channels ({all})"); + let m = median(&read_cpu).expect("the probe made no FFI sample call"); assert!( - m < GROUP_SAMPLE_MEDIAN_CEILING, - "the median of the probe's {} FFI sample calls took {m:?}, not under \ - {GROUP_SAMPLE_MEDIAN_CEILING:?}: the probe is not sampling the Energy Model group alone (a \ - group sample takes ~5-12 ms, an all-channel one 130-800 ms)", - samples.len() + m < GROUP_SAMPLE_CPU_CEILING, + "the median of the probe's {} FFI sample calls took {m:?} of this thread's CPU, by the tests' \ + own clock, not under {GROUP_SAMPLE_CPU_CEILING:?}: a group sample costs ~8-9 ms, an all-channel \ + one ~100-125 ms", + reads.len() ); assert_eq!( - ticks.len() + 1, - samples.len(), + reads.len(), + run.samples.len(), + "the probe recorded {} sample calls, the FFI was read {} times", + run.samples.len(), + reads.len() + ); + assert_eq!( + run.ticks.len() + 1, + run.samples.len(), "{} ticks between {} sample calls: the probe must tick once before every re-sample", - ticks.len(), - samples.len() - ); - } - for (i, k) in ticks.iter().enumerate() { - assert!( - k.cpu >= PROBE_TICK || k.wall >= PROBE_TICK_WALL_CAP, - "tick {i} used {:?} of this thread's CPU in {:?}: a tick burns {PROBE_TICK:?} of thread CPU \ - time, and only its {PROBE_TICK_WALL_CAP:?} wall cap may cut that short", - k.cpu, - k.wall + run.ticks.len(), + run.samples.len() ); + let ticks = assert_ticks_burned(&reads, "the real probe"); + for (k, (recorded, &(cpu, _))) in run.ticks.iter().zip(&ticks).enumerate() { + let rec = recorded.cpu.expect("the probe could not read its own thread clock"); + assert!( + rec <= recorded.wall + CLOCK_SLACK, + "tick {k}: the probe's record shows {rec:?} of CPU in {:?} of wall time", + recorded.wall + ); + assert!( + rec <= cpu + CLOCK_SLACK && cpu.saturating_sub(rec) <= RECORD_SLACK, + "tick {k}: the probe recorded {rec:?} of CPU; the tests' clock saw {cpu:?} between the two \ + reads around it" + ); + } } if intervals > 0 { let per_interval = cpu / intervals; assert!( - per_interval >= PROBE_TICK / 2, - "the probing thread used {cpu:?} of CPU over {intervals} intervals, {per_interval:?} \ - each, under half of each {PROBE_TICK:?} tick: the probe must burn CPU between samples, \ - not wait. Each tick spins until the thread's own CPU clock has advanced {PROBE_TICK:?}, \ - so on any load only its {PROBE_TICK_WALL_CAP:?} wall cap could cut this short" + per_interval >= TICK_CPU_FLOOR / 2, + "the probing thread used {cpu:?} of CPU over {intervals} intervals, {per_interval:?} each, under \ + half of each {TICK_CPU_FLOOR:?} tick: the probe must burn CPU between samples, not wait" ); - } - } - - /// The probe runs once per PROCESS, however many threads ask for the - /// status or build the meter at once, and however many times. Counted - /// by the process-wide [`PROBE_RUNS`], not timed: a probe on a live rail - /// is over in tens of milliseconds, which a clock cannot tell from a - /// cache hit. Eight threads released together by a barrier each ask - /// three times; a cache kept per thread would probe at least eight - /// times. - #[test] - fn the_liveness_status_is_probed_once_and_cached() { - let _hw = crate::ioreport_hw_lock(); - const THREADS: usize = 8; - let barrier = std::sync::Barrier::new(THREADS); - let statuses: Vec = std::thread::scope(|s| { - let workers: Vec<_> = (0..THREADS) - .map(|_| { - s.spawn(|| { - barrier.wait(); - let first = crate::ioreport_status(); - let _ = crate::Meter::detect(); - for _ in 0..3 { - assert_eq!(crate::ioreport_status(), first, "the cached status changed between calls"); - let _ = crate::Meter::detect(); - } - first - }) - }) - .collect(); - workers.into_iter().map(|w| w.join().expect("a probing thread panicked")).collect() - }); - let runs = PROBE_RUNS.load(Ordering::SeqCst); - eprintln!("ioreport cache: {THREADS} threads saw {statuses:?}; probe runs in this process = {runs}"); - assert!(statuses.iter().all(|s| *s == statuses[0]), "threads saw different statuses: {statuses:?}"); - assert_eq!(statuses[0], crate::ioreport_status(), "the spawned threads and this one disagree"); - assert_eq!(runs, 1, "the liveness probe did not run exactly once in this process"); - } - - /// When the rung IS selected, its own CPU rail is live on a fresh probe - /// and a CPU burn advances the counter itself — a reading, not the - /// time×power fallback that `measure` degrades to on a zero delta. - /// - /// When it is not, the status says why (never `Live`), and the meter - /// `detect()` returned still meters real work honestly: 100 ms of CPU - /// work is the time×power model's figure, at least 100 ms at the - /// documented default power, tagged `ModelBased` with no measurement. - /// Never a floored or zero figure for work that ran. - #[test] - fn a_selected_ioreport_meter_meters_cpu_work() { - let _hw = crate::ioreport_hw_lock(); - let meter = crate::Meter::detect(); - if let crate::Meter::IoReport(io) = &meter { - assert_eq!( - crate::ioreport_status(), - crate::IoReportStatus::Live, - "detect() selected IOReport although the liveness status is not Live" - ); - let rail = EnergyModelSubscription::open() - .and_then(|sub| probe_cpu_rail(&sub, crate::CPU_RAIL_LIVENESS_BUDGET).watch.verdict()); - assert_eq!(rail, Some(CpuRail::Live), "IOReport selected but a fresh probe says {rail:?}"); - let _ = io.read_uj(); // the first read only sets the baseline sample - let before = io.read_uj().expect("baseline read"); - cpu_work(Duration::from_millis(200)); - let after = io.read_uj().expect("work read"); - assert!( - after > before, - "a live CPU rail metered 200 ms of CPU work as zero: before={before} after={after}" - ); - let (_, e) = meter.measure(|| cpu_work(Duration::from_millis(200))); - assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased); - } else { - let status = crate::ioreport_status(); - assert_ne!(status, crate::IoReportStatus::Live, "status Live but detect() did not select the IOReport rung"); - let work = Duration::from_millis(100); - let (_, e) = meter.measure(|| cpu_work(work)); - let floor = crate::time_power_uj(work.as_nanos(), crate::DEFAULT_ACTIVE_MILLIWATTS); - eprintln!( - "declined meter: status={status:?} meter={meter:?} {work:?} of CPU work -> {} µJ, \ - provenance={:?}, measured_uj={:?}", - e.uj(), - e.provenance, - e.measured_uj - ); - e.check_provenance().expect("the declined meter's figure must be internally consistent"); - assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased, "the declined meter's figure is not ModelBased"); - assert!(e.measured_uj.is_none(), "the declined meter claimed a measurement: {:?}", e.measured_uj); - assert!(e.uj() > 0, "the declined meter metered {work:?} of CPU work as 0 µJ"); - assert!( - e.uj() >= floor, - "the declined meter metered {work:?} of CPU work as {} µJ, under the time×power model's \ - {floor} µJ at {} mW", - e.uj(), - crate::DEFAULT_ACTIVE_MILLIWATTS - ); - } - } - - /// Every channel whose name ends with `CPU Energy` or `GPU Energy`, - /// keyed by (name, ordinal among the same-named ones), in order: the - /// oracle's own matching, applied alike to its reads and the probe's. - fn keyed_rails(chans: impl IntoIterator) -> Vec<((String, u32), i64)> { - let mut out: Vec<((String, u32), i64)> = Vec::new(); - for (name, raw) in chans { - if name.ends_with("CPU Energy") || name.ends_with("GPU Energy") { - let ordinal = out.iter().filter(|((n, _), _)| *n == name).count() as u32; - out.push(((name, ordinal), raw)); + if tick_wall.iter().all(|&wall| wall < TICK_WALL_CAP) { + assert!( + per_interval >= TICK_CPU_FLOOR, + "the probing thread used {per_interval:?} of CPU per interval, under a whole \ + {TICK_CPU_FLOOR:?} tick, though no tick ran into the {TICK_WALL_CAP:?} wall cap" + ); } } - out + } + + /// What the real probe hands the rule is what the hardware held at that + /// moment. [`host_status`] runs on this thread (the tap keeps its run; + /// a stand-in passes every FFI read through unchanged and records when + /// it ran, by the tests' clock). On a helper thread an oracle (its own + /// all-channel subscription, its own strings, absolute values) reads + /// the Energy Model group's CPU and GPU rails back to back, from before + /// the probe's first read until after its last. The rails only count + /// up, so for each sample the probe handed the rule, an oracle read that + /// ENDED before that FFI read began is a floor under every rail, and one + /// that BEGAN after it ended is a ceiling: + /// - a rail the oracle saw hold across a read must have been handed + /// exactly the value the oracle read (a frozen CPU rail is constant); + /// - a rail that moved must have been handed a value between the two; + /// - the probe's CPU rails are exactly the oracle's, and so are its GPU + /// rails. + /// + /// Freshness is not left to chance. The stand-in holds the probe's + /// second FFI read until the oracle has made a read that began after the + /// probe's first read ended and saw a rail differ from it, or, failing + /// that, until the oracle has made two such reads AND 3 s have passed. + /// That is counted in the oracle's reads, not only in time: on a loaded + /// host one all-channel read can take seconds. A witness counts only if + /// it was seen before the second read was released, so it is a floor + /// under every later probe read: a sampler that repeats a stale sample, + /// per subscription or process-wide, hands the rule a value below it + /// and fails. The M5 Max's GPU rail changes every ~28 ms, so there a + /// witness is always found; on the M3 Ultra the GPU may hold for + /// seconds, and the printed line says whether a witness was found and + /// staleness asserted. + /// + /// A sampler that sums the CPU rails is caught where there are two (the + /// M3 Ultra's dies). On one rail (the M5 Max) the sum of the CPU rails + /// IS the rail; the scripted two-die tests catch a sum there. + #[test] + fn the_real_probe_hands_the_rule_what_the_oracle_reads() { + let _hw = crate::ioreport_hw_lock(); + /// Rails keyed by (name, ordinal), with their raw values. + type Keyed = Vec<((String, u32), i64)>; + struct Read { + start: Instant, + end: Instant, + rails: Keyed, + } + /// From the probe's thread: when its first FFI read ended and the + /// rails it read, then that its second read was released. From the + /// oracle's: how many reads it began after the first read ended, + /// before that release; whether one of them saw a rail differ from + /// the first read; and that it has stopped reading. + #[derive(Default)] + struct Witness { + first: Option<(Instant, Keyed)>, + looked: u32, + seen: bool, + released: bool, + oracle_done: bool, + } + let witness = std::sync::Arc::new((Mutex::new(Witness::default()), std::sync::Condvar::new())); + let windows = Rc::new(RefCell::new(Vec::<(Instant, Instant)>::new())); + let hold = { + let (witness, log, n) = (std::sync::Arc::clone(&witness), Rc::clone(&windows), Cell::new(0)); + seam::stand_in(move |read| { + let i = n.get(); + n.set(i + 1); + if i == 1 { + let (lock, woken) = &*witness; + let from = Instant::now(); + let mut w = lock.lock().unwrap(); + while !w.seen + && !(w.looked >= 2 && from.elapsed() >= WITNESS_WAIT) + && !w.oracle_done + && from.elapsed() < ORACLE_GIVE_UP + { + w = woken.wait_timeout(w, Duration::from_millis(50)).unwrap().0; + } + // From here on the oracle's reads are no floor under + // this read, so none of them may count as its witness. + w.released = true; + } + let start = Instant::now(); + let items = read(); + let end = Instant::now(); + log.borrow_mut().push((start, end)); + if let (0, Some(items)) = (i, &items) { + let rails = keyed_rails( + items + .iter() + .filter(|it| it.group.as_deref().is_some_and(|g| g.eq_ignore_ascii_case("Energy Model"))) + .filter_map(|it| Some((it.name.clone()?, it.raw))), + ); + let (lock, woken) = &*witness; + lock.lock().unwrap().first = Some((end, rails)); + woken.notify_all(); + } + items + }) + }; + let reads = Mutex::new(Vec::::new()); + let probe_ended = Mutex::new(None::); + let (first_read, gave_up) = (AtomicBool::new(false), AtomicBool::new(false)); + let (t0, status, t1, runs) = std::thread::scope(|s| { + let oracle = s.spawn(|| { + let Some(o) = oracle_subscribe() else { + gave_up.store(true, Ordering::SeqCst); + let (lock, woken) = &*witness; + lock.lock().unwrap().oracle_done = true; + woken.notify_all(); + return; + }; + let since = Instant::now(); + loop { + let start = Instant::now(); + let rails = oracle_group_rails(o.0, o.1); + let end = Instant::now(); + let ended = *probe_ended.lock().unwrap(); + if let Some(rails) = rails { + let (lock, woken) = &*witness; + let mut w = lock.lock().unwrap(); + let after_first = w.first.as_ref().is_some_and(|(first_end, _)| start >= *first_end); + if after_first && !w.released { + w.looked += 1; + if w.first.as_ref().is_some_and(|(_, first)| rails != *first) { + w.seen = true; + } + woken.notify_all(); + } + drop(w); + reads.lock().unwrap().push(Read { start, end, rails }); + first_read.store(true, Ordering::SeqCst); + if ended.is_some_and(|t1| start >= t1) { + break; + } + } + if since.elapsed() > ORACLE_GIVE_UP { + gave_up.store(true, Ordering::SeqCst); + break; + } + } + oracle_unsubscribe(o); + let (lock, woken) = &*witness; + lock.lock().unwrap().oracle_done = true; + woken.notify_all(); + }); + // One whole oracle read before the probe starts: the floor under + // its first sample. + let waiting = Instant::now(); + while !first_read.load(Ordering::SeqCst) + && !gave_up.load(Ordering::SeqCst) + && waiting.elapsed() < ORACLE_GIVE_UP + { + std::thread::sleep(Duration::from_millis(1)); + } + let tap = seam::tap(); + let t0 = Instant::now(); + let status = host_status(); + let t1 = Instant::now(); + *probe_ended.lock().unwrap() = Some(t1); + oracle.join().expect("the oracle thread panicked"); + (t0, status, t1, tap.runs()) + }); + drop(hold); + let reads = reads.into_inner().expect("the oracle's reads"); + let windows = windows.take(); + let (witnessed, looked) = { + let w = witness.0.lock().unwrap(); + (w.seen, w.looked) + }; + assert_eq!(runs.len(), 1, "host_status made {} probe runs, not one", runs.len()); + let run = &runs[0]; + assert_eq!(status, status_of(run.verdict()), "host_status said {status:?} of a run whose verdict is {:?}", run.verdict()); + if run.watch.is_none() { + let cpu_rails_seen = reads.iter().flat_map(|r| &r.rails).filter(|((n, _), _)| n.ends_with("CPU Energy")).count(); + eprintln!( + "ioreport freshness: no Energy Model subscription opens; status={status:?}; the oracle saw \ + {cpu_rails_seen} CPU rail readings in that group" + ); + assert_eq!(status, crate::IoReportStatus::Unavailable); + assert!(windows.is_empty(), "the probe read {} samples with no subscription", windows.len()); + assert_eq!(cpu_rails_seen, 0, "the oracle reads Energy Model CPU rails, but the probe could not subscribe to that group"); + return; + } + assert!(!gave_up.load(Ordering::SeqCst), "the oracle could not keep reading beside the probe ({} reads)", reads.len()); + assert_eq!( + windows.len(), + run.samples.len(), + "the probe recorded {} sample calls, the FFI was read {} times", + run.samples.len(), + windows.len() + ); + assert!( + run.started >= t0 && run.samples.first().is_none_or(|s| run.started <= s.at), + "the probe's run is recorded as starting {:?} into host_status's call, after its first sample call", + run.started.saturating_duration_since(t0) + ); + for (k, (s, &(start, end))) in run.samples.iter().zip(&windows).enumerate() { + assert!( + t0 <= s.at && s.at <= start && end <= s.at + s.wall && s.at + s.wall <= t1, + "probe sample call {k} is recorded at {:?} for {:?} into host_status's call, but its FFI read ran \ + {:?}..{:?}: the probe's own clock does not contain its read", + s.at.saturating_duration_since(t0), + s.wall, + start.saturating_duration_since(t0), + end.saturating_duration_since(t0) + ); + } + let taken: Vec<(usize, &Taken)> = + run.samples.iter().enumerate().filter_map(|(k, s)| s.taken.as_ref().map(|t| (k, t))).collect(); + assert!(taken.len() >= 2, "the probe handed the rule {} samples", taken.len()); + let (first_read_at, last_read_end) = (windows[taken[0].0].0, windows[taken[taken.len() - 1].0].1); + assert!(reads.first().is_some_and(|r| r.end <= first_read_at), "no oracle read ended before the probe's first read"); + assert!(reads.last().is_some_and(|r| r.start >= last_read_end), "no oracle read began after the probe's last read"); + let keys = |rails: &[((String, u32), i64)], suffix: &str| -> Vec<(String, u32)> { + rails.iter().filter(|((n, _), _)| n.ends_with(suffix)).map(|(k, _)| k.clone()).collect() + }; + let (oracle_cpu, oracle_gpu) = (keys(&reads[0].rails, "CPU Energy"), keys(&reads[0].rails, "GPU Energy")); + for r in &reads { + assert_eq!(keys(&r.rails, "CPU Energy"), oracle_cpu, "the oracle's CPU rails changed between its reads"); + assert_eq!(keys(&r.rails, "GPU Energy"), oracle_gpu, "the oracle's GPU rails changed between its reads"); + } + let key_of = |k: &RailKey| (k.name.clone(), k.ordinal); + let at = |r: &Read, key: &(String, u32)| -> i64 { + r.rails.iter().find(|(k, _)| k == key).map(|(_, v)| *v).expect("a rail missing from an oracle read") + }; + let handed = |t: &Taken, key: &(String, u32)| -> i64 { + t.cpu_rails + .iter() + .chain(&t.gpu_rails) + .find(|(k, _)| key_of(k) == *key) + .map(|(_, v)| *v) + .expect("a rail missing from a probe sample") + }; + let (mut held, mut moved) = (0, 0); + for &(k, t) in &taken { + let (start, end) = windows[k]; + assert_eq!( + t.cpu_rails.iter().map(|(key, _)| key_of(key)).collect::>(), + oracle_cpu, + "probe sample {k}: the probe's CPU rails are not the ones the oracle reads" + ); + assert_eq!( + t.gpu_rails.iter().map(|(key, _)| key_of(key)).collect::>(), + oracle_gpu, + "probe sample {k}: the probe's GPU rails are not the ones the oracle reads" + ); + for (key, p) in t.cpu_rails.iter().chain(&t.gpu_rails) { + let key = key_of(key); + let rail = format!("{}#{}", key.0, key.1); + let floor = reads.iter().filter(|r| r.end <= start).map(|r| at(r, &key)).max().expect("a read before"); + let ceiling = reads.iter().filter(|r| r.start >= end).map(|r| at(r, &key)).min().expect("a read after"); + if floor == ceiling { + held += 1; + assert_eq!( + *p, floor, + "{rail} held at {floor} in the oracle's reads either side of probe read {k}, but the probe \ + handed the rule {p}" + ); + } else { + moved += 1; + assert!( + (floor..=ceiling).contains(p), + "{rail} moved {floor} -> {ceiling} in the oracle's reads either side of probe read {k}, but \ + the probe handed the rule {p}, outside them: a stale or made-up sample" + ); + } + } + } + // The witness: an oracle read wholly between the probe's first read + // and its second, which saw a rail differ from the first read. + let freshness = if witnessed { + let (first_end, second_start) = (windows[0].1, windows.get(1).map_or(t1, |w| w.0)); + let first = taken.iter().find(|&&(k, _)| k == 0).map(|&(_, t)| t).expect("the probe's first read gave no sample"); + let w = reads + .iter() + .find(|r| { + r.start >= first_end + && r.end <= second_start + && r.rails.iter().any(|(key, v)| handed(first, key) != *v) + }) + .expect("the oracle said it saw a rail move before the probe's second read, but no such read is recorded"); + let later = taken.iter().find(|&&(k, _)| k >= 1).map(|&(k, t)| (k, t)).expect("no usable probe read after the first"); + let mut asserted = Vec::new(); + for (key, o) in w.rails.iter().filter(|(key, v)| handed(first, key) != *v) { + let (p0, p) = (handed(first, key), handed(later.1, key)); + let rail = format!("{}#{}", key.0, key.1); + assert!( + p >= *o && p != p0, + "{rail}: the probe handed the rule {p0} at its first read, the oracle then read {o}, and the \ + probe handed the rule {p} at read {}: a stale sample", + later.0 + ); + asserted.push(format!("{rail}: probe {p0}, then oracle {o}, then probe {p}")); + } + format!( + "witness found {:?} after the probe's first read, at the oracle's read {looked} since it, staleness \ + asserted: {asserted:?}", + w.start.saturating_duration_since(first_end) + ) + } else { + format!( + "no rail moved in the oracle's {looked} reads between the probe's first and second reads (held at \ + least {WITNESS_WAIT:?}), so staleness was not asserted" + ) + }; + eprintln!( + "ioreport freshness: status={status:?}; {} probe samples handed to the rule, bracketed by {} oracle reads; \ + {held} rail readings the oracle saw hold were handed equal, {moved} that moved were handed within the \ + oracle's reads; {freshness}", + taken.len(), + reads.len() + ); } /// The probe's own FFI sampler reads each CPU rail's true absolute @@ -1806,21 +3075,21 @@ mod ioreport { chans }); eprintln!("ioreport sampler: no Energy Model subscription opens; the oracle read {found:?}"); - let in_group = found.unwrap_or_default().iter().filter(|(g, _, _)| g.eq_ignore_ascii_case("Energy Model")).count(); + let in_group = + found.unwrap_or_default().iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")).count(); assert_eq!(in_group, 0, "the oracle reads CPU rails in the Energy Model group, but the probe cannot subscribe to it"); return; }; - let oracle = oracle_subscribe().expect("the probe's Energy Model subscription opened, the oracle's all-channel one did not"); + let oracle = + oracle_subscribe().expect("the probe's Energy Model subscription opened, the oracle's all-channel one did not"); let read = |when: &str| -> Vec<((String, u32), i64)> { - let chans = oracle_channels(oracle.0, oracle.1, |n| n.ends_with("CPU Energy") || n.ends_with("GPU Energy")) - .unwrap_or_else(|| panic!("the oracle's read {when} the probe's sample failed")); - keyed_rails(chans.into_iter().filter(|(g, _, _)| g.eq_ignore_ascii_case("Energy Model")).map(|(_, n, v)| (n, v))) + oracle_group_rails(oracle.0, oracle.1).unwrap_or_else(|| panic!("the oracle's read {when} the probe's sample failed")) }; let names = |v: &[((String, u32), i64)]| v.iter().map(|(k, _)| k.clone()).collect::>(); let mut seen = Vec::new(); for round in 0..2 { let before = read("before"); - let sample = sub.sample().expect("the probe's sampler gave no sample"); + let sample = sub.sample().expect("the probe's sampler gave no sample").values; let after = read("after"); let probe = keyed_rails(sample.iter().cloned()); assert_eq!(names(&probe), names(&before), "the probe's sampler and the oracle read different rails"); @@ -1853,61 +3122,144 @@ mod ioreport { eprintln!("ioreport sampler: oracle before | probe | oracle after, per rail: {seen:?}"); } - /// The counter sums what its rails did. The symptom seen on 2026-09-19, - /// the counter open and reading 0 µJ across a whole run, is a reader - /// that returns a constant, and only a rail that moved can tell a - /// constant from a true zero. So: take the counter's baseline; then read - /// `GPU Energy` in the Energy Model group independently (the oracle's - /// subscription, absolute values) until one such channel rises by at - /// least 10⁶ raw units (≥ 1 µJ in every unit the counter converts), or - /// 3 s pass; then read the counter again. The counter's window contains - /// the independent one and the rail is cumulative, so if the GPU rail - /// rose, the counter must have. With the GPU idle nothing can tell a - /// constant from a true zero: that branch asserts only that the counter - /// never runs backwards, and the printed line says which branch ran. + /// When the rung IS selected, its own CPU rail is live on a fresh probe + /// and a CPU burn advances the counter itself — a reading, not the + /// time×power fallback that `measure` degrades to on a zero delta. + /// + /// When it is not, the status says why (never `Live`), and the meter + /// `detect()` returned still meters real work honestly: 100 ms of CPU + /// work is the time×power model's figure, at least 100 ms at the + /// documented default power, tagged `ModelBased` with no measurement. + /// Never a floored or zero figure for work that ran. + #[test] + fn a_selected_ioreport_meter_meters_cpu_work() { + let _hw = crate::ioreport_hw_lock(); + let meter = crate::Meter::detect(); + if let crate::Meter::IoReport(io) = &meter { + assert_eq!( + crate::ioreport_status(), + crate::IoReportStatus::Live, + "detect() selected IOReport although the liveness status is not Live" + ); + let fresh = host_status(); + assert_eq!(fresh, crate::IoReportStatus::Live, "IOReport selected but a fresh probe says {fresh:?}"); + let _ = io.read_uj(); // the first read only sets the baseline sample + let before = io.read_uj().expect("baseline read"); + spin_cpu(Duration::from_millis(200)); + let after = io.read_uj().expect("work read"); + assert!( + after > before, + "a live CPU rail metered 200 ms of CPU work as zero: before={before} after={after}" + ); + let (_, e) = meter.measure(|| spin_cpu(Duration::from_millis(200))); + assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased); + } else { + let status = crate::ioreport_status(); + assert_ne!(status, crate::IoReportStatus::Live, "status Live but detect() did not select the IOReport rung"); + let work = Duration::from_millis(100); + let (_, e) = meter.measure(|| spin_cpu(work)); + let floor = crate::time_power_uj(work.as_nanos(), crate::DEFAULT_ACTIVE_MILLIWATTS); + eprintln!( + "declined meter: status={status:?} meter={meter:?} {work:?} of CPU work -> {} µJ, \ + provenance={:?}, measured_uj={:?}", + e.uj(), + e.provenance, + e.measured_uj + ); + e.check_provenance().expect("the declined meter's figure must be internally consistent"); + assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased, "the declined meter's figure is not ModelBased"); + assert!(e.measured_uj.is_none(), "the declined meter claimed a measurement: {:?}", e.measured_uj); + assert!(e.uj() > 0, "the declined meter metered {work:?} of CPU work as 0 µJ"); + assert!( + e.uj() >= floor, + "the declined meter metered {work:?} of CPU work as {} µJ, under the time×power model's \ + {floor} µJ at {} mW", + e.uj(), + crate::DEFAULT_ACTIVE_MILLIWATTS + ); + } + } + + /// The counter sums what its rails did, value for value. The symptom + /// seen on 2026-09-19, the counter open and reading 0 µJ across a whole + /// run, is a reader that returns a constant; a check on its delta alone + /// passes whenever nothing happened to move. + /// + /// So the oracle reads, absolutely on its own all-channel subscription, + /// every rail the counter sums (the Energy Model group's channels that + /// `counts_once` admits; that choice is pinned on its own by + /// `counts_once_drops_subrails`), just before and just after each of the + /// counter's two reads. The rails only count up, so, rail by rail in + /// raw units and then converted as the counter converts, what the + /// counter counted between its two reads is at least the rise between + /// the two oracle reads inside that window, and at most the rise + /// between the two outside it. When nothing moved, both are 0 and the + /// counter must have counted exactly 0. #[test] fn the_counter_advances_when_a_rail_it_sums_advanced() { let _hw = crate::ioreport_hw_lock(); let Some(io) = IoReport::open() else { + // Not a pass by default: the oracle's all-channel subscription + // must fail to open too, or the counter's opener is broken. + let oracle_opens = oracle_subscribe().map(oracle_unsubscribe).is_some(); let selected = matches!(crate::Meter::detect(), crate::Meter::IoReport(_)); - eprintln!("ioreport counter: no counter opens on this host; selected={selected}"); + eprintln!("ioreport counter: no counter opens on this host; oracle opens={oracle_opens} selected={selected}"); + assert!(!oracle_opens, "the counter does not open, but the oracle's all-channel subscription does"); assert!(!selected, "detect() selected an IOReport counter that does not open"); return; }; let oracle = oracle_subscribe().expect("the counter's all-channel subscription opened, the oracle's did not"); - let gpu = || { - oracle_channels(oracle.0, oracle.1, |n| n.ends_with("GPU Energy")).map(|chans| { - chans - .into_iter() - .filter(|(group, _, _)| group.eq_ignore_ascii_case("Energy Model")) - .map(|c| c.2) - .collect::>() - }) - }; - let base = io.read_uj().expect("the counter's baseline read"); - let g0 = gpu().expect("an independent read of GPU Energy"); - let started = Instant::now(); - let mut rose: i64 = 0; - while rose < 1_000_000 && started.elapsed() < Duration::from_secs(3) { - if let Some(g) = gpu() { - rose = g.iter().zip(&g0).map(|(now, before)| now - before).max().unwrap_or(0); + // Every rail the counter sums, keyed by (name, ordinal), with its + // unit and its absolute raw value. + let summed = |when: &str| -> Vec<((String, u32), String, i64)> { + let chans = oracle_channels(oracle.0, oracle.1, counts_once) + .unwrap_or_else(|| panic!("the oracle's read {when} failed")); + let mut out: Vec<((String, u32), String, i64)> = Vec::new(); + for c in chans.into_iter().filter(|c| c.group.eq_ignore_ascii_case("Energy Model")) { + let ordinal = out.iter().filter(|((n, _), _, _)| *n == c.name).count() as u32; + out.push(((c.name, ordinal), c.unit, c.raw)); } - } - let window = started.elapsed(); - let after = io.read_uj().expect("the counter's second read"); + out + }; + let _ = io.read_uj().expect("the counter's baseline read"); + let before_first = summed("before the counter's first read"); + let first = io.read_uj().expect("the counter's first read"); + let after_first = summed("after the counter's first read"); + spin_cpu(Duration::from_millis(200)); + let before_second = summed("before the counter's second read"); + let second = io.read_uj().expect("the counter's second read"); + let after_second = summed("after the counter's second read"); oracle_unsubscribe(oracle); + // The rise of the summed rails from one oracle read to another, in + // µJ as the counter converts each rail, and how many rails rose. + let rise = |from: &[((String, u32), String, i64)], to: &[((String, u32), String, i64)]| -> (u64, usize) { + let keys = |v: &[((String, u32), String, i64)]| v.iter().map(|(k, _, _)| k.clone()).collect::>(); + assert_eq!(keys(from), keys(to), "the summed rails changed between two oracle reads"); + let mut uj: u64 = 0; + let mut rose = 0; + for ((_, unit, a), (_, _, b)) in from.iter().zip(to) { + let raw = b.saturating_sub(*a).max(0); + rose += usize::from(raw > 0); + uj = uj.saturating_add(energy_to_uj(raw as c_long, unit)); + } + (uj, rose) + }; + let (inside, rose_inside) = rise(&after_first, &before_second); + let (around, rose_around) = rise(&before_first, &after_second); eprintln!( - "ioreport counter: GPU Energy (independent, absolute) rose {rose} raw over {window:?}; \ - the counter, over a window containing it, went {base} -> {after} µJ" + "ioreport counter: it sums {} Energy Model rails; between its two reads it counted {} µJ; by the \ + oracle's absolute reads those rails rose {inside} µJ inside that window ({rose_inside} rails rose) and \ + {around} µJ around it ({rose_around} rose)", + after_first.len(), + second.saturating_sub(first) + ); + assert!(second >= first, "the counter ran backwards: {first} -> {second} µJ"); + let counted = second - first; + assert!( + inside <= counted && counted <= around, + "the counter counted {counted} µJ between its two reads, but the rails it sums rose {inside} µJ inside \ + that window and {around} µJ around it, by the oracle's absolute reads: it did not sum what its rails did" ); - assert!(after >= base, "the counter ran backwards: {base} -> {after} µJ"); - if rose >= 1_000_000 { - assert!( - after > base, - "GPU Energy rose {rose} raw inside the counter's window, but the counter read {base} -> \ - {after} µJ: it did not sum a rail it counts" - ); - } } } } @@ -1963,7 +3315,7 @@ pub fn ioreport_status() -> IoReportStatus { #[cfg(target_os = "macos")] { static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); - *STATUS.get_or_init(|| ioreport::probe_status(CPU_RAIL_LIVENESS_BUDGET)) + *STATUS.get_or_init(ioreport::host_status) } #[cfg(not(target_os = "macos"))] { @@ -1971,6 +3323,46 @@ pub fn ioreport_status() -> IoReportStatus { } } +/// Whether a liveness status admits the IOReport rung: [`IoReportStatus::Live`] +/// and nothing else. The one gate the selection path ([`Meter::detect`]) goes +/// through, pure so that every status is checked in a test. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn admits(status: IoReportStatus) -> bool { + matches!(status, IoReportStatus::Live) +} + +/// Test instrumentation, not API: how many times the real IOReport liveness +/// probe has run in this process. [`ioreport_status`] runs it once however +/// many threads ask, and `tests/ioreport_probed_once.rs` pins that, as does a +/// lib test in a fresh process of its own. Always 0 off macOS, where there is +/// no probe. +#[doc(hidden)] +pub fn ioreport_probe_runs() -> usize { + #[cfg(target_os = "macos")] + { + ioreport::host_probe_runs() + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + +/// Test instrumentation, not API: how many subscriptions to the Energy Model +/// group this process has opened, one per probe whatever ran it. Always 0 off +/// macOS. +#[doc(hidden)] +pub fn ioreport_energy_model_opens() -> usize { + #[cfg(target_os = "macos")] + { + ioreport::energy_model_opens() + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + /// A metering backend, in ladder order. #[derive(Debug)] pub enum Meter { @@ -2000,7 +3392,8 @@ impl Meter { /// [`Meter::detect`] with the IOReport status supplied, so the gate can be /// driven with every status on a host whose own status is one value. The /// status is asked for only once RAPL and NVML are absent: the first ask - /// in a process runs the liveness probe. + /// in a process runs the liveness probe. It selects the rung only when + /// [`admits`] does. fn detect_with(ioreport_status: impl FnOnce() -> IoReportStatus) -> Self { if let Some(r) = Rapl::open() { return Meter::Rapl(r); @@ -2013,7 +3406,7 @@ impl Meter { // the rung reports GPU-only energy, zero, or a whole batch refresh as // the energy of CPU work. #[cfg(target_os = "macos")] - if ioreport_status() == IoReportStatus::Live { + if admits(ioreport_status()) { if let Some(io) = ioreport::IoReport::open() { return Meter::IoReport(io); } @@ -2107,11 +3500,11 @@ fn time_power_uj(elapsed_ns: u128, milliwatts: u64) -> u64 { u64::try_from(elapsed_ns.saturating_mul(milliwatts as u128) / 1_000_000).unwrap_or(u64::MAX) } -/// Serialises the tests that use IOReport, or may run the liveness probe, on -/// this host. Otherwise the test harness runs them side by side, and one test's -/// all-channel reads and CPU burn slow another's IOReport samples: the probe's -/// sample times and the oracle's read count would measure the sibling tests, -/// not the host. A test that panics while holding it does not poison the rest. +/// Serialises the tests that use IOReport, or burn CPU through the liveness +/// probe, on this host. Otherwise the test harness runs them side by side, and +/// one test's all-channel reads and CPU burn slow another's IOReport samples +/// and thin out its oracle's reads. A test that panics while holding it does +/// not poison the rest. #[cfg(all(test, target_os = "macos"))] fn ioreport_hw_lock() -> std::sync::MutexGuard<'static, ()> { static HW: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -2172,6 +3565,103 @@ mod tests { assert_eq!(ioreport_status(), IoReportStatus::Unavailable); } + /// Set in the environment of the fresh process that + /// [`the_liveness_probe_runs_once_in_a_fresh_process`] starts. + const FRESH_PROCESS: &str = "JCP_ENERGY_FRESH_PROCESS_PIN"; + + /// The liveness probe runs once per PROCESS, whoever asks first and + /// however many ask at once, pinned in the library's own tests: the macOS + /// CI job runs `cargo test --lib -p jcp-energy`, which does not run + /// `tests/ioreport_probed_once.rs`. In this process other tests have + /// already asked for the status, so this test runs its own binary again, + /// filtered to itself, as a process of its own. There the eight calls + /// below are the first use of the cache. Counted, not timed. + #[test] + fn the_liveness_probe_runs_once_in_a_fresh_process() { + if std::env::var_os(FRESH_PROCESS).is_some() { + return probed_once_from_eight_threads(); + } + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let out = std::process::Command::new(std::env::current_exe().expect("this test binary's path")) + .args(["tests::the_liveness_probe_runs_once_in_a_fresh_process", "--exact", "--nocapture", "--test-threads=1"]) + .env(FRESH_PROCESS, "1") + .output() + .expect("could not run this test binary again"); + let text = format!("{}{}", String::from_utf8_lossy(&out.stdout), String::from_utf8_lossy(&out.stderr)); + for line in text.lines().filter(|l| l.starts_with("ioreport cache")) { + eprintln!("{line}"); + } + assert!(out.status.success(), "the pin failed in a fresh process:\n{text}"); + // A filter that matched nothing would also exit 0. + assert!( + text.contains("running 1 test") && text.contains("1 passed"), + "the fresh process did not run the pin:\n{text}" + ); + } + + /// The body [`the_liveness_probe_runs_once_in_a_fresh_process`] runs in + /// the fresh process. Eight threads, released together, ask for the + /// status and build the meter, half in each order, then again three + /// times. The probe must have run, and opened its subscription, once. + fn probed_once_from_eight_threads() { + const THREADS: usize = 8; + assert_eq!( + (ioreport_probe_runs(), ioreport_energy_model_opens()), + (0, 0), + "something probed before the pin began, so its calls are not the first use" + ); + let barrier = std::sync::Barrier::new(THREADS); + let statuses: Vec = std::thread::scope(|s| { + let workers: Vec<_> = (0..THREADS) + .map(|i| { + let barrier = &barrier; + s.spawn(move || { + barrier.wait(); + let first = if i % 2 == 0 { + let _ = Meter::detect(); + ioreport_status() + } else { + let status = ioreport_status(); + let _ = Meter::detect(); + status + }; + for _ in 0..3 { + assert_eq!(ioreport_status(), first, "the cached status changed between calls"); + let _ = Meter::detect(); + } + first + }) + }) + .collect(); + workers.into_iter().map(|w| w.join().expect("a thread panicked")).collect() + }); + let (runs, opens) = (ioreport_probe_runs(), ioreport_energy_model_opens()); + eprintln!( + "ioreport cache (fresh process, lib tests): {THREADS} threads, first use concurrent; they saw {statuses:?}; \ + probe runs = {runs}, Energy Model subscriptions opened = {opens}" + ); + assert!(statuses.iter().all(|s| *s == statuses[0]), "threads saw different statuses: {statuses:?}"); + assert_eq!(statuses[0], ioreport_status(), "the spawned threads and this one disagree"); + if cfg!(target_os = "macos") { + assert_eq!(runs, 1, "the liveness probe did not run exactly once in this process"); + assert_eq!(opens, 1, "{opens} Energy Model subscriptions were opened: something probed besides the cached probe"); + } else { + assert_eq!((runs, opens), (0, 0), "a probe ran on a platform with no IOReport"); + assert_eq!(statuses[0], IoReportStatus::Unavailable); + } + } + + /// The gate itself, on every status: only `Live` admits the IOReport rung. + #[test] + fn only_live_admits_the_ioreport_rung() { + use IoReportStatus as S; + assert!(admits(S::Live), "Live does not admit the rung"); + for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { + assert!(!admits(status), "{status:?} admits the rung"); + } + } + /// `detect()` selects the IOReport rung on a `Live` status wherever the /// counter opens, and on no other status: the others fall to the /// time×power model at the documented default power. diff --git a/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs b/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs new file mode 100644 index 000000000..0ec3952b6 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs @@ -0,0 +1,68 @@ +//! The IOReport liveness probe runs once per PROCESS, whoever asks first and +//! however many ask at once. +//! +//! This file holds one test, so it runs in a process of its own, and its +//! concurrent calls ARE the first use: the cache starts cold, whatever order +//! other tests run in. Counted, not timed: a probe on a live rail is over in +//! tens of milliseconds, which a clock cannot tell from a cache hit. + +use std::sync::Barrier; + +use jcp_energy::{ioreport_status, IoReportStatus, Meter}; + +#[test] +fn the_liveness_probe_runs_once_per_process() { + const THREADS: usize = 8; + let (runs_before, opens_before) = (jcp_energy::ioreport_probe_runs(), jcp_energy::ioreport_energy_model_opens()); + assert_eq!( + (runs_before, opens_before), + (0, 0), + "something probed before this test began, so its calls are not the first use" + ); + let barrier = Barrier::new(THREADS); + let statuses: Vec = std::thread::scope(|s| { + let workers: Vec<_> = (0..THREADS) + .map(|i| { + let barrier = &barrier; + s.spawn(move || { + barrier.wait(); + // Half the threads build the meter first and half ask for + // the status first, so either path can be the first use. + let first = if i % 2 == 0 { + let _ = Meter::detect(); + ioreport_status() + } else { + let status = ioreport_status(); + let _ = Meter::detect(); + status + }; + for _ in 0..3 { + assert_eq!(ioreport_status(), first, "the cached status changed between calls"); + let _ = Meter::detect(); + } + first + }) + }) + .collect(); + workers.into_iter().map(|w| w.join().expect("a thread panicked")).collect() + }); + let (runs, opens) = (jcp_energy::ioreport_probe_runs(), jcp_energy::ioreport_energy_model_opens()); + eprintln!( + "ioreport cache: {THREADS} threads, first use concurrent, each asked for the status 4 times and built the \ + meter 4 times; they saw {statuses:?}; probe runs in this process = {runs}, Energy Model subscriptions \ + opened = {opens}" + ); + assert!(statuses.iter().all(|s| *s == statuses[0]), "threads saw different statuses: {statuses:?}"); + assert_eq!(statuses[0], ioreport_status(), "the spawned threads and this one disagree"); + if cfg!(target_os = "macos") { + assert_eq!(runs, 1, "the liveness probe did not run exactly once in this process"); + assert_eq!( + opens, 1, + "{opens} Energy Model subscriptions were opened in this process: something probed besides the one cached \ + probe" + ); + } else { + assert_eq!((runs, opens), (0, 0), "a probe ran on a platform with no IOReport"); + assert_eq!(statuses[0], IoReportStatus::Unavailable); + } +} -- 2.54.0 (Apple Git-157) From 3fd35db5f98a931ef4f28386f13eb2e823517d86 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 18:48:11 -0400 Subject: [PATCH 06/10] jcp-energy: a rail is live only if it keeps advancing, never on a two-step refresh The CPU-rail liveness rule said Live once the same CPU rail had advanced in two separate sample intervals. On 2026-09-30 the jouleclaw-energy verifier found a batch refresh that passes it. On this M5 Max (macOS 27.0.1), a group-only watcher sampling the Energy Model every ~16 ms saw a refresh published in two steps: CPU Energy 2643843378 -> 2705607863 (+61764485) CPU Energy 2705607863 -> 2705611298 (+3435) 64 ms later and MCPU0, MCPU1 and PCPU each stepped twice with it. The probe's intervals are ~31 ms (a 25 ms thread-CPU tick and a ~6 ms sample), so a probe window holding such a refresh sees two advancing intervals and says Live, and the Live is cached for the process. Two real probes did say Live while the oracle saw no sustained movement, coinciding with a refresh. Refreshes are ~30 minutes apart, so this is rare, but it is exactly the failure the rule exists to prevent: the rung selected on a frozen rail, and every later refresh billed whole to whichever metered span straddles it. The rail on this host still holds at 2,705,611,298, the value that refresh left. The rule is now, for one CPU rail (name + ordinal): - it advanced in at least MIN_LIVE_ADVANCES = 5 distinct intervals; - those are at least 80% of all the intervals observed so far (advancing * 5 >= intervals * 4); - the window observed so far spans at least MIN_LIVE_WINDOW = 300 ms of wall time from the first usable sample. It is checked after each interval, and the rail is Live from the first interval at which all three hold. The probe keeps sampling until Live, or until the 1 s budget has elapsed and at least five intervals were observed, capped at five budgets. A live rail is now called live about 300 ms in (12 intervals on an idle host) rather than ~60 ms. The rule needs each interval's wall time, so watch() reads the clock after every sample and hands that reading to RailWatch::observe; the tests script it. Everything else stands: per-rail keys, consecutive samples, a fall or a first appearance is no advance, one advance per rail per interval, the group-only subscription, the thread-CPU tick, the pure status mapping, the OnceLock, the test seams, and the "ioreport liveness: status=" line. The oracle gets the same spirit. CpuRailMoved(true) now needs one rail to have changed at 5 or more reads making up at least 80% of its reads. Its all-channel reads are 0.13 s or more apart, so even a refresh spread over 150 ms shows as three changes at most, and a refresh coinciding with the probe can no longer vouch for the Live it caused. It extends its window, up to 5x, until it has five reads after the first, and still fails as TooFewReads when it cannot. Its loop is now oracle_watch_with, over a read function and a clock, so its verdict is tested on scripts. Tests, on a scripted clock at 31 ms an interval unless stated; (*) fail under the two-advance rule: - (*) the observed two-step refresh, with the steps in consecutive intervals, 62 ms apart, and on a host at 160 ms an interval: Frozen; - (*) a refresh in 3, 4 or 5 steps within 150 ms at the start of the window, and in 5 steps mid-window: Frozen; - (*) the M3 Ultra shape, both dies stepping twice within 64 ms: Frozen; - a rail advancing in every interval: Live at the 10th (310 ms), not at the 5th (155 ms), on either die; - missing 1 interval in 5 (exactly 80%): Live; missing 2 in 5: never; on a loaded host, 2 early misses: not at 5 of 7, 6 of 8 or 7 of 9, Live at 8 of 10; - a loaded host at 100 ms an interval: Live at the 5th (500 ms), not at the 3rd, where the window is already met; - keep-sampling: at 600 ms an interval the probe goes past the budget to five intervals (where a live rail is proven Live, at 3 s); at 1.5 s it stops at the 5 s cap with four; - through probe_with: a sustained advance is Live at 10 intervals, and (*) the two-step refresh is Frozen; - through host_status itself (the real opener, burn tick and clocks, with scripted FFI reads): (*) the two-step refresh is CpuRailFrozen; a sustained advance is Live with 5+ advances over a 300+ ms window by both the probe's clock and the tests' own, stopping as soon as it is live; - the two-die summing check now has the dies advance in turn (each in half the intervals, summed in all): two one-step dies, summed, are only two advances, which no longer trips the rule; - the oracle on scripts: the two-step refresh is CpuRailMoved(false) (it was true), a three-step refresh false, a change at every read true at the 5th, 4 in 5 true, 3 in 5 false; slow reads are extended to five, reads too slow for that are TooFewReads. Mutations, each a temporary edit restored with git checkout, the tree clean after each: - MIN_LIVE_ADVANCES = 2: 4 tests fail (the two-step refresh on a loaded host, loaded-host Live before the 5th interval, keep-sampling, the stopping rule); - the 80% check removed: 4 fail (the 80% test, the 5-step refresh, two dies in turn through probe_with and through host_status); - the 300 ms window removed: 6 fail (Live at the 10th, the 80% test, the 5-step refresh at the start, the sustained advance through probe_with and through host_status, the stopping rule); - the oracle's bar back to >= 2 changes: both oracle tests fail. The whole old oracle (2 changes, 2 reads) fails with "a two-step refresh vouched for a moving rail: OracleRun { verdict: CpuRailMoved(true), reads: 4, ..., cpu_changes: [2] ..."; - the whole old rule (2 advances, no share, no window): 15 fail, every (*) test among them. Runs. The crate's tests pass (51 lib, 1 integration). Three runs under a helper process sampling IOReport over all channels back to back (~250-280 ms a sample, load average 14-16), 51/51 each: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 6 reads over 3.857275292s of a 3s window with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [4]) ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 7 reads over 3.051596791s of a 3s window with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [6]) ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 9 reads over 3.133878417s of a 3s window with a helper thread burning CPU; changes per CPU rail [0], per GPU rail [5]) On the M3 Ultra (macOS 27.0.1), the same binary, 51/51: ioreport liveness: status=CpuRailFrozen oracle=CpuRailMoved(false) selected=false (oracle: 21 reads over 3.033503042s of a 3s window with a helper thread burning CPU; changes per CPU rail [0, 0], per GPU rail [19]) jcp-cli (it builds; it has no tests), jcp-conformance (with the conformance binary: all checks passed, C9 energy provenance honesty among them) and jcp-intercept pass. Clippy --no-deps and rustdoc over private items with -D warnings are clean on jcp-energy. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 844 +++++++++++++----- .../jcp-energy/tests/ioreport_probed_once.rs | 4 +- 2 files changed, 640 insertions(+), 208 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index aa629c407..07901b609 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -19,11 +19,13 @@ //! ([`ioreport_status`]): a subscription can open while every CPU rail holds //! its value, refreshed only in rare batches, and then the summed total //! advances through the GPU rail — GPU energy reported as the energy of CPU -//! work — or by a whole refresh at once. Live means the same CPU rail advanced -//! in at least two separate sample intervals while the probe burned CPU; a -//! single advance is a batch refresh, not a live rail. Otherwise the platform -//! is not exposing CPU energy, so by the rule above this rung is declined and -//! the time×power model estimates instead. +//! work — or by a whole refresh at once. Live means the same CPU rail kept +//! advancing while the probe burned CPU: in at least five separate sample +//! intervals, in at least 80% of the intervals observed, over a window of at +//! least 300 ms. A batch refresh, even one published in a few steps, is not a +//! live rail. Otherwise the platform is not exposing CPU energy, so by the +//! rule above this rung is declined and the time×power model estimates +//! instead. //! 4. **Time × power model** — wall-clock duration times a calibrated active //! power, `Provenance::ModelBased`. Always available. //! 5. **Constant estimator** — a fixed per-operation figure, @@ -344,8 +346,22 @@ mod ioreport { const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); /// In how many separate sample intervals the same CPU rail must advance - /// before it counts as live. One is not enough: see [`RailWatch`]. - const LIVE_RAIL_ADVANCES: u32 = 2; + /// before it counts as live. Two is not enough: see [`RailWatch`]. Also the + /// fewest intervals the probe observes before it may call a rail frozen. + const MIN_LIVE_ADVANCES: u32 = 5; + + /// The shortest observation window, by wall clock from the first usable + /// sample, over which a rail may be called live. The batch refreshes seen + /// were published within 64 ms, so the steps of one cannot fill this + /// window, nor could those of one spread over twice as long. + const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300); + + /// Has a rail that advanced in `advancing` of `intervals` intervals + /// advanced in at least 80% of them? Integer arithmetic, so the boundary is + /// exact: 4 of 5 is enough, 3 of 4 is not. + fn sustained(advancing: u32, intervals: u32) -> bool { + u64::from(advancing) * 5 >= u64::from(intervals) * 4 + } /// However slow IOReport is, or however many of its samples fail, the probe /// stops after this many budgets. @@ -375,11 +391,13 @@ mod ioreport { /// What the liveness probe saw of the CPU energy rail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CpuRail { - /// The same CPU energy rail advanced in at least [`LIVE_RAIL_ADVANCES`] - /// separate sample intervals while the probing thread burned CPU. + /// The same CPU energy rail kept advancing while the probing thread + /// burned CPU: in at least [`MIN_LIVE_ADVANCES`] separate sample + /// intervals, in at least 80% of the intervals observed, over at least + /// [`MIN_LIVE_WINDOW`]. Live, - /// A CPU energy rail exists, but none advanced in that many intervals: - /// it held, or moved in a single step (a batch refresh). + /// A CPU energy rail exists, but none kept advancing: it held, or moved + /// in a few steps (a batch refresh). Frozen, /// No `CPU Energy` channel exists in the Energy Model group. Absent, @@ -434,20 +452,40 @@ mod ioreport { /// it, including the pairing of each sample with the previous one, runs on /// scripted samples in tests. /// - /// A rail is live only when the **same** CPU rail advances in at least - /// [`LIVE_RAIL_ADVANCES`] separate sample intervals. One advance is not - /// enough, because on macOS 27.0.1 an unprivileged reader's CPU rails are - /// not permanently frozen: they are refreshed in rare batches. On an M3 - /// Ultra both dies' rails held for at least 22 minutes, stepped +1,344,869 - /// and +1,391,637 mJ in one 100 ms sample, then held again; an M5 Max's - /// `CPU Energy` did not change in 13,614 samples over 25 minutes, yet had - /// moved from 480,083,160 to 541,893,638 mJ earlier that hour. A probe - /// whose window straddles a refresh sees one large jump. Called live, it - /// would select the rung for the life of the process, and every later - /// ~1.3 kJ refresh would be attributed whole to whichever metered span - /// straddled it. A live rail advances in nearly every interval (the M5 Max - /// GPU rail changes every ~28 ms), so it passes in its first two. Counting - /// per rail keeps one jump on each die from adding up to a false Live. + /// A rail is live only when the **same** CPU rail keeps advancing: it + /// advanced in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, + /// those are at least 80% of all the intervals observed so far + /// ([`sustained`]), and the window observed so far spans at least + /// [`MIN_LIVE_WINDOW`] of wall time from the first usable sample. The three + /// are checked after each interval, and the rail is live from the first + /// interval at which all three hold. + /// + /// Because on macOS 27.0.1 an unprivileged reader's CPU rails are not + /// permanently frozen: they are refreshed in rare batches, ~30 minutes + /// apart. On an M3 Ultra both dies' rails held for at least 22 minutes, + /// stepped +1,344,869 and +1,391,637 mJ in one 100 ms sample, then held + /// again; an M5 Max's `CPU Energy` did not change in 13,614 samples over 25 + /// minutes, yet had moved from 480,083,160 to 541,893,638 mJ earlier that + /// hour. And a refresh is not always one step. On an M5 Max, a watcher + /// sampling the group every ~16 ms saw `CPU Energy` go 2,643,843,378 -> + /// 2,705,607,863 (+61,764,485), then 2,705,607,863 -> 2,705,611,298 + /// (+3,435) 64 ms later, and `MCPU0`, `MCPU1` and `PCPU` each step twice. + /// The probe's intervals are ~31 ms (a 25 ms tick and a ~6 ms sample), so a + /// window holding that refresh sees two advancing intervals. Two advances + /// of one rail would call that Live, and a Live is cached for the process: + /// the rung is selected, and every later ~1.3 kJ refresh is attributed + /// whole to whichever metered span straddles it. + /// + /// Each condition closes a way for a refresh to pass. Five advances: a + /// refresh is published in one step, two, or a few. 80% of the intervals: the + /// steps of a refresh sit together, and are a small share of any window + /// that also holds the flat intervals around them. 300 ms: a refresh + /// published in five quick steps at the very start of a window is 100% of a + /// 155 ms window, but not of a 300 ms one. A live rail advances in nearly + /// every interval (the M5 Max GPU rail changes every ~28 ms), so it meets + /// all three about 300 ms in, or after five intervals on a host loaded + /// enough that five take longer. Counting per rail keeps a step on each die + /// from adding up to a false Live. /// /// Each interval is a sample and the one **before** it. Compared with the /// first sample instead, a rail that stepped once would show a rise in @@ -461,24 +499,36 @@ mod ioreport { prev: Option>, /// Every CPU rail seen, with the number of intervals in which it advanced. rails: Vec<(RailKey, u32)>, + /// When the first usable sample was taken, by the probe's clock: where + /// the window starts. + first_at: Option, + /// How much wall time the window spans so far: from the first usable + /// sample to the latest. + window: Duration, + /// Set at the first interval after which one rail met all three + /// conditions, and never unset. + live: bool, } impl RailWatch { - /// Fold in the next usable sample. The first only sets the baseline. + /// Fold in the next usable sample, taken at `at` by the probe's wall + /// clock. The first only sets the baseline, and starts the window. /// Each later one closes an interval with the sample before it, and a /// rail advanced over that interval when its raw value rose. A rail is /// counted at most once per interval. A rail whose value FELL did not /// advance: a cumulative counter that goes down was reset or wrapped, /// and that is not energy spent. A rail missing from the previous /// sample has nothing to be compared with, so this sample only sets its - /// baseline. - fn observe(&mut self, sample: &[(String, i64)]) { + /// baseline. After each interval, the rule is checked. + fn observe(&mut self, sample: &[(String, i64)], at: Duration) { let cur = cpu_rails(sample); for (key, _) in &cur { if !self.rails.iter().any(|(k, _)| k == key) { self.rails.push((key.clone(), 0)); } } + let first_at = *self.first_at.get_or_insert(at); + self.window = at.saturating_sub(first_at); if let Some(prev) = &self.prev { self.intervals += 1; for (key, now) in &cur { @@ -487,24 +537,31 @@ mod ioreport { *count += u32::from(rose); } } + let intervals = self.intervals; + self.live |= self.window >= MIN_LIVE_WINDOW + && self + .rails + .iter() + .any(|&(_, advancing)| advancing >= MIN_LIVE_ADVANCES && sustained(advancing, intervals)); } self.prev = Some(cur); } - /// Has one CPU rail advanced in enough separate intervals to be live? + /// Has one CPU rail kept advancing: enough advances, a high enough + /// share of the intervals, over a long enough window? fn is_live(&self) -> bool { - self.rails.iter().any(|(_, count)| *count >= LIVE_RAIL_ADVANCES) + self.live } /// Keep sampling? Until the rail proves live, or until `budget` has - /// elapsed **and** enough intervals were observed for a live rail to - /// have shown [`LIVE_RAIL_ADVANCES`] advances (one slow IOReport sample - /// must not make a live rail look frozen) — and never past - /// [`PROBE_BUDGET_CAP`] budgets. + /// elapsed **and** at least [`MIN_LIVE_ADVANCES`] intervals were + /// observed, so a live rail could have shown that many advances (slow + /// IOReport samples must not make a live rail look frozen) — and never + /// past [`PROBE_BUDGET_CAP`] budgets. fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { !self.is_live() && elapsed < budget * PROBE_BUDGET_CAP - && (elapsed < budget || self.intervals < LIVE_RAIL_ADVANCES) + && (elapsed < budget || self.intervals < MIN_LIVE_ADVANCES) } /// The verdict, or `None` if not a single interval could be observed. @@ -523,11 +580,11 @@ mod ioreport { /// The rule's loop, generic over where samples come from, what happens /// between them and the clock, so tests drive it with scripted samples and - /// a scripted clock exactly as [`probe_with`] drives it. Take a sample; - /// stop when [`RailWatch::wants_more`] says so; otherwise `tick` and sample - /// again. A sample that fails is skipped, so the next usable one is - /// compared with the last usable one. `elapsed` is the time since the - /// probe started. + /// a scripted clock exactly as [`probe_with`] drives it. Take a sample and + /// read the clock; hand the rule the sample with that reading; stop when + /// [`RailWatch::wants_more`] says so; otherwise `tick` and sample again. A + /// sample that fails is skipped, so the next usable one is compared with + /// the last usable one. `elapsed` is the wall time since the probe started. fn watch( mut sample: impl FnMut() -> Option, mut tick: impl FnMut(), @@ -536,10 +593,12 @@ mod ioreport { ) -> RailWatch { let mut rail_watch = RailWatch::default(); loop { - if let Some(s) = sample() { - rail_watch.observe(&s); + let got = sample(); + let now = elapsed(); + if let Some(s) = got { + rail_watch.observe(&s, now); } - if !rail_watch.wants_more(elapsed(), budget) { + if !rail_watch.wants_more(now, budget) { return rail_watch; } tick(); @@ -1278,8 +1337,18 @@ mod ioreport { /// A probe budget, as in production. const BUDGET: Duration = Duration::from_millis(1000); - /// One scripted tick: a 25 ms spin plus a ~5 ms Energy Model sample. - const STEP: Duration = Duration::from_millis(30); + /// One scripted tick: a 25 ms spin plus a ~6 ms Energy Model sample, the + /// interval measured on an idle M5 Max. + const STEP: Duration = Duration::from_millis(31); + + /// One interval on a loaded host. + const LOADED_STEP: Duration = Duration::from_millis(100); + + /// The tests' own copies of the rule's thresholds: literals, not the + /// implementation's constants, so a change to one cannot silently move + /// the other. + const TEST_MIN_ADVANCES: u32 = 5; + const TEST_MIN_WINDOW: Duration = Duration::from_millis(300); /// An M5 Max-shaped sample: `CPU Energy` at `cpu`, beside a `GPU /// Energy` that rises at every sample, as the M5 Max's does while @@ -1293,8 +1362,9 @@ mod ioreport { } /// One probe run through [`watch`] on a script: sample `i` is - /// `script(i)`, and each tick moves the clock on by `step`. Returns the - /// watch, how many samples were taken, and the clock when it stopped. + /// `script(i)`, and each tick moves the clock on by `step`, so sample + /// `i` is taken at `step * i`. Returns the watch, how many samples were + /// taken, and the clock when it stopped. fn scripted( mut script: impl FnMut(usize) -> Option, step: Duration, @@ -1324,6 +1394,46 @@ mod ioreport { (name.to_string(), n) } + /// The first interval, counting from 1, after which the rule calls + /// the rail live when fed `script(0..=max)` by hand, sample `i` at + /// `step * i`, with no stopping rule; `None` if it never does. + fn first_live_interval(script: impl Fn(usize) -> Sample, step: Duration, max: usize) -> Option { + let mut w = RailWatch::default(); + w.observe(&script(0), Duration::ZERO); + (1..=max).find(|&i| { + w.observe(&script(i), step * i as u32); + w.is_live() + }) + } + + /// `CPU Energy` through the two-step batch refresh seen on an M5 Max + /// (macOS 27.0.1) by a group-only watcher sampling every ~16 ms: + /// 2,643,843,378 -> 2,705,607,863 (+61,764,485), then -> 2,705,611,298 + /// (+3,435) 64 ms later, flat around both. Here the first step lands + /// by sample `first` and the second by sample `second`. + fn two_step(i: usize, first: usize, second: usize) -> i64 { + if i < first { + 2_643_843_378 + } else if i < second { + 2_705_607_863 + } else { + 2_705_611_298 + } + } + + /// An M5 Max-shaped sample through [`two_step`], with `MCPU0`, + /// `MCPU1` and `PCPU`, the summands of `CPU Energy`, stepping twice + /// with it as they were seen to (their values here are made up). They + /// are not CPU rails, and the rule never reads them. + fn m5_two_step(i: usize, first: usize, second: usize) -> Sample { + let steps = i64::from(i >= first) + i64::from(i >= second); + let mut s = m5(i, two_step(i, first, second)); + for (name, base) in [("MCPU0", 118_503_220), ("MCPU1", 121_847_018), ("PCPU", 2_403_493_140)] { + s.push((name.to_string(), base + 1_000 * steps)); + } + s + } + /// (a) The refresh seen on an M5 Max: `CPU Energy` steps once, from /// 480,083,160 to 541,893,638 mJ, and holds, while `GPU Energy` rises /// at every sample. Frozen, with the step counted once, after the whole @@ -1335,8 +1445,92 @@ mod ioreport { scripted(|i| Some(m5(i, if i < 2 { 480_083_160 } else { 541_893_638 })), STEP, BUDGET); assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a batch refresh read as live: {:?}", advances(&w)); assert_eq!(advances(&w), [rail("CPU Energy#0", 1)]); - // The whole budget: at 30 ms a tick, the clock reaches 1 s at the 34th. - assert_eq!((w.intervals, taken, stopped), (34, 35, STEP * 34)); + // The whole budget: at 31 ms a tick, the clock reaches 1 s at the 33rd. + assert_eq!((w.intervals, taken, stopped), (33, 34, STEP * 33)); + } + + /// (*) The two-step refresh seen on an M5 Max ([`two_step`]), with + /// the steps in consecutive intervals and 62 ms apart: two advancing + /// intervals of one rail, which the two-advance rule called Live. + /// Frozen, after the whole budget. And on a loaded host, where two + /// 160 ms intervals span 320 ms and both advanced (100%), two advances + /// are still not five: Frozen. + #[test] + fn the_observed_two_step_refresh_is_frozen() { + for (first, second) in [(3, 4), (3, 5)] { + assert_eq!(first_live_interval(|i| m5_two_step(i, first, second), STEP, 200), None); + let (w, taken, stopped) = scripted(|i| Some(m5_two_step(i, first, second)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "a two-step refresh (steps by samples {first} and {second}) read as live: {:?}", + advances(&w) + ); + assert_eq!(advances(&w), [rail("CPU Energy#0", 2)]); + assert_eq!((w.intervals, taken, stopped), (33, 34, STEP * 33)); + } + let slow = Duration::from_millis(160); + assert_eq!(first_live_interval(|i| m5_two_step(i, 1, 2), slow, 200), None); + let (w, _, _) = scripted(|i| Some(m5_two_step(i, 1, 2)), slow, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a two-step refresh on a loaded host read as live: {:?}", advances(&w)); + assert_eq!((w.intervals, advances(&w)), (7, vec![rail("CPU Energy#0", 2)])); + } + + /// (*) A refresh published in three, four or five steps within + /// 150 ms, each landing in its own interval, at the very start of the + /// window. Three or four are fewer than five advances. Five are five + /// advances in five intervals (100%), but then the window spans 155 ms; + /// by the tenth interval, when it first spans 300 ms, five of ten is + /// 50%. The same five steps later in the window (intervals 11 to 15) + /// are a third of the intervals. Frozen, each after the whole budget. + #[test] + fn a_refresh_published_in_three_to_five_steps_is_frozen() { + for steps in 3..=5 { + let refresh = move |i: usize| m5(i, 2_643_843_378 + 1_000_000 * i.min(steps) as i64); + assert_eq!(first_live_interval(refresh, STEP, 200), None, "{steps} steps at the start"); + let (w, taken, _) = scripted(|i| Some(refresh(i)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "a refresh in {steps} steps at the start of the window read as live: {:?}", + advances(&w) + ); + assert_eq!((advances(&w), w.intervals, taken), (vec![rail("CPU Energy#0", steps as u32)], 33, 34)); + } + let later = |i: usize| m5(i, 2_643_843_378 + 1_000_000 * (i.clamp(10, 15) - 10) as i64); + assert_eq!(first_live_interval(later, STEP, 200), None, "five steps later in the window"); + let (w, _, _) = scripted(|i| Some(later(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a refresh in 5 steps mid-window read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("CPU Energy#0", 5)]); + } + + /// (*) The same two-step publication on both of an M3 Ultra's dies: + /// each die's `CPU Energy` steps twice within 64 ms (DIE_0 by samples + /// 3 and 5, DIE_1 by samples 3 and 4), the first steps the sizes seen + /// there, beside a duplicated unprefixed name and a GPU rail, both + /// rising. Frozen: two advances per die, where the two-advance rule + /// called each die live. + #[test] + fn an_m3_ultra_refresh_stepping_each_die_twice_is_frozen() { + // The second step's size is not from a sample: 2,113 mJ stands in. + let die = |i: usize, base: i64, first_step: i64, second_by: usize| { + base + i64::from(i >= 3) * first_step + i64::from(i >= second_by) * 2_113 + }; + let (w, _, _) = scripted( + |i| { + Some(vec![ + ("ECPUDTL00".to_string(), 7_000 + i as i64), + ("DIE_0_CPU Energy".to_string(), die(i, 21_170_718, 1_344_869, 5)), + ("ECPUDTL00".to_string(), 9_000 + i as i64), + ("DIE_1_CPU Energy".to_string(), die(i, 23_300_102, 1_391_637, 4)), + ("GPU Energy".to_string(), 5_000_000 * i as i64), + ]) + }, + STEP, + BUDGET, + ); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "each die stepping twice read as live: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 2), rail("DIE_1_CPU Energy#0", 2)]); } /// (b) The refresh seen on an M3 Ultra: both dies' `CPU Energy` step in @@ -1384,40 +1578,78 @@ mod ioreport { } /// (d) A live rail: `CPU Energy` rising by the smallest possible step, - /// one raw unit, at every sample. Live after exactly two intervals, and - /// the probe stops there: three samples, two ticks. + /// one raw unit, at every sample, 31 ms apart. It has five advances at + /// the fifth interval, but the window then spans 155 ms. Live at the + /// tenth, the first interval at which the window spans 300 ms (310 ms), + /// and the probe stops there: eleven samples, ten ticks. The same on + /// the second die of two, the first holding. #[test] - fn a_rail_advancing_at_every_sample_is_live_after_two_intervals() { - let (w, taken, stopped) = scripted(|i| Some(m5(i, 541_893_638 + i as i64)), STEP, BUDGET); + fn a_rail_advancing_at_every_interval_is_live_at_five_advances_and_300_ms() { + let rising = |i: usize| m5(i, 541_893_638 + i as i64); + assert_eq!(first_live_interval(rising, STEP, 200), Some(10), "31 ms intervals first span 300 ms at the 10th"); + let (w, taken, stopped) = scripted(|i| Some(rising(i)), STEP, BUDGET); assert_eq!(w.verdict(), Some(CpuRail::Live), "a rail rising at every sample: {:?}", advances(&w)); - assert_eq!(advances(&w), [rail("CPU Energy#0", 2)]); - assert_eq!((w.intervals, taken, stopped), (2, 3, STEP * 2), "a live rail must stop the probe at once"); + assert_eq!(advances(&w), [rail("CPU Energy#0", 10)]); + assert_eq!((w.intervals, taken, stopped), (10, 11, STEP * 10), "a live rail must stop the probe at once"); + assert_eq!(w.window, Duration::from_millis(310)); + assert!(w.window >= TEST_MIN_WINDOW && w.window - STEP < TEST_MIN_WINDOW, "live at {:?}", w.window); + let second_die = |i: usize| { + vec![ + ("DIE_0_CPU Energy".to_string(), 21_170_718), + ("DIE_1_CPU Energy".to_string(), 23_300_102 + i as i64), + ] + }; + assert_eq!(first_live_interval(second_die, STEP, 200), Some(10)); + let (w, _, _) = scripted(|i| Some(second_die(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 10)]); } - /// The same rail advancing in two separate intervals is live, whether or - /// not the intervals are consecutive, and on either die. + /// A live rail on a loaded host, 100 ms an interval: the window spans + /// 300 ms at the third interval, but three advances are not five. Live + /// at the fifth, 500 ms in, and not before. #[test] - fn two_advances_of_one_rail_in_separate_intervals_are_live() { - let steps = |i: usize| match i { - 0 => 0, - 1..=3 => 1, - _ => 2, - }; - let (w, taken, _) = scripted(|i| Some(m5(i, 541_893_638 + steps(i))), STEP, BUDGET); + fn a_live_rail_on_a_loaded_host_is_live_after_five_intervals() { + let rising = |i: usize| m5(i, 541_893_638 + i as i64); + assert_eq!(first_live_interval(rising, LOADED_STEP, 200), Some(5), "live before its fifth advance"); + let (w, taken, stopped) = scripted(|i| Some(rising(i)), LOADED_STEP, BUDGET); assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); - assert_eq!((w.intervals, taken), (4, 5), "advances in intervals 1 and 4"); - let (w, _, _) = scripted( - |i| { - Some(vec![ - ("DIE_0_CPU Energy".to_string(), 21_170_718), - ("DIE_1_CPU Energy".to_string(), 23_300_102 + i as i64), - ]) - }, - STEP, - BUDGET, + assert_eq!( + (w.intervals, taken, stopped, w.window), + (5, 6, Duration::from_millis(500), Duration::from_millis(500)) ); + } + + /// A live rail that misses one interval in five advances in exactly + /// 80% of them, and that is enough: Live at the tenth interval (8 of + /// 10, 310 ms). One that misses two in five advances in 60% or less, + /// and is never live: Frozen after the whole budget. And on a loaded + /// host, where the window and the five advances come early, a rail + /// that missed two of its first five intervals is not yet live at five + /// advances in seven (71%), nor at 6 of 8 or 7 of 9, and is live once + /// its share reaches 80%, at 8 of 10. + #[test] + fn a_rail_must_advance_in_80_percent_of_the_intervals() { + // Sample `i` holds how many of intervals 1..=i advanced, so + // interval `k` advances unless `missed(k)`. + let missing = |missed: fn(usize) -> bool| { + move |i: usize| m5(i, 541_893_638 + (1..=i).filter(|&k| !missed(k)).count() as i64) + }; + let one_in_five = missing(|k| k % 5 == 0); + assert_eq!(first_live_interval(one_in_five, STEP, 200), Some(10)); + let (w, _, _) = scripted(|i| Some(one_in_five(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "a rail advancing in 80% of the intervals: {:?}", advances(&w)); + assert_eq!((w.intervals, advances(&w)), (10, vec![rail("CPU Energy#0", 8)])); + let two_in_five = missing(|k| k % 5 == 0 || k % 5 == 1); + assert_eq!(first_live_interval(two_in_five, STEP, 200), None); + let (w, _, _) = scripted(|i| Some(two_in_five(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail advancing in 60% of the intervals: {:?}", advances(&w)); + assert_eq!((w.intervals, advances(&w)), (33, vec![rail("CPU Energy#0", 20)])); + let two_early = missing(|k| k == 2 || k == 4); + assert_eq!(first_live_interval(two_early, LOADED_STEP, 200), Some(10), "live under 80%"); + let (w, taken, _) = scripted(|i| Some(two_early(i)), LOADED_STEP, BUDGET); assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); - assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 2)]); + assert_eq!((w.intervals, taken, advances(&w)), (10, 11, vec![rail("CPU Energy#0", 8)])); } /// (e) Every sample fails: the probe keeps sampling a tick apart up to @@ -1436,10 +1668,13 @@ mod ioreport { /// compared with the last usable one. #[test] fn a_failed_sample_is_skipped_and_the_pairing_goes_on() { - // Usable, failed, +2, failed, +2: two intervals, two advances. + // Usable, failed, +2, failed, +2, ...: each interval spans two + // ticks, 62 ms, and advances. The window is wall time between + // usable samples, so it spans 310 ms at the fifth interval: Live + // there, after eleven samples. let (w, taken, _) = scripted(|i| (i % 2 == 0).then(|| m5(i, 541_893_638 + i as i64)), STEP, BUDGET); assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); - assert_eq!((w.intervals, taken), (2, 5)); + assert_eq!((w.intervals, taken, w.window), (5, 11, Duration::from_millis(310))); // A refresh between two failures is still one step. let (w, _, _) = scripted( |i| (i % 3 != 1).then(|| m5(i, if i < 4 { 480_083_160 } else { 541_893_638 })), @@ -1450,24 +1685,30 @@ mod ioreport { assert_eq!(advances(&w), [rail("CPU Energy#0", 1)]); } - /// (f) Past the budget, the probe stops once it has two intervals, with - /// the verdict Frozen, and not before: one slow sample must not make a - /// live rail look frozen. + /// (f) Past the budget, the probe keeps sampling until it has five + /// intervals, and stops at the five-budget cap even without them: + /// slow samples must not make a live rail look frozen. #[test] - fn past_the_budget_the_probe_stops_only_with_two_intervals() { + fn past_the_budget_the_probe_stops_only_with_five_intervals_or_at_the_cap() { let held = |i| Some(m5(i, 541_893_638)); - // 600 ms a tick: the budget runs out at the second interval. + // 600 ms a tick: the budget runs out at the second interval; the + // probe goes on to the fifth. let (w, taken, stopped) = scripted(held, Duration::from_millis(600), BUDGET); assert_eq!( (w.verdict(), w.intervals, taken, stopped), - (Some(CpuRail::Frozen), 2, 3, Duration::from_millis(1200)) + (Some(CpuRail::Frozen), 5, 6, Duration::from_millis(3000)), + "past the budget with fewer than five intervals, the probe must keep sampling" ); - // 1.5 s a tick: past the budget with ONE interval, so it samples again. + // And that is what lets a live rail on such a host prove itself. + let (w, _, stopped) = scripted(|i| Some(m5(i, 541_893_638 + i as i64)), Duration::from_millis(600), BUDGET); + assert_eq!((w.verdict(), w.intervals, stopped), (Some(CpuRail::Live), 5, Duration::from_millis(3000))); + // 1.5 s a tick: four intervals reach 6 s, past the 5 s cap: stop, + // Frozen, with four. let (w, taken, stopped) = scripted(held, Duration::from_millis(1500), BUDGET); assert_eq!( (w.verdict(), w.intervals, taken, stopped), - (Some(CpuRail::Frozen), 2, 3, Duration::from_millis(3000)), - "past the budget with one interval, the probe must take one more sample" + (Some(CpuRail::Frozen), 4, 5, Duration::from_millis(6000)), + "the probe must stop at the cap" ); } @@ -1495,16 +1736,16 @@ mod ioreport { fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { let mut w = RailWatch::default(); assert_eq!(w.verdict(), None); - w.observe(&[("GPU Energy".to_string(), 1)]); + w.observe(&[("GPU Energy".to_string(), 1)], Duration::ZERO); assert_eq!(w.verdict(), None, "a baseline alone is not an interval"); - w.observe(&[("GPU Energy".to_string(), 2)]); - w.observe(&[("GPU Energy".to_string(), 3)]); + w.observe(&[("GPU Energy".to_string(), 2)], STEP); + w.observe(&[("GPU Energy".to_string(), 3)], STEP * 2); assert_eq!(w.verdict(), Some(CpuRail::Absent)); } /// The stopping rule on its own: within the budget keep watching until /// the rail proves live; past it only until a live rail could have - /// advanced twice; never past five budgets. + /// advanced five times; never past five budgets. #[test] fn the_probe_samples_until_live_or_long_enough_to_tell() { let budget = BUDGET; @@ -1512,22 +1753,25 @@ mod ioreport { let mut w = RailWatch::default(); assert!(w.wants_more(Duration::ZERO, budget), "nothing observed yet"); assert!(w.wants_more(budget * 2, budget), "no interval yet, past the budget"); - w.observe(&held); - w.observe(&held); - assert!(w.wants_more(budget * 2, budget), "one interval cannot show two advances"); + for k in 0..=4 { + w.observe(&held, STEP * k); + } + assert_eq!(w.intervals, 4); + assert!(w.wants_more(budget * 2, budget), "four intervals cannot show five advances"); assert!(w.wants_more(budget * 5 - Duration::from_millis(1), budget)); assert!(!w.wants_more(budget * 5, budget), "hard cap: five budgets"); assert!(!RailWatch::default().wants_more(budget * 5, budget), "hard cap with no interval"); - w.observe(&held); + w.observe(&held, STEP * 5); assert!(w.wants_more(budget / 2, budget), "within budget: keep watching"); - assert!(!w.wants_more(budget, budget), "budget spent, two intervals: stop"); + assert!(!w.wants_more(budget, budget), "budget spent, five intervals: stop"); assert_eq!(w.verdict(), Some(CpuRail::Frozen)); let mut live = RailWatch::default(); - live.observe(&m5(0, 1)); - live.observe(&m5(1, 2)); - assert!(live.wants_more(Duration::ZERO, budget), "one advance: not yet live, keep watching"); - live.observe(&m5(2, 3)); - assert!(!live.wants_more(Duration::ZERO, budget), "live: stop at once"); + for i in 0..10 { + live.observe(&m5(i, i as i64), STEP * i as u32); + assert!(live.wants_more(STEP * i as u32, budget), "sample {i}: not yet live, keep watching"); + } + live.observe(&m5(10, 10), STEP * 10); + assert!(!live.wants_more(STEP * 10, budget), "live: stop at once"); } /// Each verdict reports its status, and no verdict (not one interval @@ -1619,14 +1863,13 @@ mod ioreport { if i < 2 { 480_083_160 } else { 541_893_638 } } - /// An M3 Ultra-shaped read in which each die's `CPU Energy` steps once, - /// in different intervals: DIE_0 in the first (sample 0 to 1), DIE_1 - /// in the second (sample 1 to 2). Every probe takes at least three - /// samples, so both steps are seen however few samples a loaded host - /// allows. Beside them, a duplicated unprefixed name rising on both dies - /// and a rising GPU rail. Summed, the two dies would be one rail - /// advancing twice. - fn two_dies_stepping_apart(i: usize) -> Vec { + /// An M3 Ultra-shaped read in which the two dies' `CPU Energy` advance + /// in turn: DIE_0 in odd intervals (sample 0 to 1, 2 to 3, ...), DIE_1 + /// in even ones. Each die advances in half the intervals, and is not + /// live. Summed, the two dies would be one rail advancing in every + /// interval, which is live. Beside them, a duplicated unprefixed name + /// rising on both dies and a rising GPU rail. + fn two_dies_advancing_in_turn(i: usize) -> Vec { vec![ item("ECPUDTL00", 7_000 + i as i64), item("DIE_0_CPU Energy", die_0(i)), @@ -1637,11 +1880,17 @@ mod ioreport { } fn die_0(i: usize) -> i64 { - if i < 1 { 21_170_718 } else { 22_515_587 } + 21_170_718 + 1_000 * i.div_ceil(2) as i64 } fn die_1(i: usize) -> i64 { - if i < 2 { 23_300_102 } else { 24_691_739 } + 23_300_102 + 1_000 * (i / 2) as i64 + } + + /// Each die's advances when [`two_dies_advancing_in_turn`] ran for + /// `intervals` intervals: DIE_0 in the odd ones, DIE_1 in the even. + fn in_turn_advances(intervals: u32) -> [(String, u32); 2] { + [rail("DIE_0_CPU Energy#0", intervals.div_ceil(2)), rail("DIE_1_CPU Energy#0", intervals / 2)] } /// A scripted subscription: read `i` is `script(i)`, made into a @@ -1759,8 +2008,8 @@ mod ioreport { assert_eq!(run.verdict(), Some(CpuRail::Frozen), "a batch refresh read as live: {:?}", advances(w)); assert_eq!(status_of(run.verdict()), crate::IoReportStatus::CpuRailFrozen); assert_eq!(advances(w), [rail("CPU Energy#0", 1)]); - // The whole budget: at 30 ms a tick, the clock reaches 1 s at the 34th. - assert_eq!((calls.get(), run.samples.len(), ticks.get(), run.ticks.len()), (35, 35, 34, 34)); + // The whole budget: at 31 ms a tick, the clock reaches 1 s at the 33rd. + assert_eq!((calls.get(), run.samples.len(), ticks.get(), run.ticks.len()), (34, 34, 33, 33)); for (i, s) in run.samples.iter().enumerate() { let t = s.taken.as_ref().expect("every scripted sample is usable"); assert_eq!((t.channels, t.outside_group), (2, 0), "sample {i}"); @@ -1775,30 +2024,46 @@ mod ioreport { assert!(run.ticks.iter().all(|k| k.cpu == Some(PROBE_TICK) && k.wall == STEP), "{:?}", run.ticks); } - /// A rail advancing at every sample, through [`probe_with`]: Live after - /// two intervals, with three samples and two ticks. + /// A rail advancing at every sample, through [`probe_with`] and its + /// wall clock: Live at the tenth interval, when the window first spans + /// 300 ms, with eleven samples and ten ticks. #[test] - fn probe_with_two_advances_is_live() { + fn probe_with_a_sustained_advance_is_live() { let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); let sub = Scripted { script: |i| Some(m5_items(i, 541_893_638 + i as i64)), calls: Rc::new(Cell::new(0)) }; let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); - assert_eq!(run.verdict(), Some(CpuRail::Live), "{:?}", run.watch.as_ref().map(advances)); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Live), "{:?}", advances(w)); assert_eq!(status_of(run.verdict()), crate::IoReportStatus::Live); - assert_eq!((run.samples.len(), ticks.get(), run.ticks.len()), (3, 2, 2)); + assert_eq!((run.samples.len(), ticks.get(), run.ticks.len()), (11, 10, 10)); + assert_eq!((advances(w), w.window), (vec![rail("CPU Energy#0", 10)], Duration::from_millis(310))); } - /// Two dies each stepping once, in different intervals, through - /// [`probe_with`]: Frozen, one advance per die, and the record holds - /// each die's own value. A transform between the sampler and the rule - /// that summed the dies would see one rail advance twice, and say Live. + /// The observed two-step refresh through [`probe_with`]: Frozen, two + /// advances, after the whole budget. #[test] - fn probe_with_one_step_per_die_in_different_intervals_is_frozen() { + fn probe_with_the_observed_two_step_refresh_is_frozen() { let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); - let sub = Scripted { script: |i| Some(two_dies_stepping_apart(i)), calls: Rc::new(Cell::new(0)) }; + let sub = Scripted { script: |i| Some(m5_items(i, two_step(i, 3, 4))), calls: Rc::new(Cell::new(0)) }; let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); let w = run.watch.as_ref().expect("the scripted subscription opened"); - assert_eq!(run.verdict(), Some(CpuRail::Frozen), "one step per die read as live: {:?}", advances(w)); - assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "a two-step refresh read as live: {:?}", advances(w)); + assert_eq!((advances(w), run.samples.len()), (vec![rail("CPU Energy#0", 2)], 34)); + } + + /// Two dies advancing in turn, through [`probe_with`]: Frozen, each + /// die advancing in half the intervals, and the record holds each die's + /// own value. A transform between the sampler and the rule that summed + /// the dies would see one rail advance in every interval, and say Live. + #[test] + fn probe_with_two_dies_advancing_in_turn_is_frozen() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { script: |i| Some(two_dies_advancing_in_turn(i)), calls: Rc::new(Cell::new(0)) }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "two dies advancing in turn read as live: {:?}", advances(w)); + assert_eq!(w.intervals, 33); + assert_eq!(advances(w), in_turn_advances(w.intervals)); for (i, s) in run.samples.iter().enumerate() { let t = s.taken.as_ref().expect("every scripted sample is usable"); assert_eq!( @@ -2079,55 +2344,93 @@ mod ioreport { assert!(cpu < TICK_CPU_FLOOR, "host_status burned {cpu:?} of CPU with no subscription to watch"); } - /// `host_status` itself, with one batch refresh in place of its FFI - /// reads: Frozen, having burned a tick of CPU between every two reads, - /// by the tests' own clocks. The same refresh as (a), through every - /// production link but the FFI read. A rail that is not live keeps the - /// probe going for its whole budget, so `host_status` took at least - /// [`crate::CPU_RAIL_LIVENESS_BUDGET`]: a lower bound, which load can - /// only lengthen. + /// `host_status` itself, with the two-step refresh seen on an M5 Max + /// ([`two_step`], steps in consecutive intervals) in place of its FFI + /// reads: Frozen, two advances, having burned a tick of CPU between + /// every two reads, by the tests' own clocks. The two-advance rule + /// said Live here, through every production link but the FFI read. A + /// rail that is not live keeps the probe going for its whole budget, so + /// `host_status` took at least [`crate::CPU_RAIL_LIVENESS_BUDGET`]: a + /// lower bound, which load can only lengthen. #[test] - fn host_status_on_a_scripted_batch_refresh_is_frozen() { + fn host_status_on_the_observed_two_step_refresh_is_frozen() { let _hw = crate::ioreport_hw_lock(); - let (status, run, reads, took) = host_status_on_script(|i| Some(m5_items(i, m5_refresh(i)))); + let refresh = |i| two_step(i, 3, 4); + let (status, run, reads, took) = host_status_on_script(move |i| Some(m5_items(i, refresh(i)))); let Some(w) = &run.watch else { return no_energy_model_group_here(status, &reads); }; - let ticks = assert_ticks_burned(&reads, "host_status on a scripted batch refresh"); + let ticks = assert_ticks_burned(&reads, "host_status on the two-step refresh"); eprintln!( - "host_status, scripted batch refresh: status={status:?}, advances {:?}, {} reads, median tick CPU by the \ - tests' clock {:?}, took {took:?}", + "host_status, scripted two-step refresh: status={status:?}, advances {:?} in {} intervals over {:?}, {} \ + reads, median tick CPU by the tests' clock {:?}, took {took:?}", advances(w), + w.intervals, + w.window, reads.len(), median(&ticks.iter().map(|t| t.0).collect::>()) ); - assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "a batch refresh read as live: {:?}", advances(w)); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "a two-step refresh read as live: {:?}", advances(w)); assert!( took >= crate::CPU_RAIL_LIVENESS_BUDGET, "host_status gave up on a rail that is not live after {took:?}, before its {:?} budget", crate::CPU_RAIL_LIVENESS_BUDGET ); - assert_eq!(advances(w), [rail("CPU Energy#0", 1)]); + assert_eq!(advances(w), [rail("CPU Energy#0", 2)]); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); assert_eq!(reads.len(), run.samples.len(), "the record and the reads disagree"); for (i, s) in run.samples.iter().enumerate() { let t = s.taken.as_ref().expect("every scripted sample is usable"); - assert_eq!(named(&t.cpu_rails), [("CPU Energy#0".to_string(), m5_refresh(i))], "sample {i}"); + assert_eq!(named(&t.cpu_rails), [("CPU Energy#0".to_string(), refresh(i))], "sample {i}"); assert_eq!(named(&t.gpu_rails), [("GPU Energy#0".to_string(), m5_gpu(i))], "sample {i}"); } } - /// `host_status` itself, with a rail advancing at every read: Live after - /// two intervals, three reads and two ticks, each a tick of burned CPU. + /// `host_status` itself, with a rail advancing at every read: Live, + /// once the rail has advanced in at least five intervals and the + /// window, by the probe's real wall clock, spans 300 ms, each interval + /// a tick of burned CPU. By the tests' own clock the reads span 300 ms + /// too (less a clock read's slack), so the window is wall time, not a + /// count or a constant. And the probe stops as soon as it is live: one + /// interval earlier, the window spanned less than 300 ms, or the rail + /// had fewer than five advances. #[test] - fn host_status_on_a_scripted_live_rail_is_live() { + fn host_status_on_a_sustained_advance_is_live() { let _hw = crate::ioreport_hw_lock(); - let (status, run, reads, _) = host_status_on_script(|i| Some(m5_items(i, 541_893_638 + i as i64))); - if run.watch.is_none() { + let (status, run, reads, took) = host_status_on_script(|i| Some(m5_items(i, 541_893_638 + i as i64))); + let Some(w) = &run.watch else { return no_energy_model_group_here(status, &reads); - } - assert_ticks_burned(&reads, "host_status on a scripted live rail"); - assert_eq!(status, crate::IoReportStatus::Live, "{:?}", run.watch.as_ref().map(advances)); - assert_eq!((reads.len(), run.samples.len(), run.ticks.len()), (3, 3, 2)); + }; + let ticks = assert_ticks_burned(&reads, "host_status on a sustained advance"); + let span = reads.last().map(|l| l.1.wall.saturating_duration_since(reads[0].0.wall)).unwrap_or_default(); + // The rule's own reading at the sample before the last, from the + // probe's record: the end of that sample call, measured like the + // window, from the end of the first. + let end = |s: &SampleCall| s.at + s.wall; + let before_last = run.samples.len().checked_sub(2).map(|k| end(&run.samples[k]) - end(&run.samples[0])); + eprintln!( + "host_status, scripted sustained advance: status={status:?}, advances {:?} in {} intervals, window \ + {:?} by the probe's clock ({before_last:?} one interval earlier), {span:?} by the tests' clock, median \ + tick CPU {:?}, took {took:?}", + advances(w), + w.intervals, + w.window, + median(&ticks.iter().map(|t| t.0).collect::>()) + ); + assert_eq!(status, crate::IoReportStatus::Live, "a rail advancing at every read: {:?}", advances(w)); + assert_eq!(advances(w), [rail("CPU Energy#0", w.intervals)]); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Live after {} intervals", w.intervals); + assert!(w.window >= TEST_MIN_WINDOW, "Live over a {:?} window", w.window); + assert!(span + CLOCK_SLACK >= TEST_MIN_WINDOW, "Live, but the reads span {span:?} by the tests' clock"); + let before_last = before_last.expect("fewer than two samples"); + assert!( + w.intervals - 1 < TEST_MIN_ADVANCES || before_last < TEST_MIN_WINDOW + CLOCK_SLACK, + "the probe did not stop as soon as the rail was live: one interval earlier it had {} advances over \ + {before_last:?}", + w.intervals - 1 + ); + let n = w.intervals as usize; + assert_eq!((reads.len(), run.samples.len(), run.ticks.len()), (n + 1, n + 1, n), "reads, samples, ticks"); } /// `host_status` itself, with a CPU rail that holds at every read, as @@ -2161,23 +2464,24 @@ mod ioreport { ); assert_eq!(reads.len(), run.samples.len(), "the record and the reads disagree"); assert_eq!(run.ticks.len() + 1, run.samples.len(), "the probe must tick once before every re-read"); - assert!(w.intervals >= 2, "Frozen on {} intervals", w.intervals); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); } - /// `host_status` itself, with two dies each stepping once in different - /// intervals: Frozen, one advance per die. A transform anywhere between + /// `host_status` itself, with two dies advancing in turn: Frozen, each + /// die advancing in half the intervals. A transform anywhere between /// the FFI read and the rule that summed the dies' rails would see one - /// rail advance twice and say Live. + /// rail advance in every interval and say Live. #[test] - fn host_status_on_two_dies_stepping_apart_is_frozen() { + fn host_status_on_two_dies_advancing_in_turn_is_frozen() { let _hw = crate::ioreport_hw_lock(); - let (status, run, reads, _) = host_status_on_script(|i| Some(two_dies_stepping_apart(i))); + let (status, run, reads, _) = host_status_on_script(|i| Some(two_dies_advancing_in_turn(i))); let Some(w) = &run.watch else { return no_energy_model_group_here(status, &reads); }; - assert_ticks_burned(&reads, "host_status on two dies stepping apart"); - assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "one step per die read as live: {:?}", advances(w)); - assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); + assert_ticks_burned(&reads, "host_status on two dies advancing in turn"); + assert_eq!(status, crate::IoReportStatus::CpuRailFrozen, "two dies advancing in turn read as live: {:?}", advances(w)); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); + assert_eq!(advances(w), in_turn_advances(w.intervals)); } // ── Against the hardware ──────────────────────────────────────────── @@ -2208,23 +2512,41 @@ mod ioreport { NoIoReport, NoCpuRail, CpuRailMoved(bool), - /// Fewer than two reads after the first, even with the window - /// extended: too few for a moving rail to show two changes, so no - /// evidence either way. Every test fails on it. + /// Fewer than [`ORACLE_MIN_CHANGES`] reads after the first, even + /// with the window extended: too few for a moving rail to meet the + /// oracle's bar, so no evidence either way. Every test fails on it. TooFewReads, } + /// The oracle's bar for a moving rail: it changed at this many reads at + /// least, and at 80% of the reads at least ([`oracle_kept_moving`]). + /// Also the fewest reads after the first that the oracle takes, since a + /// rail cannot change at more reads than there are. Its own literal, + /// not the implementation's constant. + const ORACLE_MIN_CHANGES: u32 = 5; + /// How many times its window the oracle may keep reading to get the - /// two reads it needs. + /// [`ORACLE_MIN_CHANGES`] reads it needs. const ORACLE_WINDOW_CAP: u32 = 5; + /// Did a rail that changed at `changes` of `reads` reads keep moving? + /// At least [`ORACLE_MIN_CHANGES`] changes, at least 80% of the reads. + /// A live rail changes at nearly every read. The batch refreshes seen + /// were published in one step or two within 64 ms. The oracle's reads + /// are 0.13 s or more apart, so even a refresh spread over 150 ms holds + /// at most two of them, and shows as a change at three reads at most: + /// it cannot vouch for a probe that called it live. + fn oracle_kept_moving(changes: u32, reads: u32) -> bool { + changes >= ORACLE_MIN_CHANGES && changes * 5 >= reads * 4 + } + /// The failure for an oracle run with too little evidence. fn too_few_reads(run: &OracleRun, window: Duration) -> ! { panic!( "oracle evidence: only {} reads after the first in {:?} (window {window:?}, extended up to \ - {ORACLE_WINDOW_CAP}x): fewer than 2 cannot show a rail changing twice, so this run can neither \ - clear nor convict the probe (the host is too loaded for an all-channel IOReport sample, or its \ - reads fail)", + {ORACLE_WINDOW_CAP}x): fewer than {ORACLE_MIN_CHANGES} cannot show a rail changing at \ + {ORACLE_MIN_CHANGES} reads, so this run can neither clear nor convict the probe (the host is too \ + loaded for an all-channel IOReport sample, or its reads fail)", run.reads, run.took ) @@ -2237,7 +2559,7 @@ mod ioreport { /// Reads taken after the first. reads: u32, /// How long the oracle read for: its window, or longer if it had - /// to extend it to get two reads. + /// to extend it to get [`ORACLE_MIN_CHANGES`] reads. took: Duration, /// Per CPU rail, at how many reads its absolute value had changed. cpu_changes: Vec, @@ -2373,19 +2695,21 @@ mod ioreport { Some((pick("CPU Energy"), pick("GPU Energy"))) } - /// Read until `window` has elapsed since `started`, counting per rail at - /// how many reads the absolute value changed. Stops early once one CPU - /// rail has changed at two separate reads. If `window` ends with fewer - /// than two reads after the first, keeps reading until it has two, up - /// to [`ORACLE_WINDOW_CAP`] windows, and says [`Oracle::TooFewReads`] - /// if it still has not: never `CpuRailMoved(false)` on 0-1 reads. - fn oracle_watch( - sub: IOReportSubscriptionRef, - chans: CFMutableDictionaryRef, - started: Instant, + /// Read until `window` has elapsed, counting per rail at how many reads + /// the absolute value changed from the read before. Stops early once + /// one CPU rail meets [`oracle_kept_moving`]. If `window` ends with + /// fewer than [`ORACLE_MIN_CHANGES`] reads after the first, keeps + /// reading until it has that many, up to [`ORACLE_WINDOW_CAP`] windows, + /// and says [`Oracle::TooFewReads`] if it still has not: never + /// `CpuRailMoved(false)` on too few reads to tell. `read` takes one + /// read, `elapsed` is the time since the oracle started: the hardware + /// ([`oracle_watch`]), or a script and a scripted clock. + fn oracle_watch_with( + mut read: impl FnMut() -> Option<(Vec, Vec)>, + elapsed: impl Fn() -> Duration, window: Duration, ) -> OracleRun { - let Some((mut cpu_prev, mut gpu_prev)) = oracle_read(sub, chans) else { + let Some((mut cpu_prev, mut gpu_prev)) = read() else { return OracleRun::bare(Oracle::NoIoReport); }; if cpu_prev.is_empty() { @@ -2398,8 +2722,8 @@ mod ioreport { cpu_changes: vec![0; cpu_prev.len()], gpu_changes: vec![0; gpu_prev.len()], }; - while started.elapsed() < window || (run.reads < 2 && started.elapsed() < window * ORACLE_WINDOW_CAP) { - let Some((cpu, gpu)) = oracle_read(sub, chans) else { + while elapsed() < window || (run.reads < ORACLE_MIN_CHANGES && elapsed() < window * ORACLE_WINDOW_CAP) { + let Some((cpu, gpu)) = read() else { continue; }; run.reads += 1; @@ -2410,18 +2734,29 @@ mod ioreport { *n += u32::from(now != before); } (cpu_prev, gpu_prev) = (cpu, gpu); - if run.cpu_changes.iter().any(|&n| n >= 2) { + if run.cpu_changes.iter().any(|&n| oracle_kept_moving(n, run.reads)) { run.verdict = Oracle::CpuRailMoved(true); break; } } - run.took = started.elapsed(); - if run.reads < 2 { + run.took = elapsed(); + if run.reads < ORACLE_MIN_CHANGES { run.verdict = Oracle::TooFewReads; } run } + /// [`oracle_watch_with`] on the oracle's own subscription, timed from + /// `started`. + fn oracle_watch( + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + started: Instant, + window: Duration, + ) -> OracleRun { + oracle_watch_with(|| oracle_read(sub, chans), || started.elapsed(), window) + } + /// An oracle that shares nothing with the liveness probe but the raw FFI /// declarations: it opens its own subscription over every channel (not /// the probe's Energy Model group), converts CFStrings itself, reads @@ -2432,12 +2767,14 @@ mod ioreport { /// `window`, so the window is under load even while an all-channel sample /// blocks this thread. /// - /// `CpuRailMoved(true)` only if one rail's absolute value changed at two - /// or more separate reads. A live rail changes at nearly every read; a - /// batch-refreshed one at most once in a window this short, and one jump - /// is not a live reading. Its own literal threshold, not the - /// implementation's constant, so a change to one cannot silently move - /// the other. + /// `CpuRailMoved(true)` only if one rail's absolute value changed at + /// [`ORACLE_MIN_CHANGES`] reads or more, making up at least 80% of its + /// reads ([`oracle_kept_moving`]). A live rail changes at nearly every + /// read. A batch-refreshed one changes at three reads at most, even + /// when the refresh is published in steps, so a refresh that coincides + /// with the probe cannot vouch for a Live it caused. Its own literal + /// thresholds, not the implementation's constants, so a change to one + /// cannot silently move the other. fn oracle_cpu_rail(window: Duration) -> OracleRun { let Some((sub, chans)) = oracle_subscribe() else { return OracleRun::bare(Oracle::NoIoReport); @@ -2485,6 +2822,96 @@ mod ioreport { )) } + /// [`oracle_watch_with`] on a script: read `i` gives one CPU rail at + /// `cpu(i)` and a GPU rail changing at every read, and each read moves a + /// scripted clock on by `per_read`. + fn oracle_on(cpu: impl Fn(usize) -> i64, per_read: Duration, window: Duration) -> OracleRun { + let (clock, n) = (Cell::new(Duration::ZERO), Cell::new(0)); + oracle_watch_with( + || { + clock.set(clock.get() + per_read); + let i = n.get(); + n.set(i + 1); + Some((vec![cpu(i)], vec![1_000_000 * i as i64])) + }, + || clock.get(), + window, + ) + } + + /// The oracle's verdict, on scripted reads 150 ms apart (an + /// all-channel read takes 0.13-0.8 s) over its 3 s window: + /// - (*) the two-step refresh seen on an M5 Max, changing the CPU rail + /// at two consecutive reads and then flat: `CpuRailMoved(false)`, + /// after the whole window. At two changes the oracle said `true`, and + /// a refresh coinciding with the probe vouched for the Live it caused. + /// - (*) a refresh in three steps across three reads: `false`. + /// - a rail changing at every read: `true` at the fifth read, not + /// before. + /// - one changing at four reads in five: `true` at the sixth, 5 of 6. + /// - one changing at three reads in five: `false`. + #[test] + fn the_oracle_says_a_rail_moved_only_if_it_kept_moving() { + let (per_read, window) = (Duration::from_millis(150), Duration::from_millis(3000)); + let run = oracle_on(|i| two_step(i, 3, 4), per_read, window); + assert_eq!( + (&run.verdict, run.reads, run.cpu_changes.as_slice()), + (&Oracle::CpuRailMoved(false), 19, [2].as_slice()), + "a two-step refresh vouched for a moving rail: {run:?}" + ); + let run = oracle_on(|i| 2_643_843_378 + 1_000_000 * i.min(3) as i64, per_read, window); + assert_eq!( + (&run.verdict, run.cpu_changes.as_slice()), + (&Oracle::CpuRailMoved(false), [3].as_slice()), + "a three-step refresh vouched for a moving rail: {run:?}" + ); + let run = oracle_on(|i| i as i64, per_read, window); + assert_eq!( + (&run.verdict, run.reads, run.took), + (&Oracle::CpuRailMoved(true), 5, Duration::from_millis(900)), + "a rail changing at every read: {run:?}" + ); + let missing = |missed: fn(usize) -> bool| move |i: usize| (1..=i).filter(|&k| !missed(k)).count() as i64; + let run = oracle_on(missing(|k| k % 5 == 0), per_read, window); + assert_eq!((&run.verdict, run.reads), (&Oracle::CpuRailMoved(true), 6), "four changes in five reads: {run:?}"); + let run = oracle_on(missing(|k| k % 5 == 0 || k % 5 == 1), per_read, window); + assert_eq!( + (&run.verdict, run.reads, run.cpu_changes.as_slice()), + (&Oracle::CpuRailMoved(false), 19, [12].as_slice()), + "three changes in five reads: {run:?}" + ); + } + + /// The oracle takes enough reads for a moving rail to meet its bar. + /// With reads 1 s apart in a 3 s window it reads on past the window + /// until it has five after the first (at 6 s), and then a frozen rail + /// is `CpuRailMoved(false)` and a moving one `true`. With reads 4 s + /// apart it stops at five windows with three, and says `TooFewReads`, + /// never `CpuRailMoved(false)`. No read is `NoIoReport`; no CPU rail is + /// `NoCpuRail`. + #[test] + fn the_oracle_reads_long_enough_to_tell_or_says_it_cannot() { + let window = Duration::from_millis(3000); + let second = Duration::from_secs(1); + let run = oracle_on(|_| 541_893_638, second, window); + assert_eq!( + (&run.verdict, run.reads, run.took), + (&Oracle::CpuRailMoved(false), 5, Duration::from_secs(6)), + "slow reads: {run:?}" + ); + let run = oracle_on(|i| i as i64, second, window); + assert_eq!((&run.verdict, run.reads), (&Oracle::CpuRailMoved(true), 5), "slow reads: {run:?}"); + let run = oracle_on(|i| i as i64, Duration::from_secs(4), window); + assert_eq!( + (&run.verdict, run.reads, run.took), + (&Oracle::TooFewReads, 3, Duration::from_secs(16)), + "reads too slow to tell: {run:?}" + ); + assert_eq!(oracle_watch_with(|| None, || Duration::ZERO, window).verdict, Oracle::NoIoReport); + let no_cpu = oracle_watch_with(|| Some((Vec::new(), vec![1])), || Duration::ZERO, window); + assert_eq!(no_cpu.verdict, Oracle::NoCpuRail); + } + /// The property that matters: IOReport is **never** selected when its CPU /// rail is not live, because then it would report GPU-only energy, zero, /// or a whole batch refresh as the energy of CPU work. Deterministic on @@ -2494,10 +2921,11 @@ mod ioreport { /// The oracle gets three times the probe's budget, so a rail too slow for /// the probe but moving within the oracle's window is the only case in /// which declining is not proven right — and that one fails safe: - /// estimated, never over-claimed. A single batch refresh landing in both - /// windows does not rescue a probe that called it live: the oracle counts - /// it as one change, not a moving rail. An oracle with fewer than two - /// reads after the first is no evidence, and fails the test. + /// estimated, never over-claimed. A batch refresh landing in both + /// windows does not rescue a probe that called it live, even one + /// published in steps: the oracle sees it change at three reads at most, + /// not a moving rail. An oracle with fewer than five reads after the + /// first is no evidence, and fails the test. #[test] fn the_ioreport_rung_is_never_selected_on_a_frozen_cpu_rail() { let _hw = crate::ioreport_hw_lock(); @@ -2542,8 +2970,8 @@ mod ioreport { } Oracle::CpuRailMoved(true) => { // A live rail. The probe had a third of the oracle's time: if it - // saw two advances the rung must be selected; if not, declining - // is the safe side. + // saw the rail keep advancing the rung must be selected; if + // not, declining is the safe side. assert!( matches!(status, crate::IoReportStatus::Live | crate::IoReportStatus::CpuRailFrozen), "the oracle saw a CPU rail keep moving but the status is {status:?}" @@ -3276,14 +3704,17 @@ pub enum IoReportStatus { /// unfamiliar channel naming. Never selected. CpuRailAbsent, /// The CPU energy rail exists but did not keep advancing while CPU was - /// burned: no one CPU rail advanced in two separate sample intervals. It - /// held, or stepped once — a batch refresh, not a live reading. The platform + /// burned: no one CPU rail advanced in at least five separate sample + /// intervals, making up at least 80% of those observed, over at least + /// 300 ms. It held, or stepped a few times together — a batch refresh, not + /// a live reading. The platform /// is not exposing live CPU energy to this process, so selecting the rung /// would report GPU-only energy, zero, or a whole refresh as the energy of /// CPU work. Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. CpuRailFrozen, - /// The same CPU energy rail advanced in at least two separate sample - /// intervals under load; the rung is selected. + /// The same CPU energy rail kept advancing under load: in at least five + /// separate sample intervals, making up at least 80% of those observed, + /// over at least 300 ms. The rung is selected. Live, } @@ -3292,14 +3723,15 @@ pub enum IoReportStatus { /// to the Energy Model group, ~5–12 ms a sample, after every 25 ms of the /// calling thread's own CPU time (a tick that cannot get that in 500 ms of wall /// time stops there) — about 30 intervals in the budget on an idle host, fewer -/// under load, each a sample and the one before it — and the same CPU rail -/// must advance in two of them. A live Energy Model rail -/// changes many times a second (the M5 Max GPU rail every ~28 ms), so one -/// second is generous. A sample that fails is skipped. If samples are slow or -/// failing enough that fewer than two intervals fit, the probe keeps sampling -/// past the budget until it has two, up to five budgets. A slower live rail -/// fails safe — it is estimated, never over-claimed. Paid once per process; -/// see [`ioreport_status`]. +/// under load, each a sample and the one before it. The same CPU rail must +/// keep advancing: in at least five intervals, making up at least 80% of those +/// observed, over at least 300 ms from the first sample. A live Energy Model +/// rail changes many times a second (the M5 Max GPU rail every ~28 ms), so it +/// is called live about 300 ms in, and one second is generous. A sample that +/// fails is skipped. If samples are slow or failing enough that fewer than five +/// intervals fit, the probe keeps sampling past the budget until it has five, +/// up to five budgets. A slower live rail fails safe — it is estimated, never +/// over-claimed. Paid once per process; see [`ioreport_status`]. #[cfg(target_os = "macos")] pub const CPU_RAIL_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); diff --git a/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs b/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs index 0ec3952b6..4f4a5bec5 100644 --- a/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs +++ b/jcp/jcp-rs/crates/jcp-energy/tests/ioreport_probed_once.rs @@ -3,8 +3,8 @@ //! //! This file holds one test, so it runs in a process of its own, and its //! concurrent calls ARE the first use: the cache starts cold, whatever order -//! other tests run in. Counted, not timed: a probe on a live rail is over in -//! tens of milliseconds, which a clock cannot tell from a cache hit. +//! other tests run in. Counted, not timed: how long a probe takes depends on +//! the rail and on the host's load, so a clock cannot tell one probe from two. use std::sync::Barrier; -- 2.54.0 (Apple Git-157) From 340313d6b3781b110a859dc0eafa2214705a3f80 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 20:04:08 -0400 Subject: [PATCH 07/10] jcp: a live vendor counter is a reading; intercept/1 keeps a partial figure partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The questions this branch left open are now decided. A vendor energy counter (RAPL, NVML, Apple IOReport) is a real reading; what it lacks is an independent root, which is a gap in provenance, not in accuracy. It earns the hardware grade only while it is live. The live GPU rail is not reported as a partial reading of CPU work. And a partial figure must stay partial on the intercept/1 wire. The grade. jcp-energy already graded RAPL and NVML `HwShunt`. The IOReport rung carried a live reading at `ModelBased`, in the modeled slot. It now takes the same grade: `Meter::provenance()` says `HwShunt` and a live span is `Energy::measured`. The declined paths keep their own grades: the time×power model is `ModelBased`, the constant estimator `Estimator`. Raising the grade raises the stakes of what `measure()` accepts, so it is gated per span as well. It took any rise of the summed SoC total as the span's energy, and that total rises through the GPU rail, which is machine-wide. On this M5 Max, in this commit's own test runs, the counter counted 32 mJ and then 9.1 J between two reads while an independent oracle saw the CPU rail hold and one other rail move. A rail that was live at selection can still hold across one span: a span shorter than its update interval, or a rail that stops publishing after the probe. The counter now reports the CPU rails' share beside its total, from the same delta sample. `measure()` grades a span `HwShunt` only when the CPU rail itself advanced. The figure is still the SoC total's rise, which, like a RAPL package counter, over-debits the work rather than leaving part of it out. A span whose CPU rail held, or whose read failed, is the time×power figure at `ModelBased`. A model figure is never `HwShunt`. The grading is one pure function, `ioreport_span`, so every branch runs on scripted reads on any platform. The oracle test that checks the counter's total value for value, against its own all-channel subscription, now checks the CPU share the same way. The GPU rail is not reported as a partial reading. It is machine-wide (display compositing moves it), so booking it to a task misattributes energy the task did not spend, and a partial figure would under-debit the budget because the CPU's share would go unmetered. The rung is declined to the whole-task estimate. That rationale now sits in the rung's docs and in SPEC §5.1. SPEC. §5 says `HwShunt` covers vendor counters as well as shunts, and carries the liveness MUST in the shared wording. Its dashes are typeset and `Partial` is code-formatted; the text is otherwise as decided. §5.1 adds the IOReport rung. A new §5.2 defines coverage and its wire encodings, carried only when `Partial`. One is jcp/2's `energy_coverage`, which jcp-receipt already emitted but the SPEC never listed, so §8's core field list gains it. The other is intercept/1's `coverage`. intercept/1. jcp-intercept dropped coverage, so a `Partial` figure handed to it was sealed as the whole step's energy. It now adds `"coverage": "Partial"` only when the figure is partial. `verify` recovers it in a new `InterceptReceipt::coverage`, and refuses any other value, an explicit "Whole" included, because a sealer never produces one. A whole figure seals the same bytes as before. The two envelopes a Redact-then-CallLimit chain sealed at 3fd35db5 were captured by building that commit in a scratch crate before the change, and are pinned hex for hex. A whole `HwShunt` figure's object is compared, key for key and in canonical bytes, against the pre-coverage object. No conformance vector changes, and none contains an intercept/1 receipt. Prose. Provenance::HwShunt's doc names vendor counters and the liveness rule. The README's jcp-energy bullet lists the IOReport rung. Its jcp-gateway bullet no longer says "metered on real hardware ... energy actually spent". jcp-efficiency's MAPPING §4 now says "real readings of the vendor's own energy counter" instead of "power-sensor readings", and that a counter counts only while live. It also no longer calls RAPL "a model, not a meter". Tests. jcp-energy has 54 lib tests (51 before) plus its integration test. jcp-intercept has 12 (8 before). jcp-schema has 30, jcp-conformance its unit test and 5 pack tests, all passing. `jcp-conformance` prints "all checks passed", and jcp-cli builds and runs. clippy --no-deps is clean on jcp-energy and jcp-intercept. jcp-schema's one warning, in flow.rs, is pre-existing. Falsified, each by a temporary edit restored with git checkout: - A live span built with `Energy::modeled` fails `a_span_the_ioreport_cpu_rail_advanced_over_is_a_hardware_reading` and `the_ioreport_rung_stamps_the_hardware_grade`. - `Meter::provenance()` for IOReport set back to `ModelBased` fails `the_ioreport_rung_stamps_the_hardware_grade`. - Gating the span on the SoC total instead of the CPU rail fails `a_span_the_ioreport_cpu_rail_held_over_is_the_models_figure` with "the GPU rail rose, the CPU rail held ... provenance: HwShunt". - A CPU share that counts every rail fails the hardware oracle test: "CPU share rose 18127 µJ ... CPU energy rails rose 0 µJ". That one needs a non-CPU rail to move during the window, and the GPU rail did. - Sealing coverage even when Whole fails the byte-identity tests (and every test that verifies a whole receipt). - Dropping coverage when Partial fails `a_partial_figure_seals_coverage_partial`. - Letting verify read an explicit "Whole" fails `verify_refuses_a_coverage_other_than_partial`. Not done here: - jcp-gateway still seals `Energy::measured` for whatever sandbox-meter returns, estimates included. The sandbox-meter liveness branch fixes that, so the README's gateway line states the rule rather than this branch's gateway. - The NVML rung reports GPU energy as a whole-task `HwShunt` figure. Under the new MUST, a figure for work its counter does not cover (the CPU's share) is `Partial`. That needs its own decision, and nothing here is built with `--features nvml`. Co-Authored-By: Claude Opus 5.5 --- jcp/README.md | 4 +- jcp/SPEC.md | 53 +++- jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md | 24 +- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 298 +++++++++++++++----- jcp/jcp-rs/crates/jcp-intercept/src/lib.rs | 151 +++++++++- jcp/jcp-rs/crates/jcp-schema/src/lib.rs | 9 +- 6 files changed, 454 insertions(+), 85 deletions(-) diff --git a/jcp/README.md b/jcp/README.md index c247027d7..5f1222e37 100644 --- a/jcp/README.md +++ b/jcp/README.md @@ -45,8 +45,8 @@ jcp/ - **`jcp-trace`** — ingests an agent run's **OpenTelemetry GenAI trace** (`gen_ai.*` semantic conventions: `chat` / `tool` / `retriever` spans with token usage) and its **evaluation assessments** (judge / human / code feedback), attaches **per-span joules** (measured where a meter is present, else `Estimator` from token counts — provenance never silently upgrades), and seals one **`eval/1` JCR-1 receipt**: the per-span token + joule breakdown with the headline `uj_per_accepted_token` at the **weakest** provenance present, the assessments, a deterministic **quality-gate** verdict (the sealed, declarative analog of a CI assert), and a **readiness projection** an ARL validation-axis claim consumes. The observability stacks *record* a trace and *log* a judge score in a table; this is the interop projection that **seals** the same trace, puts a joule on it, and hash-chains it via `EvalLog` — no new cryptography. The **gate is content-addressed**, so the sealed receipt commits to exactly which bar it was judged against (a tamper-evident artifact, not a mutable in-code `assert`), and **`align_threshold` / `Judge`** calibrate a judge's decision threshold to human pass/fail labels — judge alignment as a reproducible number, not a UI click. Behind the `server` feature, a std-only **OTLP `/v1/traces` receiver** lets any OpenTelemetry exporter POST a `gen_ai.*` trace (with its assessments) and get the sealed `eval/1` receipt back the instant the run ends. - **`jcp-intercept`** — **attested interceptor chains**: a composable, ordered pipeline of transforms *around* each model and tool call — the `before_model` / `after_model` / `before_tool` seams an agent's middleware stack runs, composed onion-order (forward in, reverse out) and stopping at the first refusal or human-approval hold. An interceptor may inspect or **rewrite** the request (redact a secret before it reaches the model, trim stale context), **refuse** it, or **hold** it for a human. The difference from the incumbent's arbitrary in-process hooks: every step seals an **`intercept/1`** JCR-1 receipt carrying the request digest **before and after** the step (so a redaction or a trim is provable — the content itself never sealed, only its hash), the seam, the outcome, and the **metered joules** at their honest provenance, hash-chained — the exact transform pipeline that produced a model request becomes reconstructable and verifiable by anyone, and a step cannot run un-sealed. Three reference interceptors — `Redact` (a content transform), `CallLimit` (a guardrail), `Approve` (a human-in-the-loop hold) — each reuse a primitive the family already holds rather than reinventing it. - **`jcp-efficiency`** — the **energy-efficiency spec**, surfaced as a sealed receipt. The dark-factory economics turn on four figures — **joules per accepted token, tokens per second, value per token, and the tokens/joules/dollars spent per COMPLETED, VERIFIED unit of work** — and the incumbents publish, at best, a *rate* (cost per token) that can't tell you whether the spend bought a result. An **`efficiency/1`** JCR-1 receipt records all four at honest provenance, hash-chained, with the one thing the rates omit: the **outcome anchor** (`completed` + a verdict). A measurement that didn't complete is spend that bought nothing, and the receipt says so. `aggregate` then splits value-delivered from total spend across a chain — charging *every* attempt (including the failed ones) against the results actually delivered — so `tokens_per_completed_unit` / `uj_per_completed_unit` / `value_per_joule` become the honest efficiency a "cost per token" number is designed to hide. Integers and strings only (JCR-1 forbids bools/floats); the rate figures recompute from the sealed inputs on read. -- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`), NVML GPU energy behind the `nvml` feature (`HwShunt`), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. -- **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered on real hardware** (the sibling Sandbox standard's energy probe — Apple Silicon IOReport, RAPL, NVML), and the **measured** joules are debited from the grant — so the budget that stops a runaway agent is denominated in energy actually spent, not a token estimate. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. +- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`), NVML GPU energy behind the `nvml` feature (`HwShunt`), Apple Silicon IOReport (`HwShunt`, only while its CPU energy rail is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. +- **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered** by the sibling Sandbox standard's energy probe (Apple Silicon IOReport, RAPL, NVML — vendor energy counters, whose readings are real but vendor-opaque and relayed by the platform, and which count as readings only while the counter is live for the work, SPEC §5; otherwise the figure is an estimate and is graded as one), and the metered joules are debited from the grant — so the budget that stops a runaway agent is denominated in joules, not a token estimate. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. - **`jcp-wire`** — the `jcp/1` wire binding (SPEC §11): 4-byte length prefix + canonical det-CBOR over any byte stream, with TCP and Unix-socket endpoints and a client. The decoder rejects non-canonical bodies, so a message has exactly one wire form. Requests carry the full delegation chain and are **caller-signed** — the endpoint authenticates from the request itself and denies an impersonation `bad-signature`, sealed like any other decision. Responses carry the deterministic decision and the sealed receipt. - **`jcp-threshold`** — threshold actors (SPEC §2.2): a `did:key` whose private key is split k-of-n with FROST (RFC 9591); the group key is an ordinary Ed25519 key, so a 2-of-3 team issues, holds, and signs wire requests with nothing else in JCP changing. Shares come from a trusted dealer or **distributed key generation** — with DKG the group's private key never exists anywhere, not even momentarily. The two-agent rule for irreversible actions, refused by arithmetic rather than policy. - **`jcp-channel`** — receipt-settled budget channels (SPEC §4.5, the OPTIONAL profile): N enforcement points spend optimistically against one grant and settle by exchanging signed statements of cumulative spend — forged, stale, and regressing statements refused; between settlements the exposure is bounded by the window, and after settlement any overdraft is provable from the signed statements. Where bounded overdraft is unacceptable, `shard()` remains the baseline. diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 6625422d6..15c52135f 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -383,6 +383,28 @@ absent). The runtime debits `cost.uj()` from a grant's remaining budget on every allow; the coupling MUST be enforced. The energy axis is what no existing agentic protocol standardizes — JCP makes it a precondition, not a report. +`HwShunt` covers a vendor energy counter as well as a dedicated shunt. A vendor +counter's count is a real reading, not an estimate; what it lacks is an +independent root — how the vendor derives it is undisclosed, and the +platform's own software relays it — which is a gap in provenance, not in +accuracy. A reading earns the grade only while its counter is live: + +**Liveness (REQUIRED).** A reading from a vendor energy counter (RAPL, NVML, +Apple IOReport, ...) MAY be graded as a hardware reading only while the counter +is demonstrably live for the domain the figure claims: the implementation MUST +have observed the counter covering that domain advance under load (for Apple +IOReport: the CPU energy rail itself advancing in at least 5 sample intervals, +in at least 4 of every 5, over at least 300 ms while the probing thread burns +CPU). A counter that is present but not live — frozen, or republished in lumps +— MUST NOT produce a hardware-graded figure; the implementation falls back to +its estimator or reports the work unmetered, and SHOULD record why. A figure +MUST NOT claim coverage of a domain its counter does not read; where the format +has a coverage field, such a figure is Partial. + +The record of why is the implementation's to choose; the reference meter +exposes the IOReport liveness status it was selected or declined on. Coverage +is defined in §5.2. + ### 5.1 The metering ladder (REQUIRED behavior, RECOMMENDED backends) The rule: **measured when the platform exposes it, estimated when it doesn't — @@ -397,9 +419,35 @@ Recommended ladder, best first: 1. **RAPL** (Linux `powercap`) — package energy counters, wraparound-corrected → `HwShunt`. 2. **NVML** — the GPU total-energy counter → `HwShunt`. -3. **Time × calibrated power** — wall-clock duration times a host-calibrated +3. **Apple IOReport** (the Energy Model group) — the SoC energy counters + → `HwShunt`, selected only while the CPU energy rail is live by the rule + above. A span over which that rail did not advance is metered by the next + rung, never graded `HwShunt`. Where the CPU rail is not live but the GPU rail + is, the rung is declined rather than reporting the GPU rail as a `Partial` + reading of the work: the GPU rail is machine-wide (display compositing + moves it), so booking it to a task misattributes energy the task did not + spend, and the partial figure would under-debit the budget, the CPU's share + going unmetered. The whole-task estimate below is the honest figure. +4. **Time × calibrated power** — wall-clock duration times a host-calibrated active power → `ModelBased`. Always available. -4. **Constant per-operation estimate** → `Estimator`. +5. **Constant per-operation estimate** → `Estimator`. + +### 5.2 Coverage (REQUIRED where a figure is partial) + +Provenance says how a figure was obtained; **coverage** says how much of the +work it accounts for: `Whole` (the default) or `Partial`. A reading can be +hardware-rooted and still cover only part of the work — an accelerator counter +on a host whose CPU has none — and its remainder is unmeasured, not zero. A +`Partial` figure MUST NOT be presented as a task total. + +On the wire coverage is carried only when it is `Partial`, and omitted when +`Whole`, so a figure sealed before the field existed keeps its exact bytes; a +verifier reads an absent field as `Whole`. The `jcp/2` receipt carries it as +`energy_coverage` (§8); the `intercept/1` step receipt of the reference +interceptor chain (`jcp-intercept`) carries it as `coverage`, beside its +`provenance`. In `intercept/1` the field, when present, has exactly one value, +`"Partial"`; a verifier MUST refuse any other value, `"Whole"` included, rather +than read it either way. ## 6. The Frame (REQUIRED) @@ -473,6 +521,7 @@ verdict = "allow" | "deny" deny_reason = string ; omitted on allow cost_uj = uint energy_provenance = "HwShunt" | "ModelBased" | "Estimator" +energy_coverage = "Partial" ; omitted when Whole (§5.2) budget_remaining_uj, budget_uj = uint steward = the signer's id (the COSE kid) ``` diff --git a/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md b/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md index 7149a318c..a876ae1d7 100644 --- a/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md +++ b/jcp/jcp-rs/crates/jcp-efficiency/MAPPING.md @@ -97,10 +97,15 @@ here the entire field — TX included — sits on an **unsolved problem, one of provenance and tamper-evidence rather than accuracy**: - **Vendor counters are opaque and platform-relayed, not independently rooted**: - RAPL/NVML/IOReport are *real* power-sensor readings, not fabricated estimates — but - their derivation and error bounds are undisclosed, nothing binds the number to the - sensor, and the reading reaches a verifier through the platform's own (untrusted) - software. RAPL has separately been an unprivileged-readable power side-channel + RAPL/NVML/IOReport are *real* readings of the vendor's own energy counter, not + fabricated estimates — which is why JCP grades a live one `HwShunt` (SPEC §5) — but + how the vendor derives the count (a sensor, a model, or both) and its error bounds + are undisclosed, nothing binds the number to its source, and the reading reaches a + verifier through the platform's own (untrusted) software. And a counter is a reading + only while it is live: one that is present but frozen, or republished in lumps (as + Apple's CPU energy rails are to an unprivileged reader on macOS 27.0.1), reads + nothing of the work and must not yield a hardware-graded figure. + RAPL has separately been an unprivileged-readable power side-channel (PLATYPUS), and its accuracy against an external meter is itself contested (arXiv:2505.09375). - **NVML / `nvidia-smi`** is a real on-board sensor, but its coarse sampling misses @@ -157,11 +162,12 @@ re-verifies both from scratch, and derives `IntegrityAttested`. **What `PhysicallyRooted` actually requires (an honest clarification).** It is *not* reached by standing up a cloud confidential VM. A cloud SEV-SNP/TDX instance gives a -hardware-grounded *report*, but its energy counter is still RAPL — a model, not a -meter — so the derived level is `IntegrityAttested`, not `PhysicallyRooted`. That is -the whole "confidential computing attests integrity, not energy" thesis, enforced at -the type level: the ladder refuses to promote a non-physical counter no matter how -genuine the TEE. `PhysicallyRooted` needs *both* a hardware-grounded report *and* a +hardware-grounded *report*, but its energy counter is still RAPL — a vendor counter +the platform relays, not a reading signed at its source — so the derived level is +`IntegrityAttested`, not `PhysicallyRooted`. That is the whole "confidential +computing attests integrity, not energy" thesis, enforced at the type level: the +ladder refuses to promote a counter not signed at its source, no matter how genuine +the TEE. `PhysicallyRooted` needs *both* a hardware-grounded report *and* a **physical counter whose reading is signed at the source** — a current shunt with a secure element — co-located with confidential compute. That is a custom-hardware build, not an instance type. Its software-reachable precursor — deriving diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 07901b609..01d9e6e88 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -12,20 +12,30 @@ //! `Provenance::HwShunt`, wraparound-corrected. //! 2. **NVML** (feature `nvml`) — GPU energy via `libnvidia-ml`, //! `Provenance::HwShunt`. -//! 3. **IOReport** (Apple Silicon) — real on-die SoC Energy Model counters via -//! Apple's IOReport C API (no sudo, no external binary). A real reading but a -//! vendor model, so `Provenance::ModelBased` — strictly better than the -//! wall-clock model below. **Selected only when its CPU energy rail is live** -//! ([`ioreport_status`]): a subscription can open while every CPU rail holds -//! its value, refreshed only in rare batches, and then the summed total -//! advances through the GPU rail — GPU energy reported as the energy of CPU -//! work — or by a whole refresh at once. Live means the same CPU rail kept -//! advancing while the probe burned CPU: in at least five separate sample -//! intervals, in at least 80% of the intervals observed, over a window of at -//! least 300 ms. A batch refresh, even one published in a few steps, is not a -//! live rail. Otherwise the platform is not exposing CPU energy, so by the -//! rule above this rung is declined and the time×power model estimates -//! instead. +//! 3. **IOReport** (Apple Silicon) — the on-die SoC Energy Model counters via +//! Apple's IOReport C API (no sudo, no external binary), `Provenance::HwShunt`. +//! Like RAPL and NVML it is a vendor energy counter: the count is a real +//! reading, not an estimate, and what it lacks is an independent root — how +//! Apple derives it is undisclosed and the platform relays it — which is a +//! question of provenance, not accuracy, so it takes the same grade. **Only +//! while its CPU energy rail is live** ([`ioreport_status`]): a subscription +//! can open while every CPU rail holds its value, refreshed only in rare +//! batches, and then the summed total advances through the GPU rail — GPU +//! energy reported as the energy of CPU work — or by a whole refresh at +//! once. Live means the same CPU rail kept advancing while the probe burned +//! CPU: in at least five separate sample intervals, in at least 80% of the +//! intervals observed, over a window of at least 300 ms. A batch refresh, +//! even one published in a few steps, is not a live rail. Otherwise the +//! platform is not exposing CPU energy, so by the rule above this rung is +//! declined and the time×power model estimates instead. A span whose CPU +//! rail did not advance is the model's figure too, never a reading. +//! +//! Declined, the rung does not report the GPU rail as a partial reading of +//! the work, though on an M5 Max that rail stays live: it is machine-wide — +//! display compositing moves it — so booking it to a task misattributes +//! energy the task did not spend, and a partial figure would under-debit the +//! budget, the CPU's share of the work going unmetered. The whole-task +//! estimate stands. //! 4. **Time × power model** — wall-clock duration times a calibrated active //! power, `Provenance::ModelBased`. Always available. //! 5. **Constant estimator** — a fixed per-operation figure, @@ -135,10 +145,12 @@ impl std::fmt::Debug for Nvml { /// Apple Silicon IOReport energy reader — a real, no-sudo, no-external-binary /// reading of the SoC Energy Model channels through Apple's private IOReport C -/// API. Apple's Energy Model is a vendor model (frequency/voltage/utilisation), -/// not a coulomb shunt, so a reading is honestly `ModelBased` — but it is the -/// actual on-die energy, strictly better than the wall-clock × assumed-power -/// `TimePower` model it sits ahead of. Ported from jouleclaw-energy's validated +/// API. Like RAPL and NVML it is a vendor energy counter: how Apple derives the +/// count is undisclosed and the platform relays it, which is a gap in +/// provenance, not in accuracy, so a live reading is graded `HwShunt` as theirs +/// are. It counts as a reading only while its CPU rail is live +/// ([`crate::ioreport_status`]) and only for a span over which that rail +/// advanced (`ioreport_span`). Ported from jouleclaw-energy's validated /// `apple_ioreport` backend. #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] @@ -148,7 +160,7 @@ mod ioreport { use std::sync::Mutex; use std::time::{Duration, Instant}; - use crate::IoReportStatus; + use crate::{IoReportReading, IoReportStatus}; type CFTypeRef = *const c_void; type CFStringRef = *const c_void; @@ -217,7 +229,7 @@ mod ioreport { sub: IOReportSubscriptionRef, channels: CFMutableDictionaryRef, prior_sample: Option, - cumulative_uj: u64, + cumulative: IoReportReading, } impl Drop for State { @@ -283,7 +295,7 @@ mod ioreport { sub, channels: subscribed, prior_sample: None, - cumulative_uj: 0, + cumulative: IoReportReading { total_uj: 0, cpu_uj: 0 }, }), }) } @@ -292,6 +304,14 @@ mod ioreport { /// Cumulative SoC energy (µJ) since the first read. Subtract two reads /// for an interval. `None` if the interface vanished mid-read. pub fn read_uj(&self) -> Option { + self.read().map(|r| r.total_uj) + } + + /// Cumulative energy (µJ) since the first read: the SoC total the + /// counter sums, and within it the CPU energy rails' own share, both + /// from the same pair of samples. Subtract two reads for an interval. + /// `None` if the interface vanished mid-read. + pub(crate) fn read(&self) -> Option { let mut state = self.state.lock().ok()?; unsafe { let current = IOReportCreateSamples(state.sub, state.channels, std::ptr::null()); @@ -301,15 +321,18 @@ mod ioreport { if let Some(prior) = state.prior_sample { let delta = IOReportCreateSamplesDelta(prior, current, std::ptr::null()); if !delta.is_null() { - state.cumulative_uj = - state.cumulative_uj.saturating_add(sum_energy_uj(delta)); + let (total_uj, cpu_uj) = sum_energy_uj(delta); + state.cumulative = IoReportReading { + total_uj: state.cumulative.total_uj.saturating_add(total_uj), + cpu_uj: state.cumulative.cpu_uj.saturating_add(cpu_uj), + }; CFRelease(delta); } CFRelease(prior); } state.prior_sample = Some(current); } - Some(state.cumulative_uj) + Some(state.cumulative) } } @@ -1020,22 +1043,26 @@ mod ioreport { } } - unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> u64 { + /// Over one delta sample: the energy of every Energy Model rail the + /// counter sums ([`counts_once`]), and of the CPU energy rails alone + /// ([`is_cpu_energy_rail`], a share of the first), in µJ. + unsafe fn sum_energy_uj(delta: CFDictionaryRef) -> (u64, u64) { let key = CFStringCreateWithCString( std::ptr::null_mut(), c"IOReportChannels".as_ptr() as *const _, K_CFSTRING_ENCODING_UTF8, ); if key.is_null() { - return 0; + return (0, 0); } let arr = CFDictionaryGetValue(delta, key as *const _) as CFArrayRef; CFRelease(key as CFTypeRef); if arr.is_null() { - return 0; + return (0, 0); } let count = CFArrayGetCount(arr); let mut total_uj: u64 = 0; + let mut cpu_uj: u64 = 0; for i in 0..count { let item = CFArrayGetValueAtIndex(arr, i); if item.is_null() { @@ -1056,9 +1083,13 @@ mod ioreport { continue; } let unit = cfstring_to_string(IOReportChannelGetUnitLabel(item)).unwrap_or_default(); - total_uj = total_uj.saturating_add(energy_to_uj(raw, &unit)); + let uj = energy_to_uj(raw, &unit); + total_uj = total_uj.saturating_add(uj); + if is_cpu_energy_rail(&name) { + cpu_uj = cpu_uj.saturating_add(uj); + } } - total_uj + (total_uj, cpu_uj) } fn energy_to_uj(raw: c_long, unit: &str) -> u64 { @@ -3551,8 +3582,10 @@ mod ioreport { } /// When the rung IS selected, its own CPU rail is live on a fresh probe - /// and a CPU burn advances the counter itself — a reading, not the - /// time×power fallback that `measure` degrades to on a zero delta. + /// and a CPU burn advances the CPU rail itself, so `measure` gives the + /// span as a reading: `HwShunt` with the measured figure, the grade of + /// RAPL and NVML, not the time×power fallback it degrades to when the + /// CPU rail holds. /// /// When it is not, the status says why (never `Live`), and the meter /// `detect()` returned still meters real work honestly: 100 ms of CPU @@ -3571,16 +3604,29 @@ mod ioreport { ); let fresh = host_status(); assert_eq!(fresh, crate::IoReportStatus::Live, "IOReport selected but a fresh probe says {fresh:?}"); - let _ = io.read_uj(); // the first read only sets the baseline sample - let before = io.read_uj().expect("baseline read"); + let _ = io.read(); // the first read only sets the baseline sample + let before = io.read().expect("baseline read"); spin_cpu(Duration::from_millis(200)); - let after = io.read_uj().expect("work read"); + let after = io.read().expect("work read"); assert!( - after > before, - "a live CPU rail metered 200 ms of CPU work as zero: before={before} after={after}" + after.cpu_uj > before.cpu_uj, + "a live CPU rail metered 200 ms of CPU work as zero: before={before:?} after={after:?}" ); let (_, e) = meter.measure(|| spin_cpu(Duration::from_millis(200))); - assert_eq!(e.provenance, jcp_schema::Provenance::ModelBased); + eprintln!( + "selected meter: 200 ms of CPU work -> {} µJ, provenance={:?}, measured_uj={:?}", + e.uj(), + e.provenance, + e.measured_uj + ); + e.check_provenance().expect("the selected meter's figure must be internally consistent"); + assert_eq!( + e.provenance, + jcp_schema::Provenance::HwShunt, + "a span of a live IOReport CPU rail was not graded as a hardware reading" + ); + assert!(e.measured_uj.is_some_and(|uj| uj > 0), "a live reading measured {:?}", e.measured_uj); + assert!(e.is_whole(), "the SoC total was reported as covering part of the work"); } else { let status = crate::ioreport_status(); assert_ne!(status, crate::IoReportStatus::Live, "status Live but detect() did not select the IOReport rung"); @@ -3623,6 +3669,11 @@ mod ioreport { /// the two oracle reads inside that window, and at most the rise /// between the two outside it. When nothing moved, both are 0 and the /// counter must have counted exactly 0. + /// + /// The same bounds hold, from the same reads, for the CPU share the + /// counter reports beside its total — the figure `measure` decides a + /// reading on — over the CPU energy rails alone, picked by the oracle's + /// own `ends_with("CPU Energy")`. #[test] fn the_counter_advances_when_a_rail_it_sums_advanced() { let _hw = crate::ioreport_hw_lock(); @@ -3649,45 +3700,66 @@ mod ioreport { } out }; - let _ = io.read_uj().expect("the counter's baseline read"); + let _ = io.read().expect("the counter's baseline read"); let before_first = summed("before the counter's first read"); - let first = io.read_uj().expect("the counter's first read"); + let first = io.read().expect("the counter's first read"); let after_first = summed("after the counter's first read"); spin_cpu(Duration::from_millis(200)); let before_second = summed("before the counter's second read"); - let second = io.read_uj().expect("the counter's second read"); + let second = io.read().expect("the counter's second read"); let after_second = summed("after the counter's second read"); oracle_unsubscribe(oracle); - // The rise of the summed rails from one oracle read to another, in - // µJ as the counter converts each rail, and how many rails rose. - let rise = |from: &[((String, u32), String, i64)], to: &[((String, u32), String, i64)]| -> (u64, usize) { + // The rise, from one oracle read to another, of the summed rails + // whose names `pick` accepts, in µJ as the counter converts each + // rail, and how many of them rose. + let rise = |from: &[((String, u32), String, i64)], + to: &[((String, u32), String, i64)], + pick: fn(&str) -> bool| + -> (u64, usize) { let keys = |v: &[((String, u32), String, i64)]| v.iter().map(|(k, _, _)| k.clone()).collect::>(); assert_eq!(keys(from), keys(to), "the summed rails changed between two oracle reads"); let mut uj: u64 = 0; let mut rose = 0; - for ((_, unit, a), (_, _, b)) in from.iter().zip(to) { + for (((name, _), unit, a), (_, _, b)) in from.iter().zip(to) { + if !pick(name) { + continue; + } let raw = b.saturating_sub(*a).max(0); rose += usize::from(raw > 0); uj = uj.saturating_add(energy_to_uj(raw as c_long, unit)); } (uj, rose) }; - let (inside, rose_inside) = rise(&after_first, &before_second); - let (around, rose_around) = rise(&before_first, &after_second); + let every: fn(&str) -> bool = |_| true; + let cpu: fn(&str) -> bool = |n| n.ends_with("CPU Energy"); + let (inside, rose_inside) = rise(&after_first, &before_second, every); + let (around, rose_around) = rise(&before_first, &after_second, every); + let (cpu_inside, _) = rise(&after_first, &before_second, cpu); + let (cpu_around, cpu_rose_around) = rise(&before_first, &after_second, cpu); + let cpu_rails = after_first.iter().filter(|((n, _), _, _)| cpu(n)).count(); eprintln!( "ioreport counter: it sums {} Energy Model rails; between its two reads it counted {} µJ; by the \ oracle's absolute reads those rails rose {inside} µJ inside that window ({rose_inside} rails rose) and \ - {around} µJ around it ({rose_around} rose)", + {around} µJ around it ({rose_around} rose). Its CPU share, {cpu_rails} CPU rails: it counted {} µJ; \ + they rose {cpu_inside} µJ inside and {cpu_around} µJ around it ({cpu_rose_around} rose)", after_first.len(), - second.saturating_sub(first) + second.total_uj.saturating_sub(first.total_uj), + second.cpu_uj.saturating_sub(first.cpu_uj) ); - assert!(second >= first, "the counter ran backwards: {first} -> {second} µJ"); - let counted = second - first; + assert!(second.total_uj >= first.total_uj, "the counter ran backwards: {first:?} -> {second:?}"); + assert!(second.cpu_uj >= first.cpu_uj, "the counter's CPU share ran backwards: {first:?} -> {second:?}"); + let counted = second.total_uj - first.total_uj; assert!( inside <= counted && counted <= around, "the counter counted {counted} µJ between its two reads, but the rails it sums rose {inside} µJ inside \ that window and {around} µJ around it, by the oracle's absolute reads: it did not sum what its rails did" ); + let cpu_counted = second.cpu_uj - first.cpu_uj; + assert!( + cpu_inside <= cpu_counted && cpu_counted <= cpu_around, + "the counter's CPU share rose {cpu_counted} µJ between its two reads, but the CPU energy rails rose \ + {cpu_inside} µJ inside that window and {cpu_around} µJ around it, by the oracle's absolute reads" + ); } } } @@ -3709,8 +3781,9 @@ pub enum IoReportStatus { /// 300 ms. It held, or stepped a few times together — a batch refresh, not /// a live reading. The platform /// is not exposing live CPU energy to this process, so selecting the rung - /// would report GPU-only energy, zero, or a whole refresh as the energy of - /// CPU work. Observed on macOS 27.0.1 on both an M5 Max and an M3 Ultra. + /// would grade a whole refresh, landing in one span, as a hardware reading + /// of that span's CPU work. Observed on macOS 27.0.1 on both an M5 Max and + /// an M3 Ultra. CpuRailFrozen, /// The same CPU energy rail kept advancing under load: in at least five /// separate sample intervals, making up at least 80% of those observed, @@ -3803,8 +3876,10 @@ pub enum Meter { /// Hardware GPU energy → `Provenance::HwShunt`. #[cfg(feature = "nvml")] Nvml(Box), - /// Apple Silicon IOReport Energy Model — a real on-die reading, but vendor- - /// modeled, so `Provenance::ModelBased`. Sits ahead of the wall-clock model. + /// Apple Silicon IOReport Energy Model energy → `Provenance::HwShunt`, the + /// grade of RAPL's and NVML's vendor counters. [`Meter::detect`] selects it + /// only while the CPU energy rail is live, and a span over which that rail + /// did not advance is the time×power model's figure (`ioreport_span`). #[cfg(target_os = "macos")] IoReport(ioreport::IoReport), /// Wall-clock × calibrated active power → `Provenance::ModelBased`. @@ -3834,9 +3909,9 @@ impl Meter { if let Some(n) = Nvml::open() { return Meter::Nvml(Box::new(n)); } - // Opening a subscription is not enough: the CPU rail must be live, or - // the rung reports GPU-only energy, zero, or a whole batch refresh as - // the energy of CPU work. + // Opening a subscription is not enough: the CPU rail must be live + // (SPEC §5), or a whole batch refresh landing in one span is graded a + // hardware reading of that span's CPU work. #[cfg(target_os = "macos")] if admits(ioreport_status()) { if let Some(io) = ioreport::IoReport::open() { @@ -3865,7 +3940,7 @@ impl Meter { #[cfg(feature = "nvml")] Meter::Nvml(_) => Provenance::HwShunt, #[cfg(target_os = "macos")] - Meter::IoReport(_) => Provenance::ModelBased, + Meter::IoReport(_) => Provenance::HwShunt, Meter::TimePower { .. } => Provenance::ModelBased, Meter::Const { .. } => Provenance::Estimator, } @@ -3903,19 +3978,11 @@ impl Meter { } #[cfg(target_os = "macos")] Meter::IoReport(io) => { - let before = io.read_uj(); + let before = io.read(); let started = Instant::now(); let out = work(); let elapsed = started.elapsed(); - // A real on-die IOReport reading, carried at ModelBased grade in - // the modeled slot (never the HwShunt-reserved `measured_uj`). - // A sub-quantum span (delta doesn't clear ~1 mJ) or an interface - // failure degrades to the time×power model — also ModelBased. - let energy = match (before, io.read_uj()) { - (Some(b), Some(a)) if a > b => Energy::modeled(a.saturating_sub(b)), - _ => Energy::modeled(time_power_uj(elapsed.as_nanos(), DEFAULT_ACTIVE_MILLIWATTS)), - }; - (out, energy) + (out, ioreport_span(before, io.read(), elapsed.as_nanos())) } Meter::TimePower { milliwatts } => { let started = Instant::now(); @@ -3927,6 +3994,37 @@ impl Meter { } } +/// One cumulative read of the IOReport counter, in µJ since its first read. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct IoReportReading { + /// Every Energy Model rail the counter sums, CPU included: the SoC total. + total_uj: u64, + /// The CPU energy rails alone (`CPU Energy`, or `DIE__CPU Energy` on + /// multi-die parts), a share of `total_uj`. + cpu_uj: u64, +} + +/// The figure the IOReport rung gives one span, from its reads either side. +/// +/// A reading, `HwShunt`, only when the CPU energy rail itself advanced across +/// the span. The figure is then the SoC total's rise: every rail the counter +/// sums, which like a RAPL package counter takes in whatever else ran on the +/// machine meanwhile, so it over-debits the work rather than leave part of it +/// out. A rail that was live at selection can still hold across a span — a +/// span shorter than its update interval, or a rail that has since stopped +/// publishing — and then the total can rise only through rails this work did +/// not drive (the machine-wide GPU rail moves with display compositing). That +/// is not a reading of the work, so the span is the time×power model's figure, +/// `ModelBased`, as it is when a read fails. Never `HwShunt` on a model figure. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn ioreport_span(before: Option, after: Option, elapsed_ns: u128) -> Energy { + match (before, after) { + (Some(b), Some(a)) if a.cpu_uj > b.cpu_uj => Energy::measured(a.total_uj.saturating_sub(b.total_uj)), + _ => Energy::modeled(time_power_uj(elapsed_ns, DEFAULT_ACTIVE_MILLIWATTS)), + } +} + /// µJ for a span: `mW × ns` is 10⁻³ W · 10⁻⁹ s = 10⁻¹² J = 10⁻⁶ µJ. fn time_power_uj(elapsed_ns: u128, milliwatts: u64) -> u64 { u64::try_from(elapsed_ns.saturating_mul(milliwatts as u128) / 1_000_000).unwrap_or(u64::MAX) @@ -4094,6 +4192,70 @@ mod tests { } } + /// A span over which the IOReport CPU rail advanced is a vendor-counter + /// reading, graded as RAPL's and NVML's are: `HwShunt`, the SoC total's + /// rise as the measured figure, covering the whole of the work. + #[test] + fn a_span_the_ioreport_cpu_rail_advanced_over_is_a_hardware_reading() { + let before = IoReportReading { total_uj: 10_000, cpu_uj: 4_000 }; + let after = IoReportReading { total_uj: 15_500, cpu_uj: 7_000 }; + let e = ioreport_span(Some(before), Some(after), 1_000_000); + assert_eq!(e.provenance, Provenance::HwShunt, "a live span was not graded as a reading: {e:?}"); + assert_eq!(e.measured_uj, Some(5_500), "the reading is not the SoC total's rise: {e:?}"); + assert_eq!(e.uj(), 5_500); + assert!(e.is_whole(), "the SoC total was reported as partial: {e:?}"); + e.check_provenance().expect("a reading must carry its measured figure"); + } + + /// A span over which the CPU rail held is never a reading, whatever the + /// SoC total did: the total rising through other rails (the machine-wide + /// GPU rail) is not this work's energy, and a span that did not move at + /// all, or whose read failed, measured nothing. Each is the time×power + /// model's figure for the span, `ModelBased`, with no measured figure. + #[test] + fn a_span_the_ioreport_cpu_rail_held_over_is_the_models_figure() { + let elapsed_ns = 2_000_000; // 2 ms + let model = time_power_uj(elapsed_ns, DEFAULT_ACTIVE_MILLIWATTS); + assert!(model > 0); + let b = IoReportReading { total_uj: 10_000, cpu_uj: 4_000 }; + let cases = [ + ("the GPU rail rose, the CPU rail held", Some(b), Some(IoReportReading { total_uj: 90_000, cpu_uj: 4_000 })), + ("nothing moved", Some(b), Some(b)), + ("the read before failed", None, Some(IoReportReading { total_uj: 15_000, cpu_uj: 7_000 })), + ("the read after failed", Some(b), None), + ]; + for (what, before, after) in cases { + let e = ioreport_span(before, after, elapsed_ns); + assert_eq!(e.provenance, Provenance::ModelBased, "{what}: {e:?}"); + assert_eq!(e.measured_uj, None, "{what}: a model figure claimed a measurement: {e:?}"); + assert_eq!(e.uj(), model, "{what}: not the time×power figure for the span: {e:?}"); + e.check_provenance().expect("a model figure must be internally consistent"); + } + } + + /// The grade the IOReport rung says it stamps is the grade its live spans + /// carry, and it is the hardware grade. Driven through `detect_with`, so + /// the rung is built on any macOS host whose counter opens, whatever this + /// host's own liveness status. + #[cfg(target_os = "macos")] + #[test] + fn the_ioreport_rung_stamps_the_hardware_grade() { + let _hw = ioreport_hw_lock(); + let meter = Meter::detect_with(|| IoReportStatus::Live); + if matches!(meter, Meter::IoReport(_)) { + let live = ioreport_span( + Some(IoReportReading { total_uj: 0, cpu_uj: 0 }), + Some(IoReportReading { total_uj: 2, cpu_uj: 1 }), + 1, + ); + assert_eq!(meter.provenance(), Provenance::HwShunt, "the IOReport rung is not graded as a hardware reading"); + assert_eq!(meter.provenance(), live.provenance, "the rung's grade is not the grade of its live spans"); + } else { + assert!(ioreport::IoReport::open().is_none(), "a Live status did not build the rung, though the counter opens"); + assert!(matches!(meter, Meter::TimePower { .. }), "{meter:?}"); + } + } + /// `detect()` selects the IOReport rung on a `Live` status wherever the /// counter opens, and on no other status: the others fall to the /// time×power model at the documented default power. diff --git a/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs b/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs index a374a720d..a93e3fe3c 100644 --- a/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-intercept/src/lib.rs @@ -18,7 +18,9 @@ //! that a call was refused, without ever sealing the content itself (only its //! hash). A step cannot run un-sealed. //! - **Honest joule accounting.** Each step is metered ([`jcp_energy`]); the -//! receipt records the figure at its true provenance, never overclaiming. +//! receipt records the figure at its true provenance, never overclaiming — +//! and a figure that covers only part of the step says so (`"coverage": +//! "Partial"`), rather than being sealed as the whole step's energy. //! //! A [`Chain`] runs its interceptors forward through the `before_*` seams and in //! reverse through `after_model` (the onion unwinds), stopping at the first @@ -30,7 +32,7 @@ use ed25519_dalek::SigningKey; use jcp_energy::Meter; use jcp_receipt::jcr1; -use jcp_schema::{ContentId, Energy}; +use jcp_schema::{ContentId, Coverage, Energy}; use serde_json::{json, Value}; // ───────────────── the request/response/tool an interceptor transforms ───────────────── @@ -352,7 +354,8 @@ impl Chain { &self.receipts } - /// Total metered energy across every sealed step, microjoules. + /// Total metered energy across every sealed step, microjoules. Where a + /// step's figure was `Partial` (its receipt says so), so is this total. pub fn energy_uj(&self) -> u64 { self.energy_uj } @@ -409,6 +412,13 @@ fn intercept_wire( "prev": prev.to_hex(), "steward": steward, }); + // Coverage rides beside provenance only when the figure is `Partial`. The + // whole-step case is the default and is omitted, so a receipt for a whole + // figure is byte-identical to one sealed before the field existed. Without + // it a partial figure would be sealed as the step's whole energy. + if !energy.is_whole() { + o["coverage"] = json!(energy.coverage.label()); + } if let Some(r) = step.reason() { o["reason"] = json!(r); } @@ -450,6 +460,10 @@ pub struct InterceptReceipt { pub energy_uj: u64, /// The energy provenance label. pub provenance: String, + /// How much of the step the energy figure covers: `Partial` when the + /// receipt carries `"coverage": "Partial"`, `Whole` when it carries no + /// coverage (the only other encoding a receipt may have). + pub coverage: Coverage, /// The reason, for a halt/hold outcome. pub reason: Option, /// The chain sequence number. @@ -480,6 +494,13 @@ pub fn verify(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Result Coverage::Whole, + Some(c) if c.as_str() == Some(Coverage::Partial.label()) => Coverage::Partial, + Some(c) => return Err(InterceptError::Malformed(format!("coverage {c} is not \"Partial\""))), + }; Ok(InterceptReceipt { interceptor: s("interceptor")?, seam: s("seam")?, @@ -489,6 +510,7 @@ pub fn verify(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Result Value { + let core = jcr1::verify_cose_sign1(env, c.steward(), &c.steward_pubkey()).expect("a sealed receipt verifies"); + jcp_schema::from_canonical_cbor(&core).expect("the core decodes") + } + + /// A whole figure seals exactly the bytes it sealed before coverage + /// existed: no `coverage` key, the same envelopes, byte for byte. + #[test] + fn a_whole_figure_seals_byte_identically_to_before() { + let mut c = chain() + .with(Box::new(Redact::secrets())) + .with(Box::new(CallLimit::models(5))); + c.before_model(&mut req()); + assert_eq!(c.receipts().len(), 2); + for (i, env) in c.receipts().iter().enumerate() { + assert!(payload(env, &c).get("coverage").is_none(), "receipt {i} carries a coverage for a whole figure"); + assert_eq!(verify(env, c.steward(), &c.steward_pubkey()).unwrap().coverage, Coverage::Whole); + assert_eq!(hex::encode(env), WHOLE_BEFORE_COVERAGE[i], "receipt {i} is not the envelope sealed before coverage"); + } + } + + /// The same for a whole hardware reading, `HwShunt`, which the + /// constant meter cannot produce: its object is the `intercept/1` object + /// as it was, key for key, and canonicalizes to the same core bytes. + #[test] + fn a_whole_reading_seals_the_object_it_sealed_before() { + let (before, after) = (ContentId::of_bytes(b"a"), ContentId::of_bytes(b"b")); + let prev = ContentId([3u8; 32]); + let wire = intercept_wire( + "redact", Seam::BeforeTool, &Step::Continue, before, after, &Energy::measured(7), 4, &prev, "did:jcp:s", + ); + let as_before = json!({ + "intercept_receipt": "intercept/1", + "interceptor": "redact", + "seam": "before_tool", + "outcome": "continue", + "request_before": before.to_hex(), + "request_after": after.to_hex(), + "transformed": 1u64, + "energy_uj": 7u64, + "provenance": "HwShunt", + "seq": 4u64, + "prev": prev.to_hex(), + "steward": "did:jcp:s", + }); + assert_eq!(wire, as_before); + assert_eq!(jcr1::signed_core_bytes(&wire).unwrap(), jcr1::signed_core_bytes(&as_before).unwrap()); + } + + /// A partial figure is sealed as one: `"coverage": "Partial"` beside its + /// provenance, recovered by `verify`, and that key is the only difference + /// from the same figure sealed whole. + #[test] + fn a_partial_figure_seals_coverage_partial() { + let mut c = chain(); + let d = req().digest(); + c.seal("meter", Seam::BeforeModel, &Step::Continue, d, d, Energy::measured_partial(500)); + c.seal("meter", Seam::BeforeModel, &Step::Continue, d, d, Energy::measured(500)); + let (partial, whole) = (payload(&c.receipts()[0], &c), payload(&c.receipts()[1], &c)); + assert_eq!(partial["coverage"], json!("Partial"), "a partial figure sealed without its coverage: {partial}"); + let rec = verify(&c.receipts()[0], c.steward(), &c.steward_pubkey()).unwrap(); + assert_eq!((rec.coverage, rec.provenance.as_str(), rec.energy_uj), (Coverage::Partial, "HwShunt", 500)); + let strip = |mut v: Value| { + let o = v.as_object_mut().unwrap(); + o.remove("coverage"); + o.remove("seq"); + o.remove("prev"); + v + }; + assert_eq!(strip(partial), strip(whole), "a partial figure's receipt differs from a whole one's beyond its coverage"); + } + + /// `coverage` has one encoding, `"Partial"`, and absence means whole. A + /// receipt carrying any other value, an explicit `"Whole"` included, is + /// refused rather than read either way. + #[test] + fn verify_refuses_a_coverage_other_than_partial() { + let sk = SigningKey::from_bytes(&[7u8; 32]); + let steward = "did:jcp:test-intercept"; + let d = req().digest(); + let whole = intercept_wire("m", Seam::BeforeModel, &Step::Continue, d, d, &Energy::measured(1), 0, &d, steward); + for bad in [json!("Whole"), json!("partial"), json!(1u64)] { + let mut wire = whole.clone(); + wire["coverage"] = bad.clone(); + let env = jcr1::cose_sign1_envelope(&jcr1::signed_core_bytes(&wire).unwrap(), &sk, steward); + let got = verify(&env, steward, &sk.verifying_key().to_bytes()); + assert!(matches!(got, Err(InterceptError::Malformed(_))), "coverage {bad} was read: {got:?}"); + } + } } diff --git a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs index ba23423a4..ab0117e02 100644 --- a/jcp/jcp-rs/crates/jcp-schema/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-schema/src/lib.rs @@ -107,7 +107,14 @@ impl TryFrom for Did { /// How honest an energy reading is (SPEC §5) — the family's provenance model. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum Provenance { - /// Read from a hardware energy interface (RAPL/NVML). Requires a measured figure. + /// Read from a hardware energy interface: a dedicated shunt, or a vendor + /// energy counter (RAPL, NVML, Apple IOReport, ...). A vendor counter's + /// count is a real reading, not an estimate; how the vendor derives it is + /// undisclosed and the platform relays it, which is a gap in provenance, + /// not in accuracy. It earns this grade only while it is live for the domain + /// the figure claims (SPEC §5): observed advancing under load. A counter + /// that is present but frozen, or republished in lumps, never yields an + /// `HwShunt` figure. Requires a measured figure. HwShunt, /// A calibrated per-operation model. A measured figure may be absent. ModelBased, -- 2.54.0 (Apple Git-157) From 475680abcdbc7dc08f52e6da9318dc15659fcbe1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 20:39:59 -0400 Subject: [PATCH 08/10] jcp-energy: RAPL and NVML count only while live, and an NVML figure is partial MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 340313d6 left two vendor counters outside the rule it adopted. SPEC §5 says a vendor counter (RAPL, NVML, IOReport) is a hardware reading only while it is live for the domain the figure claims, and that a figure must not claim a domain its counter does not read. IOReport was held to that. RAPL and NVML were selected as soon as they opened, and NVML's GPU-only figure claimed the whole task. One rule. The sustained-advance rule (one rail advancing in at least five intervals, at least 80% of them, over at least 300 ms), its loop, its stopping rule and the CPU-burning tick lived inside the macOS-only IOReport module. They now live in a crate-level `liveness` module that compiles everywhere, and all three rungs apply it. IOReport's `RailWatch` is now the shared rule fed only its CPU rails. Its 43 tests are unchanged apart from one import line, and all pass. The thread clock gains Linux's CLOCK_THREAD_CPUTIME_ID (3) beside macOS's (16), so on Linux the RAPL probe also burns by its own thread's CPU. RAPL. `rapl_status()` probes once per process and caches the result. Each package's `energy_uj` is a rail, sampled after every 25 ms of the probing thread's CPU, for up to one second. `detect()` opens RAPL only on Live; on any other status `select` does not call the opener. With no readable powercap tree the status is Unavailable and nothing is burned, so RAPL declines as before. On macOS that costs one failed read_dir. A span over which no package counter rose now gets the time×power figure instead of a hardware reading of zero. This mirrors IOReport's per-span rule: the counter updates about once a millisecond, so a shorter span can see no rise even though the work ran. NVML. `nvml_status()` applies the same rule to device 0's total-energy counter. The probe cannot load the GPU, and burning CPU does not load it either, so it waits 100 ms between samples instead of burning. A counter refreshed at least that often advances on the device's own draw in every interval. One refreshed less often fails the 80% share and is declined, so it fails safe. An NVML span is now `Energy::measured_partial`, `HwShunt` with `Coverage::Partial`, because the GPU counter does not read the CPU's share of the work. A span over which the counter did not advance, or fell (a driver reload resets it), gets the model's whole-task figure. Without the feature `nvml_status()` is Unavailable. With it, everything compiles and clippy is clean on macOS and Linux x86_64. But no host here has an NVIDIA GPU, so the NVML probe has never run against a real counter. API, additive: `CounterStatus { Unavailable, Frozen, Live }`, `rapl_status()` and `nvml_status()`. `Meter::detect()` keeps its signature. Its private `detect_with` now takes a `Ladder` of statuses and the RAPL opener, so the tests can drive each gate with every status. SPEC §5.1 and the README's jcp-energy bullet now say that RAPL and NVML are selected only while live, and that NVML's figure is `Partial`. Tests. jcp-energy has 63 lib tests (54 before) and its integration test, all passing with and without `--features nvml`: - Scripted package counters, through the probe the RAPL rung runs. Live at the tenth interval (310 ms) when a package keeps advancing, and the probe stops there. Frozen when the counters hold, step in one lump, step in two or in five quick steps, advance in turn, or wrap at every other sample. Unavailable when every read fails, or when no tree opens (then nothing is sampled and nothing ticks). - The real RAPL probe on fake powercap trees in the temp directory: real file reads, the CPU-burning tick, the real clock. "a fake tree that holds -> Frozen in 1.004322458s"; "a fake tree advancing every 2 ms -> Live in 417.847167ms"; an absent or unparsable tree is Unavailable. - The gate: only Live admits a counter rung, and on any other status the counter is not opened. `detect_with` on a fake tree selects RAPL on Live only. - Spans: `measure` on a fake tree reads a rise, wraparound-corrected, as `HwShunt` and whole, and a span with no rise as `ModelBased`. NVML spans are `HwShunt` and `Partial`, or the model's whole figure when the counter held, fell or failed to read. - NVML-shaped scripts at 100 ms intervals: Live at the fifth interval; a counter advancing in 1 of 4 intervals is Frozen. - The fresh-process pin also counts the RAPL probe (one run) and, under the feature, the NVML probe (one run). jcp-intercept (12), jcp-schema (30) and jcp-conformance (1 unit test and 5 pack tests) pass. The jcp-conformance binary prints "all checks passed", and jcp-cli runs. clippy --no-deps is clean on jcp-energy, with and without `nvml`, for aarch64-apple-darwin, x86_64-apple-darwin and x86_64-unknown-linux-gnu. `cargo doc` with `-D warnings` is clean. Falsified, each by a temporary edit restored with git checkout (git diff was empty after each): - `detect` opening RAPL without asking its status fails `only_a_live_status_selects_the_rapl_rung` ("status Unavailable gave Rapl(...)"). It also fails the fresh-process pin, because the probe never ran. - A gate that admits Frozen fails the gate tests ("Frozen admits the rung"). - A probe that reports Live for any counter that opened fails the scripted, fake-tree and status tests ("a powercap tree whose counters hold was not declined"). - Requiring only two advances fails four IOReport rule tests and the NVML script. - A RAPL sampler that returns a constant fails the fake-tree test ("a powercap counter advancing every 2 ms was not live"). - Grading a span that did not rise as a reading fails three span tests ("measured_uj: Some(0) ... provenance: HwShunt"). - Grading an NVML span `Energy::measured` fails the partial test ("a GPU-only figure claimed the whole task"). - An uncached `rapl_status` fails the pin ("the RAPL liveness probe did not run exactly once in this process"). - A `RailWatch` that hands the rule every rail, GPU included, fails four IOReport rule tests. - A RAPL span that ignores the rise fails `a_rapl_span_is_a_reading_only_if_a_counter_rose`. Not done here: - The NVML gate in `detect_with` (`select(nvml, Nvml::open)`) cannot be exercised on a host with no NVIDIA device. It is the same `select` the RAPL gate tests drive, and the rule it gates on is tested on NVML-shaped scripts. - The 100 ms NVML wait is a design choice, not a measured refresh period, because no NVML counter was observed here. - RAPL was not exercised on Linux hardware. On most distributions `energy_uj` is readable only by root, and there RAPL stays Unavailable as before. Co-Authored-By: Claude Opus 5.5 --- jcp/README.md | 2 +- jcp/SPEC.md | 10 +- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 1278 ++++++++++++++++++----- 3 files changed, 1017 insertions(+), 273 deletions(-) diff --git a/jcp/README.md b/jcp/README.md index 5f1222e37..94897c52e 100644 --- a/jcp/README.md +++ b/jcp/README.md @@ -45,7 +45,7 @@ jcp/ - **`jcp-trace`** — ingests an agent run's **OpenTelemetry GenAI trace** (`gen_ai.*` semantic conventions: `chat` / `tool` / `retriever` spans with token usage) and its **evaluation assessments** (judge / human / code feedback), attaches **per-span joules** (measured where a meter is present, else `Estimator` from token counts — provenance never silently upgrades), and seals one **`eval/1` JCR-1 receipt**: the per-span token + joule breakdown with the headline `uj_per_accepted_token` at the **weakest** provenance present, the assessments, a deterministic **quality-gate** verdict (the sealed, declarative analog of a CI assert), and a **readiness projection** an ARL validation-axis claim consumes. The observability stacks *record* a trace and *log* a judge score in a table; this is the interop projection that **seals** the same trace, puts a joule on it, and hash-chains it via `EvalLog` — no new cryptography. The **gate is content-addressed**, so the sealed receipt commits to exactly which bar it was judged against (a tamper-evident artifact, not a mutable in-code `assert`), and **`align_threshold` / `Judge`** calibrate a judge's decision threshold to human pass/fail labels — judge alignment as a reproducible number, not a UI click. Behind the `server` feature, a std-only **OTLP `/v1/traces` receiver** lets any OpenTelemetry exporter POST a `gen_ai.*` trace (with its assessments) and get the sealed `eval/1` receipt back the instant the run ends. - **`jcp-intercept`** — **attested interceptor chains**: a composable, ordered pipeline of transforms *around* each model and tool call — the `before_model` / `after_model` / `before_tool` seams an agent's middleware stack runs, composed onion-order (forward in, reverse out) and stopping at the first refusal or human-approval hold. An interceptor may inspect or **rewrite** the request (redact a secret before it reaches the model, trim stale context), **refuse** it, or **hold** it for a human. The difference from the incumbent's arbitrary in-process hooks: every step seals an **`intercept/1`** JCR-1 receipt carrying the request digest **before and after** the step (so a redaction or a trim is provable — the content itself never sealed, only its hash), the seam, the outcome, and the **metered joules** at their honest provenance, hash-chained — the exact transform pipeline that produced a model request becomes reconstructable and verifiable by anyone, and a step cannot run un-sealed. Three reference interceptors — `Redact` (a content transform), `CallLimit` (a guardrail), `Approve` (a human-in-the-loop hold) — each reuse a primitive the family already holds rather than reinventing it. - **`jcp-efficiency`** — the **energy-efficiency spec**, surfaced as a sealed receipt. The dark-factory economics turn on four figures — **joules per accepted token, tokens per second, value per token, and the tokens/joules/dollars spent per COMPLETED, VERIFIED unit of work** — and the incumbents publish, at best, a *rate* (cost per token) that can't tell you whether the spend bought a result. An **`efficiency/1`** JCR-1 receipt records all four at honest provenance, hash-chained, with the one thing the rates omit: the **outcome anchor** (`completed` + a verdict). A measurement that didn't complete is spend that bought nothing, and the receipt says so. `aggregate` then splits value-delivered from total spend across a chain — charging *every* attempt (including the failed ones) against the results actually delivered — so `tokens_per_completed_unit` / `uj_per_completed_unit` / `value_per_joule` become the honest efficiency a "cost per token" number is designed to hide. Integers and strings only (JCR-1 forbids bools/floats); the rate figures recompute from the sealed inputs on read. -- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`), NVML GPU energy behind the `nvml` feature (`HwShunt`), Apple Silicon IOReport (`HwShunt`, only while its CPU energy rail is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. +- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`, only while a package counter is live — `rapl_status()`), NVML GPU energy behind the `nvml` feature (`HwShunt` and `Partial`, since it does not read the CPU's share; only while live — `nvml_status()`), Apple Silicon IOReport (`HwShunt`, only while its CPU energy rail is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. - **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered** by the sibling Sandbox standard's energy probe (Apple Silicon IOReport, RAPL, NVML — vendor energy counters, whose readings are real but vendor-opaque and relayed by the platform, and which count as readings only while the counter is live for the work, SPEC §5; otherwise the figure is an estimate and is graded as one), and the metered joules are debited from the grant — so the budget that stops a runaway agent is denominated in joules, not a token estimate. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. - **`jcp-wire`** — the `jcp/1` wire binding (SPEC §11): 4-byte length prefix + canonical det-CBOR over any byte stream, with TCP and Unix-socket endpoints and a client. The decoder rejects non-canonical bodies, so a message has exactly one wire form. Requests carry the full delegation chain and are **caller-signed** — the endpoint authenticates from the request itself and denies an impersonation `bad-signature`, sealed like any other decision. Responses carry the deterministic decision and the sealed receipt. - **`jcp-threshold`** — threshold actors (SPEC §2.2): a `did:key` whose private key is split k-of-n with FROST (RFC 9591); the group key is an ordinary Ed25519 key, so a 2-of-3 team issues, holds, and signs wire requests with nothing else in JCP changing. Shares come from a trusted dealer or **distributed key generation** — with DKG the group's private key never exists anywhere, not even momentarily. The two-agent rule for irreversible actions, refused by arithmetic rather than policy. diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 15c52135f..9470a88c8 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -417,8 +417,14 @@ hardware backend fails mid-reading and the figure degrades to a model. Recommended ladder, best first: 1. **RAPL** (Linux `powercap`) — package energy counters, wraparound-corrected - → `HwShunt`. -2. **NVML** — the GPU total-energy counter → `HwShunt`. + → `HwShunt`, selected only while a package counter is live under the + probe's CPU burn, by the rule above as IOReport's CPU rail is. A span over + which no package counter advanced is metered by a lower rung. +2. **NVML** — the GPU total-energy counter → `HwShunt`, `Partial` (§5.2): it + reads the GPU and not the CPU's share of the work. Selected only while the + counter is live by the same rule; the probe cannot load the GPU, so for + this counter live means published continuously, advancing on the device's + own draw. A span over which it did not advance is metered by a lower rung. 3. **Apple IOReport** (the Energy Model group) — the SoC energy counters → `HwShunt`, selected only while the CPU energy rail is live by the rule above. A span over which that rail did not advance is metered by the next diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 01d9e6e88..29018b03a 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -7,11 +7,20 @@ //! figure, it just carries `ModelBased` or `Estimator` provenance instead of //! `HwShunt`. A reading never silently upgrades its own provenance. //! -//! The ladder, best first: +//! The ladder, best first. Every vendor counter rung is selected only while +//! its counter is live by one rule (SPEC §5): one rail of it kept advancing +//! over a probe, in at least five sample intervals, at least 80% of them, over +//! at least 300 ms. Each probe runs once per process, and each rung's status +//! says why it was declined. //! 1. **RAPL** (`/sys/class/powercap`, Linux) — hardware package energy, -//! `Provenance::HwShunt`, wraparound-corrected. +//! `Provenance::HwShunt`, wraparound-corrected, **only while a package +//! counter is live** under the probe's CPU burn ([`rapl_status`]). A span +//! over which no package counter advanced is the time×power model's figure. //! 2. **NVML** (feature `nvml`) — GPU energy via `libnvidia-ml`, -//! `Provenance::HwShunt`. +//! `Provenance::HwShunt` with `Coverage::Partial`: the counter reads the +//! GPU, so the CPU's share of the work is unmetered and the figure never +//! claims the whole task. Only while the counter is live ([`nvml_status`]); +//! a span over which it did not advance is the model's whole-task figure. //! 3. **IOReport** (Apple Silicon) — the on-die SoC Energy Model counters via //! Apple's IOReport C API (no sudo, no external binary), `Provenance::HwShunt`. //! Like RAPL and NVML it is a vendor energy counter: the count is a real @@ -63,9 +72,17 @@ pub struct Rapl { impl Rapl { /// Open the RAPL interface. `None` when the platform has no readable /// powercap tree (non-Linux, locked-down sysfs, no Intel/AMD RAPL). + /// Opening is not selection: [`Meter::detect`] selects the rung only while + /// [`rapl_status`] is [`CounterStatus::Live`]. pub fn open() -> Option { + Self::open_in(std::path::Path::new("/sys/class/powercap")) + } + + /// [`Rapl::open`] on the powercap tree at `root`, so the probe runs on fake + /// trees in tests. + fn open_in(root: &std::path::Path) -> Option { let mut packages = Vec::new(); - let dir = std::fs::read_dir("/sys/class/powercap").ok()?; + let dir = std::fs::read_dir(root).ok()?; for entry in dir.flatten() { let name = entry.file_name(); let name = name.to_string_lossy(); @@ -110,6 +127,24 @@ impl Rapl { .map(|((_, range), (b, a))| if a >= b { a - b } else { range.saturating_sub(*b).saturating_add(*a) }) .sum() } + + /// One liveness sample: each package counter as a rail, named by its + /// powercap directory (`intel-rapl:0`, ...), with its raw `energy_uj`. + /// `None` when a package cannot be read, which the probe skips. A wrap + /// shows as a fall, which the rule does not count as an advance. + fn liveness_sample(&self) -> Option> { + let values = self.read()?; + let named: Vec<(String, i64)> = self + .packages + .iter() + .zip(values) + .map(|((path, _), uj)| { + let package = path.parent().and_then(|p| p.file_name()).map(|n| n.to_string_lossy().into_owned()); + (package.unwrap_or_default(), i64::try_from(uj).unwrap_or(i64::MAX)) + }) + .collect(); + Some(liveness::every_rail(&named)) + } } /// NVML GPU-energy reader (feature `nvml`). Uses the device's total-energy @@ -134,6 +169,17 @@ impl Nvml { let mj = self.nvml.device_by_index(self.device_index).ok()?.total_energy_consumption().ok()?; Some(mj.saturating_mul(1_000)) } + + /// One liveness sample: the device's total-energy counter as its one + /// rail, in its raw millijoules. `None` when the read fails, which the + /// probe skips. + fn liveness_sample(&self) -> Option> { + let mj = self.nvml.device_by_index(self.device_index).ok()?.total_energy_consumption().ok()?; + Some(liveness::every_rail(&[( + format!("nvml device {} total energy", self.device_index), + i64::try_from(mj).unwrap_or(i64::MAX), + )])) + } } #[cfg(feature = "nvml")] @@ -143,6 +189,476 @@ impl std::fmt::Debug for Nvml { } } +/// The sustained-advance liveness rule (SPEC §5), one rule for every vendor +/// counter this crate reads: a counter's count is a hardware reading only while +/// the counter is live, and live means **one of its rails kept advancing** over +/// a probe — in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, in at +/// least 80% of the intervals observed, over at least [`MIN_LIVE_WINDOW`] of +/// wall time. A rail is one cumulative count within a sample: a RAPL package's +/// `energy_uj`, NVML's total-energy counter, an IOReport `CPU Energy` channel. +/// +/// Kept apart from every counter's FFI, so the rule, its loop and its stopping +/// rule run on scripted samples and a scripted clock in tests, and so the RAPL, +/// NVML and IOReport rungs apply this one rule rather than copies of it. What a +/// rung hands the rule is its own business: IOReport picks its CPU energy rails +/// out of an Energy Model sample; RAPL hands every package counter; NVML its +/// one device counter. +/// +/// The thresholds were set against the batch refreshes an unprivileged reader +/// sees on IOReport's CPU rails (see `ioreport::RailWatch`): a count republished +/// in one step, two, or a few, is not a live count, whichever counter it comes +/// from. +#[allow(unsafe_code)] +mod liveness { + use std::os::raw::{c_int, c_long}; + use std::time::{Duration, Instant}; + + use crate::CounterStatus; + + /// CPU time the probing thread burns, by its own CPU clock + /// ([`thread_cpu_time`]), before each liveness re-sample. Thread CPU time, + /// not wall time: on a loaded host a wall-clock spin of 25 ms can get a + /// few milliseconds of CPU, and the rail would be watched while barely + /// loaded. + pub(crate) const PROBE_TICK: Duration = Duration::from_millis(25); + + /// The most wall time one tick may take. A thread that cannot get + /// [`PROBE_TICK`] of CPU in this long (under 5% of a core) stops its tick + /// here, so a starved probe still ends. + pub(crate) const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// In how many separate sample intervals the same rail must advance + /// before it counts as live. Two is not enough: see `ioreport::RailWatch`. + /// Also the fewest intervals the probe observes before it may call a rail + /// frozen. + pub(crate) const MIN_LIVE_ADVANCES: u32 = 5; + + /// The shortest observation window, by wall clock from the first usable + /// sample, over which a rail may be called live. The batch refreshes seen + /// were published within 64 ms, so the steps of one cannot fill this + /// window, nor could those of one spread over twice as long. + pub(crate) const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300); + + /// Has a rail that advanced in `advancing` of `intervals` intervals + /// advanced in at least 80% of them? Integer arithmetic, so the boundary is + /// exact: 4 of 5 is enough, 3 of 4 is not. + pub(crate) fn sustained(advancing: u32, intervals: u32) -> bool { + u64::from(advancing) * 5 >= u64::from(intervals) * 4 + } + + /// However slow a counter is, or however many of its samples fail, the + /// probe stops after this many budgets. + pub(crate) const PROBE_BUDGET_CAP: u32 = 5; + + /// The probe budget of the counter rungs (RAPL, NVML): one second, as + /// IOReport's `CPU_RAIL_LIVENESS_BUDGET`. A live counter is called live + /// about 300 ms in (five intervals in, on a loaded host); the budget only + /// bounds how long a frozen one is watched. Paid once per process. + pub(crate) const BUDGET: Duration = Duration::from_millis(1000); + + /// A rail within one sample: its name, and its ordinal among the rails of + /// that name in the same sample. Names are not unique on every counter + /// (an M3 Ultra's Energy Model group has 264 duplicated names), and keyed + /// by name alone, two same-named rails stepping together in one refresh + /// would count as two advances of one rail. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct RailKey { + pub(crate) name: String, + pub(crate) ordinal: u32, + } + + /// The channels of one sample whose names `pick` accepts, each keyed by + /// [`RailKey`], with its absolute raw value. Item by item: no arithmetic + /// across channels. + pub(crate) fn rails_where(sample: &[(String, i64)], pick: fn(&str) -> bool) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in sample { + if pick(name) { + let ordinal = rails.iter().filter(|(k, _)| k.name == *name).count() as u32; + rails.push((RailKey { name: name.clone(), ordinal }, *raw)); + } + } + rails + } + + /// Every channel of one sample as a rail: for a counter whose every + /// channel is a count of the domain it claims (RAPL's packages, NVML's one + /// counter). + pub(crate) fn every_rail(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + rails_where(sample, |_| true) + } + + /// The liveness rule over plain samples of rails, kept apart from the FFI + /// so all of it, including the pairing of each sample with the previous + /// one, runs on scripted samples in tests. + /// + /// A counter is live only when the **same** rail keeps advancing: it + /// advanced in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, + /// those are at least 80% of all the intervals observed so far + /// ([`sustained`]), and the window observed so far spans at least + /// [`MIN_LIVE_WINDOW`] of wall time from the first usable sample. The three + /// are checked after each interval, and the rail is live from the first + /// interval at which all three hold. + /// + /// Each condition closes a way for a refresh to pass. Five advances: a + /// refresh is published in one step, two, or a few. 80% of the intervals: the + /// steps of a refresh sit together, and are a small share of any window + /// that also holds the flat intervals around them. 300 ms: a refresh + /// published in five quick steps at the very start of a window is 100% of a + /// 155 ms window, but not of a 300 ms one. A live rail advances in nearly + /// every interval, so it meets all three about 300 ms in, or after five + /// intervals on a host loaded enough that five take longer. Counting per + /// rail keeps a step on each of several rails from adding up to a false + /// Live. + /// + /// Each interval is a sample and the one **before** it. Compared with the + /// first sample instead, a rail that stepped once would show a rise in + /// every interval after the step, and one refresh would read as live. + #[derive(Debug, Default, Clone)] + pub(crate) struct Advances { + /// Sample intervals observed so far: pairs of consecutive usable samples. + pub(crate) intervals: u32, + /// The previous usable sample's rails, which the next one is compared + /// with. + prev: Option>, + /// Every rail seen, with the number of intervals in which it advanced. + pub(crate) rails: Vec<(RailKey, u32)>, + /// When the first usable sample was taken, by the probe's clock: where + /// the window starts. + first_at: Option, + /// How much wall time the window spans so far: from the first usable + /// sample to the latest. + pub(crate) window: Duration, + /// Set at the first interval after which one rail met all three + /// conditions, and never unset. + live: bool, + } + + impl Advances { + /// Fold in the next usable sample's rails, taken at `at` by the probe's + /// wall clock. The first only sets the baseline, and starts the window. + /// Each later one closes an interval with the sample before it, and a + /// rail advanced over that interval when its raw value rose. A rail is + /// counted at most once per interval. A rail whose value FELL did not + /// advance: a cumulative counter that goes down was reset or wrapped, + /// and that is not energy spent. A rail missing from the previous + /// sample has nothing to be compared with, so this sample only sets its + /// baseline. After each interval, the rule is checked. + pub(crate) fn observe(&mut self, cur: Vec<(RailKey, i64)>, at: Duration) { + for (key, _) in &cur { + if !self.rails.iter().any(|(k, _)| k == key) { + self.rails.push((key.clone(), 0)); + } + } + let first_at = *self.first_at.get_or_insert(at); + self.window = at.saturating_sub(first_at); + if let Some(prev) = &self.prev { + self.intervals += 1; + for (key, now) in &cur { + let rose = prev.iter().any(|(k, before)| k == key && now > before); + if let Some((_, count)) = self.rails.iter_mut().find(|(k, _)| k == key) { + *count += u32::from(rose); + } + } + let intervals = self.intervals; + self.live |= self.window >= MIN_LIVE_WINDOW + && self + .rails + .iter() + .any(|&(_, advancing)| advancing >= MIN_LIVE_ADVANCES && sustained(advancing, intervals)); + } + self.prev = Some(cur); + } + + /// Has one rail kept advancing: enough advances, a high enough share + /// of the intervals, over a long enough window? + pub(crate) fn is_live(&self) -> bool { + self.live + } + + /// Keep sampling? Until the rail proves live, or until `budget` has + /// elapsed **and** at least [`MIN_LIVE_ADVANCES`] intervals were + /// observed, so a live rail could have shown that many advances (slow + /// samples must not make a live rail look frozen) — and never past + /// [`PROBE_BUDGET_CAP`] budgets. + pub(crate) fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { + !self.is_live() + && elapsed < budget * PROBE_BUDGET_CAP + && (elapsed < budget || self.intervals < MIN_LIVE_ADVANCES) + } + } + + /// The rule's loop, generic over where samples come from, what happens + /// between them and the clock, so tests drive it with scripted samples and + /// a scripted clock exactly as the probes drive it. Take a sample and read + /// the clock; hand the rule the sample with that reading; stop when + /// [`Advances::wants_more`] says so; otherwise `tick` and sample again. A + /// sample that fails is skipped, so the next usable one is compared with + /// the last usable one. `elapsed` is the wall time since the probe started. + pub(crate) fn watch( + mut sample: impl FnMut() -> Option>, + mut tick: impl FnMut(), + elapsed: impl Fn() -> Duration, + budget: Duration, + ) -> Advances { + let mut advances = Advances::default(); + loop { + let got = sample(); + let now = elapsed(); + if let Some(rails) = got { + advances.observe(rails, now); + } + if !advances.wants_more(now, budget) { + return advances; + } + tick(); + } + } + + /// One liveness probe of a counter rung: `open` the counter; if it opens, + /// [`watch`] its samples, `tick` between them, by the clock `now`, for + /// `budget`. `None` when it does not open: then nothing is sampled and + /// nothing ticks. Generic over each part, so the tests drive this very + /// function with a scripted counter, tick and clock; the rungs' cached + /// probes pass the real ones. + pub(crate) fn probe( + open: impl FnOnce() -> Option, + sample: impl Fn(&C) -> Option>, + tick: impl FnMut(), + now: impl Fn() -> Instant, + budget: Duration, + ) -> Option { + let started = now(); + let counter = open()?; + Some(watch(|| sample(&counter), tick, || now().saturating_duration_since(started), budget)) + } + + /// The status a counter probe reports. No probe (the counter did not + /// open) and no interval observed (no two usable samples in five budgets) + /// are both `Unavailable`, never `Live`. + pub(crate) fn status_of(probed: Option<&Advances>) -> CounterStatus { + match probed { + None => CounterStatus::Unavailable, + Some(a) if a.intervals == 0 => CounterStatus::Unavailable, + Some(a) if a.is_live() => CounterStatus::Live, + Some(_) => CounterStatus::Frozen, + } + } + + /// `struct timespec` on 64-bit macOS and Linux. + #[repr(C)] + struct Timespec { + tv_sec: c_long, + tv_nsec: c_long, + } + + /// `CLOCK_THREAD_CPUTIME_ID`: 16 in macOS ``, 3 in Linux + /// ``. + #[cfg(target_os = "macos")] + const CLOCK_THREAD_CPUTIME_ID: c_int = 16; + #[cfg(any(target_os = "linux", target_os = "android"))] + const CLOCK_THREAD_CPUTIME_ID: c_int = 3; + + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + unsafe extern "C" { + fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; + } + + /// CPU time this thread has used, user and system, by + /// `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`. `None` if the clock cannot be + /// read, and on a platform without that clock. The one thread clock of the + /// probes: [`burn`] and the IOReport probe's clocks both read it. + pub(crate) fn thread_cpu_time() -> Option { + #[cfg(any(target_os = "macos", target_os = "linux", target_os = "android"))] + { + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + (rc == 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)) + } + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "android")))] + { + let _ = Timespec { tv_sec: 0, tv_nsec: 0 }; + None + } + } + + /// Spin this thread until it has used `cpu` of CPU time by its own CPU + /// clock, or until `wall_cap` of wall time has passed, whichever comes + /// first. However loaded the host, the thread has then burned `cpu`, unless + /// it got under `cpu / wall_cap` of a core. With no readable thread clock + /// only the wall cap ends the spin: slower, never shorter. + pub(crate) fn burn(cpu: Duration, wall_cap: Duration) { + let wall = Instant::now(); + let start = thread_cpu_time(); + let mut acc: u64 = 0; + loop { + for _ in 0..1_000 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + let used = match (start, thread_cpu_time()) { + (Some(a), Some(b)) => b.saturating_sub(a), + _ => Duration::ZERO, + }; + if used >= cpu || wall.elapsed() >= wall_cap { + return; + } + } + } + + /// The real probes' tick for a CPU-energy counter: spin until this thread + /// has burned [`PROBE_TICK`] of its own CPU time, or + /// [`PROBE_TICK_WALL_CAP`] of wall time has passed. + pub(crate) fn burn_tick() { + burn(PROBE_TICK, PROBE_TICK_WALL_CAP); + } +} + +/// The liveness status of a single vendor energy counter rung — RAPL's +/// package counters, NVML's total-energy counter — as its probe found it on +/// this host. Probed once per process and cached; see [`rapl_status`] and +/// [`nvml_status`]. Only [`CounterStatus::Live`] admits the rung (SPEC §5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CounterStatus { + /// The counter could not be opened or read — absent (not this platform, + /// no such device, the feature not built), unreadable (a locked-down + /// sysfs), or it gave fewer than two usable samples in five budgets. Never + /// selected. + Unavailable, + /// The counter opened, but no rail of it kept advancing while the probe + /// ran: it held, or stepped a few times together — republished in lumps, + /// not a live count. Never selected: a lump landing in one span would be + /// graded a hardware reading of that span's work. + Frozen, + /// One rail of the counter kept advancing while the probe ran: in at least + /// five separate sample intervals, making up at least 80% of those + /// observed, over at least 300 ms. The rung is selected. + Live, +} + +/// Whether a counter's liveness status admits its rung: [`CounterStatus::Live`] +/// and nothing else. The one gate the RAPL and NVML rungs go through, pure so +/// that every status is checked in a test. +fn admits_counter(status: CounterStatus) -> bool { + matches!(status, CounterStatus::Live) +} + +/// A rung's counter, opened only when its status admits the rung: `open` is +/// not even called otherwise. What [`Meter::detect`] selects a counter rung +/// with. +fn select(status: CounterStatus, open: impl FnOnce() -> Option) -> Option { + if admits_counter(status) { + open() + } else { + None + } +} + +/// How many times the RAPL liveness probe has run in this process: the first +/// thing [`rapl_host_status`] does. [`rapl_status`] caches its answer, so this +/// never passes one; the fresh-process pin counts it. +static RAPL_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// [`RAPL_PROBE_RUNS`]. +#[cfg_attr(not(test), allow(dead_code))] +fn rapl_probe_runs() -> usize { + RAPL_PROBE_RUNS.load(std::sync::atomic::Ordering::SeqCst) +} + +/// This host's RAPL status, probed once per process and cached. +/// +/// [`Meter::detect`] selects the RAPL rung only when this is +/// [`CounterStatus::Live`]: one package counter (`intel-rapl:N/energy_uj`) kept +/// advancing while the probing thread burned CPU, in at least five sample +/// intervals, at least 80% of them, over at least 300 ms — the rule IOReport's +/// CPU rail is held to. A counter that holds, or is republished in lumps (a +/// virtualised powercap tree can be either), is declined to the time×power +/// model. Where there is no readable powercap tree — not Linux, no RAPL, or an +/// `energy_uj` only root may read — it is [`CounterStatus::Unavailable`] +/// without burning anything, as RAPL was declined before. The first call on a +/// host with a readable tree burns about 300 ms of the calling thread's CPU +/// (one second if the counter is not live). +pub fn rapl_status() -> CounterStatus { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(rapl_host_status) +} + +/// The real RAPL probe: the real opener, sampler and tick. Counts itself, +/// then composes; its only caller is the cache in [`rapl_status`]. +fn rapl_host_status() -> CounterStatus { + RAPL_PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + rapl_status_on(Rapl::open) +} + +/// The RAPL probe on a given opener: [`liveness::probe`] with RAPL's sampler, +/// the CPU-burning tick and the real clock, for one budget. The tests run it on +/// fake powercap trees. +fn rapl_status_on(open: impl FnOnce() -> Option) -> CounterStatus { + liveness::status_of( + liveness::probe(open, Rapl::liveness_sample, liveness::burn_tick, Instant::now, liveness::BUDGET).as_ref(), + ) +} + +/// How many times the NVML liveness probe has run in this process. Always 0 +/// without the `nvml` feature, which has no probe. +#[cfg_attr(not(all(test, feature = "nvml")), allow(dead_code))] +fn nvml_probe_runs() -> usize { + #[cfg(feature = "nvml")] + { + NVML_PROBE_RUNS.load(std::sync::atomic::Ordering::SeqCst) + } + #[cfg(not(feature = "nvml"))] + { + 0 + } +} + +#[cfg(feature = "nvml")] +static NVML_PROBE_RUNS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How long the NVML probe waits between samples. NVML's counter is the GPU's, +/// and burning this thread's CPU does not load the GPU, so the probe does not +/// burn: it waits, and a live counter advances on the device's own draw (a +/// powered GPU never draws zero). The wait is longer than the CPU probes' tick +/// so that a counter the driver refreshes up to every 100 ms still advances in +/// every interval; one refreshed less often fails the 80% share and is +/// declined — estimated, never over-claimed. +#[cfg(feature = "nvml")] +const NVML_PROBE_WAIT: std::time::Duration = std::time::Duration::from_millis(100); + +/// This host's NVML status, probed once per process and cached. Always +/// [`CounterStatus::Unavailable`] without the `nvml` feature. +/// +/// [`Meter::detect`] selects the NVML rung only when this is +/// [`CounterStatus::Live`]: device 0's total-energy counter advanced in at +/// least five sample intervals, at least 80% of them, over at least 300 ms. The +/// probe has no way to load the GPU, so this shows the counter is published +/// continuously, not that it tracks a load; a counter that holds or steps in +/// lumps is declined. The first call on a host where NVML opens waits about +/// half a second (one second if the counter is not live). +pub fn nvml_status() -> CounterStatus { + #[cfg(feature = "nvml")] + { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(|| { + NVML_PROBE_RUNS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + liveness::status_of( + liveness::probe( + Nvml::open, + Nvml::liveness_sample, + || std::thread::sleep(NVML_PROBE_WAIT), + Instant::now, + liveness::BUDGET, + ) + .as_ref(), + ) + }) + } + #[cfg(not(feature = "nvml"))] + { + CounterStatus::Unavailable + } +} + /// Apple Silicon IOReport energy reader — a real, no-sudo, no-external-binary /// reading of the SoC Energy Model channels through Apple's private IOReport C /// API. Like RAPL and NVML it is a vendor energy counter: how Apple derives the @@ -160,6 +676,7 @@ mod ioreport { use std::sync::Mutex; use std::time::{Duration, Instant}; + use crate::liveness::{self, rails_where, thread_cpu_time, Advances, RailKey}; use crate::{IoReportReading, IoReportStatus}; type CFTypeRef = *const c_void; @@ -350,45 +867,13 @@ mod ioreport { // failing, with scripted samples, and against the hardware. // - `probe_with()` is the loop and its record, generic over opener, // sampler, tick and clocks. The tests drive it with fakes. - // - `watch()` and `RailWatch` are the rule, on scripted samples. + // - `watch()` and `RailWatch` are the rule (the crate's shared + // `liveness` rule, fed the CPU rails), on scripted samples. // `status_of()` is a pure mapping. // - The real parts: `EnergyModelSubscription` (opener and FFI read), checked // against an independent oracle, structurally and value by value; - // `burn_tick()` and `HostClocks`, checked on the tests' own clocks. - - /// CPU time the probing thread burns, by its own CPU clock - /// ([`thread_cpu_time`]), before each liveness re-sample. Thread CPU time, - /// not wall time: on a loaded host a wall-clock spin of 25 ms can get a - /// few milliseconds of CPU, and the rail would be watched while barely - /// loaded. - const PROBE_TICK: Duration = Duration::from_millis(25); - - /// The most wall time one tick may take. A thread that cannot get - /// [`PROBE_TICK`] of CPU in this long (under 5% of a core) stops its tick - /// here, so a starved probe still ends. - const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); - - /// In how many separate sample intervals the same CPU rail must advance - /// before it counts as live. Two is not enough: see [`RailWatch`]. Also the - /// fewest intervals the probe observes before it may call a rail frozen. - const MIN_LIVE_ADVANCES: u32 = 5; - - /// The shortest observation window, by wall clock from the first usable - /// sample, over which a rail may be called live. The batch refreshes seen - /// were published within 64 ms, so the steps of one cannot fill this - /// window, nor could those of one spread over twice as long. - const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300); - - /// Has a rail that advanced in `advancing` of `intervals` intervals - /// advanced in at least 80% of them? Integer arithmetic, so the boundary is - /// exact: 4 of 5 is enough, 3 of 4 is not. - fn sustained(advancing: u32, intervals: u32) -> bool { - u64::from(advancing) * 5 >= u64::from(intervals) * 4 - } - - /// However slow IOReport is, or however many of its samples fail, the probe - /// stops after this many budgets. - const PROBE_BUDGET_CAP: u32 = 5; + // `liveness::burn_tick()` and `HostClocks`, checked on the tests' own + // clocks. /// How many times the real probe, [`host_status`], has run in this process. /// It is the first thing `host_status` does. [`crate::ioreport_status`] @@ -415,9 +900,9 @@ mod ioreport { #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CpuRail { /// The same CPU energy rail kept advancing while the probing thread - /// burned CPU: in at least [`MIN_LIVE_ADVANCES`] separate sample + /// burned CPU: in at least [`liveness::MIN_LIVE_ADVANCES`] separate sample /// intervals, in at least 80% of the intervals observed, over at least - /// [`MIN_LIVE_WINDOW`]. + /// [`liveness::MIN_LIVE_WINDOW`]. Live, /// A CPU energy rail exists, but none kept advancing: it held, or moved /// in a few steps (a batch refresh). @@ -432,33 +917,6 @@ mod ioreport { /// sample with the one before it. type Sample = Vec<(String, i64)>; - /// A rail within one sample: its channel name, and its ordinal among the - /// channels of that name in the same sample. Names are not unique: an M3 - /// Ultra's Energy Model group has 645 channels and 264 duplicated names - /// (`ECPUDTL00`..`22` once per die, with no `DIE_` prefix). The CPU - /// aggregates are unique today on both chips, but keyed by name alone, two - /// same-named rails stepping together in one refresh would count as two - /// advances of one rail. - #[derive(Debug, Clone, PartialEq, Eq)] - struct RailKey { - name: String, - ordinal: u32, - } - - /// The channels of one sample whose names `pick` accepts, each keyed by - /// [`RailKey`], with its absolute raw value. Item by item: no arithmetic - /// across channels. - fn rails_where(sample: &[(String, i64)], pick: fn(&str) -> bool) -> Vec<(RailKey, i64)> { - let mut rails: Vec<(RailKey, i64)> = Vec::new(); - for (name, raw) in sample { - if pick(name) { - let ordinal = rails.iter().filter(|(k, _)| k.name == *name).count() as u32; - rails.push((RailKey { name: name.clone(), ordinal }, *raw)); - } - } - rails - } - /// The CPU energy rails in one sample, each with its absolute raw value. fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { rails_where(sample, is_cpu_energy_rail) @@ -471,20 +929,15 @@ mod ioreport { rails_where(sample, is_gpu_energy_rail) } - /// The liveness rule over plain samples, kept apart from the FFI so all of - /// it, including the pairing of each sample with the previous one, runs on - /// scripted samples in tests. + /// The shared liveness rule ([`liveness::Advances`]) fed IOReport's CPU + /// energy rails: [`RailWatch::observe`] picks the CPU rails out of an + /// Energy Model sample and hands the rule those alone, so the GPU rail, + /// which rises wherever the GPU draws, is never counted. It dereferences + /// to the rule's state, which is all it holds. /// - /// A rail is live only when the **same** CPU rail keeps advancing: it - /// advanced in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, - /// those are at least 80% of all the intervals observed so far - /// ([`sustained`]), and the window observed so far spans at least - /// [`MIN_LIVE_WINDOW`] of wall time from the first usable sample. The three - /// are checked after each interval, and the rail is live from the first - /// interval at which all three hold. - /// - /// Because on macOS 27.0.1 an unprivileged reader's CPU rails are not - /// permanently frozen: they are refreshed in rare batches, ~30 minutes + /// Why the rule asks for five advances in 80% of the intervals over + /// 300 ms: on macOS 27.0.1 an unprivileged reader's CPU rails are not + /// permanently frozen, they are refreshed in rare batches, ~30 minutes /// apart. On an M3 Ultra both dies' rails held for at least 22 minutes, /// stepped +1,344,869 and +1,391,637 mJ in one 100 ms sample, then held /// again; an M5 Max's `CPU Energy` did not change in 13,614 samples over 25 @@ -497,94 +950,27 @@ mod ioreport { /// window holding that refresh sees two advancing intervals. Two advances /// of one rail would call that Live, and a Live is cached for the process: /// the rung is selected, and every later ~1.3 kJ refresh is attributed - /// whole to whichever metered span straddles it. - /// - /// Each condition closes a way for a refresh to pass. Five advances: a - /// refresh is published in one step, two, or a few. 80% of the intervals: the - /// steps of a refresh sit together, and are a small share of any window - /// that also holds the flat intervals around them. 300 ms: a refresh - /// published in five quick steps at the very start of a window is 100% of a - /// 155 ms window, but not of a 300 ms one. A live rail advances in nearly - /// every interval (the M5 Max GPU rail changes every ~28 ms), so it meets - /// all three about 300 ms in, or after five intervals on a host loaded - /// enough that five take longer. Counting per rail keeps a step on each die - /// from adding up to a false Live. - /// - /// Each interval is a sample and the one **before** it. Compared with the - /// first sample instead, a rail that stepped once would show a rise in - /// every interval after the step, and one refresh would read as live. + /// whole to whichever metered span straddles it. A live rail advances in + /// nearly every interval (the M5 Max GPU rail changes every ~28 ms), so it + /// meets the rule about 300 ms in. #[derive(Debug, Default, Clone)] - struct RailWatch { - /// Sample intervals observed so far: pairs of consecutive usable samples. - intervals: u32, - /// The previous usable sample's CPU rails, which the next one is - /// compared with. - prev: Option>, - /// Every CPU rail seen, with the number of intervals in which it advanced. - rails: Vec<(RailKey, u32)>, - /// When the first usable sample was taken, by the probe's clock: where - /// the window starts. - first_at: Option, - /// How much wall time the window spans so far: from the first usable - /// sample to the latest. - window: Duration, - /// Set at the first interval after which one rail met all three - /// conditions, and never unset. - live: bool, + struct RailWatch(Advances); + + impl std::ops::Deref for RailWatch { + type Target = Advances; + + fn deref(&self) -> &Advances { + &self.0 + } } impl RailWatch { - /// Fold in the next usable sample, taken at `at` by the probe's wall - /// clock. The first only sets the baseline, and starts the window. - /// Each later one closes an interval with the sample before it, and a - /// rail advanced over that interval when its raw value rose. A rail is - /// counted at most once per interval. A rail whose value FELL did not - /// advance: a cumulative counter that goes down was reset or wrapped, - /// and that is not energy spent. A rail missing from the previous - /// sample has nothing to be compared with, so this sample only sets its - /// baseline. After each interval, the rule is checked. + /// Fold in the next usable Energy Model sample, taken at `at` by the + /// probe's wall clock: its CPU energy rails ([`cpu_rails`]), and + /// nothing else, go to the rule ([`Advances::observe`]). + #[cfg_attr(not(test), allow(dead_code))] fn observe(&mut self, sample: &[(String, i64)], at: Duration) { - let cur = cpu_rails(sample); - for (key, _) in &cur { - if !self.rails.iter().any(|(k, _)| k == key) { - self.rails.push((key.clone(), 0)); - } - } - let first_at = *self.first_at.get_or_insert(at); - self.window = at.saturating_sub(first_at); - if let Some(prev) = &self.prev { - self.intervals += 1; - for (key, now) in &cur { - let rose = prev.iter().any(|(k, before)| k == key && now > before); - if let Some((_, count)) = self.rails.iter_mut().find(|(k, _)| k == key) { - *count += u32::from(rose); - } - } - let intervals = self.intervals; - self.live |= self.window >= MIN_LIVE_WINDOW - && self - .rails - .iter() - .any(|&(_, advancing)| advancing >= MIN_LIVE_ADVANCES && sustained(advancing, intervals)); - } - self.prev = Some(cur); - } - - /// Has one CPU rail kept advancing: enough advances, a high enough - /// share of the intervals, over a long enough window? - fn is_live(&self) -> bool { - self.live - } - - /// Keep sampling? Until the rail proves live, or until `budget` has - /// elapsed **and** at least [`MIN_LIVE_ADVANCES`] intervals were - /// observed, so a live rail could have shown that many advances (slow - /// IOReport samples must not make a live rail look frozen) — and never - /// past [`PROBE_BUDGET_CAP`] budgets. - fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { - !self.is_live() - && elapsed < budget * PROBE_BUDGET_CAP - && (elapsed < budget || self.intervals < MIN_LIVE_ADVANCES) + self.0.observe(cpu_rails(sample), at); } /// The verdict, or `None` if not a single interval could be observed. @@ -601,31 +987,19 @@ mod ioreport { } } - /// The rule's loop, generic over where samples come from, what happens - /// between them and the clock, so tests drive it with scripted samples and - /// a scripted clock exactly as [`probe_with`] drives it. Take a sample and - /// read the clock; hand the rule the sample with that reading; stop when - /// [`RailWatch::wants_more`] says so; otherwise `tick` and sample again. A + /// The shared rule's loop ([`liveness::watch`]) on Energy Model samples, + /// each handed over as its CPU rails: take a sample and read the clock; + /// hand the rule the sample with that reading; stop when + /// [`Advances::wants_more`] says so; otherwise `tick` and sample again. A /// sample that fails is skipped, so the next usable one is compared with /// the last usable one. `elapsed` is the wall time since the probe started. fn watch( mut sample: impl FnMut() -> Option, - mut tick: impl FnMut(), + tick: impl FnMut(), elapsed: impl Fn() -> Duration, budget: Duration, ) -> RailWatch { - let mut rail_watch = RailWatch::default(); - loop { - let got = sample(); - let now = elapsed(); - if let Some(s) = got { - rail_watch.observe(&s, now); - } - if !rail_watch.wants_more(now, budget) { - return rail_watch; - } - tick(); - } + RailWatch(liveness::watch(|| sample().map(|s| cpu_rails(&s)), tick, elapsed, budget)) } /// One channel of one sample, as the FFI reads it, and nothing more: its @@ -961,14 +1335,9 @@ mod ioreport { } } - /// The real probe's tick: spin until this thread has burned [`PROBE_TICK`] - /// of its own CPU time, or [`PROBE_TICK_WALL_CAP`] of wall time has passed. - fn burn_tick() { - burn(PROBE_TICK, PROBE_TICK_WALL_CAP); - } - /// This host's liveness status: [`probe_with`] on the real opener - /// ([`EnergyModelSubscription::open`]), the real tick ([`burn_tick`]) and + /// ([`EnergyModelSubscription::open`]), the real tick + /// ([`liveness::burn_tick`]) and /// the real clocks ([`HostClocks`]) for /// [`crate::CPU_RAIL_LIVENESS_BUDGET`], mapped by [`status_of`]. It counts /// itself, then composes, and has no branch of its own: every decision is @@ -979,7 +1348,12 @@ mod ioreport { pub(crate) fn host_status() -> IoReportStatus { HOST_PROBE_RUNS.fetch_add(1, Ordering::SeqCst); status_of( - probe_with(EnergyModelSubscription::open, burn_tick, &HostClocks, crate::CPU_RAIL_LIVENESS_BUDGET) + probe_with( + EnergyModelSubscription::open, + liveness::burn_tick, + &HostClocks, + crate::CPU_RAIL_LIVENESS_BUDGET, + ) .verdict(), ) } @@ -996,53 +1370,6 @@ mod ioreport { strip_die_prefix(name).eq_ignore_ascii_case("GPU Energy") } - /// `struct timespec` on 64-bit macOS. - #[repr(C)] - struct Timespec { - tv_sec: c_long, - tv_nsec: c_long, - } - - /// macOS ``: `_CLOCK_THREAD_CPUTIME_ID`. - const CLOCK_THREAD_CPUTIME_ID: c_int = 16; - - unsafe extern "C" { - fn clock_gettime(clock_id: c_int, tp: *mut Timespec) -> c_int; - } - - /// CPU time this thread has used, user and system, by - /// `clock_gettime(CLOCK_THREAD_CPUTIME_ID)`. `None` if the clock cannot be - /// read. The one thread clock of the probe: [`burn`] and [`HostClocks`] - /// both read it. - fn thread_cpu_time() -> Option { - let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; - let rc = unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) }; - (rc == 0).then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)) - } - - /// Spin this thread until it has used `cpu` of CPU time by its own CPU - /// clock, or until `wall_cap` of wall time has passed, whichever comes - /// first. However loaded the host, the thread has then burned `cpu`, unless - /// it got under `cpu / wall_cap` of a core. With no readable thread clock - /// only the wall cap ends the spin: slower, never shorter. - fn burn(cpu: Duration, wall_cap: Duration) { - let wall = Instant::now(); - let start = thread_cpu_time(); - let mut acc: u64 = 0; - loop { - for _ in 0..1_000 { - acc = std::hint::black_box(acc.wrapping_add(1)); - } - let used = match (start, thread_cpu_time()) { - (Some(a), Some(b)) => b.saturating_sub(a), - _ => Duration::ZERO, - }; - if used >= cpu || wall.elapsed() >= wall_cap { - return; - } - } - } - /// Over one delta sample: the energy of every Energy Model rail the /// counter sums ([`counts_once`]), and of the CPU energy rails alone /// ([`is_cpu_energy_rail`], a share of the first), in µJ. @@ -1167,6 +1494,7 @@ mod ioreport { #[cfg(test)] mod tests { use super::*; + use crate::liveness::{PROBE_BUDGET_CAP, PROBE_TICK}; use std::cell::{Cell, RefCell}; use std::rc::Rc; use std::sync::atomic::AtomicBool; @@ -3871,9 +4199,17 @@ pub fn ioreport_energy_model_opens() -> usize { /// A metering backend, in ladder order. #[derive(Debug)] pub enum Meter { - /// Hardware CPU package energy → `Provenance::HwShunt`. + /// Hardware CPU package energy → `Provenance::HwShunt`. [`Meter::detect`] + /// selects it only while a package counter is live ([`rapl_status`]), and + /// a span over which no package counter advanced is the time×power model's + /// figure. Rapl(Rapl), - /// Hardware GPU energy → `Provenance::HwShunt`. + /// Hardware GPU energy → `Provenance::HwShunt`, `Coverage::Partial`: the + /// counter reads the GPU, and the CPU's share of the work goes unmetered, + /// so a figure from it never claims the whole task (SPEC §5). + /// [`Meter::detect`] selects it only while the counter is live + /// ([`nvml_status`]), and a span over which it did not advance is the + /// time×power model's whole-task figure. #[cfg(feature = "nvml")] Nvml(Box), /// Apple Silicon IOReport Energy Model energy → `Provenance::HwShunt`, the @@ -3888,38 +4224,63 @@ pub enum Meter { Const { per_op_uj: u64 }, } +/// Where [`Meter::detect`] gets each rung's liveness status, and the RAPL +/// counter it opens: the cached host probes ([`Ladder::host`]), or in tests +/// any status and a fake counter, so every gate is driven with every status on +/// a host whose own statuses are one value each. +struct Ladder<'a> { + rapl: &'a dyn Fn() -> CounterStatus, + open_rapl: &'a dyn Fn() -> Option, + nvml: &'a dyn Fn() -> CounterStatus, + ioreport: &'a dyn Fn() -> IoReportStatus, +} + +impl Ladder<'static> { + /// The ladder [`Meter::detect`] runs: the cached host probes and the real + /// RAPL opener. + fn host() -> Self { + Ladder { rapl: &rapl_status, open_rapl: &Rapl::open, nvml: &nvml_status, ioreport: &ioreport_status } + } +} + impl Meter { /// Pick the best backend this environment exposes. Never fails: when no /// hardware interface exists the result is the time×power model — there is /// always an estimate, it just says so in its provenance. + /// + /// A vendor counter rung is selected only while its counter is live (SPEC + /// §5): RAPL on [`rapl_status`], NVML on [`nvml_status`], IOReport on + /// [`ioreport_status`], each probed once per process. Each status is + /// asked for only once every rung above it is declined, so a process pays + /// for no probe below the rung it gets. pub fn detect() -> Self { - Self::detect_with(ioreport_status) + Self::detect_with(&Ladder::host()) } - /// [`Meter::detect`] with the IOReport status supplied, so the gate can be - /// driven with every status on a host whose own status is one value. The - /// status is asked for only once RAPL and NVML are absent: the first ask - /// in a process runs the liveness probe. It selects the rung only when - /// [`admits`] does. - fn detect_with(ioreport_status: impl FnOnce() -> IoReportStatus) -> Self { - if let Some(r) = Rapl::open() { + /// [`Meter::detect`] on a given ladder. Each counter rung is opened only + /// when its status admits it ([`select`], [`admits`]). + fn detect_with(ladder: &Ladder<'_>) -> Self { + // Opening a counter is not enough: it must be live (SPEC §5), or a + // count republished in lumps landing in one span is graded a hardware + // reading of that span's work. + if let Some(r) = select((ladder.rapl)(), ladder.open_rapl) { return Meter::Rapl(r); } + let nvml = (ladder.nvml)(); #[cfg(feature = "nvml")] - if let Some(n) = Nvml::open() { + if let Some(n) = select(nvml, Nvml::open) { return Meter::Nvml(Box::new(n)); } - // Opening a subscription is not enough: the CPU rail must be live - // (SPEC §5), or a whole batch refresh landing in one span is graded a - // hardware reading of that span's CPU work. + #[cfg(not(feature = "nvml"))] + let _ = nvml; #[cfg(target_os = "macos")] - if admits(ioreport_status()) { + if admits((ladder.ioreport)()) { if let Some(io) = ioreport::IoReport::open() { return Meter::IoReport(io); } } #[cfg(not(target_os = "macos"))] - let _ = ioreport_status; + let _ = ladder.ioreport; Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS } } @@ -3957,12 +4318,13 @@ impl Meter { let started = Instant::now(); let out = work(); let elapsed = started.elapsed(); - let energy = match (before, r.read()) { - (Some(b), Some(a)) => Energy::measured(r.delta(&b, &a)), - // the interface vanished mid-span — degrade honestly - _ => Energy::modeled(time_power_uj(elapsed.as_nanos(), DEFAULT_ACTIVE_MILLIWATTS)), + // A read that failed (the interface vanished mid-span) is no + // rise, and degrades honestly. + let rose = match (before, r.read()) { + (Some(b), Some(a)) => Some(r.delta(&b, &a)), + _ => None, }; - (out, energy) + (out, counter_span(rose, elapsed.as_nanos(), Energy::measured)) } #[cfg(feature = "nvml")] Meter::Nvml(n) => { @@ -3970,11 +4332,7 @@ impl Meter { let started = Instant::now(); let out = work(); let elapsed = started.elapsed(); - let energy = match (before, n.read_uj()) { - (Some(b), Some(a)) => Energy::measured(a.saturating_sub(b)), - _ => Energy::modeled(time_power_uj(elapsed.as_nanos(), DEFAULT_ACTIVE_MILLIWATTS)), - }; - (out, energy) + (out, nvml_span(before, n.read_uj(), elapsed.as_nanos())) } #[cfg(target_os = "macos")] Meter::IoReport(io) => { @@ -4025,6 +4383,38 @@ fn ioreport_span(before: Option, after: Option } } +/// The figure a counter rung gives one span, from how much its counter rose +/// across it: `reading(rise)` only when the counter advanced. A counter that +/// was live at selection can still hold across one span — a span shorter than +/// its update interval, or a counter that has since stopped publishing — and a +/// rise of zero is then not a reading of zero energy: the work ran, and the +/// counter did not publish it. That span, and one whose read failed (`None`), +/// is the time×power model's figure, `ModelBased`, as IOReport's held spans +/// are ([`ioreport_span`]). Never `HwShunt` on a model figure. +fn counter_span(rose: Option, elapsed_ns: u128, reading: fn(u64) -> Energy) -> Energy { + match rose { + Some(uj) if uj > 0 => reading(uj), + _ => Energy::modeled(time_power_uj(elapsed_ns, DEFAULT_ACTIVE_MILLIWATTS)), + } +} + +/// The figure the NVML rung gives one span, from its counter reads either +/// side, in µJ. The counter reads the GPU and nothing else, so a span over +/// which it advanced is a hardware reading that covers only part of the work — +/// the CPU's share is unmetered, not zero — and is graded so: +/// [`Energy::measured_partial`], `HwShunt` with `Coverage::Partial`, which +/// MUST NOT be presented as the task's total (SPEC §5.2). A counter that fell +/// (a driver reload resets it) or held, or a read that failed, is no reading: +/// the time×power model's whole-task figure ([`counter_span`]). +#[cfg_attr(not(feature = "nvml"), allow(dead_code))] +fn nvml_span(before_uj: Option, after_uj: Option, elapsed_ns: u128) -> Energy { + let rose = match (before_uj, after_uj) { + (Some(b), Some(a)) => a.checked_sub(b), + _ => None, + }; + counter_span(rose, elapsed_ns, Energy::measured_partial) +} + /// µJ for a span: `mW × ns` is 10⁻³ W · 10⁻⁹ s = 10⁻¹² J = 10⁻⁶ µJ. fn time_power_uj(elapsed_ns: u128, milliwatts: u64) -> u64 { u64::try_from(elapsed_ns.saturating_mul(milliwatts as u128) / 1_000_000).unwrap_or(u64::MAX) @@ -4137,8 +4527,8 @@ mod tests { fn probed_once_from_eight_threads() { const THREADS: usize = 8; assert_eq!( - (ioreport_probe_runs(), ioreport_energy_model_opens()), - (0, 0), + (ioreport_probe_runs(), ioreport_energy_model_opens(), rapl_probe_runs(), nvml_probe_runs()), + (0, 0, 0, 0), "something probed before the pin began, so its calls are not the first use" ); let barrier = std::sync::Barrier::new(THREADS); @@ -4167,10 +4557,18 @@ mod tests { workers.into_iter().map(|w| w.join().expect("a thread panicked")).collect() }); let (runs, opens) = (ioreport_probe_runs(), ioreport_energy_model_opens()); + let (rapl_runs, nvml_runs) = (rapl_probe_runs(), nvml_probe_runs()); eprintln!( "ioreport cache (fresh process, lib tests): {THREADS} threads, first use concurrent; they saw {statuses:?}; \ - probe runs = {runs}, Energy Model subscriptions opened = {opens}" + probe runs = {runs}, Energy Model subscriptions opened = {opens}; RAPL probe runs = {rapl_runs} \ + (status {:?}), NVML probe runs = {nvml_runs} (status {:?})", + rapl_status(), + nvml_status() ); + // Every `detect()` asks for the RAPL status first, and NVML's next: + // each probe ran once, on whichever thread asked first. + assert_eq!(rapl_runs, 1, "the RAPL liveness probe did not run exactly once in this process"); + assert_eq!(nvml_runs, usize::from(cfg!(feature = "nvml")), "the NVML liveness probe ran {nvml_runs} times"); assert!(statuses.iter().all(|s| *s == statuses[0]), "threads saw different statuses: {statuses:?}"); assert_eq!(statuses[0], ioreport_status(), "the spawned threads and this one disagree"); if cfg!(target_os = "macos") { @@ -4241,7 +4639,7 @@ mod tests { #[test] fn the_ioreport_rung_stamps_the_hardware_grade() { let _hw = ioreport_hw_lock(); - let meter = Meter::detect_with(|| IoReportStatus::Live); + let meter = Meter::detect_with(&Ladder { ioreport: &|| IoReportStatus::Live, ..Ladder::none() }); if matches!(meter, Meter::IoReport(_)) { let live = ioreport_span( Some(IoReportReading { total_uj: 0, cpu_uj: 0 }), @@ -4265,14 +4663,14 @@ mod tests { let _hw = ioreport_hw_lock(); use IoReportStatus as S; for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { - let meter = Meter::detect_with(|| status); + let meter = Meter::detect_with(&Ladder { ioreport: &|| status, ..Ladder::none() }); assert!( matches!(meter, Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS }), "status {status:?} gave {meter:?}" ); } let opens = ioreport::IoReport::open().is_some(); - let meter = Meter::detect_with(|| S::Live); + let meter = Meter::detect_with(&Ladder { ioreport: &|| S::Live, ..Ladder::none() }); assert_eq!( matches!(meter, Meter::IoReport(_)), opens, @@ -4281,6 +4679,346 @@ mod tests { ); } + /// A ladder on which every rung is declined and no counter opens: tests + /// set the one rung they drive and take the rest from here. + impl Ladder<'static> { + fn none() -> Self { + Ladder { + rapl: &|| CounterStatus::Unavailable, + open_rapl: &|| None, + nvml: &|| CounterStatus::Unavailable, + ioreport: &|| IoReportStatus::Unavailable, + } + } + } + + // ── The counter rungs' gate: RAPL and NVML ────────────────────────────── + + use std::cell::Cell; + use std::time::Duration; + + /// The gate itself, on every status: only `Live` admits a counter rung, + /// and a counter whose status does not admit it is not even opened. + #[test] + fn only_live_admits_a_counter_rung_and_nothing_else_is_opened() { + use CounterStatus as S; + assert!(admits_counter(S::Live), "Live does not admit the rung"); + for status in [S::Unavailable, S::Frozen] { + assert!(!admits_counter(status), "{status:?} admits the rung"); + let opened = Cell::new(0); + let got = select(status, || { + opened.set(opened.get() + 1); + Some(()) + }); + assert_eq!((got, opened.get()), (None, 0), "{status:?}: the counter was opened"); + } + let opened = Cell::new(0); + let got = select(S::Live, || { + opened.set(opened.get() + 1); + Some(()) + }); + assert_eq!((got, opened.get()), (Some(()), 1), "Live did not open the counter once"); + assert_eq!(select(S::Live, || None::<()>), None, "a Live counter that does not open is not selected"); + } + + /// A probe's watch maps to its status: no probe and no interval are + /// Unavailable, never Live. + #[test] + fn each_counter_probe_reports_its_status() { + assert_eq!(liveness::status_of(None), CounterStatus::Unavailable); + assert_eq!(liveness::status_of(Some(&liveness::Advances::default())), CounterStatus::Unavailable); + let mut held = liveness::Advances::default(); + let rail = |v| liveness::every_rail(&[("intel-rapl:0".to_string(), v)]); + for k in 0..6u32 { + held.observe(rail(7), Duration::from_millis(100) * k); + } + assert_eq!(liveness::status_of(Some(&held)), CounterStatus::Frozen); + let mut live = liveness::Advances::default(); + for k in 0..6u32 { + live.observe(rail(i64::from(k)), Duration::from_millis(100) * k); + } + assert_eq!(liveness::status_of(Some(&live)), CounterStatus::Live); + } + + /// The tests' interval for a CPU-counter probe: a 25 ms tick and a short + /// read. + const COUNTER_STEP: Duration = Duration::from_millis(31); + + /// One counter probe through [`liveness::probe`], the function the RAPL + /// and NVML probes run, on a scripted counter and a scripted clock: sample + /// `i` is `script(i)` (`None`: the read failed), and each tick moves the + /// clock on by `step`. Returns the status, the watch, how many samples + /// were read and how many ticks ran. + fn scripted_counter( + script: impl Fn(usize) -> Option>, + step: Duration, + ) -> (CounterStatus, Option, usize, usize) { + let (base, clock, reads, ticks) = (std::time::Instant::now(), Cell::new(Duration::ZERO), Cell::new(0), Cell::new(0)); + let watch = liveness::probe( + || Some(()), + |_| { + let i = reads.get(); + reads.set(i + 1); + script(i).map(|named| liveness::every_rail(&named)) + }, + || { + clock.set(clock.get() + step); + ticks.set(ticks.get() + 1); + }, + || base + clock.get(), + liveness::BUDGET, + ); + (liveness::status_of(watch.as_ref()), watch, reads.get(), ticks.get()) + } + + /// A RAPL-shaped sample: two packages' `energy_uj`, in µJ. + fn packages(p0: i64, p1: i64) -> Option> { + Some(vec![("intel-rapl:0".to_string(), p0), ("intel-rapl:1".to_string(), p1)]) + } + + /// The RAPL gate on scripted package counters, through the probe the RAPL + /// rung runs. Live only when one package keeps advancing; a counter that + /// holds (a frozen or virtualised powercap tree), one republished in a + /// lump or two steps, packages stepping in turn, and a wrap are all + /// Frozen; a tree that does not open is Unavailable without a sample or a + /// tick. + #[test] + fn the_rapl_gate_on_scripted_package_counters() { + let ms = Duration::from_millis; + // Live: package 0 rises ~0.4 J every interval under the burn, package 1 + // idles but rises too. Live at the tenth interval, when the window + // first spans 300 ms (310 ms), and the probe stops there. + let (status, w, reads, ticks) = + scripted_counter(|i| packages(1_000_000 + 400_000 * i as i64, 9_000_000 + 30_000 * i as i64), COUNTER_STEP); + let w = w.expect("an opened counter was not watched"); + assert_eq!(status, CounterStatus::Live, "{:?}", w.rails); + assert_eq!((w.intervals, reads, ticks, w.window), (10, 11, 10, ms(310)), "a live counter must stop the probe at once"); + // Frozen: a counter that holds, after the whole budget (33 intervals). + let (status, w, reads, _) = scripted_counter(|_| packages(1_000_000, 9_000_000), COUNTER_STEP); + let w = w.unwrap(); + assert_eq!((status, w.intervals, reads), (CounterStatus::Frozen, 33, 34), "{:?}", w.rails); + // Frozen: every package republished once, in one lump. + let lump = |i: usize| if i < 3 { 0 } else { 1_300_000_000 }; + let (status, w, _, _) = scripted_counter(|i| packages(lump(i), 9_000_000 + lump(i)), COUNTER_STEP); + assert_eq!(status, CounterStatus::Frozen, "one lump read as live: {:?}", w.unwrap().rails); + // Frozen: a lump in two steps, and in five quick steps at the start. + for steps in [2usize, 5] { + let (status, w, _, _) = + scripted_counter(|i| packages(1_000_000 + 61_000_000 * i.min(steps) as i64, 9_000_000), COUNTER_STEP); + assert_eq!(status, CounterStatus::Frozen, "a lump in {steps} steps read as live: {:?}", w.unwrap().rails); + } + // Frozen: the packages advance in turn, each in half the intervals. + // Summed, they would be one counter advancing in every interval. + let (status, w, _, _) = + scripted_counter(|i| packages(1_000 * i.div_ceil(2) as i64, 9_000 + 1_000 * (i / 2) as i64), COUNTER_STEP); + let w = w.unwrap(); + assert_eq!(status, CounterStatus::Frozen, "packages in turn read as live: {:?}", w.rails); + assert!(w.rails.iter().all(|(_, n)| *n <= w.intervals.div_ceil(2)), "{:?}", w.rails); + // A wrap is a fall, not an advance: rising, then wrapping at every + // other sample, is never 80%. + let (status, _, _, _) = + scripted_counter(|i| packages(if i % 2 == 0 { 262_143_000 } else { 100 }, 9_000_000), COUNTER_STEP); + assert_eq!(status, CounterStatus::Frozen, "a counter wrapping at every other sample read as live"); + // Unavailable: every read fails, to the five-budget cap. + let (status, w, reads, _) = scripted_counter(|_| None, COUNTER_STEP); + assert_eq!((status, w.unwrap().intervals), (CounterStatus::Unavailable, 0)); + assert!(reads > 100, "the probe gave up on failing reads after {reads}"); + // Unavailable: no tree, no sample, no tick. + let (reads, ticks) = (Cell::new(0), Cell::new(0)); + let watch = liveness::probe( + || None::<()>, + |_| { + reads.set(reads.get() + 1); + None + }, + || ticks.set(ticks.get() + 1), + std::time::Instant::now, + liveness::BUDGET, + ); + assert!(watch.is_none()); + assert_eq!((liveness::status_of(watch.as_ref()), reads.get(), ticks.get()), (CounterStatus::Unavailable, 0, 0)); + } + + /// The NVML gate's rule on scripted device counters, in mJ, sampled every + /// 100 ms as the NVML probe waits. A counter the driver refreshes at least + /// every 100 ms advances in every interval and is Live after five (500 + /// ms); one refreshed every fourth interval advances in a quarter of them + /// and is declined; one that holds is Frozen. + #[test] + fn the_nvml_gate_on_scripted_device_counters() { + let step = Duration::from_millis(100); + let device = |mj: i64| Some(vec![("nvml device 0 total energy".to_string(), mj)]); + let (status, w, reads, _) = scripted_counter(|i| device(48_000_000 + 2_000 * i as i64), step); + let w = w.unwrap(); + assert_eq!((status, w.intervals, reads, w.window), (CounterStatus::Live, 5, 6, Duration::from_millis(500))); + let (status, w, _, _) = scripted_counter(|i| device(48_000_000 + 8_000 * (i / 4) as i64), step); + assert_eq!(status, CounterStatus::Frozen, "a counter advancing in 1 of 4 intervals: {:?}", w.unwrap().rails); + let (status, _, _, _) = scripted_counter(|_| device(48_000_000), step); + assert_eq!(status, CounterStatus::Frozen); + } + + /// A fake powercap tree in the OS temp directory, removed when dropped: + /// `intel-rapl:N` packages with an `energy_uj` and a + /// `max_energy_range_uj`, and a `intel-rapl:0:0` subzone the reader must + /// skip. + struct FakePowercap { + root: std::path::PathBuf, + } + + impl FakePowercap { + fn new(packages: &[&str]) -> Self { + static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let root = std::env::temp_dir().join(format!("jcp-energy-powercap-{}-{n}", std::process::id())); + let fake = FakePowercap { root }; + for (k, value) in packages.iter().enumerate() { + let dir = fake.root.join(format!("intel-rapl:{k}")); + std::fs::create_dir_all(&dir).expect("create a fake package"); + std::fs::write(dir.join("max_energy_range_uj"), "262143328850\n").unwrap(); + fake.write(k, value); + } + let sub = fake.root.join("intel-rapl:0:0"); + std::fs::create_dir_all(&sub).unwrap(); + std::fs::write(sub.join("energy_uj"), "5\n").unwrap(); + fake + } + + /// Replace package `k`'s `energy_uj`, atomically, so a reader sees + /// the old value or the new one. + fn write(&self, k: usize, value: &str) { + let dir = self.root.join(format!("intel-rapl:{k}")); + std::fs::write(dir.join("energy_uj.tmp"), format!("{value}\n")).unwrap(); + std::fs::rename(dir.join("energy_uj.tmp"), dir.join("energy_uj")).unwrap(); + } + } + + impl Drop for FakePowercap { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + /// The real RAPL probe — the real reader on files, the CPU-burning tick, + /// the real clock, one budget — on fake powercap trees. A tree whose + /// counters hold is Frozen; one whose counter another thread advances + /// every 2 ms is Live; no tree, or a tree whose `energy_uj` is not a + /// number, is Unavailable, as RAPL was declined before. + #[test] + fn the_rapl_probe_on_fake_powercap_trees() { + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let frozen = FakePowercap::new(&["73176481", "1000"]); + let started = std::time::Instant::now(); + let status = rapl_status_on(|| Rapl::open_in(&frozen.root)); + eprintln!("rapl probe: a fake tree that holds -> {status:?} in {:?}", started.elapsed()); + assert_eq!(status, CounterStatus::Frozen, "a powercap tree whose counters hold was not declined"); + + let live = FakePowercap::new(&["1000", "1000"]); + let stop = std::sync::atomic::AtomicBool::new(false); + let status = std::thread::scope(|s| { + s.spawn(|| { + let mut uj: u64 = 1_000; + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + uj += 4_000; + live.write(0, &uj.to_string()); + std::thread::sleep(Duration::from_millis(2)); + } + }); + let started = std::time::Instant::now(); + let status = rapl_status_on(|| Rapl::open_in(&live.root)); + eprintln!("rapl probe: a fake tree advancing every 2 ms -> {status:?} in {:?}", started.elapsed()); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + status + }); + assert_eq!(status, CounterStatus::Live, "a powercap counter advancing every 2 ms was not live"); + + let gone = FakePowercap::new(&[]); + assert_eq!(rapl_status_on(|| Rapl::open_in(&gone.root.join("absent"))), CounterStatus::Unavailable); + let garbled = FakePowercap::new(&["not a number"]); + assert!(Rapl::open_in(&garbled.root).is_none(), "a package whose energy_uj does not parse was opened"); + assert_eq!(rapl_status_on(|| Rapl::open_in(&garbled.root)), CounterStatus::Unavailable); + } + + /// `detect()` selects the RAPL rung on a `Live` status and on no other, + /// and opens no counter it does not select. Driven with a counter on a + /// fake tree, so it runs on hosts with no RAPL. + #[test] + fn only_a_live_status_selects_the_rapl_rung() { + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let fake = FakePowercap::new(&["1000"]); + for status in [CounterStatus::Unavailable, CounterStatus::Frozen, CounterStatus::Live] { + let opened = Cell::new(0); + let open = || { + opened.set(opened.get() + 1); + Rapl::open_in(&fake.root) + }; + let meter = Meter::detect_with(&Ladder { rapl: &|| status, open_rapl: &open, ..Ladder::none() }); + let live = status == CounterStatus::Live; + assert_eq!(matches!(meter, Meter::Rapl(_)), live, "status {status:?} gave {meter:?}"); + assert_eq!(opened.get(), usize::from(live), "status {status:?} opened the counter {} times", opened.get()); + if !live { + assert!(matches!(meter, Meter::TimePower { milliwatts: DEFAULT_ACTIVE_MILLIWATTS }), "{meter:?}"); + } + } + } + + /// The RAPL rung's spans, through `measure` itself on a fake tree. A span + /// over which a package counter rose is a reading of the rise, `HwShunt` + /// and whole, wraparound-corrected; a span over which no counter rose is + /// the time×power model's figure, not a hardware reading of zero. + #[test] + fn a_rapl_span_is_a_reading_only_if_a_counter_rose() { + let fake = FakePowercap::new(&["1000", "50"]); + let meter = Meter::Rapl(Rapl::open_in(&fake.root).expect("the fake tree opens")); + assert_eq!(meter.provenance(), Provenance::HwShunt); + let (_, e) = meter.measure(|| fake.write(0, "5500")); + assert_eq!((e.provenance, e.measured_uj, e.is_whole()), (Provenance::HwShunt, Some(4_500), true), "{e:?}"); + e.check_provenance().unwrap(); + // Package 1 wraps at 262,143,328,850 µJ: 262,143,328,000 -> 150. + fake.write(1, "262143328000"); + let (_, e) = meter.measure(|| fake.write(1, "150")); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(1_000)), "{e:?}"); + let ((), held) = meter.measure(|| std::thread::sleep(Duration::from_millis(2))); + assert_eq!((held.provenance, held.measured_uj), (Provenance::ModelBased, None), "{held:?}"); + assert!(held.uj() >= time_power_uj(2_000_000, DEFAULT_ACTIVE_MILLIWATTS), "{held:?}"); + held.check_provenance().unwrap(); + } + + /// The figure a counter rung gives a span: a reading only when the + /// counter rose; a rise of zero and a failed read are the model's figure. + #[test] + fn a_counter_span_is_a_reading_only_when_the_counter_rose() { + let model = time_power_uj(3_000_000, DEFAULT_ACTIVE_MILLIWATTS); + assert_eq!(counter_span(Some(7), 3_000_000, Energy::measured), Energy::measured(7)); + for rose in [Some(0), None] { + let e = counter_span(rose, 3_000_000, Energy::measured); + assert_eq!((e.provenance, e.measured_uj, e.uj()), (Provenance::ModelBased, None, model), "{rose:?}: {e:?}"); + } + } + + /// An NVML span over which the GPU counter advanced is a hardware reading + /// of PART of the work: `HwShunt`, `Coverage::Partial`, the counter's + /// rise as the measured figure — never a whole-task figure, since the + /// CPU's share is unmetered. A counter that held, fell (a driver reload) + /// or failed to read is the time×power model's whole-task figure. + #[test] + fn an_nvml_span_is_a_partial_reading_of_the_work() { + let e = nvml_span(Some(48_000_000_000), Some(48_000_350_000), 10_000_000); + assert_eq!(e.provenance, Provenance::HwShunt, "{e:?}"); + assert_eq!(e.measured_uj, Some(350_000), "{e:?}"); + assert_eq!(e.coverage, jcp_schema::Coverage::Partial, "a GPU-only figure claimed the whole task: {e:?}"); + assert!(!e.is_whole()); + e.check_provenance().unwrap(); + let model = time_power_uj(10_000_000, DEFAULT_ACTIVE_MILLIWATTS); + let b = Some(48_000_000_000); + for (what, before, after) in [("held", b, b), ("fell", b, Some(1_000)), ("before failed", None, b), ("after failed", b, None)] { + let e = nvml_span(before, after, 10_000_000); + assert_eq!((e.provenance, e.measured_uj, e.uj()), (Provenance::ModelBased, None, model), "{what}: {e:?}"); + assert!(e.is_whole(), "{what}: the model's figure is a whole-task estimate: {e:?}"); + } + } + #[test] fn rapl_wraparound_delta() { let r = Rapl { -- 2.54.0 (Apple Git-157) From 4f9d90ca9498182b7b016b9b8470f8671ea49ffb Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 21:22:05 -0400 Subject: [PATCH 09/10] jcp-energy: a counter is live only if every rail it sums keeps advancing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IOReport counter sums every CPU energy rail: `CPU Energy` on an M5 Max, `DIE_0_CPU Energy` and `DIE_1_CPU Energy` on an M3 Ultra. The shared liveness rule called a counter Live as soon as any one of its rails sustained advances (five intervals, 80% of them, 300 ms). On a two-die part, one live die beside a frozen one would select the rung. Every span's figure would then miss the frozen die's energy and still be graded a whole-CPU HwShunt reading. SPEC §5 forbids that: a figure must not claim coverage of a domain its counter does not read. RAPL had the same gap. A RAPL span sums every top-level package's `energy_uj`, and the rung was held to the same any-rail rule. The fake-tree test even asserted Live with package 1 holding. proof-cascade's RAPL already requires every package (9334ec0c); this crate's did not. The fix is in the one shared rule, `liveness::Advances::observe`. A counter is now Live once every rail seen meets all three conditions at the same interval. A counter with no rail at all is never Live. That guard matters: `all` over an empty list is true, so without it a group with no CPU rail would read as Live once the window passed 300 ms. A rail missing from some samples is still counted against every interval, so a rail that comes and goes holds the counter Frozen. NVML and a single-die host have one rail, so for them nothing changes: a single rail is Live at the tenth interval, as before. On a two-die part whose rails are both live, the slower die now decides. If an idle die's rail advanced in fewer than 80% of the intervals, the rung is declined and the model estimates. That is the safe side: estimated, never over-claimed. On macOS 27.0.1 every CPU rail is frozen on both machines here, so nothing changes there. The crate docs, `CounterStatus`, `IoReportStatus`, `rapl_status`, SPEC §5 and §5.1, and the README's jcp-energy bullet now say "every rail". The SPEC adds one sentence: where the figure sums several counters of the domain, every one of them must be observed live. Tests. jcp-energy has 66 lib tests (63 before) and its integration test, all passing with and without `--features nvml`: - `one_live_die_beside_a_frozen_die_is_frozen`, on scripted M3 Ultra-shaped samples. Both dies rising: Live at the tenth interval, the same interval as a single rail. DIE_0 rising, DIE_1 holding: Frozen after the whole budget (DIE_0 33 advances, DIE_1 0), and never Live when fed 200 intervals by hand. The mirror is Frozen too, and so is DIE_0 rising beside a DIE_1 republished once. - The same through `probe_with`, and through the production composition `host_status` with its real tick and clocks: `host_status_on_one_live_die_beside_a_frozen_die_is_frozen`. - The existing 300 ms test's second case was "the second die of two, the first holding -> Live". That was the any-rail behaviour, and it is now the mixed test. In its place, both dies rising -> Live. - RAPL, scripted: package 0 rising beside a held package 1 is Frozen. RAPL, the real probe on fake powercap trees: "packages [0, 1] advancing every 2 ms -> Live in 301.846625ms"; "packages [0] advancing every 2 ms -> Frozen in 1.06510775s". - No rail at all, watched past 300 ms: an Energy Model group with no CPU rail stays Absent over the whole budget, and a counter whose samples carry no rail is never Live. Falsified, each by a temporary edit restored with git checkout (git diff was empty after each): - The rule back to any rail fails five tests: the three two-die tests ("DIE_0 beside a frozen DIE_1 became live", left Some(10), right None; Live vs Frozen; Live vs CpuRailFrozen) and both RAPL tests ("one live package beside a held one read as live"; "package 0 advancing beside a held package 1 was not declined"). - Dropping the no-rail guard first passed all 66 tests. The only no-rail test spanned 62 ms, short of the window. With the full-budget cases added, it fails two: "a group with no CPU rail, watched 310ms", left Some(Live), right Some(Absent), and the counter-status test. jcp-intercept (12) and jcp-conformance (1 unit test and 5 pack tests) pass. The jcp-conformance binary prints "all checks passed", and jcp-cli runs and exits 0. clippy --no-deps -D warnings is clean on jcp-energy for macOS (with and without `nvml`) and x86_64-unknown-linux-gnu, and `cargo doc` with `-D warnings` is clean. On an M3 Ultra (macOS 27.0.1), the lib test binary passed 66 of 66: "status= CpuRailFrozen oracle=CpuRailMoved(false) selected=false", advances per CPU rail [("DIE_0_CPU Energy#0", 0), ("DIE_1_CPU Energy#0", 0)]. Not done here: - The hardware oracle in the IOReport tests still vouches for a Live when any one CPU rail kept moving. It matches `*CPU Energy` in every group, not only the Energy Model's. Making it require every rail could convict a correct probe over a channel the counter never reads, so it is unchanged. On a mixed host it would not convict an any-rail probe; the scripted tests do. - `ioreport_span` still grades a span a reading when the summed CPU rails rose, that is, when any die advanced. Requiring every die per span would decline short spans over an idle die. - This crate's RAPL reader sums every top-level `intel-rapl:N`, `psys` included where a platform exposes it. proof-cascade found that double count (9334ec0c). It is untouched here. Co-Authored-By: Claude Opus 5.5 --- jcp/README.md | 2 +- jcp/SPEC.md | 15 +- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 409 ++++++++++++++++++------ 3 files changed, 313 insertions(+), 113 deletions(-) diff --git a/jcp/README.md b/jcp/README.md index 94897c52e..0f49aa0bb 100644 --- a/jcp/README.md +++ b/jcp/README.md @@ -45,7 +45,7 @@ jcp/ - **`jcp-trace`** — ingests an agent run's **OpenTelemetry GenAI trace** (`gen_ai.*` semantic conventions: `chat` / `tool` / `retriever` spans with token usage) and its **evaluation assessments** (judge / human / code feedback), attaches **per-span joules** (measured where a meter is present, else `Estimator` from token counts — provenance never silently upgrades), and seals one **`eval/1` JCR-1 receipt**: the per-span token + joule breakdown with the headline `uj_per_accepted_token` at the **weakest** provenance present, the assessments, a deterministic **quality-gate** verdict (the sealed, declarative analog of a CI assert), and a **readiness projection** an ARL validation-axis claim consumes. The observability stacks *record* a trace and *log* a judge score in a table; this is the interop projection that **seals** the same trace, puts a joule on it, and hash-chains it via `EvalLog` — no new cryptography. The **gate is content-addressed**, so the sealed receipt commits to exactly which bar it was judged against (a tamper-evident artifact, not a mutable in-code `assert`), and **`align_threshold` / `Judge`** calibrate a judge's decision threshold to human pass/fail labels — judge alignment as a reproducible number, not a UI click. Behind the `server` feature, a std-only **OTLP `/v1/traces` receiver** lets any OpenTelemetry exporter POST a `gen_ai.*` trace (with its assessments) and get the sealed `eval/1` receipt back the instant the run ends. - **`jcp-intercept`** — **attested interceptor chains**: a composable, ordered pipeline of transforms *around* each model and tool call — the `before_model` / `after_model` / `before_tool` seams an agent's middleware stack runs, composed onion-order (forward in, reverse out) and stopping at the first refusal or human-approval hold. An interceptor may inspect or **rewrite** the request (redact a secret before it reaches the model, trim stale context), **refuse** it, or **hold** it for a human. The difference from the incumbent's arbitrary in-process hooks: every step seals an **`intercept/1`** JCR-1 receipt carrying the request digest **before and after** the step (so a redaction or a trim is provable — the content itself never sealed, only its hash), the seam, the outcome, and the **metered joules** at their honest provenance, hash-chained — the exact transform pipeline that produced a model request becomes reconstructable and verifiable by anyone, and a step cannot run un-sealed. Three reference interceptors — `Redact` (a content transform), `CallLimit` (a guardrail), `Approve` (a human-in-the-loop hold) — each reuse a primitive the family already holds rather than reinventing it. - **`jcp-efficiency`** — the **energy-efficiency spec**, surfaced as a sealed receipt. The dark-factory economics turn on four figures — **joules per accepted token, tokens per second, value per token, and the tokens/joules/dollars spent per COMPLETED, VERIFIED unit of work** — and the incumbents publish, at best, a *rate* (cost per token) that can't tell you whether the spend bought a result. An **`efficiency/1`** JCR-1 receipt records all four at honest provenance, hash-chained, with the one thing the rates omit: the **outcome anchor** (`completed` + a verdict). A measurement that didn't complete is spend that bought nothing, and the receipt says so. `aggregate` then splits value-delivered from total spend across a chain — charging *every* attempt (including the failed ones) against the results actually delivered — so `tokens_per_completed_unit` / `uj_per_completed_unit` / `value_per_joule` become the honest efficiency a "cost per token" number is designed to hide. Integers and strings only (JCR-1 forbids bools/floats); the rate figures recompute from the sealed inputs on read. -- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`, only while a package counter is live — `rapl_status()`), NVML GPU energy behind the `nvml` feature (`HwShunt` and `Partial`, since it does not read the CPU's share; only while live — `nvml_status()`), Apple Silicon IOReport (`HwShunt`, only while its CPU energy rail is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. +- **`jcp-energy`** — the metering ladder (SPEC §5.1): RAPL package counters (Linux powercap, wraparound-corrected, `HwShunt`, only while every package counter is live — `rapl_status()`), NVML GPU energy behind the `nvml` feature (`HwShunt` and `Partial`, since it does not read the CPU's share; only while live — `nvml_status()`), Apple Silicon IOReport (`HwShunt`, only while every CPU energy rail, each die's on a multi-die part, is live — on macOS 27.0.1 that rail is republished in lumps, so there the rung is declined and `ioreport_status()` says why), a time×calibrated-power model (`ModelBased`, always available), and a constant per-op estimator (`Estimator`). A vendor counter's reading is real but vendor-opaque and relayed by the platform, and it counts as a reading only while the counter is live (SPEC §5). Measured when the platform exposes it, estimated when it doesn't — and the provenance always says which; a hardware backend that fails mid-reading degrades honestly to the model. - **`jcp-gateway`** — the **energy-native MCP gateway**: a JCP enforcement point that sits in front of an MCP server so an existing client/server pair keeps its transport while every `tools/call` becomes capability-scoped, joule-budgeted, and sealed. The novel mechanic is two-phase metered enforcement: the call is *authorized* before it runs (no debit, because the cost isn't yet known), the upstream call is **metered** by the sibling Sandbox standard's energy probe (Apple Silicon IOReport, RAPL, NVML — vendor energy counters, whose readings are real but vendor-opaque and relayed by the platform, and which count as readings only while the counter is live for the work, SPEC §5; otherwise the figure is an estimate and is graded as one), and the metered joules are debited from the grant — so the budget that stops a runaway agent is denominated in joules, not a token estimate. Every decision, allow and deny, seals into a **hash-chained receipt log**: excise, reorder, or splice a receipt and the chain breaks at that point. A denied call never reaches the server (the live 2025–26 MCP exploits land on tool *output*, which the gateway frames as untrusted `data` that can never exercise a capability), and the `jcp-gateway` binary runs it over stdio in front of any MCP-server subprocess. This is the chokepoint the AI-gateway field occupies, on the two axes nobody there holds: the unit of account is the joule, and the audit is cryptographic and tamper-evident rather than a dollar figure and a span. - **`jcp-wire`** — the `jcp/1` wire binding (SPEC §11): 4-byte length prefix + canonical det-CBOR over any byte stream, with TCP and Unix-socket endpoints and a client. The decoder rejects non-canonical bodies, so a message has exactly one wire form. Requests carry the full delegation chain and are **caller-signed** — the endpoint authenticates from the request itself and denies an impersonation `bad-signature`, sealed like any other decision. Responses carry the deterministic decision and the sealed receipt. - **`jcp-threshold`** — threshold actors (SPEC §2.2): a `did:key` whose private key is split k-of-n with FROST (RFC 9591); the group key is an ordinary Ed25519 key, so a 2-of-3 team issues, holds, and signs wire requests with nothing else in JCP changing. Shares come from a trusted dealer or **distributed key generation** — with DKG the group's private key never exists anywhere, not even momentarily. The two-agent rule for irreversible actions, refused by arithmetic rather than policy. diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 9470a88c8..3dd214a3a 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -395,8 +395,11 @@ is demonstrably live for the domain the figure claims: the implementation MUST have observed the counter covering that domain advance under load (for Apple IOReport: the CPU energy rail itself advancing in at least 5 sample intervals, in at least 4 of every 5, over at least 300 ms while the probing thread burns -CPU). A counter that is present but not live — frozen, or republished in lumps -— MUST NOT produce a hardware-graded figure; the implementation falls back to +CPU). Where the figure sums several counters of the domain — a RAPL package +counter per package, an IOReport CPU energy rail per die — every one of them +MUST be observed live: a frozen one beside live ones would be left out of a +figure that claims the whole domain. A counter that is present but not live — +frozen, or republished in lumps — MUST NOT produce a hardware-graded figure; the implementation falls back to its estimator or reports the work unmetered, and SHOULD record why. A figure MUST NOT claim coverage of a domain its counter does not read; where the format has a coverage field, such a figure is Partial. @@ -417,8 +420,8 @@ hardware backend fails mid-reading and the figure degrades to a model. Recommended ladder, best first: 1. **RAPL** (Linux `powercap`) — package energy counters, wraparound-corrected - → `HwShunt`, selected only while a package counter is live under the - probe's CPU burn, by the rule above as IOReport's CPU rail is. A span over + → `HwShunt`, selected only while every package counter is live under the + probe's CPU burn, by the rule above as IOReport's CPU rails are. A span over which no package counter advanced is metered by a lower rung. 2. **NVML** — the GPU total-energy counter → `HwShunt`, `Partial` (§5.2): it reads the GPU and not the CPU's share of the work. Selected only while the @@ -426,8 +429,8 @@ Recommended ladder, best first: this counter live means published continuously, advancing on the device's own draw. A span over which it did not advance is metered by a lower rung. 3. **Apple IOReport** (the Energy Model group) — the SoC energy counters - → `HwShunt`, selected only while the CPU energy rail is live by the rule - above. A span over which that rail did not advance is metered by the next + → `HwShunt`, selected only while every CPU energy rail (one per die on a + multi-die part) is live by the rule above. A span over which that rail did not advance is metered by the next rung, never graded `HwShunt`. Where the CPU rail is not live but the GPU rail is, the rung is declined rather than reporting the GPU rail as a `Partial` reading of the work: the GPU rail is machine-wide (display compositing diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 29018b03a..edac7082e 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -8,12 +8,14 @@ //! `HwShunt`. A reading never silently upgrades its own provenance. //! //! The ladder, best first. Every vendor counter rung is selected only while -//! its counter is live by one rule (SPEC §5): one rail of it kept advancing -//! over a probe, in at least five sample intervals, at least 80% of them, over -//! at least 300 ms. Each probe runs once per process, and each rung's status -//! says why it was declined. +//! its counter is live by one rule (SPEC §5): every rail of it that the figure +//! sums kept advancing over a probe, each in at least five sample intervals, +//! at least 80% of them, over at least 300 ms. A figure that sums a frozen rail +//! with live ones would leave out the frozen rail's share while claiming the +//! whole domain. Each probe runs once per process, and each rung's status says +//! why it was declined. //! 1. **RAPL** (`/sys/class/powercap`, Linux) — hardware package energy, -//! `Provenance::HwShunt`, wraparound-corrected, **only while a package +//! `Provenance::HwShunt`, wraparound-corrected, **only while every package //! counter is live** under the probe's CPU burn ([`rapl_status`]). A span //! over which no package counter advanced is the time×power model's figure. //! 2. **NVML** (feature `nvml`) — GPU energy via `libnvidia-ml`, @@ -31,10 +33,13 @@ //! can open while every CPU rail holds its value, refreshed only in rare //! batches, and then the summed total advances through the GPU rail — GPU //! energy reported as the energy of CPU work — or by a whole refresh at -//! once. Live means the same CPU rail kept advancing while the probe burned -//! CPU: in at least five separate sample intervals, in at least 80% of the -//! intervals observed, over a window of at least 300 ms. A batch refresh, -//! even one published in a few steps, is not a live rail. Otherwise the +//! once. Live means every CPU rail kept advancing while the probe burned +//! CPU — `CPU Energy`, or on a multi-die part each die's `DIE__CPU +//! Energy`, since the counter sums them all: each in at least five separate +//! sample intervals, in at least 80% of the intervals observed, over a +//! window of at least 300 ms. A batch refresh, even one published in a few +//! steps, is not a live rail, and one live die beside a frozen one is not a +//! live CPU: the figure would miss the frozen die. Otherwise the //! platform is not exposing CPU energy, so by the rule above this rung is //! declined and the time×power model estimates instead. A span whose CPU //! rail did not advance is the model's figure too, never a reading. @@ -191,11 +196,16 @@ impl std::fmt::Debug for Nvml { /// The sustained-advance liveness rule (SPEC §5), one rule for every vendor /// counter this crate reads: a counter's count is a hardware reading only while -/// the counter is live, and live means **one of its rails kept advancing** over -/// a probe — in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, in at -/// least 80% of the intervals observed, over at least [`MIN_LIVE_WINDOW`] of -/// wall time. A rail is one cumulative count within a sample: a RAPL package's -/// `energy_uj`, NVML's total-energy counter, an IOReport `CPU Energy` channel. +/// the counter is live, and live means **every one of its rails kept +/// advancing** over a probe — each in at least [`MIN_LIVE_ADVANCES`] separate +/// sample intervals, in at least 80% of the intervals observed, over at least +/// [`MIN_LIVE_WINDOW`] of wall time. A rail is one cumulative count within a +/// sample: a RAPL package's `energy_uj`, NVML's total-energy counter, an +/// IOReport `CPU Energy` channel. Every rail, because each rung's figure sums +/// every rail it hands the rule — RAPL every package, IOReport every CPU rail +/// (both dies' on a two-die part) — and a figure summing a live rail with a +/// frozen one leaves the frozen rail's share out while claiming the whole +/// domain. /// /// Kept apart from every counter's FFI, so the rule, its loop and its stopping /// rule run on scripted samples and a scripted clock in tests, and so the RAPL, @@ -227,8 +237,8 @@ mod liveness { /// here, so a starved probe still ends. pub(crate) const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); - /// In how many separate sample intervals the same rail must advance - /// before it counts as live. Two is not enough: see `ioreport::RailWatch`. + /// In how many separate sample intervals each rail must advance before + /// it counts as live. Two is not enough: see `ioreport::RailWatch`. /// Also the fewest intervals the probe observes before it may call a rail /// frozen. pub(crate) const MIN_LIVE_ADVANCES: u32 = 5; @@ -292,13 +302,23 @@ mod liveness { /// so all of it, including the pairing of each sample with the previous /// one, runs on scripted samples in tests. /// - /// A counter is live only when the **same** rail keeps advancing: it - /// advanced in at least [`MIN_LIVE_ADVANCES`] separate sample intervals, - /// those are at least 80% of all the intervals observed so far - /// ([`sustained`]), and the window observed so far spans at least - /// [`MIN_LIVE_WINDOW`] of wall time from the first usable sample. The three - /// are checked after each interval, and the rail is live from the first - /// interval at which all three hold. + /// A counter is live only when **every** rail seen keeps advancing, each + /// on its own count: it advanced in at least [`MIN_LIVE_ADVANCES`] + /// separate sample intervals, those are at least 80% of all the intervals + /// observed so far ([`sustained`]), and the window observed so far spans + /// at least [`MIN_LIVE_WINDOW`] of wall time from the first usable sample. + /// The three are checked after each interval, and the counter is live from + /// the first interval at which all three hold for every rail. A counter + /// with no rail is never live. + /// + /// Every rail, not one: each rung sums every rail it hands the rule into + /// its figure. On a two-die part (an M3 Ultra's `DIE_0_CPU Energy` and + /// `DIE_1_CPU Energy`), one live die beside a frozen one would give a + /// figure that misses the frozen die while claiming the whole CPU, and on a + /// two-package RAPL host, one that misses a package. A rail seen in only + /// some samples is still counted against every interval observed, so one + /// missing from many of them fails the share and holds the counter frozen: + /// a rail that comes and goes is not a live count either. /// /// Each condition closes a way for a refresh to pass. Five advances: a /// refresh is published in one step, two, or a few. 80% of the intervals: the @@ -329,7 +349,7 @@ mod liveness { /// How much wall time the window spans so far: from the first usable /// sample to the latest. pub(crate) window: Duration, - /// Set at the first interval after which one rail met all three + /// Set at the first interval after which every rail met all three /// conditions, and never unset. live: bool, } @@ -361,17 +381,20 @@ mod liveness { } } let intervals = self.intervals; + // Every rail, and at least one: `all` over no rails is true, + // and a sample with no rail in it is no evidence of anything. self.live |= self.window >= MIN_LIVE_WINDOW + && !self.rails.is_empty() && self .rails .iter() - .any(|&(_, advancing)| advancing >= MIN_LIVE_ADVANCES && sustained(advancing, intervals)); + .all(|&(_, advancing)| advancing >= MIN_LIVE_ADVANCES && sustained(advancing, intervals)); } self.prev = Some(cur); } - /// Has one rail kept advancing: enough advances, a high enough share - /// of the intervals, over a long enough window? + /// Has every rail kept advancing: enough advances, a high enough + /// share of the intervals, over a long enough window? pub(crate) fn is_live(&self) -> bool { self.live } @@ -524,14 +547,15 @@ pub enum CounterStatus { /// sysfs), or it gave fewer than two usable samples in five budgets. Never /// selected. Unavailable, - /// The counter opened, but no rail of it kept advancing while the probe - /// ran: it held, or stepped a few times together — republished in lumps, - /// not a live count. Never selected: a lump landing in one span would be - /// graded a hardware reading of that span's work. + /// The counter opened, but not every rail of it kept advancing while the + /// probe ran: one held, or stepped a few times together — republished in + /// lumps, not a live count. Never selected: a lump landing in one span + /// would be graded a hardware reading of that span's work, and a held rail + /// beside live ones would be left out of a figure that claims it. Frozen, - /// One rail of the counter kept advancing while the probe ran: in at least - /// five separate sample intervals, making up at least 80% of those - /// observed, over at least 300 ms. The rung is selected. + /// Every rail of the counter kept advancing while the probe ran: each in + /// at least five separate sample intervals, making up at least 80% of + /// those observed, over at least 300 ms. The rung is selected. Live, } @@ -567,12 +591,14 @@ fn rapl_probe_runs() -> usize { /// This host's RAPL status, probed once per process and cached. /// /// [`Meter::detect`] selects the RAPL rung only when this is -/// [`CounterStatus::Live`]: one package counter (`intel-rapl:N/energy_uj`) kept -/// advancing while the probing thread burned CPU, in at least five sample -/// intervals, at least 80% of them, over at least 300 ms — the rule IOReport's -/// CPU rail is held to. A counter that holds, or is republished in lumps (a -/// virtualised powercap tree can be either), is declined to the time×power -/// model. Where there is no readable powercap tree — not Linux, no RAPL, or an +/// [`CounterStatus::Live`]: every package counter (`intel-rapl:N/energy_uj`) +/// kept advancing while the probing thread burned CPU, each in at least five +/// sample intervals, at least 80% of them, over at least 300 ms — the rule +/// IOReport's CPU rails are held to. Every package, because a span's figure +/// sums them all: one live package beside a frozen one would leave the frozen +/// package's energy out of a figure graded whole. A counter that holds, or is +/// republished in lumps (a virtualised powercap tree can be either), is +/// declined to the time×power model. Where there is no readable powercap tree — not Linux, no RAPL, or an /// `energy_uj` only root may read — it is [`CounterStatus::Unavailable`] /// without burning anything, as RAPL was declined before. The first call on a /// host with a readable tree burns about 300 ms of the calling thread's CPU @@ -664,8 +690,8 @@ pub fn nvml_status() -> CounterStatus { /// API. Like RAPL and NVML it is a vendor energy counter: how Apple derives the /// count is undisclosed and the platform relays it, which is a gap in /// provenance, not in accuracy, so a live reading is graded `HwShunt` as theirs -/// are. It counts as a reading only while its CPU rail is live -/// ([`crate::ioreport_status`]) and only for a span over which that rail +/// are. It counts as a reading only while every CPU rail is live +/// ([`crate::ioreport_status`]) and only for a span over which a CPU rail /// advanced (`ioreport_span`). Ported from jouleclaw-energy's validated /// `apple_ioreport` backend. #[cfg(target_os = "macos")] @@ -896,16 +922,17 @@ mod ioreport { ENERGY_MODEL_OPENS.load(Ordering::SeqCst) } - /// What the liveness probe saw of the CPU energy rail. + /// What the liveness probe saw of the CPU energy rails. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CpuRail { - /// The same CPU energy rail kept advancing while the probing thread - /// burned CPU: in at least [`liveness::MIN_LIVE_ADVANCES`] separate sample - /// intervals, in at least 80% of the intervals observed, over at least - /// [`liveness::MIN_LIVE_WINDOW`]. + /// Every CPU energy rail kept advancing while the probing thread + /// burned CPU: each in at least [`liveness::MIN_LIVE_ADVANCES`] + /// separate sample intervals, in at least 80% of the intervals + /// observed, over at least [`liveness::MIN_LIVE_WINDOW`]. Live, - /// A CPU energy rail exists, but none kept advancing: it held, or moved - /// in a few steps (a batch refresh). + /// A CPU energy rail exists, but not every one kept advancing: one + /// held, or moved in a few steps (a batch refresh) — on a two-die part, + /// possibly beside a live die. Frozen, /// No `CPU Energy` channel exists in the Energy Model group. Absent, @@ -932,8 +959,12 @@ mod ioreport { /// The shared liveness rule ([`liveness::Advances`]) fed IOReport's CPU /// energy rails: [`RailWatch::observe`] picks the CPU rails out of an /// Energy Model sample and hands the rule those alone, so the GPU rail, - /// which rises wherever the GPU draws, is never counted. It dereferences - /// to the rule's state, which is all it holds. + /// which rises wherever the GPU draws, is never counted. Every CPU rail + /// handed over must keep advancing (the rule's every-rail condition): on a + /// multi-die part the counter sums each die's `DIE__CPU Energy`, so one + /// live die beside a frozen one is Frozen, never a Live that would leave + /// the frozen die out of a whole-CPU figure. It dereferences to the rule's + /// state, which is all it holds. /// /// Why the rule asks for five advances in 80% of the intervals over /// 300 ms: on macOS 27.0.1 an unprivileged reader's CPU rails are not @@ -1720,6 +1751,20 @@ mod ioreport { ] } + /// An M3 Ultra-shaped sample: each die's `CPU Energy` at its own + /// value, `d0` and `d1`, among a duplicated unprefixed name + /// (`ECPUDTL00`, once per die, rising) and a `GPU Energy` rising at + /// every sample. Neither of those is a CPU rail. + fn m3(i: usize, d0: i64, d1: i64) -> Sample { + vec![ + ("ECPUDTL00".to_string(), 7_000 + i as i64), + ("DIE_0_CPU Energy".to_string(), d0), + ("ECPUDTL00".to_string(), 9_000 + i as i64), + ("DIE_1_CPU Energy".to_string(), d1), + ("GPU Energy".to_string(), 5_000_000 * i as i64), + ] + } + /// One probe run through [`watch`] on a script: sample `i` is /// `script(i)`, and each tick moves the clock on by `step`, so sample /// `i` is taken at `step * i`. Returns the watch, how many samples were @@ -1940,8 +1985,9 @@ mod ioreport { /// one raw unit, at every sample, 31 ms apart. It has five advances at /// the fifth interval, but the window then spans 155 ms. Live at the /// tenth, the first interval at which the window spans 300 ms (310 ms), - /// and the probe stops there: eleven samples, ten ticks. The same on - /// the second die of two, the first holding. + /// and the probe stops there: eleven samples, ten ticks. The same on a + /// two-die part with both dies rising: the second rail does not make it + /// later. #[test] fn a_rail_advancing_at_every_interval_is_live_at_five_advances_and_300_ms() { let rising = |i: usize| m5(i, 541_893_638 + i as i64); @@ -1952,16 +1998,11 @@ mod ioreport { assert_eq!((w.intervals, taken, stopped), (10, 11, STEP * 10), "a live rail must stop the probe at once"); assert_eq!(w.window, Duration::from_millis(310)); assert!(w.window >= TEST_MIN_WINDOW && w.window - STEP < TEST_MIN_WINDOW, "live at {:?}", w.window); - let second_die = |i: usize| { - vec![ - ("DIE_0_CPU Energy".to_string(), 21_170_718), - ("DIE_1_CPU Energy".to_string(), 23_300_102 + i as i64), - ] - }; - assert_eq!(first_live_interval(second_die, STEP, 200), Some(10)); - let (w, _, _) = scripted(|i| Some(second_die(i)), STEP, BUDGET); + let both_dies = |i: usize| m3(i, 21_170_718 + i as i64, 23_300_102 + i as i64); + assert_eq!(first_live_interval(both_dies, STEP, 200), Some(10)); + let (w, _, _) = scripted(|i| Some(both_dies(i)), STEP, BUDGET); assert_eq!(w.verdict(), Some(CpuRail::Live), "{:?}", advances(&w)); - assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 10)]); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 10), rail("DIE_1_CPU Energy#0", 10)]); } /// A live rail on a loaded host, 100 ms an interval: the window spans @@ -2089,8 +2130,63 @@ mod ioreport { assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 1), rail("DIE_1_CPU Energy#0", 1)]); } + /// Every CPU rail must keep advancing, not one. The counter sums both + /// dies' `CPU Energy` on an M3 Ultra-shaped part, so a Live on one die + /// beside a frozen one would grade a figure that misses the frozen die + /// as a whole-CPU reading. + /// - Both dies rising at every interval: Live at the tenth interval + /// (310 ms), exactly where one rail alone is (a single-die host is + /// judged as before). + /// - DIE_0 rising at every interval, DIE_1 holding: Frozen after the + /// whole budget, DIE_0 advancing in all 33 intervals and DIE_1 in + /// none. Fed by hand for 200 intervals, never live. DIE_0 alone meets + /// the rule at the tenth interval, so a rule satisfied by any one + /// rail calls this Live. + /// - The mirror, DIE_0 holding and DIE_1 rising: Frozen. + /// - DIE_0 rising, DIE_1 republished once in a batch refresh: Frozen. + #[test] + fn one_live_die_beside_a_frozen_die_is_frozen() { + let single = |i: usize| m5(i, 541_893_638 + i as i64); + let both = |i: usize| m3(i, 21_170_718 + i as i64, 23_300_102 + i as i64); + assert_eq!(first_live_interval(single, STEP, 200), Some(10)); + assert_eq!(first_live_interval(both, STEP, 200), Some(10), "a second live die moved the verdict"); + let (w, _, _) = scripted(|i| Some(both(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Live), "both dies rising: {:?}", advances(&w)); + + let die_0_live = |i: usize| m3(i, 21_170_718 + i as i64, 23_300_102); + assert_eq!(first_live_interval(die_0_live, STEP, 200), None, "DIE_0 beside a frozen DIE_1 became live"); + let (w, taken, _) = scripted(|i| Some(die_0_live(i)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "DIE_0 live beside a frozen DIE_1 read as live: {:?}", + advances(&w) + ); + assert_eq!(status_of(w.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!((w.intervals, taken), (33, 34), "a mixed part must be watched for the whole budget"); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 33), rail("DIE_1_CPU Energy#0", 0)]); + + let die_1_live = |i: usize| m3(i, 21_170_718, 23_300_102 + i as i64); + let (w, _, _) = scripted(|i| Some(die_1_live(i)), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "DIE_1 live beside a frozen DIE_0: {:?}", advances(&w)); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 0), rail("DIE_1_CPU Energy#0", 33)]); + + let die_1_refresh = |i: usize| m3(i, 21_170_718 + i as i64, if i < 3 { 23_300_102 } else { 24_691_739 }); + let (w, _, _) = scripted(|i| Some(die_1_refresh(i)), STEP, BUDGET); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "DIE_0 live beside a batch-refreshed DIE_1: {:?}", + advances(&w) + ); + assert_eq!(advances(&w), [rail("DIE_0_CPU Energy#0", 33), rail("DIE_1_CPU Energy#0", 1)]); + } + /// No interval at all is no verdict: a baseline alone is not one. - /// Intervals with no CPU rail are Absent, however much the GPU moves. + /// Intervals with no CPU rail are Absent, however much the GPU moves, + /// and however long they are watched: over the whole budget, past the + /// 300 ms window, no rail is not every rail advancing (`all` over no + /// rails is vacuously true, and would read as Live). #[test] fn no_interval_is_no_verdict_and_no_cpu_rail_is_absent() { let mut w = RailWatch::default(); @@ -2100,6 +2196,11 @@ mod ioreport { w.observe(&[("GPU Energy".to_string(), 2)], STEP); w.observe(&[("GPU Energy".to_string(), 3)], STEP * 2); assert_eq!(w.verdict(), Some(CpuRail::Absent)); + let (w, taken, _) = + scripted(|i| Some(vec![("GPU Energy".to_string(), 1_000_000 * i as i64)]), STEP, BUDGET); + assert_eq!(w.verdict(), Some(CpuRail::Absent), "a group with no CPU rail, watched {:?}", w.window); + assert_eq!(status_of(w.verdict()), crate::IoReportStatus::CpuRailAbsent); + assert_eq!((w.intervals, taken), (33, 34), "a group with no CPU rail must be watched for the whole budget"); } /// The stopping rule on its own: within the budget keep watching until @@ -2238,6 +2339,11 @@ mod ioreport { ] } + /// [`m3`] as the FFI hands it over: each channel an Energy Model item. + fn m3_items(i: usize, d0: i64, d1: i64) -> Vec { + m3(i, d0, d1).into_iter().map(|(name, raw)| item(&name, raw)).collect() + } + fn die_0(i: usize) -> i64 { 21_170_718 + 1_000 * i.div_ceil(2) as i64 } @@ -2433,6 +2539,36 @@ mod ioreport { } } + /// Two dies through [`probe_with`]: DIE_0 rising at every interval + /// beside a DIE_1 that holds is Frozen, after the whole budget, with + /// each die's own advances in the record; both rising is Live at the + /// tenth interval and stops the probe there, as one rail does. + #[test] + fn probe_with_one_live_die_beside_a_frozen_die_is_frozen() { + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { + script: |i| Some(m3_items(i, 21_170_718 + i as i64, 23_300_102)), + calls: Rc::new(Cell::new(0)), + }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Frozen), "one live die beside a frozen one: {:?}", advances(w)); + assert_eq!(status_of(run.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!((w.intervals, run.samples.len()), (33, 34)); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 33), rail("DIE_1_CPU Energy#0", 0)]); + + let (clocks, ticks) = (FakeClocks::new(), Cell::new(0)); + let sub = Scripted { + script: |i| Some(m3_items(i, 21_170_718 + i as i64, 23_300_102 + i as i64)), + calls: Rc::new(Cell::new(0)), + }; + let run = probe_with(|| Some(sub), fake_tick(&clocks, &ticks), &clocks, BUDGET); + let w = run.watch.as_ref().expect("the scripted subscription opened"); + assert_eq!(run.verdict(), Some(CpuRail::Live), "both dies rising: {:?}", advances(w)); + assert_eq!((run.samples.len(), ticks.get()), (11, 10)); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", 10), rail("DIE_1_CPU Energy#0", 10)]); + } + /// [`group_sample`] counts every channel, counts the ones outside the /// group, and hands on each in-group channel's own raw value, in order. #[test] @@ -2843,6 +2979,29 @@ mod ioreport { assert_eq!(advances(w), in_turn_advances(w.intervals)); } + /// One die live beside a frozen one, through the production + /// composition [`host_status`] (the real tick and clocks, scripted + /// reads): `CpuRailFrozen`, DIE_0 advancing in every interval and + /// DIE_1 in none. + #[test] + fn host_status_on_one_live_die_beside_a_frozen_die_is_frozen() { + let _hw = crate::ioreport_hw_lock(); + let (status, run, reads, _) = + host_status_on_script(|i| Some(m3_items(i, 21_170_718 + i as i64, 23_300_102))); + let Some(w) = &run.watch else { + return no_energy_model_group_here(status, &reads); + }; + assert_ticks_burned(&reads, "host_status on one live die beside a frozen one"); + assert_eq!( + status, + crate::IoReportStatus::CpuRailFrozen, + "one live die beside a frozen one read as live: {:?}", + advances(w) + ); + assert!(w.intervals >= TEST_MIN_ADVANCES, "Frozen on {} intervals", w.intervals); + assert_eq!(advances(w), [rail("DIE_0_CPU Energy#0", w.intervals), rail("DIE_1_CPU Energy#0", 0)]); + } + // ── Against the hardware ──────────────────────────────────────────── /// The median of some durations (the upper one of an even count). @@ -4103,19 +4262,22 @@ pub enum IoReportStatus { /// The Energy Model group was sampled, but it has no `CPU Energy` rail — an /// unfamiliar channel naming. Never selected. CpuRailAbsent, - /// The CPU energy rail exists but did not keep advancing while CPU was - /// burned: no one CPU rail advanced in at least five separate sample - /// intervals, making up at least 80% of those observed, over at least - /// 300 ms. It held, or stepped a few times together — a batch refresh, not - /// a live reading. The platform - /// is not exposing live CPU energy to this process, so selecting the rung - /// would grade a whole refresh, landing in one span, as a hardware reading - /// of that span's CPU work. Observed on macOS 27.0.1 on both an M5 Max and - /// an M3 Ultra. + /// A CPU energy rail exists but not every one kept advancing while CPU + /// was burned: some CPU rail did not advance in at least five separate + /// sample intervals, making up at least 80% of those observed, over at + /// least 300 ms. It held, or stepped a few times together — a batch + /// refresh, not a live reading. The platform is not exposing live CPU + /// energy to this process, so selecting the rung would grade a whole + /// refresh, landing in one span, as a hardware reading of that span's CPU + /// work. On a two-die part one die's rail may be live and the other's + /// not; that is Frozen too, since the counter sums both dies and would miss + /// the frozen one's energy. Observed on macOS 27.0.1 on both an M5 Max and + /// an M3 Ultra, where every CPU rail held. CpuRailFrozen, - /// The same CPU energy rail kept advancing under load: in at least five - /// separate sample intervals, making up at least 80% of those observed, - /// over at least 300 ms. The rung is selected. + /// Every CPU energy rail kept advancing under load (`CPU Energy`, or each + /// die's on a multi-die part): each in at least five separate sample + /// intervals, making up at least 80% of those observed, over at least + /// 300 ms. The rung is selected. Live, } @@ -4124,9 +4286,10 @@ pub enum IoReportStatus { /// to the Energy Model group, ~5–12 ms a sample, after every 25 ms of the /// calling thread's own CPU time (a tick that cannot get that in 500 ms of wall /// time stops there) — about 30 intervals in the budget on an idle host, fewer -/// under load, each a sample and the one before it. The same CPU rail must -/// keep advancing: in at least five intervals, making up at least 80% of those -/// observed, over at least 300 ms from the first sample. A live Energy Model +/// under load, each a sample and the one before it. Every CPU rail must keep +/// advancing (each die's, on a multi-die part): each in at least five +/// intervals, making up at least 80% of those observed, over at least 300 ms +/// from the first sample. A live Energy Model /// rail changes many times a second (the M5 Max GPU rail every ~28 ms), so it /// is called live about 300 ms in, and one second is generous. A sample that /// fails is skipped. If samples are slow or failing enough that fewer than five @@ -4200,7 +4363,7 @@ pub fn ioreport_energy_model_opens() -> usize { #[derive(Debug)] pub enum Meter { /// Hardware CPU package energy → `Provenance::HwShunt`. [`Meter::detect`] - /// selects it only while a package counter is live ([`rapl_status`]), and + /// selects it only while every package counter is live ([`rapl_status`]), and /// a span over which no package counter advanced is the time×power model's /// figure. Rapl(Rapl), @@ -4214,8 +4377,8 @@ pub enum Meter { Nvml(Box), /// Apple Silicon IOReport Energy Model energy → `Provenance::HwShunt`, the /// grade of RAPL's and NVML's vendor counters. [`Meter::detect`] selects it - /// only while the CPU energy rail is live, and a span over which that rail - /// did not advance is the time×power model's figure (`ioreport_span`). + /// only while every CPU energy rail is live, and a span over which no CPU + /// rail advanced is the time×power model's figure (`ioreport_span`). #[cfg(target_os = "macos")] IoReport(ioreport::IoReport), /// Wall-clock × calibrated active power → `Provenance::ModelBased`. @@ -4738,6 +4901,14 @@ mod tests { live.observe(rail(i64::from(k)), Duration::from_millis(100) * k); } assert_eq!(liveness::status_of(Some(&live)), CounterStatus::Live); + // Samples that carry no rail at all, past the 300 ms window: no rail + // is not every rail advancing, so never Live. + let mut empty = liveness::Advances::default(); + for k in 0..12u32 { + empty.observe(Vec::new(), Duration::from_millis(100) * k); + } + assert!(!empty.is_live(), "a counter with no rail read as live over {:?}", empty.window); + assert_eq!(liveness::status_of(Some(&empty)), CounterStatus::Frozen); } /// The tests' interval for a CPU-counter probe: a 25 ms tick and a short @@ -4777,11 +4948,11 @@ mod tests { } /// The RAPL gate on scripted package counters, through the probe the RAPL - /// rung runs. Live only when one package keeps advancing; a counter that - /// holds (a frozen or virtualised powercap tree), one republished in a - /// lump or two steps, packages stepping in turn, and a wrap are all - /// Frozen; a tree that does not open is Unavailable without a sample or a - /// tick. + /// rung runs. Live only when every package keeps advancing; one package + /// advancing beside one that holds, a counter that holds (a frozen or + /// virtualised powercap tree), one republished in a lump or two steps, + /// packages stepping in turn, and a wrap are all Frozen; a tree that does + /// not open is Unavailable without a sample or a tick. #[test] fn the_rapl_gate_on_scripted_package_counters() { let ms = Duration::from_millis; @@ -4793,6 +4964,14 @@ mod tests { let w = w.expect("an opened counter was not watched"); assert_eq!(status, CounterStatus::Live, "{:?}", w.rails); assert_eq!((w.intervals, reads, ticks, w.window), (10, 11, 10, ms(310)), "a live counter must stop the probe at once"); + // Frozen: package 0 rises at every interval, package 1 holds. A span's + // figure sums both, so it would leave package 1's energy out. + let (status, w, reads, _) = + scripted_counter(|i| packages(1_000_000 + 400_000 * i as i64, 9_000_000), COUNTER_STEP); + let w = w.unwrap(); + assert_eq!(status, CounterStatus::Frozen, "one live package beside a held one read as live: {:?}", w.rails); + assert_eq!((w.intervals, reads), (33, 34)); + assert_eq!(w.rails.iter().map(|(_, n)| *n).collect::>(), [33, 0], "{:?}", w.rails); // Frozen: a counter that holds, after the whole budget (33 intervals). let (status, w, reads, _) = scripted_counter(|_| packages(1_000_000, 9_000_000), COUNTER_STEP); let w = w.unwrap(); @@ -4900,11 +5079,38 @@ mod tests { /// The real RAPL probe — the real reader on files, the CPU-burning tick, /// the real clock, one budget — on fake powercap trees. A tree whose - /// counters hold is Frozen; one whose counter another thread advances - /// every 2 ms is Live; no tree, or a tree whose `energy_uj` is not a - /// number, is Unavailable, as RAPL was declined before. + /// counters hold is Frozen; one whose two packages another thread + /// advances every 2 ms is Live; one where that thread advances package 0 + /// alone, package 1 holding, is Frozen; no tree, or a tree whose + /// `energy_uj` is not a number, is Unavailable, as RAPL was declined + /// before. #[test] fn the_rapl_probe_on_fake_powercap_trees() { + /// The real probe on `tree` while another thread advances the + /// packages in `advancing` by 4 mJ every 2 ms. + fn probe_while_advancing(tree: &FakePowercap, advancing: &[usize]) -> CounterStatus { + let stop = std::sync::atomic::AtomicBool::new(false); + std::thread::scope(|s| { + s.spawn(|| { + let mut uj: u64 = 1_000; + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + uj += 4_000; + for &k in advancing { + tree.write(k, &uj.to_string()); + } + std::thread::sleep(Duration::from_millis(2)); + } + }); + let started = std::time::Instant::now(); + let status = rapl_status_on(|| Rapl::open_in(&tree.root)); + eprintln!( + "rapl probe: a fake tree with packages {advancing:?} advancing every 2 ms -> {status:?} in {:?}", + started.elapsed() + ); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + status + }) + } #[cfg(target_os = "macos")] let _hw = ioreport_hw_lock(); let frozen = FakePowercap::new(&["73176481", "1000"]); @@ -4914,23 +5120,14 @@ mod tests { assert_eq!(status, CounterStatus::Frozen, "a powercap tree whose counters hold was not declined"); let live = FakePowercap::new(&["1000", "1000"]); - let stop = std::sync::atomic::AtomicBool::new(false); - let status = std::thread::scope(|s| { - s.spawn(|| { - let mut uj: u64 = 1_000; - while !stop.load(std::sync::atomic::Ordering::SeqCst) { - uj += 4_000; - live.write(0, &uj.to_string()); - std::thread::sleep(Duration::from_millis(2)); - } - }); - let started = std::time::Instant::now(); - let status = rapl_status_on(|| Rapl::open_in(&live.root)); - eprintln!("rapl probe: a fake tree advancing every 2 ms -> {status:?} in {:?}", started.elapsed()); - stop.store(true, std::sync::atomic::Ordering::SeqCst); - status - }); - assert_eq!(status, CounterStatus::Live, "a powercap counter advancing every 2 ms was not live"); + let status = probe_while_advancing(&live, &[0, 1]); + assert_eq!(status, CounterStatus::Live, "two packages advancing every 2 ms were not live"); + let half = FakePowercap::new(&["1000", "1000"]); + assert_eq!( + probe_while_advancing(&half, &[0]), + CounterStatus::Frozen, + "package 0 advancing beside a held package 1 was not declined" + ); let gone = FakePowercap::new(&[]); assert_eq!(rapl_status_on(|| Rapl::open_in(&gone.root.join("absent"))), CounterStatus::Unavailable); -- 2.54.0 (Apple Git-157) From 25bfdcd98dfde487fa91efe5c1e729d9d3eb5b81 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 21:57:07 -0400 Subject: [PATCH 10/10] jcp-energy: the RAPL figure sums the package zones by name, never psys or dram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The RAPL reader opened every top-level powercap directory named `intel-rapl:` and summed them as the CPU package figure, and its liveness probe watched every one of them. A top-level zone is not necessarily a package. Many Intel hosts expose `psys`, the platform zone, at the top level (`intel-rapl:1` on a typical laptop), and its count already includes the packages', so the figure counted package energy twice and graded it `HwShunt`. Some platforms put a `dram` zone at the top level, which is not CPU energy at all. The probe had the mirror-image fault: a live `psys` and a held `dram` zone decided whether the package counter was live. The fix is the rule proof-cascade already follows (9334ec0c): the figure is the sum of every top-level zone whose `name` file reads `package-`, every socket's, and nothing else. `psys`, `dram`, a package's subzones (`intel-rapl:0:0`, its `core`, `uncore` or `dram`) and the second interface to the same packages (`intel-rapl-mmio:`) are neither summed nor probed. The liveness sample is built from the opened packages, so the rule that every rail must keep advancing now applies to every package and only to packages. A host whose top level holds no package zone (only `psys`) opens no reader and is `Unavailable` without a probe. Two holes that would leave a package out of a figure graded whole close with it. A package zone whose `energy_uj` does not read used to be skipped, so on a two-socket host the figure could be one socket's energy claiming the CPU; now the reader does not open. A top-level zone whose `name` does not read may be a package, so it declines the reader too. Wrap handling, checked per zone. Each package was already corrected at its own `max_energy_range_uj`, but a range that did not read defaulted to `u64::MAX`, so a counter that fell across a span was booked as about 1.8e19 µJ, `HwShunt`. A range that does not read, or reads zero, is now unknown, and a fall with an unknown range, or from above the range, gives no figure: the span is the time×power model's, as a failed read is. A package snapshot that does not match the opened packages gives none either. The SPEC §5.1 RAPL entry now names the package zones and says why `psys` and `dram` are not summed. Tests, on fake powercap trees in the temp directory (they run on macOS): - `rapl_sums_and_probes_the_package_zones_only`: a two-socket tree with `psys`, a top-level `dram` zone, `core`/`dram` subzones and an MMIO interface. The reader holds exactly `intel-rapl:0` and `intel-rapl:2`, in zone order, each with its own range; the liveness sample names those two; a span over which every zone rose is metered as the two packages' 8,000 µJ alone; package 1 wrapping at its own 65,532,610,987 µJ range while `psys` and `dram` also fall is 12,487 µJ. The real probe is Live with both packages advancing and `psys`/`dram` holding, and Frozen with `psys`, `dram`, the MMIO zone and package 0 advancing while package 1 holds. - `a_rapl_figure_never_leaves_a_package_out_or_takes_another_zone`: a tree of `psys` and `dram` (with a `package-0` subzone) opens nothing and is Unavailable without burning; a package whose `energy_uj` does not parse beside a readable one, and an unnamed top-level zone, do not open; a package with no range meters a rise and gives the model's figure for a fall. - `a_rapl_zone_wraps_at_its_own_range_or_gives_no_figure` and `a_rapl_package_is_a_zone_named_package_n`: the per-zone delta and the name and directory matchers, edge cases included. - The existing fake trees now carry `name` files; every earlier RAPL test is unchanged in what it asserts. Falsified, each restored through git before the next: - selecting by directory name again (the `name` check disabled) fails `rapl_sums_and_probes_the_package_zones_only` — the reader held `intel-rapl:0..3`, `psys` and `dram` among them — and `a_rapl_figure_never_leaves_a_package_out_or_takes_another_zone` — "a tree with no package zone opened"; - an unreadable range defaulting to `u64::MAX` again fails the latter: the fall was metered as `HwShunt`, 18,446,744,073,709,550,715 µJ. jcp-energy (70 unit tests + the fresh-process pin), jcp-intercept, jcp-conformance (its tests and the binary: all checks passed) and jcp-cli pass on macOS (M5 Max, macOS 27.0.1). `cargo clippy --all-targets` on those four is clean for aarch64-apple-darwin and x86_64-unknown-linux-gnu apart from a pre-existing jcp-schema lint. The reader was not run against a real Linux sysfs here. Co-Authored-By: Claude Opus 5.5 --- jcp/SPEC.md | 6 +- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 393 ++++++++++++++++++++---- 2 files changed, 343 insertions(+), 56 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 3dd214a3a..8800979b4 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -422,7 +422,11 @@ Recommended ladder, best first: 1. **RAPL** (Linux `powercap`) — package energy counters, wraparound-corrected → `HwShunt`, selected only while every package counter is live under the probe's CPU burn, by the rule above as IOReport's CPU rails are. A span over - which no package counter advanced is metered by a lower rung. + which no package counter advanced is metered by a lower rung. The package + counters are the top-level zones whose `name` is `package-`, every + socket's, each corrected at its own `max_energy_range_uj`; no other zone is + summed or probed. A `psys` zone already includes the packages, so summing it + beside them counts their energy twice, and a `dram` zone is not CPU energy. 2. **NVML** — the GPU total-energy counter → `HwShunt`, `Partial` (§5.2): it reads the GPU and not the CPU's share of the work. Selected only while the counter is live by the same rule; the probe cannot load the GPU, so for diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index edac7082e..a20556a00 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -15,9 +15,13 @@ //! whole domain. Each probe runs once per process, and each rung's status says //! why it was declined. //! 1. **RAPL** (`/sys/class/powercap`, Linux) — hardware package energy, -//! `Provenance::HwShunt`, wraparound-corrected, **only while every package -//! counter is live** under the probe's CPU burn ([`rapl_status`]). A span -//! over which no package counter advanced is the time×power model's figure. +//! `Provenance::HwShunt`, **only while every package counter is live** +//! under the probe's CPU burn ([`rapl_status`]). The figure sums the +//! top-level zones whose `name` is `package-`, every socket's, each +//! wraparound-corrected at its own range; never `psys` (the platform zone, +//! which already includes the packages), a top-level `dram` zone, or a +//! package's subzones. A span over which no package counter advanced is the +//! time×power model's figure. //! 2. **NVML** (feature `nvml`) — GPU energy via `libnvidia-ml`, //! `Provenance::HwShunt` with `Coverage::Partial`: the counter reads the //! GPU, so the CPU's share of the work is unmetered and the figure never @@ -66,17 +70,49 @@ use jcp_schema::{Energy, Provenance}; pub const DEFAULT_ACTIVE_MILLIWATTS: u64 = 15_000; /// RAPL package-energy reader (Linux `powercap`). Reads `energy_uj` for every -/// top-level `intel-rapl:N` package and corrects counter wraparound with +/// top-level zone (`intel-rapl:N`) whose `name` file reads `package-` — every +/// socket's package — and corrects each counter's wraparound at that zone's own /// `max_energy_range_uj`. +/// +/// The figure is the packages' energy and nothing else. A top-level zone is not +/// necessarily a package: many Intel hosts expose `psys`, the platform zone, +/// whose count already includes the packages', so summing it beside them counts +/// that energy twice; some expose a top-level `dram` zone, which is not CPU +/// energy at all. Subzones (`intel-rapl:0:0`, a package's `core`, `uncore` or +/// `dram`) are a share of their package and never summed, and a second +/// interface to the same packages (`intel-rapl-mmio:N`) is not read either. #[derive(Debug)] pub struct Rapl { - /// `(energy_uj path, max_energy_range_uj)` per package. - packages: Vec<(std::path::PathBuf, u64)>, + /// `(energy_uj path, max_energy_range_uj)` per package, in zone order. The + /// range is `None` where it could not be read, or read as zero: a counter + /// that falls across a span then has no known wrap, and gives no figure. + packages: Vec<(std::path::PathBuf, Option)>, +} + +/// Whether a powercap zone's `name` file names a CPU package: `package-`, +/// `` one or more ASCII digits, trailing newline allowed. Not `psys`, `dram`, +/// `core`, `uncore`, or anything else. +fn is_rapl_package_name(name: &str) -> bool { + name.trim() + .strip_prefix("package-") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) +} + +/// The zone index of a top-level RAPL zone directory, `intel-rapl:`; `None` +/// for anything else — the `intel-rapl` control type itself, a subzone +/// (`intel-rapl:0:0`), or another interface (`intel-rapl-mmio:0`). +fn rapl_top_level_zone(dir_name: &str) -> Option { + let n = dir_name.strip_prefix("intel-rapl:")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + n.parse().ok() } impl Rapl { /// Open the RAPL interface. `None` when the platform has no readable - /// powercap tree (non-Linux, locked-down sysfs, no Intel/AMD RAPL). + /// powercap tree (non-Linux, locked-down sysfs, no Intel/AMD RAPL), no + /// package zone, or a package zone whose counter cannot be read. /// Opening is not selection: [`Meter::detect`] selects the rung only while /// [`rapl_status`] is [`CounterStatus::Live`]. pub fn open() -> Option { @@ -85,34 +121,37 @@ impl Rapl { /// [`Rapl::open`] on the powercap tree at `root`, so the probe runs on fake /// trees in tests. + /// + /// Every package zone or none: the figure claims the whole CPU, so a + /// package whose `energy_uj` cannot be read is not left out of it — the + /// reader does not open. Nor does it open when a top-level zone's `name` + /// cannot be read, since that zone may be a package. fn open_in(root: &std::path::Path) -> Option { let mut packages = Vec::new(); let dir = std::fs::read_dir(root).ok()?; for entry in dir.flatten() { - let name = entry.file_name(); - let name = name.to_string_lossy(); - // top-level packages only: "intel-rapl:0", not "intel-rapl:0:0" - if !name.starts_with("intel-rapl:") || name.matches(':').count() != 1 { + let dir_name = entry.file_name(); + let Some(index) = rapl_top_level_zone(&dir_name.to_string_lossy()) else { + continue; + }; + let name = std::fs::read_to_string(entry.path().join("name")).ok()?; + if !is_rapl_package_name(&name) { continue; } let energy = entry.path().join("energy_uj"); // must actually be readable, not just present - let readable = std::fs::read_to_string(&energy) - .map(|s| s.trim().parse::().is_ok()) - .unwrap_or(false); - if !readable { - continue; - } + std::fs::read_to_string(&energy).ok()?.trim().parse::().ok()?; let range = std::fs::read_to_string(entry.path().join("max_energy_range_uj")) .ok() .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(u64::MAX); - packages.push((energy, range)); + .filter(|&r| r > 0); + packages.push((index, energy, range)); } + packages.sort_by_key(|(index, _, _)| *index); if packages.is_empty() { None } else { - Some(Self { packages }) + Some(Self { packages: packages.into_iter().map(|(_, energy, range)| (energy, range)).collect() }) } } @@ -124,19 +163,27 @@ impl Rapl { .collect() } - /// Energy spent between two counter snapshots, wraparound-corrected. - fn delta(&self, before: &[u64], after: &[u64]) -> u64 { - self.packages - .iter() - .zip(before.iter().zip(after)) - .map(|((_, range), (b, a))| if a >= b { a - b } else { range.saturating_sub(*b).saturating_add(*a) }) - .sum() + /// Energy spent between two counter snapshots: the sum of each package's + /// own delta, each wraparound-corrected at that package's own + /// `max_energy_range_uj` ([`rapl_zone_delta`]). `None` — no figure, never + /// an invented one — if any package's delta is unknown. + fn delta(&self, before: &[u64], after: &[u64]) -> Option { + if before.len() != self.packages.len() || after.len() != self.packages.len() { + return None; + } + let mut total = 0u64; + for ((_, range), (b, a)) in self.packages.iter().zip(before.iter().zip(after)) { + total = total.checked_add(rapl_zone_delta(*b, *a, *range)?)?; + } + Some(total) } /// One liveness sample: each package counter as a rail, named by its /// powercap directory (`intel-rapl:0`, ...), with its raw `energy_uj`. - /// `None` when a package cannot be read, which the probe skips. A wrap - /// shows as a fall, which the rule does not count as an advance. + /// Only the package zones the figure sums: `psys`, `dram` and subzones are + /// neither summed nor probed. `None` when a package cannot be read, which + /// the probe skips. A wrap shows as a fall, which the rule does not count + /// as an advance. fn liveness_sample(&self) -> Option> { let values = self.read()?; let named: Vec<(String, i64)> = self @@ -152,6 +199,19 @@ impl Rapl { } } +/// One RAPL zone's energy between two reads of its `energy_uj`, modulo its own +/// wrap. A counter that went down wrapped at `max_range` (its +/// `max_energy_range_uj`): the energy is what it had left to the wrap plus what +/// it counted since. Without a known range, or with a first read above it, the +/// delta is unknown: `None`. It cannot see more than one wrap in a span — +/// minutes at package power. +fn rapl_zone_delta(before: u64, after: u64, max_range: Option) -> Option { + if after >= before { + return Some(after - before); + } + max_range?.checked_sub(before)?.checked_add(after) +} + /// NVML GPU-energy reader (feature `nvml`). Uses the device's total-energy /// counter (millijoules since driver load). #[cfg(feature = "nvml")] @@ -543,9 +603,9 @@ mod liveness { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CounterStatus { /// The counter could not be opened or read — absent (not this platform, - /// no such device, the feature not built), unreadable (a locked-down - /// sysfs), or it gave fewer than two usable samples in five budgets. Never - /// selected. + /// no such device, the feature not built, for RAPL no zone named + /// `package-`), unreadable (a locked-down sysfs), or it gave fewer than + /// two usable samples in five budgets. Never selected. Unavailable, /// The counter opened, but not every rail of it kept advancing while the /// probe ran: one held, or stepped a few times together — republished in @@ -591,14 +651,18 @@ fn rapl_probe_runs() -> usize { /// This host's RAPL status, probed once per process and cached. /// /// [`Meter::detect`] selects the RAPL rung only when this is -/// [`CounterStatus::Live`]: every package counter (`intel-rapl:N/energy_uj`) -/// kept advancing while the probing thread burned CPU, each in at least five -/// sample intervals, at least 80% of them, over at least 300 ms — the rule -/// IOReport's CPU rails are held to. Every package, because a span's figure +/// [`CounterStatus::Live`]: every package counter (the `energy_uj` of each +/// top-level `intel-rapl:N` zone whose `name` is `package-`) kept advancing +/// while the probing thread burned CPU, each in at least five sample +/// intervals, at least 80% of them, over at least 300 ms — the rule IOReport's +/// CPU rails are held to. Every package, because a span's figure /// sums them all: one live package beside a frozen one would leave the frozen /// package's energy out of a figure graded whole. A counter that holds, or is /// republished in lumps (a virtualised powercap tree can be either), is -/// declined to the time×power model. Where there is no readable powercap tree — not Linux, no RAPL, or an +/// declined to the time×power model. Only the package zones are probed, as +/// only they are summed: a live `psys` or `dram` zone beside a held package +/// does not make the counter live. Where there is no readable powercap tree — +/// not Linux, no RAPL, no package zone (a host exposing only `psys`), or an /// `energy_uj` only root may read — it is [`CounterStatus::Unavailable`] /// without burning anything, as RAPL was declined before. The first call on a /// host with a readable tree burns about 300 ms of the calling thread's CPU @@ -4483,8 +4547,9 @@ impl Meter { let elapsed = started.elapsed(); // A read that failed (the interface vanished mid-span) is no // rise, and degrades honestly. + // So is a package that fell with no known wrap. let rose = match (before, r.read()) { - (Some(b), Some(a)) => Some(r.delta(&b, &a)), + (Some(b), Some(a)) => r.delta(&b, &a), _ => None, }; (out, counter_span(rose, elapsed.as_nanos(), Energy::measured)) @@ -5037,35 +5102,63 @@ mod tests { } /// A fake powercap tree in the OS temp directory, removed when dropped: - /// `intel-rapl:N` packages with an `energy_uj` and a - /// `max_energy_range_uj`, and a `intel-rapl:0:0` subzone the reader must - /// skip. + /// zone directories each with a `name`, an `energy_uj` and a + /// `max_energy_range_uj`, as sysfs lays them out. struct FakePowercap { root: std::path::PathBuf, } + /// One zone of a [`FakePowercap`]: its directory, its `name` file (`None`: + /// no such file), its `energy_uj`, and its `max_energy_range_uj` (`None`: + /// no such file). + type FakeZone<'a> = (&'a str, Option<&'a str>, &'a str, Option<&'a str>); + impl FakePowercap { + /// `intel-rapl:N` zones named `package-N`, one per value, wrapping at + /// 262,143,328,850 µJ, and an `intel-rapl:0:0` `core` subzone the + /// reader must skip. fn new(packages: &[&str]) -> Self { + let dirs: Vec<(String, String)> = + (0..packages.len()).map(|k| (format!("intel-rapl:{k}"), format!("package-{k}"))).collect(); + let mut zones: Vec> = dirs + .iter() + .zip(packages) + .map(|((dir, name), value)| (dir.as_str(), Some(name.as_str()), *value, Some("262143328850"))) + .collect(); + zones.push(("intel-rapl:0:0", Some("core"), "5", Some("262143328850"))); + Self::with_zones(&zones) + } + + /// A tree of exactly these zones. + fn with_zones(zones: &[FakeZone<'_>]) -> Self { static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst); let root = std::env::temp_dir().join(format!("jcp-energy-powercap-{}-{n}", std::process::id())); let fake = FakePowercap { root }; - for (k, value) in packages.iter().enumerate() { - let dir = fake.root.join(format!("intel-rapl:{k}")); - std::fs::create_dir_all(&dir).expect("create a fake package"); - std::fs::write(dir.join("max_energy_range_uj"), "262143328850\n").unwrap(); - fake.write(k, value); + std::fs::create_dir_all(&fake.root).expect("create a fake powercap tree"); + for (dir, name, value, range) in zones { + let zone = fake.root.join(dir); + std::fs::create_dir_all(&zone).expect("create a fake zone"); + if let Some(name) = name { + std::fs::write(zone.join("name"), format!("{name}\n")).unwrap(); + } + if let Some(range) = range { + std::fs::write(zone.join("max_energy_range_uj"), format!("{range}\n")).unwrap(); + } + fake.write_zone(dir, value); } - let sub = fake.root.join("intel-rapl:0:0"); - std::fs::create_dir_all(&sub).unwrap(); - std::fs::write(sub.join("energy_uj"), "5\n").unwrap(); fake } - /// Replace package `k`'s `energy_uj`, atomically, so a reader sees - /// the old value or the new one. + /// Replace package `k`'s `energy_uj` (zone `intel-rapl:k`). fn write(&self, k: usize, value: &str) { - let dir = self.root.join(format!("intel-rapl:{k}")); + self.write_zone(&format!("intel-rapl:{k}"), value); + } + + /// Replace zone `dir`'s `energy_uj`, atomically, so a reader sees the + /// old value or the new one. + fn write_zone(&self, dir: &str, value: &str) { + let dir = self.root.join(dir); std::fs::write(dir.join("energy_uj.tmp"), format!("{value}\n")).unwrap(); std::fs::rename(dir.join("energy_uj.tmp"), dir.join("energy_uj")).unwrap(); } @@ -5219,10 +5312,200 @@ mod tests { #[test] fn rapl_wraparound_delta() { let r = Rapl { - packages: vec![(std::path::PathBuf::from("/dev/null"), 1_000)], + packages: vec![(std::path::PathBuf::from("/dev/null"), Some(1_000))], }; - assert_eq!(r.delta(&[100], &[400]), 300); + assert_eq!(r.delta(&[100], &[400]), Some(300)); // counter wrapped at 1_000: 900 → 50 means 150 spent - assert_eq!(r.delta(&[900], &[50]), 150); + assert_eq!(r.delta(&[900], &[50]), Some(150)); + // a snapshot that does not match the packages is no figure + assert_eq!(r.delta(&[900, 1], &[950, 2]), None); + } + + /// One zone's delta, modulo its own wrap: a rise is the rise; a fall + /// wrapped at the zone's range; a fall with no known range, or from above + /// the range, is unknown, never an invented figure. + #[test] + fn a_rapl_zone_wraps_at_its_own_range_or_gives_no_figure() { + assert_eq!(rapl_zone_delta(100, 400, None), Some(300)); + assert_eq!(rapl_zone_delta(7, 7, Some(1_000)), Some(0)); + assert_eq!(rapl_zone_delta(900, 50, Some(1_000)), Some(150)); + assert_eq!(rapl_zone_delta(65_532_600_000, 500, Some(65_532_610_987)), Some(11_487)); + assert_eq!(rapl_zone_delta(900, 50, None), None, "a fall with no known range gave a figure"); + assert_eq!(rapl_zone_delta(2_000, 50, Some(1_000)), None, "a fall from above the range gave a figure"); + // Each package at its own range, summed: package 0 rose 1,000 µJ, + // package 1 wrapped at its own (smaller) range. + let r = Rapl { + packages: vec![ + (std::path::PathBuf::from("/dev/null"), Some(262_143_328_850)), + (std::path::PathBuf::from("/dev/null"), Some(65_532_610_987)), + ], + }; + assert_eq!(r.delta(&[5_000, 65_532_600_000], &[6_000, 500]), Some(12_487)); + // One package's delta unknown: no figure for the span. + let r = Rapl { + packages: vec![ + (std::path::PathBuf::from("/dev/null"), Some(262_143_328_850)), + (std::path::PathBuf::from("/dev/null"), None), + ], + }; + assert_eq!(r.delta(&[5_000, 900], &[6_000, 50]), None); + } + + /// The package zones by their `name` file, and no other: `package-`. + #[test] + fn a_rapl_package_is_a_zone_named_package_n() { + for name in ["package-0", "package-1\n", "package-12", " package-3 \n"] { + assert!(is_rapl_package_name(name), "{name:?}"); + } + for name in ["psys", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", "package0", ""] { + assert!(!is_rapl_package_name(name), "{name:?}"); + } + assert_eq!(rapl_top_level_zone("intel-rapl:0"), Some(0)); + assert_eq!(rapl_top_level_zone("intel-rapl:12"), Some(12)); + for dir in ["intel-rapl", "intel-rapl:", "intel-rapl:0:0", "intel-rapl:1:2", "intel-rapl-mmio:0", "intel-rapl:x"] { + assert_eq!(rapl_top_level_zone(dir), None, "{dir:?}"); + } + } + + /// A tree laid out as a two-socket Intel host with a platform zone: two + /// packages (`intel-rapl:0`, `intel-rapl:2`), `psys` (`intel-rapl:1`), a + /// top-level `dram` zone (`intel-rapl:3`), each package's `core` and + /// `dram` subzones, and the MMIO interface to package 0. Each package + /// wraps at a range of its own. + fn two_sockets_with_psys_and_dram() -> FakePowercap { + FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")), + ("intel-rapl:1", Some("psys"), "90000", Some("262143328850")), + ("intel-rapl:2", Some("package-1"), "2000", Some("65532610987")), + ("intel-rapl:3", Some("dram"), "30000", Some("65532610987")), + ("intel-rapl:0:0", Some("core"), "700", Some("262143328850")), + ("intel-rapl:0:1", Some("dram"), "800", Some("65532610987")), + ("intel-rapl:2:0", Some("core"), "900", Some("262143328850")), + ("intel-rapl-mmio:0", Some("package-0"), "1000", Some("262143328850")), + ]) + } + + /// The figure and the probe take the package zones and nothing else. On + /// a tree with two packages, `psys`, a top-level `dram` zone, subzones and + /// an MMIO interface: the reader holds the two packages, in zone order; + /// the liveness sample names those two; a span's figure is their rise + /// alone, although every other zone rose too; a package wrapping at its own + /// range is corrected at that range. And the real probe is Live while the + /// two packages advance with `psys` and `dram` holding, Frozen while + /// `psys`, `dram` and one package advance with the other package holding. + #[test] + fn rapl_sums_and_probes_the_package_zones_only() { + #[cfg(target_os = "macos")] + let _hw = ioreport_hw_lock(); + let tree = two_sockets_with_psys_and_dram(); + let r = Rapl::open_in(&tree.root).expect("a tree with two package zones opens"); + let opened: Vec<_> = r.packages.iter().map(|(p, range)| (p.strip_prefix(&tree.root).unwrap().to_owned(), *range)).collect(); + assert_eq!( + opened, + [ + (std::path::PathBuf::from("intel-rapl:0/energy_uj"), Some(262_143_328_850)), + (std::path::PathBuf::from("intel-rapl:2/energy_uj"), Some(65_532_610_987)), + ], + "the reader did not hold exactly the two package zones" + ); + let sample = r.liveness_sample().expect("the packages read"); + let probed: Vec<(&str, i64)> = sample.iter().map(|(k, v)| (k.name.as_str(), *v)).collect(); + assert_eq!(probed, [("intel-rapl:0", 1_000), ("intel-rapl:2", 2_000)], "the probe watched other zones"); + + let meter = Meter::Rapl(r); + let (_, e) = meter.measure(|| { + tree.write_zone("intel-rapl:0", "4000"); + tree.write_zone("intel-rapl:1", "190000"); + tree.write_zone("intel-rapl:2", "7000"); + tree.write_zone("intel-rapl:3", "80000"); + tree.write_zone("intel-rapl:0:0", "2700"); + tree.write_zone("intel-rapl:0:1", "1800"); + tree.write_zone("intel-rapl-mmio:0", "4000"); + }); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(8_000)), "not the packages' rise alone: {e:?}"); + // Package 1 wraps at its own range, 65,532,610,987 µJ, as psys and + // dram wrap too: 65,532,600,000 -> 500 is 11,487 µJ, plus package 0's + // 1,000. + tree.write_zone("intel-rapl:2", "65532600000"); + let (_, e) = meter.measure(|| { + tree.write_zone("intel-rapl:0", "5000"); + tree.write_zone("intel-rapl:2", "500"); + tree.write_zone("intel-rapl:1", "10"); + tree.write_zone("intel-rapl:3", "10"); + }); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(12_487)), "{e:?}"); + drop(meter); + + /// The real probe on `tree` while another thread advances the zones + /// in `advancing` by 4 mJ every 2 ms. + fn probe_advancing(tree: &FakePowercap, advancing: &[&str]) -> CounterStatus { + let stop = std::sync::atomic::AtomicBool::new(false); + std::thread::scope(|s| { + s.spawn(|| { + let mut uj: u64 = 100_000; + while !stop.load(std::sync::atomic::Ordering::SeqCst) { + uj += 4_000; + for dir in advancing { + tree.write_zone(dir, &uj.to_string()); + } + std::thread::sleep(Duration::from_millis(2)); + } + }); + let status = rapl_status_on(|| Rapl::open_in(&tree.root)); + eprintln!("rapl probe: zones {advancing:?} advancing, the rest holding -> {status:?}"); + stop.store(true, std::sync::atomic::Ordering::SeqCst); + status + }) + } + let tree = two_sockets_with_psys_and_dram(); + assert_eq!( + probe_advancing(&tree, &["intel-rapl:0", "intel-rapl:2"]), + CounterStatus::Live, + "both packages advancing, psys and dram holding, was not live" + ); + let tree = two_sockets_with_psys_and_dram(); + assert_eq!( + probe_advancing(&tree, &["intel-rapl:0", "intel-rapl:1", "intel-rapl:3", "intel-rapl-mmio:0"]), + CounterStatus::Frozen, + "package 1 holding beside a live psys, dram and package 0 was not declined" + ); + } + + /// No package zone, no RAPL counter: a host exposing only `psys` (and a + /// `dram` zone) is Unavailable without a probe, never a figure. Nor does + /// the reader open with a package left out: one whose `energy_uj` does + /// not read, or a top-level zone whose `name` does not (it may be a + /// package). A package whose range does not read opens, and a span over + /// which it fell is the model's figure, not an invented reading. + #[test] + fn a_rapl_figure_never_leaves_a_package_out_or_takes_another_zone() { + let psys_only = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("psys"), "90000", Some("262143328850")), + ("intel-rapl:1", Some("dram"), "30000", Some("65532610987")), + ("intel-rapl:0:0", Some("package-0"), "1000", Some("262143328850")), + ]); + assert!(Rapl::open_in(&psys_only.root).is_none(), "a tree with no package zone opened"); + let started = std::time::Instant::now(); + assert_eq!(rapl_status_on(|| Rapl::open_in(&psys_only.root)), CounterStatus::Unavailable); + assert!(started.elapsed() < Duration::from_millis(250), "a tree with no package burned {:?}", started.elapsed()); + + let package_unreadable = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")), + ("intel-rapl:1", Some("package-1"), "not a number", Some("262143328850")), + ]); + assert!(Rapl::open_in(&package_unreadable.root).is_none(), "a package that does not read was left out"); + let unnamed = FakePowercap::with_zones(&[ + ("intel-rapl:0", Some("package-0"), "1000", Some("262143328850")), + ("intel-rapl:1", None, "2000", Some("262143328850")), + ]); + assert!(Rapl::open_in(&unnamed.root).is_none(), "a zone whose name does not read was left out"); + + let no_range = FakePowercap::with_zones(&[("intel-rapl:0", Some("package-0"), "900", None)]); + let meter = Meter::Rapl(Rapl::open_in(&no_range.root).expect("a package with no range opens")); + let (_, e) = meter.measure(|| no_range.write(0, "950")); + assert_eq!((e.provenance, e.measured_uj), (Provenance::HwShunt, Some(50)), "{e:?}"); + let (_, e) = meter.measure(|| no_range.write(0, "50")); + assert_eq!((e.provenance, e.measured_uj), (Provenance::ModelBased, None), "a fall with no range: {e:?}"); + e.check_provenance().unwrap(); } } -- 2.54.0 (Apple Git-157)