//! 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 ``. 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 `` (`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 { 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"); }