Skip to content

Shim waits for the panel to release a buffer before the guest renders into it - #60

Open
utkarshdalal wants to merge 2 commits into
masterfrom
shim-waits-panel-release-fence
Open

utkarshdalal wants to merge 2 commits into
masterfrom
shim-waits-panel-release-fence

Conversation

@utkarshdalal

@utkarshdalal utkarshdalal commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A user reports tearing in DXVK games under gamescope on a Mangmi Air Y Pro (SD865, 1280x960 at 60 Hz): Skyrim LE, Arkham City, L4D and NFS Most Wanted 2012. It only shows above about 40 fps, with vsync on or off, and screen recordings don't capture it. That points at scan-out, not at the frames themselves.

Mechanism

On direct scan-out the guest's pool buffer goes straight to the panel, and the guest gets that buffer back before the panel has finished reading it:

  1. The daemon publishes the buffer and sends gamescope's page-flip event at publish time, not when SurfaceFlinger shows the frame (modules/gndrm-daemon/src/lib.rs:2305, commit_worker_loop).
  2. gamescope's page_flip_handler then releases the buffer that flip replaced (wl_buffer.release).
  3. The shim frees that slot (icd_shim/src/proxy.rs:8065, on_buffer_release), and the game's next vkAcquireNextImageKHR hands it out right away.
  4. The game renders into that buffer while SurfaceFlinger may still be scanning it out, until the replacement is actually on screen. The daemon does wait on the release fence (modules/gndrm-android/src/asc.rs:1467), but that wait only guards the daemon's own next publish of the slot, not the game's writes into it.

The shim already waits for the game's render fence before it commits, so a frame that is still rendering is not the cause.

Why not a daemon-only fix

The fix

The flip event keeps its timing. The release goes to the process that renders into the buffer:

  • Wire (modules/gndrm-uapi/src/wire.rs): a new ReleaseFence note ("GNRF") on the existing scan-out channel. It is only sent to connections that declared CAP_RELEASE_FENCE (bit 3). There are two kinds:
    • a pending note: buffer ino was replaced on the layer, and its fence will follow;
    • a final note: carries SurfaceFlinger's release fence as a sync file over SCM_RIGHTS, or no fd when SurfaceFlinger reported none (the buffer is already free).
  • Daemon (modules/gndrm-android/src/asc.rs):
    • Each show transaction queues the inode of the buffer it replaces on its registration. build_show (:3607) sends the pending note before the apply, so it is on the shim's socket before the flip event leaves.
    • on_complete (:414) pops that entry and forwards the ASurfaceTransactionStats_getPreviousReleaseFenceFd it already queries. The fd is borrowed (pushes::push_with_fd, modules/gndrm-daemon/src/pushes.rs:133), so the daemon's own wait still gets its copy.
  • Shim (icd_shim/src/scanout.rs):
    • It declares CAP_RELEASE_FENCE and keeps notes only for buffers it was handed (record_release, :850).
    • When vkAcquireNextImageKHR hands out a slot (icd_shim/src/proxy.rs:2847), wait_release (:976) waits for that slot's release: first for a note still on its way, then for the fence. The wait is bounded by 33 ms or the acquire's own timeout, and a timeout == 0 acquire never blocks.
    • Each fence is waited at most once and then closed. A fence that arrives after its wait ran out is dropped. Timeouts are counted, and the first one is logged.
    • poll_notes now drains every queued frame and receives descriptors.

Why the wait is on the CPU and not imported into the acquire semaphore/fence. The release fence only exists once the transaction that replaced the buffer completes, and that is normally after gamescope released the buffer, so often after the acquire. Binary semaphores can't be waited on before their signal is submitted, and the shim signals the guest's semaphore/fence with an empty submit inside the acquire. So a fence that hasn't arrived yet has to be waited for on the CPU anyway. When it does arrive (SurfaceFlinger's completion fires after the replacement is presented), it has usually already signalled, so the poll returns immediately. An import would also need VK_KHR_external_semaphore_fd enabled on the game's device, which the shim does not control.

Compatibility. An old daemon sends no notes, so the shim behaves as on master. An old shim never subscribes, so the daemon sends it nothing.

What is unchanged

  • The page-flip event timing, and the daemon's internal release-fence wait.
  • The foreign-copy path, the cursor plane and frame generation.
  • The GL / Xwayland path is not covered: those producers don't read these notes.

Test plan

