open-standards/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs
Transaction Science e838070cdf
All checks were successful
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 43s
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 2m9s
standards workspaces build + test / aac-rs (pull_request) Successful in 1m34s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 52s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 40s
standards workspaces build + test / map-rs (pull_request) Successful in 30s
standards workspaces build + test / mesh-rs (pull_request) Successful in 29s
standards workspaces build + test / openpay (pull_request) Successful in 1m19s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m1s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 6m24s
no-vanity-metrics / Scan committed Markdown for vanity metrics (pull_request) Successful in 4s
no-vanity-metrics / WAI public text names no comparison (pull_request) Successful in 4s
no-vanity-metrics / WAI public text names no company or product (pull_request) Successful in 3s
standards workspaces build + test / arl-rs (pull_request) Successful in 1m12s
standards workspaces build + test / eoc-rs (pull_request) Successful in 2m2s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m35s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 1m11s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 1m15s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m22s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m30s
standards workspaces build + test / sense-rs (pull_request) Successful in 1m9s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 3m45s
sandbox-meter: the oracle's switch count needs no conversion
clippy flagged the i64::from calls in the process-energy test's switch
count as useless (ru_nvcsw is already a c_long, i64 here): two new
warnings, now gone. The count is summed as the c_long it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 23:05:45 -04:00

256 lines
12 KiB
Rust

