Skip to content

perf(vk): less copying of captured memory on the game and replay threads - #1375

Merged
hckrman101 merged 5 commits into
NotRexed:mainfrom
valonsodev:perf/capture-memory
Nov 10, 2026
Merged

hckrman101 merged 5 commits into
NotRexed:mainfrom
valonsodev:perf/capture-memory

Conversation

@valonsodev

@valonsodev valonsodev commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

This branch is on main at 0828863. The 3+3 table below was measured on v2.0.11 (7dbdcdc), and the single run on 581afaf.

What changed

Two changes to how the renderer reads and keeps its captures of guest memory, both in gpu/vk/vk_memory.cpp:

  1. Keep 32 submitted pages a thread, not one (memory_read_as_submitted).
    • Reuse a captured page for the rest of its uniform words #315 added a one-page cache here, so the next read of a page skips pools_mu and the capture lookup. The answer is now kept per page, 32 a thread, picked by page number (submitted_page_for).
    • The reuse rule is unchanged: same page, same page generation, same capture view.
  2. The texture shadow's pages kept in place and copied by the capture helpers (take_capture).
    • The pages copied now keep their shadow slot and are overwritten in place. Only pages written but not copied are erased.
    • A full shadow drops what it can't replace, as before.
    • The 4 KB copies run on the capture's copy helpers (copy_pairs), with shadow_mu still held.
    • copy_pages and copy_pairs share the helpers, and each call now waits for its own parts, not every caller's.

Why

On a profile of v2.0.11 in Hyrule Castle Town, on a test route (Tracy zones on the host and renderer, perf for the rest), the game thread (sead::MainThread) is the frame's critical path:

  • Its own CPU: about 21 ms of a 45 ms frame (under capture).
  • Then its job batches, about 16 ms, run on the six BaseProcExecutors.
  • Then about 6 ms waiting for the replay.

The replay thread waits about 16 ms a frame for the game.

Change 2: the game thread's copies. About half of the game thread's CPU was libc memcpy, in the capture work of its own submits:

  • take_capture's shadow pass erased the shadow copy of every page written since the last capture (a hash erase and a 4 KB delete[] each),
  • then inserted the copied pages again (a hash insert and a 4 KB new[] each),
  • then copied their 4 KB one after another.

OoT's game thread submits twice a frame and rewrites mostly the same pages. On a profiling build (v2.0.10), Tracy zones around the pass measured 6.8 ms a frame before this change and 1.4 ms after.

