mesh-meter: a span shorter than its counter's minimum window is an estimate #630

Merged
dcharlot merged 1 commit from mesh-meter/min-window into main 2026-11-05 04:31:21 -05:00
3 changed files with 193 additions and 21 deletions

View file

@ -297,6 +297,15 @@ impl ProcessSpanMeter {
self.fallback.active_name()
}
/// The fallback meter's minimum window: its active meter's
/// [`crate::EnergyCounter::min_window_ns`]. A fallback figure over a span
/// shorter than this is noise for that meter, so a caller that bills the
/// span by it should treat it as no reading. Zero where it has no active
/// meter.
pub fn fallback_min_window(&self) -> std::time::Duration {
std::time::Duration::from_nanos(self.fallback.active().map_or(0, |m| m.min_window_ns()))
}
/// Take the span's starting readings: the GPU snapshot first, then the host
/// meter, and the CPU reading last (the reverse in [`Self::finish`]). So
/// the CPU time the snapshots spend — a wait for the GPU rail to

View file

@ -335,6 +335,16 @@ IOReport), but a counter counts only while it is live:
the whole request, such as a GPU counter on a host whose CPU has none, MUST NOT
be graded `HwShunt`.
**A span shorter than its counter's minimum window.** A counter declares the
smallest window over which its readings are not noise (JouleClaw's
`min_window_ns`: about 10 ms for RAPL and IOReport, 100 ms for NVML). Across a
shorter span its delta is whatever the counter's update and quantization gave
— often zero, or a whole update period — not the span's energy. A span shorter
than the minimum window of the counter that would grade it MUST NOT be graded
`HwShunt` or `ModelBased` from that counter: it is an estimate of the request,
`Estimator`, and SHOULD be billed by an estimator that resolves the span's own
length, so that a short request is never billed zero.
### §3.6 Typed decisions — the other unit of work
A **decision model** takes state plus typed questions and returns a calibrated

View file

