diff --git a/joule-code/examples/l3-jouleclaw/Cargo.lock b/joule-code/examples/l3-jouleclaw/Cargo.lock index 2e1d0ae0f..95a850371 100644 --- a/joule-code/examples/l3-jouleclaw/Cargo.lock +++ b/joule-code/examples/l3-jouleclaw/Cargo.lock @@ -501,6 +501,7 @@ dependencies = [ "joule-code-receipt", "joule-code-schema", "jouleclaw-energy", + "jouleclaw-fabric", "jouleclaw-llm", "jouleclaw-loader-gguf", "jouleclaw-runtime", diff --git a/joule-code/examples/l3-jouleclaw/Cargo.toml b/joule-code/examples/l3-jouleclaw/Cargo.toml index 78592155a..4e0dddea4 100644 --- a/joule-code/examples/l3-jouleclaw/Cargo.toml +++ b/joule-code/examples/l3-jouleclaw/Cargo.toml @@ -23,4 +23,7 @@ jouleclaw-energy = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-en # the in-process L3 backend. jouleclaw-runtime = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-runtime" } jouleclaw-loader-gguf = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-loader-gguf", features = ["rayon", "fabric"] } +# The grades (`Measured` / `Benchmarked` / `Estimated`) the fabric ledger prices +# a call from, which the L3 stage grades the call's projected energy by. +jouleclaw-fabric = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-fabric" } serde_json = "1" diff --git a/joule-code/examples/l3-jouleclaw/README.md b/joule-code/examples/l3-jouleclaw/README.md index 66be51754..24ea69f5e 100644 --- a/joule-code/examples/l3-jouleclaw/README.md +++ b/joule-code/examples/l3-jouleclaw/README.md @@ -39,11 +39,21 @@ JOULE_CODE_L3_ENGINE=llama cargo run --release --bin run -- /path/to/model.gguf ``` The `run` binary generates with the local model, content-addresses the result as -a `CodingOp` (tier L3), and **meters host energy over the inference** with -JouleClaw's energy counter — so the receipt's joule cost is a real local -measurement, honestly tagged with the counter's provenance (`HwShunt` on -RAPL/NVML hosts; `Estimator` on Apple Silicon until IOReport is wired), not a -datacenter token estimate. +a `CodingOp` (tier L3), and puts the **host** energy spent during the inference on +the receipt, tagged with the provenance of whatever metered it: + +- **Apple Silicon whose IOReport CPU energy rail is live** — JouleClaw's IOReport + counter, a live vendor energy counter: a real local measurement, tagged + `HwShunt` (with `measured_uj` set). +- **Apple Silicon whose CPU rail is not live** — macOS 27.0.1 for an unprivileged + process, observed on an M5 Max and an M3 Ultra — JouleClaw's load-average + estimator, tagged `Estimator`. Not a measurement. +- **Anywhere else**, Linux and Windows included — the token-count estimate, + tagged `Estimator`. This binding builds `jouleclaw-energy` with the `ioreport` + feature only, so it reads no RAPL or NVML counter even where one exists. + +The printout names the meter that ran and the IOReport status, and says whether +the figure was measured or estimated. Verified live: the llama.cpp path against a local 350M GGUF, and the in-process from-scratch runtime against `ternary-bonsai-1.7b-q2_0` (qwen3, Q2_0) — real text diff --git a/joule-code/examples/l3-jouleclaw/src/bin/run.rs b/joule-code/examples/l3-jouleclaw/src/bin/run.rs index 2d685d3ab..73d7ce18b 100644 --- a/joule-code/examples/l3-jouleclaw/src/bin/run.rs +++ b/joule-code/examples/l3-jouleclaw/src/bin/run.rs @@ -7,12 +7,16 @@ //! By default it runs the model **in-process on JouleClaw's own from-scratch GGUF //! tensor runtime** (`jouleclaw-runtime`); set `JOULE_CODE_L3_ENGINE=llama` to use //! the `llama.cpp` shell-out backend instead. Either way it content-addresses the -//! result as a `CodingOp` and meters the **host** energy spent during the -//! inference with JouleClaw's energy counter — so the receipt's joule cost is a -//! real local measurement (honestly tagged with the counter's provenance), not a -//! datacenter token estimate. +//! result as a `CodingOp` and puts the **host** energy spent during the inference +//! on the receipt, from JouleClaw's best counter on this host and tagged with that +//! counter's provenance: a measurement where the host exposes a live counter +//! (IOReport on Apple Silicon whose CPU rail advances, `HwShunt`), an estimate +//! from host load where it does not (Apple Silicon whose IOReport CPU rail is +//! frozen, as on macOS 27.0.1), and the token-count estimate where there is no +//! counter at all. The printed receipt says which. -use jouleclaw_energy::{EnergyCounter, Provenance as JcProvenance}; +use jouleclaw_energy::meter::EnergyMeter; +use jouleclaw_energy::Provenance as JcProvenance; use jouleclaw_llm::{LlmBackend, LlmRequest}; use joule_code_cargo::{Anchor, CodingOp, Edit}; @@ -23,11 +27,13 @@ use l3_jouleclaw_binding::llama_backend::LlamaCompletionBackend; use l3_jouleclaw_binding::runtime_backend::JouleclawRuntimeBackend; /// The host's best energy counter, or None where unavailable (e.g. CI/Linux). -fn host_counter() -> Option> { +fn host_counter() -> Option> { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { - // Prefer the real IOReport energy-model counter (ModelBased); the - // load-avg estimator is only a last resort if IOReport will not open. + // The IOReport energy counter (HwShunt, a live vendor counter) where it can + // meter CPU work: it is unavailable unless IOReport opens AND its CPU energy + // rail advances under load (`jouleclaw_energy::ioreport_status()` says why + // not). Otherwise the load-average estimator (Estimator). let ioreport = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); if ioreport.is_available() { return Some(Box::new(ioreport)); @@ -40,6 +46,39 @@ fn host_counter() -> Option> { None } +/// The receipt energy for an inference: the host counter's delta across it, with +/// the counter's provenance, or — without a counter, or without two readings of +/// it — the token-count estimate. `true` when the figure came from the counter. +/// A counter that went backwards, one that registered nothing across the +/// inference (no inference runs on zero energy), or readings that disagree about +/// provenance give no counter figure: never a zero. +fn receipt_energy( + before: Option<(u64, JcProvenance)>, + after: Option<(u64, JcProvenance)>, + input_tokens: u64, + output_tokens: u64, +) -> (Energy, bool) { + if let (Some((b, before_prov)), Some((a, prov))) = (before, after) { + if let Some(uj) = a.checked_sub(b).filter(|uj| *uj > 0 && before_prov == prov) { + let prov = map_provenance(prov); + let measured_uj = (prov == Provenance::HwShunt).then_some(uj); + return (Energy { measured_uj, estimated_uj: uj, provenance: prov }, true); + } + } + (Energy::from_tokens(input_tokens, output_tokens), false) +} + +/// How the receipt's energy was obtained, for the printout: by which counter, or +/// from token counts. An estimate is never called metered. +fn energy_note(energy: &Energy, counter: &str, from_counter: bool) -> String { + match (from_counter, energy.provenance) { + (false, _) => "estimated from token counts".to_string(), + (true, Provenance::HwShunt) => format!("measured by {counter}"), + (true, Provenance::ModelBased) => format!("host-metered by {counter} (energy model)"), + (true, Provenance::Estimator) => format!("estimated by {counter} from host load"), + } +} + fn map_provenance(p: JcProvenance) -> Provenance { match p { JcProvenance::HwShunt => Provenance::HwShunt, @@ -77,10 +116,11 @@ fn main() { }, }; let counter = host_counter(); + let counter_name = counter.as_ref().map_or("none", |c| c.name()); // Meter host energy across the inference. Prime the counter once so the // first-sample init delta is excluded from the measured window. - let read = |c: &Option>| { + let read = |c: &Option>| { c.as_ref() .and_then(|c| c.read().ok()) .map(|r| (r.uj, r.provenance)) @@ -113,27 +153,14 @@ fn main() { }; let op_id = op.op_id().expect("op_id"); - // Receipt energy: real measured host energy if a counter was available, - // else the token-based estimate (honest fallback). - let energy = match (before, after) { - (Some((b, _)), Some((a, prov))) => { - let uj = a.saturating_sub(b); - let prov = map_provenance(prov); - match prov { - Provenance::HwShunt => Energy { - measured_uj: Some(uj), - estimated_uj: uj, - provenance: prov, - }, - _ => Energy { - measured_uj: None, - estimated_uj: uj, - provenance: prov, - }, - } - } - _ => Energy::from_tokens(response.input_tokens as u64, response.output_tokens as u64), - }; + // Receipt energy: the host counter's figure at its provenance, else the + // token-based estimate (honest fallback). + let (energy, from_counter) = receipt_energy( + before, + after, + response.input_tokens as u64, + response.output_tokens as u64, + ); let receipt = Receipt { op_id: op_id.clone(), tier: Tier::L3, @@ -151,18 +178,109 @@ fn main() { println!("op_id : {op_id}"); println!("edits : {} (GENERATED.rs)", op.edits.len()); println!("tier : {}", receipt.tier); + println!( + "meter : {counter_name} (IOReport: {:?})", + jouleclaw_energy::ioreport_status() + ); println!( "tokens : in={} out={}", response.input_tokens, response.output_tokens ); println!( - "energy : {:.3} J ({:?}{})", + "energy : {:.3} J ({:?}, {})", receipt.energy.estimated_uj as f64 / 1_000_000.0, receipt.energy.provenance, - if receipt.energy.measured_uj.is_some() { - ", measured" - } else { - ", host-metered" - } + energy_note(&receipt.energy, counter_name, from_counter) ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_counter_reading_keeps_its_provenance() { + let (e, from_counter) = + receipt_energy(Some((100, JcProvenance::HwShunt)), Some((400, JcProvenance::HwShunt)), 7, 9); + assert!(from_counter); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (Some(300), 300, Provenance::HwShunt)); + for (jc, p) in [(JcProvenance::ModelBased, Provenance::ModelBased), (JcProvenance::Estimator, Provenance::Estimator)] { + let (e, from_counter) = receipt_energy(Some((100, jc)), Some((400, jc)), 7, 9); + assert!(from_counter); + assert_eq!((e.measured_uj, e.estimated_uj, e.provenance), (None, 300, p)); + assert!(e.check_provenance().is_ok()); + } + } + + #[test] + fn without_two_readings_the_receipt_is_the_token_estimate() { + let tokens = Energy::from_tokens(7, 9); + let est = JcProvenance::Estimator; + for (before, after) in [ + (None, None), + (None, Some((400, est))), + (Some((100, est)), None), + (Some((400, est)), Some((100, est))), // went backwards + (Some((400, est)), Some((400, est))), // registered nothing + (Some((9, JcProvenance::ModelBased)), Some((9, JcProvenance::ModelBased))), // a held rail + (Some((100, JcProvenance::ModelBased)), Some((400, est))), // disagree + ] { + let (e, from_counter) = receipt_energy(before, after, 7, 9); + assert!(!from_counter, "{before:?} → {after:?}"); + assert_eq!(e, tokens); + } + } + + #[test] + fn an_estimate_is_never_printed_as_metered() { + let est = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::Estimator }; + assert_eq!(energy_note(&est, "apple-silicon", true), "estimated by apple-silicon from host load"); + assert_eq!(energy_note(&est, "none", false), "estimated from token counts"); + let modeled = Energy { measured_uj: None, estimated_uj: 5, provenance: Provenance::ModelBased }; + assert_eq!(energy_note(&modeled, "apple-ioreport", true), "host-metered by apple-ioreport (energy model)"); + let measured = Energy { measured_uj: Some(5), estimated_uj: 5, provenance: Provenance::HwShunt }; + assert_eq!(energy_note(&measured, "rapl", true), "measured by rapl"); + } + + /// On this host: the IOReport counter is used only where its CPU rail is live. + /// Elsewhere on Apple Silicon (macOS 27.0.1 on an M5 Max and an M3 Ultra) the + /// load estimator runs and meters the burn, and the receipt says Estimator; with + /// no counter at all (CI's Linux runner), the token estimate does. + #[test] + fn on_this_host_the_estimator_fallback_runs_unless_ioreport_is_live() { + let status = jouleclaw_energy::ioreport_status(); + let counter = host_counter(); + let name = counter.as_ref().map_or("none", |c| c.name()); + let read = || counter.as_ref().and_then(|c| c.read().ok()).map(|r| (r.uj, r.provenance)); + let _ = read(); // prime, as main does + let before = read(); + let t = std::time::Instant::now(); + let mut x = 1u64; + while t.elapsed() < std::time::Duration::from_millis(20) { + x = x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1); + } + std::hint::black_box(x); + let after = read(); + let (e, from_counter) = receipt_energy(before, after, 7, 9); + let note = energy_note(&e, name, from_counter); + println!("l3 on this host: ioreport={status:?} meter={name} energy={e:?} ({note})"); + if status == jouleclaw_energy::IoReportStatus::Live { + assert_eq!(name, "apple-ioreport"); + assert_eq!(e.provenance, Provenance::HwShunt, "a live IOReport reading is a hardware reading"); + assert!(from_counter && e.measured_uj.is_some_and(|uj| uj > 0), "{e:?}"); + } else { + assert_ne!(name, "apple-ioreport", "IOReport is used only when its CPU rail is live"); + assert_eq!(e.provenance, Provenance::Estimator); + assert_eq!(e.measured_uj, None); + assert!(note.starts_with("estimated"), "{note}"); + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + assert_eq!(name, "apple-silicon"); + assert!(from_counter && e.estimated_uj > 0, "the estimator metered 20 ms of burn: {e:?}"); + assert_eq!(note, "estimated by apple-silicon from host load"); + } else { + assert_eq!((name, from_counter), ("none", false)); + assert_eq!(e, Energy::from_tokens(7, 9)); + } + } + } +} diff --git a/joule-code/examples/l3-jouleclaw/src/hybrid_backend.rs b/joule-code/examples/l3-jouleclaw/src/hybrid_backend.rs index 92276d6fd..7ae839d4a 100644 --- a/joule-code/examples/l3-jouleclaw/src/hybrid_backend.rs +++ b/joule-code/examples/l3-jouleclaw/src/hybrid_backend.rs @@ -180,10 +180,13 @@ impl LlmBackend for HybridBackend { .with_sampling(sampling); // Fabric attribution window: GFLOPs per member for - // THIS call, priced by the machine's calibration — - // a calibrated per-operation model (ModelBased), not - // a meter reading; reported only when the fabric is - // on and calibrated. + // THIS call, each priced by the member's loaded matmul + // figure — a projection, not a meter reading; reported + // only when the fabric priced some of it. The L3 stage + // grades it by its weakest input + // (`crate::projection_grade`): ModelBased only when + // every member was priced by a Measured or Benchmarked + // figure. jouleclaw_loader_gguf::fabric_dispatch::ledger_reset(); let out = match &self.model { Hybrid::NemotronH(m) => generate(m, &prompt, ¶ms), @@ -201,10 +204,10 @@ impl LlmBackend for HybridBackend { finish_reason: if stopped { FinishReason::Stop } else { FinishReason::Length }, input_tokens: prompt.len() as u32, output_tokens: out.len() as u32, - // ModelBased when the fabric ledger priced the call - // against this machine's calibration; honest None - // otherwise (the run binary can still meter host - // energy over the whole call). + // The fabric projection when the ledger priced the + // call (graded by the stage from the ledger's + // inputs); honest None otherwise (the run binary can + // still meter host energy over the whole call). energy_joules, }) } diff --git a/joule-code/examples/l3-jouleclaw/src/lib.rs b/joule-code/examples/l3-jouleclaw/src/lib.rs index 164a37352..3639b99dd 100644 --- a/joule-code/examples/l3-jouleclaw/src/lib.rs +++ b/joule-code/examples/l3-jouleclaw/src/lib.rs @@ -31,16 +31,53 @@ use joule_code_cargo::{Anchor, CodingOp, Edit}; use joule_code_cascade::{FnStage, Request, Resolution, Stage}; use joule_code_receipt::{Energy, Provenance, Receipt}; use joule_code_schema::{ContentId, Tier, ALG_ED25519}; +use jouleclaw_fabric::EnergyProvenance; use jouleclaw_llm::{EchoBackend, LlmBackend, LlmRequest}; +use jouleclaw_loader_gguf::fabric_dispatch::{ledger_reset, ledger_snapshot, LedgerEntry}; + +/// The grade of a per-call energy figure projected from the fabric attribution +/// ledger (`window`: the ledger's entries for that call), by its **weakest +/// input**. +/// +/// A projection is GFLOPs × each member's µJ/GFLOP; it is never a reading, so +/// at best it is a calibrated per-operation model, `ModelBased` (joule-code +/// SPEC §6). It is that only when every member that ran was priced by a +/// calibrated figure: `Measured` (a calibration row a live meter read) or +/// `Benchmarked` (a recorded run on that member class). One input priced by an +/// `Estimated` figure — a coarse model, such as the fabric's seeded GPU figures — +/// makes the whole projection an estimate, `Estimator`; so does a member that +/// ran unpriced (its work is missing from the sum), and an empty window (a +/// figure that cannot be traced to any priced input). +pub fn projection_grade(window: &[LedgerEntry]) -> Provenance { + if window.is_empty() { + return Provenance::Estimator; + } + let mut grade = Provenance::ModelBased; + for entry in window { + match entry.projected_grade { + Some(EnergyProvenance::Measured | EnergyProvenance::Benchmarked) => {} + Some(EnergyProvenance::Estimated) | None => grade = Provenance::Estimator, + } + } + grade +} /// Build an L3 cascade [`Stage`] backed by any JouleClaw [`LlmBackend`]. The /// stage generates a coding operation from the request intent and reports the /// backend's joule cost as the receipt energy (tier L3, joules class). +/// +/// The stage opens the fabric attribution window around the call (it resets +/// the process-wide ledger first), so the figure a backend reports is graded by +/// the inputs this call was priced from ([`projection_grade`]) and by nothing an +/// earlier call left in the ledger. The ledger is process-wide: work another +/// thread dispatches during the call lands in the same window. pub fn jouleclaw_l3_stage(backend: Box) -> impl Stage { FnStage::new(Tier::L3, move |req: &Request| { + ledger_reset(); let response = backend .complete(&LlmRequest::from_prompt(req.intent.clone(), 256)) .ok()?; + let window = ledger_snapshot(); // Reference materialization: the model's output becomes one edit. A // production binding would parse structured edits from the model. @@ -59,15 +96,15 @@ pub fn jouleclaw_l3_stage(backend: Box) -> impl Stage { }; let op_id = op.op_id().ok()?; - // L3 energy, by best available provenance: when the - // backend reports a per-call figure (the hybrid - // backend's fabric ledger — GFLOPs attributed per - // member, priced by THIS machine's IOReport - // calibration), that is a calibrated per-operation - // model: ModelBased per SPEC §6. Otherwise the - // catalog constant, honestly tagged Estimator. + // L3 energy. When the backend reports a per-call figure + // (the hybrid backend's fabric projection — GFLOPs + // attributed per member, priced by each member's loaded + // matmul figure), it is graded by its weakest input: + // ModelBased only if every member was priced by a + // Measured or Benchmarked figure, Estimator otherwise. + // Without a figure, the catalog constant, Estimator. let (estimated_uj, provenance) = match response.energy_joules { - Some(j) => ((j * 1_000_000.0) as u64, Provenance::ModelBased), + Some(j) => ((j * 1_000_000.0) as u64, projection_grade(&window)), None => ( (backend.typical_joules_per_call() * 1_000_000.0) as u64, Provenance::Estimator, @@ -121,6 +158,98 @@ mod tests { assert_eq!(res.receipt.energy.provenance, Provenance::Estimator); } + /// A ledger entry for `member`, priced at `grade` (`None`: unpriced). + fn entry(member: &str, grade: Option) -> LedgerEntry { + LedgerEntry { + member_id: member.into(), + ops: 3, + gflops: 1.5, + projected_uj: grade.map(|_| 1.5 * 7_000.0), + projected_grade: grade, + } + } + + /// The weakest input decides: calibrated figures (Measured, Benchmarked) + /// make a ModelBased projection; one Estimated or unpriced input, or no input + /// at all, makes it an Estimator figure. + #[test] + fn a_projection_is_graded_by_its_weakest_input() { + use EnergyProvenance::*; + let cases: [(&[Option], Provenance); 8] = [ + (&[Some(Measured)], Provenance::ModelBased), + (&[Some(Benchmarked)], Provenance::ModelBased), + (&[Some(Measured), Some(Benchmarked)], Provenance::ModelBased), + (&[Some(Estimated)], Provenance::Estimator), + (&[Some(Benchmarked), Some(Estimated)], Provenance::Estimator), + (&[Some(Estimated), Some(Measured)], Provenance::Estimator), + (&[Some(Measured), None], Provenance::Estimator), + (&[], Provenance::Estimator), + ]; + for (grades, want) in cases { + let window: Vec = + grades.iter().enumerate().map(|(i, g)| entry(&format!("local/m{i}"), *g)).collect(); + assert_eq!(projection_grade(&window), want, "{grades:?}"); + } + } + + /// A backend that runs one real matmul through the fabric dispatcher (the + /// path the hybrid models take) and reports a per-call figure, as the hybrid + /// backend does from the ledger. + struct DispatchingBackend { + /// Run the matmul (and so leave a ledger entry), or not. + dispatch: bool, + } + + impl LlmBackend for DispatchingBackend { + fn model_name(&self) -> &str { + "dispatching" + } + fn complete(&self, request: &LlmRequest) -> Result { + if self.dispatch { + let (m, n, k) = (1usize, 32usize, 64usize); + let a = vec![0.5f32; m * k]; + let w = vec![0u8; 2 * n * k]; + let mut out = vec![0f32; m * n]; + jouleclaw_loader_gguf::fabric_dispatch::matmul_bf16_auto(&a, &w, &[], m, n, k, &mut out); + } + Ok(jouleclaw_llm::LlmResponse { + text: format!("fn answer() {{}} // {}", request.prompt), + finish_reason: jouleclaw_llm::FinishReason::Stop, + input_tokens: 4, + output_tokens: 4, + energy_joules: Some(0.25), + }) + } + fn typical_joules_per_call(&self) -> f64 { + 1.0 + } + } + + fn resolve(backend: DispatchingBackend) -> Energy { + let stage = jouleclaw_l3_stage(Box::new(backend)); + let res = stage + .try_resolve(&Request { prior: "b3:AAAA".into(), intent: "write a hello function".into() }) + .expect("L3 resolved"); + res.receipt.energy + } + + /// A per-call figure is no longer ModelBased by default. Here the fabric is + /// off (no `JOULECLAW_FABRIC=1`), so the member that ran was priced by + /// nothing: the figure is reported, as an estimate. A figure with no ledger + /// input at all is an estimate too. + #[test] + fn a_figure_the_ledger_did_not_price_is_an_estimate() { + for dispatch in [true, false] { + let e = resolve(DispatchingBackend { dispatch }); + assert_eq!( + (e.measured_uj, e.estimated_uj, e.provenance), + (None, 250_000, Provenance::Estimator), + "dispatch={dispatch}" + ); + assert!(e.check_provenance().is_ok()); + } + } + #[test] fn cascade_falls_through_l0_to_l3() { // Empty L0 cache in front of the L3 model stage: L0 misses, L3 closes. diff --git a/joule-code/examples/l3-jouleclaw/tests/common/mod.rs b/joule-code/examples/l3-jouleclaw/tests/common/mod.rs new file mode 100644 index 000000000..5f459a26c --- /dev/null +++ b/joule-code/examples/l3-jouleclaw/tests/common/mod.rs @@ -0,0 +1,65 @@ +//! Shared by the fabric-grade tests: run the L3 stage, with the fabric ON in this +//! test process and priced from a calibration file the test writes (never the +//! machine's own `~/.jouleclaw/fabric-calibration.json`), over a backend that +//! dispatches one real matmul through the fabric and reports the ledger's +//! projection, as the hybrid backend does. + +use joule_code_cascade::{Request, Stage}; +use joule_code_receipt::Energy; +use jouleclaw_fabric::EnergyProvenance; +use jouleclaw_llm::{FinishReason, LlmBackend, LlmError, LlmRequest, LlmResponse}; +use jouleclaw_loader_gguf::fabric_dispatch::{ledger_reset, ledger_snapshot, matmul_bf16_auto, LedgerEntry}; + +/// One decode-shaped matmul (m = 1, a 4 KiB weight): the fabric places it on the +/// CPU SIMD member, `local/cpu-neon`. +struct OneMatmul; + +impl LlmBackend for OneMatmul { + fn model_name(&self) -> &str { + "one-matmul" + } + fn complete(&self, _request: &LlmRequest) -> Result { + let (m, n, k) = (1usize, 32usize, 64usize); + let a = vec![0.5f32; m * k]; + let w = vec![0u8; 2 * n * k]; + let mut out = vec![0f32; m * n]; + ledger_reset(); + matmul_bf16_auto(&a, &w, &[], m, n, k, &mut out); + let projected_j: f64 = ledger_snapshot().iter().filter_map(|e| e.projected_uj).sum::() / 1e6; + Ok(LlmResponse { + text: "fn hello() {}".into(), + finish_reason: FinishReason::Stop, + input_tokens: 1, + output_tokens: 1, + energy_joules: (projected_j > 0.0).then_some(projected_j), + }) + } + fn typical_joules_per_call(&self) -> f64 { + 1.0 + } +} + +/// Write a calibration file pricing `local/cpu-neon`'s matmul at `grade` (or +/// declaring none), switch the fabric on for this process, resolve one L3 +/// request, and return its receipt energy with the ledger window it was priced +/// from. Call once per test process: the fabric is discovered once. +pub fn resolve_priced_at(tag: &str, grade: Option) -> (Energy, Vec) { + let path = std::path::Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("l3-fabric-grade-{tag}.json")); + let declared = grade.map(|g| format!(r#","provenance":"{g:?}""#)).unwrap_or_default(); + // Priced high enough that the one 4.1e-6 GFLOP matmul projects to ~1 mJ, a + // whole number of µJ on the receipt. + let row = format!( + r#"[{{"member_id":"local/cpu-neon","op":"matmul","uj_per_gflop":250000000.0,"method":"test row"{declared}}}]"# + ); + std::fs::write(&path, row).expect("write the test calibration file"); + std::env::set_var("JOULECLAW_FABRIC_CALIBRATION", &path); + std::env::set_var("JOULECLAW_FABRIC", "1"); + + let stage = l3_jouleclaw_binding::jouleclaw_l3_stage(Box::new(OneMatmul)); + let res = stage + .try_resolve(&Request { prior: "b3:AAAA".into(), intent: "write a hello function".into() }) + .expect("L3 resolved"); + let window = ledger_snapshot(); + let _ = std::fs::remove_file(&path); + (res.receipt.energy, window) +} diff --git a/joule-code/examples/l3-jouleclaw/tests/fabric_grade_calibrated.rs b/joule-code/examples/l3-jouleclaw/tests/fabric_grade_calibrated.rs new file mode 100644 index 000000000..10d914649 --- /dev/null +++ b/joule-code/examples/l3-jouleclaw/tests/fabric_grade_calibrated.rs @@ -0,0 +1,22 @@ +//! The L3 stage grades a fabric-priced figure by its weakest input: with every +//! member that ran priced by a calibrated figure (here a `Measured` row), the +//! receipt says ModelBased — a calibrated per-operation model, never a reading. +//! Its own test process, because the fabric is discovered once per process. + +mod common; + +use joule_code_receipt::Provenance; +use jouleclaw_fabric::EnergyProvenance; + +#[test] +fn a_call_priced_by_calibrated_figures_is_model_based() { + let (energy, window) = common::resolve_priced_at("measured", Some(EnergyProvenance::Measured)); + eprintln!("window: {window:?}\nenergy: {energy:?}"); + assert!(!window.is_empty()); + for e in &window { + assert_eq!(e.projected_grade, Some(EnergyProvenance::Measured), "{e:?}"); + } + assert!(energy.estimated_uj > 0, "the projection is reported: {energy:?}"); + assert_eq!((energy.provenance, energy.measured_uj), (Provenance::ModelBased, None), "{energy:?}"); + assert!(energy.check_provenance().is_ok()); +} diff --git a/joule-code/examples/l3-jouleclaw/tests/fabric_grade_estimated.rs b/joule-code/examples/l3-jouleclaw/tests/fabric_grade_estimated.rs new file mode 100644 index 000000000..1e3f2258c --- /dev/null +++ b/joule-code/examples/l3-jouleclaw/tests/fabric_grade_estimated.rs @@ -0,0 +1,19 @@ +//! The L3 stage grades a fabric-priced figure by its weakest input: with the +//! member that ran priced by an `Estimated` figure, the receipt says Estimator. +//! Its own test process, because the fabric is discovered once per process. + +mod common; + +use joule_code_receipt::Provenance; +use jouleclaw_fabric::EnergyProvenance; + +#[test] +fn a_call_priced_by_an_estimated_figure_is_an_estimate() { + let (energy, window) = common::resolve_priced_at("estimated", Some(EnergyProvenance::Estimated)); + eprintln!("window: {window:?}\nenergy: {energy:?}"); + let neon = window.iter().find(|e| e.member_id == "local/cpu-neon").expect("the m=1 matmul ran on cpu-neon"); + assert_eq!(neon.projected_grade, Some(EnergyProvenance::Estimated), "the ledger carries the row's grade"); + assert!(energy.estimated_uj > 0, "the projection is reported: {energy:?}"); + assert_eq!((energy.provenance, energy.measured_uj), (Provenance::Estimator, None), "{energy:?}"); + assert!(energy.check_provenance().is_ok()); +} diff --git a/joule-code/examples/l3-jouleclaw/tests/hybrid_l3.rs b/joule-code/examples/l3-jouleclaw/tests/hybrid_l3.rs index 4a5495032..9a7133fab 100644 --- a/joule-code/examples/l3-jouleclaw/tests/hybrid_l3.rs +++ b/joule-code/examples/l3-jouleclaw/tests/hybrid_l3.rs @@ -48,13 +48,15 @@ fn hybrid_backend_completes_in_process() { "smoke answer should mention Paris, got {:?}", resp.text ); - // Energy contract: with the fabric on and calibrated, - // the call reports a ModelBased figure from the - // attribution ledger; otherwise honest None. + // Energy contract: with the fabric on, the call reports + // the attribution ledger's projection, graded by its + // weakest priced input; otherwise honest None. match resp.energy_joules { Some(j) => { assert!(j > 0.0); - eprintln!(" fabric-priced energy: {j:.1} J (ModelBased)"); + let window = jouleclaw_loader_gguf::fabric_dispatch::ledger_snapshot(); + let grade = l3_jouleclaw_binding::projection_grade(&window); + eprintln!(" fabric-priced energy: {j:.1} J ({grade:?})"); } None => eprintln!(" energy: None (fabric off — honest)"), } diff --git a/jouleclaw/SPEC.md b/jouleclaw/SPEC.md index 1c35e2466..572d6c552 100644 --- a/jouleclaw/SPEC.md +++ b/jouleclaw/SPEC.md @@ -70,10 +70,38 @@ how the value was obtained: | Provenance | Meaning | |----------------|--------------------------------------------------------| -| `HwShunt` | Real hardware shunt / coulomb counter (RAPL MSR, NVML cumulative-energy counter, Jetson INA3221) | -| `ModelBased` | Vendor-provided estimate from freq / voltage / utilisation (Apple IOReport, ROCm SMI, NVML `power.draw`) | +| `HwShunt` | Hardware reading: a shunt / coulomb counter (Jetson INA3221), or a vendor energy counter (RAPL MSR, NVML cumulative-energy counter, Apple IOReport) while it is live for the domain it claims (below) | +| `ModelBased` | Vendor-provided estimate from freq / voltage / utilisation (ROCm SMI, NVML `power.draw`) | | `Estimator` | JouleClaw static cost model from arch × precision × batch tables | +A vendor energy counter's reading is a real reading. What separates +one such reading from another is provenance — which counter, over +which domain, and whether it was live when read — not accuracy. So a +live vendor counter is graded `HwShunt`, and liveness is the +condition: + +**Liveness (MUST).** A reading from a vendor energy counter (RAPL, +NVML, Apple IOReport, …) **MAY** be graded as a hardware reading only +while the counter is demonstrably live for the domain the figure +claims: the implementation **MUST** have observed the counter +covering that domain advance under load (for Apple IOReport: the CPU +energy rail itself advancing in at least 5 sample intervals, in at +least 4 of every 5, over at least 300 ms while the probing thread +burns CPU). A counter that is present but not live — frozen, or +republished in lumps — **MUST NOT** produce a hardware-graded figure; +the implementation falls back to its estimator or reports the work +unmetered, and **SHOULD** record why. A figure **MUST NOT** claim +coverage of a domain its counter does not read; where the format has +a coverage field, such a figure is Partial. + +Apple IOReport's GPU rail is not a partial reading of CPU work. It is +machine-wide — it meters every client of the GPU on the host, display +compositing among them, and moves while nothing of the metered work +runs — so where the CPU rail is frozen and only the GPU rail advances, +the reference implementation declines the counter and estimates the +work rather than reporting the GPU rail as `Partial`: the rail does +not cover part of the work, it covers other work. + The thermodynamic circuit breaker **MUST** enforce at the granularity of the **worst** counter in the request's span. Implementations **MUST** surface `resolution_uj()` and `min_window_ns()` so callers @@ -85,7 +113,10 @@ Realistic floors: - Intel / AMD x86 RAPL: ~1 μJ, ~10 ms window. `HwShunt`. - Jetson INA3221: ~10 mW (integrate for energy). `HwShunt`. - NVIDIA discrete (NVML cumulative-energy): ~1 mJ, ~50 ms. `HwShunt`. -- Apple Silicon IOReport: ~1 mJ, ~10 ms. **Model-based**, not measured. +- Apple Silicon IOReport: ~1 mJ, ~10 ms. `HwShunt` while its CPU + energy rail is live. On macOS 27.0.1 an unprivileged reader's CPU + rails are frozen (observed on an M5 Max and an M3 Ultra), so the + counter is declined there and the host is estimated (`Estimator`). Marketing claims of microjoule precision on Apple Silicon are sales talk; the spec is honest about this. - Consumer AMD GPU, ARM PMU: no usable counter. Use a calibrated @@ -1031,6 +1062,14 @@ counter (`Provenance::HwShunt`), a documented vendor model (`Provenance::ModelBased`), or a calibrated static estimator (`Provenance::Estimator`) with a wider tolerance band. +A hardware counter here means one that was live for the domain the +figure claims (§1.3, Liveness). A counter that is present but not +live — frozen, or republished in lumps — is not a hardware reading +however real the silicon behind it: its span is the estimator's, or +the work is reported unmetered, and the receipt says so. Grading a +frozen counter's figure `HwShunt` is the receipts-as-marketing +failure mode in its most literal form. + The breaker enforces at the granularity of the worst counter in the span. This is normative. diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs index d0146777f..f97c57d29 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-cli/src/main.rs @@ -341,11 +341,13 @@ async fn run(cli: Cli) -> Result<(), CliError> { // EnergyCounter impl, this command reads the live one. println!("Energy counter (not bound in v0.1)"); println!(" resolution varies by platform: 1 μJ on Intel/AMD RAPL,"); - println!(" ~1 mJ on Apple IOReport (model-based),"); + println!(" ~1 mJ on Apple IOReport,"); println!(" ~1 mJ on NVIDIA NVML,"); println!(" ~10 mW on Jetson INA3221 (shunt; integrate)."); - println!(" honesty `Provenance::HwShunt` on RAPL/NVML/INA3221,"); - println!(" `Provenance::ModelBased` on Apple IOReport / ROCm SMI,"); + println!(" honesty `Provenance::HwShunt` on RAPL/NVML/INA3221, and on"); + println!(" Apple IOReport while its CPU energy rail is live"); + println!(" (a counter that is not live gives no HwShunt figure),"); + println!(" `Provenance::ModelBased` on ROCm SMI,"); println!(" `Provenance::Estimator` on consumer AMD GPU / ARM PMU."); println!(); println!("Bind a counter at startup with `jouleclaw_energy::EnergyCounter`."); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs index 02c25d0e7..502172733 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/lib.rs @@ -17,9 +17,16 @@ //! Every passport links a [`passport::MeasurementMethod`] to a //! [`jouleclaw_energy::Provenance`] tag — measurement honesty travels //! all the way from the platform counter into the compliance report. -//! Apple Silicon (until the IOReport FFI ships) flows through as -//! `Provenance::Estimator`; RAPL and NVML flow through as -//! `Provenance::HwShunt`. +//! RAPL and NVML flow through as `Provenance::HwShunt`. So does Apple +//! Silicon IOReport observed live (`MeasurementMethod::IoReportLive`): +//! a vendor energy counter's reading is a real reading, and what +//! decides its grade is provenance — was the counter live for the +//! domain the figure claims — not accuracy. A bare +//! `MeasurementMethod::IoReport` records no such observation; where +//! the CPU energy rail is frozen (macOS 27.0.1 for an unprivileged +//! reader) the figure came from the estimator, so it flows through as +//! `Provenance::Estimator`. A passport may say its inference energy +//! was measured only when its method is graded `HwShunt`. #![forbid(unsafe_code)] #![warn(missing_docs)] diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/passport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/passport.rs index b17ced9d3..62b5d929c 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/passport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-compliance/src/passport.rs @@ -114,8 +114,19 @@ impl Ord for EnergyLabel { pub enum MeasurementMethod { /// Intel / AMD RAPL counters. Rapl, - /// Apple Silicon IOReport (or estimator until FFI ships). + /// Apple Silicon IOReport, with no record that its CPU energy rail was + /// observed live while the figure was taken. Graded `Estimator`: the + /// counter yields a reading only where that rail advances under load + /// (`jouleclaw_energy::ioreport_status() == Live`), and where it does not + /// — every CPU rail frozen for an unprivileged reader on macOS 27.0.1 — + /// the host's figure is the estimator's. Use [`Self::IoReportLive`] for a + /// figure read while the rail was live. IoReport, + /// Apple Silicon IOReport, read while its CPU energy rail was observed + /// live (`jouleclaw_energy::IoReportStatus::Live`). A live vendor energy + /// counter is a hardware reading, graded `HwShunt` like RAPL and NVML: + /// its trust gap is provenance, which liveness answers, not accuracy. + IoReportLive, /// NVIDIA Management Library. Nvml, /// Combined GPU (NVML) + CPU (RAPL) measurement. @@ -129,11 +140,18 @@ impl MeasurementMethod { /// Pure-Rust translation from the JouleClaw energy contract. pub fn provenance(&self) -> Provenance { match self { - Self::Rapl | Self::Nvml | Self::NvmlRapl => Provenance::HwShunt, + Self::Rapl | Self::Nvml | Self::NvmlRapl | Self::IoReportLive => Provenance::HwShunt, Self::IoReport => Provenance::Estimator, Self::Estimated => Provenance::Estimator, } } + + /// Whether a figure obtained this way is a hardware reading from a live + /// counter — the only grade under which inference energy may be called + /// measured. + pub fn is_live_hardware_reading(&self) -> bool { + self.provenance() == Provenance::HwShunt + } } impl fmt::Display for MeasurementMethod { @@ -141,6 +159,7 @@ impl fmt::Display for MeasurementMethod { match self { Self::Rapl => write!(f, "RAPL"), Self::IoReport => write!(f, "IOReport"), + Self::IoReportLive => write!(f, "IOReport (live)"), Self::Nvml => write!(f, "NVML"), Self::NvmlRapl => write!(f, "NVML+RAPL"), Self::Estimated => write!(f, "Estimated"), @@ -335,6 +354,18 @@ pub enum PassportError { /// A required field was missing. #[error("required field missing: {0}")] MissingField(&'static str), + /// The compliance record says inference energy was measured, but the + /// energy profile's method is not graded as a live hardware reading. + #[error( + "inference energy is claimed measured, but the energy profile's method {method} is graded \ + {provenance}, not a live hardware reading" + )] + MeasuredWithoutHardwareReading { + /// The energy profile's measurement method. + method: MeasurementMethod, + /// That method's grade. + provenance: Provenance, + }, /// JSON encoding failed. #[error("json encoding failed: {0}")] JsonEncode(#[from] serde_json::Error), @@ -485,9 +516,13 @@ impl PassportBuilder { } /// Consume the builder and return a passport. Errors on missing - /// required fields. + /// required fields, and on a compliance record that calls inference + /// energy measured (`inference_energy_measured`) when the energy + /// profile's method is not graded as a live hardware reading + /// ([`MeasurementMethod::is_live_hardware_reading`]): a bare + /// `IoReport`, or `Estimated`. pub fn build(self) -> Result { - Ok(AiServicePassport { + let passport = AiServicePassport { passport_version: self.passport_version, service_id: self.service_id, provider: self.provider, @@ -512,7 +547,15 @@ impl PassportBuilder { .compliance .ok_or(PassportError::MissingField("compliance"))?, pricing: self.pricing.unwrap_or_default(), - }) + }; + let method = passport.energy_profile.measurement_method; + if passport.compliance.eu_ai_act.inference_energy_measured && !method.is_live_hardware_reading() { + return Err(PassportError::MeasuredWithoutHardwareReading { + method, + provenance: method.provenance(), + }); + } + Ok(passport) } } @@ -698,7 +741,13 @@ mod tests { MeasurementMethod::NvmlRapl.provenance(), Provenance::HwShunt ); - // IOReport is Estimator until the FFI is wired — see apple.rs. + // IOReport read while its CPU rail was live is a hardware reading, the + // grade of RAPL and NVML. A bare IoReport records no liveness: where the + // rail is frozen (macOS 27.0.1) the figure is the estimator's. + assert_eq!( + MeasurementMethod::IoReportLive.provenance(), + Provenance::HwShunt + ); assert_eq!( MeasurementMethod::IoReport.provenance(), Provenance::Estimator @@ -717,6 +766,51 @@ mod tests { assert_eq!(p.model.parameters, 70_000_000_000); } + fn build_with(method: MeasurementMethod, measured: bool) -> Result { + let now = Utc::now(); + let mut compliance = sample_compliance(); + compliance.eu_ai_act.inference_energy_measured = measured; + AiServicePassport::builder("svc-method") + .provider("jouleclaw.dev") + .issued_at(now) + .valid_until(now + Duration::days(90)) + .model(sample_model()) + .hardware(sample_hardware()) + .energy_profile(EnergyProfile { measurement_method: method, ..sample_energy_profile() }) + .carbon_profile(sample_carbon_profile()) + .energy_label(sample_label(EnergyLabel::A, 0.35)) + .compliance(compliance) + .build() + } + + /// A passport may call its inference energy measured only when its method is + /// graded as a live hardware reading. A bare IOReport records no liveness, so + /// paired with "measured" it is refused, as an estimate is; IOReport observed + /// live, RAPL and NVML are accepted. Unclaimed, every method builds. + #[test] + fn a_measured_claim_needs_a_live_hardware_reading() { + for method in [MeasurementMethod::IoReport, MeasurementMethod::Estimated] { + match build_with(method, true) { + Err(PassportError::MeasuredWithoutHardwareReading { method: m, provenance }) => { + assert_eq!((m, provenance), (method, Provenance::Estimator)); + } + other => panic!("{method} paired with a measured claim was not refused: {other:?}"), + } + assert!(build_with(method, false).is_ok(), "{method} without a measured claim was refused"); + } + for method in [ + MeasurementMethod::IoReportLive, + MeasurementMethod::Rapl, + MeasurementMethod::Nvml, + MeasurementMethod::NvmlRapl, + ] { + let p = build_with(method, true).unwrap_or_else(|e| panic!("{method} refused: {e}")); + assert!(p.compliance.eu_ai_act.inference_energy_measured); + } + let why = build_with(MeasurementMethod::IoReport, true).unwrap_err().to_string(); + assert!(why.contains("IOReport") && why.contains("estimator") && why.contains("live hardware reading"), "{why}"); + } + #[test] fn build_passport_errors_without_required_field() { let now = Utc::now(); @@ -754,6 +848,7 @@ mod tests { fn measurement_method_display() { assert_eq!(format!("{}", MeasurementMethod::Rapl), "RAPL"); assert_eq!(format!("{}", MeasurementMethod::IoReport), "IOReport"); + assert_eq!(format!("{}", MeasurementMethod::IoReportLive), "IOReport (live)"); assert_eq!(format!("{}", MeasurementMethod::Nvml), "NVML"); assert_eq!(format!("{}", MeasurementMethod::NvmlRapl), "NVML+RAPL"); assert_eq!(format!("{}", MeasurementMethod::Estimated), "Estimated"); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml index 7de432440..dc5cd665d 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/Cargo.toml @@ -16,7 +16,9 @@ serde_json = { workspace = true, optional = true } tracing = { workspace = true, optional = true } nvml-wrapper = { workspace = true, optional = true } -[target.'cfg(target_os = "macos")'.dependencies] +# `libc`: the IOReport backend's CoreFoundation types and thread clock on macOS, +# and the liveness probe's thread CPU clock on every Unix (RAPL on Linux). +[target.'cfg(unix)'.dependencies] libc = "0.2" [features] @@ -25,8 +27,8 @@ serde = ["dep:serde", "dep:serde_json"] # Per-platform energy backends. Off by default so the foundation is OS-neutral; # downstream crates / binaries enable what their target supports. -rapl = [] # Intel/AMD x86 — Linux sysfs powercap -nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumulative-energy counter -ioreport = [] # Apple Silicon M-series — real IOReport energy counter (ModelBased), load-avg estimator fallback +rapl = [] # Intel/AMD x86 — Linux sysfs powercap (HwShunt while its counter is live) +nvml = ["dep:nvml-wrapper", "dep:tracing"] # NVIDIA discrete — NVML cumulative-energy counter (HwShunt while live), power-draw integral (ModelBased) otherwise +ioreport = [] # Apple Silicon M-series — real IOReport energy counter (HwShunt while every CPU rail is live), load-avg estimator fallback ina3221 = [] # NVIDIA Jetson (Orin / Thor) — sysfs i2c shunt reader hwmon = [] # Linux generic — for AMD Strix Halo / Ryzen AI Max iGPU + others diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md index a6cfd7747..6bfb60c9a 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/MEASURED_ENERGY.md @@ -1,11 +1,18 @@ # Measured energy — cross-platform verification JouleClaw energy readings carry an honest [`Provenance`](src/lib.rs): `HwShunt` -(a real shunt / MSR / coulomb counter), `ModelBased` (a vendor freq/voltage/ -utilisation model — a real on-device reading, not a shunt), or `Estimator` (a -static cost model, used only where no counter exists). +(a hardware reading: a shunt, or a vendor energy counter — RAPL, NVML, Apple +IOReport — while it is live for the domain it claims), `ModelBased` (a vendor +freq/voltage/utilisation model), or `Estimator` (a static cost model, used where +no live counter exists). -The goal is that on real hardware a reading is **never** an `Estimator`. The +A vendor energy counter's reading is a real reading; what has to be established +is its provenance — which counter, which domain, and whether it was live — not +its accuracy. A counter that is present but not live (frozen, or republished in +lumps) gives no `HwShunt` figure: the meter falls back to its estimator. + +The goal is that on real hardware a reading is **never** an `Estimator`. It is +not met on Apple Silicon under macOS 27.0.1 (below). The [`energy_probe`](examples/energy_probe.rs) example reads the best available counter for its build features, integrates over a ~2 s CPU-churn window, and prints the measured microjoules with provenance: @@ -20,18 +27,32 @@ cargo run -p jouleclaw-energy --release --example energy_probe --features iorepo | Platform | Counter | Domain | Provenance | Reading (2 s churn) | |---|---|---|---|---| -| Apple Silicon (this dev host) | IOReport "Energy Model" | SoC total | **ModelBased** | 74.1 J · 25.5 W | +| Apple Silicon (this dev host; chip and macOS version not recorded) | IOReport "Energy Model" | SoC total | **ModelBased** (the tag then) | 74.1 J · 25.5 W | | Windows 11 + GeForce RTX 2060 | NVML (`nvml.dll`) | GPU | **HwShunt** | 18.8 J · 9.4 W | | Linux x86 + Tesla T4 (AWS g4dn.metal) | NVML (`libnvidia-ml`) | GPU | **HwShunt** | 148.4 J · 71.8 W | | Linux x86, Xeon Platinum 8259CL (AWS g4dn.metal) | RAPL powercap | CPU package | **HwShunt** | 154.2 J · 77.1 W (1 µJ res) | Notes: -- **Apple** replaced a load-avg `Estimator` (which guessed ~10.7 W for the same - churn — under-reporting the real ~25.5 W by ~2.4×). The IOReport counter - ([`apple_ioreport`](src/apple_ioreport.rs)) reads the SoC's CPU/GPU/ANE/DRAM - Energy Model channels with no sudo. End-to-end, a Joule Code L3 inference on - Apple now emits a receipt tagged `ModelBased`, not `Estimator`. +- **Apple — dated note (2026-09-30).** The Apple row was committed on + 2026-06-12 and predates macOS 27. Which chip and which macOS version it was + read on were not recorded, so it cannot be reproduced or re-checked; it was + tagged `ModelBased`, the grade IOReport carried then, and no liveness check + existed. It stands as a historical figure only. A live IOReport reading is now + graded `HwShunt`, like RAPL and NVML. +- **Apple on macOS 27.0.1.** The IOReport counter + ([`apple_ioreport`](src/apple_ioreport.rs)) opens with no sudo, but an + unprivileged reader does **not** get live CPU energy: every CPU rail in the + Energy Model group holds its value under minutes of full CPU load, moving only + in batch refreshes tens of minutes apart. Observed on an M5 Max (`CPU Energy` + frozen while `GPU Energy` advances) and on an M3 Ultra (all 645 Energy Model + rails frozen, CPU, DRAM and display included). So the counter checks that its + CPU rail is live before it reads (`ioreport_status()`), declines when it is + not, and the load-avg estimator meters the host instead: on those hosts a + Joule Code L3 inference emits a receipt tagged `Estimator`. Where the + rail is live, the receipt is tagged `HwShunt`. The load-avg estimator is + coarse: on the dev host it once guessed ~10.7 W for the churn above, against + 25.5 W read from IOReport. - **NVML** is one cross-platform counter ([`nvml`](src/nvml.rs)) — the same code reports `HwShunt` on Windows and Linux; it loads the driver's NVML library by name on each OS. @@ -42,6 +63,8 @@ Notes: ## Not yet a hardware reading +- **Apple Silicon CPU energy on macOS 27.0.1**, for an unprivileged process — + the IOReport CPU rails are frozen (above), so the load-avg estimator meters it. - **x86 CPU energy on Windows** — RAPL MSRs need a kernel driver (e.g. WinRing0) on Windows; the NVML GPU path is the Windows `HwShunt` route. (CPU-side is an `Estimator` there for now.) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/PROTOCOL.md b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/PROTOCOL.md index 532ead769..8c8947840 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/PROTOCOL.md +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/PROTOCOL.md @@ -3,11 +3,15 @@ Honest energy accounting requires honest energy readings. The [`EnergyCounter`] trait in this crate names three provenance tiers: -- **HwShunt** — a hardware shunt reading (Intel/AMD RAPL MSR, - NVML pstate sampling, board-level current sense, IPMI sensor). - Authoritative. +- **HwShunt** — a hardware reading: a shunt (board-level current + sense, IPMI sensor), or a vendor energy counter (Intel/AMD RAPL + MSR, NVML cumulative energy, Apple IOReport) while it is + demonstrably live for the domain the figure claims — the + measurer observed the counter covering that domain advance under + load. A counter that is present but not live (frozen, or + republished in lumps) gives no HwShunt figure. Authoritative. - **ModelBased** — a vendor-published energy model (DCGM-X, - Apple `powermetrics`, vendor whitepapers) consulted at run time. + NVML `power.draw`, vendor whitepapers) consulted at run time. Trustworthy if the model itself was validated by HwShunt. - **Estimator** — anything derived from wall-clock × static power ceiling. Useful as a floor; never as a ceiling. @@ -37,8 +41,12 @@ Every workload measurement MUST capture: 1. **Hardware identification** — `vendor`, `model`, `tdp_w` (datasheet), `host_os`. 2. **Counter** — which trait impl was used (`RaplCounter`, - `NvmlCounter`, `AppleEstimator`, or a vendor-specific impl). -3. **Provenance** — `HwShunt` | `ModelBased` | `Estimator`. + `NvmlCounter`, `AppleIoReportCounter`, `AppleSiliconMeter`, or a + vendor-specific impl). +3. **Provenance** — `HwShunt` | `ModelBased` | `Estimator`. `HwShunt` + only for a counter observed live over the run (for Apple IOReport, + `ioreport_status()` returned `Live`); record in `method` how + liveness was established. 4. **Resolution** — `resolution_uj` (smallest delta the counter meaningfully reports) and `min_window_ns` (smallest sampling window below which the reading is noise-dominated). Honest diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/README.md b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/README.md index da57ccfc9..b5bc5e19d 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/README.md +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/README.md @@ -24,7 +24,7 @@ under named workloads. This directory carries the second half. | `vendors/amd-cpu-rapl.json` | `RaplCounter` | HwShunt | | `vendors/nvidia-gpu-nvml.json` | `NvmlCounter` | HwShunt | | `vendors/amd-gpu-rocm.json` | `RocmSmiCounter` | HwShunt | -| `vendors/apple-silicon-ioreport.json` | `AppleIoReport` | ModelBased | +| `vendors/apple-silicon-ioreport.json` | `AppleIoReportCounter` | HwShunt, while live (below) | | `vendors/huawei-ascend-npu.json` | `AscendSmiCounter` | HwShunt | The Cloud TPU, Intel Gaudi, Cambricon, and Qualcomm Cloud AI @@ -63,6 +63,18 @@ past peer review. | Tag | What you're trusting | Use case | |---|---|---| -| `HwShunt` | A hardware shunt or vendor-grade MSR | Authoritative; the circuit breaker may use these directly. | -| `ModelBased` | A validated vendor energy model (Apple `powermetrics`, DCGM-X) | Trustworthy iff the model was validated against HwShunt elsewhere. | +| `HwShunt` | A hardware shunt, or a vendor energy counter (RAPL, NVML, Apple IOReport) while it is live for the domain it claims | Authoritative; the circuit breaker may use these directly. | +| `ModelBased` | A validated vendor energy model (DCGM-X, NVML `power.draw`) | Trustworthy iff the model was validated against HwShunt elsewhere. | | `Estimator` | Wall-clock × static TDP, or a hand-tuned heuristic | A floor, never a ceiling. The breaker treats Estimator as worst-case. | + +A vendor energy counter's reading is a real reading; what a fixture must +pin down is its provenance, not its accuracy. A `HwShunt` figure in a +fixture is a claim that the counter was demonstrably live for the domain +the figure claims while the workload ran: the measurer observed the counter +covering that domain advance under load. For Apple IOReport that means +`jouleclaw_energy::ioreport_status()` was `Live` (the CPU energy rail +advanced in at least 5 sample intervals, in at least 4 of every 5, over at +least 300 ms while the probing thread burned CPU). A counter that is present +but not live — frozen, or republished in lumps, as the CPU rails are for an +unprivileged reader on macOS 27.0.1 — gives no `HwShunt` figure; that run is +an `Estimator` run, or no run at all. diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/vendors/apple-silicon-ioreport.json b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/vendors/apple-silicon-ioreport.json index a092016fe..96b3f1e55 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/vendors/apple-silicon-ioreport.json +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/conformance/vendors/apple-silicon-ioreport.json @@ -8,8 +8,8 @@ "host_os": null }, "counter": { - "impl": "AppleIoReport", - "provenance": "ModelBased", + "impl": "AppleIoReportCounter", + "provenance": "HwShunt", "resolution_uj": 1000, "min_window_ns": 10000000 }, diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs index e35ce0e42..50739b31f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/examples/energy_probe.rs @@ -1,9 +1,9 @@ //! Cross-platform energy probe — read the best available hardware counter on -//! this host and print a **measured** energy delta with its honest provenance. +//! this host and print its energy delta with its honest provenance. //! -//! The point of the probe: prove that on real hardware JouleClaw reports a -//! `HwShunt` / `ModelBased` reading, not an `Estimator` fallback. Build with the -//! feature that matches the host's counter: +//! The point of the probe: show whether on real hardware JouleClaw reports a +//! `HwShunt` / `ModelBased` reading or an `Estimator` fallback, and label the +//! figure accordingly. Build with the feature that matches the host's counter: //! //! # NVIDIA discrete GPU (Linux or Windows — loads libnvidia-ml / nvml.dll): //! cargo run -p jouleclaw-energy --release --example energy_probe --features nvml @@ -11,7 +11,8 @@ //! # Intel/AMD x86 on Linux (powercap sysfs RAPL): //! cargo run -p jouleclaw-energy --release --example energy_probe --features rapl //! -//! # Apple Silicon (load-avg estimator until IOReport is wired): +//! # Apple Silicon (IOReport Energy Model when its CPU rail is live, else the +//! # load-avg estimator, with the reason printed): //! cargo run -p jouleclaw-energy --release --example energy_probe --features ioreport //! //! It samples the counter across a ~2 s window (driving a small CPU workload so @@ -29,16 +30,17 @@ fn pick_counter() -> Option<(&'static str, Box)> { { let c = jouleclaw_energy::nvml::NvmlCounter::new(); if c.is_available() { - // Report which NVML path is live — the true cumulative-energy - // counter (accurate even when `power_usage()`/`nvidia-smi power.draw` - // report garbage) or the power-integration fallback. + // Report which NVML path reads — the cumulative-energy counter, + // only where it was observed live (HwShunt), or the power-draw + // integral (ModelBased) — and the counter's liveness. println!( - "nvml path : {}", + "nvml path : {} (counter liveness: {:?})", if c.uses_true_counter() { - "nvmlDeviceGetTotalEnergyConsumption (true cumulative counter)" + "nvmlDeviceGetTotalEnergyConsumption (live cumulative counter, HwShunt)" } else { - "power_usage() integration (fallback)" - } + "power_usage() integration (ModelBased)" + }, + c.liveness() ); return Some(("nvml (NVIDIA GPU shunt)", Box::new(c))); } @@ -46,18 +48,23 @@ fn pick_counter() -> Option<(&'static str, Box)> { #[cfg(feature = "rapl")] { let c = jouleclaw_energy::rapl::RaplCounter::new(); + println!("rapl : liveness {:?}", c.liveness()); if c.is_available() { return Some(("rapl (x86 package MSR)", Box::new(c))); } } #[cfg(all(feature = "ioreport", target_os = "macos"))] { - // Prefer the real IOReport energy-model counter (ModelBased); fall back - // to the load-avg estimator only if IOReport will not open. + // Prefer the real IOReport energy counter (HwShunt, like RAPL and NVML). + // It is unavailable unless IOReport opens AND its CPU energy rail + // advances under load — a counter that is present but not live gives no + // hardware-graded figure; say which before falling back to the load-avg + // estimator. let c = jouleclaw_energy::apple_ioreport::AppleIoReportCounter::new(); if c.is_available() { return Some(("apple-ioreport (Energy Model)", Box::new(c))); } + println!("ioreport : declined ({:?})", c.status()); let c = jouleclaw_energy::apple::AppleSiliconMeter::new(); if c.is_available() { return Some(("apple (load-avg estimator)", Box::new(c))); @@ -82,6 +89,7 @@ fn churn(deadline: Instant) -> u64 { } fn main() { + println!("== JouleClaw energy probe =="); let Some((label, counter)) = pick_counter() else { eprintln!( "no hardware energy counter available on this host/build.\n\ @@ -90,7 +98,6 @@ fn main() { std::process::exit(1); }; - println!("== JouleClaw energy probe =="); println!("counter : {label}"); println!("provenance : {}", counter.provenance()); println!("domain : {:?}", counter.domain()); @@ -128,7 +135,12 @@ fn main() { let elapsed = start.elapsed().as_secs_f64(); let delta_uj = after.uj.saturating_sub(before.uj); let joules = delta_uj as f64 / 1_000_000.0; - println!("--- measured ---"); + // An estimator's figure is not a measurement; say which one this is. + if after.provenance == jouleclaw_energy::Provenance::Estimator { + println!("--- estimated ---"); + } else { + println!("--- measured ---"); + } println!("window : {:.3} s", elapsed); println!( "energy : {} uj ({:.3} J) avg {:.2} W [{}]", diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs index 5d3423983..fad013e91 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple.rs @@ -1,10 +1,12 @@ //! Apple Silicon energy estimator — `Provenance::Estimator`. //! -//! **HONEST DISCLOSURE.** The production-quality path is IOReport's -//! `"Energy Model"` channel group (`PMP`/`Energy Model` keys), which -//! returns vendor-modelled package energy at ~1 mJ / ~10 ms. JouleClaw -//! has not yet wired the IOReport FFI; until then this backend uses -//! the system 1-minute load average, normalises it against +//! **HONEST DISCLOSURE.** The measured Apple path is IOReport's +//! `"Energy Model"` channel group, wired as +//! [`crate::apple_ioreport::AppleIoReportCounter`] (`HwShunt`, while live). This +//! backend is the fallback wherever that counter is unavailable: IOReport +//! will not open, or its CPU energy rail does not keep advancing under load +//! (macOS 27.0.1 on an M5 Max and an M3 Ultra; [`crate::ioreport_status`] +//! says which). It uses the system 1-minute load average, normalises it against //! `available_parallelism`, and interpolates between a fixed //! `idle_watts = 8 W` and `peak_watts = 40 W` band. //! @@ -14,9 +16,9 @@ //! ~100 ms (load-average is updated by the kernel at ~5 s but quoted //! at much higher frequency; the practical noise floor is ~100 ms). //! -//! When the IOReport bridge ships, this module should be replaced or -//! re-tagged as `Provenance::ModelBased`. **DO NOT change the tag in -//! place** — the breaker depends on it. +//! It is selected precisely when no live reading exists, so it stays +//! `Provenance::Estimator` on every host. **DO NOT change the tag** — +//! the breaker depends on it. use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -87,7 +89,7 @@ impl EnergyCounter for AppleSiliconMeter { } fn provenance(&self) -> Provenance { - // Until IOReport is wired this is unambiguously an Estimator. + // A load-average model: unambiguously an Estimator, on every host. Provenance::Estimator } @@ -151,7 +153,7 @@ mod tests { fn apple_meter_declares_estimator_provenance() { let m = AppleSiliconMeter::new(); // The honesty tag is what the breaker reads. It MUST be - // Estimator until IOReport ships. + // Estimator: this is what runs when IOReport has no live CPU rail. assert_eq!(m.provenance(), Provenance::Estimator); assert_eq!(m.domain(), EnergyDomain::SocTotal); assert_eq!(m.name(), "apple-silicon"); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs index 8a8c31f1b..9f6144367 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/apple_ioreport.rs @@ -1,16 +1,56 @@ -//! Apple Silicon **IOReport** energy counter — `Provenance::ModelBased`. +//! Apple Silicon **IOReport** energy counter — a hardware reading +//! (`Provenance::HwShunt`) while its CPU energy rail is live, and no reading at +//! all otherwise. //! //! This is the measured Apple path: it reads the SoC's `Energy Model` channels //! (CPU / GPU / ANE / DRAM + independent rails) directly through Apple's private //! IOReport C API — the same counters `powermetrics`, Activity Monitor, and -//! Xcode Instruments use. Unlike `powermetrics` it needs **no sudo**, samples at -//! arbitrary instants, and gives ~1 mJ resolution per channel. +//! Xcode Instruments use. Unlike `powermetrics` it opens with **no sudo**, +//! samples at arbitrary instants, and gives ~1 mJ resolution per channel; but an +//! unprivileged reader is not always given live CPU rails (below). //! -//! Apple's Energy Model is a vendor model derived from frequency, voltage, and -//! utilisation — a real on-device reading, but not a coulomb-counter shunt — so -//! it is tagged [`Provenance::ModelBased`], the honest tier between a hardware -//! shunt and a static estimator. It replaces the load-avg -//! [`crate::apple::AppleSiliconMeter`] (`Estimator`) wherever IOReport opens. +//! **Grade.** A vendor energy counter (RAPL, NVML, IOReport) is a real reading +//! of the silicon's own energy accounting. What separates one such reading from +//! another is provenance — which counter, over which domain, and whether it was +//! live when it was read — not accuracy. So a reading from this counter carries +//! the grade this crate gives RAPL (`RaplCounter`) and NVML (`NvmlCounter`): +//! [`Provenance::HwShunt`]. It may carry that grade only while the counter is +//! demonstrably live for the domain the figure claims (JouleClaw SPEC §1.3): +//! the counter exists to read only where its CPU energy rail was observed +//! advancing under load, and a counter that is present but not live produces no +//! figure at all. It replaces the load-avg [`crate::apple::AppleSiliconMeter`] +//! (`Estimator`) wherever IOReport opens **and its CPU energy rail is live**. +//! +//! **CPU-rail liveness.** An open subscription is not a working meter. On macOS +//! 27.0.1, for an unprivileged reader, every CPU rail in the Energy Model group +//! holds its cumulative value under minutes of full CPU load, moving only in +//! batch updates tens of minutes apart: on an M5 Max (`CPU Energy`, while +//! `GPU Energy` keeps advancing) and on an M3 Ultra (`DIE__CPU Energy`). +//! Summing the rails would then report GPU-only energy, or zero, as the energy +//! of CPU work. So [`AppleIoReportCounter::new`] is available only when every +//! CPU rail the counter sums itself keeps advancing — `CPU Energy`, or each +//! `DIE__CPU Energy` on a multi-die part — between consecutive samples, in +//! at least five separate intervals, four in every five intervals observed, +//! over at least 300 ms of wall-clock time, while the probing thread burns +//! 25 ms of its own CPU time between samples; probed once per process and +//! cached, see [`crate::ioreport_status`]. A batch refresh, which macOS 27 +//! publishes in one step or in a few steps tens of milliseconds apart, cannot +//! meet that, and neither can one live die beside a frozen one: the summed +//! figure would leave the frozen die's energy out while claiming the whole +//! SoC. Otherwise the counter is unavailable and yields no reading, +//! [`crate::meter::detect_meter`] falls through to the load-avg estimator, and +//! that reading says `Estimator`. +//! +//! **No partial reading from the GPU rail.** Where the CPU rail is frozen but +//! `GPU Energy` still advances (the M5 Max on macOS 27.0.1), the counter is +//! declined; it does not report the GPU rail as a partial reading of the work. +//! The GPU rail is machine-wide: it meters every client of the GPU on the host, +//! the window server's display compositing among them, and it moves while +//! nothing of this process runs. A caller metering CPU work would be handed a +//! figure whose movement is mostly someone else's, labelled a partial reading of +//! its own work, and no coverage field makes that honest: the rail does not +//! cover part of the work, it covers other work. So a frozen CPU rail yields no +//! reading here, and the host is estimated. //! //! IOReport is private API. Apple has not deprecated it for M1+ in any released //! macOS; if the surface ever changes, [`AppleIoReportCounter::new`] returns an @@ -24,10 +64,11 @@ use std::os::raw::{c_int, c_long, c_void}; use std::sync::Mutex; -use std::time::{SystemTime, UNIX_EPOCH}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use crate::meter::{EnergyMeter, EnergySource}; -use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance}; +use crate::{EnergyCounter, EnergyDomain, EnergyError, EnergyReading, IoReportStatus, Provenance}; // ── Minimal CoreFoundation / IOReport FFI ─────────────────────────────────── // All CF handles are opaque pointers; we never dereference them in Rust. @@ -42,6 +83,13 @@ const K_CFSTRING_ENCODING_UTF8: u32 = 0x0800_0100; 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, @@ -101,6 +149,21 @@ struct State { cumulative_uj: u64, } +#[cfg(test)] +impl State { + /// A subscription with no IOReport object behind it, for an opener a test + /// has scripted ([`seam`]): every sample of it comes from the script, and + /// dropping it releases nothing. + fn inert() -> Self { + Self { + sub: std::ptr::null(), + channels: std::ptr::null_mut(), + prior_sample: None, + cumulative_uj: 0, + } + } +} + impl Drop for State { fn drop(&mut self) { unsafe { @@ -117,10 +180,13 @@ impl Drop for State { } } -/// Apple Silicon IOReport energy counter (`ModelBased`, SoC-total). +/// Apple Silicon IOReport energy counter (SoC-total). Its readings are graded +/// [`Provenance::HwShunt`], and it produces them only while its CPU energy rail +/// is live; see the module docs. pub struct AppleIoReportCounter { state: Mutex>, available: bool, + status: IoReportStatus, } // The raw CF pointers are confined to `State` behind a `Mutex`, and IOReport @@ -136,23 +202,59 @@ impl Default for AppleIoReportCounter { impl AppleIoReportCounter { /// Open an IOReport subscription over all advertised channels (the energy - /// group is filtered at sample-walk time). Returns an unavailable counter if - /// IOReport cannot be opened (non-Apple-Silicon, Rosetta, or a future macOS - /// that changes the surface). + /// group is filtered at sample-walk time) — **only if** this host's CPU + /// energy rail is live, i.e. [`crate::ioreport_status`] is + /// [`IoReportStatus::Live`]. + /// + /// Otherwise returns an unavailable counter: `is_available()` is `false`, + /// [`Self::status`] says why, and every `read()` fails with + /// [`EnergyError::NoCounter`] — it never produces a number. That covers + /// IOReport not opening (non-Apple-Silicon, Rosetta, a future macOS that + /// changes the surface), no CPU energy rail in the Energy Model group, and + /// a CPU rail that does not advance under load (macOS 27.0.1 on an M5 Max + /// and an M3 Ultra), where the summed rails would pass GPU-only energy, or + /// zero, off as the energy of CPU work. + /// + /// The first call in a process runs the liveness probe on the calling + /// thread (see [`crate::ioreport_status`] for its cost); later calls reuse + /// the cached answer. The status comes from that cache and nowhere else; + /// the gate on it is `new_given`, which the tests drive with every status. pub fn new() -> Self { + Self::new_given(crate::ioreport_status()) + } + + /// The counter for a given host IOReport `status`. Unless the status + /// [`admits`] a counter (it is `Live`), it is declined before anything is + /// opened. Otherwise the all-channel subscription is opened, and if that + /// fails the counter is declined as `Unavailable`. Production passes the + /// cached [`crate::ioreport_status`] ([`Self::new`]); the tests pass every + /// status. + fn new_given(status: IoReportStatus) -> Self { + if !admits(status) { + return Self::declined(status); + } match Self::open() { Some(state) => Self { state: Mutex::new(Some(state)), available: true, + status, }, - None => Self { - state: Mutex::new(None), - available: false, - }, + None => Self::declined(IoReportStatus::Unavailable), + } + } + + /// A counter that meters nothing: no subscription, `read()` always fails. + fn declined(status: IoReportStatus) -> Self { + Self { + state: Mutex::new(None), + available: false, + status, } } fn open() -> Option { + #[cfg(test)] + seam::all_channel_open(); unsafe { let all = IOReportCopyAllChannels(0, 0); if all.is_null() { @@ -186,21 +288,573 @@ impl AppleIoReportCounter { } } - /// Whether IOReport opened successfully. + /// A subscription to the `Energy Model` group alone, for the liveness probe. + /// Its CPU rails are the same counters the counter's own all-channel + /// subscription ([`Self::open`]) reads, at the same absolute values, but one + /// sample of this group takes ~6 ms, against 0.1–1 s for every channel on + /// an M5 Max (~11,860 channels) and ~0.14–0.26 s on an M3 Ultra. On every + /// channel the probe would spend most of its budget inside IOReport rather + /// than burning CPU between samples. `None` if the group cannot be + /// subscribed. Every call is counted in [`GROUP_OPENS`]. In test builds, a + /// thread can script whether it opens (the test-only `seam`); a scripted + /// open is not counted there, since it asks IOReport for nothing. + fn open_energy_model() -> Option { + #[cfg(test)] + if let Some(opens) = seam::group_open() { + return opens.then(State::inert); + } + GROUP_OPENS.fetch_add(1, Ordering::SeqCst); + 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; + } + 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() { + return None; + } + if subscribed.is_null() { + CFRelease(sub); + return None; + } + Some(State { + sub, + channels: subscribed, + prior_sample: None, + cumulative_uj: 0, + }) + } + } + + /// Whether this counter meters: IOReport opened **and** the host's CPU + /// energy rail is live. When `false`, [`Self::status`] says why. pub fn is_available(&self) -> bool { self.available } + + /// Why this counter is, or is not, available. It is [`IoReportStatus::Live`] + /// exactly when [`Self::is_available`] is `true`. + pub fn status(&self) -> IoReportStatus { + self.status + } } +/// Whether a host IOReport status lets the counter meter: `Live`, and nothing +/// else. `Unavailable` saw nothing. `CpuRailAbsent` has no CPU rail to meter. +/// `CpuRailFrozen` would pass GPU-only energy, or zero, off as the energy of +/// CPU work. The match is exhaustive, so a new status has to be decided here. +fn admits(status: IoReportStatus) -> bool { + match status { + IoReportStatus::Live => true, + IoReportStatus::Unavailable | IoReportStatus::CpuRailAbsent | IoReportStatus::CpuRailFrozen => false, + } +} + +// ── CPU-rail liveness ─────────────────────────────────────────────────────── +// +// The rule is the family's sustained-advance rule, shared with the RAPL and NVML +// backends (`crate::liveness`), with its numbers, so the backends cannot drift +// apart: every channel the figure sums must be live. What differs here is what +// is watched: the CPU energy rails among the Energy Model group's many channels +// (`RailWatch`), each keyed on its own, and not the other channels. + +/// How long (wall clock) the liveness probe watches the CPU energy rail, +/// burning CPU on its thread between samples, before concluding the rail is +/// frozen. A live Energy Model rail moves many times a second (the M5 Max GPU +/// rail changed in 166 of 176 samples ~28 ms apart), and the probe samples the +/// `Energy Model` group alone after every [`PROBE_TICK`] of its own CPU time, +/// so an unloaded second holds ~30 intervals of ~31 ms, where a live rail +/// needs ~10 of them (300 ms, `MIN_LIVE_WINDOW`) to prove itself; a loaded +/// host gives fewer, longer intervals, each of them still after a whole tick +/// of the probing thread's CPU time, and the probe runs past this budget until +/// it has five intervals (`MIN_LIVE_ADVANCES`). A slower live rail fails safe — the +/// counter is declined and the host estimated, never over-claimed. Paid once +/// per process, see [`crate::ioreport_status`]. +pub const CPU_RAIL_LIVENESS_BUDGET: Duration = Duration::from_millis(1000); + +/// Thread CPU time the probing thread burns before each liveness re-sample, +/// measured on its own `CLOCK_THREAD_CPUTIME_ID` clock, so a host whose other +/// work deschedules the probe still sees the probe's CPU load in every +/// interval. +const PROBE_TICK: Duration = Duration::from_millis(25); + +/// The most wall-clock time one [`PROBE_TICK`] may take. Only a host so loaded +/// that this thread gets less than a tick of CPU in this long cuts a tick +/// short; the probe's wall-clock budget and cap bound the probe either way. +const PROBE_TICK_WALL_CAP: Duration = Duration::from_millis(500); + +/// In at least how many separate sample intervals each CPU rail must advance to +/// count as live. One is not enough, nor two: a batch refresh can be published +/// in two steps. See [`RailWatch`]. +const MIN_LIVE_ADVANCES: u32 = crate::liveness::MIN_LIVE_ADVANCES; + +/// The least share of all the intervals observed so far in which each rail +/// must itself have advanced, as (numerator, denominator): four in every five. +/// A live rail advances in nearly every interval; a refresh's few steps are a +/// small share of any window that meets [`MIN_LIVE_WINDOW`]. +const LIVE_SHARE: (u64, u64) = crate::liveness::LIVE_SHARE; + +/// The least wall-clock span, from the probe's first sample to its latest, +/// over which those advances must have been observed. Five ~31 ms intervals +/// span ~155 ms, which a refresh published in a few quick steps can fill; a +/// window of 300 ms is ~10 of them, of which a refresh's steps are too few. +const MIN_LIVE_WINDOW: Duration = crate::liveness::MIN_LIVE_WINDOW; + +/// The probe never runs longer than this many budgets, however slow or failing +/// its samples are. +const PROBE_CAP_BUDGETS: u32 = crate::liveness::PROBE_CAP_BUDGETS; + +/// What the liveness probe saw of the CPU energy rails. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CpuRail { + /// Every CPU energy rail in the samples advanced, interval after interval, + /// while the probing thread burned CPU: each in at least + /// [`MIN_LIVE_ADVANCES`] intervals, at least [`LIVE_SHARE`] of them, over + /// at least [`MIN_LIVE_WINDOW`]. + Live, + /// A CPU rail exists but not every one kept advancing: one held its value + /// across the whole burn, or moved only in the few steps of a batch + /// refresh — not a live reading of everything the counter sums. + Frozen, + /// No CPU energy channel exists in the `Energy Model` group. + Absent, +} + +/// One CPU rail within a sample: its channel name, and its ordinal among the +/// CPU rails of that name in the same sample. A name alone is not a key: the M3 +/// Ultra's Energy Model group carries 264 duplicated channel names (per-die +/// sub-rails with no `DIE__` prefix), and two same-named rails stepping in +/// one refresh must count as one step each, not as two steps of one rail. +type RailKey = (String, u32); + +/// The CPU rails of one sample, keyed, at their absolute raw values: every +/// channel that [`is_cpu_energy_rail`], in sample order. +fn cpu_rails(sample: &[(String, i64)]) -> Vec<(RailKey, i64)> { + let mut rails: Vec<(RailKey, i64)> = Vec::new(); + for (name, raw) in sample.iter().filter(|(name, _)| is_cpu_energy_rail(name)) { + let ordinal = rails.iter().filter(|((n, _), _)| n == name).count() as u32; + rails.push(((name.clone(), ordinal), *raw)); + } + rails +} + +/// The liveness decision, kept apart from the FFI so it can be tested on its +/// own. It is fed whole samples — each channel's name and ABSOLUTE raw integer +/// value — each with the wall-clock time it was taken, and compares each +/// sample's CPU rails with the previous sample's. +/// +/// The counter is live only when **every** CPU rail in the samples keeps +/// advancing, each on its own: each advanced in at least [`MIN_LIVE_ADVANCES`] +/// separate sample intervals, those are at least [`LIVE_SHARE`] (four in five) +/// of all the intervals observed so far, and the samples so far span at least +/// [`MIN_LIVE_WINDOW`] of wall-clock time from the first. It is decided after +/// every interval, and is live as soon as all three hold for every rail. +/// +/// Why every rail. The counter sums every CPU rail (`CPU Energy`, or on a +/// multi-die part each `DIE__CPU Energy`; [`counts_once`]), so a figure +/// from it claims each one's energy. On an M3 Ultra, `DIE_0_` live beside a +/// frozen `DIE_1_` would give a figure that leaves the frozen die out while +/// claiming the whole SoC. This is the rule `crate::liveness` holds RAPL and +/// NVML to: every channel the figure sums. A rail counts from the first sample +/// it is in, the baseline included, so a rail that drops out of later samples +/// stops advancing and holds the counter back, never drops out of the rule. A +/// die the probe's burn does not run on must still be seen advancing: its idle +/// draw is energy the figure claims too. Where a live die's idle draw is too +/// small to step its rail in four of every five ~31 ms intervals, the counter +/// is declined and the host estimated — the safe side, never an over-claim. +/// +/// Why all three. On macOS 27.0.1 the CPU rail is not permanently frozen: it +/// is batch-refreshed, tens of minutes apart. Watched for 25 minutes, an M3 +/// Ultra's `DIE_0_` / `DIE_1_CPU Energy` changed exactly once, both dies in +/// the same 100 ms sample (+1,344,869 and +1,391,637 mJ), and an M5 Max's +/// `CPU Energy` not at all in 13,614 samples — though it had moved from +/// 480,083,160 to 541,893,638 mJ earlier that hour, and a probe running at +/// that moment saw it move. And a refresh is not always one step: on an M5 +/// Max (2026-09-30), a watcher sampling the group alone every ~16 ms saw +/// `CPU Energy` go 2,643,843,378 → 2,705,607,863 (+61,764,485) and, 64 ms +/// later, → 2,705,611,298 (+3,435); `MCPU0`, `MCPU1` and `PCPU` each stepped +/// twice with it. The probe's intervals are ~31 ms, so a probe window holding +/// that refresh sees the rail advance in two intervals, and a rule that asked +/// for two called it live — for the whole process, since the answer is +/// cached. To be live under this rule a refresh would have to step in at +/// least five intervals of its own, four in every five, sustained for 300 ms +/// (eight of the probe's first ten ~31 ms intervals): that is a live rail, not +/// a refresh. A live rail advances in nearly every interval: the M5 Max GPU +/// rail changed in 13,125 of those 13,614 samples, 100 ms apart (96%). +/// +/// Each interval is one pair of consecutive samples, and each rail +/// ([`RailKey`]) advances at most once in it. Comparing every sample with the +/// first one instead would count one refresh again at every later sample. +/// Counting per rail keeps two dies' separate steps, or two same-named rails +/// stepping in one refresh, from adding up to a false "live". +#[derive(Debug, Default)] +struct RailWatch { + /// Sample intervals observed so far: pairs of consecutive samples compared. + intervals: u32, + /// The wall-clock time of the first sample; `None` before it. + first_at: Option, + /// How much wall-clock time the samples so far span: from the first to the + /// latest. + window: Duration, + /// The previous sample's CPU rails, at their absolute raw values; `None` + /// before the first sample. + prev: Option>, + /// Every CPU rail seen in any sample, the baseline included, in the order + /// first seen, with the number of intervals in which it advanced. + rails: Vec<(RailKey, u32)>, +} + +impl RailWatch { + /// Fold in the next sample: every channel in it, as (name, absolute raw + /// value), and `at`, the wall-clock time it was taken, on any clock whose + /// origin is fixed for the watch. Its CPU rails are compared with the + /// previous sample's; the first sample is only the baseline for the next, + /// and the start of the window. In test builds, a thread that records (the + /// test-only `seam`) keeps every sample and its time exactly as they + /// arrive here: what the rule was handed, not what some earlier step meant + /// to hand it. + fn observe(&mut self, sample: &[(String, i64)], at: Duration) { + #[cfg(test)] + seam::fed(sample, at); + let cur = cpu_rails(sample); + let first_at = *self.first_at.get_or_insert(at); + let prev = self.prev.take(); + if prev.is_some() { + self.intervals += 1; + self.window = at.saturating_sub(first_at); + } + for (key, now) in &cur { + // A rail with no value in the previous sample (the baseline, or a + // rail new in this one) has nothing to advance from. + let advanced = prev.as_ref().is_some_and(|prev| prev.iter().any(|(k, before)| k == key && now > before)); + match self.rails.iter_mut().find(|(k, _)| k == key) { + Some((_, count)) => *count += u32::from(advanced), + None => self.rails.push((key.clone(), u32::from(advanced))), + } + } + self.prev = Some(cur); + } + + /// Has every CPU rail seen kept advancing — each in enough separate + /// intervals, in enough of all the intervals so far, over a long enough + /// window — to be live? No CPU rail at all is never live. + fn is_live(&self) -> bool { + self.window >= MIN_LIVE_WINDOW + && !self.rails.is_empty() + && self.rails.iter().all(|(_, advances)| self.sustained(*advances)) + } + + /// Whether a rail that advanced in `advances` intervals did so in at least + /// [`MIN_LIVE_ADVANCES`] of them and in at least [`LIVE_SHARE`] of all the + /// intervals observed so far. + fn sustained(&self, advances: u32) -> bool { + let (num, den) = LIVE_SHARE; + advances >= MIN_LIVE_ADVANCES && u64::from(advances) * den >= u64::from(self.intervals) * num + } + + /// Keep sampling? Until the rails have proved live or `budget` is spent — but + /// never stop before a live rail could have shown [`MIN_LIVE_ADVANCES`] + /// advances (slow IOReport samples must not make a live rail look + /// frozen), up to a hard cap of [`PROBE_CAP_BUDGETS`] budgets. + fn wants_more(&self, elapsed: Duration, budget: Duration) -> bool { + !self.is_live() + && (elapsed < budget + || (self.intervals < MIN_LIVE_ADVANCES && elapsed < budget * PROBE_CAP_BUDGETS)) + } + + /// The verdict, or `None` if not a single interval could be observed. + 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 liveness probe's loop, over any sampler, tick and clock: take a +/// baseline sample, then `tick` and sample again for as long as +/// [`RailWatch::wants_more`] says, feeding every sample that came back to the +/// rule with the clock's reading as it came back. A failed sample (`None`) is +/// skipped, and the next good one is compared with the last good one; the +/// clock still runs, so a sampler that keeps failing ends at the cap with no +/// verdict. `now` is the wall-clock time since any fixed origin. The real +/// probe ([`probe_host_status`], through [`probe_with`]) passes the `Energy +/// Model` group sampler, a burn of [`PROBE_TICK`] of thread CPU time +/// ([`probe_tick`]) and [`Instant`]; the tests pass scripts. +fn watch( + mut sample: impl FnMut() -> Option>, + mut tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, +) -> RailWatch { + let started = now(); + let mut w = RailWatch::default(); + 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 +} + +/// The status a probe verdict maps to; no verdict at all means IOReport could +/// not be sampled — the probe could not subscribe to the `Energy Model` group, +/// or its subscription never gave it one interval. The probe saw nothing then, +/// so it can say neither that a CPU rail is live nor that none exists. +fn status_of(verdict: Option) -> IoReportStatus { + match verdict { + None => IoReportStatus::Unavailable, + Some(CpuRail::Live) => IoReportStatus::Live, + Some(CpuRail::Frozen) => IoReportStatus::CpuRailFrozen, + Some(CpuRail::Absent) => IoReportStatus::CpuRailAbsent, + } +} + +/// How many times this process has run the real liveness probe +/// ([`probe_host_status`]), on any thread: bumped first thing in every run. +/// Read through [`crate::ioreport_probe_runs`] by the test that pins +/// [`crate::ioreport_status`] to one run per process, from a process of its +/// own. +pub(crate) static REAL_RUNS: AtomicUsize = AtomicUsize::new(0); + +/// How many times this process has asked IOReport for a subscription to the +/// `Energy Model` group ([`AppleIoReportCounter::open_energy_model`]), on any +/// thread. In production only the probe asks, once a run, so this equals +/// [`REAL_RUNS`] — unless some path re-implements the probe around the cache +/// (a constructor that opens and watches the group itself, say), which the +/// same once-per-process test then sees. Read through +/// [`crate::ioreport_group_opens`]. +pub(crate) static GROUP_OPENS: AtomicUsize = AtomicUsize::new(0); + +/// This host's IOReport status from one real, uncached run of the liveness +/// probe: count the run, then map ([`status_of`]) the verdict of +/// [`probe_with`] over the real opener ([`open_probe_sampler`]: the `Energy +/// Model` group subscription and its sampler), the real tick ([`probe_tick`]: +/// a burn of this thread's CPU time), the monotonic clock and +/// [`CPU_RAIL_LIVENESS_BUDGET`]. +/// +/// One composition with no branch of its own, so the path the tests drive — +/// with IOReport scripted underneath it, or real — is the path the counter is +/// gated on. The clock is the monotonic wall clock: it paces the budget and +/// the cap, and dates each sample for the rule's [`MIN_LIVE_WINDOW`]. Its one +/// production caller is the cache in [`crate::ioreport_status`]; call that, +/// not this. +pub(crate) fn probe_host_status() -> IoReportStatus { + REAL_RUNS.fetch_add(1, Ordering::SeqCst); + let epoch = Instant::now(); + status_of(probe_with(open_probe_sampler, probe_tick, move || epoch.elapsed(), CPU_RAIL_LIVENESS_BUDGET)) +} + +/// The probe over any opener, tick and clock: open a sampler, [`watch`] the +/// CPU rails with it for `budget`, and give the rule's verdict. `None` — +/// which [`status_of`] maps to `Unavailable` — when nothing opened (then +/// nothing is sampled or ticked) or when not one interval was observed. The +/// sampler, and the subscription it owns, is dropped once watched. Its one +/// production caller is [`probe_host_status`]; the tests pass scripts. +fn probe_with( + open: impl FnOnce() -> Option, + tick: impl FnMut(), + now: impl Fn() -> Duration, + budget: Duration, +) -> Option +where + F: FnMut() -> Option>, +{ + open().and_then(|sample| watch(sample, tick, now, budget).verdict()) +} + +/// The real opener: a subscription to the `Energy Model` group alone +/// ([`AppleIoReportCounter::open_energy_model`]), and a sampler over it +/// ([`sample_energy_model`]) that owns it and releases it when dropped. `None` +/// if the group cannot be subscribed. +/// +/// The probe reads the group alone because one sample of it takes a few +/// milliseconds of this thread's CPU, against 0.1–1 s for every channel on an +/// M5 Max: on every channel the probe would spend its budget inside IOReport +/// rather than burning CPU between samples. The summed total is not a liveness +/// signal either: on an M5 Max the GPU rail keeps advancing while every CPU +/// rail holds, so liveness is decided on the CPU rails alone ([`RailWatch`]), +/// from the probe's own samples, and no counter's cumulative reading is +/// touched. +fn open_probe_sampler() -> Option Option>> { + AppleIoReportCounter::open_energy_model().map(|state| move || sample_energy_model(&state)) +} + +/// The real tick: burn [`PROBE_TICK`] of this thread's CPU time, or stop at +/// [`PROBE_TICK_WALL_CAP`] of wall time on a host too loaded to grant it. +fn probe_tick() { + burn_thread_cpu(PROBE_TICK, PROBE_TICK_WALL_CAP); +} + +/// This thread's CPU time so far (`CLOCK_THREAD_CPUTIME_ID`: user and system +/// time the thread actually ran, not time it waited to be scheduled). `None` +/// if the clock cannot be read. +fn thread_cpu_time() -> Option { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: `ts` is a valid, writable timespec for the duration of the call. + let rc = unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + (rc == 0 && ts.tv_sec >= 0 && ts.tv_nsec >= 0) + .then(|| Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)) +} + +/// Burn `cpu` of this thread's own CPU time — until its thread CPU clock has +/// advanced by `cpu`, not until the wall clock has — or stop after `wall_cap` +/// of wall-clock time, whichever comes first. A wall-clock spin on a loaded +/// host burns only the share of its time the scheduler grants it; this burns +/// the whole amount unless the thread gets less than `cpu` of CPU in +/// `wall_cap`. If the thread CPU clock cannot be read, it spins for `cpu` of +/// wall-clock time instead. +fn burn_thread_cpu(cpu: Duration, wall_cap: Duration) { + let wall = Instant::now(); + let start = thread_cpu_time(); + let mut acc: u64 = 0; + loop { + for _ in 0..1024 { + acc = std::hint::black_box(acc.wrapping_add(1)); + } + let burned = match (start, thread_cpu_time()) { + (Some(start), Some(now)) => now.saturating_sub(start), + _ => wall.elapsed(), + }; + if burned >= cpu || wall.elapsed() >= wall_cap { + break; + } + } + std::hint::black_box(acc); +} + +/// One plain sample of `state`'s subscription (absolute values, no delta): +/// every `Energy Model` channel in it, in sample order, as (name, absolute raw +/// integer value) — raw integers, before unit conversion, so a small advance +/// in a fine unit cannot truncate to zero. `None` if IOReport returned no +/// sample, or one without a channel array: a failed sample, never a sample +/// with no CPU rail in it. Deciding anything is left to [`RailWatch`]. +fn sample_energy_model(state: &State) -> Option> { + sample_raw(state).map(energy_model_channels) +} + +/// The `Energy Model` channels of one raw sample, in sample order, as (name, +/// raw value): what the probe hands the rule. A channel whose name could not +/// be read is left out. +fn energy_model_channels(raw: Vec<(String, String, i64)>) -> Vec<(String, i64)> { + raw.into_iter() + .filter(|(group, name, _)| group.eq_ignore_ascii_case("Energy Model") && !name.is_empty()) + .map(|(_, name, value)| (name, value)) + .collect() +} + +/// The FFI leaf of every probe sample: one `IOReportCreateSamples` over +/// `state`'s subscription, and every channel in it as (group, name, absolute +/// raw value), in sample order, unfiltered. A group or name that cannot be +/// read is empty. In test builds a thread can script what this returns, and +/// record every call with its cost on the tests' own clock (the test-only +/// `seam`). +fn sample_raw(state: &State) -> Option> { + #[cfg(test)] + let started = seam::Stamp::now(); + #[cfg(test)] + if let Some(scripted) = seam::scripted_sample() { + seam::sampled(started, &scripted); + return scripted; + } + let raw = read_channels(state); + #[cfg(test)] + seam::sampled(started, &raw); + raw +} + +/// [`sample_raw`]'s IOReport calls. +fn read_channels(state: &State) -> Option> { + // SAFETY: `state` holds a live subscription and its channel dictionary + // (released only when `state` drops). Every CF object created here is + // released on every path; the ones got from it are borrowed. + unsafe { + let smp = IOReportCreateSamples(state.sub, state.channels, 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 + }; + if arr.is_null() { + CFRelease(smp); + return None; + } + let count = CFArrayGetCount(arr); + let mut out = Vec::with_capacity(usize::try_from(count).unwrap_or(0)); + for i in 0..count { + let item = CFArrayGetValueAtIndex(arr, i); + if item.is_null() { + continue; + } + out.push(( + cfstring_to_string(IOReportChannelGetGroup(item)).unwrap_or_default(), + cfstring_to_string(IOReportChannelGetChannelName(item)).unwrap_or_default(), + IOReportSimpleGetIntegerValue(item, 0) as i64, + )); + } + CFRelease(smp); + Some(out) + } +} + +/// Whether a channel is a CPU energy aggregate: `CPU Energy`, optionally +/// `DIE__`-prefixed. +fn is_cpu_energy_rail(name: &str) -> bool { + strip_die_prefix(name).eq_ignore_ascii_case("CPU Energy") +} + +/// The grade of every reading this counter produces: a hardware reading, the +/// grade this crate gives the other vendor energy counters (`RaplCounter`, +/// `NvmlCounter`). A vendor counter's reading is a real reading; its trust gap is +/// provenance, not accuracy (module docs). The counter earns the grade by being +/// live: it holds a subscription, and so reads, only where [`admits`] let it +/// through (its CPU energy rail was observed advancing under load), and a +/// declined counter produces no reading to carry any grade. +const READING_GRADE: Provenance = Provenance::HwShunt; + impl EnergyCounter for AppleIoReportCounter { fn domain(&self) -> EnergyDomain { EnergyDomain::SocTotal } fn provenance(&self) -> Provenance { - // Apple's Energy Model is a vendor utilisation/freq/voltage model — a - // real on-device reading, not a coulomb shunt. - Provenance::ModelBased + READING_GRADE } fn resolution_uj(&self) -> u64 { @@ -251,7 +905,7 @@ impl EnergyCounter for AppleIoReportCounter { uj: state.cumulative_uj, timestamp_ns: now_ns, domain: EnergyDomain::SocTotal, - provenance: Provenance::ModelBased, + provenance: READING_GRADE, }) } } @@ -348,10 +1002,20 @@ fn strip_die_prefix(name: &str) -> &str { name } -/// Whether a channel should be summed: the canonical per-domain aggregates -/// (`CPU/GPU/ANE Energy`, optionally `DIE__`-prefixed), DRAM controller -/// rails, and independent SoC rails — but **not** the per-core / per-cluster -/// sub-rails of CPU/GPU/ANE, which would double-count the aggregate. +/// 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. DRAM controller rails and the other independent SoC rails (AMCC, +/// DCS, display, ISP, AVE, MSR, FAB, PCIe) are no aggregate's summand and are +/// summed. fn counts_once(name: &str) -> bool { let base = strip_die_prefix(name); if base.eq_ignore_ascii_case("CPU Energy") @@ -360,12 +1024,12 @@ fn counts_once(name: &str) -> bool { { return true; } - let l = name.to_ascii_lowercase(); - if l.contains("cpu") || l.contains("gpu") || l.contains("ane") { - return false; // sub-rail of an aggregated domain + let u = base.to_ascii_uppercase(); + if u.contains("CPU") || u.contains("GPU") || u.contains("ANE") { + return false; // a summand of an aggregated domain } - // DRAM controller rails and independent rails sum cleanly. - true + let cpu_summand = ["PACC", "EACC", "MACC"].iter().any(|p| u.starts_with(p)) || u.contains("CPM"); + !cpu_summand } /// Read a CFString into an owned Rust String (UTF-8, ≤255 bytes). @@ -401,9 +1065,199 @@ fn cfstring_eq_ic(s: CFStringRef, target: &str) -> bool { .unwrap_or(false) } +/// Test-only fault injection and recording, INSIDE the real group opener +/// ([`AppleIoReportCounter::open_energy_model`]), the all-channel opener +/// ([`AppleIoReportCounter::open`]), the real sampler's FFI leaf +/// ([`sample_raw`]) and the rule's input ([`RailWatch::observe`]). +/// +/// A test installs it on its own thread for the length of one call — usually +/// the real, uncached [`probe_host_status`] — and gets back what happened on +/// that thread: every open, every sample call (what it returned, and its start +/// and end on the tests' own thread CPU clock and a monotonic one), and every +/// sample the rule was handed, with the time handed with it. With a script, +/// the group subscription and its samples are the script's; everything +/// between them and the rule — the production closures, the tick, the clock, +/// the loop, the mapping — is the production code. Every other thread, and every build that ships, reads the +/// real IOReport. A thread with a script installed must never ask for the +/// cached status: the cache would keep the scripted answer for the whole +/// process. +#[cfg(test)] +mod seam { + use std::cell::RefCell; + use std::time::{Duration, Instant}; + + /// `CLOCK_THREAD_CPUTIME_ID` as the macOS SDK defines it (`<_time.h>`: + /// `_CLOCK_THREAD_CPUTIME_ID = 16`), written out by the tests themselves, + /// so that their clock shares nothing with the one the production tick + /// reads. + pub(super) const TEST_CLOCK_THREAD_CPUTIME_ID: libc::clockid_t = 16; + + /// This thread's CPU time, on the tests' own clock. + pub(super) fn test_thread_cpu() -> Duration { + let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 }; + // SAFETY: `ts` is a valid, writable timespec for the duration of the call. + let rc = unsafe { libc::clock_gettime(TEST_CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + assert_eq!(rc, 0, "the tests' own thread CPU clock did not read"); + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) + } + + /// A moment on this thread: its CPU time by the tests' own clock, and a + /// monotonic reading. + #[derive(Debug, Clone, Copy)] + pub(super) struct Stamp { + pub(super) cpu: Duration, + pub(super) at: Instant, + } + + impl Stamp { + pub(super) fn now() -> Self { + let at = Instant::now(); + Self { cpu: test_thread_cpu(), at } + } + } + + /// One sample as the FFI leaf returns it: every channel, as (group, name, + /// absolute raw value). + pub(super) type Raw = Vec<(String, String, i64)>; + + /// One call of the sampler's FFI leaf. + #[derive(Debug)] + pub(super) struct SampleCall { + pub(super) start: Stamp, + pub(super) end: Stamp, + /// What it returned; `None` for a failed sample. + pub(super) raw: Option, + } + + /// What happened on the thread while the seam was installed. + #[derive(Debug, Default)] + pub(super) struct Record { + /// Calls of the `Energy Model` group opener. + pub(super) group_opens: usize, + /// Calls of the all-channel opener (the counter's own subscription). + pub(super) all_channel_opens: usize, + /// Every call of the sampler's FFI leaf, in order. + pub(super) calls: Vec, + /// Every sample the liveness rule was handed, in order, exactly as it + /// arrived there. + pub(super) fed: Vec>, + /// The wall-clock time handed with each of them, on the probe's own + /// clock, in the same order. + pub(super) fed_at: Vec, + } + + type Script = Box Option>; + + struct Seam { + /// `Some(opens)`: the group opener is scripted, and opens or not. + /// `None`: the real one. + opens: Option, + /// The i-th sample of a scripted subscription. + script: Option