energy tests: a 0.1 J burn floor, tick-driven fake RAPL trees, stale trees cleared #136

Merged
dcharlot merged 2 commits from energy/burn-floor-0-1-j into main 2026-10-04 14:04:32 -04:00
4 changed files with 47 additions and 41 deletions

View file

@ -5774,6 +5774,9 @@ mod tests {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let root = std::env::temp_dir().join(format!("jcp-energy-powercap-{}-{n}", std::process::id()));
// A test process that was killed never dropped its trees, and a
// later one with the same pid would build on top of them.
let _ = std::fs::remove_dir_all(&root);
let fake = FakePowercap { root };
std::fs::create_dir_all(&fake.root).expect("create a fake powercap tree");
for (dir, name, value, range) in zones {

View file

@ -71,13 +71,17 @@ fn the_process_counter_meters_this_process_and_nothing_else() {
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 reads 0.2 to 20 J.
// 2. 200 ms of one thread's CPU time reads 0.1 to 20 J. The floor is
// 0.1 J, not 0.2, because what 200 ms of CPU costs depends on the core
// class that ran it: 0.18-1.24 J on an M5 Max, and 0.171 J on a busy
// host (the oracle agreeing). The sleep below must read under 0.1 J,
// so a counter that reads wall time at a fixed power still fails one.
let t = Instant::now();
let ((), burned) = counter.measure(|| burn(Duration::from_millis(200)));
let wall = t.elapsed();
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)", uj as f64 / 1e6, wall.as_secs_f64() * 1e3, uj as f64 / 1e6 / 0.2);
assert!((200_000..=20_000_000).contains(&uj), "{uj} µJ for 200 ms of one thread's CPU");
assert!((100_000..=20_000_000).contains(&uj), "{uj} µJ for 200 ms of one thread's CPU");
// 3. A 200 ms sleep reads under 0.1 J — or, if nothing of this process ran,
// is not a reading at all; never a zero.

View file

@ -423,6 +423,9 @@ mod tests {
static N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let n = N.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let root = std::env::temp_dir().join(format!("sandbox-meter-powercap-{}-{n}", std::process::id()));
// A test process that was killed never dropped its trees, and a
// later one with the same pid would build on top of them.
let _ = fs::remove_dir_all(&root);
let fake = FakePowercap { root };
fs::create_dir_all(&fake.root).expect("create a fake powercap tree");
for (dir, name, value, range) in zones {
@ -544,27 +547,24 @@ mod tests {
/// holds; and a tree with no package zone is never ticked.
#[test]
fn the_liveness_run_watches_the_package_zones_only() {
/// The run over `tree` while another thread advances the zones in
/// `advancing` by 4 mJ every 2 ms.
/// The run over `tree` with each tick advancing the zones in
/// `advancing` by 4 mJ, then sleeping 25 ms: every interval sees one
/// advance of those zones and none of the rest, however loaded the
/// host is. (A writer thread on a 2 ms timer could be starved for a
/// whole interval on a busy host, and the run then read a live tree
/// as Frozen.)
fn run_advancing(tree: &FakePowercap, advancing: &[&str]) -> CounterStatus {
let stop = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|s| {
s.spawn(|| {
let mut uj: u64 = 100_000;
while !stop.load(std::sync::atomic::Ordering::SeqCst) {
uj += 4_000;
for dir in advancing {
tree.write(dir, &uj.to_string());
}
std::thread::sleep(Duration::from_millis(2));
}
});
let watch = run_over(&tree.root, || std::thread::sleep(Duration::from_millis(25)));
let status = counter_status_of(watch.verdict());
eprintln!("rapl run: zones {advancing:?} advancing, the rest holding -> {status:?}");
stop.store(true, std::sync::atomic::Ordering::SeqCst);
status
})
let mut uj: u64 = 100_000;
let watch = run_over(&tree.root, || {
uj += 4_000;
for dir in advancing {
tree.write(dir, &uj.to_string());
}
std::thread::sleep(Duration::from_millis(25));
});
let status = counter_status_of(watch.verdict());
eprintln!("rapl run: zones {advancing:?} advancing, the rest holding -> {status:?}");
status
}
let tree = two_sockets_with_psys_and_dram();
assert_eq!(
@ -675,24 +675,17 @@ mod tests {
/// broken count stays broken.
#[test]
fn the_public_package_counter_opens_only_over_live_packages() {
/// `RaplPackages::live_in(tree)` while another thread advances the
/// zones in `advancing` by 4 mJ every 2 ms.
/// `RaplPackages::live_in(tree)` with each tick advancing the zones
/// in `advancing` by 4 mJ, then sleeping 25 ms, as in
/// `the_liveness_run_watches_the_package_zones_only`.
fn live_in_advancing(tree: &FakePowercap, advancing: &[&str]) -> Result<RaplPackages, ProbeError> {
let stop = std::sync::atomic::AtomicBool::new(false);
std::thread::scope(|s| {
s.spawn(|| {
let mut uj: u64 = 100_000;
while !stop.load(std::sync::atomic::Ordering::SeqCst) {
uj += 4_000;
for dir in advancing {
tree.write(dir, &uj.to_string());
}
std::thread::sleep(Duration::from_millis(2));
}
});
let opened = RaplPackages::live_in(&tree.root, || std::thread::sleep(Duration::from_millis(25)));
stop.store(true, std::sync::atomic::Ordering::SeqCst);
opened
let mut uj: u64 = 100_000;
RaplPackages::live_in(&tree.root, || {
uj += 4_000;
for dir in advancing {
tree.write(dir, &uj.to_string());
}
std::thread::sleep(Duration::from_millis(25));
})
}
let tree = two_sockets_with_psys_and_dram();

View file

@ -109,8 +109,14 @@ fn the_process_energy_probe_is_live() {
}
/// 200 ms of one thread's CPU, burned in this process and metered through
/// `detect_in_process`, is a `Cpu` hardware reading between 0.2 J and 20 J,
/// `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();
@ -121,7 +127,7 @@ fn an_in_process_burn_is_a_hardware_reading_of_its_energy() {
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!((200_000..=20_000_000).contains(&uj), "{uj} µJ for 200 ms of one thread");
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)");
}