From 660ab68a0454477704eff864e8ce7cfad224f976 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 21:04:59 -0400 Subject: [PATCH 1/2] jouleclaw-energy: a live counter of this process's own CPU energy, kept out of detect_meter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS 27.0.1 an unprivileged process gets no live CPU energy from IOReport: its CPU rails are frozen on an M5 Max and an M3 Ultra, so `detect_meter` falls to the load-average estimator there. The kernel does keep one live CPU energy figure an unprivileged process can read: `proc_pid_rusage(getpid(), RUSAGE_INFO_V6)` returns `ri_energy_nj`, the cumulative energy it attributes to this process's threads. Public libproc API, no privilege. Measured here (M5 Max, macOS 27.0.1, 2026-09-30, host heavily loaded): it changes about every 0.6 ms (86 changes in 50 ms of polling); 200 ms of one thread's CPU time read 0.448 J; a 200 ms sleep read 137 µJ. The trap is coverage. It reads this process's threads and nothing else: not other processes, not this process's children, not the GPU, ANE or DRAM, this process's own GPU work included. A figure from it is a hardware reading of work that ran on this process's threads, and would bill zero for a model served by a subprocess. JouleClaw SPEC §1.3: a figure must not claim coverage of a domain its counter does not read. - `process::ProcessCpuCounter` (macOS, no feature) reads it over a new domain, `EnergyDomain::ProcessCpu`, documented to exactly that scope, graded HwShunt like RAPL and NVML, and only while live: the shared sustained-advance rule, with the probing thread burning 25 ms of its own CPU between samples, probed once per process and cached (`process_cpu_liveness`). Whether AMX work a thread drives is in the kernel's figure is not verified, and the docs say so. - A window over which it did not advance by a whole µJ is not a reading: `ProcessCpuCounter::measure` returns the new `EnergyError::NotAdvanced`, never a zero; a failed read or a counter going backwards is an error too. - `detect_meter` does not include it, and says why. `detect_in_process_meter` returns it when live, else exactly `detect_meter`; its docs limit it to work on this process's own threads. `CompositeMeter::names` lists what a composite holds, so a test can say what is not in it. - mesh-meter decides the new domain, as its exhaustive match requires: a per-process figure does not cover a mesh/1 request, so through `HwMeter` it would be an estimate. `HwMeter::detect` uses `detect_meter`, so it never meets this counter unless bridged by hand. Tests. The hardware checks run in `tests/process_cpu.rs`, one test in its own process, because the counter reads every thread of the process and concurrent tests would be in the figure: live, probed once across four threads; 200 ms of one thread's CPU reads 0.2 to 20 J, HwShunt over ProcessCpu; a 200 ms sleep reads under 0.1 J or is NotAdvanced; `detect_meter` holds apple-ioreport, apple-silicon and estimation and not process-cpu, while the in-process meter selects it; a busy `/bin/sh` child that burned 0.744 J (its own counter, read independently at the SDK's offset) put 264 µJ in this process's window, which spans its kill and reap. In the lib: the struct matches the SDK layout (464 bytes, energy at 336, checked with the C compiler); a counter given Frozen or Unavailable reads and measures nothing; the production probe over a held reader is Frozen after the budget and over a failing one Unavailable; the window rule; and a live one-sided burn check. Ran: `cargo test -p jouleclaw-energy` with default features and with `ioreport,rapl,nvml`; mesh-meter with `hw-meter`, `hw-meter-apple`, `hw-meter-nvml`; clippy clean on the new code. Co-Authored-By: Claude Opus 5.5 --- .../crates/jouleclaw-energy/src/lib.rs | 42 +++ .../crates/jouleclaw-energy/src/liveness.rs | 15 +- .../crates/jouleclaw-energy/src/meter.rs | 72 +++- .../crates/jouleclaw-energy/src/process.rs | 328 ++++++++++++++++++ .../jouleclaw-energy/tests/process_cpu.rs | 127 +++++++ mesh/mesh-rs/crates/mesh-meter/src/lib.rs | 11 +- 6 files changed, 584 insertions(+), 11 deletions(-) create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/process.rs create mode 100644 jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs index 45e106674..819bc1292 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs @@ -29,6 +29,14 @@ //! an NVML cumulative-energy counter that is not live gives way to the //! power-draw integral, which is graded [`Provenance::ModelBased`]. //! +//! A figure MUST NOT claim coverage of a domain its counter does not read. On +//! macOS this crate also reads this process's own CPU energy +//! (`process::ProcessCpuCounter`, domain [`EnergyDomain::ProcessCpu`]), live +//! for an unprivileged reader where IOReport's CPU rails are not. It covers this +//! process's threads only, so it is not in [`meter::detect_meter`] (for work +//! wherever it runs, a subprocess included); [`meter::detect_in_process_meter`] +//! prefers it, for work that runs on this process's own threads. +//! //! The thermodynamic circuit breaker is only as honest as the **worst** //! counter in a request's span. The breaker MUST surface //! [`EnergyCounter::resolution_uj`] and [`EnergyCounter::min_window_ns`] so @@ -63,6 +71,12 @@ pub mod apple; #[cfg(all(feature = "ioreport", target_os = "macos"))] pub mod apple_ioreport; +/// This process's own CPU energy (macOS `proc_pid_rusage`): public API, no +/// feature needed. Not part of [`meter::detect_meter`]; see +/// [`meter::detect_in_process_meter`]. +#[cfg(target_os = "macos")] +pub mod process; + pub mod meter; pub mod ledger; @@ -198,6 +212,22 @@ pub fn ioreport_group_opens() -> usize { } } +/// How many times this process has run the real liveness probe of its own +/// CPU-energy counter ([`process::process_cpu_liveness`]), on any thread. For +/// the test that pins that probe to one run per process; not part of the +/// metering API. Always 0 off macOS. +#[doc(hidden)] +pub fn process_cpu_probe_runs() -> usize { + #[cfg(target_os = "macos")] + { + process::REAL_RUNS.load(std::sync::atomic::Ordering::SeqCst) + } + #[cfg(not(target_os = "macos"))] + { + 0 + } +} + /// Hardware energy domain a counter reports on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))] @@ -219,6 +249,13 @@ pub enum EnergyDomain { Ane, /// External rail measured by an INA-class shunt (Jetson, custom hw). ExternalRail(u16), + /// The CPU energy the kernel attributes to **this process's own threads** + /// (macOS `proc_pid_rusage` `ri_energy_nj`, [`process::ProcessCpuCounter`]). + /// Not other processes', not this process's children's, and not the GPU, + /// ANE or DRAM energy of any work, this process's own GPU work included. A + /// figure over this domain covers work that ran on this process's threads, + /// and nothing else. + ProcessCpu, } /// A single energy reading with full provenance. @@ -254,6 +291,11 @@ pub enum EnergyError { /// Counter wrap or stale read. #[error("counter wrap or stale read")] CounterWrap, + /// The counter did not advance (by at least its resolution) across a + /// window of work. No work runs on zero energy, so a counter that held did + /// not see the work: that is not a reading, and never a zero. + #[error("the counter did not advance across the work: not a reading")] + NotAdvanced, /// Underlying platform error. #[error("platform error: {0}")] Platform(String), diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/liveness.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/liveness.rs index 61eb5a5c3..6c0746b64 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/liveness.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/liveness.rs @@ -1,6 +1,6 @@ //! The sustained-advance liveness rule, shared by the vendor energy counters that //! read one cumulative value per channel (RAPL's `energy_uj`, NVML's -//! cumulative-energy counter). +//! cumulative-energy counter, this process's CPU energy on macOS). //! //! A vendor energy counter's reading may be graded a hardware reading //! ([`crate::Provenance::HwShunt`]) only while the counter is demonstrably live @@ -29,11 +29,14 @@ //! The decision ([`AdvanceWatch`]) is kept apart from the sampling, the burn and //! the clock, so the tests drive it with scripts. -// The probe machinery runs for the RAPL backend on Linux and the NVML backend; -// the rule's numbers are also read by the IOReport backend. A build with neither -// probing backend keeps the public `CounterLiveness` type, and nothing calls the -// rest outside the tests. -#![cfg_attr(not(any(all(feature = "rapl", target_os = "linux"), feature = "nvml")), allow(dead_code))] +// The probe machinery runs for the RAPL backend on Linux, the NVML backend and +// the process CPU-energy counter on macOS; the rule's numbers are also read by +// the IOReport backend. A build with no probing backend keeps the public +// `CounterLiveness` type, and nothing calls the rest outside the tests. +#![cfg_attr( + not(any(all(feature = "rapl", target_os = "linux"), feature = "nvml", target_os = "macos")), + allow(dead_code) +)] use std::time::{Duration, Instant}; diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs index 0bce655d7..80dd9024e 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/meter.rs @@ -127,6 +127,12 @@ impl CompositeMeter { self.active().map(|m| m.name()).unwrap_or("none") } + /// The short names of every meter this composite holds, available or not, + /// in the order it tries them. + pub fn names(&self) -> Vec<&'static str> { + self.meters.iter().map(|m| m.name()).collect() + } + /// `true` if at least one configured meter is available. pub fn is_available(&self) -> bool { self.active_index.is_some() @@ -239,8 +245,15 @@ impl EnergyMeter for EstimationMeter { } } -/// Detect the best meter for this platform. Always returns *something* -/// — at minimum the [`EstimationMeter`] fallback. +/// Detect the best meter for this platform, for work wherever it runs on this +/// host. Always returns *something* — at minimum the [`EstimationMeter`] +/// fallback. +/// +/// It never includes this process's own CPU-energy counter +/// (`crate::process::ProcessCpuCounter`): that counter reads this process's +/// threads alone, and the work metered here may run in a subprocess or on the +/// GPU. Work that runs on this process's own threads can use +/// [`detect_in_process_meter`]. /// /// With the `ioreport` feature on macOS, the IOReport counter is selected only /// when [`crate::ioreport_status`] is `Live`; the first call in a process runs @@ -289,6 +302,33 @@ pub fn detect_meter() -> CompositeMeter { CompositeMeter::new(meters) } +/// Detect the best meter for work that runs on **this process's own threads**, +/// and only there: an in-process decode loop, a CPU kernel, a thread pool this +/// process owns. +/// +/// On macOS that is this process's own CPU-energy counter +/// ([`crate::process::ProcessCpuCounter`]) when it is live (the first call in a +/// process runs its liveness probe on the calling thread): a hardware reading +/// (`HwShunt`) over [`EnergyDomain::ProcessCpu`], which does not move with +/// anything else the host runs. Where it is not live, and off macOS, this is +/// [`detect_meter`]. +/// +/// Do not use it for work that runs anywhere else. The counter does not read a +/// subprocess (a model served by `llama-completion`, say), another process, or +/// the GPU — including GPU kernels this process dispatches — so a figure from it +/// would bill that work nothing, or claim a domain its counter does not read. +/// Such work is metered by [`detect_meter`]. +pub fn detect_in_process_meter() -> CompositeMeter { + #[cfg(target_os = "macos")] + { + let process = crate::process::ProcessCpuCounter::new(); + if process.is_available() { + return CompositeMeter::new(vec![Box::new(process)]); + } + } + detect_meter() +} + #[cfg(test)] mod tests { use super::*; @@ -334,6 +374,34 @@ mod tests { )); } + /// `detect_meter` never holds this process's own CPU-energy counter: it + /// meters work wherever it runs, and that counter reads this process's + /// threads alone. + #[test] + fn detect_meter_never_includes_the_process_counter() { + let names = detect_meter().names(); + assert!(!names.is_empty()); + assert!(!names.contains(&"process-cpu"), "{names:?}"); + } + + /// `detect_in_process_meter` prefers the process counter where it is live, + /// and is `detect_meter` where it is not (and off macOS). + #[test] + fn the_in_process_meter_is_the_process_counter_where_it_is_live() { + let m = detect_in_process_meter(); + #[cfg(target_os = "macos")] + { + let live = crate::process::process_cpu_liveness() == crate::CounterLiveness::Live; + assert_eq!(m.active_name() == "process-cpu", live, "{:?}", m.names()); + if live { + let r = m.read().expect("a live process counter reads"); + assert_eq!((r.domain, r.provenance), (EnergyDomain::ProcessCpu, Provenance::HwShunt)); + } + } + #[cfg(not(target_os = "macos"))] + assert_eq!(m.names(), detect_meter().names()); + } + #[test] fn empty_composite_reports_no_meter() { let composite = CompositeMeter::new(vec![]); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/process.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/process.rs new file mode 100644 index 000000000..8440b8a77 --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/process.rs @@ -0,0 +1,328 @@ +//! This process's own CPU energy, as the kernel attributes it (macOS) — +//! `Provenance::HwShunt` while the counter is live, over +//! [`EnergyDomain::ProcessCpu`] and nothing else. +//! +//! `proc_pid_rusage(getpid(), RUSAGE_INFO_V6, ..)` (libproc: public API, no +//! privilege, no entitlement) returns `ri_energy_nj`, the cumulative energy in +//! nanojoules the kernel attributes to the CPU time of this process's threads. +//! On Apple Silicon the kernel derives it from the cores' own energy accounting, +//! per thread, and an unprivileged reader sees it live: measured on an M5 Max, +//! macOS 27.0.1 (2026-09-30), it changes about every 0.5 ms; idle 0.002 J/s, +//! one busy thread ~2.4 J/s, two ~5.3 J/s. This is the live CPU energy reading +//! that IOReport no longer gives an unprivileged process on macOS 27 (its CPU +//! rails are frozen, see `apple_ioreport`) — but only for this process. +//! +//! **The domain, exactly.** The CPU energy of **this process's own threads**. +//! Not other processes'. Not this process's children's: a child is another +//! process with its own counter, and the kernel attributes nothing of it here. +//! Not the GPU, ANE or DRAM energy of any work, this process's own GPU work +//! included (the thread that encodes a GPU command buffer is metered, the GPU +//! that runs it is not). Whether a matrix coprocessor a thread drives (AMX) is +//! in it is the kernel's accounting; it has not been verified here. +//! +//! So a figure from this counter is a hardware reading of work that ran on this +//! process's threads, and claims nothing else (JouleClaw SPEC §1.3: a figure +//! MUST NOT claim coverage of a domain its counter does not read). That is why +//! [`crate::meter::detect_meter`] does **not** include it: that meter is for +//! work wherever it runs, including a model served by a subprocess, which this +//! counter would bill zero. [`crate::meter::detect_in_process_meter`] puts it +//! first, for work that runs on this process's own threads and only there. +//! +//! **Liveness.** A vendor energy counter is a hardware reading only while it is +//! demonstrably live for the domain the figure claims, so the counter is gated +//! on the family's sustained-advance rule ([`crate::CounterLiveness`]): while +//! the probing thread — one of this process's threads — burns 25 ms of its own +//! CPU time between samples, `ri_energy_nj` must advance in at least five +//! intervals, four in every five, over at least 300 ms. Probed once per process +//! and cached ([`process_cpu_liveness`]); about 0.3 s where it is live, about +//! 1 s where it is not. A counter that is not live meters nothing. +//! +//! **A read that did not advance is not a reading.** No work runs on zero +//! energy, so a window over which the counter held did not see the work. +//! [`ProcessCpuCounter::measure`] reports such a window as +//! [`EnergyError::NotAdvanced`], never as zero. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::OnceLock; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use crate::meter::{EnergyMeter, EnergySource}; +use crate::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance}; + +/// Thread CPU time the liveness probe burns between samples. The counter moves +/// about every 0.5 ms under load, so a live counter advances in every interval. +const PROBE_TICK: Duration = Duration::from_millis(25); + +/// The grade of every reading this counter produces: a hardware reading, the +/// grade this crate gives RAPL, NVML's live cumulative-energy counter and a +/// live IOReport counter. It earns it by being live; a counter that is not +/// produces no reading. +const READING_GRADE: Provenance = Provenance::HwShunt; + +/// `RUSAGE_INFO_V6`, from ``. +const RUSAGE_INFO_V6: libc::c_int = 6; + +/// `struct rusage_info_v6` from the macOS SDK's ``, field for +/// field: 464 bytes, `ri_energy_nj` at offset 336 (checked against the SDK +/// header with the C compiler, and asserted in the tests). Only the energy is +/// read. +#[repr(C)] +struct RusageInfoV6 { + _ri_uuid: [u8; 16], + /// `ri_user_time` through `ri_pcycles`: 40 `uint64_t` fields. + _before_energy: [u64; 40], + ri_energy_nj: u64, + _ri_penergy_nj: u64, + /// `ri_secure_time_in_system` through `ri_page_cache_hits`, then + /// `ri_reserved[6]`. + _after_energy: [u64; 14], +} + +/// The cumulative CPU energy, in nanojoules, the kernel attributes to the +/// threads of process `pid`, or `None` if `proc_pid_rusage` fails. +fn rusage_energy_nj(pid: libc::pid_t) -> Option { + let mut info = std::mem::MaybeUninit::::zeroed(); + // SAFETY: `info` is a zeroed, writable buffer of exactly + // `sizeof(struct rusage_info_v6)` bytes for the duration of the call, which + // is what `RUSAGE_INFO_V6` makes the kernel write. The prototype's + // `rusage_info_t *buffer` is that buffer's address. + let rc = unsafe { libc::proc_pid_rusage(pid, RUSAGE_INFO_V6, info.as_mut_ptr() as *mut libc::rusage_info_t) }; + if rc != 0 { + return None; + } + // SAFETY: the call succeeded and filled the struct; a zeroed struct of + // plain integers is a valid value either way. + Some(unsafe { info.assume_init() }.ri_energy_nj) +} + +/// This process's cumulative CPU energy, in nanojoules. +fn energy_nj() -> Option { + // SAFETY: `getpid` has no preconditions and cannot fail. + rusage_energy_nj(unsafe { libc::getpid() }) +} + +/// How many times this process has run the real liveness probe. Read through +/// [`crate::process_cpu_probe_runs`] by the test that pins the probe to one run +/// per process. +pub(crate) static REAL_RUNS: AtomicUsize = AtomicUsize::new(0); + +/// This process's CPU-energy counter liveness, probed once per process and +/// cached: the first call, on whichever thread, runs the probe on that thread +/// (it burns that thread's CPU for about 0.3 s where the counter is live, about +/// 1 s where it is not); every later call, every [`ProcessCpuCounter::new`] and +/// every [`crate::meter::detect_in_process_meter`] reuse the answer. +pub fn process_cpu_liveness() -> CounterLiveness { + static LIVENESS: OnceLock = OnceLock::new(); + *LIVENESS.get_or_init(|| { + REAL_RUNS.fetch_add(1, Ordering::SeqCst); + probe_reader(energy_nj) + }) +} + +/// One run of the liveness probe over `read`, a reader of a cumulative counter: +/// the shared probe ([`crate::liveness::probe`]) over one channel, burning +/// [`PROBE_TICK`] of this thread's CPU time between samples. Production passes +/// [`energy_nj`]; the tests pass readers that hold or fail. +fn probe_reader(read: impl Fn() -> Option) -> CounterLiveness { + crate::liveness::probe(|| read().map(|_| (1, || read().map(|nj| vec![nj]))), PROBE_TICK) +} + +/// The energy of a window of work from the counter's raw readings, in nJ, +/// before and after it, as whole µJ: or why it is not a reading. A failed read +/// is not a zero; a counter that went backwards wrapped or was reset; and one +/// that did not advance by a whole µJ — the resolution of the figure — did not +/// see the work, which runs on more than zero energy. +fn window_uj(before_nj: Option, after_nj: Option) -> Result { + let (Some(before), Some(after)) = (before_nj, after_nj) else { + return Err(EnergyError::Platform("proc_pid_rusage failed".to_string())); + }; + match after.checked_sub(before).map(|nj| nj / 1_000) { + None => Err(EnergyError::CounterWrap), + Some(0) => Err(EnergyError::NotAdvanced), + Some(uj) => Ok(uj), + } +} + +/// This process's own CPU energy counter (macOS): see the module docs for its +/// domain, which is exactly this process's threads' CPU energy. +pub struct ProcessCpuCounter { + liveness: CounterLiveness, +} + +impl Default for ProcessCpuCounter { + fn default() -> Self { + Self::new() + } +} + +impl ProcessCpuCounter { + /// The counter, available only when this process's CPU-energy counter is + /// live ([`process_cpu_liveness`]; the first call in a process runs the + /// probe on the calling thread). + pub fn new() -> Self { + Self::given(process_cpu_liveness()) + } + + /// The counter for a host whose liveness probe said `liveness`. + fn given(liveness: CounterLiveness) -> Self { + Self { liveness } + } + + /// Whether the counter meters: it was observed live. + pub fn is_available(&self) -> bool { + admits(self.liveness) + } + + /// Why the counter is, or is not, available: [`CounterLiveness::Live`] + /// exactly when [`Self::is_available`] is `true`. + pub fn liveness(&self) -> CounterLiveness { + self.liveness + } + + /// Run `work` and meter it: its result, and the CPU energy this process's + /// threads spent while it ran, in µJ — a hardware reading over + /// [`EnergyDomain::ProcessCpu`] — or why there is no figure: the counter is + /// not live ([`EnergyError::NoCounter`]), a read failed, the counter went + /// backwards ([`EnergyError::CounterWrap`]), or it did not advance by a + /// whole µJ while the work ran ([`EnergyError::NotAdvanced`]: not a + /// reading, never a zero). + /// + /// Every thread of this process is in the figure, the work's and any other + /// running at the same time; no other process's, and none of the GPU's. + /// `work` runs whether or not the counter is live. + pub fn measure(&self, work: impl FnOnce() -> T) -> (T, Result) { + if !self.is_available() { + return (work(), Err(EnergyError::NoCounter(EnergyDomain::ProcessCpu))); + } + let before = energy_nj(); + let out = work(); + let after = energy_nj(); + (out, window_uj(before, after)) + } +} + +/// Whether a liveness verdict lets the counter read: `Live`, and nothing else. +fn admits(liveness: CounterLiveness) -> bool { + match liveness { + CounterLiveness::Live => true, + CounterLiveness::Unavailable | CounterLiveness::Frozen => false, + } +} + +impl EnergyCounter for ProcessCpuCounter { + fn domain(&self) -> EnergyDomain { + EnergyDomain::ProcessCpu + } + + fn provenance(&self) -> Provenance { + READING_GRADE + } + + fn resolution_uj(&self) -> u64 { + // The counter is in nJ; readings are whole µJ. + 1 + } + + fn min_window_ns(&self) -> u64 { + // It moves about every 0.5 ms under load; below ~1 ms a window may + // straddle no update at all. + 1_000_000 + } + + fn read(&self) -> Result { + if !self.is_available() { + return Err(EnergyError::NoCounter(EnergyDomain::ProcessCpu)); + } + let nj = energy_nj().ok_or_else(|| EnergyError::Platform("proc_pid_rusage failed".to_string()))?; + let timestamp_ns = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + Ok(EnergyReading { + uj: nj / 1_000, + timestamp_ns, + domain: EnergyDomain::ProcessCpu, + provenance: READING_GRADE, + }) + } +} + +impl EnergyMeter for ProcessCpuCounter { + fn name(&self) -> &'static str { + "process-cpu" + } + + fn is_available(&self) -> bool { + admits(self.liveness) + } + + fn energy_source(&self) -> EnergySource { + EnergySource::Unknown + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The hand-written layout is the SDK's: 464 bytes, the energy at 336. + #[test] + fn the_struct_matches_the_sdk_layout() { + assert_eq!(std::mem::size_of::(), 464); + assert_eq!(std::mem::offset_of!(RusageInfoV6, ri_energy_nj), 336); + assert_eq!(std::mem::offset_of!(RusageInfoV6, _ri_penergy_nj), 344); + } + + /// A counter given any verdict but `Live` meters nothing: no reading, no + /// measurement, and its work still runs. + #[test] + fn a_counter_that_is_not_live_meters_nothing() { + for liveness in [CounterLiveness::Frozen, CounterLiveness::Unavailable] { + let c = ProcessCpuCounter::given(liveness); + assert!(!c.is_available() && !EnergyMeter::is_available(&c), "{liveness:?}"); + assert!(matches!(c.read(), Err(EnergyError::NoCounter(EnergyDomain::ProcessCpu))), "{liveness:?}"); + let (out, figure) = c.measure(|| 7); + assert_eq!(out, 7); + assert!(matches!(figure, Err(EnergyError::NoCounter(EnergyDomain::ProcessCpu))), "{liveness:?}"); + } + let live = ProcessCpuCounter::given(CounterLiveness::Live); + assert!(live.is_available()); + assert_eq!((live.domain(), live.provenance(), live.name()), (EnergyDomain::ProcessCpu, Provenance::HwShunt, "process-cpu")); + } + + /// The production probe over a reader that holds its value is `Frozen` + /// (after the whole budget); over one that fails, `Unavailable` at once. + #[test] + fn the_probe_refuses_a_held_or_failing_counter() { + let t = std::time::Instant::now(); + assert_eq!(probe_reader(|| Some(42_000_000)), CounterLiveness::Frozen); + assert!(t.elapsed() >= Duration::from_millis(1000), "{:?}", t.elapsed()); + assert_eq!(probe_reader(|| None), CounterLiveness::Unavailable); + } + + /// A window is a reading only if the counter advanced by a whole µJ. + #[test] + fn a_window_that_did_not_advance_is_not_a_reading() { + assert_eq!(window_uj(Some(5_000_000), Some(7_500_999)).ok(), Some(2_500)); + assert!(matches!(window_uj(Some(5_000_000), Some(5_000_000)), Err(EnergyError::NotAdvanced))); + assert!(matches!(window_uj(Some(5_000_000), Some(5_000_999)), Err(EnergyError::NotAdvanced))); + assert!(matches!(window_uj(Some(5_000_000), Some(4_000_000)), Err(EnergyError::CounterWrap))); + assert!(matches!(window_uj(None, Some(4_000_000)), Err(EnergyError::Platform(_)))); + assert!(matches!(window_uj(Some(1), None), Err(EnergyError::Platform(_)))); + } + + /// On this host: the counter is live, and 100 ms of this thread's CPU time + /// meters as at least 0.05 J (a core at 0.5 W). The process-wide figure + /// includes any concurrent test's threads, so the bound here is one-sided; + /// the integration test `process_cpu` asserts two-sided bounds, and a sleep + /// and a child process, in a process of its own. + #[test] + fn on_this_host_the_counter_is_live_and_meters_this_threads_burn() { + let c = ProcessCpuCounter::new(); + let (_, figure) = c.measure(|| crate::liveness::burn_thread_cpu(Duration::from_millis(100), Duration::from_secs(2))); + eprintln!("process-cpu on this host: liveness={:?} 100 ms thread-CPU burn={figure:?} µJ", c.liveness()); + assert_eq!(c.liveness(), CounterLiveness::Live); + let uj = figure.expect("a live counter meters a burn"); + assert!(uj >= 50_000, "{uj} µJ for 100 ms of CPU"); + } +} diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs new file mode 100644 index 000000000..8f1995842 --- /dev/null +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs @@ -0,0 +1,127 @@ +//! This process's own CPU-energy counter on real hardware, asserted in a process +//! of its own. +//! +//! The counter reads every thread of this process, so a test that bounds it from +//! above cannot share its process with other tests running at the same time: +//! their threads would be in the figure. This file is one test binary with one +//! test, which runs its checks in sequence on one thread. Nothing in it returns +//! early: off macOS it is not compiled, and on macOS a counter that is not live +//! fails it. + +#![cfg(target_os = "macos")] + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use jouleclaw_energy::meter::{detect_in_process_meter, detect_meter, EnergyMeter}; +use jouleclaw_energy::process::{process_cpu_liveness, ProcessCpuCounter}; +use jouleclaw_energy::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, Provenance}; + +/// This thread's CPU time. +fn 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(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) }; + assert_eq!(rc, 0, "the thread CPU clock"); + Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32) +} + +/// Burn `cpu` of this thread's own CPU time (not wall time, so a loaded host +/// that deschedules the thread still gets the whole burn). +fn burn(cpu: Duration) { + let start = thread_cpu(); + let mut x = 1u64; + while thread_cpu() - start < cpu { + for _ in 0..10_000 { + x = std::hint::black_box(x.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1)); + } + } +} + +/// Another process's cumulative CPU energy, in nJ, read independently of the +/// crate: `proc_pid_rusage(pid, RUSAGE_INFO_V6)` into a 464-byte buffer, whose +/// `ri_energy_nj` is the u64 at byte offset 336 (the SDK's layout). +fn energy_nj_of(pid: libc::pid_t) -> u64 { + let mut buf = [0u64; 58]; + // SAFETY: `buf` is 464 writable bytes, the size of `rusage_info_v6`. + let rc = unsafe { libc::proc_pid_rusage(pid, 6, buf.as_mut_ptr() as *mut libc::rusage_info_t) }; + assert_eq!(rc, 0, "proc_pid_rusage({pid})"); + buf[336 / 8] +} + +#[test] +fn the_process_counter_meters_this_process_and_nothing_else() { + // 1. Live, probed once per process however many threads ask. + let askers: Vec<_> = (0..4).map(|_| std::thread::spawn(|| ProcessCpuCounter::new().liveness())).collect(); + let seen: Vec = askers.into_iter().map(|t| t.join().expect("asker")).collect(); + let counter = ProcessCpuCounter::new(); + let _ = detect_in_process_meter(); + let _ = detect_in_process_meter(); + // Counted before anything here asks for the liveness directly: the counters + // and meters above must have run the probe themselves, once. + let runs = jouleclaw_energy::process_cpu_probe_runs(); + println!("process-cpu: liveness={:?} seen by 4 threads={seen:?} probe runs={runs}", counter.liveness()); + assert_eq!(runs, 1, "the counters did not run the liveness probe exactly once"); + assert_eq!(counter.liveness(), CounterLiveness::Live, "this process's CPU-energy counter is not live here"); + assert_eq!(process_cpu_liveness(), CounterLiveness::Live); + assert!(seen.iter().all(|l| *l == CounterLiveness::Live), "{seen:?}"); + assert_eq!(jouleclaw_energy::process_cpu_probe_runs(), 1, "the liveness probe ran more than once"); + assert!(counter.is_available()); + let r = counter.read().expect("a live counter reads"); + assert_eq!((r.domain, r.provenance), (EnergyDomain::ProcessCpu, Provenance::HwShunt)); + assert_eq!((counter.domain(), counter.provenance(), counter.name()), (EnergyDomain::ProcessCpu, Provenance::HwShunt, "process-cpu")); + + // 2. 200 ms of one thread's CPU time reads 0.2 to 20 J. + let t = Instant::now(); + let ((), burned) = counter.measure(|| burn(Duration::from_millis(200))); + let wall = t.elapsed(); + let uj = burned.expect("a 200 ms burn is a reading"); + println!("process-cpu: 200 ms one-thread CPU burn = {:.3} J over {:.0} ms wall ({:.2} J per CPU-second)", uj as f64 / 1e6, wall.as_secs_f64() * 1e3, uj as f64 / 1e6 / 0.2); + assert!((200_000..=20_000_000).contains(&uj), "{uj} µJ for 200 ms of one thread's CPU"); + + // 3. A 200 ms sleep reads under 0.1 J — or, if nothing of this process ran, + // is not a reading at all; never a zero. + let ((), slept) = counter.measure(|| std::thread::sleep(Duration::from_millis(200))); + println!("process-cpu: 200 ms sleep = {slept:?} µJ"); + match slept { + Ok(uj) => assert!(uj < 100_000, "{uj} µJ for a sleep"), + Err(EnergyError::NotAdvanced) => {} + Err(e) => panic!("a sleep window failed: {e}"), + } + + // 4. `detect_meter` does not include it; `detect_in_process_meter` selects it. + let names = detect_meter().names(); + println!("process-cpu: detect_meter holds {names:?}, in-process meter is {}", detect_in_process_meter().active_name()); + assert!(!names.contains(&"process-cpu"), "{names:?}"); + assert_ne!(detect_meter().active_name(), "process-cpu"); + assert_eq!(detect_in_process_meter().active_name(), "process-cpu"); + + // 5. A child process's work is not attributed: a child burns CPU while this + // process sleeps, and is killed and reaped inside the window. The child's + // own counter, read independently, shows the work happened. + let mut child = Command::new("/bin/sh") + .args(["-c", "while :; do :; done"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn a busy child"); + let pid = child.id() as libc::pid_t; + let child_before = energy_nj_of(pid); + let (child_burned_nj, parent) = counter.measure(|| { + std::thread::sleep(Duration::from_millis(300)); + let burned = energy_nj_of(pid) - child_before; + child.kill().expect("kill the child"); + child.wait().expect("reap the child"); + burned + }); + println!( + "process-cpu: child burned {:.3} J in 300 ms; this process's window = {parent:?} µJ", + child_burned_nj as f64 / 1e9 + ); + assert!(child_burned_nj >= 200_000_000, "the child did not burn: {child_burned_nj} nJ"); + match parent { + Ok(uj) => assert!(uj < 100_000, "{uj} µJ in this process while only the child worked"), + Err(EnergyError::NotAdvanced) => {} + Err(e) => panic!("the parent window failed: {e}"), + } +} diff --git a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs index c442cedcf..02357e2af 100644 --- a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs @@ -304,8 +304,11 @@ mod hw { /// no coverage field. The whole SoC does, and so does a CPU package (a RAPL /// figure, on a host serving from its CPU). A GPU, NPU or ANE alone does not: /// the CPU that drives the accelerator is unmetered. Nor does one core, DRAM, - /// or an external rail whose scope this crate cannot know. The match is - /// exhaustive, so a new domain has to be decided here. + /// or an external rail whose scope this crate cannot know. Nor does one + /// process's own CPU energy (`ProcessCpu`): this bridge cannot see whether a + /// request ran on that process's threads alone, and it misses any GPU work + /// and any other process serving it. The match is exhaustive, so a new domain + /// has to be decided here. pub fn covers_whole_request(domain: EnergyDomain) -> bool { match domain { EnergyDomain::SocTotal | EnergyDomain::CpuPkg => true, @@ -314,7 +317,8 @@ mod hw { | EnergyDomain::Gpu | EnergyDomain::Npu | EnergyDomain::Ane - | EnergyDomain::ExternalRail(_) => false, + | EnergyDomain::ExternalRail(_) + | EnergyDomain::ProcessCpu => false, } } @@ -632,6 +636,7 @@ mod hw { (Npu, false), (Ane, false), (ExternalRail(0), false), + (ProcessCpu, false), ] { assert_eq!(covers_whole_request(d), whole, "{d:?}"); } -- 2.54.0 (Apple Git-157) From df42af5c225c6793a3b35682533acaf94eb85361 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Wed, 30 Sep 2026 21:05:11 -0400 Subject: [PATCH 2/2] jouleclaw-harness-econ: an in-process decode is metered by the process counter, and only that work `detect_in_process_meter` is for work that runs on this process's own threads. Each caller that meters model work was checked against that, and adopted it only where it holds. - jouleclaw-harness-econ: `local_model_econ`, `swebench_local_coder` and `swebench_local_suite` meter a decode that runs in this process, on the loader's CPU kernels and rayon pool; the examples build the loader with `rayon` only, without its GPU `fabric`, and the metered window is the decode alone (the `python3` judge, a subprocess, runs outside it). They now use the in-process meter, so on macOS 27.0.1, where the host meter is the load-average estimator, a dispatch is a live hardware reading of its own CPU energy. `window_joules` already refuses a window the counter did not advance across. `meter_probe` reports both meters. Run here on a host under load average ~50: the host meter was the estimator (115.6 J over 2.89 s, Estimator, SocTotal), the in-process meter process-cpu (2.397 J over 2.84 s, HwShunt, ProcessCpu) for the same one-thread spin. - jouledesk-github stays on `detect_meter`: its model runs in a `llama-completion` subprocess, which a per-process counter would bill nothing. Its docs say so, and a test pins the meter to the host meter. - l3-jouleclaw's `run` binary stays on the host counter, and says why: `llama` is a subprocess, and the in-process `jouleclaw-runtime` engine boots the Apple backend's GPU kernels (Metal and MPS matmuls, the quantized GEMVs that serve decode), whose energy is the GPU's. - jouleclaw-loader-gguf is unchanged: its metered paths (`tune_shape_live`, `fabric_calibrate`, `mpp_energy`) compare or calibrate GPU against CPU members, so they need the whole SoC, not one process's CPU. Ran: `cargo test -p jouleclaw-harness-econ`, `cargo check` of its examples, `cargo test -p jouledesk-github` (13 passed), and `cargo test` in l3-jouleclaw. No model was loaded; the examples' model runs were not repeated. Co-Authored-By: Claude Opus 5.5 --- .../examples/l3-jouleclaw/src/bin/run.rs | 9 +++++ .../examples/local_model_econ.rs | 36 +++++++++++-------- .../examples/meter_probe.rs | 27 ++++++++------ .../examples/swebench_local_coder.rs | 26 ++++++++------ .../examples/swebench_local_suite.rs | 26 ++++++++------ .../crates/jouledesk-github/src/lib.rs | 15 ++++++++ 6 files changed, 95 insertions(+), 44 deletions(-) diff --git a/joule-code/examples/l3-jouleclaw/src/bin/run.rs b/joule-code/examples/l3-jouleclaw/src/bin/run.rs index 73d7ce18b..52adaf3a2 100644 --- a/joule-code/examples/l3-jouleclaw/src/bin/run.rs +++ b/joule-code/examples/l3-jouleclaw/src/bin/run.rs @@ -27,6 +27,15 @@ 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). +/// +/// Not this process's own CPU-energy counter (`jouleclaw_energy::process`, the +/// in-process meter), although that one is live on macOS 27.0.1 where IOReport's +/// CPU rail is not: neither engine's inference runs on this process's threads +/// alone. `llama` runs in a `llama-completion` subprocess, and the in-process +/// `jouleclaw-runtime` registers the Apple backend's GPU kernels (Metal and MPS +/// matmuls, the quantized GEMVs that serve decode), whose energy is the GPU's. +/// A per-process CPU figure would bill that work nothing, or claim a domain its +/// counter does not read. fn host_counter() -> Option> { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs index a4215e8d1..11752f391 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/local_model_econ.rs @@ -10,13 +10,19 @@ //! deterministic skill and the rest resolve at ~0 — the cheapest-sufficient //! thesis in metered energy. //! -//! HONEST ENERGY: the auto-detected meter is `jouleclaw-energy`'s. On Apple Silicon -//! that is the IOReport energy counter (`Provenance::HwShunt`, a live vendor counter) -//! only where its CPU energy rail advances under load; elsewhere — macOS 27.0.1 on an M5 Max -//! and an M3 Ultra — it is a load-average × power-band ESTIMATOR -//! (`Provenance::Estimator`). Each dispatch prints which meter ran and whether its -//! joules were measured or estimated. Decode wall-time and token counts are exact -//! measurements. A LOCAL model is priced energy-only (no per-call API dollars). +//! HONEST ENERGY: the decode runs on this process's own threads (the loader's CPU +//! kernels on its rayon pool; this example builds the loader without its GPU +//! `fabric`), so the meter is `jouleclaw-energy`'s in-process meter +//! (`detect_in_process_meter`). On macOS that is this process's own CPU-energy +//! counter (`process-cpu`, `Provenance::HwShunt` over `EnergyDomain::ProcessCpu`) +//! where it is live, as it is for an unprivileged reader on macOS 27.0.1 on an M5 +//! Max and an M3 Ultra: a hardware reading of the decode's CPU energy, which does +//! not move with anything else the host runs. Where it is not live, and off macOS, +//! it is the host meter (`detect_meter`): the IOReport counter where its CPU rail +//! is live, else a load-average × power-band ESTIMATOR (`Provenance::Estimator`). +//! Each dispatch prints which meter ran and whether its joules were measured or +//! estimated. Decode wall-time and token counts are exact measurements. A LOCAL +//! model is priced energy-only (no per-call API dollars). //! //! Run (defaults to a Nemotron-3-Nano-4B snapshot if present): //! ```text @@ -34,7 +40,7 @@ use jouleclaw_cascade::{ L3ModelId, QualityFloor, Query, QueryInput, Runtime, Tier, TierEstimate, TierId, VerificationStatus, }; -use jouleclaw_energy::meter::detect_meter; +use jouleclaw_energy::meter::detect_in_process_meter; use jouleclaw_energy::Provenance; use jouleclaw_harness_econ::{joules_obtained, window_joules, EconHarness, PriceModel, Task}; use jouleclaw_loader_gguf::bpe_tokenizer::BpeTokenizer; @@ -93,12 +99,14 @@ impl Tier for LocalModelTier { let ids: Vec = std::iter::once(self.bos).chain(self.tok.encode(&prompt)).collect(); - // Fresh meter per dispatch: its first read sets the baseline, the second - // yields the energy integrated over the decode window. A window the meter - // put no figure on stops the run (`window_joules`): a failed read is not a - // zero-energy reading, and a figure the meter did not produce is never - // reported. - let meter = detect_meter(); + // Fresh meter per dispatch (its liveness probe runs once per process): its + // first read sets the baseline, the second yields the energy over the + // decode window. The decode runs on this process's threads, so the + // in-process meter covers it. A window the meter put no figure on stops + // the run (`window_joules`): a failed read is not a zero-energy reading, + // a counter that did not advance did not see the work, and a figure the + // meter did not produce is never reported. + let meter = detect_in_process_meter(); let e0 = meter.read(); let t = Instant::now(); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/meter_probe.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/meter_probe.rs index 61641eb44..a5c3ff411 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/meter_probe.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/meter_probe.rs @@ -1,13 +1,15 @@ -//! Probe: what does jouleclaw-energy's auto-detected meter report on this box? -use jouleclaw_energy::meter::detect_meter; +//! Probe: what do jouleclaw-energy's meters report on this box? The host meter +//! (`detect_meter`, for work wherever it runs) and the in-process meter +//! (`detect_in_process_meter`, for work on this process's own threads — this +//! probe's own CPU burn is such work). +use jouleclaw_energy::meter::{detect_in_process_meter, detect_meter, CompositeMeter}; -fn main() { - let m = detect_meter(); - println!("active meter: {}", m.active_name()); - println!("available: {}", m.is_available()); +fn probe(label: &str, m: &CompositeMeter) { + println!("{label}: active meter: {} (holds {:?})", m.active_name(), m.names()); + println!("{label}: available: {}", m.is_available()); let r0 = m.read().expect("read0"); - println!("provenance: {:?}", r0.provenance); - // Burn some CPU so a load-derived counter shows a delta. + println!("{label}: provenance: {:?}, domain: {:?}", r0.provenance, r0.domain); + // Burn some CPU on this thread so a counter shows a delta. let t = std::time::Instant::now(); let mut x = 0u64; for i in 0..3_000_000_000u64 { @@ -16,11 +18,16 @@ fn main() { let r1 = m.read().expect("read1"); let secs = t.elapsed().as_secs_f64(); let dj = r1.uj.saturating_sub(r0.uj) as f64 / 1e6; - println!("spin sentinel: {x}"); + println!("{label}: spin sentinel: {x}"); println!( - "over {:.2}s: delta {:.3} J (~{:.1} W avg)", + "{label}: over {:.2}s: delta {:.3} J (~{:.1} W avg)", secs, dj, if secs > 0.0 { dj / secs } else { 0.0 } ); } + +fn main() { + probe("host", &detect_meter()); + probe("in-process", &detect_in_process_meter()); +} diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs index 2cd868122..406e2bdf4 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_coder.rs @@ -7,12 +7,16 @@ //! The local model emits the corrected file (the protocol that works for local //! models — they fumble unified-diff syntax), wrapped in Granite's chat //! template. Verification is operational: the patch is written and `python3` -//! actually imports + asserts. Energy is `jouleclaw-energy`'s auto-detected -//! meter at its own provenance: the IOReport energy counter (`HwShunt`) only -//! where its CPU energy rail advances under load, else the load-derived -//! estimator (`Estimator`), as on macOS 27.0.1 on an M5 Max and an M3 Ultra; the -//! report says which (decode time + tokens are exact). A local model is priced -//! energy-only. +//! actually imports + asserts. Energy covers the decode alone, +//! which runs on this process's own threads (the loader's CPU kernels; built +//! without its GPU `fabric`), so the meter is `jouleclaw-energy`'s in-process meter +//! (`detect_in_process_meter`) at its own provenance: this process's own +//! CPU-energy counter (`process-cpu`, `HwShunt` over `ProcessCpu`) where it is +//! live, as on macOS 27.0.1 on an M5 Max and an M3 Ultra; else the host meter — +//! the IOReport counter (`HwShunt`) where its CPU rail is live, or the +//! load-derived estimator (`Estimator`). The `python3` judge is a subprocess and +//! runs outside the metered window. The report says which meter ran (decode time +//! + tokens are exact). A local model is priced energy-only. //! //! Run (defaults to the granite-4.0-h-micro snapshot if present): //! ```text @@ -31,7 +35,7 @@ use jouleclaw_cascade::{ L3ModelId, QualityFloor, Query, QueryInput, Runtime, Tier, TierEstimate, TierId, VerificationStatus, }; -use jouleclaw_energy::meter::detect_meter; +use jouleclaw_energy::meter::detect_in_process_meter; use jouleclaw_energy::Provenance; use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator}; use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance}; @@ -96,9 +100,11 @@ impl Tier for LocalCoderTier { }; let ids = self.chat_prompt(&user); - // A window the meter put no figure on stops the run (`window_joules`): a - // failed read is not a zero-energy reading. - let meter = detect_meter(); + // The decode runs on this process's threads, so the in-process meter + // covers it. A window the meter put no figure on stops the run + // (`window_joules`): a failed read is not a zero-energy reading, and a + // counter that did not advance did not see the work. + let meter = detect_in_process_meter(); let e0 = meter.read(); let t = Instant::now(); diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs index 0400f1e78..2d968fbe5 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-harness-econ/examples/swebench_local_suite.rs @@ -7,12 +7,16 @@ //! This is the multi-instance generalisation of `swebench_local_coder`: instead //! of one fix it reports a metered curve across bug types — which classes a 4B //! local model resolves, and the joules/$ each verified fix actually cost. The -//! denominator is fixes that PASS their test, never attempts. Energy is -//! `jouleclaw-energy`'s auto-detected meter at its own provenance: the IOReport -//! energy counter (`HwShunt`) only where its CPU energy rail advances under -//! load, else the load-derived estimator (`Estimator`), as on macOS 27.0.1 on an -//! M5 Max and an M3 Ultra; the report says which (decode time + tokens exact). A -//! local model is priced energy-only. +//! denominator is fixes that PASS their test, never attempts. Energy covers the decode alone, +//! which runs on this process's own threads (the loader's CPU kernels; built +//! without its GPU `fabric`), so the meter is `jouleclaw-energy`'s in-process meter +//! (`detect_in_process_meter`) at its own provenance: this process's own +//! CPU-energy counter (`process-cpu`, `HwShunt` over `ProcessCpu`) where it is +//! live, as on macOS 27.0.1 on an M5 Max and an M3 Ultra; else the host meter — +//! the IOReport counter (`HwShunt`) where its CPU rail is live, or the +//! load-derived estimator (`Estimator`). The `python3` judge is a subprocess and +//! runs outside the metered window. The report says which meter ran (decode time +//! + tokens are exact). A local model is priced energy-only. //! //! Run (defaults to the granite-4.0-h-micro snapshot if present): //! ```text @@ -32,7 +36,7 @@ use jouleclaw_cascade::{ L3ModelId, QualityFloor, Query, QueryInput, Runtime, Tier, TierEstimate, TierId, VerificationStatus, }; -use jouleclaw_energy::meter::detect_meter; +use jouleclaw_energy::meter::detect_in_process_meter; use jouleclaw_energy::Provenance; use jouleclaw_harness_econ::swebench::exec::{ShellTestRunner, WholeFileValidator}; use jouleclaw_harness_econ::swebench::{PatchValidator, SweInstance}; @@ -128,9 +132,11 @@ impl Tier for LocalCoderTier { ); let ids = self.tok.encode(&prompt); - // A window the meter put no figure on stops the run (`window_joules`): a - // failed read is not a zero-energy reading. - let meter = detect_meter(); + // The decode runs on this process's threads, so the in-process meter + // covers it. A window the meter put no figure on stops the run + // (`window_joules`): a failed read is not a zero-energy reading, and a + // counter that did not advance did not see the work. + let meter = detect_in_process_meter(); let e0 = meter.read(); let t = Instant::now(); diff --git a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs index 5c8d82a4d..68becd665 100644 --- a/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs +++ b/jouledesk/jouledesk-rs/crates/jouledesk-github/src/lib.rs @@ -71,6 +71,11 @@ pub fn do_work(question: &str) -> Work { /// [`do_work`] with the model given as the path to a local GGUF; a path that does not /// exist answers with the deterministic fallback, and runs no subprocess. +/// +/// The meter is the host's, `jouleclaw_energy::meter::detect_meter`, and never the +/// in-process meter (`detect_in_process_meter`): the model runs in a subprocess +/// (`llama-completion`), and this process's own CPU-energy counter reads this +/// process's threads alone, so it would bill the inference nothing. pub(crate) fn do_work_with(question: &str, model: &str) -> Work { let meter = jouleclaw_energy::meter::detect_meter(); let ((answer, model), figure) = metered(&meter, || match run_model(question, model) { @@ -606,6 +611,16 @@ pub(crate) mod tests { } } + /// The work is metered by the host meter, never by this process's own CPU-energy + /// counter: with a model configured, the inference runs in a `llama-completion` + /// subprocess that counter does not read. + #[test] + fn the_work_is_metered_by_the_host_meter_not_the_process_counter() { + let w = do_work_with("what changed?", NO_MODEL); + assert_ne!(w.meter, "process-cpu", "metered by {}", w.meter); + assert_eq!(w.meter, jouleclaw_energy::meter::detect_meter().active_name()); + } + #[test] fn ignores_comments_that_dont_address_the_handle() { let event = json!({ "comment": { "user": { "login": "x" }, "body": "just chatting" } }); -- 2.54.0 (Apple Git-157)