diff --git a/eoc/eoc-rs/Cargo.lock b/eoc/eoc-rs/Cargo.lock index 5232af78d..02cf854f8 100644 --- a/eoc/eoc-rs/Cargo.lock +++ b/eoc/eoc-rs/Cargo.lock @@ -716,6 +716,7 @@ name = "eoc" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "clap", "eoc-cache", "eoc-cascade", diff --git a/eoc/eoc-rs/crates/eoc-agent/src/agent.rs b/eoc/eoc-rs/crates/eoc-agent/src/agent.rs index 4fd2c6ba8..594624aa1 100644 --- a/eoc/eoc-rs/crates/eoc-agent/src/agent.rs +++ b/eoc/eoc-rs/crates/eoc-agent/src/agent.rs @@ -11,8 +11,10 @@ use std::collections::HashMap; use std::sync::Arc; +use std::time::{Duration, Instant}; use async_trait::async_trait; +use eoc_core::JouleCost; use eoc_meter::{JouleCounter, StubCounter}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -98,6 +100,23 @@ impl ToolBox { } } +/// The power a tool step is taken to draw while it runs when no live counter +/// measured it, in watts: about one busy core. A reference figure, to be +/// replaced by one calibrated on the deployment host. +pub const DEFAULT_TOOL_WATTS: f64 = 15.0; + +/// The estimate of a step that ran for `elapsed` at `watts`, in micro-joules: +/// `elapsed` × `watts`, to the nearest micro-joule. Never 0, because the step +/// ran: a product under half a micro-joule is 1 µJ, and a power that is not a +/// finite positive number is not one to estimate with, so +/// [`DEFAULT_TOOL_WATTS`] stands in for it. +pub fn duration_estimate(elapsed: Duration, watts: f64) -> u64 { + let watts = if watts.is_finite() && watts > 0.0 { watts } else { DEFAULT_TOOL_WATTS }; + let uj = (elapsed.as_secs_f64() * watts * 1_000_000.0).round(); + // `as` saturates at u64::MAX. + (uj as u64).max(1) +} + /// Combined per-loop budget. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub struct Budget { @@ -238,28 +257,79 @@ impl AgentLoop { } } - /// Take a meter reading. Returns 0 if the counter is unavailable. - pub fn read_meter(&self) -> u64 { - self.meter.read_microjoules().unwrap_or(0) + /// Take a meter reading: the counter's cumulative micro-joules, or `None` + /// when it could not be read. A failed read is not 0: subtracting a 0 + /// from the next successful read would charge the counter's whole + /// since-boot total to whatever ran in between. + pub fn read_meter(&self) -> Option { + self.meter.read_microjoules().ok() } - /// Wrap an async block, charging measured wall-energy from the meter - /// **plus** an upper-bound `estimated_microjoules` so loops degrade - /// gracefully on stub-only hosts. + /// What the meter measured over a span whose opening reading was + /// `start`: `Some(delta)` only when that read succeeded, a read now + /// succeeds, and the counter advanced between the two. `None` otherwise — + /// a failed read at either end, a counter that held or went down, the + /// [`StubCounter`] — never 0, and never the counter's since-boot total. + /// Without an opening reading the counter is not read again. + pub fn span_reading(&self, start: Option) -> Option { + let start = start?; + let now = self.read_meter()?; + (now > start).then(|| now - start) + } + + /// Run an async block as one step and say what to charge it: what the + /// meter measured over the step ([`Self::span_reading`]), or the larger of + /// that and `estimated_microjoules`, the step's own estimate; and when + /// the meter measured nothing, the estimate alone. The same rule + /// eoc-local's backends and eoc-streaming meter by: a span is measured + /// only when both reads succeed and the counter advanced. The figure + /// alone; [`Self::measured_with`] also says whether it was measured. pub async fn measured(&self, estimated_microjoules: u64, f: F) -> (T, u64) where F: FnOnce() -> Fut, Fut: std::future::Future, { - let before = self.read_meter(); + let (out, cost) = self.measured_with(|_| estimated_microjoules, f).await; + (out, cost.microjoules) + } + + /// [`Self::measured`], with the step's estimate made once it has run, by + /// `estimate`, from how long it ran (monotonic wall-clock time), and the + /// figure's provenance: [`JouleCost::measured`] when the figure is what + /// the meter read, [`JouleCost::estimated`] when it is the estimate — + /// because the meter measured nothing, or read less than the estimate. + pub async fn measured_with(&self, estimate: E, f: F) -> (T, JouleCost) + where + E: FnOnce(Duration) -> u64, + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + let start = self.read_meter(); + let started = Instant::now(); let out = f().await; - let after = self.read_meter(); - let measured = after.saturating_sub(before); - // Use the larger of measured vs estimated as the charge. - let charged = measured.max(estimated_microjoules); + let estimated = estimate(started.elapsed()); + let charged = match self.span_reading(start) { + Some(measured) if measured >= estimated => JouleCost::measured(measured), + _ => JouleCost::estimated(estimated), + }; (out, charged) } + /// Run a tool step and say what to charge it, and whether that was + /// measured, by the rule of [`Self::measured_with`], with the step's + /// estimate its duration at [`DEFAULT_TOOL_WATTS`] + /// ([`duration_estimate`]). A tool that ran did work, so a step the meter + /// did not measure — the [`StubCounter`], a failed read, a counter that + /// held — is charged that estimate, never 0 µJ, and a tool that failed is + /// charged for the time it ran too. + pub async fn measured_tool(&self, f: F) -> (T, JouleCost) + where + F: FnOnce() -> Fut, + Fut: std::future::Future, + { + self.measured_with(|elapsed| duration_estimate(elapsed, DEFAULT_TOOL_WATTS), f).await + } + /// Append a span to the trace. pub fn push_span(&mut self, span: Span) { self.trace.push(span); @@ -267,6 +337,10 @@ impl AgentLoop { /// Charge an LLM step against the budget *and* record the span. /// Returns the stop reason if the budget is exhausted. + /// + /// The step's figure is the provider's own, [`LlmReply::microjoules`], + /// an estimate by [`LlmProvider`]'s contract that no counter here read, + /// so the span is [`JouleSource::Estimated`](eoc_core::JouleSource). pub fn charge_llm_step( &mut self, kind: SpanKind, @@ -276,7 +350,7 @@ impl AgentLoop { ) -> Option { let span = Span::new(self.trace.len(), kind, label, input, reply.text.clone()) .with_tokens(reply.tokens) - .with_microjoules(reply.microjoules); + .with_joule_cost(JouleCost::estimated(reply.microjoules)); self.push_span(span); self.budget.charge_llm(reply.tokens, reply.microjoules) } @@ -296,9 +370,173 @@ impl AgentLoop { } } +/// Test support: a counter that reads a script. +#[cfg(test)] +pub(crate) mod scripted { + use std::sync::Mutex; + + use eoc_meter::JouleCounter; + + /// A counter that reads the scripted values in turn, then repeats the + /// last one; `None` is a failed read. Counts its reads. + #[derive(Debug)] + pub(crate) struct Scripted { + script: Vec>, + reads: Mutex, + } + + impl Scripted { + pub(crate) fn new(script: &[Option]) -> Self { + assert!(!script.is_empty(), "a script needs at least one read"); + Self { script: script.to_vec(), reads: Mutex::new(0) } + } + + /// How many times the counter has been read. + pub(crate) fn reads(&self) -> usize { + *self.reads.lock().unwrap() + } + } + + impl JouleCounter for Scripted { + fn read_microjoules(&self) -> eoc_core::Result { + let mut reads = self.reads.lock().unwrap(); + let value = self.script[(*reads).min(self.script.len() - 1)]; + *reads += 1; + value.ok_or(eoc_core::Error::NoCounter) + } + fn name(&self) -> &'static str { + "scripted" + } + } +} + #[cfg(test)] mod tests { + use super::scripted::Scripted; use super::*; + use eoc_core::JouleSource; + + /// What `measured` charges for one step with the estimate `estimate`, + /// metered by a counter reading `script`, and how many reads it took. + async fn charge(script: &[Option], estimate: u64) -> (u64, usize) { + let meter = Arc::new(Scripted::new(script)); + let loop_ = AgentLoop::with_meter(Budget::unlimited(10), meter.clone()); + let (out, charged) = loop_.measured(estimate, || async { 42 }).await; + assert_eq!(out, 42); + (charged, meter.reads()) + } + + /// A failed read is `None`, never 0. + #[test] + fn a_failed_read_is_no_reading() { + let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(&[None, Some(9_000_000)]))); + assert_eq!(loop_.read_meter(), None); + assert_eq!(loop_.read_meter(), Some(9_000_000)); + } + + /// A failed first read is no baseline: the next read is the counter's + /// since-boot total (9 J here), and the step is charged its own estimate, + /// not that. The counter is not read a second time. + #[tokio::test] + async fn a_failed_first_read_never_charges_the_since_boot_total() { + assert_eq!(charge(&[None, Some(9_000_000)], 250).await, (250, 1)); + assert_eq!(charge(&[None, Some(9_000_000)], 0).await, (0, 1)); + } + + /// Both reads succeeded and the counter advanced: the step is charged + /// the delta, or its estimate when that is larger. + #[tokio::test] + async fn a_counter_that_advanced_charges_the_delta() { + assert_eq!(charge(&[Some(1_000), Some(1_450)], 0).await, (450, 2)); + assert_eq!(charge(&[Some(1_000), Some(1_450)], 100).await, (450, 2)); + assert_eq!(charge(&[Some(1_000), Some(1_450)], 1_000).await, (1_000, 2)); + } + + /// A failed last read, a counter that held, one that went down, and the + /// stub measured nothing: the step is charged its estimate. + #[tokio::test] + async fn a_span_that_measured_nothing_charges_the_estimate() { + assert_eq!(charge(&[Some(1_000), None], 250).await, (250, 2)); + assert_eq!(charge(&[Some(7_000), Some(7_000)], 250).await, (250, 2)); + assert_eq!(charge(&[Some(7_000), Some(6_000)], 250).await, (250, 2)); + let stub = AgentLoop::new(Budget::unlimited(1)); + assert_eq!(stub.measured(250, || async {}).await.1, 250); + assert_eq!(stub.span_reading(stub.read_meter()), None); + } + + /// A step's duration estimate is its duration at the given power, and + /// never 0: a step too short to cost half a micro-joule costs 1 µJ, and a + /// power that is no power gives way to the default. + #[test] + fn a_duration_estimate_is_time_by_power_and_never_zero() { + assert_eq!(duration_estimate(Duration::from_secs(2), 15.0), 30_000_000); + assert_eq!(duration_estimate(Duration::from_millis(20), DEFAULT_TOOL_WATTS), 300_000); + assert_eq!(duration_estimate(Duration::from_micros(10), 2.5), 25); + assert_eq!(duration_estimate(Duration::ZERO, 15.0), 1); + assert_eq!(duration_estimate(Duration::from_nanos(1), 15.0), 1); + for no_power in [0.0, -3.0, f64::NAN, f64::INFINITY] { + assert_eq!(duration_estimate(Duration::from_secs(1), no_power), 15_000_000, "{no_power}"); + } + assert_eq!(duration_estimate(Duration::MAX, 15.0), u64::MAX); + } + + /// A tool step the meter did not measure is charged its duration at the + /// default power, never 0: here the stub, a failed first read, and a + /// counter that held, each over a step that runs at least 20 ms. A step + /// the meter measured is charged the reading, or the estimate when that + /// is larger. + #[tokio::test] + async fn a_tool_step_is_charged_at_least_its_duration_estimate() { + let floor = duration_estimate(Duration::from_millis(20), DEFAULT_TOOL_WATTS); + let step = || async { tokio::time::sleep(Duration::from_millis(20)).await }; + let stub = AgentLoop::new(Budget::unlimited(1)); + let (_, charged) = stub.measured_tool(step).await; + assert!(charged.microjoules >= floor, "the stub: charged {charged} for 20 ms of work"); + for script in [&[None, Some(9_000_000)][..], &[Some(7_000), Some(7_000)][..]] { + let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(script))); + let (_, charged) = loop_.measured_tool(step).await; + assert!(charged.microjoules >= floor && charged.microjoules < 9_000_000, "{script:?}: charged {charged}"); + } + let big = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(&[Some(0), Some(50_000_000)]))); + assert_eq!(big.measured_tool(step).await.1.microjoules, 50_000_000, "a reading above the estimate"); + } + + /// A step's figure is measured only when it is what a live counter read: + /// the counter advanced over the step, by at least the step's estimate. + /// The stub, a failed first or last read, a counter that held or went + /// down, and a counter that advanced by less than the estimate (10 µJ + /// over a 20 ms step, so the estimate is charged) all give an estimate. + #[tokio::test] + async fn a_step_is_measured_only_when_charged_a_live_counters_reading() { + let floor = duration_estimate(Duration::from_millis(20), DEFAULT_TOOL_WATTS); + let step = || async { tokio::time::sleep(Duration::from_millis(20)).await }; + let (_, stub) = AgentLoop::new(Budget::unlimited(1)).measured_tool(step).await; + assert_eq!(stub.source, JouleSource::Estimated, "the stub: {stub}"); + for script in [ + &[None, Some(9_000_000)][..], + &[Some(7_000), None][..], + &[Some(7_000), Some(7_000)][..], + &[Some(7_000), Some(6_000)][..], + &[Some(0), Some(10)][..], + ] { + let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(script))); + let (_, cost) = loop_.measured_tool(step).await; + assert_eq!(cost.source, JouleSource::Estimated, "{script:?}: {cost}"); + assert!(cost.microjoules >= floor, "{script:?}: {cost}"); + } + let big = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(&[Some(0), Some(50_000_000)]))); + assert_eq!(big.measured_tool(step).await.1, JouleCost::measured(50_000_000)); + // A fixed estimate: the reading when it is at least the estimate. + for (script, estimate, cost) in [ + (&[Some(1_000), Some(1_450)], 100, JouleCost::measured(450)), + (&[Some(1_000), Some(1_450)], 450, JouleCost::measured(450)), + (&[Some(1_000), Some(1_450)], 1_000, JouleCost::estimated(1_000)), + (&[Some(7_000), Some(7_000)], 250, JouleCost::estimated(250)), + ] { + let loop_ = AgentLoop::with_meter(Budget::unlimited(1), Arc::new(Scripted::new(script))); + assert_eq!(loop_.measured_with(|_| estimate, || async {}).await.1, cost, "{script:?} {estimate}"); + } + } #[tokio::test] async fn budget_charge_llm_trips_token_cap() { @@ -315,5 +553,7 @@ mod tests { assert!(stop.is_none()); assert_eq!(loop_.trace.len(), 1); assert_eq!(loop_.trace.total_tokens(), 3); + // The provider's own figure: an estimate. + assert_eq!(loop_.trace.spans[0].joule_cost(), JouleCost::estimated(100)); } } diff --git a/eoc/eoc-rs/crates/eoc-agent/src/codeact.rs b/eoc/eoc-rs/crates/eoc-agent/src/codeact.rs index df5f02598..004bec521 100644 --- a/eoc/eoc-rs/crates/eoc-agent/src/codeact.rs +++ b/eoc/eoc-rs/crates/eoc-agent/src/codeact.rs @@ -168,20 +168,23 @@ impl Agent for CodeActLoop { return Ok((last_output, StopReason::Error("no code block in reply".into()))); } }; - let (out_res, microjoules) = self + // Charged what the meter measured, or at least the step's + // duration estimate: code that ran is never 0 µJ. The span says + // which. + let (out_res, cost) = self .inner - .measured(0, || async { self.runtime.run(&code).await }) + .measured_tool(|| async { self.runtime.run(&code).await }) .await; let out = match out_res { Ok(o) => o, Err(e) => format!("runtime_error: {e}"), }; - if let Some(stop) = self.inner.budget.charge_tool(microjoules) { + if let Some(stop) = self.inner.budget.charge_tool(cost.microjoules) { return Ok((last_output, stop)); } self.inner.push_span( crate::trace::Span::new(0, SpanKind::Act, "codeact.exec", code.clone(), out.clone()) - .with_microjoules(microjoules), + .with_joule_cost(cost), ); transcript.push_str(&format!("CODE:\n{code}\nOBSERVATION:\n{out}\n")); } @@ -200,6 +203,7 @@ impl Agent for CodeActLoop { #[cfg(test)] mod tests { use super::*; + use eoc_core::{JouleCost, JouleSource}; #[test] fn extract_code_finds_block() { @@ -218,6 +222,80 @@ mod tests { assert!(CodeActLoop::extract_final("not done").is_none()); } + /// A provider that replies with a script, 1,000 µJ a reply. + struct Replies(std::sync::Mutex>); + + #[async_trait] + impl LlmProvider for Replies { + async fn complete(&self, _prompt: &str) -> AgentResult { + let text = self.0.lock().unwrap().remove(0); + Ok(crate::agent::LlmReply::new(text, 5, 1_000)) + } + } + + /// A runtime that takes at least 20 ms, and fails or not as told. + struct SlowRuntime(bool); + + #[async_trait] + impl CodeRuntime for SlowRuntime { + async fn run(&self, code: &str) -> AgentResult { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + if self.0 { + Err(AgentError::Tool { tool: "runtime".into(), reason: format!("failed: {code}") }) + } else { + Ok(format!("ran {code}")) + } + } + } + + /// Code the meter does not measure (the host default, the stub) is + /// charged its duration estimate — at least 20 ms at the default power for + /// a runtime that runs at least 20 ms — on the span and against the + /// budget, whether it succeeded or failed: it ran either way. + #[tokio::test] + async fn unmetered_code_is_charged_its_duration_estimate() { + let floor = crate::agent::duration_estimate(std::time::Duration::from_millis(20), crate::agent::DEFAULT_TOOL_WATTS); + for fails in [false, true] { + let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["```\nnoop()\n```", "FINAL: done"]))); + let mut agent = CodeActLoop::new(provider, Arc::new(SlowRuntime(fails)), Budget::unlimited(10)); + let (out, stop) = agent.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("done", StopReason::Done)); + let act = agent.trace().spans.iter().find(|s| s.kind == SpanKind::Act).expect("an act span"); + assert!(act.microjoules >= floor, "fails={fails}: 20 ms of code charged {} µJ", act.microjoules); + assert_eq!(agent.budget().joules.used_microjoules, 2_000 + act.microjoules); + for span in &agent.trace().spans { + assert_eq!(span.joule_source, JouleSource::Estimated, "fails={fails}: {span:?}"); + } + } + } + + /// Code over which the counter advanced 50 J is measured, and charged + /// that, whether it succeeded or failed; the model steps around it (the + /// provider's own figures) are estimates. + #[tokio::test] + async fn metered_code_is_measured_and_model_steps_are_estimated() { + for fails in [false, true] { + let meter = Arc::new(crate::agent::scripted::Scripted::new(&[Some(0), Some(50_000_000)])); + let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["```\nnoop()\n```", "FINAL: done"]))); + let budget = Budget::unlimited(10); + let mut agent = CodeActLoop::new(provider, Arc::new(SlowRuntime(fails)), budget); + agent.inner = AgentLoop::with_meter(budget, meter); + let (out, stop) = agent.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("done", StopReason::Done)); + let costs: Vec<_> = agent.trace().spans.iter().map(|s| (s.kind, s.joule_cost())).collect(); + assert_eq!( + costs, + [ + (SpanKind::Think, JouleCost::estimated(1_000)), + (SpanKind::Act, JouleCost::measured(50_000_000)), + (SpanKind::Think, JouleCost::estimated(1_000)), + ], + "fails={fails}" + ); + assert_eq!(agent.budget().joules.used_microjoules, 2_000 + 50_000_000); + } + } + #[test] fn parse_line_lenient() { let (n, a) = LineLangRuntime::parse_line("calc(1+1)").unwrap(); diff --git a/eoc/eoc-rs/crates/eoc-agent/src/plan_execute.rs b/eoc/eoc-rs/crates/eoc-agent/src/plan_execute.rs index 71ae4f0d7..361abe758 100644 --- a/eoc/eoc-rs/crates/eoc-agent/src/plan_execute.rs +++ b/eoc/eoc-rs/crates/eoc-agent/src/plan_execute.rs @@ -12,7 +12,7 @@ use async_trait::async_trait; use crate::agent::{Agent, AgentLoop, Budget, LlmProvider, StopReason}; use crate::error::{AgentError, AgentResult}; -use crate::trace::SpanKind; +use crate::trace::{Span, SpanKind}; /// Decomposes a goal into ordered subgoals. #[async_trait] @@ -110,34 +110,48 @@ impl PlanExecuteLoop { #[async_trait] impl Agent for PlanExecuteLoop { + /// Neither a [`Planner`] nor a [`Worker`] reports what its step cost, so + /// each step is metered here, by the rule of [`AgentLoop::measured_tool`]: + /// what the meter measured over it, or at least its duration at + /// [`DEFAULT_TOOL_WATTS`](crate::agent::DEFAULT_TOOL_WATTS) — never 0 µJ + /// for a step that ran — and its span says which of the two it is + /// charged. The plan step is charged against the joule cap, + /// and each worker step against the joule cap and the tool-call quota. A + /// step that fails ran too: it is recorded and charged, and then its + /// error is returned. async fn run(&mut self, goal: &str) -> AgentResult<(String, StopReason)> { - let subgoals = self.planner.plan(goal).await?; - // Charge a notional cost for the planner step; planners that - // wrap LlmProvider directly already self-meter via charge_llm_step, - // but the trait-level abstraction does not. - self.inner.push_span(crate::trace::Span::new( - 0, - SpanKind::Plan, - "plan_execute.plan", - goal, - subgoals.join(" | "), - )); + let (planned, cost) = self.inner.measured_tool(|| self.planner.plan(goal)).await; + let output = match &planned { + Ok(subgoals) => subgoals.join(" | "), + Err(e) => format!("ERROR: {e}"), + }; + self.inner.push_span( + Span::new(0, SpanKind::Plan, "plan_execute.plan", goal, output).with_joule_cost(cost), + ); + let within_cap = self.inner.budget.joules.charge(cost.microjoules); + let subgoals = planned?; + if !within_cap { + return Ok((String::new(), StopReason::BudgetExhausted("joules".into()))); + } let mut prior = String::new(); for (i, sub) in subgoals.iter().enumerate() { if let Some(stop) = self.inner.iteration_check(i) { return Ok((prior, stop)); } - let out = self.worker.execute(sub, &prior).await?; - self.inner.push_span(crate::trace::Span::new( - 0, - SpanKind::Execute, - "plan_execute.execute", - sub, - out.clone(), - )); - // Charge tool-call quota for the worker step. - if let Some(stop) = self.inner.budget.charge_tool(0) { + let (executed, cost) = + self.inner.measured_tool(|| self.worker.execute(sub, &prior)).await; + let output = match &executed { + Ok(out) => out.clone(), + Err(e) => format!("ERROR: {e}"), + }; + self.inner.push_span( + Span::new(0, SpanKind::Execute, "plan_execute.execute", sub, output) + .with_joule_cost(cost), + ); + let stop = self.inner.budget.charge_tool(cost.microjoules); + let out = executed?; + if let Some(stop) = stop { return Ok((out, stop)); } prior = out; @@ -157,6 +171,7 @@ impl Agent for PlanExecuteLoop { #[cfg(test)] mod tests { use super::*; + use eoc_core::{JouleCost, JouleSource}; struct StaticPlanner(Vec); #[async_trait] @@ -183,4 +198,118 @@ mod tests { assert_eq!(out, "|a|b|c"); assert_eq!(reason, StopReason::Done); } + + const STEP: std::time::Duration = std::time::Duration::from_millis(20); + + /// What a step of at least [`STEP`] is charged at least, unmetered. + fn floor() -> u64 { + crate::agent::duration_estimate(STEP, crate::agent::DEFAULT_TOOL_WATTS) + } + + /// A planner that takes at least [`STEP`] to plan `a`, `b`. + struct SlowPlanner; + #[async_trait] + impl Planner for SlowPlanner { + async fn plan(&self, _goal: &str) -> AgentResult> { + tokio::time::sleep(STEP).await; + Ok(vec!["a".into(), "b".into()]) + } + } + + /// A worker that takes at least [`STEP`], and fails or not as told. + struct SlowWorker(bool); + #[async_trait] + impl Worker for SlowWorker { + async fn execute(&self, subgoal: &str, prior: &str) -> AgentResult { + tokio::time::sleep(STEP).await; + if self.0 { + Err(AgentError::Tool { tool: "worker".into(), reason: format!("failed: {subgoal}") }) + } else { + Ok(format!("{prior}|{subgoal}")) + } + } + } + + /// Steps the meter does not measure (the host default, the stub) are + /// charged their duration estimates, never 0: the plan step and each + /// worker step, each running at least 20 ms, carry at least 20 ms at the + /// default power on their spans, and the budget is charged exactly what + /// the spans carry, with one tool call a worker step. + #[tokio::test] + async fn unmetered_steps_are_charged_their_duration_estimates() { + let mut pe = PlanExecuteLoop::new(Arc::new(SlowPlanner), Arc::new(SlowWorker(false)), Budget::unlimited(10)); + let (out, stop) = pe.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("|a|b", StopReason::Done)); + let spans = &pe.trace().spans; + let kinds: Vec<_> = spans.iter().map(|s| s.kind).collect(); + assert_eq!(kinds, [SpanKind::Plan, SpanKind::Execute, SpanKind::Execute]); + for span in spans { + assert!(span.microjoules >= floor(), "a 20 ms step charged {} µJ: {span:?}", span.microjoules); + assert_eq!(span.joule_source, JouleSource::Estimated, "{span:?}"); + } + assert_eq!(pe.budget().joules.used_microjoules, pe.trace().total_microjoules()); + assert_eq!(pe.budget().tool_calls_used, 2); + } + + /// A step the meter measured is charged the counter's reading, and its + /// span says measured: 40 J over the plan step and 50 J over worker `b`. + /// Over worker `a` the counter held, so `a` is charged its own estimate, + /// which is not 0 and is far below a joule for an echo, and says + /// estimated. + #[tokio::test] + async fn a_metered_step_is_charged_the_counters_reading() { + let meter = Arc::new(crate::agent::scripted::Scripted::new(&[ + Some(0), + Some(40_000_000), + Some(40_000_000), + Some(40_000_000), + Some(40_000_000), + Some(90_000_000), + ])); + let planner = Arc::new(StaticPlanner(vec!["a".into(), "b".into()])); + let budget = Budget::unlimited(10); + let mut pe = PlanExecuteLoop::new(planner, Arc::new(EchoWorker), budget); + pe.inner = AgentLoop::with_meter(budget, meter.clone()); + let (out, stop) = pe.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("|a|b", StopReason::Done)); + assert_eq!(meter.reads(), 6); + let spans = &pe.trace().spans; + assert_eq!(spans[0].joule_cost(), JouleCost::measured(40_000_000), "{spans:?}"); + assert!((1..1_000_000).contains(&spans[1].microjoules), "{spans:?}"); + assert_eq!(spans[1].joule_source, JouleSource::Estimated, "{spans:?}"); + assert_eq!(spans[2].joule_cost(), JouleCost::measured(50_000_000), "{spans:?}"); + assert_eq!(pe.budget().joules.used_microjoules, 90_000_000 + spans[1].microjoules); + } + + /// A worker step that fails ran too: its span records the error and its + /// duration estimate, the budget is charged that and a tool call, and + /// then the error is returned. + #[tokio::test] + async fn a_failed_worker_step_is_recorded_and_charged() { + let planner = Arc::new(StaticPlanner(vec!["a".into(), "b".into()])); + let mut pe = PlanExecuteLoop::new(planner, Arc::new(SlowWorker(true)), Budget::unlimited(10)); + let err = pe.run("go").await.unwrap_err(); + assert!(matches!(err, AgentError::Tool { .. }), "{err:?}"); + let last = pe.trace().spans.last().expect("a span"); + assert_eq!(last.kind, SpanKind::Execute); + assert!(last.output.starts_with("ERROR: "), "{last:?}"); + assert!(last.microjoules >= floor(), "a failed 20 ms step charged {} µJ", last.microjoules); + assert_eq!(pe.budget().joules.used_microjoules, pe.trace().total_microjoules()); + assert_eq!(pe.budget().tool_calls_used, 1); + } + + /// A joule cap the plan step already spends stops the loop before any + /// worker runs. + #[tokio::test] + async fn a_plan_step_over_the_joule_cap_stops_the_loop() { + let mut pe = PlanExecuteLoop::new( + Arc::new(SlowPlanner), + Arc::new(SlowWorker(false)), + Budget::new(1_000, 0.000_001, 10, 10), + ); + let (out, stop) = pe.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("", StopReason::BudgetExhausted("joules".into()))); + assert_eq!(pe.trace().len(), 1); + assert_eq!(pe.budget().tool_calls_used, 0); + } } diff --git a/eoc/eoc-rs/crates/eoc-agent/src/react.rs b/eoc/eoc-rs/crates/eoc-agent/src/react.rs index 319e3336d..b7391730c 100644 --- a/eoc/eoc-rs/crates/eoc-agent/src/react.rs +++ b/eoc/eoc-rs/crates/eoc-agent/src/react.rs @@ -160,15 +160,18 @@ impl Agent for ReActLoop { }; let args = serde_json::json!({ "input": step.argument }); - let (result, microjoules) = self + // Charged what the meter measured, or at least the step's + // duration estimate: a tool that ran is never 0 µJ. The span + // says which. + let (result, cost) = self .inner - .measured(0, || async { tool.run(args).await }) + .measured_tool(|| async { tool.run(args).await }) .await; let observation = match result { Ok(v) => v.to_string(), Err(e) => format!("ERROR: {e}"), }; - if let Some(stop) = self.inner.budget.charge_tool(microjoules) { + if let Some(stop) = self.inner.budget.charge_tool(cost.microjoules) { return Ok((self.last_output.clone(), stop)); } self.inner.push_span( @@ -179,7 +182,7 @@ impl Agent for ReActLoop { step.argument.clone(), observation.clone(), ) - .with_microjoules(microjoules), + .with_joule_cost(cost), ); self.transcript.push_str(&format!( "Thought: {th}\nAction: {ac}[{arg}]\nObservation: {obs}\n", @@ -204,6 +207,7 @@ impl Agent for ReActLoop { #[cfg(test)] mod tests { use super::*; + use eoc_core::{JouleCost, JouleSource}; #[test] fn parse_thought_action() { @@ -225,4 +229,116 @@ mod tests { let err = ReActStep::parse("Thought: nope\n").unwrap_err(); assert!(matches!(err, AgentError::Parse(_))); } + + /// A provider that replies with a script, 1,000 µJ a reply. + struct Replies(std::sync::Mutex>); + + #[async_trait] + impl LlmProvider for Replies { + async fn complete(&self, _prompt: &str) -> AgentResult { + let text = self.0.lock().unwrap().remove(0); + Ok(crate::agent::LlmReply::new(text, 5, 1_000)) + } + } + + struct Echo; + + #[async_trait] + impl crate::agent::Tool for Echo { + fn name(&self) -> &str { + "echo" + } + async fn run(&self, args: serde_json::Value) -> AgentResult { + Ok(args) + } + } + + /// A tool that takes at least 20 ms and echoes its arguments. + struct Slow; + + #[async_trait] + impl crate::agent::Tool for Slow { + fn name(&self) -> &str { + "slow" + } + async fn run(&self, args: serde_json::Value) -> AgentResult { + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + Ok(args) + } + } + + /// A tool step whose first meter read fails is charged its own estimate + /// (its duration at the default power), never the counter's since-boot + /// total: with a 5 J budget and a counter that reads 9 J once it reads at + /// all, the agent finishes, having charged its two replies and the tool + /// step's estimate, which is not 0. + #[tokio::test] + async fn a_failed_first_read_never_exhausts_the_joule_budget() { + let meter = Arc::new(crate::agent::scripted::Scripted::new(&[None, Some(9_000_000)])); + let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["Action: echo[hi]", "Action: Finish[done]"]))); + let mut tools = ToolBox::new(); + tools.insert(Echo); + let budget = Budget::new(1_000, 5.0, 10, 10); + let mut agent = ReActLoop::new(provider, tools, budget, "system"); + agent.inner = AgentLoop::with_meter(budget, meter.clone()); + let (out, stop) = agent.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("done", StopReason::Done)); + assert_eq!(meter.reads(), 1, "the counter was read again after a failed first read"); + let act = agent.trace().spans.iter().find(|s| s.kind == SpanKind::Act).expect("an act span"); + assert!(act.microjoules > 0 && act.microjoules < 9_000_000, "{:?}", act); + assert_eq!(agent.budget().joules.used_microjoules, 2_000 + act.microjoules, "{:?}", agent.trace().spans); + } + + /// A tool step the meter does not measure (the host default, the stub) is + /// charged its duration estimate: at least 20 ms at the default power for + /// a tool that runs at least 20 ms, on the span and against the budget. + #[tokio::test] + async fn an_unmetered_tool_step_is_charged_its_duration_estimate() { + let provider = Arc::new(Replies(std::sync::Mutex::new(vec!["Action: slow[hi]", "Action: Finish[done]"]))); + let mut tools = ToolBox::new(); + tools.insert(Slow); + let mut agent = ReActLoop::new(provider, tools, Budget::unlimited(10), "system"); + let (out, stop) = agent.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("done", StopReason::Done)); + let act = agent.trace().spans.iter().find(|s| s.kind == SpanKind::Act).expect("an act span"); + let floor = crate::agent::duration_estimate(std::time::Duration::from_millis(20), crate::agent::DEFAULT_TOOL_WATTS); + assert!(act.microjoules >= floor, "a 20 ms tool step charged {} µJ", act.microjoules); + assert_eq!(agent.budget().joules.used_microjoules, 2_000 + act.microjoules); + for span in &agent.trace().spans { + assert_eq!(span.joule_source, JouleSource::Estimated, "{span:?}"); + } + } + + /// Each span says whether its figure was measured: a tool step over which + /// the counter advanced 50 J is measured, and charged that; a model step + /// (the provider's own figure) and an unknown tool's observation (nothing + /// ran, 0 µJ) are estimates. + #[tokio::test] + async fn a_span_is_measured_only_when_a_live_counter_read_it() { + let meter = Arc::new(crate::agent::scripted::Scripted::new(&[Some(0), Some(50_000_000)])); + let provider = Arc::new(Replies(std::sync::Mutex::new(vec![ + "Action: nope[x]", + "Action: echo[hi]", + "Action: Finish[done]", + ]))); + let mut tools = ToolBox::new(); + tools.insert(Echo); + let budget = Budget::unlimited(10); + let mut agent = ReActLoop::new(provider, tools, budget, "system"); + agent.inner = AgentLoop::with_meter(budget, meter.clone()); + let (out, stop) = agent.run("go").await.unwrap(); + assert_eq!((out.as_str(), stop), ("done", StopReason::Done)); + let costs: Vec<_> = agent.trace().spans.iter().map(|s| (s.kind, s.joule_cost())).collect(); + assert_eq!( + costs, + [ + (SpanKind::Think, JouleCost::estimated(1_000)), + (SpanKind::Observe, JouleCost::estimated(0)), + (SpanKind::Think, JouleCost::estimated(1_000)), + (SpanKind::Act, JouleCost::measured(50_000_000)), + (SpanKind::Think, JouleCost::estimated(1_000)), + ] + ); + assert_eq!(agent.budget().joules.used_microjoules, 3_000 + 50_000_000); + } } diff --git a/eoc/eoc-rs/crates/eoc-agent/src/trace.rs b/eoc/eoc-rs/crates/eoc-agent/src/trace.rs index 2f1d8f6d5..6791af0a9 100644 --- a/eoc/eoc-rs/crates/eoc-agent/src/trace.rs +++ b/eoc/eoc-rs/crates/eoc-agent/src/trace.rs @@ -3,10 +3,12 @@ //! Every loop emits a [`Trace`] consisting of ordered [`Span`]s. A span //! captures *what kind of step* ran (think / act / observe / reflect), //! its inputs and outputs as opaque strings, and the joule + token cost -//! attributed to it. Traces are content-addressable via the existing +//! attributed to it, with whether that joule figure was measured or +//! estimated. Traces are content-addressable via the existing //! [`eoc_core::Receipt`] machinery — the cascade can memoize entire //! agent runs, not just individual responses. +use eoc_core::{JouleCost, JouleSource}; use serde::{Deserialize, Serialize}; /// Kind of step. @@ -60,10 +62,25 @@ pub struct Span { pub tokens: u64, /// Energy charged to this span (micro-joules). pub microjoules: u64, + /// Where `microjoules` came from: [`JouleSource::Measured`] only when it + /// is what a live hardware counter read over the step — both reads + /// succeeded and the counter advanced — and [`JouleSource::Estimated`] + /// otherwise: a provider's own figure, a duration estimate, a zero no + /// counter read. A span serialized without this field reads back as + /// `Estimated`: nothing in it says it was measured. + #[serde(default = "unrecorded_joule_source")] + pub joule_source: JouleSource, +} + +/// The provenance of a span that recorded none: an estimate, never a +/// measurement. +fn unrecorded_joule_source() -> JouleSource { + JouleSource::Estimated } impl Span { - /// Build a new span. + /// Build a new span, charged nothing yet: 0 µJ, which no counter read, + /// so [`JouleSource::Estimated`]. pub fn new( index: usize, kind: SpanKind, @@ -79,6 +96,7 @@ impl Span { output: output.into(), tokens: 0, microjoules: 0, + joule_source: JouleSource::Estimated, } } @@ -88,11 +106,25 @@ impl Span { self } - /// Attach an energy charge. - pub fn with_microjoules(mut self, microjoules: u64) -> Self { - self.microjoules = microjoules; + /// Attach an energy charge with no provenance, which makes it an + /// estimate: it replaces any [`JouleSource::Measured`] attached before, + /// since that tag belonged to a different figure. A figure a live counter + /// read is attached with [`Self::with_joule_cost`]. + pub fn with_microjoules(self, microjoules: u64) -> Self { + self.with_joule_cost(JouleCost::estimated(microjoules)) + } + + /// Attach an energy charge and its provenance. + pub fn with_joule_cost(mut self, cost: JouleCost) -> Self { + self.microjoules = cost.microjoules; + self.joule_source = cost.source; self } + + /// The energy charged to this span, with its provenance. + pub fn joule_cost(&self) -> JouleCost { + JouleCost { microjoules: self.microjoules, source: self.joule_source } + } } /// An ordered execution trace. @@ -157,4 +189,41 @@ mod tests { assert_eq!(t.total_tokens(), 12); assert_eq!(t.total_microjoules(), 250); } + + /// A span is measured only when given a measured cost. A new span and a + /// bare figure are estimates, and a bare figure attached after a measured + /// one does not inherit its tag. + #[test] + fn a_span_is_measured_only_when_given_a_measured_cost() { + let span = || Span::new(0, SpanKind::Act, "calc", "1+1", "2"); + assert_eq!(span().joule_cost(), JouleCost::estimated(0)); + assert_eq!(span().with_microjoules(50).joule_cost(), JouleCost::estimated(50)); + assert_eq!(span().with_joule_cost(JouleCost::measured(50)).joule_cost(), JouleCost::measured(50)); + assert_eq!(span().with_joule_cost(JouleCost::estimated(50)).joule_cost(), JouleCost::estimated(50)); + let relabelled = span().with_joule_cost(JouleCost::measured(50)).with_microjoules(70); + assert_eq!(relabelled.joule_cost(), JouleCost::estimated(70)); + } + + /// A span's provenance survives serialization, and a trace written + /// before spans carried one reads back with every span estimated. + #[test] + fn a_span_without_a_joule_source_reads_back_as_estimated() { + let mut t = Trace::new(); + t.push(Span::new(0, SpanKind::Act, "calc", "1+1", "2").with_joule_cost(JouleCost::measured(50))); + t.push(Span::new(0, SpanKind::Think, "t", "in", "out").with_microjoules(200)); + let json = serde_json::to_string(&t).unwrap(); + assert!(json.contains(r#""joule_source":"Measured""#), "{json}"); + let back: Trace = serde_json::from_str(&json).unwrap(); + let costs: Vec<_> = back.spans.iter().map(Span::joule_cost).collect(); + assert_eq!(costs, [JouleCost::measured(50), JouleCost::estimated(200)]); + + let old = r#"{"spans":[ + {"index":0,"kind":"Act","label":"calc","input":"1+1","output":"2","tokens":0,"microjoules":50}, + {"index":1,"kind":"Think","label":"t","input":"in","output":"out","tokens":3,"microjoules":200} + ]}"#; + let back: Trace = serde_json::from_str(old).unwrap(); + let costs: Vec<_> = back.spans.iter().map(Span::joule_cost).collect(); + assert_eq!(costs, [JouleCost::estimated(50), JouleCost::estimated(200)]); + assert_eq!(back.total_tokens(), 3); + } } diff --git a/eoc/eoc-rs/crates/eoc-cache/src/lib.rs b/eoc/eoc-rs/crates/eoc-cache/src/lib.rs index 6b5a7d0a9..f165c7858 100644 --- a/eoc/eoc-rs/crates/eoc-cache/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-cache/src/lib.rs @@ -1,9 +1,10 @@ //! EOC stage 1 — cache. //! //! The cheapest stage: an LRU keyed by `QueryId` returning a previously -//! computed `Response`. Cache hits are reported with `JouleCost::zero()` — -//! the energy of a hashmap lookup is below the noise floor of any -//! contemporary hardware energy counter. +//! computed `Response`. Cache hits are reported with `JouleCost::zero()`: +//! zero micro-joules, tagged `Estimated`. The energy of a hashmap lookup is +//! below the noise floor of any contemporary hardware energy counter, and no +//! counter is read for a hit, so a hit never claims a measurement. #![forbid(unsafe_code)] @@ -56,7 +57,8 @@ impl LruCache { } /// Insert (or refresh) a `Response`. Re-stamps the response as `Cache` - /// stage with zero joule cost so subsequent hits look like hits. + /// stage with `JouleCost::zero()` (an estimated zero: no counter is read + /// for a hit) so subsequent hits look like hits. pub fn insert(&self, q: &Query, payload: impl Into) -> Response { let r = Response::new(q.id, payload.into(), StageKind::Cache, JouleCost::zero()); self.inner @@ -101,7 +103,8 @@ mod tests { let r = cache.try_resolve(&q).await.expect("hit"); assert_eq!(r.stage, StageKind::Cache); assert_eq!(r.joule_cost.microjoules, 0); - assert_eq!(r.joule_cost.source, JouleSource::Measured); + // No counter was read for the hit: its zero is an estimate. + assert_eq!(r.joule_cost.source, JouleSource::Estimated); } #[tokio::test] diff --git a/eoc/eoc-rs/crates/eoc-carbon/src/account.rs b/eoc/eoc-rs/crates/eoc-carbon/src/account.rs index 730d0394a..1fca272ef 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/src/account.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/src/account.rs @@ -5,8 +5,16 @@ //! Inputs are typed: joules come from [`eoc_meter`] / [`eoc_core::JouleCost`], //! intensity comes from [`crate::intensity::CarbonIntensity`], overhead //! comes from [`crate::pue::Pue`]. +//! +//! An account keeps the provenance of its energy. Its IT-layer joules are +//! exactly as measured as the [`JouleCost`] they came from. Its facility +//! joules are measured only when every part of them was: the IT joules +//! were, and the PUE added no overhead. The overhead a PUE above 1 adds is a +//! published or assumed facility ratio applied to the reading, energy no +//! counter on the host read, so a facility figure that includes any of it +//! is an estimate. -use eoc_core::JouleCost; +use eoc_core::{JouleCost, JouleSource}; use serde::{Deserialize, Serialize}; use crate::intensity::CarbonIntensity; @@ -28,6 +36,36 @@ pub struct CarbonAccount { pub pue: f64, /// Final gCO2e number. pub g_co2e: f64, + /// Where `it_joules` came from: the input [`JouleCost`]'s provenance, + /// unchanged. An account serialized without this field reads back as + /// `Estimated`: nothing in it says it was measured. + #[serde(default = "unrecorded_source")] + pub it_energy_source: JouleSource, + /// Where `facility_joules`, and so the energy behind `g_co2e`, came from: + /// `Measured` only when every part of it was — the IT joules were + /// measured and the PUE added no overhead (a PUE of exactly 1). Any + /// overhead is a facility ratio applied to the reading, not a reading, + /// and makes the figure an estimate. Grid intensity has its own + /// provenance (the reading's provider and kind) and does not enter this + /// tag. Reads back as `Estimated` when absent. + #[serde(default = "unrecorded_source")] + pub facility_energy_source: JouleSource, +} + +/// The provenance of an account that recorded none: an estimate, never a +/// measurement. +fn unrecorded_source() -> JouleSource { + JouleSource::Estimated +} + +/// The provenance of `it × pue`: measured only when the IT joules were and +/// the PUE added nothing to them. +fn facility_source(it: JouleSource, pue: &Pue) -> JouleSource { + if it == JouleSource::Measured && pue.value() <= 1.0 { + JouleSource::Measured + } else { + JouleSource::Estimated + } } /// Anything that turns an `it_joules + intensity + pue` tuple into a @@ -62,6 +100,8 @@ impl CarbonAccounting for StandardAccounting { g_co2e_per_kwh: intensity.g_co2e_per_kwh, pue: pue.value(), g_co2e, + it_energy_source: joule_cost.source, + facility_energy_source: facility_source(joule_cost.source, pue), } } } @@ -104,4 +144,40 @@ mod tests { let acc = account_request(&JouleCost::zero(), &intensity(800.0), &Pue::new(1.4)); assert_eq!(acc.g_co2e, 0.0); } + + /// The IT joules keep their input's provenance. The facility joules are + /// measured only when every part of them was: measured IT joules and no + /// PUE overhead. The figures do not depend on the tags. + #[test] + fn an_account_is_measured_only_where_every_part_was() { + let ci = intensity(400.0); + let tags = |cost: JouleCost, pue: f64| { + let acc = account_request(&cost, &ci, &Pue::new(pue)); + (acc.it_energy_source, acc.facility_energy_source) + }; + use JouleSource::{Estimated, Measured}; + assert_eq!(tags(JouleCost::measured(1_000_000), 1.0), (Measured, Measured)); + assert_eq!(tags(JouleCost::measured(1_000_000), 1.2), (Measured, Estimated), "PUE overhead is not a reading"); + assert_eq!(tags(JouleCost::estimated(1_000_000), 1.0), (Estimated, Estimated)); + assert_eq!(tags(JouleCost::estimated(1_000_000), 1.2), (Estimated, Estimated)); + // A PUE below 1 is clamped to 1: no overhead, so nothing unread. + assert_eq!(tags(JouleCost::measured(1_000_000), 0.5), (Measured, Measured)); + + let m = account_request(&JouleCost::measured(1_000_000), &ci, &Pue::new(1.2)); + let e = account_request(&JouleCost::estimated(1_000_000), &ci, &Pue::new(1.2)); + assert_eq!((m.it_joules, m.facility_joules, m.g_co2e), (e.it_joules, e.facility_joules, e.g_co2e)); + } + + /// The provenance survives a round trip; an account written before it + /// existed recorded none, so it reads back as an estimate. + #[test] + fn an_account_without_provenance_reads_back_estimated() { + let acc = account_request(&JouleCost::measured(1_000_000), &intensity(400.0), &Pue::new(1.0)); + let back: CarbonAccount = serde_json::from_str(&serde_json::to_string(&acc).unwrap()).unwrap(); + assert_eq!(back, acc); + assert_eq!(back.facility_energy_source, JouleSource::Measured); + let old = r#"{"it_joules":1.0,"facility_joules":1.0,"g_co2e_per_kwh":400.0,"pue":1.0,"g_co2e":0.1}"#; + let back: CarbonAccount = serde_json::from_str(old).unwrap(); + assert_eq!((back.it_energy_source, back.facility_energy_source), (JouleSource::Estimated, JouleSource::Estimated)); + } } diff --git a/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs b/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs index ab1a31279..7fc6b8912 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/src/lib.rs @@ -43,7 +43,7 @@ pub use error::{CarbonError, Result}; pub use intensity::{CarbonIntensity, ProviderKind, Zone}; pub use pue::{Pue, PueTable}; pub use scheduler::{DemandShifter, RegionScheduler, ShiftDecision}; -pub use sci_bridge::{SciInputs, SciScore}; +pub use sci_bridge::{EvalEnergy, SciInputs, SciScore, sci_per_correct_answer}; /// gCO2e per kWh in a "typical" world-average grid (IEA 2024). Useful as /// a last-resort fallback when even the IEA per-country table cannot be diff --git a/eoc/eoc-rs/crates/eoc-carbon/src/sci_bridge.rs b/eoc/eoc-rs/crates/eoc-carbon/src/sci_bridge.rs index 3fddedf3b..12c2d2b62 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/src/sci_bridge.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/src/sci_bridge.rs @@ -15,21 +15,24 @@ //! correct answer for an eval). //! //! `eoc-eval` already references SCI; this module is the explicit -//! computation surface, and when the `eval-bridge` feature is on it -//! exposes a constructor that turns an [`eoc_eval::EvalReport`] into an -//! [`SciScore`] with `R = number of correct cases`. +//! computation surface. [`sci_per_correct_answer`] scores an eval run's +//! energy with `R = number of correct cases`, and when the `eval-bridge` +//! feature is on, `sci_from_eval_report` feeds it an +//! [`eoc_eval::EvalReport`]. A run with no correct answer has no score per +//! correct answer: both return `None` for it. use serde::{Deserialize, Serialize}; use crate::account::{CarbonAccount, account_request}; use crate::intensity::CarbonIntensity; use crate::pue::Pue; -use eoc_core::JouleCost; +use eoc_core::{JouleCost, JouleSource}; /// Inputs to one SCI computation. #[derive(Debug, Clone)] pub struct SciInputs<'a> { - /// Measured joules at the IT layer (from [`eoc_meter`]). + /// Joules at the IT layer, with their provenance: measured by a live + /// counter (see [`eoc_meter`]) or estimated. The score carries it. pub joule_cost: &'a JouleCost, /// Grid intensity (marginal preferred, average acceptable). pub intensity: &'a CarbonIntensity, @@ -57,6 +60,21 @@ pub struct SciScore { pub functional_units: f64, /// The SCI value (gCO2e per functional unit). pub sci: f64, + /// Where the energy term came from. The term is facility energy (IT + /// joules × PUE), so this is the account's + /// [`facility_energy_source`](CarbonAccount::facility_energy_source): + /// `Measured` only when the input joules were measured and the PUE + /// added no overhead, which no counter read. An SCI value is never more + /// measured than the joules it was computed from. A score serialized + /// without this field reads back as `Estimated`. + #[serde(default = "unrecorded_energy_source")] + pub energy_source: JouleSource, +} + +/// The provenance of a score that recorded none: an estimate, never a +/// measurement. +fn unrecorded_energy_source() -> JouleSource { + JouleSource::Estimated } impl SciScore { @@ -77,35 +95,86 @@ impl SciScore { embodied_g_co2e: inputs.embodied_g_co2e, functional_units: r, sci, + energy_source: account.facility_energy_source, } } } +/// An eval run's energy, as `eoc_eval::EvalReport` carries it: micro-joules +/// per correct answer, the number of correct answers, and the provenance of +/// the joules. Kept free of `eoc-eval` so the scoring below is compiled, and +/// tested, in every build; the `eval-bridge` feature only fills it from a +/// report. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EvalEnergy { + /// Micro-joules per correct answer. `u64::MAX` is the report's sentinel + /// for "no correct answer", not a figure. + pub joules_per_correct: u64, + /// Correct answers in the run. + pub correct: usize, + /// Where the joules came from: `Measured` only when every case's were. + pub joule_source: JouleSource, +} + +/// The SCI score per correct answer of an eval run: total IT energy +/// `joules_per_correct × correct`, with the run's own provenance (never +/// upgraded; see [`SciScore::energy_source`]), and `R = correct`. +/// +/// `None` when the run has no correct answer — `correct == 0`, or the +/// `u64::MAX` sentinel a report carries for that — since there is no figure +/// per correct answer to score. The sentinel is never read as energy. +pub fn sci_per_correct_answer( + energy: EvalEnergy, + intensity: &CarbonIntensity, + pue: &Pue, + embodied_g_co2e: f64, +) -> Option { + if energy.correct == 0 || energy.joules_per_correct == u64::MAX { + return None; + } + let total_microjoules = (energy.joules_per_correct as u128) + .saturating_mul(energy.correct as u128) + .min(u64::MAX as u128) as u64; + let cost = JouleCost { + microjoules: total_microjoules, + source: energy.joule_source, + }; + Some(SciScore::compute(SciInputs { + joule_cost: &cost, + intensity, + pue, + embodied_g_co2e, + functional_units: energy.correct as f64, + })) +} + /// `eoc-eval` bridge — turns an `EvalReport` into an SCI score with -/// `R = correct`. Compiled only with the `eval-bridge` Cargo feature. +/// `R = correct`. Compiled only with the `eval-bridge` Cargo feature; it +/// only hands the report's energy to [`sci_per_correct_answer`], which is +/// compiled and tested in every build. /// /// `EvalReport` carries `joules_per_correct` in micro-joules and a -/// `correct` count, so we reconstruct the total IT-layer energy as -/// `joules_per_correct * correct`. +/// `correct` count, so the total IT-layer energy is reconstructed as +/// `joules_per_correct * correct`. Its provenance is the report's own +/// `joule_source`: measured only when every case of the run was. `None` +/// for a run with no correct answer. #[cfg(feature = "eval-bridge")] pub fn sci_from_eval_report( report: &eoc_eval::EvalReport, intensity: &CarbonIntensity, pue: &Pue, embodied_g_co2e: f64, -) -> SciScore { - let correct = report.correct.max(1); - let total_microjoules = (report.joules_per_correct as u128) - .saturating_mul(correct as u128) - .min(u64::MAX as u128) as u64; - let cost = JouleCost::measured(total_microjoules); - SciScore::compute(SciInputs { - joule_cost: &cost, +) -> Option { + sci_per_correct_answer( + EvalEnergy { + joules_per_correct: report.joules_per_correct, + correct: report.correct, + joule_source: report.joule_source, + }, intensity, pue, embodied_g_co2e, - functional_units: correct as f64, - }) + ) } #[cfg(test)] @@ -134,4 +203,111 @@ mod tests { assert_eq!(s.functional_units, 1.0); assert!(s.sci > 0.0); } + + fn intensity() -> CarbonIntensity { + CarbonIntensity::new("US", 400.0, IntensityKind::Average, ProviderKind::Mock, Utc::now()) + } + + fn score(cost: JouleCost, pue: f64) -> SciScore { + SciScore::compute(SciInputs { + joule_cost: &cost, + intensity: &intensity(), + pue: &Pue::new(pue), + embodied_g_co2e: 0.0, + functional_units: 1.0, + }) + } + + /// The score says where its energy term came from, and the same figure + /// yields the same numbers either way. The term is facility energy, so + /// it is measured only when the joules were and the PUE added nothing. + #[test] + fn the_score_carries_its_energy_provenance() { + assert_eq!(score(JouleCost::measured(1_000_000), 1.0).energy_source, JouleSource::Measured); + assert_eq!(score(JouleCost::estimated(1_000_000), 1.0).energy_source, JouleSource::Estimated); + assert_eq!( + score(JouleCost::measured(1_000_000), 1.2).energy_source, + JouleSource::Estimated, + "a PUE's overhead is not a reading" + ); + assert_eq!(score(JouleCost::measured(1_000_000), 1.2).sci, score(JouleCost::estimated(1_000_000), 1.2).sci); + } + + /// A score written before `energy_source` existed recorded no + /// provenance, so it reads back as an estimate. + #[test] + fn a_score_without_provenance_reads_back_estimated() { + let json = r#"{"energy_kwh":1.0,"operational_g_co2e":1.0,"embodied_g_co2e":0.0, + "functional_units":1.0,"sci":1.0}"#; + let back: SciScore = serde_json::from_str(json).unwrap(); + assert_eq!(back.energy_source, JouleSource::Estimated); + } + + fn run(joules_per_correct: u64, correct: usize, joule_source: JouleSource) -> EvalEnergy { + EvalEnergy { joules_per_correct, correct, joule_source } + } + + /// An eval run's score is measured exactly when the run's joules were + /// (and the PUE adds nothing); the run's provenance is never upgraded. + /// Compiled in every build: the `eval-bridge` feature only fills the + /// run's energy from a report. + #[test] + fn an_eval_runs_score_keeps_the_runs_provenance() { + let ci = intensity(); + let per_correct = |source, pue: f64| { + sci_per_correct_answer(run(1_000_000, 2, source), &ci, &Pue::new(pue), 0.0).expect("two correct answers") + }; + assert_eq!(per_correct(JouleSource::Estimated, 1.0).energy_source, JouleSource::Estimated); + assert_eq!(per_correct(JouleSource::Measured, 1.0).energy_source, JouleSource::Measured); + assert_eq!(per_correct(JouleSource::Measured, 1.2).energy_source, JouleSource::Estimated); + // 2 correct × 1 J, × 1.2 PUE = 2.4 J either way, over R = 2. + let (e, m) = (per_correct(JouleSource::Estimated, 1.2), per_correct(JouleSource::Measured, 1.2)); + assert!((e.energy_kwh - 2.4 / 3_600_000.0).abs() < 1e-15, "{}", e.energy_kwh); + assert_eq!(e.functional_units, 2.0); + assert_eq!(e.sci, m.sci); + } + + /// A run with no correct answer has no score per correct answer. Its + /// `u64::MAX` sentinel is never read as a u64::MAX µJ energy term, + /// whatever `correct` says beside it. + #[test] + fn a_run_with_no_correct_answer_has_no_score() { + let ci = intensity(); + let pue = Pue::new(1.2); + for source in [JouleSource::Measured, JouleSource::Estimated] { + assert_eq!(sci_per_correct_answer(run(u64::MAX, 0, source), &ci, &pue, 0.0), None); + assert_eq!(sci_per_correct_answer(run(5_000_000, 0, source), &ci, &pue, 0.0), None); + assert_eq!(sci_per_correct_answer(run(u64::MAX, 3, source), &ci, &pue, 0.0), None); + } + assert!(sci_per_correct_answer(run(1, 1, JouleSource::Measured), &ci, &pue, 0.0).is_some()); + } + + /// The bridge scores a report exactly as the run's energy is scored, and + /// gives none for a report with no correct answer. + #[cfg(feature = "eval-bridge")] + #[test] + fn an_eval_reports_score_is_its_runs_score() { + let report = |correct, joules_per_correct, joule_source| eoc_eval::EvalReport { + harness: "fixed".into(), + model: "test".into(), + total_cases: 4, + correct, + score: correct as f64 / 4.0, + metric: eoc_eval::Metric::Accuracy, + joules_per_correct, + joule_source, + latency_p50_ms: 1, + latency_p95_ms: 1, + }; + let ci = intensity(); + for pue in [Pue::new(1.0), Pue::new(1.2)] { + for source in [JouleSource::Estimated, JouleSource::Measured] { + let got = sci_from_eval_report(&report(2, 1_000_000, source), &ci, &pue, 0.1); + let want = sci_per_correct_answer(run(1_000_000, 2, source), &ci, &pue, 0.1); + assert_eq!(got, want); + assert!(got.is_some()); + } + } + assert_eq!(sci_from_eval_report(&report(0, u64::MAX, JouleSource::Measured), &ci, &Pue::new(1.2), 0.0), None); + } } diff --git a/eoc/eoc-rs/crates/eoc-carbon/tests/account_per_request.rs b/eoc/eoc-rs/crates/eoc-carbon/tests/account_per_request.rs index 7fc0dbbfa..7ddac18cc 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/tests/account_per_request.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/tests/account_per_request.rs @@ -30,4 +30,7 @@ fn one_kilojoule_400g_pue_1_2() { assert!((acc.facility_joules - 1200.0).abs() < 1e-9); assert_eq!(acc.pue, 1.2); assert_eq!(acc.g_co2e_per_kwh, 400.0); + // The IT joules were read; the PUE's overhead was not. + assert_eq!(acc.it_energy_source, JouleSource::Measured); + assert_eq!(acc.facility_energy_source, JouleSource::Estimated); } diff --git a/eoc/eoc-rs/crates/eoc-carbon/tests/sci_for_ai.rs b/eoc/eoc-rs/crates/eoc-carbon/tests/sci_for_ai.rs index f1b8f10a3..f19cc23d6 100644 --- a/eoc/eoc-rs/crates/eoc-carbon/tests/sci_for_ai.rs +++ b/eoc/eoc-rs/crates/eoc-carbon/tests/sci_for_ai.rs @@ -1,14 +1,15 @@ -//! End-to-end: joules (from eoc-meter stub) → CarbonAccount → SCI value. +//! End-to-end: joules → CarbonAccount → SCI value. //! //! We exercise the joule pipeline by using `eoc_meter::detect()` which //! always returns *some* counter on every platform, then drive the SCI -//! computation with a deterministic test reading. +//! computation with a deterministic test figure. No counter read that +//! figure, so it is an estimate, and the score says so. use chrono::Utc; use eoc_carbon::intensity::{CarbonIntensity, IntensityKind, ProviderKind}; use eoc_carbon::pue::Pue; use eoc_carbon::sci_bridge::{SciInputs, SciScore}; -use eoc_core::JouleCost; +use eoc_core::{JouleCost, JouleSource}; #[test] fn full_request_to_sci_uses_meter_and_carbon_pipeline() { @@ -18,8 +19,10 @@ fn full_request_to_sci_uses_meter_and_carbon_pipeline() { let _ = counter.read_microjoules(); assert!(!counter.name().is_empty()); - // Step 2 — pretend the meter reported 2000 J on the stub backend. - let joule_cost = JouleCost::measured(2_000_000_000); + // Step 2 — a fixed 2000 J for the request. No counter read it, so it + // is an estimate: the stub reads 0, and a live counter would not read + // this figure. + let joule_cost = JouleCost::estimated(2_000_000_000); // Step 3 — grid intensity for the request's region. let intensity = CarbonIntensity::new( @@ -49,4 +52,6 @@ fn full_request_to_sci_uses_meter_and_carbon_pipeline() { assert!((sci.operational_g_co2e - 0.03667).abs() < 1e-4); assert_eq!(sci.embodied_g_co2e, 0.05); assert!((sci.sci - 0.08667).abs() < 1e-4); + // The score is no more measured than the joules it was computed from. + assert_eq!(sci.energy_source, JouleSource::Estimated); } diff --git a/eoc/eoc-rs/crates/eoc-cascade/src/lib.rs b/eoc/eoc-rs/crates/eoc-cascade/src/lib.rs index e7e1a7215..a063a5ec5 100644 --- a/eoc/eoc-rs/crates/eoc-cascade/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-cascade/src/lib.rs @@ -1,9 +1,11 @@ //! EOC cascade — the four-stage memoizing pipeline. //! //! `Cascade::resolve` walks `cache → kv → graph → neural` in order. The -//! first stage to return `Some(Response)` wins. Joule cost is the sum of -//! the stage-level cost reported by the stage plus any cascade-level -//! overhead measured by the meter between the start and end of the call. +//! first stage to return `Some(Response)` wins. Its joule cost combines the +//! cost the stage reported with what the cascade's meter read over the whole +//! call, when that meter is live; it is `Measured` only when every +//! micro-joule of it was read from a live counter. Any micro-joule that was +//! estimated makes the whole an estimate. #![forbid(unsafe_code)] @@ -107,16 +109,17 @@ impl Cascade { r }; - // Re-derive the response with the cascade-attributed joule cost - // (stage-reported + measured overhead, if any). + // Re-derive the response with the cascade-attributed joule cost: the + // stage's own cost, combined with the meter's span when it is live. attach_cascade_cost(result, start, self.meter.as_ref()) } fn maybe_memoize(&self, _q: &Query, r: &Response) { if self.memoize { // Memoize as a *cache* entry so future hits report `Stage::Cache` - // and `JouleCost::zero()`. The original receipt is no longer - // valid (different stage + cost) — we mint a new one. + // and `JouleCost::zero()`, an estimated zero: no counter is read + // for a hit. The original receipt is no longer valid (different + // stage + cost) — we mint a new one. let cached = Response::new(r.query_id, r.payload.clone(), eoc_core::Stage::Cache, JouleCost::zero()); self.cache.insert_response(r.query_id, cached); @@ -125,29 +128,54 @@ impl Cascade { } fn attach_cascade_cost( - mut response: Response, + response: Response, start: Option, meter: &dyn JouleCounter, ) -> Response { let end = meter.read_microjoules().ok(); - let overhead = match (start, end) { - (Some(s), Some(e)) if e >= s => e - s, - _ => 0, - }; - let stage_cost = response.joule_cost.microjoules; - let total = stage_cost.saturating_add(overhead); - let source = if overhead > 0 { - JouleSource::Measured - } else { - response.joule_cost.source - }; - let joule_cost = JouleCost { - microjoules: total, - source, - }; + let joule_cost = cascade_cost(response.joule_cost, live_span(start, end)); // Rebuild the response so the receipt covers the final joule cost. - response = Response::new(response.query_id, response.payload, response.stage, joule_cost); - response + Response::new(response.query_id, response.payload, response.stage, joule_cost) +} + +/// What the cascade's meter read over one resolve: the delta between its two +/// reads, only when both succeeded and the counter advanced. A counter that +/// read but did not move — the stub, a frozen rail — measured nothing, and a +/// failed read leaves no span; neither is a figure. +fn live_span(start: Option, end: Option) -> Option { + match (start, end) { + (Some(s), Some(e)) if e > s => Some(e - s), + _ => None, + } +} + +/// The joule cost of one resolve, from the answering stage's own cost and +/// the cascade meter's live span over the whole resolve, if there was one. +/// +/// It is `Measured` only when every micro-joule of it was read from a live +/// counter; any micro-joule that was estimated makes the whole an estimate. +/// +/// - No live span: the cascade read nothing, adds nothing, and the stage's +/// cost stands as the stage reported it. +/// - A live span and a measured stage: the stage read a counter on this host +/// during the resolve, so inside the span. On the cascade's own counter the +/// span already holds that reading, and adding the two would count it +/// twice. The cost is the larger of the two, measured: exact when the stage +/// read the cascade's counter, a lower bound if it read another. +/// - A live span and an estimated stage: the estimate is energy no counter +/// read, possibly spent elsewhere (a remote backend), so it is added to the +/// span and the total is an estimate. An estimated zero, which is what a +/// cache hit costs, adds nothing: the cost is the span's reading alone, and +/// measured. +fn cascade_cost(stage: JouleCost, span: Option) -> JouleCost { + let Some(span) = span else { + return stage; + }; + match stage.source { + JouleSource::Measured => JouleCost::measured(stage.microjoules.max(span)), + JouleSource::Estimated if stage.microjoules == 0 => JouleCost::measured(span), + JouleSource::Estimated => JouleCost::estimated(stage.microjoules.saturating_add(span)), + } } #[cfg(test)] @@ -209,4 +237,157 @@ mod tests { let r2 = cascade.resolve(q).await; assert_eq!(r2.stage, StageKind::Cache); } + + /// A counter that reads the scripted values in turn, then the last one + /// again; `None` is a failed read. One instance can be shared by the + /// cascade and a stage, as one host counter would be. + struct Scripted { + reads: std::sync::Mutex>>, + } + + impl Scripted { + fn new(reads: &[Option]) -> Arc { + Arc::new(Self { + reads: std::sync::Mutex::new(reads.iter().copied().collect()), + }) + } + } + + impl JouleCounter for Scripted { + fn read_microjoules(&self) -> eoc_core::Result { + let mut r = self.reads.lock().unwrap(); + let next = if r.len() > 1 { + r.pop_front().unwrap() + } else { + r[0] + }; + next.ok_or(eoc_core::Error::NoCounter) + } + fn name(&self) -> &'static str { + "scripted" + } + } + + /// A neural backend that meters itself the way the local backends do: it + /// reads its counter before and after, and reports the delta as measured + /// when the counter advanced. + struct SelfMetered { + meter: Arc, + } + + #[async_trait::async_trait] + impl eoc_neural::NeuralBackend for SelfMetered { + async fn infer(&self, q: &Query) -> Response { + let start = self.meter.read_microjoules().ok(); + let end = self.meter.read_microjoules().ok(); + let cost = match (start, end) { + (Some(s), Some(e)) if e > s => JouleCost::measured(e - s), + _ => JouleCost::estimated(1), + }; + Response::new(q.id, "metered".to_string(), StageKind::Neural, cost) + } + } + + fn metered(meter: Arc, neural: Box) -> Cascade { + Cascade::new( + Arc::new(LruCache::new(8)), + Arc::new(KvStage::new(Box::new(MemoryKvBackend::new()))), + Arc::new(GraphStage::new()), + Arc::new(NeuralStage::new(neural)), + ) + .with_meter(meter) + } + + /// No meter is read for a cache hit, so it claims no measurement: not a + /// memoized answer, not an inserted one. + #[tokio::test] + async fn a_cache_hit_claims_no_measurement() { + let cascade = fixture(); + let q = Query::new("totally novel question"); + cascade.resolve(q.clone()).await; + let memoized = cascade.resolve(q).await; + assert_eq!(memoized.stage, StageKind::Cache); + assert_eq!(memoized.joule_cost, JouleCost::estimated(0)); + + let q = Query::new("inserted by hand"); + cascade.cache().insert(&q, "answer"); + let inserted = cascade.resolve(q).await; + assert_eq!(inserted.stage, StageKind::Cache); + assert_eq!(inserted.joule_cost, JouleCost::estimated(0)); + } + + /// A live counter advanced 500 µJ over the resolve, and the stage reported + /// a 50 J estimate. The total holds an estimate, so it is one. + #[tokio::test] + async fn an_estimate_plus_a_live_span_is_an_estimate() { + let meter = Scripted::new(&[Some(1_000), Some(1_500)]); + let cascade = metered(meter, Box::new(EchoBackend::new().with_cost(50_000_000))); + let r = cascade.resolve(Query::new("totally novel question")).await; + assert_eq!(r.stage, StageKind::Neural); + assert_eq!(r.joule_cost, JouleCost::estimated(50_000_500)); + assert!(r.receipt.verify(&r.query_id, &r.stage, &r.joule_cost, &r.payload)); + } + + /// The stage metered itself on the cascade's own counter: it read 300 µJ + /// (1_100 -> 1_400) inside the cascade's 500 µJ span (1_000 -> 1_500). The + /// span already holds those 300 µJ, so the resolve cost 500 µJ, measured, + /// never 800. + #[tokio::test] + async fn a_stage_metered_on_the_cascade_counter_is_counted_once() { + let meter = Scripted::new(&[Some(1_000), Some(1_100), Some(1_400), Some(1_500)]); + let shared: Arc = meter; + let cascade = metered(shared.clone(), Box::new(SelfMetered { meter: shared })); + let r = cascade.resolve(Query::new("totally novel question")).await; + assert_eq!(r.stage, StageKind::Neural); + assert_eq!(r.joule_cost, JouleCost::measured(500)); + } + + /// A cache hit adds no figure of its own, so on a live counter the cost is + /// the span's reading alone, every micro-joule of it read: measured. + #[tokio::test] + async fn a_cache_hit_on_a_live_counter_is_the_span_reading() { + let meter = Scripted::new(&[Some(2_000), Some(2_250)]); + let cascade = metered(meter, Box::new(EchoBackend::new())); + let q = Query::new("seen before"); + cascade.cache().insert(&q, "answer"); + let r = cascade.resolve(q).await; + assert_eq!(r.stage, StageKind::Cache); + assert_eq!(r.joule_cost, JouleCost::measured(250)); + } + + /// A counter that reads but does not move, or fails a read, measured + /// nothing: the stage's own cost stands, figure and tag. + #[tokio::test] + async fn a_span_that_is_not_live_adds_nothing() { + for reads in [vec![Some(7_000)], vec![None, Some(9_000)], vec![Some(9_000), None]] { + let meter = Scripted::new(&reads); + let cascade = metered(meter, Box::new(EchoBackend::new().with_cost(42))); + let r = cascade.resolve(Query::new("totally novel question")).await; + assert_eq!(r.joule_cost, JouleCost::estimated(42), "reads {reads:?}"); + } + } + + /// Every branch of the rule, on the figures alone. + #[test] + fn the_cost_is_measured_only_when_every_microjoule_was_read() { + let e = JouleCost::estimated; + let m = JouleCost::measured; + // No live span: the stage's cost, untouched. + assert_eq!(cascade_cost(e(40), None), e(40)); + assert_eq!(cascade_cost(m(40), None), m(40)); + assert_eq!(cascade_cost(e(0), None), e(0)); + // A live span and an estimate: summed, and an estimate. + assert_eq!(cascade_cost(e(40), Some(10)), e(50)); + // A live span and a measurement: the larger reading, never the sum. + assert_eq!(cascade_cost(m(40), Some(100)), m(100)); + assert_eq!(cascade_cost(m(400), Some(100)), m(400)); + // A live span and an estimated zero: the span's reading. + assert_eq!(cascade_cost(e(0), Some(100)), m(100)); + // A span is live only when the counter advanced. + assert_eq!(live_span(Some(5), Some(5)), None); + assert_eq!(live_span(Some(6), Some(5)), None); + assert_eq!(live_span(None, Some(5)), None); + assert_eq!(live_span(Some(5), None), None); + assert_eq!(live_span(Some(5), Some(8)), Some(3)); + } } diff --git a/eoc/eoc-rs/crates/eoc-core/src/joule_cost.rs b/eoc/eoc-rs/crates/eoc-core/src/joule_cost.rs index 68a831b00..be5be856d 100644 --- a/eoc/eoc-rs/crates/eoc-core/src/joule_cost.rs +++ b/eoc/eoc-rs/crates/eoc-core/src/joule_cost.rs @@ -5,9 +5,11 @@ use serde::{Deserialize, Serialize}; /// Where the joule reading came from. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum JouleSource { - /// Reading from a hardware energy counter (RAPL, NVML, powermetrics). + /// Read from a live hardware energy counter (RAPL, NVML, or IOReport + /// while its CPU rail is live): the counter advanced over the span. Measured, - /// Synthesized estimate (no hardware counter available). + /// Anything else: a synthesized estimate, or a figure no live counter + /// read (no counter, a failed read, a counter that did not advance). Estimated, } @@ -21,11 +23,19 @@ pub struct JouleCost { } impl JouleCost { - /// A zero-cost reading — typical for in-process cache hits. + /// Zero micro-joules, tagged [`JouleSource::Estimated`]: a figure no + /// counter read. It is what a cache hit costs by assumption, since the + /// energy of an in-process lookup is below any counter's resolution and + /// no meter is read for it. + /// + /// It is never [`JouleSource::Measured`]. Measured means a live hardware + /// counter read the span and advanced over it; a zero that no counter read + /// is an estimate, and a counter that read but did not move has measured + /// nothing. pub fn zero() -> Self { Self { microjoules: 0, - source: JouleSource::Measured, + source: JouleSource::Estimated, } } @@ -37,7 +47,8 @@ impl JouleCost { } } - /// A measured cost — used when a hardware counter reading is attached. + /// A measured cost: the delta a live hardware counter (RAPL, NVML, a live + /// IOReport rail) read over the span of the work, and nothing else. pub fn measured(microjoules: u64) -> Self { Self { microjoules, @@ -60,3 +71,15 @@ impl std::fmt::Display for JouleCost { write!(f, "{} µJ ({tag})", self.microjoules) } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A zero no counter read is an estimate, never a measurement. + #[test] + fn zero_claims_no_measurement() { + assert_eq!(JouleCost::zero(), JouleCost::estimated(0)); + assert_eq!(JouleCost::zero().to_string(), "0 µJ (estimated)"); + } +} diff --git a/eoc/eoc-rs/crates/eoc-eval/src/runner.rs b/eoc/eoc-rs/crates/eoc-eval/src/runner.rs index c3c818cc4..f910792ed 100644 --- a/eoc/eoc-rs/crates/eoc-eval/src/runner.rs +++ b/eoc/eoc-rs/crates/eoc-eval/src/runner.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use serde::{Deserialize, Serialize}; -use eoc_core::Query; +use eoc_core::{JouleSource, Query}; use eoc_neural::NeuralBackend; use crate::error::Result; @@ -77,6 +77,8 @@ impl EvalRunner { let mut total_score = 0.0f64; let mut total_microjoules: u128 = 0; + // Whether every case's cost so far was read from a live counter. + let mut every_case_measured = true; let mut latencies: Vec = Vec::with_capacity(total_cases); for case in &cases { @@ -85,6 +87,7 @@ impl EvalRunner { let core_resp = self.backend.infer(&q).await; let latency_ms = start.elapsed().as_millis().min(u64::MAX as u128) as u64; total_microjoules += u128::from(core_resp.joule_cost.microjoules); + every_case_measured &= core_resp.joule_cost.source == JouleSource::Measured; latencies.push(latency_ms); let response = Response::from_core(core_resp, latency_ms); let s = self.harness.score(&response, &case.expected).await; @@ -107,6 +110,14 @@ impl EvalRunner { (total_microjoules / (correct as u128)).min(u64::MAX as u128) as u64 }; + // A total is only as good as its weakest part: one case that was not + // measured makes it an estimate, and a run with no cases read nothing. + let joule_source = if total_cases > 0 && every_case_measured { + JouleSource::Measured + } else { + JouleSource::Estimated + }; + latencies.sort_unstable(); let latency_p50_ms = percentile(&latencies, 0.50); let latency_p95_ms = percentile(&latencies, 0.95); @@ -119,6 +130,7 @@ impl EvalRunner { score, metric, joules_per_correct, + joule_source, latency_p50_ms, latency_p95_ms, }) @@ -144,12 +156,25 @@ pub struct EvalReport { pub metric: Metric, /// Micro-joules per correct case. `u64::MAX` when zero correct. pub joules_per_correct: u64, + /// Where the energy behind `joules_per_correct` came from: `Measured` + /// only when every case's cost was read from a live counter, `Estimated` + /// otherwise, including a run with no cases. A report serialized without + /// this field reads back as `Estimated`: nothing in it says it was + /// measured. + #[serde(default = "unrecorded_joule_source")] + pub joule_source: JouleSource, /// Latency p50 in milliseconds. pub latency_p50_ms: u64, /// Latency p95 in milliseconds. pub latency_p95_ms: u64, } +/// The provenance of a report that recorded none: an estimate, never a +/// measurement. +fn unrecorded_joule_source() -> JouleSource { + JouleSource::Estimated +} + /// Nearest-rank percentile (no interpolation). fn percentile(sorted: &[u64], q: f64) -> u64 { if sorted.is_empty() { @@ -248,6 +273,7 @@ mod tests { score: 1.0, metric: Metric::Accuracy, joules_per_correct: 100, + joule_source: JouleSource::Measured, latency_p50_ms: 1, latency_p95_ms: 1, }; @@ -255,5 +281,65 @@ mod tests { let back: EvalReport = serde_json::from_str(&json).unwrap(); assert_eq!(back.harness, "mmlu"); assert_eq!(back.correct, 1); + assert_eq!(back.joule_source, JouleSource::Measured); + } + + /// A report written before `joule_source` existed recorded no + /// provenance, so it reads back as an estimate, never a measurement. + #[test] + fn a_report_without_provenance_reads_back_estimated() { + let json = r#"{"harness":"mmlu","model":"m","total_cases":1,"correct":1, + "score":1.0,"metric":"Accuracy","joules_per_correct":100, + "latency_p50_ms":1,"latency_p95_ms":1}"#; + let back: EvalReport = serde_json::from_str(json).unwrap(); + assert_eq!(back.joule_source, JouleSource::Estimated); + } + + /// A backend whose every answer reports the given cost. + struct Costing(JouleCost); + #[async_trait] + impl NeuralBackend for Costing { + async fn infer(&self, q: &CoreQuery) -> CoreResponse { + CoreResponse::new(q.id, "yes".into(), Stage::Neural, self.0) + } + } + + /// One case's cost is measured exactly when its backend's is; a total + /// with one estimated case in it is an estimate. + #[tokio::test] + async fn the_report_is_measured_only_when_every_case_was() { + let run = |backend: Arc| async move { + let harness = Box::new(FixedHarness { n_cases: 4, correct: "yes" }); + EvalRunner::new(harness, backend).run().await.unwrap() + }; + let measured = run(Arc::new(Costing(JouleCost::measured(10)))).await; + assert_eq!(measured.joule_source, JouleSource::Measured); + let estimated = run(Arc::new(Costing(JouleCost::estimated(10)))).await; + assert_eq!(estimated.joule_source, JouleSource::Estimated); + + // Measured, measured, then estimated from the third answer on. + struct Mixed(AtomicUsize); + #[async_trait] + impl NeuralBackend for Mixed { + async fn infer(&self, q: &CoreQuery) -> CoreResponse { + let cost = if self.0.fetch_add(1, Ordering::Relaxed) < 2 { + JouleCost::measured(10) + } else { + JouleCost::estimated(10) + }; + CoreResponse::new(q.id, "yes".into(), Stage::Neural, cost) + } + } + let mixed = run(Arc::new(Mixed(AtomicUsize::new(0)))).await; + assert_eq!(mixed.joule_source, JouleSource::Estimated); + + let none = EvalRunner::new( + Box::new(FixedHarness { n_cases: 0, correct: "yes" }), + Arc::new(Costing(JouleCost::measured(10))), + ) + .run() + .await + .unwrap(); + assert_eq!(none.joule_source, JouleSource::Estimated, "no case ran, nothing was read"); } } diff --git a/eoc/eoc-rs/crates/eoc-local/src/lib.rs b/eoc/eoc-rs/crates/eoc-local/src/lib.rs index 262421e8b..0fedc4fc6 100644 --- a/eoc/eoc-rs/crates/eoc-local/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-local/src/lib.rs @@ -4,9 +4,9 @@ //! the same [`eoc_neural::NeuralBackend`] trait that //! [`eoc-vendor-api`](https://docs.rs/eoc-vendor-api) uses for commercial //! endpoints. Where vendor APIs *estimate* joule cost from token counts, -//! local backends *measure* it — the host hardware is right there, and -//! [`eoc_meter::JouleCounter`] readings taken on either side of the -//! inference give an honest reading. +//! local backends can *measure* it — the host hardware is right there, and +//! when the host has a live counter, [`eoc_meter::JouleCounter`] readings +//! taken on either side of the inference give an honest reading. //! //! ## Backends (all feature-gated) //! @@ -23,11 +23,29 @@ //! //! ## Joule attribution //! -//! Each backend takes a [`JouleCounter`](eoc_meter::JouleCounter), reads -//! it before and after inference, and reports the delta as -//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured). When the -//! attached counter is [`StubCounter`](eoc_meter::StubCounter), cost is -//! zero — useful for tests, deterministic playback, and CI. +//! Each backend takes a [`JouleCounter`](eoc_meter::JouleCounter) and reads +//! it around the inference, by one rule ([`span`]): +//! +//! - The inference is **measured** only when the counter read at the start, +//! read at the end, and advanced between them. The delta is then a live +//! hardware counter's reading, reported as +//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured). +//! - Otherwise nothing was measured: the +//! [`StubCounter`](eoc_meter::StubCounter), which always reads 0; a counter +//! that read but did not move; or a read that failed at either end, such +//! as the powermetrics stand-in's, which [`eoc_meter::detect`] selects on a +//! Mac whose IOReport CPU rail is not live. A failed first read is never +//! taken as 0, which would report the counter's since-boot total. +//! - Inference that ran and that nothing measured is never 0 µJ. Every +//! backend then reports its per-token estimate, `fallback_joules_per_token` +//! (0.05 J by default, [`span::DEFAULT_FALLBACK_JOULES_PER_TOKEN`]) × the +//! tokens it generated, tagged +//! [`JouleSource::Estimated`](eoc_core::JouleSource::Estimated). +//! +//! `llamacpp` also reads the counter with each streamed token, so a chunk +//! carries the reading so far when there is one; the inference's cost is +//! the final read's. A generation that ends in an error is estimated over +//! the tokens streamed before it, at least one. //! //! ## WASM //! @@ -49,10 +67,17 @@ pub mod tokenizer; #[cfg(any(feature = "gguf", feature = "llamacpp"))] pub mod gguf; -#[cfg(feature = "llamacpp")] +pub mod span; + +// The module binds no native symbol (its generator is a placeholder until an +// operator wires llama.cpp in), so this crate's tests compile it without the +// feature, and its metering is tested in every build. +#[cfg(any(feature = "llamacpp", test))] pub mod llamacpp; -#[cfg(all(feature = "mlx", target_os = "macos"))] +// Likewise binds no native symbol yet: compiled for this crate's tests on +// macOS without the feature. +#[cfg(all(any(feature = "mlx", test), target_os = "macos"))] pub mod mlx; #[cfg(feature = "mlc")] diff --git a/eoc/eoc-rs/crates/eoc-local/src/llamacpp.rs b/eoc/eoc-rs/crates/eoc-local/src/llamacpp.rs index e13994fbd..731e09f0e 100644 --- a/eoc/eoc-rs/crates/eoc-local/src/llamacpp.rs +++ b/eoc/eoc-rs/crates/eoc-local/src/llamacpp.rs @@ -11,22 +11,33 @@ //! | Linux + Intel Arc | SYCL | //! | otherwise | Vulkan (cross-vendor) or CPU | //! -//! Joule attribution: a [`JouleCounter`] reading is taken before and -//! after each inference. The delta is reported as -//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured) when the -//! counter actually produced a number, else `Estimated`. +//! Joule attribution: a [`JouleCounter`] span is opened before generation +//! and read with each streamed token and once more at the end +//! ([`crate::span`]). The inference's cost is the final reading, +//! [`JouleSource::Measured`](eoc_core::JouleSource::Measured), only when +//! the counter read at the start, read at the end, and advanced between +//! them. Otherwise nothing was measured, and the cost is a per-token +//! estimate ([`LlamaCppBackend::fallback_joules_per_token`] × tokens +//! streamed), tagged estimated, never 0 µJ. A generation that ends in an +//! error is estimated the same way, over the tokens streamed before it (at +//! least one): inference may have run, so its cost is never 0 µJ either. //! //! Streaming: tokens are emitted through a `tokio::sync::mpsc` channel //! for incremental UIs. The non-streaming `infer` is implemented as //! "drain the stream". //! +//! The module's own logic needs no native code: the reference build's +//! generator below is a placeholder, so the module is compiled for this +//! crate's tests without the `llamacpp` feature, and its metering runs +//! under test on a scripted counter. +//! //! [`llama-cpp-2`]: https://crates.io/crates/llama-cpp-2 use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; -use eoc_core::{JouleCost, JouleSource, Query, Response, Stage}; +use eoc_core::{JouleCost, Query, QueryId, Response, Stage}; use eoc_meter::JouleCounter; use eoc_neural::NeuralBackend; use tokio::sync::mpsc; @@ -34,6 +45,7 @@ use tokio::sync::mpsc; use crate::error::{LocalError, LocalResult}; use crate::model_registry::Quantization; use crate::sampling::{GreedySampler, Sampler}; +use crate::span::{self, MeterSpan}; /// llama.cpp backend. pub struct LlamaCppBackend { @@ -54,6 +66,9 @@ pub struct LlamaCppBackend { pub sampler: Box, /// Joule counter used to attribute energy cost. pub meter: Arc, + /// Estimated joules per streamed token, used when the counter did not + /// measure the inference ([`crate::span::per_token_estimate`]). + pub fallback_joules_per_token: f64, } /// Configuration for [`LlamaCppBackend`]. The runtime model object @@ -105,6 +120,7 @@ impl LlamaCppBackend { quantization: config.quantization, sampler: Box::new(GreedySampler), meter, + fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN, }) } @@ -114,9 +130,16 @@ impl LlamaCppBackend { self } + /// Override the per-token estimate used when nothing was measured. + pub fn with_fallback_joules_per_token(mut self, j: f64) -> Self { + self.fallback_joules_per_token = j; + self + } + /// Streaming inference: emits one token per send. The receiver is - /// closed when generation completes. Each chunk includes the - /// running joule estimate. + /// closed when generation completes. Each chunk carries what the + /// counter has read since generation started, when it has read + /// anything ([`TokenChunk::cumulative_microjoules`]). /// /// Implemented as `spawn_blocking` so the synchronous llama.cpp /// call doesn't starve the tokio reactor. @@ -139,8 +162,12 @@ impl LlamaCppBackend { pub struct TokenChunk { /// Decoded UTF-8 fragment for this token. pub text: String, - /// Cumulative joule cost since the start of generation. - pub cumulative_microjoules: u64, + /// What the counter has read since generation started, in micro-joules: + /// `Some` only when it read at the start, read for this chunk, and + /// advanced between the two. `None` when nothing was measured — a failed + /// read, the stub, a counter that did not move — which is not 0 µJ: the + /// tokens were generated. + pub cumulative_microjoules: Option, /// Whether this is the last chunk. pub final_chunk: bool, } @@ -148,46 +175,68 @@ pub struct TokenChunk { #[async_trait] impl NeuralBackend for LlamaCppBackend { async fn infer(&self, q: &Query) -> Response { - // Drain the stream into a single response. - let mut rx = self.stream(q).await; - let mut text = String::new(); - let mut last_cum = 0u64; - let mut source = JouleSource::Measured; - while let Some(item) = rx.recv().await { - match item { - Ok(chunk) => { - text.push_str(&chunk.text); - last_cum = chunk.cumulative_microjoules; - if chunk.cumulative_microjoules == 0 { - source = JouleSource::Estimated; - } - } - Err(e) => { - return Response::new( - q.id, - format!("[llamacpp-error: {e}]"), - Stage::Neural, - JouleCost { - microjoules: 0, - source: JouleSource::Estimated, - }, - ); - } + drain(self.stream(q).await, q.id, self.fallback_joules_per_token).await + } +} + +/// Drain a generation's stream into the response for query `id`. The cost +/// of a stream that ends is [`StreamTally::cost`]. A stream that errors ends +/// there, with the error text; its inference may have run — the prompt +/// processed and every token streamed before the error generated — so its +/// cost is the per-token estimate over those tokens, at least one, tagged +/// estimated: never 0 µJ, and never a reading that covers only part of it. +async fn drain(mut rx: mpsc::Receiver>, id: QueryId, joules_per_token: f64) -> Response { + let mut text = String::new(); + let mut tally = StreamTally::default(); + while let Some(item) = rx.recv().await { + match item { + Ok(chunk) => { + text.push_str(&chunk.text); + tally.observe(&chunk); } + Err(e) => { + return Response::new( + id, + format!("[llamacpp-error: {e}]"), + Stage::Neural, + JouleCost::estimated(span::per_token_estimate(tally.tokens, joules_per_token)), + ); + } + } + } + Response::new(id, text, Stage::Neural, tally.cost(joules_per_token)) +} + +/// What a drained stream says about its cost: how many tokens it carried, +/// and the final chunk's reading. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct StreamTally { + /// Chunks that carried a token. + tokens: u64, + /// The final chunk's reading: what the counter read over the whole + /// generation, when it read anything. `None` until the final chunk, and + /// after it when nothing was measured. + final_reading: Option, +} + +impl StreamTally { + fn observe(&mut self, chunk: &TokenChunk) { + if chunk.final_chunk { + self.final_reading = chunk.cumulative_microjoules; + } else if !chunk.text.is_empty() { + self.tokens += 1; } - Response::new( - q.id, - text, - Stage::Neural, - JouleCost { - microjoules: last_cum, - source, - }, - ) + } + + /// The generation's cost: the final reading, measured, when the counter + /// measured the whole span; otherwise the per-token estimate. A reading + /// from an earlier chunk covers only part of the generation, so it is + /// never the cost of the whole. + fn cost(&self, joules_per_token: f64) -> JouleCost { + span::span_cost(self.final_reading, || span::per_token_estimate(self.tokens, joules_per_token)) } } -#[cfg(feature = "llamacpp")] fn run_llamacpp_blocking( prompt: &str, model_path: &std::path::Path, @@ -221,7 +270,7 @@ fn run_llamacpp_blocking( // plumbing. Operators wire in the real call against their pinned // llama-cpp-2 revision. - let start = meter.read_microjoules().unwrap_or(0); + let span = MeterSpan::open(meter); let mut out = String::new(); let n = max_tokens.min(64) as usize; for i in 0..n { @@ -243,15 +292,10 @@ fn run_llamacpp_blocking( break; } out.push_str(&frag); - let cum = meter - .read_microjoules() - .ok() - .map(|now| now.saturating_sub(start)) - .unwrap_or(0); if tx .blocking_send(Ok(TokenChunk { text: frag, - cumulative_microjoules: cum, + cumulative_microjoules: span.reading(meter), final_chunk: false, })) .is_err() @@ -259,37 +303,18 @@ fn run_llamacpp_blocking( break; } } - let cum = meter - .read_microjoules() - .ok() - .map(|now| now.saturating_sub(start)) - .unwrap_or(0); let _ = tx.blocking_send(Ok(TokenChunk { text: String::new(), - cumulative_microjoules: cum, + cumulative_microjoules: span.reading(meter), final_chunk: true, })); let _ = out; } -#[cfg(not(feature = "llamacpp"))] -#[allow(dead_code)] -fn run_llamacpp_blocking( - _prompt: &str, - _model_path: &std::path::Path, - _max_tokens: u32, - _meter: &dyn JouleCounter, - tx: mpsc::Sender>, -) { - let _ = tx.blocking_send(Err(LocalError::Backend { - backend: "llamacpp", - message: "feature `llamacpp` not enabled".into(), - })); -} - #[cfg(test)] mod tests { use super::*; + use crate::span::scripted::Scripted; use eoc_meter::StubCounter; use std::sync::Arc; @@ -303,21 +328,135 @@ mod tests { assert!(matches!(r, Err(LocalError::ModelNotFound(_)))); } - #[tokio::test] - async fn stub_counter_reports_zero_cost() { - // Construct a temp file so the existence check passes, then - // verify that with a StubCounter the reported joule cost is 0. + /// A backend on a dummy model file, metered by `meter`. The placeholder + /// generator streams four tokens for "hello world" (its header, the two + /// words, a filler), so the per-token estimate at 0.05 J/token is + /// 200,000 µJ. + fn backend(meter: Arc) -> (tempfile::TempDir, LlamaCppBackend) { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("dummy.gguf"); std::fs::write(&p, b"").unwrap(); - let cfg = LlamaCppConfig { - model_path: p, - ..LlamaCppConfig::default() - }; - let backend = LlamaCppBackend::new(cfg, Arc::new(StubCounter)).unwrap(); - let q = Query::new("hello world"); - let r = backend.infer(&q).await; - assert_eq!(r.joule_cost.microjoules, 0); + let cfg = LlamaCppConfig { model_path: p, ..LlamaCppConfig::default() }; + let b = LlamaCppBackend::new(cfg, meter).unwrap().with_fallback_joules_per_token(0.05); + (dir, b) + } + + const FOUR_TOKENS_UJ: u64 = 200_000; + + /// Every chunk of a stream metered by `meter`, in order. + async fn chunks(meter: Arc) -> Vec { + let (_dir, b) = backend(meter); + let mut rx = b.stream(&Query::new("hello world")).await; + let mut out = Vec::new(); + while let Some(item) = rx.recv().await { + out.push(item.expect("the placeholder never errors")); + } + out + } + + async fn cost(meter: Arc) -> JouleCost { + let (_dir, b) = backend(meter); + let r = b.infer(&Query::new("hello world")).await; assert_eq!(r.stage, Stage::Neural); + r.joule_cost + } + + /// The stub measured nothing, and the tokens were generated: the cost is + /// the per-token estimate, never 0 µJ. + #[tokio::test] + async fn the_stub_costs_the_per_token_estimate_never_zero() { + assert_eq!(cost(Arc::new(StubCounter)).await, JouleCost::estimated(FOUR_TOKENS_UJ)); + let streamed = chunks(Arc::new(StubCounter)).await; + assert_eq!(streamed.iter().filter(|c| !c.final_chunk).count(), 4); + assert!(streamed.iter().all(|c| c.cumulative_microjoules.is_none()), "{streamed:?}"); + } + + /// A failed first read is no baseline. The next reading is the counter's + /// since-boot total (9 J here), not the generation's energy: no chunk + /// carries it and the cost is not it. + #[tokio::test] + async fn a_failed_first_read_never_reports_the_since_boot_total() { + let streamed = chunks(Arc::new(Scripted::new(&[None, Some(9_000_000)]))).await; + assert!(streamed.iter().all(|c| c.cumulative_microjoules.is_none()), "{streamed:?}"); + let c = cost(Arc::new(Scripted::new(&[None, Some(9_000_000)]))).await; + assert_eq!(c, JouleCost::estimated(FOUR_TOKENS_UJ)); + } + + /// A counter that advanced over the whole generation measured it: the + /// final reading, measured. Each chunk carries the reading so far. + #[tokio::test] + async fn a_counter_that_advanced_measures_the_generation() { + let script = [Some(1_000), Some(1_100), Some(1_200), Some(1_300), Some(1_400), Some(1_450)]; + let meter = Arc::new(Scripted::new(&script)); + let streamed = chunks(meter.clone()).await; + assert_eq!(meter.reads(), 6, "one read to open, one per token, one at the end"); + let readings: Vec> = streamed.iter().map(|c| c.cumulative_microjoules).collect(); + assert_eq!(readings, vec![Some(100), Some(200), Some(300), Some(400), Some(450)]); + assert_eq!(cost(Arc::new(Scripted::new(&script))).await, JouleCost::measured(450)); + } + + /// A counter that read but did not move measured nothing. + #[tokio::test] + async fn a_counter_that_held_is_an_estimate() { + assert_eq!(cost(Arc::new(Scripted::new(&[Some(7_000)]))).await, JouleCost::estimated(FOUR_TOKENS_UJ)); + } + + /// The last read failed: the readings the earlier chunks carried cover + /// part of the generation, so none of them is its cost. + #[tokio::test] + async fn a_failed_last_read_is_an_estimate_not_a_partial_reading() { + let script = [Some(1_000), Some(1_100), Some(1_200), Some(1_300), Some(1_400), None]; + let streamed = chunks(Arc::new(Scripted::new(&script))).await; + assert_eq!(streamed.last().map(|c| (c.final_chunk, c.cumulative_microjoules)), Some((true, None))); + assert_eq!(cost(Arc::new(Scripted::new(&script))).await, JouleCost::estimated(FOUR_TOKENS_UJ)); + } + + /// A stream that ends in an error, drained as `infer` drains one: `sent` + /// token chunks, each carrying `reading`, then the error. + async fn drain_with_error(sent: usize, reading: Option) -> Response { + let (tx, rx) = mpsc::channel(64); + for i in 0..sent { + let chunk = TokenChunk { text: format!("t{i} "), cumulative_microjoules: reading, final_chunk: false }; + tx.send(Ok(chunk)).await.unwrap(); + } + let error = LocalError::Backend { backend: "llamacpp", message: "decode failed".to_string() }; + tx.send(Err(error)).await.unwrap(); + drop(tx); + drain(rx, Query::new("hello world").id, 0.05).await + } + + /// An error ends the generation, but inference may have run: the cost is + /// the per-token estimate over the tokens streamed before the error, + /// tagged estimated — three tokens, 150,000 µJ at 0.05 J — and one token + /// (50,000 µJ) when the error came first. Never 0 µJ, and never a reading + /// an earlier chunk carried, which covers only part of the work. + #[tokio::test] + async fn an_error_costs_the_per_token_estimate_never_zero() { + let r = drain_with_error(3, None).await; + assert_eq!(r.payload, "[llamacpp-error: backend error (llamacpp): decode failed]"); + assert_eq!((r.stage, r.joule_cost), (Stage::Neural, JouleCost::estimated(150_000))); + assert_eq!(drain_with_error(3, Some(400)).await.joule_cost, JouleCost::estimated(150_000)); + assert_eq!(drain_with_error(0, None).await.joule_cost, JouleCost::estimated(50_000)); + } + + /// The tally counts tokens and takes only the final chunk's reading. + #[test] + fn the_tally_costs_only_the_final_reading() { + let chunk = |text: &str, reading, final_chunk| TokenChunk { + text: text.to_string(), + cumulative_microjoules: reading, + final_chunk, + }; + let mut t = StreamTally::default(); + t.observe(&chunk("a", Some(100), false)); + t.observe(&chunk("b", Some(250), false)); + assert_eq!(t.cost(0.05), JouleCost::estimated(100_000), "no final chunk yet"); + t.observe(&chunk("", None, true)); + assert_eq!(t, StreamTally { tokens: 2, final_reading: None }); + assert_eq!(t.cost(0.05), JouleCost::estimated(100_000)); + let mut t = StreamTally::default(); + t.observe(&chunk("a", Some(100), false)); + t.observe(&chunk("", Some(300), true)); + assert_eq!(t.cost(0.05), JouleCost::measured(300)); } } diff --git a/eoc/eoc-rs/crates/eoc-local/src/mlc.rs b/eoc/eoc-rs/crates/eoc-local/src/mlc.rs index b45cd5884..1690dd37a 100644 --- a/eoc/eoc-rs/crates/eoc-local/src/mlc.rs +++ b/eoc/eoc-rs/crates/eoc-local/src/mlc.rs @@ -9,19 +9,23 @@ //! provides the *runtime loader* only. The expected on-disk layout is //! the standard `mlc-chat-config.json` + compiled library + weights. //! -//! Joule attribution piggy-backs on the `mlc_chat` runtime's own -//! reported timing where available; otherwise we sandwich the inference -//! call with [`JouleCounter`] reads exactly like the other backends. +//! Joule attribution sandwiches the inference call with [`JouleCounter`] +//! reads, by the rule every backend here shares ([`crate::span`]): the +//! delta is measured only when both reads succeed and the counter +//! advanced. Otherwise nothing was measured, and the cost is a per-token +//! estimate ([`MlcBackend::fallback_joules_per_token`]), tagged estimated, +//! as `mlx`'s is — never 0 µJ for inference that ran. use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; -use eoc_core::{JouleCost, JouleSource, Query, Response, Stage}; +use eoc_core::{Query, Response, Stage}; use eoc_meter::JouleCounter; use eoc_neural::NeuralBackend; use crate::error::{LocalError, LocalResult}; +use crate::span::{self, MeterSpan}; /// MLC-LLM backend. pub struct MlcBackend { @@ -37,6 +41,9 @@ pub struct MlcBackend { pub max_tokens: u32, /// Joule counter for attribution. pub meter: Arc, + /// Estimated joules per output token, used when the counter did not + /// measure the inference ([`crate::span::per_token_estimate`]). + pub fallback_joules_per_token: f64, } impl MlcBackend { @@ -52,6 +59,7 @@ impl MlcBackend { device: default_device().to_string(), max_tokens: 512, meter, + fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN, }) } @@ -73,6 +81,12 @@ impl MlcBackend { self.max_tokens = n; self } + + /// Override the per-token estimate used when nothing was measured. + pub fn with_fallback_joules_per_token(mut self, j: f64) -> Self { + self.fallback_joules_per_token = j; + self + } } fn default_device() -> &'static str { @@ -99,34 +113,29 @@ impl NeuralBackend for MlcBackend { // let response = mlc.generate(&q.prompt, GenerationOptions { max_tokens, ... })?; // // Reference build emits a placeholder and uses the meter - // sandwich for attribution. + // sandwich for attribution. Its token count, for the estimate, is + // the placeholder's own (as `mlx`'s is); a wired runtime passes the + // number of tokens it generated. - let start = self.meter.read_microjoules().ok(); + let span = MeterSpan::open(self.meter.as_ref()); + let generated_tokens = u64::from(self.max_tokens.min(64)); let payload = format!( "[mlc device={} model_dir={}]", self.device, self.model_dir.display() ); - let end = self.meter.read_microjoules().ok(); - let (microjoules, source) = match (start, end) { - (Some(s), Some(e)) if e >= s && (e - s) > 0 => (e - s, JouleSource::Measured), - _ => (0, JouleSource::Estimated), - }; - Response::new( - q.id, - payload, - Stage::Neural, - JouleCost { - microjoules, - source, - }, - ) + let cost = span::span_cost(span.reading(self.meter.as_ref()), || { + span::per_token_estimate(generated_tokens, self.fallback_joules_per_token) + }); + Response::new(q.id, payload, Stage::Neural, cost) } } #[cfg(test)] mod tests { use super::*; + use crate::span::scripted::Scripted; + use eoc_core::JouleCost; use eoc_meter::StubCounter; #[tokio::test] @@ -142,13 +151,38 @@ mod tests { assert!(matches!(r, Err(LocalError::ModelNotFound(_)))); } + /// Inference that ran and that nothing measured costs the per-token + /// estimate, tagged estimated: 8 tokens × 0.1 J = 800,000 µJ, never 0. #[tokio::test] - async fn stub_counter_reports_zero() { + async fn the_stub_costs_the_per_token_estimate_never_zero() { let dir = tempfile::tempdir().unwrap(); - let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)).unwrap(); - let q = Query::new("hello"); - let r = b.infer(&q).await; - assert_eq!(r.joule_cost.microjoules, 0); + let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)) + .unwrap() + .with_max_tokens(8) + .with_fallback_joules_per_token(0.1); + let r = b.infer(&Query::new("hello")).await; + assert_eq!(r.joule_cost, JouleCost::estimated(800_000)); assert_eq!(r.stage, Stage::Neural); + // The default coefficient, over the placeholder's 64-token cap. + let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)).unwrap(); + assert_eq!(b.infer(&Query::new("hello")).await.joule_cost, JouleCost::estimated(64 * 50_000)); + } + + /// Each way a span can fail to measure gives the estimate; only a counter + /// that read twice and advanced gives a measured figure, its delta. + #[tokio::test] + async fn only_a_counter_that_advanced_is_measured() { + let dir = tempfile::tempdir().unwrap(); + let cost = |script: &'static [Option]| { + let b = MlcBackend::new(dir.path(), Arc::new(Scripted::new(script))) + .unwrap() + .with_max_tokens(8) + .with_fallback_joules_per_token(0.1); + async move { b.infer(&Query::new("hello")).await.joule_cost } + }; + assert_eq!(cost(&[Some(1_000), Some(1_450)]).await, JouleCost::measured(450)); + assert_eq!(cost(&[Some(7_000)]).await, JouleCost::estimated(800_000), "held"); + assert_eq!(cost(&[None, Some(9_000_000)]).await, JouleCost::estimated(800_000), "failed first read"); + assert_eq!(cost(&[Some(1_000), None]).await, JouleCost::estimated(800_000), "failed last read"); } } diff --git a/eoc/eoc-rs/crates/eoc-local/src/mlx.rs b/eoc/eoc-rs/crates/eoc-local/src/mlx.rs index d4ef866cd..a0f533020 100644 --- a/eoc/eoc-rs/crates/eoc-local/src/mlx.rs +++ b/eoc/eoc-rs/crates/eoc-local/src/mlx.rs @@ -5,11 +5,18 @@ //! choice on M-series Macs for models that have been converted to MLX //! format. The Rust binding lives in [`mlx-rs`]. //! -//! Joule attribution on macOS uses `powermetrics`, which is already -//! wired into [`eoc_meter`]. `powermetrics` requires root in practice; -//! when readings fail, the backend tags joule cost as +//! Joule attribution reads whatever [`JouleCounter`] the backend is given +//! (on a Mac, [`eoc_meter::detect`] picks IOReport only when its CPU rail is +//! live; its `powermetrics` stand-in never reads), by the rule every backend +//! here shares ([`crate::span`]). A delta from a counter that read twice and +//! advanced is reported as measured; when a read fails or the counter does +//! not move, the backend tags joule cost as //! [`JouleSource::Estimated`](eoc_core::JouleSource::Estimated) and -//! falls back to a coefficient-based estimate. +//! falls back to a coefficient-based estimate, never 0 µJ. +//! +//! The module binds no `mlx-rs` symbol yet (its generator is a placeholder), +//! so this crate's tests compile it on macOS without the feature, and its +//! metering is tested in every macOS build. //! //! This module is hard-gated to `target_os = "macos"`. On any other //! platform compilation of `--features mlx` will fail with a clear @@ -23,11 +30,12 @@ use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; -use eoc_core::{JouleCost, JouleSource, Query, Response, Stage}; +use eoc_core::{Query, Response, Stage}; use eoc_meter::JouleCounter; use eoc_neural::NeuralBackend; use crate::error::{LocalError, LocalResult}; +use crate::span::{self, MeterSpan}; /// Apple MLX backend. pub struct MlxBackend { @@ -62,7 +70,7 @@ impl MlxBackend { tokenizer_path, max_tokens: 512, meter, - fallback_joules_per_token: 0.05, + fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN, }) } @@ -97,33 +105,22 @@ impl NeuralBackend for MlxBackend { // the trait + plumbing are exercised end-to-end. Operators // swap in the real mlx-rs symbols against their pinned rev. - let start = self.meter.read_microjoules().ok(); - let estimated_tokens = self.max_tokens.min(64) as u32; + let span = MeterSpan::open(self.meter.as_ref()); + let estimated_tokens = u64::from(self.max_tokens.min(64)); let payload = format!("[mlx model={} prompt={:?}]", self.model_path.display(), q.prompt); - let end = self.meter.read_microjoules().ok(); - let (microjoules, source) = match (start, end) { - (Some(s), Some(e)) if e >= s && (e - s) > 0 => (e - s, JouleSource::Measured), - _ => { - let j = (estimated_tokens as f64) * self.fallback_joules_per_token; - ((j * 1_000_000.0).max(0.0) as u64, JouleSource::Estimated) - } - }; - Response::new( - q.id, - payload, - Stage::Neural, - JouleCost { - microjoules, - source, - }, - ) + let cost = span::span_cost(span.reading(self.meter.as_ref()), || { + span::per_token_estimate(estimated_tokens, self.fallback_joules_per_token) + }); + Response::new(q.id, payload, Stage::Neural, cost) } } #[cfg(test)] mod tests { use super::*; + use crate::span::scripted::Scripted; + use eoc_core::{JouleCost, JouleSource}; use eoc_meter::StubCounter; #[tokio::test] @@ -145,6 +142,28 @@ mod tests { assert_eq!(r.joule_cost.microjoules, 800_000); } + /// Only a counter that read twice and advanced is a measurement; a + /// failed first read is no baseline, so a since-boot total is not one. + #[tokio::test] + async fn only_a_counter_that_advanced_is_measured() { + let dir = tempfile::tempdir().unwrap(); + let model = dir.path().join("model"); + std::fs::create_dir(&model).unwrap(); + let tok = model.join("tokenizer.json"); + for (script, want) in [ + (&[Some(1_000), Some(1_450)][..], JouleCost::measured(450)), + (&[Some(7_000)][..], JouleCost::estimated(800_000)), + (&[None, Some(9_000_000)][..], JouleCost::estimated(800_000)), + (&[Some(1_000), None][..], JouleCost::estimated(800_000)), + ] { + let b = MlxBackend::new(&model, &tok, Arc::new(Scripted::new(script))) + .unwrap() + .with_max_tokens(8) + .with_fallback_joules_per_token(0.1); + assert_eq!(b.infer(&Query::new("ping")).await.joule_cost, want, "{script:?}"); + } + } + #[tokio::test] async fn missing_model_returns_not_found() { let r = MlxBackend::new("/no/such/path", "/no/tok", Arc::new(StubCounter)); diff --git a/eoc/eoc-rs/crates/eoc-local/src/onnx.rs b/eoc/eoc-rs/crates/eoc-local/src/onnx.rs index d39b778f1..370b10887 100644 --- a/eoc/eoc-rs/crates/eoc-local/src/onnx.rs +++ b/eoc/eoc-rs/crates/eoc-local/src/onnx.rs @@ -11,7 +11,11 @@ //! is composed locally via the [`crate::sampling`] samplers. //! //! Joule attribution: pre/post counter reads sandwich the generation -//! call. +//! call, by the rule every backend here shares ([`crate::span`]): the +//! delta is measured only when both reads succeed and the counter +//! advanced. Otherwise nothing was measured, and the cost is a per-token +//! estimate ([`OnnxBackend::fallback_joules_per_token`]), tagged estimated, +//! as `mlx`'s is — never 0 µJ for inference that ran. //! //! [ONNX Runtime]: https://onnxruntime.ai //! [`ort`]: https://crates.io/crates/ort @@ -20,12 +24,13 @@ use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; -use eoc_core::{JouleCost, JouleSource, Query, Response, Stage}; +use eoc_core::{Query, Response, Stage}; use eoc_meter::JouleCounter; use eoc_neural::NeuralBackend; use crate::error::{LocalError, LocalResult}; use crate::sampling::{GreedySampler, Sampler}; +use crate::span::{self, MeterSpan}; use crate::tokenizer::LocalTokenizer; /// Generation-loop configuration. @@ -100,6 +105,9 @@ pub struct OnnxBackend { pub sampler: Box, /// Joule counter. pub meter: Arc, + /// Estimated joules per output token, used when the counter did not + /// measure the inference ([`crate::span::per_token_estimate`]). + pub fallback_joules_per_token: f64, } impl OnnxBackend { @@ -120,6 +128,7 @@ impl OnnxBackend { generation_config: GenerationConfig::default(), sampler: Box::new(GreedySampler), meter, + fallback_joules_per_token: span::DEFAULT_FALLBACK_JOULES_PER_TOKEN, }) } @@ -140,6 +149,12 @@ impl OnnxBackend { self.sampler = sampler; self } + + /// Override the per-token estimate used when nothing was measured. + pub fn with_fallback_joules_per_token(mut self, j: f64) -> Self { + self.fallback_joules_per_token = j; + self + } } #[async_trait] @@ -164,51 +179,69 @@ impl NeuralBackend for OnnxBackend { // } // let text = self.tokenizer.decode(&tokens[input_ids.len()..], true)?; // - // Reference build sandwich-meters around a placeholder. + // Reference build sandwich-meters around a placeholder. Its token + // count, for the estimate, is the placeholder's own (as `mlx`'s is); + // the wired loop above passes the number of tokens it generated. - let start = self.meter.read_microjoules().ok(); + let span = MeterSpan::open(self.meter.as_ref()); + let generated_tokens = u64::from(self.generation_config.max_tokens.min(64)); let payload = format!( "[onnx ep={} model={}]", self.execution_provider.as_str(), self.model_path.display() ); - let end = self.meter.read_microjoules().ok(); - let (microjoules, source) = match (start, end) { - (Some(s), Some(e)) if e >= s && (e - s) > 0 => (e - s, JouleSource::Measured), - _ => (0, JouleSource::Estimated), - }; - Response::new( - q.id, - payload, - Stage::Neural, - JouleCost { - microjoules, - source, - }, - ) + let cost = span::span_cost(span.reading(self.meter.as_ref()), || { + span::per_token_estimate(generated_tokens, self.fallback_joules_per_token) + }); + Response::new(q.id, payload, Stage::Neural, cost) } } #[cfg(test)] mod tests { use super::*; + use crate::span::scripted::Scripted; + use eoc_core::JouleCost; use eoc_meter::StubCounter; - #[tokio::test] - async fn stub_counter_reports_zero_cost() { - let dir = tempfile::tempdir().unwrap(); - let model = dir.path().join("model.onnx"); + fn backend(dir: &std::path::Path, meter: Arc) -> OnnxBackend { + let model = dir.join("model.onnx"); std::fs::write(&model, b"").unwrap(); - let tok_path = dir.path().join("tokenizer.json"); + let tok_path = dir.join("tokenizer.json"); std::fs::write(&tok_path, b"{}").unwrap(); let tok = LocalTokenizer::from_path(&tok_path); - let b = OnnxBackend::new(&model, tok, Arc::new(StubCounter)).unwrap(); - let q = Query::new("hi"); - let r = b.infer(&q).await; - assert_eq!(r.joule_cost.microjoules, 0); + OnnxBackend::new(&model, tok, meter) + .unwrap() + .with_generation_config(GenerationConfig { max_tokens: 8, ..GenerationConfig::default() }) + .with_fallback_joules_per_token(0.1) + } + + /// Inference that ran and that nothing measured costs the per-token + /// estimate, tagged estimated: 8 tokens × 0.1 J = 800,000 µJ, never 0. + #[tokio::test] + async fn the_stub_costs_the_per_token_estimate_never_zero() { + let dir = tempfile::tempdir().unwrap(); + let r = backend(dir.path(), Arc::new(StubCounter)).infer(&Query::new("hi")).await; + assert_eq!(r.joule_cost, JouleCost::estimated(800_000)); assert_eq!(r.stage, Stage::Neural); } + /// Each way a span can fail to measure gives the estimate; only a counter + /// that read twice and advanced gives a measured figure, its delta. + #[tokio::test] + async fn only_a_counter_that_advanced_is_measured() { + let dir = tempfile::tempdir().unwrap(); + for (script, want) in [ + (&[Some(1_000), Some(1_450)][..], JouleCost::measured(450)), + (&[Some(7_000)][..], JouleCost::estimated(800_000)), + (&[None, Some(9_000_000)][..], JouleCost::estimated(800_000)), + (&[Some(1_000), None][..], JouleCost::estimated(800_000)), + ] { + let b = backend(dir.path(), Arc::new(Scripted::new(script))); + assert_eq!(b.infer(&Query::new("hi")).await.joule_cost, want, "{script:?}"); + } + } + #[tokio::test] async fn missing_model_returns_not_found() { let dir = tempfile::tempdir().unwrap(); diff --git a/eoc/eoc-rs/crates/eoc-local/src/span.rs b/eoc/eoc-rs/crates/eoc-local/src/span.rs new file mode 100644 index 000000000..d6e5cf6c4 --- /dev/null +++ b/eoc/eoc-rs/crates/eoc-local/src/span.rs @@ -0,0 +1,188 @@ +//! What a counter read over one span of inference, and what a backend +//! reports when it read nothing. +//! +//! Every backend in this crate meters the same way, so the rule lives here +//! once and is tested without any native runtime: +//! +//! - A span is **measured** only when the counter read at its start, read +//! again at its end, and advanced between the two. The reading is then the +//! delta, tagged [`JouleSource::Measured`](eoc_core::JouleSource::Measured). +//! - Anything else measured nothing: a failed read at either end, a counter +//! that read but did not move (the [`StubCounter`](eoc_meter::StubCounter), +//! a frozen rail, a placeholder). A failed first read is never taken as 0, +//! which would turn the next successful read into the counter's whole +//! since-boot total. +//! - A span that measured nothing still ran inference, so its cost is never +//! 0 µJ. The backend reports a per-token estimate instead, tagged +//! [`JouleSource::Estimated`](eoc_core::JouleSource::Estimated). + +use eoc_core::JouleCost; +use eoc_meter::JouleCounter; + +/// The joules per output token a backend estimates with when no live counter +/// measured its inference, unless it is given another coefficient. The same +/// figure `mlx` has always used; a reference value, to be replaced by one +/// calibrated on the deployment host. +pub const DEFAULT_FALLBACK_JOULES_PER_TOKEN: f64 = 0.05; + +/// One span of inference, opened by reading the counter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MeterSpan { + /// The counter's reading when the span opened; `None` when that read + /// failed, and then nothing over this span can be measured. + start: Option, +} + +impl MeterSpan { + /// Open a span: read `meter` once. + pub fn open(meter: &dyn JouleCounter) -> Self { + Self { start: meter.read_microjoules().ok() } + } + + /// What `meter` has read since the span opened: `Some(delta)` only when + /// the opening read succeeded, this read succeeds, and the counter + /// advanced between them. `None` otherwise — never 0, and never the + /// counter's since-boot total. + pub fn reading(&self, meter: &dyn JouleCounter) -> Option { + let start = self.start?; + let now = meter.read_microjoules().ok()?; + (now > start).then(|| now - start) + } +} + +/// The cost of a span of inference that ran: the counter's reading, +/// measured, when there is one; otherwise `estimate()`, tagged estimated. +/// The estimate is asked for only when nothing was read. +pub fn span_cost(reading: Option, estimate: impl FnOnce() -> u64) -> JouleCost { + match reading { + Some(uj) => JouleCost::measured(uj), + None => JouleCost::estimated(estimate()), + } +} + +/// A per-token estimate of inference that ran, in micro-joules: `tokens` × +/// `joules_per_token`, to the nearest micro-joule. Never 0, because the +/// inference ran: at least one token is counted (a pass that emitted nothing +/// still processed its prompt), a coefficient that is not a finite positive +/// number is not one to estimate with, so [`DEFAULT_FALLBACK_JOULES_PER_TOKEN`] +/// stands in for it, and a product under half a micro-joule is 1 µJ. +pub fn per_token_estimate(tokens: u64, joules_per_token: f64) -> u64 { + let per_token = if joules_per_token.is_finite() && joules_per_token > 0.0 { + joules_per_token + } else { + DEFAULT_FALLBACK_JOULES_PER_TOKEN + }; + let uj = (tokens.max(1) as f64 * per_token * 1_000_000.0).round(); + // `as` saturates at u64::MAX. + (uj as u64).max(1) +} + +/// Test support: a counter that reads a script. +#[cfg(test)] +pub(crate) mod scripted { + use std::sync::Mutex; + + use eoc_meter::JouleCounter; + + /// A counter that reads the scripted values in turn, then repeats the + /// last one; `None` is a failed read. Counts its reads. + #[derive(Debug)] + pub(crate) struct Scripted { + script: Vec>, + reads: Mutex, + } + + impl Scripted { + pub(crate) fn new(script: &[Option]) -> Self { + assert!(!script.is_empty(), "a script needs at least one read"); + Self { script: script.to_vec(), reads: Mutex::new(0) } + } + + /// How many times the counter has been read. + pub(crate) fn reads(&self) -> usize { + *self.reads.lock().unwrap() + } + } + + impl JouleCounter for Scripted { + fn read_microjoules(&self) -> eoc_core::Result { + let mut reads = self.reads.lock().unwrap(); + let value = self.script[(*reads).min(self.script.len() - 1)]; + *reads += 1; + value.ok_or(eoc_core::Error::NoCounter) + } + fn name(&self) -> &'static str { + "scripted" + } + } +} + +#[cfg(test)] +mod tests { + use super::scripted::Scripted; + use super::*; + use eoc_core::JouleSource; + use eoc_meter::StubCounter; + + fn read_span(script: &[Option]) -> Option { + let c = Scripted::new(script); + let span = MeterSpan::open(&c); + let reading = span.reading(&c); + assert_eq!(c.reads(), if script[0].is_some() { 2 } else { 1 }); + reading + } + + /// Both reads succeeded and the counter advanced: the delta. + #[test] + fn a_counter_that_advanced_reads_the_delta() { + assert_eq!(read_span(&[Some(1_000), Some(1_450)]), Some(450)); + } + + /// A failed first read is no baseline: a later reading of the counter's + /// since-boot total is not the span's energy. + #[test] + fn a_failed_first_read_measures_nothing() { + assert_eq!(read_span(&[None, Some(9_000_000)]), None); + } + + /// A failed last read leaves no reading, whatever the first read was. + #[test] + fn a_failed_last_read_measures_nothing() { + assert_eq!(read_span(&[Some(1_000), None]), None); + } + + /// A counter that read but did not move measured nothing; one that went + /// down (reset or wrapped) did not measure the span either. + #[test] + fn a_counter_that_did_not_advance_measures_nothing() { + assert_eq!(read_span(&[Some(7_000), Some(7_000)]), None); + assert_eq!(read_span(&[Some(7_000), Some(6_000)]), None); + let stub = StubCounter; + assert_eq!(MeterSpan::open(&stub).reading(&stub), None); + } + + /// A reading is measured and the estimate is not asked for; no reading is + /// the estimate, tagged estimated. + #[test] + fn a_span_costs_its_reading_or_the_estimate() { + assert_eq!(span_cost(Some(450), || panic!("estimated a measured span")), JouleCost::measured(450)); + let cost = span_cost(None, || 800_000); + assert_eq!(cost, JouleCost::estimated(800_000)); + assert_eq!(cost.source, JouleSource::Estimated); + } + + /// Tokens × coefficient, never 0: a run that emitted no token counts one, + /// a coefficient that is not finite and positive gives way to the + /// default, and a tiny product is 1 µJ. + #[test] + fn the_per_token_estimate_is_never_zero() { + assert_eq!(per_token_estimate(8, 0.1), 800_000); + assert_eq!(per_token_estimate(10, 0.05), 500_000); + assert_eq!(per_token_estimate(0, 0.1), 100_000); + for bad in [0.0, -1.0, f64::NAN, f64::INFINITY] { + assert_eq!(per_token_estimate(4, bad), 200_000, "coefficient {bad}"); + } + assert_eq!(per_token_estimate(1, 1e-12), 1); + assert_eq!(per_token_estimate(u64::MAX, 1e9), u64::MAX); + } +} diff --git a/eoc/eoc-rs/crates/eoc-local/tests/joule_attribution.rs b/eoc/eoc-rs/crates/eoc-local/tests/joule_attribution.rs index b8de2e0a4..e095ecf7e 100644 --- a/eoc/eoc-rs/crates/eoc-local/tests/joule_attribution.rs +++ b/eoc/eoc-rs/crates/eoc-local/tests/joule_attribution.rs @@ -1,9 +1,9 @@ //! Joule-attribution integration test. //! -//! Verifies the contract: when the meter is a [`StubCounter`] the -//! reported joule cost is 0; when the meter is a non-stub counter -//! whose readings monotonically advance, the reported joule cost is -//! non-zero. +//! Verifies the contract: when the meter is a [`StubCounter`] nothing +//! was measured, so the cost is the backend's per-token estimate, never +//! 0 µJ; when the meter is a non-stub counter whose readings monotonically +//! advance, the reported joule cost is non-zero and measured. //! //! These tests exercise the *plumbing* — the backends themselves use //! their respective placeholder implementations in the reference @@ -48,14 +48,20 @@ impl JouleCounter for TickingCounter { #[cfg(feature = "mlc")] #[tokio::test] -async fn mlc_stub_counter_yields_zero_cost() { +async fn mlc_stub_counter_yields_an_estimate_never_zero() { use eoc_local::MlcBackend; use eoc_neural::NeuralBackend; let dir = tempfile::tempdir().unwrap(); - let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)).unwrap(); + let b = MlcBackend::new(dir.path(), Arc::new(StubCounter)) + .unwrap() + .with_max_tokens(10) + .with_fallback_joules_per_token(0.05); let q = Query::new("hello"); let r = b.infer(&q).await; - assert_eq!(r.joule_cost.microjoules, 0); + // Nothing measured the inference, which ran: 10 * 0.05 J = 500,000 µJ, + // an estimate. + assert_eq!(r.joule_cost.microjoules, 500_000); + assert_eq!(r.joule_cost.source, JouleSource::Estimated); } #[cfg(feature = "mlc")] diff --git a/eoc/eoc-rs/crates/eoc-meter/src/lib.rs b/eoc/eoc-rs/crates/eoc-meter/src/lib.rs index 7a18d9414..504476135 100644 --- a/eoc/eoc-rs/crates/eoc-meter/src/lib.rs +++ b/eoc/eoc-rs/crates/eoc-meter/src/lib.rs @@ -3,23 +3,61 @@ //! The meter reads cumulative micro-joule counters from whatever hardware //! is available. Production deployments care a great deal which counter //! is in use; reference deployments fall back to `StubCounter`, which -//! always returns 0 and reports its source as `JouleSource::Estimated`. +//! always reads 0. +//! +//! A counter carries no provenance of its own: [`JouleCounter`] only reads, +//! and its consumers decide. eoc-cascade, eoc-streaming and eoc-local's +//! `mlx`, `mlc` and `onnx` backends call a delta `JouleSource::Measured` +//! only when the counter advanced over the span they read it across. +//! Anything else — a failed read, a counter that did not move, the stub, +//! which never moves — is no reading, and yields no measured figure: +//! eoc-streaming reports none, eoc-cascade keeps the answering stage's own +//! cost as the stage reported it, and those backends report an estimate. //! //! Hardware counter dependencies are **platform-conditional and //! feature-gated**: //! -//! - Linux RAPL is read via `/sys/class/powercap/intel-rapl:0/energy_uj`. -//! No external crate required. -//! - macOS `powermetrics` is invoked via `Command::new("powermetrics")`. -//! Requires root in practice — the wrapper degrades to `StubCounter` -//! when the process can't fork it. -//! - NVIDIA NVML is gated behind the `cuda` feature. Off by default. +//! Every hardware counter is held to one liveness rule before [`detect`] +//! selects it: probed once per process, it must keep advancing — in at least +//! five separate sample intervals, at least 80% of those observed, over at +//! least 300 ms. A counter that holds, or is republished in lumps, is +//! declined, because a lump landing in one span would be read as that span's +//! energy. A live counter's delta is a reading; nothing else is. +//! +//! - Linux RAPL is read through powercap (`/sys/class/powercap`) as the sum +//! of every package zone — each top-level `intel-rapl:` zone whose `name` +//! reads `package-`, so every socket of a multi-socket server, and never +//! `psys` (the platform zone, whose energy includes the packages'), `dram` +//! or a sub-zone — each zone's wrap taken at its own `max_energy_range_uj`; +//! **only when every package counter reads and is live** ([`rapl_status`]; +//! the probe burns CPU on the calling thread). No external crate required. +//! - Apple Silicon IOReport Energy Model counters are read through Apple's +//! IOReport C API (no sudo, no external binary) — **only when every CPU +//! energy rail is live**, each die's on a multi-die part +//! ([`ioreport_status`]). A subscription can open while every CPU rail +//! holds its value, refreshed only now and then; the summed rails then +//! advance through the GPU rail, a stale refresh, or not at all, and would +//! pass that off as a reading of CPU work. The counter sums every die's CPU +//! rail, so one die live beside one frozen is not live either. When a CPU +//! rail is not live the counter is declined and [`detect`] falls through to +//! the next rung. A live IOReport counter is a vendor energy counter, as +//! RAPL and NVML are, so a delta it reads is `Measured` — but only while the +//! rails are live: the counter exists only on a host whose rails the probe +//! found live, and on any other there is no IOReport reading to tag. +//! - macOS `powermetrics`: detected when the binary is on the PATH, but the +//! reference wrapper does not parse it, so every read returns +//! `Err(NoCounter)`: no figure, and callers keep their own estimate. +//! - NVIDIA NVML is gated behind the `cuda` feature (off by default), and +//! selected only when GPU 0's total-energy counter is live +//! ([`nvml_status`]). //! - WASM and unknown targets fall through to `StubCounter`. -// `deny` not `forbid`: the whole crate is safe except the audited Apple-Silicon -// IOReport FFI module (`macos_ioreport`), which carries a scoped -// `#[allow(unsafe_code)]`. Real on-die energy reading is unreachable without the -// `unsafe extern "C"` IOReport calls; everything else stays unsafe-free. +// `deny` not `forbid`: the whole crate is safe except two audited FFI modules, +// each with a scoped `#[allow(unsafe_code)]`: the Apple-Silicon IOReport module +// (`macos_ioreport`) — the on-die energy counters are unreachable without the +// `unsafe extern "C"` IOReport calls — and `clocks`, whose one call is +// `clock_gettime` for the probing thread's CPU clock. Everything else stays +// unsafe-free. #![deny(unsafe_code)] // `Error` is used by platform-conditional modules below. @@ -53,18 +91,1445 @@ impl JouleCounter for StubCounter { } } -// Apple Silicon IOReport energy counter — a real, no-sudo, no-external-binary -// reading of the SoC Energy Model channels (the same counters powermetrics / -// Activity Monitor use) through Apple's private IOReport C API. Replaces the -// always-erroring `powermetrics` stub so eoc reports Measured energy on Apple -// Silicon instead of degrading to Estimated. Ported from jouleclaw-energy's -// validated `apple_ioreport` backend, adapted to eoc's `JouleCounter`. +// The sustained-advance liveness rule, kept apart from every counter's FFI so +// every part of it — the name rule, the pairing of samples, the per-rail count, +// when to stop sampling, and the verdict's mapping to a status — runs under test +// with scripted samples and a scripted clock. The FFI only takes a sample and +// lists its channels as (name, absolute raw integer); everything that decides +// lives here. One rule for every vendor counter: IOReport watches the CPU energy +// rails of an Energy Model sample; RAPL hands each package counter as its own +// channel, and NVML its one counter. Every rail watched must keep advancing, each +// on its own, because a counter's reading sums every rail it watches: IOReport's +// adds every die's `CPU Energy`, RAPL's every package's. Compiled on every platform, so a Linux CI leg runs its tests. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +mod rail_liveness { + use std::time::Duration; + + /// In how many separate sample intervals the same CPU rail must advance + /// before it can count as live; see [`sustained`] for the whole rule. + /// + /// One is not enough, and neither is two. On macOS 27.0.1 an unprivileged + /// reader's CPU rails are not permanently frozen; they are refreshed in + /// rare batches. An M3 Ultra's two dies held for at least 22 minutes, then + /// stepped +1,344,869 and +1,391,637 mJ within 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: a watcher sampling the M5 + /// Max's Energy Model group every ~16 ms saw `CPU Energy` step + /// 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 (`MCPU0`, `MCPU1` and `PCPU` each + /// stepped twice too). The probe samples every ~31 ms, so a probe whose + /// window holds such a refresh sees two advancing intervals. Called live, + /// it would select the counter for the life of the process, and every + /// later refresh would land, whole, in whichever caller's interval + /// straddled it. A live rail sampled every ~30 ms under load steps in + /// nearly every interval, for as long as it is watched. + pub(crate) const MIN_LIVE_ADVANCES: u32 = 5; + + /// How much wall time, from the first sample, the probe must have watched + /// a rail advance before it can count as live. A refresh published in + /// steps lands within ~64 ms; five or more intervals at the probe's + /// ~31 ms can all fall inside a burst that short, and would then be every + /// interval observed. Over 300 ms of sustained advance they cannot. + pub(crate) const MIN_LIVE_WINDOW: Duration = Duration::from_millis(300); + + /// However slow sampling is, the probe stops after this many budgets. + pub(crate) const PROBE_BUDGET_CAP: u32 = 5; + + /// The liveness rule for one rail, evaluated after each interval: it + /// advanced in at least [`MIN_LIVE_ADVANCES`] separate intervals, those + /// were at least 80% of every interval observed so far, and the samples + /// observed so far span at least [`MIN_LIVE_WINDOW`] of wall time from the + /// first. A burst of steps — a refresh — fails the window while it is + /// young and the share once it is not; a live rail passes all three. + pub(crate) fn sustained(advances: u32, intervals: u32, window: Duration) -> bool { + advances >= MIN_LIVE_ADVANCES + && u64::from(advances) * 5 >= u64::from(intervals) * 4 + && window >= MIN_LIVE_WINDOW + } + + /// What the liveness probe saw of the CPU energy rails. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) enum CpuRail { + /// Every CPU energy rail in the samples kept advancing, each by + /// [`sustained`]: in at least [`MIN_LIVE_ADVANCES`] separate intervals, + /// at least 80% of those observed, over at least [`MIN_LIVE_WINDOW`]. + Live, + /// A CPU energy rail exists, but not every one kept advancing: one + /// held, or stepped only in a burst (a batch refresh, in one step or + /// several), though another may have been live. + Frozen, + /// Samples were taken, and no `CPU Energy` channel was in them. + Absent, + } + + /// `DIE__CPU Energy` → `CPU Energy`; any other name unchanged. + pub(crate) fn strip_die_prefix(name: &str) -> &str { + if let Some(rest) = name.strip_prefix("DIE_") { + let after = rest.trim_start_matches(|c: char| c.is_ascii_digit()); + if let Some(r) = after.strip_prefix('_') { + return r; + } + } + name + } + + /// A CPU energy aggregate: `CPU Energy`, optionally `DIE__`-prefixed, + /// compared case-insensitively. Sub-rails (`PCPU`, `MCPU*`, `PACC_*`, + /// `DIE_n_PACC*_CPM*`) are not the aggregate and are not watched. + pub(crate) fn is_cpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") + } + + /// A GPU energy aggregate: `GPU Energy`, optionally `DIE__`-prefixed, + /// compared case-insensitively. Never watched. A probe run records it + /// beside the CPU rails because it is the one Energy Model rail that moves + /// on hosts whose CPU rails hold (the M5 Max and the M3 Ultra on macOS + /// 27.0.1), so a sampler that stopped sampling shows there. + pub(crate) fn is_gpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("GPU Energy") + } + + /// One CPU rail's identity within a sample: its channel name and its + /// ordinal among the channels of that name in the same sample. An M3 + /// Ultra's Energy Model group lists 264 of its 645 channel names twice + /// (once per die, with no `DIE_` prefix); the CPU aggregates are uniquely + /// named today, but two same-named rails stepping together in one refresh + /// must count as one advance each, never as two advances of one rail. + pub(crate) type RailKey = (String, u32); + + /// The channels of one sample whose names pass `keep`, keyed by + /// [`RailKey`] (the ordinal counts same-named channels among those kept), + /// in sample order. + fn keyed(sample: &[(String, i64)], keep: impl Fn(&str) -> bool) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in sample { + if !keep(name) { + continue; + } + let ordinal = rails.iter().filter(|((n, _), _)| n == name).count() as u32; + rails.push(((name.clone(), ordinal), *raw)); + } + rails + } + + /// The liveness rule over a sequence of samples. + /// + /// Each usable sample is compared with the PREVIOUS usable one — never + /// with the first — on each rail's absolute raw integer, before unit + /// conversion (a small advance in a fine unit cannot truncate to zero). A + /// rail advances in an interval when its value rose over that interval; so + /// a rail advances at most once per interval, and each rail is counted on + /// its own: two dies stepping twice each never add up to a live rail. The + /// summed total is never consulted, because the GPU rail, which is live on + /// an M5 Max, is exactly what corrupts it. After each interval every rail + /// is put to [`sustained`], over the intervals observed so far and the wall + /// time since the first usable sample; the first time EVERY rail seen in + /// an interval passes, the watch says Live, and stays Live. + /// + /// Every rail, not any one: the counter a Live verdict admits reads the + /// sum of every rail the watch picks. On a two-die part (an M3 Ultra's + /// `DIE_0_CPU Energy` and `DIE_1_CPU Energy`), one die live and the other + /// frozen would give a figure that leaves out the frozen die's energy, + /// or carries its refreshes, while claiming the whole CPU. A rail that + /// joins late is held to the intervals observed since the first, so it + /// is live only once its own advances are 80% of all of them. RAPL's + /// figure sums every package counter, so on a multi-socket server each + /// package is a rail, and every one must be live; NVML's one channel is + /// every rail it has. + /// + /// Which channels of a sample are rails is the watch's `pick`: the CPU + /// energy aggregates for IOReport ([`RailWatch::default`]), every channel + /// for a single vendor counter ([`RailWatch::every_channel`]). + #[derive(Debug)] + pub(crate) struct RailWatch { + /// Which channels of a sample are watched as rails. + pick: fn(&str) -> bool, + /// The rails of the last usable sample. + prev: Option>, + /// The wall-clock reading of the first usable sample. + first: Option, + /// Intervals observed: pairs of consecutive usable samples. + pub(crate) intervals: u32, + /// Wall time from the first usable sample to the last. + pub(crate) window: Duration, + /// Every CPU rail seen in an interval, with the number of intervals in + /// which it advanced. + pub(crate) rails: Vec<(RailKey, u32)>, + /// Whether every rail has passed [`sustained`] after the same interval. + live: bool, + } + + impl Default for RailWatch { + /// A watch of IOReport's CPU energy rails ([`is_cpu_energy_rail`]). + fn default() -> Self { + Self::of(is_cpu_energy_rail) + } + } + + impl RailWatch { + /// A watch of the channels `pick` accepts. + fn of(pick: fn(&str) -> bool) -> Self { + Self { pick, prev: None, first: None, intervals: 0, window: Duration::ZERO, rails: Vec::new(), live: false } + } + + /// A watch of every channel of a sample: for a vendor counter (RAPL's + /// package counters, one channel a package; NVML's total-energy + /// counter), whose every channel counts the domain it claims. + #[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs + pub(crate) fn every_channel() -> Self { + Self::of(|_| true) + } + + /// Fold in one usable sample: every channel in it, as (name, absolute + /// raw integer), and `at`, the wall-clock reading when the sample was + /// in hand. A sample that could not be taken is simply not observed, + /// so the next usable one pairs with the last usable one. + pub(crate) fn observe(&mut self, sample: &[(String, i64)], at: Duration) { + let cur = keyed(sample, self.pick); + let first = *self.first.get_or_insert(at); + self.window = at.saturating_sub(first); + if let Some(prev) = &self.prev { + self.intervals += 1; + for (key, now) in &cur { + let advanced = prev + .iter() + .find(|(k, _)| k == key) + .is_some_and(|(_, before)| now > before); + match self.rails.iter_mut().find(|(k, _)| k == key) { + Some((_, n)) => *n += u32::from(advanced), + None => self.rails.push((key.clone(), u32::from(advanced))), + } + } + let (intervals, window) = (self.intervals, self.window); + // Every rail, and at least one: `all` of no rails is vacuously + // true, and a sample with no rail in it is Absent, never Live. + self.live |= !self.rails.is_empty() && self.rails.iter().all(|(_, n)| sustained(*n, intervals, window)); + } + self.prev = Some(cur); + } + + /// Has every rail kept advancing, each by [`sustained`], after some + /// interval observed so far? + pub(crate) fn is_live(&self) -> bool { + self.live + } + + /// Keep sampling? Until the rails prove live; or until `budget` has + /// elapsed **and** at least [`MIN_LIVE_ADVANCES`] intervals were + /// observed, enough for a live rail to have shown its advances (slow or + /// failed 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 verdict, or `None` when not one interval could be observed. + pub(crate) 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) + } + } + } + + /// The probe loop, generic over how a sample is taken, what happens between + /// samples, and the clock, so it runs under test exactly as it runs on + /// hardware. Takes a sample, then ticks and samples again for as long as + /// [`RailWatch::wants_more`] says. `sample` returns `None` when no sample + /// could be taken; each one taken is handed to the rule with the wall + /// clock `now` read as it returns. [`probe_with`] runs it with the + /// sampler, tick and wall clock it is given, and records each sample call. + pub(crate) fn watch( + sample: impl FnMut() -> Option>, + tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, + ) -> RailWatch { + watch_from(RailWatch::default(), sample, tick, now, budget) + } + + /// [`watch`], folding samples into the watch `w` it is given (so a + /// single vendor counter's probe watches every channel). + pub(crate) fn watch_from( + mut w: RailWatch, + mut sample: impl FnMut() -> Option>, + mut tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, + ) -> RailWatch { + let started = now(); + if let Some(s) = sample() { + w.observe(&s, now()); + } + while w.wants_more(now().saturating_sub(started), budget) { + tick(); + if let Some(s) = sample() { + w.observe(&s, now()); + } + } + w + } + + /// Spin on the calling thread until `thread_cpu`, this thread's CPU clock, + /// has advanced by `want`, or `wall` has advanced by `wall_cap`, whichever + /// comes first. Returns the thread CPU consumed. + /// + /// The target is CPU time, not wall time, so every tick burns the same CPU + /// however loaded the host is: while the thread is descheduled its CPU + /// clock stands still, and the tick simply takes longer. Only the wall cap + /// can cut a tick short, on a host that gives this thread less than + /// `want / wall_cap` of a core (5% for the probe's 25 ms in 500 ms). + pub(crate) fn spin_for_cpu( + thread_cpu: impl Fn() -> Duration, + wall: impl Fn() -> Duration, + want: Duration, + wall_cap: Duration, + ) -> Duration { + let (cpu0, wall0) = (thread_cpu(), wall()); + let mut acc: u64 = 0; + loop { + let used = thread_cpu().saturating_sub(cpu0); + if used >= want || wall().saturating_sub(wall0) >= wall_cap { + std::hint::black_box(acc); + return used; + } + for _ in 0..256 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + } + } + + /// One sample, as a sampler returns it: the shape of the IOReport sample it + /// read, and that sample's Energy Model channels. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct Sample { + /// Channels in the IOReport sample, in any group. + pub(crate) channels: usize, + /// Whether every one of those channels is in the Energy Model group. + pub(crate) group_only: bool, + /// Each Energy Model channel's (name, absolute raw integer), in sample + /// order: what the probe hands the rule. + pub(crate) values: Vec<(String, i64)>, + } + + /// One call the probe made to its sampler, as the probe itself recorded it, + /// so a test can pin what the probe did without timing it from outside. + #[derive(Debug, Clone)] + #[cfg_attr(not(test), allow(dead_code))] // read only by the tests + pub(crate) struct SampleCall { + /// Wall time of the call. Recorded, never asserted: on a shared host + /// another process's all-channel sample can hold IOReport for hundreds + /// of milliseconds, and this call waits for it. + pub(crate) wall: Duration, + /// The probing thread's CPU clock just before and just after the call. + pub(crate) cpu_before: Duration, + pub(crate) cpu_after: Duration, + /// For a sample that was taken: how many channels it held, and whether + /// every one is in the Energy Model group. `None` for no sample. + pub(crate) shape: Option<(usize, bool)>, + /// For a sample that was taken: every CPU rail and GPU Energy aggregate + /// in what was handed to the rule, as ([`RailKey`], raw), in sample + /// order. `None` for no sample. + pub(crate) handed: Option>, + } + + /// What one liveness probe did and saw. + #[derive(Debug)] + #[cfg_attr(not(test), allow(dead_code))] // all but `watch` read only by the tests + pub(crate) struct ProbeRun { + /// Whether the opener returned a sampler. + pub(crate) opened: bool, + /// Ticks run between samples. + pub(crate) ticks: u32, + /// Every sample call, in order. + pub(crate) calls: Vec, + /// The rule, folded over every sample that was taken. + pub(crate) watch: RailWatch, + } + + impl ProbeRun { + /// The rule's verdict: `None` when not one interval was observed, and + /// so also when the opener returned no sampler. + pub(crate) fn verdict(&self) -> Option { + self.watch.verdict() + } + } + + /// The liveness probe, generic over its real-world inputs so that tests + /// drive the very composition the production status runs: `open` makes the + /// sampler (`None`: no subscription, so no sample and no tick), `tick` runs + /// between samples, and `thread_cpu` and `wall` are the clocks. Samples by + /// the rule in [`watch`] for `budget`, and records every sample call. + pub(crate) fn probe_with( + open: impl FnOnce() -> Option, + mut tick: impl FnMut(), + thread_cpu: impl Fn() -> Duration, + wall: impl Fn() -> Duration, + budget: Duration, + ) -> ProbeRun + where + S: FnMut() -> Option, + { + let Some(mut sampler) = open() else { + return ProbeRun { opened: false, ticks: 0, calls: Vec::new(), watch: RailWatch::default() }; + }; + let (mut calls, mut ticks) = (Vec::new(), 0u32); + let watch = watch( + || { + let (cpu_before, started) = (thread_cpu(), wall()); + let sample = sampler(); + let (ended, cpu_after) = (wall(), thread_cpu()); + calls.push(SampleCall { + wall: ended.saturating_sub(started), + cpu_before, + cpu_after, + shape: sample.as_ref().map(|s| (s.channels, s.group_only)), + handed: sample.as_ref().map(|s| keyed(&s.values, |n| is_cpu_energy_rail(n) || is_gpu_energy_rail(n))), + }); + sample.map(|s| s.values) + }, + || { + ticks += 1; + tick(); + }, + &wall, + budget, + ); + ProbeRun { opened: true, ticks, calls, watch } + } + + /// The status a probe verdict gives this host. No verdict — no + /// subscription, or not one interval observed — is `Unavailable`: nothing + /// was learned about the rail, and only `Live` makes the counter available. + /// Never `CpuRailAbsent`, which says samples were taken and no CPU rail was + /// in them. + pub(crate) fn status_of(verdict: Option) -> crate::IoReportStatus { + match verdict { + None => crate::IoReportStatus::Unavailable, + Some(CpuRail::Absent) => crate::IoReportStatus::CpuRailAbsent, + Some(CpuRail::Frozen) => crate::IoReportStatus::CpuRailFrozen, + Some(CpuRail::Live) => crate::IoReportStatus::Live, + } + } + + /// Whether a host with this status may use the IOReport counter: only when + /// its CPU rail is live. Absent, frozen or unavailable all decline, so + /// callers estimate rather than seal a reading that did not track the work. + /// The one gate every constructor and selection path passes through. + pub(crate) fn admits_counter(status: crate::IoReportStatus) -> bool { + status == crate::IoReportStatus::Live + } + + /// One liveness probe of a single vendor energy counter (RAPL, NVML): + /// `open` it; if it opens, sample it — each sample its channels as + /// (name, absolute raw integer), every one of them a rail — `tick` + /// between samples, by the wall clock `now`, for `budget`, by the same + /// rule and stopping rule as the IOReport probe ([`watch`]). `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; each rung's cached probe passes the real ones. + #[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs + pub(crate) fn probe_counter( + open: impl FnOnce() -> Option, + mut sample: impl FnMut(&C) -> Option>, + tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, + ) -> Option { + let counter = open()?; + Some(watch_from(RailWatch::every_channel(), || sample(&counter), tick, now, budget)) + } + + /// The status a counter probe gives its rung. No probe (the counter did + /// not open) and not one interval observed (no two usable samples in five + /// budgets) are both `Unavailable`; only a rail that kept advancing is + /// `Live`. + #[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs + pub(crate) fn counter_status(probed: Option<&RailWatch>) -> crate::CounterStatus { + match probed { + None => crate::CounterStatus::Unavailable, + Some(w) if w.intervals == 0 => crate::CounterStatus::Unavailable, + Some(w) if w.is_live() => crate::CounterStatus::Live, + Some(_) => crate::CounterStatus::Frozen, + } + } + + /// Whether a counter rung with this status may be selected: only when it + /// is live. The one gate the RAPL and NVML constructors pass through. + #[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs + pub(crate) fn admits_vendor_counter(status: crate::CounterStatus) -> bool { + status == crate::CounterStatus::Live + } + + #[cfg(test)] + mod tests { + use super::*; + use std::cell::Cell; + + const TICK: Duration = Duration::from_millis(25); + const BUDGET: Duration = Duration::from_millis(1000); + + /// One probe interval on an unloaded M5 Max: a 25 ms tick and a ~6 ms + /// Energy Model sample. + const INTERVAL: Duration = Duration::from_millis(31); + + /// The observed two-step refresh of the M5 Max's `CPU Energy`, as a + /// probe sampling every interval sees it when the steps land in + /// intervals `first` and `second` (`first` < `second`): held, stepped + /// +61,764,485, stepped +3,435, held. + fn two_step_refresh(i: usize, first: usize, second: usize) -> i64 { + let a = 2_643_843_378; + if i < first { + a + } else if i < second { + a + 61_764_485 + } else { + a + 61_764_485 + 3_435 + } + } + + /// Fold samples `0..n` straight into a [`RailWatch`], the i-th stamped + /// `i * step` on the wall clock. Returns the index of the sample after + /// which the watch first said Live, if any, and the watch. + fn first_live(step: Duration, n: usize, generate: impl Fn(usize) -> Vec<(&'static str, i64)>) -> (Option, RailWatch) { + let mut w = RailWatch::default(); + let mut live_at = None; + for i in 0..n { + let s: Vec<(String, i64)> = generate(i).into_iter().map(|(n, raw)| (n.to_string(), raw)).collect(); + w.observe(&s, step * i as u32); + if w.is_live() && live_at.is_none() { + live_at = Some(i); + } + } + (live_at, w) + } + + /// Run [`watch`] over scripted samples — `gen(i)` is the i-th sample, + /// `None` for one that could not be taken — with a clock that only the + /// tick moves, by `step`. Returns the watch and how many samples it took. + fn scripted(step: Duration, mut generate: impl FnMut(usize) -> Option>) -> (RailWatch, usize) { + let clock = Cell::new(Duration::ZERO); + let mut taken = 0usize; + let w = watch( + || { + assert!(taken < 100_000, "the probe never stopped sampling"); + let s = generate(taken); + taken += 1; + s.map(|v| v.into_iter().map(|(n, raw)| (n.to_string(), raw)).collect()) + }, + || clock.set(clock.get() + step), + || clock.get(), + BUDGET, + ); + (w, taken) + } + + fn key(name: &str, ordinal: u32) -> RailKey { + (name.to_string(), ordinal) + } + + #[test] + fn only_the_cpu_energy_aggregate_is_a_cpu_rail() { + for name in ["CPU Energy", "cpu energy", "DIE_0_CPU Energy", "DIE_1_CPU Energy", "DIE_12_CPU Energy"] { + assert!(is_cpu_energy_rail(name), "{name}"); + } + for name in ["GPU Energy", "DIE_0_GPU Energy", "ANE Energy", "PCPU", "MCPU0", "PACC_0", "DIE_0_PACC0_CPU0", "ECPUDTL00", "CPU Energy X", "DIE__CPU"] { + assert!(!is_cpu_energy_rail(name), "{name}"); + } + } + + /// (a) The M5 Max refresh: `CPU Energy` held at 480,083,160, stepped + /// once to 541,893,638 and held, while `GPU Energy` moved at every + /// sample. One advance in 40 intervals: Frozen. A rule that compared + /// every sample with the first would see an advance in every interval + /// after the step and call it live. + #[test] + fn a_the_m5_max_refresh_is_frozen() { + let (w, taken) = scripted(TICK, |i| { + Some(vec![ + ("CPU Energy", if i < 2 { 480_083_160 } else { 541_893_638 }), + ("GPU Energy", 1_000 + 7 * i as i64), + ]) + }); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one refresh counted as a live rail: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 1)], "the refresh is one advance of one rail"); + // The budget is 40 ticks; with five intervals long since seen, it stops there. + assert_eq!((w.intervals, taken), (40, 41)); + } + + /// (b) The M3 Ultra refresh: both dies step once, together, in one + /// sample, amid unprefixed per-die channels that repeat a name. + #[test] + fn b_the_m3_ultra_two_die_refresh_in_one_sample_is_frozen() { + let (w, _) = scripted(TICK, |i| { + let stepped = i >= 5; + Some(vec![ + ("DIE_0_CPU Energy", 9_494_336 + if stepped { 1_344_869 } else { 0 }), + ("ECPUDTL00", 10 * i as i64), + ("DIE_1_CPU Energy", 10_212_656 + if stepped { 1_391_637 } else { 0 }), + ("ECPUDTL00", 11 * i as i64), + ("GPU Energy", 3 * i as i64), + ]) + }); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one refresh of both dies counted as live: {w:?}"); + assert_eq!(w.rails, vec![(key("DIE_0_CPU Energy", 0), 1), (key("DIE_1_CPU Energy", 0), 1)]); + } + + /// (c) Two channels with the same name are two rails: stepping together + /// once is one advance each — in the same interval or in different ones — + /// never two advances of one rail. Both are summed by the counter, so + /// both must prove live: one live beside one that holds is Frozen. + #[test] + fn c_two_same_named_cpu_rails_are_counted_apart() { + let (w, _) = scripted(TICK, |i| { + let j = if i >= 3 { 1_300_000 } else { 0 }; + Some(vec![("CPU Energy", 100 + j), ("CPU Energy", 200 + j)]) + }); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one refresh of two same-named rails counted as live: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 1), (key("CPU Energy", 1), 1)]); + + let (w, _) = scripted(TICK, |i| { + Some(vec![("CPU Energy", 100 + if i >= 3 { 5 } else { 0 }), ("CPU Energy", 200 + if i >= 7 { 5 } else { 0 })]) + }); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "two same-named rails stepping once each counted as live: {w:?}"); + + // The first of two same-named rails is live; the second holds: + // Frozen, watched for the whole budget. + let (w, taken) = scripted(TICK, |i| Some(vec![("CPU Energy", 100 + i as i64), ("CPU Energy", 200)])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one live rail beside one that holds counted as live: {w:?}"); + assert_eq!((w.intervals, taken), (40, 41)); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 40), (key("CPU Energy", 1), 0)]); + + // Both live. At 25 ms a sample, the 300 ms window is what they wait + // for: 12 intervals. + let (w, taken) = scripted(TICK, |i| Some(vec![("CPU Energy", 100 + i as i64), ("CPU Energy", 200 + 3 * i as i64)])); + assert_eq!(w.verdict(), Some(CpuRail::Live), "{w:?}"); + assert_eq!((w.intervals, taken), (12, 13)); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 12), (key("CPU Energy", 1), 12)]); + } + + /// (d) A rail advancing in every interval, sampled every 31 ms, is not + /// Live at its fifth advance (155 ms), nor at its ninth (279 ms): only + /// at the tenth, once the window reaches 300 ms (310 ms). Sampling stops + /// right there. Five advances and the window are both needed, and the + /// window is inclusive: at 60 ms a sample, Live at the fifth interval + /// (exactly 300 ms); at 59 ms, not until the sixth (295 ms is short). + #[test] + fn d_a_rail_advancing_every_interval_is_live_at_five_advances_and_300_ms() { + let live = |i: usize| vec![("GPU Energy", 5), ("CPU Energy", 1_000 + 13 * i as i64)]; + let (at, w) = first_live(INTERVAL, 40, live); + assert_eq!(at, Some(10), "a rail advancing every 31 ms must be Live at the 10th interval (310 ms), not before: {w:?}"); + let (at, w) = first_live(INTERVAL, 10, live); + assert_eq!((at, w.intervals, w.window), (None, 9, Duration::from_millis(279)), "Live before 300 ms: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 9)]); + + let (w, taken) = scripted(INTERVAL, |i| Some(live(i))); + assert_eq!(w.verdict(), Some(CpuRail::Live)); + assert_eq!((w.intervals, taken), (10, 11), "a live rail must stop the probe as soon as the rule holds"); + assert_eq!((w.rails.clone(), w.window), (vec![(key("CPU Energy", 0), 10)], Duration::from_millis(310))); + + assert_eq!(first_live(Duration::from_millis(60), 20, live).0, Some(5), "300 ms is enough"); + assert_eq!(first_live(Duration::from_millis(59), 20, live).0, Some(6), "295 ms is not"); + } + + /// The rule's three bars, at their edges: five advances, 80% of the + /// intervals observed, 300 ms of window. + #[test] + fn sustained_needs_five_advances_80_percent_and_300_ms() { + let ms = Duration::from_millis; + assert!(sustained(5, 5, ms(300))); + assert!(!sustained(4, 4, ms(1000)), "four advances"); + assert!(!sustained(5, 5, ms(299)), "299 ms"); + assert!(sustained(5, 6, ms(300)), "5 of 6 is 83%"); + assert!(!sustained(5, 7, ms(1000)), "5 of 7 is 71%"); + assert!(sustained(8, 10, ms(300)), "8 of 10 is 80%"); + assert!(!sustained(7, 9, ms(1000)), "7 of 9 is 78%"); + assert!(sustained(800, 1000, ms(5000)) && !sustained(799, 1000, ms(5000))); + assert!(!sustained(2, 2, ms(1000)), "two advances: the old rule's Live"); + } + + /// (h) The observed macOS 27 refresh published in two steps, +61,764,485 + /// then +3,435 (64 ms apart, so in consecutive or next-but-one 31 ms + /// intervals), wherever it falls in the probe, beside a moving GPU rail + /// and sub-rails that step with it: two advances, Frozen. The old rule + /// (two advancing intervals) called every one of these Live. + #[test] + fn h_the_two_step_refresh_is_frozen() { + for (first, second) in [(3, 4), (1, 2), (1, 3), (3, 5), (20, 21), (31, 33)] { + let sample = |i: usize| { + let cpu = two_step_refresh(i, first, second); + vec![ + ("CPU Energy", cpu), + ("MCPU0", cpu / 4), + ("MCPU1", cpu / 4), + ("PCPU", cpu / 2), + ("GPU Energy", 1_000 + 7 * i as i64), + ] + }; + let (w, taken) = scripted(INTERVAL, |i| Some(sample(i))); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "the two-step refresh in intervals {first} and {second} counted as a live rail: {w:?}" + ); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 2)], "steps in intervals {first} and {second}"); + assert_eq!((w.intervals, taken), (33, 34), "the budget is 33 intervals of 31 ms"); + assert_eq!(first_live(INTERVAL, 200, sample).0, None, "steps in intervals {first} and {second}"); + } + } + + /// (i) A refresh published in three, four or five steps within 150 ms + /// at the very start of the probe (30 ms a sample): while it lasts it is + /// every interval observed, and at five steps it has five advances, but + /// the window is under 300 ms; by the time it is not, the rail has held + /// long enough that its advances are under 80% of the intervals. Frozen. + #[test] + fn i_a_refresh_in_three_to_five_steps_at_the_start_is_frozen() { + let step = Duration::from_millis(30); + let sizes = [61_764_485, 3_435, 1_207, 88, 19]; + for steps in 3..=5usize { + let sample = |i: usize| vec![("CPU Energy", 2_643_843_378 + sizes[..i.min(steps)].iter().sum::())]; + let (w, _) = scripted(step, |i| Some(sample(i))); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a refresh in {steps} steps counted as a live rail: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), steps as u32)]); + assert_eq!(first_live(step, 200, sample).0, None, "a refresh in {steps} steps"); + } + } + + /// (j) The M3 Ultra's shape: `DIE_0_CPU Energy` and `DIE_1_CPU Energy` + /// each step twice within 64 ms (two consecutive 31 ms intervals), + /// together or one interval apart, amid unprefixed per-die channels that + /// repeat a name; at the start of the probe and inside it. Two advances + /// per die: Frozen. The old rule called each die Live. + #[test] + fn j_the_m3_ultra_two_steps_on_each_die_are_frozen() { + for (d0, d1) in [(1, 1), (1, 2), (6, 6), (6, 7)] { + let sample = |i: usize| { + let steps = |from: usize| i64::from(i >= from) * 1_344_869 + i64::from(i > from) * 2_113; + vec![ + ("DIE_0_CPU Energy", 9_494_336 + steps(d0)), + ("ECPUDTL00", 10 * i as i64), + ("DIE_1_CPU Energy", 10_212_656 + steps(d1)), + ("ECPUDTL00", 11 * i as i64), + ("GPU Energy", 3 * i as i64), + ] + }; + let (w, _) = scripted(INTERVAL, |i| Some(sample(i))); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "two steps on each die ({d0}, {d1}) counted as live: {w:?}"); + assert_eq!(w.rails, vec![(key("DIE_0_CPU Energy", 0), 2), (key("DIE_1_CPU Energy", 0), 2)]); + assert_eq!(first_live(INTERVAL, 200, sample).0, None, "dies stepping from {d0} and {d1}"); + } + } + + /// (k) A live rail that misses one interval in every five advances in + /// exactly 80% of them: Live, at the tenth interval (8 of 10, 310 ms). + /// One that missed two of its first five is not yet Live at the fifth + /// (3 of 5), nor at the ninth (7 of 9, 78%), though it advanced in every + /// interval since; it is at the tenth (8 of 10) — here sampled every + /// 100 ms, so the window never binds. And one that misses two in every + /// five (60%) is never Live: Frozen. + #[test] + fn k_a_live_rail_missing_one_interval_in_five_is_live_two_is_not_yet() { + let miss_one = |i: usize| vec![("CPU Energy", (1..=i).filter(|k| k % 5 != 0).count() as i64)]; + let (at, w) = first_live(INTERVAL, 40, miss_one); + assert_eq!(at, Some(10), "missing one in five must be Live at 8 of 10 intervals: {w:?}"); + let (w, taken) = scripted(INTERVAL, |i| Some(miss_one(i))); + assert_eq!((w.verdict(), w.intervals, taken), (Some(CpuRail::Live), 10, 11)); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 8)]); + + let miss_first_two = |i: usize| vec![("CPU Energy", (1..=i).filter(|k| *k != 1 && *k != 3).count() as i64)]; + let step = Duration::from_millis(100); + for n in [6, 10] { + let (at, w) = first_live(step, n, miss_first_two); + assert_eq!(at, None, "Live at {} of {} intervals: {w:?}", w.rails[0].1, w.intervals); + } + let (at, w) = first_live(step, 40, miss_first_two); + assert_eq!(at, Some(10), "8 of 10 intervals must be Live: {w:?}"); + + let miss_two = |i: usize| vec![("CPU Energy", (1..=i).filter(|k| k % 5 != 0 && k % 5 != 2).count() as i64)]; + let (w, _) = scripted(INTERVAL, |i| Some(miss_two(i))); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail advancing in 60% of intervals counted as live: {w:?}"); + assert_eq!(first_live(INTERVAL, 1000, miss_two).0, None); + } + + /// (l) A loaded host, where each interval takes 100 ms: a live rail is + /// Live after exactly five intervals (500 ms). By 300 ms it has only + /// three advances. Sampling stops right there. + #[test] + fn l_a_live_rail_on_a_loaded_host_is_live_after_five_intervals() { + let step = Duration::from_millis(100); + let live = |i: usize| vec![("CPU Energy", 1_000 + 13 * i as i64)]; + let (at, w) = first_live(step, 40, live); + assert_eq!(at, Some(5), "{w:?}"); + let (w, taken) = scripted(step, |i| Some(live(i))); + assert_eq!((w.verdict(), w.intervals, taken, w.window), (Some(CpuRail::Live), 5, 6, Duration::from_millis(500))); + } + + /// (m) Every die must sustain its own advance, because the counter + /// sums every die's `CPU Energy`. On an M3 Ultra's two dies, sampled + /// every 31 ms beside a moving GPU rail and unprefixed per-die channels + /// that repeat a name: + /// - both dies advancing at every interval: Live, at the tenth interval + /// (310 ms), exactly where one die alone is (d); + /// - `DIE_0` advancing at every interval and `DIE_1` frozen — holding, + /// stepping once, or stepping twice in a refresh: Frozen, however + /// long it is watched. A rule that took any one live rail called each + /// of these Live at the tenth interval, and the counter it admitted + /// would leave `DIE_1` out of a figure claimed for the whole CPU; + /// - `DIE_0` live and `DIE_1` advancing in 60% of intervals: Frozen; + /// - `DIE_1` frozen for its first twenty intervals, then live: Live + /// only once its own advances reach 80% of every interval observed + /// (at the hundredth, 80 of 100), never at `DIE_0`'s tenth. + #[test] + fn m_every_die_must_sustain_its_own_advance() { + let dies = |die0: i64, die1: i64, i: usize| { + vec![ + ("DIE_0_CPU Energy", die0), + ("ECPUDTL00", 10 * i as i64), + ("DIE_1_CPU Energy", die1), + ("ECPUDTL00", 11 * i as i64), + ("GPU Energy", 3 * i as i64), + ] + }; + let live0 = |i: usize| 9_494_336 + 1_307 * i as i64; + + let both = |i: usize| dies(live0(i), 10_212_656 + 1_211 * i as i64, i); + let (at, w) = first_live(INTERVAL, 40, both); + assert_eq!(at, Some(10), "two live dies must be Live at the 10th interval (310 ms), as one is: {w:?}"); + let (w, taken) = scripted(INTERVAL, |i| Some(both(i))); + assert_eq!((w.verdict(), w.intervals, taken), (Some(CpuRail::Live), 10, 11)); + assert_eq!(w.rails, vec![(key("DIE_0_CPU Energy", 0), 10), (key("DIE_1_CPU Energy", 0), 10)]); + + // DIE_1 frozen: what it does, its value at sample i, its advances. + let frozen = [ + ("holds", (|_| 10_212_656) as fn(usize) -> i64, 0), + ("steps once", |i| 10_212_656 + i64::from(i >= 4) * 1_391_637, 1), + ("steps twice in a refresh", |i| 10_212_656 + i64::from(i >= 4) * 1_391_637 + i64::from(i >= 5) * 2_113, 2), + ]; + for (what, die1, steps) in frozen { + let mixed = |i: usize| dies(live0(i), die1(i), i); + let (w, taken) = scripted(INTERVAL, |i| Some(mixed(i))); + assert_eq!( + w.verdict(), + Some(CpuRail::Frozen), + "DIE_0 live and DIE_1 frozen ({what}) counted as live: the counter would leave DIE_1 out: {w:?}" + ); + assert_eq!(w.rails, vec![(key("DIE_0_CPU Energy", 0), 33), (key("DIE_1_CPU Energy", 0), steps)], "{what}"); + assert_eq!((w.intervals, taken), (33, 34), "{what}: the budget is 33 intervals of 31 ms"); + assert_eq!(first_live(INTERVAL, 1000, mixed).0, None, "DIE_1 {what}"); + } + + let sixty = |i: usize| dies(live0(i), 10_212_656 + (1..=i).filter(|k| k % 5 != 0 && k % 5 != 2).count() as i64, i); + let (w, _) = scripted(INTERVAL, |i| Some(sixty(i))); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "DIE_1 advancing in 60% of intervals counted as live: {w:?}"); + assert_eq!(first_live(INTERVAL, 1000, sixty).0, None); + + let late = |i: usize| dies(live0(i), 10_212_656 + i.saturating_sub(20) as i64, i); + let (at, w) = first_live(INTERVAL, 200, late); + assert_eq!(at, Some(100), "DIE_1 live from the 21st interval must be Live at 80 of 100: {w:?}"); + assert_eq!(w.rails, vec![(key("DIE_0_CPU Energy", 0), 199), (key("DIE_1_CPU Energy", 0), 179)]); + let (w, _) = scripted(INTERVAL, |i| Some(late(i))); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "DIE_1 live for only 13 of 33 intervals counted as live: {w:?}"); + assert_eq!(w.rails, vec![(key("DIE_0_CPU Energy", 0), 33), (key("DIE_1_CPU Energy", 0), 13)]); + } + + /// (e) Samples that cannot be taken keep the probe sampling to its cap of + /// five budgets, and end with no verdict: Unavailable. + #[test] + fn e_failed_samples_sample_to_the_cap_and_give_no_verdict() { + let (w, taken) = scripted(TICK, |_| None); + assert_eq!(w.verdict(), None); + assert_eq!(status_of(w.verdict()), crate::IoReportStatus::Unavailable); + assert_eq!(taken, 5 * 40 + 1, "the probe must keep sampling to five budgets"); + // One usable sample is still no interval. + let (w, taken) = scripted(TICK, |i| (i == 0).then(|| vec![("CPU Energy", 7)])); + assert_eq!((w.verdict(), w.intervals, taken), (None, 0, 201)); + } + + /// (f) Past the budget, the probe stops at once if it has seen five + /// intervals; with fewer it keeps sampling until it has five, or hits + /// the cap of five budgets. + #[test] + fn f_the_budget_stops_the_probe_only_once_five_intervals_are_seen() { + // Every tick takes 600 ms: past the budget after two, it still + // takes six samples, and stops at the fifth interval (3 s). + let (w, taken) = scripted(Duration::from_millis(600), |_| Some(vec![("CPU Energy", 42)])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen)); + assert_eq!((w.intervals, taken, w.window), (5, 6, Duration::from_millis(3000))); + // A live rail sampled that slowly is Live at its fifth advance. + let (w, taken) = scripted(Duration::from_millis(600), |i| Some(vec![("CPU Energy", i as i64)])); + assert_eq!((w.verdict(), w.intervals, taken), (Some(CpuRail::Live), 5, 6)); + // One interval, then failures: past the budget it keeps sampling, to the cap. + let (w, taken) = scripted(TICK, |i| (i < 2).then(|| vec![("CPU Energy", 42)])); + assert_eq!((w.verdict(), w.intervals, taken), (Some(CpuRail::Frozen), 1, 201)); + // Four intervals, then failures: still to the cap. + let (w, taken) = scripted(TICK, |i| (i < 5).then(|| vec![("CPU Energy", i as i64)])); + assert_eq!((w.verdict(), w.intervals, taken), (Some(CpuRail::Frozen), 4, 201)); + // The fifth interval arrives at 2.5 s, past the budget: it stops right there. + let (w, taken) = scripted(TICK, |i| (i < 5 || i == 100).then(|| vec![("CPU Energy", 42)])); + assert_eq!((w.verdict(), w.intervals, taken), (Some(CpuRail::Frozen), 5, 101)); + // Within the budget, five intervals are not enough to stop. + let (w, taken) = scripted(Duration::from_millis(100), |_| Some(vec![("CPU Energy", 42)])); + assert_eq!((w.intervals, taken), (10, 11)); + // A tick of 2 s: the cap (5 s) stops it at three intervals, short of + // five, and a rail advancing in all three is still not Live. + let (w, taken) = scripted(Duration::from_secs(2), |i| Some(vec![("CPU Energy", i as i64)])); + assert_eq!((w.verdict(), w.intervals, taken), (Some(CpuRail::Frozen), 3, 4)); + } + + /// (g) DIE_0 steps once and DIE_1 steps once, in different intervals: + /// advances are counted per rail, never summed across dies. + #[test] + fn g_one_step_on_each_die_in_different_intervals_is_frozen() { + let (w, _) = scripted(TICK, |i| { + Some(vec![ + ("DIE_0_CPU Energy", if i >= 3 { 9_500_000 } else { 9_494_336 }), + ("DIE_1_CPU Energy", if i >= 6 { 10_300_000 } else { 10_212_656 }), + ]) + }); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one step per die counted as a live rail: {w:?}"); + assert_eq!(w.rails, vec![(key("DIE_0_CPU Energy", 0), 1), (key("DIE_1_CPU Energy", 0), 1)]); + } + + /// Samples with no CPU rail in them, however much else moves: Absent. + #[test] + fn no_cpu_rail_is_absent() { + let (w, _) = scripted(TICK, |i| Some(vec![("GPU Energy", i as i64), ("PCPU", i as i64)])); + assert_eq!(w.verdict(), Some(CpuRail::Absent)); + assert!(w.rails.is_empty(), "{w:?}"); + } + + /// A rail that goes DOWN between consecutive samples — a reset or a + /// wrap — has not advanced. A rail falling at every sample is Frozen + /// with no advance at all; one real step followed by a reset is one + /// advance; and a rail that alternately rises and falls advances in + /// only half of its intervals, which is not the 80% a live rail needs. + #[test] + fn a_decrease_is_not_an_advance() { + let (w, _) = scripted(TICK, |i| Some(vec![("CPU Energy", 1_000 - i as i64)])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a falling rail counted as live: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 0)], "a decrease counted as an advance"); + + let (w, _) = scripted(TICK, |i| Some(vec![("CPU Energy", [500, 500, 500, 900, 900, 900, 10][i.min(6)])])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "one step and one reset counted as live: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 1)], "the reset counted as an advance"); + + let (w, _) = scripted(INTERVAL, |i| Some(vec![("CPU Energy", 500 + (i % 2) as i64)])); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail rising and falling in turn counted as live: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 17)], "each fall counted as an advance"); + } + + /// A rail absent from the previous sample only sets its baseline in + /// this one; it has not advanced. A rail that joins late, at any value, + /// and holds is Frozen with no advance; and a rail that drops out of + /// every other sample is never "new, so advanced" when it comes back. + #[test] + fn a_rail_new_in_a_sample_only_sets_its_baseline() { + let (w, _) = scripted(TICK, |i| { + let mut s = vec![("DIE_0_CPU Energy", 100)]; + if i >= 3 { + s.push(("DIE_1_CPU Energy", 5_000_000)); + } + Some(s) + }); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "{w:?}"); + assert_eq!( + w.rails, + vec![(key("DIE_0_CPU Energy", 0), 0), (key("DIE_1_CPU Energy", 0), 0)], + "a rail's first appearance counted as an advance" + ); + + let (w, _) = scripted(TICK, |i| { + Some(if i % 2 == 0 { vec![("CPU Energy", 7)] } else { vec![("GPU Energy", i as i64)] }) + }); + assert_eq!(w.verdict(), Some(CpuRail::Frozen), "a rail's reappearances counted as advances: {w:?}"); + assert_eq!(w.rails, vec![(key("CPU Energy", 0), 0)]); + } + + /// Every verdict maps to its status; only a live rail is `Live`. + #[test] + fn the_verdict_maps_to_the_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); + } + + /// No verdict (not one interval observed) is `Unavailable`: never + /// `Live`, never `CpuRailAbsent`. + #[test] + fn no_verdict_is_unavailable() { + let status = status_of(None); + assert_eq!(status, crate::IoReportStatus::Unavailable); + assert!(!admits_counter(status), "no verdict admitted the counter"); + } + + /// A sample the way the FFI returns one of the Energy Model group: + /// every channel in the group. + fn group_sample(values: &[(&str, i64)]) -> Sample { + Sample { + channels: values.len(), + group_only: true, + values: values.iter().map(|(n, raw)| (n.to_string(), *raw)).collect(), + } + } + + /// What a fake run of [`probe_with`] did: the run, and how many times + /// the fake opener, sampler and tick were called. + struct Fake { + run: ProbeRun, + opens: u32, + samples: usize, + ticks: u32, + } + + /// [`probe_with`] as the production status composes it (an opener, a + /// tick, a thread CPU clock, a wall clock, the budget), with fakes for + /// every real-world input: the opener opens or not; `generate(i)` is + /// the i-th sample; the wall clock moves only on a tick, by `step`, and + /// the thread CPU clock by 25 ms per tick and 1 ms per sample call. + fn fake(opens: bool, step: Duration, mut generate: impl FnMut(usize) -> Option) -> Fake { + let (wall, cpu) = (Cell::new(Duration::ZERO), Cell::new(Duration::ZERO)); + let (opened, ticked) = (Cell::new(0u32), Cell::new(0u32)); + let mut taken = 0usize; + let sampler = || { + assert!(taken < 100_000, "the probe never stopped sampling"); + let s = generate(taken); + taken += 1; + cpu.set(cpu.get() + Duration::from_millis(1)); + s + }; + let run = probe_with( + || { + opened.set(opened.get() + 1); + opens.then_some(sampler) + }, + || { + ticked.set(ticked.get() + 1); + wall.set(wall.get() + step); + cpu.set(cpu.get() + TICK); + }, + || cpu.get(), + || wall.get(), + BUDGET, + ); + Fake { run, opens: opened.get(), samples: taken, ticks: ticked.get() } + } + + /// No subscription: the opener is asked once, returns no sampler, and + /// the probe takes no sample and runs no tick. No verdict: + /// `Unavailable` — never `Live`, and never `CpuRailAbsent`. A + /// subscription that never yields two usable samples is `Unavailable` + /// too: nothing was learned about the CPU rail. + #[test] + fn no_energy_model_subscription_is_unavailable() { + use crate::IoReportStatus as S; + let f = fake(false, TICK, |_| panic!("sampled without a subscription")); + assert_eq!((f.opens, f.samples, f.ticks), (1, 0, 0), "no subscription: no sample, no tick"); + assert!(!f.run.opened && f.run.calls.is_empty() && f.run.ticks == 0, "{:?}", f.run); + assert_eq!(f.run.verdict(), None); + assert_eq!(status_of(f.run.verdict()), S::Unavailable, "no group subscription"); + let never = fake(true, TICK, |_| None); + assert_eq!(status_of(never.run.verdict()), S::Unavailable, "a subscription with no sample"); + assert!(never.run.calls.iter().all(|c| c.shape.is_none() && c.handed.is_none()), "a failed sample recorded as taken"); + let once = fake(true, TICK, |i| (i == 0).then(|| group_sample(&[("CPU Energy", 7)]))); + assert_eq!(status_of(once.run.verdict()), S::Unavailable, "a subscription with one sample"); + } + + /// The M5 Max refresh through the whole composition: `CPU Energy` + /// steps once, `GPU Energy` moves at every sample. Frozen. Every tick + /// runs between two samples, and the run records, for every sample, its + /// shape, the thread CPU clock it was given, and exactly the CPU and GPU + /// rails it handed the rule. + #[test] + fn probe_with_a_single_batch_jump_is_frozen() { + let sample = |i: usize| { + group_sample(&[ + ("CPU Energy", if i < 3 { 480_083_160 } else { 541_893_638 }), + ("PCPU", 7 * i as i64), + ("GPU Energy", 1_000 + 7 * i as i64), + ]) + }; + let f = fake(true, TICK, |i| Some(sample(i))); + assert_eq!(f.run.verdict(), Some(CpuRail::Frozen), "one refresh counted as a live rail: {:?}", f.run.watch); + assert_eq!(status_of(f.run.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!((f.samples, f.ticks, f.run.ticks as usize, f.run.calls.len()), (41, 40, 40, 41)); + for (i, call) in f.run.calls.iter().enumerate() { + assert_eq!(call.shape, Some((3, true)), "sample {i}"); + let want = vec![ + (key("CPU Energy", 0), if i < 3 { 480_083_160 } else { 541_893_638 }), + (key("GPU Energy", 0), 1_000 + 7 * i as i64), + ]; + assert_eq!(call.handed.as_ref(), Some(&want), "sample {i}: the record is not what the rule was handed"); + // The injected clocks: the fake thread clock moves 1 ms in a + // sample call, the fake wall clock not at all. + assert_eq!(call.cpu_after - call.cpu_before, Duration::from_millis(1), "sample {i}"); + assert_eq!(call.wall, Duration::ZERO, "sample {i}"); + } + // Between one call's end and the next one's start, the fake thread + // clock moved by exactly the tick. + for w in f.run.calls.windows(2) { + assert_eq!(w[1].cpu_before - w[0].cpu_after, TICK); + } + } + + /// The same CPU rail advancing in every interval, 25 ms of wall clock + /// apart: Live once the rule holds, at the 12th interval (300 ms), and + /// the probe stops right there. The window the rule saw is the + /// composition's own wall clock, from the first sample to the last. + #[test] + fn probe_with_a_sustained_advance_is_live() { + let f = fake(true, TICK, |i| Some(group_sample(&[("GPU Energy", 5), ("CPU Energy", 1_000 + 13 * i as i64)]))); + assert_eq!(f.run.verdict(), Some(CpuRail::Live)); + assert_eq!(status_of(f.run.verdict()), crate::IoReportStatus::Live); + assert_eq!((f.samples, f.ticks), (13, 12), "a live rail must stop the probe as soon as the rule holds"); + assert_eq!((f.run.watch.intervals, f.run.watch.window), (12, Duration::from_millis(300))); + } + + /// The observed two-step refresh through the whole composition, beside + /// a moving GPU rail: Frozen, after the whole budget. + #[test] + fn probe_with_the_two_step_refresh_is_frozen() { + let f = fake(true, TICK, |i| { + Some(group_sample(&[("CPU Energy", two_step_refresh(i, 3, 4)), ("GPU Energy", 1_000 + 7 * i as i64)])) + }); + assert_eq!(f.run.verdict(), Some(CpuRail::Frozen), "the two-step refresh counted as live: {:?}", f.run.watch); + assert_eq!(status_of(f.run.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!(f.run.watch.rails, vec![(key("CPU Energy", 0), 2)]); + assert_eq!((f.samples, f.ticks), (41, 40)); + } + + /// Two dies, each stepping once, in different intervals: Frozen, and + /// each die's own value is what the rule was handed. A transform that + /// gave each CPU rail the sum of the CPU rails would step both "rails" + /// in both intervals and call them live. + #[test] + fn probe_with_one_step_on_each_die_in_different_intervals_is_frozen() { + let die = |i: usize| { + [ + ("DIE_0_CPU Energy", if i >= 3 { 9_500_000 } else { 9_494_336 }), + ("DIE_1_CPU Energy", if i >= 6 { 10_300_000 } else { 10_212_656 }), + ] + }; + let f = fake(true, TICK, |i| Some(group_sample(&die(i)))); + assert_eq!(f.run.verdict(), Some(CpuRail::Frozen), "one step per die counted as a live rail: {:?}", f.run.watch); + assert_eq!(f.run.watch.rails, vec![(key("DIE_0_CPU Energy", 0), 1), (key("DIE_1_CPU Energy", 0), 1)]); + for (i, call) in f.run.calls.iter().enumerate() { + let [(n0, v0), (n1, v1)] = die(i); + assert_eq!(call.handed, Some(vec![(key(n0, 0), v0), (key(n1, 0), v1)]), "sample {i}"); + } + } + + /// One die live, one frozen, through the whole composition: Frozen, + /// after the whole budget, with both dies' own values handed to the + /// rule. Both live: Live, where one die alone is. + #[test] + fn probe_with_one_die_live_and_one_frozen_is_frozen() { + let die = |i: usize, die1_live: bool| { + [ + ("DIE_0_CPU Energy", 9_494_336 + 1_307 * i as i64), + ("DIE_1_CPU Energy", 10_212_656 + if die1_live { 1_211 * i as i64 } else { 0 }), + ] + }; + let f = fake(true, TICK, |i| Some(group_sample(&die(i, false)))); + assert_eq!(f.run.verdict(), Some(CpuRail::Frozen), "one die live, one frozen, counted as live: {:?}", f.run.watch); + assert_eq!(status_of(f.run.verdict()), crate::IoReportStatus::CpuRailFrozen); + assert_eq!(f.run.watch.rails, vec![(key("DIE_0_CPU Energy", 0), 40), (key("DIE_1_CPU Energy", 0), 0)]); + assert_eq!((f.samples, f.ticks), (41, 40)); + for (i, call) in f.run.calls.iter().enumerate() { + let [(n0, v0), (n1, v1)] = die(i, false); + assert_eq!(call.handed, Some(vec![(key(n0, 0), v0), (key(n1, 0), v1)]), "sample {i}"); + } + + let f = fake(true, TICK, |i| Some(group_sample(&die(i, true)))); + assert_eq!(f.run.verdict(), Some(CpuRail::Live), "{:?}", f.run.watch); + assert_eq!(status_of(f.run.verdict()), crate::IoReportStatus::Live); + assert_eq!((f.samples, f.ticks), (13, 12)); + } + + /// A clock pair for [`spin_for_cpu`] that only the spin moves: each read + /// of the CPU clock advances it by `cpu_step`, and each read of the wall + /// clock advances it by `wall_step`. The spin reads each once per turn, + /// so a turn is `cpu_step` of CPU in `wall_step` of wall time; and a + /// spin that ignores one clock still ends on the other. Returns the CPU + /// the spin reported and how many times it read the CPU clock. + fn spin_scripted(cpu_step: Duration, wall_step: Duration, want: Duration, cap: Duration) -> (Duration, u32) { + let (cpu_reads, wall_reads) = (Cell::new(0u32), Cell::new(0u32)); + let used = spin_for_cpu( + || { + assert!(cpu_reads.get() < 1_000_000, "the tick never stopped"); + cpu_reads.set(cpu_reads.get() + 1); + cpu_step * cpu_reads.get() + }, + || { + assert!(wall_reads.get() < 1_000_000, "the tick never stopped"); + wall_reads.set(wall_reads.get() + 1); + wall_step * wall_reads.get() + }, + want, + cap, + ); + (used, cpu_reads.get()) + } + + /// The tick spins until this thread's CPU clock has advanced by the + /// tick, not the wall clock: a thread that gets a quarter of a core + /// still burns the whole 25 ms (in 100 ms of wall time). Only the wall + /// cap ends a tick early: a thread that never runs, or gets under 5% of + /// a core, stops at 500 ms of wall time. + #[test] + fn the_tick_spins_until_the_thread_cpu_clock_advanced() { + let ms = Duration::from_millis; + let (want, cap) = (ms(25), ms(500)); + // A quarter of a core: 25 ms of CPU, 100 ms of wall time. + let (used, steps) = spin_scripted(ms(1), ms(4), want, cap); + assert_eq!((used, steps), (ms(25), 26), "the tick must burn 25 ms of thread CPU, however slowly it gets it"); + // A whole core. + let (used, _) = spin_scripted(ms(1), ms(1), want, cap); + assert_eq!(used, ms(25)); + // Never scheduled: the wall cap ends it with no CPU burned. + let (used, steps) = spin_scripted(Duration::ZERO, ms(10), want, cap); + assert_eq!((used, steps), (Duration::ZERO, 51), "the wall cap must end a tick that gets no CPU"); + // 2.5% of a core: the wall cap, at 13 ms of CPU. + let (used, steps) = spin_scripted(ms(1), ms(40), want, cap); + assert_eq!((used, steps), (ms(13), 14)); + } + + /// Only a live CPU rail admits the counter. + #[test] + fn only_a_live_rail_admits_the_counter() { + use crate::IoReportStatus as S; + assert!(admits_counter(S::Live)); + for declined in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { + assert!(!admits_counter(declined), "{declined:?} admitted the counter"); + } + } + + // ── A single vendor counter (RAPL, NVML) ───────────────────────────── + + /// What a scripted counter probe did: its status, how many samples it + /// took and how many ticks it ran. `opens` is whether the counter + /// opens; `read(i)` is its i-th sample, `None` for a failed read. The + /// clock moves only with the tick, by `step`. + fn counter_probe(opens: bool, step: Duration, mut read: impl FnMut(usize) -> Option) -> (crate::CounterStatus, usize, u32) { + let clock = Cell::new(Duration::ZERO); + let (mut taken, mut ticks) = (0usize, 0u32); + let probed = probe_counter( + || opens.then_some("counter"), + |c: &&str| { + assert!(taken < 100_000, "the probe never stopped sampling"); + taken += 1; + read(taken - 1).map(|raw| vec![(c.to_string(), raw)]) + }, + || { + ticks += 1; + clock.set(clock.get() + step); + }, + || clock.get(), + BUDGET, + ); + (counter_status(probed.as_ref()), taken, ticks) + } + + /// A counter that advances at every sample is live once five intervals + /// and 300 ms have been watched: at 25 ms a sample, 12 intervals. + #[test] + fn a_vendor_counter_advancing_every_sample_is_live() { + let (status, taken, ticks) = counter_probe(true, TICK, |i| Some(81_000_000 + 1_400 * i as i64)); + assert_eq!((status, taken, ticks), (crate::CounterStatus::Live, 13, 12)); + // Slow samples: five intervals of 100 ms are 500 ms, past the window. + let (status, taken, _) = counter_probe(true, Duration::from_millis(100), |i| Some(i as i64)); + assert_eq!((status, taken), (crate::CounterStatus::Live, 6)); + } + + /// A counter that holds is frozen, watched for the whole budget. + #[test] + fn a_vendor_counter_that_holds_is_frozen() { + let (status, taken, _) = counter_probe(true, TICK, |_| Some(81_000_000)); + assert_eq!((status, taken), (crate::CounterStatus::Frozen, 41)); + } + + /// A counter republished in lumps — one step, or a refresh in two + /// steps 64 ms apart — is frozen: never the sustained advance of a live + /// count, and a lump landing in one caller's span would be sealed as + /// that span's reading. + #[test] + fn a_vendor_counter_republished_in_lumps_is_frozen() { + let (status, ..) = counter_probe(true, TICK, |i| Some(if i < 7 { 1_000 } else { 61_000 })); + assert_eq!(status, crate::CounterStatus::Frozen, "one step"); + let (status, ..) = counter_probe(true, Duration::from_millis(32), |i| Some(two_step_refresh(i, 3, 5))); + assert_eq!(status, crate::CounterStatus::Frozen, "a two-step refresh"); + // Advancing in two of every three intervals is not a live count... + let (status, ..) = counter_probe(true, TICK, |i| Some((i - i / 3) as i64)); + assert_eq!(status, crate::CounterStatus::Frozen, "advancing in 2 of every 3 intervals"); + // ...and a counter missing one interval in five still is live. + let (status, ..) = counter_probe(true, TICK, |i| Some((i - i / 5) as i64)); + assert_eq!(status, crate::CounterStatus::Live, "advancing in 4 of every 5 intervals"); + } + + /// A counter that wraps (RAPL's `energy_uj` rolls over at + /// `max_energy_range_uj`) fell over that interval, which is no advance; + /// it is live again from the next one. + #[test] + fn a_wrap_is_not_an_advance() { + let (status, ..) = counter_probe(true, TICK, |i| Some(if i < 3 { 262_000_000 + i as i64 } else { i as i64 })); + assert_eq!(status, crate::CounterStatus::Live); + let w = { + let mut w = RailWatch::every_channel(); + for (i, raw) in [(0, 900), (1, 950), (2, 10), (3, 60)] { + w.observe(&[("intel-rapl:0".to_string(), raw)], TICK * i); + } + w + }; + assert_eq!(w.rails, vec![(key("intel-rapl:0", 0), 2)], "the fall is no advance: {w:?}"); + } + + /// A counter that opens but never reads is sampled to the cap of five + /// budgets and is unavailable; one that does not open is unavailable + /// with nothing sampled and nothing ticked. + #[test] + fn a_vendor_counter_that_never_reads_or_opens_is_unavailable() { + let (status, taken, _) = counter_probe(true, TICK, |_| None); + assert_eq!((status, taken), (crate::CounterStatus::Unavailable, 201)); + let (status, taken, ticks) = counter_probe(false, TICK, |_| panic!("sampled a counter that did not open")); + assert_eq!((status, taken, ticks), (crate::CounterStatus::Unavailable, 0, 0)); + } + + /// Every channel of a vendor counter's sample is a rail; IOReport's + /// watch keeps only the CPU energy aggregates. + #[test] + fn a_vendor_counter_watches_every_channel() { + let sample = |i: i64| vec![("intel-rapl:0".to_string(), 10 * i), ("CPU Energy".to_string(), 5)]; + let (mut all, mut cpu) = (RailWatch::every_channel(), RailWatch::default()); + for i in 0..3 { + all.observe(&sample(i), TICK * i as u32); + cpu.observe(&sample(i), TICK * i as u32); + } + assert_eq!(all.rails, vec![(key("intel-rapl:0", 0), 2), (key("CPU Energy", 0), 0)]); + assert_eq!(cpu.rails, vec![(key("CPU Energy", 0), 0)]); + } + + /// Only a live counter admits its rung. + #[test] + fn only_a_live_vendor_counter_admits_its_rung() { + use crate::CounterStatus as S; + assert!(admits_vendor_counter(S::Live)); + for declined in [S::Unavailable, S::Frozen] { + assert!(!admits_vendor_counter(declined), "{declined:?} admitted the rung"); + } + } + } +} + +/// The clocks and the tick of the liveness probes that burn CPU (IOReport's, +/// RAPL's): this thread's CPU clock, a monotonic wall clock, and a tick that +/// spins until this thread has burned [`clocks::PROBE_TICK`] of its own CPU. +/// The one other module allowed `unsafe`: the thread CPU clock is only +/// reachable through `clock_gettime`. +#[cfg(any(target_os = "macos", target_os = "linux"))] +#[allow(unsafe_code)] +mod clocks { + use std::os::raw::{c_int, c_long}; + use std::sync::OnceLock; + use std::time::{Duration, Instant}; + + /// `struct timespec` on 64-bit macOS and Linux. + #[repr(C)] + struct Timespec { + tv_sec: c_long, + tv_nsec: c_long, + } + + /// `clockid_t`: an unsigned enum on macOS, an `int` on Linux. + #[cfg(target_os = "macos")] + type ClockId = std::os::raw::c_uint; + #[cfg(target_os = "linux")] + type ClockId = c_int; + + /// `CLOCK_THREAD_CPUTIME_ID`: 16 in macOS ``, 3 in Linux + /// ``. + #[cfg(target_os = "macos")] + const CLOCK_THREAD_CPUTIME_ID: ClockId = 16; + #[cfg(target_os = "linux")] + const CLOCK_THREAD_CPUTIME_ID: ClockId = 3; + + unsafe extern "C" { + fn clock_gettime(clock_id: ClockId, tp: *mut Timespec) -> c_int; + } + + /// CPU time the calling thread has consumed. Zero if the clock cannot be + /// read, which leaves a probe tick spinning to its wall cap: slower, but + /// still burning CPU. + pub(crate) fn thread_cpu_time() -> Duration { + let mut ts = Timespec { tv_sec: 0, tv_nsec: 0 }; + if unsafe { clock_gettime(CLOCK_THREAD_CPUTIME_ID, &mut ts) } != 0 { + return Duration::ZERO; + } + Duration::new(ts.tv_sec.max(0) as u64, ts.tv_nsec.clamp(0, 999_999_999) as u32) + } + + /// Wall time on the monotonic clock, from a fixed origin in this process. + pub(crate) fn wall_now() -> Duration { + static ORIGIN: OnceLock = OnceLock::new(); + ORIGIN.get_or_init(Instant::now).elapsed() + } + + /// The CPU the probing thread burns between two liveness samples: this + /// much of its own CPU time, measured on its CPU clock, so host load + /// stretches a tick's wall time but never shortens its CPU. + pub(crate) const PROBE_TICK: Duration = Duration::from_millis(25); + + /// However little CPU the probing thread gets, one tick ends after this + /// much wall time. + pub(crate) const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// One tick of a CPU-burning probe: spin until this thread's CPU clock has + /// advanced [`PROBE_TICK`], or [`PROBE_TICK_WALL_CAP`] of wall time has + /// passed. + pub(crate) fn cpu_burn_tick() { + crate::rail_liveness::spin_for_cpu(thread_cpu_time, wall_now, PROBE_TICK, PROBE_TICK_WALL_CAP); + } +} + +// Apple Silicon IOReport energy counter — a no-sudo, no-external-binary read of +// the SoC Energy Model channels through Apple's private IOReport C API. Ported +// from jouleclaw-energy's `apple_ioreport` backend, adapted to eoc's +// `JouleCounter`. +// +// An open subscription is not evidence that the counters are live. On macOS +// 27.0.1, for an unprivileged reader, the CPU rails (`CPU Energy`, `PCPU`, +// `MCPU*`, `PACC_*`, `DIE__CPU Energy`) hold their value under full CPU load +// on both an M5 Max and an M3 Ultra, and are refreshed only now and then (an M3 +// Ultra's held for at least 22 minutes, then stepped by ~1.3 kJ per die in one +// sample; an M5 Max's `CPU Energy` has been seen to publish one refresh in two +// steps 64 ms apart), while `GPU Energy` still moves (steadily on the M5 Max, +// intermittently on the M3 Ultra). Summed, the rails report GPU-only energy, +// zero, or a refresh of past CPU energy, and consumers such as eoc-cascade, +// eoc-local and eoc-streaming tag what a hardware counter returns as Measured. +// So the counter is constructed only when `ioreport_status()` is `Live` — every +// CPU rail in the samples (each die's, on a multi-die part) kept advancing while +// the probing thread burned CPU: each in at least five separate sample +// intervals, at least 80% of those observed, over at least 300 ms — and +// otherwise `try_detect()` returns `Err(NoCounter)`, leaving the caller's own +// estimate. Every rail, because the counter sums them all. #[cfg(target_os = "macos")] #[allow(non_camel_case_types, unsafe_code)] mod macos_ioreport { use super::*; + use crate::clocks::{thread_cpu_time, wall_now}; + use crate::rail_liveness::{self, ProbeRun, Sample}; use std::os::raw::{c_int, c_long, c_void}; use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; type CFTypeRef = *const c_void; type CFStringRef = *const c_void; @@ -76,6 +1541,13 @@ mod macos_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, @@ -142,7 +1614,13 @@ mod macos_ioreport { } } - /// Apple Silicon IOReport energy counter (SoC-total, Measured-grade). + /// Apple Silicon IOReport energy counter (SoC-total). + /// + /// Constructed only on a host whose every CPU energy rail is live; see + /// [`crate::ioreport_status`]. A live IOReport reading is a vendor + /// energy counter's reading, so the delta it gives is `Measured`, as + /// RAPL's and NVML's are; that holds only while the rail is live, which + /// is why the counter is never constructed when it is not. pub struct MacosIoReportCounter { state: Mutex, } @@ -151,42 +1629,317 @@ mod macos_ioreport { unsafe impl Send for MacosIoReportCounter {} unsafe impl Sync for MacosIoReportCounter {} + /// Subscribe to `desired` (consumed). `None`, with everything released, + /// if IOReport returns no subscription or no subscribed-channel dictionary. + unsafe fn subscribe(desired: CFMutableDictionaryRef) -> Option { + unsafe { + let mut subscribed: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription( + std::ptr::null(), + desired, + &mut subscribed as *mut _, + 0, + std::ptr::null(), + ); + CFRelease(desired as CFTypeRef); + if sub.is_null() || subscribed.is_null() { + if !sub.is_null() { + CFRelease(sub); + } + if !subscribed.is_null() { + CFRelease(subscribed as CFTypeRef); + } + return None; + } + Some(State { sub, channels: subscribed, prior_sample: None, cumulative_uj: 0 }) + } + } + impl MacosIoReportCounter { - /// Open an IOReport subscription; `Err(NoCounter)` if IOReport can't open - /// (non-Apple-Silicon, Rosetta, or a future macOS that changes the API). + /// Open an IOReport subscription on a host whose CPU energy rail is + /// live. `Err(NoCounter)` if IOReport can't open (non-Apple-Silicon, + /// Rosetta, or a future macOS that changes the API), **and** if it opens + /// but its CPU energy rail is absent, or does not keep advancing under + /// load — then the summed rails would report GPU-only energy, zero, or a + /// stale refresh as the energy of CPU work. [`crate::ioreport_status`] + /// says which. pub fn try_detect() -> Result { + Self::try_detect_given(crate::ioreport_status()) + } + + /// The constructor behind [`Self::try_detect`], given this host's + /// status: every status the pure gate does not admit declines before + /// anything opens. Opening a subscription is not enough; the CPU rail + /// must be live. + pub(crate) fn try_detect_given(status: crate::IoReportStatus) -> Result { + if !rail_liveness::admits_counter(status) { + return Err(Error::NoCounter); + } match Self::open() { Some(state) => Ok(Self { state: Mutex::new(state) }), None => Err(Error::NoCounter), } } + /// The counter's own subscription: every channel. fn open() -> Option { + // Test builds only: count the attempt on this thread, so a test can + // pin that a declined status never gets this far. + #[cfg(test)] + script::note_counter_open(); unsafe { let all = IOReportCopyAllChannels(0, 0); if all.is_null() { return None; } - let mut subscribed: CFMutableDictionaryRef = std::ptr::null_mut(); - let sub = IOReportCreateSubscription( - std::ptr::null(), - all, - &mut subscribed as *mut _, - 0, - std::ptr::null(), - ); - if sub.is_null() { - CFRelease(all as CFTypeRef); - return None; - } - CFRelease(all as CFTypeRef); - if subscribed.is_null() { - CFRelease(sub); - return None; - } - Some(State { sub, channels: subscribed, prior_sample: None, cumulative_uj: 0 }) + subscribe(all) } } + + /// A subscription to the Energy Model group alone, for the liveness + /// probe. The CPU rails are the same counters the all-channel + /// subscription above reads, with the same absolute values, but one + /// sample of this group takes ~6-12 ms on an M5 Max and an M3 Ultra, + /// against 0.13-0.8 s and ~0.14 s for every channel — slow enough that a + /// probe sampling all channels would spend nearly its whole budget + /// inside IOReport instead of burning CPU. On an M5 Max a probe on + /// every channel saw 2-3 intervals in ~2 s; on this group, 25-31 in one. + fn open_energy_model() -> Option { + // Test builds only: a script installed on this thread stands in for + // IOReport here and in `State::group_sample`, and nowhere else. + #[cfg(test)] + if let Some(opens) = script::opens() { + return opens.then(State::detached); + } + 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; + } + subscribe(desired) + } + } + } + + impl State { + /// A state that holds no subscription (its drop releases nothing), for + /// a script to sample in place of IOReport. + #[cfg(test)] + fn detached() -> State { + State { + sub: std::ptr::null(), + channels: std::ptr::null_mut(), + prior_sample: None, + cumulative_uj: 0, + } + } + + /// One sample of this subscription: how many channels it held, whether + /// every one is in the Energy Model group, and each Energy Model + /// channel's (name, absolute raw integer), in sample order. `None` when + /// IOReport returns no sample, or a sample with no channel list. This + /// only extracts; the liveness rule lives in [`rail_liveness`]. + fn group_sample(&self) -> Option { + // Test builds only: a script installed on this thread stands in for + // IOReport here and in `open_energy_model`, and nowhere else. + #[cfg(test)] + if let Some(sample) = script::next() { + return sample; + } + unsafe { + let smp = IOReportCreateSamples(self.sub, self.channels, std::ptr::null()); + if smp.is_null() { + return None; + } + let sample = energy_model_channels(smp); + CFRelease(smp); + sample + } + } + } + + /// In a sample (absolute, not a delta): its channel count, whether every + /// channel is in the Energy Model group (by `IOReportChannelGetGroup`), and + /// each Energy Model channel's name and raw integer value. `None` when the + /// sample carries no channel list. + fn energy_model_channels(sample: CFDictionaryRef) -> Option { + unsafe { + 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 group_only, mut values) = (true, Vec::new()); + for i in 0..count { + let item = CFArrayGetValueAtIndex(arr, i); + if item.is_null() || !cfstring_eq_ic(IOReportChannelGetGroup(item), "energy model") { + group_only = false; + continue; + } + let Some(name) = cfstring_to_string(IOReportChannelGetChannelName(item)) else { + continue; + }; + values.push((name, IOReportSimpleGetIntegerValue(item, 0) as i64)); + } + Some(Sample { channels: count.max(0) as usize, group_only, values }) + } + } + + /// One tick of the probe: [`crate::clocks::cpu_burn_tick`]. + fn probe_tick() { + crate::clocks::cpu_burn_tick(); + } + + /// How many times [`host_status`] — the probe behind the cached + /// [`crate::ioreport_status`] — has run in this process, on any thread. + pub(crate) static HOST_STATUS_RUNS: AtomicUsize = AtomicUsize::new(0); + + /// How many times this process has opened the liveness probe's sampler, + /// on any thread: every real probe opens one, whoever calls it. + pub(crate) static SAMPLER_OPENS: AtomicUsize = AtomicUsize::new(0); + + /// The real opener: a fresh subscription to the Energy Model group, and the + /// sampler that reads it. The probe takes its own samples, so no counter's + /// cumulative reading is touched; the subscription is dropped with the + /// sampler, when the probe ends. + fn open_group_sampler() -> Option Option> { + SAMPLER_OPENS.fetch_add(1, Ordering::SeqCst); + let state = MacosIoReportCounter::open_energy_model()?; + Some(move || state.group_sample()) + } + + /// The production probe: [`rail_liveness::probe_with`] on the real opener, + /// the real tick, this thread's CPU clock and the monotonic wall clock, for + /// [`crate::CPU_RAIL_LIVENESS_BUDGET`]. The only production caller of + /// `probe_with`, called in production only by [`host_status`]; the + /// hardware tests run it too, uncounted and uncached, to pin what the + /// production composition hands the rule. + pub(crate) fn production_probe() -> ProbeRun { + rail_liveness::probe_with(open_group_sampler, probe_tick, thread_cpu_time, wall_now, crate::CPU_RAIL_LIVENESS_BUDGET) + } + + /// This host's status: the pure mapping of the production probe's verdict. + /// Counts its own runs first; cached, once per process, by + /// [`crate::ioreport_status`], its only production caller. + pub(crate) fn host_status() -> crate::IoReportStatus { + HOST_STATUS_RUNS.fetch_add(1, Ordering::SeqCst); + rail_liveness::status_of(production_probe().verdict()) + } + + /// Test support: a scripted sample stream that stands in for IOReport in + /// the probe's two FFI leaves — opening the Energy Model group + /// subscription, and taking one sample of it — on the thread that installs + /// it. Everything above those leaves runs as in production: the counted + /// [`host_status`], [`production_probe`] with the real tick and clocks, the + /// real opener's sampler closure, the rule and the status mapping. So a + /// test can run the production status itself with no subscription, a + /// batch refresh, or a live rail, none of which these hosts produce on + /// demand. Likewise a status installed on a thread stands in for the + /// cached one in [`crate::ioreport_status`], so the real constructor and + /// selection run under every status; and each thread counts its attempts + /// to open the counter's own all-channel subscription. + #[cfg(test)] + pub(crate) mod script { + use super::Sample; + use std::cell::RefCell; + + /// What the leaves return while a script is installed. + pub(crate) struct Script { + /// Whether the group subscription opens. + pub(crate) opens: bool, + /// The i-th sample; `None` for one that cannot be taken. + pub(crate) samples: Box Option>, + } + + thread_local! { + static SCRIPT: RefCell> = const { RefCell::new(None) }; + static STATUS: std::cell::Cell> = const { std::cell::Cell::new(None) }; + static COUNTER_OPENS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + /// Called by the counter's own opener on every attempt to open its + /// all-channel subscription. + pub(crate) fn note_counter_open() { + COUNTER_OPENS.with(|c| c.set(c.get() + 1)); + } + + /// How many times this thread has tried to open the counter's + /// all-channel subscription. Per thread, so tests running beside it + /// that open one do not move it. + pub(crate) fn counter_opens() -> usize { + COUNTER_OPENS.with(|c| c.get()) + } + + /// `Some(status)` while a test has installed a status for + /// [`crate::ioreport_status`] to return on this thread. + pub(crate) fn status() -> Option { + STATUS.with(|s| s.get()) + } + + /// Run `f` on this thread with [`crate::ioreport_status`] returning + /// `status`, so the real `try_detect()` and `detect()` can be run + /// under every status. Removed even if `f` panics. + pub(crate) fn with_status(status: crate::IoReportStatus, f: impl FnOnce() -> R) -> R { + struct Remove; + impl Drop for Remove { + fn drop(&mut self) { + STATUS.with(|s| s.set(None)); + } + } + STATUS.with(|s| s.set(Some(status))); + let _remove = Remove; + f() + } + + /// `Some(whether the subscription opens)` while a script is installed. + pub(crate) fn opens() -> Option { + SCRIPT.with(|s| s.borrow().as_ref().map(|(script, _)| script.opens)) + } + + /// `Some(the next scripted sample)` while a script is installed. + pub(crate) fn next() -> Option> { + SCRIPT.with(|s| { + s.borrow_mut().as_mut().map(|(script, served)| { + *served += 1; + (script.samples)(*served - 1) + }) + }) + } + + /// Run `f` on this thread with `script` installed; returns what `f` + /// returned and how many samples the script served. The script is + /// removed even if `f` panics. + pub(crate) fn with(script: Script, f: impl FnOnce() -> R) -> (R, usize) { + struct Remove; + impl Drop for Remove { + fn drop(&mut self) { + SCRIPT.with(|s| s.borrow_mut().take()); + } + } + SCRIPT.with(|s| *s.borrow_mut() = Some((script, 0))); + let _remove = Remove; + let out = f(); + let served = SCRIPT.with(|s| s.borrow().as_ref().map_or(0, |(_, served)| *served)); + (out, served) + } } impl JouleCounter for MacosIoReportCounter { @@ -278,29 +2031,32 @@ mod macos_ioreport { uj as u64 } - fn strip_die_prefix(name: &str) -> &str { - if let Some(rest) = name.strip_prefix("DIE_") { - let after = rest.trim_start_matches(|c: char| c.is_ascii_digit()); - if let Some(r) = after.strip_prefix('_') { - return r; - } - } - 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` = its `EACC_CPU` + `PACC0_CPU` + + /// `PACC1_CPU`, each the sum of its cores, their SRAM and its `_CPM` rails + /// (all to within 1-8 mJ). Summing `PACC_*` or `*_CPM` beside `CPU Energy` + /// would count that CPU energy twice. fn counts_once(name: &str) -> bool { - let base = strip_die_prefix(name); + let base = rail_liveness::strip_die_prefix(name); if base.eq_ignore_ascii_case("CPU Energy") || base.eq_ignore_ascii_case("GPU Energy") || base.eq_ignore_ascii_case("ANE Energy") { 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 } fn cfstring_to_string(s: CFStringRef) -> Option { @@ -327,7 +2083,59 @@ mod macos_ioreport { #[cfg(test)] mod tests { + use super::script::{self, Script}; use super::*; + use crate::rail_liveness::{CpuRail, RailKey, SampleCall}; + use std::time::{Duration, Instant}; + use std::cell::RefCell; + use std::rc::Rc; + use std::sync::atomic::AtomicBool; + + unsafe extern "C" { + fn clock_gettime_nsec_np(clock_id: u32) -> u64; + } + + /// `CLOCK_THREAD_CPUTIME_ID` in macOS ``: the tests' own copy, + /// declared and read independently of the implementation's. + const OWN_CLOCK_THREAD_CPUTIME_ID: u32 = 16; + + /// This thread's CPU time, read by the tests themselves + /// (`clock_gettime_nsec_np`), not through the implementation's clock. + fn own_thread_cpu() -> Duration { + Duration::from_nanos(unsafe { clock_gettime_nsec_np(OWN_CLOCK_THREAD_CPUTIME_ID) }) + } + + /// The tick the probe is specified to burn, stated here rather than + /// read from the implementation's constant. + const TICK: Duration = Duration::from_millis(25); + + /// The wall time after which a tick is specified to give up, stated + /// here rather than read from the implementation's constant. + const TICK_WALL_CAP: Duration = Duration::from_millis(500); + + /// How far two readings of different clocks can disagree by + /// granularity alone: both step at the 24 MHz timebase (~42 ns), so a + /// tick that ran without a break can read a few tens of ns more CPU + /// than wall time. Real CPU never exceeds wall by more. + const CLOCK_STEP: Duration = Duration::from_micros(1); + + /// Real CPU work for the tests: spin this thread for `d` of wall time. + fn spin(d: Duration) { + let started = Instant::now(); + let mut acc: u64 = 0; + while started.elapsed() < d { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + } + + /// The median of a non-empty list (the mean of the middle two when even). + fn median(v: &[Duration]) -> Duration { + assert!(!v.is_empty(), "the median of nothing"); + let mut v = v.to_vec(); + v.sort(); + let m = v.len() / 2; + if v.len().is_multiple_of(2) { (v[m - 1] + v[m]) / 2 } else { v[m] } + } #[test] fn energy_to_uj_scales_by_unit() { @@ -336,30 +2144,1382 @@ mod macos_ioreport { assert_eq!(energy_to_uj(2, "J"), 2_000_000); } + /// The counter sums each aggregate once and none of its summands, on + /// the real Energy Model channel names of both chips. #[test] fn counts_once_drops_subrails() { - assert!(counts_once("CPU Energy")); - assert!(counts_once("DIE_0_CPU Energy")); - assert!(!counts_once("DIE_0_PACC0_CPU0")); - assert!(!counts_once("GPU0_0")); + // Aggregates, and domains that are no aggregate's summand. + for name in [ + "CPU Energy", "DIE_0_CPU Energy", "DIE_1_CPU Energy", "GPU Energy", "ANE Energy", "AFR0", "ISP0", + "AVE0", "MSR0", "AMCC0", "DCS0", "DRAM0", "DISP0", "DISPEXT0", "FAB0", "PCIe Port 0 Energy", + "apciec0 Energy", "ISP0_1", "DRAM0_0", "AMCC0_1", "DISPEXT0_1", + ] { + assert!(counts_once(name), "{name} is not summed"); + } + // An M5 Max, from one absolute sample: `CPU Energy` = `MCPU0` + + // `MCPU1` + `PCPU`; `MCPUn` = its cores + their SRAM + `MCPMn` + + // `MCPMn_SRAM`; `PCPU` = `PACC_0..5` + `PCPU0..5_SRAM` + `PCPM` + + // `PCPM_SRAM`. + for name in [ + "MCPU0", "MCPU1", "PCPU", "MCPU0_3", "MCPU1_5_SRAM", "PCPU2_SRAM", "PACC_0", "PACC_5", "MCPM0", + "MCPM1", "PCPM", "MCPM0_SRAM", "MCPM1_SRAM", "PCPM_SRAM", "MCPU0DTL0e", + ] { + assert!(!counts_once(name), "{name}, a summand of CPU Energy on an M5 Max, is summed beside it"); + } + // An M3 Ultra: `DIE_n_CPU Energy` = its `EACC_CPU` + `PACC0_CPU` + + // `PACC1_CPU`, each = its cores + their SRAM + its `_CPM` + `_CPM_SRAM`. + for name in [ + "DIE_0_EACC_CPU", "DIE_1_PACC0_CPU", "DIE_0_PACC1_CPU", "DIE_0_PACC1_CPU3", "DIE_1_EACC_CPU2_SRAM", + "DIE_0_EACC_CPM", "DIE_1_PACC0_CPM", "DIE_0_PACC1_CPM_SRAM", "DIE_1_EACC_CPM_SRAM", "ECPUDTL00", + ] { + 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"); + } } + // ── An independent oracle ──────────────────────────────────────────── + + /// The oracle's own CFString conversion, so a broken implementation + /// helper cannot make the oracle agree with it. + fn oracle_name(s: CFStringRef) -> String { + if s.is_null() { + return String::new(); + } + 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 String::new(); + } + let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).into_owned() + } + + /// One channel as the oracle read it: its group, name and unit label, + /// and its ABSOLUTE raw integer (no delta samples). + #[derive(Debug, Clone)] + struct OracleChannel { + group: String, + name: String, + unit: String, + raw: i64, + } + + /// The oracle's own subscription, over every channel (not the probe's + /// Energy Model group), opened with its own calls. It shares nothing + /// with the implementation but the raw FFI declarations. + struct OracleSub { + sub: IOReportSubscriptionRef, + chans: CFMutableDictionaryRef, + } + + impl OracleSub { + /// A subscription to every channel. + fn open() -> Option { + Self::subscribe(unsafe { IOReportCopyAllChannels(0, 0) }) + } + + /// A subscription to the Energy Model group alone, by the oracle's + /// own calls: reads of ~7 ms instead of the 0.1-0.8 s of every + /// channel, for brackets that must be tight. + fn open_group() -> 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); + Self::subscribe(desired) + } + } + + /// Subscribe to `desired` (consumed; null gives `None`). + fn subscribe(desired: CFMutableDictionaryRef) -> Option { + unsafe { + if desired.is_null() { + return None; + } + let mut chans: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = IOReportCreateSubscription(std::ptr::null(), desired, &mut chans, 0, std::ptr::null()); + CFRelease(desired 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(Self { sub, chans }) + } + } + + /// One read: how many channels the sample held, in any group, and + /// every channel `keep(group, name)` accepts, in sample order. + /// `None` when IOReport returns no sample, or one with no channel + /// list. + fn read_where(&self, keep: impl Fn(&str, &str) -> bool) -> Option<(usize, Vec)> { + unsafe { + let smp = IOReportCreateSamples(self.sub, self.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 arr = if key.is_null() { + std::ptr::null() + } else { + let arr = CFDictionaryGetValue(smp, key as *const _) as CFArrayRef; + CFRelease(key as CFTypeRef); + arr + }; + let mut out = None; + if !arr.is_null() { + let count = CFArrayGetCount(arr); + let mut chans = Vec::new(); + for i in 0..count { + let it = CFArrayGetValueAtIndex(arr, i); + if it.is_null() { + continue; + } + let group = oracle_name(IOReportChannelGetGroup(it)); + let name = oracle_name(IOReportChannelGetChannelName(it)); + if keep(&group, &name) { + chans.push(OracleChannel { + unit: oracle_name(IOReportChannelGetUnitLabel(it)), + raw: IOReportSimpleGetIntegerValue(it, 0) as i64, + group, + name, + }); + } + } + out = Some((count.max(0) as usize, chans)); + } + CFRelease(smp); + out + } + } + + /// One read: every channel, in any group, whose name ends with `CPU + /// Energy` or `GPU Energy`, in sample order. + fn read(&self) -> Option> { + self.read_where(|_, name| name.ends_with("CPU Energy") || name.ends_with("GPU Energy")).map(|(_, c)| c) + } + + /// One read's shape: (every channel, the Energy Model group's channels). + fn shape(&self) -> Option<(usize, usize)> { + self.read_where(|group, _| group == "Energy Model").map(|(all, group)| (all, group.len())) + } + + /// One read of the Energy Model group's CPU and GPU energy rails (the + /// oracle's own name rule), keyed by (name, ordinal among the same + /// name), in sample order. + fn energy_rails(&self) -> Vec<(RailKey, i64)> { + let (_, chans) = self + .read_where(|group, name| { + group == "Energy Model" && (name.ends_with("CPU Energy") || name.ends_with("GPU Energy")) + }) + .expect("an oracle read"); + oracle_keyed(chans.into_iter().map(|c| (c.name, c.raw))) + } + } + + impl Drop for OracleSub { + fn drop(&mut self) { + unsafe { + CFRelease(self.sub); + CFRelease(self.chans as CFTypeRef); + } + } + } + + /// (name, value) pairs keyed by (name, ordinal among the same name), in + /// order: the oracle's own keying. + fn oracle_keyed(items: impl IntoIterator) -> Vec<(RailKey, V)> { + let mut out: Vec<(RailKey, V)> = Vec::new(); + for (name, v) in items { + let ordinal = out.iter().filter(|((n, _), _)| *n == name).count() as u32; + out.push(((name, ordinal), v)); + } + out + } + + /// What the oracle saw on this host. + #[derive(Debug, PartialEq)] + enum Oracle { + NoIoReport, + NoCpuRail, + CpuRailMoved(bool), + } + + #[derive(Debug)] + struct OracleRun { + verdict: Oracle, + reads: u32, + /// Pairs of consecutive reads compared since the last baseline: + /// what the per-rail change counts are out of. + compared: u32, + took: Duration, + cpu_changes: Vec, + gpu_changes: Vec, + } + + /// The fewest changes at which one rail counts as moving, and so the + /// fewest pairs of consecutive reads the oracle compares (six reads) + /// before its verdict can say anything: it reads past its window until + /// it has this many, so a live rail, changing at every read, can meet + /// its bar. Its own literal, not the implementation's constant. + const ORACLE_MIN_CHANGES: u32 = 5; + + /// However slow its reads, the oracle stops after this many windows. + const ORACLE_WINDOW_CAP: u32 = 5; + + /// The oracle's bar for moving CPU rails, over `compared` pairs of + /// consecutive reads: EVERY CPU rail it found (each die's, on a + /// multi-die part) changed at least [`ORACLE_MIN_CHANGES`] times, at no + /// fewer than 80% of them. A live rail changes at nearly every read. A + /// refresh — one step, or two 64 ms apart, or a few in a burst — + /// changes at a handful of reads in a window of 0.1-0.8 s reads, so it + /// cannot vouch for a Live verdict it happened to coincide with. Every + /// rail, because the counter sums every die: one die moving beside one + /// that holds cannot vouch for a Live verdict either. + fn oracle_moved(cpu_changes: &[u32], compared: u32) -> bool { + !cpu_changes.is_empty() + && cpu_changes.iter().all(|&n| n >= ORACLE_MIN_CHANGES && u64::from(n) * 5 >= u64::from(compared) * 4) + } + + /// What the oracle has counted, read by read: per rail, at how many + /// reads its value changed since the read before, and out of how many + /// pairs of reads compared since the last baseline. Pure, so the bar + /// runs under test on scripted reads. + #[derive(Debug, Default)] + struct OracleTally { + prev: Option<(Vec, Vec)>, + saw_cpu_rail: bool, + compared: u32, + cpu_changes: Vec, + gpu_changes: Vec, + } + + impl OracleTally { + /// Fold in one read: every channel whose name ends with `CPU + /// Energy` or `GPU Energy`, in sample order. + fn fold(&mut self, chans: Vec) { + let names = |v: &[OracleChannel]| v.iter().map(|c| c.name.clone()).collect::>(); + let (cpu, gpu): (Vec<_>, Vec<_>) = chans.into_iter().partition(|c| c.name.ends_with("CPU Energy")); + self.saw_cpu_rail |= !cpu.is_empty(); + match &self.prev { + Some((pc, pg)) if names(pc) == names(&cpu) && names(pg) == names(&gpu) => { + self.compared += 1; + for (n, (now, before)) in self.cpu_changes.iter_mut().zip(cpu.iter().zip(pc)) { + *n += u32::from(now.raw != before.raw); + } + for (n, (now, before)) in self.gpu_changes.iter_mut().zip(gpu.iter().zip(pg)) { + *n += u32::from(now.raw != before.raw); + } + } + // The first read, or rails that differ from the last read's: a new baseline. + _ => { + self.compared = 0; + self.cpu_changes = vec![0; cpu.len()]; + self.gpu_changes = vec![0; gpu.len()]; + } + } + self.prev = Some((cpu, gpu)); + } + + fn verdict(&self) -> Oracle { + if self.saw_cpu_rail { + Oracle::CpuRailMoved(oracle_moved(&self.cpu_changes, self.compared)) + } else { + Oracle::NoCpuRail + } + } + } + + impl OracleRun { + /// The oracle's evidence is only as good as its reads. Fewer than + /// five compared pairs cannot show a rail changing five times, so + /// they say nothing about whether the CPU rail is frozen: that is a + /// test FAILURE, never a Frozen verdict. With no subscription there + /// are no reads to have. + fn assert_evidence(&self) { + if self.verdict == Oracle::NoIoReport { + return; + } + assert!( + self.compared >= ORACLE_MIN_CHANGES, + "the oracle compared only {} pairs of consecutive reads ({} reads) in {:?}, though it keeps \ + reading past its window until it has {ORACLE_MIN_CHANGES}, for up to {ORACLE_WINDOW_CAP} \ + windows: too few for any rail to change {ORACLE_MIN_CHANGES} times, so there is no evidence \ + about the CPU rail either way", + self.compared, + self.reads, + self.took + ); + } + } + + /// The oracle's view of the CPU rail, over at least `window` while a + /// HELPER thread burns CPU the whole time (so the window is under load + /// even while an all-channel read blocks this thread). + /// + /// It reads its own all-channel subscription over and over, matches + /// names with `ends_with("CPU Energy")` in any group (not the + /// implementation's group filter and die-prefix stripping), and counts + /// per rail at how many reads the value changed since the read before + /// ([`OracleTally`]). `CpuRailMoved(true)` only by [`oracle_moved`]: + /// every CPU rail changed at 80% or more of the compared reads, and at + /// least five times. It reads until the window has passed AND it has compared + /// [`ORACLE_MIN_CHANGES`] pairs of reads, for at most + /// [`ORACLE_WINDOW_CAP`] windows, so slow reads on a loaded host + /// lengthen the window rather than thin the evidence. + fn oracle_cpu_rail(window: Duration) -> OracleRun { + let Some(sub) = OracleSub::open() else { + return OracleRun { + verdict: Oracle::NoIoReport, + reads: 0, + compared: 0, + took: Duration::ZERO, + cpu_changes: Vec::new(), + gpu_changes: Vec::new(), + }; + }; + let stop = AtomicBool::new(false); + std::thread::scope(|s| { + s.spawn(|| { + let mut acc: u64 = 0; + while !stop.load(Ordering::Relaxed) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + }); + let started = Instant::now(); + let (mut reads, mut tally) = (0u32, OracleTally::default()); + while (started.elapsed() < window || tally.compared < ORACLE_MIN_CHANGES) + && started.elapsed() < window * ORACLE_WINDOW_CAP + { + let Some(chans) = sub.read() else { + continue; + }; + reads += 1; + tally.fold(chans); + } + stop.store(true, Ordering::Relaxed); + OracleRun { + verdict: tally.verdict(), + reads, + compared: tally.compared, + took: started.elapsed(), + cpu_changes: tally.cpu_changes, + gpu_changes: tally.gpu_changes, + } + }) + } + + /// A scripted oracle read: `CPU Energy` at `cpu` and a `GPU Energy` + /// that moves at every read. + fn oracle_read(i: usize, cpu: i64) -> Vec { + let chan = |name: &str, raw: i64| OracleChannel { + group: "Energy Model".to_string(), + name: name.to_string(), + unit: "mJ".to_string(), + raw, + }; + vec![chan("CPU Energy", cpu), chan("GPU Energy", 1_000 + 7 * i as i64)] + } + + /// The oracle cannot vouch for a Live verdict with a refresh. Over a + /// window of scripted reads, the observed two-step refresh (+61,764,485 + /// then +3,435), a single step, and a five-step burst are no movement; + /// a rail changing at every read, or at four reads in five, is; one + /// changing at three reads in four is not, nor one that changed at + /// every one of only four compared pairs. The old bar (two changes) + /// called the two-step refresh movement. #[test] - fn ioreport_advances_after_cpu_burn() { - let Ok(c) = MacosIoReportCounter::try_detect() else { - return; // not Apple Silicon + fn the_oracle_does_not_count_a_refresh_as_movement() { + let tally = |reads: usize, cpu: &dyn Fn(usize) -> i64| { + let mut t = OracleTally::default(); + for i in 0..reads { + t.fold(oracle_read(i, cpu(i))); + } + t + }; + let a = 2_643_843_378; + let two_step = |i: usize| if i < 4 { a } else if i < 5 { a + 61_764_485 } else { a + 61_764_485 + 3_435 }; + let t = tally(12, &two_step); + assert_eq!( + (t.verdict(), t.compared, t.cpu_changes.clone(), t.gpu_changes.clone()), + (Oracle::CpuRailMoved(false), 11, vec![2], vec![11]), + "the oracle counted the two-step refresh as a moving CPU rail" + ); + let one_step = |i: usize| if i < 4 { a } else { a + 61_764_485 }; + assert_eq!(tally(12, &one_step).verdict(), Oracle::CpuRailMoved(false)); + let burst = |i: usize| a + 100 * (i.clamp(3, 8) as i64 - 3); + let t = tally(20, &burst); + assert_eq!((t.verdict(), t.cpu_changes.clone()), (Oracle::CpuRailMoved(false), vec![5]), "a five-step burst"); + + let live = |i: usize| a + 13 * i as i64; + assert_eq!(tally(6, &live).verdict(), Oracle::CpuRailMoved(true), "5 changes in 5 compared pairs"); + assert_eq!(tally(5, &live).verdict(), Oracle::CpuRailMoved(false), "4 changes in 4 compared pairs"); + let four_in_five = |i: usize| a + (1..=i).filter(|k| k % 5 != 0).count() as i64; + assert_eq!(tally(21, &four_in_five).verdict(), Oracle::CpuRailMoved(true), "16 changes in 20"); + let three_in_four = |i: usize| a + (1..=i).filter(|k| k % 4 != 0).count() as i64; + assert_eq!(tally(21, &three_in_four).verdict(), Oracle::CpuRailMoved(false), "15 changes in 20"); + assert!(!oracle_moved(&[2], 11) && !oracle_moved(&[3, 3], 5) && !oracle_moved(&[], 11)); + // Two dies: both must have moved. + assert!(oracle_moved(&[11, 9], 11), "two moving dies"); + assert!(!oracle_moved(&[0, 11], 11) && !oracle_moved(&[11, 2], 11), "one die moving beside one that holds"); + } + + // ── The production status, on scripted IOReport leaves ─────────────── + + /// A sample the way the FFI returns one of the Energy Model group: + /// every channel in the group. + fn group_sample(values: &[(&str, i64)]) -> Sample { + Sample { + channels: values.len(), + group_only: true, + values: values.iter().map(|(n, raw)| (n.to_string(), *raw)).collect(), + } + } + + /// One production tick as the tests' own clocks saw it. + #[derive(Debug, Clone, Copy)] + struct TickSeen { + cpu: Duration, + wall: Duration, + } + + /// What a script saw of the production ticks: when it last returned a + /// sample (wall, then this thread's CPU), and every tick since the first. + #[derive(Default)] + struct TickMarks { + returned: Option<(Instant, Duration)>, + ticks: Vec, + } + + /// The production status — the counted, uncached [`host_status`] — + /// with a script standing in for IOReport's two FFI leaves: the group + /// subscription opens or not, and `sample(i)` is its i-th sample. + /// Returns the status, how many samples the script served, and every + /// production tick as the tests' own clocks saw it: from the moment the + /// script returned one sample to the moment it was asked for the next, + /// with the CPU reading nested inside the wall reading. + fn scripted_host_status( + opens: bool, + sample: impl Fn(usize) -> Option + 'static, + ) -> (IoReportStatus, usize, Vec) { + let seen: Rc> = Rc::default(); + let marks = Rc::clone(&seen); + let script = Script { + opens, + samples: Box::new(move |i| { + let (cpu, wall) = (own_thread_cpu(), Instant::now()); + let mut m = marks.borrow_mut(); + if let Some((wall0, cpu0)) = m.returned { + m.ticks.push(TickSeen { cpu: cpu.saturating_sub(cpu0), wall: wall.saturating_duration_since(wall0) }); + } + assert!(i < 10_000, "the probe took {i} samples in one budget: its tick does not burn CPU"); + let s = sample(i); + m.returned = Some((Instant::now(), own_thread_cpu())); + s + }), + }; + let (status, served) = script::with(script, host_status); + let ticks = seen.borrow().ticks.clone(); + (status, served, ticks) + } + + /// The production status itself: [`host_status`] — the real opener's + /// sampler closure, [`production_probe`] with the real tick and clocks, + /// the rule and the mapping — with a script standing in for IOReport's + /// two FFI leaves. No subscription is `Unavailable`, with no sample + /// taken; a single batch refresh is `CpuRailFrozen`; the observed + /// refresh published in two steps is `CpuRailFrozen`; a rail advancing + /// at every sample is `Live`, once five intervals and 300 ms of the + /// real wall clock have passed and no later; one step on each of two + /// dies, in different intervals, is `CpuRailFrozen`, and so is one die + /// advancing at every sample beside one that holds. These hosts + /// produce none of the first four on demand, so no hardware test can + /// reach them. Every scripted step falls in the first five intervals, + /// which the probe always observes however loaded the host: it takes + /// at least six samples unless the rail proves live first. + #[test] + fn host_status_is_the_rule_on_the_production_composition() { + let (status, served, ticks) = scripted_host_status(false, |_| panic!("sampled without a subscription")); + assert_eq!( + (status, served, ticks.len()), + (IoReportStatus::Unavailable, 0, 0), + "no group subscription must be Unavailable, with no sample and no tick" + ); + + let refresh = + |i: usize| [("CPU Energy", if i < 1 { 480_083_160 } else { 541_893_638 }), ("GPU Energy", 1_000 + 7 * i as i64)]; + let started = Instant::now(); + let (status, served, _) = scripted_host_status(true, move |i| Some(group_sample(&refresh(i)))); + let took = started.elapsed(); + assert_eq!(status, IoReportStatus::CpuRailFrozen, "a single batch refresh ({served} samples) must be frozen"); + // A frozen verdict takes the whole budget, 1 s of wall time: the probe + // samples until it has passed. Load can only lengthen this. + assert!(took >= Duration::from_secs(1), "the production probe gave a frozen verdict after {took:?}"); + + // The M5 Max's two-step refresh: +61,764,485 then +3,435, in the + // third and fourth intervals. The old rule's two advances. + let two_step = |i: usize| { + let a = 2_643_843_378; + let cpu = if i < 3 { a } else if i < 4 { a + 61_764_485 } else { a + 61_764_485 + 3_435 }; + [("CPU Energy", cpu), ("MCPU0", cpu / 4), ("PCPU", cpu / 2), ("GPU Energy", 1_000 + 7 * i as i64)] + }; + let (status, served, _) = scripted_host_status(true, move |i| Some(group_sample(&two_step(i)))); + assert_eq!(status, IoReportStatus::CpuRailFrozen, "a refresh in two steps ({served} samples) must be frozen"); + + // A rail advancing at every sample. Each production tick burns 25 ms + // of this thread's CPU, so at least 25 ms of wall time: 300 ms have + // passed by the 12th interval at the latest (13 allowed, for clock + // granularity), and never before the 5th. + let live = |i: usize| [("GPU Energy", 5), ("CPU Energy", 1_000 + 13 * i as i64)]; + let started = Instant::now(); + let (status, served, _) = scripted_host_status(true, move |i| Some(group_sample(&live(i)))); + let took = started.elapsed(); + assert_eq!(status, IoReportStatus::Live, "a rail advancing at every one of {served} samples must be live"); + assert!( + (6..=14).contains(&served) && took >= Duration::from_millis(300), + "a sustained advance was declared live after {served} samples in {took:?}: the rule needs five \ + intervals and 300 ms, and the probe stops as soon as it holds" + ); + + let dies = |i: usize| { + [ + ("DIE_0_CPU Energy", if i >= 1 { 9_500_000 } else { 9_494_336 }), + ("DIE_1_CPU Energy", if i >= 2 { 10_300_000 } else { 10_212_656 }), + ] }; - let _ = c.read_microjoules(); - let before = c.read_microjoules().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); + let (status, served, _) = scripted_host_status(true, move |i| Some(group_sample(&dies(i)))); + assert_eq!(status, IoReportStatus::CpuRailFrozen, "one step on each die ({served} samples) must be frozen"); + + // DIE_0 advancing at every sample, DIE_1 held: the counter would + // sum a live die and a frozen one, so the whole is frozen. + let one_live_die = |i: usize| [("DIE_0_CPU Energy", 9_494_336 + 1_307 * i as i64), ("DIE_1_CPU Energy", 10_212_656)]; + let (status, served, _) = scripted_host_status(true, move |i| Some(group_sample(&one_live_die(i)))); + assert_eq!(status, IoReportStatus::CpuRailFrozen, "one live die beside a frozen one ({served} samples) must be frozen"); + } + + /// Each production tick, in the production composition, burns 25 ms of + /// this thread's CPU by the tests' own clock, or ran into its 500 ms + /// wall cap; and none shows more CPU than wall time. Between one + /// scripted sample and the next the production code runs its tick and + /// only bookkeeping besides, so the script's own clock readings + /// bracket each tick. + #[test] + fn each_production_tick_burns_its_cpu_by_the_tests_own_clock() { + let (status, served, ticks) = + scripted_host_status(true, |i| Some(group_sample(&[("CPU Energy", 42), ("GPU Energy", i as i64)]))); + let cpus: Vec = ticks.iter().map(|t| t.cpu).collect(); + let walls: Vec = ticks.iter().map(|t| t.wall).collect(); + eprintln!( + "ioreport production ticks (the tests' own clocks): {} ticks between {served} scripted samples; \ + thread CPU min {:?} median {:?} max {:?}; wall min {:?} max {:?}", + ticks.len(), + cpus.iter().min(), + (!cpus.is_empty()).then(|| median(&cpus)), + cpus.iter().max(), + walls.iter().min(), + walls.iter().max() + ); + assert_eq!(status, IoReportStatus::CpuRailFrozen); + assert!(ticks.len() >= 5 && ticks.len() == served - 1, "{} ticks for {served} samples", ticks.len()); + for (i, t) in ticks.iter().enumerate() { + assert!( + t.cpu >= TICK || t.wall >= TICK_WALL_CAP, + "production tick {i} burned {:?} of this thread's CPU in {:?} of wall time (every tick: {ticks:?}): \ + a tick burns 25 ms of the thread's CPU, or gives up after 500 ms of wall time", + t.cpu, + t.wall + ); + assert!( + t.cpu <= t.wall + CLOCK_STEP, + "production tick {i}: {:?} of this thread's CPU in {:?} of wall time", + t.cpu, + t.wall + ); + } + } + + /// The production tick burns thread CPU, not wall time. With every core + /// oversubscribed by busy threads, a tick that spun for 25 ms of WALL + /// time would get only part of that as CPU; the production tick keeps + /// spinning until this thread's own CPU clock has advanced 25 ms (or its + /// 500 ms wall cap). Pinned per tick, by the tests' own clocks, in the + /// production composition, over a frozen verdict's whole budget, which + /// always holds five ticks at least; and the contention must have been + /// real: at least one tick shared its core. The probe starts only once + /// every busy thread is running. + #[test] + fn the_production_tick_burns_cpu_not_wall_time_on_a_shared_core() { + let spinners = 2 * std::thread::available_parallelism().map_or(8, |n| n.get()); + let (stop, running) = (AtomicBool::new(false), AtomicUsize::new(0)); + let (status, served, ticks) = std::thread::scope(|s| { + // Stops the spinners however the scope's body ends. + struct Stop<'a>(&'a AtomicBool); + impl Drop for Stop<'_> { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } + } + let _stop = Stop(&stop); + for _ in 0..spinners { + s.spawn(|| { + running.fetch_add(1, Ordering::SeqCst); + let mut acc: u64 = 0; + while !stop.load(Ordering::Relaxed) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + }); + } + while running.load(Ordering::SeqCst) < spinners { + std::thread::yield_now(); + } + scripted_host_status(true, |i| Some(group_sample(&[("CPU Energy", 42), ("GPU Energy", i as i64)]))) + }); + let shared = ticks.iter().filter(|t| t.wall > t.cpu + Duration::from_millis(5)).count(); + eprintln!( + "ioreport production ticks on a shared core ({spinners} busy threads): status={status:?} after {served} \ + samples; {shared} of {} ticks shared their core; every tick (cpu, wall): {:?}", + ticks.len(), + ticks.iter().map(|t| (t.cpu, t.wall)).collect::>() + ); + assert_eq!(status, IoReportStatus::CpuRailFrozen); + assert!(ticks.len() >= 5 && ticks.len() == served - 1, "{} ticks for {served} samples", ticks.len()); + for (i, t) in ticks.iter().enumerate() { + assert!( + t.cpu >= TICK || t.wall >= TICK_WALL_CAP, + "production tick {i} burned {:?} of this thread's CPU in {:?} of wall time on a shared core: \ + the tick must spin until the thread's CPU clock has advanced 25 ms, not for 25 ms of wall time", + t.cpu, + t.wall + ); + assert!( + t.cpu <= t.wall + CLOCK_STEP, + "production tick {i}: {:?} of this thread's CPU in {:?} of wall time", + t.cpu, + t.wall + ); + } + assert!( + shared >= 1, + "no tick shared its core with the {spinners} busy threads ({ticks:?}), so this run could not tell a \ + tick on the thread's CPU clock from one on the wall clock" + ); + } + + /// The implementation's thread clock is this thread's CPU time, checked + /// against the tests' own `CLOCK_THREAD_CPUTIME_ID`: it keeps pace with + /// the tests' clock while this thread burns CPU; it does not advance + /// across a 100 ms sleep (so it is no wall clock); and it does not + /// advance while another thread burns 100 ms of CPU and this one waits + /// (so it is not the process's CPU clock). + #[test] + fn the_probe_thread_clock_is_this_threads_cpu_time() { + let ms = Duration::from_millis; + let (own0, p0) = (own_thread_cpu(), thread_cpu_time()); + while own_thread_cpu() - own0 < ms(20) { + std::hint::black_box(0u64); + } + let (p1, own1) = (thread_cpu_time(), own_thread_cpu()); + let (burned, own) = (p1.saturating_sub(p0), own1 - own0); + + let p0 = thread_cpu_time(); + std::thread::sleep(ms(100)); + let slept = thread_cpu_time().saturating_sub(p0); + + let p0 = thread_cpu_time(); + let helper = std::thread::scope(|s| { + s.spawn(|| { + let c0 = own_thread_cpu(); + let mut acc: u64 = 0; + while own_thread_cpu() - c0 < ms(100) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + own_thread_cpu() - c0 + }) + .join() + .expect("the helper thread panicked") + }); + let waited = thread_cpu_time().saturating_sub(p0); + eprintln!( + "ioreport thread clock: {burned:?} while this thread burned {own:?} (tests' own clock); {slept:?} across \ + a 100 ms sleep; {waited:?} while a helper burned {helper:?} and this thread waited" + ); + assert!( + burned <= own && own - burned < ms(1), + "the implementation's thread clock advanced {burned:?} while the tests' own thread clock advanced {own:?}" + ); + assert!( + slept < ms(5), + "the implementation's thread clock advanced {slept:?} across a 100 ms sleep: it reads wall time, not \ + this thread's CPU" + ); + assert!(helper >= ms(100), "the helper burned only {helper:?}"); + assert!( + waited < ms(5), + "the implementation's thread clock advanced {waited:?} while another thread burned {helper:?} of CPU and \ + this one waited: it reads the process's CPU, not this thread's" + ); + } + + /// A declined status declines before anything opens: given + /// `Unavailable`, `CpuRailAbsent` or `CpuRailFrozen`, the constructor + /// returns `Err(NoCounter)` without trying to open the counter's + /// subscription, and so does selection, whatever IOReport does on this + /// host. Given `Live`, the constructor does try to open it — the count + /// is live. + #[test] + fn a_declined_status_declines_before_anything_opens() { + use IoReportStatus as S; + for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen] { + let before = script::counter_opens(); + let given = MacosIoReportCounter::try_detect_given(status); + assert!(matches!(given, Err(Error::NoCounter)), "try_detect_given({status:?}) did not decline with NoCounter"); + let picked = crate::detect_with(|| CounterStatus::Unavailable, || CounterStatus::Unavailable, || status).name(); + assert_ne!(picked, "ioreport", "detect_with({status:?}) selected the IOReport counter"); + let (built, picked) = + script::with_status(status, || (MacosIoReportCounter::try_detect().is_ok(), crate::detect().name())); + assert!(!built && picked != "ioreport", "try_detect()/detect() under {status:?}: built={built} picked={picked}"); + assert_eq!( + script::counter_opens(), + before, + "under {status:?} the constructor or selection tried to open the counter's subscription before declining" + ); + } + let before = script::counter_opens(); + let _ = MacosIoReportCounter::try_detect_given(S::Live); + assert_eq!(script::counter_opens(), before + 1, "given Live, the constructor did not try to open its subscription"); + } + + /// Every constructor and selection path gates on the pure + /// `admits_counter`: given each declined status, `try_detect_given` + /// declines, and `detect_with` falls through to a counter that is not + /// IOReport; given `Live`, both build the IOReport counter wherever the + /// all-channel subscription opens. And the real `try_detect()` and + /// `detect()`, run with each status installed for `ioreport_status()` + /// on this thread, do the same; with none installed they agree with + /// the cached status. + #[test] + fn only_a_live_status_constructs_or_selects_the_counter() { + use IoReportStatus as S; + let opens = MacosIoReportCounter::open().is_some(); + for status in [S::Unavailable, S::CpuRailAbsent, S::CpuRailFrozen, S::Live] { + let admitted = status == S::Live && opens; + let given = MacosIoReportCounter::try_detect_given(status); + assert_eq!(given.is_ok(), admitted, "try_detect_given({status:?}), the subscription opening={opens}"); + if let Err(e) = given { + assert!(matches!(e, Error::NoCounter), "try_detect_given({status:?}) declined with {e:?}"); + } + let picked = crate::detect_with(|| CounterStatus::Unavailable, || CounterStatus::Unavailable, || status).name(); + assert_eq!(picked == "ioreport", admitted, "detect_with({status:?}) selected {picked}"); + let (built, picked) = script::with_status(status, || { + assert_eq!(crate::ioreport_status(), status, "the installed status"); + (MacosIoReportCounter::try_detect().is_ok(), crate::detect().name()) + }); + assert_eq!(built, admitted, "try_detect() with the status {status:?}, the subscription opening={opens}"); + assert_eq!(picked == "ioreport", admitted, "detect() with the status {status:?} selected {picked}"); + } + let status = crate::ioreport_status(); + assert_eq!(MacosIoReportCounter::try_detect().is_ok(), status == S::Live && opens, "status={status:?}"); + assert_eq!(crate::detect().name() == "ioreport", status == S::Live && opens, "status={status:?}"); + } + + // ── Against the hardware ───────────────────────────────────────────── + + /// The property that matters: the IOReport counter is **never** + /// constructed, and never picked by [`crate::detect`], when a CPU rail + /// it sums is not live — then it would report GPU-only energy, zero, a + /// batch refresh, or one die's energy as the energy of CPU work, and + /// consumers would tag it Measured. Asserts the meter's decision against what the rail actually + /// did; every branch asserts, so there is no way to pass without + /// checking anything, and an oracle with too few reads is a failure. + /// + /// The oracle gets at least 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 where declining is accepted — and that fails safe: estimated, + /// never over-claimed. A batch refresh in both windows does not rescue + /// a probe that called it live, even one published in two or more + /// steps: the oracle counts a rail as moving only if it changed at 80% + /// or more of its reads, at least five times, and the CPU as moving only + /// if every CPU rail it found did. + #[test] + fn the_ioreport_counter_is_never_selected_on_a_frozen_cpu_rail() { + let status = crate::ioreport_status(); + let constructible = MacosIoReportCounter::try_detect().is_ok(); + let selected = crate::detect().name() == "ioreport"; + let run = oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3); + // One line, so a CI log says which branch below was actually asserted. + eprintln!( + "ioreport liveness: status={status:?} oracle={:?} selected={selected} \ + (oracle: {} reads over {:?} with a helper thread burning CPU; \ + changes per CPU rail {:?}, per GPU rail {:?}, of {} compared pairs)", + run.verdict, run.reads, run.took, run.cpu_changes, run.gpu_changes, run.compared + ); + run.assert_evidence(); + match run.verdict { + Oracle::NoIoReport => { + assert!(!selected, "IOReport selected (status={status:?}) but no subscription opens"); + assert_eq!(status, IoReportStatus::Unavailable); + } + Oracle::NoCpuRail => { + assert!(!selected, "IOReport selected (status={status:?}) but it has no CPU rail"); + assert!( + matches!(status, IoReportStatus::CpuRailAbsent | IoReportStatus::Unavailable), + "the oracle found no CPU rail, the status is {status:?}" + ); + } + Oracle::CpuRailMoved(false) => { + assert!( + !selected, + "IOReport selected (status={status:?}) although its CPU rail did not keep moving \ + under {:?} of CPU burn — it would report GPU-only energy, zero, or a batch \ + refresh as the energy of CPU work", + run.took + ); + assert_eq!( + status, + IoReportStatus::CpuRailFrozen, + "the oracle did not see the CPU rail keep moving under {:?} of CPU burn", + run.took + ); + } + Oracle::CpuRailMoved(true) => { + // A live rail. The probe had a third of the oracle's time: if it + // saw the rail keep advancing the counter must be selected; if + // not, declining is the safe side. + assert!( + matches!(status, IoReportStatus::Live | IoReportStatus::CpuRailFrozen), + "the oracle saw every CPU rail keep moving but the status is {status:?}" + ); + assert_eq!(selected, status == IoReportStatus::Live, "status={status:?}"); + } + } + // On macOS IOReport is the first rung, so detect() picks it exactly when + // try_detect() constructs it, and that is exactly when the status is Live. + assert_eq!(selected, constructible, "detect() and try_detect() disagree"); + assert_eq!(constructible, status == IoReportStatus::Live); + } + + /// The dangerous direction on its own, against the production + /// composition run fresh (uncounted, uncached) beside the cached status: + /// it never reports Live for a rail the oracle did not see keep moving, + /// and on every host both agree with the oracle about what exists. + #[test] + fn the_liveness_probe_never_reports_a_frozen_rail_as_live() { + let run = production_probe(); + let probed = run.opened.then(|| run.verdict()); + let cached = crate::ioreport_status(); + let oracle = oracle_cpu_rail(crate::CPU_RAIL_LIVENESS_BUDGET * 3); + eprintln!( + "ioreport probe: probed={probed:?} cached={cached:?} intervals={} over {:?} sample calls={} \ + ticks={} advances per CPU rail {:?}; oracle={:?} ({} reads over {:?}, changes per CPU rail {:?}, \ + per GPU rail {:?}, of {} compared pairs)", + run.watch.intervals, + run.watch.window, + run.calls.len(), + run.ticks, + run.watch.rails, + oracle.verdict, + oracle.reads, + oracle.took, + oracle.cpu_changes, + oracle.gpu_changes, + oracle.compared + ); + oracle.assert_evidence(); + if probed == Some(Some(CpuRail::Live)) || cached == IoReportStatus::Live { + assert_eq!( + oracle.verdict, + Oracle::CpuRailMoved(true), + "the probe said Live (probed={probed:?}, cached={cached:?}), the oracle did not see \ + the CPU rail keep moving" + ); + } + match oracle.verdict { + Oracle::NoIoReport => { + assert!(matches!(probed, None | Some(None)), "the oracle could not open IOReport, the probe did"); + assert_eq!(cached, IoReportStatus::Unavailable); + } + Oracle::NoCpuRail => assert!( + matches!(probed, None | Some(None) | Some(Some(CpuRail::Absent))) + && matches!(cached, IoReportStatus::CpuRailAbsent | IoReportStatus::Unavailable), + "the oracle found no CPU rail, the probe reported {probed:?} (cached={cached:?})" + ), + Oracle::CpuRailMoved(false) => assert!( + probed == Some(Some(CpuRail::Frozen)) && cached == 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 every CPU rail keep moving but the probe reported {probed:?}" + ), + } + } + + /// The production probe burns CPU between samples, and exactly its tick + /// of it, pinned per tick on the probe's own records of this thread's + /// CPU clock (identified as such by the thread-clock test) so host load + /// cannot fail it: the CPU the thread consumed between the end of one + /// sample call and the start of the next must be at least half the + /// 25 ms tick (a probe that sleeps, or does nothing, between samples + /// burns ~0) and at most twice it (a slower cadence, e.g. a 100 ms tick, + /// burns more). And the tick CPU the probe recorded is checked against + /// the tests' own reading of the thread's CPU clock over the whole probe, + /// and every thread-clock reading the probe recorded must lie between the + /// tests' own readings of this thread's CPU clock just before and just + /// after it: the clock the production composition hands the probe is + /// this thread's CPU clock, not the wall clock or the process's. + #[test] + fn the_probe_burns_its_tick_of_thread_cpu_between_samples() { + let own0 = own_thread_cpu(); + let started = Instant::now(); + let run = production_probe(); + let own1 = own_thread_cpu(); + let own = own1.saturating_sub(own0); + let took = started.elapsed(); + for (i, c) in run.calls.iter().enumerate() { + assert!( + own0 <= c.cpu_before && c.cpu_before <= c.cpu_after && c.cpu_after <= own1, + "sample call {i} recorded the probe's thread clock at {:?}..{:?}, outside this thread's own CPU \ + clock readings {own0:?}..{own1:?} around the probe: the production probe is not timing this \ + thread's CPU", + c.cpu_before, + c.cpu_after + ); + } + if !run.opened { + let status = crate::ioreport_status(); + eprintln!("ioreport probe tick: no Energy Model subscription opens here (status={status:?}): no probe to pin"); + assert_eq!(status, IoReportStatus::Unavailable); + assert!(run.calls.is_empty() && run.ticks == 0, "{run:?}"); + return; + } + let ticks: Vec = + run.calls.windows(2).map(|w| w[1].cpu_before.saturating_sub(w[0].cpu_after)).collect(); + let recorded: Duration = ticks.iter().sum(); + eprintln!( + "ioreport probe tick: {} ticks of this thread's CPU between {} sample calls: min {:?}, median {:?}, \ + max {:?} (specified 25 ms); the tests' own reading of the thread's CPU clock over the whole \ + probe: {own:?} in {took:?} of wall time", + ticks.len(), + run.calls.len(), + ticks.iter().min(), + (!ticks.is_empty()).then(|| median(&ticks)), + ticks.iter().max() + ); + assert!(ticks.len() >= 2, "the probe made only {} sample calls", run.calls.len()); + assert_eq!(run.ticks as usize, ticks.len(), "every tick runs between two sample calls"); + for (i, t) in ticks.iter().enumerate() { + assert!( + *t >= TICK / 2, + "tick {i} burned only {t:?} of this thread's CPU (every tick: {ticks:?}): the probe must burn \ + CPU between samples, not wait or skip. A tick spins until the thread's CPU clock has \ + advanced 25 ms, so host load cannot shorten it; only its 500 ms wall cap can, on a host \ + that gives this thread under 5% of a core" + ); + assert!( + *t <= TICK * 2, + "tick {i} burned {t:?} of this thread's CPU (every tick: {ticks:?}): the tick is 25 ms of CPU" + ); + } + assert!( + own >= recorded, + "the probe recorded {recorded:?} of tick CPU, but this thread's CPU clock advanced only {own:?} \ + over the whole probe" + ); + } + + /// The production probe samples the Energy Model group and nothing else, + /// pinned by structure, not time: every sample it took held exactly the + /// Energy Model group's channels — as many as the oracle's all-channel + /// read counts in that group, far fewer than every channel — and every + /// one of them is in the group. And, second, what a sample costs the + /// probing thread in CPU: the group takes ~6-9 ms of it on an M5 Max and + /// an M3 Ultra, every channel ~80-120 ms. No wall-clock bound: another + /// process's all-channel read can hold IOReport, and a group sample + /// then waits in wall time without costing this thread CPU. + #[test] + fn the_probe_samples_only_the_energy_model_group() { + let Some(oracle) = OracleSub::open() else { + let run = production_probe(); + eprintln!("ioreport probe structure: no IOReport subscription opens here; probe opened={}", run.opened); + assert!(!run.opened && run.calls.is_empty(), "the oracle cannot open IOReport, the probe did: {run:?}"); + assert_eq!(crate::ioreport_status(), IoReportStatus::Unavailable); + return; + }; + let (all, group) = oracle.shape().expect("an oracle read"); + let run = production_probe(); + let taken: Vec<&SampleCall> = run.calls.iter().filter(|c| c.shape.is_some()).collect(); + let mut shapes: Vec<(usize, bool)> = taken.iter().filter_map(|c| c.shape).collect(); + shapes.dedup(); + let cpus: Vec = run.calls.iter().map(|c| c.cpu_after.saturating_sub(c.cpu_before)).collect(); + let walls: Vec = run.calls.iter().map(|c| c.wall).collect(); + eprintln!( + "ioreport probe structure: {} of {} sample calls took a sample, shaped (channels, all Energy Model) \ + {shapes:?}; the Energy Model group is {group} of the {all} channels in the oracle's all-channel \ + read; thread CPU per sample call: median {:?} (limit 50 ms), max {:?}; wall per call (not \ + asserted): median {:?}, max {:?}", + taken.len(), + run.calls.len(), + (!cpus.is_empty()).then(|| median(&cpus)), + cpus.iter().max(), + (!walls.is_empty()).then(|| median(&walls)), + walls.iter().max() + ); + assert!(run.opened, "the oracle's all-channel subscription opened, the probe's group subscription did not"); + assert!(taken.len() >= 2, "the probe took only {} samples", taken.len()); + assert!( + group > 0 && group * 10 <= all, + "the oracle counts {group} Energy Model channels of {all}: the group is not far below every channel" + ); + for (i, c) in taken.iter().enumerate() { + assert_eq!( + c.shape, + Some((group, true)), + "sample {i} held {:?} (channels, all in the Energy Model group), the group is {group} of {all} \ + channels: the probe must sample its own subscription to the Energy Model group, not every \ + channel", + c.shape + ); + } + let cost = median(&cpus); + assert!( + cost < Duration::from_millis(50), + "the probe's sample calls took a median {cost:?} of this thread's CPU (every call: {cpus:?}): the \ + Energy Model group costs ~6-9 ms, every channel ~80-120 ms" + ); + } + + /// The probe's sampler reads each CPU rail's true value. Around each of + /// three samples from the sampler the probe uses, on the probe's own + /// Energy Model subscription, the oracle reads the same rail names + /// (Energy Model group, absolute) just before and just after. A frozen + /// rail is constant, so where the oracle saw a rail hold across its two + /// reads the sampler's value must EQUAL it; where the rail moved, the + /// sampler's value must lie between the oracle's reads. The sampler must + /// also list exactly the oracle's CPU rails. A sampler that returned a + /// constant 0, a sum of rails, or another rail's value fails here. + #[test] + fn the_probe_sampler_reads_each_cpu_rail_as_the_oracle_does() { + let Some(state) = MacosIoReportCounter::open_energy_model() else { + let status = crate::ioreport_status(); + eprintln!("ioreport sampler: no Energy Model subscription opens here (status={status:?})"); + assert_eq!(status, IoReportStatus::Unavailable); + return; + }; + let oracle = OracleSub::open().expect("the probe's Energy Model subscription opened, the oracle's did not"); + let oracle_rails = || -> Vec<(String, i64)> { + oracle + .read() + .expect("an oracle read") + .into_iter() + .filter(|c| c.group == "Energy Model" && c.name.ends_with("CPU Energy")) + .map(|c| (c.name, c.raw)) + .collect() + }; + let names = |v: &[(String, i64)]| v.iter().map(|(n, _)| n.clone()).collect::>(); + let mut seen = Vec::new(); + for _ in 0..3 { + let before = oracle_rails(); + let sampled: Vec<(String, i64)> = state + .group_sample() + .expect("the probe's sampler returned no sample") + .values + .into_iter() + .filter(|(n, _)| n.ends_with("CPU Energy")) + .collect(); + let after = oracle_rails(); + assert_eq!(names(&before), names(&after), "the oracle's CPU rails changed between two reads"); + assert_eq!( + names(&sampled), + names(&before), + "the probe's sampler does not list the CPU rails the oracle reads" + ); + for ((name, got), ((_, lo), (_, hi))) in sampled.iter().zip(before.iter().zip(&after)) { + if lo == hi { + assert_eq!( + got, lo, + "{name} held at {lo} across the oracle's reads either side of the sample, and the probe's \ + sampler read {got}" + ); + } else { + assert!( + (*lo.min(hi)..=*lo.max(hi)).contains(got), + "{name} moved {lo} -> {hi} across the oracle's reads either side of the sample, and the \ + probe's sampler read {got}, outside them" + ); + } + seen.push(format!("{name}: oracle {lo}..{hi}, sampler {got}")); + } + } + eprintln!("ioreport sampler: {} CPU-rail values checked against the oracle: {seen:?}", seen.len()); + } + + /// What the production composition hands the rule is what the rails + /// hold. The oracle reads the Energy Model group's CPU and GPU energy + /// rails, the production probe runs (uncounted, uncached), and the + /// oracle reads again. In every sample the probe took: its rails are + /// exactly the oracle's; a CPU rail the oracle saw hold must EQUAL the + /// oracle's value, and one that moved must lie within the oracle's two + /// reads; so must GPU Energy, which moves on these hosts, so a sampler + /// replaying a sample from before the bracket fails. And where the + /// oracle saw GPU Energy change across the bracket, the probe's samples + /// must show it changing too, so a sampler replaying its own first + /// sample fails; where the oracle saw it hold, that is not asserted, and + /// the line says so. + #[test] + fn the_production_probe_hands_the_rule_what_the_oracle_reads() { + let Some(oracle) = OracleSub::open() else { + let run = production_probe(); + eprintln!("ioreport freshness: no IOReport subscription opens here; probe opened={}", run.opened); + assert!(!run.opened && run.calls.is_empty(), "the oracle cannot open IOReport, the probe did: {run:?}"); + return; + }; + let before = oracle.energy_rails(); + let run = production_probe(); + let after = oracle.energy_rails(); + let keys = |v: &[(RailKey, i64)]| { + let mut k: Vec = v.iter().map(|(k, _)| k.clone()).collect(); + k.sort(); + k + }; + assert_eq!(keys(&before), keys(&after), "the oracle's rails changed between its two reads"); + let bracket = |key: &RailKey| -> (i64, i64) { + let find = |v: &[(RailKey, i64)]| v.iter().find(|(k, _)| k == key).map(|(_, raw)| *raw); + (find(&before).expect("a rail the oracle read"), find(&after).expect("a rail the oracle read")) + }; + assert!(run.opened, "the oracle's all-channel subscription opened, the probe's group subscription did not"); + let handed: Vec<&Vec<(RailKey, i64)>> = run.calls.iter().filter_map(|c| c.handed.as_ref()).collect(); + assert!(handed.len() >= 2, "the probe took only {} samples", handed.len()); + for (i, sample) in handed.iter().enumerate() { + assert_eq!( + keys(sample), + keys(&before), + "sample {i}: the production probe handed the rule these CPU and GPU rails, the oracle reads others" + ); + for (key, got) in sample.iter() { + let (lo, hi) = bracket(key); + if lo == hi { + assert_eq!( + *got, lo, + "sample {i}: {key:?} held at {lo} across the oracle's reads either side of the production \ + probe, and the probe handed the rule {got}" + ); + } else { + assert!( + (lo.min(hi)..=lo.max(hi)).contains(got), + "sample {i}: {key:?} moved {lo} -> {hi} across the oracle's reads either side of the \ + production probe, and the probe handed the rule {got}, outside them" + ); + } + } + } + let mut lines = Vec::new(); + for (key, lo) in before.iter().filter(|((name, _), _)| name.ends_with("GPU Energy")) { + let hi = bracket(key).1; + let mut values: Vec = + handed.iter().filter_map(|s| s.iter().find(|(k, _)| k == key).map(|(_, v)| *v)).collect(); + values.dedup(); + if *lo != hi { + assert!( + values.len() >= 2, + "{key:?} moved {lo} -> {hi} across the oracle's reads, but every one of the {} samples the \ + production probe took read {values:?}: its sampler is not sampling", + handed.len() + ); + lines.push(format!("{}: moved {lo} -> {hi}, the probe read {} distinct values within it", key.0, values.len())); + } else { + lines.push(format!("{}: held at {lo} across the bracket, so freshness is NOT asserted on it", key.0)); + } + } + let cpu: Vec = before + .iter() + .filter(|((name, _), _)| name.ends_with("CPU Energy")) + .map(|(key, lo)| format!("{}: {lo}..{}", key.0, bracket(key).1)) + .collect(); + eprintln!( + "ioreport freshness: {} samples from the production probe, each handing the rule the oracle's rails; \ + CPU rails (oracle before..after) {cpu:?}; {lines:?}", + handed.len() + ); + assert!(!lines.is_empty(), "the oracle read no GPU Energy rail in the Energy Model group"); + } + + /// A rise of `raw` in `unit`, in whole µJ, rounded down, by the oracle's + /// own table: `None` for a unit it has no exact conversion for. A fall + /// is no rise. + fn exact_uj(raw: i64, unit: &str) -> Option { + let raw = raw.max(0) as u64; + match unit { + "pJ" => Some(raw / 1_000_000), + "nJ" => Some(raw / 1_000), + "uJ" | "µJ" => Some(raw), + "mJ" => Some(raw.saturating_mul(1_000)), + "J" => Some(raw.saturating_mul(1_000_000)), + _ => None, + } + } + + /// Every Energy Model channel the counter sums (by [`counts_once`]), as + /// the oracle reads it: ((name, ordinal), (unit, absolute raw)). + fn summed_rails(oracle: &OracleSub) -> Vec<(RailKey, (String, i64))> { + let (_, chans) = oracle.read_where(|group, _| group == "Energy Model").expect("an oracle read"); + oracle_keyed(chans.into_iter().filter(|c| counts_once(&c.name)).map(|c| (c.name, (c.unit, c.raw)))) + } + + /// What the summed rails rose from one oracle read to another, rail by + /// rail, rounded down to whole µJ per rail as the counter rounds. + fn rose_uj(from: &[(RailKey, (String, i64))], to: &[(RailKey, (String, i64))]) -> u64 { + assert_eq!( + from.iter().map(|(k, _)| k).collect::>(), + to.iter().map(|(k, _)| k).collect::>(), + "the summed rails changed between two oracle reads" + ); + from.iter() + .zip(to) + .map(|((key, (unit, a)), (_, (_, b)))| { + exact_uj(b - a, unit).unwrap_or_else(|| panic!("{key:?} is in {unit:?}, which the oracle cannot convert")) + }) + .sum() + } + + /// The counter's own read path, which a declined host never runs: built + /// past the liveness gate on purpose (given `Live`), it must report + /// exactly what its rails did. The oracle reads every rail the counter + /// sums (its own subscription to the Energy Model group, absolute + /// values, ~7 ms a read) just before and just after the counter's + /// baseline read, and again just before and just after its second read, + /// holding the window between them open for at least 3 s and until the + /// summed rails rose (GPU Energy moves on these hosts), for at most 5 s. + /// The rails are cumulative, so the counter's delta must lie between + /// their rise from the second oracle read to the third (inside the + /// counter's window) and from the first to the fourth (around it) — + /// rail by rail, rounded down as the counter rounds. Nothing moved: + /// 0 == 0, still asserted. A counter that left a moving rail out lands + /// below the bracket. The bracket is wider than the window by the + /// counter's own two reads (every channel: 0.1 s alone, over 1 s while + /// other processes hold IOReport), so one that summed a moving rail + /// twice lands above it only while those two reads together take less + /// than the window. + #[test] + fn the_counter_advances_when_a_rail_it_sums_advanced() { + let counter = match MacosIoReportCounter::try_detect_given(IoReportStatus::Live) { + Ok(counter) => counter, + Err(e) => { + let selected = crate::detect().name() == "ioreport"; + eprintln!("ioreport counter: no IOReport subscription opens here ({e:?}); selected={selected}"); + assert!(matches!(e, Error::NoCounter), "declined with {e:?}"); + assert!(OracleSub::open().is_none(), "the counter's all-channel subscription did not open, the oracle's did"); + assert!(!selected, "detect() selected an IOReport counter that does not open"); + return; + } + }; + let oracle = OracleSub::open_group().expect("the counter's subscription opened, the oracle's group one did not"); + let o1 = summed_rails(&oracle); + let base = counter.read_microjoules().expect("the counter's baseline read"); + let o2 = summed_rails(&oracle); + let started = Instant::now(); + let mut o3 = summed_rails(&oracle); + while started.elapsed() < Duration::from_secs(3) + || (rose_uj(&o2, &o3) == 0 && started.elapsed() < Duration::from_secs(5)) + { + std::thread::sleep(Duration::from_millis(20)); + o3 = summed_rails(&oracle); + } + let after = counter.read_microjoules().expect("the counter's second read"); + let o4 = summed_rails(&oracle); + let (inner, outer) = (rose_uj(&o2, &o3), rose_uj(&o1, &o4)); + let delta = after.saturating_sub(base); + let moved: Vec = o1 + .iter() + .zip(&o4) + .filter(|((_, (_, a)), (_, (_, b)))| a != b) + .map(|((key, (unit, a)), (_, (_, b)))| format!("{} +{} {unit}", key.0, b - a)) + .collect(); + eprintln!( + "ioreport counter: {} rails summed; across the bracket they rose {inner} µJ inside the counter's \ + window and {outer} µJ around it (moved: {moved:?}); the counter read {base} -> {after} µJ = {delta} µJ", + o1.len() + ); + assert!(after >= base, "the counter went backwards: {base} -> {after} µJ"); + assert!( + (inner..=outer).contains(&delta), + "the counter read {delta} µJ over its window, but the rails it sums rose {inner} µJ inside that window \ + and {outer} µJ around it (moved: {moved:?}): it did not sum what its rails did" + ); + } + + /// When the counter IS available, a CPU burn meters as non-zero energy + /// through a rail that moves. When it is not, the status is never `Live`, + /// and what [`crate::detect`] falls back to meters real work as no figure + /// at all — its reads fail, or it is the stub, whose zero every consumer + /// treats as no reading — so each caller keeps its own estimate: never a + /// hardware-tagged number, and never a zero passed off as a reading. + #[test] + fn a_selected_ioreport_counter_meters_cpu_work() { + match MacosIoReportCounter::try_detect() { + Ok(c) => { + assert_eq!( + crate::ioreport_status(), + IoReportStatus::Live, + "try_detect() constructed the counter although the status is not Live" + ); + let rail = production_probe().verdict(); + assert_eq!(rail, Some(CpuRail::Live), "counter available but its CPU rail is {rail:?}"); + let _ = c.read_microjoules(); // first read only sets the baseline sample + let before = c.read_microjoules().unwrap(); + spin(Duration::from_millis(200)); + let after = c.read_microjoules().unwrap(); + assert!( + after > before, + "a live CPU rail metered 200 ms of CPU work as zero: before={before} after={after}" + ); + } + Err(e) => { + let status = crate::ioreport_status(); + assert!(matches!(e, Error::NoCounter), "declined with {e:?}, expected NoCounter"); + assert_ne!(status, IoReportStatus::Live, "status Live but try_detect() declined"); + let fallback = crate::detect(); + let before = fallback.read_microjoules(); + spin(Duration::from_millis(100)); + let after = fallback.read_microjoules(); + eprintln!( + "ioreport declined ({status:?}): fallback={} read {before:?} then {after:?} across \ + 100 ms of CPU work", + fallback.name() + ); + assert_ne!(fallback.name(), "ioreport", "detect() fell back to the declined counter"); + if fallback.name() == "stub" { + assert!(matches!((&before, &after), (Ok(0), Ok(0))), "the stub read {before:?}, {after:?}"); + } else { + assert!( + before.is_err() && after.is_err(), + "the fallback counter {} returned a figure ({before:?}, {after:?}) though no \ + hardware counter measured the work", + fallback.name() + ); + } + } } - std::hint::black_box(acc); - let _ = c.read_microjoules(); - let after = c.read_microjoules().unwrap(); - assert!(after > before, "energy must advance: before={before} after={after}"); } } } @@ -367,41 +3527,851 @@ mod macos_ioreport { #[cfg(target_os = "macos")] pub use macos_ioreport::MacosIoReportCounter; -#[cfg(target_os = "linux")] +/// Why the IOReport counter was or was not made available on this host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IoReportStatus { + /// No subscription to IOReport's Energy Model group could be opened (not + /// macOS, not Apple Silicon, Rosetta, or no such group), or one opened but + /// never yielded two usable samples, so nothing could be learned about the + /// CPU rail. + Unavailable, + /// IOReport's Energy Model group was sampled, and no `CPU Energy` rail was + /// in it — an unfamiliar channel naming. The counter is not used. + CpuRailAbsent, + /// A CPU energy rail exists but did not keep advancing while CPU was + /// burned: it held, or stepped only in a burst (a refresh, which macOS 27 + /// can publish in two or more steps a few tens of milliseconds apart). On + /// a multi-die part, one die's frozen rail is enough, even beside a live + /// one: the counter sums every die, and would claim the frozen die's + /// energy as measured. + /// The platform is not exposing live CPU energy to this process, so using + /// the counter would report GPU-only energy, zero, or a stale refresh as + /// the energy of CPU work. Observed on macOS 27.0.1 on both an M5 Max and + /// an M3 Ultra. + CpuRailFrozen, + /// Every CPU energy rail (each die's, on a multi-die part) kept advancing + /// under load — each in at least five separate sample intervals, at least + /// 80% of those observed, over at least 300 ms of wall time; the counter is + /// available. + Live, +} + +/// How long, in WALL time, the liveness probe keeps sampling before it +/// concludes the CPU rail is not live. It spends that second burning CPU on +/// the calling thread in ticks of 25 ms of that thread's own CPU time, +/// sampling the rail after each (a tick that cannot get its CPU ends after +/// 500 ms of wall time). A loaded host gives the probing thread less of a core +/// in the same second, so the probe sees fewer intervals (on an M5 Max under +/// 1 × ncpu busy spinners, often half as many or fewer), and a live rail has +/// fewer chances to show a sustained advance: five advancing intervals, at +/// least 80% of those observed, over at least 300 ms. That fails safe: +/// declined, so callers estimate; never over-claimed. If sampling is so slow +/// that fewer than five intervals fit in the budget, the probe keeps going +/// until five have, and never past five budgets. A live rail ends the probe +/// early, after 300 ms at the least. 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. +/// +/// `MacosIoReportCounter::try_detect` (and so [`detect`]) makes the IOReport +/// counter available only when this is [`IoReportStatus::Live`]. The first +/// call on macOS burns CPU on the calling thread for about +/// `CPU_RAIL_LIVENESS_BUDGET` of wall time (less when the rail proves live, +/// though never under 300 ms; at most five budgets). Exposed so a caller or CLI can say *why* an Apple +/// Silicon host is not metered by IOReport. +pub fn ioreport_status() -> IoReportStatus { + #[cfg(target_os = "macos")] + { + // Test builds only: a status a test installed on this thread stands in + // for the cached one. + #[cfg(test)] + if let Some(status) = macos_ioreport::script::status() { + return status; + } + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(macos_ioreport::host_status) + } + #[cfg(not(target_os = "macos"))] + { + IoReportStatus::Unavailable + } +} + +/// Test support, not API: how many times the liveness probe behind +/// [`ioreport_status`] has run in this process (always 0 off macOS). The +/// cache must keep it at 1 however many threads ask, construct or select. +#[doc(hidden)] +pub fn liveness_probe_runs() -> usize { + #[cfg(target_os = "macos")] + { + macos_ioreport::HOST_STATUS_RUNS.load(std::sync::atomic::Ordering::SeqCst) + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + +/// Test support, not API: how many times this process has opened the +/// liveness probe's Energy Model sampler, whoever called it (always 0 off +/// macOS). A probe run outside the cache opens one too. +#[doc(hidden)] +pub fn liveness_sampler_opens() -> usize { + #[cfg(target_os = "macos")] + { + macos_ioreport::SAMPLER_OPENS.load(std::sync::atomic::Ordering::SeqCst) + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + + +/// The liveness of a single vendor energy counter rung — RAPL's package +/// counter, NVML's total-energy counter — as its probe found it on this host, +/// by the rule IOReport's CPU rail is held to. Probed once per process and +/// cached; see [`rapl_status`] and [`nvml_status`]. Only +/// [`CounterStatus::Live`] admits the rung: a live vendor counter's reading is +/// a measurement, and nothing else from it is. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CounterStatus { + /// The counter could not be opened or read — not this platform, the + /// feature not built, no such device, no RAPL zone named `package-` + /// (a host that lists only `psys`), a locked-down sysfs (`energy_uj` is + /// root-only on most current kernels) — or it gave fewer than two usable + /// samples in five budgets. Never selected. + Unavailable, + /// The counter read, but did not keep advancing while the probe ran: it + /// held, or was republished in lumps. Never selected: a lump landing in + /// one caller's span would be sealed as that span's reading. + Frozen, + /// The counter kept advancing while the probe ran: in at least five + /// separate sample intervals, at least 80% of those observed, over at + /// least 300 ms. The rung is selected. + Live, +} + +/// How long, in wall time, a vendor counter's liveness probe watches a counter +/// that is not live before declining it: one second, as +/// `CPU_RAIL_LIVENESS_BUDGET`. A live counter is called live about 300 ms in. +/// Paid once per process, and only where the counter opens. +#[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs +const VENDOR_COUNTER_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000); + +/// This host's RAPL status, probed once per process and cached. +/// +/// [`detect`] selects the RAPL rung only when this is [`CounterStatus::Live`]: +/// every package counter — each top-level powercap zone whose `name` reads +/// `package-`, so each socket of a multi-socket server, and never `psys`, +/// `dram` or a sub-zone, wherever it sits — read, and each 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 counter's figure sums them +/// all, so one that holds, or is republished in lumps (a virtualised powercap +/// tree can do either), declines the rung. Where there is no package zone, or +/// a package counter is unreadable, it is [`CounterStatus::Unavailable`] +/// without burning anything. Always `Unavailable` off Linux. The first call on +/// a host whose counters read burns about 300 ms of the calling thread's CPU +/// (a second if they are not live); the package set it watched is cached +/// beside the status, and is the set the counter sums. +pub fn rapl_status() -> CounterStatus { + #[cfg(target_os = "linux")] + { + linux_rapl::host().status + } + #[cfg(not(target_os = "linux"))] + { + CounterStatus::Unavailable + } +} + +/// This host's NVML status, probed once per process and cached. Always +/// [`CounterStatus::Unavailable`] without the `cuda` feature. +/// +/// [`detect`] selects the NVML rung only when this is [`CounterStatus::Live`]: +/// GPU 0's total-energy counter kept advancing, in at least five sample +/// intervals, at least 80% of them, over at least 300 ms. The probe cannot +/// load the GPU, so it waits between samples rather than burning CPU, and a +/// powered GPU's own draw advances a live counter; this shows the counter is +/// published continuously, not that it tracks a load. One that holds or steps +/// in lumps is declined. The first call where NVML opens waits about half a +/// second (a second if the counter is not live). +pub fn nvml_status() -> CounterStatus { + #[cfg(feature = "cuda")] + { + static STATUS: std::sync::OnceLock = std::sync::OnceLock::new(); + *STATUS.get_or_init(nvml::host_status) + } + #[cfg(not(feature = "cuda"))] + { + CounterStatus::Unavailable + } +} + +// Compiled for tests on every platform, so its zone listing, sampler, counter +// and gate run under test on a Mac too, over fake powercap directories; +// selected only on Linux. +#[cfg(any(target_os = "linux", test))] +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] mod linux_rapl { use super::*; + use crate::rail_liveness; + use std::path::{Path, PathBuf}; + use std::sync::Mutex; + use std::time::Duration; - /// Reads Intel RAPL counters via the powercap sysfs interface. - pub struct LinuxRaplCounter { - path: std::path::PathBuf, + /// Where the kernel lists its powercap zones, RAPL's among them. + pub(crate) const POWERCAP_ROOT: &str = "/sys/class/powercap"; + + /// One RAPL package zone: a top-level `intel-rapl:` zone whose + /// `name` reads `package-`. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub(crate) struct Package { + /// `` in `intel-rapl:`. + pub(crate) index: u32, + /// The zone's `max_energy_range_uj`, the value its `energy_uj` wraps + /// at, when that reads as a number above zero. + pub(crate) max_energy_range_uj: Option, } - impl LinuxRaplCounter { - /// Try to construct a RAPL counter pointing at the package-0 domain. - pub fn try_detect() -> Result { - let path = std::path::PathBuf::from( - "/sys/class/powercap/intel-rapl:0/energy_uj", - ); - if path.exists() { - Ok(Self { path }) - } else { - Err(Error::NoCounter) + /// Whether a top-level zone's `name` file names a package: `package-`, + /// `` one or more digits (the file ends in a newline). Not `psys` (the + /// platform zone, whose energy includes the packages'), `dram`, `core` + /// or `uncore`. + pub(crate) fn is_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 index of a top-level RAPL zone directory, `intel-rapl:`. + /// `None` for anything else: a sub-zone (`intel-rapl:0:0`, a package's + /// core, uncore or dram), the control type itself (`intel-rapl`), and the + /// MMIO interface (`intel-rapl-mmio:0`, also named `package-0`, which + /// republishes a package's energy and would count it twice). + pub(crate) fn top_level_zone(dir: &str) -> Option { + let n = dir.strip_prefix("intel-rapl:")?; + if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + n.parse().ok() + } + + /// Zone `index`'s directory under the powercap directory `root`. + fn zone_dir(root: &Path, index: u32) -> PathBuf { + root.join(format!("intel-rapl:{index}")) + } + + /// The file at `path`, read and parsed as an unsigned integer; `None` when + /// it cannot be. + pub(crate) fn read_u64(path: &Path) -> Option { + std::fs::read_to_string(path).ok()?.trim().parse::().ok() + } + + /// The package zones under the powercap directory `root`, in index order: + /// every top-level zone whose `name` reads `package-`, with the range + /// its counter wraps at. Everything else is left out wherever it sits, + /// zone 0 included: `psys`, a `dram` zone, sub-zones, the MMIO interface. + /// Empty when no zone is a package, or `root` cannot be listed. + pub(crate) fn packages_under(root: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(root) else { + return Vec::new(); + }; + let mut packages: Vec = entries + .flatten() + .filter_map(|entry| { + let index = top_level_zone(entry.file_name().to_str()?)?; + let dir = entry.path(); + let name = std::fs::read_to_string(dir.join("name")).ok()?; + is_package_name(&name).then(|| Package { + index, + max_energy_range_uj: read_u64(&dir.join("max_energy_range_uj")).filter(|&m| m > 0), + }) + }) + .collect(); + packages.sort_by_key(|p| p.index); + packages + } + + /// Every package's `energy_uj` under `root`, in `packages` order. `None` + /// when there is no package, or when any one cannot be read: a figure that + /// left a package out would miss its energy while claiming the whole CPU. + pub(crate) fn read_packages(root: &Path, packages: &[Package]) -> Option> { + if packages.is_empty() { + return None; + } + packages.iter().map(|p| read_u64(&zone_dir(root, p.index).join("energy_uj"))).collect() + } + + /// One zone's energy between two reads, modulo its own wrap. A counter + /// that went down wrapped at `max_range`; without a known (non-zero) + /// range, or with an earlier read above it, the energy is unknown and + /// `None`. Undercounts a wrap by at most one counter unit, and cannot see + /// more than one wrap between two reads: about a quarter of an hour at + /// 300 W for a package's usual 262,143,328,850 µJ range. + pub(crate) fn zone_delta_uj(e0: u64, e1: u64, max_range: Option) -> Option { + if e1 >= e0 { + return Some(e1 - e0); + } + max_range.filter(|&m| m > 0)?.checked_sub(e0)?.checked_add(e1) + } + + /// A RAPL counter's running total across its reads. + #[derive(Debug)] + pub(crate) enum Running { + /// Not read yet. + Unread, + /// Each package's raw `energy_uj` at the last read, and the total so + /// far: the first read's counters summed, plus every package's energy + /// since, each taken modulo its own range. + At { last: Vec, total: u64 }, + /// A package's energy between two reads could not be known: its + /// counter went down and its range is unknown. The total no longer + /// holds that energy, so every read from then on fails rather than + /// hand out a figure short by it. + Lost(String), + } + + impl Running { + /// Fold in `now`, every package's raw counter read in `packages` order, + /// and return the running total. Each package's wrap is taken at ITS + /// OWN range; summing the raw counters instead would turn one + /// package's wrap into a fall of the sum. + pub(crate) fn advance(&mut self, packages: &[Package], now: &[u64]) -> Result { + match self { + Running::Lost(why) => Err(Error::Backend(why.clone())), + Running::Unread => { + let total = now + .iter() + .try_fold(0u64, |sum, &uj| sum.checked_add(uj)) + .ok_or_else(|| Error::Backend("the RAPL package counters' sum overflows u64".into()))?; + *self = Running::At { last: now.to_vec(), total }; + Ok(total) + } + Running::At { last, total } => { + if last.len() != now.len() || packages.len() != now.len() { + return Err(Error::Backend("a RAPL read did not cover every package".into())); + } + match energy_between(packages, last, now).and_then(|energy| { + total.checked_add(energy).ok_or_else(|| "the RAPL running total overflows u64".to_string()) + }) { + Ok(sum) => { + *total = sum; + last.copy_from_slice(now); + Ok(sum) + } + Err(why) => { + *self = Running::Lost(why.clone()); + Err(Error::Backend(why)) + } + } + } } } } + /// Every package's energy between the raw counters `last` and `now`, + /// summed, each taken modulo its own range ([`zone_delta_uj`]); why not, + /// when one package's energy is unknown. + fn energy_between(packages: &[Package], last: &[u64], now: &[u64]) -> std::result::Result { + let mut energy = 0u64; + for ((p, &e0), &e1) in packages.iter().zip(last).zip(now) { + let delta = zone_delta_uj(e0, e1, p.max_energy_range_uj).ok_or_else(|| { + format!( + "RAPL zone intel-rapl:{} went down from {e0} to {e1} uJ with no readable \ + max_energy_range_uj; its energy between the two reads is unknown", + p.index + ) + })?; + energy = energy.checked_add(delta).ok_or_else(|| "RAPL package energy overflows u64".to_string())?; + } + Ok(energy) + } + + /// Reads RAPL's package energy through powercap: the sum over every + /// package zone (`intel-rapl:` named `package-`), each zone's wrap + /// taken at its own `max_energy_range_uj`, as a running total. + /// + /// Constructed only on a host whose package counters the liveness probe + /// found live, every one of them ([`crate::rapl_status`]); a live RAPL + /// counter is a vendor energy counter, so a delta it reads is a + /// measurement. It sums exactly the packages the probe watched. + pub struct LinuxRaplCounter { + root: PathBuf, + packages: Vec, + running: Mutex, + } + + impl LinuxRaplCounter { + /// This host's RAPL package counters, on a host where every one is + /// live. `Err(NoCounter)` when there is no package zone, or one is + /// unreadable or does not keep advancing under load; + /// [`crate::rapl_status`] says which. + pub fn try_detect() -> Result { + Self::try_detect_given(crate::rapl_status()) + } + + /// The constructor behind [`Self::try_detect`], given this host's + /// status: every status the gate does not admit declines. The counter + /// sums the package zones the host's probe watched. + pub(crate) fn try_detect_given(status: CounterStatus) -> Result { + Self::admitted(status, Path::new(POWERCAP_ROOT), host_packages) + } + + /// The counter over `packages` under the powercap directory `root`, + /// when `status` admits it; `packages` is asked for only then. + pub(crate) fn admitted(status: CounterStatus, root: &Path, packages: impl FnOnce() -> Vec) -> Result { + if rail_liveness::admits_vendor_counter(status) { + Ok(Self::over(root, packages())) + } else { + Err(Error::NoCounter) + } + } + + /// The counter over `packages` under `root`, ungated. + pub(crate) fn over(root: &Path, packages: Vec) -> Self { + Self { root: root.to_path_buf(), packages, running: Mutex::new(Running::Unread) } + } + } + impl JouleCounter for LinuxRaplCounter { + /// The running total. Fails — no figure, never a partial one — when + /// there is no package, when any package's counter cannot be read, or + /// once a package's energy between two reads is unknown. The counters + /// are read under the lock, so two threads' reads are folded in the + /// order they were taken, and an older read is never taken for a wrap. fn read_microjoules(&self) -> Result { - let s = std::fs::read_to_string(&self.path) - .map_err(|e| Error::Io(e.to_string()))?; - s.trim() - .parse::() - .map_err(|e| Error::Backend(e.to_string())) + if self.packages.is_empty() { + return Err(Error::NoCounter); + } + let mut running = self.running.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let now = read_packages(&self.root, &self.packages).ok_or_else(|| { + Error::Io(format!("a RAPL package zone's energy_uj under {} could not be read", self.root.display())) + })?; + running.advance(&self.packages, &now) } fn name(&self) -> &'static str { "rapl" } } + + /// The name a package's counter has in a liveness sample: its zone, + /// `intel-rapl:`. + pub(crate) fn rail_name(index: u32) -> String { + format!("intel-rapl:{index}") + } + + /// One liveness sample of the package counters `read` reads, in + /// `packages` order: each package's raw `energy_uj` as its own rail, under + /// its zone's name, so the rule asks EVERY package to keep advancing (the + /// figure sums them all). `None` for a failed read, which the probe + /// skips. A wrap shows as a fall, which is no advance. + pub(crate) fn sample(packages: &[Package], read: impl Fn() -> Option>) -> Option> { + let raw = read()?; + if raw.len() != packages.len() { + return None; + } + Some(packages.iter().zip(raw).map(|(p, uj)| (rail_name(p.index), i64::try_from(uj).unwrap_or(i64::MAX))).collect()) + } + + /// The RAPL probe of `packages` on a given reader. With no package it is + /// `Unavailable`, with nothing read and nothing burned. Otherwise it opens + /// when every package counter reads, and is then sampled by + /// [`rail_liveness::probe_counter`], with `tick` between samples, by the + /// clock `now`: live only when every package counter keeps advancing. + pub(crate) fn status_with( + packages: &[Package], + read: impl Fn() -> Option>, + tick: impl FnMut(), + now: impl Fn() -> Duration, + ) -> CounterStatus { + if packages.is_empty() { + return CounterStatus::Unavailable; + } + rail_liveness::counter_status( + rail_liveness::probe_counter( + || read().map(|_| ()), + |_| sample(packages, &read), + tick, + now, + crate::VENDOR_COUNTER_LIVENESS_BUDGET, + ) + .as_ref(), + ) + } + + /// What one probe found under a powercap directory: its package zones, + /// and whether every one of their counters is live. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) struct Host { + pub(crate) packages: Vec, + pub(crate) status: CounterStatus, + } + + /// One probe of the package zones under `root`: list them + /// ([`packages_under`]), then probe all of their counters together + /// ([`status_with`]). + pub(crate) fn probe_root(root: &Path, tick: impl FnMut(), now: impl Fn() -> Duration) -> Host { + let packages = packages_under(root); + let status = status_with(&packages, || read_packages(root, &packages), tick, now); + Host { packages, status } + } + + /// This host's package zones and their liveness, probed once per process + /// and cached: the real powercap directory, the CPU-burning tick (a + /// package counter must advance while this thread burns CPU) and the + /// monotonic clock. Behind [`crate::rapl_status`] and the counter's + /// constructor, so the counter sums exactly the packages the probe found + /// live. + #[cfg(target_os = "linux")] + pub(crate) fn host() -> &'static Host { + static HOST: std::sync::OnceLock = std::sync::OnceLock::new(); + HOST.get_or_init(|| probe_root(Path::new(POWERCAP_ROOT), crate::clocks::cpu_burn_tick, crate::clocks::wall_now)) + } + + /// The package zones the host's probe watched; none off Linux. + fn host_packages() -> Vec { + #[cfg(target_os = "linux")] + { + host().packages.clone() + } + #[cfg(not(target_os = "linux"))] + { + Vec::new() + } + } + + #[cfg(test)] + mod tests { + use super::*; + use std::cell::Cell; + + /// A package zone's range as the kernel commonly reports it (2^32 + /// units of 61.035 µJ), and a smaller one, so that a wrap taken at the + /// wrong zone's range shows. + const RANGE: u64 = 262_143_328_850; + const RANGE_B: u64 = 65_532_610_987; + + /// A fake powercap directory for one test, removed when dropped. + struct Powercap(PathBuf); + + impl Powercap { + fn new(tag: &str) -> Self { + let root = std::env::temp_dir().join(format!("eoc-meter-powercap-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("create a fake powercap directory"); + Self(root) + } + + /// Add the zone directory `dir` (`intel-rapl:0`, `intel-rapl:0:1`, + /// `intel-rapl-mmio:0`, …) with its `name`, its `energy_uj` and, + /// when given, its `max_energy_range_uj`, newline-terminated as + /// the kernel writes them. + fn zone(&self, dir: &str, name: &str, energy_uj: u64, max: Option) -> &Self { + let d = self.0.join(dir); + std::fs::create_dir_all(&d).expect("create a fake zone"); + std::fs::write(d.join("name"), format!("{name}\n")).expect("write name"); + if let Some(m) = max { + std::fs::write(d.join("max_energy_range_uj"), format!("{m}\n")).expect("write max_energy_range_uj"); + } + self.set(dir, energy_uj); + self + } + + /// Set zone `dir`'s `energy_uj`. + fn set(&self, dir: &str, energy_uj: u64) { + std::fs::write(self.0.join(dir).join("energy_uj"), format!("{energy_uj}\n")).expect("write energy_uj"); + } + + fn root(&self) -> &Path { + &self.0 + } + } + + impl Drop for Powercap { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A two-socket server's powercap directory as the kernel lists it: + /// the control type, two packages each with core or dram sub-zones, + /// a platform (`psys`) zone, a top-level `dram` zone, and the MMIO + /// interface republishing package 0. + fn two_socket_server(tag: &str) -> Powercap { + let pc = Powercap::new(tag); + std::fs::create_dir_all(pc.root().join("intel-rapl")).expect("the control type"); + pc.zone("intel-rapl:0", "package-0", 1_000_000, Some(RANGE)) + .zone("intel-rapl:0:0", "core", 400_000, Some(RANGE)) + .zone("intel-rapl:0:1", "dram", 90_000, Some(RANGE_B)) + .zone("intel-rapl:1", "package-1", 5_000_000, Some(RANGE_B)) + .zone("intel-rapl:1:0", "dram", 70_000, Some(RANGE_B)) + .zone("intel-rapl:2", "psys", 80_000_000, Some(RANGE)) + .zone("intel-rapl:3", "dram", 3_000_000, Some(RANGE_B)) + .zone("intel-rapl-mmio:0", "package-0", 1_000_000, Some(RANGE)); + pc + } + + fn pkg(index: u32, max: u64) -> Package { + Package { index, max_energy_range_uj: Some(max) } + } + + /// The probe over a fake directory, run as the host's probe runs + /// (list, read, the rule), with a tick that sets each zone `step(n)` + /// names at the n-th tick, and a clock that moves 25 ms a tick. + fn probe_tree(pc: &Powercap, step: impl Fn(u64) -> Vec<(&'static str, u64)>) -> Host { + let ticks = Cell::new(0u32); + probe_root( + pc.root(), + || { + ticks.set(ticks.get() + 1); + for (dir, uj) in step(u64::from(ticks.get())) { + pc.set(dir, uj); + } + }, + || Duration::from_millis(25) * ticks.get(), + ) + } + + /// A package zone is named `package-`; `psys`, `dram`, `core` and + /// malformed names are not. A top-level zone is `intel-rapl:`; a + /// sub-zone, the control type and the MMIO interface are not. + #[test] + fn only_a_top_level_zone_named_package_n_is_a_package() { + for name in ["package-0", "package-1\n", "package-12"] { + assert!(is_package_name(name), "{name:?}"); + } + for name in ["psys", "psys\n", "dram", "core", "uncore", "package-", "package-x", "package-0a", "Package-0", ""] { + assert!(!is_package_name(name), "{name:?}"); + } + assert_eq!(top_level_zone("intel-rapl:0"), Some(0)); + assert_eq!(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", "dtpm"] { + assert_eq!(top_level_zone(dir), None, "{dir:?}"); + } + } + + /// A two-socket server: the figure is both packages' energy and + /// nothing else — not `psys` (which includes the packages, so it would + /// count them twice), not a `dram` zone at the top level or under a + /// package, not a core sub-zone, not the MMIO copy of package 0. + #[test] + fn a_two_socket_server_sums_its_two_packages_and_nothing_else() { + let pc = two_socket_server("two-socket"); + let packages = packages_under(pc.root()); + assert_eq!(packages, vec![pkg(0, RANGE), pkg(1, RANGE_B)]); + assert_eq!(read_packages(pc.root(), &packages), Some(vec![1_000_000, 5_000_000])); + let counter = LinuxRaplCounter::over(pc.root(), packages); + let before = counter.read_microjoules().expect("both packages read"); + assert_eq!(before, 1_000_000 + 5_000_000); + for (dir, uj) in [ + ("intel-rapl:0", 1_250_000), + ("intel-rapl:1", 5_400_000), + ("intel-rapl:0:0", 600_000), + ("intel-rapl:0:1", 190_000), + ("intel-rapl:1:0", 170_000), + ("intel-rapl:2", 99_000_000), + ("intel-rapl:3", 3_900_000), + ("intel-rapl-mmio:0", 1_250_000), + ] { + pc.set(dir, uj); + } + let after = counter.read_microjoules().expect("both packages read"); + assert_eq!(after - before, 250_000 + 400_000, "package 0's energy and package 1's, nothing else"); + } + + /// Zone 0 is the platform zone and the package is zone 1: zone 0 is + /// neither summed nor sampled, and the figure is zone 1's. + #[test] + fn a_zone_0_named_psys_is_left_out() { + let pc = Powercap::new("psys-at-0"); + pc.zone("intel-rapl:0", "psys", 70_000_000, Some(RANGE)) + .zone("intel-rapl:1", "package-0", 2_000_000, Some(RANGE)) + .zone("intel-rapl:1:0", "core", 1_000, Some(RANGE)); + let packages = packages_under(pc.root()); + assert_eq!(packages, vec![pkg(1, RANGE)]); + assert_eq!( + sample(&packages, || read_packages(pc.root(), &packages)), + Some(vec![("intel-rapl:1".to_string(), 2_000_000)]), + "the liveness sample holds the package alone" + ); + let counter = LinuxRaplCounter::over(pc.root(), packages); + let before = counter.read_microjoules().expect("the package reads"); + pc.set("intel-rapl:0", 90_000_000); + pc.set("intel-rapl:1", 2_300_000); + assert_eq!(counter.read_microjoules().expect("the package reads") - before, 300_000); + } + + /// A `psys` zone 0 that moves at every tick beside a package that + /// holds: frozen. The platform zone is not the package, and is never + /// watched. + #[test] + fn a_moving_psys_at_zone_0_never_makes_a_held_package_live() { + let pc = Powercap::new("psys-moving"); + pc.zone("intel-rapl:0", "psys", 70_000_000, Some(RANGE)).zone("intel-rapl:1", "package-0", 2_000_000, Some(RANGE)); + let host = probe_tree(&pc, |t| vec![("intel-rapl:0", 70_000_000 + 99_000 * t)]); + assert_eq!(host, Host { packages: vec![pkg(1, RANGE)], status: CounterStatus::Frozen }); + } + + /// Every package must keep advancing, because the figure sums them + /// all: both moving at every tick is live; package 1 holding is + /// frozen, though package 0 and `psys` move at every tick. + #[test] + fn every_package_counter_must_be_live() { + let both = two_socket_server("live-both"); + let host = probe_tree(&both, |t| vec![("intel-rapl:0", 1_000_000 + 40_000 * t), ("intel-rapl:1", 5_000_000 + 35_000 * t)]); + assert_eq!(host, Host { packages: vec![pkg(0, RANGE), pkg(1, RANGE_B)], status: CounterStatus::Live }); + let one = two_socket_server("live-one"); + let host = probe_tree(&one, |t| vec![("intel-rapl:0", 1_000_000 + 40_000 * t), ("intel-rapl:2", 80_000_000 + 99_000 * t)]); + assert_eq!(host.status, CounterStatus::Frozen, "package 1 held its value"); + } + + /// Each package wraps at its own range: package 0 rolls over at RANGE + /// while package 1 advances, then package 1 rolls over at RANGE_B. The + /// energy is each zone's delta modulo its own range, and the total + /// keeps rising across both wraps. + #[test] + fn each_package_wraps_at_its_own_range() { + let pc = Powercap::new("wrap"); + pc.zone("intel-rapl:0", "package-0", RANGE - 1_000, Some(RANGE)) + .zone("intel-rapl:1", "package-1", RANGE_B - 300, Some(RANGE_B)); + let counter = LinuxRaplCounter::over(pc.root(), packages_under(pc.root())); + let r0 = counter.read_microjoules().expect("read"); + pc.set("intel-rapl:0", 500); + pc.set("intel-rapl:1", RANGE_B - 100); + let r1 = counter.read_microjoules().expect("read across package 0's wrap"); + assert_eq!(r1 - r0, (1_000 + 500) + 200); + pc.set("intel-rapl:0", 2_500); + pc.set("intel-rapl:1", 400); + let r2 = counter.read_microjoules().expect("read across package 1's wrap"); + assert_eq!(r2 - r1, 2_000 + (100 + 400)); + // At the other zone's range, package 1's wrap would be off by the + // difference of the two ranges. + assert_eq!(zone_delta_uj(RANGE_B - 100, 400, Some(RANGE_B)), Some(500)); + assert_eq!(zone_delta_uj(RANGE_B - 100, 400, Some(RANGE)), Some(RANGE - RANGE_B + 500)); + } + + /// A package whose range is unknown (absent, or zero): summed while it + /// rises; once it goes down its energy since the last read is + /// unknown, so that read fails, and every later one too, since the + /// total would be short by that energy. + #[test] + fn a_fall_with_no_known_range_ends_the_figure() { + let pc = Powercap::new("no-range"); + pc.zone("intel-rapl:0", "package-0", 10_000, None).zone("intel-rapl:1", "package-1", 20_000, Some(0)); + let packages = packages_under(pc.root()); + assert_eq!(packages, vec![Package { index: 0, max_energy_range_uj: None }, Package { index: 1, max_energy_range_uj: None }]); + let counter = LinuxRaplCounter::over(pc.root(), packages); + let r0 = counter.read_microjoules().expect("read"); + pc.set("intel-rapl:0", 15_000); + pc.set("intel-rapl:1", 21_000); + assert_eq!(counter.read_microjoules().expect("read") - r0, 5_000 + 1_000); + pc.set("intel-rapl:0", 3_000); + assert!(matches!(counter.read_microjoules(), Err(Error::Backend(_)))); + pc.set("intel-rapl:0", 4_000); + assert!(matches!(counter.read_microjoules(), Err(Error::Backend(_))), "a total short by the lost energy"); + assert_eq!(zone_delta_uj(1_000, 10, Some(0)), None, "a zero range is no range"); + } + + /// A package that cannot be read fails the whole read: no figure that + /// leaves a package out. + #[test] + fn an_unreadable_package_fails_the_read() { + let pc = Powercap::new("unreadable"); + pc.zone("intel-rapl:0", "package-0", 10_000, Some(RANGE)).zone("intel-rapl:1", "package-1", 20_000, Some(RANGE)); + let packages = packages_under(pc.root()); + let counter = LinuxRaplCounter::over(pc.root(), packages.clone()); + std::fs::remove_file(pc.root().join("intel-rapl:1/energy_uj")).expect("remove"); + assert_eq!(read_packages(pc.root(), &packages), None); + assert!(matches!(counter.read_microjoules(), Err(Error::Io(_)))); + } + + /// A host whose only zone is the platform (`psys`) has no package: the + /// probe reads and burns nothing and finds RAPL unavailable, and no + /// RAPL counter is constructed; one forced over no package never + /// reads. So too an empty powercap directory, and a missing one. + #[test] + fn a_host_with_only_psys_has_no_rapl_counter() { + let pc = Powercap::new("only-psys"); + pc.zone("intel-rapl:0", "psys", 70_000_000, Some(RANGE)); + let ticks = Cell::new(0u32); + let host = probe_root(pc.root(), || ticks.set(ticks.get() + 1), || Duration::from_millis(25) * ticks.get()); + assert_eq!(host, Host { packages: Vec::new(), status: CounterStatus::Unavailable }); + assert_eq!(ticks.get(), 0, "burned CPU probing a host with no package"); + let asked = Cell::new(false); + let counter = LinuxRaplCounter::admitted(host.status, pc.root(), || { + asked.set(true); + host.packages.clone() + }); + assert!(matches!(counter, Err(Error::NoCounter))); + assert!(!asked.get(), "listed the packages of a declined counter"); + assert!(matches!(LinuxRaplCounter::over(pc.root(), Vec::new()).read_microjoules(), Err(Error::NoCounter))); + let empty = Powercap::new("empty"); + assert_eq!(probe_root(empty.root(), || panic!("ticked"), || Duration::ZERO).status, CounterStatus::Unavailable); + let missing = Path::new("/no/such/powercap"); + assert_eq!(probe_root(missing, || panic!("ticked"), || Duration::ZERO), Host { packages: Vec::new(), status: CounterStatus::Unavailable }); + } + + /// The RAPL probe on scripted reads of `packages`, with a clock that + /// only the tick moves, by 25 ms. + fn scripted(packages: &[Package], read: impl Fn(usize) -> Option>) -> CounterStatus { + let (clock, reads) = (Cell::new(Duration::ZERO), Cell::new(0usize)); + status_with( + packages, + || { + reads.set(reads.get() + 1); + read(reads.get() - 1) + }, + || clock.set(clock.get() + Duration::from_millis(25)), + || clock.get(), + ) + } + + /// A package counter that advances at every read is live; one that + /// holds, steps once, or cannot be read (root-only `energy_uj`) is + /// declined, and so is one that reads only once. Of two packages, + /// both must advance. + #[test] + fn the_rapl_probe_admits_only_counters_that_keep_advancing() { + let one = [pkg(0, RANGE)]; + assert_eq!(scripted(&one, |i| Some(vec![81_000_000 + 1_400_000 * i as u64])), CounterStatus::Live); + assert_eq!(scripted(&one, |_| Some(vec![81_000_000])), CounterStatus::Frozen, "a held value"); + assert_eq!(scripted(&one, |i| Some(vec![if i < 9 { 1 } else { 2 }])), CounterStatus::Frozen, "one step"); + assert_eq!(scripted(&one, |_| None), CounterStatus::Unavailable, "EACCES on every read"); + assert_eq!(scripted(&one, |i| (i == 0).then(|| vec![5])), CounterStatus::Unavailable, "read once, then never"); + let two = [pkg(0, RANGE), pkg(1, RANGE)]; + assert_eq!(scripted(&two, |i| Some(vec![1_000 + 7 * i as u64, 9_000 + 5 * i as u64])), CounterStatus::Live); + assert_eq!(scripted(&two, |i| Some(vec![1_000 + 7 * i as u64, 9_000])), CounterStatus::Frozen, "package 1 held"); + assert_eq!(scripted(&two, |i| Some(vec![1_000, 9_000 + 5 * i as u64])), CounterStatus::Frozen, "package 0 held"); + assert_eq!(scripted(&[], |_| panic!("read a host with no package")), CounterStatus::Unavailable); + } + + /// The sampler hands each package's raw counter as its own rail, and + /// saturates rather than wrapping a value past `i64::MAX`. + #[test] + fn the_rapl_sample_is_each_package_counter_raw() { + let two = [pkg(0, RANGE), pkg(3, RANGE)]; + assert_eq!( + sample(&two, || Some(vec![42, u64::MAX])), + Some(vec![("intel-rapl:0".to_string(), 42), ("intel-rapl:3".to_string(), i64::MAX)]) + ); + assert_eq!(sample(&two, || None), None); + assert_eq!(sample(&two, || Some(vec![42])), None, "a read that missed a package"); + assert_eq!(read_u64(Path::new("/no/such/powercap/energy_uj")), None); + } + + /// Only a live status constructs the counter. + #[test] + fn only_a_live_rapl_status_constructs_the_counter() { + assert!(LinuxRaplCounter::try_detect_given(CounterStatus::Live).is_ok()); + for status in [CounterStatus::Unavailable, CounterStatus::Frozen] { + assert!( + matches!(LinuxRaplCounter::try_detect_given(status), Err(Error::NoCounter)), + "{status:?} constructed the RAPL counter" + ); + } + } + } } #[cfg(target_os = "linux")] @@ -411,19 +4381,17 @@ pub use linux_rapl::LinuxRaplCounter; mod macos_powermetrics { use super::*; - /// Wraps `powermetrics(1)` to estimate cumulative energy. macOS does - /// not expose a cumulative joule counter at user level; this wrapper - /// reports the integral of the most recent `--samplers cpu_power` - /// reading. In practice it requires root, so it usually degrades to - /// `StubCounter` at detection time. + /// Stands in for `powermetrics(1)`. It is detected whenever the binary + /// is on the PATH, but it does not run or parse it (that needs root): + /// every read returns `Err(NoCounter)`, so callers keep their own + /// estimate. It never degrades to `StubCounter`. pub struct MacosPowermetricsCounter; impl MacosPowermetricsCounter { /// Attempt to detect `powermetrics` availability. pub fn try_detect() -> Result { - // We don't require root here — `detect()` will fall back to - // StubCounter if reads fail. We do require the binary to be - // present on the PATH. + // Only the binary's presence on the PATH is checked; reads + // always fail (see `read_microjoules`). let ok = std::process::Command::new("which") .arg("powermetrics") .output() @@ -437,8 +4405,8 @@ mod macos_powermetrics { fn read_microjoules(&self) -> Result { // Real implementation would parse `powermetrics --samplers // cpu_power -i 100 -n 1 --hide-cpu-duty-cycle`. For the - // reference implementation we always error so the cascade - // attaches `JouleSource::Estimated` instead. + // reference implementation we always error, so no consumer + // gets a figure from it and each keeps its own estimate. Err(Error::NoCounter) } fn name(&self) -> &'static str { @@ -453,23 +4421,81 @@ pub use macos_powermetrics::MacosPowermetricsCounter; #[cfg(feature = "cuda")] mod nvml { use super::*; + use crate::rail_liveness; use nvml_wrapper::Nvml; /// Reads cumulative GPU energy via NVML. + /// + /// Constructed only on a host whose total-energy counter the liveness + /// probe found live ([`crate::nvml_status`]); a live NVML counter is a + /// vendor energy counter, so a delta it reads is a measurement of GPU 0. pub struct NvmlCounter { _nvml: Nvml, device_index: u32, } impl NvmlCounter { - /// Attempt to detect NVML and pick GPU 0. + /// NVML on GPU 0, on a host where its counter is live. + /// `Err(NoCounter)` when the counter is not live; NVML's own error + /// when it does not initialise. pub fn try_detect() -> Result { - let nvml = Nvml::init().map_err(|e| Error::Backend(e.to_string()))?; - Ok(Self { - _nvml: nvml, - device_index: 0, - }) + Self::try_detect_given(crate::nvml_status()) } + + /// The constructor behind [`Self::try_detect`], given this host's + /// status: every status the gate does not admit declines before NVML + /// is touched. + pub(crate) fn try_detect_given(status: CounterStatus) -> Result { + if !rail_liveness::admits_vendor_counter(status) { + return Err(Error::NoCounter); + } + Self::open().ok_or(Error::NoCounter) + } + + /// NVML on GPU 0, when it initialises and the counter reads. + fn open() -> Option { + // Test builds only: count the attempt on this thread, so a test can + // pin that a declined status never gets this far. + #[cfg(test)] + OPEN_ATTEMPTS.with(|n| n.set(n.get() + 1)); + let nvml = Nvml::init().ok()?; + nvml.device_by_index(0).ok()?.total_energy_consumption().ok()?; + Some(Self { _nvml: nvml, device_index: 0 }) + } + + /// One liveness sample: the device's total-energy counter as its one + /// rail, in its raw millijoules. `None` for a failed read. + fn sample(&self) -> Option> { + let mj = self._nvml.device_by_index(self.device_index).ok()?.total_energy_consumption().ok()?; + Some(vec![(format!("nvml device {} total energy", self.device_index), i64::try_from(mj).unwrap_or(i64::MAX))]) + } + } + + #[cfg(test)] + thread_local! { + /// Attempts on this thread to initialise NVML for a counter. + static OPEN_ATTEMPTS: std::cell::Cell = const { std::cell::Cell::new(0) }; + } + + /// How long the NVML probe waits between samples. Burning this thread's + /// CPU does not load the GPU, so the probe waits; long enough 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. + const NVML_PROBE_WAIT: std::time::Duration = std::time::Duration::from_millis(100); + + /// The real NVML probe. Cached by [`crate::nvml_status`], its only caller. + pub(crate) fn host_status() -> CounterStatus { + rail_liveness::counter_status( + rail_liveness::probe_counter( + NvmlCounter::open, + NvmlCounter::sample, + || std::thread::sleep(NVML_PROBE_WAIT), + crate::nvml_wall_now, + crate::VENDOR_COUNTER_LIVENESS_BUDGET, + ) + .as_ref(), + ) } impl JouleCounter for NvmlCounter { @@ -488,6 +4514,44 @@ mod nvml { "nvml" } } + + #[cfg(test)] + mod tests { + use super::*; + + /// Only a live status gets as far as initialising NVML: every declined + /// status is `NoCounter` without an attempt to open it, whether or not + /// NVML would open on this host. Live tries, and constructs the + /// counter only where NVML opens. + #[test] + fn only_a_live_nvml_status_constructs_the_counter() { + let attempts = || OPEN_ATTEMPTS.with(|n| n.get()); + for status in [CounterStatus::Unavailable, CounterStatus::Frozen] { + let before = attempts(); + assert!( + matches!(NvmlCounter::try_detect_given(status), Err(Error::NoCounter)), + "{status:?} constructed the NVML counter" + ); + assert_eq!(attempts(), before, "under {status:?} the constructor tried to open NVML"); + } + let opens = NvmlCounter::open().is_some(); + let before = attempts(); + assert_eq!(NvmlCounter::try_detect_given(CounterStatus::Live).is_ok(), opens); + assert_eq!(attempts(), before + 1, "given Live, the constructor did not try to open NVML"); + eprintln!("nvml: opens={opens} status={:?}", crate::nvml_status()); + if !opens { + assert_eq!(crate::nvml_status(), CounterStatus::Unavailable, "a status for an NVML that does not open"); + } + } + } +} + +/// Wall time on a monotonic clock, from a fixed origin in this process: the +/// NVML probe's clock (it needs no thread CPU clock, and builds on every OS). +#[cfg(feature = "cuda")] +fn nvml_wall_now() -> std::time::Duration { + static ORIGIN: std::sync::OnceLock = std::sync::OnceLock::new(); + ORIGIN.get_or_init(std::time::Instant::now).elapsed() } #[cfg(feature = "cuda")] @@ -495,20 +4559,38 @@ pub use nvml::NvmlCounter; /// Runtime-detect the best available counter on this host. /// -/// Preference order: NVML (if compiled with `cuda` and present) → RAPL on -/// Linux → IOReport on macOS (real Energy-Model counters, no sudo) → -/// powermetrics on macOS → `StubCounter`. +/// Preference order: NVML (if compiled with `cuda`, present, and its counter +/// live — see [`nvml_status`]) → RAPL on Linux (only when every package +/// counter is live — see [`rapl_status`]) → IOReport on macOS (Energy-Model counters, +/// no sudo; only when every CPU energy rail is live — see [`ioreport_status`]) +/// → powermetrics on macOS → `StubCounter`. Every hardware rung is held to +/// the same sustained-advance rule; a rung whose counter is not live is +/// skipped, so callers estimate rather than seal what it read. pub fn detect() -> Box { + detect_with(nvml_status, rapl_status, ioreport_status) +} + +/// The selection behind [`detect`], given where each rung's status comes from +/// (each asked only when the ladder reaches its rung, and only on a platform +/// and build that has the rung). +// Each status is asked for only where its rung exists, so on any one platform +// and build some of them go unused. +#[allow(unused_variables)] +pub(crate) fn detect_with( + nvml: impl FnOnce() -> CounterStatus, + rapl: impl FnOnce() -> CounterStatus, + ioreport: impl FnOnce() -> IoReportStatus, +) -> Box { #[cfg(feature = "cuda")] - if let Ok(c) = NvmlCounter::try_detect() { + if let Ok(c) = NvmlCounter::try_detect_given(nvml()) { return Box::new(c); } #[cfg(target_os = "linux")] - if let Ok(c) = LinuxRaplCounter::try_detect() { + if let Ok(c) = LinuxRaplCounter::try_detect_given(rapl()) { return Box::new(c); } #[cfg(target_os = "macos")] - if let Ok(c) = MacosIoReportCounter::try_detect() { + if let Ok(c) = MacosIoReportCounter::try_detect_given(ioreport()) { return Box::new(c); } #[cfg(target_os = "macos")] @@ -537,4 +4619,73 @@ mod tests { let _ = c.read_microjoules(); assert!(!c.name().is_empty()); } + + #[cfg(not(target_os = "macos"))] + #[test] + fn ioreport_is_unavailable_off_macos() { + assert_eq!(ioreport_status(), IoReportStatus::Unavailable); + } + + /// RAPL and NVML are selected only when their counter is live, and only + /// where their rung exists: RAPL on Linux, NVML with `cuda` where NVML + /// opens. A frozen or unavailable counter is never selected, whatever + /// the other rungs say. + #[test] + fn a_vendor_counter_is_selected_only_when_live() { + use CounterStatus as S; + let declined_ioreport = || IoReportStatus::CpuRailFrozen; + for status in [S::Unavailable, S::Frozen] { + let picked = detect_with(|| status, || status, declined_ioreport).name(); + assert!(!matches!(picked, "rapl" | "nvml"), "a {status:?} counter was selected: {picked}"); + let picked = detect_with(|| S::Unavailable, || status, declined_ioreport).name(); + assert_ne!(picked, "rapl", "a {status:?} RAPL counter was selected"); + } + let picked = detect_with(|| S::Unavailable, || S::Live, declined_ioreport).name(); + assert_eq!(picked == "rapl", cfg!(target_os = "linux"), "a live RAPL status selected {picked}"); + #[cfg(feature = "cuda")] + { + let opens = nvml::NvmlCounter::try_detect_given(S::Live).is_ok(); + let picked = detect_with(|| S::Live, || S::Unavailable, declined_ioreport).name(); + assert_eq!(picked == "nvml", opens, "a live NVML status selected {picked}"); + } + } + + /// Off Linux there is no RAPL rung to probe; without `cuda`, no NVML one. + #[test] + fn a_vendor_counter_with_no_rung_is_unavailable() { + #[cfg(not(target_os = "linux"))] + assert_eq!(rapl_status(), CounterStatus::Unavailable); + #[cfg(not(feature = "cuda"))] + assert_eq!(nvml_status(), CounterStatus::Unavailable); + } + + /// Against this host's RAPL package counters, where there are any: the + /// status the probe cached agrees with what they do now. Live: every one + /// advances across 200 ms of CPU work, and `detect()` selects RAPL unless + /// NVML is live. Not live: `detect()` never selects it. Logs what it saw. + #[cfg(target_os = "linux")] + #[test] + fn the_rapl_rung_agrees_with_the_counter() { + let status = rapl_status(); + let host = linux_rapl::host(); + let root = std::path::Path::new(linux_rapl::POWERCAP_ROOT); + let before = linux_rapl::read_packages(root, &host.packages); + let started = std::time::Instant::now(); + let mut acc: u64 = 0; + while started.elapsed() < std::time::Duration::from_millis(200) { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + let after = linux_rapl::read_packages(root, &host.packages); + let picked = detect().name(); + eprintln!("rapl: status={status:?} packages={:?} before={before:?} after={after:?} detect()={picked}", host.packages); + if status == CounterStatus::Live { + let (b, a) = (before.expect("live counters read"), after.expect("live counters read")); + for ((p, b), a) in host.packages.iter().zip(&b).zip(&a) { + assert!(a != b, "live package zone intel-rapl:{} did not move across 200 ms of CPU work: {b} -> {a}", p.index); + } + assert!(picked == "rapl" || nvml_status() == CounterStatus::Live, "detect() picked {picked}"); + } else { + assert_ne!(picked, "rapl", "detect() selected a {status:?} RAPL counter"); + } + } } diff --git a/eoc/eoc-rs/crates/eoc-meter/tests/liveness_probe_once_per_process.rs b/eoc/eoc-rs/crates/eoc-meter/tests/liveness_probe_once_per_process.rs new file mode 100644 index 000000000..7c4f537b5 --- /dev/null +++ b/eoc/eoc-rs/crates/eoc-meter/tests/liveness_probe_once_per_process.rs @@ -0,0 +1,93 @@ +//! The liveness probe behind `eoc_meter::ioreport_status()` runs once per +//! PROCESS, however many threads ask for the status, construct the IOReport +//! counter or select a counter, and in whatever order. +//! +//! This file is a test binary of its own, so it runs in a fresh process, and +//! its single test is the first use of the cache: nothing in the process has +//! probed before it starts (asserted). Two process-wide counts are pinned: the +//! runs of the probe behind the cache, and the opens of the probe's Energy +//! Model sampler, which every real probe makes whoever calls it — so a +//! constructor or selection path that probed outside the cache would show as a +//! second open even if it bypassed the cached function entirely. + +use std::sync::Barrier; + +use eoc_meter::IoReportStatus; + +/// Threads released together at the cold cache. +const THREADS: usize = 8; + +/// What one thread got: the status, whether the IOReport counter was +/// constructed, and the counter `detect()` selected. +type Seen = (IoReportStatus, bool, &'static str); + +#[cfg(target_os = "macos")] +fn constructed() -> bool { + eoc_meter::MacosIoReportCounter::try_detect().is_ok() +} + +#[cfg(not(target_os = "macos"))] +fn constructed() -> bool { + false +} + +/// Status, construction and selection, each thread starting on a different +/// one of the three paths into the cache. +fn ask(thread: usize) -> Seen { + match thread % 3 { + 0 => { + let status = eoc_meter::ioreport_status(); + (status, constructed(), eoc_meter::detect().name()) + } + 1 => { + let built = constructed(); + let picked = eoc_meter::detect().name(); + (eoc_meter::ioreport_status(), built, picked) + } + _ => { + let picked = eoc_meter::detect().name(); + let built = constructed(); + (eoc_meter::ioreport_status(), built, picked) + } + } +} + +#[test] +fn the_liveness_probe_runs_once_per_process() { + assert_eq!( + (eoc_meter::liveness_probe_runs(), eoc_meter::liveness_sampler_opens()), + (0, 0), + "something in this process probed before the test's first use of the cache" + ); + let gate = Barrier::new(THREADS); + let seen: Vec = std::thread::scope(|s| { + let handles: Vec<_> = (0..THREADS) + .map(|thread| { + let gate = &gate; + s.spawn(move || { + gate.wait(); + ask(thread) + }) + }) + .collect(); + handles.into_iter().map(|h| h.join().expect("a thread asking for the status panicked")).collect() + }); + let (runs, opens) = (eoc_meter::liveness_probe_runs(), eoc_meter::liveness_sampler_opens()); + eprintln!( + "ioreport cache: {THREADS} threads at a cold cache asked, constructed and selected and got {seen:?}; \ + probe runs in this process: {runs}, sampler opens: {opens}" + ); + let first = seen[0].0; + for (status, built, picked) in &seen { + assert_eq!(*status, first, "the threads got different statuses: {seen:?}"); + assert_eq!(*built, *status == IoReportStatus::Live, "construction disagrees with the status: {seen:?}"); + assert_eq!(*picked == "ioreport", *built, "selection disagrees with construction: {seen:?}"); + } + assert_eq!(eoc_meter::ioreport_status(), first, "the cached status changed"); + let once = usize::from(cfg!(target_os = "macos")); + assert_eq!(runs, once, "the liveness probe behind ioreport_status() ran {runs} times in this process"); + assert_eq!(opens, once, "the liveness probe's sampler was opened {opens} times in this process: a probe ran outside the cache"); + if !cfg!(target_os = "macos") { + assert_eq!(first, IoReportStatus::Unavailable); + } +} diff --git a/eoc/eoc-rs/crates/eoc-streaming/src/account.rs b/eoc/eoc-rs/crates/eoc-streaming/src/account.rs index 0d5011105..97a4d8cd4 100644 --- a/eoc/eoc-rs/crates/eoc-streaming/src/account.rs +++ b/eoc/eoc-rs/crates/eoc-streaming/src/account.rs @@ -3,39 +3,41 @@ //! A [`StreamMeter`] samples a [`JouleCounter`](eoc_meter::JouleCounter) //! at the start of a stream and again at the end (or on each //! checkpoint). The difference is attributed to the stream and surfaced -//! as a [`JouleCost`](eoc_core::JouleCost). Token counts can be -//! accumulated alongside so the cascade sees both axes. +//! as a [`JouleCost`] when, and only when, the counter advanced over the +//! span. A stream the counter did not meter has no figure at all, never +//! 0 µJ: its work ran, and the caller keeps its own estimate. Token counts +//! are accumulated alongside, metered or not, so the cascade sees both axes. use std::sync::Arc; use std::sync::Mutex; -use eoc_core::{JouleCost, JouleSource}; +use eoc_core::JouleCost; use eoc_meter::JouleCounter; use crate::error::{StreamError, StreamResult}; /// Per-stream accounting record. -#[derive(Debug, Clone, Copy, Default)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct StreamAccount { /// Tokens streamed so far. pub tokens: u64, - /// Cumulative micro-joule delta attributed to this stream. - pub microjoules: u64, - /// Whether the joule reading is from a hardware counter. - pub measured: bool, + /// A hardware counter's reading of this stream, in micro-joules: the + /// counter read at the start and at the latest checkpoint, and advanced + /// between them. `None` when the stream was not metered — the stub, a + /// failed read, a counter that did not move (a frozen rail, a + /// placeholder), or no checkpoint yet. Not metered is not 0 µJ: the work + /// ran, and no counter read what it cost. + pub microjoules: Option, } impl StreamAccount { - /// Convert to the canonical [`JouleCost`] type. - pub fn joule_cost(&self) -> JouleCost { - JouleCost { - microjoules: self.microjoules, - source: if self.measured { - JouleSource::Measured - } else { - JouleSource::Estimated - }, - } + /// The stream's cost as the canonical [`JouleCost`]: `Some`, and + /// [`JouleSource::Measured`](eoc_core::JouleSource::Measured), when a + /// counter metered it; `None` when nothing did. There is deliberately no + /// estimated zero here: a caller with no reading supplies its own + /// estimate (from tokens, or time) and tags it as one. + pub fn joule_cost(&self) -> Option { + self.microjoules.map(JouleCost::measured) } } @@ -48,8 +50,9 @@ impl StreamAccount { #[derive(Clone)] pub struct StreamMeter { counter: Arc, - start_uj: u64, - measured: bool, + /// The counter's reading when the stream started; `None` when that read + /// failed, and then no span can be measured. + start_uj: Option, state: Arc>, } @@ -58,27 +61,22 @@ impl std::fmt::Debug for StreamMeter { f.debug_struct("StreamMeter") .field("counter", &self.counter.name()) .field("start_uj", &self.start_uj) - .field("measured", &self.measured) .finish() } } impl StreamMeter { - /// Start metering a stream. Reads the counter once; if the read - /// fails the meter degrades to `Estimated` and continues. + /// Start metering a stream. Reads the counter once; if the read fails + /// the stream is never metered, and it continues counting tokens. + /// Nothing is metered until a checkpoint sees the counter advance. pub fn start(counter: Arc) -> Self { - let (start_uj, measured) = match counter.read_microjoules() { - Ok(uj) => (uj, counter.name() != "stub"), - Err(_) => (0, false), - }; + let start_uj = counter.read_microjoules().ok(); Self { counter, start_uj, - measured, state: Arc::new(Mutex::new(StreamAccount { tokens: 0, - microjoules: 0, - measured, + microjoules: None, })), } } @@ -92,17 +90,20 @@ impl StreamMeter { /// Refresh the joule delta from the counter. Cheap and safe to /// call at every checkpoint; the cost is one counter read. + /// + /// The delta is a reading only if the counter advanced since the start. + /// A counter that reads but does not move — a frozen rail, a placeholder + /// — has not measured the work: the stream is left not metered, never + /// given its zero. pub fn checkpoint(&self) -> StreamResult { - let now_uj = match self.counter.read_microjoules() { - Ok(uj) => uj, - Err(_) => { - // No measurement available — leave the existing delta in place. - let g = self.state.lock().map_err(poisoned)?; - return Ok(*g); - } + let (Some(start_uj), Ok(now_uj)) = (self.start_uj, self.counter.read_microjoules()) else { + // No baseline, or no reading now: nothing to measure. Leave the + // account as it was — never a counter's since-boot total. + let g = self.state.lock().map_err(poisoned)?; + return Ok(*g); }; let mut g = self.state.lock().map_err(poisoned)?; - g.microjoules = now_uj.saturating_sub(self.start_uj); + g.microjoules = (now_uj > start_uj).then(|| now_uj - start_uj); Ok(*g) } @@ -126,16 +127,87 @@ fn poisoned(_: T) -> StreamError { #[cfg(test)] mod tests { use super::*; + use eoc_core::JouleSource; use eoc_meter::StubCounter; + /// A counter that reads the scripted values in turn (then the last one + /// again); `None` is a failed read. + struct Scripted { + reads: Mutex>>, + } + + impl Scripted { + fn new(reads: &[Option]) -> Arc { + let mut reads = reads.to_vec(); + reads.reverse(); + Arc::new(Self { reads: Mutex::new(reads) }) + } + } + + impl JouleCounter for Scripted { + fn read_microjoules(&self) -> eoc_core::Result { + let mut r = self.reads.lock().unwrap(); + let next = if r.len() > 1 { r.pop().unwrap() } else { r[0] }; + next.ok_or(eoc_core::Error::NoCounter) + } + fn name(&self) -> &'static str { + "scripted" + } + } + + /// A non-stub counter that reads but does not move has not metered the + /// stream: it has no figure, never a zero sealed as one. #[test] - fn stub_counter_estimated() { + fn a_counter_that_did_not_advance_is_not_a_measurement() { + let meter = StreamMeter::start(Scripted::new(&[Some(7_000)])); + assert_eq!(meter.snapshot().unwrap().microjoules, None, "metered before any checkpoint"); + meter.on_token(3).unwrap(); + let acct = meter.finish().unwrap(); + assert_eq!(acct, StreamAccount { tokens: 3, microjoules: None }); + assert_eq!(acct.joule_cost(), None); + } + + /// A counter that advanced over the span is a measurement of it. + #[test] + fn a_counter_that_advanced_is_a_measurement() { + let meter = StreamMeter::start(Scripted::new(&[Some(7_000), Some(7_000), Some(7_450)])); + let mid = meter.checkpoint().unwrap(); + assert_eq!(mid.microjoules, None, "no advance yet"); + let acct = meter.finish().unwrap(); + assert_eq!(acct.microjoules, Some(450)); + assert_eq!(acct.joule_cost(), Some(JouleCost::measured(450))); + assert_eq!(acct.joule_cost().unwrap().source, JouleSource::Measured); + } + + /// Without a starting reading there is no span: a later reading is the + /// counter's since-boot total, not this stream's energy. + #[test] + fn a_failed_start_measures_nothing() { + let meter = StreamMeter::start(Scripted::new(&[None, Some(9_000_000)])); + let acct = meter.finish().unwrap(); + assert_eq!(acct.microjoules, None); + assert_eq!(acct.joule_cost(), None); + } + + /// A counter that advanced once keeps that reading when a later read + /// fails, and never turns it into the counter's since-boot total. + #[test] + fn a_failed_later_read_keeps_the_last_reading() { + let meter = StreamMeter::start(Scripted::new(&[Some(1_000), Some(1_200), None])); + assert_eq!(meter.checkpoint().unwrap().microjoules, Some(200)); + assert_eq!(meter.finish().unwrap().microjoules, Some(200)); + } + + /// A stream the stub "metered" streamed its tokens and has no energy + /// figure: not 0 µJ, which would present work that ran as free. + #[test] + fn an_unmetered_stream_has_no_figure() { let meter = StreamMeter::start(Arc::new(StubCounter)); meter.on_token(5).unwrap(); let acct = meter.finish().unwrap(); assert_eq!(acct.tokens, 5); - assert_eq!(acct.microjoules, 0); - assert!(!acct.measured); - assert_eq!(acct.joule_cost().source, JouleSource::Estimated); + assert_eq!(acct.microjoules, None); + assert_eq!(acct.joule_cost(), None); + assert_eq!(StreamAccount::default().joule_cost(), None); } } diff --git a/eoc/eoc-rs/crates/eoc/Cargo.toml b/eoc/eoc-rs/crates/eoc/Cargo.toml index b5d8e1f48..a2933a5ea 100644 --- a/eoc/eoc-rs/crates/eoc/Cargo.toml +++ b/eoc/eoc-rs/crates/eoc/Cargo.toml @@ -23,3 +23,6 @@ eoc-cascade.workspace = true clap.workspace = true anyhow.workspace = true tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"] } + +[dev-dependencies] +async-trait.workspace = true diff --git a/eoc/eoc-rs/crates/eoc/src/main.rs b/eoc/eoc-rs/crates/eoc/src/main.rs index 0df01d7c6..3e4ff6914 100644 --- a/eoc/eoc-rs/crates/eoc/src/main.rs +++ b/eoc/eoc-rs/crates/eoc/src/main.rs @@ -5,7 +5,8 @@ //! - `eoc query ""` — run a prompt through the cascade and print //! the stage that resolved it plus the attributed joule cost. //! - `eoc meter` — detect available hardware energy counters and report -//! the micro-joules consumed during one second of idle. +//! the micro-joules consumed during one second of idle, or why nothing +//! was metered. #![forbid(unsafe_code)] @@ -20,7 +21,7 @@ use eoc_core::Query; use eoc_graph::{GraphStage, Triple}; use eoc_kv::{KvBackend, KvStage, MemoryKvBackend}; use eoc_meter::detect; -use eoc_neural::{EchoBackend, NeuralStage}; +use eoc_neural::{EchoBackend, NeuralBackend, NeuralStage}; #[derive(Parser, Debug)] #[command( @@ -40,7 +41,8 @@ enum Cmd { /// The prompt to resolve. prompt: String, }, - /// Detect the joule counter and report idle micro-joules per second. + /// Detect the joule counter and report idle micro-joules per second, or + /// why nothing was metered. Meter, } @@ -54,6 +56,11 @@ async fn main() -> Result<()> { } fn build_cascade() -> Cascade { + build_cascade_with(Box::new(EchoBackend::new().with_prefix("[neural fallback] "))) +} + +/// The CLI's cascade around a given neural backend, metered by [`detect`]. +fn build_cascade_with(neural: Box) -> Cascade { let cache = Arc::new(LruCache::new(1024)); let kv_backend = Box::new(MemoryKvBackend::new()); @@ -69,9 +76,7 @@ fn build_cascade() -> Cascade { Triple::new("Mars", "fourth planet from", "the Sun"), ]); - let neural = Arc::new(NeuralStage::new(Box::new( - EchoBackend::new().with_prefix("[neural fallback] "), - ))); + let neural = Arc::new(NeuralStage::new(neural)); Cascade::new(cache, kv, graph, neural).with_meter(Arc::from(detect())) } @@ -90,10 +95,114 @@ async fn run_query(prompt: String) -> Result<()> { async fn run_meter() -> Result<()> { let counter = detect(); println!("counter: {}", counter.name()); - let start = counter.read_microjoules().unwrap_or(0); + #[cfg(target_os = "macos")] + println!("ioreport: {}", ioreport_line(eoc_meter::ioreport_status())); + let start = counter.read_microjoules(); tokio::time::sleep(Duration::from_secs(1)).await; - let end = counter.read_microjoules().unwrap_or(0); - let delta = end.saturating_sub(start); - println!("idle : {} µJ over 1.0 s ({:.3} W)", delta, (delta as f64) / 1_000_000.0); + let end = counter.read_microjoules(); + println!("idle : {}", idle_line(counter.name(), start, end)); Ok(()) } + +/// Why the IOReport counter is, or is not, the one in use on this Mac. +#[cfg(target_os = "macos")] +fn ioreport_line(status: eoc_meter::IoReportStatus) -> &'static str { + use eoc_meter::IoReportStatus; + match status { + IoReportStatus::Live => "live (its CPU energy rail keeps advancing under load)", + IoReportStatus::CpuRailFrozen => { + "declined: its CPU energy rail did not keep advancing under load, so \ + its readings would be GPU-only energy, zero, or a stale refresh" + } + IoReportStatus::CpuRailAbsent => "declined: no CPU energy rail in its Energy Model group", + IoReportStatus::Unavailable => { + "unavailable: its Energy Model group could not be opened, or yielded no usable samples" + } + } +} + +/// The idle figure, printed only when a hardware counter actually read. +/// A failed read or the stub is reported as not metered — never as 0 W. +fn idle_line(counter: &str, start: eoc_core::Result, end: eoc_core::Result) -> String { + if counter == "stub" { + return "not metered (stub counter: no hardware energy counter)".to_string(); + } + match (start, end) { + (Ok(s), Ok(e)) => { + let delta = e.saturating_sub(s); + format!("{delta} µJ over 1.0 s ({:.3} W)", (delta as f64) / 1_000_000.0) + } + (Err(err), _) | (_, Err(err)) => format!("not metered (counter read failed: {err})"), + } +} + +#[cfg(test)] +mod tests { + use super::{build_cascade_with, idle_line}; + use async_trait::async_trait; + use eoc_core::{Error, JouleCost, JouleSource, Query, Response, Stage}; + use eoc_neural::NeuralBackend; + use std::time::{Duration, Instant}; + + /// A neural backend that does ~100 ms of real CPU work and reports its own + /// estimate for it, as a real backend without a meter would. + struct BurningBackend; + + const BURN_ESTIMATE_UJ: u64 = 50_000_000; + + #[async_trait] + impl NeuralBackend for BurningBackend { + async fn infer(&self, q: &Query) -> Response { + let started = Instant::now(); + let mut acc: u64 = 0; + while started.elapsed() < Duration::from_millis(100) { + acc = acc.wrapping_add(1); + } + std::hint::black_box(acc); + Response::new(q.id, "burned".to_string(), Stage::Neural, JouleCost::estimated(BURN_ESTIMATE_UJ)) + } + } + + /// The whole CLI path on this host, around ~100 ms of real CPU work. Where + /// the meter [`super::detect`] picks is no hardware reading — the stub, or + /// the powermetrics stand-in, which is what a Mac whose IOReport CPU rail is + /// not live falls back to — the cost is the backend's own estimate: the + /// same non-zero figure, tagged estimated. Where it is a live hardware + /// counter, the cost is that estimate plus what the counter read, still + /// tagged estimated, because part of it is. Never a zero for the work, and + /// never tagged measured. + #[tokio::test] + async fn work_on_an_unmetered_host_keeps_its_estimate() { + let cascade = build_cascade_with(Box::new(BurningBackend)); + let meter = cascade.meter().name(); + #[cfg(target_os = "macos")] + assert_eq!( + meter == "ioreport", + eoc_meter::ioreport_status() == eoc_meter::IoReportStatus::Live, + "the CLI's meter is {meter} while IOReport is {:?}", + eoc_meter::ioreport_status() + ); + let r = cascade.resolve(Query::new("a question nothing has cached")).await; + eprintln!("eoc cost on this host: meter={meter} cost={}", r.joule_cost); + assert_eq!(r.stage, Stage::Neural); + if matches!(meter, "stub" | "powermetrics") { + assert_eq!(r.joule_cost, JouleCost::estimated(BURN_ESTIMATE_UJ), "meter={meter}"); + } else { + // A hardware counter: the estimate plus what it read, never less, + // and an estimate, since the backend's part of it is one. + assert!(r.joule_cost.microjoules >= BURN_ESTIMATE_UJ, "{}", r.joule_cost); + assert_eq!(r.joule_cost.source, JouleSource::Estimated, "{}", r.joule_cost); + } + } + + #[test] + fn a_counter_that_did_not_read_is_never_printed_as_a_figure() { + let failed = idle_line("powermetrics", Err(Error::NoCounter), Err(Error::NoCounter)); + assert_eq!(failed, "not metered (counter read failed: no joule counter available)"); + let half = idle_line("rapl", Ok(1_000), Err(Error::Io("gone".into()))); + assert!(half.starts_with("not metered"), "{half}"); + let stub = idle_line("stub", Ok(0), Ok(0)); + assert!(stub.starts_with("not metered"), "{stub}"); + assert_eq!(idle_line("rapl", Ok(1_000), Ok(501_000)), "500000 µJ over 1.0 s (0.500 W)"); + } +}