perf(vk): a readback waits for the part that writes its range back, and no game thread waits behind a present for nothing #2602
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
2 participants
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2602
Loading…
Reference in a new issue
No description provided.
Delete branch "perf/readback-part-wait"
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?
Hyrule Field runs about 15 fps slower on
mainthan on v2.0.11-588 (ba86aee), and uses almost twice the CPU. One of the game's job threads sits blocked for 5.5 ms of every frame in a CPU read of GPU results, and the other job threads poll while they wait for it. This PR removes that wait without undoing what #2125 and #2419 fixed.What regressed
RTX 3090, Ryzen 9 7900X, Linux, 2560x1440, uncapped, standing in Hyrule Field from the same save:
ba86aee)fce53453)GPU time and the replay's own time are the same on both.
Cause
An all-thread sample of both builds (
HOS_SAMPLE_ALL) shows the extra CPU is one item:sched_yieldin the host's short-sleep loop, 0.28 cores on the old build and 1.92 on main. It is the game's six job threads (ModuleSystemWorker1-5andsead::MainThread), which poll with 20 us sleeps while one of them is blocked. On main a job thread is inside a readback at 38.7 % of all instants, with 3.9 threads polling meanwhile.The blocked read is of the results that the first part of the scene's submission writes (two buffers, alternating by frame). That submission then stops at an event wait and is finished later, on the presenting thread. Three things held the read up:
sync_order_muwhenever any queue was stopped. #2125 added a look at the stopped queues before the read. The presenting thread holds that mutex for the whole replay of its submission and for its present, so the read waited for both (24 % of all instants, 3.9 ms a frame) for a look that let nothing on.dev.present_muto flush held submits. Held submits exist only with batched submits (RADV). A present holds that mutex about 4 ms a frame. A readback whose recording was already done waited there, and so did the host's copy bookkeeping (rx_nvn_replayed, called under its own lock), which queued the other job threads behind it. The old build has this one too.The fix
All of it is on by default; the two switches only turn parts off, for A/B.
RX_VK_READBACK_PART=0restores the last part. The read still waits for its submission's last part, as on main, in two cases:nvnEventSignal) nobody has looked at since, or while a queue is stopped at an event that neither the stream nornvnEventSignalever set, whose value is read from guest memory. A look counts as taken only once the burst it ran in has finished its recording, so its write-backs are in the page table. In the field 0-9 of 3000 readbacks look; on main every readback that found a queue stopped did.RX_VK_READBACK_SETTLE_ALWAYS=1restores the look whenever anything is stopped.dev.present_mu. An atomic says whether any submit is held. With batched submits a recording is now put where waiters find it only after its submit is in the held list, under the mutex, which also closes a window main has. A wait returns at once when the recordings' timeline has already reached it. No switch.The trade-off against main
Two reads of guest memory at the GPU's time are deliberately not counted for the part wait. Both behave as they did in v2.0.14 and before, that is before #2419:
memory_rb_refresh, in every part's uploads). These copies are how a CPU write to a page it reads back reaches the mirror at all, and a later part that writes such a page again is waited for through its own part. Counting them would put an in-place read in every part and cancel the change. What they carry along is any other data that shares a 4 KB page with a readback range. mooki000 counted it on an RTX 4090 (see their comment in this PR). In Kokiri Forest 0 of 20,371 readbacks shared a page this way. In Hyrule Field 610 of 45,916 did, all with read-only bindings, and in every case the reading part's recording was already complete when the readback's wait returned.Review
Three commits:
798d625a: the three parts above.9daa0718: first review. The part wait gives way to in-place reads in later parts; the look also covers events whose value is in guest memory.4ba31740: mooki000's two findings on this PR, both real, and the second review.Measured
Hyrule Field, 30 s each, alternating builds in one session. Rounds 1-3 are
798d625a's renderer (built before its rebase onto6f7ab2a7) on main's game binary (fce53453); the last two rows are the later commits, built as committed.9daa07184ba31740Round 3's fix run had 367,000 pages swapped in on the PC during its 30 s (the others 20,000-135,000), so I count it as disturbed. The PC was short of memory for the whole session, which is also why the old build swings between 66 and 81.
Time blocked in readbacks, per 3000: about 220 ms with the fix (1500-1540 waits of 0.14-0.16 ms), about 230 ms on the old build, about 1100 ms on main. The counts themselves are not comparable between builds: main counts a readback as waited whenever the replay was behind.
Kokiri Forest,
798d625a, two rounds: fix 65.3 / 58.2 fps at 68-69 ms of CPU a frame, main 40.5 (a hitched run) / 45.9 at 113-117 ms, the old build 50.8 / 56.0 at 84-88 ms. The number of draws there varies from run to run (955 to 1255) on every build, so these are rougher than the field's.The CPU is still 6-8 ms a frame above the old build in the field. The read now makes one real wait a frame, for the first part on the GPU (1-2 ms), and about three job threads poll during it; on the old build those threads sat blocked on mutexes instead. That is the yield loop for sleeps under a millisecond.
HOS_SHORT_SLEEP_TIMER=1on main brings the CPU to 37.5 ms a frame at the same fps, so it is a separate follow-up in the host, not part of this PR.SadTransGirl measured the cost of #2419 independently with an
RX_VK_WAIT_FIRST_PARTswitch (branch) on an RTX 4070 SUPER, DLAA 1440p: about 4-7 fps (Town to Hyrule Field 55.9-56.2 before #2419, 47.4-47.5 on main, 52.7-54.2 with the switch).Tested
python -I -B tests/run_all.pyon Linux at4ba31740: 447 passed, 3 failed, 25 skipped. The three are not this change:test_bake_portabilityandtest_builder_log_read_only(nozstandardortkinteron this PC) andtest_env_on(HOS_FPS_LOGinhost/oot_high_fps.c, on main).tests/test_readback_part.pyruns the page table, the part bookkeeping, the readback's choice of wait, the look at the stopped queues and the burst's end, cut from the source, and counts every copy out of a guest pool.tests/test_flush_nothing_held.pychecks the order in which a held submit is listed, flagged and made findable. Both fail on the commit before the fix they cover.tests/test_readback_settle.py,tests/test_recorded_wait_flush.py, and five fixtures that compile the changed functions.RX_VK_FRAMELOGwithscripts/framelog.py, 30 s each,798d625aagainst the same build with both switches off:4ba31740, fix only: Hyrule Field 0 events in 2558 frames (brightness 37.6); Kokiri Forest 0 events in 1692 frames. That Kokiri run was the 964-draw variant of the scene, darker at 79.1, and I have no switches-off run of that variant to compare its brightness with.Not yet verified
4ba31740in the game beyond one field pair and one framelog pass (above): no Kokiri timing, no run of the old build beside it.4ba31740, so a check of798d625adoes not cover it; it is covered by a source test only.Testing this on Linux with an RTX 4090 (NVIDIA 615.78.08, i9-13900K, Arch); I'll post the numbers for this exact commit as each run is read. Two things from your "Not yet verified" list are what I can give: Kokiri Forest timing, and the flash check with
RX_VK_FRAMELOGandscripts/framelog.pyagainst the same build with both switches off. I am building798d625aitself, so that is the third.What I have already, measured tonight before this pull request existed, and it agrees with your "one item": in a forest scene the whole loss of main is the readback wait, and nothing else in the range costs anything there.
Kokiri Forest, a save with camera turns, 2048x1152, the game's own upscaler, uncapped (
HOS_FPS_MAX=240), headless, a 30 s window after the load, builds alternated A B A B inside one exclusive hold each, the same counting patch in every build (it counts every buffer readback and how long it waited):49f75dc849f75dc89df055f29df055f29df055f2RX_VK_READBACK_NO_CATCHUP=1RX_VK_READBACK_NO_CATCHUP=149f75dc8is v2.1.0-rc.3-53, the last build I had from before #2125 and #2419. "Both taken back" is main with those two pull requests' code hunks reverted, a local diagnostic only.RX_VK_READBACK_NO_CATCHUP=1removes. With both taken back the build is at the old build's rate (79.2 and 80.0 against 78.6 and 78.5 fps).Coming for
798d625aagainst its base6f7ab2a7and against49f75dc8: the forest and an open-field scene uncapped, the forest at a 60 cap with DLAA, your two switches one at a time, the flash check, and your new tests with gcc 16.2 and clang 23.1. Not something I can test: Windows, AMD, an integrated GPU, RADV's batched submits.One thing from reading the code while the timing runs build, in part 2 (the look only after a CPU signal). I think there is a window in which a readback can go through without waiting for work that
schedule()has just let on. It comes from whereev_cpu_settledis stored. Lines aregpu/vk/vk_replay.cppat798d625a.The order today
sync_settle()(2140) takessync_order_mu,burst_begin(0),schedule()(2144).schedule()lets the met queues on, which replays their work into the open recording, and at its end storesev_cpu_settled = signals(2094).burst_end()(2145) takesreplay_mu(1920) andfinish_recording()runsmemory_end_submission(1931), which is where the pages that work writes back get their new value in the page table.The window is between 2 and 3. A readback on another thread calls
sync_settle_met()(2463): the two counters are equal (2159), so it returns without takingsync_order_mu. It then asks the page table (memory_writeback_value, 2476), gets the value from before the work that was just let on, finds it complete, and returns without a wait. That is the read #2125 was for: the range's writer is not recorded yet and its write-back not known. On main the readback tooksync_order_muwhenever anything was stopped, so it could not get past a settle that was still open; withRX_VK_READBACK_SETTLE_ALWAYS=1it still does. The window is short, butburst_end()waits forreplay_muinside it. The same holds for the other places that callschedule()betweenburst_beginandburst_end(2171, 4559, 4635), anddrain()(2105) goes on recording forced queues after the store.Shown without a game. A fixture that cuts
schedule,sync_settle,sync_settle_met, the page table and the readback's choice of wait out of this branch and holds the settling thread atburst_end:The fixture (it imports the helpers of your
tests/test_readback_part.py):python -I <fixture> --source <checkout of this branch> --output <a folder> [--always]. I ran it with g++ 16.2; I have not seen a wrong frame from this in the game, and the flash check is still to come.A way to close it, if you agree it is one: store
ev_cpu_settledafter the burst's recording is finished and its pages are noted (afterburst_end()in each caller, withschedule()returning the count it read), or let the readback's quick test also see that a settle is open.A second one, in part 3 (the flush with nothing held), and only on the batched path, which your description lists as checked by a source test only. Same file and commit.
What the flag is meant to guarantee (your comment at 397): a thread that finds a recording in the maps and waits for it never skips the flush its wait needs. The flag is raised before the maps are written (399) and again after the append (435). In between, a flush of older submits clears it (249).
The order that breaks it
pend_held = true(399), its recording goes into the maps (406 to 424).queue_flush_locked()ends withpend_held = false(249).dev.present_mu(428) and has not appended yet (434) nor raised the flag again (435).queue_flush_for(FR_WAIT)(2256) sees the flag down and returns without the mutex (263); the wait that follows has no time limit (2488, 2489) and is for work that is still to be held.After that the readback waits until something else sends the batch: the producer's own
batch_gpu_idle()check (443), the cap, a poll flush or a present. So it is a wait of up to a frame on the path this pull request frees from exactly that, not a wrong read. With the mutex always taken, as on main, the waiter in step 4 queued behind the producer when the producer already held the mutex, and flushed after it; when both were waiting for the mutex main could lose the same way, so main was not strict here either, but the quick test makes the skip certain for the whole of step 3.Shown without a game. The real
submit_recording,queue_flush_locked,queue_flush_forandwait_rec_seqbodies cut from this branch, Vulkan replaced by a model that says what was sent, the producer paused before its append with the mutex held:The fixture:
python -I <fixture> --source <checkout of this branch> --output <a folder> [--locked-control]. It fails by default and passes with the control with g++ 16.2 and clang++ 23.1 here. (The first fixture now builds with clang++ too, same result: updated.) I have no RADV card, so I cannot say how often this order happens in play.A way to close it: count the producers that have raised the flag and not appended yet (up at 399, down after 435 under the mutex), and let the quick test return only when the flag is down and that count is zero; or have the flush leave the flag up while that count is not zero.
@mooki000 thanks for testing. Heads up: I pushed
9daa0718on top of798d625a. It fixes two review findings: the readback's part wait now gives way to the whole-submission wait while any part reads guest memory in place (direct-bound pools, live mirror syncs), and the look at stopped queues also covers events in guest memory. On a GPU with enough VRAM for mirrors it shouldn't change timing (no in-place reads in our Field/Kokiri logs). Your798d625anumbers still count for perf; the flash check would be most useful on9daa0718.@hckrman101 thanks, the flash check and the timing will be on
9daa0718then (I build that one; the forest table above stays as the picture of main).One note so the two comments above do not get lost under the new head: both still apply to
9daa0718as far as I can see.schedule()still storesev_cpu_settledat its end (now line 2107) andsync_settle()still callsburst_end()after it, so the quick test insync_settle_met()can still pass before the pages of the work just let on are noted. The newev_wait_in_memorytest sits beside it and does not change that order. My fixture cuts the old quick test out of the source and no longer builds against the new one; I'll adjust it and update the link.9daa0718(fails by default, passes with the quick test removed; g++ 16.2).@mooki000 thanks, both are real as far as I can see from the code, and both are good catches.
ev_cpu_settledis published beforeburst_end()notes the pages, so a readback can pass the quick test and read the old value: exactly the read #2125 is for. The fix will publish the count only after the burst's recording is finished and its pages are noted, for everyschedule()caller anddrain().Both get tests of their own that fail on
9daa0718and pass with the fix. I'll push them on top and ping here. Until then the PR stays in draft. Your flash check and timing on9daa0718are still useful, since neither change should move the frame rate on NVIDIA.Thanks. In case it helps for the two tests: the settle fixture adjusted to
9daa0718is here. On that head, with g++ 16.2 and clang++ 23.1:It models an event the CPU signalled (
ev_wait_in_memoryfalse) and asserts that no later in-place part hides the window, so9daa0718's new guards are not what it trips over. The batch fixture linked above runs on9daa0718unchanged.Timing and the flash check on
9daa0718are building; numbers follow here.A third one, on
9daa0718's new guard (the part wait gives way while a later part reads guest memory in place). The guard goes bynote_in_place_read(), and one way of reading guest memory in place does not call it.note_in_place_read()(gpu/vk/vk_memory.cpp1652) is called at five places: 2443, 2750, 2782, 2810, 3118. Its comment names them: a pool bound with no mirror, a mirror's dirty pages no capture holds, a mirror's first copy.gpu_buffer()(2578) hands out the imported guest buffer itself (p->buffer) and does not call it. Its two users give that buffer to the GPU to read at the GPU's time: the GPU unswizzle's source (gpu/vk/vk_texture.cpp1142, bound as a storage buffer at 1177) and the texel buffer views (3580, 3601).memory_in_place_part()where it was;in_place_afteris false (vk_replay.cpp2509) and the readback waits for the first part only (2511). That is the early return issue 997 was about, for these two readers.Shown without a game. The real page table, readback choice, recording bookkeeping, marker and
gpu_buffercut from9daa0718; two parts, the first writes the page back, the second takes it throughgpu_buffer:The fixture:
python -I <fixture> --source <checkout> --output <a folder> [--noted-control]; g++ 16.2 and clang++ 23.1, same result. I am not claiming a wrong picture from it: whether the game overwrites a texture's source or a texel buffer's memory that soon I have not shown.By reading only, not run:
memory_rb_refreshcopiespp->bufferinto the mirror at the GPU's time (2567) without the note as well.A way to close it: call
note_in_place_read()ingpu_buffer()itself, so every present and future user of the raw buffer is covered, and at the refresh copy.The refresh copy from my last comment is now run as well, no longer only read: the same fixture with
--refreshcuts the realmemory_rb_refreshbody, which records one copy from the imported guest buffer into the mirror, and the marker stays where it was:g++ 16.2 and clang++ 23.1 on
9daa0718, same result; thegpu_buffercase is unchanged.@mooki000 thanks again. Both of these were on
9daa0718;4ba31740(now the head) handles them:gpu_buffer(): it now callsnote_in_place_read()itself, as you suggested, so the GPU unswizzle's source and the texel buffer views are covered, and so is any future user of the raw buffer.memory_rb_refresh: deliberately not noted, with the reason in the comment atnote_in_place_readand in the PR description's "trade-off against main" section. Those copies run in every part's uploads and are how a CPU write to a page it reads back reaches the mirror at all. A later part that writes such a page again is waited for through its own part. Noting the refresh would put an in-place read in every part and cancel the change. So what it leaves open is other data sharing a 4 KB page with a readback range, the same as before #2419. If you think that residual is worse than it sounds (e.g. a readback range that regularly shares its page with data a later part reads), I'd like to hear it.4ba31740also has your two races fixed (the count is published atburst_end, and a held submit is mapped only after it is appended underpresent_mu), plus a second review. First field pair on it: fix 77.2 fps / 47.0 ms CPU, main 61.1 / 75.5. The flash check is running.Status: ready for review at
4ba31740.Measured on
4ba31740(Hyrule Field, RTX 3090 / 7900X, 2560x1440, uncapped, same session): fix 77.2 fps / 47.0 ms CPU a frame, main 61.1 fps / 75.5 ms. Earlier builds of the change: 76.8–78.7 fps in the field; Kokiri 65.3 / 58.2 fps against main's 40.5 / 45.9 (798d625a).Review:
798d625afound the in-place reads in later parts and the events in guest memory; both fixed in9daa0718.9daa0718: the look being counted before its burst's pages were noted, and a held submit being found before it was held. Both fixed in4ba31740, each with a test that fails on9daa0718.gpu_buffer()is noted in4ba31740. The readback-page refresh is left out on purpose; see "The trade-off against main" in the description.4ba31740found it correct: no new lock-order problem (the one new edge ispresent_mu→rec_map_mu, andrec_map_muis a leaf).Still running: the framelog flash check on
4ba31740in the field and Kokiri, and the batched-submit path (RADV, RX 580). Results will be posted here.Not testable here: Windows, a no-mirror iGPU.
If something turns up after merging:
RX_VK_READBACK_PART=0andRX_VK_READBACK_SETTLE_ALWAYS=1restore main's readback waits.Flash check on
4ba31740(RX_VK_FRAMELOG+scripts/framelog.py): Hyrule Field 0 events in 2558 frames, Kokiri Forest 0 events in 1692 frames. Readbacks in the field pair: 1534-1551 of 3000 waited, at 0.10-0.12 ms each (main: 172-176 at 6.3-6.8 ms). 0-1 of 3000 took the look at the stopped queues, and 0 fell back to the whole-submission wait for in-place reads. The RADV batched-submit check is still to come.Checked
4ba31740against the three fixtures, each adjusted to the moved code with its assertion unchanged (settle, held submit, in-place readers). g++ 16.2 and clang++ 23.1, same result with both:9daa07184ba31740gpu_buffer()as an in-place reader--refresh)So the two races and the
gpu_buffer()reader are closed as far as these can show. What the fixtures cannot show is a path that reaches the same state another way; I am reading the callers ofschedule()anddrain()and the two mutexes' order for that and will say what I find.On the refresh copy: your argument makes sense to me from the code, and I would rather answer your question with counts than with a guess. I am taking them from this PC's runs (how often a readback range's page also holds data a later part reads through a refresh, forest and open field) and will post them with the timing and the flash check on
4ba31740.First timing here, on an RTX 4090 (NVIDIA 615.78.08, i9-13900K, Arch Linux). This hold ran on
9daa0718, just before4ba31740came; the same arms on4ba31740are building and follow.Kokiri Forest, uncapped (a save with camera turns, 2048x1152, the game's own upscaler, headless, 30 s after the load, base and fix in turn inside one exclusive hold, the same counting patch in both):
6f7ab2a7(base)9daa07186f7ab2a7(base)9daa0718The fix: 3.20 ms a frame less (15.65 to 12.45), 16.4 fps more, 21.1 ms less CPU a frame, and the long frames gone (399 and 375 over 20 ms against 20 and 17). GPU time is the same in all four (5.91 to 5.99 ms a frame). For scale, in other holds tonight the build from before #2125 and #2419 (
49f75dc8) ran this scene at 78.4 to 78.6 fps; the comparison with it in one hold comes on4ba31740.What main costs under a 60 cap, since most people play capped: the same scene with DLAA and the 60 cap,
49f75dc8against main9df055f2(one exclusive hold, run before this pull request's builds were ready):49f75dc89df055f249f75dc89df055f2So even capped, main is 4.2 fps lower here, with about 5 times the frames over 20 ms. The same arm with the fix follows.
Reading
4ba31740for the two races moved elsewhere: nothing found. All six callers ofschedule()leave the count toburst_end()when a burst is open, andburst_end()publishes it afterfinish_recording();drain()'s early return comes before any look and its forced resumes stay inside the burst.rec_map_muis taken at four places, only ever insidepresent_muand never the other way round.Still to come from here: forest and open field on
4ba31740against its base and against49f75dc8, the 60-cap arm, your two switches, the flash check, and counts for the refresh question.Counts for the refresh question (whether leaving
memory_rb_refreshout of the in-place readers can hand later work bytes newer than its submission expected).How it was counted:
4ba31740plus an observation-only counter, no wait changed,RX_VK_PARALLEL=0so that a part belongs to one recording. It logs every readback after its wait, the refresh copies actually made (guest to mirror), the mirror bindings on pages that have had a readback, and each part's recording with whether it had completed. A readback is "sharing" when a later part of the same submission value refreshes its page and binds bytes of that page outside the readback's own range. RTX 4090, headless, one 30 s window a scene.All 610 are one shape: a 192-byte readback with a read-only 16-byte binding that starts 464 bytes before it and a read-only 64-byte binding that starts 192 bytes after it, both outside every readback range of that submission. In each case the recording of the part that binds them had completed when the readback's wait returned, so the game could not have changed those bytes under it.
So in these two scenes the choice costs nothing: the sharing is rare, read-only, and never still in flight. This is an answer in numbers for one card and two scenes, not a proof:
The counter is a small local patch on top of
4ba31740(off unless its switch is set). If you want it for another scene or for RADV, say so and I put it in a gist.Batched-submit path on hardware: RX 580 (RADV, GCN), with batching forced on (
RX_VK_BATCH=1; it is off by default on GCN), Kokiri Forest, 150 s of play per run, headless.4ba31740defaults4ba31740defaults4ba31740,RX_VK_READBACK_PART=0 RX_VK_READBACK_SETTLE_ALWAYS=16f7ab2a74ba31740works on the batched path.The same readback wait on Windows, with an 8 GB NVIDIA card and a 6-core CPU
My logs show the wait this PR removes, on a build that contains
fce53453. These are the "main" numbers for another platform; I have not run this PR's build yet.RX_VK_WB_MAX_KB=1024inhookshot_env.txt. VRAM budget Auto in session A, 4.8 GB in session B.hookshot_log.1.txt)hookshot_log.txt)sead::MainThread)GPU-bound waitis 0.0 ms in every report, so this blocked read is where the GPU's time enters the frame here.its waits. That is a separate topic.
How I counted: from the log's
readbacks of GPU resultslines, against theN presentscounter. Gameplay is the report periods with more than 300 draws a frame.@mooki000 thank you, that's a lot of useful data.
9daa0718(63-65 → 80), 21 ms less CPU a frame, and the frames over 20 ms down from ~390 to ~20. That puts it just above your pre-#2125/#2419 build (78.4-78.6), which matches what we see in the field.4ba31740for the races.@luiz-august0 thanks, this is the Windows data we were missing. About one wait a frame at 9-12 ms, roughly one frame's GPU time, is the same cause-1 picture on a 4060 / 5600X. Right: batched submits are RADV only, so cause 3 doesn't apply to you. If you can run this PR's build (the Windows CI artifact of
4ba31740) with the same settings, the readbacks line and gameplay fps against that nightly would be very valuable. Windows is the one platform nobody has run it on yet. The 4.8 GB budget / 144 MB pool in system memory is worth its own issue.The capped arm with the fix, as asked: Kokiri Forest at a player's settings (DLAA, the 60 cap, 2048x1152), headless, RTX 4090 (NVIDIA 615.78.08), base and fix in turn in one exclusive hold, all four runs counting. This hold ran on the pull request's head
4ba31740just before the merge; the same on merged main is running now.6f7ab2a7(before the fix)4ba31740(the fix)6f7ab2a7(before the fix)4ba31740(the fix)With the fix the scene holds 59.3 and 59.2 of 60 (before: 54.2 and 53.9), frames over 20 ms go from 468 and 484 to 64 and 71, over 25 ms from 39 and 47 to 10 and 7, and the game uses about 25 ms less CPU a frame. For scale, the build from before #2125 and #2419 in another hold tonight (so not a paired comparison): 58.4 and 58.9 fps, 85 and 85 frames over 20 ms.
Uncapped, the fix against the build from before the two changes, in one hold each (own upscaler), and the field against the base:
4ba31740(the fix)49f75dc8(before #2125 and #2419)4ba31740(the fix)49f75dc8(before #2125 and #2419)6f7ab2a7(before the fix)4ba31740(the fix)Against the old build the ranges overlap in both scenes, so nothing is left over on this PC: the fix gives back all of it. What remains of the readback wait is 0.6 to 0.7 ms a frame in the forest and about 0.3 ms in the field (0.02 ms on the old build).
Still coming from here, on merged main: this capped hold again, and the flash check with a window and the pixel frame log (a run with the readback wait switched off first, to show the check sees a flash at all).
Windows numbers, since Windows was on the "not yet verified" list. These ran on the first head (
798d625a), not on the merged4ba31740, so read them as the size of the effect on this platform, not as a check of the final code.Windows 11, RTX 3070 8 GB (driver 610.88), i7-14700KF, the game's 2048x1152,
RX_VK_IMAGE_BLOCK_MB=32andRX_VK_WB_MAX_KB=1024. Teleport, then about 2 minutes standing, with arms alternated in one session (P O O P P O O P). P = the branch's defaults; O = the same build withRX_VK_READBACK_PART=0andRX_VK_READBACK_SETTLE_ALWAYS=1(so part 3 stays on in O).Castle Town Market (uncapped in practice: GPU ~10.5 ms a frame, the replay is the limit):
So +27 % here (54.7 against 42.8 fps on average).
Hyrule Field at the teleport spot sits at the 60 cap with either arm (GPU ~9 ms), so it shows only the dips: the lowest 5 s window per run was 56.2-58.9 fps in P and 53.6-55.0 in O.
One O run in the field (the third) ended in
VK_ERROR_DEVICE_LOSTafter 72 s; the 8 P runs and the other 7 O runs had none. O is the old wait path on a new build, so I would not pin it on this change, but it is the only device lost in the 43 runs on this card tonight.Follow-up on the RX 580 (RADV, GCN) runs: the earlier fix-on runs drew a different scene (about 2435 draws a frame against about 2050; the camera was farther back, from input timing on the way into play), so that 1.3 fps gap was partly a scene difference. I re-ran 4 alternating pairs of
4ba31740, warm, 120 s of play, Kokiri Forest:The fix arm at the 60 cap, as asked, on the merged commit: once without a window and once in a real one; then the flash check. Linux 7.2.15-1-omarchy, RTX 4090 (NVIDIA 615.78.08), i9-13900K. Builds: merged main
09aac439(this pull request as76bbe4aa), the commit before it6f7ab2a7, its last head4ba31740, and49f75dc8(the last build I have from before #2125 and #2419); each is the exact commit plus one local counting patch that logs every buffer readback and how long it waited.1. The 60 cap with DLAA, Kokiri Forest, 2048x1152 without a window
09aac43959.4 and 59.3 fps against6f7ab2a754.4 and 55.7 (-1.31 ms a frame, -7.2 %); frames over 20 ms 37 and 65 against 474 and 383, over 25 ms 4 and 7 against 44 and 24; 1 % low 41.7 and 39.4 against 32.2 and 32.5; CPU a frame 59.1 and 57.4 ms against 82.6 and 79.5; waited in readbacks a frame 0.78 and 0.69 ms against 5.97 and 5.48. The build from before #2125 and #2419 ran 58.4 and 58.9 fps here with 85 and 85 frames over 20 ms (the pair posted earlier, another hold), so the fix is back at that level.6f7ab2a709aac4396f7ab2a709aac4392. The same pair in a real window (1920x1080 on a 60 Hz monitor, Hyprland on Wayland; the log says mailbox, V-Sync Auto)
09aac43959.9 and 59.9 fps against6f7ab2a755.9 and 56.2 (-1.15 ms a frame, -6.4 %); frames over 20 ms 82 and 67 against 665 and 616, over 25 ms 8 and 6 against 41 and 40, over 33 ms 0 and 0 against 1 and 1; 1 % low 41.0 and 42.5 against 35.4 and 35.0; CPU a frame 56.1 and 56.6 ms against 80.1 and 78.6.6f7ab2a709aac4396f7ab2a709aac439Runs of 105 s here, the window 15 to 70 s after the save's load, judged in two halves by the quiet rule below; frame times are the swapchain's present intervals. Memory available 56.7 GB before the hold and 75.5 GB after, 5058 pages swapped out during it.
3. Flash check (
RX_VK_FRAMELOGandscripts/framelog.pyof the merged commit; a real window as above, the 60 cap, the game's own upscaler, the frame log on for 76 s of play with the camera turns; not timing runs)09aac439,RX_VK_NO_READBACK_WAIT=1, forest6f7ab2a7, forest09aac439, forest09aac439, fieldNo event is larger than 4 of the picture's 144 blocks. I looked at the eight largest of each run (before, frame, after): Navi, falling leaves and, in the field, a tree against the sky crossing a block while the camera turns; no change of the whole picture in any of them. The number of these small events differs from run to run; my reading, not a measurement, is that it goes with how evenly the frames came in these shared runs (a small bright thing crosses more of a block when a frame is late), not with the build.
The control shows nothing, so this check has no proof on a real fault here. WF1 was meant to be that proof: the merged build run once on purpose with the wait of #296 switched off (
RX_VK_NO_READBACK_WAIT=1; none of its 231,148 readbacks waited, by the counting patch), where the exposure is documented to step. On this card at the 60 cap it does not: its events are of the same kind and size as the normal builds' (164, between the 63 and 284 of the two normal runs of the same scene), and there is no step of the picture's brightness. So the rows above say that nothing of a flash's size happened in these runs; they do not show that the check would have caught the fault the wait is there for.The script itself does report a flash that is in the log (the instrument's proof only): in 400 records of the merged run with a white rectangle of 12 blocks painted into one frame and one whole frame made 30 % brighter, it reports those two frames with 12 and 127 blocks; before the painting it reported none there.
What this cannot see: what the monitor showed (with mailbox the compositor takes the newest picture per refresh), a fault that needs another area or more than 76 s, and anything under the script's thresholds.
4. Also measured, on the last head
4ba31740(without a window; the tables are folded below)4ba3174059.3 and 59.2 fps against6f7ab2a754.2 and 53.9 (-1.62 ms a frame, -8.7 %); frames over 20 ms 64 and 71 against 468 and 484, over 25 ms 10 and 7 against 39 and 47; 1 % low 38.9 and 39.6 against 28.6 and 23.6; CPU a frame 57.2 and 58.6 ms against 83.3 and 83.0.HOS_FPS_MAX=240, the game's own upscaler), forest:4ba3174080.1 fps against6f7ab2a763.0 and 63.4 (-3.33 ms a frame, -21.1 %); CPU a frame 55.2 ms against 76.7 and 76.4;4ba3174079.2 and 81.2 fps against49f75dc879.3 and 79.5 (-0.12 ms a frame, -1.0 %).4ba3174092.8 and 92.7 fps against6f7ab2a769.2 and 68.6 (-3.74 ms a frame, -25.7 %); CPU a frame 50.1 and 50.1 ms against 71.0 and 71.8;4ba3174092.6 and 92.1 fps against49f75dc891.5 and 91.9 (-0.07 ms a frame, -0.7 %).Every run of these holds
The 60 cap with DLAA, forest:
6f7ab2a74ba317406f7ab2a74ba31740Uncapped, forest:
6f7ab2a74ba317406f7ab2a74ba317404ba3174049f75dc84ba3174049f75dc8Uncapped, field:
6f7ab2a74ba317406f7ab2a74ba317404ba3174049f75dc84ba3174049f75dc8How the runs without a window were made. Each scene from a save, with the same scripted camera turns; runs of 82 s, the numbers from a 30 s window that starts 15 s after the save has loaded; two builds in turn, A B A B, inside one exclusive hold of the card (nothing else on the GPU, no builds); every copy with prepared shaders and one unmeasured run of each scene first. A run counts when over its window everything else on the PC used under 3 cores on average, no 5 s sample of that was over 6, and the two runs of an arm agree within 5 %. "CPU a frame" is the process's processor time over the window (all threads, from /proc) times the mean frame time; "waited in readbacks" is the time from a readback's entry into its wait to its end, summed over a frame.
4ba31740and76bbe4aadiffer only by #2598's files;09aac439adds CI files.Left out of every figure above, and shown in the tables: Q1/4 (
4ba31740: others 5.2 cores; a 5 s sample of 19.8 cores). The cause: a desktop job of this PC that starts about 37rgsearches for 3 to 5 s every 15 minutes.Not tested here: Windows, AMD, an integrated GPU or any card without VRAM mirrors, RADV's batched submits, frame generation, more than these two scenes. The game-free checks I ran with g++ 16.2.1 and clang 23.1.3 were on
9daa0718(above); not again on the merged commit.Results from an 8 GB laptop card on Linux. This PR fixes the main thing that held my game back: one job thread blocked in readbacks about a third of the time. At the same scene size, fps went up 30-50%.
Setup: RTX 3070 Laptop 8 GB, NVIDIA 620.70.06, CachyOS (Linux). DLSS (built with the SDK), output 1920x1080,
RX_VK_VRAM_MB=3072,RX_VK_COMPACT=1, built-in frame interpolation (DLSS-G needs an RTX 40). Both builds also have #2482 merged.ef81aee+ #2482 (v2.1.0-rc.3-195-geb0c4a2b), a 102-minute session.09aac43(this PR as76bbe4aa) + #2482 (v2.1.0-rc.3-200-gd82f41c7), a 52-minute session: the Shadow Temple, then Lake Hylia and the Desert Colossus.The commits in between are #2598, #2347 and #2556. On this card DLSS-G is not available, so none of those three should change performance. The two sessions are normal play, not an A/B run in one spot, and they took different routes. So I compare them by draw count rather than by place.
Readbacks
submita frame (median)More readbacks wait now, but each wait is about 100 times shorter. Before this PR the line came out about every 5.4 s. So a game thread was blocked for about 1.1 s of every 5.4, the same picture as in your Hyrule Field trace. GPU time did not change, so the gain is all on the CPU side.
Fps at the same scene size
(Median of the
presentslines' fps, grouped by their draw count.)On an 8 GB card the gain is larger than the 3090 numbers in the description. Before this PR my waits were 10-20 ms each, not 5.5 ms. The gain shrinks as scenes get bigger, where the replay itself (#995) takes over.
I saw no visual problems: no flashes, no missing shadows or geometry. There was one 930 ms hitch, when VRAM ran out entering a large area (
VRAM fullx4, a 64 MB staging arena took 159 ms to make). It is memory pressure (#2412), not this PR.Windows test: Hyrule Field, stock and with our backend switches
Setup
55b976c0(one commit past the PR's base6f7ab2a7, only the DLSS frame-generation commit, novk_replay/vk_memorychange) + the three commits of this PR (798d625a,9daa0718,4ba31740), applied cleanly. Vanilla lift, no PGO.hookshot.exe469,956,608 B,hookshot_vk.dll11,190,272 B.RX_VK_READBACK_PART=0andRX_VK_READBACK_SETTLE_ALWAYS=1. Headless, fixed save, scripted menu input, teleport toHyruleField_Adult @ HyruleField_FrontOfLostWoodBridge, 330 s per run, ~1430 draws; fps counted after the teleport and 600 frames, readback lines from steady play.Result (Hyrule Field, 1080p, same save and route)
PART=0,SETTLE_ALWAYS=1)RX_VK_VRAM_MB=4096,RX_VK_COMPACT_PCT=90RX_VK_READBACK_NO_CATCHUP=1SYNC_LIVE(4 GB, pct 90, NO_CATCHUP, WB_MAX 1024)This is the same direction as your RTX 3090 result: +11.2 fps stock (+23%), 5.5 ms -> 0.02 ms, and the long frames go away (min 42.7 -> 56.3). Windows was on the "not yet verified" list; this covers it for one NVIDIA machine at 1080p.
Kakariko Village (same build and final config, ~2510 draws): 47.4 fps avg (40.4-50.0, last 48.2), 1227-1245 readbacks of 3000 at 0.15-0.16 ms, 0 fallback. The town is CPU-bound; the readback wait is no longer what limits it.
The one interaction we found:
RX_VK_SYNC_LIVE=1Our config sets
RX_VK_SYNC_LIVE=1(dirty pages not in the submission's capture are read from guest memory). With only that switch added to the stock build:SYNC_LIVE=1fixSYNC_LIVE+ 4 GB + pct 90 + NO_CATCHUP + WB_MAX)The log's own counter shows why: with
SYNC_LIVE=1the mirror syncs read 80,954 pages "from guest memory as it was at the replay" per 600 submits (0 with it off), so a later part reads guest memory in place and the part wait gives way to the whole-submission wait for ~90% of the readbacks. That is exactly the guard you added in9daa0718, not a wrong read; the switch and the part wait just cannot both apply. We will dropSYNC_LIVEin our config and keep the part wait.RX_VK_VRAM_MB=4096+RX_VK_COMPACT_PCT=90alone andRX_VK_READBACK_NO_CATCHUP=1alone do not trigger the fallback (both at 59.8-60.0 fps, 0 fallback), so the 4 GB texture budget is not the problem.Checks
tests/test_readback_part.py,tests/test_flush_nothing_held.py,tests/test_readback_settle.py,tests/test_recorded_wait_flush.py: pass on Windows at this head.0 looked at the stopped queues firstin every fix line matches your field count;SETTLE_ALWAYS=1restores the look (172 of 3000 in the stock-waits arm), as documented.Limits
github-sync referenced this pull request2026-11-12 06:22:54 +00:00
One more data point for the absolute level, same save and route, 1080p: my current playable build (
4a53c897+ zstd + BC7, readbacks 0.02 ms) runs it at 59.67 fps avg (56.4-60.5, last 60.3), 1325/3000 readbacks at 0.02 ms, 7142 MB VRAM, 3490 MB textures. The PR at55b976c0with the 4 GB config runs it at 59.77 fps avg (57.2-60.5, last 60.3), 1340/3000 at 0.01 ms, 7280 MB VRAM, 3495 MB textures. So the PR brings newest main to the old build's level in this scene, against 48.12 fps on main without it.An integrated GPU without VRAM mirrors, which the description listed as untested: Intel UHD Graphics 770 (RPL-S), Mesa 26.2.5 (ANV), Linux 7.2.15-1-omarchy, i9-13900K. Merged main
09aac439plus a local counting patch, with what the game chose for this device at its first start (the log:the Minimum (4GB) preset, which sets Resolution to 50 %;VRAM mirror of buffer pools off; the compute fallback for mesh shaders), 2048x1152 output without a window, Kokiri Forest from a save, standing still. Started withRX_VK_PRECOMPILE=0 RX_VK_NO_WARM=1 RX_VK_NO_PREDICT=1: without them the driver's shader compiles take more memory than a run may have here.The part wait does not act on this card, so there is nothing for it to cost.
wb_pages_note(..., part)in the write-back ofgpu/vk/vk_memory.cpp); results the GPU writes in place are noted with part 0, and without mirrors that is every one of them.invalidatethen has no part to wait for and waits for the whole submission, as before this pull request.RX_VK_READBACK_PART=0 RX_VK_READBACK_SETTLE_ALWAYS=1RX_VK_READBACK_PART=0 RX_VK_READBACK_SETTLE_ALWAYS=1The card is GPU-bound in this scene (85 ms of GPU time in a 95 ms frame in the first, long run), and the game spends 75 ms of each frame waiting in readbacks: here that is the wait for the GPU's frame itself, the whole-submission wait your follow-up would fall back to.
How to read the table: the Intel device has no exclusive hold on this PC, so other work ran beside these runs (the last column; a run counts for me only under 3 cores on average with no 5 s over 6), and the frame rates are rough. I1 is the copy's first start on this device (one run of 440 s, the window 15 to 250 s after the load); I2 is the pair (105 s runs, the window 30 to 70 s after the load).
What does differ between the two arms is how the same wait is cut up, and by the code that can only be the settle switch, since no readback here has a part: with the defaults 604 and 593 of 3000 readbacks wait 30.7 and 31.2 ms each (51 and 57 of them after a look at the stopped queues); with both switches 236 and 224 wait 83.7 and 87.2 ms each, every one after that look. About 2.6 times as many waits, each about 2.8 times shorter, for about the same time a frame (85.4 and 83.6 ms against 88.5 and 87.7 ms): the shape you describe on the RX 580, on a card where the part wait cannot be its cause. Whether it costs frames here I cannot say: other work used 13 to 30 cores beside all four runs of the pair, so none of them counts as a timing.
Not a correct picture on this device, with or without this change: on today's main the UHD 770 draws this area in false colours (dispatches are left out while their compute pipelines compile; reported separately). "The same scene" above means the same draws a frame. No device lost, no i915 line in the kernel log, in any run.
Windows run for @hckrman101's request: main against the last nightly before this PR, on an RTX 3080.
This complements DFN-master's RTX 3080 run above: there, one build with the switches that restore the old waits, at a 60 Hz cap; here, two separate builds (main and the nightly itself), uncapped, at the same teleport spot. The nightly matches DFN-master's "stock waits" arm (172 of 3000, ~5.6 ms each), and without the cap main's gain shows in full.
Setup. Windows 11, RTX 3080 10 GB (driver 621.14), i9-12900K, 64 GB. Both built from source with llvm-mingw, no DLSS: main
87991bc8and55b976c0(this PR's parent, the last commit whose Windows and Linux nightly runs both succeeded before it). Main also carries about 50 other merges after #2602, so this is main against that nightly, not #2602 alone. Window 1280x720 (mailbox, V-Sync Auto), default options,HOS_FPS_MAX=1000(uncapped). Scene: the same save, teleported to Hyrule Field (adult, by the Lost Woods bridge, the teleport's own view), standing still; 45 s to settle, then 120 s measured. A warm-up run in each build first, so no pipeline was compiled during the measurement (1481-1495 before and after). Two pairs, interleaved main, nightly, main, nightly.55b976c087991bc8So on this card about +31 % fps (median frame 16.35 -> 12.2 ms), in line with the 3090's 63-66 -> 77-79. On main, more readbacks wait (about half, against 6 %), but each one for 0.04-0.10 ms instead of ~5.7 ms. "Looked at the stopped queues first" and "waited for their submission's last part" are 0 in every line of both main runs.
Log lines, the first five of each measured window (55 and 55 lines on main, 42 and 43 on the nightly):
Not tested: DLSS, frame generation, Reflex, a frame rate cap, other areas, moving through the field.
Windows numbers for this one, after the merge: it recovered the loss from #2125/#2419 on an RTX 5090. (#2700 has RTX 3080 data on the same question.)
Setup. i7-13700K (24 threads), 98 GB, Windows 11, RTX 5090 (driver 621.14), 165 Hz. Every build was made from source with the Hookshot Builder's toolchain and v2.1.0's profile and hot list. Kokiri Forest, the same autosave loaded by a scripted input each run, 2048x1152. Frame generation off and
HOS_FPS_MAX=240("frame rate cap 240" in the log). Frame times come fromHOS_PRESENT_TRACE, 40-70 s after the first present, about 2,330 draws. Builds alternated within each round, with a warm-up run each; 3 runs per build (6 for main).9776c36d(with this PR)55b976c0(main just before this PR)49f75dc8(before #2125/#2419)The readback wait is the sum of "waited x ms each" from the
readbacks of GPU resultslines, divided by the game's frames in play (frames while the scene draws 1,000 or more draws). Edited: the first version divided by every frame of the run, title screen included, which halved these numbers. CPU is the process's CPU time over the measured window.With the player's own settings (frame generation Auto) the game's limiter holds every build at 58-60 fps, so this is the uncapped view. No run had a stall, a "timed out" line or a device loss, and screenshots of main and
49f75dc8in the same view match.What's left after this PR
scripts/cpu_report.py) puts the extra in os/sleep-and-timers (44% of a core against 21% on49f75dc8: the job workers yielding in their 20 us sleeps while one waits in a readback) and render/replay-and-submit (28% against 12%).HOS_SHORT_SLEEP_TIMER=1on main, 2 runs each: 0.9 fewer cores, but about 2 fps lower with worse p95/p99 on this 24-thread CPU. So it's not a default here; CPUs with fewer cores may differ.On top of this PR I built the stack I actually play (zstd program cache + BC7 big textures + the 4 GB texture budget) and ran the same save and route:
One cross-PR note from the Kakariko run: the BC7 path's GPU unswizzle reads the source through
gpu_buffer(), which now notes an in-place read (4ba31740), so the part wait gives way to the last part for essentially every readback there (1221-1261 of 3000, 0.42-0.46 ms). Field and Lake show 0 fallback. It is the guard working as designed; it just means BC7 users lose the part wait in texture-heavy scenes. Town fps is CPU-bound and unchanged.No device loss on this stack; the earlier crash in my runs was stale pipeline play-cache files from older builds (
mkw_vk_pipelines_play_*.bin), not this PR; the zstd program cache was fine.Thanks @dankman42 for the uncapped two-build RTX 3080 comparison, @Brett-D for including v2.1.0 and separating the remaining readback/CPU cost on the RTX 5090, and @DFN-master for the RTX 3080 switch comparisons and follow-up with your play stack. The Linux three-column comparison is on #2595, and I'm measuring Brett-D's remaining cost on Linux now. I'm also checking the BC7 interaction you reported, without drawing a conclusion yet.
The Linux follow-up I promised above, on the cost that is left: the extra CPU cores Brett-D measured on Windows do not show up here. Main uses about 0.21 fewer cores in the forest and 0.45 fewer in Kakariko than v2.1.0, despite the remaining readback wait. This differs from Brett-D’s Windows result; main already includes #2637, which changes Linux’s guest short sleeps to timers while leaving the Windows default unchanged on his CPU.
Setup: RTX 4090, NVIDIA 615.78.08, i9-13900K, Arch Linux 7.2.15-1-omarchy; main
46780503versus v2.1.0, matched release profile/toolchain and equivalent readback logging. Headless, 2048×1152, game’s upscaler, cap 240, frame generation off, warmed caches. Each scene ran A/B/A/B in one exclusive hold; 30s measured after loading. All eight rows below passed the quiet/repeat checks. Other work used 0.90–1.96 cores on average, with no five-second peak above 2.33.467805038ef84611467805038ef84611467805038ef84611467805038ef84611The forest is a fixed, roughly 4,000-draw view; it is not the earlier moving-camera, roughly 2,000-draw measurement. The remaining wait there is 1.29–1.36ms/frame, not the earlier ~0.5ms. Two earlier forest attempts were not used for a conclusion: one had repeat spread above 5%, and one had a run remain at the title screen. Those failed rows were retained. The small frame-rate differences above do not establish a new speed gain beyond the paired repeat spread.
Where the CPU goes: in Kakariko the five module workers together use 0.933–0.934 cores on main versus 1.296–1.311 on the release; the main thread uses 0.307–0.309 versus 0.393. Sampled stacks support the sleep-policy difference: workers yielding inside their sleep calls account for 5.05% of their release samples and zero on main, where 7.99% instead show the timer wait. Those are stack shares, not CPU-core estimates; the cores in the table come from per-thread/process CPU time. The Presentation thread does not show a Windows-sized CPU increase.
The remaining wait is real. The sampled large-result read sometimes reaches a writer recording that has not completed, with later recordings still beyond it. The game then imports frame-tagged result data. Returning without that completion would lose the guarantee that the CPU receives the GPU’s finished results. The stopped-queue publication guarantee from #2125 and whole-submission fences from #2419 still matter. I found no demonstrated safe no-wait change to offer; Linux’s short-sleep follow-up is already merged. “Readback wait” above is only the instrumented invalidate path, not all CPU waiting on the GPU or an additive frame-time penalty.
BC7 interaction: I could enable BC7, but did not reproduce the near-universal fallback in this Kakariko entrance sweep:
RX_VK_ASTC_BC7=1The budget arm used
RX_VK_VRAM_MB=4096 RX_VK_COMPACT_PCT=90 RX_VK_WB_MAX_KB=1024; neitherSYNC_LIVEnorREADBACK_NO_CATCHUPwas set. Both BC7 runs confirmed BC7 in the log. This is not DFN-master’s card, exact save/view or custom stack: my sweep averages about 1,150–1,350 draws, not his heavy town scene. It does not refute his result.The fallback is the intended safety cost when a later part reads guest memory directly. One attribution detail: on this pinned main the BC7 path stages its encoded source, while the separate raw GPU-unswizzle path excludes ASTC/BC textures. The counter alone cannot identify the BC7 encoder as the reader that triggered it; other raw readers or mirror pressure in that stack may be responsible.
Limits: no Windows, AMD/no-mirror GPU, exact tester stack, long play or continuous flash test. Inspected captures show the intended scenes and ordinary HUDs, not proof against one-frame faults. No synchronization was disabled and no product change was tested. All game runs ended without a device loss or kernel GPU fault.