jouleclaw-energy: a live per-process CPU energy counter for in-process work #123

Merged
dcharlot merged 2 commits from jouleclaw-energy/process-energy into main 2026-10-01 06:21:51 -04:00
12 changed files with 679 additions and 55 deletions

View file

@ -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<Box<dyn EnergyMeter>> {
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
{

View file

@ -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),

View file

@ -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};

View file

@ -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![]);

View file

@ -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 `<sys/resource.h>`.
const RUSAGE_INFO_V6: libc::c_int = 6;
/// `struct rusage_info_v6` from the macOS SDK's `<sys/resource.h>`, 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<u64> {
let mut info = std::mem::MaybeUninit::<RusageInfoV6>::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<u64> {
// 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<CounterLiveness> = 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<u64>) -> 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<u64>, after_nj: Option<u64>) -> Result<u64, EnergyError> {
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<T>(&self, work: impl FnOnce() -> T) -> (T, Result<u64, EnergyError>) {
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<EnergyReading, EnergyError> {
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::<RusageInfoV6>(), 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");
}
}

View file

@ -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<CounterLiveness> = 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}"),
}
}

View file

@ -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<u32> =
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();

View file

@ -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());
}

View file

@ -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();

View file

@ -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();

View file

@ -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" } });

View file

@ -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:?}");
}