//! The per-process CPU energy probe on real hardware. Its counter covers
//! every thread of THIS process, so these tests run in a process of their own
//! (this file) and one at a time (a lock): a burn in a test running beside
//! another would land in the other's span. Each asserts against an oracle of
//! its own — `proc_pid_rusage` read into a raw buffer at the SDK's offset,
//! sharing no code with the probe — and logs what it saw.
#![cfg(all(target_os = "macos", target_arch = "aarch64"))]
use sandbox_meter::probe::process_energy_probe_runs;
use sandbox_meter::{process_energy_status, CounterStatus, Measurement, PowerMeter, Provenance};
use sandbox_schema::DeviceClass;
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
/// One test at a time: the counter is process-wide.
static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
fn alone() -> MutexGuard<'static, ()> {
ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner())
}
/// The oracle: `ri_energy_nj` of `pid`, read with `proc_pid_rusage(pid,
/// RUSAGE_INFO_V6)` into a raw buffer and taken at byte 336, where the macOS
/// SDK's `<sys/resource.h>` puts it (a 16-byte uuid, then 40 `uint64_t`s).
fn oracle_energy_nj(pid: libc::pid_t) -> Option<u64> {
let mut buf = [0u64; 64]; // 512 bytes, more than the struct's 464
// SAFETY: `buf` is writable and larger than `rusage_info_v6`.
let rc = unsafe { libc::proc_pid_rusage(pid, 6, buf.as_mut_ptr() as *mut libc::rusage_info_t) };
(rc == 0).then_some(buf[336 / 8])
}
fn me() -> libc::pid_t {
// SAFETY: no preconditions.
unsafe { libc::getpid() }
}
/// This thread's CPU time.
fn thread_cpu() -> Duration {
let mut ts = libc::timespec { tv_sec: 0, tv_nsec: 0 };
// SAFETY: `ts` is writable.
unsafe { libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) };
Duration::new(ts.tv_sec as u64, ts.tv_nsec as u32)
}
/// Burn `cpu` of this thread's CPU time on this one thread — CPU time, not
/// wall time, so a loaded machine that takes the core away makes the burn
/// longer, never smaller — at the user-interactive QoS class, so the burn
/// lands on the cores interactive work gets.
fn burn_cpu(cpu: Duration) {
// SAFETY: sets this thread's own QoS class; no preconditions.
unsafe { libc::pthread_set_qos_class_self_np(libc::qos_class_t::QOS_CLASS_USER_INTERACTIVE, 0) };
let start = thread_cpu();
let mut x = 0u64;
while thread_cpu() - start < cpu {
for _ in 0..10_000 {
x = x.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
}
}
std::hint::black_box(x);
}
fn joules(uj: u64) -> f64 {
uj as f64 / 1e6
}
/// The span's one record, which must be a `Cpu` hardware reading.
fn the_reading(m: &Measurement) -> u64 {
assert_eq!(m.source, "process-energy", "{m:?}");
assert_eq!(m.devices.len(), 1, "{m:?}");
let d = m.devices[0];
assert_eq!((d.class, d.provenance), (DeviceClass::Cpu, Provenance::HwShunt), "{m:?}");
d.check_provenance().unwrap();
d.measured_uj.expect("a reading carries its measured figure")
}
/// The counter is live on this host, and the oracle agrees: over a second of
/// burn sampled every ~25 ms of this thread's CPU, it advanced in at least
/// nine intervals in ten. The probe ran once, however often it is asked.
#[test]
fn the_process_energy_probe_is_live() {
let _one = alone();
let status = process_energy_status();
let (mut advanced, mut intervals) = (0, 0);
let mut last = oracle_energy_nj(me()).expect("oracle read");
let t = Instant::now();
while t.elapsed() < Duration::from_secs(1) {
burn_cpu(Duration::from_millis(25));
let now = oracle_energy_nj(me()).expect("oracle read");
intervals += 1;
advanced += u32::from(now > last);
last = now;
}
for _ in 0..8 {
assert_eq!(process_energy_status(), status);
let _ = PowerMeter::detect_in_process();
}
let runs = process_energy_probe_runs();
eprintln!("process energy: status={status:?} oracle advanced in {advanced}/{intervals} intervals, probe runs={runs}");
assert_eq!(runs, 1, "the liveness probe runs once per process");
if advanced * 10 >= intervals * 9 {
assert_eq!(status, CounterStatus::Live, "the oracle saw the counter keep advancing");
}
if status == CounterStatus::Live {
assert!(advanced * 5 >= intervals * 4, "Live, yet the oracle saw it advance in {advanced}/{intervals}");
}
#[cfg(target_arch = "aarch64")]
assert_eq!(status, CounterStatus::Live, "measured live on this Apple Silicon fleet (macOS 27.0.1)");
}
/// 200 ms of one thread's CPU, burned in this process and metered through
/// `detect_in_process`, is a `Cpu` hardware reading between 0.1 J and 20 J,
/// and inside the oracle's reading of the same work (read around the span).
///
/// The floor is 0.1 J, not 0.2: 200 ms of CPU costs a different amount
/// depending on which core class ran it. On an M5 Max it read 0.18-1.24 J,
/// and 0.171 J on a busy host where the burn took 1 s of wall time (the
/// oracle read the same 0.171 J). 0.1 J is also the sleeping test's ceiling,
/// so no counter that reads wall time at a fixed power passes both.
#[test]
fn an_in_process_burn_is_a_hardware_reading_of_its_energy() {
let _one = alone();
let mut meter = PowerMeter::detect_in_process();
assert_eq!(meter.sources(), ["process-energy"], "status {:?}", process_energy_status());
let outer_before = oracle_energy_nj(me()).unwrap();
let ((), m) = meter.measure(|| burn_cpu(Duration::from_millis(200)));
let outer = oracle_energy_nj(me()).unwrap() - outer_before;
let uj = the_reading(&m);
eprintln!("200 ms in-process burn: {:.4} J over {:?} wall (oracle around it: {:.4} J)", joules(uj), m.wall, outer as f64 / 1e9);
assert!((100_000..=20_000_000).contains(&uj), "{uj} µJ for 200 ms of one thread");
assert!(uj <= outer / 1_000 + 1, "the span read more ({uj} µJ) than the oracle around it ({outer} nJ)");
}
/// This process's context switches, every thread's: `getrusage`'s
/// voluntary and involuntary counts.
fn switches() -> libc::c_long {
// SAFETY: an all-zero `rusage` is a valid out-parameter.
let mut ru: libc::rusage = unsafe { std::mem::zeroed() };
// SAFETY: `ru` is writable for the call.
assert_eq!(unsafe { libc::getrusage(libc::RUSAGE_SELF, &mut ru) }, 0);
ru.ru_nvcsw + ru.ru_nivcsw
}
/// The oracle's own read of this process, after taking this thread off its
/// core in a way that shares nothing with the meter's: a sleep of 1 ms or
/// more, then 2, 4 and 8 ms, until this process's context-switch count moved
/// across one. A sleep of a millisecond does not end before the thread has
/// blocked (the timer would have to fire before the block, a millisecond
/// after it was set), and the count moving says a switch happened; the
/// meter instead watches its thread's own energy count after sleeps from
/// 50 µs. A thread never seen to switch is the oracle's failure, said so.
fn folded_oracle_nj() -> u64 {
for ms in [1, 2, 4, 8] {
let before = switches();
std::thread::sleep(Duration::from_millis(ms));
if switches() > before {
return oracle_energy_nj(me()).expect("oracle read");
}
}
panic!("the oracle's thread was not seen to switch across sleeps of 1, 2, 4 and 8 ms");
}
/// A short span is read whole on the thread that runs it. The kernel folds
/// a thread's energy into the count only when the thread leaves a core, and
/// a read of this process does not fold the thread reading (its CPU time is
/// current, its energy is not), so the meter takes that thread off its core
/// before each read and checks that it left. 1 ms of this thread's CPU,
/// metered 20 times, is a `Cpu` hardware reading every time — never the
/// estimator a count that held gives — and inside the oracle's reading
/// around it, which folds on its own and by its own check
/// ([`folded_oracle_nj`]), short of it by no more than a quarter: the
/// meter's reads and wake-ups, a few tens of microjoules.
#[test]
fn a_short_in_process_span_is_read_whole_on_its_own_thread() {
let _one = alone();
let mut meter = PowerMeter::detect_in_process();
for rep in 0..20 {
let before = folded_oracle_nj();
let ((), m) = meter.measure(|| burn_cpu(Duration::from_millis(1)));
let outer = folded_oracle_nj() - before;
eprintln!("1 ms in-process burn rep {rep}: span {:?}, oracle around it {outer} nJ", m.devices);
let uj = the_reading(&m);
// The probe's µJ are its nJ floored at each end: within 1 µJ of the
// nJ advance.
assert!(uj * 1_000 <= outer + 1_000, "the span read more ({uj} µJ) than the oracle around it ({outer} nJ)");
assert!((uj + 1) * 1_000 * 4 >= outer * 3, "the span ({uj} µJ) is short of the oracle around it ({outer} nJ)");
}
}
/// A 200 ms sleep in this process is a reading under 0.1 J: the counter
/// covers this process's threads, and they did next to nothing.
#[test]
fn an_in_process_sleep_reads_under_a_tenth_of_a_joule() {
let _one = alone();
let mut meter = PowerMeter::detect_in_process();
let ((), m) = meter.measure(|| std::thread::sleep(Duration::from_millis(200)));
let uj = the_reading(&m);
eprintln!("200 ms in-process sleep: {uj} µJ");
assert!(uj < 100_000, "{uj} µJ for a 200 ms sleep");
}
/// `detect()` never reads this process's counter, even where it is live:
/// `detect` meters subprocesses, which that counter does not cover.
#[test]
fn detect_does_not_include_the_process_probe() {
let _one = alone();
let status = process_energy_status();
let sources = PowerMeter::detect().sources();
eprintln!("detect(): {sources:?} (process energy {status:?})");
assert!(!sources.contains(&"process-energy"), "{sources:?}");
assert_eq!(status, CounterStatus::Live, "setup: the probe was available to include");
}
/// Work in a child process is not attributed to the in-process probe. A child
/// burns ~0.4 s of CPU while this process waits; the oracle reads the child's
/// own energy from the exited child before it is reaped, and this process's
/// energy around the wait. The in-process span over the wait is a fraction
/// of the child's energy, and under 0.1 J — or, if this process's counter did
/// not move at all while it waited, an estimate, never a reading.
#[test]
fn work_in_a_child_process_is_not_attributed_to_the_in_process_probe() {
let _one = alone();
let mut meter = PowerMeter::detect_in_process();
let mine_before = oracle_energy_nj(me()).unwrap();
let span = meter.start();
let child = std::process::Command::new("awk")
.arg("BEGIN { for (i = 0; i < 12000000; i++) x += i; print x }")
.stdout(std::process::Stdio::null())
.spawn()
.expect("spawn awk");
let pid = child.id() as libc::pid_t;
// Wait for the exit without reaping, so the child is still readable.
// SAFETY: an all-zero siginfo_t is a valid out-parameter; `pid` is ours.
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::waitid(libc::P_PID, pid as libc::id_t, &mut info, libc::WEXITED | libc::WNOWAIT) };
assert_eq!(rc, 0, "waitid: {}", std::io::Error::last_os_error());
let m = span.stop(&mut meter);
let mine = oracle_energy_nj(me()).unwrap() - mine_before;
let childs = oracle_energy_nj(pid).expect("the exited, unreaped child reads");
let mut child = child;
assert!(child.wait().expect("reap").success());
eprintln!(
"child work: child {:.4} J, this process {:.6} J around the wait, span {:?}",
childs as f64 / 1e9,
mine as f64 / 1e9,
m.devices
);
assert!(childs >= 100_000_000, "setup: the child did real work ({childs} nJ)");
assert!(mine < 100_000_000 && mine * 10 < childs, "this process's counter took the child's work: {mine} nJ");
if m.reads_cpu() {
let uj = the_reading(&m);
assert!(uj < 100_000 && uj * 1_000 * 10 < childs, "the span read the child's work: {uj} µJ");
} else {
assert_eq!(m.devices[0].provenance, Provenance::Estimator, "{m:?}");
}
}