1use serde::{Deserialize, Serialize};
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
25pub struct ThermalZone {
26 pub name: String,
28 pub temp_c: f64,
29}
30
31#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
39pub struct ThrottleCounters {
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub sw_power_cap_us: Option<u64>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
43 pub sw_thermal_us: Option<u64>,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub hw_thermal_us: Option<u64>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub hw_power_brake_us: Option<u64>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub sync_boost_us: Option<u64>,
50}
51
52#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
57pub struct ThrottleActive {
58 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub sw_power_cap: Option<bool>,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub sw_thermal: Option<bool>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub hw_thermal: Option<bool>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub hw_power_brake: Option<bool>,
66}
67
68impl ThrottleActive {
69 pub fn thermal(&self) -> Option<bool> {
77 match (self.sw_thermal, self.hw_thermal, self.hw_power_brake) {
78 (None, None, None) => None,
79 (sw, hw, brake) => {
80 Some(sw.unwrap_or(false) || hw.unwrap_or(false) || brake.unwrap_or(false))
81 }
82 }
83 }
84}
85
86#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
88pub struct GpuComputeApp {
89 pub pid: u32,
90 pub name: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub used_mib: Option<u64>,
94}
95
96#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
100pub struct MachineIdentity {
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub hostname: Option<String>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub machine_id: Option<String>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
107 pub gpu: Option<String>,
108 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub driver: Option<String>,
110}
111
112impl MachineIdentity {
113 pub fn perf_class(&self) -> String {
118 let class = self
119 .gpu
120 .as_deref()
121 .map(|g| super::Hardware {
122 gpu: g.to_string(),
123 ..super::Hardware::default()
124 })
125 .map(|h| h.gate_key())
126 .unwrap_or_else(|| "unknown".to_string());
127 let host = self.hostname.as_deref().unwrap_or("unknown");
128 format!("{class}@{host}")
129 }
130}
131
132#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
136pub struct HardwareState {
137 #[serde(default)]
139 pub captured_at: u64,
140 #[serde(default)]
141 pub machine: MachineIdentity,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub gpu_temp_c: Option<f64>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub chassis_temps_c: Option<Vec<ThermalZone>>,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
150 pub sm_clock_mhz: Option<f64>,
151 #[serde(default, skip_serializing_if = "Option::is_none")]
152 pub sm_clock_max_mhz: Option<f64>,
153 #[serde(default)]
154 pub throttle_counters: ThrottleCounters,
155 #[serde(default)]
156 pub throttle_active: ThrottleActive,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub mem_available_kb: Option<u64>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub mem_total_kb: Option<u64>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub page_cache_kb: Option<u64>,
165 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub gpu_compute_apps: Option<Vec<GpuComputeApp>>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub cpu_governor: Option<String>,
172 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub persistence_mode: Option<bool>,
174 #[serde(default, skip_serializing_if = "Vec::is_empty")]
177 pub sources: Vec<String>,
178}
179
180impl HardwareState {
181 pub fn collect() -> Self {
184 super::collect::collect()
185 }
186
187 pub fn foreign_compute_apps(&self) -> Option<usize> {
192 let own = std::process::id();
193 Some(
194 self.gpu_compute_apps
195 .as_ref()?
196 .iter()
197 .filter(|a| a.pid != own)
198 .count(),
199 )
200 }
201
202 pub fn hottest_chassis_c(&self) -> Option<f64> {
204 self.chassis_temps_c
205 .as_ref()?
206 .iter()
207 .map(|z| z.temp_c)
208 .fold(None::<f64>, |acc, t| Some(acc.map_or(t, |a| a.max(t))))
209 }
210
211 pub fn clock_headroom(&self) -> Option<f64> {
217 let max = self.sm_clock_max_mhz?;
218 (max > 0.0).then(|| self.sm_clock_mhz.map(|c| c / max))?
219 }
220
221 pub fn one_line(&self) -> String {
223 let mut parts = vec![self.machine.perf_class()];
224 if let Some(t) = self.gpu_temp_c {
225 parts.push(format!("gpu {t:.0} °C"));
226 }
227 if let Some(t) = self.hottest_chassis_c() {
228 parts.push(format!("chassis max {t:.0} °C"));
229 }
230 match (self.sm_clock_mhz, self.sm_clock_max_mhz) {
231 (Some(c), Some(m)) => parts.push(format!("sm {c:.0}/{m:.0} MHz")),
232 (Some(c), None) => parts.push(format!("sm {c:.0} MHz")),
233 _ => {}
234 }
235 match self.foreign_compute_apps() {
236 Some(n) => parts.push(format!("{n} foreign gpu proc")),
237 None => parts.push("foreign gpu proc unknown".to_string()),
238 }
239 if let Some(kb) = self.mem_available_kb {
240 parts.push(format!("{:.1} GiB avail", kb as f64 / 1_048_576.0));
241 }
242 parts.join(" · ")
243 }
244}
245
246#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
252pub struct HardwareStateDelta {
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub elapsed_s: Option<u64>,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
256 pub sw_power_cap_us: Option<u64>,
257 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub sw_thermal_us: Option<u64>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
260 pub hw_thermal_us: Option<u64>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub hw_power_brake_us: Option<u64>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub gpu_temp_delta_c: Option<f64>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub hottest_chassis_delta_c: Option<f64>,
267}
268
269fn advance(before: Option<u64>, after: Option<u64>) -> Option<u64> {
272 let (b, a) = (before?, after?);
273 a.checked_sub(b)
277}
278
279impl HardwareStateDelta {
280 pub fn between(before: &HardwareState, after: &HardwareState) -> Self {
281 let (b, a) = (&before.throttle_counters, &after.throttle_counters);
282 Self {
283 elapsed_s: after.captured_at.checked_sub(before.captured_at),
284 sw_power_cap_us: advance(b.sw_power_cap_us, a.sw_power_cap_us),
285 sw_thermal_us: advance(b.sw_thermal_us, a.sw_thermal_us),
286 hw_thermal_us: advance(b.hw_thermal_us, a.hw_thermal_us),
287 hw_power_brake_us: advance(b.hw_power_brake_us, a.hw_power_brake_us),
288 gpu_temp_delta_c: Option::zip(before.gpu_temp_c, after.gpu_temp_c).map(|(b, a)| a - b),
289 hottest_chassis_delta_c: Option::zip(
290 before.hottest_chassis_c(),
291 after.hottest_chassis_c(),
292 )
293 .map(|(b, a)| a - b),
294 }
295 }
296
297 pub fn thermal_throttle_advanced(&self) -> Option<bool> {
305 let counters = [
306 self.sw_thermal_us,
307 self.hw_thermal_us,
308 self.hw_power_brake_us,
309 ];
310 if counters.into_iter().flatten().any(|value| value > 0) {
311 return Some(true);
312 }
313 counters
314 .into_iter()
315 .all(|value| value == Some(0))
316 .then_some(false)
317 }
318
319 pub fn thermal_counters_complete(&self) -> bool {
325 self.sw_thermal_us.is_some()
326 && self.hw_thermal_us.is_some()
327 && self.hw_power_brake_us.is_some()
328 }
329
330 pub fn thermal_throttle_fraction(&self) -> Option<f64> {
337 let secs = self.elapsed_s.filter(|s| *s > 0)? as f64;
338 let counters = [
339 self.sw_thermal_us,
340 self.hw_thermal_us,
341 self.hw_power_brake_us,
342 ];
343 if counters.iter().all(Option::is_none) {
344 return None;
345 }
346 let us: u64 = counters.into_iter().flatten().sum();
347 Some(us as f64 / 1e6 / secs)
348 }
349}
350
351#[cfg(test)]
352#[path = "state_tests.rs"]
353mod tests;