Skip to content

Commit 78de8a7

Browse files
huacnleeclaude
andcommitted
fps: Cap the headline by asking the platform what the panel runs at
Nothing about a window's own frames can establish its display's refresh rate. The gaps between presents 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 every estimate tried read a real window wrong: 169 and 149 from the shortest and the densest gaps, 75 from a window drawing every other refresh, and 24 from an application whose own timer fired every 41.7ms. So the platform is asked. GPUI hands out its display handle through `DisplayId`: a `CGDirectDisplayID` on macOS, an `HMONITOR` on Windows. Wayland gives out per-connection object ids that mean nothing to a second connection, so the outputs are enumerated again there and matched to GPUI's displays by the identity it derives from their names. X11 and everything else have no query and stay uncapped, as does a panel that reports no fixed rate — which is what a ProMotion display honestly is. 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. Measured on Wayland against two panels: 143.998Hz and 59.997Hz discovered, against 143.999 and 59.997 from the compositor, and a window whose frames cost 4.3ms — 232 uncapped — reading MAX 144 on the faster one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri
1 parent cecb4e7 commit 78de8a7

7 files changed

Lines changed: 315 additions & 55 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: 54 additions & 21 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
};
@@ -141,23 +142,25 @@ struct Readout {
141142
invalidations: f32,
142143
}
143144

