Skip to content

vk: a readback's wait flushes the held batch (FR_READBACK) - #1905

Closed
kafasam wants to merge 1 commit into
NotRexed:mainfrom
kafasam:readback-flush-only
Closed

kafasam wants to merge 1 commit into
NotRexed:mainfrom
kafasam:readback-flush-only

Conversation

@kafasam

@kafasam kafasam commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

What

When a readback waits for a GPU write-back, the held command buffer batch is flushed before the wait, so the GPU starts processing it immediately instead of waiting for the next present. This closes the ~23-28ms readback latency bubble measured in the graveyard investigation.

Why

The investigation measured 163 of 3000 reads waiting 23-28ms because results only landed when the display thread's present flushed the batch (flush reasons were 60/61 "present"). The code's own design comment at vk_replay.cpp line 132 promised a readback flush that was never wired.

Changes (one file, ~20 lines)

  • New flush reason FR_READBACK in the batch enum, printed in the stats log line
  • rec_out tracks the recordings' timeline value the last flush sent
  • The flush in wait_recorded() fires only when the exact recording being waited on is still held in the batch

Safety

  • Only sends already-recorded command buffers — stopped queues are never forced past their event waits
  • Both wait_recorded call sites hold no locks (present_mu is a leaf)
  • Fires at most once per held recording (no flush loops)
  • With RX_VK_BATCH=0 the pend is always empty so it never fires
  • With RX_VK_ASYNC=1 the readback goes through wait_value() which already flushes — this fix is load-bearing on sync mode only

Testing

Built and deployed on RX 6700 XT. With RX_VK_ASYNC=1 the readback counter reads 0 (expected — the async path already flushes). With RX_VK_ASYNC=0 (sync), the fix addresses the measured 23-28ms bubble.

AI assistance disclosure: the analysis and fix were developed with AI assistance, verified and tested by a human on the affected hardware.

@DarkGhostHunter

Copy link
Copy Markdown

Would this fix the write-read wait from #1133?

@kafasam

kafasam commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

Yes — this addresses the same class of problem as #1133. The readback wait in wait_recorded() was the sync-mode path where the batch wasn't flushed before waiting (the design comment at vk_replay.cpp line 132 promised a readback flush that was never wired). With RX_VK_ASYNC=1 the async path (wait_value()) already flushes, which is why the bubble was only measured in sync mode. This fix wires the flush for the sync path, closing the gap for both configurations.

@DarkGhostHunter

Copy link
Copy Markdown

I built it as AppImage and I can confirm the texture flickering that #1133 fixed returned. Does this need a rebase?

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for chasing the readback bubble. The diagnosis is right, but main already has this fix: #1276 (merged yesterday, after this branch's base) put an unconditional queue_flush_for(FR_WAIT) at the top of wait_recorded(), so in sync mode the held batch already goes out before the wait. Against main this PR has nothing left to add, and it conflicts there (the branch is ~920 commits behind and GitHub shows it as unmergeable). No CI ran on it either.

Two other things:

  • The diff isn't one file. The branch also carries 8716b2996 (the FSR 1 EASU+RCAS commit: two shaders, their SPIR-V headers and ~170 lines in vk_draw.cpp). That's #1439's work, and an older copy of it than #1439's head, so merging this would bring in the upscaler without its review. #1906 is stacked the same way: it carries this commit and the FSR one under its own vk_memory.cpp change. Each of these should be its own branch off current main.
  • @DarkGhostHunter's flicker report: that's the stale base, not this change. #1133 merged today and this branch predates it, so an AppImage built from it doesn't have the #1133 fix. (On their question: #1133 fixed tracking writes from direct buffer bindings, which is a different problem from this batch flush. This PR doesn't replace it.)

If you still want something here after rebasing, the gating you added (flush only when seq > rec_out, with its own FR_READBACK reason in the stats line) could be a small follow-up on top of #1276's flush, if it measurably cuts flushes. But on main the bubble should already be gone, so I'd close this one in favour of #1276.

