perf(vk): RX_VK_WB_MAX_KB writes large GPU-written bindings back only where the CPU reads or writes them (opt-in) #2731
Closed
github-sync
wants to merge 5 commits from
github/pr/2731 into main
pull from: github/pr/2731
merge into: MagicBeanDev:main
MagicBeanDev:main
MagicBeanDev:github/pr/2893
MagicBeanDev:github/pr/2890
MagicBeanDev:github/pr/2887
MagicBeanDev:github/pr/2878
MagicBeanDev:github/pr/2875
MagicBeanDev:github/pr/2874
MagicBeanDev:github/pr/2873
MagicBeanDev:github/pr/2871
MagicBeanDev:github/pr/2756
MagicBeanDev:github/pr/2897
MagicBeanDev:github/pr/2896
MagicBeanDev:github/pr/2718
MagicBeanDev:github/pr/2842
MagicBeanDev:github/pr/2691
MagicBeanDev:github/pr/2895
MagicBeanDev:github/pr/2743
MagicBeanDev:github/pr/2765
MagicBeanDev:github/pr/2708
MagicBeanDev:github/pr/2732
MagicBeanDev:github/pr/2755
MagicBeanDev:github/pr/2817
MagicBeanDev:github/pr/2876
MagicBeanDev:release-please--branches--main
MagicBeanDev:release-please--branches--main--release-notes
MagicBeanDev:github/pr/2778
MagicBeanDev:github/pr/2888
MagicBeanDev:github/pr/2866
MagicBeanDev:github/pr/2877
MagicBeanDev:github/pr/2800
MagicBeanDev:github/pr/2829
MagicBeanDev:github/pr/2850
MagicBeanDev:github/pr/2889
MagicBeanDev:github/pr/2882
MagicBeanDev:github/pr/2860
MagicBeanDev:github/pr/2849
MagicBeanDev:github/pr/2886
MagicBeanDev:github/pr/2794
MagicBeanDev:github/pr/2840
MagicBeanDev:github/pr/2830
MagicBeanDev:github/pr/2834
MagicBeanDev:github/pr/2719
MagicBeanDev:github/pr/2742
MagicBeanDev:github/pr/2741
MagicBeanDev:github/pr/2820
MagicBeanDev:github/pr/2771
MagicBeanDev:github/pr/2859
MagicBeanDev:github/pr/2858
MagicBeanDev:github/pr/2823
MagicBeanDev:github/pr/2757
MagicBeanDev:github/pr/2769
MagicBeanDev:github/pr/2844
MagicBeanDev:github/pr/2843
MagicBeanDev:github/pr/2673
MagicBeanDev:github/pr/2855
MagicBeanDev:github/pr/2739
MagicBeanDev:github/pr/2807
MagicBeanDev:github/pr/2851
MagicBeanDev:github/pr/2789
MagicBeanDev:github/pr/2814
MagicBeanDev:github/pr/2818
MagicBeanDev:github/pr/2774
MagicBeanDev:github/pr/2772
MagicBeanDev:github/pr/2750
MagicBeanDev:github/pr/2786
MagicBeanDev:github/pr/2785
MagicBeanDev:github/pr/2782
MagicBeanDev:github/pr/2779
MagicBeanDev:github/pr/2775
MagicBeanDev:github/pr/2706
MagicBeanDev:github/pr/2766
MagicBeanDev:github/pr/2747
MagicBeanDev:github/pr/2759
MagicBeanDev:github/pr/2745
MagicBeanDev:github/pr/2740
MagicBeanDev:github/pr/2733
MagicBeanDev:github/pr/2725
MagicBeanDev:github/pr/2727
MagicBeanDev:github/pr/2728
MagicBeanDev:github/pr/2729
MagicBeanDev:github/pr/2721
MagicBeanDev:github/pr/2722
MagicBeanDev:github/pr/2715
MagicBeanDev:github/pr/2714
MagicBeanDev:github/pr/2707
MagicBeanDev:github/pr/2703
MagicBeanDev:github/pr/2705
MagicBeanDev:github/pr/2697
MagicBeanDev:github/pr/2679
MagicBeanDev:github/pr/2676
MagicBeanDev:github/pr/2669
MagicBeanDev:docs/what-hookshot-connects-to
MagicBeanDev:fix/hdr-encode-test
MagicBeanDev:fix/main-tests
MagicBeanDev:fix/play-cache-files
MagicBeanDev:perf/game-barrier-order
MagicBeanDev:feat/heap-lazy
MagicBeanDev:feat/texture-detail
MagicBeanDev:perf/astc-opaque-bc1
MagicBeanDev:fix/fs-dir-lock
MagicBeanDev:fix/framegen-native-kept
MagicBeanDev:fix/update-no-downgrade
MagicBeanDev:port/k10-gcn-f16-mirrors
MagicBeanDev:fix/framegen-mult-cap
MagicBeanDev:chore/release-as-2.1.1
MagicBeanDev:perf/pcache-load-switch
MagicBeanDev:fix/prepare-progress-log
MagicBeanDev:fix/update-closes-stale-game
MagicBeanDev:fix/hdr-scaled-sampled
MagicBeanDev:perf/first-start-preset
MagicBeanDev:perf/zero-upload
MagicBeanDev:fix/vcore-escape-2s
MagicBeanDev:test/fake-gh-atomic
MagicBeanDev:perf/batch-idle-flush
MagicBeanDev:test/finish-stub-a
MagicBeanDev:chore/record-2.1.0
MagicBeanDev:fix/main-red-tests
MagicBeanDev:fix/test-display-startup-driver-cache
MagicBeanDev:feat/mesh-shader-mode
MagicBeanDev:fix/menu-probes-at-startup
MagicBeanDev:fix/fps-counter-classic
MagicBeanDev:fix/test-log-batch-flake
MagicBeanDev:fix/narrow-screen-hud
MagicBeanDev:ci/prereleases
MagicBeanDev:release/v2.0.15
MagicBeanDev:hotfix/v2.0.15
MagicBeanDev:fix/present-ahead-replay
MagicBeanDev:release/v2.0.13
MagicBeanDev:fix/bracket-paths-2013
MagicBeanDev:fix/hdr-overlays-2013
MagicBeanDev:fix/fps-counter-scale-2013
MagicBeanDev:fix/stick-seen-at-rest
MagicBeanDev:hotfix/menu-fits-screen
MagicBeanDev:fix/f1-always-opens-menu
MagicBeanDev:fix/ini-keep-lines
MagicBeanDev:claude/cranky-pike-7cf247
MagicBeanDev:perf/texture-answer-flat
MagicBeanDev:fix/stamp-test-modules
MagicBeanDev:fix/appimage-fonts
MagicBeanDev:fix/display-awake
MagicBeanDev:release/v2.0.12
MagicBeanDev:fix/discord-windows
MagicBeanDev:rc/v2.0.12-tests
MagicBeanDev:fix/dlss-keep-feature
MagicBeanDev:rc/v2.0.12
MagicBeanDev:button-icons
No reviewers
Labels
Clear labels
accessibility
Barrier affecting people with disabilities
autorelease: pending
autorelease: tagged
bug
Something isn't working
dependencies
Pull requests that update a dependency file
documentation
Improvements or additions to documentation
duplicate
This issue or pull request already exists
enhancement
New feature or request
github_actions
Pull requests that update GitHub Actions code
good first issue
Good for newcomers
help wanted
Extra attention is needed
invalid
This doesn't seem right
question
Further information is requested
testing wanted
wontfix
This will not be worked on
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
Milestone
Clear milestone
No items
No milestone
Assignees
Clear assignees
No assignees
1 participant
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!2731
Loading…
Reference in a new issue
No description provided.
Delete branch "github/pr/2731"
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?
Why. Every frame OoT writes ~68 MB through the GPU into bindings over 1 MB (culling lists and per-frame buffers), and all of it is copied back to guest memory at the end of its submission (#384: ~4.4 ms a submit on an RTX 3080). The CPU touches only small parts of those bindings.
RX_VK_WB_MAX_KB=1024(opt-in, #384's experiment) skips them all and is fast, but it is not safe:invalidate, 236 of every 3,000 CPU reads in Hyrule Field landed in a skipped range: four 4.3 MB buffers (+0x44b040) read in full after every frame the GPU writes them, and an 8.4 MB range now and then. Those reads got what guest memory held before the GPU wrote them. The same in Kokiri Forest (168 of 3,000), the Market (76) and Kakariko (128).0x1178000000), always the same ones. A mirror sync copies such a page whole from guest memory, so with the plain cap the rest of the page went back to stale bytes over the GPU's: ~28,000 pages in a 40 s run.This keeps most of the saving and drops both hazards. It stays opt-in (
RX_VK_WB_MAX_KB=1024): after review, on the two machines that ran it (this RTX 3060 Ti, and hckrman101's RX 580 with its mirror forced on) it writes back ~50 MB a frame less at the same frame rate, so the default waits for a machine where that shows as fps.What
gpu/vk/wb_learn.h(plain rules, no Vulkan), held byvk_memory.cppunder its own mutex:RX_VK_WB_MAX_KB(unset or 0: off, the default; 1024 is the tested value) is written back whole in the firstRX_VK_WB_PROBATIONsubmissions that write it (8; several binds in one submission count once).invalidate, before its write-back wait) and the CPU writes a mirror sync copies (each dirty page in a binding the table follows) are noted by range.vk: the CPU read/wrote ..., in a binding whose write-back was left out).resetsin the log line). CPU reads and writes are noted only inside a binding the table follows, and a mirror sync asks once per pool.RX_VK_WB_MAX_KBevery binding is written back, as before.RX_VK_LOG_WB_SKIPS=1lists the ranges left out every 600 submits. A log line every 600 submits gives the count and MB left out.Measured
Windows 11, RTX 3060 Ti 8 GB (driver 32.0.16.2114), Ryzen 5 5600X, 2560x1440 DLSS,
main5b96a55a; 40 s with the camera turning after a settle,RX_VK_FRAMEGEN=0,HOS_FPS_MAX=240,RX_VK_IMAGE_PREALLOC=0in both arms (#2690), arms alternated.RX_VK_WB_MAX_KB=0(as before)The Market's GPU time changes from launch to launch on 8 GB cards (blushOFF's note in #995), so two runs each there are suggestive, not proof. Late CPU touches of a range left out, which the log names: one page in one Market run; and, in the three runs started from a later save, one sweep of ~1,690 consecutive pages (
0x117ffbd000on, one 6.3 MB buffer) during the teleport's scene load, right after the new scene's pools are imported. That is the CPU setting up memory the title screen's buffers had used, whole pages, so nothing stale is mixed in; those pages are written back from then on. Fps of those runs: 58.2 / 57.6 / 55.7 mean.The frame here is mostly the GPU and the once-a-frame exposure wait, so 50 MB less PCIe traffic a frame is worth ~2% on this card; it should be worth more where the copies are slower (laptops, x8 cards). The plain cap was faster (~61 fps, 9 MB a frame) because it also left out the pages the CPU writes and reads, which is the stale data above.
Screenshots at the same spot with and without it match.
Updates after review, the long-session counts and the flash check: see the comments below.
Tests
tests/test_wb_learn.pycompiles and runstests/test_wb_learn.cpp(probation, a binding read every write, pages written during probation, touches clipped to the binding, a late touch reported once, merging, the bounds) and checks thatvk_memory.cppandvk_replay.cppstill call it where they should.tests/direct_buffer_writes_test.cppandtests/parallel_rb_refresh_test.cppget stubs for the new calls (every write-back queued, as before).python -I -B tests/run_all.pyon Windows: 455 passed, 3 failed, 34 skipped. The three fail on unchangedmaintoo here (nx2/test_nup_paths.pyandtests/test_input_background.py: this machine's file links and input;tests/test_finish_output.pyonly when run beside the others, and passes alone).🤖 Generated with Claude Code
writescounts every written bind, and written binds skip the bind memo. A buffer that N dispatches write per frame therefore finishes probation after about 8/N frames, possibly before the CPU's firstinvalidateof its results is seen (the replay can be a frame ahead of that read). Counting at most once per submission would fix it: pass the recording's submission value intoplan()and only increment when it changes.Once
full_is set it never clears, and neitherseen_norread_is ever trimmed. Every invalidated range ends up here, including ones outside any large binding, so a long session may drift into writing everything back with only one log line saying so. Two suggestions: filter reads throughfollowed()inmemory_note_cpu_access, as writes already are, and please report the counts from a long run.This takes
wb_learn_muand runsfollowed()'s backward walk overseen_(up to 1 GB below) for every dirty page of every synced pool. Meanwhile the replay threads' binds wait on the same mutex inwb_leave_out. It might be worth checking once per pool, before the page loop, whether[p.lo, p.hi)overlaps anything followed, and skipping the per-page call otherwise.When
staleis true, the CPU is about to read (or the sync is about to copy) a range whose last write-back was left out. The mirror still has the GPU's data, sincegpu_writtenkeeps it. Could the read path queue that range's write-back and wait for it here, instead of only logging? That would turn the first-touch case from wrong data once into a slow read once.Nit: 57 -> 61 fps is the plain cap's result in the description. This PR measured about 58.8 -> 60.0 median and 57.2 -> 58.6 mean in Hyrule Field, so I'd quote that, or just the 68 -> 18 MB a frame.
Thanks, this is a careful follow-up to #384's experiment: the plain cap's two hazards are clearly explained, and learning what the CPU touches, rather than guessing from size, is the right shape.
wb_learn.his small, has no Vulkan in it, and is unit-tested, which makes it easy to review. CI is green.What I checked and found sound:
invalidate. The read is noted before the write-back wait. So work that is recorded during the catch-up /sync_settlewait already keeps the range, andtest_wb_learn.pypins that order. Ranges the CPU reads keep their write-back, somemory_writeback_valuestill finds them and the readback wait (exposure meter, PR 430's refresh) is unchanged for them.mirror_sync. They are noted before the page copy, and at page granularity, so a page the CPU writes during probation keeps the GPU's rest of the page from then on. That is the flicker case from the whole-page sync.gpu_writtenalready keeps these pools' mirrors from being given back, so the left-out data isn't dropped bymirrors_give_back.wb_learn_muis always taken last: afterpools_mufrom the bind path andmirror_sync, and on its own frominvalidate. I didn't find a lock-order problem with the parallel replay or the readback refresh.RX_VK_WB_MAX_KB=0restores the old behaviour exactly.Points I'd like addressed before this becomes the default (details inline):
RX_VK_WB_PROBATION=8mean what the README says.invalidatecould queue that range's write-back from the mirror, which still holds the GPU's data, and wait for it as it does for kept ranges. If that's not practical, please at least say in the README that an occasional one-off stale read is the trade-off.seen_andread_are never pruned, and every invalidated range goes intoread_, including small readbacks that are in no followed binding. Once either table reaches 8192 entries,full_latches and the session silently falls back to writing everything back. Please report thebindings followed/ranges the CPU readcounts from the log after a long session (30+ min, several scene changes, a save reload). Filtering reads throughfollowed(), as writes already are, would also help.mirror_sync. Every dirty page now takeswb_learn_muand walksseen_back up to 1 GB, and that work runs while the replay threads plan their binds. The walk is cheap while there are few followed bindings, but it grows with them (see 3). Checking once per pool whether it overlaps any followed binding would keep this off the common path.Measurements (our rule from #2712). Thanks for the before/after table: it covers the same scenes, alternates the arms, names the GPU and driver, and includes the late-touch counts. That's what we ask for. For a new default I'd still like:
Nit: the comment in
gpu_buffer_bindquotes "57 -> 61 fps and no frame over 50 ms". 61 fps is the plain cap's figure in the description, and this PR measured about 58.8 -> 60.0 median, so I'd quote this PR's own numbers.No overlap with #2338 or #1377 that would block this, but both edit the same
writebacks.push_backblock ingpu_buffer_bind. #1377 marks a binding over the capgpu_only. Whichever lands second needs a small rebase there, and with this PR "not written back" becomes partial rather than whole.Thanks for the careful review. Pushed
0dd04592andbce3d17e:plan()takes the submission (mirror_epoch); several binds in one submission count once, and a binding is left out only from the submission after its 8th. Each of these bindings is written in one of a frame's six submissions, so 8 is about 8 frames, as the README says. Test: 12 binds in each of 8 submissions, still in probation until the 9th.invalidateit means recording and submitting a mirror -> guest copy from a game thread, outside the replay, and by then the mirror may already hold a later submission's write of that range, so it is a different staleness rather than none. I'd rather do that as its own change if it is wanted. What I did instead:0x117c35a000,0x117b425000); a mirror sync copies that page whole. A binding's first and last page are now always written back when it starts or ends inside one (at most 8 KB a binding). Before: 1 late touch in Goron City, 2 in Zora's Domain, 1 in the Market. After: none during play in any run.0x117ffbd000). That is the CPU setting up memory that the previous scene's buffers used, which I read as whole-page writes, though I have no byte-level proof.resetsin the log line). Reads go throughfollowed()like writes. Long session: two rounds of 12 scenes (Hyrule Field, Kokiri Forest, Market, Kakariko, Lost Woods, Goron City, Zora's Domain, Graveyard, Death Mountain Trail, Zora's River, Sacred Forest Meadow, Gerudo Valley), 24 teleports, 25 s of camera turning in each, 14.5 minutes:49 bindings followed, 49 ranges the CPU read, 0 resetsat the end. No save reload in it.mirror_syncasks once per pool (memory_wb_followed(p.lo, p.hi)) before noting its pages.Nit fixed (the comment now quotes 68 -> 18 MB a frame).
Measurements on the final code (with #2711 merged locally in both arms, since it changes this card's VRAM picture a lot; Hyrule Field, same test as the description, arms alternated):
RX_VK_WB_MAX_KB=0So with #2711 in, Hyrule Field on this card is within noise (GPU ~0.2 ms less); the description's ~2% was measured with
RX_VK_IMAGE_PREALLOC=0instead. Goron City (2.4 MB written back) and Zora's Domain (16.1 MB): 0 late touches each, where the previous push had 2 and 1. On this machine the saving is PCIe traffic more than frame time; whether that turns into fps on a slower bus is what a second machine would show.Second machine. I only have this PC (RTX 3060 Ti, Windows). The other box I can reach is a Steam Deck, where RADV runs without buffer mirrors, so this path never runs there. A Linux/AMD desktop or a laptop / x8 card would need someone else; it is fine by me if this waits for one, or goes in default-off until then.
Screenshots and flashes. One round of the same 12-scene tour with this PR and one with
RX_VK_WB_MAX_KB=0, each with the flash detector on (RX_VK_FLASH=1,RX_VK_SHOT_EVERY=1, so every frame is read back and compared; that is why those tours ran at ~35 fps): no flash detected in either arm (noflash_*files, no flash lines in the log). A screenshot after the camera turn in each scene of both tours: the exposure, fog and lighting match scene for scene (Market, Hyrule Field, Kokiri Forest, Kakariko, ...); only the camera angle and Navi differ. I did not watch the runs live, so that is the detector's word plus the pictures, not my eyes on every frame. I can attach pairs if you want them; they are the game's frames, so I'd rather post only a couple.One case in
wb_learn.hatbce3d17ewhere the new edge-page rule can lose a page, found while testing this on Linux (the game results follow separately):When the table of CPU-touched ranges is one short of
max_read, a binding with two partial edge pages keeps only its last one.edges()adds the first page (the table is now full), then the secondadd_readsees a full table, callsreset(), which forgets everything including the first edge just added, and stores the second. After the new probation only the last edge page is written back; the first shared page is left out, which is the stale-page case the edge rule is there to prevent.A small check against the real header (from the repository root:
g++ -std=c++20 -I. file.cpp), with g++ 16.2.1 and clang++ 23.1.3:The lines are the capacity test inside
add_read(if (read_.size() >= max_read) reset();before theemplace) andedges()calling it twice. Making the pair atomic would close it: for example check the room for both pages before adding either, or haveedges()add both again after a reset.How likely: not seen in play here. A five-scene tour on this PC ended with 31 bindings and 43 ranges, far from 8,192, so it would take a long session with very scattered CPU reads. But when it happens nothing says so, and it only matters once the default leaves large bindings out, so it seemed worth fixing before that.
Not tested for this note: Windows (the check compiles for the Windows target; it was not run there).
@mooki000 thanks, confirmed and fixed in
309e6c35:edges()now makes room for both pages before adding either (if (read_.size() + first + last > max_read) reset();), so a reset can no longer fall between the two.tests/test_wb_learn.cpphas your case (a table ofmax_read4 holding 2 and 3 ranges, then a binding with both edges partial: both pages kept after the probation); it fails onbce3d17eat that check and passes now. Looking forward to the Linux results.The same check on
309e6c35: passes. With 8,191 ranges before, both edge pages are kept after the probation (resets=1 kept=2 first_edge=1 last_edge=1), and the 8,190 control is unchanged. The Linux game runs will use this head.I merged
309e6c35into current main and ran tests/test_wb_learn.py, test_direct_buffer_writes.py and test_parallel_rb_refresh.py: all pass.Against hckrman101's review: the probation now counts submissions (
plan()takesmirror_epoch), the table resets instead of latching, reads are filtered throughfollowed(), mirror_sync checks once per pool, and the nit is fixed. mooki000's edge-page case is fixed inedges()and covered by a test. The point still open is the second one: a range first touched after probation still gets stale data once. That is now documented, not repaired.So what remains is a product call. The default goes from off to 1024 KB for everyone on the strength of one RTX 3060 Ti on Windows. With #2711 in, Hyrule Field is within noise there. The Steam Deck never runs this path, and AMD/Intel desktops and slower PCIe links haven't been run. mooki000's Linux game results are still to come. I'd ship it default-off until a second machine shows a gain, as you offered.
One small fix whichever way that goes:
RX_VK_WB_PROBATIONis read with getenv/atoi (gpu/vk/vk_memory.cpp:2710). Please useenv_int.Test request T-SRQJ: pass
Hardware: AMD Radeon RX 580 8 GB (RADV Polaris10), Mesa 26.2.6, CachyOS Linux, 8 threads, 15 GB RAM. Built
bce3d17e(the PR head) against main51da0464(main is 3 commits past the PR's merge base, none of them ingpu/); both from the same lift, same shader set, equally warm caches (one programs prepare, copied; a warm-up run in each folder; every timed run started from the same Mesa cache).What I checked: Kokiri Forest (the test save), then Debug teleports to Hyrule Field and the Market; in each a 34 s window starting 8 s after the scene first drew, the camera turning (right stick held 30 s).
HOS_FPS_MAX=240 RX_VK_FRAMEGEN=0 RX_VK_IMAGE_PREALLOC=0 RX_VK_PRECOMPILE=0, headless, 2560x1440 present. Frame times fromRX_VK_FRAME_TRACE; 1% low = 1000 / mean of the slowest 1% of frames. Arms alternated.On this card the PR's code does not run by default. GCN keeps the VRAM mirror off (
vk: VRAM mirror of buffer pools off (GCN)), so nothing is written back on main or the PR:0.00 MB written backevery frame, and the PR's line reads0 bindings followedfor the whole run. So I ran a second set withRX_VK_MIRROR=1on all arms, where the PR's path is live.Default (no mirror), median fps per run → median of runs; 1% low:
RX_VK_WB_MAX_KB=0(1)RX_VK_MIRROR=1on every arm:RX_VK_WB_MAX_KB=0(3)The PR's diagnostics (mirror on):
CPU reads or writes of one left outwas 1,686 in every PR run, all in one burst while the Hyrule Field teleport loads (CPU writes to consecutive pages of one buffer, before the scene first draws), and 0 in Kokiri and the Market. Nothe CPU read ...lines at all: every late touch was a write.RX_VK_LOG_WB_SKIPS=1: 25 ranges, ~2.1 GB left out per 600 submits; 26 bindings followed, 37 ranges the CPU read, 0 resets.Correctness (mirror on, the arm where the PR acts):
RX_VK_VALIDATION=1, PR, mirror on, all three scenes): one message ID,VUID-vkCmdDrawIndirect-pStrides-04913, which earlier validation runs on main show too. Nothing about copies or buffers.VK_ERRORin 21 runs.Summary: it's safe on this card and changes nothing in its default config, because GCN has no VRAM mirror and so nothing to write back. With the mirror forced on it writes back ~50 MB a frame less and shows nothing stale, but there's no fps gain on an RX 580.
perf(vk): large GPU-written bindings are written back only where the CPU reads or writes themto perf(vk): RX_VK_WB_MAX_KB writes large GPU-written bindings back only where the CPU reads or writes them (opt-in)@Joey-Magnus @hckrman101 thanks both, and for the RX 580 run (T-SRQJ). Agreed on the call:
db1359afmakes it default-off.RX_VK_WB_MAX_KBunset or0writes every binding back whole, as on main;RX_VK_WB_MAX_KB=1024turns the learned cap on. So this PR no longer changes anyone's default: it makes the existing opt-in switch safe to try (no stale reads of the 4.3 MB buffers, no stale page copies) instead of fast and wrong. The readme says why it is off and what result is wanted (a laptop or another slow PCIe link).RX_VK_WB_PROBATIONis read withenv_int.On the RX 580's Field numbers (43.3 against 45.7 with the mirror forced on, GPU 19.6 against 17.6 ms): I have no explanation, and on the 3060 Ti the GPU time went the other way by 0.2 ms, so I would not read a direction into either. One more reason for off by default.
Test request T-SRQJ: pass
Hardware: NVIDIA GeForce RTX 3080 (10 GB), driver 621.14, AMD Ryzen 9 3900X (12 cores, 24 threads), Windows 11. Built
db1359af(the PR head, default off;v2.1.0-rc.3-258-gdb1359af) with the Builder's llvm-mingw.What I checked: the same build with
RX_VK_WB_MAX_KB=0against=1024, alternating, three runs each, withRX_VK_FRAMEGEN=0,RX_VK_IMAGE_PREALLOC=0andHOS_FPS_MAX=240as in your table. Each run starts headless from a save through the control bridge, teleports to Hyrule Field (Lost Woods bridge), settles 10 s, then holds the right stick fully right (the camera turning at full speed) and samples the status fps once a second for 20 s.Result: the write-backs are left out as described, at the same frame rate, with nothing broken.
RX_VK_WB_MAX_KBOne run of each arm is not counted: the teleport did not happen (once
cheats: teleport waits until the current event ends), so it sampled Kokiri Forest.device lost, crash or exception line in any run.@jordanella thanks for the run. That makes three machines (RTX 3060 Ti, RX 580 with the mirror forced on, RTX 3080) where it leaves ~50 MB a frame out at the same frame rate and with nothing stale beyond the one scene-load sweep, so opt-in is the right state for it. Nothing more planned on this branch unless a review asks.
github-sync referenced this pull request2026-11-12 16:54:48 +00:00
A note on my measurements in this thread: this PC's DLSS model row was on Transformer 2 (L), not the default K, for all of them. L costs ~4 ms more GPU time a frame on this card and more VRAM (#2836), so my absolute fps are ~10 lower than the default would give in Hyrule Field and the card was shorter of memory than a default setup. The comparisons were all made with the same setting in both arms, so their direction stands, but their size is for that harder case.
Re-reviewed be7b6d29..db1359af. Approving as opt-in.
Default off is the right state, and it is old main's behaviour. With
RX_VK_WB_MAX_KBunset or 0,wb_leave_outreturns before it locks anything. The bind path queues{p, off, whi - p->lo}, the same range as main'sstd::min(off + size, p->hi - p->lo).memory_note_cpu_access,memory_wb_followed(somirror_sync's once-a-pool check) andwb_learn_reportall return onwb_cap()before they takewb_learn_mu. So the default path costs one cached static per call and no lock, table or per-page work. The only thing left is thegpu_guestlookup ininvalidatethat feeds a no-op. That is negligible, so it's a nit at most. Three machines now show no fps change: klikso's 3060 Ti, our RX 580 and jordanella's RTX 3080. To turn it on by default we'd want a run on a slow PCIe link (a laptop, or an x4/x8 card) that shows a real fps gain, with no late touches during play. The readme already asks for that kind of result.Our earlier points:
plan()takesmirror_epoch, binds in one submission count once, and a binding is left out from the submission after its 8th. The new test (12 binds in each of 8 submissions) pins this.followed(), and the long session ended with 0 resets.edges()now makes room for both pages before adding either, so mooki000's case is closed. His case is intest_wb_learn.cppat 2 and 3 ranges before, and his own check passes on309e6c35.mirror_syncasks once per pool. The nit and Joey-Magnus'senv_intpoint are both fixed too.Small things, none blocking:
max_readbelow 2,edges()can still lose the first page, because the secondadd_readresets again. That can't happen at 8,192, and themax_read = 1test only checks that the binding is still followed.CI is green and it merges cleanly. Thanks klikso for the quick turnarounds, mooki000 for finding the edge-page bug, Joey-Magnus for the review, and jordanella for the RTX 3080 run.
This merged while the Linux run was in the queue; here is the result I promised, for the record.
Test request T-SRQJ — Linux/NVIDIA result on
db1359af, where the learned cap is opt-in. All game numbers below are for that head or its exact main baseba005ca4, not the earlier default-on heads.Result: the head's default writes back the same amount as main, 67.89–68.10 MB/frame across their runs. With
RX_VK_WB_MAX_KB=1024, that falls to 18.13–18.15 MB/frame. The repeat rule gives no speed verdict: the first main/default runs hit 0.51/0.62-second hitches. Both rounds are retained below.Three arms, alternated
Arch Linux, kernel 7.2.15, i9-13900K, RTX 4090 24 GB, NVIDIA 615.78.08, Clang 23.1.3 release/PGO builds. Separate game copies with matching translated inputs and initially identical program/driver-cache snapshots, then one ordinary warm-up per arm. Windowless Hyrule Field, copied save, camera turns, 2048×1152 game output / 1920×1080 offscreen presentation, cap 240, DLSS/frame generation off, precompile skipped. Each run lasts 70 seconds; the measured window is 10–30 seconds after the load.
One exclusive hold, 430.1 seconds, in main / default / 1024 / main / default / 1024 order. All six runs met the quiet/fault checks: other work averaged 1.30–2.70 cores, with no five-second sample above 3.07. Both main and default failed the 5% repeatability check, so I do not average away their first runs.
ba005ca4db1359af, defaultdb1359af, 1024ba005ca4db1359af, defaultdb1359af, 1024Second-round figures main 91.60, default 91.64, opt-in 93.50 fps are observations, not a speed/parity claim for the whole comparison. The first-round stalls are real retained observations; their cause was not isolated. The write-back figures are the result that holds across both rounds. They come from complete 120-present log blocks inside each measurement window; frame and CPU figures come from the recorded traces and thread samples, extracted and asserted by script.
Correctness and late touches
Separate fixed-view, every-frame screenshot-detector runs visited Kokiri Forest, the Hyrule Field entrance, Market, Goron City and Zora's Domain. Main/default/1024 ran 117.0 / 117.8 / 119.0 seconds, recording 5,486 / 5,904 / 5,593 game-present rows. All exited 0; no crash, lost device or kernel driver fault; zero flash-detector reports in all three. One known NVIDIA host-mapping refusal occurred during the opt-in run.
The opt-in's last report was 31 bindings, 43 ranges, 0 resets, 1,686 late touches, in the forest→field load. The individually printed sample is capped at 20; all 20 were CPU writes. Subsequent reports stayed at 1,686. This matches the load-time case already reported here, but I have no byte-level proof that each affected page was wholly initialized. The two direct-to-Field timing runs reported 28 bindings, 40 ranges, 0 resets and 0 late touches.
I inspected all three arms' scene pictures and a frame inside each measured window. No obvious geometry, fog or persistent exposure corruption appeared. The fixed-view scene comparisons show the same scenes; animation and wind differ. Zero detector reports without a positive flashing control are not proof that every possible stale read is harmless.
Build and game-free checks
The
309e6c35edge-capacity fix is retained. I reran the independent fixture ondb1359afwith GCC 16.2 and Clang 23.1.3: the 8,190 control and 8,191 boundary both pass; the boundary now reportsresets=1 kept=2 first_edge=1 last_edge=1.The three affected checks pass with both compilers; the changed C++ fixture passes Windows-target syntax. Full suite on
db1359af: 474 passed, 0 failed, 20 skipped. Windows execution was not tested. The new build reused compatible translated code/objects and rebuilt native code through the normal build process; no retranslation or new SDK download.Limits: this 24 GB desktop on a full-speed PCIe link is the opposite of the slower-link laptop case you want. It establishes reduced copy traffic here, not a laptop FPS or low-VRAM paging benefit. No Windows/AMD/Intel runtime, frame generation, 30-minute/save-reload tour, or byte-level proof of the late load-time writes. The opt-in still has the documented first-late-touch trade-off.