All checks were successful
standards workspaces build + test / jouleclaw-rs (pull_request) Successful in 1m41s
standards workspaces build + test / aac-rs (pull_request) Successful in 34s
standards workspaces build + test / joule-code-rs (pull_request) Successful in 34s
standards workspaces build + test / joulecontract-rs (pull_request) Successful in 36s
standards workspaces build + test / map-rs (pull_request) Successful in 32s
standards workspaces build + test / mesh-rs (pull_request) Successful in 25s
standards workspaces build + test / openpay (pull_request) Successful in 1m23s
standards workspaces build + test / eoc-rs (pull_request) Successful in 1m53s
standards workspaces build + test / jcp-rs (pull_request) Successful in 1m16s
standards workspaces build + test / proof-rs (pull_request) Successful in 1m10s
standards workspaces build + test / sandbox-rs (pull_request) Successful in 2m6s
standards workspaces build + test / smart-byte-rs (pull_request) Successful in 2m32s
standards workspaces build + test / arl-rs (pull_request) Successful in 3m52s
standards workspaces build + test / joule-ui-rs (pull_request) Successful in 3m42s
standards workspaces build + test / jouledesk-rs (pull_request) Successful in 3m12s
standards workspaces build + test / joulehook-rs (pull_request) Successful in 3m16s
standards workspaces build + test / sense-rs (pull_request) Successful in 3m17s
standards workspaces build + test / macOS energy backend (jcp-energy) (pull_request) Successful in 2m48s
The process-energy burn test asked for user-interactive QoS so its burn
would run on the fast cores, and it asserted at least 0.2 J. The QoS call
returns 0 but changes nothing: the kernel caps a process with no app role,
such as a test binary, at default QoS. rusage books all of the burn as
default QoS (measured in jcp-energy's matching test, 22e5a6ad).
The burn already counts 200 ms of the thread's own CPU time, not wall time,
but the energy those 200 ms cost still depends on which core class ran
them, and at what clock. On an M5 Max at default QoS it read 0.18-1.24 J,
so a 0.2 J floor sits at the bottom of the real range.
This drops the ineffective QoS call and its declarations, records why in
the test's doc, and lowers the floor to 0.1 J: half the lowest reading,
and the sleeping test's ceiling. A counter that reads wall time at a fixed
power still cannot pass both tests, and a counter reading 0 still fails.
On this M5 Max, 4/4 pass: the burn read 0.504 J, and 500 ms asleep 21 µJ.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
235 lines
10 KiB
Rust
235 lines
10 KiB
Rust
//! 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");
|
|
}
|