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
25fn 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 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 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 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}