@ -34,6 +34,14 @@
//! span is graded as mesh grades an estimate of the whole request, `Estimator`
//! with no `measured_uj`, and its figure is the counter's reading plus the
//! bridge's own estimate of the rest of the host over the same window.
//!
//! A span shorter than the minimum window of the counter that would grade it
//! (`min_window_ns`: about 10 ms for RAPL and IOReport, 100 ms for NVML) is not a
//! reading of the span either: below that floor a counter's delta is whatever its
//! updates and quantization gave. Such a span is graded `Estimator`, with no
//! `measured_uj`, and billed this crate's estimator over the span's own window
//! (SPEC §3.3), by [`HwMeter`] and by [`ProcessMeter`] where it falls back to the
//! host meter.
#![forbid(unsafe_code)]
@ -110,13 +118,19 @@ impl<'m> Session<'m> {
Open::Span(window) => return window.close(),
Open::Snapshot(start) => start,
};
let end = self.meter.snapshot();
let delta = end.uj.saturating_sub(start.uj);
match end.provenance {
Provenance::HwShunt => Energy::measured(delta),
Provenance::ModelBased => Energy::modeled(delta),
Provenance::Estimator => Energy::estimator(delta),
}
graded(start, self.meter.snapshot())
}
}
/// The energy between two snapshots, at the closing one's provenance: a
/// `HwShunt` snapshot yields a measured figure, a `ModelBased` one a modeled
/// one, an `Estimator` one an estimate.
fn graded(start: Sample, end: Sample) -> Energy {
let delta = end.uj.saturating_sub(start.uj);
match end.provenance {
Provenance::HwShunt => Energy::measured(delta),
Provenance::ModelBased => Energy::modeled(delta),
Provenance::Estimator => Energy::estimator(delta),
}
}
@ -246,12 +260,13 @@ impl Meter for SteppingMeter {
#[cfg(feature = "hw-meter")]
mod hw {
use std::sync::{Mutex, MutexGuard};
use std::time::{Duration, Instant};
use super::{EstimatorMeter, Meter, Sample};
use super::{graded, EstimatorMeter, Meter, Sample, Window};
use jouleclaw_energy::meter::{detect_meter, CompositeMeter};
use jouleclaw_energy::EnergyDomain;
use jouleclaw_energy::Provenance as JpProvenance;
use mesh_schema::Provenance;
use mesh_schema::{Energy, Provenance};
/// [`EstimatorMeter`]'s name: what [`HwMeter`] reports once it has fallen back.
const FALLBACK_NAME: &str = "estimator";
@ -284,6 +299,15 @@ mod hw {
/// counter's figure plus this crate's [`EstimatorMeter`] for the rest of the
/// host, so a span is billed what the counter read and an estimate of what it
/// did not.
///
/// A span shorter than the active counter's minimum window
/// ([`HwMeter::min_window`]) is not a reading of the span: below it the
/// counter's delta is noise, often zero or a whole update period. The span is
/// graded as an estimate of the request, `Estimator`, and billed this crate's
/// [`EstimatorMeter`] over the span's own window, as a span across a failed
/// read is. A [`super::Session`] opens the bridge's own window
/// ([`Meter::open_span`]) so that the span is timed; a caller that subtracts
/// [`Meter::snapshot`]s itself gets no such check.
pub struct HwMeter {
inner: CompositeMeter,
fallback: EstimatorMeter,
@ -333,6 +357,17 @@ mod hw {
}
}
/// The smallest span the active backend's figure stands for: its
/// counter's `min_window_ns`. Zero where this crate's estimator bills the
/// spans (the backend is a whole-host estimate, or a read has failed),
/// since that resolves a microjoule.
pub fn min_window(&self) -> Duration {
if self.estimates || self.state().failed {
return Duration::ZERO;
}
Duration::from_nanos(self.inner.active().map_or(0, |m| m.min_window_ns()))
}
fn state(&self) -> MutexGuard<'_, Fallback> {
self.state
.lock()
@ -417,6 +452,43 @@ mod hw {
provenance: Provenance::Estimator,
}
}
/// A span on its own window, so that its length is judged against the
/// backend's minimum window ([`HwWindow::close`]).
fn open_span(&self) -> Option<Box<dyn Window + '_>> {
let floor = self.min_window();
let start = self.snapshot();
let estimate_at = self.fallback.snapshot().uj;
Some(Box::new(HwWindow { meter: self, start, estimate_at, floor, opened: Instant::now() }))
}
}
/// A span on an [`HwMeter`]: its opening snapshot, the estimator's figure at
/// that point, the backend's minimum window then, and when the span opened.
struct HwWindow<'m> {
meter: &'m HwMeter,
start: Sample,
estimate_at: u64,
floor: Duration,
opened: Instant,
}
impl Window for HwWindow<'_> {
/// The span's energy as the bridge grades a snapshot pair — unless the
/// span was shorter than the backend's minimum window, which makes it
/// this crate's estimate of the span's own length. The length is taken
/// from after the opening snapshot to before the closing one: a little
/// shorter than the backend's own window, so a span at the floor is
/// judged short rather than long.
fn close(self: Box<Self>) -> Energy {
let short = self.opened.elapsed() < self.floor;
let end = self.meter.snapshot();
let estimate = self.meter.fallback.snapshot().uj.saturating_sub(self.estimate_at);
if short {
return Energy::estimator(estimate);
}
graded(self.start, end)
}
}
#[cfg(test)]
@ -614,6 +686,7 @@ mod hw {
reads: AtomicU64,
domain: EnergyDomain,
grade: JpProvenance,
floor: Duration,
}
impl EnergyCounter for Stepping {
@ -627,7 +700,7 @@ mod hw {
1
}
fn min_window_ns(&self) -> u64 {
1
self.floor.as_nanos() as u64
}
fn read(&self) -> Result<EnergyReading, EnergyError> {
let n = self.reads.fetch_add(1, Ordering::SeqCst);
@ -650,10 +723,51 @@ mod hw {
}
fn stepping(domain: EnergyDomain, grade: JpProvenance) -> HwMeter {
let backend = Stepping { reads: AtomicU64::new(0), domain, grade };
stepping_with_floor(domain, grade, Duration::from_nanos(1))
}
/// [`stepping`] with a minimum window of `floor`.
fn stepping_with_floor(domain: EnergyDomain, grade: JpProvenance, floor: Duration) -> HwMeter {
let backend = Stepping { reads: AtomicU64::new(0), domain, grade, floor };
HwMeter::bridging(CompositeMeter::new(vec![Box::new(backend)]))
}
/// A span shorter than the backend's minimum window is not a reading of
/// it, whatever the counter's grade: it is graded `Estimator`, with no
/// `measured_uj`, and billed the estimator over the span's own length,
/// not the counter's delta. Past the floor the counter's figure stands.
#[test]
fn a_span_under_the_counters_minimum_window_is_an_estimate() {
for grade in [JpProvenance::HwShunt, JpProvenance::ModelBased] {
let m = stepping_with_floor(EnergyDomain::SocTotal, grade, Duration::from_millis(50));
assert_eq!(m.min_window(), Duration::from_millis(50));
let short = span(&m); // 20 ms
assert_eq!((short.provenance, short.measured_uj), (Provenance::Estimator, None), "{grade:?}: {short:?}");
assert!(short.check_provenance().is_ok());
// 20 ms at the estimator's 15 W is ~300,000 µJ; the counter's STEP is not it.
assert!(short.estimated_uj > STEP && short.estimated_uj < 2_000_000, "{grade:?}: {short:?}");
let s = Session::start(&m);
std::thread::sleep(Duration::from_millis(60));
let long = s.stop();
assert_eq!(long.estimated_uj, STEP, "{grade:?}: past the floor the counter's figure stands: {long:?}");
assert_ne!(long.provenance, Provenance::Estimator, "{grade:?}");
assert!(long.check_provenance().is_ok());
}
}
/// The bridge's minimum window is its counter's while the counter bills
/// the spans, and zero once this crate's estimator does (after a failed
/// read), since that resolves a microjoule.
#[test]
fn the_minimum_window_is_the_counters_until_the_bridge_estimates() {
let m = bridge(|n| n >= 1);
assert_eq!(m.min_window(), Duration::from_nanos(1));
let _ = m.snapshot();
let _ = m.snapshot(); // the second read fails
assert_eq!(m.min_window(), Duration::ZERO, "the bridge now bills by its estimator");
}
/// A live GPU counter's span is not a measurement of the request: mesh/1
/// has no coverage field, so a HwShunt GPU figure would claim the CPU it
/// did not read. It is graded as an estimate of the whole request —
@ -799,7 +913,7 @@ pub use jouleclaw_energy::span::SpanUses;
/// and GPU energy over the span ([`jouleclaw_energy::span::ProcessSpanMeter`]).
#[cfg(feature = "hw-meter")]
mod process {
use jouleclaw_energy::span::{ProcessSpanMeter, SpanEnergy, SpanStart, SpanUses};
use jouleclaw_energy::span::{ProcessSpanMeter, SpanEnergy, SpanSource, SpanStart, SpanUses};
use jouleclaw_energy::{Coverage, EnergyError, Provenance as JpProvenance};
use mesh_schema::Energy;
@ -830,7 +944,10 @@ mod process {
/// that fallback is an estimate, the figure is this crate's
/// [`EstimatorMeter`] over the work's own window, the estimator this crate
/// bills the rest of a partial span with, so an estimated request is
/// billed one way whichever path it took.
/// billed one way whichever path it took. So it is where the span was
/// shorter than the host meter's minimum window
/// ([`ProcessSpanMeter::fallback_min_window`]): the host meter's figure over
/// it is noise, not a reading of the span.
pub struct ProcessMeter {
span: ProcessSpanMeter,
uses: SpanUses,
@ -886,19 +1003,28 @@ mod process {
impl Window for ProcessWindow<'_> {
fn close(mut self: Box<Self>) -> Energy {
let rest_uj = self.meter.rest.snapshot().uj.saturating_sub(self.rest_at);
// Timed from the span's opening readings to before its closing ones:
// a little shorter than the host meter's own window, so a span at the
// floor is judged short rather than long.
let host_floor = self.meter.span.fallback_min_window();
let short = self.start.as_ref().is_some_and(|start| start.at().elapsed() < host_floor);
let span = match self.start.take() {
Some(start) => self.meter.span.finish(start, self.meter.uses),
None => Err(EnergyError::NotCurrent),
};
energy_of(span, rest_uj)
energy_of(span, rest_uj, short)
}
}
/// The `mesh/1` energy of a span: see [`ProcessMeter`] for the rule. Pure,
/// so the tests drive every case. `rest_uj` is the estimator's figure for
/// the host over the same window.
pub(crate) fn energy_of(span: Result<SpanEnergy, EnergyError>, rest_uj: u64) -> Energy {
/// the host over the same window; `short_for_host` says the span was shorter
/// than the host meter's minimum window.
pub(crate) fn energy_of(span: Result<SpanEnergy, EnergyError>, rest_uj: u64, short_for_host: bool) -> Energy {
match span {
// The host meter's figure over a span shorter than its floor is
// noise: billed as this crate's estimate of the span's own length.
Ok(s) if short_for_host && matches!(s.source, SpanSource::Fallback { .. }) => Energy::estimator(rest_uj),
Ok(s) if s.coverage == Coverage::Whole => match s.provenance {
JpProvenance::HwShunt => Energy::measured(s.uj),
JpProvenance::ModelBased => Energy::modeled(s.uj),
@ -944,28 +1070,55 @@ mod process {
coverage: Coverage::Whole,
source: SpanSource::Fallback { meter: "apple-silicon", why: "process CPU: did not advance".to_string() },
});
let e = energy_of(fallback, 169);
let e = energy_of(fallback, 169, false);
assert_eq!((e.provenance, e.measured_uj, e.estimated_uj), (Provenance::Estimator, None, 169));
assert!(e.check_provenance().is_ok());
}
/// A span shorter than the host meter's minimum window is not a reading
/// of it, whatever the host meter's grade: a fallback figure over it is
/// billed as this crate's estimate of the span's own length. A span that
/// this process's own counters metered is not the host meter's, so the
/// host meter's floor does not apply to it.
#[test]
fn a_fallback_span_under_the_host_meters_floor_is_an_estimate() {
for grade in [JpProvenance::HwShunt, JpProvenance::ModelBased] {
let fallback = || {
Ok(SpanEnergy {
uj: 2_000_000,
provenance: grade,
coverage: Coverage::Whole,
source: SpanSource::Fallback { meter: "host", why: "process CPU: not live".to_string() },
})
};
let short = energy_of(fallback(), 169, true);
assert_eq!((short.provenance, short.measured_uj, short.estimated_uj), (Provenance::Estimator, None, 169));
assert!(short.check_provenance().is_ok());
let long = energy_of(fallback(), 169, false);
assert_eq!(long.estimated_uj, 2_000_000, "{grade:?}: past the floor the host meter's figure stands");
assert_ne!(long.provenance, Provenance::Estimator, "{grade:?}");
}
let own = energy_of(span(9_000, JpProvenance::HwShunt, Coverage::Whole), 40_000, true);
assert_eq!((own.provenance, own.measured_uj), (Provenance::HwShunt, Some(9_000)), "not the host meter's span");
}
/// Only a whole span keeps its hardware grade; a partial one is an
/// estimate of the whole request, the reading plus the estimator's
/// figure for the rest of the host; no figure is the estimator's.
#[test]
fn a_span_keeps_its_hardware_grade_only_when_it_covers_the_whole_request() {
let whole = energy_of(span(9_000, JpProvenance::HwShunt, Coverage::Whole), 40_000);
let whole = energy_of(span(9_000, JpProvenance::HwShunt, Coverage::Whole), 40_000, false);
assert_eq!((whole.provenance, whole.measured_uj, whole.estimated_uj), (Provenance::HwShunt, Some(9_000), 9_000));
assert!(whole.check_provenance().is_ok());
let partial = energy_of(span(9_000, JpProvenance::HwShunt, Coverage::Partial), 40_000);
let partial = energy_of(span(9_000, JpProvenance::HwShunt, Coverage::Partial), 40_000, false);
assert_eq!((partial.provenance, partial.measured_uj, partial.estimated_uj), (Provenance::Estimator, None, 49_000));
assert!(partial.check_provenance().is_ok());
let modeled = energy_of(span(7_000, JpProvenance::ModelBased, Coverage::Whole), 40_000);
let modeled = energy_of(span(7_000, JpProvenance::ModelBased, Coverage::Whole), 40_000, false);
assert_eq!(modeled.provenance, Provenance::ModelBased);
let none = energy_of(Err(EnergyError::NotCurrent), 40_000);
let none = energy_of(Err(EnergyError::NotCurrent), 40_000, false);
assert_eq!((none.provenance, none.measured_uj, none.estimated_uj), (Provenance::Estimator, None, 40_000));
}
}