Skip to content

android: A52 — release limited frames on the vblank time grid, not on every Nth wakeup - #22

Merged
utkarshdalal merged 1 commit into
gamenativefrom
fps-limit-time-grid
Oct 31, 2026
Merged

utkarshdalal merged 1 commit into
gamenativefrom
fps-limit-time-grid

Conversation

@utkarshdalal

Copy link
Copy Markdown

Rebased replacement for #21, which GitHub closed when its base branch (fix/gamescope-resolution-scanout, merged as #20) was deleted. One patch, 0050-A52-fps-limit-on-the-vblank-time-grid.patch, on top of A51. The latch-grid snap that #21 carried for a while is gone: the grid is the nested refresh and the target alone.

What this fixes

The non-VRR frame-rate limit counts compositor wakeups: a window gets its frame callback on every refresh / targetth wakeup (steamcompmgr_should_vblank_window, vblank_idx % nVblankDivisor). That equals every Nth vblank only while no wakeup is ever late. CVBlankTimer::GetNextVBlank steps to the next vblank after now, so a wakeup that lands more than one vblank late skips the vblanks in between, and vblank_idx never counts them. A session runs on the 1200 Hz grid (-r 1200), where a wakeup is late by a vblank after 0.83 ms of scheduling delay, so every late wakeup holds the next release a whole vblank longer and the cap lands below the target in proportion to the wakeups lost.

The change

The limiter keeps a release grid in time: the next release is the previous one plus the target period, and a frame is released on the first vblank whose nominal time (VBlankTime::schedule.ulTargetVBlank) reaches it, with half a vblank of tolerance for the anchor moving. A late wakeup delays that one release by the lateness and the grid absorbs it on the next, so the average rate is the target. A stall longer than a period is a lost frame, never a debt repaid with back-to-back releases. The VRR path is untouched.

Measured (Retroid Pocket 6, 8 Gen 2 / Adreno 740, Android 13, SteamWorld Dig 2 windowed, GL via zink, cap 60, payload 20261030.3 with only bin/gamescope swapped)

Frames counted at SurfaceFlinger (dumpsys SurfaceFlinger --latency), 30 s per run, same in-game scene; the app's on-screen counter agreed in every run.

gamescope panel fps 1200 Hz interval histogram
shipped (20261030.3) 120 Hz 54.55 20x 1162, 30x 430, 10x 141, 40x 34
shipped, fresh session 120 Hz 54.49 20x 1283, 30x 393, 10x 74, 40x 22
shipped 60 Hz 53.67 20x 1545, 40x 206
A52 120 Hz 59.97 20x 1382, 10x 292, 30x 268, 40x 15
A52, 4 min later 120 Hz 59.90 20x 1364, 10x 300, 30x 271, 40x 18
A52 60 Hz 59.71 20x 1925, 40x 9

Swapped shipped → A52 → shipped → A52; it reproduced both ways. The loss follows the 1200 Hz nested grid, not the panel rate (same ~10 % at 60 Hz and at 120 Hz). A52 held 60 with the app's power tuner at minimum clocks (prime core 595 MHz), so CPU frequency is not the cause. At 120 Hz, A52's releases are still not phase-locked to the panel (the 10x/30x pairs); that is a separate pacing matter, not the cap.

… every Nth wakeup

The non-VRR frame-rate limit counted compositor wakeups: a window got its frame
callback on every (refresh / target)th wakeup, which equals every Nth vblank only
while no wakeup is ever late. The timer re-arms by stepping to the next vblank
after "now", so a wakeup that comes more than one vblank late skips the vblanks
in between, and those never count. On the 1200 Hz grid a session runs on
(-r 1200, the nested refresh), a wakeup is late by a vblank after 0.83 ms of
scheduling delay, and every late one holds the next frame a whole vblank
longer: a 60 cap then reads 50-57, a 30 cap 25-28, in proportion to the wakeups
lost, and it sinks further as the device throttles and the compositor thread is
scheduled later still.

The limiter now keeps a release grid in time: the next release is the previous
one plus the target period, and a frame is released on the first vblank whose
nominal time reaches it, with half a vblank of tolerance for the anchor moving.
A late wakeup delays that one release by the lateness, and the grid absorbs it
on the next, so the average rate is the target. A stall longer than a period is
a lost frame, never a debt to repay with back-to-back releases. The grid is the
nested refresh and the target alone; the panel's rate plays no part here. The
VRR path is untouched: it already schedules by time.
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