From 633dc71f82fc3d6a9dfcf7d285cb41bf39222820 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 5 Oct 2026 09:15:48 -0400 Subject: [PATCH] jcp-energy: the Neural Engine is reported only once its rail is proved live FabricMeter listed the Neural Engine as readable whenever IOReport opened, and reported its `ANE0` delta over every span. A rail that did not advance was read as zero, "on an idle fabric that is the truth". On macOS 27.0.1 an unprivileged reader's ANE0 is frozen. It did not advance in any of 25 sample intervals over 3 s of Core ML inference on the ANE, while root powermetrics read 0.47 J of ANE energy over the same span. So real Neural Engine work was reported as 0 J, which is what the ANE0 fix set out to stop. - `npu_rail_liveness(load)`, cached once per process. It runs the shared liveness rule over the ANE rails the counter sums, the `ANE Energy` aggregate or else every `ANE` rail, with the caller's Neural-Engine load between samples. A sample with no ANE rail is no sample, so a part without one is Unavailable, never a vacuous Live. - FabricMeter lists and reports the Neural Engine only once that proof says Live. - The survey now uses the shared rule instead of a single advance over 60 ms. Its CPU status comes from `ioreport_status()`, and its Neural Engine status from the proof. A rail republished in lumps passes a one-advance test. Tests: - the ANE rails the probe watches match the counter's sum; - the scripted probe gives Frozen for a holding ANE0, Live for an advancing one, Frozen for a lump and for one engine of two, and Unavailable with no ANE rail; - on this Mac the meter lists only the CPU and the GPU, and a load that never touches the ANE leaves its rail Frozen. Mutations caught: emitting the Neural Engine figure unproved, and dropping the empty-rail filter. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 182 ++++++++++++++++-- .../jcp-energy/tests/npu_needs_proof.rs | 31 +++ 2 files changed, 202 insertions(+), 11 deletions(-) create mode 100644 jcp/jcp-rs/crates/jcp-energy/tests/npu_needs_proof.rs diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index 65fdbca26..930183d30 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -1397,6 +1397,40 @@ mod ioreport { rails_where(sample, is_gpu_energy_rail) } + /// The Neural Engine rails in one sample, as the counter sums them + /// ([`sum_energy_uj`]): the `ANE Energy` aggregate where a part publishes + /// one, else every per-engine `ANE` rail. Never both. + fn ane_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + let aggregate = rails_where(sample, is_ane_energy_rail); + if aggregate.is_empty() { rails_where(sample, is_ane_base_rail) } else { aggregate } + } + + /// One Neural Engine liveness probe: the shared rule + /// ([`crate::liveness::probe`]) over the ANE rails of the Energy Model + /// group subscription, with `load` between samples. A sample with no ANE + /// rail is no sample, so a part without one is `Unavailable`, never a + /// vacuous `Live`. + pub(crate) fn npu_status(load: impl FnMut()) -> crate::CounterStatus { + npu_status_with(EnergyModelSubscription::open, load, Instant::now) + } + + /// [`npu_status`], generic over the subscription and the clock, so the + /// tests drive this very function with scripted samples. + fn npu_status_with( + open: impl FnOnce() -> Option, + load: impl FnMut(), + now: impl Fn() -> Instant, + ) -> crate::CounterStatus { + let probed = crate::liveness::probe( + open, + |sub: &S| sub.sample().map(|g| ane_rails(&g.values)).filter(|rails| !rails.is_empty()), + load, + now, + crate::liveness::BUDGET, + ); + crate::liveness::status_of(probed.as_ref()) + } + /// 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, @@ -2215,6 +2249,69 @@ mod ioreport { assert!(!is_ane_energy_rail("ANE0")); } + /// The Neural Engine probe watches the rails the counter sums: the + /// `ANE Energy` aggregate where a part has one, else every engine's + /// `ANE`, never both, and nothing else. + #[test] + fn the_npu_probe_watches_the_rails_the_counter_sums() { + let sample = |names: &[&str]| names.iter().map(|n| (n.to_string(), 7)).collect::>(); + let keys = |rails: Vec<(RailKey, i64)>| rails.into_iter().map(|(k, _)| k.name).collect::>(); + assert_eq!(keys(ane_rails(&sample(&["CPU Energy", "ANE0", "GPU Energy", "ANE0_0"]))), ["ANE0"]); + assert_eq!(keys(ane_rails(&sample(&["ANE0", "ANE1", "DIE_1_ANE0"]))), ["ANE0", "ANE1", "DIE_1_ANE0"]); + assert_eq!(keys(ane_rails(&sample(&["ANE Energy", "ANE0", "ANE1"]))), ["ANE Energy"]); + assert!(ane_rails(&sample(&["CPU Energy", "GPU Energy", "GPU0"])).is_empty()); + } + + /// The Neural Engine probe ([`npu_status_with`], the function the + /// cached liveness runs) on scripted Energy Model samples and a + /// scripted clock: + /// - an `ANE0` that holds through the load is Frozen. An unprivileged + /// reader's does that on macOS 27.0.1, over 3 s of real ANE work; + /// - one that advances in every interval is Live; + /// - one that steps once, a lump, is Frozen; + /// - one engine advancing beside one that holds is Frozen; + /// - a part with no ANE rail is Unavailable, never a vacuous Live. + #[test] + fn the_npu_probe_is_live_only_when_every_ane_rail_keeps_advancing() { + struct Script { + next: std::cell::Cell, + values: fn(usize) -> Vec<(String, i64)>, + } + impl Sampler for Script { + fn sample(&self) -> Option { + let i = self.next.get(); + self.next.set(i + 1); + let values = (self.values)(i); + Some(GroupSample { channels: values.len(), outside_group: 0, values }) + } + } + fn run(values: fn(usize) -> Vec<(String, i64)>) -> crate::CounterStatus { + let (base, clock) = (Instant::now(), std::cell::Cell::new(Duration::ZERO)); + npu_status_with( + || Some(Script { next: std::cell::Cell::new(0), values }), + || clock.set(clock.get() + Duration::from_millis(100)), + || base + clock.get(), + ) + } + fn em(ane0: i64, ane1: Option, i: usize) -> Vec<(String, i64)> { + let mut v = vec![ + ("CPU Energy".to_string(), 541_893_638), + ("GPU Energy".to_string(), 14_809 * i as i64), + ("ANE0".to_string(), ane0), + ]; + if let Some(a) = ane1 { + v.push(("ANE1".to_string(), a)); + } + v + } + use crate::CounterStatus::{Frozen, Live, Unavailable}; + assert_eq!(run(|i| em(1_234, None, i)), Frozen, "an ANE0 holding through the load, beside a moving GPU rail"); + assert_eq!(run(|i| em(1_234 + 40 * i as i64, None, i)), Live, "an ANE0 advancing in every interval"); + assert_eq!(run(|i| em(1_234 + if i >= 3 { 470 } else { 0 }, None, i)), Frozen, "one lump"); + assert_eq!(run(|i| em(1_234 + 40 * i as i64, Some(9), i)), Frozen, "ANE1 holding beside a live ANE0"); + assert_eq!(run(|i| vec![("GPU Energy".to_string(), i as i64)]), Unavailable, "no ANE rail at all"); + } + /// The fabrics must not bleed into one another: each classifier claims /// only its own rails, so a per-fabric figure is that fabric's. #[test] @@ -4860,6 +4957,56 @@ pub fn ioreport_status() -> IoReportStatus { } } +/// The Neural Engine rail's liveness, probed once per process and cached. +/// +/// The first call runs the shared liveness rule over the ANE rails of +/// IOReport's `Energy Model` group, with `load` as the work between samples: +/// `load` must put work on the **Neural Engine** (a Core ML prediction on the +/// ANE, say) and wait for it. Burning CPU does not move an ANE rail, and an +/// idle ANE's rail holds, so no other load can prove it live. Every later call +/// ignores its `load` and returns the cached answer. +/// +/// Measured on an M5 Max, macOS 27.0.1, as an unprivileged reader: `ANE0` did +/// not advance in any of 25 sample intervals over 3 s of Core ML inference on +/// the ANE, while root `powermetrics` read 0.47 J of ANE energy over the same +/// span. So a Neural Engine figure from that rail would read 0 J for real +/// work; [`FabricMeter`] reports one only once this has said +/// [`CounterStatus::Live`]. `Unavailable` off macOS and where no ANE rail is +/// published. +pub fn npu_rail_liveness(load: impl FnMut()) -> CounterStatus { + #[cfg(target_os = "macos")] + { + *NPU_RAIL_LIVENESS.get_or_init(|| ioreport::npu_status(load)) + } + #[cfg(not(target_os = "macos"))] + { + let _ = load; + CounterStatus::Unavailable + } +} + +/// The cached Neural Engine rail liveness, if this process has probed it +/// ([`npu_rail_liveness`]). +pub fn npu_rail_liveness_cached() -> Option { + #[cfg(target_os = "macos")] + { + NPU_RAIL_LIVENESS.get().copied() + } + #[cfg(not(target_os = "macos"))] + { + None + } +} + +#[cfg(target_os = "macos")] +static NPU_RAIL_LIVENESS: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Whether the Neural Engine rail has been proved live in this process. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +fn npu_proved_live() -> bool { + npu_rail_liveness_cached() == Some(CounterStatus::Live) +} + /// 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. @@ -5326,7 +5473,9 @@ impl FabricMeter { } if self.ioreport.is_some() { out.push((Fabric::Gpu, Attribution::Machine)); - out.push((Fabric::Npu, Attribution::Machine)); + if npu_proved_live() { + out.push((Fabric::Npu, Attribution::Machine)); + } } } out @@ -5336,7 +5485,10 @@ impl FabricMeter { /// /// A fabric whose counter did not advance is reported as zero rather than /// omitted: on an idle fabric that is the truth, and the caller can tell - /// "read zero" from "could not read" by [`Self::readable`]. + /// "read zero" from "could not read" by [`Self::readable`]. That holds + /// only for a counter that is live: the Neural Engine is reported only + /// once [`npu_rail_liveness`] has proved its rail live, because on macOS + /// 27.0.1 an unprivileged reader's `ANE0` holds through real ANE work. pub fn measure(&self, work: impl FnOnce() -> T) -> (T, Vec) { let started = std::time::Instant::now(); #[cfg(target_os = "macos")] @@ -5366,12 +5518,16 @@ impl FabricMeter { attribution: Attribution::Machine, span_ns, }); - readings.push(FabricEnergy { - fabric: Fabric::Npu, - uj: a.ane_uj.saturating_sub(b.ane_uj), - attribution: Attribution::Machine, - span_ns, - }); + // Only from a rail proved live: an unproven ANE rail's zero is + // not the Neural Engine's energy ([`npu_rail_liveness`]). + if npu_proved_live() { + readings.push(FabricEnergy { + fabric: Fabric::Npu, + uj: a.ane_uj.saturating_sub(b.ane_uj), + attribution: Attribution::Machine, + span_ns, + }); + } } } let _ = span_ns; @@ -5541,7 +5697,7 @@ fn ioreport_rail_survey() -> Vec<(Fabric, bool, &'static str)> { (Fabric::Gpu, "IOReport `Energy Model` / `GPU Energy`"), ( Fabric::Npu, - "IOReport `Energy Model` / `ANE Energy` where a part has one, else the per-engine `ANE` rails", + "IOReport `Energy Model` / `ANE Energy` where a part has one, else the per-engine `ANE` rails; live only once proved under a Neural Engine load (`npu_rail_liveness`)", ), ]; let Some(m) = ioreport::IoReport::open() else { @@ -5562,10 +5718,14 @@ fn ioreport_rail_survey() -> Vec<(Fabric, bool, &'static str)> { let Some(after) = m.read() else { return NOTES.iter().map(|(f, n)| (*f, false, *n)).collect(); }; + // The CPU and the Neural Engine by the shared liveness rule, never by one + // advance: a CPU rail republished in lumps advances once now and then, and + // one advance over 60 ms would call it live. The Neural Engine needs its + // own load to prove it ([`npu_rail_liveness`]); unproved, it is not live. vec![ - (Fabric::Cpu, after.cpu_uj > before.cpu_uj, NOTES[0].1), + (Fabric::Cpu, ioreport_status() == IoReportStatus::Live, NOTES[0].1), (Fabric::Gpu, after.gpu_uj > before.gpu_uj, NOTES[1].1), - (Fabric::Npu, after.ane_uj > before.ane_uj, NOTES[2].1), + (Fabric::Npu, npu_proved_live(), NOTES[2].1), ] } diff --git a/jcp/jcp-rs/crates/jcp-energy/tests/npu_needs_proof.rs b/jcp/jcp-rs/crates/jcp-energy/tests/npu_needs_proof.rs new file mode 100644 index 000000000..68b90d75f --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-energy/tests/npu_needs_proof.rs @@ -0,0 +1,31 @@ +//! The Neural Engine is reported only once its rail is proved live. +//! +//! On macOS 27.0.1 an unprivileged reader's `ANE0` holds through real Neural +//! Engine work: no advance in 25 sample intervals over 3 s of Core ML +//! inference on the ANE, while root `powermetrics` read 0.47 J of ANE energy +//! over the same span. A figure from it would read 0 J for that work. This +//! runs in a process of its own, so the once-per-process proof starts unproved. + +#![cfg(target_os = "macos")] + +use std::time::Duration; + +use jcp_energy::{npu_rail_liveness, npu_rail_liveness_cached, CounterStatus, Fabric, FabricMeter}; + +#[test] +fn the_neural_engine_is_reported_only_once_its_rail_is_proved_live() { + let meter = FabricMeter::detect(); + assert_eq!(npu_rail_liveness_cached(), None, "nothing has probed the ANE rail yet"); + let readable = meter.readable(); + assert!(!readable.iter().any(|(f, _)| *f == Fabric::Npu), "an unproved ANE rail was listed readable: {readable:?}"); + let ((), readings) = meter.measure(|| std::thread::sleep(Duration::from_millis(50))); + assert!(!readings.iter().any(|r| r.fabric == Fabric::Npu), "an unproved ANE rail gave a figure: {readings:?}"); + + // A load that never touches the Neural Engine cannot prove its rail live + // on this host (macOS 27.0.1, unprivileged), whatever the rail does. + let status = npu_rail_liveness(|| std::thread::sleep(Duration::from_millis(25))); + eprintln!("npu rail liveness under a load that never touches the ANE: {status:?}; readable {readable:?}"); + assert_ne!(status, CounterStatus::Live, "the ANE rail was proved live by a load that never touched the ANE"); + assert_eq!(npu_rail_liveness_cached(), Some(status), "the proof was not cached"); + assert!(!meter.readable().iter().any(|(f, _)| *f == Fabric::Npu)); +} -- 2.54.0 (Apple Git-157)