Skip to content

perf(vk): large GPU-written bindings are written back only where the CPU reads or writes them - #2731

Open
klikso wants to merge 4 commits into
NotRexed:mainfrom
klikso:perf/wb-cap-learned
Open

klikso wants to merge 4 commits into
NotRexed:mainfrom
klikso:perf/wb-cap-learned

Conversation

@klikso

@klikso klikso commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

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 also makes it the default.

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 (now 1024 by default) 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.
  • RX_VK_WB_MAX_KB=0 writes every binding 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 5b96a55; 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

…CPU reads or writes them

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 <noreply@anthropic.com>

@hckrman101 hckrman101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks, 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.

Comment thread gpu/vk/wb_learn.h Outdated
it->second = Seen{hi, 0, false}; // (another binding at this start: its own probation)
}
if (it->second.writes < probation) {
it->second.writes++;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread gpu/vk/wb_learn.h Outdated
if (it->second > hi) hi = it->second;
it = read_.erase(it);
}
if (read_.size() >= max_read) full_ = true;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread gpu/vk/vk_memory.cpp Outdated
}
// (hookshot) the CPU wrote this page: in a large binding whose write-back may be left out, the copy below would bring
// guest memory's stale rest of the page over the GPU's: that binding is written back from now on (wb_learn.h)
if (dirty) memory_note_cpu_access(page << kPageShift, 1ull << kPageShift, true);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread gpu/vk/vk_memory.cpp
{
std::lock_guard lk(wb_learn_mu);
if (write && !wb_table.followed(guest, guest + size)) return;
stale = wb_table.read(guest, guest + size);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread gpu/vk/vk_memory.cpp Outdated
// (hookshot) a binding over RX_VK_WB_MAX_KB (1024 by default; 0: none) that the CPU has not read through its
// probation queues no write-back: OoT writes tens of MB of culling lists and per-frame buffers through the GPU that
// the CPU never reads, and copying them back was most of a submit's memory sync (issue 384: 68 MB a frame; Hyrule
// Field on an RTX 3060 Ti, 57 -> 61 fps and no frame over 50 ms). Learned, not a plain size cap: the CPU does read

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

klikso and others added 2 commits November 12, 2026 10:53
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 <noreply@anthropic.com>
…stead of latching full

Review of NotRexed#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 <noreply@anthropic.com>
@klikso

klikso commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

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.

@mooki000

Copy link
Copy Markdown
Contributor

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

sheepbun added a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
…d for good in Hyrule Field

RX_VK_WB_SKIP_LEARN keeps +1.4% in Kokiri Forest with stale first touches: not taken. The slow no-cap run in Hyrule Field
was a 224 MB pool whose mirror allocation failed once and was refused for good (5.1 ms readback waits); upstream NotRexed#2618
is taken for it, and NotRexed#2731 is to be measured against this branch's learning cap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
sheepbun added a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
…of two speeds

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… both

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 <noreply@anthropic.com>
@klikso

klikso commented Nov 12, 2026

Copy link
Copy Markdown
Contributor Author

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

@mooki000

Copy link
Copy Markdown
Contributor

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.

@Joey-Magnus Joey-Magnus left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I merged 309e6c3 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.

@hckrman101

Copy link
Copy Markdown
Collaborator

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 bce3d17 (the PR head) against main 51da046 (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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants