Repository navigation
gamescope pacing on the display's real vsync: Choreographer flip events, FIFO under a cap, present wait from wp_presentation - #44
Conversation
…rapher The flip events were stamped on a timeline of the daemon's own: a constant walk of 1e12 / mode_refresh_mhz() from CLOCK_MONOTONIC, 16 666 666 ns on a "60 Hz" mode. gamescope re-arms its composite timer from every stamp (DRMBackend.cpp page_flip_handler -> MarkVBlank; vblankmanager.cpp: wakeup = stamp + interval - draw time - red zone), so the stamps decide where in the display's cycle it commits. The Retroid Pocket 6 panel latches at 59.97-59.99 Hz, so the walk slid across SurfaceFlinger's latch every half minute or so; while the composite sat on the latch edge, ~1 ms of post jitter put two buffers in one latch window and SurfaceFlinger dropped the older one (HUD 58.7-59.3 fps, SurfaceFlinger 54-57, at a 60 cap). A KMS driver stamps the hardware vblank and none of this happens on a PC. The stamps now follow the display. A thread with an ALooper keeps one Choreographer callback posted per vsync while a session runs (AChoreographer_postVsyncCallback on API 33+, postFrameCallback64 on 29-32) and parks between sessions. The daemon keeps the last 32 vsyncs, counts each step in whole periods (a missed callback is two steps, not one long one) and fits the period by least squares over them, so the period is the measured one and never the app's integer Hz. A step that is not a whole number of periods, or a full ring with no single-period step, re-seeds the fit, so a live mode switch is followed; mode_refresh_mhz() still serves the advertised rate to the connector. Which instant is stamped: KMS has one instant per cycle, the vblank, which is both the point a flip must be queued by and the start of scan-out. Android splits it into SurfaceFlinger's latch and the present one or more periods later. gamescope treats the stamp as the point its commit has to beat by its red zone (1.65 ms) plus its rolling draw time, so the stamps sit on the latch grid: the preferred frame timeline's deadline on API 33+, moved by where SurfaceFlinger actually latched the daemon's publishes against it (latch-after-deadline, smoothed from every publish's ASurfaceTransactionStats_getLatchTime). A present-time stamp would aim gamescope's margin at an instant whose phase to the latch is the compositor's work duration. Each publish claims the first latch at or after it, the latch that takes it, even when that is after Choreographer's deadline. The stamp is that latch minus a lead. gamescope's red zone was sized for a kernel commit; here a commit still crosses the commit worker and a binder apply with ~1 ms of spread. Stamped on the deadline itself (RP6, SWD2, cap 60) the applies landed 1.04 / 2.13 / 4.47 ms (p10 / p50 / p90) ahead of the latch, the tail crossed it, and SurfaceFlinger showed 58.65 fps on a 59.967 Hz panel. The lead is measured: every completed publish's margin (latch - apply, folded into [-P/4, 3P/4)) feeds a quantile loop (+9 steps of 20 us below target, -1 above) that holds the 10th percentile at 3 ms, or 30 % of the period on faster panels; start 2 ms, cap P/3. GNDRM_GS_LATCH_LEAD_US (app process environment) fixes it instead. The cost is latency: about 2 ms more between gamescope's commit and the latch than a deadline stamp on that device. The stamps are earlier than the light by the lead plus SurfaceFlinger's pipeline; what reads them (gamescope's grid, mangoapp's frame time) uses their spacing, which a constant offset does not change. The VsyncNote every scan-out client receives now carries the measured period while Choreographer is locked and fresh (the mode's advertised rate otherwise); a period move pushes the note only above 1 us, since the fit wanders by a few microseconds and the 300 us phase push re-anchors long before that adds up. Fallbacks: on API 29-32 the measured period is used with the latch grid's phase (vsync.rs), or the vsync time's own phase until a latch was seen; with Choreographer silent for a second the latch grid alone is followed at the mode's period; with neither reporting yet, the previous walk of the mode's interval. A claim is never earlier than the previous one and never repeats an instant: a second frame inside a period claims the next instant, a late frame skips whole periods, and a source switch keeps at least half a period between claims; claims moved past their latch by that rule are counted. Ungated lines: vsync: flip events on the display's vsync: source=choreographer-deadline period=16675.123us (59.969 Hz, mode 60.000 Hz) latch-vs-grid=+250us once per lock (the first, and the first after a rate change), and every 10 s while publishes complete: vsync: check source=choreographer-deadline period=16675.672us (59.967 Hz) publishes=598 vsyncs=600 late=0 pushed=0 margin p10/p50/p90=+3.0ms/+4.1ms/+5.9ms lead=+2.8ms latch-after-deadline=+0.4ms late = publishes that missed their latch, pushed = claims moved by the ordering rule, publishes below vsyncs at a cap at or above the panel's rate = gamescope skipping a wakeup. - modules/gndrm-daemon/src/vblank.rs (new): VsyncRing (period fit), Grid, DisplayVsync (source selection, freshness, measured_period_ns), LeadLoop, fold_margin, VsyncReport, the process-global feed and VblankTimeline, moved out of lib.rs and taking the grid; unit tests for the fit, rate changes, pauses, source selection, staleness, the claim rules, the lead and the report. - modules/gndrm-daemon/src/lib.rs: the commit worker claims on the display grid, with the mode's interval as the walk. - modules/gndrm-android/src/choreographer.rs (new): the vsync thread and the Choreographer FFI (dlsym'd from libandroid), started by nativeStart and stopped by nativeStop; the lock and check lines. - modules/gndrm-android/src/vsync.rs: the latch tracker's anchor feeds the daemon, every completion feeds note_publish_latch, the note's period is the measured one (PERIOD_STEP_NS). - modules/gndrm-android/src/ffi.rs: sym() is crate-visible for the new table. - modules/gndrm-android/src/lib.rs: module wiring and the header's account of the grid. - modules/gndrm-daemon/tests/golden.rs: the first-claim comment names the no-source case (no assertion changes). - docs/15-fps-limiter-pacing-and-vsync.md: section 3 on the vsync source, the latch grid, the claim, the lead and its cost, fallbacks and ordering; section 4 on the vsync timeline, the note's period and the two lines. docs/11-refresh-model.md: the flip timeline is no longer "one mode period".
…ayer does; gamescope's limiter is the cap On a PC, gamescope's WSI layer creates the driver swapchain as mailbox and implements FIFO itself: per present it declares the app's own mode to gamescope through gamescope_swapchain.set_present_mode, or FIFO while gamescope's limiter is engaged. gamescope latches one FIFO commit per limited vblank (steamcompmgr.cpp handle_done_commits_xwayland) and the client is paced only by wl_buffer.release. MESA_VK_WSI_PRESENT_MODE reaches Mesa below the layer and never changes what is declared. The shim is that layer on gamescope's override route (its own wl_surface wrapped in a gamescope_swapchain), but it let MESA_VK_WSI_PRESENT_MODE decide the mode, and every container carries mailbox there as a default for the X11 path. So every commit was declared mailbox, which gamescope's limiter never holds, and an acquire gate on the shim's own clock stood in for the cap. That gate beat against the display (57-59 fps at cap 60), and with mailbox there was no queue to absorb a late frame. On the override route, per present: - frame generation on: mailbox, exactly as before (generated frames are committed without a declaration, and a real frame declared FIFO would queue behind them); - a cap in force (the daemon's FrameCapNote, the value the app also hands gamescope's limiter): FIFO; - otherwise: the guest's own mode. The same rule picks the present discipline and both set_present_mode sites. The env no longer leads the offered mode list there, and the extra mailbox slot follows the guest's request (or frame generation at creation). A present wait is answered at commit when the guest asked for mailbox or immediate, or its frames go out as mailbox; a FIFO guest declared FIFO keeps the release-or-grace answer. Off the override route (the opt-in X11 Present route) nothing changes. The time gate now runs on the override route only while frame generation sets the source rate (the app disarms gamescope's limiter then); otherwise gamescope's limiter is the cap. A cap that is not the display's rate over a whole number (40 or 45 on 60 Hz) runs at gamescope's next divisor rate above it, upstream gamescope's behaviour, left to it for now. One ungated line every 10 s while frames are presented: gn-vkshim: present mode: declared=fifo requested=fifo route=gamescope cap=60 lsfg=0 acquires=… commits=… declared_fifo=… declared_other=… releases=… - icd_shim/src/proxy.rs: live_present_mode / choose_live_mode, declared_present_mode, mode_census; effective_present_mode ignores the env on the override route; present_dispatch, both declarations, the present-wait rule, the slot count, the offered modes and the time gate follow; tests. - icd_shim/src/scanout.rs: cap_in_force, cap_fps_note. - docs/10-fps-limit.md, docs/15-fps-limiter-pacing-and-vsync.md, docs/README.md: the cap is gamescope's limiter on the shim's FIFO declaration; the acquire gate is described as frame generation's source-rate gate.
With FIFO declared, a guest that waits on every frame (DXVK with in-game VSync on: FIFO, present wait, frame latency 1) ran at 35 fps at a 60 cap on a 60 Hz panel, 364 of 526 intervals two vblanks long. The shim answered vkWaitForPresentKHR from the compositor's release, which under FIFO comes when the next frame is latched, a vblank after this one was shown, or two refresh periods after the commit; the guest's next frame started a vblank late every time. Mesa's Wayland WSI answers the present wait from wp_presentation: each present that carries an id requests a wp_presentation_feedback on the surface, and presented or discarded signals that id. gamescope offers wp_presentation (version 1, CLOCK_MONOTONIC), sends presented for the commit it latched with the refresh it targets, and discarded for a commit it replaced or never showed (including a surface that goes away). The shim now does the same on the override route: - wp_presentation is bound with the gamescope swapchain factory (not on the guest-surface route); its clock_id is recorded. - A FIFO commit that carries a wait requests a feedback before wl_surface.commit, under the send lock, with the id as the listener's data. The id is registered as feedback-pending before the commit, so nothing can answer it first. - presented answers the wait at the reported time (CLOCK_MONOTONIC; the receipt time otherwise); discarded answers it at once. The object is destroyed in either event. - While a feedback is pending, the release does not answer the id and the two-period grace does not apply; a feedback that never arrives is answered 100 ms after the commit, well inside the guest's one-second bound. - An id is answered once: record() ignores ids no longer armed, so a second answer from another path (the daemon's latch report, a release) leaves nothing stale. - Without the global, off the override route, or when the feedback request fails, the release and the grace answer as before. The mailbox/immediate answer-at-commit rule (drm #37) is unchanged, and so is frame generation's path (its commits carry no feedback). The ungated present-mode line adds feedback=on|off presented= discarded= fallback= (waits answered by release, grace or the safety). - icd_shim/src/wlproto.rs: wp_presentation / wp_presentation_feedback interfaces, presentation_feedback(). - icd_shim/src/proxy.rs: bind, clock and feedback listeners, the request in commit_real, census fields. - icd_shim/src/present.rs: expect_feedback, feedback_presented / feedback_discarded, cancel_feedback, the pending rule for release and grace, counters; tests. - docs/15-fps-limiter-pacing-and-vsync.md: the FIFO present-wait answer.
Review fixes for the Choreographer vsync source: - AChoreographer_postVsyncCallback and AChoreographer_postFrameCallback64 return void (<android/choreographer.h>, NDK 27.3). They were declared to return an int and the thread exited on a non-zero value, which was whatever the callee left in w0. Both are now declared `-> ()` with no check. - The thread no longer has an exit after it publishes its looper. A callback that has not come within a second is posted once more (logged once), and the flip events fall back to the latch grid on their own while the vsync is stale. The looper is held with ALooper_acquire for the life of the process, and any end of the thread (a panic included) clears LOOPER and SPAWNED, so start() never wakes a freed looper and the next session spawns a new thread. - The fit is bounded by the mode's advertised period. Steps are counted against it and a fit more than 2 % from it is rejected (the previous period stays, logged once per run): a callback thread starved into delivering every second vsync counts two periods per step instead of locking the timeline at twice the period. A new advertised mode (nativeSetPanelRefreshHz on a display change) starts the fit again. With no mode reported the old spacing rules apply. - The callbacks stop while a session is suspended and resume with it (nativeInjectorSetSuspended). - GNDRM_GS_LATCH_LEAD_US is clamped to half a period: a lead of a period or more would stamp every event before the previous latch.
…amescope swapchain Review fixes for the present-wait and declaration paths: - Present ids restart at 1 on every swapchain a guest creates (DXVK recreates on a resize or a mode change), and the pending feedback, the commit list, the answers and the release path were keyed by id alone, so an old swapchain's late `discarded` (its surface torn down) or release could answer the new swapchain's frame early. Every entry is now keyed by (id, swapchain); the feedback listener carries a per-request token instead of the id; destroying a swapchain drops its pending feedback, commits and unconsumed answers. The daemon's latch report, which names an id only, answers the armed frame with that id. - commit_real and present_sync_commit declared the present mode on gamescope_swapchain unconditionally; on the guest-surface route (or a gamescope without the swapchain factory) that proxy is null. The declaration is now skipped there.
…wers presentation feedback docs/15 gains a "Behaviour changes" section (FIFO games vsynced without a cap, mailbox/immediate keep their mode, MESA_VK_WSI_PRESENT_MODE ignored on the gamescope route, non-divisor caps at gamescope's divisor rate, FIFO queue latency, frame generation switched on mid-session, the toggle's uncapped moment) and records what gamescope does with presentation feedback: `presented` every vblank for the last latched commit of every window, visible or not, and `discarded` for a replaced or destroyed commit, so an unfocused game is still answered per vblank and the 100 ms safety is only a backstop. §3 also covers the period's bound to the mode and the callback re-post.
|
Review fixes pushed (5831f1e). Each finding of the adversarial review, verified against the code, the NDK 27.3 headers and gamescope's source:
Builds at 5831f1e (NDK 27.3, |
|
The shim ( |
Base
master(efd50ab), headreal-vsync-fifo-present-wait(ee1efa2). Three commits (one in the daemon, two in the shim) and a one-line docs fix. Supersedes #43.Problem
Measured on a Retroid Pocket 6 (Android 13, Adreno 740, 1080x1920 panel in its 60 Hz mode), gamescope session, cap 60.
Two free-running 60.000 Hz clocks against a 59.97-59.99 Hz panel.
1e12 / mode_refresh_mhz()from CLOCK_MONOTONIC, exactly 16 666 666 ns on a "60 Hz" mode. gamescope re-arms its composite timer from every flip event (DRMBackend.cpppage_flip_handler→MarkVBlank;vblankmanager.cpp: wakeup = stamp + interval − draw time − red zone). So the stamps decide where in the display's cycle gamescope commits.1e9 / Nns. It also beat against the panel (57-59 fps at cap 60). And because the container'sMESA_VK_WSI_PRESENT_MODE=mailboxdecided the mode declared to gamescope, every commit was mailbox, which gamescope's limiter never holds. A frame released by the gate had one period to reach gamescope's latch, with no queue behind it to absorb a late frame.35 fps with VSync on. Making the shim declare FIFO (commit 2) exposed a second problem. A guest that waits on every frame (DXVK with in-game VSync on: FIFO,
vkWaitForPresentKHR, frame latency 1) ran at 35.3 fps at cap 60, with 364 of 526 intervals two vblanks long. The shim answered the present wait fromwl_buffer.release. Under FIFO that comes when the next frame is latched, a vblank after this one was shown, so every frame started a vblank late.What the native stacks do
VkLayer_FROG_gamescope_wsi.cpp) always creates the driver swapchain as MAILBOX and implements FIFO itself. Per present, it declares the app's own mode throughgamescope_swapchain.set_present_mode, or FIFO while gamescope's limiter is engaged. gamescope latches one FIFO commit per limited vblank (steamcompmgr.cpphandle_done_commits_xwayland). The client is paced only bywl_buffer.release.MESA_VK_WSI_PRESENT_MODEacts on Mesa below the layer and never changes what is declared.vkWaitForPresentKHRfromwp_presentation. Each present that carries an id requests awp_presentation_feedback, andpresentedordiscardedsignals that id. gamescope offerswp_presentation(version 1, CLOCK_MONOTONIC). It sendspresentedfor the commit it latched, with the refresh it targets, anddiscardedfor a commit it replaced or never showed.The shim already plays the WSI layer's part on gamescope's override route: its own
wl_surface, wrapped in agamescope_swapchain, with slots reused only on release. This PR makes the daemon act like the KMS driver and makes the shim declare and wait the way the layer and Mesa do.Commits
1. daemon: flip events carry the display's real vsync, read from Choreographer
ALooperkeeps one Choreographer callback posted per vsync while a session runs, and parks between sessions. It usesAChoreographer_postVsyncCallbackon API 33+ andpostFrameCallback64on 29-32.latch-after-deadline, smoothed fromASurfaceTransactionStats_getLatchTime).latch − applyat 3 ms (30 % of a period on faster panels). It starts at 2 ms and is capped at P/3.GNDRM_GS_LATCH_LEAD_USin the app process's environment fixes the lead instead.VsyncNote(frame generation's latch grid) now carries the measured period while Choreographer is locked. It is pushed on a period move above 1 µs.2. shim: declare FIFO while a cap is set, as gamescope's WSI layer does; gamescope's limiter is the cap
On the override route, the mode declared per present is:
FrameCapNotecarries a cap, which is the same value the app hands gamescope's limiter;Other effects:
MESA_VK_WSI_PRESENT_MODEno longer decides this route. It still applies on the opt-in X11 Present route.3. shim: answer the present wait from gamescope's presentation feedback
wp_presentationis bound with the gamescope swapchain factory. A FIFO commit that carries a wait requests a feedback beforewl_surface.commit, and the id is registered as pending first.presentedanswers the wait at the reported time;discardedanswers it at once. While a feedback is pending, neither the release nor the two-period grace answers. A feedback that never comes is answered 100 ms after the commit.feedback= presented= discarded= fallback=.Docs:
docs/15§1, §3 and §4,docs/10,docs/11anddocs/READMEchange with the commits they describe. A fourth commit fixes one sentence indocs/15§4: the note's measured period serves frame generation.Review fixes (commits 5-7)
AChoreographer_postVsyncCallback/postFrameCallback64returnvoid: declared so, no return check.ALooper_acquire, and any end of the thread clearsLOOPER/SPAWNED, sostart()never wakes a freed looper.nativeSetPanelRefreshHz) starts the fit again.GNDRM_GS_LATCH_LEAD_USis clamped to half a period.set_present_modeis skipped whengamescope_swapchainis null (guest-surface route).Measured
Retroid Pocket 6 (Android 13, Adreno 740), cap 60, 60 Hz mode, same scene per game, frames counted at SurfaceFlinger (
dumpsys SurfaceFlinger --latency).libgamenative_drm.soe8d23581…, shimb9aedd01…. The review-fix commits (5-7) have not been measured on device yet; payload 20261102.3 carries them.The SteamWorld Dig 2 residual (about 1 % doubled intervals) is not fixed by this PR and is under separate investigation.
From the VSync-on run's daemon check lines: margin p10 held at +3.0 ms,
late=0in every window,latch-after-deadline+0.46 to +0.70 ms.What does not change
VsyncNotekeeps its wire layout. Its period is now the measured one, which is closer to the display.floor(refresh / target)-th vblank, so 40 or 45 on 60 Hz runs at 60, and 50 on 120 Hz runs at 60. That is upstream gamescope's behaviour and is left to it for now. Before this PR the shim's time gate held those caps.Behaviour changes
MESA_VK_WSI_PRESENT_MODE=mailboxdefault was declared for every swapchain; a guest that asks for FIFO now gets FIFO, as on a PC.MESA_VK_WSI_PRESENT_MODEis ignored on the gamescope route, including a value the user picked in the driver settings. It still applies to the X11 Present route. On a PC it only reaches Mesa below gamescope's layer.floor(refresh / target)vblanks, so 40 or 45 on 60 Hz runs at 60, and 50 on 120 Hz at 60.slots − 1frames can wait in gamescope's FIFO list. At low caps that is up to that many limited periods of input latency, as on a PC. Present-wait guests are bounded by their frame latency.minImageCount + 1, one fewer than when created under generation) until the guest recreates its swapchain.Known limitations
minImageCount + 1frames in gamescope, as on a PC. That can add up to about two limited periods of input latency against the old zero-depth gate. A guest that uses present wait (DXVK with VSync on) is bounded by its frame latency.presentedevery vblank for the last latched commit of every window, visible or not (steamcompmgr.cpp, the comment beforehandle_presented_xwayland: "we return presented for any window not visible … for forward progress"), anddiscardedfor a replaced or destroyed commit (commit_t::~commit_t) or a window that is gone. An unfocused game, or one behind a dialog, is still answered once per vblank. Mesa blocks until the event comes; the shim's 100 ms answer for a feedback that never comes stays as a backstop for a compositor fault and is counted infallback=.set_app_target_refresh_cycleon every (re)connection and whenever the value changes (checked every 100 ms), and gamescope's limiter applies to every non-Steam, non-overlay window, not per focus. If the shim'sFrameCapNotelands before the injector's first send, the FIFO-declared game runs at the refresh rate (vsynced, not uncapped) for up to ~100 ms after gamescope's control socket connects.Tests
cargo test --workspace --exclude gn-utilsinrust:1-bookworm(withlibwayland-dev libdrm-dev libvulkan-dev libxcb-{dri3,present,sync}-dev): all pass./usr/include/drm→ libdrm headers).cargo test -p adrenotools_gamescope --lib, 71 passed. gndrm-daemon unit tests: 99.--release --target aarch64-linux-android:-p gndrm-android -p adrenotools_gamescope,SOURCE_DATE_EPOCH= head commit time) is9d361968…, reproduced byte-identical after a clean rebuild. Built alone (-p gndrm-android) the hash differs only by cargo's feature unification across the two crates.9e181cd7…(build string2026110208325831f1); it embeds a build stamp, so it is not bit-reproducible.How to test
Use a gamescope session with a cap of 60 on a 60 Hz panel, with payload 20261102.3 and the app build that carries the daemon. In logcat, all of these lines are ungated:
gndrm-daemon: vsync: flip events on the display's vsync: source=choreographer-deadline period=16676.286us (59.965 Hz, mode 60.000 Hz) latch-vs-grid=…The period should be the panel's real rate, not 60.000.
gndrm-daemon: vsync: check source=choreographer-deadline period=… publishes=… vsyncs=600 late=0 pushed=0 margin p10/p50/p90=+3.0ms/… lead=… latch-after-deadline=…Expect
publishesclose tovsyncs,late0-1, and margin p10 about +3.0 ms.gn-vkshim: present mode: declared=fifo requested=… route=gamescope cap=60 lsfg=0 … feedback=on presented=… discarded=0 fallback=0declared=fifois the cap in force, andpresentedshould climb withcommits.dumpsys SurfaceFlinger --latency <layer>over 30 s should read the panel's rate (about 59.97-59.99 on the RP6). Turning in-game VSync on in a DXVK game should no longer halve the rate.declared=mailbox lsfg=1, 80 fps;cap=reads the no-cap sentinel (FPS_UNCAPPED), anddeclared=is the guest's own mode.