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