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