From f090e6df0f82e37d205178307f6ac4b08a1e2991 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Sun, 18 Oct 2026 07:27:24 -0400 Subject: [PATCH] jouleclaw: the NVML sampling floor is ~100 ms, measured, not ~50 ms MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SPEC §1.3 published a normative floor for the NVML cumulative-energy counter — "~1 mJ, ~50 ms. `HwShunt`." — feeding the MUST three lines above it: "Implementations MUST surface `resolution_uj()` and `min_window_ns()` so callers can refuse to claim microjoule accuracy on platforms that cannot deliver it." `min_window_ns()` returned 50_000_000, and the figure was restated in three more places. Dauner et al., *Evaluating the Influence of Measurement Frequency on Energy Readings Using Intel RAPL and NVIDIA NVML*, ACM SIGENERGY Energy Informatics Review 6(2), July 2026, measured it across 588 controlled runs: the cumulative counter **does not update faster than roughly 100 ms on any device they tested**. The stale-read fraction is 93.4 % at 0.5 ms, 86.3 % at 10 ms, 1.0 % at 100 ms, and zero from 500 ms. So 50 ms does not merely understate the floor — it sits *inside* the stale-read region, where a read mostly returns the preceding value. We graded that `HwShunt`, our strongest provenance. A counter read below its own update rate is not a reading of the span; that is the rule this family exists to enforce, and our own SPEC broke it. Two numbers, kept apart on purpose: - `min_window_ns()` is documented as "the smallest sampling window below which readings are noise-dominated" — the counter's UPDATE period. That is what the paper measures at ~100 ms, and what this raises it to. It is load-bearing, not advisory: `jouledesk-github`'s `window_energy` refuses a figure for work shorter than it. - Accuracy against integrated power above that floor is a DIFFERENT property and device-class dependent — an A100 agreed to ±0.2 % at every interval, an unvalidated RTX 4090 was still 13.3 % low at 100 ms and within 1.6 % only at 1 s. Folding that into the same scalar would repeat the category error being fixed, so it is stated in SPEC §1.3 prose, with the paper's own recommendation: on an unvalidated counter prefer the power integral — which `Mode::PowerIntegral` already is, graded `ModelBased`. `PROBE_TICK` also moves, 100 ms → 150 ms, and this is the sharper bug. Its rationale read "two of its ~50 ms sample periods"; against a real ~100 ms period the tick EQUALLED the update period, so roughly half the intervals catch no update, the probe misses `LIVE_SHARE` (four in five) and grades a perfectly healthy counter not-live — demoting a real `HwShunt` reading to the power integral. 150 ms matches `sandbox-meter`'s `NVML_PROBE_TICK`, whose comment already carried the right derivation, and `MIN_LIVE_ADVANCES` (5) intervals then take 750 ms, inside the 1000 ms `LIVENESS_BUDGET`. `the_probe_tick_clears_the_update_period_and_fits_the_budget` pins both halves; nothing pinned either before. Not changed: the RAPL floor. The same paper finds RAPL stable at 10 ms or coarser, which is what SPEC §1.3 already says. Verified with the feature on — `cargo test -p jouleclaw-energy --features rapl,nvml`, 84 passed — because the `nvml` module is not in a default build. `tests/nvml_hardware.rs` ran 0 tests here: it needs a real GPU and only exercises on the `linux-hw` runner. Co-Authored-By: Claude Opus 5 --- jouleclaw/SPEC.md | 17 ++++- .../crates/jouleclaw-energy/src/lib.rs | 2 +- .../crates/jouleclaw-energy/src/nvml.rs | 63 ++++++++++++++++--- 3 files changed, 72 insertions(+), 10 deletions(-) diff --git a/jouleclaw/SPEC.md b/jouleclaw/SPEC.md index dde682244..ddf4b270a 100644 --- a/jouleclaw/SPEC.md +++ b/jouleclaw/SPEC.md @@ -112,7 +112,22 @@ Realistic floors: - Intel / AMD x86 RAPL: ~1 μJ, ~10 ms window. `HwShunt`. - Jetson INA3221: ~10 mW (integrate for energy). `HwShunt`. -- NVIDIA discrete (NVML cumulative-energy): ~1 mJ, ~50 ms. `HwShunt`. +- NVIDIA discrete (NVML cumulative-energy): ~1 mJ, ~100 ms. + `HwShunt` — with a caveat measured across 588 runs by Dauner et al., + *Evaluating the Influence of Measurement Frequency on Energy Readings + Using Intel RAPL and NVIDIA NVML*, ACM SIGENERGY Energy Informatics + Review 6(2), July 2026. The cumulative counter **does not update + faster than roughly 100 ms on any device they tested**: below that it + mostly returns the preceding value (a stale-read fraction of 93.4 % at + 0.5 ms, 86.3 % at 10 ms, 1.0 % at 100 ms, zero from 500 ms). Accuracy + against integrated power is then **device-class dependent**: a + data-center part (A100) agreed to ±0.2 % at every interval, while an + unvalidated consumer part (RTX 4090) was still 13.3 % low at 100 ms + and came within 1.6 % only at 1 s. So on a GPU whose counter has not + been validated against integrated power, prefer the power integral — + which this standard's reference implementation already falls back to, + graded `ModelBased`. A counter read below its own update rate is not a + reading of the span, whatever the interface. - Apple Silicon IOReport: ~1 mJ, ~10 ms. `HwShunt` while its CPU energy rail is live. On macOS 27.0.1 an unprivileged reader's CPU rails are frozen (observed on an M5 Max and an M3 Ultra), so the diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs index e8e440062..21226932b 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/lib.rs @@ -414,7 +414,7 @@ pub enum EnergyError { /// For Apple IOReport this is ~1 mJ. For static estimators it is the /// model's bin size. /// - [`Self::min_window_ns`] — the smallest sampling window below which -/// readings are noise-dominated. RAPL: ~10 ms. NVML: ~50 ms. IOReport: +/// readings are noise-dominated. RAPL: ~10 ms. NVML: ~100 ms. IOReport: /// ~10 ms but estimator noise dominates anyway. /// - [`Self::provenance`] — see [`Provenance`]. /// diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/nvml.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/nvml.rs index 7b5c13b57..e4cd7c82f 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/nvml.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/nvml.rs @@ -29,7 +29,11 @@ //! else. The CPU work that drives them is not in it. //! //! Resolution: ~1 mJ (the counter's quantum). Minimum useful sampling window: -//! ~50 ms. Faster polls return the same sampled value. +//! ~100 ms — the counter does not update faster than that on any device +//! tested, so faster polls return the same sampled value (measured: a stale +//! read fraction of 93.4 % at 0.5 ms and 86.3 % at 10 ms, falling to 1.0 % at +//! 100 ms). Accuracy against integrated power above that floor is +//! device-class dependent; see `jouleclaw/SPEC.md` §1.3. //! //! Without the `nvml` cargo feature this module is not compiled. @@ -43,8 +47,16 @@ use crate::meter::{EnergyMeter, EnergySource}; use crate::{CounterLiveness, EnergyCounter, EnergyDomain, EnergyError, EnergyReading, Provenance}; /// Thread CPU time the liveness probe burns between samples of the -/// cumulative-energy counter: two of its ~50 ms sample periods. -const PROBE_TICK: Duration = Duration::from_millis(100); +/// cumulative-energy counter. +/// +/// Longer than the counter's ~100 ms update period, not equal to it. At a tick +/// equal to the update period roughly half the intervals catch no update, the +/// probe fails [`crate::liveness::LIVE_SHARE`] (four in five) and a perfectly +/// healthy counter is graded not-live. 150 ms matches +/// `sandbox-meter`'s `NVML_PROBE_TICK`, and +/// [`crate::liveness::MIN_LIVE_ADVANCES`] (5) intervals then take 750 ms — +/// inside the 1000 ms [`crate::liveness::LIVENESS_BUDGET`]. +const PROBE_TICK: Duration = Duration::from_millis(150); /// How `read()` obtains a figure, decided once at construction. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -235,8 +247,12 @@ impl EnergyCounter for NvmlCounter { } fn min_window_ns(&self) -> u64 { - // Sample period ~50 ms. - 50_000_000 + // The counter's update period: ~100 ms on every device tested. Below + // it a read repeats the preceding value, so a shorter window is not a + // measurement of that window. This is the noise floor this method + // names; it is NOT a promise of accuracy against integrated power, + // which is device-class dependent above it (SPEC §1.3). + 100_000_000 } fn read(&self) -> Result { @@ -276,7 +292,7 @@ impl EnergyCounter for NvmlCounter { } // The power integral: the shunt-sampled instantaneous power, integrated - // over wall time — a Riemann sum, hence ~50 ms windows, graded ModelBased. + // over wall time — a Riemann sum, hence ~100 ms windows, graded ModelBased. let mut total_milliwatts: u64 = 0; for i in 0..self.device_count { let device = nvml @@ -342,13 +358,44 @@ mod tests { c.is_available(), c.uses_true_counter(), c.liveness(), c.provenance()); } + /// The liveness probe's tick must CLEAR the counter's update period, not + /// equal it. + /// + /// At a tick equal to the period, roughly half the intervals catch no + /// update, the probe misses [`crate::liveness::LIVE_SHARE`] (four in five) + /// and grades a perfectly healthy counter not-live — which demotes a real + /// `HwShunt` reading to the power integral. The tick was 100 ms against a + /// period documented as ~50 ms; the period is measured at ~100 ms, so the + /// margin was zero and the stated rationale ("two of its ~50 ms sample + /// periods") was false. + /// + /// The other half of the constraint: [`crate::liveness::MIN_LIVE_ADVANCES`] + /// intervals have to fit inside one [`crate::liveness::LIVENESS_BUDGET`], + /// or the probe runs past its budget on every healthy host. + #[test] + fn the_probe_tick_clears_the_update_period_and_fits_the_budget() { + let c = NvmlCounter::new(); + let tick = u64::try_from(PROBE_TICK.as_nanos()).expect("tick fits u64"); + assert!( + tick > c.min_window_ns(), + "probe tick {tick} ns must exceed the counter's {} ns update period", + c.min_window_ns() + ); + let needed = PROBE_TICK * crate::liveness::MIN_LIVE_ADVANCES; + assert!( + needed <= crate::liveness::LIVENESS_BUDGET, + "{needed:?} for MIN_LIVE_ADVANCES intervals must fit in {:?}", + crate::liveness::LIVENESS_BUDGET + ); + } + #[test] fn nvml_resolution_is_millijoule_floor() { let c = NvmlCounter::new(); // NVML is honest only down to ~1 mJ. assert!(c.resolution_uj() >= 1_000); - // 50 ms minimum window. - assert!(c.min_window_ns() >= 50_000_000); + // ~100 ms minimum window: the counter's measured update period. + assert!(c.min_window_ns() >= 100_000_000); } /// The cumulative-energy counter is read, as a hardware reading, only where -- 2.54.0 (Apple Git-157)