eoc-meter: a live per-process CPU energy counter for in-process cascades #124

Merged
dcharlot merged 4 commits from eoc-meter/process-energy into main 2026-10-01 06:22:08 -04:00
4 changed files with 687 additions and 27 deletions

View file

@ -47,6 +47,18 @@
//! the final read's. A generation that ends in an error is estimated over
//! the tokens streamed before it, at least one.
//!
//! Which counter to hand a backend is the caller's choice, and the counter's
//! domain must cover where the backend's work runs. Every backend here runs
//! in this process (each binds its runtime as a library, none spawns a
//! subprocess), but most offload to an accelerator: `mlx` to the GPU and
//! Neural Engine, `mlc` to the GPU, `llamacpp` to the GPU by default
//! (`n_gpu_layers` 999), and `onnx` too with a CoreML, CUDA, DirectML or
//! TensorRT provider. This process's own CPU energy counter
//! ([`eoc_meter::detect_in_process`]) sees none of that. It covers a backend
//! only while all of its work stays on the CPU here: `onnx` with the CPU
//! provider, `llamacpp` with `n_gpu_layers` 0. Otherwise hand it what
//! [`eoc_meter::detect`] selects.
//!
//! ## WASM
//!
//! [`gguf`] and [`tokenizer`] compile to `wasm32-unknown-unknown`. The

View file