Host tests ran in rust:1-bookworm on aarch64. The crates use Linux libc, so they don't build on macOS.

  • cargo test -p gndrm-uapi --lib: 20 passed. Covers the GNRF round-trip and rejection of other frames. (abi_ground_truth needs the Linux C headers; it fails the same way on master on a macOS host.)
  • cargo test -p gndrm-daemon: 100 + 8 + 36 passed. push_with_fd sends the fd only to CAP_RELEASE_FENCE sinks, and each sink gets its own duplicate.
  • cargo test -p adrenotools_gamescope --lib: 130 passed, including these new cases:
    • a fence is waited once and closed;
    • the acquire waits for a fence still on its way;
    • an unsignalled fence is bounded;
    • a lost fence costs one bounded wait, and a late fence is dropped;
    • a release with no fence frees the slot;
    • notes for other buffers are ignored;
    • a pushed fd is received.
  • cargo clippy --target aarch64-linux-android -p gndrm-android -p adrenotools_gamescope -p gndrm-daemon -p gndrm-uapi --all-targets -- -D warnings: clean (77c581d fixes a doc_markdown lint).
  • Android release build, using the NDK 27.3 / rustc 1.98.1 rustflags from tools/build-android.sh with the darwin NDK linker. check-exports PASS (daemon 20 exports, shim 3) and check-elf-deps OK for both libraries. The gn-utils probes don't link against the local prefix's libvulkan; they don't ship and this PR doesn't touch them. 9e79a84 and 77c581d build byte-identical libraries:
    • libgamenative_drm.so: sha256 28d5d474273052cf24adf64b353139da2ee9c4c4ebf8b522c87427c7e85493c3, md5 ece0bab966db488fec6876515194f3d6
    • libadrenotools_gamescope.so: sha256 2dcb1948521f82ed59ab254e6866d3c1fa124dca07354b8ec17701f347cc4ce6
  • Delivery: gamescope: payload 20261107.1 and daemon from gamenative-drm#60, the shim waits for the panel's release (DXVK tearing at 60 Hz) utkarshdalal/GameNative#2369 ships the daemon in jniLibs and the shim as payload gamescope-gamenative-20261107.1.tzst (sha256 a14c58fe…19cf). It must not merge before this PR.
  • Device run: pending. Expected signal: an uncapped DXVK game on a 60 Hz panel (one of the reported titles) stays tear-free at the same fps as master. On master a cap below the panel rate already hides the tear, so the comparison has to be uncapped.

Utkarsh Dalal added 2 commits November 7, 2026 23:56
…renders into it

DXVK games under gamescope tear on 60 Hz panels above about 40 fps, vsync on
or off. On direct scan-out the daemon publishes the guest's pool buffer and
sends gamescope's page-flip event with the publish, so gamescope hands back
the buffer that flip replaced while SurfaceFlinger may still be scanning it
out. The shim frees the slot on that release, the guest acquires it at once
and renders into the buffer on the panel. The daemon's own release-fence wait
only guards its next publish of that buffer, not the guest's writes.

The flip-event timing stays as it is (holding the event for the release
halves the rate above half the panel's), so the release goes to the buffer's
producer instead:

- wire: a ReleaseFence note ("GNRF") on the scan-out channel, sent only to a
  connection that declared CAP_RELEASE_FENCE. A pending note says a buffer was
  replaced on the layer and its fence follows; the final note carries the
  release fence as a sync file (SCM_RIGHTS), or none when SurfaceFlinger
  reported none.
- daemon: each show transaction records the inode of the buffer it replaces.
  The pending note goes out when the transaction is built, before the apply
  and so before the flip event; the completion callback forwards the
  previous-release fence it already queries for that inode. The daemon's own
  wait is unchanged.
- shim: subscribes with CAP_RELEASE_FENCE, keeps notes only for buffers it
  was handed, and at acquire waits for the slot's release (the note still on
  its way, then the fence) for at most 33 ms or the acquire's timeout. Each
  fence is waited once and closed; a fence that arrives after its wait ran
  out is dropped; a timeout is counted and logged once. The wait is on the
  CPU: the fence only exists once the replacing transaction completes, which
  can be after the acquire, and a binary semaphore cannot be waited before
  its signal is submitted. An older daemon sends no notes and acquire is as
  before.
clippy's doc_markdown lint (-D warnings) flags the bare name.
@joshuatam

Copy link
Copy Markdown
Collaborator

Frame pacing: read this before the device run

