jouleclaw: the NVML sampling floor is ~100 ms, measured, not ~50 ms #316
3 changed files with 72 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`].
|
||||
///
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue