open-standards/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/nvml.rs
Transaction Science 04086d2e38
All checks were successful
standards workspaces build + test / which workspaces this change touches (pull_request) Successful in 6s
wai-quantum WebGPU member / does this change touch it (pull_request) Successful in 8s
energy meters on real hardware / RAPL and NVML on jetson-hub (pull_request) Successful in 41s
repository checks / lockfiles, tests in CI, workflow hygiene, spec and section references, public text (pull_request) Successful in 52s
jouleclaw public surface must stay documented / Public-surface audit (jouleclaw-rs) (pull_request) Successful in 1m11s
standards workspaces build + test / mesh-rs (mesh/mesh-rs, -p mesh-engine-gguf -p mesh-openai --features mesh-engine-gguf/gguf,mesh-openai/server, mesh-rs, linux-arm64) (pull_request) Successful in 1m31s
wai-quantum WebGPU member / member · software Vulkan (linux) (pull_request) Successful in 2m15s
wai-quantum WebGPU member / shader checks · browser build · wasip2 inertness · MSRV (pull_request) Successful in 3m20s
wai-quantum WebGPU member / member · integrated GPU, Vulkan (framework-128gb) (pull_request) Successful in 7m22s
standards workspaces build + test / jouleclaw-rs (-nodebug, jouleclaw/jouleclaw-rs, -p jouleclaw-energy --features rapl,nvml, jouleclaw-rs, linux-arm64, -C target-feature=+fp16) (pull_request) Successful in 8m24s
wai-quantum WebGPU member / member · DX12 and Vulkan (cbio-windows) (pull_request) Successful in 12m15s
wai-quantum WebGPU member / member · discrete GPU, Vulkan (jetson-hub) (pull_request) Successful in 18m16s
standards workspaces build + test / macOS energy backends (pull_request) Successful in 13m56s
standards workspaces build + test / jouledesk-rs (jouledesk/jouledesk-rs, jouledesk-rs, macos) (pull_request) Successful in 6m34s
wai-quantum WebGPU member / member · Metal (macos) (pull_request) Successful in 5m7s
wai-quantum WebGPU member / member · browser WebGPU (macos) (pull_request) Successful in 3m10s
jouleclaw: bound a GPU energy counter from below, not only above
`GpuPlausibility` judged an NVML cumulative-energy counter against the board's
own telemetry in one direction only. Its three verdicts were `Plausible`,
`CounterOverRate` ("ran faster than the board can draw") and
`BoardTelemetryUnusable`. Nothing caught a counter reading **low**.

That is the wrong direction to guard. By this family's own doctrine (jcp/SPEC
§5): "Under-reporting is the unsafe direction at a ceiling, because it admits
work the budget cannot cover." A counter running fast fails safe — it spends a
budget sooner. A counter reading low is graded `HwShunt` and then spends an
energy ceiling it is not measuring. jouleclaw/SPEC §1.3 already refused a
counter "republished in lumps", but nothing implemented it: such a counter
*advances*, so the sustained-advance liveness probe calls it live while it
accrues a fraction of the energy.

`CounterUnderRate` closes it, bounded by the **same** board telemetry the
ceiling bound already uses, so no threshold is invented:

    floor = (mean_usable_draw - GPU_DRAW_SLACK_MW) / GPU_UNDER_FACTOR

The slack comes off the draw *before* the division, so the floor collapses to
zero at or below 5 W of mean draw: on an idle board a correct counter accrues
almost nothing, and refuting it would be noise rather than a finding. The
factor is deliberately looser than `GPU_DRAW_FACTOR` because the draw readings
are one-second averages, so a bursty load idle between samples really does
integrate to less than its sampled mean. The verdict carries the yardstick it
was judged against, so the judgement can be re-made against a different factor.

It ranks **above** a proven overrun: both refute, but only this one admits work
a budget cannot cover, so where one GPU of a probe over-reports and another
under-reports, the under-report is what an operator must act on. Among two
under-reporting GPUs the ordering is by shortfall, not by rate.

Two things found while doing it, beyond the reported gap:

- **`_ => CounterLiveness::Live` in the verdict mapping** meant a newly added
  verdict would silently become *live* — and "unrecognised verdict is
  admissible" is the unsafe default. That match is now exhaustive, so a future
  arm must be graded deliberately or the crate does not compile.
- **The wiring was untested.** The existing test covered
  `plausible_gpu_counter` and the over-rate path through `GpuWatch::liveness`,
  but a verdict that never reaches `CounterLiveness` changes nothing. Added a
  test driving an under-reporting GPU through the probe, whose discriminating
  half is the *same slow counter beside an idle board* — which must stay live,
  because nothing about the counter changed, only the yardstick.

`mode_for` needed no change: `Implausible` already falls back to the vendor
power integral and grades `ModelBased`, so the fix completes itself once the
verdict exists and maps. SPEC §1.3 now states the bound is two-sided, that it
refutes and never verifies, that an indeterminate is not hardware-graded, and
that it must not fire near idle. Doc comments that described the bound as
one-directional are corrected.

Figures throughout are the jetson-hub RTX 4050's own, as the existing test's
already are.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-19 20:26:38 -04:00

460 lines
19 KiB
Rust

//! NVIDIA discrete GPU energy counter via NVML — `Provenance::HwShunt` while its
//! cumulative-energy counter is live, `Provenance::ModelBased` otherwise.
//!
//! On Volta+ silicon, `nvmlDeviceGetTotalEnergyConsumption()` returns a
//! cumulative-energy counter (mJ since driver reload), summed here across every
//! GPU. That is a vendor energy counter, and its reading is graded a hardware
//! reading — but only while it is demonstrably live for the domain the figure
//! claims (JouleClaw SPEC §1.3): every GPU's counter must keep advancing under
//! the sustained-advance rule ([`crate::CounterLiveness`]) — in at least five
//! sample intervals, four in every five, over at least 300 ms, while the
//! probing thread burns 100 ms of its own CPU time between samples (an RTX
//! 4050 Laptop GPU, driver 595.84, republishes the counter every ~100 ms, so a
//! live counter has moved in nearly every interval; even at a 2 W idle that is
//! ~0.2 J an interval, against a 1 mJ quantum). And every GPU's counter must
//! plausibly count its board's energy over the probe, bounded in **both**
//! directions: no faster than 1.5 times the board's maximum power limit, or
//! than twice the board's own power draw plus 5 W, and no slower than a
//! quarter of that draw once 5 W of slack is taken off it
//! ([`crate::CounterLiveness::Implausible`]). The lower bound is the one that
//! matters at a ceiling — a counter reading low is graded as hardware and then
//! spends a budget it is not measuring — while a counter running fast fails
//! safe. That RTX 4050's counter runs at 4 to 30 times its board's draw while
//! it is polled, so it is declined there. Probed once per process and cached.
//!
//! Where that counter is unsupported (pre-Volta, some consumer parts) or not
//! live, the counter integrates `power_usage()` over wall time instead. That
//! integral is a Riemann sum of the driver's sampled power, not a counter: it
//! advances whenever time passes, so no liveness probe can vouch for it, and
//! its readings are graded [`Provenance::ModelBased`] — the grade JouleClaw
//! SPEC §1.3 gives NVML `power.draw`. Never `HwShunt`.
//!
//! The domain is [`EnergyDomain::Gpu`]: every GPU NVML enumerates, nothing
//! else. The CPU work that drives them is not in it.
//!
//! Resolution: ~1 mJ (the counter's quantum). Minimum useful sampling window:
//! ~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.
#![forbid(unsafe_code)]
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
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.
///
/// 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)]
enum Mode {
/// NVML did not initialise, or found no GPU: no reading.
Unavailable,
/// The cumulative-energy counter, live: a hardware reading (`HwShunt`).
Counter,
/// The integral of `power_usage()`: a vendor energy model (`ModelBased`).
PowerIntegral,
}
/// The reading mode for a host whose cumulative-energy counter is `supported`
/// (or not) and whose liveness probe said `liveness`: the counter only when it
/// is supported AND live, the power integral otherwise.
fn mode_for(supported: bool, liveness: CounterLiveness) -> Mode {
match (supported, liveness) {
(true, CounterLiveness::Live) => Mode::Counter,
// An unjudgeable counter is no more admissible than an implausible
// one: neither can be graded `HwShunt`, so both fall back to the
// vendor's power model.
(
_,
CounterLiveness::Live
| CounterLiveness::Frozen
| CounterLiveness::Unavailable
| CounterLiveness::Implausible
| CounterLiveness::Unjudgeable,
) => Mode::PowerIntegral,
}
}
/// The grade of every reading a counter in `mode` produces.
fn grade(mode: Mode) -> Provenance {
match mode {
Mode::Counter => Provenance::HwShunt,
Mode::PowerIntegral => Provenance::ModelBased,
// No reading is produced; this is the grade a reading would have had
// from the counter, as the IOReport backend reports for a declined one.
Mode::Unavailable => Provenance::HwShunt,
}
}
/// NVIDIA discrete GPU energy counter.
///
/// Initialises NVML at construction time. If NVML init fails or no
/// GPUs are present the counter is permanently unavailable.
pub struct NvmlCounter {
nvml: Option<nvml_wrapper::Nvml>,
device_count: u32,
mode: Mode,
liveness: CounterLiveness,
cumulative_uj: AtomicU64,
last_timestamp_ms: AtomicU64,
}
impl Default for NvmlCounter {
fn default() -> Self {
Self::new()
}
}
impl NvmlCounter {
/// Initialise NVML and enumerate GPUs. Where every GPU supports the
/// cumulative-energy counter, the first call in a process runs its liveness
/// probe on the calling thread (about 0.5 s where it is live, about 1 s where
/// it is not) and caches the answer ([`Self::liveness`]).
pub fn new() -> Self {
let nvml = match nvml_wrapper::Nvml::init() {
Ok(nvml) => nvml,
Err(e) => {
tracing::debug!("NVML init failed: {e}");
return Self::unavailable(None);
}
};
let device_count = nvml.device_count().unwrap_or(0);
if device_count == 0 {
tracing::debug!("NVML initialized but no GPUs found");
return Self::unavailable(Some(nvml));
}
// The cumulative-energy counter must be readable on every GPU the figure
// sums; `NotSupported` on any of them leaves the power integral.
let supported = total_energy_uj(&nvml, device_count).is_some();
let liveness = if supported {
static LIVENESS: OnceLock<CounterLiveness> = OnceLock::new();
*LIVENESS.get_or_init(|| probe_liveness(&nvml, device_count))
} else {
CounterLiveness::Unavailable
};
let mode = mode_for(supported, liveness);
tracing::info!(gpu_count = device_count, supported, ?liveness, ?mode, "NVML initialized");
Self::with(Some(nvml), device_count, mode, liveness)
}
fn with(nvml: Option<nvml_wrapper::Nvml>, device_count: u32, mode: Mode, liveness: CounterLiveness) -> Self {
Self {
nvml,
device_count,
mode,
liveness,
cumulative_uj: AtomicU64::new(0),
last_timestamp_ms: AtomicU64::new(0),
}
}
/// An unavailable counter (NVML init failed or no GPUs), keeping any NVML
/// handle so diagnostics can still query the library.
fn unavailable(nvml: Option<nvml_wrapper::Nvml>) -> Self {
Self::with(nvml, 0, Mode::Unavailable, CounterLiveness::Unavailable)
}
/// Whether NVML initialised and at least one GPU was found.
pub fn is_available(&self) -> bool {
self.mode != Mode::Unavailable
}
/// `true` when `read()` uses the hardware's cumulative-energy counter
/// (`nvmlDeviceGetTotalEnergyConsumption`) — supported on every GPU and
/// live — and grades its readings `HwShunt`; `false` when it integrates
/// `power_usage()` and grades them `ModelBased`, or reads nothing.
pub fn uses_true_counter(&self) -> bool {
self.mode == Mode::Counter
}
/// The cumulative-energy counter's liveness on this host:
/// [`CounterLiveness::Live`] exactly when [`Self::uses_true_counter`];
/// `Unavailable` where it is unsupported or NVML has no GPU.
pub fn liveness(&self) -> CounterLiveness {
self.liveness
}
}
/// Every GPU's cumulative energy, in µJ, in device order; `None` if any of them
/// cannot be read.
/// The real liveness probe of every GPU's cumulative-energy counter: the
/// sustained-advance rule at [`PROBE_TICK`], with every GPU's power draw read
/// at each sample and its maximum power limit once, so that a live counter
/// which ran faster than its board can draw, or far slower than it, is
/// [`CounterLiveness::Implausible`] ([`crate::liveness::GpuWatch`]).
fn probe_liveness(nvml: &nvml_wrapper::Nvml, device_count: u32) -> CounterLiveness {
let ceilings: Vec<Option<u32>> = (0..device_count)
.map(|i| {
let limits = nvml.device_by_index(i).ok()?.power_management_limit_constraints().ok()?;
(limits.max_limit > 0).then_some(limits.max_limit)
})
.collect();
let draws = move || -> Vec<Option<u32>> {
(0..device_count).map(|i| nvml.device_by_index(i).ok()?.power_usage().ok()).collect()
};
let watch = std::cell::RefCell::new(crate::liveness::GpuWatch::default());
let seen = &watch;
let verdict = crate::liveness::probe(
move || {
total_energy_uj(nvml, device_count).map(|first| {
(first.len(), move || {
let sample = total_energy_uj(nvml, device_count);
seen.borrow_mut().saw(sample.as_deref(), &draws(), std::time::Instant::now());
sample
})
})
},
PROBE_TICK,
);
watch.into_inner().liveness(verdict, &ceilings)
}
fn total_energy_uj(nvml: &nvml_wrapper::Nvml, device_count: u32) -> Option<Vec<u64>> {
(0..device_count)
.map(|i| {
let mj = nvml.device_by_index(i).ok()?.total_energy_consumption().ok()?;
Some(mj.saturating_mul(1_000))
})
.collect()
}
impl EnergyCounter for NvmlCounter {
fn domain(&self) -> EnergyDomain {
EnergyDomain::Gpu
}
fn provenance(&self) -> Provenance {
grade(self.mode)
}
fn resolution_uj(&self) -> u64 {
// ~1 mJ vendor-reported. Conservative.
1_000
}
fn min_window_ns(&self) -> u64 {
// 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> {
let nvml = self
.nvml
.as_ref()
.ok_or(EnergyError::NoCounter(EnergyDomain::Gpu))?;
if self.mode == Mode::Unavailable {
return Err(EnergyError::NoCounter(EnergyDomain::Gpu));
}
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|e| EnergyError::Platform(e.to_string()))?
.as_millis() as u64;
// The hardware's own cumulative-energy counter, summed across GPUs, read
// only where it was observed live. No integration, so no timing error.
if self.mode == Mode::Counter {
let mut total_uj: u64 = 0;
for i in 0..self.device_count {
let device = nvml
.device_by_index(i)
.map_err(|e| EnergyError::Platform(format!("GPU {i}: {e}")))?;
let mj = device
.total_energy_consumption()
.map_err(|e| EnergyError::Platform(format!("GPU {i} energy: {e}")))?;
total_uj = total_uj.saturating_add(mj.saturating_mul(1_000));
}
return Ok(EnergyReading {
uj: total_uj,
timestamp_ns: now_ms.saturating_mul(1_000_000),
domain: EnergyDomain::Gpu,
provenance: grade(self.mode),
});
}
// The power integral: the shunt-sampled instantaneous power, integrated
// 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
.device_by_index(i)
.map_err(|e| EnergyError::Platform(format!("GPU {i}: {e}")))?;
let power_mw = device
.power_usage()
.map_err(|e| EnergyError::Platform(format!("GPU {i} power: {e}")))?;
total_milliwatts = total_milliwatts.saturating_add(power_mw as u64);
}
let watts = total_milliwatts as f64 / 1000.0;
let last_ms = self.last_timestamp_ms.swap(now_ms, Ordering::Relaxed);
if last_ms > 0 && now_ms > last_ms {
let elapsed_secs = (now_ms - last_ms) as f64 / 1000.0;
let delta_uj = (watts * elapsed_secs * 1_000_000.0) as u64;
self.cumulative_uj.fetch_add(delta_uj, Ordering::Relaxed);
}
let total_uj = self.cumulative_uj.load(Ordering::Relaxed);
Ok(EnergyReading {
uj: total_uj,
timestamp_ns: now_ms.saturating_mul(1_000_000),
domain: EnergyDomain::Gpu,
provenance: grade(self.mode),
})
}
}
impl EnergyMeter for NvmlCounter {
fn name(&self) -> &'static str {
"nvml"
}
fn is_available(&self) -> bool {
self.mode != Mode::Unavailable
}
fn energy_source(&self) -> EnergySource {
EnergySource::WallPower
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::liveness::tests::EVERY;
use crate::liveness::{probe_with, LIVENESS_BUDGET};
use std::cell::Cell;
#[test]
fn nvml_counter_unavailable_without_gpu() {
// CI / macOS / non-NVIDIA hosts will all hit this branch.
let c = NvmlCounter::new();
assert_eq!(c.name(), "nvml");
assert_eq!(c.domain(), EnergyDomain::Gpu);
if !c.is_available() {
assert!(c.read().is_err());
assert!(!c.uses_true_counter());
assert_eq!(c.liveness(), CounterLiveness::Unavailable);
}
eprintln!("nvml on this host: available={} true_counter={} liveness={:?} grade={:?}",
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);
// ~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
/// it is supported AND live. A frozen or unprobed counter leaves the power
/// integral, which is never graded `HwShunt`.
#[test]
fn only_a_live_supported_counter_is_a_hardware_reading() {
use CounterLiveness::*;
for (supported, liveness, mode) in [
(true, Live, Mode::Counter),
(true, Frozen, Mode::PowerIntegral),
(true, Unavailable, Mode::PowerIntegral),
(false, Unavailable, Mode::PowerIntegral),
(false, Live, Mode::PowerIntegral),
(true, Implausible, Mode::PowerIntegral),
] {
assert_eq!(mode_for(supported, liveness), mode, "supported={supported} {liveness:?}");
let c = NvmlCounter::with(None, 1, mode, liveness);
assert_eq!(c.uses_true_counter(), mode == Mode::Counter);
let want = if mode == Mode::Counter { Provenance::HwShunt } else { Provenance::ModelBased };
assert_eq!(c.provenance(), want, "supported={supported} {liveness:?}");
assert!(c.is_available());
}
assert_eq!(grade(Mode::PowerIntegral), Provenance::ModelBased, "a power.draw integral is a vendor model");
}
/// The probe over scripted per-GPU counters (mJ → µJ, as `total_energy_uj`
/// returns them) and a clock advancing `EVERY` per tick.
fn nvml_probe(gpus: usize, mj: impl Fn(usize, usize) -> Option<u64>) -> CounterLiveness {
let t = Cell::new(Duration::ZERO);
let n = Cell::new(0usize);
let sample = || {
let i = n.get();
n.set(i + 1);
(0..gpus).map(|g| mj(i, g).map(|v| v * 1_000)).collect::<Option<Vec<u64>>>()
};
probe_with(
|| sample().map(|first| (first.len(), sample)),
|| t.set(t.get() + EVERY),
|| t.get(),
LIVENESS_BUDGET,
)
}
#[test]
fn every_gpu_must_keep_advancing() {
// ~60 W idle per GPU: ~2 J per interval.
assert_eq!(nvml_probe(2, |i, g| Some(5_000_000 * (g as u64 + 1) + 2_000 * i as u64)), CounterLiveness::Live);
assert_eq!(
nvml_probe(2, |i, g| Some(if g == 0 { 5_000_000 + 2_000 * i as u64 } else { 9_000_000 })),
CounterLiveness::Frozen,
"one GPU's counter held: the sum would leave its share of the domain unread"
);
assert_eq!(nvml_probe(1, |_, _| Some(5_000_000)), CounterLiveness::Frozen);
assert_eq!(nvml_probe(1, |_, _| None), CounterLiveness::Unavailable);
assert_eq!(nvml_probe(0, |_, _| Some(1)), CounterLiveness::Frozen, "no GPU is never live");
}
}