jouleclaw: the NVML sampling floor is ~100 ms, measured, not ~50 ms #316

Merged
dcharlot merged 1 commit from fix/jouleclaw-nvml-sampling-floor into main 2026-10-18 13:44:41 -04:00
3 changed files with 72 additions and 10 deletions

View file

@ -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

View file

@ -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`].
///

View file

@ -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<EnergyReading, EnergyError> {
@ -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