Skip to main content

loam_app/trace/
overlay.rs

1use std::time::Duration;
2
3use loam_time::frame_trace;
4
5pub struct PerfOverlay {
6    visible: bool,
7    toggle_key: loam_egui::egui::Key,
8    window: usize,
9}
10
11impl Default for PerfOverlay {
12    fn default() -> Self {
13        Self {
14            visible: false,
15            toggle_key: loam_egui::egui::Key::F3,
16            window: 60,
17        }
18    }
19}
20
21const OVERLAY_WIDTH: f32 = 260.0;
22
23const OVERLAY_MARGIN: f32 = 12.0;
24
25// `content_rect` ignores panels and would seat the readout behind the menu bar.
26fn perf_overlay_seat(ctx: &loam_egui::egui::Context) -> loam_egui::egui::Pos2 {
27    let area = ctx.available_rect();
28    loam_egui::egui::pos2(
29        (area.right() - OVERLAY_MARGIN - OVERLAY_WIDTH).max(area.left()),
30        area.top() + OVERLAY_MARGIN,
31    )
32}
33
34impl PerfOverlay {
35    pub fn new() -> Self {
36        Self::default()
37    }
38
39    pub fn with_toggle_key(mut self, key: loam_egui::egui::Key) -> Self {
40        self.toggle_key = key;
41        self
42    }
43
44    pub fn always_visible(mut self) -> Self {
45        self.visible = true;
46        self
47    }
48
49    pub fn show(&mut self, ctx: &loam_egui::egui::Context) {
50        use loam_egui::egui;
51
52        if ctx.input(|i| i.key_pressed(self.toggle_key)) {
53            self.visible = !self.visible;
54        }
55        if !self.visible {
56            return;
57        }
58
59        let window = self.window.min(MAX_WINDOW);
60        let mut cadence = StackBuf::new();
61        let mut frames_buf = StackBuf::new();
62        let mut idles = StackBuf::new();
63        let mut heap_count = 0usize;
64        let mut heap_peak = 0i64;
65        let mut heap_net = 0i64;
66        let mut alloc_frames = 0usize;
67        let mut alloc_count_sum: u64 = 0;
68        let mut alloc_peak_bytes: u64 = 0;
69        let mut alloc_net_bytes: i64 = 0;
70        let mut any = false;
71
72        frame_trace::with_history(|history| {
73            let start = history.len().saturating_sub(window);
74            for frame in history.iter().skip(start) {
75                any = true;
76                for section in &frame.sections {
77                    match section.name {
78                        "between-frames" => cadence.push(section.elapsed),
79                        "frame" => frames_buf.push(section.elapsed),
80                        "idle" => idles.push(section.elapsed),
81                        _ => {}
82                    }
83                }
84                if let Some(d) = frame.heap_delta_bytes {
85                    heap_count += 1;
86                    if d > heap_peak {
87                        heap_peak = d;
88                    }
89                    heap_net = heap_net.saturating_add(d);
90                }
91                if let Some(a) = frame.allocs {
92                    alloc_frames += 1;
93                    alloc_count_sum = alloc_count_sum.saturating_add(a.alloc_count);
94                    if a.alloc_bytes > alloc_peak_bytes {
95                        alloc_peak_bytes = a.alloc_bytes;
96                    }
97                    alloc_net_bytes = alloc_net_bytes.saturating_add(a.net_bytes);
98                }
99            }
100        });
101
102        if !any {
103            return;
104        }
105
106        let cadence_mean = cadence.mean();
107        let cadence_p99 = cadence.percentile(99);
108        let frame_mean = frames_buf.mean();
109        let frame_p99 = frames_buf.percentile(99);
110        let idle_mean = idles.mean();
111        let idle_p99 = idles.percentile(99);
112
113        let cadence_max_ever = frame_trace::max_ever("between-frames");
114        let idle_max_ever = frame_trace::max_ever("idle");
115        let frame_max_ever = frame_trace::max_ever("frame");
116
117        let fps = if cadence_mean.as_secs_f32() > 0.0 {
118            1.0 / cadence_mean.as_secs_f32()
119        } else {
120            0.0
121        };
122
123        // `anchor` ignores drag; `default_pos` lets egui persist the dragged offset.
124        egui::Area::new(egui::Id::new("loam-perf-overlay"))
125            .default_pos(perf_overlay_seat(ctx))
126            .movable(true)
127            .order(egui::Order::Foreground)
128            .show(ctx, |ui| {
129                egui::Frame::popup(ui.style())
130                    .stroke(egui::Stroke::new(1.0, egui::Color32::from_rgb(60, 60, 75)))
131                    .show(ui, |ui| {
132                        ui.set_min_width(OVERLAY_WIDTH);
133                        let mono = egui::FontId::monospace(11.0);
134                        let label_color = egui::Color32::from_rgb(180, 190, 200);
135                        ui.label(
136                            egui::RichText::new(format!("FPS    {fps:5.1}"))
137                                .font(mono.clone())
138                                .color(egui::Color32::from_rgb(220, 230, 240)),
139                        );
140                        ui.label(
141                            egui::RichText::new(format!(
142                                "total  {:>5.1}  p99 {:>5.1}  ms",
143                                cadence_mean.as_secs_f32() * 1000.0,
144                                cadence_p99.as_secs_f32() * 1000.0,
145                            ))
146                            .font(mono.clone())
147                            .color(label_color),
148                        );
149                        ui.label(
150                            egui::RichText::new(format!(
151                                "idle   {:>5.1}  p99 {:>5.1}  ms",
152                                idle_mean.as_secs_f32() * 1000.0,
153                                idle_p99.as_secs_f32() * 1000.0,
154                            ))
155                            .font(mono.clone())
156                            .color(label_color),
157                        );
158                        ui.label(
159                            egui::RichText::new(format!(
160                                "frame  {:>5.2}  p99 {:>5.2}  ms",
161                                frame_mean.as_secs_f32() * 1000.0,
162                                frame_p99.as_secs_f32() * 1000.0,
163                            ))
164                            .font(mono.clone())
165                            .color(label_color),
166                        );
167                        ui.separator();
168                        ui.label(
169                            egui::RichText::new("worst-ever (session)")
170                                .font(mono.clone())
171                                .color(egui::Color32::from_rgb(140, 150, 160)),
172                        );
173                        let worst_color = |d: Duration| {
174                            let ms = d.as_secs_f32() * 1000.0;
175                            if ms >= 100.0 {
176                                egui::Color32::from_rgb(220, 100, 80)
177                            } else if ms >= 50.0 {
178                                egui::Color32::from_rgb(220, 180, 90)
179                            } else {
180                                label_color
181                            }
182                        };
183                        ui.label(
184                            egui::RichText::new(format!(
185                                "total  {:>6.1} ms",
186                                cadence_max_ever.as_secs_f32() * 1000.0,
187                            ))
188                            .font(mono.clone())
189                            .color(worst_color(cadence_max_ever)),
190                        );
191                        ui.label(
192                            egui::RichText::new(format!(
193                                "idle   {:>6.1} ms",
194                                idle_max_ever.as_secs_f32() * 1000.0,
195                            ))
196                            .font(mono.clone())
197                            .color(worst_color(idle_max_ever)),
198                        );
199                        ui.label(
200                            egui::RichText::new(format!(
201                                "frame  {:>6.1} ms",
202                                frame_max_ever.as_secs_f32() * 1000.0,
203                            ))
204                            .font(mono.clone())
205                            .color(worst_color(frame_max_ever)),
206                        );
207                        if alloc_frames > 0 {
208                            ui.separator();
209                            ui.label(
210                                egui::RichText::new("allocs (Rust heap)")
211                                    .font(mono.clone())
212                                    .color(egui::Color32::from_rgb(140, 150, 160)),
213                            );
214                            let mean_count = alloc_count_sum / alloc_frames as u64;
215                            let count_color = |n: u64| {
216                                if n >= 1_000 {
217                                    egui::Color32::from_rgb(220, 100, 80)
218                                } else if n >= 100 {
219                                    egui::Color32::from_rgb(220, 180, 90)
220                                } else if n >= 10 {
221                                    egui::Color32::from_rgb(180, 200, 130)
222                                } else {
223                                    egui::Color32::from_rgb(120, 200, 130)
224                                }
225                            };
226                            let byte_color = |bytes: i64| {
227                                let mb = bytes.abs() as f32 / (1024.0 * 1024.0);
228                                if mb >= 10.0 {
229                                    egui::Color32::from_rgb(220, 100, 80)
230                                } else if mb >= 1.0 {
231                                    egui::Color32::from_rgb(220, 180, 90)
232                                } else {
233                                    label_color
234                                }
235                            };
236                            ui.label(
237                                egui::RichText::new(format!("mean  {mean_count:>6} allocs/frame",))
238                                    .font(mono.clone())
239                                    .color(count_color(mean_count)),
240                            );
241                            ui.label(
242                                egui::RichText::new(format!(
243                                    "peak  {:>+6.2} KB/frame",
244                                    alloc_peak_bytes as f32 / 1024.0,
245                                ))
246                                .font(mono.clone())
247                                .color(byte_color(alloc_peak_bytes as i64)),
248                            );
249                            ui.label(
250                                egui::RichText::new(format!(
251                                    "net   {:>+6.2} MB / window",
252                                    alloc_net_bytes as f32 / (1024.0 * 1024.0),
253                                ))
254                                .font(mono.clone())
255                                .color(byte_color(alloc_net_bytes)),
256                            );
257                        }
258                        if heap_count > 0 {
259                            ui.separator();
260                            ui.label(
261                                egui::RichText::new("heap (Chromium)")
262                                    .font(mono.clone())
263                                    .color(egui::Color32::from_rgb(140, 150, 160)),
264                            );
265                            let heap_color = |bytes: i64| {
266                                let mb = bytes.abs() as f32 / (1024.0 * 1024.0);
267                                if mb >= 10.0 {
268                                    egui::Color32::from_rgb(220, 100, 80)
269                                } else if mb >= 2.0 {
270                                    egui::Color32::from_rgb(220, 180, 90)
271                                } else {
272                                    label_color
273                                }
274                            };
275                            ui.label(
276                                egui::RichText::new(format!(
277                                    "peak  {:>+6.2} MB/frame",
278                                    heap_peak as f32 / (1024.0 * 1024.0),
279                                ))
280                                .font(mono.clone())
281                                .color(heap_color(heap_peak)),
282                            );
283                            ui.label(
284                                egui::RichText::new(format!(
285                                    "net   {:>+6.2} MB / window",
286                                    heap_net as f32 / (1024.0 * 1024.0),
287                                ))
288                                .font(mono.clone())
289                                .color(heap_color(heap_net)),
290                            );
291                        }
292                        ui.separator();
293                        draw_sparkline(ui, cadence.as_slice());
294                    });
295            });
296    }
297}
298
299pub const MAX_WINDOW: usize = 256;
300
301#[derive(Clone)]
302struct StackBuf {
303    samples: [Duration; MAX_WINDOW],
304    len: usize,
305}
306
307impl StackBuf {
308    fn new() -> Self {
309        Self {
310            samples: [Duration::ZERO; MAX_WINDOW],
311            len: 0,
312        }
313    }
314
315    fn push(&mut self, d: Duration) {
316        if self.len < MAX_WINDOW {
317            self.samples[self.len] = d;
318            self.len += 1;
319        }
320    }
321
322    fn as_slice(&self) -> &[Duration] {
323        &self.samples[..self.len]
324    }
325
326    fn mean(&self) -> Duration {
327        if self.len == 0 {
328            return Duration::ZERO;
329        }
330        let sum: Duration = self.samples[..self.len].iter().sum();
331        sum / self.len as u32
332    }
333
334    // Sorts a copy: the sparkline reads `self` in time order.
335    fn percentile(&self, percent: u8) -> Duration {
336        if self.len == 0 {
337            return Duration::ZERO;
338        }
339        let mut local: [Duration; MAX_WINDOW] = self.samples;
340        local[..self.len].sort_unstable();
341        frame_trace::percentile(&local[..self.len], percent)
342    }
343}
344
345fn draw_sparkline(ui: &mut loam_egui::egui::Ui, gaps: &[Duration]) {
346    use loam_egui::egui;
347    let (rect, _) = ui.allocate_exact_size(egui::vec2(240.0, 36.0), egui::Sense::hover());
348    if gaps.is_empty() {
349        return;
350    }
351    let painter = ui.painter();
352    painter.rect_filled(rect, 2.0, egui::Color32::from_rgb(18, 18, 24));
353
354    // Outliers clamp to the top so one spike does not squash the baseline.
355    let y_max_ms = 50.0_f32;
356    let y_for_ms = |ms: f32| {
357        let clamped = ms.min(y_max_ms);
358        rect.bottom() - (clamped / y_max_ms) * rect.height()
359    };
360    let ref_60 = y_for_ms(16.67);
361    let ref_30 = y_for_ms(33.33);
362    painter.line_segment(
363        [
364            egui::pos2(rect.left(), ref_60),
365            egui::pos2(rect.right(), ref_60),
366        ],
367        egui::Stroke::new(0.5, egui::Color32::from_rgb(60, 100, 70)),
368    );
369    painter.line_segment(
370        [
371            egui::pos2(rect.left(), ref_30),
372            egui::pos2(rect.right(), ref_30),
373        ],
374        egui::Stroke::new(0.5, egui::Color32::from_rgb(120, 90, 60)),
375    );
376
377    let n = gaps.len() as f32;
378    let dx = rect.width() / n.max(1.0);
379    let bar_w = dx.max(1.0);
380    for (i, gap) in gaps.iter().enumerate() {
381        let x = rect.left() + i as f32 * dx;
382        let ms = gap.as_secs_f32() * 1000.0;
383        let y = y_for_ms(ms);
384        let color = if ms > 33.3 {
385            egui::Color32::from_rgb(220, 100, 80)
386        } else if ms > 20.0 {
387            egui::Color32::from_rgb(220, 180, 90)
388        } else {
389            egui::Color32::from_rgb(120, 200, 130)
390        };
391        painter.line_segment(
392            [egui::pos2(x, rect.bottom()), egui::pos2(x, y)],
393            egui::Stroke::new(bar_w, color),
394        );
395    }
396}
397
398#[cfg(test)]
399mod tests {
400    use super::*;
401
402    #[test]
403    fn stackbuf_push_silently_drops_past_max_window() {
404        let mut buf = StackBuf::new();
405        for i in 0..(MAX_WINDOW + 10) {
406            buf.push(Duration::from_nanos(i as u64));
407        }
408        assert_eq!(
409            buf.as_slice().len(),
410            MAX_WINDOW,
411            "push beyond cap must not allocate; samples drop"
412        );
413    }
414
415    #[test]
416    fn stackbuf_percentile_picks_nearest_rank() {
417        let mut buf = StackBuf::new();
418        for ms in [10, 4, 7, 1, 8, 2, 9, 6, 3, 5] {
419            buf.push(Duration::from_millis(ms));
420        }
421        assert_eq!(buf.percentile(50), Duration::from_millis(5));
422        assert_eq!(buf.percentile(95), Duration::from_millis(10));
423        assert_eq!(buf.percentile(99), Duration::from_millis(10));
424    }
425}
426
427#[cfg(test)]
428mod seat_tests {
429    use super::*;
430    use loam_egui::egui;
431    const BAR_HEIGHT: f32 = 64.0;
432
433    const PANEL_WIDTH: f32 = 120.0;
434    const VIEWPORT: egui::Vec2 = egui::vec2(1280.0, 800.0);
435    const NARROW_VIEWPORT: egui::Vec2 = egui::vec2(200.0, 400.0);
436    fn viewport(size: egui::Vec2) -> egui::RawInput {
437        egui::RawInput {
438            screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, size)),
439            ..Default::default()
440        }
441    }
442
443    #[test]
444    fn perf_overlay_boots_into_the_top_right_of_the_panel_band() {
445        frame_trace::begin_frame();
446        frame_trace::end_frame();
447
448        let ctx = egui::Context::default();
449        let mut overlay = PerfOverlay::new().always_visible();
450        let mut bar_bottom = 0.0;
451        let mut band = egui::Rect::NOTHING;
452        let _ = ctx.run(viewport(VIEWPORT), |ctx| {
453            bar_bottom = egui::TopBottomPanel::top("shell-menu-bar")
454                .exact_height(BAR_HEIGHT)
455                .show(ctx, |ui| {
456                    ui.label("bar");
457                })
458                .response
459                .rect
460                .bottom();
461            band = ctx.available_rect();
462            overlay.show(ctx);
463        });
464        let rect = ctx
465            .memory(|m| m.area_rect(egui::Id::new("loam-perf-overlay")))
466            .expect("overlay area is registered once shown");
467        assert!(
468            rect.top() >= bar_bottom,
469            "overlay top {} must clear the menu bar bottom {bar_bottom}",
470            rect.top(),
471        );
472        assert!(
473            rect.center().x > band.center().x,
474            "overlay center x {} must sit in the band's right half of {band:?}",
475            rect.center().x,
476        );
477        assert!(
478            rect.center().y < band.center().y,
479            "overlay center y {} must sit in the band's top half of {band:?}",
480            rect.center().y,
481        );
482    }
483
484    #[test]
485    fn perf_overlay_seat_clamps_to_the_band_left_when_the_band_is_narrower_than_the_readout() {
486        let ctx = egui::Context::default();
487        let mut seat = None;
488        let mut band = egui::Rect::NOTHING;
489        let _ = ctx.run(viewport(NARROW_VIEWPORT), |ctx| {
490            egui::SidePanel::left("left")
491                .exact_width(PANEL_WIDTH)
492                .show(ctx, |ui| {
493                    ui.label("left");
494                });
495            band = ctx.available_rect();
496            seat = Some(perf_overlay_seat(ctx));
497        });
498        let seat = seat.expect("run closure sets the seat");
499        assert!(
500            band.width() < OVERLAY_MARGIN + OVERLAY_WIDTH && band.left() > 0.0,
501            "fixture must leave a band {band:?} too narrow for the readout and \
502             offset from the viewport origin, or the clamp goes untested",
503        );
504        assert_eq!(
505            seat.x,
506            band.left(),
507            "a band too narrow for the readout must seat it at the band's left \
508             edge, not at the viewport origin or off-screen",
509        );
510    }
511}