Skip to content

fps: Derive the headline rate from frame cost instead of driving the frame loop - #2954

Merged
huacnlee merged 4 commits into
mainfrom
fps-stop-driving-background-frames
Sep 4, 2026
Merged

fps: Derive the headline rate from frame cost instead of driving the frame loop#2954
huacnlee merged 4 commits into
mainfrom
fps-stop-driving-background-frames

Conversation

@huacnlee

@huacnlee huacnlee commented Sep 4, 2026

Copy link
Copy Markdown
Member

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::cached boundary. 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: 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.

Table page, focused, untouched Before After
Process CPU ~62% ~0%
HUD reads 60 FPS / CPU 27% MAX 115 FPS / FRAME 8.7ms / DROP 0.0%

Changes

Headline is two rates, right-click to switch, MAX says which. MAX FPS is 1 / FRAME; FPS is 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.

MAX is 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.

continuous is gone. Its only effect was to manufacture the thing it was measuring. Removed from gpui-fps and 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.md and its zh-CN mirror, with the reasoning rather than just the behaviour — MAX FPS is a rate nothing was clocked at, and without that written down the next reader concludes the framework cannot hold a frame rate.

Test plan

  • HUD on, window focused, untouched: CPU near zero, MAX matches 1000/FRAME, DROP and P95 green
  • Right-click switches to FPS (single digits while idle) and back
  • After scrolling, MAX stops at the panel's rate — 60 on a 60Hz display
  • macOS ProMotion: caps at 120 while scrolling, and still 120 after it settles
  • A heavy page (Table / DataTable) reads a visibly lower MAX
  • show_resources(false) still refreshes rather than freezing
  • fps_monitor().continuous(false) in a script now errors as an unknown property
  • Animated stories no longer step at 2 FPS in a background window

AI Assistance

🤖 Code and docs generated by Claude Code. The direction — derive MAX from FRAME, drop continuous, right-click between the two rates, never exceed the display, open healthy — is the author's. Verified locally on Linux/Wayland with 144Hz and 60Hz panels (debug build, /proc/<pid>/stat sampling, screenshots, temporary instrumentation for the refresh estimate); cargo test -p gpui-fps (31) and -p gpui-shell (695+4+4) pass, clippy --deny warnings clean, wasm target builds. ProMotion is covered by a unit test only — not verified on macOS hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_01USSMRpQ5W58YP3UKUCzrri

@huacnlee
huacnlee force-pushed the fps-stop-driving-background-frames branch from 767b44a to c262e3e Compare September 4, 2026 15:28
@huacnlee huacnlee changed the title fps: Stop the HUD from driving the frame loop by default fps: Derive the headline rate from frame cost instead of driving the frame loop Sep 4, 2026
@huacnlee
huacnlee force-pushed the fps-stop-driving-background-frames branch from c262e3e to 527f19c Compare September 4, 2026 15:38
huacnlee and others added 4 commits September 4, 2026 23:57
…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
huacnlee force-pushed the fps-stop-driving-background-frames branch from 527f19c to 54a522e Compare September 4, 2026 15:57
@huacnlee
huacnlee merged commit b586fad into main Sep 4, 2026
9 checks passed
@huacnlee
huacnlee deleted the fps-stop-driving-background-frames branch September 4, 2026 16:08
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant