gamescope: frames on the display's real vsync, FIFO under a cap, compositor and game placed by cluster, tuner counts repeated frames (payload 20261102.2)

Merged
UT
8d ago
+1735-18

Description

This delivers GameNative/gamenative-drm#44 (gamescope frames on the display's real vsync, FIFO under a cap, present wait from wp_presentation). Supersedes #2319.

  • jniLibs/arm64-v8a/libgamenative_drm.so is rebuilt from gamenative-drm#44 at 5831f1e (real-vsync-fifo-present-wait, with the review fixes): sha256 9d3619681d9d5e382f78f2953c98ba1eff645334df8b7af8e88bc166ea774828 (5144ef9c… in the APK after stripping). The daemon stamps gamescope's flip events on the display's measured vsync, read from Choreographer and placed on SurfaceFlinger's latch grid minus a measured lead, as a KMS driver stamps the hardware vblank. Before, they came from a 60.000 Hz walk that slid against the 59.97-59.99 Hz panel.
  • The drm review fixes are in (daemon and shim from #44 at 5831f1e, payload 20261102.3). The re-anchoring device runs on the RP6 and the Odin 3 are pending.
  • GamescopePayload.VERSION = 20261102.3.
  • Compositor chain placed off the low-capacity cores, on PServer devices only (PowerManager.applyCompositorAffinity, GamescopeServerComponent, TaskAffinity + libgnaffinity.so, a 4 KB sched_setaffinity wrapper, source and build script under app/src/main/cpp/gnaffinity). Placement runs only on devices with a PServer driver and its cluster grouping, whatever the container's power control toggle says. Dave the Diver's RP6 profile has power control off and needs the chain placed. Clock control stays behind the toggle; placement only reads the grouping and moves this app's own threads. A cluster is low-capacity when its kernel cpu_capacity is below 0.6 × the device's largest. When the driver only has max frequencies (no cpu_capacity), nothing is placed and the game rule is unchanged. The chain is pinned only when at least 4 cores remain outside the low-capacity clusters. Every thread of gamescope, Xwayland and the app's gndrm* daemon threads goes on all cores outside the low-capacity clusters. A device with no low-capacity cluster keeps the chain on all cores. On the RP6 (Snapdragon 8 Gen 2), the little cores 0-2 have about a third of the prime core's capacity, so the chain goes on 3-7. On the Odin 3 (Snapdragon 8 Elite: 6 cores at 792, 2 at 1024), no cluster is low-capacity, so the chain is not pinned. The chain is placed when the session connects, when power control starts, on every activity resume and after every game pin in a gamescope session, and it is released at session end only if it was placed. Same-uid sched_setaffinity, no privilege, no new setting; X11 sessions untouched. Measured need: on the shipping payload without it, the daemon's commit thread waited 0.1-8.6 ms for a CPU on cores 0-2 (shared with SurfaceFlinger, the composer HAL and audio), and 22 of 36 repeated frames per minute were frames ready on time but posted after the latch. One-variable A/B on the same build and scene (Dave the Diver, in-game VSync on, cap 60, 60 Hz, 60 s): without 59.37 fps / 22 latched-late / daemon late 41; with 59.92 fps / 0 latched-late / daemon late 0. A gamescope nice -20 patch alone and a daemon stamp change alone changed nothing (59.39 and 59.36).

Power tuner: repeated frames count against "steady"; game pinned to the clusters above the little one

What the tuner got wrong. On a Retroid Pocket 6 (Snapdragon 8 Gen 2: c0-2 little, c3-6 big, c7 prime) with SteamWorld Dig 2 under gamescope (cap 60, 60 Hz panel), the ClusterTuner treated 59-60 fps with a p95 of 17 ms as steady and kept trimming. It ended with the perf cluster c3-6 pinned at 499 MHz (min = max) and c7 capped at 1.25-1.7 GHz. Meanwhile PowerManager pinned the game to "all cores but the two slowest" (2-7), so the main thread usually ran on c2 (a little core) or a 0.5 GHz big core. There a frame costs about 11 ms, against 4.4 ms on the prime core, and the game repeats a frame every few seconds. Neither fps nor p95 moves enough to show it.

Change 1: repeated frames count against "steady".

  • In gamescope sessions without frame generation, the metrics collector counts repeats from the app's own frame ring (the one the HUD fps uses, fed by onFramePublished) over the last 10 s. A repeat is an interval longer than the expected one plus half a display period. The expected interval is display period × max(1, floor(refresh / cap)), gamescope's limiter rule, using the rate the session reports to the daemon. The count is passed to the tuner as repeatedFrameCount; X11 sessions and frame generation report none, so the tuner there is as on master. The window restarts when the cap or the rate changes.
  • Any repeat in the window blocks the next trim and holds back a pending warm-start state.
  • A repeat during the watch that follows a trim gives that trim back (and freezes the domain), the same undo an fps drop triggers.
  • Outside the watch, 2 or more repeats in 10 s step back the latest trim made this session; with no trim this session, nothing is raised. Step-backs are limited to one per 10 s window, so one burst steps back once. After 3 step-backs that did not cut the repeats in the window after them, repeats only hold for the rest of the session. A cap change drops a step-back in flight.
  • The fps and p95 inputs are unchanged.

Change 2: game core set off the low-capacity clusters.

  • Only in gamescope sessions on PServer devices (the game pin itself still runs only with power control on, as on master), the game is pinned to every core outside the low-capacity clusters (capacity below 0.6 × the device max, from PServerDriver's grouping): 3-7 on the RP6. X11 sessions keep "all cores but the 2 slowest" exactly.
  • A device with no low-capacity cluster (Odin 3) keeps "all but the 2 slowest". The same rule applies when fewer than 4 cores are outside the low-capacity clusters (for example 6 little cores + 2 big), or when there is only one cluster.
  • wineserver, winhandler, services and PulseAudio stay on the 2 slowest cores as before. The pin and winhandler affinity lines are unchanged.

Neither change adds a setting.

Measurement. RP6, SWD2, gamescope cap 60 @ 60 Hz, power control on, payload 20261102.2, same scene (mine entrance), 180 s settle + 90 s capture. Measured on 04c5e7397, before the fixes after review (at cap 60 @ 60 Hz the repeat threshold is 25 ms in both).

Control (compositor affinity, old tuner)Tuner change
SurfaceFlinger fps (90 s)59.3359.85
Repeats / 90 s57 (42 missing game frames, 15 latched late)11 (2 game, 9 latched late)
Game allowed cores2-73-7
Main thread on c7 (10 s samples)1 / 3928 / 39
Perf cluster cap during capture614 MHz floor730 MHz (77 s), 614 (10), 499 (5)
Tuner40 trims, 6 undos26 trims, 13 undos (7 repeat-undos), 136 repeat-holds; 6 trims to perf 499 MHz, each stepped back after repeats

Caveat: the run with this PR started from the tuner state file the control run left behind (prime 1.0 / perf 0.61 GHz, against 2.73 / 2.32 GHz for the control), and the prime cap was still being trimmed during the capture.

Reference, power control off: 59.968 fps, 1 repeat in 90 s.

Open knobs.

  • Step-back threshold: 2 repeats per 10 s (chosen because power control off gives about 1 per 90 s, so 2 in one window rarely happens on its own) against 1 per 10 s. A single repeat already blocks trims.
  • The tuner can still trim the perf cluster to its 499 MHz floor before repeats bring it back. A perf-cluster floor during gamescope sessions would stop those dips.

Log lines to look for.

  • PServerDriver: CPU cluster capacities (cpu_capacity): {EFFICIENCY=…, PERFORMANCE=…, PRIME=…} at startup (max_freq when the kernel reports no capacity)
  • RP6: PowerManager: Game core set 3, 4, 5, 6, 7 low-capacity [0, 1, 2] left out (gamescope; clusters efficiency=[0, 1, 2]@… performance=[3, 4, 5, 6]@… prime=[7]@1024 (cpu_capacity), background=0, 1) and PowerManager: Applied affinity 3, 4, 5, 6, 7 (mask 0xf8) to gamescope … (gamescope session start; clusters …)
  • Odin 3: PowerManager: Game core set 2, 3, 4, 5, 6, 7 (unchanged rule) no low-capacity cluster (gamescope; …) and PowerManager: no low-capacity cluster; gamescope chain left on all cores (…), with no Applied affinity … to gamescope line
  • RP6 with power control off in the profile: PowerManager: gamescope chain placed on 3-7 (power control off: placement only) (…) followed by the Applied affinity 3, 4, 5, 6, 7 (mask 0xf8) line
  • No PServer driver: PowerManager: gamescope chain left on all cores (no PServer driver) (…) once per session, with no Applied affinity … to gamescope line
  • No cpu_capacity: PowerManager: cluster capacity unknown (max_freq only); placement skipped (…) and Game core set … (unchanged rule) cluster capacity unknown (max_freq only); placement skipped
  • 6 little + 2 big: PowerManager: only 2 core(s) outside the low-capacity cluster [0, 1, 2, 3, 4, 5]; gamescope chain left on all cores
  • X11: PowerManager: Game core set 2, 3, 4, 5, 6, 7 (unchanged rule) (x11; …)
  • PowerManager: Applied affinity … (activity resume; …) after returning to the app (power control on, low-capacity cluster present)
  • PowerTuner: hold repeat-hold repeated=N in 10s step-back off: … once step-backs are off for the session
  • PowerTuner: hold repeat-hold repeated=N in 10s: …
  • PowerTuner: undo repeat-undo:<prime|perf|gpu> repeated=N in 10s: …
  • PowerTuner: undo undo:<domain> repeated=N in 10s (repeat during a trim watch)
  • repeated=N in 10s on every applied tuner line and on the periodic PowerMetrics line (repeated=- on X11 / frame generation)
  • "repeated":N in the tuner jsonl

Tests. 30 new unit tests, all passing (the newest: warm-start step-back and a 10-step warm-start floor fully stepped back under constant repeats in TunerRepeatedFramesTest, cap 120 on a 60 Hz panel in GameCoreSetTest):

  • TunerRepeatedFramesTest (9): trim on a clean render, hold on one repeat, undo on a repeat during the watch, one step-back per window, a single repeat holds, no raise without a trim this session, step-backs off after 3 that did not help, step-backs that help stay on, a cap change drops a step-back in flight
  • RepeatedFrameCountTest (9): thresholds for cap 60 @ 60 (25 ms), 60 @ 120 (20.8), 30 @ 60 (41.7), 45 @ 60 (25), 40 @ 120 (29.2), cap above refresh and no cap (one period); an extra vblank counts once and ±2 ms jitter counts nothing in each; counting only in gamescope sessions without frame generation (X11, LSFG x2/x3 off)
  • GameCoreSetTest (20), with the real layouts: RP6 with the profile's power control off → chain placed on 3-7; no PServer driver → no call; max-frequency capacities → no placement, game rule unchanged; 6 little + 2 big → chain not pinned; RP6 → chain 3-7, game 3-7 (gamescope) / 2-7 (x11); Odin 3 (6×792 + 2×1024) → chain not pinned, game 2-7 (unchanged rule); 4+4 (4×400 + 4×1024) → chain 4-7, game 4-7; 6 little + 2 big and a single cluster keep the unchanged rule. RP6 by max frequency (2016/3187 MHz = 0.63) is not low-capacity. Power control off: no sched_setaffinity call and no chain lookup (injected fakes fail on any call), and release after no placement makes no call. RP6 placement sends mask 0xf8 to every chain thread and releases with 0xff. The tuner target equals the cap on X11 for every cap and panel rate.
  • TunerMasterEquivalenceTest (1): master's TunerDecisionEngine is copied verbatim into the test sources. 200 seeded sessions × 600 cycles (all 4 strategies, cap changes, releaseHolds, warm starts noted) without repeats decide identically to master on every cycle (action, domain, steps, reason, steady count, frozen set). A one-word change to a reason string makes it fail.

Payload 20261102.3

gamescope-gamenative-20261102.3.tzst, 14295744 B, sha256 d102d40b4b747e1653b4437c5d43f89679c4e8ffb8e0ad9526710d6d1951a4ff. It is uploaded and verified on both CDN hosts. It is 20261101.6 with only the shim replaced; every other member is byte-identical.

membersha256from
lib/libadrenotools_gamescope.so9e181cd76b5a23e0b614dea39219ee0349efba23f8c1d20b0b5e2082b1d173b5gamenative-drm#44 at 5831f1e (build string 2026110208325831f1)
bin/gamescope (unchanged)65d024fcee6af1b1f81b2281cc5223ee07615daa05aa8d9ecebe865526f8df69as in 20261101.6
bin/Xwayland (unchanged)4dded8566661423ee3c91490523d96240c85906bd00e0774adc912df9dd38698as in 20261101.6

The new shim does two things:

  • it declares FIFO to gamescope while a cap is set, as gamescope's WSI layer does on a PC, so gamescope's limiter is the cap;
  • it answers vkWaitForPresentKHR from gamescope's wp_presentation feedback, as Mesa does, instead of from the buffer release a vblank later.

Measured

Retroid Pocket 6 (Android 13, Adreno 740), gamescope, cap 60, 60 Hz mode, same scene per game, frames counted at SurfaceFlinger.

GameBeforeAfter
Dave the Diver (DXVK), in-game VSync on35.3 fps (every other frame two vblanks)59.95 at a 59.99 Hz panel; 0 discarded / 0 fallback present-wait answers over 120 s
Dave the Diver, VSync off95 % of vsyncs got a new frame98.8 %
SteamWorld Dig 2 (GL)57.1 fps (periodic drop zones)59.66, drop zones gone

Caveat: the "after" runs also carried extras that are not in this PR: a daemon timing-diagnostics commit, a gamescope priority patch and a compositor CPU-affinity change. The drm commits delivered here are the ones that moved these numbers. SteamWorld Dig 2's remaining ~1 % of doubled intervals is not fixed here.

Behaviour changes

  • A cap that does not divide the display's rate (40 or 45 on 60 Hz) now runs at gamescope's next divisor rate (60), which is upstream gamescope's limiter. Under a cap, frames queue FIFO in gamescope as on a PC. Frame generation keeps its own path.
  • Core placement happens only on devices with a PServer driver and its cluster grouping, whatever the container's power control toggle; clock control stays behind the toggle. A cluster is low-capacity when its cpu_capacity is below 0.6 × the device max. With only max frequencies (no cpu_capacity) nothing is placed and the game rule is unchanged (cluster capacity unknown (max_freq only); placement skipped).
  • Game core set (gamescope sessions; applied when the game is pinned, which needs power control on as on master): every core outside the low-capacity clusters. RP6: 3-7 (was 2-7). 4+4 phone with littles at about 400 of 1024: 4-7. Odin 3 (6 × 792 + 2 × 1024): no low-capacity cluster, so it keeps "all but the 2 slowest" (2-7), logged (unchanged rule). The same applies when fewer than 4 cores remain or there is a single cluster. X11 sessions are unchanged.
  • Compositor chain (gamescope sessions, PServer devices, power control on or off): every core outside the low-capacity clusters, when at least 4 remain. RP6: 3-7. 4+4: 4-7. Odin 3: not pinned (no low-capacity cluster; gamescope chain left on all cores). 6 little + 2 big: not pinned. It is placed again on power control start, on activity resume and after every game pin, and released at session end only if it was placed.
  • Devices without power control (no PServer driver): nothing changes except the display-side fixes (vsync-stamped flip events, FIFO under a cap, present wait). There are no sched_setaffinity calls; the game pin and the tuner run as on master. On a PServer device with the toggle off, only the chain placement is added. Clocks, game pin and tuner stay as on master.
  • Tuner (gamescope sessions without frame generation): any repeated frame in the last 10 s blocks trims and warm-start caps; 2 or more step back the latest trim this session (warm-start caps count as trims), at most once per 10 s, until 3 step-backs have failed to cut repeats. Games that stutter on their own therefore run with fewer trims (more power) than before. X11 and frame-generation sessions tune as on master. Under gamescope the tuners aim for min(cap, panel rate).
  • First launch after the update re-downloads the whole gamescope payload (20261102.3, 14.3 MB); offline, a gamescope session cannot start until it has.

Fixes after review

  1. Repeat definition no longer assumes cap = refresh: expected interval = display period × max(1, floor(refresh / cap)), repeat = more than half a display period above it. Fixes false repeats at caps 45/50/55 on 60 Hz and missed repeats at cap 30 @ 60, 60 @ 120 and 40 @ 120.

  2. Repeats are counted only in gamescope sessions without frame generation; X11 and LSFG sessions tune as on master.

  3. A repeat step-back only undoes trims made this session (no raise fallback); 3 step-backs that did not cut the repeats turn step-backs off for the session (the hold stays); a cap or rate change restarts the repeat window and drops a step-back in flight.

  4. Cluster game core set fenced to gamescope sessions; the log line names the rule.

  5. Compositor-chain guard: all cores, logged, when fewer than 2 cores are clear of SurfaceFlinger or the fastest cluster is left out (e.g. a 0-6 SurfaceFlinger mask).

  6. Compositor placement re-applied on activity resume: a cpuset move resets the app threads' affinity before Linux 6.2.

  7. The game re-pin places the compositor chain only in gamescope sessions (it ran after every pin, X11 included).

  8. Warm-start caps enter the session's trim history when applied (most-trimmed domain stepped back first), so a state file left at the floor by the old tuner is stepped back by repeats instead of only held. Warm-start steps are exempt from the 3-ineffective cutoff: they keep stepping back, one per window, until the floor is gone or the repeats stop; trims made this session keep the cutoff.

  9. Review #4: on the gamescope route the tuners aim for min(cap, panel rate); a cap above the panel rate no longer drives them to uncapped. The cap sent to gamescope and reported in telemetry is unchanged.

  10. Core placement only on PServer devices (whatever the container toggle; clock control stays behind it), using PServerDriver's clusters with a capacity criterion (cpu_capacity below 0.6 × the device max; max-frequency-only capacities never place anything); the chain is not pinned without a low-capacity cluster (Odin 3) or with fewer than 4 cores outside it; SurfaceFlinger and /sys capacity lookups in PowerManager removed (PServerDriver reads cpu_capacity with its grouping).

Not addressed in this round: upcall jitter vs latch time (#10); warm start blocked by a single repeat (#11); stale gamescope PID selection (#12); restoring per-thread masks at session end (#13); CI rebuild of libgnaffinity.so (#14); offline payload fallback (#15).

Test steps

  1. Install the APK, open a game with the display server set to gamescope, and set the FPS limit to 60 on a 60 Hz panel. On first launch, payload 20261102.3 downloads. In logcat, expect Gamescope payload 20261102.3 ready.
  2. In logcat:
    • gndrm-daemon: vsync: flip events on the display's vsync: source=choreographer-deadline period=… (59.9xx Hz, mode 60.000 Hz);
    • every 10 s, vsync: check … publishes≈vsyncs late=0 … margin p10≈+3.0ms.
  3. In logcat, every 10 s: gn-vkshim: present mode: declared=fifo … cap=60 … feedback=on presented=… discarded=0 fallback=0.
  4. Check the rate: dumpsys SurfaceFlinger --latency over 30 s reads the panel's rate. Turning in-game VSync on in Dave the Diver no longer halves it.
  5. Regression check: frame generation x2 at cap 40 still produces 80, with declared=mailbox lsfg=1.

Recording

Type of Change

  • Bug fix
  • Performance / stability improvement
  • Compatibility improvements
  • Other (requires prior approval)

Checklist

  • If I have access to #code-changes, I have discussed this change there and it has been green-lighted. If I do not have access, I have still provided clear context in this PR. If I skip both, I accept that this change may face delays in review, may not be reviewed at all, or may be closed.
  • This change aligns with the current project scope (core functionality, stability, or performance). If not, it has been explicitly approved beforehand.
  • I have attached a recording of the change.
  • I have read and agree to the contribution guidelines in CONTRIBUTING.md.

Summary by cubic

Aligns gamescope's flip events with the display's real vsync instead of a fixed 60.000 Hz walk, switches gamescope to FIFO queuing under an FPS cap, and keeps the gamescope chain off the cores the display server uses. The power tuner also stops trimming into repeated frames, and pinned games now run only on high-capacity clusters.

  • Rebuilds libgamenative_drm.so from gamenative-drm real-vsync-fifo-present-wait: the daemon stamps flip events on the display's real vsync, read from Choreographer on SurfaceFlinger's latch grid.
  • Replaces the shim in payload 20261102.3: it declares FIFO to gamescope while a cap is set (so the cap is the limiter) and answers vkWaitForPresentKHR from wp_presentation feedback.
  • On any device with a PServer driver, pins every gamescope and Xwayland thread plus the daemon's gndrm* threads to all cores outside the low-capacity clusters (cpu_capacity below 0.6 of the device max) via libgnaffinity.so, applied at session start, power control start, activity resume and on a game re-pin, and reversed at session end. The placement does not gate on the power control toggle, only clock control does; capacities read as max frequency alone skip it. Nothing changes in an X11 session.
  • On a Retroid Pocket 6 at cap 60: Dave the Diver (DXVK, in-game VSync on) goes from 35.3 to 59.95 fps, SteamWorld Dig 2 (GL) from 57.1 to 59.66.

Behavior changes

  • A cap that doesn't divide the display's rate (e.g. 40 or 45 on 60 Hz) now runs at gamescope's next divisor rate (60).
  • Under a cap, frames queue FIFO in gamescope as on a PC; frame generation keeps its own path.
  • A cap above the panel rate is unreachable under FIFO, so the tuners aim at the panel rate in gamescope sessions; the cap sent to gamescope is unchanged.
  • The power tuner counts frame intervals half a display period or more above the expected vblank interval in the last 10 s. Any repeat blocks the next trim and fails a trim watch; two or more step the last trim back once per window.
  • Warm-start caps persisted by an earlier session enter the session's trim history, so repeats step them back like trims. A warm-start floor keeps stepping back until it is gone or the repeats stop; trims made this session stop after 3 ineffective step-backs.
  • In gamescope sessions with power control on, pinned games run only on clusters at least 0.6 of the device's max capacity (when those hold at least 4 cores), instead of all cores except the two slowest.

Summary by CodeRabbit

  • Updates
    • Updated the Gamescope payload to version 20261102.3; the app’s readiness check recognizes this version.
    • In Gamescope sessions, the tuner now respects the panel refresh rate when setting its target FPS.
  • New Features
    • CPU allocation for the Gamescope compositor is managed during a gaming session and restored when the session ends.
    • Game core selection adapts to the device’s CPU cluster layout.
    • Performance tuning tracks repeated frames in Gamescope sessions and uses them to guide trimming and recovery decisions.