metrale_bench/hardware/
throttle_monitor.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: How much of each recent interval the clocks spent throttled,
4//! from successive [`ThrottleCounters`] reads (used by the TUI, `tui/data/thermal.rs`).
5//!
6//! Owner: bench hardware.
7//! Invariants:
8//! - A window is never reported for the first read or for a clock that did not advance.
9//! - A thermal counter that went backwards makes the window's `thermal_frac` `None`
10//!   and `thermal_active` false, never a number.
11//!
12//! The counters are cumulative, so one read says how much the box has ever
13//! throttled; the difference of two says what fraction of the interval the clocks
14//! were held. Sustained throttling and flapping in and out of it are different
15//! failures, so the monitor reports the fraction per window and the number of
16//! on/off transitions across its history ([`ThrottleMonitor::transitions`]).
17//!
18//! SW power cap is not counted as throttling here, for the reason given on
19//! [`super::state::ThrottleActive::thermal`]; it has its own field, `power_cap_frac`.
20
21use super::state::ThrottleCounters;
22
23/// 2026-09-26: One differenced observation of the throttle counters.
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct ThrottleWindow {
26    /// 2026-09-26: Time between the two reads, ms.
27    pub window_ms: u64,
28    /// 2026-09-26: Fraction of the window (clamped to 0.0..=1.0) the clocks were
29    /// held by a thermal reason: SW thermal, HW thermal or HW power brake. `None`
30    /// when none of the three was readable or one went backwards.
31    ///
32    /// A lower bound: the three counters can overlap, so the union lies between
33    /// the largest single counter and their sum, and the largest is reported, so
34    /// the figure never overstates throttling.
35    pub thermal_frac: Option<f64>,
36    /// 2026-09-26: Fraction of the window spent under the SW power cap, kept apart
37    /// from `thermal_frac` (see the module docs). `None` when unreadable or when
38    /// its counter went backwards.
39    pub power_cap_frac: Option<f64>,
40    /// 2026-09-26: True when a thermal counter advanced in this window and none
41    /// went backwards.
42    pub thermal_active: bool,
43}
44
45/// 2026-09-26: Rolling detector over successive counter reads.
46///
47/// Holds the previous read; each [`observe`](Self::observe) returns the window
48/// between the two. The first call after construction returns `None`: one read
49/// cannot be differenced, and a zero would read as "no throttling" rather than
50/// "not known yet".
51#[derive(Debug, Default, Clone)]
52pub struct ThrottleMonitor {
53    prev: Option<(u64, ThrottleCounters)>,
54    /// 2026-09-26: Recent `thermal_active` flags, oldest first, for the transition count.
55    history: Vec<bool>,
56}
57
58/// 2026-09-26: How many windows of `thermal_active` history to keep.
59const HISTORY: usize = 32;
60
61impl ThrottleMonitor {
62    pub fn new() -> Self {
63        Self::default()
64    }
65
66    /// 2026-09-26: Difference `counters` (read at `now_ms`) against the previous read.
67    ///
68    /// Returns `None` on the first read and when `now_ms` is not after the previous
69    /// read; in the second case the previous read is kept. A counter that went
70    /// backwards (a driver reload or GPU reset) still yields a window, with the
71    /// fraction it feeds `None` rather than a negative or saturated number.
72    pub fn observe(&mut self, now_ms: u64, counters: ThrottleCounters) -> Option<ThrottleWindow> {
73        let out = match &self.prev {
74            None => None,
75            Some((prev_ms, prev)) => {
76                let window_ms = now_ms.checked_sub(*prev_ms).filter(|d| *d > 0)?;
77                // 2026-09-26: `Ok(None)`: the counter was not reported on one read.
78                // `Ok(Some)`: a usable delta. `Err(())`: it went backwards.
79                //
80                // Kept distinct: folding "went backwards" into `None` and taking
81                // the max over the rest would report Some(0.0), "not throttling",
82                // from a reset.
83                type Delta = Result<Option<u64>, ()>;
84                let delta = |now: Option<u64>, before: Option<u64>| -> Delta {
85                    match (now, before) {
86                        (Some(n), Some(b)) => n.checked_sub(b).map(Some).ok_or(()),
87                        _ => Ok(None),
88                    }
89                };
90                let window_us = (window_ms as f64) * 1000.0;
91                let frac = |us: Option<u64>| us.map(|us| (us as f64 / window_us).clamp(0.0, 1.0));
92
93                let sw_t = delta(counters.sw_thermal_us, prev.sw_thermal_us);
94                let hw_t = delta(counters.hw_thermal_us, prev.hw_thermal_us);
95                let brake = delta(counters.hw_power_brake_us, prev.hw_power_brake_us);
96                let cap = delta(counters.sw_power_cap_us, prev.sw_power_cap_us);
97
98                // 2026-09-26: Any thermal counter going backwards makes the whole
99                // thermal reading unknown.
100                let thermal_us: Option<u64> = match (sw_t, hw_t, brake) {
101                    (Err(()), _, _) | (_, Err(()), _) | (_, _, Err(())) => None,
102                    (Ok(a), Ok(b), Ok(c)) => {
103                        // 2026-09-26: Max, not sum: the three can overlap.
104                        [a, b, c].into_iter().flatten().max()
105                    }
106                };
107                let thermal_reset = matches!((sw_t, hw_t, brake), (Err(()), _, _))
108                    || matches!((sw_t, hw_t, brake), (_, Err(()), _))
109                    || matches!((sw_t, hw_t, brake), (_, _, Err(())));
110                Some(ThrottleWindow {
111                    window_ms,
112                    thermal_frac: if thermal_reset {
113                        None
114                    } else {
115                        frac(thermal_us)
116                    },
117                    power_cap_frac: frac(cap.unwrap_or(None)),
118                    thermal_active: !thermal_reset && thermal_us.is_some_and(|us| us > 0),
119                })
120            }
121        };
122        if let Some(w) = out {
123            self.history.push(w.thermal_active);
124            if self.history.len() > HISTORY {
125                self.history.remove(0);
126            }
127        }
128        self.prev = Some((now_ms, counters));
129        out
130    }
131
132    /// 2026-09-26: Times `thermal_active` flipped across the retained history.
133    ///
134    /// Separate from the fraction: a part pinned at full throttle has a high
135    /// fraction and zero transitions, unlike one that flaps every window.
136    pub fn transitions(&self) -> usize {
137        self.history.windows(2).filter(|w| w[0] != w[1]).count()
138    }
139
140    /// 2026-09-26: Windows retained for [`transitions`](Self::transitions), so a
141    /// caller can tell "0 transitions over 30 windows" from "0 over 1".
142    pub fn samples(&self) -> usize {
143        self.history.len()
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    fn counters(sw_t: u64, hw_t: u64, brake: u64, cap: u64) -> ThrottleCounters {
152        ThrottleCounters {
153            sw_thermal_us: Some(sw_t),
154            hw_thermal_us: Some(hw_t),
155            hw_power_brake_us: Some(brake),
156            sw_power_cap_us: Some(cap),
157            sync_boost_us: None,
158        }
159    }
160
161    /// 2026-09-26: One read cannot be differenced, so the first observation is
162    /// `None`, not 0.0.
163    #[test]
164    fn the_first_sample_is_unknown_not_zero() {
165        let mut m = ThrottleMonitor::new();
166        assert_eq!(m.observe(1_000, counters(0, 0, 0, 0)), None);
167    }
168
169    #[test]
170    fn half_a_window_of_hw_thermal_reads_as_half() {
171        let mut m = ThrottleMonitor::new();
172        m.observe(1_000, counters(0, 0, 0, 0));
173        let w = m.observe(2_000, counters(0, 500_000, 0, 0)).unwrap();
174        assert_eq!(w.window_ms, 1_000);
175        assert_eq!(w.thermal_frac, Some(0.5));
176        assert!(w.thermal_active);
177    }
178
179    /// 2026-09-26: The three thermal counters can overlap, so the union lies
180    /// between max and sum; the max is reported.
181    #[test]
182    fn overlapping_thermal_reasons_take_the_max_not_the_sum() {
183        let mut m = ThrottleMonitor::new();
184        m.observe(0, counters(0, 0, 0, 0));
185        let w = m
186            .observe(1_000, counters(400_000, 300_000, 200_000, 0))
187            .unwrap();
188        // 2026-09-26: The sum would be 0.9; the max is 0.4.
189        assert_eq!(w.thermal_frac, Some(0.4));
190    }
191
192    /// 2026-09-26: SW power cap time goes to `power_cap_frac`, never to the
193    /// thermal fraction.
194    #[test]
195    fn sw_power_cap_never_counts_as_thermal() {
196        let mut m = ThrottleMonitor::new();
197        m.observe(0, counters(0, 0, 0, 0));
198        let w = m.observe(1_000, counters(0, 0, 0, 1_000_000)).unwrap();
199        assert_eq!(w.thermal_frac, Some(0.0));
200        assert!(!w.thermal_active);
201        assert_eq!(w.power_cap_frac, Some(1.0));
202    }
203
204    /// 2026-09-26: A counter that went backwards (a driver reload or GPU reset)
205    /// makes the thermal fraction unknown rather than saturated.
206    #[test]
207    fn counters_going_backwards_report_unknown_rather_than_garbage() {
208        let mut m = ThrottleMonitor::new();
209        m.observe(0, counters(0, 900_000, 0, 0));
210        let w = m.observe(1_000, counters(0, 10_000, 0, 0)).unwrap();
211        assert_eq!(w.thermal_frac, None, "must not saturate on a counter reset");
212        assert!(!w.thermal_active);
213    }
214
215    #[test]
216    fn a_non_advancing_clock_yields_no_window() {
217        let mut m = ThrottleMonitor::new();
218        m.observe(5_000, counters(0, 0, 0, 0));
219        assert_eq!(m.observe(5_000, counters(0, 100, 0, 0)), None);
220    }
221
222    /// 2026-09-26: Pinned throttling and flapping both look bad by fraction; only
223    /// flapping registers transitions.
224    #[test]
225    fn pinned_throttling_and_flapping_are_told_apart() {
226        let mut pinned = ThrottleMonitor::new();
227        let mut flapping = ThrottleMonitor::new();
228        let (mut p_us, mut f_us) = (0u64, 0u64);
229        for i in 0..10u64 {
230            p_us += 1_000_000;
231            pinned.observe(i * 1_000, counters(0, p_us, 0, 0));
232            if i % 2 == 0 {
233                f_us += 1_000_000;
234            }
235            flapping.observe(i * 1_000, counters(0, f_us, 0, 0));
236        }
237        assert_eq!(pinned.transitions(), 0, "pinned throttling does not flap");
238        assert!(
239            flapping.transitions() >= 6,
240            "flapping must register transitions, got {}",
241            flapping.transitions()
242        );
243    }
244
245    /// 2026-09-26: `transitions()` needs its denominator: zero over one window
246    /// means nothing, zero over thirty means stable.
247    #[test]
248    fn sample_count_is_exposed_so_zero_transitions_is_interpretable() {
249        let mut m = ThrottleMonitor::new();
250        assert_eq!(m.samples(), 0);
251        m.observe(0, counters(0, 0, 0, 0));
252        assert_eq!(
253            m.samples(),
254            0,
255            "the undifferenced first sample is not history"
256        );
257        m.observe(1_000, counters(0, 0, 0, 0));
258        assert_eq!(m.samples(), 1);
259    }
260}