Comment thread gpu/vk/vk_replay.cpp
// set for another's flush, which queue_flush_locked's empty return would do)
{
std::lock_guard lk(dev.present_mu);
if (seq > rec_out && !pend.cbs.empty()) flush_reason = FR_READBACK, queue_flush_locked();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Main has queue_flush_for(FR_WAIT); at this point already (#1276). It flushes whenever the batch is non-empty, so the seq > rec_out check only narrows that. If you keep the narrowing, put it on top of main's version, and the rec_out/FR_READBACK bookkeeping should replace the FR_WAIT flush rather than stack with it.

A read of GPU results on the sync replay waited on the recordings'
timeline without sending the batch: when the recording holding the
write-back was still held (the flushes were present-time, 60/61 of
them), the read blocked the rest of the frame for the display
thread's present to flush it - 163 of 3000 reads at 23-28 ms each,
~1.4 blocking waits a frame (the ~25 ms pipeline bubble of the
cpu-bottleneck investigation, its NotRexed#2 remaining lever).

wait_recorded now flushes the batch first, gated on the recording
still being held (its seq above what the last flush sent out): the
GPU starts the write-back at read demand instead of at present.
Empty or older batches are not flushed, and the reason is counted
in the batched-submits log line as 'readback'.
@kafasam
kafasam force-pushed the readback-flush-only branch from 26b9466 to c0e8479 Compare November 11, 2026 03:58
@kafasam

kafasam commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Yes — rebased onto current main (c0e8479), sorry about that. The branch was cut before #1133 merged, so an AppImage built from it lacked the direct-buffer-write bookkeeping and capture-readback invalidation fixes — that's the flickering you saw, not something the flush introduced.

One thing worth noting for the rebuild: main has since added its own unconditional batch flush in wait_recorded() (FR_WAIT, for the RADV load hang). This PR keeps the conditional FR_READBACK flush instead — it only fires when the waited-on recording is actually still held (seq > rec_out && !pend.cbs.empty()), so it doesn't defeat batching for unrelated held submits, and the reason stays distinguishable in the flush stats. If the flickering persists on a rebuild from the updated branch, that would be a real interaction worth digging into.

@kafasam

kafasam commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

Your review was of the pre-force-push branch — it is now a single commit (c0e8479f) rebased onto main at force-push time (fab8cb37), one file gpu/vk/vk_replay.cpp +14/−5, and GitHub reports it mergeable.

On the substance we agree: with #1276's unconditional queue_flush_for(FR_WAIT) at the top of wait_recorded(), the bubble is fixed on main and this has nothing left to carry — happy to close.

The one residual from our version is the conditional gate (flush only while the recording is still held, seq > rec_out, with its own FR_READBACK reason in the stats), so unrelated held batches aren't flushed. With #1993's idle-send on RADV, fewer spurious flushes may matter on Linux. If useful we can send a small follow-up PR with measured FR_READBACK vs FR_WAIT counts from a run; otherwise closing here is right.

@kafasam kafasam closed this Nov 11, 2026
@kafasam

kafasam commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@DarkGhostHunter since you were testing the AppImage build: we are closing this PR in favour of #1276, whose flush in wait_recorded() is already on main and covers the fix you were testing for — a fresh main build should carry it.

hckrman101 pushed a commit that referenced this pull request Nov 11, 2026
…k that writes it again (#2125)

> **EDIT (2026-11-10):** the first version of this PR said it fixes the
cyan flashes and the UI thrown about from #1005. It doesn't. My runs
with and without it also differed in Render resolution and session
length, and the cyan that reproduces reliably turned out to come from
VRAM mirrors given back while idle, after a pause with the game in the
background and (mostly) after a save reload: #2154. What this PR does
fix is below.

## What it fixes

#296 made every CPU read of GPU results wait for the GPU work that
writes them. Without that wait, OoT's exposure flashed for a frame
(#296: "OoT exposure flashes").

With per-queue fences on (the default since #523) that guarantee has a
hole. When the work that writes the range is stopped at a wait that is
already met (for example a CPU event the game signalled, which is
applied at the replay's next look at the queues), the read-back finds no
pending write-back, waits for nothing, and reads the range as it was: an
earlier frame's result.

This PR closes that hole. Before the lookup the read-back lets that
stopped work on, never forcing anything, so its write-back is known and
the read waits for exactly that work.

## Why it's worth merging

- **It restores #296's guarantee on the default path.** The hole looks
accidental. #523 skipped the read-back's catch-up because a catch-up
then pushed stopped work past its waits. #449 had already made the
catch-up safe, 53 minutes before #523 merged, so the skip outlived its
reason.
- **It's cheap.** A read-back that has to wait now takes about 1 ms
(0.65 ms median), the time for the GPU to do the write.
`RX_VK_PER_QUEUE_FENCES=0`, the workaround in #1005, closes the same
hole by waiting for every queue's work: 19–87 ms per read-back on the
same card, 12.5 fps against 31.8 with this PR at the same settings.
- **It's small and contained.** One `if` in `invalidate`, using the same
`sync_settle()` the texture branch further down already calls. Nothing
changes with `RX_VK_PER_QUEUE_FENCES=0`, `RX_VK_ASYNC=1` or
`RX_VK_SYNC_WAITS=0`.
- **It's covered.** `tests/test_readback_settle.py` fails on main and
passes with this change.

What I haven't seen is a visible change I can pin on it alone. It's a
correctness fix for a known bug class (#296), not a fix for a reported
picture problem.

## How the hole works

`invalidate` looks up the range's write-back value, which is known only
once the work that writes the range is recorded. #523 skipped the
read-back's catch-up with per-queue fences on. So work stopped at a wait
that was already met stayed unrecorded, its write-back was unknown, and
the read went ahead with nothing to wait for.

## Why the read-back changes again

The read-back in `invalidate` has changed several times, each step
fixing the one before:

1. **#296** (evilmfkr): a CPU read of GPU results waits for the work
that writes it back. Today each range remembers the submission that
writes it back (`wb_pages_note`, noted when that work is recorded), and
`invalidate` waits for that value. Before the lookup the replay is
caught up, so work handed over can note its write-back first.
2. **The catch-up forced stopped work.** In the sync replay, that
catch-up pushed work stopped at an event wait past its wait, ahead of
the work it waits for: the fog and exposure of the frame before, for a
frame.
3. **#449** (azlekayn, merged 2026-11-08 08:19 UTC): a catch-up lets on
only the stopped work whose waits are met, and forces nothing
(`wait_queue_empty` in the sync replay, `sync_settle`).
4. **#523** (hckrman101, merged 2026-11-08 09:12 UTC): per-queue fences.
With them on, the read-back skips its catch-up (`!unforced`) and waits
on the recordings' timeline instead. That skip was written while a
catch-up still forced (step 2). It merged 53 minutes after #449 had made
the catch-up safe, so the skip outlived its reason.
5. **#1276** (Odrannnn): the recordings' wait sends the held batch first
(RADV).

`RX_VK_PER_QUEUE_FENCES=0` goes back to the old path: a catch-up, then a
wait on the shared timeline. That timeline is signalled only as far as
no older submission of any queue is stopped, so each read-back also
waits for stopped work whose waits are not met, which waits for other
threads' submissions: tens of milliseconds.

## Change

In `invalidate`'s read-back, with per-queue fences on, settle before the
lookup: `sync_settle()` applies the CPU's pending signals and lets on
the stopped work whose waits are met, never forcing anything (the same
call the texture branch further down already makes). That work is
recorded, its write-back value is known, and `wait_recorded` waits for
exactly that recording, as before. Stopped work whose waits are not met
is not waited for.

- With `RX_VK_PER_QUEUE_FENCES=0`, `RX_VK_ASYNC=1` or
`RX_VK_SYNC_WAITS=0` nothing changes: those still call
`wait_queue_empty()` as before.
- `RX_VK_READBACK_NO_CATCHUP` still turns the catch-up off.

## What it changes, measured

RX 5500M (RDNA1, 4 GB), Windows 11, AMD driver, main `92907f7` with
#1581. Read-back figures come from the `readbacks of GPU results: N of
3000 waited` lines.

| | main | `RX_VK_PER_QUEUE_FENCES=0` | this PR |
|---|---|---|---|
| read-backs that waited, per 3000 | ~890 | 65–68 | ~710 |
| time per wait | 0.07 ms (0.02–0.19) | 19–87 ms | 0.65 ms median, 1.28
mean |
| main thread's time waiting, per 3000 reads | 24 ms | — | 499 ms |

On main the waits are tiny because they usually wait for nothing: the
work that writes the range has not been recorded yet. With this PR a
read-back waits for the GPU to finish the write. On the main thread that
is about 0.17 ms per read, roughly 24 ms in each second of play at that
run's read rate (about 2.4% of the thread). Of that run's 122 read-back
lines (3000 reads each), six averaged more than 5 ms a wait; the
highest, 30 ms, was during an area load.

Frame rate against `RX_VK_PER_QUEUE_FENCES=0`, the workaround in #1005,
at the same settings (Render resolution 50%, Hyrule Market):

| | `RX_VK_PER_QUEUE_FENCES=0` | this PR |
|---|---|---|
| 5-second stretches | 57 | 116 |
| mean fps | 12.5 | 31.8 |
| stretches under 8 fps | 29 (51%) | 4 (3%) |

I did not run main and this PR at the same settings on the same route,
so this is no claim that the PR is faster than main.

## Tests

`tests/test_readback_settle.py` (new) reads the source and checks two
things: the read-back settles before the write-back lookup, and it no
longer skips its catch-up with per-queue fences. It fails on current
main and passes with this change. `tests/test_recorded_wait_flush.py`
still passes.

`tests/run_all.py` on main `bdfe09c` with this change: 311 passed. The 6
that fail are the same 6 that fail on plain main on this PC. Rebased
onto `fab8cb3` since, with no conflict, and the two tests above pass
there. It also merges cleanly with #1905, which changes the same wait's
batch flush.

Related: #1005, #523, #449, #296.
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.

3 participants