kataglyphis_rustprojecttemplate/
resource_monitor.rs

1// src/resource_monitor.rs — Lightweight background resource monitor.
2
3use std::fs::OpenOptions;
4use std::io::{BufWriter, Write};
5use std::path::PathBuf;
6use std::sync::{
7    Arc,
8    atomic::{AtomicBool, AtomicU64, Ordering},
9};
10use std::thread;
11use std::time::{Duration, Instant};
12
13use log::info;
14use sysinfo::{Pid, ProcessesToUpdate, System};
15
16const BYTES_PER_MIB: f64 = 1024.0 * 1024.0;
17
18#[cfg(target_os = "windows")]
19use crate::gpu_wmi::{gpu_connect, gpu_sample_wmi};
20
21#[derive(Clone, Debug, Default)]
22pub(crate) struct GpuSample {
23    pub utilization_pct: Option<f64>,
24    pub dedicated_used_bytes: Option<u64>,
25    pub shared_used_bytes: Option<u64>,
26    pub total_committed_bytes: Option<u64>,
27}
28
29/// Global counters singleton for tracking inference and camera metrics.
30///
31/// This struct encapsulates all atomic counters in a single type,
32/// making it easier to manage and reducing global state sprawl.
33static GLOBAL_COUNTERS: GlobalCounters = GlobalCounters {
34    inference_completions: AtomicU64::new(0),
35    camera_frames: AtomicU64::new(0),
36    inference_time_ns_total: AtomicU64::new(0),
37    inference_time_samples: AtomicU64::new(0),
38};
39
40struct GlobalCounters {
41    inference_completions: AtomicU64,
42    camera_frames: AtomicU64,
43    inference_time_ns_total: AtomicU64,
44    inference_time_samples: AtomicU64,
45}
46
47impl GlobalCounters {
48    #[inline]
49    fn record_inference_completion(&self) {
50        self.inference_completions.fetch_add(1, Ordering::Relaxed);
51    }
52
53    #[inline]
54    fn record_camera_frame(&self) {
55        self.camera_frames.fetch_add(1, Ordering::Relaxed);
56    }
57
58    #[inline]
59    fn record_inference_duration(&self, duration: Duration) {
60        let ns = duration.as_nanos().min(u128::from(u64::MAX)) as u64;
61        self.inference_time_ns_total
62            .fetch_add(ns, Ordering::Relaxed);
63        self.inference_time_samples.fetch_add(1, Ordering::Relaxed);
64    }
65
66    fn snapshot(&self) -> CounterValues {
67        CounterValues {
68            camera_frames: self.camera_frames.load(Ordering::Relaxed),
69            inference_completions: self.inference_completions.load(Ordering::Relaxed),
70            inference_time_ns_total: self.inference_time_ns_total.load(Ordering::Relaxed),
71            inference_time_samples: self.inference_time_samples.load(Ordering::Relaxed),
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy)]
77struct CounterValues {
78    camera_frames: u64,
79    inference_completions: u64,
80    inference_time_ns_total: u64,
81    inference_time_samples: u64,
82}
83
84#[derive(Clone, Debug)]
85pub struct ResourceMonitorConfig {
86    pub interval: Duration,
87    pub log_file: Option<PathBuf>,
88    pub include_gpu: bool,
89}
90
91#[inline]
92#[cfg_attr(not(gui_wgpu_backend), allow(dead_code))]
93pub(crate) fn record_inference_completion() {
94    GLOBAL_COUNTERS.record_inference_completion();
95}
96
97#[inline]
98#[cfg_attr(not(gui_wgpu_backend), allow(dead_code))]
99pub(crate) fn record_camera_frame() {
100    GLOBAL_COUNTERS.record_camera_frame();
101}
102
103#[inline]
104#[cfg_attr(not(gui_wgpu_backend), allow(dead_code))]
105pub(crate) fn record_inference_duration(duration: Duration) {
106    GLOBAL_COUNTERS.record_inference_duration(duration);
107}
108
109#[inline]
110pub(crate) fn bytes_to_mib(bytes: u64) -> f64 {
111    (bytes as f64) / BYTES_PER_MIB
112}
113
114pub struct ResourceMonitor {
115    stop: Arc<AtomicBool>,
116    handle: Option<thread::JoinHandle<()>>,
117}
118
119impl ResourceMonitor {
120    pub fn start(config: ResourceMonitorConfig) -> Self {
121        let stop = Arc::new(AtomicBool::new(false));
122        let stop_thread = Arc::clone(&stop);
123
124        let handle = match thread::Builder::new()
125            .name("resource-monitor".to_string())
126            .spawn(move || run_monitor_loop(config, stop_thread))
127        {
128            Ok(h) => Some(h),
129            Err(e) => {
130                log::warn!("Failed to spawn resource-monitor thread: {e}");
131                None
132            }
133        };
134
135        Self { stop, handle }
136    }
137}
138
139impl Drop for ResourceMonitor {
140    fn drop(&mut self) {
141        self.stop.store(true, Ordering::Relaxed);
142        if let Some(handle) = self.handle.take()
143            && let Err(e) = handle.join()
144        {
145            log::warn!("Resource monitor thread panicked: {e:?}");
146        }
147    }
148}
149
150#[derive(Debug, Clone, Copy, PartialEq)]
151pub(crate) struct Rates {
152    pub cam_fps: f64,
153    pub infer_fps: f64,
154    pub infer_latency_ms: f64,
155    pub infer_capacity_fps: f64,
156}
157
158pub(crate) struct CounterSnapshot {
159    at: Instant,
160    values: CounterValues,
161}
162
163impl Default for CounterSnapshot {
164    fn default() -> Self {
165        Self::new()
166    }
167}
168
169impl CounterSnapshot {
170    pub(crate) fn new() -> Self {
171        Self {
172            at: Instant::now(),
173            values: GLOBAL_COUNTERS.snapshot(),
174        }
175    }
176
177    pub(crate) fn tick(&mut self, now: Instant) -> Rates {
178        let dt_s = now.duration_since(self.at).as_secs_f64().max(0.001);
179        self.at = now;
180
181        let new_values = GLOBAL_COUNTERS.snapshot();
182
183        let cam_fps = new_values
184            .camera_frames
185            .wrapping_sub(self.values.camera_frames) as f64
186            / dt_s;
187        let infer_fps = new_values
188            .inference_completions
189            .wrapping_sub(self.values.inference_completions) as f64
190            / dt_s;
191
192        let ns_delta = new_values
193            .inference_time_ns_total
194            .wrapping_sub(self.values.inference_time_ns_total);
195        let samples_delta = new_values
196            .inference_time_samples
197            .wrapping_sub(self.values.inference_time_samples);
198
199        self.values = new_values;
200
201        let infer_latency_ms = if samples_delta > 0 {
202            (ns_delta as f64 / samples_delta as f64) / 1_000_000.0
203        } else {
204            0.0
205        };
206        let infer_capacity_fps = if infer_latency_ms > 0.0 {
207            1000.0 / infer_latency_ms
208        } else {
209            0.0
210        };
211
212        Rates {
213            cam_fps,
214            infer_fps,
215            infer_latency_ms,
216            infer_capacity_fps,
217        }
218    }
219}
220
221fn run_monitor_loop(config: ResourceMonitorConfig, stop: Arc<AtomicBool>) {
222    let pid = Pid::from_u32(std::process::id());
223    let mut sys = System::new();
224
225    let mut file_writer = config
226        .log_file
227        .as_ref()
228        .and_then(|path| OpenOptions::new().create(true).append(true).open(path).ok())
229        .map(BufWriter::new);
230
231    #[cfg(target_os = "windows")]
232    let wmi_conn = if config.include_gpu {
233        gpu_connect()
234    } else {
235        None
236    };
237
238    let mut next_tick = Instant::now();
239    let mut counters = CounterSnapshot::new();
240
241    loop {
242        if stop.load(Ordering::Relaxed) {
243            break;
244        }
245
246        let sample_instant = Instant::now();
247        sys.refresh_processes(ProcessesToUpdate::Some(&[pid]), true);
248        sys.refresh_memory();
249
250        let (proc_cpu_pct, proc_rss_bytes) = sys
251            .process(pid)
252            .map(|p| (p.cpu_usage() as f64, p.memory()))
253            .unwrap_or((0.0, 0));
254
255        let sys_total_bytes = sys.total_memory();
256        let sys_used_bytes = sys.used_memory();
257
258        let gpu = if config.include_gpu {
259            #[cfg(target_os = "windows")]
260            {
261                wmi_conn.as_ref().and_then(gpu_sample_wmi)
262            }
263            #[cfg(not(target_os = "windows"))]
264            {
265                None
266            }
267        } else {
268            None
269        };
270
271        let rates = counters.tick(sample_instant);
272
273        let mut line = format!(
274            "resource cpu={cpu:.1}% rss={rss:.1}MiB sys_used={sys_used:.1}MiB \
275             sys_total={sys_total:.1}MiB cam_fps={cam_fps:.2} infer_fps={infer_fps:.2} \
276             infer_ms={infer_ms:.2} infer_capacity_fps={infer_cap:.2}",
277            cpu = proc_cpu_pct,
278            rss = bytes_to_mib(proc_rss_bytes),
279            sys_used = bytes_to_mib(sys_used_bytes),
280            sys_total = bytes_to_mib(sys_total_bytes),
281            cam_fps = rates.cam_fps,
282            infer_fps = rates.infer_fps,
283            infer_ms = rates.infer_latency_ms,
284            infer_cap = rates.infer_capacity_fps,
285        );
286
287        if let Some(sample) = gpu {
288            append_gpu_metrics(&mut line, &sample);
289        }
290
291        info!("{line}");
292        write_line(&mut file_writer, &line);
293
294        next_tick += config.interval;
295        let sleep_for = next_tick.saturating_duration_since(Instant::now());
296        thread::sleep(sleep_for);
297    }
298
299    if let Some(mut w) = file_writer
300        && let Err(e) = w.flush()
301    {
302        log::warn!("Failed to flush resource log file: {e}");
303    }
304}
305
306fn write_line(writer: &mut Option<BufWriter<std::fs::File>>, line: &str) {
307    let Some(w) = writer.as_mut() else {
308        return;
309    };
310    if let Err(e) = writeln!(w, "{line}") {
311        log::warn!("Failed to write resource log line: {e}");
312    }
313}
314
315fn append_gpu_metrics(line: &mut String, sample: &GpuSample) {
316    use std::fmt::Write;
317    if let Some(util) = sample.utilization_pct {
318        let _ = write!(line, " gpu_util={util:.1}%");
319    }
320    if let Some(dedicated) = sample.dedicated_used_bytes {
321        let _ = write!(line, " gpu_dedicated={:.1}MiB", bytes_to_mib(dedicated));
322    }
323    if let Some(shared) = sample.shared_used_bytes {
324        let _ = write!(line, " gpu_shared={:.1}MiB", bytes_to_mib(shared));
325    }
326    if let Some(total) = sample.total_committed_bytes {
327        let _ = write!(line, " gpu_total={:.1}MiB", bytes_to_mib(total));
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use super::*;
334
335    #[test]
336    fn test_bytes_to_mib() {
337        assert!((bytes_to_mib(1024 * 1024) - 1.0).abs() < 0.001);
338        assert!((bytes_to_mib(512 * 1024 * 1024) - 512.0).abs() < 0.001);
339        assert!((bytes_to_mib(0) - 0.0).abs() < 0.001);
340    }
341
342    #[test]
343    fn test_counter_snapshot_initial() {
344        let snapshot = CounterSnapshot::new();
345        assert_eq!(snapshot.values.camera_frames, 0);
346        assert_eq!(snapshot.values.inference_completions, 0);
347    }
348
349    #[test]
350    fn test_rates_zero_values() {
351        let zero = Rates {
352            cam_fps: 0.0,
353            infer_fps: 0.0,
354            infer_latency_ms: 0.0,
355            infer_capacity_fps: 0.0,
356        };
357        assert!(!zero.cam_fps.is_nan());
358        assert!(!zero.infer_fps.is_nan());
359    }
360
361    #[test]
362    fn test_gpu_sample_default() {
363        let sample = GpuSample::default();
364        assert!(sample.utilization_pct.is_none());
365        assert!(sample.dedicated_used_bytes.is_none());
366    }
367}