jcp-energy: the Neural Engine is reported only once its rail is proved live #158

Merged
dcharlot merged 1 commit from jcp-energy/npu-needs-proof into main 2026-10-05 20:52:35 -04:00
2 changed files with 202 additions and 11 deletions

View file

@ -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<n>` 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<S: Sampler>(
open: impl FnOnce() -> Option<S>,
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<n>`, 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::<Vec<_>>();
let keys = |rails: Vec<(RailKey, i64)>| rails.into_iter().map(|(k, _)| k.name).collect::<Vec<_>>();
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<usize>,
values: fn(usize) -> Vec<(String, i64)>,
}
impl Sampler for Script {
fn sample(&self) -> Option<GroupSample> {
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<i64>, 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<CounterStatus> {
#[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<CounterStatus> = 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<T>(&self, work: impl FnOnce() -> T) -> (T, Vec<FabricEnergy>) {
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<n>` rails",
"IOReport `Energy Model` / `ANE Energy` where a part has one, else the per-engine `ANE<n>` 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),
]
}

View file

@ -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));
}