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}