@ -44,6 +44,11 @@
//! RAPL and NVML are, so a delta it reads is `Measured` — but only while the
//! rails are live: the counter exists only on a host whose rails the probe
//! found live, and on any other there is no IOReport reading to tag.
//! - macOS per-process energy ([`MacosProcessEnergyCounter`]): this
//! process's own threads' CPU energy, from `proc_pid_rusage`, live for an
//! unprivileged reader. **Never selected by [`detect`]** — it does not see
//! a child process's work, or the GPU's — only by [`detect_in_process`],
//! for callers whose metered work runs on the CPU in this process.
//! - macOS `powermetrics`: detected when the binary is on the PATH, but the
//! reference wrapper does not parse it, so every read returns
//! `Err(NoCounter)`: no figure, and callers keep their own estimate.
@ -52,12 +57,13 @@
//! ([`nvml_status`]).
//! - WASM and unknown targets fall through to `StubCounter`.
// `deny` not `forbid`: the whole crate is safe except two audited FFI modules,
// each with a scoped `#[allow(unsafe_code)]`: the Apple-Silicon IOReport module
// (`macos_ioreport`) — the on-die energy counters are unreachable without the
// `unsafe extern "C"` IOReport calls — and `clocks`, whose one call is
// `clock_gettime` for the probing thread's CPU clock. Everything else stays
// unsafe-free.
// `deny` not `forbid`: the whole crate is safe except three audited FFI
// modules, each with a scoped `#[allow(unsafe_code)]`: the Apple-Silicon
// IOReport module (`macos_ioreport`) — the on-die energy counters are
// unreachable without the `unsafe extern "C"` IOReport calls — `clocks`, whose
// one call is `clock_gettime` for the probing thread's CPU clock, and
// `macos_process_energy`, whose calls are `getpid` and `proc_pid_rusage`.
// Everything else stays unsafe-free.
#![deny(unsafe_code)]
// `Error` is used by platform-conditional modules below.
@ -276,7 +282,7 @@ mod rail_liveness {
/// A watch of every channel of a sample: for a vendor counter (RAPL's
/// package counters, one channel a package; NVML's total-energy
/// counter), whose every channel counts the domain it claims.
#[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", feature = "cuda")), allow(dead_code))] // used by RAPL, NVML and the process counter
pub(crate) fn every_channel() -> Self {
Self::of(|_| true)
}
@ -536,7 +542,7 @@ mod rail_liveness {
/// it does not open: then nothing is sampled and nothing ticks. Generic
/// over each part so the tests drive this very function with a scripted
/// counter, tick and clock; each rung's cached probe passes the real ones.
#[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", feature = "cuda")), allow(dead_code))] // used by RAPL, NVML and the process counter
pub(crate) fn probe_counter<C>(
open: impl FnOnce() -> Option<C>,
mut sample: impl FnMut(&C) -> Option<Vec<(String, i64)>>,
@ -552,7 +558,7 @@ mod rail_liveness {
/// not open) and not one interval observed (no two usable samples in five
/// budgets) are both `Unavailable`; only a rail that kept advancing is
/// `Live`.
#[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", feature = "cuda")), allow(dead_code))] // used by RAPL, NVML and the process counter
pub(crate) fn counter_status(probed: Option<&RailWatch>) -> crate::CounterStatus {
match probed {
None => crate::CounterStatus::Unavailable,
@ -564,7 +570,7 @@ mod rail_liveness {
/// Whether a counter rung with this status may be selected: only when it
/// is live. The one gate the RAPL and NVML constructors pass through.
#[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", feature = "cuda")), allow(dead_code))] // used by RAPL, NVML and the process counter
pub(crate) fn admits_vendor_counter(status: crate::CounterStatus) -> bool {
status == crate::CounterStatus::Live
}
@ -3658,7 +3664,7 @@ pub enum CounterStatus {
/// that is not live before declining it: one second, as
/// `CPU_RAIL_LIVENESS_BUDGET`. A live counter is called live about 300 ms in.
/// Paid once per process, and only where the counter opens.
#[cfg_attr(not(any(target_os = "linux", feature = "cuda")), allow(dead_code))] // used by the RAPL and NVML rungs
#[cfg_attr(not(any(target_os = "linux", target_os = "macos", feature = "cuda")), allow(dead_code))] // used by RAPL, NVML and the process counter
const VENDOR_COUNTER_LIVENESS_BUDGET: std::time::Duration = std::time::Duration::from_millis(1000);
/// This host's RAPL status, probed once per process and cached.
@ -4418,6 +4424,301 @@ mod macos_powermetrics {
#[cfg(target_os = "macos")]
pub use macos_powermetrics::MacosPowermetricsCounter;
/// This process's own CPU energy on macOS, as the kernel attributes it to
/// this process's threads: `ri_energy_nj` of `proc_pid_rusage(getpid(),
/// RUSAGE_INFO_V6)` (libproc; public, no privilege, no entitlement).
///
/// The domain, exactly: the CPU energy of **this process's threads**, all of
/// them, cumulative since the process started. Not a child process's (a
/// child's energy is its own, and is not added to the parent's when it is
/// reaped), and not the GPU, the Neural Engine, DRAM, or anything else on the
/// SoC. So it measures work this process runs on the CPU, in-process, and
/// nothing else; and it measures all of that, including another thread's
/// unrelated work during the same span.
///
/// That is why [`crate::detect`] never selects it: eoc's counter brackets
/// stages that may run their work in a subprocess (a CLI-backed tool, a
/// scoring interpreter) or on the GPU, and a span over such work would read
/// only this process's share — a figure for work it did not see, tagged
/// measured. A caller whose metered work runs on the CPU in this process
/// opts in with [`crate::detect_in_process`] or
/// [`MacosProcessEnergyCounter::try_new`].
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
mod macos_process_energy {
use super::*;
use crate::rail_liveness;
use std::os::raw::{c_int, c_void};
/// `RUSAGE_INFO_V6` in `<sys/resource.h>`.
const RUSAGE_INFO_V6: c_int = 6;
/// `struct rusage_info_v6` from the macOS 27 SDK's `<sys/resource.h>`,
/// field for field. Only `ri_energy_nj` is read; the layout up to it must
/// match exactly, and the size must cover what the kernel writes.
#[repr(C)]
#[derive(Default)]
struct RusageInfoV6 {
ri_uuid: [u8; 16],
ri_user_time: u64,
ri_system_time: u64,
ri_pkg_idle_wkups: u64,
ri_interrupt_wkups: u64,
ri_pageins: u64,
ri_wired_size: u64,
ri_resident_size: u64,
ri_phys_footprint: u64,
ri_proc_start_abstime: u64,
ri_proc_exit_abstime: u64,
ri_child_user_time: u64,
ri_child_system_time: u64,
ri_child_pkg_idle_wkups: u64,
ri_child_interrupt_wkups: u64,
ri_child_pageins: u64,
ri_child_elapsed_abstime: u64,
ri_diskio_bytesread: u64,
ri_diskio_byteswritten: u64,
ri_cpu_time_qos_default: u64,
ri_cpu_time_qos_maintenance: u64,
ri_cpu_time_qos_background: u64,
ri_cpu_time_qos_utility: u64,
ri_cpu_time_qos_legacy: u64,
ri_cpu_time_qos_user_initiated: u64,
ri_cpu_time_qos_user_interactive: u64,
ri_billed_system_time: u64,
ri_serviced_system_time: u64,
ri_logical_writes: u64,
ri_lifetime_max_phys_footprint: u64,
ri_instructions: u64,
ri_cycles: u64,
ri_billed_energy: u64,
ri_serviced_energy: u64,
ri_interval_max_phys_footprint: u64,
ri_runnable_time: u64,
ri_flags: u64,
ri_user_ptime: u64,
ri_system_ptime: u64,
ri_pinstructions: u64,
ri_pcycles: u64,
ri_energy_nj: u64,
ri_penergy_nj: u64,
ri_secure_time_in_system: u64,
ri_secure_ptime_in_system: u64,
ri_neural_footprint: u64,
ri_lifetime_max_neural_footprint: u64,
ri_interval_max_neural_footprint: u64,
ri_conclave_footprint: u64,
ri_page_wait_time_mach: u64,
ri_page_cache_hits: u64,
ri_reserved: [u64; 6],
}
// `sizeof(struct rusage_info_v6)` and `offsetof(.., ri_energy_nj)`, as a C
// program compiled against the macOS 27 SDK prints them.
const _: () = assert!(std::mem::size_of::<RusageInfoV6>() == 464);
const _: () = assert!(std::mem::offset_of!(RusageInfoV6, ri_energy_nj) == 336);
unsafe extern "C" {
fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut c_void) -> c_int;
fn getpid() -> c_int;
}
/// This process's id.
pub(crate) fn this_pid() -> i32 {
unsafe { getpid() }
}
/// The cumulative CPU energy, in nanojoules, the kernel attributes to the
/// threads of process `pid`; `None` when `proc_pid_rusage` fails (no such
/// process, or one this user may not inspect).
pub(crate) fn energy_nj(pid: i32) -> Option<u64> {
let mut info = RusageInfoV6::default();
let rc = unsafe { proc_pid_rusage(pid, RUSAGE_INFO_V6, &mut info as *mut RusageInfoV6 as *mut c_void) };
(rc == 0).then_some(info.ri_energy_nj)
}
/// This process's own CPU energy counter; see the module documentation
/// for its domain. Constructed only where the counter is live
/// ([`crate::process_energy_status`]), and then a delta it reads is a
/// measurement of this process's CPU work over the span.
#[derive(Debug)]
pub struct MacosProcessEnergyCounter {
pid: i32,
}
impl MacosProcessEnergyCounter {
/// This process's energy counter, on a host where it is live.
/// `Err(NoCounter)` otherwise; [`crate::process_energy_status`] says
/// why. For callers whose metered work runs on the CPU in this
/// process only: not a subprocess's work, not the GPU's.
pub fn try_new() -> Result<Self> {
Self::try_new_given(crate::process_energy_status())
}
/// The constructor behind [`Self::try_new`], given this host's status:
/// every status the gate does not admit declines.
pub(crate) fn try_new_given(status: CounterStatus) -> Result<Self> {
if rail_liveness::admits_vendor_counter(status) {
Ok(Self { pid: this_pid() })
} else {
Err(Error::NoCounter)
}
}
}
impl JouleCounter for MacosProcessEnergyCounter {
/// This process's cumulative CPU energy since it started, in µJ
/// (`ri_energy_nj` / 1000).
fn read_microjoules(&self) -> Result<u64> {
energy_nj(self.pid)
.map(|nj| nj / 1_000)
.ok_or_else(|| Error::Backend("proc_pid_rusage(RUSAGE_INFO_V6) failed".to_string()))
}
fn name(&self) -> &'static str {
"process-energy"
}
}
/// One liveness sample of the counter `read` reads: its raw nanojoules
/// as the one rail. `None` for a failed read, which the probe skips.
fn sample(read: impl Fn() -> Option<u64>) -> Option<Vec<(String, i64)>> {
let nj = read()?;
Some(vec![("ri_energy_nj".to_string(), i64::try_from(nj).unwrap_or(i64::MAX))])
}
/// The process counter's probe on a given reader: it opens when the
/// counter reads at all, and is then sampled by
/// [`rail_liveness::probe_counter`], with `tick` between samples, by the
/// clock `now`.
pub(crate) fn status_with(
read: impl Fn() -> Option<u64>,
tick: impl FnMut(),
now: impl Fn() -> std::time::Duration,
) -> CounterStatus {
rail_liveness::counter_status(
rail_liveness::probe_counter(
|| read().map(|_| ()),
|_| sample(&read),
tick,
now,
crate::VENDOR_COUNTER_LIVENESS_BUDGET,
)
.as_ref(),
)
}
/// The real probe: this process's counter, the CPU-burning tick (this
/// thread's burn is this process's work, so a live counter advances in
/// every interval) and the monotonic clock. Cached by
/// [`crate::process_energy_status`], its only caller.
pub(crate) fn host_status() -> CounterStatus {
let pid = this_pid();
status_with(|| energy_nj(pid), crate::clocks::cpu_burn_tick, crate::clocks::wall_now)
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
use std::time::Duration;
/// The probe on scripted reads, with a clock that only the tick
/// moves, by 25 ms.
fn scripted(read: impl Fn(usize) -> Option<u64>) -> CounterStatus {
let (clock, reads) = (Cell::new(Duration::ZERO), Cell::new(0usize));
status_with(
|| {
reads.set(reads.get() + 1);
read(reads.get() - 1)
},
|| clock.set(clock.get() + Duration::from_millis(25)),
|| clock.get(),
)
}
/// A counter that advances at every read is live; one that holds (a
/// constant), steps once, or cannot be read is declined.
#[test]
fn the_process_probe_admits_only_a_counter_that_keeps_advancing() {
assert_eq!(scripted(|i| Some(1_000_000 + 60_000_000 * i as u64)), CounterStatus::Live);
assert_eq!(scripted(|_| Some(1_000_000)), CounterStatus::Frozen, "a constant");
assert_eq!(scripted(|i| Some(if i < 9 { 1 } else { 2 })), CounterStatus::Frozen, "one step");
assert_eq!(scripted(|_| None), CounterStatus::Unavailable, "proc_pid_rusage failing");
}
/// Only a live status constructs the counter, and it reads this
/// process.
#[test]
fn only_a_live_status_constructs_the_process_counter() {
for status in [CounterStatus::Unavailable, CounterStatus::Frozen] {
assert!(
matches!(MacosProcessEnergyCounter::try_new_given(status), Err(Error::NoCounter)),
"{status:?} constructed the process counter"
);
}
let c = MacosProcessEnergyCounter::try_new_given(CounterStatus::Live).expect("Live constructs");
assert_eq!(c.pid, this_pid());
assert_eq!(c.name(), "process-energy");
}
/// A process that does not exist has no energy to read.
#[test]
fn no_process_no_reading() {
assert_eq!(energy_nj(-1), None);
assert!(MacosProcessEnergyCounter { pid: -1 }.read_microjoules().is_err());
}
}
}
#[cfg(target_os = "macos")]
pub use macos_process_energy::MacosProcessEnergyCounter;
/// This host's process-energy status: whether this process's own CPU energy
/// counter ([`MacosProcessEnergyCounter`]) is live, probed once per process
/// and cached, by the rule every counter here is held to — it kept advancing
/// while the probing thread burned CPU, in at least five sample intervals, at
/// least 80% of them, over at least 300 ms. Always
/// [`CounterStatus::Unavailable`] off macOS. The first call burns about 300 ms
/// of the calling thread's CPU where the counter is live (a second if not).
pub fn process_energy_status() -> CounterStatus {
#[cfg(target_os = "macos")]
{
static STATUS: std::sync::OnceLock<CounterStatus> = std::sync::OnceLock::new();
*STATUS.get_or_init(macos_process_energy::host_status)
}
#[cfg(not(target_os = "macos"))]
{
CounterStatus::Unavailable
}
}
/// The counter for a caller whose metered work runs **on the CPU, in this
/// process** — no subprocess, no GPU or Neural Engine offload: this process's
/// own CPU energy counter where it is live ([`process_energy_status`]), and
/// otherwise whatever [`detect`] selects.
///
/// Its domain is this process's threads' CPU energy and nothing else, so a
/// span over work done anywhere else reads only this process's share of it.
/// That is why [`detect`] never selects it, and why only a caller that knows
/// its work stays in-process should ask for it.
pub fn detect_in_process() -> Box<dyn JouleCounter> {
detect_in_process_with(process_energy_status, detect)
}
/// The selection behind [`detect_in_process`], given the process counter's
/// status and the fallback.
#[cfg_attr(not(target_os = "macos"), allow(unused_variables))]
pub(crate) fn detect_in_process_with(
status: impl FnOnce() -> CounterStatus,
fallback: impl FnOnce() -> Box<dyn JouleCounter>,
) -> Box<dyn JouleCounter> {
#[cfg(target_os = "macos")]
if let Ok(c) = MacosProcessEnergyCounter::try_new_given(status()) {
return Box::new(c);
}
fallback()
}
#[cfg(feature = "cuda")]
mod nvml {
use super::*;
@ -4566,6 +4867,11 @@ pub use nvml::NvmlCounter;
/// → powermetrics on macOS → `StubCounter`. Every hardware rung is held to
/// the same sustained-advance rule; a rung whose counter is not live is
/// skipped, so callers estimate rather than seal what it read.
///
/// It never selects this process's own energy counter
/// ([`MacosProcessEnergyCounter`]), whose domain stops at this process's
/// CPU threads: what eoc meters may run in a subprocess or on the GPU. A
/// caller whose work is in-process asks [`detect_in_process`].
pub fn detect() -> Box<dyn JouleCounter> {
detect_with(nvml_status, rapl_status, ioreport_status)
}
@ -4650,6 +4956,45 @@ mod tests {
}
}
/// `detect()` never selects this process's own energy counter, under any
/// status of any rung, nor on this host, whatever that counter's status:
/// eoc's counter brackets stages that may run in a subprocess or on the
/// GPU, which it does not see.
#[test]
fn detect_never_selects_the_process_counter() {
use CounterStatus as S;
let ioreport = [
IoReportStatus::Unavailable,
IoReportStatus::CpuRailAbsent,
IoReportStatus::CpuRailFrozen,
IoReportStatus::Live,
];
for nvml in [S::Unavailable, S::Frozen, S::Live] {
for rapl in [S::Unavailable, S::Frozen, S::Live] {
for io in ioreport {
let picked = detect_with(|| nvml, || rapl, || io).name();
assert_ne!(picked, "process-energy", "detect_with({nvml:?}, {rapl:?}, {io:?})");
}
}
}
let picked = detect().name();
eprintln!("detect()={picked} process_energy_status()={:?}", process_energy_status());
assert_ne!(picked, "process-energy");
}
/// `detect_in_process()` picks the process counter exactly where it is
/// live (only on macOS), and otherwise falls back to what it is given.
#[test]
fn detect_in_process_picks_the_process_counter_only_when_live() {
use CounterStatus as S;
let fallback = || -> Box<dyn JouleCounter> { Box::new(StubCounter) };
let picked = detect_in_process_with(|| S::Live, fallback).name();
assert_eq!(picked == "process-energy", cfg!(target_os = "macos"), "Live picked {picked}");
for status in [S::Unavailable, S::Frozen] {
assert_eq!(detect_in_process_with(|| status, fallback).name(), "stub", "{status:?}");
}
}
/// Off Linux there is no RAPL rung to probe; without `cuda`, no NVML one.
#[test]
fn a_vendor_counter_with_no_rung_is_unavailable() {
@ -4657,6 +5002,8 @@ mod tests {
assert_eq!(rapl_status(), CounterStatus::Unavailable);
#[cfg(not(feature = "cuda"))]
assert_eq!(nvml_status(), CounterStatus::Unavailable);
#[cfg(not(target_os = "macos"))]
assert_eq!(process_energy_status(), CounterStatus::Unavailable);
}
/// Against this host's RAPL package counters, where there are any: the

View file

@ -0,0 +1,235 @@
//! This process's own CPU energy counter, against the hardware.
//!
//! In a test binary of their own because the counter's domain is the whole
//! process: every thread of it. In the library's test binary, other tests
//! burn CPU on other threads at the same time (the IOReport liveness probes),
//! and that energy is this process's too, so "a sleeping span reads almost
//! nothing" could not hold there. Here the tests take one lock, so only the
//! test holding it does any work.
//!
//! Every figure is checked against an oracle the tests read for themselves:
//! `proc_pid_rusage` declared here, into a raw buffer, with the SDK's offsets
//! (`ri_energy_nj` at byte 336), not through the implementation's struct.
//! None of these tests passes without asserting: off Apple Silicon the
//! counter's status must agree with the oracle, and on Apple Silicon both
//! must say live.
#![cfg(target_os = "macos")]
use std::os::raw::{c_int, c_void};
use std::process::{Command, Stdio};
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
use eoc_meter::{CounterStatus, JouleCounter, MacosProcessEnergyCounter};
/// One test at a time: each reads the energy of the whole process.
static SERIAL: Mutex<()> = Mutex::new(());
fn serial() -> MutexGuard<'static, ()> {
SERIAL.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
unsafe extern "C" {
fn proc_pid_rusage(pid: c_int, flavor: c_int, buffer: *mut c_void) -> c_int;
fn mach_timebase_info(info: *mut [u32; 2]) -> c_int;
fn clock_gettime_nsec_np(clock_id: u32) -> u64;
}
/// `CLOCK_THREAD_CPUTIME_ID` in macOS `<time.h>`.
const CLOCK_THREAD_CPUTIME_ID: u32 = 16;
/// This thread's CPU time.
fn thread_cpu() -> Duration {
Duration::from_nanos(unsafe { clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID) })
}
/// `struct rusage_info_v6` is 464 bytes; read it as words. Offsets from the
/// macOS 27 SDK's `<sys/resource.h>` (`offsetof`, as a C program prints them):
/// `ri_user_time` 16, `ri_system_time` 24, `ri_child_user_time` 96,
/// `ri_child_system_time` 104, `ri_energy_nj` 336.
const WORDS: usize = 464 / 8;
const USER_TIME: usize = 16 / 8;
const SYSTEM_TIME: usize = 24 / 8;
const CHILD_USER_TIME: usize = 96 / 8;
const CHILD_SYSTEM_TIME: usize = 104 / 8;
const ENERGY_NJ: usize = 336 / 8;
const PENERGY_NJ: usize = 344 / 8;
/// `proc_pid_rusage(pid, RUSAGE_INFO_V6)`, the tests' own read.
fn rusage(pid: u32) -> Option<[u64; WORDS]> {
let mut words = [0u64; WORDS];
let rc = unsafe { proc_pid_rusage(pid as c_int, 6, words.as_mut_ptr() as *mut c_void) };
(rc == 0).then_some(words)
}
fn energy_nj(pid: u32) -> Option<u64> {
rusage(pid).map(|w| w[ENERGY_NJ])
}
/// Mach absolute time units to a duration (rusage's CPU times are in them).
fn mach(units: u64) -> Duration {
let mut tb = [0u32; 2];
assert_eq!(unsafe { mach_timebase_info(&mut tb) }, 0, "mach_timebase_info");
Duration::from_nanos((u128::from(units) * u128::from(tb[0]) / u128::from(tb[1])) as u64)
}
/// The CPU time of the children this process has reaped, user and system.
fn reaped_children_cpu() -> Duration {
let w = rusage(std::process::id()).expect("this process's rusage");
mach(w[CHILD_USER_TIME] + w[CHILD_SYSTEM_TIME])
}
fn own_cpu() -> Duration {
let w = rusage(std::process::id()).expect("this process's rusage");
mach(w[USER_TIME] + w[SYSTEM_TIME])
}
/// Spin this thread until it has used `d` of its own CPU time (or 20 s of
/// wall time have passed), however loaded the host is.
fn burn(d: Duration) {
let (started, wall) = (thread_cpu(), Instant::now());
let mut acc: u64 = 0;
while thread_cpu() - started < d && wall.elapsed() < Duration::from_secs(20) {
for _ in 0..1_000 {
acc = std::hint::black_box(acc.wrapping_add(1));
}
}
}
/// The oracle's verdict on this process's counter: read 21 times, 25 ms of
/// this thread's CPU burned between reads; live if it changed in at least 80%
/// of the 20 intervals. Logs what it saw.
fn oracle_live() -> bool {
let pid = std::process::id();
let mut reads = vec![energy_nj(pid)];
for _ in 0..20 {
burn(Duration::from_millis(25));
reads.push(energy_nj(pid));
}
let changed = reads.windows(2).filter(|w| matches!((w[0], w[1]), (Some(a), Some(b)) if b > a)).count();
eprintln!(
"process-energy oracle: {changed} of 20 intervals advanced; first {:?} last {:?} nJ",
reads.first().copied().flatten(),
reads.last().copied().flatten()
);
changed * 5 >= 20 * 4
}
/// The counter's status agrees with the oracle, and on Apple Silicon both say
/// live: this is the hardware fact the counter rests on. Where it is live,
/// `detect_in_process()` picks it; `detect()` never does.
#[test]
fn the_process_counter_is_live_and_only_detect_in_process_picks_it() {
let _one = serial();
let status = eoc_meter::process_energy_status();
let oracle = oracle_live();
let (in_process, default) = (eoc_meter::detect_in_process().name(), eoc_meter::detect().name());
eprintln!("process-energy: status={status:?} oracle_live={oracle} detect_in_process()={in_process} detect()={default}");
assert_eq!(status == CounterStatus::Live, oracle, "the status disagrees with the oracle");
if cfg!(target_arch = "aarch64") {
assert_eq!(status, CounterStatus::Live, "this process's CPU energy counter is not live on Apple Silicon");
}
assert_eq!(in_process == "process-energy", status == CounterStatus::Live, "detect_in_process() picked {in_process}");
assert_ne!(default, "process-energy", "detect() selected the process counter");
}
/// 200 ms of one thread's CPU work reads between 0.1 and 20 J, and the
/// counter reads what the oracle reads around it.
///
/// The burn counts 200 ms of the thread's own CPU, by its CPU clock, not of
/// wall time: on a loaded host a 200 ms wall-time spin got 90 ms of CPU and
/// read 0.175 J. Even a fixed 200 ms of CPU costs a different amount of
/// energy depending on which core class it ran on and at what clock. On an
/// M5 Max at default QoS it read 0.18-1.24 J. Asking for user-interactive QoS
/// does not help: the call returns 0, but the kernel caps a process with no
/// app role (a test binary) at default QoS, and rusage books the burn as
/// default. So the floor is 0.1 J, half the lowest reading seen. That is also
/// the sleeping test's ceiling, so no counter that reads wall time at a fixed
/// power can pass both tests.
#[test]
fn a_200_ms_one_thread_burn_reads_between_0_1_and_20_j() {
let _one = serial();
let c = MacosProcessEnergyCounter::try_new().expect("the process counter is live here");
let pid = std::process::id();
let (w0, cpu0, wall0) = (rusage(pid).expect("oracle"), own_cpu(), Instant::now());
let before = c.read_microjoules().expect("read");
let started = thread_cpu();
burn(Duration::from_millis(200));
let burned = thread_cpu() - started;
let after = c.read_microjoules().expect("read");
let (w1, cpu1) = (rusage(pid).expect("oracle"), own_cpu());
let (o0, o1) = (w0[ENERGY_NJ], w1[ENERGY_NJ]);
let uj = after - before;
eprintln!(
"process-energy: one-thread burn of {burned:?} thread CPU in {:?} wall read {uj} µJ = {:.3} J \
({:.2} J per CPU-second); process CPU {:?}; oracle {o0} -> {o1} nJ, P-core share {} nJ",
wall0.elapsed(),
uj as f64 / 1e6,
uj as f64 / 1e6 / burned.as_secs_f64(),
cpu1 - cpu0,
w1[PENERGY_NJ] - w0[PENERGY_NJ]
);
assert!(burned >= Duration::from_millis(200), "the thread got only {burned:?} of CPU in 20 s");
assert!(o0 / 1_000 <= before && before <= after && after <= o1 / 1_000, "the counter read outside the oracle's bracket");
assert!((100_000..=20_000_000).contains(&uj), "{uj} µJ for 200 ms of one thread's CPU work");
}
/// A sleeping span reads under 0.1 J: the counter is this process's energy,
/// not the machine's.
#[test]
fn a_sleeping_span_reads_under_0_1_j() {
let _one = serial();
let c = MacosProcessEnergyCounter::try_new().expect("the process counter is live here");
let before = c.read_microjoules().expect("read");
std::thread::sleep(Duration::from_millis(500));
let after = c.read_microjoules().expect("read");
let uj = after - before;
eprintln!("process-energy: 500 ms asleep read {uj} µJ = {:.4} J", uj as f64 / 1e6);
assert!(uj < 100_000, "{uj} µJ while this process slept");
}
/// A child process's work is not this process's energy, neither while it
/// runs nor once it is reaped. The child (`yes`, to /dev/null) runs for at
/// least 300 ms and until its own counter has read at least 0.5 J (or 5 s);
/// the parent's counter, read before the spawn and after the reap, moves by
/// under 0.1 J and by under a fifth of the child's own.
#[test]
fn a_child_processs_work_is_not_attributed() {
let _one = serial();
let c = MacosProcessEnergyCounter::try_new().expect("the process counter is live here");
let reaped0 = reaped_children_cpu();
let before = c.read_microjoules().expect("read");
let mut child = Command::new("yes").stdout(Stdio::null()).spawn().expect("spawn yes");
let started = Instant::now();
let mut child_nj = None;
while started.elapsed() < Duration::from_secs(5) {
std::thread::sleep(Duration::from_millis(50));
child_nj = energy_nj(child.id());
if child_nj.is_some_and(|nj| nj >= 500_000_000) && started.elapsed() >= Duration::from_millis(300) {
break;
}
}
if child_nj.is_none() {
// The child's own counter cannot be read here: let it run 600 ms.
std::thread::sleep(Duration::from_millis(600).saturating_sub(started.elapsed()));
}
child.kill().expect("kill yes");
child.wait().expect("reap yes");
let after = c.read_microjoules().expect("read");
let child_cpu = reaped_children_cpu() - reaped0;
let uj = after - before;
eprintln!(
"process-energy: child ran {:?}, its own counter read {:?} nJ, its CPU (reaped) {child_cpu:?}; \
the parent's counter moved {uj} µJ = {:.4} J",
started.elapsed(),
child_nj,
uj as f64 / 1e6
);
assert!(child_cpu >= Duration::from_millis(150), "the child did not work: {child_cpu:?} of CPU");
if let Some(nj) = child_nj {
assert!(nj >= 200_000_000, "the child's own counter read only {nj} nJ");
assert!(uj * 1_000 * 5 < nj, "the parent read {uj} µJ of a child that read {nj} nJ");
}
assert!(uj < 100_000, "the parent's counter moved {uj} µJ while its child worked");
}

View file

@ -20,7 +20,7 @@ use eoc_cascade::Cascade;
use eoc_core::Query;
use eoc_graph::{GraphStage, Triple};
use eoc_kv::{KvBackend, KvStage, MemoryKvBackend};
use eoc_meter::detect;
use eoc_meter::{detect, detect_in_process};
use eoc_neural::{EchoBackend, NeuralBackend, NeuralStage};
#[derive(Parser, Debug)]
@ -59,7 +59,16 @@ fn build_cascade() -> Cascade {
build_cascade_with(Box::new(EchoBackend::new().with_prefix("[neural fallback] ")))
}
/// The CLI's cascade around a given neural backend, metered by [`detect`].
/// The CLI's cascade around a given neural backend.
///
/// Metered by [`detect_in_process`], because every stage the CLI builds runs
/// on the CPU in this process: the LRU cache, the in-memory KV store, the
/// in-memory graph, and the echo backend (string formatting). None spawns a
/// subprocess or offloads to the GPU, so this process's own CPU energy
/// counter, where it is live, sees all of their work, and less of everything
/// else than a whole-machine counter would. A backend passed here must keep
/// to that too: work it ran in a child process, or on the GPU, would be
/// outside the counter's domain.
fn build_cascade_with(neural: Box<dyn NeuralBackend>) -> Cascade {
let cache = Arc::new(LruCache::new(1024));
@ -78,7 +87,7 @@ fn build_cascade_with(neural: Box<dyn NeuralBackend>) -> Cascade {
let neural = Arc::new(NeuralStage::new(neural));
Cascade::new(cache, kv, graph, neural).with_meter(Arc::from(detect()))
Cascade::new(cache, kv, graph, neural).with_meter(Arc::from(detect_in_process()))
}
async fn run_query(prompt: String) -> Result<()> {
@ -97,6 +106,10 @@ async fn run_meter() -> Result<()> {
println!("counter: {}", counter.name());
#[cfg(target_os = "macos")]
println!("ioreport: {}", ioreport_line(eoc_meter::ioreport_status()));
println!(
"query : metered by {} (its stages all run on the CPU in this process)",
detect_in_process().name()
);
let start = counter.read_microjoules();
tokio::time::sleep(Duration::from_secs(1)).await;
let end = counter.read_microjoules();
@ -144,6 +157,11 @@ mod tests {
use eoc_neural::NeuralBackend;
use std::time::{Duration, Instant};
/// The tests that burn CPU take this, one at a time: on a Mac the CLI's
/// meter is this process's own counter, whose domain is every thread of
/// the process, so one test's burn would land in the other's span.
static BURNING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
/// A neural backend that does ~100 ms of real CPU work and reports its own
/// estimate for it, as a real backend without a meter would.
struct BurningBackend;
@ -164,24 +182,31 @@ mod tests {
}
/// The whole CLI path on this host, around ~100 ms of real CPU work. Where
/// the meter [`super::detect`] picks is no hardware reading — the stub, or
/// the powermetrics stand-in, which is what a Mac whose IOReport CPU rail is
/// not live falls back to — the cost is the backend's own estimate: the
/// same non-zero figure, tagged estimated. Where it is a live hardware
/// counter, the cost is that estimate plus what the counter read, still
/// tagged estimated, because part of it is. Never a zero for the work, and
/// never tagged measured.
/// the meter [`super::detect_in_process`] picks is no hardware reading —
/// the stub, or the powermetrics stand-in — the cost is the backend's own
/// estimate: the same non-zero figure, tagged estimated. Where it is a live
/// counter (this process's own, on a Mac where it is live), the cost is
/// that estimate plus what the counter read, still tagged estimated,
/// because part of it is. Never a zero for the work, and never tagged
/// measured.
#[tokio::test]
async fn work_on_an_unmetered_host_keeps_its_estimate() {
let _one = BURNING.lock().await;
let cascade = build_cascade_with(Box::new(BurningBackend));
let meter = cascade.meter().name();
#[cfg(target_os = "macos")]
assert_eq!(
meter == "ioreport",
eoc_meter::ioreport_status() == eoc_meter::IoReportStatus::Live,
"the CLI's meter is {meter} while IOReport is {:?}",
eoc_meter::ioreport_status()
);
{
let process = eoc_meter::process_energy_status() == eoc_meter::CounterStatus::Live;
assert_eq!(meter == "process-energy", process, "the CLI's meter is {meter}");
if !process {
assert_eq!(
meter == "ioreport",
eoc_meter::ioreport_status() == eoc_meter::IoReportStatus::Live,
"the CLI's meter is {meter} while IOReport is {:?}",
eoc_meter::ioreport_status()
);
}
}
let r = cascade.resolve(Query::new("a question nothing has cached")).await;
eprintln!("eoc cost on this host: meter={meter} cost={}", r.joule_cost);
assert_eq!(r.stage, Stage::Neural);
@ -195,6 +220,47 @@ mod tests {
}
}
/// A neural backend that does ~100 ms of real CPU work, in this process,
/// and claims no figure for it: the estimated zero a stage reports when
/// it has nothing to estimate with. Built only where its one test runs:
/// off macOS there is no process counter for it to be measured by.
#[cfg(target_os = "macos")]
struct BurningUnestimatedBackend;
#[cfg(target_os = "macos")]
#[async_trait]
impl NeuralBackend for BurningUnestimatedBackend {
async fn infer(&self, q: &Query) -> Response {
let started = Instant::now();
let mut acc: u64 = 0;
while started.elapsed() < Duration::from_millis(100) {
acc = acc.wrapping_add(1);
}
std::hint::black_box(acc);
Response::new(q.id, "burned".to_string(), Stage::Neural, JouleCost::zero())
}
}
/// On a Mac whose process counter is live, the CLI's cascade measures
/// in-process CPU work: ~100 ms of one thread's work reads as a measured
/// figure — the counter read all of it, and nothing was estimated —
/// between 1 mJ and 10 J. The floor is low on purpose: a wall-time burn
/// on a loaded host, or on efficiency cores, can draw little (an M3
/// Ultra's efficiency cores read 0.013 J per 200 ms of CPU); the size of
/// a reading is pinned in eoc-meter's own hardware test.
#[cfg(target_os = "macos")]
#[tokio::test]
async fn in_process_work_is_measured_by_the_process_counter() {
let _one = BURNING.lock().await;
assert_eq!(eoc_meter::process_energy_status(), eoc_meter::CounterStatus::Live, "not live on this Mac");
let cascade = build_cascade_with(Box::new(BurningUnestimatedBackend));
assert_eq!(cascade.meter().name(), "process-energy");
let r = cascade.resolve(Query::new("a question nothing has cached")).await;
eprintln!("eoc in-process cost: {}", r.joule_cost);
assert_eq!(r.joule_cost.source, JouleSource::Measured, "{}", r.joule_cost);
assert!((1_000..=10_000_000).contains(&r.joule_cost.microjoules), "{}", r.joule_cost);
}
#[test]
fn a_counter_that_did_not_read_is_never_printed_as_a_figure() {
let failed = idle_line("powermetrics", Err(Error::NoCounter), Err(Error::NoCounter));