jouleclaw-energy: a live per-process CPU energy counter for in-process work #123
12 changed files with 679 additions and 55 deletions
|
|
@ -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"))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
||||
|
|
|
|||
|
|
@ -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![]);
|
||||
|
|
|
|||
328
jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/process.rs
Normal file
328
jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/process.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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}"),
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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" } });
|
||||
|
|
|
|||
|
|
@ -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:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue