WIP: perf(vk): flat per-draw caches, inline op payload copies and a flat c1 map in the replay #231
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
good first issue
gpu/amd
gpu/igpu
gpu/intel
gpu/nvidia
help wanted
kind
bug
kind
chore
kind
docs
kind
feature
kind
performance
kind
question
needs measurements
platform/linux
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!231
Loading…
Reference in a new issue
No description provided.
Delete branch "dukenheimer/Longshot:opus/replay-flat-caches"
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?
Why
The Vulkan replay on the game's render thread sets the frame rate on CPU-bound machines (a Steam Deck above all), and each draw spent a good part of its time in hash-map lookups, a C library
memmoveper replayed op, and a heap allocation per kept c1 upload. This makes those per-draw costs cheaper, with the same results.What changed
gpu/vk/flat_map.h(new):FlatMap<K, V>, an integer-keyed table in one array (open addressing, linear probing, at most 3/4 full, slot from a multiplicative hash;find/end,operator[],size,clear; a clear gives back an array over 4096 slots). Ported from Corentin Bonjour's https://github.com/NotRexed/Hookshot/pull/2774 (its two commits, with authorship kept).handle_hits(texture_view_of,gpu/vk/vk_draw.cpp),graphics_pipeline'smine(vk_draw.cpp) andpicks(program_for,gpu/vk/vk_shader.cpp).HandleHitis reordered and itstex_epochis 32 bits, astexture_epoch()returns. The clear thresholds are the same, so entries are kept and dropped as before.payload<T>()(gpu/vk/vk_replay.cpp) copiessizeof(T)bytes, inline, when the op is at least that long (nearly always), instead of a variable-lengthmemcpy, which was a call of libc'smemmovefor every op replayed. A shorter op is copied for its length as before.RecordingRes::c1_at(vk_draw.cpp) is aFlatMapkeyed by the stage's address: no node allocation for each c1 upload a recording keeps, and no frees at the recording's clear. It is emptied at each recording's start withFlatMap::reset, which keeps its array (aclearwould give an array over 4096 slots back and grow it again from 16 every recording); the recording streams' log line (every 3000 recordings) now says the most c1 stages a recording uploaded.program_for(vk_shader.cpp) holdsPick& pkacrossprogram_pick, the one place a reference into aFlatMapis held across a possible insert; a comment there says why that is safe (nothing in between inserts into the thread'spicks:program_picktakes the non-recursiveshader_muand can't reachprogram_foragain).tests/test_flat_map.py(from #2774:FlatMapagainststd::unordered_map, andtexture_view_ofover either; here alsoresetagainstclear, andc1_atemptied withreset);tests/test_replay_copies.py(payload<T>cut out and run for every length, and the flatc1_at);test_fsr_uniform_ownership.py,test_recording_admission.pyandtest_draw_stream_visibility.pyincludeflat_map.h, since they cutRecordingResout of the source.vk_shader.cppchanges (the picks' map), so the translator's fingerprint changes: the first start after updating prepares the shaders again (the program cache of the old build is not read).Flags and settings
None. The change is internal and gives the same results; reverting the commits removes it.
Measurements
Two machines. On the Steam Deck the same changes were measured as part of a combined build, not alone (below). The evidence is thin, and a measurement of the head is still owed before this leaves WIP: MP165 rests on two pairs (after round 1 was dropped and rerun), M1152 is one pair inside the noise, the PC is an RTX 5090 rather than a CPU-bound machine, and the Deck saw these changes only inside a combined build. Owed: the head against main, several interleaved runs, on a CPU-bound machine (the Steam Deck), with the replay's "descriptors" and "submit" ms as well as fps; then the test request and the
docs/tested-configurations.mdrow.PC (PC runner, one prepared game folder per arm under
D:\Githubproject\Longshot-bench, warm-up per folder; experiments.md, "flat caches redone with separate bench folders")main:
e28236b4(2026-11-12)this PR: opus/replay-flat-caches-measured @ 53a83cca (pushed to the fork so it can be seen: the same five changes on that main). The head has been rebased since (onto
fbd4202, after #198 and #107, without conflict in these files) and has the review'sc1_atreset and comment on top, not measured.Hardware: RTX 5090, Windows 11 (the runner's PC)
Scene: Kokiri Forest, the runner's moving route; M1152 = 2048x1152 window; MP165 = the 165 Hz present config
Method: 2 interleaved rounds each (MP165 round 1 excluded and rerun as round 2: main's run had outside CPU load, 22 fps for ~20 s); fps and p99 from the runner's frame log, replay times from
vk: a frame's replay time (ms)linesHow to reproduce: the runner's
pcrun.ps1labelslsflat(two folders: main and this PR, each built withmake_gameand shaders prepared)e28236b4An earlier pair that swapped only the renderer DLL into one folder is withdrawn: this branch changes the translator's fingerprint, so the swapped DLL dropped the shader cache and prepared in play.
Steam Deck (Deck agent, Hookshot-based user build, the same five changes among others; render-thread
perf, Kokiri moving route)Measured in a combined build, not alone, so no fps is claimed for this PR there. The profile of the render thread before and after showed what these changes target:
texture_view_of3.1%,program_for2.5% andgraphics_pipeline1.5% of the thread were these three maps' lookups; libcmemmove6.7% (in part the per-op payload copies); malloc/free about 3.5%.Testing
Tested:
python -I -B tests/run_all.py, Linux (Ubuntu 22.04 container, clang 23, the CPM Vulkan headers,--jobs 12): this head @559f480and main @fbd4202fail the same 4 and nothing else:test_cpp_preflight.py,test_dev_packs.py,test_prof_clock.py, and the byte-compile of every tracked.py(no.gitin the container).python -I -B tests/run_all.py, Windows (llvm-mingw): this head @559f480: 454 passed, 5 failed, 72 skipped; main @fbd4202fails the same 5 on this PC:test_fpx_sticky,test_bake_portability,test_check_third_party,test_spawn_emit,test_release.tests/test_flat_map.pyandtests/test_replay_copies.pypass on both.c1_atreset and comment have not run in the game.Not tested:
Needs other testers:
Screenshots
Nothing on screen.
Risk and rollback
A lookup that answered differently would show as a wrong texture or pipeline; the tests compare
FlatMapwithstd::unordered_mapover random keys andtexture_view_ofover both. Reverting the commits is enough. The first start after the change prepares the shaders again (the translator's fingerprint changes withvk_shader.cpp).Memory:
FlatMapholds its entries in one array. Athandle_hits' clear threshold (65536 entries) that is 131072 × 56 B ≈ 7.3 MB per recording thread, against about 5 MB of nodes before, with a moment at about 11 MB while it grows (the old array and the new). With several recording threads (RX_VK_RECORDER=thread, the parallel workers) that adds up to a few MB more on the 8 GB / Steam Deck tier.c1_atkeeps its array between recordings (its largest size so far, logged).Reviewer notes
Read
gpu/vk/flat_map.hfirst, then the three map declarations invk_draw.cppandvk_shader.cpp, thenpayload<T>invk_replay.cpp.Issues
See https://github.com/NotRexed/Hookshot/pull/2774 (the flat caches, by Corentin Bonjour).
Checklist
For everyone
type(scope): description).main.python -I -B tests/run_all.pyrun and passing (any failure that also fails onmainis named above).third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.docs/tested-configurations.mdfor each measured run. (Not added yet.)tests/that reads changed source updated in this PR, not weakened.Read-only review of
8ca9aa04against main@origin (1ef93e79). Nothing built or run.What's good
FlatMapis correct as far as I can trace it. The load factor stays at or below 3/4, so a probe always reaches an empty slot.shift_is right for every power-of-two size.findcheckscount_before it hashes, so the shift of 64 on an empty table is never evaluated. A failed allocation ingrow()throws before the swap and leaves the map as it was. The hash is multiplicative and takes the top bits, so the zero low bits ofc1_at's pointer keys and the slot/want/use/format fields ofhandle_hits' keys spread well.begin(), so it wouldn't compile), and the clear thresholds haven't moved.tex_epochgoing to 32 bits matchesuint32_t texture_epoch(), and the reorderedHandleHitinitializer at its only use matches the new field order.pipelinesis never erased, somine'sPipeEntry*can't dangle.handle_hitsstill checksversionanddestroy_genbefore it truststex.thread_local,TLS_OBJECT, or reached through the thread-localcur), as on main, soRX_VK_RECORDER=threadand the parallel workers don't share them.FlatMapmoves its entries when it grows, whereunordered_mapdidn't. Only one place holds a reference across a possible insert:Pick& pk = picks[key]is held acrossprogram_pick. That is safe, becauseprogram_picktakes the non-recursiveshader_muand can't reachprogram_foragain. Intexture_view_ofanduniform_range, the found slot isn't used after theoperator[]write.payload<T>does the same thing as before: it never reads pastbytes, andmemcpyneeds no alignment.vk_draw.cpphunks are all at lines 3814 and later, away from these; its one-line change torecording_admission_test.cppbuilds with the-include flat_map.hadded here). None with #216 (it changes onlyvk_memory.cpp).RecordingResget the-include.test_handle_lookup,test_same_draw,test_pick_tex_genandtest_mem_trimstill find their markers.test_flat_map.pyis a good differential check.Must change (before it leaves WIP)
"Rebased on current
main" is ticked but isn't true. The fork point isfdc2840b, and main has08771847and1ef93e79on top. Neither touches these files, so a rebase is trivial. Rebase, or untick the box.The measurement is of a commit nobody else can see. The PC numbers are for
53a83cca, which isn't on the forge, and the head has been rebased since. The evidence is also thin:The Measured box is rightly unticked. Before
pr ready, measure the head (or a pushed commit) against main with several interleaved runs, ideally on a CPU-bound machine, and report the replay "descriptors"/"submit" ms as well as fps. Then open the test request and add thedocs/tested-configurations.mdrow (both boxes are still open).Should change
pkinprogram_foris safe to hold acrossprogram_pick. The header says no reference may be held across an insert, and this is the only place that does it. A future callback fromprogram_pickinto the draw path would turn it into a use-after-free that the old map would have survived.c1_atis cleared at every recording start, andclear()gives back any array over 4096 slots. A recording with more than 3072 distinct c1 stages would therefore free its table and grow it again from 16 every recording: about 9 reallocations and rehashes, a cost the old map didn't have in that form. Below that size, a clear walks up to 4096 slots. Both are probably small for OoT. Loggingc1_at.size()'s peak once would settle it, orc1_atcould take a clear that keeps its capacity.handle_hitsholds 131072 × 56 B ≈ 7.3 MB per recording thread, against about 5 MB before (as the commit message says), with a moment at about 11 MB while it grows. That's fine, but worth a line under Risk for the 8 GB/Deck tier when several recording threads are running.@ -0,0 +38,4 @@void clear() {if (!count_) return;count_ = 0;if (slots_.size() > kKeptSlots) { // (a cache cleared at its size limit: its memory goes back too)This suits the caches cleared at their size limit.
RecordingRes::c1_at, though, is cleared at every recording start: a recording with more than 3072 c1 stages would free its table and grow it again from 16 (about 9 reallocations and rehashes) every recording. Below that size the clear walks up to 4096 slots. Probably small for OoT. Do you havec1_at's peak size? If it can get near this,c1_atcould take a clear that keeps its capacity.@ -1731,3 +1732,3 @@std::vector<uint32_t> handles; // (at its answers' descriptors: their slots and the slots' versions)};thread_local std::unordered_map<uint64_t, Pick> picks; // (each recording thread's: program_pick has its own lock)thread_local FlatMap<uint64_t, Pick> picks; // (each recording thread's: program_pick has its own lock)program_forholdsPick& pk = picks[key]acrossprogram_pick(...)and writes through it afterwards. WithFlatMap, an insert intopicksduring that call would move the entry and leavepkdangling, whereunordered_mapkept references stable. It's safe today, becauseprogram_picktakes the non-recursiveshader_muand never gets back toprogram_for. Please add a line here or atpksaying so, since flat_map.h's own rule is that no reference is held across an insert.8ca9aa042347271165114727116511559f48074bThanks for the review. Point by point, at
559f480(rebased on mainfbd4202):Must change
fbd4202(after #198, #107 and the rest), with no conflicts, so the box is true. Suites below.opus/replay-flat-caches-measured, and the description names it. The description now says plainly how thin the evidence is: two MP165 pairs after a rerun, one M1152 pair inside the noise, an RTX 5090, and the Deck only inside a combined build. Measured stays unticked. Still owed beforepr ready: this head against main with several interleaved runs on a CPU-bound machine (the Steam Deck), with the replay's "descriptors" and "submit" ms as well as fps, then the test request and thedocs/tested-configurations.mdrow. I don't run the game myself, so I've asked for those runs.Should change
pkacrossprogram_pick: there is now a comment atPick& pk = picks[key](gpu/vk/vk_shader.cpp, in559f480). It says nothing between the lookup and the write inserts into this thread'spicks:program_picktakes the non-recursiveshader_muand can't reachprogram_foragain. A future callback from it into the draw path would have to lookpkup again.c1_atcleared at every recording:FlatMap::reset()empties the map and keeps its array whatever its size, andc1_atuses it at each recording's start (clear()still gives a big array back for the caches cleared at their size limit). I don't have a peak size yet, so the recording streams' log line (every 3000 recordings) now ends withc1 stages: at most N a recording, and the next run will show it.tests/test_flat_map.pychecksresetagainstclear(capacity kept or given back, then refilled) and alternates the two in the differential againststd::unordered_map; its source check requiresr.c1_at.reset().handle_hits' threshold against ~5 MB before, ~11 MB for a moment while it grows, a few MB more on the 8 GB / Deck tier with several recording threads.Tests (
python -I -B tests/run_all.py): on Linux,559f480and main @fbd4202fail the same 4 (test_cpp_preflight,test_dev_packs,test_prof_clock, the byte-compile without.git); on Windows, 454 passed and the same 5 failed as on main (test_fpx_sticky,test_bake_portability,test_check_third_party,test_spawn_emit,test_release).View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.