Repository navigation
perf(vk): less copying of captured memory on the game and replay threads - #1375
Conversation
6d77d3a to
545ddb8
Compare
|
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). CorrectnessI found no correctness problem.
Both commits cherry-pick cleanly onto v2.0.11. Findings
Does it matter outside Castle Town?
A possible follow-upThe 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).
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 |
|
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 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. |
545ddb8 to
448bd27
Compare
…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>
|
Thanks all for the careful reviews and the measurements. Rebased on main (779bdcf), two commits added: 1. djs5008's change, folded in ( 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
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 (
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
|
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.
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>
448bd27 to
cfefd0e
Compare
|
Rebased on main ( 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
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 |
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.
|
Windows/NVIDIA smoke test of the actual merged commit 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. |
perf(vk): less copying of captured memory on the game and replay threads
…, 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).
This branch is on
mainat 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: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)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(on 581afaf): 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