Skip to content

Commit bc0ac76

Browse files
huacnleeclaudecodex
authored
fps: Cap the headline by asking the platform what the panel runs at (longbridge#2956)
## Summary Follow-up to longbridge#2954. `MAX FPS` was capped by a refresh rate inferred from the gaps between presents. That inference cannot work: those gaps are whole multiples of the panel's period, so they bound it **from below and never from above** — 41.7ms is six refreshes at 144Hz and one at 24Hz, and nothing in the timing distinguishes them. Four estimators, four wrong readings on real windows, the last three found in longbridge-lite: | Estimator | Read | Should have been | | --- | --- | --- | | Shortest gap ever seen | 169 | 144 — a compositor catch-up is not a refresh | | Densest group of gaps | 149 | 144 — bucketing truncates the distribution it measures | | Densest group of gaps | 75 | ~137 — the densest thing a demand-drawing window does is idle | | Fastest sustained run | **24** | ~116 — the application's own timer fired every 41.7ms | | Frame budget until established | **60** | ~167 — a ceiling under the truth hides the figure entirely | A steady 41.7ms application tick is indistinguishable from a 24Hz panel. No tuning gets past that. ## Changes **The guessing is gone, and the platform is asked instead.** GPUI hands out the platform's own display handle through `DisplayId`, and the HUD takes it from there: - **macOS** — `CGDisplayCopyDisplayMode` on the `CGDirectDisplayID` - **Windows** — `EnumDisplaySettingsW` on the monitor's device name - **Wayland** — object ids are per-connection and mean nothing across one, so the outputs are enumerated again and matched to GPUI's displays by the identity it derives from their names - **X11 and everything else** — no query, so no cap A panel that reports no fixed rate — which is what ProMotion honestly is through CoreGraphics — is read as no cap. Where nobody will say, the reading is left uncapped rather than held to a guess. **The answer is re-asked when the window moves to another display**, and not otherwise: it is a property of the panel, and on some platforms asking is a round trip. **The warm-up stays.** Frames from before the HUD was mounted, and the cold ones right after it, are dropped rather than measured, so a window that just opened still reads healthy. ## Verified On Wayland against two panels, with a temporary probe: ``` output 1492e027-… -> 143.998 Hz (compositor: 143.999) output 09e7b298-… -> 59.997 Hz (compositor: 59.997) window on the first -> 143.998 Hz ``` and the HUD on that window, `FRAME 4.3ms` — 232 uncapped — reading **MAX 144**, from the moment it opened and with no interaction. **Not verified on hardware:** the macOS and Windows queries could not be compiled here, let alone run. The Wayland path and everything above it is measured. ## Test plan - [ ] macOS: `MAX` stops at the panel's rate on an external display; a built-in ProMotion panel reads uncapped - [ ] Windows: `MAX` stops at the panel's rate - [ ] Drag the window between two monitors of different rates: `MAX` follows - [ ] A window with a regular timer (quote ticks, a clock) is not capped by its own cadence - [ ] A window that just opened still reads `DROP 0.0%` and a green `P95` ## AI Assistance > 🤖 Code and docs generated by Claude Code; the wrong readings were caught by the author on real windows. Verified locally on Linux/Wayland with 144Hz and 60Hz panels; `cargo test -p gpui-fps` (28) passes, clippy `--deny warnings` clean. The macOS and Windows queries are unverified — they cannot be built on this machine. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Codex <codex@openai.com>
1 parent b586fad commit bc0ac76

8 files changed

Lines changed: 307 additions & 315 deletions

File tree

‎Cargo.lock‎

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/fps/Cargo.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ sysinfo = "0.37"
2929
# counter the platform's own activity monitor attributes per process with, so no
3030
# vendor SDK or elevated privilege is involved.
3131
[target.'cfg(target_os = "macos")'.dependencies]
32+
core-graphics = "0.24"
3233
libc = "0.2"
3334
objc2-core-foundation = { version = "0.3", default-features = false, features = [
3435
"std",
@@ -42,8 +43,13 @@ objc2-io-kit = { version = "0.3", default-features = false, features = [
4243
"libc",
4344
] }
4445

46+
[target.'cfg(target_os = "linux")'.dependencies]
47+
uuid = { version = "1", features = ["v5"] }
48+
wayland-client = "0.31"
49+
4550
[target.'cfg(target_os = "windows")'.dependencies]
4651
windows = { workspace = true, features = [
52+
"Win32_Graphics_Gdi",
4753
"Win32_System_Performance",
4854
"Win32_System_ProcessStatus",
4955
"Win32_System_Threading",

‎crates/fps/src/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ mod gpu;
4040
mod memory;
4141
mod monitor;
4242
mod overlay;
43+
mod refresh;
4344
mod sampler;
4445
mod style;
4546

‎crates/fps/src/monitor.rs‎

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ use std::time::Duration;
33
use web_time::Instant;
44

55
use gpui::{
6-
Bounds, Context, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement,
7-
PathBuilder, Pixels, Point, Render, StatefulInteractiveElement as _, Styled, Window, canvas,
8-
div, point, prelude::FluentBuilder as _, px, relative,
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,
99
};
1010

1111
use gpui::Task;
1212

1313
use crate::{
1414
FrameTraceGuard,
15+
refresh::display_refresh_rate,
1516
sampler::{FrameSampler, ResourceSample, minimum_resource_interval},
1617
style::FpsStyle,
1718
};
@@ -121,9 +122,9 @@ struct Readout {
121122
/// resource row right underneath. The frame cost answers the same question
122123
/// without being paid for.
123124
///
124-
/// Held to the display's refresh rate once that is known. A frame drawn in
125-
/// 3ms is not 333 frames the reader could ever see, and printing it that
126-
/// way turns the headline back into a benchmark score rather than a rate.
125+
/// A ceiling the frame cost can prove, not one the display can show: a
126+
/// window whose frames cost 3ms could redraw 333 times a second, on a
127+
/// panel that would scan out sixty of them.
127128
max_fps: f32,
128129
/// Frames presented per second: the rate the window is actually drawing
129130
/// at, which an idle application drives to zero. The reciprocal of
@@ -142,24 +143,23 @@ struct Readout {
142143
}
143144

144145
/// The rate a full redraw could sustain: what a frame's cost implies, held to
145-
/// what the display can present.
146+
/// what the panel can scan out.
146147
///
147148
/// The cap is the half the derivation loses. Counting presents could never
148149
/// exceed the refresh rate — frames go to the compositor on vsync, so the
149-
/// bound came for free — and a figure derived from frame cost has no such
150-
/// ceiling: a frame drawn in 3ms reads as 333, a number nobody could ever
151-
/// see. `display` is `None` until the window has presented two frames a
152-
/// plausible refresh apart, and an uncapped reading is better than one capped
153-
/// by a guess.
154-
fn sustainable_rate(mean_draw: Duration, display: Option<f32>) -> f32 {
150+
/// bound came for free — while a frame drawn in 3ms reads as 333, a rate
151+
/// nobody could ever see. `display` is `None` where the platform would not say
152+
/// what the panel runs at, and an uncapped reading is better than one held to
153+
/// a guess: see [`crate::refresh`] for why guessing was tried and abandoned.
154+
fn sustainable_rate(mean_draw: Duration, display: Option<Duration>) -> f32 {
155155
let mean_draw = mean_draw.as_secs_f32();
156156
if mean_draw <= 0. {
157157
return 0.;
158158
}
159159
let rate = 1. / mean_draw;
160-
match display {
161-
Some(display) => rate.min(display),
162-
None => rate,
160+
match display.map(|period| period.as_secs_f32()) {
161+
Some(period) if period > 0. => rate.min(1. / period),
162+
_ => rate,
163163
}
164164
}
165165

@@ -184,6 +184,10 @@ pub struct FpsMonitor {
184184
style: FpsStyle,
185185
frame_budget: Duration,
186186
headline: Headline,
187+
/// The panel's refresh period, and which display it was asked about, so
188+
/// that moving the window to another monitor re-asks and staying on one
189+
/// does not ask again every frame.
190+
display: Option<(DisplayId, Option<Duration>)>,
187191
show_resources: bool,
188192
resource_interval: Duration,
189193
resources: Option<ResourceSample>,
@@ -204,6 +208,7 @@ impl FpsMonitor {
204208
style: FpsStyle::default(),
205209
frame_budget,
206210
headline: Headline::Max,
211+
display: None,
207212
show_resources: true,
208213
resource_interval: DEFAULT_RESOURCE_INTERVAL,
209214
resources: None,
@@ -336,6 +341,19 @@ impl FpsMonitor {
336341
}));
337342
}
338343

344+
/// Re-asks the platform for the refresh rate when the window has moved to
345+
/// another display, and not otherwise: the answer is a property of the
346+
/// panel, and on some platforms asking is a round trip.
347+
fn update_display(&mut self, window: &Window, cx: &App) {
348+
let Some(display) = window.display(cx) else {
349+
return;
350+
};
351+
let id = display.id();
352+
if self.display.map(|(asked, _)| asked) != Some(id) {
353+
self.display = Some((id, display_refresh_rate(display.as_ref())));
354+
}
355+
}
356+
339357
/// Republishes the readings if [`READOUT_INTERVAL`] has passed.
340358
fn update_readout(&mut self) {
341359
let now = Instant::now();
@@ -347,7 +365,10 @@ impl FpsMonitor {
347365
}
348366

349367
self.readout = Readout {
350-
max_fps: sustainable_rate(self.sampler.mean_draw(), self.sampler.peak_present_rate()),
368+
max_fps: sustainable_rate(
369+
self.sampler.mean_draw(),
370+
self.display.and_then(|(_, refresh_rate)| refresh_rate),
371+
),
351372
fps: self.sampler.fps(),
352373
interval_millis: self.sampler.present_interval().as_secs_f32() * 1000.,
353374
// The mean over the interval rather than the latest frame, which
@@ -497,8 +518,9 @@ impl FpsMonitor {
497518
}
498519

499520
impl Render for FpsMonitor {
500-
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
521+
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
501522
self.sampler.tick();
523+
self.update_display(window, cx);
502524
self.update_readout();
503525
self.update_axis();
504526
self.start_clock(cx);
@@ -739,19 +761,19 @@ mod tests {
739761
use super::*;
740762

741763
#[test]
742-
fn the_headline_rate_never_exceeds_what_the_display_can_present() {
764+
fn the_headline_rate_is_what_a_frame_costs_and_the_panel_allows() {
765+
let sixty = Duration::from_micros(16_667);
743766
// A cheap frame on a 60Hz panel is not 333 frames anyone could see.
767+
assert!((sustainable_rate(Duration::from_millis(3), Some(sixty)) - 60.).abs() < 0.01);
768+
// A frame that costs more than a refresh sets the rate itself.
744769
assert_eq!(
745-
sustainable_rate(Duration::from_millis(3), Some(60.)),
746-
60.,
747-
"a frame cheaper than a refresh is capped by the refresh"
770+
sustainable_rate(Duration::from_millis(20), Some(sixty)),
771+
50.
748772
);
749-
// Until the display has shown its cadence, capping would be a guess.
773+
// Where the platform will not say, an uncapped reading beats a guess.
750774
assert!((sustainable_rate(Duration::from_millis(3), None) - 333.33).abs() < 0.1);
751-
// A frame that costs more than a refresh sets the rate itself.
752-
assert_eq!(sustainable_rate(Duration::from_millis(20), Some(60.)), 50.);
753775
// No frames drawn yet is no rate, not an infinite one.
754-
assert_eq!(sustainable_rate(Duration::ZERO, Some(60.)), 0.);
776+
assert_eq!(sustainable_rate(Duration::ZERO, Some(sixty)), 0.);
755777
}
756778

757779
#[gpui::test]

0 commit comments

Comments
 (0)