android: A58 — under the FPS limiter, hold a second mailbox commit for the next vblank
Branch gn-mailbox-hold-under-limiter (9fad070), base gamenative. Adds one patch, android/patches/gamescope/0054-A58-mailbox-hold-under-limiter.patch. The number is 0054 because 0052 belongs to #25 (A56) and #23 (A54), and 0053/A57 to the closed #30. A58 applies over the 0001-0051 series alone. CI build: run 41567798845 (9fad070), green. Binary sha256 4feeadc116518bd7ff2c4f158022f054106f8607d0f451bf6d4ff36560f7bd94. H1 and H2 below ran on the first revision, 52e1b1f (47d8fe6c…, run 41564086468), which had no carry bound. The baseline is the shipped 65d024fc…, which is the CI build of the same series without A58.
Pairs with GameNative/gamenative-drm#46.
Problem
Under the FPS limiter, gamescope's mailbox merges a second commit into one composite window. A guest pacing itself just under the refresh rate hits this about once a second. The case here is DTD with in-game VSync off: Unity targets ~59.3 fps, the cap is 60 and the panel runs at 59.95-59.98 Hz.
- Non-FIFO done commits are latched on every steamcompmgr pass, not only at vblank (
handle_done_commits_xwayland/handle_done_commits_xdg). - When two commits complete before the same paint, the second replaces the first in
HeldCommits[BASE]. The first is released and its presentation feedback is discarded. - The panel then shows the previous frame twice. Without this patch, the shim-only mailbox attempts in GameNative/gamenative-drm#46 measured 58.3-59.0 fps with 2.7-2.9 % doubled intervals.
The client cannot avoid the merge, because it cannot predict gamescope's latch point:
- The latch point is gamescope's vblank wakeup: the target vblank minus the rolling draw time minus the red zone (
src/vblankmanager.cpp142-199). - The rolling draw time holds peaks. A draw more than half a red zone above it replaces it at once (164-165), and it then decays per wakeup (167).
- "Draw time" runs from wakeup to page flip queued (
src/Backends/DRMBackend.cpp~4465). On gamenative-drm that includes the commit to the daemon, so it is spiky. - So the latch point moves by milliseconds and jumps on any spike. All the client gets back is
presented/discarded, one inequality per frame. - GameNative/gamenative-drm#46 tried booking late frames (B5) and a commit-window queue at a guessed boundary (B6). Both still had ~0.8-1 commit/s merged.
Only gamescope knows its latch point, so the hold has to live in gamescope.
Change
All of this is gamenative-only, behind the convar gn_mailbox_hold. It applies only while the FPS limiter is engaged for the window: a target FPS is set, steamcompmgr_window_should_limit_fps is true and VRR is off. For non-FIFO done commits:
- Latch: the first commit to complete for the coming paint is latched as today.
- Hold: a newer commit is kept in the done list instead of replacing it. It is latched by the first done-commit pass of the next vblank cycle; the vblank pass always runs one, so a held commit never waits for the client.
- Replace: a third commit replaces the held one. The replaced commit is dropped from the window's
commit_queue;~commit_tthen releases its buffer and sendsdiscarded, exactly as a mailbox replace does today.
Properties:
- The queue is one deep and the client is never blocked.
- A window that delivers at or below the refresh rate never loses a frame.
- A held commit's earliest present time is stamped by the pass that latches it, so presentation feedback reports the vblank it was actually shown on.
- Frame callbacks are unchanged: one per vblank, after a latch.
- FIFO commits are untouched, as are unlimited windows and VRR. With a shim that declares FIFO (today's payload), A58 changes nothing.
The carry is bounded (9fad070)
A held commit is shown one paint late. A client that keeps delivering exactly one commit per vblank never drains that delay:
- Every new commit finds its paint already taken by the carried one, so it is carried in turn.
- The result is a standing one-frame delay that the frame rate does not show. H2 hit this: every commit was carried, from a hitch during loading onwards.
So after gn_mailbox_hold_max_carry paints in a row (default 8, about 133 ms) have shown a carried commit, the next fresh commit is latched at once and replaces the carried one:
- This is a mailbox replace: one frame is dropped and the window is back on its own paint.
- A client below the refresh rate (H1) drains the carry by itself at its next gap, as before.
0makes the carry unbounded, which is the 52e1b1f behaviour.1latches every fresh commit at once whenever its paint holds a carried one. That keeps no carry across a second paint, which makes the hold close to plain mailbox for a client near the refresh rate.
Implementation:
- A per-window marker,
gnMailboxLatchedForPaint(steamcompmgr_shared.hpp), records which paint the window's last non-FIFO commit was latched for. It holdsvblank_idx + 1;vblank_idxadvances after the vblank pass's done-commit handling, so every pass with the same index feeds the same paint. gn_mailbox_hold_entry()is called from both done-commit handlers, after the FIFO check.
Switches
gn_mailbox_hold: -1 = auto, the default (on whenGAMENATIVE_DRM_SOCKETis set), 0 = off, 1 = on.- The env
gamescope_gn_mailbox_hold=0/1overrides it through the existing convar env mapping. - On Android, the property
debug.gamescope.gn_mailbox_hold=0/1wins when set. It is settable with an unrootedadb shell setprop. gn_mailbox_hold_max_carry(default 8, 0 = unbounded) follows the same pattern: envgamescope_gn_mailbox_hold_max_carry, propertydebug.gamescope.gn_mailbox_hold_max_carry.- The value is resolved once, at the first done-commit pass.
Logs (xwm scope)
- Once at start:
gn_mailbox_hold=<0|1> source=<default|env|prop> max_carry=N. - About every 600 vblanks, while the hold does anything:
gn_mailbox_hold: deferred=N carried_paints=C max_run=R collapsed=X replaced=M over K vblanks.deferred: commits held for a later paint, each counted once.carried_paints: paints that showed a commit one paint late. This is the latency the hold adds.max_run: the longest run of carried paints in a row.collapsed: carries ended by the bound.replaced: held commits dropped for a newer one.
The 52e1b1f counter
52e1b1f logged held=N, counting a held commit once for every compositor pass in which it stayed held. A held commit stays held for at least two passes: the pass it completes in, and the vblank pass of the paint it was held from. It is latched by the first pass after vblank_idx advances.
So H2's steady held=1200 over 600 vblanks means every one of the ~600 commits was deferred once. That is a real one-paint delay on every frame, not a counting artefact. Likewise, H1's held=38-164 means about 19-82 deferrals per 600 vblanks.
There is no off-by-one on the marker. It is set to vblank_idx + 1 at latch, and every pass after the vblank pass computes vblank_idx + 2, so the first commit of a new paint is never mistaken for an already-latched one. It was the carried commit that took the paint.
Device verification (Retroid Pocket 6)
H1 setup:
- DTD title scene, in-game VSync off (
requested=immediate), cap 60, 59.95 Hz panel. - A58, with the gamenative-drm GameNative/gamenative-drm#46 shim at
b084eadand its test switchdebug.gndrm.mailbox_at_rate=1, which declares mailbox at the display's rate with the vblank-grid gate kept. debug.gamescope.gn_mailbox_hold=1.
H1 result:
- Census:
declared=mailbox requested=immediate source=env-at-rate cap=60 gate=vsync,declared_fifo=0. - gamescope:
gn_mailbox_hold=1 source=prop. - Holds in steady state:
held=38-164 replaced=0per 600 vblanks, on the 52e1b1f counter. All 5 replacements in the run happened during loading.
H2 setup: the same stack (52e1b1f), with in-game VSync on (requested=fifo) and a 59.98 Hz panel.
H2 result:
- Census:
declared=mailbox requested=fifo source=env-at-rate cap=60 gate=vsync. - Shim: +600 acquires/commits/releases per 10 s,
gate_holds+600,late0-4,skipped0,slot_waits0,commit_lat_us~6.0 ms. - Daemon: 600/600,
late=0, margin p50 +3.45 ms, lead ~2.2 ms. - gamescope:
gn_mailbox_hold=1 source=prop,replaced=0in steady state. held=1200per 600 vblanks from the fourth window on, meaning every commit was carried one paint (see above). 9fad070 bounds this.- Shim commits 592-594 per 10 s against daemon publishes 592-594, so no commit is merged.
slot_waits=0.
| run | stack | SurfaceFlinger, 60 s | doubled intervals |
|---|---|---|---|
| H1 | mailbox + vblank-grid gate, A58 hold | 59.269 fps | 41/3556 (1.2 %) |
| master | FIFO declared, gamescope's limiter | 59.22 fps | 1.3 % |
| GameNative/gamenative-drm#46 B4-B6 | shim-only mailbox (gate / late booking / commit window), no A58 | 58.3-59.0 fps | 2.7-2.9 % |
| H2 | DTD, in-game VSync on, mailbox + gate, A58 52e1b1f | 59.984 fps, 3599 singles | 0 (but every commit shown one paint late; see above) |
| H2' | same, A58 9fad070 (carry bound 8) | pending | pending (expect carried_paints near 0 after one collapsed) |
| H1' | as H1, A58 9fad070 | pending | pending (checks that the bound keeps H1 lossless) |
The remaining shortfall from 59.95 is the game's own ~59.3 fps self-pacing, which is the same as under master's FIFO.
Buffer pressure
With the hold, a window can have one buffer on screen, one latched and one held. A client with only three swapchain images could then wait for a release now and then. In H1 the shim's slot_waits stayed at 0. Clients that render directly to Xwayland with a three-image swapchain have not been measured.
Pairing with gamenative-drm GameNative/gamenative-drm#46
GameNative/gamenative-drm#46 makes the shim declare the container's present mode on the override route. Its one exception is a cap at the display's rate, where it declares FIFO because the shim alone cannot keep two commits out of one composite. With A58 in the payload, that exception can be dropped: gamescope and the shim ship in the same archive. Until then, GameNative/gamenative-drm#46 keeps the exception by default, and debug.gndrm.mailbox_at_rate turns on mailbox at the display's rate for testing.
@joshuatam, I'd value your read on the hold point. Is latching the held commit at the next cycle's first done-commit pass right, and should the hold also cover windows that are not the focus base plane?