From bc48cb73dbb13373ee90cbbcdbdea22b87fa9ffc Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 12:34:11 -0400 Subject: [PATCH 1/2] energy tests: a 200 ms burn's floor is 0.1 J in sandbox and jouleclaw sandbox-meter's process_energy and jouleclaw-energy's process_cpu required 0.2 J for 200 ms of one thread's CPU. On a busy host the burn took 1 s of wall time and read 0.171 J, and the oracle around it read the same 0.171 J. The meter was right and the floor was wrong. What 200 ms of CPU costs depends on the core class that ran it: on an M5 Max it read 0.18-1.24 J. jcp-energy's and eoc-meter's equivalent tests already use 0.1 J. The floor is now 0.1 J in both. That is also the sleeping test's ceiling, so a counter that reads wall time at a fixed power still fails one of the two. Co-Authored-By: Claude Opus 5.5 --- .../crates/jouleclaw-energy/tests/process_cpu.rs | 8 ++++++-- .../crates/sandbox-meter/tests/process_energy.rs | 10 ++++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs index 8f1995842..cfaf9cbb8 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/tests/process_cpu.rs @@ -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. diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs b/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs index 54185f97d..8cda244f9 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/tests/process_energy.rs @@ -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)"); } -- 2.54.0 (Apple Git-157) From cc133929c1ecbe6114eb7df74ecfe7d99ce734c1 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Thu, 1 Oct 2026 18:39:55 -0400 Subject: [PATCH 2/2] energy tests: drive the fake RAPL trees from the probe's tick, and clear stale ones sandbox-meter's the_public_package_counter_opens_only_over_live_packages failed once in a full workspace run on a heavily loaded host, just after a restart. It passed on its own, and in 12 of 12 runs under 24 CPU hogs in both its old and new forms. The cause was not reproduced. Two inputs the tests did not control could produce it, and both are removed here. - Thread scheduling: sandbox's two liveness tests advanced the fake packages from a writer thread on a 2 ms timer, while the probe's tick slept 25 ms. A writer starved for a whole interval reads a live tree as Frozen. The tick now advances the zones itself, then sleeps, so every interval sees exactly one advance. The 300 ms window rule still runs on the real clock. jcp-energy's and jouleclaw-energy's equivalents keep their writer thread, because they test the real probe with its own tick. - Leftover trees: sandbox's and jcp-energy's fake powercap roots are named by pid and a counter, and were built into whatever already sat at that path. A killed test process never drops its trees, so a later process reusing that pid could inherit stale zones. The root is now cleared first, as eoc-meter's and jouleclaw-energy's fakes already do. Co-Authored-By: Claude Opus 5.5 --- jcp/jcp-rs/crates/jcp-energy/src/lib.rs | 3 + .../crates/sandbox-meter/src/probe/rapl.rs | 67 +++++++++---------- 2 files changed, 33 insertions(+), 37 deletions(-) diff --git a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs index e72664012..ed961003d 100644 --- a/jcp/jcp-rs/crates/jcp-energy/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-energy/src/lib.rs @@ -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 { diff --git a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs index e41d77a05..e15a0dfaa 100644 --- a/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs +++ b/sandbox/sandbox-rs/crates/sandbox-meter/src/probe/rapl.rs @@ -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 { - 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(); -- 2.54.0 (Apple Git-157)