tests/process_cpu.rs (macOS) starts a child that spins `while :; do :; done` and kills it
inside the window it meters. Between its spawn and that kill each energy read asserts; a read
that failed there unwound past a bare std::process::Child, which is not killed when it is
dropped, and left the child spinning a core once the test binary had ended, one more for each
failed run. The child is now held, as it is spawned, in a `Running` that kills it and reaps it
when it is dropped, waiting up to 10 s (as wai-quantum-service's tests hold theirs, 04d19cfd8);
the test still kills and reaps it inside the window, so what it meters is unchanged.
Planted fault, on the old-prod Mac Studio (macOS, M3 Ultra): the test made to panic right
after its first read of the child's counter, with the child running, run alone in a session and
process group of its own (POSIX setsid; macOS has no setsid command, and its ps and pgrep
cannot select by session, so the group was listed), 3 s after cargo ended. At main the group
held the busy child (/bin/sh -c while :; do :; done, 3 s old), which was then killed by its
group; with the guard it held nothing.
Gates, on the old-prod Mac Studio: cargo test --locked -p jouleclaw-energy passed (76 tests in
9 binaries) and with --features ioreport (144 in 9), each leaving nothing in its process group;
cargo clippy --locked --all-targets --no-deps -p jouleclaw-energy -- -D warnings is clean in
the default build and with ioreport. The light checks pass.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
219 lines
10 KiB
Rust
219 lines
10 KiB
Rust
//! 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::{Child, 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]
|
|
}
|
|
|
|
/// How long [`Running`] waits for a process it has killed to be reaped. `SIGKILL` ends a
|
|
/// process at once unless it is stuck in the kernel; a drop that waited on such a one without
|
|
/// a bound would hang the test binary instead of failing it.
|
|
const REAPED_WITHIN: Duration = Duration::from_secs(10);
|
|
|
|
/// A process the test started, killed and reaped when it is dropped: at the end of the test, or
|
|
/// while a failed assertion unwinds through it. A [`Child`] is not killed when it is dropped, so
|
|
/// an assertion that failed while the busy child ran left it spinning a core after the test
|
|
/// binary had ended, one more for each failed run. It reads as the child it holds.
|
|
struct Running(Child);
|
|
|
|
impl Running {
|
|
/// Kills the process and reaps it, waiting up to [`REAPED_WITHIN`]: whether it was reaped.
|
|
/// A process already reaped is not signalled again (`Child::kill` does nothing then, as its
|
|
/// pid may be another process's by now).
|
|
fn stop(&mut self) -> bool {
|
|
let _ = self.0.kill();
|
|
let t = Instant::now();
|
|
loop {
|
|
match self.0.try_wait() {
|
|
Ok(Some(_)) => return true,
|
|
Ok(None) if t.elapsed() < REAPED_WITHIN => std::thread::sleep(Duration::from_millis(10)),
|
|
_ => return false,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::ops::Deref for Running {
|
|
type Target = Child;
|
|
|
|
fn deref(&self) -> &Child {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::ops::DerefMut for Running {
|
|
fn deref_mut(&mut self) -> &mut Child {
|
|
&mut self.0
|
|
}
|
|
}
|
|
|
|
impl Drop for Running {
|
|
fn drop(&mut self) {
|
|
if !self.stop() {
|
|
eprintln!("process {} was killed and not reaped within {} s", self.0.id(), REAPED_WITHIN.as_secs());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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 is a reading, and the reading is this
|
|
// process's own energy over the burn: no more than an oracle's window
|
|
// around it, and most of it. No joule figure is asserted — what 200 ms
|
|
// of CPU costs depends on the core class that ran it (0.099 J on an
|
|
// efficiency core at a load average of 114; 0.18-1.24 J otherwise on
|
|
// an M5 Max), so a fixed floor fails on the host's load, not on the
|
|
// meter. The oracle sleeps before each read: a thread that leaves its
|
|
// core has its energy folded into the process's count, so the oracle's
|
|
// window holds the whole burn. The counter's own reads may each miss
|
|
// up to a scheduling quantum of the reading thread, hence "most of".
|
|
// SAFETY: `getpid` has no preconditions.
|
|
let me = unsafe { libc::getpid() };
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
let oracle_before = energy_nj_of(me);
|
|
let t = Instant::now();
|
|
let ((), burned) = counter.measure(|| burn(Duration::from_millis(200)));
|
|
let wall = t.elapsed();
|
|
std::thread::sleep(Duration::from_millis(2));
|
|
let oracle_nj = energy_nj_of(me) - oracle_before;
|
|
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); oracle window {:.3} J",
|
|
uj as f64 / 1e6,
|
|
wall.as_secs_f64() * 1e3,
|
|
uj as f64 / 1e6 / 0.2,
|
|
oracle_nj as f64 / 1e9
|
|
);
|
|
assert!(uj > 0, "a burn read as zero");
|
|
assert!(uj * 1_000 <= oracle_nj + 1_000, "the burn ({uj} µJ) exceeds the oracle's window around it ({oracle_nj} nJ)");
|
|
assert!(uj * 1_000 * 4 >= oracle_nj * 3, "the burn ({uj} µJ) is under three quarters of the oracle's window ({oracle_nj} nJ)");
|
|
|
|
// 3. A 200 ms sleep reads under a tenth of the burn — or, if nothing of
|
|
// this process ran, is not a reading at all; never a zero. A counter
|
|
// that reads wall time at a fixed power reads the sleep at 200 ms over
|
|
// the burn's wall time of the burn's figure — a sixth of it at the
|
|
// heaviest load seen here (1.26 s of wall for the burn) — and fails.
|
|
let ((), slept) = counter.measure(|| std::thread::sleep(Duration::from_millis(200)));
|
|
println!("process-cpu: 200 ms sleep = {slept:?} µJ");
|
|
match slept {
|
|
Ok(slept_uj) => assert!(slept_uj * 10 < uj, "{slept_uj} µJ for a sleep, against {uj} µJ for the burn"),
|
|
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. The child
|
|
// runs until its own counter has read 0.2 J (at least 300 ms, at most
|
|
// 5 s), not for a fixed 300 ms: on a heavily loaded host a fixed 300 ms
|
|
// got the child too little CPU (0.076 J at a load average of 170), and
|
|
// the test failed on the host's load, not on the meter. The child is
|
|
// held as it starts, so a read that fails while it runs kills it too.
|
|
let mut child = Running(
|
|
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 started = Instant::now();
|
|
let (child_burned_nj, parent) = counter.measure(|| {
|
|
let mut burned = 0;
|
|
while started.elapsed() < Duration::from_secs(5) {
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
burned = energy_nj_of(pid) - child_before;
|
|
if burned >= 200_000_000 && started.elapsed() >= Duration::from_millis(300) {
|
|
break;
|
|
}
|
|
}
|
|
child.kill().expect("kill the child");
|
|
child.wait().expect("reap the child");
|
|
burned
|
|
});
|
|
println!(
|
|
"process-cpu: child burned {:.3} J in {:?}; this process's window = {parent:?} µJ",
|
|
child_burned_nj as f64 / 1e9,
|
|
started.elapsed()
|
|
);
|
|
assert!(child_burned_nj >= 200_000_000, "the child did not burn: {child_burned_nj} nJ");
|
|
match parent {
|
|
// Under a tenth of what the child burned: this process only polled.
|
|
Ok(uj) => assert!(uj * 1_000 * 10 < child_burned_nj, "{uj} µJ in this process while only the child worked ({child_burned_nj} nJ)"),
|
|
Err(EnergyError::NotAdvanced) => {}
|
|
Err(e) => panic!("the parent window failed: {e}"),
|
|
}
|
|
}
|