Skip to main content

gpu_histop/
gui.rs

1use std::time::{Duration, Instant};
2
3use anyhow::Result;
4use crossbeam_channel::Receiver;
5use eframe::egui;
6use eframe::egui::{Color32, RichText, Stroke};
7use egui_plot::{Legend, Line, Plot, PlotPoints};
8
9use crate::history::History;
10use crate::model::{GpuInfo, GpuProcess, GpuProcessKind, GpuSample, MetricKind};
11use crate::sampler::{SamplerEvent, SamplerHandle};
12
13#[derive(Debug, Clone, Copy)]
14pub struct GuiConfig {
15    pub sample_interval: Duration,
16    pub frame_interval: Duration,
17    pub history_retention: Duration,
18    pub initial_window: Duration,
19}
20
21pub struct GuiApp {
22    devices: Vec<GpuInfo>,
23    backend_label: String,
24    rx: Receiver<SamplerEvent>,
25    sampler: Option<SamplerHandle>,
26    histories: Vec<History>,
27    config: GuiConfig,
28    started: Instant,
29    last_error: Option<String>,
30    samples_seen: u64,
31    selected_gpu: usize,
32    window_index: usize,
33    auto_follow: bool,
34}
35
36impl GuiApp {
37    pub fn new(
38        devices: Vec<GpuInfo>,
39        backend_label: String,
40        rx: Receiver<SamplerEvent>,
41        sampler: SamplerHandle,
42        config: GuiConfig,
43    ) -> Self {
44        let histories = devices
45            .iter()
46            .map(|_| History::new(config.history_retention))
47            .collect();
48
49        Self {
50            devices,
51            backend_label,
52            rx,
53            sampler: Some(sampler),
54            histories,
55            config,
56            started: Instant::now(),
57            last_error: None,
58            samples_seen: 0,
59            selected_gpu: 0,
60            window_index: closest_window(config.initial_window),
61            auto_follow: true,
62        }
63    }
64
65    fn ingest(&mut self) {
66        while let Ok(event) = self.rx.try_recv() {
67            match event {
68                SamplerEvent::Samples(samples) => {
69                    self.samples_seen += samples.len() as u64;
70                    for sample in samples {
71                        if let Some(history) = self.histories.get_mut(sample.gpu_id) {
72                            history.push(sample);
73                        }
74                    }
75                }
76                SamplerEvent::Error(error) => {
77                    self.last_error = Some(error);
78                }
79            }
80        }
81        if self.selected_gpu >= self.devices.len() {
82            self.selected_gpu = self.devices.len().saturating_sub(1);
83        }
84    }
85
86    fn selected_history(&self) -> Option<&History> {
87        self.histories.get(self.selected_gpu)
88    }
89
90    fn visible_window(&self) -> Duration {
91        GUI_WINDOWS[self.window_index]
92    }
93
94    fn total_stored_samples(&self) -> usize {
95        self.histories.iter().map(History::len).sum()
96    }
97
98    fn draw_top_bar(&mut self, ui: &mut egui::Ui) {
99        ui.horizontal_wrapped(|ui| {
100            ui.heading("gpu-histop");
101            ui.separator();
102            ui.label(format!("{} GPU(s)", self.devices.len()));
103            ui.label(format!("backend {}", self.backend_label));
104            ui.label(format!("sample {:.1} Hz", hz(self.config.sample_interval)));
105            ui.label(format!("stored {}", self.total_stored_samples()));
106            ui.label(format!("up {}", format_duration(self.started.elapsed())));
107            ui.separator();
108            ui.checkbox(&mut self.auto_follow, "follow");
109
110            egui::ComboBox::from_id_salt("history_window")
111                .selected_text(format_duration(self.visible_window()))
112                .show_ui(ui, |ui| {
113                    for (index, window) in GUI_WINDOWS.iter().enumerate() {
114                        ui.selectable_value(
115                            &mut self.window_index,
116                            index,
117                            format_duration(*window),
118                        );
119                    }
120                });
121        });
122
123        if let Some(error) = &self.last_error {
124            ui.colored_label(Color32::from_rgb(230, 80, 70), error);
125        }
126    }
127
128    fn draw_sidebar(&mut self, ui: &mut egui::Ui) {
129        ui.heading("GPUs");
130        ui.add_space(6.0);
131
132        for (index, device) in self.devices.iter().enumerate() {
133            let latest = self.histories.get(index).and_then(History::latest);
134            let title = format!("GPU {}  {}", device.id, device.name);
135            let summary = latest
136                .map(gpu_sidebar_summary)
137                .unwrap_or_else(|| "waiting for samples".to_owned());
138
139            let response = ui.selectable_label(self.selected_gpu == index, title);
140            ui.label(RichText::new(summary).small().color(Color32::GRAY));
141            ui.add_space(8.0);
142            if response.clicked() {
143                self.selected_gpu = index;
144            }
145        }
146    }
147
148    fn draw_main(&mut self, ui: &mut egui::Ui) {
149        let Some(device) = self.devices.get(self.selected_gpu) else {
150            ui.centered_and_justified(|ui| {
151                ui.label("No GPUs found");
152            });
153            return;
154        };
155        let Some(history) = self.selected_history() else {
156            ui.label("No history available");
157            return;
158        };
159
160        ui.horizontal_wrapped(|ui| {
161            ui.heading(format!("GPU {} {}", device.id, device.name));
162            if let Some(uuid) = &device.uuid {
163                ui.label(RichText::new(short_uuid(uuid)).color(Color32::GRAY));
164            }
165        });
166
167        if let Some(sample) = history.latest() {
168            draw_metric_strip(ui, sample);
169        }
170
171        ui.separator();
172        egui::ScrollArea::vertical()
173            .auto_shrink([false, false])
174            .show(ui, |ui| {
175                self.draw_metric_plots(ui, history);
176                ui.separator();
177                self.draw_process_table(ui, history.latest());
178            });
179    }
180
181    fn draw_metric_plots(&self, ui: &mut egui::Ui, history: &History) {
182        let columns = if ui.available_width() >= 980.0 { 2 } else { 1 };
183        ui.columns(columns, |cols| {
184            for (index, metric) in MetricKind::ALL.iter().copied().enumerate() {
185                let column = index % columns;
186                draw_metric_plot(
187                    &mut cols[column],
188                    history,
189                    metric,
190                    self.selected_gpu,
191                    self.visible_window(),
192                );
193            }
194        });
195    }
196
197    fn draw_process_table(&self, ui: &mut egui::Ui, sample: Option<&GpuSample>) {
198        ui.heading("Processes");
199        ui.label(
200            RichText::new("NVML compute, graphics, and MPS contexts; sorted by GPU memory.")
201                .small()
202                .color(Color32::GRAY),
203        );
204
205        let Some(sample) = sample else {
206            ui.label("Waiting for samples.");
207            return;
208        };
209
210        if sample.processes.is_empty() {
211            ui.label("No processes reported for this GPU.");
212            return;
213        }
214
215        egui::ScrollArea::vertical()
216            .max_height(260.0)
217            .auto_shrink([false, false])
218            .show(ui, |ui| {
219                egui::Grid::new("process_table")
220                    .striped(true)
221                    .num_columns(6)
222                    .spacing([16.0, 6.0])
223                    .show(ui, |ui| {
224                        table_header(ui, "TYPE");
225                        table_header(ui, "PID");
226                        table_header(ui, "USER");
227                        table_header(ui, "GPU MEM");
228                        table_header(ui, "MIG");
229                        table_header(ui, "COMMAND");
230                        ui.end_row();
231
232                        for process in &sample.processes {
233                            ui.colored_label(process_color(process), process.kind_label());
234                            ui.monospace(process.pid.to_string());
235                            ui.label(process.user.as_deref().unwrap_or("?"));
236                            ui.label(
237                                process
238                                    .used_gpu_memory_bytes
239                                    .map(format_bytes_compact)
240                                    .unwrap_or_else(|| "n/a".to_owned()),
241                            );
242                            ui.label(process_mig_label(process));
243                            ui.monospace(process.command.as_deref().unwrap_or("?"));
244                            ui.end_row();
245                        }
246                    });
247            });
248    }
249}
250
251impl Drop for GuiApp {
252    fn drop(&mut self) {
253        if let Some(sampler) = self.sampler.take() {
254            sampler.stop();
255        }
256    }
257}
258
259impl eframe::App for GuiApp {
260    fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
261        self.ingest();
262
263        egui::Panel::top("top_bar")
264            .resizable(false)
265            .show_inside(ui, |ui| {
266                ui.add_space(6.0);
267                self.draw_top_bar(ui);
268                ui.add_space(6.0);
269            });
270
271        egui::Panel::left("gpu_sidebar")
272            .resizable(true)
273            .default_size(260.0)
274            .size_range(220.0..=380.0)
275            .show_inside(ui, |ui| {
276                self.draw_sidebar(ui);
277            });
278
279        egui::CentralPanel::default().show_inside(ui, |ui| {
280            self.draw_main(ui);
281        });
282
283        if self.auto_follow {
284            ui.ctx().request_repaint_after(self.config.frame_interval);
285        }
286    }
287}
288
289pub fn run(app: GuiApp) -> Result<()> {
290    let native_options = eframe::NativeOptions {
291        viewport: egui::ViewportBuilder::default()
292            .with_title("gpu-histop")
293            .with_inner_size([1420.0, 920.0])
294            .with_min_inner_size([980.0, 660.0]),
295        ..Default::default()
296    };
297
298    eframe::run_native(
299        "gpu-histop",
300        native_options,
301        Box::new(move |_cc| Ok(Box::new(app))),
302    )?;
303    Ok(())
304}
305
306fn draw_metric_strip(ui: &mut egui::Ui, sample: &GpuSample) {
307    ui.horizontal_wrapped(|ui| {
308        metric_badge(
309            ui,
310            "GPU",
311            sample.gpu_util_percent,
312            "%",
313            metric_color(MetricKind::GpuUtil),
314        );
315        metric_badge(
316            ui,
317            "MEM",
318            sample.mem_util_percent,
319            "%",
320            metric_color(MetricKind::MemUtil),
321        );
322        ui.label(format!(
323            "VRAM {}",
324            vram_summary(sample.vram_used_bytes, sample.vram_total_bytes)
325        ));
326        metric_badge(
327            ui,
328            "PWR",
329            sample.power_watts,
330            "W",
331            metric_color(MetricKind::Power),
332        );
333        if let Some(limit) = sample.power_limit_watts {
334            ui.label(RichText::new(format!("/{limit:.0}W")).color(Color32::GRAY));
335        }
336        metric_badge(
337            ui,
338            "TEMP",
339            sample.temperature_celsius,
340            "C",
341            metric_color(MetricKind::Temperature),
342        );
343        metric_badge(
344            ui,
345            "FAN",
346            sample.fan_percent,
347            "%",
348            metric_color(MetricKind::Fan),
349        );
350        ui.label(format!(
351            "CLK {}/{} MHz",
352            whole_or_na(sample.graphics_clock_mhz),
353            whole_or_na(sample.memory_clock_mhz)
354        ));
355        ui.label(format!("PROC {}", sample.processes.len()));
356    });
357}
358
359fn metric_badge(ui: &mut egui::Ui, label: &str, value: Option<f64>, unit: &str, color: Color32) {
360    let value = value
361        .map(|value| format!("{value:.0}{unit}"))
362        .unwrap_or_else(|| "n/a".to_owned());
363    ui.label(
364        RichText::new(format!("{label} {value}"))
365            .color(color)
366            .strong(),
367    );
368}
369
370fn draw_metric_plot(
371    ui: &mut egui::Ui,
372    history: &History,
373    metric: MetricKind,
374    gpu_id: usize,
375    window: Duration,
376) {
377    let now = Instant::now();
378    let latest = history.latest();
379    let points = metric_points(history, metric, now, window);
380    let latest_value = latest.and_then(|sample| metric.value(sample));
381    let title = latest_value
382        .map(|value| format!("{}  {:.1}{}", metric.title(), value, metric.unit()))
383        .unwrap_or_else(|| format!("{}  n/a", metric.title()));
384    let color = metric_color(metric);
385    let (min_y, max_y) = metric_scale(history, metric, latest, now, window);
386
387    ui.group(|ui| {
388        ui.horizontal(|ui| {
389            ui.label(RichText::new(title).strong().color(color));
390            ui.label(
391                RichText::new(format_duration(window))
392                    .small()
393                    .color(Color32::GRAY),
394            );
395        });
396
397        Plot::new(format!("gpu_{gpu_id}_{metric:?}"))
398            .height(170.0)
399            .legend(Legend::default())
400            .show_grid(true)
401            .allow_drag(true)
402            .allow_zoom(true)
403            .include_x(-(window.as_secs_f64()))
404            .include_x(0.0)
405            .include_y(min_y)
406            .include_y(max_y)
407            .label_formatter(move |name, point| {
408                if name.is_empty() {
409                    format!("{:.2}s, {:.2}{}", point.x, point.y, metric.unit())
410                } else {
411                    format!("{name}\n{:.2}s, {:.2}{}", point.x, point.y, metric.unit())
412                }
413            })
414            .show(ui, |plot_ui| {
415                if !points.is_empty() {
416                    plot_ui.line(
417                        Line::new(metric.title(), points)
418                            .color(color)
419                            .stroke(Stroke::new(1.6, color)),
420                    );
421                }
422            });
423    });
424}
425
426fn metric_points(
427    history: &History,
428    metric: MetricKind,
429    now: Instant,
430    window: Duration,
431) -> PlotPoints<'static> {
432    history
433        .iter_window(now, window)
434        .filter_map(|sample| {
435            let value = metric.value(sample)?;
436            let age = if sample.at <= now {
437                now.duration_since(sample.at).as_secs_f64()
438            } else {
439                0.0
440            };
441            Some([-age, value])
442        })
443        .collect::<Vec<_>>()
444        .into()
445}
446
447fn metric_scale(
448    history: &History,
449    metric: MetricKind,
450    latest: Option<&GpuSample>,
451    now: Instant,
452    window: Duration,
453) -> (f64, f64) {
454    if let Some(range) = metric.fixed_range(latest) {
455        return range;
456    }
457
458    let mut max_seen: f64 = 0.0;
459    for sample in history.iter_window(now, window) {
460        if let Some(value) = metric.value(sample) {
461            max_seen = max_seen.max(value);
462        }
463    }
464    (0.0, nice_ceiling((max_seen * 1.15).max(1.0)))
465}
466
467fn table_header(ui: &mut egui::Ui, text: &str) {
468    ui.label(RichText::new(text).small().strong().color(Color32::GRAY));
469}
470
471fn gpu_sidebar_summary(sample: &GpuSample) -> String {
472    format!(
473        "GPU {} | VRAM {} | {} proc | PWR {} | TEMP {}",
474        percent_or_na(sample.gpu_util_percent),
475        sample
476            .vram_used_percent()
477            .map(|v| format!("{v:.0}%"))
478            .unwrap_or_else(|| "n/a".to_owned()),
479        sample.processes.len(),
480        sample
481            .power_watts
482            .map(|v| format!("{v:.0}W"))
483            .unwrap_or_else(|| "n/a".to_owned()),
484        sample
485            .temperature_celsius
486            .map(|v| format!("{v:.0}C"))
487            .unwrap_or_else(|| "n/a".to_owned())
488    )
489}
490
491fn metric_color(metric: MetricKind) -> Color32 {
492    match metric {
493        MetricKind::GpuUtil => Color32::from_rgb(65, 200, 230),
494        MetricKind::MemUtil => Color32::from_rgb(80, 210, 140),
495        MetricKind::VramUsed => Color32::from_rgb(230, 190, 75),
496        MetricKind::Power => Color32::from_rgb(210, 120, 230),
497        MetricKind::Temperature => Color32::from_rgb(235, 100, 85),
498        MetricKind::Fan => Color32::from_rgb(115, 150, 245),
499    }
500}
501
502fn process_color(process: &GpuProcess) -> Color32 {
503    if process.kinds.contains(&GpuProcessKind::Graphics) {
504        Color32::from_rgb(230, 190, 75)
505    } else if process.kinds.contains(&GpuProcessKind::Mps) {
506        Color32::from_rgb(210, 120, 230)
507    } else {
508        Color32::from_rgb(80, 210, 140)
509    }
510}
511
512fn process_mig_label(process: &GpuProcess) -> String {
513    match (process.gpu_instance_id, process.compute_instance_id) {
514        (Some(gpu), Some(compute)) => format!("gi={gpu}/ci={compute}"),
515        (Some(gpu), None) => format!("gi={gpu}"),
516        _ => String::new(),
517    }
518}
519
520fn vram_summary(used: Option<u64>, total: Option<u64>) -> String {
521    match (used, total) {
522        (Some(used), Some(total)) if total > 0 => format!(
523            "{:.1}/{:.1} GiB {:.0}%",
524            bytes_to_gib(used),
525            bytes_to_gib(total),
526            used as f64 * 100.0 / total as f64
527        ),
528        (Some(used), _) => format!("{:.1} GiB used", bytes_to_gib(used)),
529        _ => "n/a".to_owned(),
530    }
531}
532
533fn bytes_to_gib(bytes: u64) -> f64 {
534    bytes as f64 / 1024.0 / 1024.0 / 1024.0
535}
536
537fn format_bytes_compact(bytes: u64) -> String {
538    let gib = bytes_to_gib(bytes);
539    if gib >= 10.0 {
540        format!("{gib:.0} GiB")
541    } else if gib >= 1.0 {
542        format!("{gib:.1} GiB")
543    } else {
544        format!("{:.0} MiB", bytes as f64 / 1024.0 / 1024.0)
545    }
546}
547
548fn percent_or_na(value: Option<f64>) -> String {
549    value
550        .map(|value| format!("{value:.0}%"))
551        .unwrap_or_else(|| "n/a".to_owned())
552}
553
554fn whole_or_na(value: Option<f64>) -> String {
555    value
556        .map(|v| format!("{v:.0}"))
557        .unwrap_or_else(|| "n/a".to_owned())
558}
559
560fn short_uuid(uuid: &str) -> &str {
561    uuid.rsplit('-').next().unwrap_or(uuid)
562}
563
564fn hz(duration: Duration) -> f64 {
565    1.0 / duration.as_secs_f64().max(0.001)
566}
567
568fn format_duration(duration: Duration) -> String {
569    let seconds = duration.as_secs();
570    if seconds < 60 {
571        format!("{seconds}s")
572    } else if seconds < 3600 {
573        format!("{}m{:02}s", seconds / 60, seconds % 60)
574    } else {
575        format!("{}h{:02}m", seconds / 3600, (seconds % 3600) / 60)
576    }
577}
578
579fn nice_ceiling(value: f64) -> f64 {
580    if value <= 10.0 {
581        return 10.0;
582    }
583
584    let magnitude = 10_f64.powf(value.log10().floor());
585    let normalized = value / magnitude;
586    let rounded = if normalized <= 2.0 {
587        2.0
588    } else if normalized <= 5.0 {
589        5.0
590    } else {
591        10.0
592    };
593    rounded * magnitude
594}
595
596const GUI_WINDOWS: [Duration; 8] = [
597    Duration::from_secs(10),
598    Duration::from_secs(30),
599    Duration::from_secs(60),
600    Duration::from_secs(5 * 60),
601    Duration::from_secs(10 * 60),
602    Duration::from_secs(30 * 60),
603    Duration::from_secs(60 * 60),
604    Duration::from_secs(3 * 60 * 60),
605];
606
607fn closest_window(target: Duration) -> usize {
608    GUI_WINDOWS
609        .iter()
610        .enumerate()
611        .min_by_key(|(_, window)| window.abs_diff(target))
612        .map(|(index, _)| index)
613        .unwrap_or(2)
614}