Skip to main content

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}