The fix is right for tearing, but it changes what bounds an acquire, so the pacing question is worth settling explicitly rather than discovering on a panel. Three risks, in the order I'd check them.

1. It collides with the acquire gate at a cap equal to the panel rate

The wait lands at the end of gn_acquire_common, i.e. after the slot is granted and after the gate has booked its schedule:

(*(*sc).slots.as_mut_ptr().add(idx as usize)).acquired_ns = now_ns;
if gated {
    (*sc).release.book_paced(now_ns, pacing);   // ← the gate says "start now"
}
break;
...
crate::scanout::wait_release(ino, budget);      // ← and then the acquire blocks

vsync_gate releases on the vblank grid precisely so the frame has the whole period to reach the next composite; that is the lead it was built to give (docs/15, and the A52 grid work). A release wait taken after the booking can push the render start past the grid point the gate chose, which is the one thing the gate cannot recover from: its schedule for the next frame is measured from a start it did not control.

The fragile combination is cap == panel rate with gate=vsync — 60 on 60. Expected signal if it bites: gate_late climbing and fps landing at 58–59 instead of 60. At cap 30 the fence is old by the time the gate fires, so it should be untouched; if that one moves, something else is wrong.

If it does bite, the order is the fix rather than the wait: take the release wait before the gate books, so the gate's release instant stays the last thing that happens before the guest renders.

2. A missing note costs 33 ms, not one frame

RELEASE_WAIT is 33 ms — two frames at 60 Hz. On a lost or late note the guest stalls the whole budget in wait_release, then abandon_release drops the late fence, so the tear returns for exactly that frame and the frame was late. With two images the hitch propagates into the next frame.

RELEASE_TIMEOUTS plus the one-shot log are the right instrumentation; a healthy run should read zero. Worth stating the expectation in the PR so a single timeout in a long run is not read as noise.

3. With two images there is no alternative; with three or more most of the wait is avoidable

Two images means one free slot, so "wait" or "tear" are the only options — that is why this is the right fix and not a wrong one.

With three or more, acquire_take_free still takes the first free slot without looking at release state, so an acquire can pick a slot with a pending note while a ready one sits beside it. A preference — free and release-satisfied first, any free slot as the fallback — is small and makes the common case cost nothing. It does not weaken the fix: the slot that is picked still cannot be one the panel is reading.

Note on the counters

acqus in the census now includes this wait, so a before/after comparison of that field will look like a regression by construction. The fields to read are the new RELEASE_WAITS / RELEASE_TIMEOUTS, the spacing tail (>20), and gate_late at cap == panel rate. If the census is to be compared across this change, slot_waits is the closest existing analogue and the two are worth reporting side by side.


Suggestion: re-derive the acquire gate against this change

Separately from the fix itself, this PR breaks an assumption the gate was built on, and I think the gate is worth a look as a whole rather than patched around.

The assumption that no longer holds. The gate's model is: a slot that is free is a slot that is renderable, so releasing the guest at grid instant k gives it from k to k+1 to render and commit. That was true while the release came from the compositor's wl_buffer.release at publish. It is no longer true: a free slot can now be a slot the panel is still reading, so "renderable" has a second gate behind the first.

What that implies for the gate, concretely:

  1. book_paced/Schedule book from the acquire instant. With a release wait, the instant the gate controls is no longer the instant the guest starts, so the schedule drifts by whatever the wait consumed — and the wait is largest exactly when the guest is keeping up with the panel, which is when the grid matters most.
  2. The "a frame that arrives after its release point is not held: it starts at once" rule (docs/15) becomes "starts at once, then may block", which is the case the gate's late-frame rule was written to avoid.
  3. GateClock::Clock (the cap's own clock) has the same problem from the other side: it assumes the slot is ready when the cap's instant arrives.

What I'd suggest checking, in one device run per arm (cap 60 / cap 30 / uncapped, same scene, gate=vsync):

  • the distribution of time from gate release to the first render command, before and after this PR — if the mean moves by more than ~1 ms at cap 60, the gate's grid is being eaten;
  • whether the guest's commit still lands before the next grid point at cap 60 (gate_late ≈ 0 is the pass condition);
  • whether, with the release fence in play, the gate should be scheduling from max(grid instant, release signalled) rather than from the grid instant alone.

If that comes back clean, the gate needs nothing. If it doesn't, the ordering fix is small but the reasoning in docs/15 §"Which grid points release a frame" will need the release fence added as a term, otherwise the next person will re-derive a model that no longer matches the code.

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.

2 participants