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
Owner

@klikso opened this pull request on GitHub · 2026-11-12 09:45 UTC · original
Merged on GitHub in 8e6c15af42

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:

  • The CPU reads some large ones. With the plain cap and a diagnostic on 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).
  • The CPU writes pages inside them. ~12 pages a frame inside the 160 MB culling pool (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 by vk_memory.cpp under its own mutex:

  • A written binding over RX_VK_WB_MAX_KB (unset or 0: off, the default; 1024 is the tested value) is written back whole in the first RX_VK_WB_PROBATION submissions that write it (8; several binds in one submission count once).
  • Meanwhile the CPU's reads (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.
  • After the probation only those ranges are written back, plus the binding's first and last page when it starts or ends inside a page (a page shared with what is next to it); the rest of the binding is left out.
  • A range the CPU first touches after that finds the stale copy once, is written back from then on, and is logged (vk: the CPU read/wrote ..., in a binding whose write-back was left out).
  • Past 8,192 bindings or 8,192 merged ranges the table forgets everything and every binding starts its probation again (resets in the log line). CPU reads and writes are noted only inside a binding the table follows, and a mirror sync asks once per pool.
  • Without RX_VK_WB_MAX_KB every binding is written back, as before. RX_VK_LOG_WB_SKIPS=1 lists 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, main 5b96a55a; 40 s with the camera turning after a settle, RX_VK_FRAMEGEN=0, HOS_FPS_MAX=240, RX_VK_IMAGE_PREALLOC=0 in both arms (#2690), arms alternated.

written back a frame fps median fps mean GPU a frame
Hyrule Field, RX_VK_WB_MAX_KB=0 (as before) 67.4 MB 58.8 / 58.2 / 58.6 57.2 / 56.4 / 56.8 12.4 ms
Hyrule Field, this PR 17.9 MB 60.0 / 59.4 / 60.5 58.6 / 57.3 / 58.0 12.15 ms
Kokiri Forest, as before 66.2 MB 59.1 / 63.4 59.6 / 62.7 11.8 ms
Kokiri Forest, this PR 16.1 MB 59.0 / 62.6 60.1 / 62.6 11.6 ms
Castle Town Market, as before 60.8 MB 27.5 / 40.6 29.6 / 38.9 19.5 / 14.0 ms
Castle Town Market, this PR 17.1 MB 38.3 / 43.5 40.2 / 42.1 11.9 / 11.8 ms

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 (0x117ffbd000 on, 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

  • New tests/test_wb_learn.py compiles and runs tests/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 that vk_memory.cpp and vk_replay.cpp still call it where they should.
  • tests/direct_buffer_writes_test.cpp and tests/parallel_rb_refresh_test.cpp get stubs for the new calls (every write-back queued, as before).
  • python -I -B tests/run_all.py on Windows: 455 passed, 3 failed, 34 skipped. The three fail on unchanged main too here (nx2/test_nup_paths.py and tests/test_input_background.py: this machine's file links and input; tests/test_finish_output.py only when run beside the others, and passes alone).

🤖 Generated with Claude Code

> [@klikso](https://github.com/klikso) opened this pull request on GitHub · 2026-11-12 09:45 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731) > **Merged on GitHub** in https://git.magicbean.dev/MagicBeanDev/Hookshot/commit/8e6c15af42e6e1af14aed7a6d75f4c34fa9f74d9 **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: - **The CPU reads some large ones.** With the plain cap and a diagnostic on `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). - **The CPU writes pages inside them.** ~12 pages a frame inside the 160 MB culling pool (`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 by `vk_memory.cpp` under its own mutex: - A written binding over `RX_VK_WB_MAX_KB` (unset or 0: off, the default; 1024 is the tested value) is written back whole in the first `RX_VK_WB_PROBATION` submissions that write it (8; several binds in one submission count once). - Meanwhile the CPU's reads (`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. - After the probation only those ranges are written back, plus the binding's first and last page when it starts or ends inside a page (a page shared with what is next to it); the rest of the binding is left out. - A range the CPU first touches after that finds the stale copy once, is written back from then on, and is logged (`vk: the CPU read/wrote ..., in a binding whose write-back was left out`). - Past 8,192 bindings or 8,192 merged ranges the table forgets everything and every binding starts its probation again (`resets` in the log line). CPU reads and writes are noted only inside a binding the table follows, and a mirror sync asks once per pool. - Without `RX_VK_WB_MAX_KB` every binding is written back, as before. `RX_VK_LOG_WB_SKIPS=1` lists 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, `main` 5b96a55a; 40 s with the camera turning after a settle, `RX_VK_FRAMEGEN=0`, `HOS_FPS_MAX=240`, `RX_VK_IMAGE_PREALLOC=0` in both arms (#2690), arms alternated. | | written back a frame | fps median | fps mean | GPU a frame | |---|---|---|---|---| | Hyrule Field, `RX_VK_WB_MAX_KB=0` (as before) | 67.4 MB | 58.8 / 58.2 / 58.6 | 57.2 / 56.4 / 56.8 | 12.4 ms | | Hyrule Field, this PR | 17.9 MB | 60.0 / 59.4 / 60.5 | 58.6 / 57.3 / 58.0 | 12.15 ms | | Kokiri Forest, as before | 66.2 MB | 59.1 / 63.4 | 59.6 / 62.7 | 11.8 ms | | Kokiri Forest, this PR | 16.1 MB | 59.0 / 62.6 | 60.1 / 62.6 | 11.6 ms | | Castle Town Market, as before | 60.8 MB | 27.5 / 40.6 | 29.6 / 38.9 | 19.5 / 14.0 ms | | Castle Town Market, this PR | 17.1 MB | 38.3 / 43.5 | 40.2 / 42.1 | 11.9 / 11.8 ms | 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 (`0x117ffbd000` on, 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 - New `tests/test_wb_learn.py` compiles and runs `tests/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 that `vk_memory.cpp` and `vk_replay.cpp` still call it where they should. - `tests/direct_buffer_writes_test.cpp` and `tests/parallel_rb_refresh_test.cpp` get stubs for the new calls (every write-back queued, as before). - `python -I -B tests/run_all.py` on Windows: 455 passed, 3 failed, 34 skipped. The three fail on unchanged `main` too here (`nx2/test_nup_paths.py` and `tests/test_input_background.py`: this machine's file links and input; `tests/test_finish_output.py` only when run beside the others, and passes alone). 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- github-sync:item:2731 -->
OoT writes ~68 MB a frame through the GPU into bindings over 1 MB (culling lists and per-frame
buffers), and every byte was copied back to guest memory at the end of its submission. The CPU
touches only small parts of them: four 4.3 MB buffers it reads in full, and ~12 pages a frame it
writes inside the culling pool. A binding over RX_VK_WB_MAX_KB (now 1024 by default) is written
back whole for its first RX_VK_WB_PROBATION writes (8) while the CPU's reads (invalidate) and the
writes a mirror sync copies are noted by range; after that only those ranges are written back.
A range the CPU first touches later is written back from then on, and logged.

Hyrule Field, RTX 3060 Ti: 67.4 -> 17.9 MB written back a frame, 56.8 -> 58.0 fps (mean of three
alternated runs each), no CPU touch of a range left out. RX_VK_WB_MAX_KB=0 writes everything
back as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
A binding that starts or ends inside a page shares that page with what is next to it. The 15 MB
culling lists end 0x80 bytes into a page the CPU writes, and a mirror sync copies that page whole,
so the binding's last bytes were found stale once (Goron City, Zora's Domain) before the page was
learned. Those pages are now written back from the binding's first write: at most 8 KB a binding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Review of #2731 (hckrman101):
- A binding bound by several dispatches in one submission counted each bind toward its probation,
  so it could finish in a frame. Probation now counts the submissions that write it (mirror_epoch),
  and a binding is left out only from the submission after its last one.
- Past its bounds the table latched "full" and wrote everything back for the rest of the session.
  It now forgets everything and every binding starts its probation again; the log counts resets.
- CPU reads are noted only in a large binding the table follows, as writes already were, so small
  readbacks elsewhere do not fill it.
- A mirror sync asks once per pool whether it overlaps a followed binding before noting its pages.
- The readme states the trade-off: a range first touched after the probation is stale once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Owner

@hckrman101 commented on gpu/vk/wb_learn.h line 58 on GitHub · 2026-11-12 10:52 UTC · original

writes counts 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 first invalidate of 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 into plan() and only increment when it changes.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/wb_learn.h` line 58 on GitHub · 2026-11-12 10:52 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#discussion_r4549432909) `writes` counts 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 first `invalidate` of 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 into `plan()` and only increment when it changes. <!-- github-sync:rc:4549432909 -->
Author
Owner

@hckrman101 commented on gpu/vk/wb_learn.h line 130 on GitHub · 2026-11-12 10:52 UTC · original

Once full_ is set it never clears, and neither seen_ nor read_ 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 through followed() in memory_note_cpu_access, as writes already are, and please report the counts from a long run.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/wb_learn.h` line 130 on GitHub · 2026-11-12 10:52 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#discussion_r4549432920) Once `full_` is set it never clears, and neither `seen_` nor `read_` 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 through `followed()` in `memory_note_cpu_access`, as writes already are, and please report the counts from a long run. <!-- github-sync:rc:4549432920 -->
Author
Owner

@hckrman101 commented on gpu/vk/vk_memory.cpp line 2634 on GitHub · 2026-11-12 10:52 UTC · original

This takes wb_learn_mu and runs followed()'s backward walk over seen_ (up to 1 GB below) for every dirty page of every synced pool. Meanwhile the replay threads' binds wait on the same mutex in wb_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.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/vk_memory.cpp` line 2634 on GitHub · 2026-11-12 10:52 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#discussion_r4549432927) This takes `wb_learn_mu` and runs `followed()`'s backward walk over `seen_` (up to 1 GB below) for every dirty page of every synced pool. Meanwhile the replay threads' binds wait on the same mutex in `wb_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. <!-- github-sync:rc:4549432927 -->
Author
Owner

@hckrman101 commented on gpu/vk/vk_memory.cpp line 2734 on GitHub · 2026-11-12 10:52 UTC · original

When stale is 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, since gpu_written keeps 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.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/vk_memory.cpp` line 2734 on GitHub · 2026-11-12 10:52 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#discussion_r4549432935) When `stale` is 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, since `gpu_written` keeps 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. <!-- github-sync:rc:4549432935 -->
Author
Owner

@hckrman101 commented on gpu/vk/vk_memory.cpp line 3158 on GitHub · 2026-11-12 10:52 UTC · original

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.

> [@hckrman101](https://github.com/hckrman101) commented on `gpu/vk/vk_memory.cpp` line 3158 on GitHub · 2026-11-12 10:52 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#discussion_r4549432948) 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. <!-- github-sync:rc:4549432948 -->
Author
Owner

@hckrman101 reviewed on GitHub · 2026-11-12 10:52 UTC · original

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.h is 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:

  • Ordering in invalidate. The read is noted before the write-back wait. So work that is recorded during the catch-up / sync_settle wait already keeps the range, and test_wb_learn.py pins that order. Ranges the CPU reads keep their write-back, so memory_writeback_value still finds them and the readback wait (exposure meter, PR 430's refresh) is unchanged for them.
  • CPU writes in 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.
  • Learned state. It is keyed by guest range, not by pool, so retiring or re-importing a pool doesn't lose it. A binding at the same start with a different size starts a new probation. gpu_written already keeps these pools' mirrors from being given back, so the left-out data isn't dropped by mirrors_give_back.
  • Locking. wb_learn_mu is always taken last: after pools_mu from the bind path and mirror_sync, and on its own from invalidate. I didn't find a lock-order problem with the parallel replay or the readback refresh.
  • Off switch. RX_VK_WB_MAX_KB=0 restores the old behaviour exactly.

Points I'd like addressed before this becomes the default (details inline):

  1. Probation counts bind calls, not submissions. Written binds aren't memoized, so a buffer that several dispatches write in one frame uses up its 8 writes in a frame or two. The CPU's first read of that buffer can come after the replay has already recorded the next frame. In that case the read finds stale data, which is exactly the case probation is meant to cover. Counting at most one write per submission would make RX_VK_WB_PROBATION=8 mean what the README says.
  2. First touch is stale by design. A range the CPU first reads or writes after probation gets the old copy once. The logs in the description show this is rare in these scenes, but it is silent wrong data when it happens, and it's on by default. Could the late touch be repaired instead of only logged? For example, invalidate could 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.
  3. The table only grows. seen_ and read_ are never pruned, and every invalidated range goes into read_, 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 the bindings followed / ranges the CPU read counts from the log after a long session (30+ min, several scene changes, a save reload). Filtering reads through followed(), as writes already are, would also help.
  4. Per-page cost in mirror_sync. Every dirty page now takes wb_learn_mu and walks seen_ 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:

  • A second machine, ideally Linux and/or AMD, and the slower-PCIe case the description expects to gain most (laptop or x8). As measured, the gain is about 2% on the 3060 Ti in Hyrule Field and Kokiri Forest, and the Market is noise-bound, as you note. The default needs to stand on more than one NVIDIA/Windows box.
  • The screenshot pairs attached. The description says they match. Please also say explicitly that no white flashes, exposure steps or fog from the previous frame showed up during the runs, since those are the past symptoms of stale write-backs.

Nit: the comment in gpu_buffer_bind quotes "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_back block in gpu_buffer_bind. #1377 marks a binding over the cap gpu_only. Whichever lands second needs a small rebase there, and with this PR "not written back" becomes partial rather than whole.

> [@hckrman101](https://github.com/hckrman101) reviewed on GitHub · 2026-11-12 10:52 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#pullrequestreview-5873123101) 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.h` is 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: - **Ordering in `invalidate`.** The read is noted before the write-back wait. So work that is recorded during the catch-up / `sync_settle` wait already keeps the range, and `test_wb_learn.py` pins that order. Ranges the CPU reads keep their write-back, so `memory_writeback_value` still finds them and the readback wait (exposure meter, PR 430's refresh) is unchanged for them. - **CPU writes in `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. - **Learned state.** It is keyed by guest range, not by pool, so retiring or re-importing a pool doesn't lose it. A binding at the same start with a different size starts a new probation. `gpu_written` already keeps these pools' mirrors from being given back, so the left-out data isn't dropped by `mirrors_give_back`. - **Locking.** `wb_learn_mu` is always taken last: after `pools_mu` from the bind path and `mirror_sync`, and on its own from `invalidate`. I didn't find a lock-order problem with the parallel replay or the readback refresh. - **Off switch.** `RX_VK_WB_MAX_KB=0` restores the old behaviour exactly. Points I'd like addressed before this becomes the default (details inline): 1. **Probation counts bind calls, not submissions.** Written binds aren't memoized, so a buffer that several dispatches write in one frame uses up its 8 writes in a frame or two. The CPU's first read of that buffer can come after the replay has already recorded the next frame. In that case the read finds stale data, which is exactly the case probation is meant to cover. Counting at most one write per submission would make `RX_VK_WB_PROBATION=8` mean what the README says. 2. **First touch is stale by design.** A range the CPU first reads or writes after probation gets the old copy once. The logs in the description show this is rare in these scenes, but it is silent wrong data when it happens, and it's on by default. Could the late touch be repaired instead of only logged? For example, `invalidate` could 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. 3. **The table only grows.** `seen_` and `read_` are never pruned, and every invalidated range goes into `read_`, 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 the `bindings followed` / `ranges the CPU read` counts from the log after a long session (30+ min, several scene changes, a save reload). Filtering reads through `followed()`, as writes already are, would also help. 4. **Per-page cost in `mirror_sync`.** Every dirty page now takes `wb_learn_mu` and walks `seen_` 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: - **A second machine**, ideally Linux and/or AMD, and the slower-PCIe case the description expects to gain most (laptop or x8). As measured, the gain is about 2% on the 3060 Ti in Hyrule Field and Kokiri Forest, and the Market is noise-bound, as you note. The default needs to stand on more than one NVIDIA/Windows box. - **The screenshot pairs attached.** The description says they match. Please also say explicitly that no white flashes, exposure steps or fog from the previous frame showed up during the runs, since those are the past symptoms of stale write-backs. Nit: the comment in `gpu_buffer_bind` quotes "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_back` block in `gpu_buffer_bind`. #1377 marks a binding over the cap `gpu_only`. Whichever lands second needs a small rebase there, and with this PR "not written back" becomes partial rather than whole. <!-- github-sync:r:5873123101 -->
Author
Owner

@klikso commented on GitHub · 2026-11-12 11:48 UTC · original

Thanks for the careful review. Pushed 0dd04592 and bce3d17e:

  1. Probation counts submissions. 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.
  2. Late touches. I did not add the repair yet: from invalidate it 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:
    • The cause of every late touch during play was a shared edge page. The 15 MB culling lists end 0x80 bytes into a page the CPU writes (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.
    • What remains are scene loads. Over a 24-scene tour (below), all late touches came in two bursts, each right after the new scene's pools were imported: 1,686 and 3,012 consecutive pages written in one go (the first from 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.
    • The README now states the trade-off (a range first touched after the probation is stale once, and the log names it).
  3. The table. It no longer latches: past its bounds it forgets everything and every binding starts its probation again (counted as resets in the log line). Reads go through followed() 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 resets at the end. No save reload in it.
  4. mirror_sync asks 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):

written back a frame fps median fps mean GPU a frame late touches
RX_VK_WB_MAX_KB=0 68.2 / 68.0 MB 58.4 / 59.2 57.5 / 57.5 12.4 ms -
this PR 18.0 / 18.0 MB 58.2 / 59.9 56.6 / 58.4 12.2 ms 0 / 0

So 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=0 instead. 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 (no flash_* 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.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 11:48 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6822765067) Thanks for the careful review. Pushed `0dd04592` and `bce3d17e`: 1. **Probation counts submissions.** `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. 2. **Late touches.** I did not add the repair yet: from `invalidate` it 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: - **The cause of every late touch during play was a shared edge page.** The 15 MB culling lists end 0x80 bytes into a page the CPU writes (`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. - **What remains are scene loads.** Over a 24-scene tour (below), all late touches came in two bursts, each right after the new scene's pools were imported: 1,686 and 3,012 consecutive pages written in one go (the first from `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. - **The README now states the trade-off** (a range first touched after the probation is stale once, and the log names it). 3. **The table.** It no longer latches: past its bounds it forgets everything and every binding starts its probation again (counted as `resets` in the log line). Reads go through `followed()` 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 resets` at the end. No save reload in it. 4. **`mirror_sync`** asks 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): | | written back a frame | fps median | fps mean | GPU a frame | late touches | |---|---|---|---|---|---| | `RX_VK_WB_MAX_KB=0` | 68.2 / 68.0 MB | 58.4 / 59.2 | 57.5 / 57.5 | 12.4 ms | - | | this PR | 18.0 / 18.0 MB | 58.2 / 59.9 | 56.6 / 58.4 | 12.2 ms | 0 / 0 | So 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=0` instead. 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** (no `flash_*` 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. <!-- github-sync:c:6822765067 -->
When the table of CPU-touched ranges was one short of full, edges() added the first page, the
second add reset the table and only the last edge page was kept, so the first shared page could
be left out (found by mooki000). edges() now makes room for both before adding either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Author
Owner

@mooki000 commented on GitHub · 2026-11-12 13:47 UTC · original

One case in wb_learn.h at bce3d17e where 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 second add_read sees a full table, calls reset(), 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:

8,190 ranges before:  resets=0 kept=2 first_edge=1 last_edge=1   PASS
8,191 ranges before:  resets=1 kept=1 first_edge=0 last_edge=1   FAIL

The lines are the capacity test inside add_read (if (read_.size() >= max_read) reset(); before the emplace) and edges() calling it twice. Making the pair atomic would close it: for example check the room for both pages before adding either, or have edges() 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](https://github.com/mooki000) commented on GitHub · 2026-11-12 13:47 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6825358246) One case in `wb_learn.h` at `bce3d17e` where 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 second `add_read` sees a full table, calls `reset()`, 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](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/58988b6ddf026f5eb4d54e751cf4f09b20ef7ea2/hookshot-2731-wb-edge-capacity.cpp) (from the repository root: `g++ -std=c++20 -I. file.cpp`), with g++ 16.2.1 and clang++ 23.1.3: ```text 8,190 ranges before: resets=0 kept=2 first_edge=1 last_edge=1 PASS 8,191 ranges before: resets=1 kept=1 first_edge=0 last_edge=1 FAIL ``` The lines are the capacity test inside `add_read` (`if (read_.size() >= max_read) reset();` before the `emplace`) and `edges()` calling it twice. Making the pair atomic would close it: for example check the room for both pages before adding either, or have `edges()` 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). <!-- github-sync:c:6825358246 -->
Author
Owner

@klikso commented on GitHub · 2026-11-12 13:52 UTC · original

@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.cpp has your case (a table of max_read 4 holding 2 and 3 ranges, then a binding with both edges partial: both pages kept after the probation); it fails on bce3d17e at that check and passes now. Looking forward to the Linux results.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 13:52 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6825472223) @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.cpp` has your case (a table of `max_read` 4 holding 2 and 3 ranges, then a binding with both edges partial: both pages kept after the probation); it fails on `bce3d17e` at that check and passes now. Looking forward to the Linux results. <!-- github-sync:c:6825472223 -->
Author
Owner

@mooki000 commented on GitHub · 2026-11-12 13:53 UTC · original

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.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 13:53 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6825510485) 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. <!-- github-sync:c:6825510485 -->
Author
Owner

@Joey-Magnus reviewed on GitHub · 2026-11-12 14:34 UTC · original

I merged 309e6c35 into 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() takes mirror_epoch), the table resets instead of latching, reads are filtered through followed(), mirror_sync checks once per pool, and the nit is fixed. mooki000's edge-page case is fixed in edges() 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_PROBATION is read with getenv/atoi (gpu/vk/vk_memory.cpp:2710). Please use env_int.

> [@Joey-Magnus](https://github.com/Joey-Magnus) reviewed on GitHub · 2026-11-12 14:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#pullrequestreview-5876241969) I merged 309e6c35 into 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()` takes `mirror_epoch`), the table resets instead of latching, reads are filtered through `followed()`, mirror_sync checks once per pool, and the nit is fixed. mooki000's edge-page case is fixed in `edges()` 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_PROBATION` is read with getenv/atoi (gpu/vk/vk_memory.cpp:2710). Please use `env_int`. <!-- github-sync:r:5876241969 -->
Author
Owner

@hckrman101 commented on GitHub · 2026-11-12 14:43 UTC · original

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 main 51da0464 (main is 3 commits past the PR's merge base, none of them in gpu/); 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 from RX_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 back every frame, and the PR's line reads 0 bindings followed for the whole run. So I ran a second set with RX_VK_MIRROR=1 on all arms, where the PR's path is live.

Default (no mirror), median fps per run → median of runs; 1% low:

scene main (3) PR (3) PR RX_VK_WB_MAX_KB=0 (1)
Kokiri 21.8 / 21.7 / 21.9 → 21.8; 8.7 21.7 / 22.0 / 21.8 → 21.8; 8.7 21.8; 8.1
Hyrule Field 30.6 / 30.4 / 32.4 → 30.6; 3.8 30.6 / 30.9 / 31.2 → 30.9; 4.0 31.6; 3.9
Market 39.9 / 39.5 / 40.9 → 39.9; 3.4 34.6 / 39.2 / 39.4 → 39.2; 3.7 36.2; 3.6

RX_VK_MIRROR=1 on every arm:

scene main (3) PR (3) PR RX_VK_WB_MAX_KB=0 (3) written back a frame, main → PR
Kokiri 28.5 / 25.0 / 27.5 → 27.5; 9.2 28.1 / 28.4 / 27.7 → 28.1; 9.0 28.1 / 27.6 / 28.5 → 28.1; 9.0 67.1 → 17.1 MB
Hyrule Field 45.6 / 46.0 / 45.7 → 45.7; 6.5 38.0 / 43.8 / 43.3 → 43.3; 5.5 45.2 / 46.3 / 44.8 → 45.2; 5.1 68.2 → 18.0 MB
Market 40.4 / 41.0 / 39.4 → 40.4; 4.2 24.0 / 40.7 / 44.8 → 40.7; 4.0 38.5 / 33.7 / 30.4 → 33.7; 3.8 61.1 → 17.5 MB
  • The off switch matches main: 67-68 MB written back a frame, the same fps.
  • With the mirror, the PR cuts the write-back from 61-68 to 17-18 MB a frame, and main's write-back diff compares ~0.6 GB instead of ~2.2 GB per 600 submits in the Field. That doesn't show as fps here: Kokiri and the Market are within run-to-run noise, and the Field is 2-3 fps lower with the PR in all three runs (GPU time 19.6 vs 17.6 ms a frame). With only 3 runs and the camera turning, I can't tell whether that's real.
  • Where the frame goes (main, default): Kokiri 46 ms a frame with 33 ms of GPU time and a 17-21 ms CPU replay, the Field ~33 ms with ~23 ms GPU, the Market ~25 ms with ~20 ms GPU. The CPU waits on the GPU only ~0.1 ms a frame. The Field and Market hitches (p99 200+ ms, every arm alike) are pipeline compiles with background precompile off.

The PR's diagnostics (mirror on): CPU reads or writes of one left out was 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. No the 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):

  • Screenshots once a second, PR vs main, no camera turn, matched by time after each scene drew (+2/5/10/20/30 s): mean absolute difference 4-9 of 255, 0.5-5% of pixels more than 32 off (wind and animation), same mean brightness (Kokiri ~94-96, Field ~37-42, Market ~52-55).
  • No white frames and no exposure jumps in play. A brightness scan over all 285 + 285 shots, and the timed runs' shots, found no shot that jumps from both neighbours, except title-screen frames before play, which main has too. No fog or geometry difference in the matched pairs.
  • Validation (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.
  • No crash, hang, device loss or VK_ERROR in 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.

> [@hckrman101](https://github.com/hckrman101) commented on GitHub · 2026-11-12 14:43 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6826733928) 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 main 51da0464 (main is 3 commits past the PR's merge base, none of them in `gpu/`); 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 from `RX_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 back` every frame, and the PR's line reads `0 bindings followed` for the whole run. So I ran a second set with `RX_VK_MIRROR=1` on all arms, where the PR's path is live. Default (no mirror), median fps per run → median of runs; 1% low: | scene | main (3) | PR (3) | PR `RX_VK_WB_MAX_KB=0` (1) | |---|---|---|---| | Kokiri | 21.8 / 21.7 / 21.9 → **21.8**; 8.7 | 21.7 / 22.0 / 21.8 → **21.8**; 8.7 | 21.8; 8.1 | | Hyrule Field | 30.6 / 30.4 / 32.4 → **30.6**; 3.8 | 30.6 / 30.9 / 31.2 → **30.9**; 4.0 | 31.6; 3.9 | | Market | 39.9 / 39.5 / 40.9 → **39.9**; 3.4 | 34.6 / 39.2 / 39.4 → **39.2**; 3.7 | 36.2; 3.6 | `RX_VK_MIRROR=1` on every arm: | scene | main (3) | PR (3) | PR `RX_VK_WB_MAX_KB=0` (3) | written back a frame, main → PR | |---|---|---|---|---| | Kokiri | 28.5 / 25.0 / 27.5 → **27.5**; 9.2 | 28.1 / 28.4 / 27.7 → **28.1**; 9.0 | 28.1 / 27.6 / 28.5 → **28.1**; 9.0 | 67.1 → 17.1 MB | | Hyrule Field | 45.6 / 46.0 / 45.7 → **45.7**; 6.5 | 38.0 / 43.8 / 43.3 → **43.3**; 5.5 | 45.2 / 46.3 / 44.8 → **45.2**; 5.1 | 68.2 → 18.0 MB | | Market | 40.4 / 41.0 / 39.4 → **40.4**; 4.2 | 24.0 / 40.7 / 44.8 → **40.7**; 4.0 | 38.5 / 33.7 / 30.4 → **33.7**; 3.8 | 61.1 → 17.5 MB | - The off switch matches main: 67-68 MB written back a frame, the same fps. - With the mirror, the PR cuts the write-back from 61-68 to 17-18 MB a frame, and main's write-back diff compares ~0.6 GB instead of ~2.2 GB per 600 submits in the Field. That doesn't show as fps here: Kokiri and the Market are within run-to-run noise, and the Field is 2-3 fps lower with the PR in all three runs (GPU time 19.6 vs 17.6 ms a frame). With only 3 runs and the camera turning, I can't tell whether that's real. - Where the frame goes (main, default): Kokiri 46 ms a frame with 33 ms of GPU time and a 17-21 ms CPU replay, the Field ~33 ms with ~23 ms GPU, the Market ~25 ms with ~20 ms GPU. The CPU waits on the GPU only ~0.1 ms a frame. The Field and Market hitches (p99 200+ ms, every arm alike) are pipeline compiles with background precompile off. The PR's diagnostics (mirror on): `CPU reads or writes of one left out` was **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. No `the 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): - Screenshots once a second, PR vs main, no camera turn, matched by time after each scene drew (+2/5/10/20/30 s): mean absolute difference 4-9 of 255, 0.5-5% of pixels more than 32 off (wind and animation), same mean brightness (Kokiri ~94-96, Field ~37-42, Market ~52-55). - No white frames and no exposure jumps in play. A brightness scan over all 285 + 285 shots, and the timed runs' shots, found no shot that jumps from both neighbours, except title-screen frames before play, which main has too. No fog or geometry difference in the matched pairs. - Validation (`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. - No crash, hang, device loss or `VK_ERROR` in 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. <!-- github-sync:c:6826733928 -->
Review of #2731 (Joey-Magnus, hckrman101): on the two machines that ran it (an RTX 3060 Ti on
Windows, an RX 580 on Linux with its mirror forced on) it writes back ~50 MB a frame less at the
same frame rate, and a range first touched after its probation is still stale once. So it stays
opt-in, as RX_VK_WB_MAX_KB was, until a machine with a slower link shows a gain. RX_VK_WB_PROBATION
is read with env_int.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
github-sync changed title from perf(vk): large GPU-written bindings are written back only where the CPU reads or writes them to perf(vk): RX_VK_WB_MAX_KB writes large GPU-written bindings back only where the CPU reads or writes them (opt-in) 2026-11-12 15:52:25 +00:00
Author
Owner

@klikso commented on GitHub · 2026-11-12 15:05 UTC · original

@Joey-Magnus @hckrman101 thanks both, and for the RX 580 run (T-SRQJ). Agreed on the call: db1359af makes it default-off.

  • Default off. RX_VK_WB_MAX_KB unset or 0 writes every binding back whole, as on main; RX_VK_WB_MAX_KB=1024 turns 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_PROBATION is read with env_int.
  • Title and description updated to say opt-in.

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.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 15:05 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6827286436) @Joey-Magnus @hckrman101 thanks both, and for the RX 580 run (T-SRQJ). Agreed on the call: `db1359af` makes it default-off. - **Default off.** `RX_VK_WB_MAX_KB` unset or `0` writes every binding back whole, as on main; `RX_VK_WB_MAX_KB=1024` turns 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_PROBATION`** is read with `env_int`. - Title and description updated to say opt-in. 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. <!-- github-sync:c:6827286436 -->
Author
Owner

@jordanella commented on GitHub · 2026-11-12 15:34 UTC · original

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=0 against =1024, alternating, three runs each, with RX_VK_FRAMEGEN=0, RX_VK_IMAGE_PREALLOC=0 and HOS_FPS_MAX=240 as 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_KB Hyrule Field median fps (min-max), each run
0 (as before) 34.9 (26.1-37.9), 33.0 (28.1-36.0)
1024 33.0 (19.5-40.0), 33.3 (27.4-37.0)

One 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.

  • Left out with 1024, from the PR's own line every 600 submits, in both counted runs: median 3,496 MB per 600 submits (max 4,370), with 31 bindings followed and 43 ranges the CPU read, 0 resets. With 0, the line does not appear.
  • CPU reads or writes of a range left out: 1,686 in each run, the same count both times, which looks like the one sweep during the teleport's scene load your description names (~1,690 pages). No other late touches.
  • No device lost, crash or exception line in any run.
  • This scene is CPU-bound here (about 33 fps), so ~50 MB a frame less PCIe traffic doesn't show as fps on this machine; it confirms the behaviour and no regression on an RTX 3080, not a gain. I didn't compare screenshots.
> [@jordanella](https://github.com/jordanella) commented on GitHub · 2026-11-12 15:34 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6828028076) 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=0` against `=1024`, alternating, three runs each, with `RX_VK_FRAMEGEN=0`, `RX_VK_IMAGE_PREALLOC=0` and `HOS_FPS_MAX=240` as 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_KB` | Hyrule Field median fps (min-max), each run | |---|---| | 0 (as before) | 34.9 (26.1-37.9), 33.0 (28.1-36.0) | | 1024 | 33.0 (19.5-40.0), 33.3 (27.4-37.0) | One 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. - Left out with 1024, from the PR's own line every 600 submits, in both counted runs: median **3,496 MB** per 600 submits (max 4,370), with 31 bindings followed and 43 ranges the CPU read, 0 resets. With 0, the line does not appear. - CPU reads or writes of a range left out: **1,686** in each run, the same count both times, which looks like the one sweep during the teleport's scene load your description names (~1,690 pages). No other late touches. - No `device lost`, crash or exception line in any run. - This scene is CPU-bound here (about 33 fps), so ~50 MB a frame less PCIe traffic doesn't show as fps on this machine; it confirms the behaviour and no regression on an RTX 3080, not a gain. I didn't compare screenshots. <!-- github-sync:c:6828028076 -->
Author
Owner

@klikso commented on GitHub · 2026-11-12 15:36 UTC · original

@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.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 15:36 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6828082509) @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:c:6828082509 -->
github-sync closed this pull request 2026-11-12 16:55:52 +00:00
Author
Owner

@klikso commented on GitHub · 2026-11-12 16:09 UTC · original

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.

> [@klikso](https://github.com/klikso) commented on GitHub · 2026-11-12 16:09 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6828921955) 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. <!-- github-sync:c:6828921955 -->
Author
Owner

@hckrman101 approved on GitHub · 2026-11-12 16:17 UTC · original

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_KB unset or 0, wb_leave_out returns before it locks anything. The bind path queues {p, off, whi - p->lo}, the same range as main's std::min(off + size, p->hi - p->lo). memory_note_cpu_access, memory_wb_followed (so mirror_sync's once-a-pool check) and wb_learn_report all return on wb_cap() before they take wb_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 the gpu_guest lookup in invalidate that 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:

  1. Probation counts submissions now. plan() takes mirror_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.
  2. Late touches. The repair is deferred, and the trade-off is written in the readme and logged. That's fine for an opt-in switch. The edge-page rule fixes the cause of every late touch you saw during play: a binding's first or last page is always kept when the binding shares it, clipped to the binding, and the tests cover one edge, both edges and page-aligned bindings. What's left is the scene-load sweep, which jordanella's run reproduced (the same count in both runs).
  3. The table resets instead of latching. Reads are filtered through 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 in test_wb_learn.cpp at 2 and 3 ranges before, and his own check passes on 309e6c35.
  4. mirror_sync asks once per pool. The nit and Joey-Magnus's env_int point are both fixed too.

Small things, none blocking:

  • With max_read below 2, edges() can still lose the first page, because the second add_read resets again. That can't happen at 8,192, and the max_read = 1 test only checks that the binding is still followed.
  • After a reset, a CPU read of a binding that was left out in the submission just before it is no longer followed, so it isn't logged as stale. It's rare, and the binding is written back whole from then on.

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.

> [@hckrman101](https://github.com/hckrman101) approved on GitHub · 2026-11-12 16:17 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#pullrequestreview-5877963892) 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_KB` unset or 0, `wb_leave_out` returns before it locks anything. The bind path queues `{p, off, whi - p->lo}`, the same range as main's `std::min(off + size, p->hi - p->lo)`. `memory_note_cpu_access`, `memory_wb_followed` (so `mirror_sync`'s once-a-pool check) and `wb_learn_report` all return on `wb_cap()` before they take `wb_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 the `gpu_guest` lookup in `invalidate` that 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: 1. **Probation counts submissions now.** `plan()` takes `mirror_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. 2. **Late touches.** The repair is deferred, and the trade-off is written in the readme and logged. That's fine for an opt-in switch. The edge-page rule fixes the cause of every late touch you saw during play: a binding's first or last page is always kept when the binding shares it, clipped to the binding, and the tests cover one edge, both edges and page-aligned bindings. What's left is the scene-load sweep, which jordanella's run reproduced (the same count in both runs). 3. **The table resets instead of latching.** Reads are filtered through `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 in `test_wb_learn.cpp` at 2 and 3 ranges before, and his own check passes on 309e6c35. 4. **`mirror_sync`** asks once per pool. The nit and Joey-Magnus's `env_int` point are both fixed too. Small things, none blocking: - With `max_read` below 2, `edges()` can still lose the first page, because the second `add_read` resets again. That can't happen at 8,192, and the `max_read = 1` test only checks that the binding is still followed. - After a reset, a CPU read of a binding that was left out in the submission just before it is no longer followed, so it isn't logged as stale. It's rare, and the binding is written back whole from then on. 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. <!-- github-sync:r:5877963892 -->
Author
Owner

@mooki000 commented on GitHub · 2026-11-12 16:43 UTC · original

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 base ba005ca4, 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.

Run Arm MB written back/frame FPS CPU ms/frame 1% low Frames >20 / >25 ms Worst frame ms Load start→end
1 main ba005ca4 68.10 62.23 45.88 3.44 53 / 47 511.18 15.75→11.98
2 db1359af, default 67.91 69.07 46.89 3.86 38 / 32 624.40 11.98→8.75
3 db1359af, 1024 18.13 92.99 44.74 54.02 1 / 1 25.09 8.75→7.55
4 main ba005ca4 67.89 91.60 45.20 54.25 2 / 1 25.73 7.55→8.24
5 db1359af, default 67.97 91.64 44.99 51.00 4 / 1 27.46 8.24→8.75
6 db1359af, 1024 18.15 93.50 44.12 51.87 5 / 0 24.61 8.75→7.54

Second-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.

Five scenes, main, the default and the opt-in side by side: the same pictures in all three

Build and game-free checks

The 309e6c35 edge-capacity fix is retained. I reran the independent fixture on db1359af with GCC 16.2 and Clang 23.1.3: the 8,190 control and 8,191 boundary both pass; the boundary now reports resets=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.

> [@mooki000](https://github.com/mooki000) commented on GitHub · 2026-11-12 16:43 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2731#issuecomment-6829816063) 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 base **`ba005ca4`**, 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. | Run | Arm | MB written back/frame | FPS | CPU ms/frame | 1% low | Frames >20 / >25 ms | Worst frame ms | Load start→end | |---|---|---:|---:|---:|---:|---:|---:|---| | 1 | main `ba005ca4` | 68.10 | 62.23 | 45.88 | 3.44 | 53 / 47 | 511.18 | 15.75→11.98 | | 2 | `db1359af`, default | 67.91 | 69.07 | 46.89 | 3.86 | 38 / 32 | 624.40 | 11.98→8.75 | | 3 | `db1359af`, 1024 | 18.13 | 92.99 | 44.74 | 54.02 | 1 / 1 | 25.09 | 8.75→7.55 | | 4 | main `ba005ca4` | 67.89 | 91.60 | 45.20 | 54.25 | 2 / 1 | 25.73 | 7.55→8.24 | | 5 | `db1359af`, default | 67.97 | 91.64 | 44.99 | 51.00 | 4 / 1 | 27.46 | 8.24→8.75 | | 6 | `db1359af`, 1024 | 18.15 | 93.50 | 44.12 | 51.87 | 5 / 0 | 24.61 | 8.75→7.54 | Second-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. ![Five scenes, main, the default and the opt-in side by side: the same pictures in all three](https://gist.githubusercontent.com/mooki000/b86bd3f35969803fb032f71f035844f8/raw/67d20c0bce7f3135e063d3f1c0a6651784d50843/hookshot-pr2731-db1359af-three-arm-scenes.jpg) ## Build and game-free checks The `309e6c35` edge-capacity fix is retained. I reran the independent fixture on **`db1359af`** with GCC 16.2 and Clang 23.1.3: the 8,190 control and 8,191 boundary both pass; the boundary now reports `resets=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. <!-- github-sync:c:6829816063 -->
Commenting is not possible because the repository is archived.
No description provided.