perf(vk): less copying of captured memory on the game and replay threads #1375
No reviewers
Labels
No labels
accessibility
autorelease: pending
autorelease: tagged
bug
dependencies
documentation
duplicate
enhancement
github_actions
good first issue
help wanted
invalid
question
testing wanted
wontfix
No milestone
No assignees
1 participant
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Hookshot!1375
Loading…
Reference in a new issue
No description provided.
Delete branch "perf/capture-memory"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This branch is on
mainat0828863. The 3+3 table below was measured on v2.0.11 (7dbdcdc), and the single run on581afaf.What changed
Two changes to how the renderer reads and keeps its captures of guest memory, both in
gpu/vk/vk_memory.cpp:memory_read_as_submitted).pools_muand the capture lookup. The answer is now kept per page, 32 a thread, picked by page number (submitted_page_for).take_capture).copy_pairs), withshadow_mustill held.copy_pagesandcopy_pairsshare 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,
perffor the rest), the game thread (sead::MainThread) is the frame's critical path:BaseProcExecutors.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 KBdelete[]each),new[]each),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 takespools_muand copies the whole 4 KB page to serve 4 bytes.program_forwas 41% of the replay thread, andub_read35%, mostly that copy.vk: replay reads of guest memory as submittedcounter: 2.68 M → 0.84 M).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).main(v2.0.11)581afaf(mainwhen opened)The first two rows were run alternately,
mainthen 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 currentmain, with no baseline run on thatmainbeside 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_pairsandcopy_pagesrun on the shared helpers from two threads at once;take_capture's shadow pass keeps the shape described above.With
copy_pairs' wait removed, it fails.python -I -B tests/run_all.py(on581afaf): 182 passed, 19 skipped, 1 failed. The failure istests/test_make_game_cache.py, and it fails onmaintoo 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_vkper thread that touches the renderer's thread-locals, up from 4 KB.Assisted-by: Claude Opus 5.5
🤖 Generated with Claude Code
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.
shadow_seq, so the sweep can't drop one whose copy is still pending.left, changed only undercopy_helpers.mu, socopy_pagesandcopy_pairscan overlap without one returning early or waiting forever.Both commits cherry-pick cleanly onto v2.0.11.
Findings
tests/test_capture_copy_helpers.pydoes not build on Windows. It compiles its fixture with-Werror, and the MSVC CRT marksgetenvdeprecated (-Wdeprecated-declarations, twice, incopy_pagesandcopy_pairs). With-D_CRT_SECURE_NO_WARNINGSadded to the compile line (the game build passes it too) the test passes here.tests/test_submitted_page.pypasses as it is.copy_pairslooks 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 withpools_muandshadow_muheld, behind whatevercopy_pageshas 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_pagesuses 128 pages for copies that are slower (guarded reads of guest memory).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?
memory_read_as_submittedtakes its slow path 3,528 times a frame and copies 769 pages from captures (Kokiri Forest: 1,250 and 162), from thevk: replay reads of guest memory as submittedcounter over 720 frames.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
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).memory_read_as_submittedslow-path readsNot tested here: Castle Town, frame generation, a game-thread-bound scene, change 2's helpers on a low-core PC.
🤖 Measured with Claude Code
Thanks, this is a nice find, and the write-up makes it easy to check. I read both changes against main (
5609af4, which only touches two unrelated bounds checks in vk_memory.cpp, so it merges cleanly) and looked mostly at thread safety. I didn't find a correctness bug.c->slotsbeing ascending indices intoc->pages, whichcapture_makeguarantees. Slots taken in this pass carry the currentshadow_seq, so the in-loop sweep (seq + 64 < shadow_seq) can't free a buffer that's still waiting inpairs.slot.b.get()stays valid across rehashes because the 4 KB buffer lives outside the map node.--*w.leftundercopy_helpers.mu. The caller seesleft == 0under the same mutex, then releasesshadow_mu, somemory_as_submittedandmemory_drop_shadowonly ever see finished pages. Helpers take none of our locks, so waiting with pools_mu + shadow_mu held can't deadlock. The per-callerleftis also a real fix: with the sharedpending, amemory_capture_nowon the replay thread and a game-threadcopy_pagescould each wait on the other's parts.Two things before merge, both small, plus nits inline:
-Werror, and against the MSVC CRT (clang targeting msvc)getenvraises-Wdeprecated-declarations, so it fails to build there, as klimovich found. Adding-D_CRT_SECURE_NO_WARNINGSto the compile line fixes it. I'm not sure whether the windows-2022 job's g++ (mingw) warns, but the flag costs nothing.run_all.py --strictjob needs to go green once a maintainer approves the workflow run.On the perf side, klimovich's Hyrule Field A/B (fewer slow reads, no fps change where the replay is the limit) and your Castle Town numbers fit together: change 2 is where the gain comes from. Splitting it into in-place vs helpers, as he suggested, would still be good to know but shouldn't block this.
@ -1371,1 +1345,4 @@int* left = nullptr; // (the caller's parts not done yet (copy_helpers.mu): a caller waits for its own, not another's too)};void copy_part(const CopyPart& w) {if (w.pairs) {Nit: nothing reads
copy_helpers.pendinganymore (both waits useleftnow), so it's just a counter. Drop it, or keep it with a comment saying it's only for debugging. Also nit: thehelpersexpression is now copied incopy_pagesandcopy_pairs. A smallcapture_copy_helpers()would keep the two from drifting apart, since they share one pool of threads that only the first caller starts.Not a bug, but this caller waits with pools_mu and shadow_mu held, and the queue is FIFO. If the game thread's
copy_pagesalready queued its parts (guarded copies of ~9 MB, which go through ReadProcessMemory on Windows), the replay sits behind them under both locks while its own parts haven't started. Two cheap options: raise the threshold (klimovich's point; 64 pages inline is ~10-15 us), or have the waiting caller pop and run its own unstarted parts (those withleft == &left) instead of only waiting. Either way the wait under the locks stays bounded by its own work.@ -0,0 +1,152 @@"""The capture's copy helpers: copy_pages and copy_pairs share them, and each call waits for its own parts only.With
-Werror, the twogetenvcalls in the extractedcopy_pages/copy_pairsraise-Wdeprecated-declarationsagainst the MSVC CRT, so the fixture doesn't build there. Adding"-D_CRT_SECURE_NO_WARNINGS"here (the game build passes it too) fixes it.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.
(plus removing the overlay fast path at the top and the
bytes/overlayfields)Kokiri gameplay, same save and settings, DLSS on:
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.
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 underpools_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: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; ...):-D_CRT_SECURE_NO_WARNINGS, like the other-Werrorfixtures and the game's own Windows build.copy_pagesandcopy_pairsare now onecopy_split, with the helper count in onecapture_copy_helpers().copy_helpers.pendingis gone.copy_pairs).pools_mu/shadow_muis bounded by its own work and not by another caller's queued guest copies. The test checks it: with both helpers and anothercopy_pagescaller held mid-copy,copy_pairsstill returns (without the new loop it times out).Not done: splitting change 2 into in-place vs helpers (klimovich's point 3).
CI still needs a maintainer to approve the workflow run.
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.dlldiffers: main at779bdcf, 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.779bdcfmemory_read_as_submittedwas 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.tests/run_all.pyon 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) andtest_precompile_compute_guard.py, which passes from a VS developer prompt.Thanks for the follow-up, and for folding in djs5008's change. Re-read at 448bd27.
Fixed since my last review:
-D_CRT_SECURE_NO_WARNINGS.copy_pages/copy_pairsare onecopy_split, the helper count lives incapture_copy_helpers(), andpendingis gone.pools_mu/shadow_mu: the threshold is 128 for both now, and the caller takes back its own unstarted parts (w.left == &left) after its first one, so it never waits behind another caller's queued guest copies. I checked the accounting:leftonly changes undercopy_helpers.mu, a stolen part is erased from the queue before it runs, and the caller doesn't return beforeleft == 0, so no helper can touch the stackleftafter the caller returns. The held-helpers case in the test covers it.from == 0), so a kept answer is just guest memory, and a captured page's bytes are copied from the capture underpools_muon every read. Nothing outlives its capture, and the TLS goes down to 768 bytes. The slot hash still keeps neighbours and pages 32 apart in different slots.The shadow pass is unchanged from what I traced last time.
Two things before this can land, neither a code problem:
memory_read_as_submitted: four round-robin entries that each still hold a 4 KB copy of the captured page, plus anRX_VK_SUBMITTED_PAGESswitch, awhole pages keptcounter, and a rewrittentests/test_submitted_page.py. All three conflicts ingpu/vk/vk_memory.cppare in that function, and the test conflicts too. Main's other changes to the file since your base (779bdcf) merge cleanly. Your version covers what #1762 was after (two pages read in turn stay kept) and also drops the whole-page copy, so I'd resolve toward this PR's side: dropsubmitted_find/submitted_slot/the 4 KBbytes, and adapt the test. KeepingRX_VK_SUBMITTED_PAGES=1as a way back to one slot would be a nice touch but isn't needed. Please merge main. A quick A/B in #1762's scene (Kokiri village view, where it went 50 -> 57 fps) against post-#1762 main would confirm that reading captured pages under the lock is at least as good as main's four kept copies there. For context, draft #1541 rewrites this same function again (16 hashed slots that still keep page copies), so whichever lands second will need a rebase.run_all.py --strictjob can go green.Not blocking: splitting change 2 into in-place vs helpers (klimovich's point 3) is still open. It would be good to know, but it shouldn't hold this up.
@ -1080,1 +1076,4 @@// capture holds is not kept: its words are copied from the capture under pools_mu at each read, not the whole 4 KB page// into a copy of the thread's own (that copy was a third of the replay's thread in Kokiri Forest on NVIDIA Linux)constexpr int kSubmittedPages = 32;thread_local SubmittedPage submitted_pages[kSubmittedPages];This is where main conflicts now: #1762 replaced the single
submitted_pagewithsubmitted_pages[4]+submitted_find/submitted_slot(round-robin, still with the 4 KBbytesoverlay) andRX_VK_SUBMITTED_PAGES. When you merge main, I'd keep this hashed, overlay-free table and drop main's version, along with its whole-page counter in the log line.tests/test_submitted_page.pyneeds the same resolution, since main's version extractssubmitted_find(/submitted_slot(/submitted_ways(.Rebased on main (
428818b, after #1762 and #1619). The conflicts with #1762 are resolved toward this branch:submitted_find/submitted_slot, the 4 KBbytescopy and the "whole pages kept" counter are gone. The new last commit keepsRX_VK_SUBMITTED_PAGES=1as one slot for every page.tests/test_submitted_page.pychecks both modes and builds with-D_CRT_SECURE_NO_WARNINGS.test_capture_copy_helpersstubs #1619'scopy_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: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.Re-review at
cfefd0e(rebased on428818b, after #1762, #1619 and #1822).#1762 conflict: resolved the way we suggested.
submitted_find/submitted_slot, the 4 KBbytescopy and the "whole pages kept" counter are gone, and the hashed, copy-free 32-slot table stays. Against main the vk_memory.cpp diff deletes only #1762's submitted-page code. Nothing else of main's is lost, and #1619'scopy_fence/NT path is untouched (the pairs path is plain memcpy, so it needs no fence).RX_VK_SUBMITTED_PAGES=1is kept as one slot. It was never in docs/, so there's nothing to update there. The test now checks both modes.A/B in #1762's scene: done, thanks. Kokiri village view, 4 alternating rounds against
f758d9f: 32.9 → 33.8 fps median, ahead in every round. So dropping main's four kept copies costs nothing there.New code: I re-read
copy_splitand the read path.leftlives on the caller's stack and is only changed undercopy_helpers.mu. Stolen parts are erased from the deque before they run, and the caller can't return before the last helper's decrement. The captured bytes are read fromcap_bufs[...].mapunderpools_mu, and retire happens under the same lock, so nothing outlives its buffer. I found no problems.Still open / small:
tests/test_capture_copy_helpers.pydoesn't declareRUN_ALL_ARGS = ["--cxx", "{cxx}"], unlike its siblings (test_capture_copy_nt,test_capture_copy_runs,test_capture_slot_ownership). Sorun_all.pyruns it with its defaultg++instead of the compiler it found, and doesn't skip it when there is none.test_submitted_page.pylacks it on main too, so you could add it to both while you're there.copy_pages/copy_pairsas two functions, and "on main at0828863". Worth refreshing before merge.action_required, so a maintainer has to approve them.Approving. A maintainer still needs to approve and run the CI workflows before this merges.
@ -1120,1 +1096,4 @@if (page0 == page1 && submitted_page_reuse(page0, page_gen(page0), view, submitted_page.page, submitted_page.pgen,submitted_page.view))return true;static std::atomic<int64_t> reads{0}, pages_from{0};Nit: "as before #1762" isn't quite the old behaviour. Before #1762 the one slot also kept a 4 KB copy of a captured page, and now it only keeps "in no capture". It's fine as an A/B switch, but maybe say "one slot" without the "as before".
@ -0,0 +14,4 @@import subprocessimport tempfileROOT = Path(__file__).resolve().parents[1]Since #1822, siblings declare what run_all passes them. This one should have
RUN_ALL_ARGS = ["--cxx", "{cxx}"]after the imports, as intest_capture_copy_nt.py/test_capture_copy_runs.py. Without it run_all calls it with no arguments, so it usesg++even where only clang++ was found, and fails rather than skips with no compiler.Nit: main's version dropped
RX_VK_SUBMITTED_PAGESfrom the env for this default run. Withos.environpassed through, a shell that has it set to anything (say4from the #1762 days) takes the fixture's one-slot branch, whilesubmitted_page_foronly treats== 1as one slot, so the test fails for a reason that isn't a bug. Stripping it like before (or checkingatoi(...) == 1in the fixture) avoids that.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.