1use 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
16struct 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 fn f(&mut self) -> f64 {
31 (self.next() >> 11) as f64 / (1u64 << 53) as f64
32 }
33
34 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 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 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 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 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 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 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 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 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, 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 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 #[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 #[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 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}