Skip to main content

gpuviewer_core/
mock.rs

1//! Mock backend: deterministic-ish simulation used for CI and demos (no GPU required).
2//!
3//! Device 0 simulates a training run: high util with periodic idle gaps (dataloader /
4//! checkpoint pattern), VRAM climbing toward OOM, temperature chasing util until a thermal
5//! throttle cycle kicks in. Device 1 simulates a desktop/inference box: bursty util and an
6//! `ollama` process that periodically attaches with a large allocation, then exits.
7
8use crate::backend::{BackendError, GpuBackend};
9use crate::model::{
10    now_ms, DeviceId, DynamicSample, ProcessKind, ProcessSample, StaticInfo, ThrottleReasons,
11    Vendor,
12};
13
14const GIB: u64 = 1024 * 1024 * 1024;
15
16/// Tiny xorshift PRNG — keeps the core crate dependency-free.
17struct Rng(u64);
18
19impl Rng {
20    fn next(&mut self) -> u64 {
21        let mut x = self.0;
22        x ^= x << 13;
23        x ^= x >> 7;
24        x ^= x << 17;
25        self.0 = x;
26        x
27    }
28
29    /// Uniform float in [0, 1).
30    fn f(&mut self) -> f64 {
31        (self.next() >> 11) as f64 / (1u64 << 53) as f64
32    }
33
34    /// Uniform float in [lo, hi).
35    fn range(&mut self, lo: f64, hi: f64) -> f64 {
36        lo + self.f() * (hi - lo)
37    }
38}
39
40struct TrainSim {
41    rng: Rng,
42    tick: u64,
43    temp_c: f64,
44    vram_python: f64,
45    throttling: bool,
46    fan_pct: f64,
47    /// tick at which the current idle gap ends (0 = not idle).
48    idle_until: u64,
49}
50
51struct DesktopSim {
52    rng: Rng,
53    tick: u64,
54    ollama_present: bool,
55    ollama_toggle_at: u64,
56    util_level: f64,
57}
58
59pub struct MockBackend {
60    ids: [DeviceId; 2],
61    train: TrainSim,
62    desktop: DesktopSim,
63}
64
65impl MockBackend {
66    pub fn new() -> Self {
67        Self {
68            ids: [
69                DeviceId("mock:0000:01:00.0".into()),
70                DeviceId("mock:0000:03:00.0".into()),
71            ],
72            train: TrainSim {
73                rng: Rng(0x9E37_79B9_7F4A_7C15),
74                tick: 0,
75                temp_c: 52.0,
76                vram_python: 19.2 * GIB as f64,
77                throttling: false,
78                fan_pct: 35.0,
79                idle_until: 0,
80            },
81            desktop: DesktopSim {
82                rng: Rng(0xD1B5_4A32_D192_ED03),
83                tick: 0,
84                ollama_present: false,
85                ollama_toggle_at: 45,
86                util_level: 12.0,
87            },
88        }
89    }
90}
91
92impl MockBackend {
93    /// One simulation step for ALL devices at a synthetic timestamp, in `devices()` order —
94    /// the seeding entry point for `gpuviewer demo`, which replays hours of history through
95    /// the sims in seconds. The live path (`refresh_dynamic` at `now_ms()`) and this one
96    /// share the same `step()`, so seeded history and live mock data are the same
97    /// simulation, just on different clocks.
98    pub fn tick_at(&mut self, ts_ms: u64) -> Vec<(DeviceId, DynamicSample, Vec<ProcessSample>)> {
99        vec![
100            (
101                self.ids[0].clone(),
102                self.train.step(ts_ms),
103                self.train.processes(),
104            ),
105            (
106                self.ids[1].clone(),
107                self.desktop.step(ts_ms),
108                self.desktop.processes(),
109            ),
110        ]
111    }
112}
113
114impl Default for MockBackend {
115    fn default() -> Self {
116        Self::new()
117    }
118}
119
120impl GpuBackend for MockBackend {
121    fn name(&self) -> &'static str {
122        "mock"
123    }
124
125    fn devices(&mut self) -> Vec<DeviceId> {
126        self.ids.to_vec()
127    }
128
129    fn static_info(&mut self, dev: &DeviceId) -> Result<StaticInfo, BackendError> {
130        if *dev == self.ids[0] {
131            Ok(StaticInfo {
132                id: dev.clone(),
133                vendor: Vendor::Nvidia,
134                name: "GeForce RTX 4090 (mock)".into(),
135                backend: "mock".into(),
136                mem_total_bytes: Some(24 * GIB),
137                power_limit_mw: Some(450_000),
138                max_sm_clock_mhz: Some(2_520),
139                temp_slowdown_c: Some(84.0),
140                driver_version: Some("mock 999.99".into()),
141                process_hint: None,
142                source_caveat: None,
143            })
144        } else if *dev == self.ids[1] {
145            Ok(StaticInfo {
146                id: dev.clone(),
147                vendor: Vendor::Amd,
148                name: "Radeon RX 7900 XTX (mock)".into(),
149                backend: "mock".into(),
150                mem_total_bytes: Some(24 * GIB),
151                power_limit_mw: Some(355_000),
152                max_sm_clock_mhz: Some(2_500),
153                temp_slowdown_c: Some(110.0),
154                driver_version: Some("mock amdgpu".into()),
155                process_hint: None,
156                source_caveat: None,
157            })
158        } else {
159            Err(BackendError::DeviceNotFound(dev.clone()))
160        }
161    }
162
163    fn refresh_dynamic(&mut self, dev: &DeviceId) -> Result<DynamicSample, BackendError> {
164        let ts = now_ms();
165        if *dev == self.ids[0] {
166            Ok(self.train.step(ts))
167        } else if *dev == self.ids[1] {
168            Ok(self.desktop.step(ts))
169        } else {
170            Err(BackendError::DeviceNotFound(dev.clone()))
171        }
172    }
173
174    fn refresh_processes(&mut self, dev: &DeviceId) -> Result<Vec<ProcessSample>, BackendError> {
175        if *dev == self.ids[0] {
176            Ok(self.train.processes())
177        } else if *dev == self.ids[1] {
178            Ok(self.desktop.processes())
179        } else {
180            Err(BackendError::DeviceNotFound(dev.clone()))
181        }
182    }
183}
184
185impl TrainSim {
186    fn step(&mut self, ts_ms: u64) -> DynamicSample {
187        self.tick += 1;
188
189        // Idle gaps: every ~90 ticks, go idle for 8-20 ticks (checkpoint/validation pattern).
190        if self.idle_until == 0 && self.tick.is_multiple_of(90) {
191            self.idle_until = self.tick + 8 + (self.rng.next() % 13);
192        }
193        let idle = self.idle_until > self.tick;
194        if !idle {
195            self.idle_until = 0;
196        }
197
198        let util = if idle {
199            self.rng.range(0.0, 4.0)
200        } else {
201            self.rng.range(91.0, 99.5)
202        };
203
204        // VRAM: python allocation climbs ~4.5 MiB/tick (~270 MiB/min at 1s ticks) so the
205        // pressure event fires within a few minutes of watching; resets when OOM-adjacent
206        // to keep the demo looping.
207        self.vram_python += self.rng.range(3.0, 6.0) * 1024.0 * 1024.0;
208        if self.vram_python > 22.8 * GIB as f64 {
209            self.vram_python = 16.5 * GIB as f64;
210        }
211
212        // Temperature chases util; fan chases temperature; throttle with hysteresis at the
213        // slowdown threshold.
214        let target = if idle { 48.0 } else { 87.0 };
215        let cooling = (self.fan_pct - 30.0) * 0.06;
216        self.temp_c += (target - self.temp_c) * 0.06 - cooling * 0.02 + self.rng.range(-0.3, 0.3);
217        self.fan_pct += ((self.temp_c - 55.0).max(0.0) * 2.6 - self.fan_pct) * 0.08;
218        self.fan_pct = self.fan_pct.clamp(28.0, 100.0);
219
220        if !self.throttling && self.temp_c >= 84.0 {
221            self.throttling = true;
222        } else if self.throttling && self.temp_c <= 79.0 {
223            self.throttling = false;
224        }
225
226        let max_clock = 2520.0;
227        let clock = if self.throttling {
228            self.rng.range(1750.0, 1860.0)
229        } else if idle {
230            self.rng.range(210.0, 420.0)
231        } else {
232            self.rng.range(max_clock - 90.0, max_clock)
233        };
234
235        let power = if idle {
236            self.rng.range(28_000.0, 45_000.0)
237        } else if self.throttling {
238            self.rng.range(300_000.0, 330_000.0)
239        } else {
240            self.rng.range(390_000.0, 448_000.0)
241        };
242
243        DynamicSample {
244            ts_ms,
245            util_pct: Some(util as f32),
246            util_engine: None,
247            mem_used_bytes: Some(self.vram_python as u64 + 700 * 1024 * 1024),
248            power_mw: Some(power as u32),
249            temp_c: Some(self.temp_c as f32),
250            fan_pct: Some(self.fan_pct as f32),
251            sm_clock_mhz: Some(clock as u32),
252            mem_clock_mhz: Some(10_500),
253            encoder_pct: Some(0.0),
254            decoder_pct: Some(0.0),
255            // The mock OBSERVES throttling by design (it scripts it) — always `Some`,
256            // never the unobservable `None` (design §5.4: mock stays Some).
257            throttle: Some(ThrottleReasons {
258                thermal: self.throttling,
259                ..Default::default()
260            }),
261        }
262    }
263
264    fn processes(&mut self) -> Vec<ProcessSample> {
265        let idle = self.idle_until > self.tick;
266        vec![
267            ProcessSample {
268                pid: 4521,
269                name: "python".into(),
270                kind: ProcessKind::Compute,
271                mem_bytes: Some(self.vram_python as u64),
272                util_pct: Some(if idle { 1.0 } else { 96.0 }),
273                // A dataloader pegging a few cores while the GPU works (more during an idle
274                // gap, the CPU-bound stall fingerprint) — gives the CPU% column live coverage.
275                cpu_pct: Some(if idle { 320.0 } else { 180.0 }),
276                container: None,
277            },
278            ProcessSample {
279                pid: 1203,
280                name: "Xorg".into(),
281                kind: ProcessKind::Graphics,
282                mem_bytes: Some(420 * 1024 * 1024),
283                util_pct: Some(2.0),
284                cpu_pct: Some(6.0),
285                container: None,
286            },
287        ]
288    }
289}
290
291impl DesktopSim {
292    fn step(&mut self, ts_ms: u64) -> DynamicSample {
293        self.tick += 1;
294
295        // ollama attaches/leaves on a cycle to exercise process lifecycle events.
296        if self.tick >= self.ollama_toggle_at {
297            self.ollama_present = !self.ollama_present;
298            let hold = if self.ollama_present { 70 } else { 50 };
299            self.ollama_toggle_at = self.tick + hold + (self.rng.next() % 30);
300        }
301
302        let target = if self.ollama_present {
303            self.rng.range(55.0, 92.0)
304        } else {
305            self.rng.range(3.0, 28.0)
306        };
307        self.util_level += (target - self.util_level) * 0.3;
308
309        let used = if self.ollama_present {
310            (12.4 * GIB as f64) + self.rng.range(-0.2, 0.2) * GIB as f64
311        } else {
312            1.1 * GIB as f64
313        };
314
315        DynamicSample {
316            ts_ms,
317            util_pct: Some(self.util_level as f32),
318            util_engine: None,
319            mem_used_bytes: Some(used as u64),
320            power_mw: Some(if self.ollama_present { 248_000 } else { 41_000 }),
321            temp_c: Some(if self.ollama_present { 71.0 } else { 44.0 }),
322            fan_pct: Some(if self.ollama_present { 58.0 } else { 0.0 }),
323            sm_clock_mhz: Some(if self.ollama_present { 2_390 } else { 350 }),
324            mem_clock_mhz: None, // exercise the Option path: not every metric exists
325            encoder_pct: None,
326            decoder_pct: None,
327            throttle: Some(ThrottleReasons::default()),
328        }
329    }
330
331    fn processes(&mut self) -> Vec<ProcessSample> {
332        let mut v = vec![ProcessSample {
333            pid: 980,
334            name: "gnome-shell".into(),
335            kind: ProcessKind::Graphics,
336            mem_bytes: Some(610 * 1024 * 1024),
337            util_pct: Some(3.0),
338            cpu_pct: Some(12.0),
339            container: None,
340        }];
341        if self.ollama_present {
342            v.push(ProcessSample {
343                pid: 7777,
344                name: "ollama".into(),
345                kind: ProcessKind::Compute,
346                mem_bytes: Some(11 * GIB + 350 * 1024 * 1024),
347                util_pct: Some(74.0),
348                // Runs in a container (the cluster-operator's "which pod" column) and burns a
349                // core or so serving the model — gives both new columns mock coverage.
350                cpu_pct: Some(140.0),
351                container: Some("docker:3f2a9c1b4d5e".into()),
352            });
353        }
354        v
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361
362    /// Two fresh backends stepped with the same timestamp sequence must produce identical
363    /// frames — `gpuviewer demo` relies on this: the seeded story is reproducible, and any
364    /// hidden wall-clock dependence in the sims (which would make the demo unrepeatable)
365    /// fails here.
366    #[test]
367    fn tick_at_is_deterministic_across_instances() {
368        let mut a = MockBackend::new();
369        let mut b = MockBackend::new();
370        for i in 0..500u64 {
371            let ts = 1_700_000_000_000 + i * 1_000;
372            let fa = a.tick_at(ts);
373            let fb = b.tick_at(ts);
374            assert_eq!(fa, fb, "frames diverged at tick {i}");
375            assert_eq!(fa.len(), 2, "tick_at must cover BOTH mock devices");
376            for (_, sample, procs) in &fa {
377                assert_eq!(sample.ts_ms, ts, "samples carry the synthetic timestamp");
378                assert!(!procs.is_empty(), "mock devices always have processes");
379            }
380        }
381    }
382
383    /// `tick_at` and the live `refresh_dynamic` route through the same simulation step, so
384    /// driving one backend via `tick_at` and another via the trait methods yields the same
385    /// per-tick *state evolution* (only the timestamps differ — the live path stamps
386    /// `now_ms()`). Throttling within 500 ticks proves the seeded story actually contains
387    /// the throttle onset the demo scrolls back to.
388    #[test]
389    fn tick_at_drives_the_same_simulation_as_refresh_dynamic() {
390        use crate::backend::GpuBackend;
391        let mut seeded = MockBackend::new();
392        let mut live = MockBackend::new();
393        let train = live.devices()[0].clone();
394        let mut seeded_throttled = false;
395        for i in 0..500u64 {
396            let frame = seeded.tick_at(i * 1_000);
397            let (_, s, _) = &frame[0];
398            let mut l = live.refresh_dynamic(&train).unwrap();
399            // Same evolution apart from the clock: align it and compare everything else.
400            l.ts_ms = s.ts_ms;
401            assert_eq!(*s, l, "sim state diverged at tick {i}");
402            let _ = live.refresh_processes(&train).unwrap();
403            seeded_throttled |= s.throttle.is_some_and(|t| t.any());
404        }
405        assert!(
406            seeded_throttled,
407            "500 ticks of the training sim must include a throttle episode"
408        );
409    }
410}