All checks were successful
wai-app and Create / wai-app · does this change touch it (pull_request) Successful in 5s
joule-moq-web browser client / joule-moq-web · does this change touch it (pull_request) Successful in 6s
joule-moq-room room service / joule-moq-room · does this change touch it (pull_request) Successful in 6s
joule-moq-remote host agent / joule-moq-remote · does this change touch it (pull_request) Successful in 8s
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 8s
WAI examples / WAI examples · does this change touch them (pull_request) Successful in 10s
wai-fleet service / does this change touch it (pull_request) Successful in 8s
WAI Studio / does this change touch it (pull_request) Successful in 7s
joule-code conformance gate / joule-moq-relay, its own energy on a counter (macos) (pull_request) Successful in 40s
wai-app and Create / wai-app · tests, clippy, the line language's verifier (linux) (pull_request) Successful in 32s
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 42s
standards workspaces build + test / eoc-rs (eoc/eoc-rs, -p eoc-meter --features cuda, eoc-rs, linux) (pull_request) Successful in 40s
WAI Studio / the catalogue's actions, the unit tests and the core · linux (pull_request) Successful in 1m6s
joule-moq-remote host agent / joule-moq-remote, every target (macos) (pull_request) Successful in 1m16s
joule-moq-remote host agent / joule-moq-remote, every target (linux-arm64) (pull_request) Successful in 1m52s
wai-fleet service / service · linux (pull_request) Successful in 2m5s
energy meters on real hardware / sink decode table · per-task energy and the GPU rail on macOS (pull_request) Successful in 3m9s
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 3m24s
joule-code conformance gate / joule-moq-relay, every target (pull_request) Successful in 3m31s
joule-code conformance gate / Build + round-trip C1-C4 vectors (pull_request) Successful in 3m37s
energy meters on real hardware / sink decode table · RAPL on jetson-hub (NVML surveyed) (pull_request) Successful in 3m24s
joule-moq-room room service / joule-moq-room, every target (pull_request) Successful in 4m16s
joule-code conformance gate / joule-moq-ingest, every target (pull_request) Successful in 6m1s
wai-app and Create / wai-app · Create, browser end-to-end (macos) (pull_request) Successful in 7m18s
WAI examples / WAI examples · android-app · linux-arm64 (pull_request) Successful in 5m9s
standards workspaces build + test / joule-code-rs (joule-code/joule-code-rs, joule-code-rs, linux-arm64, true) (pull_request) Successful in 6m35s
WAI examples / WAI examples · portable · linux (pull_request) Successful in 8m48s
WAI Studio / Studio in WebKit · linux (pull_request) Successful in 10m16s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 9m57s
wai-fleet service / service · macos (pull_request) Successful in 52s
joule-moq-web browser client / joule-moq-web · browser end-to-end (macos) (pull_request) Successful in 15m23s
WAI examples / WAI examples · the archive, the readiness check, the browser and Apple · macos (pull_request) Successful in 15m15s
WAI Studio / Studio in Chromium and Firefox · macos (pull_request) Successful in 13m33s
Two tests failed intermittently on loaded CI hosts. One of them was a relay bug.
joule-moq-relay (age_rising.rs:138, linux-arm64). A rise of a rating ends the
subscriber's groups (end_groups -> kill). kill() resets the lane's stream only
when it is in the lane, but while a write is in flight the write holds it. That
write then returned it to the dead lane, the lane was removed, and a stream
dropped unreset is finished whole. So a group blocked on flow control when the
rise was held reached a viewer below the threshold whole. That breaks WAI
age-assurance §5.5 ("forwards none of that group's objects"); cancel() has the
same gap against §3.1.2's "MUST reset any open streams". on_done now resets a
returned stream whose lane died meanwhile, with the lane's code, and cancel()
records its code for that.
Reproduced on the Linux build host, 200 runs eight at a time: 14 and 11
failures before (all ten groups whole, the relay sent the slow viewer no
RESET_STREAM), 0 after (it sends four to seven). The crate's whole suite passes.
eoc-meter (process_energy.rs:229, macOS). The child test stopped `yes` once its
counter read 0.5 J and 300 ms of wall time had passed, then required 150 ms of
reaped CPU. On the loaded Studio, 300 ms of wall time gave it 130 ms. The loop
now waits on the child's own CPU (rusage, as it reads the energy) to 300 ms,
for at most 10 s, and the reaped CPU is at least that. 30 of 30 runs pass on the
Studio five at a time; clippy (-D warnings, as CI lints eoc-meter) is clean.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
240 lines
11 KiB
Rust
240 lines
11 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 until it
|
|
/// has used at least 300 ms of CPU and its own counter has read at least 0.5 J (or
|
|
/// 10 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;
|
|
// Wait on the child's own CPU time, not the wall clock: on a loaded host 300 ms of wall
|
|
// time gave it 130 ms of CPU. What is reaped below is at least what is read here.
|
|
while started.elapsed() < Duration::from_secs(10) {
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
let w = rusage(child.id());
|
|
child_nj = w.map(|w| w[ENERGY_NJ]);
|
|
let cpu = w.map(|w| mach(w[USER_TIME] + w[SYSTEM_TIME]));
|
|
if child_nj.is_some_and(|nj| nj >= 500_000_000) && cpu.is_some_and(|c| c >= 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");
|
|
}
|