Repository navigation
gamescope pacing on the display's real vsync: Choreographer flip events, FIFO under a cap, present wait from wp_presentation - #43
Closed
utkarshdalal wants to merge 5 commits into
Closed
utkarshdalal wants to merge 5 commits into
utkarshdalal wants to merge 5 commits into
Conversation
added 5 commits
November 2, 2026 11:40
…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".
…te commit waited A KMS flip event reports the vblank the flip actually took. The daemon stamped the first latch after its apply *returned*. When the commit worker was preempted inside or after the apply (SteamWorld Dig 2, RP6: at 18.42 s SurfaceFlinger had the frame 0.85 ms before its latch and the apply returned 6 ms after it), the event named the next latch and gamescope's next wakeup moved a whole period out: 3 of 20 doubled intervals in a 30 s trace. The stamp now comes from the instant the apply started, which the presenter reports (Presenter::take_apply_start_ns): SurfaceFlinger can take the transaction from then on. The event is still sent with the publish. Stamping it with the latch SurfaceFlinger reports would mean holding the event for the completion, a period or more later, and putting that round trip back into gamescope's present loop. A stamp in the past is what a KMS event always carries: gamescope arms its next wakeup at stamp + interval - draw time - red zone and steps forward whole intervals if that has passed. The claim is never earlier than the previous one, as before. The clock stays the fallback for a commit that applied nothing (and for test presenters). Four other doubles were commits the daemon held 4.6-6.6 ms with its threads idle. Each commit now carries stamps from the ioctl through the worker to the apply (Presenter::note_commit_timing, CommitTiming), copied into the buffer's completion mailbox at the apply. A commit whose apply started more than 2 ms after it was queued, or that missed the latch it was due for, logs one ungated line from its completion, at most one a second: gndrm-daemon: commit timing (us from ioctl): queued=… pickup=… state_lock=… pool=… retired=… fence_wait=… apply_start=… apply_ret=… latch=… held=0|1 missed_latches=N (times from the start of the atomic ioctl; fence_wait is the duration of the wait on this buffer's previous release fence; missed_latches counts latches after the first one following the queue.) Reading those threads in ps/top needed their names: the commit worker and the scan-out thread read as Thread-72 and Thread-74 in the app process. Both are named (gndrm-commit, gndrm-scanout), but they attach to the VM for the onFramePublished and clearBootingSplash upcalls, and ART names a thread it attaches without a name Thread-N and renames the native thread to match. call_app_static now reads the kernel name before a first attach and sets it back after. - modules/gndrm-daemon/src/lib.rs: CommitTiming, mono_ns, the two Presenter methods, CommitJob received/queued stamps, the worker's pickup stamp and the apply-start claim. - modules/gndrm-daemon/src/commit.rs: the ioctl and queue stamps. - modules/gndrm-daemon/src/vblank.rs: the claim's documentation; a test. - modules/gndrm-android/src/asc.rs: the stamps through publish (state lock, pool, retire, fence wait, apply start) into the registration, the apply start for the worker, and log_commit_timing in the completion. - modules/gndrm-android/src/lib.rs: call_app_static keeps the thread's name. - docs/15-fps-limiter-pacing-and-vsync.md: the claim from the apply start, and why the event is not held for the latch.
…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.
This was referenced Nov 2, 2026
Contributor
Author
|
superseded by #44 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Base
master(efd50ab), headflip-events-on-the-real-vsync-pr(f5ac0bc), four commits plus a one-line docs fix: two in the daemon, two in the shim.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 3) 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. daemon: stamp the flip from the apply's start; log where a held or late commit waited
Thread-Nand they could not be told apart inps/top.3. 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.4. 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.Measured
Retroid Pocket 6, cap 60, 60 Hz mode, same scene per game, frames counted at SurfaceFlinger (
dumpsys SurfaceFlinger --latency).What each column ran.
libgamenative_drm.sod48e27cd…) and shim (libadrenotools_gamescope.sob9aedd01…), plus two other changes:nice-via-rlimit-nice);gamescope-real-vsync-payload-20261102-1).The daemon and shim alone have not been measured without those two.
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: publishes 597-600 per 600 vsyncs,
late=0in every window, margin p10 held at +3.0 ms,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.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.set_present_modeon a nullgamescope_swapchain. It is untouched here.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, 70 passed.tools/build-android.sh-equivalent builds (NDK 27.3, rustc 1.98.1, daemon and shim as separatecargo buildinvocations) from this head give a daemon byte-identical to the device-tested one (d48e27cd…).b9aedd01…only in the 10 bytes of its embedded build stamp and git tag (GN_SHIM_BUILD/GN_SHIM_GIT). This head's tree is identical to the tree that was device-tested.How to test
Use a gamescope session with a cap of 60 on a 60 Hz panel, with payload 20261102.1 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.gndrm-daemon: commit timing (us from ioctl): queued=… pickup=… … apply_start=… apply_ret=… latch=… held=0|1 missed_latches=Ndumpsys 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.