Skip to content

android: A53/A54 — vblank on the panel latch grid, compositor sleeps when idle - #23

Open
utkarshdalal wants to merge 2 commits into
gamenativefrom
vblank-on-panel-latch-grid
Open

utkarshdalal wants to merge 2 commits into
gamenativefrom
vblank-on-panel-latch-grid

Conversation

@utkarshdalal

Copy link
Copy Markdown

android: A53, A54 — vblank on the panel latch grid, compositor sleeps when idle

Branch vblank-on-panel-latch-grid (8a0f100, 5d4c927), base gamenative. Two new patches in the Android train (android/patches/gamescope/), on top of A52 (#22):

  • 0051-A53-vblank-on-the-panel-latch-grid.patch
  • 0052-A54-compositor-sleeps-when-idle.patch

Companion PRs:

  • gamenative-drm flip-stamps-on-the-panel-latch-grid: flip stamps on the latch grid, gn_panel_refresh forwarding. Needed for the phase lock.
  • GameNative app gamescope-panel-rate-vblank: passes -r <active panel Hz> instead of -r 1200. Needed for the panel-rate grid.
  • gamenative-drm present-wait-answered-at-commit-in-mailbox (ICD shim): the actual fix for the 47 fps at cap 60. Independent of this PR.

Each half degrades safely without the others (see Compatibility).

What this PR does and does not fix

  • Does: cuts gamescope's own cost. At cap 60, gamescope goes from 8.1 % to 5.9 % of a core and from 1723 to 623 wakeups/s. At cap 30, from 10.2 % to 4.2 % and 1510 to 401 wakeups/s. The steamcompmgr thread drops from 1411 to 424 voluntary wakeups/s. The SF p99 frame interval at cap 60 drops from 41.6 to 33.3 ms. No regressions in fps or composition (SurfaceFlinger stays DEVICE in every run).
  • Does not: fix the 47 fps at cap 60. It reads 46.91 with this PR alone. The 60 comes from the shim PR.
  • Wakeup target not reached. The design aimed for at most one wakeup per panel period in game. The timer itself now runs at 120 Hz, but steamcompmgr still wakes about 424 times/s at cap 60, about 3.5 per panel period. The extra wakes are nudges from the per-frame commit, flip-complete and frame-done events, which A54's re-arm gate runs on every loop pass. Idle sleep on a static menu was not measured.

Background: the gap vs X11 is frames, not resources

The shipped stack (A52, -r 1200) delivered 47.05 fps at cap 60 against 59.89 on X11. Per frame delivered, CPU and GPU cost tie (GPU 60.4 % at 47 fps vs 79.9 % at 60). The SF intervals alternate strictly between 2 and 3 panel periods. Cap 30 is exact. The cursor is composited by gamescope and SurfaceFlinger keeps DEVICE composition with no layout switch. gamescope itself costs 8.1 % of a core and about 1720 wakeups/s whatever the frame count, because the timerfd steps on the 1200 Hz grid on the steamcompmgr thread.

Correction to the earlier draft of this PR. It explained the 47 fps as a two-frames-in-flight release loop through Xwayland, gamescope's release-on-next-flip and SurfaceFlinger's release, and expected the phase lock to help. That is refuted:

  • the game's frames go from the ICD shim's own wl_surface straight to gamescope; Xwayland sits at 0 % CPU;
  • the shim forces mailbox (MESA_VK_WSI_PRESENT_MODE=mailbox), but DXVK still believes it has FIFO and waits on vkWaitForPresentKHR for every frame;
  • the shim answers that wait a fixed 2 panel periods after commit, which gives F = (a + p + 2P) / (L + 1) = 21.2 ms = 47 fps;
  • cap 30 holds because the shim's acquire gate (33.3 ms) binds, not A52.

Fix 1 could not change that: the grace is a fixed time after commit whatever gamescope's grid. The device run confirms it (46.91 fps). See the shim PR for the mechanism and the fix.

A53 — the vblank grid is the panel's latch grid (0051)

  • src/Backends/DRMBackend.cpp init_drm: under gamenative-drm, after DRM_CLIENT_CAP_ATOMIC, it sets client cap GNDRM_CLIENT_CAP_LATCH_GRID = 0x474E4C47 ("GNLG") to 1. That happens only when -r is a panel rate (500 Hz or below, or unset); a legacy -r 1200 session does not ask. One log line covers the result: accepted, rejected (errno), or legacy grid. An old daemon or a render node answers EINVAL, and that is the no-phase-lock fallback.
  • With the daemon's latch-grid stamps, gamescope's vblank is the latch minus the daemon's publish lead. The timer wakes once per latch, a fixed lead before it, so a done commit is painted and published at a fixed point just before a latch rather than at a drifting phase.
  • src/vblankmanager.{hpp,cpp}: two new methods.
    • CVBlankTimer::RequestRefreshmHz stores into an atomic and can be called from any thread.
    • ApplyPendingRefresh runs on the steamcompmgr thread. It moves g_nNestedRefresh, logs gn_panel_refresh applied: vblank grid N mHz (was M mHz), and is a no-op for the current value.
  • src/steamcompmgr.cpp:
    • ConVar gn_panel_refresh <Hz>, posted by the daemon through gamescope_private.execute.
      • The daemon re-posts it on every panel report, so a repeat of the current rate changes nothing: no limiter re-anchor, no log, no repaint.
      • Out-of-range values, and values sent to a legacy-grid session, are ignored and logged once per value.
      • It is applied at the top of the main loop: the A52 release point is reset and the timer is re-armed on the new grid.
    • The limited refresh cycle handed to clients was refresh * (refresh / target) in whole vblanks. That truncates on a coarse grid: 40 fps on 60 Hz read as 16.7 ms instead of 25. It is now the target's period, which matches A52's time grid.
  • src/wlserver.cpp gamescope_private_execute: a successful command calls force_repaint(), since scaling_filter, scaling_sharpness and debug_force_repaint change the next frame with no commit behind them. gn_panel_refresh is the exception.
  • Flip stamps ahead of now. A latch-grid stamp can be up to one panel period plus the lead ahead of the moment the event arrives; the old timeline was at most 0.83 ms ahead. Every reader was checked:
    • GetNextVBlank only steps forward. A stamp ahead skips the vblank the flip already occupies, which is correct.
    • mangoapp's frametime uses stamp-to-stamp deltas and already drops a stamp older than the last one.
    • The page_flip_handler delta is a debug print.
    • Nothing subtracts the stamp from now.

A52 stays exact at the panel rate:

Cap Panel Release interval Notes
60 120 Hz 16,666,666 ns = 2 × 8,333,333 releases every 2nd tick
30 120 Hz 33,333,333 ns releases every 4th tick, 1 ns/period drift against a 4.17 ms tolerance
60 60 Hz — refresh <= target makes the limiter inert, so every tick releases
30 60 Hz — releases every 2nd tick

A54 — the compositor stops waking when nothing is pending (0052)

  • src/steamcompmgr.cpp:
    • Re-arm gate. The unconditional re-arm after every timer wakeup is replaced by steamcompmgr_rearm_vblank. It runs on every loop pass, so a nudge from a commit, X event or input arms the next grid point.
    • What counts as pending (steamcompmgr_vblank_has_work): a repaint of any kind or a forwarded VR overlay; a focus fading out; a flip in flight; a window with a done commit whose frame callback has not gone out (steamcompmgr_any_window_owes_frame_done), or a done commit held for a later vblank; a shown cursor before its auto-hide time (plus 100 ms); VRR active or PipeWire streaming.
    • When it stops. After vblank_idle_ticks timer wakeups with none of that (new ConVar, default 2), the timer stays off. vblank_idle_ticks 0 restores the old behaviour. The flip handler still re-arms on every flip.
    • Commits done while idle take their earliest present time from the next grid point rather than a stale target (steamcompmgr_next_refresh_time). The present margin saturates at 0 instead of wrapping.
    • Debug logging. With vblank_debug, idle/resume transitions are logged, rate-limited.
  • src/wlserver.cpp: new wlserver_request_repaint() sets hasRepaint and nudges once per painted frame. It replaces every bare hasRepaint = true there: cursor motion and buttons (wlserver_oncursorevent), wlserver_mousehide, the set_look ConCommand, gamescope_control set_look / unset_look.
  • src/steamcompmgr_shared.hpp CScreenshotManager::TakeScreenshot: nudges.

Before / after

Setup, every column. Retroid Pocket 6 (Adreno 740 class, 1080x1920 panel at 120 Hz, landscape), Dave the Diver (1868140), DXVK 2.7.1-gplasync, proton-10.0-arm64ec-2, container 1280x720, FPS limiter as listed, the same boat-deck scene. Segments are 180 s (launch 4: 60-120 s). "SF" = the game's SurfaceFlinger layer histogram; "PowerMetrics" = the app's own counter (5 s samples). gamescope-xwm is the steamcompmgr thread.

Cap 60, cursor hidden X11 Shipped gamescope (A52, -r 1200) Fix 1 Fix 1 + shim fix
fps, SF 59.89 47.05 46.91 59.13 (47 s segment)
fps, PowerMetrics 59.9 47.1 46.9 59.97 (59.3-60.3)
2x / 3x panel-period share, SF 95.2 / 2.5 % 41.5 / 41.3 % 43.3 / 45.6 % 94.2 / 2.7 %
frame interval p95 / p99, SF, ms 16.7 / 25.0 33.3 / 41.6 33.3 / 33.3 16.7 / 25.0
game process CPU, % of a core 176.1 127.7 128.4 135.0 (fix 1 + shim + cursor plane run, 60 s)
gamescope CPU, % of a core — 8.1 5.9 6.6 (same run)
gamescope wakeups/s — 1723 623 not captured (segment cut)
gamescope-xwm voluntary wakeups/s — 1411 424 ~520 (same run)
GPU busy %, PowerMetrics 79.9 60.4 59.8 77.3
CPU temp °C, PowerMetrics mean 76.5 69.8 66.3 73.7
Cap 30, cursor hidden X11 Shipped gamescope (A52, -r 1200) Fix 1 Fix 1 + shim fix
fps, SF not run 30.00 30.00 not run
2x / 3x / 4x share, SF not run 0.1 / 7.0 / 85.4 % 0.1 / 5.3 / 88.9 % not run
gamescope CPU, % of a core — 10.2 4.2 not run
gamescope wakeups/s — 1510 401 not run
gamescope-xwm voluntary wakeups/s — 1281 269 not run
GPU busy %, PowerMetrics not run 38.5 38.1 not run
CPU temp °C, PowerMetrics mean not run 61.9 63.9 not run

The fix 1 + shim fix column: SF fps and histogram from a 47 s cap-60 segment (launch 4); PowerMetrics 59.97 fps over 143 samples; game/gamescope CPU from the 60 s hidden-cursor segment of the cursor-plane run (fix 1 + shim + cursor layer, cursor hidden), where the SF fps was 60.00. Temperatures are loose: the X11 run started hot (CPU zone 92.7 °C at segment start).

Cap 60 with the cursor moving (fix 1 alone): SF 70.78 fps vs 61.75 shipped, gamescope 8.7 % / 1122 wakeups/s vs 10.8 % / 1747. The layer count includes cursor-only composites, so it is not a game frame rate.

Testing

  • CI build 41187036207.
  • Device: RP6, app debug build from gamescope-panel-rate-vblank (daemon 0630e1a in jniLibs), payload 20261030.3 with this gamescope binary swapped in. Launch line shows -r 120; gamescope logs drm: vblank: daemon latch-grid stamps on (GNLG accepted, grid 120000 mHz) and the daemon vblank: latch-grid flip stamps on (lead 2000us).
  • Not exercised on the device: the old-daemon EINVAL fallback, the legacy -r 1200 path with the new daemon, a 120 ↔ 60 Hz switch (gn_panel_refresh applied), a 60 Hz panel, idle sleep on a static menu, LSFG.

Compatibility

Build pair Behaviour
New app (-r panel) + new daemon + this gamescope latch-locked grid, timer at the panel rate, idle sleep
New app + old daemon GNLG rejected with EINVAL (one log line); timer at the panel rate, no phase lock; idle sleep
Old app (-r 1200) + new daemon no GNLG request, gn_panel_refresh ignored (logged once): the 1200 Hz timeline as today, plus idle sleep
Old gamescope + new daemon daemon keeps its 1200 Hz timeline (no cap set)

Shipping needs the app PR (-r = panel rate) and the daemon PR (phase lock), plus a gamescope payload containing this binary and the shim fix.

Risks

  • Timers that only ran because the loop ticked now wait for the next activity while idle: the VR session recheck, the dynamic-refresh 600 ms debounce and the mangoapp teardown grace. None of them changes the Android session's output.
  • A window with a done commit but no current surface keeps the loop awake. That is today's behaviour, not a regression.
  • During a 120 → 60 switch, until gn_panel_refresh lands, gamescope ticks at 120 on a 60 grid: extra work, nothing visible. During 60 → 120 it stays capped at 60 until the ConVar arrives.

Utkarsh Dalal added 2 commits October 31, 2026 14:30
… when idle

A53 asks gamenative-drm for page-flip stamps on SurfaceFlinger's latch grid
(client cap GNLG, 0x474E4C47) when -r is a panel rate, takes live panel-rate
changes through the gn_panel_refresh ConVar, gives limited clients the
target's period as their refresh cycle (the whole-vblank divisor truncated on
a coarse grid) and repaints once after a gamescope_private command.

A54 stops re-arming the vblank timer after every wakeup: it re-arms on every
loop pass while a repaint, a flip, a frame callback, a held commit, a fade or
a visible cursor's auto-hide is pending, and stops after vblank_idle_ticks
(default 2) idle wakeups. Every out-of-loop repaint request now nudges.
… of now are safe

gn_panel_refresh is re-posted by the daemon on every panel report: a repeat of
the current rate changes nothing and does not repaint, and a refused value is
logged once. A commit done while the compositor is idle takes its earliest
present time from the next grid point, and the present margin no longer
wraps when the earliest present time is behind the latch 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