jcp-energy: the Neural Engine is reported only once its rail is proved live #158
2 changed files with 202 additions and 11 deletions
|
|
@ -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),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
31
jcp/jcp-rs/crates/jcp-energy/tests/npu_needs_proof.rs
Normal file
31
jcp/jcp-rs/crates/jcp-energy/tests/npu_needs_proof.rs
Normal 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));
|
||||
}
|
||||
Loading…
Reference in a new issue