gpui_fps/monitor.rs
1use std::time::Duration;
2
3use web_time::Instant;
4
5use gpui::{
6 App, Bounds, Context, DisplayId, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton,
7 ParentElement, PathBuilder, Pixels, Point, Render, StatefulInteractiveElement as _, Styled,
8 Window, canvas, div, point, prelude::FluentBuilder as _, px, relative,
9};
10
11use gpui::Task;
12
13use crate::{
14 FrameTraceGuard,
15 refresh::display_refresh_rate,
16 sampler::{FrameSampler, ResourceSample, minimum_resource_interval},
17 style::FpsStyle,
18};
19
20/// One frame at 60Hz, the default budget a frame is judged against.
21const DEFAULT_FRAME_BUDGET: Duration = Duration::from_nanos(16_666_667);
22const DEFAULT_CAPACITY: usize = 120;
23const DEFAULT_RESOURCE_INTERVAL: Duration = Duration::from_millis(500);
24
25/// How far back CPU, memory and GPU are averaged over. At the default interval
26/// that is six readings: long enough to settle the churn between one sample and
27/// the next, short enough that a real change reaches the HUD while the reader
28/// is still looking at what caused it.
29#[cfg(not(target_family = "wasm"))]
30const RESOURCE_WINDOW: Duration = Duration::from_secs(3);
31
32/// Which frame the `P95` row reports. The 95th rather than the 99th: the chart
33/// keeps 120 frames by default, so the 99th is the second slowest of them — one
34/// frame, which moves the row on its own and reads as noise.
35const FRAME_PERCENTILE: f32 = 0.95;
36
37/// How fast the chart's y axis relaxes back down after a spike. Growth is
38/// immediate so a slow frame is never clipped, while the decay is gradual so
39/// the bars don't visibly rescale every frame.
40const AXIS_DECAY: f32 = 0.04;
41
42/// A fixed width keeps every row flush with the chart and stops the HUD from
43/// resizing as the readings gain or lose digits. Collapsed, the HUD hugs its
44/// text instead and only the figure gets a fixed box.
45const HUD_WIDTH: Pixels = px(172.);
46const COMPACT_FIGURE_WIDTH: Pixels = px(25.);
47
48/// Size of every label and reading. Collapsed, the figure uses it too.
49const TEXT_SIZE: Pixels = px(10.);
50
51/// The trace sits behind the headline, so it is dimmed enough to stay out of
52/// the figure's way while still showing its shape and color.
53const TRACE_OPACITY: f32 = 0.35;
54
55/// Tall enough to give the trace room to show its shape around the figure.
56const HEADLINE_HEIGHT: Pixels = px(35.);
57
58/// The headline figure. Its box has to fit four digits at [`FIGURE_SIZE`] —
59/// a monospace digit runs about 0.6em, and an uncapped frame rate on a small
60/// window reaches four figures — or the reading is clipped instead of merely
61/// looking cramped.
62const FIGURE_SIZE: Pixels = px(28.);
63const FIGURE_WIDTH: Pixels = px(70.);
64
65/// Width of the `FPS` unit, and of the empty box mirroring it on the other side
66/// of the figure so the figure lands on the HUD's true center.
67const UNIT_WIDTH: Pixels = px(28.);
68
69/// Ticks of the clock the HUD may go unrendered before it is taken to be
70/// hidden and the clock stops. One tick is ambiguous — the frame the previous
71/// tick asked for may not have landed yet — so two: a second in which nothing
72/// drew the HUD.
73const HIDDEN_TICKS: u32 = 2;
74
75/// How often the numbers are recomputed.
76///
77/// The trace keeps up with every frame, but the readings do not: recomputed
78/// per frame they flicker through digits too fast to read, and the eye tracks
79/// the churn rather than the value. Twice a second is slow enough to read and
80/// fast enough to feel live.
81const READOUT_INTERVAL: Duration = Duration::from_millis(500);
82
83/// A monospace family that ships with the platform, so the value column stays
84/// aligned without the application having to configure a font. The generic
85/// `monospace` alias is not resolvable by every platform's font backend, hence
86/// the concrete names.
87#[cfg(target_os = "macos")]
88const DEFAULT_FONT: &str = "Menlo";
89#[cfg(target_os = "windows")]
90const DEFAULT_FONT: &str = "Consolas";
91// The iOS backend resolves this native family but not the generic monospace alias.
92#[cfg(target_os = "ios")]
93const DEFAULT_FONT: &str = ".SystemUIFont";
94#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "ios")))]
95const DEFAULT_FONT: &str = "monospace";
96
97/// A realtime performance HUD: frames per second, a rolling frame time chart,
98/// and this process' GPU, CPU and memory usage.
99///
100/// This is a view rather than a stateless component on purpose: driving
101/// redraws goes through [`Window::request_animation_frame`], which notifies the
102/// *current* view, and from inside a stateless component that would be whoever
103/// rendered the HUD — dirtying the host's own state to move a frame counter.
104///
105/// The HUD never asks for a frame of its own. A dirty view schedules a *window*
106/// draw and GPUI re-renders every view outside an [`Entity::cached`] boundary,
107/// so a HUD that drove the frame loop to keep its counter moving would be
108/// paying a full layout and paint per frame — and reporting that cost in the
109/// resource row as if it were the application's. The headline is derived from
110/// what a frame costs instead, which answers the same question for free and
111/// leaves the readings measuring the application alone.
112///
113/// The one frame the HUD does cause — the readout clock dirtying the window to
114/// move the digits — announces itself to the sampler and is left out of the
115/// readings, unless the application asked for that frame as well.
116///
117/// ```no_run
118/// # use gpui::*;
119/// # use gpui_fps::FpsMonitor;
120/// # fn example(window: &mut Window, cx: &mut App) {
121/// let monitor = cx.new(|cx| FpsMonitor::new(window, cx).capacity(240));
122/// # }
123/// ```
124/// The numbers as last published to the screen.
125#[derive(Clone, Copy, Default)]
126struct Readout {
127 /// The rate a full redraw of this window could sustain: the reciprocal of
128 /// `frame_millis`.
129 ///
130 /// Derived rather than counted, because counting it would mean causing it.
131 /// A frame rate measured from presents is only the rate the application
132 /// happens to be drawing at, and the only way to make that number mean
133 /// "as fast as this UI can go" is to keep the window drawing back to back
134 /// — which costs a full layout and paint per frame and lands in the
135 /// resource row right underneath. The frame cost answers the same question
136 /// without being paid for.
137 ///
138 /// A ceiling the frame cost can prove, not one the display can show: a
139 /// window whose frames cost 3ms could redraw 333 times a second, on a
140 /// panel that would scan out sixty of them.
141 max_fps: f32,
142 /// Frames presented per second: the rate the window is actually drawing
143 /// at, which an idle application drives to zero. The reciprocal of
144 /// `interval_millis`.
145 fps: f32,
146 /// Mean time between presents, in milliseconds: the platform overlay's
147 /// "frame interval".
148 interval_millis: f32,
149 /// Mean `Window::draw` cost of the retained frames, in milliseconds.
150 frame_millis: f32,
151 /// The slow tail of the same frames `frame_millis` is the mean of.
152 percentile_millis: f32,
153 dropped_percent: f32,
154 /// Mean invalidations coalesced into one frame; one means none were wasted.
155 invalidations: f32,
156}
157
158/// The rate a full redraw could sustain: what a frame's cost implies, held to
159/// what the panel can scan out.
160///
161/// The cap is the half the derivation loses. Counting presents could never
162/// exceed the refresh rate — frames go to the compositor on vsync, so the
163/// bound came for free — while a frame drawn in 3ms reads as 333, a rate
164/// nobody could ever see. `display` is `None` where the platform would not say
165/// what the panel runs at, and an uncapped reading is better than one held to
166/// a guess: see [`crate::refresh`] for why guessing was tried and abandoned.
167fn sustainable_rate(mean_draw: Duration, display: Option<Duration>) -> f32 {
168 let mean_draw = mean_draw.as_secs_f32();
169 if mean_draw <= 0. {
170 return 0.;
171 }
172 let rate = 1. / mean_draw;
173 match display.map(|period| period.as_secs_f32()) {
174 Some(period) if period > 0. => rate.min(1. / period),
175 _ => rate,
176 }
177}
178
179/// Which question the headline answers.
180///
181/// Both readings come out of the same samples, so switching is free — which is
182/// the whole point. The rate a UI can hold and the rate it is holding are
183/// different questions, and the only expensive way to answer the first is to
184/// stop the second from being answerable.
185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186enum Headline {
187 /// The rate a full redraw could sustain, from what one costs.
188 Max,
189 /// The rate the window is drawing at.
190 Observed,
191}
192
193pub struct FpsMonitor {
194 sampler: FrameSampler,
195 readout: Readout,
196 readout_at: Option<Instant>,
197 style: FpsStyle,
198 frame_budget: Duration,
199 /// Whether the caller chose the budget, in which case the display's
200 /// refresh period does not replace it.
201 budget_explicit: bool,
202 headline: Headline,
203 /// The panel's refresh period, and which display it was asked about, so
204 /// that moving the window to another monitor re-asks and staying on one
205 /// does not ask again every frame.
206 display: Option<(DisplayId, Option<Duration>)>,
207 show_resources: bool,
208 resource_interval: Duration,
209 resources: Option<ResourceSample>,
210 compact: bool,
211 /// Upper bound of the chart's y axis, in seconds.
212 axis_max: f32,
213 /// Renders so far, which is how the clock tells a shown HUD from a hidden
214 /// one: a shown HUD is rendered at least once per tick, by the frame the
215 /// tick itself asks for.
216 frames_rendered: u64,
217 /// Running while the HUD is shown; `None` before the first render and
218 /// again once the HUD has gone unrendered for [`HIDDEN_TICKS`].
219 clock: Option<Task<()>>,
220 /// Held while the HUD is shown, so a hidden HUD does not keep GPUI
221 /// recording every frame into the trace buffer.
222 frame_trace: Option<FrameTraceGuard>,
223}
224
225/// Tells a hidden HUD from a shown one by whether anything has rendered it
226/// since the clock last looked.
227struct RenderWatch {
228 seen: u64,
229 still_ticks: u32,
230}
231
232impl RenderWatch {
233 fn watching(frames_rendered: u64) -> Self {
234 Self {
235 seen: frames_rendered,
236 still_ticks: 0,
237 }
238 }
239
240 /// `true` once `frames_rendered` has stood still for [`HIDDEN_TICKS`].
241 fn tick(&mut self, frames_rendered: u64) -> bool {
242 if self.seen == frames_rendered {
243 self.still_ticks += 1;
244 } else {
245 self.seen = frames_rendered;
246 self.still_ticks = 0;
247 }
248 self.still_ticks >= HIDDEN_TICKS
249 }
250}
251
252impl FpsMonitor {
253 pub fn new(window: &Window, _cx: &mut Context<Self>) -> Self {
254 let frame_budget = DEFAULT_FRAME_BUDGET;
255 Self {
256 sampler: FrameSampler::new(window.window_handle().window_id(), DEFAULT_CAPACITY),
257 readout: Readout::default(),
258 readout_at: None,
259 style: FpsStyle::default(),
260 frame_budget,
261 budget_explicit: false,
262 headline: Headline::Max,
263 display: None,
264 show_resources: true,
265 resource_interval: DEFAULT_RESOURCE_INTERVAL,
266 resources: None,
267 compact: false,
268 axis_max: frame_budget.as_secs_f32() * 2.,
269 frames_rendered: 0,
270 clock: None,
271 frame_trace: Some(FrameTraceGuard::acquire()),
272 }
273 }
274
275 /// Counts a render, which is what keeps the clock running, and brings a HUD
276 /// back from [`Self::sleep`]: tracing on again, and the sampler started
277 /// over — turning the trace off cleared its buffer and the counter the
278 /// collector's cursor is measured against, so the old cursor would skip
279 /// every frame until that counter caught up.
280 fn wake(&mut self) {
281 self.frames_rendered = self.frames_rendered.wrapping_add(1);
282 if self.frame_trace.is_none() {
283 self.frame_trace = Some(FrameTraceGuard::acquire());
284 self.sampler.reset();
285 }
286 }
287
288 /// What a hidden HUD does: nothing. The clock ends, the frame trace is let
289 /// go of unless something else holds it, and the resource readings are
290 /// dropped rather than shown stale when the HUD next appears.
291 fn sleep(&mut self) {
292 self.clock = None;
293 self.frame_trace = None;
294 self.resources = None;
295 }
296
297 /// How many frames the chart keeps. Defaults to 120.
298 pub fn capacity(mut self, capacity: usize) -> Self {
299 self.sampler.set_capacity(capacity);
300 self
301 }
302
303 /// The per-frame budget `FRAME`, `P95` and `DROP` are graded against, and
304 /// the chart's baseline.
305 ///
306 /// Left alone, the budget is one refresh of the display the window is on,
307 /// or one 60Hz frame where the platform will not say what the panel runs
308 /// at. Set it to hold a budget regardless of the panel.
309 pub fn frame_budget(mut self, budget: Duration) -> Self {
310 self.set_frame_budget(budget);
311 self
312 }
313
314 /// Pins the budget: the display's refresh period no longer replaces it.
315 pub(crate) fn set_frame_budget(&mut self, budget: Duration) {
316 self.budget_explicit = true;
317 self.apply_budget(budget);
318 }
319
320 /// Only a changed budget moves the axis. The overlay repeats its budget on
321 /// every render, and resetting the axis each time would undo the decay
322 /// [`Self::update_axis`] relies on.
323 fn apply_budget(&mut self, budget: Duration) {
324 if self.frame_budget == budget {
325 return;
326 }
327 self.frame_budget = budget;
328 self.axis_max = budget.as_secs_f32() * 2.;
329 }
330
331 /// Takes the panel's refresh period as the budget, unless the caller chose
332 /// one. A frame that misses a refresh is what the graded rows are about,
333 /// and the period is what a refresh is: a 60Hz default on a 120Hz panel
334 /// grades a frame that missed its slot as healthy.
335 fn adopt_display_budget(&mut self, refresh_rate: Option<Duration>) {
336 if self.budget_explicit {
337 return;
338 }
339 self.apply_budget(refresh_rate.unwrap_or(DEFAULT_FRAME_BUDGET));
340 }
341
342 /// Whether to sample and show CPU, memory and GPU usage. Defaults to
343 /// `true`, and is always off on the web.
344 ///
345 /// The GPU reading is left out on its own where the platform publishes no
346 /// counter for it, so turning this on does not guarantee three readings.
347 pub fn show_resources(mut self, show_resources: bool) -> Self {
348 self.show_resources = show_resources;
349 self
350 }
351
352 /// How often CPU, memory and GPU are resampled. Defaults to 500ms, and is
353 /// clamped up to the shortest interval that yields a meaningful CPU delta.
354 pub fn resource_interval(mut self, interval: Duration) -> Self {
355 self.resource_interval = interval;
356 self
357 }
358
359 /// The clock that republishes the readings, and stops itself once the HUD
360 /// has gone unrendered for [`HIDDEN_TICKS`]. Started on the first render so
361 /// that the builder methods have already been applied by the time its
362 /// interval is read.
363 ///
364 /// Nothing else wakes the HUD. It does not drive the frame loop, and a
365 /// window that has stopped drawing produces no renders to refresh it from,
366 /// so without this the figures would freeze at whatever the application
367 /// last drew — exactly when a frozen `137` is most likely to be read as
368 /// the truth.
369 #[cfg(not(target_family = "wasm"))]
370 fn start_clock(&mut self, cx: &mut Context<Self>) {
371 use crate::sampler::ResourceProbe;
372
373 if self.clock.is_some() {
374 return;
375 }
376
377 let show_resources = self.show_resources;
378 let interval = if show_resources {
379 self.resource_interval.max(minimum_resource_interval())
380 } else {
381 READOUT_INTERVAL
382 };
383 let frames_rendered = self.frames_rendered;
384 self.clock = Some(cx.spawn(async move |this, cx| {
385 let executor = cx.background_executor().clone();
386 // Probing walks the process table, so it never runs on the render
387 // thread. The probe moves in and out of each background task rather
388 // than living behind a lock. A platform that cannot provide one
389 // still gets the clock; it just has no resource row to fill.
390 let mut probe = if show_resources {
391 executor
392 .spawn(async { ResourceProbe::new(RESOURCE_WINDOW) })
393 .await
394 } else {
395 None
396 };
397
398 let mut watch = RenderWatch::watching(frames_rendered);
399 loop {
400 executor.timer(interval).await;
401
402 let sample = match probe.take() {
403 Some(mut owned) => {
404 let (returned, sample) = executor
405 .spawn(async move {
406 let sample = owned.sample();
407 (owned, sample)
408 })
409 .await;
410 probe = Some(returned);
411 sample
412 }
413 None => None,
414 };
415
416 let shown = this.update(cx, |this, cx| {
417 if watch.tick(this.frames_rendered) {
418 this.sleep();
419 return false;
420 }
421 if sample.is_some() {
422 this.resources = sample;
423 }
424 // The frame this notify buys is the HUD's, not the
425 // application's; say so before it is drawn.
426 this.sampler.expect_own_frame(Instant::now());
427 cx.notify();
428 true
429 });
430 if !matches!(shown, Ok(true)) {
431 break;
432 }
433 }
434 }));
435 }
436
437 #[cfg(target_family = "wasm")]
438 fn start_clock(&mut self, cx: &mut Context<Self>) {
439 let _ = minimum_resource_interval();
440
441 if self.clock.is_some() {
442 return;
443 }
444 let frames_rendered = self.frames_rendered;
445 self.clock = Some(cx.spawn(async move |this, cx| {
446 let executor = cx.background_executor().clone();
447 let mut watch = RenderWatch::watching(frames_rendered);
448 loop {
449 executor.timer(READOUT_INTERVAL).await;
450 let shown = this.update(cx, |this, cx| {
451 if watch.tick(this.frames_rendered) {
452 this.sleep();
453 return false;
454 }
455 this.sampler.expect_own_frame(Instant::now());
456 cx.notify();
457 true
458 });
459 if !matches!(shown, Ok(true)) {
460 break;
461 }
462 }
463 }));
464 }
465
466 /// Re-asks the platform for the refresh rate when the window has moved to
467 /// another display, and not otherwise: the answer is a property of the
468 /// panel, and on some platforms asking is a round trip.
469 fn update_display(&mut self, window: &Window, cx: &App) {
470 let Some(display) = window.display(cx) else {
471 return;
472 };
473 let id = display.id();
474 if self.display.map(|(asked, _)| asked) != Some(id) {
475 let refresh_rate = display_refresh_rate(display.as_ref());
476 self.display = Some((id, refresh_rate));
477 self.adopt_display_budget(refresh_rate);
478 }
479 }
480
481 /// Republishes the readings if [`READOUT_INTERVAL`] has passed.
482 fn update_readout(&mut self) {
483 let now = Instant::now();
484 let due = self
485 .readout_at
486 .is_none_or(|at| now.duration_since(at) >= READOUT_INTERVAL);
487 if !due {
488 return;
489 }
490
491 self.readout = Readout {
492 max_fps: sustainable_rate(
493 self.sampler.mean_draw(),
494 self.display.and_then(|(_, refresh_rate)| refresh_rate),
495 ),
496 fps: self.sampler.fps(),
497 interval_millis: self.sampler.present_interval().as_secs_f32() * 1000.,
498 // The mean over the interval rather than the latest frame, which
499 // at this cadence would be an arbitrary sample.
500 frame_millis: self.sampler.mean_draw().as_secs_f32() * 1000.,
501 percentile_millis: self.sampler.percentile_draw(FRAME_PERCENTILE).as_secs_f32() * 1000.,
502 dropped_percent: self.sampler.over_budget_ratio(self.frame_budget) * 100.,
503 invalidations: self.sampler.mean_invalidations(),
504 };
505 self.readout_at = Some(now);
506 }
507
508 /// Grows immediately to fit the slowest retained frame and decays back
509 /// slowly, so a single spike doesn't make the whole chart jump.
510 fn update_axis(&mut self) {
511 let floor = self.frame_budget.as_secs_f32() * 2.;
512 let target = self.sampler.peak_draw().as_secs_f32().max(floor);
513 self.axis_max = if target > self.axis_max {
514 target
515 } else {
516 self.axis_max + (target - self.axis_max) * AXIS_DECAY
517 };
518 }
519
520 /// The frame time trace, drawn behind the readings so it fills the HUD
521 /// instead of taking a band of its own. It is dimmed to stay legible under
522 /// the text.
523 fn render_chart(&self) -> impl IntoElement {
524 let style = self.style;
525 let budget = self.frame_budget.as_secs_f32();
526 let axis_max = self.axis_max.max(f32::EPSILON);
527 let capacity = self.sampler.capacity();
528 let samples: Vec<(f32, Hsla)> = self
529 .sampler
530 .samples()
531 .map(|sample| {
532 let seconds = sample.draw.as_secs_f32();
533 (
534 (seconds / axis_max).clamp(0., 1.),
535 style.level_color(seconds, budget).opacity(TRACE_OPACITY),
536 )
537 })
538 .collect();
539
540 canvas(
541 |_, _, _| (),
542 move |bounds: Bounds<Pixels>, _, window, _| {
543 let slot = bounds.size.width / capacity as f32;
544 // Fewer samples than the capacity means the chart is still
545 // filling up; keep the newest frame pinned to the right edge so
546 // the history scrolls instead of stretching.
547 let leading = capacity.saturating_sub(samples.len());
548 let points: Vec<(Point<Pixels>, Hsla)> = samples
549 .iter()
550 .enumerate()
551 .map(|(index, (ratio, color))| {
552 (
553 point(
554 bounds.origin.x + slot * (leading + index) as f32 + slot / 2.,
555 bounds.origin.y + bounds.size.height * (1. - *ratio),
556 ),
557 *color,
558 )
559 })
560 .collect();
561
562 // The line is drawn as runs of equal color rather than one
563 // segment per frame: a single path can only carry one color,
564 // and in the common case where nothing is dropped the whole
565 // chart collapses into one path.
566 let mut start = 0;
567 while start + 1 < points.len() {
568 // A segment is as slow as the frame it ends on, so the
569 // color of the later point decides the run.
570 let color = points[start + 1].1;
571 let mut path = PathBuilder::stroke(px(1.));
572 path.move_to(points[start].0);
573
574 let mut end = start + 1;
575 while end < points.len() && points[end].1 == color {
576 path.line_to(points[end].0);
577 end += 1;
578 }
579
580 if let Ok(path) = path.build() {
581 window.paint_path(path, color);
582 }
583 // Share the boundary point with the next run so the line
584 // stays connected across a color change.
585 start = end - 1;
586 }
587 },
588 )
589 .absolute()
590 .inset_0()
591 }
592
593 /// The headline reading, with the frame time trace painted behind it.
594 ///
595 /// The trace lives in this row rather than spanning the whole HUD because
596 /// this is its emptiest part — the figure is centered and short, leaving
597 /// both flanks open — so the trace stays readable instead of being cut up
598 /// by the denser rows below.
599 ///
600 /// The figure is centered in a fixed box so neither the unit nor the group
601 /// shifts as the count gains or loses a digit; the two share a bottom edge.
602 fn render_headline(&self, rate: f32, color: Hsla) -> Div {
603 let style = self.style;
604
605 div()
606 .relative()
607 .overflow_hidden()
608 .w_full()
609 .h(HEADLINE_HEIGHT)
610 .child(self.render_chart())
611 .child(
612 div()
613 .flex()
614 .size_full()
615 .items_end()
616 .justify_center()
617 .gap_1()
618 // The box that balances the unit on the right. Without it
619 // the unit's own width pushes the figure off center by half
620 // of it, which reads as misalignment — so the mode marker
621 // goes here, where it costs no layout and lands where it
622 // is read: immediately before the figure it qualifies.
623 .child(
624 div()
625 .w(UNIT_WIDTH)
626 .text_right()
627 .text_color(style.muted)
628 .when(self.headline == Headline::Max, |this| this.child("MAX")),
629 )
630 .child(
631 div()
632 .w(FIGURE_WIDTH)
633 .text_center()
634 .text_size(FIGURE_SIZE)
635 .line_height(relative(1.))
636 .text_color(color)
637 .child(format!("{rate:.0}")),
638 )
639 .child(div().w(UNIT_WIDTH).text_color(style.muted).child("FPS")),
640 )
641 }
642}
643
644impl Render for FpsMonitor {
645 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
646 self.wake();
647 self.sampler.tick();
648 self.update_display(window, cx);
649 self.update_readout();
650 self.update_axis();
651 self.start_clock(cx);
652
653 let style = self.style;
654 let budget = self.frame_budget;
655 let Readout {
656 max_fps,
657 fps,
658 interval_millis,
659 frame_millis,
660 percentile_millis,
661 dropped_percent: dropped,
662 invalidations,
663 } = self.readout;
664 // Printed plain, never graded. It is the reciprocal of `FRAME`, which
665 // is graded already, and grading the same measurement twice in two
666 // units would just say the same thing louder.
667 let fps_color = style.foreground;
668 let resources = self.resources.filter(|_| self.show_resources);
669 let compact = self.compact;
670 let headline = self.headline;
671 let rate = match headline {
672 Headline::Max => max_fps,
673 Headline::Observed => fps,
674 };
675
676 div()
677 .id("gpui-fps-hud")
678 .flex()
679 .bg(style.background)
680 .font_family(DEFAULT_FONT)
681 .text_size(TEXT_SIZE)
682 .text_color(style.muted)
683 .on_click(cx.listener(|this, _, _, cx| {
684 this.compact = !this.compact;
685 cx.notify();
686 }))
687 // The `MAX` marker is what says which of the two the figure is.
688 .on_mouse_down(
689 MouseButton::Right,
690 cx.listener(|this, _, _, cx| {
691 this.headline = match this.headline {
692 Headline::Max => Headline::Observed,
693 Headline::Observed => Headline::Max,
694 };
695 cx.stop_propagation();
696 cx.notify();
697 }),
698 )
699 .map(|this| {
700 if compact {
701 // Collapsed, the HUD is one small tag: the figure drops to
702 // the same size as its unit, the box shrinks to the text,
703 // and everything else is dropped, so it sits over the
704 // interface without competing with it.
705 this.items_center()
706 .gap_1()
707 .px_1p5()
708 .py_0p5()
709 .rounded(px(3.))
710 .when(headline == Headline::Max, |this| this.child("MAX"))
711 .child(
712 div()
713 .w(COMPACT_FIGURE_WIDTH)
714 .text_right()
715 .text_color(fps_color)
716 .child(format!("{rate:.0}")),
717 )
718 .child("FPS")
719 } else {
720 this.flex_col()
721 .w(HUD_WIDTH)
722 .px_2()
723 .py_1p5()
724 .rounded(px(4.))
725 .child(self.render_headline(rate, fps_color))
726 .child(reading(
727 // The same figure the platform overlay calls its
728 // frame interval: time between presents. Where the
729 // headline says how fast this UI could go, this
730 // says how often it actually went — a wide gap
731 // between them is an idle window, not a slow one.
732 "INTERVAL",
733 format!("{interval_millis:.1} ms"),
734 style.foreground,
735 style,
736 ))
737 .child(reading(
738 "FRAME",
739 format!("{frame_millis:.1} ms"),
740 // Graded against the budget, and the first reading
741 // in the HUD that is: the rate above says how often
742 // frames happened, this says whether they were
743 // affordable. It is the one to read when something
744 // feels slow.
745 style.level_color(frame_millis / 1000., budget.as_secs_f32()),
746 style,
747 ))
748 .child(reading(
749 // Graded the same way, so the two millisecond rows
750 // read as one measurement seen twice: what a frame
751 // usually costs, and what its slow tail costs.
752 "P95",
753 format!("{percentile_millis:.1} ms"),
754 style.level_color(percentile_millis / 1000., budget.as_secs_f32()),
755 style,
756 ))
757 .child(
758 // Dropped frames and wasted invalidations share a
759 // row: both count redundant work rather than
760 // measuring a duration, so neither belongs in the
761 // millisecond column above.
762 row()
763 .child(pair(
764 "DROP",
765 format!("{dropped:.1}%"),
766 style.level_color(if dropped > 0. { 1. } else { 0. }, 0.5),
767 style,
768 ))
769 .child(pair(
770 "INV",
771 format!("{invalidations:.1}"),
772 // Ungraded. One per frame is the ideal,
773 // but it is not the floor here: the
774 // baseline depends on how the application
775 // drives its own redraws — an animation
776 // asking for a frame per tick and a data
777 // stream invalidating on every message both
778 // read above one legitimately — which is
779 // not something the HUD can grade. So the
780 // number is reported and the reading is
781 // left to whoever knows what to expect.
782 style.foreground,
783 style,
784 )),
785 )
786 .when_some(
787 resources.and_then(|resources| resources.gpu_percent),
788 |this, gpu| {
789 this.child(reading(
790 "GPU",
791 format!("{gpu:.1}%"),
792 style.foreground,
793 style,
794 ))
795 },
796 )
797 .when_some(resources, |this, resources| {
798 this.child(
799 // CPU and memory share a row: both are coarse
800 // background samples, unlike the per-frame
801 // numbers.
802 row()
803 .child(pair(
804 "CPU",
805 format_cpu(resources.cpu_percent),
806 style.foreground,
807 style,
808 ))
809 .child(pair(
810 "MEM",
811 format_bytes(resources.memory_bytes),
812 style.foreground,
813 style,
814 )),
815 )
816 })
817 }
818 })
819 }
820}
821
822/// A row carrying two [`pair`]s, pushed to either inner edge.
823fn row() -> Div {
824 div().flex().w_full().justify_between().gap_2().py(px(1.))
825}
826
827/// A `LABEL value` pair kept together, for rows that carry more than one
828/// reading. The label stays muted so it reads as a caption, not as data.
829fn pair(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
830 div()
831 .flex()
832 .gap_1()
833 .child(div().text_color(style.muted).child(label))
834 .child(div().text_color(value_color).child(value))
835}
836
837/// One `LABEL … value` row. The value is right aligned against the HUD's inner
838/// edge, so in a monospace font every row's digits line up in a column and
839/// nothing shifts as the readings change width.
840fn reading(label: &'static str, value: String, value_color: Hsla, style: FpsStyle) -> Div {
841 div()
842 .flex()
843 .w_full()
844 .justify_between()
845 .gap_2()
846 .py(px(1.))
847 .child(div().text_color(style.muted).child(label))
848 .child(div().text_color(value_color).child(value))
849}
850
851/// A CPU reading on the single core scale, which passes 100 as soon as the
852/// process spreads over more than one core and reaches the core count times a
853/// hundred when it saturates the machine.
854///
855/// A tenth is worth showing while the reading is small, where it is the
856/// difference between idle and a busy timer; past ten the extra digit only
857/// churns, and dropping it also keeps the reading inside the row's share of the
858/// HUD on a machine with enough cores to reach four figures.
859fn format_cpu(percent: f32) -> String {
860 if percent < 10. {
861 format!("{percent:.1}%")
862 } else {
863 format!("{percent:.0}%")
864 }
865}
866
867fn format_bytes(bytes: u64) -> String {
868 const MIB: f64 = 1024. * 1024.;
869 const GIB: f64 = MIB * 1024.;
870
871 let bytes = bytes as f64;
872 if bytes >= GIB {
873 format!("{:.2} GB", bytes / GIB)
874 } else {
875 format!("{:.0} MB", bytes / MIB)
876 }
877}
878
879#[cfg(test)]
880mod tests {
881 use gpui::{AppContext as _, TestAppContext};
882
883 use super::*;
884
885 #[test]
886 fn the_headline_rate_is_what_a_frame_costs_and_the_panel_allows() {
887 let sixty = Duration::from_micros(16_667);
888 // A cheap frame on a 60Hz panel is not 333 frames anyone could see.
889 assert!((sustainable_rate(Duration::from_millis(3), Some(sixty)) - 60.).abs() < 0.01);
890 // A frame that costs more than a refresh sets the rate itself.
891 assert_eq!(
892 sustainable_rate(Duration::from_millis(20), Some(sixty)),
893 50.
894 );
895 // Where the platform will not say, an uncapped reading beats a guess.
896 assert!((sustainable_rate(Duration::from_millis(3), None) - 333.33).abs() < 0.1);
897 // No frames drawn yet is no rate, not an infinite one.
898 assert_eq!(sustainable_rate(Duration::ZERO, Some(sixty)), 0.);
899 }
900
901 #[gpui::test]
902 fn test_fps_monitor_builder(cx: &mut TestAppContext) {
903 let cx = cx.add_empty_window();
904 cx.update(|window, cx| {
905 let budget = Duration::from_micros(6_944);
906 let monitor = cx.new(|cx| {
907 FpsMonitor::new(window, cx)
908 .capacity(240)
909 .frame_budget(budget)
910 .show_resources(false)
911 .resource_interval(Duration::from_secs(2))
912 });
913
914 let monitor = monitor.read(cx);
915 assert_eq!(monitor.sampler.capacity(), 240);
916 assert_eq!(monitor.frame_budget, budget);
917 assert!(!monitor.show_resources);
918 assert_eq!(monitor.resource_interval, Duration::from_secs(2));
919 // The axis floor tracks the budget so a 144Hz budget doesn't leave
920 // the chart scaled for 60Hz frames.
921 assert_eq!(monitor.axis_max, budget.as_secs_f32() * 2.);
922 });
923 }
924
925 #[gpui::test]
926 fn the_budget_follows_the_display_unless_the_caller_chose_one(cx: &mut TestAppContext) {
927 let cx = cx.add_empty_window();
928 cx.update(|window, cx| {
929 let pro_motion = Duration::from_micros(8_333);
930
931 let monitor = cx.new(|cx| FpsMonitor::new(window, cx));
932 monitor.update(cx, |monitor, _| {
933 monitor.adopt_display_budget(Some(pro_motion));
934 assert_eq!(monitor.frame_budget, pro_motion);
935 assert_eq!(monitor.axis_max, pro_motion.as_secs_f32() * 2.);
936
937 // A panel nobody will describe falls back to the 60Hz default.
938 monitor.adopt_display_budget(None);
939 assert_eq!(monitor.frame_budget, DEFAULT_FRAME_BUDGET);
940 });
941
942 let pinned = Duration::from_micros(6_944);
943 let monitor = cx.new(|cx| FpsMonitor::new(window, cx).frame_budget(pinned));
944 monitor.update(cx, |monitor, _| {
945 monitor.adopt_display_budget(Some(pro_motion));
946 assert_eq!(monitor.frame_budget, pinned, "a chosen budget is kept");
947 });
948 });
949 }
950
951 #[gpui::test]
952 fn repeating_the_budget_leaves_the_axis_where_it_grew(cx: &mut TestAppContext) {
953 let cx = cx.add_empty_window();
954 cx.update(|window, cx| {
955 let budget = Duration::from_micros(8_333);
956 let monitor = cx.new(|cx| FpsMonitor::new(window, cx).frame_budget(budget));
957 monitor.update(cx, |monitor, _| {
958 // The axis grew to fit a slow frame, and the overlay repeating
959 // its budget on the next render must not snap it back down.
960 monitor.axis_max = 0.05;
961 monitor.set_frame_budget(budget);
962 assert_eq!(monitor.axis_max, 0.05);
963
964 // A different budget is a new floor.
965 let other = Duration::from_micros(6_944);
966 monitor.set_frame_budget(other);
967 assert_eq!(monitor.axis_max, other.as_secs_f32() * 2.);
968 });
969 });
970 }
971
972 #[test]
973 fn the_watch_calls_the_hud_hidden_after_two_still_ticks() {
974 let mut watch = RenderWatch::watching(7);
975 assert!(!watch.tick(8), "rendered since the clock started: shown");
976 assert!(
977 !watch.tick(8),
978 "one still tick could be a frame not yet landed"
979 );
980 assert!(
981 watch.tick(8),
982 "two is a second with nothing drawing the HUD"
983 );
984
985 // A render between ticks starts the count over.
986 let mut watch = RenderWatch::watching(7);
987 assert!(!watch.tick(7));
988 assert!(!watch.tick(9));
989 assert!(!watch.tick(9));
990 assert!(watch.tick(9));
991 }
992
993 #[gpui::test]
994 fn a_hidden_hud_lets_go_of_the_trace_and_takes_it_back_when_shown(cx: &mut TestAppContext) {
995 let cx = cx.add_empty_window();
996 cx.update(|window, cx| {
997 let monitor = cx.new(|cx| FpsMonitor::new(window, cx));
998 monitor.update(cx, |monitor, _| {
999 assert!(monitor.frame_trace.is_some());
1000
1001 monitor.sleep();
1002 assert!(monitor.frame_trace.is_none());
1003 assert!(monitor.clock.is_none());
1004 assert!(monitor.resources.is_none());
1005
1006 let rendered = monitor.frames_rendered;
1007 monitor.wake();
1008 assert!(monitor.frame_trace.is_some());
1009 assert_eq!(monitor.frames_rendered, rendered + 1);
1010 });
1011 });
1012 }
1013
1014 #[test]
1015 fn formats_memory_by_magnitude() {
1016 assert_eq!(format_bytes(184 * 1024 * 1024), "184 MB");
1017 assert_eq!(format_bytes(3 * 1024 * 1024 * 1024), "3.00 GB");
1018 }
1019
1020 /// The reading is on the single core scale, so it passes 100 and keeps
1021 /// going — the row must show that rather than round it away or clip it.
1022 #[test]
1023 fn formats_cpu_on_the_single_core_scale() {
1024 // A process spread over a core and a half, which under a scale where
1025 // 100 is the whole machine would have read 5.8% on a 24 core desktop.
1026 assert_eq!(format_cpu(140.), "140%");
1027 // Saturating every core of a big machine still has somewhere to go.
1028 assert_eq!(format_cpu(2400.), "2400%");
1029 // Small readings keep the tenth that distinguishes them.
1030 assert_eq!(format_cpu(0.4), "0.4%");
1031 assert_eq!(format_cpu(9.9), "9.9%");
1032 assert_eq!(format_cpu(12.4), "12%");
1033 }
1034}