Change 1: the replay's per-draw reads. A handle program reads a uniform word on one page and a storage-buffer word on another (stage_handle → stage_cbuf_word → ub_read → memory_read_as_submitted). With one page kept, those reads take turns missing: each takes pools_mu and copies the whole 4 KB page to serve 4 bytes.

  • Profile: on the v2.0.10 profiling build, before the change, program_for was 41% of the replay thread, and ub_read 35%, mostly that copy.
  • Slow-path reads: on v2.0.11, 3.2× fewer with the change (the vk: replay reads of guest memory as submitted counter: 2.68 M → 0.84 M).
  • Replay thread CPU: 63–67% → 48–56% in each A/B pair (the log's per-thread times).

It matters less for fps here, because the replay thread already waits for the game. It matters where the replay is the limit.

How it was tested

Hardware: NVIDIA GeForce RTX 3060 Ti, driver 615.78.08; AMD Ryzen 9 5900X; Arch Linux, kernel 7.2.14. Windows is not tested.

Game area: Hyrule Castle Town (young Link, from a save at noon), on a scripted 30 s test route through the plaza: the same stick and camera inputs every run, with camera sweeps over the crowd.

Settings: the same options file for every run: quality preset 5, frame generation 3× (frame_generation=3); built without the DLSS SDK. The fps counts the game's own presents (nvnQueuePresentTexture), not the interpolated frames.

Builds: normal build settings (make_game.py, RX_MARCH=native), from the same tree. A local debugging commit that drives the route over a socket is in every build (it isn't part of this PR).

build run 1 run 2 run 3 mean
main (v2.0.11) 23.99 23.86 25.42 24.42
change 1 only 25.53 23.72 26.99 25.41
this PR (1 + 2) 32.35 32.62 30.99 31.99
this PR on 581afaf (main when opened) 33.22 one run

The first two rows were run alternately, main then change 1. The third was run right after, on the same PC and route. The last row is a single run of this branch rebased on current main, with no baseline run on that main beside it. It shows the gain holds there, not a new percentage.

With both changes: +7.6 fps over main (+31%). The slowest run with them (30.99) is faster than every run without them.

The game thread's CPU (the log's per-thread times) fell from about 44% to 31%, and the replay thread's rose to about 61%, now that it gets more frames. All ten runs ended at the same point on the route. Their end-of-route screenshots render as before, with no device losses.

Checks:

  • tests/test_submitted_page.py, extended: the reuse rule, the per-page lookup in both places, and no slot shared by a page and its neighbour or by pages 32 apart.

  • tests/test_capture_copy_helpers.py, new:

    • copy_pairs and copy_pages run on the shared helpers from two threads at once;
    • every call returns with all of its own copies made, for sizes around the helpers' threshold;
    • take_capture's shadow pass keeps the shape described above.

    With copy_pairs' wait removed, it fails.

  • python -I -B tests/run_all.py (on 581afaf): 182 passed, 19 skipped, 1 failed. The failure is tests/test_make_game_cache.py, and it fails on main too on this PC: it runs the builder, which finds this machine's real build-cache profile.

Cost: 32 × 4 KB = 128 KB of thread-local memory in hookshot_vk per thread that touches the renderer's thread-locals, up from 4 KB.

Assisted-by: Claude Opus 5.5

🤖 Generated with Claude Code

@klimovich008

Copy link
Copy Markdown
Contributor

Review from reading the diff against v2.0.11 (581e83f), running the two tests, and checking logs of our own Windows runs. I have not measured the PR's fps effect yet; a 3×3 A/B on Windows/AMD is queued and I'll add the numbers here.

Setup for the numbers below: Windows 11, Ryzen 7 5700X3D, RX 9070 XT, v2.0.11, 32:9, warm caches, replay inline on the game's threads (the default).

Correctness

I found no correctness problem.

  • Change 1: each of the 32 slots keeps the old rule (same page, same page generation, same capture view), so it is as safe as the single slot.
  • Change 2: the shadow ends in the same state as before, including the full-shadow path (a page already there is overwritten even when full; a page that can't be placed loses its stale copy in the trailing loop). Slots taken in the pass carry the current shadow_seq, so the sweep can't drop one whose copy is still pending.
  • Helpers: each caller waits on its own left, changed only under copy_helpers.mu, so copy_pages and copy_pairs can overlap without one returning early or waiting forever.

Both commits cherry-pick cleanly onto v2.0.11.

Findings

  1. tests/test_capture_copy_helpers.py does not build on Windows. It compiles its fixture with -Werror, and the MSVC CRT marks getenv deprecated (-Wdeprecated-declarations, twice, in copy_pages and copy_pairs). With -D_CRT_SECURE_NO_WARNINGS added to the compile line (the game build passes it too) the test passes here. tests/test_submitted_page.py passes as it is.
  2. The helper threshold in copy_pairs looks low. From 64 pages (256 KB) the copies go to the helpers. Copying 64 pages inline is on the order of 10–15 µs; waking two to four threads through a condition variable and waiting for them usually costs more. The caller now also waits with pools_mu and shadow_mu held, behind whatever copy_pages has queued from another thread, so on a loaded or low-core PC a short copy can become a wait under two locks. This is from reading, not a measurement. copy_pages uses 128 pages for copies that are slower (guarded reads of guest memory).
  3. The gain of change 2 is not split. The 6.8 → 1.4 ms figure covers keeping the pages in place and moving the copies to helpers. A row for change 2 without the helpers would show whether they are needed at all; my guess is that the in-place part (no free, allocation and two hash operations a page) carries most of it.
  4. Memory, minor. thread_local SubmittedPage submitted_pages[32] is about 132 KB of TLS. On Windows every thread of the process gets a module's TLS block when it starts, used or not; it was 4 KB. A lazily allocated table would avoid that.

Does it matter outside Castle Town?

  • Change 1: yes, by our logs. In Hyrule Field memory_read_as_submitted takes its slow path 3,528 times a frame and copies 769 pages from captures (Kokiri Forest: 1,250 and 162), from the vk: replay reads of guest memory as submitted counter over 720 frames.
  • Change 2: probably little in those two scenes. The texture shadow holds about 840 pages (3 MB) there.

A possible follow-up

The reuse rule compares the capture view, which moves at every capture and every stamp anywhere, six or more times a frame, so all 32 slots are emptied each time even for pages nothing touched. What a kept answer depends on is narrower: the page's own generation, and, when the answer is a capture's copy, that capture still being kept. Captures are retired oldest first, so one published "oldest kept generation" is enough to check that. I have this as a small patch on top of this PR with a test, behind a switch for the old rule; I'll measure it with the A/B and open it separately only if it shows a gain.

🤖 Reviewed with Claude Code

@klimovich008

Copy link
Copy Markdown
Contributor

The measurement I promised above, and a correction to my own comment.

Setup: Windows 11, Ryzen 7 5700X3D, RX 9070 XT, v2.0.11 (581e83f) exe, 32:9, warm caches, replay inline. Hyrule Field, camera turning, 20 s windows anchored on the save load, 3 runs an arm, alternating. Both renderer DLLs are built with the same recipe. The variant is v2.0.11 plus this PR's two commits, cherry-picked, plus my follow-up patch from above set to this PR's own reuse rule by its environment switch (so it behaves as this PR, with one extra counter in the log line).

per frame, median of 3 v2.0.11 this PR
fps 58.7 (57.2 / 58.7 / 58.8) 58.5 (57.4 / 58.5 / 58.8)
replay "submit" 13.42 ms 13.50 ms
of it "programs" 1.23 ms 1.18 ms
memory_read_as_submitted slow-path reads 3,556 833
pages copied from captures 773 214
process CPU 4.00 cores 4.01 cores
  • The PR does what it says: slow-path reads fall by 77 % and page copies by 72 %.
  • On this scene it changes nothing measurable. The reads were not a real cost here: the function is asked about 4,850 times a frame in all, and removing 2,700 slow reads moved "programs" by 0.05 ms. fps and replay time are within run-to-run noise. This scene is replay-bound at about 58 fps, so it is the case the description says the change should help; here it doesn't, and it doesn't hurt either.
  • Correction: I wrote above that change 1 "matters, by our logs". The counter was high, but its cost was small. I inferred a cost from a count; the A/B says otherwise for this scene. Castle Town with a large texture shadow may well be different, as your numbers show.
  • The follow-up I floated (keeping pages across submits by the oldest kept capture's generation) took slow reads from 833 to 639 a frame with no change in any phase or in fps (3 runs against 2). Not worth a PR; dropped.

Not tested here: Castle Town, frame generation, a game-thread-bound scene, change 2's helpers on a low-core PC.

🤖 Measured with Claude Code

@djs5008

djs5008 commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Some data from Linux + NVIDIA that might help with change 1.

I'm on an RTX 5080 + Ryzen 9 9950X3D, Arch, driver 615.78, same as the author. On my machine that 4 KB copy is really expensive. In Kokiri it was about 35% of the Presentation Thread, and the thread was the bottleneck at 75 to 85% of a core. That's roughly 120 page copies a frame costing about 9 ms, so around 75 µs per copy. klimovich's Windows numbers show the same copies being basically free, so I think something about how that memory is mapped on NVIDIA Linux makes CPU reads from it very slow.

I tried a different approach: don't snapshot captured pages at all. A read of a captured page just copies the n bytes it needs under pools_mu, which the slow path already does. The one-page cache stays, but only for pages no capture holds. Retiring happens under pools_mu in take_capture, so nothing keeps a pointer into a buffer that could go away.

         if (page0 == page1) {
-            submitted_page.page = page0;
-            ...
-            std::memcpy(submitted_page.bytes, (const uint8_t*)b->second.map + (uint64_t)e.slot * 4096, 4096);
-            submitted_page.overlay = true;
+            submitted_page.view = 0;
+            if (from == 0) {
+                submitted_page.page = page0;
+                submitted_page.pgen = page_gen(page0);
+                submitted_page.view = cap_view_gen.load(std::memory_order_relaxed);
+            }
         }

(plus removing the overlay fast path at the top and the bytes/overlay fields)

Kokiri gameplay, same save and settings, DLSS on:

before after
median fps 38.7 55.9
lowest 5 s window 32.1 50.7
Presentation Thread 75 to 85% 60 to 65%

test_submitted_page.py and test_ub_memo.py pass. I haven't tested Windows, and from klimovich's numbers I wouldn't expect much there. This should stack with change 2, since that one is on the game thread. Happy to open it as a separate PR or leave it for you to fold in, whichever is easier.

valonsodev added a commit to valonsodev/Hookshot that referenced this pull request Nov 10, 2026
…opy kept a thread

memory_read_as_submitted kept, for a page a capture holds, a 4 KB copy of it a thread (NotRexed#315), and
the 32 kept pages (the commit before) made that 128 KB of TLS a thread. A read of a captured page
now copies the words it asks for from the capture under pools_mu, as a read that misses already did;
the 32 slots keep only "in no capture", 24 bytes each.

On NVIDIA Linux the 4 KB copies were a third of the replay's thread in Kokiri Forest
(djs5008's measurements on NotRexed#1375). Where the copy is cheap it still cost more than the extra
lock: OoT's Market on a test route, Linux, RTX 3060 Ti (cached host memory, 0.3 us a 4 KB read),
only hookshot_vk.so swapped, 4 rounds alternating after a warm-up (one of the first's runs hung at
boot waiting on a GPU fence, as runs before this change did too):

                       fps (median)   replay thread (Presentation Thread)
  before (one commit)  32.96          66.8 %
  after                35.48          60.9 %

Every run after (34.1-35.8) was above every run before (32.0-33.5). Reads that went to the
captures rose from ~0.3 to ~1.0 million a run, each a few bytes under pools_mu.

Co-authored-by: djs5008 <35700921+djs5008@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
valonsodev added a commit to valonsodev/Hookshot that referenced this pull request Nov 10, 2026
…ts own parts no helper has taken

Review of NotRexed#1375. copy_pages and copy_pairs each had their own copy of the split and of the helper
count; they share one pool of helpers, so both are copy_split now, with capture_copy_helpers. Its
threshold is 128 copies for both (copy_pairs' was 64: 256 KB is ~10-15 us inline, less than waking
the helpers). A caller that has made its first part takes back its own parts no helper has started
and runs them, instead of waiting behind another caller's queued parts: copy_pairs runs with
pools_mu and shadow_mu held, and copy_pages' guest copies can take milliseconds. copy_helpers.pending
was no longer read and is gone.

The test builds with -D_CRT_SECURE_NO_WARNINGS (getenv's deprecation under the MSVC CRT, as the
game's Windows build passes), checks sizes around the threshold, and that copy_pairs returns while
every helper and the other caller are held in copy_pages (it times out without the new loop).

OoT's Market on a test route, Linux, RTX 3060 Ti, only hookshot_vk.so swapped, 3 rounds
alternating: 33.9 / 35.2 / 35.1 fps before, 34.2 / 35.3 / 33.9 after (the same within the runs'
spread), no hang.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@valonsodev

Copy link
Copy Markdown
Contributor Author

Thanks all for the careful reviews and the measurements. Rebased on main (779bdcf), two commits added:

1. djs5008's change, folded in (perf(vk): a captured page's words read from the capture, not a 4 KB copy kept a thread, with djs5008 as co-author). A read of a captured page now copies only the bytes it asks for from the capture under pools_mu. The 32 slots keep only "this page is in no capture", so each thread's TLS for them is 768 bytes, not ~132 KB (klimovich's point 4).

I measured it where the 4 KB copy should be cheap: Linux, RTX 3060 Ti, capture buffers in host-cached memory (about 0.3 µs per 4 KB read here). OoT's Market on a scripted route; the only difference between the two builds was hookshot_vk.so; 4 rounds alternating after a warm-up:

fps (median) Presentation Thread CPU
this PR before 32.96 (32.0 / 33.0 / 33.3) 66.8 %
with djs5008's change 35.48 (34.1 / 35.2 / 35.8 / 35.8) 60.9 %

Every run with the change was faster than every run without it. Reads that go to the captures rose from ~0.3 M to ~1.0 M per run, so the lock is taken more often, and it's still cheaper than the 4 KB copy. (One "before" run hung at boot waiting on a GPU fence. That hang happened in runs before this PR too and isn't in this code.)

On the 75 µs per copy: I timed 4 KB CPU reads from each host-visible memory type on this card. Host-cached took 0.28 µs, host-visible uncached 12 µs, and device-local host-visible (ReBAR) 89 µs. So djs5008's 75 µs matches uncached reads, and on cached memory the change still wins, as the table shows.

2. Review fixes (perf(vk): the capture's copies split by one function; ...):

  • The test builds with -D_CRT_SECURE_NO_WARNINGS, like the other -Werror fixtures and the game's own Windows build.
  • copy_pages and copy_pairs are now one copy_split, with the helper count in one capture_copy_helpers(). copy_helpers.pending is gone.
  • Threshold 128 copies for both (was 64 for copy_pairs).
  • hckrman101's second option: once a caller has done its first part, it takes back and runs its own parts no helper has started yet, so the wait under pools_mu/shadow_mu is bounded by its own work and not by another caller's queued guest copies. The test checks it: with both helpers and another copy_pages caller held mid-copy, copy_pairs still returns (without the new loop it times out).
  • Market, 3 rounds alternating: 33.9 / 35.2 / 35.1 fps before and 34.2 / 35.3 / 33.9 after, within run-to-run noise, no hangs.

Not done: splitting change 2 into in-place vs helpers (klimovich's point 3).

CI still needs a maintainer to approve the workflow run.

@itaskaev2

Copy link
Copy Markdown
Contributor

Tested on Windows + NVIDIA, since the numbers so far are from Linux and from AMD.

Setup: Windows 11, Ryzen 7 5800X3D, RTX 4080 SUPER (driver 616.92), 4K output, DLSS, Auto resolution, replay inline. Kokiri Forest from the spawn, 16 camera swings, 130 s runs (the summary is of the camera part, from 30 s). Only hookshot_vk.dll differs: main at 779bdcf, this PR at 448bd27, and this PR with #1619 (non-temporal capture copies) merged on top. Same exe, the three arms alternating, two runs each.

median frame fps p99 capture a submit
main 779bdcf 19.1 / 19.8 ms 51.5 / 49 28.9 / 31.2 ms 0.69 / 0.73 ms
this PR 20.0 / 19.4 ms 49 / 50.5 30.5 / 31.3 ms 0.74 / 0.72 ms
this PR + #1619 19.5 / 19.1 ms 50 / 52 31.1 / 28.4 ms 0.57 / 0.57 ms
  • No measurable change on this machine: the spread is this scene's run-to-run noise (about ±0.4 ms). No flash lines in any run's log.
  • That fits the profile: on main, memory_read_as_submitted was 1.5 % of the Presentation Thread's samples (2.2 % of its running ones; all-thread sampler, 50 s of the same scene), with the capture buffers in host-cached memory. The 4 KB copy that cost ~35 % of the thread on Linux (djs5008's numbers) doesn't show up on Windows/NVIDIA, so here the change neither helps nor hurts.
  • Kokiri puts no texture storage into the capture ("0 texture pages copied" in the take's log line), so these runs don't exercise the shadow-pass part of the PR.
  • tests/run_all.py on Windows (llvm-mingw, clang 23): the capture tests pass (test_capture_copy_helpers, test_submitted_page, test_capture_slot_ownership, test_capture_copy_runs, ...). The only failures are the two that fail on main the same way on this PC: nx2/test_nup_paths.py (no symlink rights here) and test_precompile_compute_guard.py, which passes from a VS developer prompt.
  • It merges cleanly with perf(vk): copy captured pages with non-temporal stores #1619, and the two together ran fine: this PR's reads from the capture buffers come after a streaming copy there (from memory, not the cache), and that cost nothing measurable either.

DFN-master added a commit to DFN-master/Hookshot that referenced this pull request Nov 10, 2026
Snapshot of the sources that built E:\\Games\\Hookshot and produced the 59-60 fps measurements: PR NotRexed#1375 applied locally, RX_VK_WB_MAX_KB, and the builder fixes. PR NotRexed#1375 is a separate open PR and is not part of the perf/wb-max PR.
valonsodev and others added 5 commits November 10, 2026 18:50
memory_read_as_submitted kept the last page it read (NotRexed#315) so the next read of that page
skipped pools_mu. A handle program reads a uniform word on one page and a storage buffer
word on another, so with one page kept those reads took turns missing: each took pools_mu
and copied the whole 4 KB page for its 4 bytes. In Hyrule Castle Town, on a test route,
that was about a quarter of the replay thread's draw time (program_for's answer handles).

The answers are kept per page now, 32 a thread, by page number; a page is reused under
the same rule as before (same page, page generation and capture view).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…capture helpers

take_capture refreshes the texture shadow for every capture. It erased the shadow
copy of every page written since the last capture (a hash erase and a 4 KB delete
each), inserted the copied ones again (a hash insert and a 4 KB new each), and copied
their 4 KB one after another on the submitting thread. OoT's game thread submits
twice a frame and rewrites mostly the same pages, so in Hyrule Castle Town, on a
test route, this was most of the game thread's time in libc memcpy, about half of
its CPU, on the frame's critical path.

The pages copied now keep their slot and are overwritten in place; only the pages
written but not copied are erased. A shadow that is full drops the pages it can't
replace, as before. The 4 KB copies run on the capture's copy helpers (copy_pairs)
with shadow_mu still held, and each copy call now waits for its own parts only, not
for every caller's (copy_pages and copy_pairs share the helpers).

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…opy kept a thread

memory_read_as_submitted kept, for a page a capture holds, a 4 KB copy of it a thread (NotRexed#315), and
the 32 kept pages (the commit before) made that 128 KB of TLS a thread. A read of a captured page
now copies the words it asks for from the capture under pools_mu, as a read that misses already did;
the 32 slots keep only "in no capture", 24 bytes each.

On NVIDIA Linux the 4 KB copies were a third of the replay's thread in Kokiri Forest
(djs5008's measurements on NotRexed#1375). Where the copy is cheap it still cost more than the extra
lock: OoT's Market on a test route, Linux, RTX 3060 Ti (cached host memory, 0.3 us a 4 KB read),
only hookshot_vk.so swapped, 4 rounds alternating after a warm-up (one of the first's runs hung at
boot waiting on a GPU fence, as runs before this change did too):

                       fps (median)   replay thread (Presentation Thread)
  before (one commit)  32.96          66.8 %
  after                35.48          60.9 %

Every run after (34.1-35.8) was above every run before (32.0-33.5). Reads that went to the
captures rose from ~0.3 to ~1.0 million a run, each a few bytes under pools_mu.

Co-authored-by: djs5008 <35700921+djs5008@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ts own parts no helper has taken

Review of NotRexed#1375. copy_pages and copy_pairs each had their own copy of the split and of the helper
count; they share one pool of helpers, so both are copy_split now, with capture_copy_helpers. Its
threshold is 128 copies for both (copy_pairs' was 64: 256 KB is ~10-15 us inline, less than waking
the helpers). A caller that has made its first part takes back its own parts no helper has started
and runs them, instead of waiting behind another caller's queued parts: copy_pairs runs with
pools_mu and shadow_mu held, and copy_pages' guest copies can take milliseconds. copy_helpers.pending
was no longer read and is gone.

The test builds with -D_CRT_SECURE_NO_WARNINGS (getenv's deprecation under the MSVC CRT, as the
game's Windows build passes), checks sizes around the threshold, and that copy_pairs returns while
every helper and the other caller are held in copy_pages (it times out without the new loop).

OoT's Market on a test route, Linux, RTX 3060 Ti, only hookshot_vk.so swapped, 3 rounds
alternating: 33.9 / 35.2 / 35.1 fps before, 34.2 / 35.3 / 33.9 after (the same within the runs'
spread), no hang.

Assisted-by: Claude Opus 5.5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…otRexed#1762; tests follow NotRexed#1762 and NotRexed#1619

The rebase onto NotRexed#1762 takes this branch's hashed table without page copies in place of NotRexed#1762's four kept copies.
RX_VK_SUBMITTED_PAGES=1 still puts every page in one slot, and test_submitted_page checks both modes (and builds with
_CRT_SECURE_NO_WARNINGS for the MSVC CRT's getenv). test_capture_copy_helpers stubs NotRexed#1619's copy_fence.

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

Copy link
Copy Markdown
Contributor Author

Rebased on main (428818b, after #1762 and #1619). The conflicts with #1762 are resolved toward this branch: submitted_find/submitted_slot, the 4 KB bytes copy and the "whole pages kept" counter are gone. The new last commit keeps RX_VK_SUBMITTED_PAGES=1 as one slot for every page. tests/test_submitted_page.py checks both modes and builds with -D_CRT_SECURE_NO_WARNINGS. test_capture_copy_helpers stubs #1619's copy_fence. run_all.py test_submitted_page test_capture_copy_helpers test_capture_copy_nt test_capture_copy_runs: 4 passed (Linux, clang and gcc).

A/B in #1762's scene: Linux, RTX 3060 Ti (615.78), Ryzen 9 5900X. Kokiri village view from the Lost Woods bridge, standing still 25 s (2,950–3,570 draws). Main at f758d9f (with #1762) against this branch merged with it (the same changes as this rebase), both built the same way, one warm-up each, then 4 rounds alternating in both orders:

fps median
main (four kept copies) 32.80, 32.12, 33.24, 33.00 32.9
this PR 33.96, 34.12, 33.44, 33.56 33.8

This PR was ahead in every round (+0.2 to +2.0 fps). Main read from the captures ~370k times per 3M reads, copying a whole 4 KB page each time. This branch read from them ~1.1M times, each copying only the bytes asked for, under pools_mu.

@hckrman101
hckrman101 merged commit e63d30d into NotRexed:main Nov 10, 2026
omgitsgela pushed a commit to omgitsgela/Hookshot-SteamDeck that referenced this pull request Nov 10, 2026
Ranked, with what each targets and its risk: RX_VK_BATCH_POLL_US=2000
(runtime, no rebuild - baseline flushes every progress poll), RX_VK_TEX_SKIP_MB
+ MAX_DIM=1024 (fewer bytes staged/decoded for wide ASTC), and NotRexed#1375's
a4ccf07 (parallel reuse of texture-shadow pages). Also records two
measurement traps: GPU timestamps do not cover upload compute, and
slow-frame logging requires <500ms AND >=500 draws, so the worst hitches
are invisible in that log line.
@Nitnek

Nitnek commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Windows/NVIDIA smoke test of the actual merged commit e63d30dbea2bc8b660a890dd1620489d50e61229: Ryzen 7 5800X, RTX 3070 8GB, driver 610.88, Windows 11 26300.9818.

Two independent launches loaded isolated copies of the same custom save/profile at the stationary castle approach, 1280×720 output, frame generation off. Each had at least 60 seconds of warm-up followed by approximately 60 seconds of measurement. The position/camera was checked before measurement; all eight measurement PNGs were inspected. No gross rendering corruption was visible in those samples, and no unexpected exit, device-loss or fatal-error marker was observed. The test processes were stopped deliberately at the measurement limit.

Observed throughput was 44.24 / 43.32 FPS, computed over equal 120-present intervals, retaining the slow intervals. This is not a measured speedup: I have no first-parent runtime control, and background desktop activity/instrumentation were not eliminated.

Caveat: the first cold preparation reported 14 failed programs out of 38,809 prepared (164.8 seconds total startup). The cached second launch started in 3.0 seconds and reported zero runtime failures, but it did not repeat the same preparation population; it does not erase those first 14 failures. I cannot attribute them to this change without a parent control.

This is limited compatibility evidence for this scene/hardware, not a general no-regression guarantee. Sparse still images cannot exclude single-frame flashes, and this test did not cover repeated scene transitions or a long session.

briossant pushed a commit to briossant/Hookshot that referenced this pull request Nov 11, 2026
perf(vk): less copying of captured memory on the game and replay threads
NotRexed pushed a commit that referenced this pull request Nov 12, 2026
…, not the write-combined capture buffer (#2314)

Long frames in Hyrule Field on an NVIDIA Linux PC came from
take_capture's texture-shadow copies: the replay thread copied the
submit's texture pages out of the capture buffer, and that buffer was
write-combined (host-visible, not cached) memory, where the CPU reads at
~0.3 GB/s (~14 µs per 4 KB page with RX_VK_CAPTURE_THREADS=0; ~3 µs with
the four helpers). Frames that streamed ~5000 texture pages spent up to
16 ms in those copies (RX_VK_FRAME_TRACE's memsync).

The capture buffers come from `create_buffer_cached`, which asks VMA for
cached host memory, but on this driver only the first ~30 MB of
HOST_CACHED allocations succeed; later capture buffers (87 / 32 / 16 MB)
get VK_ERROR_OUT_OF_DEVICE_MEMORY for the cached type and land in the
uncached one. Requiring HOST_CACHED, or importing malloc'd RAM with
VK_EXT_external_memory_host, hits the same limit, so the fix doesn't
depend on the buffer's memory type:

- `capture_make` (game threads) copies the pages that are texture
storage (the merged `tex_cap_ranges`, already taken there) guest -> a
staging block in ordinary heap RAM -> the capture buffer, so both hold
the same bytes; a page `guarded_copy` can't read is marked the same way
as before.
- `take_capture` builds the shadow's `CopyPair`s from the staging block;
a page without a slot there (none staged, or a late page, which
take_capture copies into the capture buffer after its GPU wait) is read
from the capture buffer as before.
- The block goes back to a small free list (at most 4 blocks / 96 MB)
after the copies, so a big block isn't faulted in again on the game
thread.
- `RX_VK_SHADOW_STAGE=0` turns it off. A line every 600 takes counts the
shadow copies from staging and from the capture buffer.

The game thread does one extra cached copy per texture page (~0.4 µs) to
save the replay thread's uncached read (~3-14 µs).

Related: #1375 and #1619 (the capture copies this builds on), #995
(CPU-bound Hyrule Field).

### A/B on this PC
RTX 3080 Ti (driver 615.78.08), Ryzen 7 5800X3D, CachyOS (kernel 7.3.1),
headless gamescope 1920x1080 at 60 Hz, Hyrule Field (day), main 49f75dc
with this change, the same build with RX_VK_SHADOW_STAGE=0 and without,
2 rounds each, alternating, upstream's defaults otherwise:

| run | Field fps | p50 | p99 | frames over 33 ms / min | Vulkan errors
|
|---|---|---|---|---|---|
| off 1 | 45.2 | 20.1 ms | 48.3 ms | 116 | 0 |
| on 1 | 45.3 | 20.1 ms | 49.0 ms | **73** | 0 |
| off 2 | 44.2 | 20.4 ms | 49.7 ms | 140 | 0 |
| on 2 | **50.1** | 19.4 ms | **28.3 ms** | **16** | 0 |

Deku Tree (the same build, one round): off 57.1 fps, p99 38.0 ms, 38.8
frames over 33 ms a minute; on 60.0 fps, p99 19.5 ms, 0.

The frames over 33 ms drop in both rounds; the average went up in the
second round and not the first, so the fps gain is somewhere between
none and ~13% here. On our fork, with the threaded recorder on top
(RX_VK_RECORDER=thread), the same change was 47.9 / 48.8 -> 52.7 / 52.6
fps and 172 / 43 -> 9 / 10 frames over 33 ms a minute over two rounds.
The log line (staging on) counted ~87,000 shadow copies from staging and
none from the capture buffer per 600 takes; with RX_VK_SHADOW_STAGE=0,
~90,000-128,000 from the capture buffer.

On our fork the same change also kept the title screen, the same frame's
picture and the audio unchanged, and a 10-minute Field run (threaded
recorder on top) held 53.5 fps with 0 errors and flat memory.

Tests: `python -I -B tests/run_all.py` 323 passed, 3 failed:
test_updater_asset_digest, test_wayland_libdecor_hint and
test_builder_log_read_only fail the same way on main on this PC
(environment: no Tk, the updater and libdecor checks).

`test_capture_copy_nt`'s source check now finds the fence after both of
copy_part's page-by-page retries (the plain one and the staged one).

Assisted-by: Claude Sonnet 5.5 (implementation), Claude Opus 5.5
(diagnosis, review, measurements).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants