From cecb4e739c1db9b6e6ccaad96a14fdb647f58742 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 5 Sep 2026 00:18:51 +0800 Subject: [PATCH 1/3] fps: Stop guessing the display's refresh rate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MAX FPS` was capped by a refresh rate inferred from the gaps between presents. It cannot be: 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 says which. Four estimators, four wrong readings on real windows. The shortest gap ever seen read 169 on a 144Hz panel, because a compositor catch-up is not a refresh. The densest group of gaps read 149, because bucketing truncates the distribution it measures, and 75 on a window drawing every other refresh, because the densest thing a window that draws on demand does is idle. The fastest sustained run read 24 on an application whose own timer fired every 41.7ms, which is a cadence held perfectly steady and has nothing to do with the display. Falling back to the frame budget until a cadence was established read 60 against a 6ms frame — a ceiling under the truth, which hides the figure the reader came for. So the headline is what the frame cost can prove and claims nothing more. Capping it needs the refresh rate from the platform, which every backend already has — xrandr mode info on X11, `CVTimeStamp`'s video refresh period on macOS, the `wl_output` mode event on Wayland — and which the display trait does not carry. The warm-up stays: the frames from before the HUD was mounted, and the cold ones right after, are still dropped rather than measured, so a window that just opened still reads healthy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri --- crates/fps/src/monitor.rs | 51 +++----- crates/fps/src/sampler.rs | 248 +------------------------------------- website/docs/fps.md | 52 ++++---- website/zh-CN/docs/fps.md | 26 ++-- 4 files changed, 56 insertions(+), 321 deletions(-) diff --git a/crates/fps/src/monitor.rs b/crates/fps/src/monitor.rs index a357bbe18a..0ecdc42bd1 100644 --- a/crates/fps/src/monitor.rs +++ b/crates/fps/src/monitor.rs @@ -121,9 +121,9 @@ struct Readout { /// resource row right underneath. The frame cost answers the same question /// without being paid for. /// - /// Held to the display's refresh rate once that is known. A frame drawn in - /// 3ms is not 333 frames the reader could ever see, and printing it that - /// way turns the headline back into a benchmark score rather than a rate. + /// A ceiling the frame cost can prove, not one the display can show: a + /// window whose frames cost 3ms could redraw 333 times a second, on a + /// panel that would scan out sixty of them. max_fps: f32, /// Frames presented per second: the rate the window is actually drawing /// at, which an idle application drives to zero. The reciprocal of @@ -141,26 +141,23 @@ struct Readout { invalidations: f32, } -/// The rate a full redraw could sustain: what a frame's cost implies, held to -/// what the display can present. +/// The rate a full redraw could sustain: what a frame's cost implies. /// -/// The cap is the half the derivation loses. Counting presents could never -/// exceed the refresh rate — frames go to the compositor on vsync, so the -/// bound came for free — and a figure derived from frame cost has no such -/// ceiling: a frame drawn in 3ms reads as 333, a number nobody could ever -/// see. `display` is `None` until the window has presented two frames a -/// plausible refresh apart, and an uncapped reading is better than one capped -/// by a guess. -fn sustainable_rate(mean_draw: Duration, display: Option) -> f32 { +/// Not held to the display's refresh rate, which GPUI does not expose and +/// which cannot be recovered from the frames this window happened to present. +/// Gaps between presents are whole multiples of the panel's period, so they +/// put a *lower* bound on it and never an upper one: 41.7ms is six refreshes +/// at 144Hz and one at 24Hz, and nothing in the timing says which. Every +/// estimate tried here read a real window wrong — 169 and 149 from the +/// shortest and the densest gaps, 75 from a window drawing every other +/// refresh, 24 from an application whose own timer fired every 41.7ms — and a +/// ceiling under the truth hides the figure the reader came for. +fn sustainable_rate(mean_draw: Duration) -> f32 { let mean_draw = mean_draw.as_secs_f32(); if mean_draw <= 0. { return 0.; } - let rate = 1. / mean_draw; - match display { - Some(display) => rate.min(display), - None => rate, - } + 1. / mean_draw } /// Which question the headline answers. @@ -347,7 +344,7 @@ impl FpsMonitor { } self.readout = Readout { - max_fps: sustainable_rate(self.sampler.mean_draw(), self.sampler.peak_present_rate()), + max_fps: sustainable_rate(self.sampler.mean_draw()), fps: self.sampler.fps(), interval_millis: self.sampler.present_interval().as_secs_f32() * 1000., // The mean over the interval rather than the latest frame, which @@ -739,19 +736,11 @@ mod tests { use super::*; #[test] - fn the_headline_rate_never_exceeds_what_the_display_can_present() { - // A cheap frame on a 60Hz panel is not 333 frames anyone could see. - assert_eq!( - sustainable_rate(Duration::from_millis(3), Some(60.)), - 60., - "a frame cheaper than a refresh is capped by the refresh" - ); - // Until the display has shown its cadence, capping would be a guess. - assert!((sustainable_rate(Duration::from_millis(3), None) - 333.33).abs() < 0.1); - // A frame that costs more than a refresh sets the rate itself. - assert_eq!(sustainable_rate(Duration::from_millis(20), Some(60.)), 50.); + fn the_headline_rate_is_what_a_frame_costs() { + assert!((sustainable_rate(Duration::from_millis(3)) - 333.33).abs() < 0.1); + assert_eq!(sustainable_rate(Duration::from_millis(20)), 50.); // No frames drawn yet is no rate, not an infinite one. - assert_eq!(sustainable_rate(Duration::ZERO, Some(60.)), 0.); + assert_eq!(sustainable_rate(Duration::ZERO), 0.); } #[gpui::test] diff --git a/crates/fps/src/sampler.rs b/crates/fps/src/sampler.rs index b6dfeae2cc..28b3492318 100644 --- a/crates/fps/src/sampler.rs +++ b/crates/fps/src/sampler.rs @@ -1,7 +1,4 @@ -use std::{ - collections::{BTreeMap, VecDeque}, - time::Duration, -}; +use std::{collections::VecDeque, time::Duration}; use gpui::{ WindowId, @@ -23,76 +20,6 @@ const FPS_WINDOW: Duration = Duration::from_secs(1); /// one. const WARMUP_FRAMES: u32 = 8; -/// The band a gap between presents has to fall in to be taken for the -/// display's frame period. -/// -/// Below the floor it is a catch-up burst rather than a refresh — the fastest -/// panels ship at 240Hz, a period of 4.2ms. Above the ceiling it is the -/// application not having had anything to draw: an idle window presents twice -/// a second, and believing that gap would put the refresh rate at 2Hz. -const SHORTEST_PLAUSIBLE_REFRESH: Duration = Duration::from_micros(3_000); -const LONGEST_PLAUSIBLE_REFRESH: Duration = Duration::from_millis(50); - -/// Gaps are grouped this finely before being counted. Coarse enough that -/// vsync jitter lands in one bucket, fine enough to tell the common rates -/// apart: 6.5-7.0ms is 143-154Hz, and nothing else ships in there. -const REFRESH_BUCKET: Duration = Duration::from_micros(500); - -/// How many buckets either side of the busiest one are averaged with it. -/// -/// Bucketing truncates the very group it is measuring: the jitter around the -/// period spills into the neighbours, so the busiest bucket holds a -/// distribution cut off on both sides and its mean sits below the period. On a -/// 144Hz panel that read 149. Averaging across the neighbourhood puts the -/// centre back, and the clusters worth telling apart — one refresh against -/// two — are far further than this reaches. -const REFRESH_SPREAD: u32 = 2; - -/// The refresh rates panels actually ship at. -/// -/// The estimate comes from timestamps that jitter, so it lands *near* the -/// panel's rate rather than on it, and "near 144" printed as 146 is a headline -/// above a ceiling it is supposed to be held to. Snapping to the rate a real -/// display would have turns a good estimate into the right number. A panel -/// that is on none of these keeps the raw estimate rather than being rounded -/// to a rate it does not have. -const STANDARD_REFRESH_RATES: &[f32] = &[ - 24., 25., 30., 48., 50., 60., 72., 75., 90., 100., 120., 144., 165., 180., 240., 360., -]; - -/// How far the estimate may sit from a standard rate and still be taken for -/// it. -/// -/// Deliberately tight. A wide tolerance would snap an 85Hz panel up to 90 and -/// print a ceiling above the one it is enforcing, which is the failure this -/// whole cap exists to avoid; the jitter it has to absorb is a percent or two, -/// so it never needs to reach that far. -const REFRESH_SNAP_TOLERANCE: f32 = 0.025; - -/// How many times a gap has to recur before it is believed to be the display's -/// period rather than a one-off. -/// -/// A real refresh recurs every frame of every scroll, so the threshold costs -/// nothing to clear and a glitch never clears it. -const REFRESH_SUPPORT: u32 = 8; - -/// What share of the busiest group a faster one needs before it is taken for a -/// cadence of its own, as a divisor. -/// -/// The wanted figure is the display's *ceiling*, and a variable refresh panel -/// spends most of its time below it: a ProMotion window that scrolls at 120Hz -/// and settles at 60 has its 60Hz group win on count, and capping at 60 would -/// be capping at the rate it happened to rest at. A real second cadence -/// arrives in bulk; the jitter skirt around one does not. -const REFRESH_MINORITY: u32 = 4; - -/// And it has to be at least twice as fast, which the skirt never is. -/// -/// A window presenting slower than the panel misses whole refreshes, so the -/// cadences below the ceiling are its halves and thirds — far outside the -/// millisecond of jitter that spills into the buckets next door. -const REFRESH_SEPARATION: u32 = 2; - /// One drawn frame. #[derive(Clone, Copy, Debug, PartialEq)] pub(crate) struct FrameSample { @@ -121,32 +48,6 @@ pub(crate) struct FrameSampler { /// frame in it onto one instant -- the rate then depends on how often the /// HUD looked, not on how often the window presented. present_times: VecDeque, - /// How often each plausible gap between two consecutive presents has been - /// seen, grouped to [`REFRESH_BUCKET`]. - /// - /// Stands in for the display's frame period, which GPUI does not expose. - /// Frames are handed to the compositor on vsync, so a window drawing back - /// to back presents one refresh apart over and over: the period is the gap - /// that keeps happening, and the estimate is the busiest group's mean. - /// - /// The mean of the busiest group rather than the shortest gap anywhere, - /// twice over. A present is stamped when GPUI finished handing the frame - /// over rather than when the display scanned it out, so the gaps jitter by - /// a millisecond either way and the shortest of them is the low tail, not - /// the period — that read 164 on a 144Hz panel. And one gap on its own is - /// no evidence at all: two presents 5.9ms apart there is the compositor - /// catching up, not a 169Hz display. - /// - /// Empty until the window has drawn back to back at all, which one that - /// has only ever drawn on demand never does — so an application nobody has - /// touched yet is left uncapped rather than held to the rate at which it - /// happened to be idling. - /// - /// The failure mode is a window so slow that no two frames ever land - /// adjacent: its cap comes out as its own worst cadence. It reads low - /// either way, and the rows below say why. - refresh_candidates: BTreeMap, - /// How many more frames are dropped before the statistics begin. warmup: u32, /// Whether the backlog has been discarded yet. /// @@ -166,7 +67,6 @@ impl FrameSampler { window_id, samples: VecDeque::with_capacity(capacity), present_times: VecDeque::new(), - refresh_candidates: BTreeMap::new(), warmup: WARMUP_FRAMES, drained_backlog: false, capacity, @@ -232,45 +132,6 @@ impl FrameSampler { (self.present_times.len() - 1) as f32 / span } - /// The fastest cadence this window has repeatedly presented at, taken as - /// the display's refresh rate. `None` until some gap has recurred often - /// enough to mean something — see [`refresh_candidates`]. - /// - /// [`refresh_candidates`]: FrameSampler::refresh_candidates - pub(crate) fn peak_present_rate(&self) -> Option { - let busiest = self - .refresh_candidates - .values() - .filter(|candidate| candidate.hits >= REFRESH_SUPPORT) - .map(|candidate| candidate.hits) - .max()?; - let mode = *self - .refresh_candidates - .iter() - .find(|(_, candidate)| candidate.hits == busiest) - .map(|(bucket, _)| bucket)?; - let peak = self - .refresh_candidates - .iter() - .find(|(bucket, candidate)| { - *bucket * REFRESH_SEPARATION <= mode - && candidate.hits >= REFRESH_SUPPORT - && candidate.hits * REFRESH_MINORITY >= busiest - }) - .map_or(mode, |(bucket, _)| *bucket); - - let (hits, total) = self - .refresh_candidates - .range(peak.saturating_sub(REFRESH_SPREAD)..=peak + REFRESH_SPREAD) - .fold((0u32, Duration::ZERO), |(hits, total), (_, candidate)| { - (hits + candidate.hits, total + candidate.total) - }); - // The peak is inside its own neighbourhood, and it cleared the support - // threshold to be the peak, so the count is never zero here. - let mean = total.as_secs_f32() / hits as f32; - (mean > 0.).then(|| snap_to_standard_refresh(1. / mean)) - } - /// Mean time between consecutive presents inside [`FPS_WINDOW`], as the /// platform's overlay reports its frame interval. The reciprocal of /// [`fps`](Self::fps); zero when there is no rate. @@ -385,18 +246,7 @@ impl FrameSampler { /// Records when frames were presented and forgets the ones that have aged /// out of [`FPS_WINDOW`] as of `now`. `presented` must be in order. fn ingest_presents(&mut self, presented: impl IntoIterator, now: Instant) { - for present in presented { - if let Some(previous) = self.present_times.back() - && let Some(interval) = present.checked_duration_since(*previous) - && (SHORTEST_PLAUSIBLE_REFRESH..=LONGEST_PLAUSIBLE_REFRESH).contains(&interval) - { - let bucket = (interval.as_micros() / REFRESH_BUCKET.as_micros()) as u32; - let candidate = self.refresh_candidates.entry(bucket).or_default(); - candidate.hits = candidate.hits.saturating_add(1); - candidate.total = candidate.total.saturating_add(interval); - } - self.present_times.push_back(present); - } + self.present_times.extend(presented); while let Some(oldest) = self.present_times.front() { if now.duration_since(*oldest) > FPS_WINDOW { @@ -408,24 +258,6 @@ impl FrameSampler { } } -/// The standard refresh rate within [`REFRESH_SNAP_TOLERANCE`] of `rate`, or -/// `rate` itself when no panel ships at anything near it. -fn snap_to_standard_refresh(rate: f32) -> f32 { - STANDARD_REFRESH_RATES - .iter() - .copied() - .find(|standard| (rate - standard).abs() <= standard * REFRESH_SNAP_TOLERANCE) - .unwrap_or(rate) -} - -/// One group of near-equal gaps between presents: how often it has come up, -/// and their sum, so the group can report its mean. -#[derive(Default)] -struct RefreshCandidate { - hits: u32, - total: Duration, -} - /// A sample of the resource usage shown beside the frame numbers. #[derive(Clone, Copy, Debug, Default, PartialEq)] pub(crate) struct ResourceSample { @@ -731,82 +563,6 @@ mod tests { ); } - #[test] - fn the_peak_present_rate_stands_in_for_the_refresh_rate() { - let mut sampler = warmed_sampler(WindowId::from(1), 256); - let start = Instant::now(); - - // Idle: one present every half second says nothing about the display, - // and must not be mistaken for a 2Hz one. - let idle = Duration::from_millis(500); - let presents: Vec = (0..8).map(|frame| start + idle * frame).collect(); - sampler.ingest_presents(presents, start + idle * 7); - assert_eq!(sampler.peak_present_rate(), None); - - // A single short gap is the compositor catching up, not a display. - let after_idle = start + idle * 7; - sampler.ingest_presents([after_idle + Duration::from_micros(5_900)], after_idle); - assert_eq!( - sampler.peak_present_rate(), - None, - "one 5.9ms gap must not pass for a 169Hz panel" - ); - - // Then the window is scrolled: frames land on a 144Hz vsync, stamped - // when GPUI handed each one over rather than when it was scanned out, - // so the gaps jitter by up to a millisecond around 6.944ms. - let jitter = [-900i64, -300, 0, 200, 700, -100, 400, -600]; - let began = start + Duration::from_secs(10); - let mut at = began; - let mut presents = vec![at]; - for frame in 0..60 { - let offset = jitter[frame % jitter.len()]; - at += Duration::from_micros((6_944 + offset) as u64); - presents.push(at); - } - sampler.ingest_presents(presents, at); - assert_eq!( - sampler.peak_present_rate(), - Some(144.), - "the estimate must land on the panel's rate, not near it" - ); - } - - #[test] - fn a_variable_refresh_panel_is_capped_by_its_ceiling_not_its_resting_rate() { - let mut sampler = warmed_sampler(WindowId::from(1), 512); - let start = Instant::now(); - - // A ProMotion window: a short scroll at 120Hz, then a long stretch - // settled at 60. The 60Hz group wins on count and is not the ceiling. - let mut at = start; - let mut presents = vec![at]; - for _ in 0..30 { - at += Duration::from_micros(8_333); - presents.push(at); - } - for _ in 0..120 { - at += Duration::from_micros(16_667); - presents.push(at); - } - sampler.ingest_presents(presents, at); - - assert_eq!(sampler.peak_present_rate(), Some(120.)); - } - - #[test] - fn an_estimate_near_a_standard_rate_becomes_it() { - assert_eq!(snap_to_standard_refresh(143.1), 144.); - assert_eq!(snap_to_standard_refresh(146.), 144.); - assert_eq!(snap_to_standard_refresh(120.5), 120.); - assert_eq!(snap_to_standard_refresh(59.4), 60.); - // Between two rates, and closer to neither than the tolerance allows. - assert_eq!(snap_to_standard_refresh(155.), 155.); - // A panel that ships at nothing standard keeps its own rate rather - // than being rounded up to a ceiling it does not have. - assert_eq!(snap_to_standard_refresh(85.), 85.); - } - #[test] fn fps_is_taken_from_when_frames_were_presented_not_when_they_were_read() { let window_id = WindowId::from(1); diff --git a/website/docs/fps.md b/website/docs/fps.md index 31eb8241a5..61a1c798e3 100644 --- a/website/docs/fps.md +++ b/website/docs/fps.md @@ -54,36 +54,28 @@ The frame cost already answers the question. `FRAME` is what a full redraw costs, so its reciprocal is the rate those redraws could sustain, and nothing has to be drawn to find it. The HUD never requests a frame. -### Why MAX is capped by the display - -Counting presents had a ceiling for free: frames go to the compositor on vsync, -so a counted rate can never exceed the refresh rate. A derived figure has no -such ceiling — a frame drawn in 3ms reads as 333, a rate nobody could ever see -— so the cap is applied explicitly. - -GPUI does not expose the refresh rate, so the sampler infers it from the gaps -between presents: - -- Gaps outside 3ms–50ms are ignored. Below is a compositor catch-up burst, not - a refresh; above is the application having had nothing to draw. -- The rest are grouped to half a millisecond and counted. A gap has to recur - before it means anything: two presents 5.9ms apart on a 144Hz panel is a - hiccup, not a 169Hz display. -- The estimate is the mean of the busiest group and its neighbours, because - bucketing truncates the group it is measuring and the busiest bucket alone - reads high. -- A faster group is preferred when it is at least twice as fast and arrives in - bulk. A variable refresh panel spends most of its time below its ceiling: a - ProMotion window that scrolls at 120Hz and rests at 60 must be capped at 120, - not at the rate it happened to rest at. -- The result is snapped to a standard refresh rate when it lands within 2.5% of - one, so a 144Hz panel reads 144 rather than 146. A panel that ships at - nothing standard keeps its own rate rather than being rounded up to a ceiling - it does not have. - -Until the window has presented back to back often enough for that to mean -something — which a window nobody has touched never does — there is no cap, and -`MAX` is whatever the frame cost implies. +### Why MAX is not capped by the display + +It should be. A frame drawn in 3ms reads as 333, and no panel will ever show +that. Counting presents had the ceiling for free — frames go to the compositor +on vsync — and a figure derived from frame cost has no such bound. + +GPUI does not expose the refresh rate, and it cannot be recovered from the +frames a window happened to present. Gaps between presents are whole multiples +of the panel's period, so they put a **lower** bound on it and never an upper +one: 41.7ms is six refreshes at 144Hz and one at 24Hz, and nothing in the +timing distinguishes them. Every estimate tried here 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 happened to +fire every 41.7ms. A ceiling under the truth hides the figure the reader came +for, which is worse than one that is honestly above what the panel can scan +out. + +So the headline is what the frame cost can prove, and nothing more is claimed +for it. Capping it correctly needs the real refresh rate from the platform, +which every backend already has — xrandr mode info on X11, `CVTimeStamp`'s +video refresh period on macOS, the `wl_output` mode event on Wayland — and +which the display trait does not yet carry. ## The rows diff --git a/website/zh-CN/docs/fps.md b/website/zh-CN/docs/fps.md index 87a7c9e174..abe954b27a 100644 --- a/website/zh-CN/docs/fps.md +++ b/website/zh-CN/docs/fps.md @@ -46,23 +46,21 @@ Table 页上,这意味着没人碰窗口时也有约 62% 的 CPU。 而帧耗时本身已经回答了这个问题。`FRAME` 就是一次完整重绘的成本,取倒数就是这种重绘能撑住 的速率,一帧都不用多画。HUD 从不请求帧。 -### 为什么 MAX 要夹在显示器刷新率上 +### 为什么 MAX 没有夹在显示器刷新率上 -数 present 的时候这个上界是免费的:帧走 vsync 交给合成器,所以计数在物理上就超不过刷新率。 -推导出来的数字没有这个天花板——3ms 的一帧会读成 333,一个谁也看不到的速率——所以必须显式夹。 +本来应该夹。3ms 的一帧会读成 333,没有任何面板能显示这个数。数 present 的时候这个上界是免费的 +(帧走 vsync 交给合成器),而从帧耗时推导出来的数字没有这个天花板。 -GPUI 不暴露刷新率,因此 sampler 从 present 之间的间隔里把它推断出来: +但 GPUI 不暴露刷新率,而且**它无法从窗口恰好 present 过的那些帧里还原出来**。present 之间的间隔 +是面板周期的整数倍,所以它们只能给出刷新率的**下界**,永远给不出上界:41.7ms 在 144Hz 屏上是 +6 个刷新周期,在 24Hz 屏上是 1 个,时序本身无法区分这两种情况。这里试过的每一种估计法都在真实 +窗口上读错过——取最短间隔得到 169、取最稠密的一组得到 149、隔帧绘制的窗口得到 75、而一个自身 +定时器每 41.7ms 触发一次的应用得到 24。**夹到真值以下会把读者想看的数字藏起来**,比诚实地高于 +面板能扫出的帧数更糟。 -- 落在 3ms–50ms 之外的间隔直接丢弃。更短的是合成器追帧,不是刷新;更长的是应用根本没东西可画。 -- 其余按 0.5ms 分组计数。一个间隔必须**重复出现**才算数:144Hz 屏上两帧相隔 5.9ms 是一次抖动, - 不是 169Hz 的显示器。 -- 估计值取"出现最多的那一组及其邻域"的均值——分组会把它要测的分布切断,只取众数桶本身会偏高。 -- 当一组明显更快(至少快一倍)且样本量成规模时优先取它。可变刷新率的面板大部分时间都低于自己的 - 上限:ProMotion 窗口滚动时 120Hz、静止时 60Hz,该夹的是 120,而不是它恰好停在的那个速率。 -- 结果若落在某个标准刷新率的 2.5% 以内则吸附过去,这样 144Hz 屏读出的是 144 而不是 146。 - 不在标准表里的面板保留自己的估计值,而不是被向上取整到一个它并不具备的上限。 - -在窗口连续绘制到足以说明问题之前——没人碰过的窗口永远达不到——不做夹取,`MAX` 就是帧耗时算出来的值。 +所以主读数就是帧耗时能证明的那个上限,不多声称任何东西。要正确地夹,需要平台提供真实的刷新率—— +各个后端其实都已经拿到了它(X11 的 xrandr mode info、macOS 的 `CVTimeStamp` video refresh period、 +Wayland 的 `wl_output` mode 事件),只是 display trait 还没有把它带出来。 ## 各行含义 From 78de8a7d2e92b9bb43ac327dbeb1d2779e9a5303 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 5 Sep 2026 01:13:51 +0800 Subject: [PATCH 2/3] fps: Cap the headline by asking the platform what the panel runs at MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri --- Cargo.lock | 3 + crates/fps/Cargo.toml | 6 ++ crates/fps/src/lib.rs | 1 + crates/fps/src/monitor.rs | 75 ++++++++++---- crates/fps/src/refresh.rs | 203 ++++++++++++++++++++++++++++++++++++++ website/docs/fps.md | 52 +++++----- website/zh-CN/docs/fps.md | 30 +++--- 7 files changed, 315 insertions(+), 55 deletions(-) create mode 100644 crates/fps/src/refresh.rs diff --git a/Cargo.lock b/Cargo.lock index daa8addb5d..4747afa677 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3575,11 +3575,14 @@ dependencies = [ name = "gpui-fps" version = "0.6.0" dependencies = [ + "core-graphics 0.24.0", "gpui-pre", "libc", "objc2-core-foundation", "objc2-io-kit", "sysinfo 0.37.2", + "uuid", + "wayland-client", "web-time", "windows 0.58.0", ] diff --git a/crates/fps/Cargo.toml b/crates/fps/Cargo.toml index 5b5277e532..026bf3e76a 100644 --- a/crates/fps/Cargo.toml +++ b/crates/fps/Cargo.toml @@ -29,6 +29,7 @@ sysinfo = "0.37" # counter the platform's own activity monitor attributes per process with, so no # vendor SDK or elevated privilege is involved. [target.'cfg(target_os = "macos")'.dependencies] +core-graphics = "0.24" libc = "0.2" objc2-core-foundation = { version = "0.3", default-features = false, features = [ "std", @@ -42,8 +43,13 @@ objc2-io-kit = { version = "0.3", default-features = false, features = [ "libc", ] } +[target.'cfg(target_os = "linux")'.dependencies] +uuid = { version = "1", features = ["v5"] } +wayland-client = "0.31" + [target.'cfg(target_os = "windows")'.dependencies] windows = { workspace = true, features = [ + "Win32_Graphics_Gdi", "Win32_System_Performance", "Win32_System_ProcessStatus", "Win32_System_Threading", diff --git a/crates/fps/src/lib.rs b/crates/fps/src/lib.rs index be8d9094ec..168ee0c6be 100644 --- a/crates/fps/src/lib.rs +++ b/crates/fps/src/lib.rs @@ -40,6 +40,7 @@ mod gpu; mod memory; mod monitor; mod overlay; +mod refresh; mod sampler; mod style; diff --git a/crates/fps/src/monitor.rs b/crates/fps/src/monitor.rs index 0ecdc42bd1..f524d0b7db 100644 --- a/crates/fps/src/monitor.rs +++ b/crates/fps/src/monitor.rs @@ -3,15 +3,16 @@ use std::time::Duration; use web_time::Instant; use gpui::{ - Bounds, Context, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton, ParentElement, - PathBuilder, Pixels, Point, Render, StatefulInteractiveElement as _, Styled, Window, canvas, - div, point, prelude::FluentBuilder as _, px, relative, + App, Bounds, Context, DisplayId, Div, Hsla, InteractiveElement as _, IntoElement, MouseButton, + ParentElement, PathBuilder, Pixels, Point, Render, StatefulInteractiveElement as _, Styled, + Window, canvas, div, point, prelude::FluentBuilder as _, px, relative, }; use gpui::Task; use crate::{ FrameTraceGuard, + refresh::display_refresh_rate, sampler::{FrameSampler, ResourceSample, minimum_resource_interval}, style::FpsStyle, }; @@ -141,23 +142,25 @@ struct Readout { invalidations: f32, } -/// The rate a full redraw could sustain: what a frame's cost implies. +/// The rate a full redraw could sustain: what a frame's cost implies, held to +/// what the panel can scan out. /// -/// Not held to the display's refresh rate, which GPUI does not expose and -/// which cannot be recovered from the frames this window happened to present. -/// Gaps between presents are whole multiples of the panel's period, so they -/// put a *lower* bound on it and never an upper one: 41.7ms is six refreshes -/// at 144Hz and one at 24Hz, and nothing in the timing says which. Every -/// estimate tried here read a real window wrong — 169 and 149 from the -/// shortest and the densest gaps, 75 from a window drawing every other -/// refresh, 24 from an application whose own timer fired every 41.7ms — and a -/// ceiling under the truth hides the figure the reader came for. -fn sustainable_rate(mean_draw: Duration) -> f32 { +/// The cap is the half the derivation loses. Counting presents could never +/// exceed the refresh rate — frames go to the compositor on vsync, so the +/// bound came for free — while a frame drawn in 3ms reads as 333, a rate +/// nobody could ever see. `display` is `None` where the platform would not say +/// what the panel runs at, and an uncapped reading is better than one held to +/// a guess: see [`crate::refresh`] for why guessing was tried and abandoned. +fn sustainable_rate(mean_draw: Duration, display: Option) -> f32 { let mean_draw = mean_draw.as_secs_f32(); if mean_draw <= 0. { return 0.; } - 1. / mean_draw + let rate = 1. / mean_draw; + match display.map(|period| period.as_secs_f32()) { + Some(period) if period > 0. => rate.min(1. / period), + _ => rate, + } } /// Which question the headline answers. @@ -181,6 +184,10 @@ pub struct FpsMonitor { style: FpsStyle, frame_budget: Duration, headline: Headline, + /// The panel's refresh period, and which display it was asked about, so + /// that moving the window to another monitor re-asks and staying on one + /// does not ask again every frame. + display: Option<(DisplayId, Option)>, show_resources: bool, resource_interval: Duration, resources: Option, @@ -201,6 +208,7 @@ impl FpsMonitor { style: FpsStyle::default(), frame_budget, headline: Headline::Max, + display: None, show_resources: true, resource_interval: DEFAULT_RESOURCE_INTERVAL, resources: None, @@ -333,6 +341,19 @@ impl FpsMonitor { })); } + /// Re-asks the platform for the refresh rate when the window has moved to + /// another display, and not otherwise: the answer is a property of the + /// panel, and on some platforms asking is a round trip. + fn update_display(&mut self, window: &Window, cx: &App) { + let Some(display) = window.display(cx) else { + return; + }; + let id = display.id(); + if self.display.map(|(asked, _)| asked) != Some(id) { + self.display = Some((id, display_refresh_rate(display.as_ref()))); + } + } + /// Republishes the readings if [`READOUT_INTERVAL`] has passed. fn update_readout(&mut self) { let now = Instant::now(); @@ -344,7 +365,10 @@ impl FpsMonitor { } self.readout = Readout { - max_fps: sustainable_rate(self.sampler.mean_draw()), + max_fps: sustainable_rate( + self.sampler.mean_draw(), + self.display.and_then(|(_, refresh_rate)| refresh_rate), + ), fps: self.sampler.fps(), interval_millis: self.sampler.present_interval().as_secs_f32() * 1000., // The mean over the interval rather than the latest frame, which @@ -494,8 +518,9 @@ impl FpsMonitor { } impl Render for FpsMonitor { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { self.sampler.tick(); + self.update_display(window, cx); self.update_readout(); self.update_axis(); self.start_clock(cx); @@ -736,11 +761,19 @@ mod tests { use super::*; #[test] - fn the_headline_rate_is_what_a_frame_costs() { - assert!((sustainable_rate(Duration::from_millis(3)) - 333.33).abs() < 0.1); - assert_eq!(sustainable_rate(Duration::from_millis(20)), 50.); + fn the_headline_rate_is_what_a_frame_costs_and_the_panel_allows() { + let sixty = Duration::from_micros(16_667); + // A cheap frame on a 60Hz panel is not 333 frames anyone could see. + assert!((sustainable_rate(Duration::from_millis(3), Some(sixty)) - 60.).abs() < 0.01); + // A frame that costs more than a refresh sets the rate itself. + assert_eq!( + sustainable_rate(Duration::from_millis(20), Some(sixty)), + 50. + ); + // Where the platform will not say, an uncapped reading beats a guess. + assert!((sustainable_rate(Duration::from_millis(3), None) - 333.33).abs() < 0.1); // No frames drawn yet is no rate, not an infinite one. - assert_eq!(sustainable_rate(Duration::ZERO), 0.); + assert_eq!(sustainable_rate(Duration::ZERO, Some(sixty)), 0.); } #[gpui::test] diff --git a/crates/fps/src/refresh.rs b/crates/fps/src/refresh.rs new file mode 100644 index 0000000000..0b83b7f27e --- /dev/null +++ b/crates/fps/src/refresh.rs @@ -0,0 +1,203 @@ +//! The refresh rate of the display a window is on, read from the platform. +//! +//! GPUI does not report it, and it cannot be recovered from the frames a +//! window presented: 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 says which. Every estimate +//! tried before this read a real window wrong. +//! +//! So it is asked for. GPUI does hand out the platform's own display handle +//! through [`gpui::DisplayId`], which is a `CGDirectDisplayID` on macOS and an +//! `HMONITOR` on Windows, and on Wayland the outputs can be enumerated again +//! and matched by the identity GPUI derives from their names. +//! +//! `None` means nobody could say — a platform without a query here, a virtual +//! display, or a panel with no fixed rate — and the caller shows an uncapped +//! reading rather than one held to a guess. + +use std::time::Duration; + +use gpui::PlatformDisplay; + +/// The period between refreshes of `display`, when the platform reports one. +pub(crate) fn display_refresh_rate(display: &dyn PlatformDisplay) -> Option { + platform::refresh_rate(display) +} + +/// Turns a rate in hertz into the period the rest of the crate works in, +/// rejecting the zeroes platforms use to mean "no fixed rate". +#[cfg(any(target_os = "macos", target_os = "windows"))] +fn period_from_hertz(hertz: f64) -> Option { + (hertz > 1.).then(|| Duration::from_secs_f64(1. / hertz)) +} + +#[cfg(target_os = "macos")] +mod platform { + use super::*; + use core_graphics::display::{CGDirectDisplayID, CGDisplay}; + + pub(super) fn refresh_rate(display: &dyn PlatformDisplay) -> Option { + let id: u64 = display.id().into(); + // Zero rather than an error is how CoreGraphics says this display has + // no fixed rate, which is what a built-in panel reports: on ProMotion + // there genuinely is not one, and the nominal period would have to come + // from CoreVideo instead. + let mode = CGDisplay::new(id as CGDirectDisplayID).display_mode()?; + period_from_hertz(mode.refresh_rate()) + } +} + +#[cfg(target_os = "windows")] +mod platform { + use super::*; + use windows::{ + Win32::{Foundation::*, Graphics::Gdi::*}, + core::*, + }; + + pub(super) fn refresh_rate(display: &dyn PlatformDisplay) -> Option { + let id: u64 = display.id().into(); + let monitor = HMONITOR(id as _); + + let mut info = MONITORINFOEXW { + monitorInfo: MONITORINFO { + cbSize: std::mem::size_of::() as u32, + ..Default::default() + }, + ..Default::default() + }; + if !unsafe { GetMonitorInfoW(monitor, &mut info as *mut _ as *mut MONITORINFO) }.as_bool() { + return None; + } + + let mut mode = DEVMODEW { + dmSize: std::mem::size_of::() as u16, + ..Default::default() + }; + let device = PCWSTR(info.szDevice.as_ptr()); + if !unsafe { EnumDisplaySettingsW(device, ENUM_CURRENT_SETTINGS, &mut mode) }.as_bool() { + return None; + } + // Zero and one both mean "whatever the hardware defaults to" rather + // than a rate, which is what a driver reports when it has none to give. + period_from_hertz(mode.dmDisplayFrequency as f64) + } +} + +#[cfg(target_os = "linux")] +mod platform { + use super::*; + use std::{collections::HashMap, sync::OnceLock}; + use uuid::Uuid; + use wayland_client::{ + Connection, Dispatch, Proxy as _, QueueHandle, WEnum, + protocol::{wl_output, wl_registry}, + }; + + /// Wayland hands each client its own object ids, so the id GPUI reports for + /// an output means nothing on a second connection. What both sides can + /// agree on is the output's name, which GPUI folds into the display's uuid + /// — so the outputs are enumerated again and matched by that. + pub(super) fn refresh_rate(display: &dyn PlatformDisplay) -> Option { + let uuid = display.uuid().ok()?; + outputs().get(&uuid).copied() + } + + /// Asked once. Outputs change when a monitor is plugged in or its mode is + /// changed, and neither happens in the middle of reading a frame counter. + fn outputs() -> &'static HashMap { + static OUTPUTS: OnceLock> = OnceLock::new(); + OUTPUTS.get_or_init(|| query_outputs().unwrap_or_default()) + } + + fn query_outputs() -> Option> { + let connection = Connection::connect_to_env().ok()?; + let mut queue = connection.new_event_queue(); + let handle = queue.handle(); + let _registry = connection.display().get_registry(&handle, ()); + + let mut state = State::default(); + // Once for the globals, once for the events the outputs send back. + queue.roundtrip(&mut state).ok()?; + queue.roundtrip(&mut state).ok()?; + Some(state.rates) + } + + #[derive(Default)] + struct State { + /// Name and current mode, per output object, until its `Done`. + pending: HashMap, Option)>, + rates: HashMap, + } + + impl Dispatch for State { + fn event( + _: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _: &(), + _: &Connection, + handle: &QueueHandle, + ) { + if let wl_registry::Event::Global { + name, + interface, + version, + } = event + && interface == wl_output::WlOutput::interface().name + { + // Version 4 is where an output started naming itself, which is + // the only thing this connection and GPUI's can match on. + if version >= 4 { + registry.bind::(name, 4, handle, ()); + } + } + } + } + + impl Dispatch for State { + fn event( + state: &mut Self, + output: &wl_output::WlOutput, + event: wl_output::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + let id = output.id().protocol_id(); + match event { + wl_output::Event::Name { name } => { + state.pending.entry(id).or_default().0 = Some(name); + } + wl_output::Event::Mode { flags, refresh, .. } => { + // Outputs advertise every mode they support; only one of + // them is the one being scanned out. + let current = matches!(flags, WEnum::Value(mode) if mode.contains(wl_output::Mode::Current)); + if current && refresh > 0 { + state.pending.entry(id).or_default().1 = + Some(Duration::from_nanos(1_000_000_000_000 / refresh as u64)); + } + } + wl_output::Event::Done => { + if let Some((Some(name), Some(rate))) = state.pending.remove(&id) { + // The same derivation GPUI uses for a Wayland display's + // uuid, which is what makes the two sides comparable. + state + .rates + .insert(Uuid::new_v5(&Uuid::NAMESPACE_DNS, name.as_bytes()), rate); + } + } + _ => {} + } + } + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] +mod platform { + use super::*; + + pub(super) fn refresh_rate(_display: &dyn PlatformDisplay) -> Option { + None + } +} diff --git a/website/docs/fps.md b/website/docs/fps.md index 61a1c798e3..6e9ae2c30b 100644 --- a/website/docs/fps.md +++ b/website/docs/fps.md @@ -54,28 +54,36 @@ The frame cost already answers the question. `FRAME` is what a full redraw costs, so its reciprocal is the rate those redraws could sustain, and nothing has to be drawn to find it. The HUD never requests a frame. -### Why MAX is not capped by the display - -It should be. A frame drawn in 3ms reads as 333, and no panel will ever show -that. Counting presents had the ceiling for free — frames go to the compositor -on vsync — and a figure derived from frame cost has no such bound. - -GPUI does not expose the refresh rate, and it cannot be recovered from the -frames a window happened to present. Gaps between presents are whole multiples -of the panel's period, so they put a **lower** bound on it and never an upper -one: 41.7ms is six refreshes at 144Hz and one at 24Hz, and nothing in the -timing distinguishes them. Every estimate tried here 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 happened to -fire every 41.7ms. A ceiling under the truth hides the figure the reader came -for, which is worse than one that is honestly above what the panel can scan -out. - -So the headline is what the frame cost can prove, and nothing more is claimed -for it. Capping it correctly needs the real refresh rate from the platform, -which every backend already has — xrandr mode info on X11, `CVTimeStamp`'s -video refresh period on macOS, the `wl_output` mode event on Wayland — and -which the display trait does not yet carry. +### Why MAX is capped by asking, not by measuring + +A frame drawn in 3ms reads as 333, and no panel will ever show that. Counting +presents had the ceiling for free — frames go to the compositor on vsync — and +a figure derived from frame cost has no such bound, so the cap is applied +explicitly. + +It cannot be inferred. The gaps between a window's 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 nothing in the timing +distinguishes them. 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 happened to fire every +41.7ms. + +So 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`. A built-in + panel reports no fixed rate, which is the truth on ProMotion, and is read as + no cap. +- **Windows** — `EnumDisplaySettingsW` on the monitor's device name. +- **Wayland** — the outputs are enumerated on a second connection and matched + to GPUI's displays by the identity it derives from their names, because + object ids are per-connection and mean nothing across one. +- **X11 and everything else** — no query, so no cap. + +The answer is re-asked when the window moves to another display and not +otherwise. Where nobody will say, the reading is left uncapped rather than held +to a guess: a ceiling under the truth hides the figure the reader came for. ## The rows diff --git a/website/zh-CN/docs/fps.md b/website/zh-CN/docs/fps.md index abe954b27a..6a92aa81e3 100644 --- a/website/zh-CN/docs/fps.md +++ b/website/zh-CN/docs/fps.md @@ -46,21 +46,27 @@ Table 页上,这意味着没人碰窗口时也有约 62% 的 CPU。 而帧耗时本身已经回答了这个问题。`FRAME` 就是一次完整重绘的成本,取倒数就是这种重绘能撑住 的速率,一帧都不用多画。HUD 从不请求帧。 -### 为什么 MAX 没有夹在显示器刷新率上 +### 为什么是「问平台」而不是「测出来」 -本来应该夹。3ms 的一帧会读成 333,没有任何面板能显示这个数。数 present 的时候这个上界是免费的 -(帧走 vsync 交给合成器),而从帧耗时推导出来的数字没有这个天花板。 +3ms 的一帧会读成 333,没有任何面板能显示这个数。数 present 的时候这个上界是免费的(帧走 vsync +交给合成器),而从帧耗时推导出来的数字没有这个天花板,所以必须显式地夹。 -但 GPUI 不暴露刷新率,而且**它无法从窗口恰好 present 过的那些帧里还原出来**。present 之间的间隔 -是面板周期的整数倍,所以它们只能给出刷新率的**下界**,永远给不出上界:41.7ms 在 144Hz 屏上是 -6 个刷新周期,在 24Hz 屏上是 1 个,时序本身无法区分这两种情况。这里试过的每一种估计法都在真实 -窗口上读错过——取最短间隔得到 169、取最稠密的一组得到 149、隔帧绘制的窗口得到 75、而一个自身 -定时器每 41.7ms 触发一次的应用得到 24。**夹到真值以下会把读者想看的数字藏起来**,比诚实地高于 -面板能扫出的帧数更糟。 +**它推不出来。** present 之间的间隔是面板周期的整数倍,所以只能给出刷新率的**下界**,永远给不出 +上界:41.7ms 在 144Hz 屏上是 6 个刷新周期,在 24Hz 屏上是 1 个,时序本身无法区分。试过的每一种 +估计法都在真实窗口上读错过——取最短间隔得到 169、取最稠密的一组得到 149、隔帧绘制的窗口得到 75、 +而一个自身定时器每 41.7ms 触发一次的应用得到 24。 -所以主读数就是帧耗时能证明的那个上限,不多声称任何东西。要正确地夹,需要平台提供真实的刷新率—— -各个后端其实都已经拿到了它(X11 的 xrandr mode info、macOS 的 `CVTimeStamp` video refresh period、 -Wayland 的 `wl_output` mode 事件),只是 display trait 还没有把它带出来。 +所以改成向平台索取。GPUI 通过 `DisplayId` 把平台自己的显示器句柄透了出来,HUD 从那里接手: + +- **macOS** —— 用 `CGDirectDisplayID` 调 `CGDisplayCopyDisplayMode`。内置屏报告「没有固定速率」, + 在 ProMotion 上这是实情,按「不夹」处理。 +- **Windows** —— 用显示器的设备名调 `EnumDisplaySettingsW`。 +- **Wayland** —— 另开一个连接重新枚举 outputs,再按 GPUI 从名字派生的标识与它的 display 对上; + 因为对象 id 是每连接独立的,跨连接没有意义。 +- **X11 及其它** —— 没有查询,也就不夹。 + +窗口移动到另一块显示器时会重新索取,其余时候不会。**没人能给出答案时保持不夹**,而不是按猜测夹: +夹到真值以下会把读者想看的数字藏起来。 ## 各行含义 From d918403b03726fad622746a9fecb116752be776c Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Sat, 5 Sep 2026 10:12:19 +0800 Subject: [PATCH 3/3] fix: remove unused Windows refresh rate import Co-authored-by: Codex --- crates/fps/src/refresh.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/fps/src/refresh.rs b/crates/fps/src/refresh.rs index 0b83b7f27e..23808100ae 100644 --- a/crates/fps/src/refresh.rs +++ b/crates/fps/src/refresh.rs @@ -50,10 +50,7 @@ mod platform { #[cfg(target_os = "windows")] mod platform { use super::*; - use windows::{ - Win32::{Foundation::*, Graphics::Gdi::*}, - core::*, - }; + use windows::{Win32::Graphics::Gdi::*, core::*}; pub(super) fn refresh_rate(display: &dyn PlatformDisplay) -> Option { let id: u64 = display.id().into();