144-
/// The rate a full redraw could sustain: what a frame's cost implies.
145+
/// The rate a full redraw could sustain: what a frame's cost implies, held to
146+
/// what the panel can scan out.
145147
///
146-
/// Not held to the display's refresh rate, which GPUI does not expose and
147-
/// which cannot be recovered from the frames this window happened to present.
148-
/// Gaps between presents are whole multiples of the panel's period, so they
149-
/// put a *lower* bound on it and never an upper one: 41.7ms is six refreshes
150-
/// at 144Hz and one at 24Hz, and nothing in the timing says which. Every
151-
/// estimate tried here read a real window wrong — 169 and 149 from the
152-
/// shortest and the densest gaps, 75 from a window drawing every other
153-
/// refresh, 24 from an application whose own timer fired every 41.7ms — and a
154-
/// ceiling under the truth hides the figure the reader came for.
155-
fn sustainable_rate(mean_draw: Duration) -> f32 {
148+
/// The cap is the half the derivation loses. Counting presents could never
149+
/// exceed the refresh rate — frames go to the compositor on vsync, so the
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 {
156155
let mean_draw = mean_draw.as_secs_f32();
157156
if mean_draw <= 0. {
158157
return 0.;
159158
}
160-
1. / mean_draw
159+
let rate = 1. / mean_draw;
160+
match display.map(|period| period.as_secs_f32()) {
161+
Some(period) if period > 0. => rate.min(1. / period),
162+
_ => rate,
163+
}
161164
}
162165

163166
/// Which question the headline answers.
@@ -181,6 +184,10 @@ pub struct FpsMonitor {
181184
style: FpsStyle,
182185
frame_budget: Duration,
183186
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>)>,
184191
show_resources: bool,
185192
resource_interval: Duration,
186193
resources: Option<ResourceSample>,
@@ -201,6 +208,7 @@ impl FpsMonitor {
201208
style: FpsStyle::default(),
202209
frame_budget,
203210
headline: Headline::Max,
211+
display: None,
204212
show_resources: true,
205213
resource_interval: DEFAULT_RESOURCE_INTERVAL,
206214
resources: None,
@@ -333,6 +341,19 @@ impl FpsMonitor {
333341
}));
334342
}
335343

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+
336357
/// Republishes the readings if [`READOUT_INTERVAL`] has passed.
337358
fn update_readout(&mut self) {
338359
let now = Instant::now();
@@ -344,7 +365,10 @@ impl FpsMonitor {
344365
}
345366

346367
self.readout = Readout {
347-
max_fps: sustainable_rate(self.sampler.mean_draw()),
368+
max_fps: sustainable_rate(
369+
self.sampler.mean_draw(),
370+
self.display.and_then(|(_, refresh_rate)| refresh_rate),
371+
),
348372
fps: self.sampler.fps(),
349373
interval_millis: self.sampler.present_interval().as_secs_f32() * 1000.,
350374
// The mean over the interval rather than the latest frame, which
@@ -494,8 +518,9 @@ impl FpsMonitor {
494518
}
495519

496520
impl Render for FpsMonitor {
497-
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 {
498522
self.sampler.tick();
523+
self.update_display(window, cx);
499524
self.update_readout();
500525
self.update_axis();
501526
self.start_clock(cx);
@@ -736,11 +761,19 @@ mod tests {
736761
use super::*;
737762

738763
#[test]
739-
fn the_headline_rate_is_what_a_frame_costs() {
740-
assert!((sustainable_rate(Duration::from_millis(3)) - 333.33).abs() < 0.1);
741-
assert_eq!(sustainable_rate(Duration::from_millis(20)), 50.);
764+
fn the_headline_rate_is_what_a_frame_costs_and_the_panel_allows() {
765+
let sixty = Duration::from_micros(16_667);
766+
// 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.
769+
assert_eq!(
770+
sustainable_rate(Duration::from_millis(20), Some(sixty)),
771+
50.
772+
);
773+
// Where the platform will not say, an uncapped reading beats a guess.
774+
assert!((sustainable_rate(Duration::from_millis(3), None) - 333.33).abs() < 0.1);
742775
// No frames drawn yet is no rate, not an infinite one.
743-
assert_eq!(sustainable_rate(Duration::ZERO), 0.);
776+
assert_eq!(sustainable_rate(Duration::ZERO, Some(sixty)), 0.);
744777
}
745778

746779
#[gpui::test]

crates/fps/src/refresh.rs

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
//! The refresh rate of the display a window is on, read from the platform.
2+
//!
3+
//! GPUI does not report it, and it cannot be recovered from the frames a
4+
//! window presented: those gaps are whole multiples of the panel's period, so
5+
//! they bound it from below and never from above — 41.7ms is six refreshes at
6+
//! 144Hz and one at 24Hz, and nothing in the timing says which. Every estimate
7+
//! tried before this read a real window wrong.
8+
//!
9+
//! So it is asked for. GPUI does hand out the platform's own display handle
10+
//! through [`gpui::DisplayId`], which is a `CGDirectDisplayID` on macOS and an
11+
//! `HMONITOR` on Windows, and on Wayland the outputs can be enumerated again
12+
//! and matched by the identity GPUI derives from their names.
13+
//!
14+
//! `None` means nobody could say — a platform without a query here, a virtual
15+
//! display, or a panel with no fixed rate — and the caller shows an uncapped
16+
//! reading rather than one held to a guess.
17+
18+
use std::time::Duration;
19+
20+
use gpui::PlatformDisplay;
21+
22+
/// The period between refreshes of `display`, when the platform reports one.
23+
pub(crate) fn display_refresh_rate(display: &dyn PlatformDisplay) -> Option<Duration> {
24+
platform::refresh_rate(display)
25+
}
26+
27+
/// Turns a rate in hertz into the period the rest of the crate works in,
28+
/// rejecting the zeroes platforms use to mean "no fixed rate".
29+
#[cfg(any(target_os = "macos", target_os = "windows"))]
30+
fn period_from_hertz(hertz: f64) -> Option<Duration> {
31+
(hertz > 1.).then(|| Duration::from_secs_f64(1. / hertz))
32+
}
33+
34+
#[cfg(target_os = "macos")]
35+
mod platform {
36+
use super::*;
37+
use core_graphics::display::{CGDirectDisplayID, CGDisplay};
38+
39+
pub(super) fn refresh_rate(display: &dyn PlatformDisplay) -> Option<Duration> {
40+
let id: u64 = display.id().into();
41+
// Zero rather than an error is how CoreGraphics says this display has
42+
// no fixed rate, which is what a built-in panel reports: on ProMotion
43+
// there genuinely is not one, and the nominal period would have to come
44+
// from CoreVideo instead.
45+
let mode = CGDisplay::new(id as CGDirectDisplayID).display_mode()?;
46+
period_from_hertz(mode.refresh_rate())
47+
}
48+
}
49+
50+
#[cfg(target_os = "windows")]
51+
mod platform {
52+
use super::*;
53+
use windows::{
54+
Win32::{Foundation::*, Graphics::Gdi::*},
55+
core::*,
56+
};
57+
58+
pub(super) fn refresh_rate(display: &dyn PlatformDisplay) -> Option<Duration> {
59+
let id: u64 = display.id().into();
60+
let monitor = HMONITOR(id as _);
61+
62+
let mut info = MONITORINFOEXW {
63+
monitorInfo: MONITORINFO {
64+
cbSize: std::mem::size_of::<MONITORINFOEXW>() as u32,
65+
..Default::default()
66+
},
67+
..Default::default()
68+
};
69+
if !unsafe { GetMonitorInfoW(monitor, &mut info as *mut _ as *mut MONITORINFO) }.as_bool() {
70+
return None;
71+
}
72+
73+
let mut mode = DEVMODEW {
74+
dmSize: std::mem::size_of::<DEVMODEW>() as u16,
75+
..Default::default()
76+
};
77+
let device = PCWSTR(info.szDevice.as_ptr());
78+
if !unsafe { EnumDisplaySettingsW(device, ENUM_CURRENT_SETTINGS, &mut mode) }.as_bool() {
79+
return None;
80+
}
81+
// Zero and one both mean "whatever the hardware defaults to" rather
82+
// than a rate, which is what a driver reports when it has none to give.
83+
period_from_hertz(mode.dmDisplayFrequency as f64)
84+
}
85+
}
86+
87+
#[cfg(target_os = "linux")]
88+
mod platform {
89+
use super::*;
90+
use std::{collections::HashMap, sync::OnceLock};
91+
use uuid::Uuid;
92+
use wayland_client::{
93+
Connection, Dispatch, Proxy as _, QueueHandle, WEnum,
94+
protocol::{wl_output, wl_registry},
95+
};
96+
97+
/// Wayland hands each client its own object ids, so the id GPUI reports for
98+
/// an output means nothing on a second connection. What both sides can
99+
/// agree on is the output's name, which GPUI folds into the display's uuid
100+
/// — so the outputs are enumerated again and matched by that.
101+
pub(super) fn refresh_rate(display: &dyn PlatformDisplay) -> Option<Duration> {
102+
let uuid = display.uuid().ok()?;
103+
outputs().get(&uuid).copied()
104+
}
105+
106+
/// Asked once. Outputs change when a monitor is plugged in or its mode is
107+
/// changed, and neither happens in the middle of reading a frame counter.
108+
fn outputs() -> &'static HashMap<Uuid, Duration> {
109+
static OUTPUTS: OnceLock<HashMap<Uuid, Duration>> = OnceLock::new();
110+
OUTPUTS.get_or_init(|| query_outputs().unwrap_or_default())
111+
}
112+
113+
fn query_outputs() -> Option<HashMap<Uuid, Duration>> {
114+
let connection = Connection::connect_to_env().ok()?;
115+
let mut queue = connection.new_event_queue();
116+
let handle = queue.handle();
117+
let _registry = connection.display().get_registry(&handle, ());
118+
119+
let mut state = State::default();
120+
// Once for the globals, once for the events the outputs send back.
121+
queue.roundtrip(&mut state).ok()?;
122+
queue.roundtrip(&mut state).ok()?;
123+
Some(state.rates)
124+
}
125+
126+
#[derive(Default)]
127+
struct State {
128+
/// Name and current mode, per output object, until its `Done`.
129+
pending: HashMap<u32, (Option<String>, Option<Duration>)>,
130+
rates: HashMap<Uuid, Duration>,
131+
}
132+
133+
impl Dispatch<wl_registry::WlRegistry, ()> for State {
134+
fn event(
135+
_: &mut Self,
136+
registry: &wl_registry::WlRegistry,
137+
event: wl_registry::Event,
138+
_: &(),
139+
_: &Connection,
140+
handle: &QueueHandle<Self>,
141+
) {
142+
if let wl_registry::Event::Global {
143+
name,
144+
interface,
145+
version,
146+
} = event
147+
&& interface == wl_output::WlOutput::interface().name
148+
{
149+
// Version 4 is where an output started naming itself, which is
150+
// the only thing this connection and GPUI's can match on.
151+
if version >= 4 {
152+
registry.bind::<wl_output::WlOutput, _, _>(name, 4, handle, ());
153+
}
154+
}
155+
}
156+
}
157+
158+
impl Dispatch<wl_output::WlOutput, ()> for State {
159+
fn event(
160+
state: &mut Self,
161+
output: &wl_output::WlOutput,
162+
event: wl_output::Event,
163+
_: &(),
164+
_: &Connection,
165+
_: &QueueHandle<Self>,
166+
) {
167+
let id = output.id().protocol_id();
168+
match event {
169+
wl_output::Event::Name { name } => {
170+
state.pending.entry(id).or_default().0 = Some(name);
171+
}
172+
wl_output::Event::Mode { flags, refresh, .. } => {
173+
// Outputs advertise every mode they support; only one of
174+
// them is the one being scanned out.
175+
let current = matches!(flags, WEnum::Value(mode) if mode.contains(wl_output::Mode::Current));
176+
if current && refresh > 0 {
177+
state.pending.entry(id).or_default().1 =
178+
Some(Duration::from_nanos(1_000_000_000_000 / refresh as u64));
179+
}
180+
}
181+
wl_output::Event::Done => {
182+
if let Some((Some(name), Some(rate))) = state.pending.remove(&id) {
183+
// The same derivation GPUI uses for a Wayland display's
184+
// uuid, which is what makes the two sides comparable.
185+
state
186+
.rates
187+
.insert(Uuid::new_v5(&Uuid::NAMESPACE_DNS, name.as_bytes()), rate);
188+
}
189+
}
190+
_ => {}
191+
}
192+
}
193+
}
194+
}
195+
196+
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
197+
mod platform {
198+
use super::*;
199+
200+
pub(super) fn refresh_rate(_display: &dyn PlatformDisplay) -> Option<Duration> {
201+
None
202+
}
203+
}

0 commit comments

Comments
 (0)