diff --git a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/span.rs b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/span.rs index 5b9428134..a769ce438 100644 --- a/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/span.rs +++ b/jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/src/span.rs @@ -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 diff --git a/mesh/SPEC.md b/mesh/SPEC.md index 1b0991152..940f2d63c 100644 --- a/mesh/SPEC.md +++ b/mesh/SPEC.md @@ -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 diff --git a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs index 6010221bf..309e2c26c 100644 --- a/mesh/mesh-rs/crates/mesh-meter/src/lib.rs +++ b/mesh/mesh-rs/crates/mesh-meter/src/lib.rs @@ -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> { + 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) -> 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 { 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) -> 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, 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, 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)); } }