fix(vk): with per-queue fences, a CPU read-back waits for the GPU work that writes it again #2125
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2125
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/readback-settle"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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
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.ifininvalidate, using the samesync_settle()the texture branch further down already calls. Nothing changes withRX_VK_PER_QUEUE_FENCES=0,RX_VK_ASYNC=1orRX_VK_SYNC_WAITS=0.tests/test_readback_settle.pyfails 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
invalidatelooks 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
invalidatehas changed several times, each step fixing the one before:wb_pages_note, noted when that work is recorded), andinvalidatewaits for that value. Before the lookup the replay is caught up, so work handed over can note its write-back first.wait_queue_emptyin the sync replay,sync_settle).!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.RX_VK_PER_QUEUE_FENCES=0goes 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, andwait_recordedwaits for exactly that recording, as before. Stopped work whose waits are not met is not waited for.RX_VK_PER_QUEUE_FENCES=0,RX_VK_ASYNC=1orRX_VK_SYNC_WAITS=0nothing changes: those still callwait_queue_empty()as before.RX_VK_READBACK_NO_CATCHUPstill turns the catch-up off.What it changes, measured
RX 5500M (RDNA1, 4 GB), Windows 11, AMD driver, main
92907f7with #1581. Read-back figures come from thereadbacks of GPU results: N of 3000 waitedlines.RX_VK_PER_QUEUE_FENCES=0On 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=0I 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.pystill passes.tests/run_all.pyon mainbdfe09cwith this change: 311 passed. The 6 that fail are the same 6 that fail on plain main on this PC. Rebased ontofab8cb3since, 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.
Read through this against current main. The change is small and does what the description says: with the sync replay and per-queue fences on,
sync_settle()lets on only the stopped work whose waits are met (it's the same burst/schedule thatwait_queue_empty()does in the non-async case, minus theRX_VK_CATCHUP_FORCEescape), so the work that writes the range gets recorded, its write-back value is known, andwait_recordedwaits for exactly that. Nothing is forced, and withRX_VK_PER_QUEUE_FENCES=0,RX_VK_ASYNC=1orRX_VK_SYNC_WAITS=0the branch is unchanged. The texture branch below already settles from the same entry point on the game's threads, so there's no new locking pattern. The history in the description (the!unforcedskip from #523 outliving the reason #449 removed) matches the code, and I appreciate the edit at the top being upfront that this doesn't fix the #1005 flashes.The cost is real but measured and bounded (about 0.17 ms per read on the main thread in your run), and far cheaper than the
RX_VK_PER_QUEUE_FENCES=0workaround.On #2222 (Brett-D's 250 ms limit on the read-back's
wait_value): no conflict. I applied both diffs to current main one after the other and both apply cleanly, and neither test checks text the other changes. They also mostly cover different setups. #2222's Deck hang is withRX_VK_EVENT_WAITS=0, wheresync_waits_on()is false, sounforcedis false and this PR changes nothing there. On the default path this PR sends more read-backs throughwait_recorded(v, UINT64_MAX, ...), which #2222 deliberately leaves unbounded. That's fine as it stands (#1276 already fixed the batch flush there), but if a hang like #2222's ever turns up with per-queue fences on, that's the wait to look at.One nit inline. CI is green. Looks good to me.
@ -2299,1 +2303,4 @@unforced ? sync_settle() : wait_queue_empty();w = true;}const auto w1 = std::chrono::steady_clock::now();Nit: with per-queue fences this now marks every read-back that finds stopped work as waited, even when the settle had nothing to let on and
v <= completed()afterwards. Before, that path only counted a realwait_recorded/wait_value. So theN of 3000 waited ... ms eachline isn't quite like-for-like with main (it also pulls the per-wait average down). Not a problem for correctness. If you want the numbers to compare cleanly, you could setwonly when the settle actually recorded something, or just mention it in the comment.github-sync referenced this pull request2026-11-12 06:22:54 +00:00