gpuviewer_core/model.rs
1//! Core data model. Every metric is `Option<T>`: absence (`NOT_SUPPORTED`, missing sysfs
2//! file, privilege wall) is a normal per-metric outcome, never an error.
3
4use serde::{Deserialize, Serialize};
5
6/// Stable device identity. For PCI devices this is the PCI address (`0000:01:00.0`) so the
7/// same physical GPU dedupes across backends; platform devices (Apple) use a platform key.
8#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
9pub struct DeviceId(pub String);
10
11impl std::fmt::Display for DeviceId {
12 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13 f.write_str(&self.0)
14 }
15}
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "lowercase")]
19pub enum Vendor {
20 Nvidia,
21 Amd,
22 Intel,
23 Apple,
24 Unknown,
25}
26
27impl Vendor {
28 pub fn label(self) -> &'static str {
29 match self {
30 Vendor::Nvidia => "NVIDIA",
31 Vendor::Amd => "AMD",
32 Vendor::Intel => "Intel",
33 Vendor::Apple => "Apple",
34 Vendor::Unknown => "GPU",
35 }
36 }
37}
38
39/// Queried once per device at startup (nvtop's `populate_static_info` split).
40#[derive(Clone, Debug, Serialize, Deserialize)]
41pub struct StaticInfo {
42 pub id: DeviceId,
43 pub vendor: Vendor,
44 pub name: String,
45 pub backend: String,
46 pub mem_total_bytes: Option<u64>,
47 pub power_limit_mw: Option<u32>,
48 pub max_sm_clock_mhz: Option<u32>,
49 /// Temperature at which the driver starts thermal slowdown, if exposed.
50 pub temp_slowdown_c: Option<f32>,
51 pub driver_version: Option<String>,
52 /// One-line explanation of why the process list may be incomplete or absent (WSL2
53 /// driver limitation, privilege wall); `None` when there is nothing to explain.
54 pub process_hint: Option<String>,
55 /// Mandatory honesty label for sources whose numbers need qualification to be read
56 /// correctly (design cross-platform.md §5.4): macOS's `mem_total_bytes` is a
57 /// unified-memory working-set budget, not VRAM; WDDM's `util_pct` is the busiest
58 /// engine's scheduler duty-cycle, not whole-device capacity. Surfaced TUI/report-side
59 /// only — deliberately NOT part of the NDJSON device object for now. `None` when the
60 /// source needs no caveat. `serde(default)` so older serialized infos still decode.
61 #[serde(default)]
62 pub source_caveat: Option<String>,
63}
64
65/// Decoded throttle/clocks-event reasons. Decoding is tolerant: unknown future bits land in
66/// `other` instead of failing (NVML renamed/extended these bits across versions).
67#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct ThrottleReasons {
69 pub thermal: bool,
70 pub power_cap: bool,
71 pub hw_slowdown: bool,
72 pub sync_boost: bool,
73 pub other: bool,
74}
75
76impl ThrottleReasons {
77 /// True if any *performance-limiting* reason is active (idle is not throttling).
78 pub fn any(&self) -> bool {
79 self.thermal || self.power_cap || self.hw_slowdown || self.sync_boost || self.other
80 }
81
82 pub fn labels(&self) -> Vec<&'static str> {
83 let mut v = Vec::new();
84 if self.thermal {
85 v.push("thermal");
86 }
87 if self.power_cap {
88 v.push("power cap");
89 }
90 if self.hw_slowdown {
91 v.push("hw slowdown");
92 }
93 if self.sync_boost {
94 v.push("sync boost");
95 }
96 if self.other {
97 v.push("other");
98 }
99 v
100 }
101}
102
103/// One per-tick sample of a device (nvtop's `refresh_dynamic_info` split).
104#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
105pub struct DynamicSample {
106 /// Unix millis; one timestamp per collection frame (charts jitter otherwise).
107 pub ts_ms: u64,
108 pub util_pct: Option<f32>,
109 /// Name of the engine whose busy% `util_pct` reports, when utilization is an
110 /// engine-headline rather than a whole-device number (Windows WDDM: the busiest
111 /// single engine, Task-Manager-comparable — the name makes the headline
112 /// self-explaining: "Copy 97%" reads differently from "3D 97%"). `None` where
113 /// utilization is device-wide. `serde(default)` for frames recorded before this
114 /// field existed.
115 #[serde(default)]
116 pub util_engine: Option<String>,
117 pub mem_used_bytes: Option<u64>,
118 pub power_mw: Option<u32>,
119 pub temp_c: Option<f32>,
120 pub fan_pct: Option<f32>,
121 pub sm_clock_mhz: Option<u32>,
122 pub mem_clock_mhz: Option<u32>,
123 pub encoder_pct: Option<f32>,
124 pub decoder_pct: Option<f32>,
125 /// `Some(reasons)` only when the source can actually observe throttling (NVML
126 /// clocks-event bitmask, AMD `gpu_metrics`, Intel `throttle_reason_*` sysfs).
127 /// **`None` means "unobservable", never "not throttling"** — sources with no
128 /// throttle interface (Windows WDDM counters, macOS) must not assert the all-false
129 /// negative as fact (design cross-platform.md §5.4). `serde(default)` so frames
130 /// recorded before this change deserialize (missing field → `None`).
131 #[serde(default)]
132 pub throttle: Option<ThrottleReasons>,
133}
134
135#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "lowercase")]
137pub enum ProcessKind {
138 Compute,
139 Graphics,
140 Both,
141 Unknown,
142}
143
144impl ProcessKind {
145 /// nvidia-smi-style short code for the TUI process table.
146 pub fn label(self) -> &'static str {
147 match self {
148 ProcessKind::Compute => "C",
149 ProcessKind::Graphics => "G",
150 ProcessKind::Both => "C+G",
151 ProcessKind::Unknown => "?",
152 }
153 }
154
155 /// Prose form for event evidence ("new compute client", not "new C client").
156 pub fn prose(self) -> &'static str {
157 match self {
158 ProcessKind::Compute => "compute",
159 ProcessKind::Graphics => "graphics",
160 ProcessKind::Both => "compute+graphics",
161 ProcessKind::Unknown => "unknown-type",
162 }
163 }
164}
165
166/// Per-process attribution (nvtop's `refresh_running_processes` split).
167#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
168pub struct ProcessSample {
169 pub pid: u32,
170 pub name: String,
171 pub kind: ProcessKind,
172 pub mem_bytes: Option<u64>,
173 pub util_pct: Option<f32>,
174 /// Process CPU usage as % of one core (100.0 = one full core); `None` when unknown.
175 /// `serde(default)` so frames recorded before this field existed still deserialize.
176 #[serde(default)]
177 pub cpu_pct: Option<f32>,
178 /// Container identity if the process runs in one (e.g. `docker:1a2b3c4d5e6f`);
179 /// `None` for host processes or when unknown. `serde(default)` for old recordings.
180 #[serde(default)]
181 pub container: Option<String>,
182}
183
184/// Normalize a PCI address (`domain:bus:dev.func`) for cross-backend dedupe: NVML reports
185/// `00000000:01:00.0` while sysfs and D3DKMT-derived ids report `0000:01:00.0` — the same
186/// physical GPU. Lowercase everything; trim/zero-pad the domain to 4 hex digits (a
187/// genuinely >16-bit domain keeps its extra digits — all sources print those the same
188/// way). Returns `None` for anything that isn't a PCI address (`mock:…`, `wddm:…`,
189/// `apple:…`, `nvml:0` fallback ids) — those are never deduped: wrongly merging two
190/// distinct devices is worse than listing one twice.
191///
192/// Lives in core (moved from the tui collector per design cross-platform.md §5.4) so the
193/// collector's dedupe and the Windows backends' LUID↔PCI matching share one rule.
194pub fn normalize_pci_id(id: &str) -> Option<String> {
195 let id = id.to_ascii_lowercase();
196 let (domain, rest) = id.split_once(':')?;
197 let (bus, devfn) = rest.split_once(':')?;
198 let (dev, func) = devfn.split_once('.')?;
199 // Each segment must be pure hex of plausible width (catches embedded extra `:`/`.`
200 // too, since those aren't hex digits).
201 let hex = |s: &str, max: usize| {
202 !s.is_empty() && s.len() <= max && s.bytes().all(|b| b.is_ascii_hexdigit())
203 };
204 if !hex(domain, 8) || !hex(bus, 2) || !hex(dev, 2) || !hex(func, 1) {
205 return None;
206 }
207 let domain = format!("{:0>4}", domain.trim_start_matches('0'));
208 Some(format!("{domain}:{bus:0>2}:{dev:0>2}.{func}"))
209}
210
211pub fn now_ms() -> u64 {
212 std::time::SystemTime::now()
213 .duration_since(std::time::UNIX_EPOCH)
214 .map(|d| d.as_millis() as u64)
215 .unwrap_or(0)
216}
217
218/// Human formatting helpers shared by frontends.
219pub fn fmt_bytes(b: u64) -> String {
220 const GIB: f64 = 1024.0 * 1024.0 * 1024.0;
221 const MIB: f64 = 1024.0 * 1024.0;
222 let b = b as f64;
223 if b >= GIB {
224 format!("{:.1} GiB", b / GIB)
225 } else {
226 format!("{:.0} MiB", b / MIB)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn normalize_pci_id_unifies_nvml_and_sysfs_forms() {
236 // NVML's 8-hex-digit-domain form, sysfs/D3DKMT's 4-digit form, and case all fold
237 // to one key — that equality IS the cross-backend dedupe and the Windows
238 // LUID↔PCI match (design §2.5).
239 assert_eq!(
240 normalize_pci_id("00000000:01:00.0").as_deref(),
241 Some("0000:01:00.0")
242 );
243 assert_eq!(
244 normalize_pci_id("0000:01:00.0").as_deref(),
245 Some("0000:01:00.0")
246 );
247 assert_eq!(
248 normalize_pci_id("00000000:0A:00.0").as_deref(),
249 Some("0000:0a:00.0")
250 );
251 // Non-zero domains survive normalization in both widths.
252 assert_eq!(
253 normalize_pci_id("00000001:03:00.0").as_deref(),
254 Some("0001:03:00.0")
255 );
256 assert_eq!(
257 normalize_pci_id("0001:03:00.0").as_deref(),
258 Some("0001:03:00.0")
259 );
260 }
261
262 #[test]
263 fn normalize_pci_id_rejects_non_pci_ids() {
264 // Refuse-to-dedupe cases: synthetic ids must never merge with a real device.
265 assert_eq!(normalize_pci_id("mock:0000:01:00.0"), None);
266 assert_eq!(normalize_pci_id("nvml:0"), None);
267 assert_eq!(normalize_pci_id("wddm:10de:2684:0"), None);
268 assert_eq!(normalize_pci_id("apple:m2-max"), None);
269 assert_eq!(normalize_pci_id(""), None);
270 assert_eq!(normalize_pci_id("0000:01:00"), None); // no function part
271 assert_eq!(normalize_pci_id("0000:01:00.0.1"), None); // trailing junk
272 assert_eq!(normalize_pci_id("0000:01:02:00.0"), None); // extra segment
273 }
274
275 #[test]
276 fn throttle_none_is_a_distinct_state_from_observed_all_false() {
277 // The honesty pivot of the §5.4 model change: `None` (source cannot observe
278 // throttling) and `Some(all-false)` (observed: not throttling) are different
279 // claims and must never compare equal. Wire-level assertions (None → JSON null,
280 // missing field → None) live in the NDJSON conformance suite
281 // (crates/tui/tests/ndjson_contract.rs) — core stays serde_json-free.
282 let unobservable: Option<ThrottleReasons> = None;
283 let observed_quiet = Some(ThrottleReasons::default());
284 assert_ne!(unobservable, observed_quiet);
285 // And neither state reads as "throttling".
286 assert!(!unobservable.is_some_and(|t| t.any()));
287 assert!(!observed_quiet.is_some_and(|t| t.any()));
288 }
289}