fps: Derive the headline rate from frame cost instead of driving the frame loop - #2954
Merged
Conversation
huacnlee
force-pushed
the
fps-stop-driving-background-frames
branch
from
September 4, 2026 15:28
767b44a to
c262e3e
Compare
huacnlee
force-pushed
the
fps-stop-driving-background-frames
branch
from
September 4, 2026 15:38
c262e3e to
527f19c
Compare
…loop The HUD asked for a frame after every render so its counter would keep moving, and the doc claimed that being its own view kept that repaint to the HUD's subtree. It does not: a dirty view schedules a *window* draw and GPUI re-renders every view outside an `Entity::cached` boundary, so each of those frames was a full layout and paint of the application — reported, one row down, as the application's CPU. On the story gallery's Table page that was ~62% CPU with nobody touching the window, and ~0% now. Both rates were already in the samples, so the headline switches between them on a right-click and the `MAX` marker says which is showing: - `MAX FPS`, the reciprocal of the measured frame cost: the rate a full redraw could sustain, derived rather than caused. - `FPS`, presents per second: the rate the window is drawing at, which falls to nothing while it idles. Deriving loses the ceiling that counting presents had for free — those go to the compositor on vsync, so a counted rate could never exceed the refresh rate, while a frame drawn in 3ms reads as 333. GPUI does not expose the refresh rate, so the sampler infers it from the gaps between presents: only gaps that could be a refresh, only ones that recur, the mean of the busiest group and its neighbours, preferring a group at least twice as fast when one arrives in bulk (a ProMotion window resting at 60 must still be capped at 120), and snapped to a standard rate when it lands within 2.5% of one. A 144Hz panel measures 146.6 and reads 144. The first frames are dropped rather than measured. Everything GPUI recorded before the HUD was mounted is either somebody else's history or the cold start, and the frames right after it are shaders, atlases and cold caches: one of them is 100ms against a budget of 16, which a HUD that has seen eight frames reports as a twelfth of the window's work, in amber, before the reader has done anything. A window that just opened now reads healthy. Nothing else woke the HUD once it stopped asking for frames, so the resource sampler's timer becomes a readout clock that ticks whether or not resources are shown; `show_resources(false)` used to leave the figures frozen at whatever the application last drew. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri
It selected between a HUD that drove the frame loop and one that did not, and `gpui-fps` no longer has the first: the headline is derived from frame cost, which is the number the option existed to produce, and the reader switches between that and the observed rate on the HUD itself. Removes the script property, its typing, and the snapshot test that pinned the driving default, in favour of one asserting the HUD never requests a frame. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri
The 500ms cap held the FPS HUD's self-driven redraws to 2 FPS in the background. Nothing drives them now, so the window can go back to the framework default and animated demos stay smooth in a side-by-side window instead of stepping at 2 FPS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri
Every figure on the HUD is now the answer to a question someone could ask it the wrong way: `MAX FPS` is a rate nothing was clocked at, `INTERVAL` is far below it on any window that draws on demand, and the display cap behind the headline is inferred rather than read. Written down in both languages, with the reasoning rather than just the behaviour, because the reasoning is what stops the next reader from concluding the framework cannot hold a frame rate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri
huacnlee
force-pushed
the
fps-stop-driving-background-frames
branch
from
September 4, 2026 15:57
527f19c to
54a522e
Compare
5 tasks
huacnlee
added a commit
that referenced
this pull request
Sep 5, 2026
…2956) ## Summary Follow-up to #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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The HUD asked for a frame after every render, and the doc claimed that being its own view kept the repaint to the HUD's subtree. It does not — a dirty view schedules a window draw, and GPUI re-renders every view outside an
Entity::cachedboundary. So each frame the HUD asked for was a full layout and paint of the application, reported one row down as the application's CPU.The rate it was buying is already in the samples:
FRAMEis what a full redraw costs, so its reciprocal is the rate those redraws could sustain, and nothing has to be drawn to find it.Changes
Headline is two rates, right-click to switch,
MAXsays which.MAX FPSis1 / FRAME;FPSis presents per second. Both come from the same samples, so switching is free — which is the point: the only expensive way to make the first number meaningful is to stop the second from being readable. The marker goes in the box that already balanced the unit, so the figure does not move. Click still collapses.MAXis capped by the display. Counting presents had that ceiling for free (frames go to the compositor on vsync, see #2944); a derived figure does not, and a frame drawn in 3ms reads as 333. GPUI does not expose the refresh rate, so the sampler infers it from the gaps between presents: only gaps that could be a refresh (3–50ms), only ones that recur, the mean of the busiest group and its neighbours (bucketing truncates the group it measures — that read 149 on a 144Hz panel), preferring a group at least twice as fast when one arrives in bulk (a ProMotion window resting at 60 must still be capped at 120), snapped to a standard rate within 2.5%. Measured on a 143.999Hz panel: estimate 146.6, reads 144.The cold start is not measured. Mounting the HUD drained everything GPUI had recorded since process start — shaders, glyph atlas, icons, every cache cold. One of those frames is 100ms against a budget of 16, which a HUD that has seen eight frames reports as a twelfth of the window's work, in amber, before the reader has done anything. Both the backlog and the first frames after it are now dropped, so a window that just opened reads healthy.
continuousis gone. Its only effect was to manufacture the thing it was measuring. Removed fromgpui-fpsand from the script API —show_fps_monitor({ continuous }),.continuous(), the typing and the key list. This is a breaking change for scripts, but the option can no longer select anything.A readout clock replaces the resource timer. Nothing else wakes a HUD that has stopped asking for frames;
show_resources(false)used to freeze the figures at whatever the application last drew.story drops
inactive_frame_interval: 500ms, which existed only to hold the HUD's self-driven redraws to 2 FPS in the background.Docs:
website/docs/fps.mdand its zh-CN mirror, with the reasoning rather than just the behaviour —MAX FPSis a rate nothing was clocked at, and without that written down the next reader concludes the framework cannot hold a frame rate.Test plan
MAXmatches1000/FRAME,DROPandP95greenFPS(single digits while idle) and backMAXstops at the panel's rate — 60 on a 60Hz displayMAXshow_resources(false)still refreshes rather than freezingfps_monitor().continuous(false)in a script now errors as an unknown propertyAI Assistance
🤖 Generated with Claude Code
https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri