perf(vk): a draw takes its thread's pass and descriptor writer once, not at every use #198
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!198
Loading…
Reference in a new issue
No description provided.
Delete branch "p0lux/Longshot:perf/accessors-once-longshot"
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
In heavy views on a Steam Deck the render thread's CPU time is most of the frame, and the per-draw functions of the Vulkan replay spend part of it asking for the same per-thread object again and again:
pass()anddw()are function calls, and in the renderer's shared library each one reads its thread-local pointer through__tls_get_addron Linux.What changed
begin_pass,end_pass,draw,draw_hazardsanddynamic_stateingpu/vk/vk_draw.cpptakePass& ps = pass();once at their top, andbind_descriptorstakesDescWriter& wr = dw();, then use the reference. They asked 30, 64, 15, 11 and 4 times for the pass, and three times a write for the writer. Nothing else changes: it is the same per-thread object, made once.build_descriptorsalready does this for the writer (#41).tests/test_pass_hazard.py,tests/test_push_state.py, and the fixturestests/sampler_admission_test.cppandtests/recording_admission_test.cpp, which compilebind_descriptorswhole and its tail.docs/tested-configurations.md.Flags and settings
None.
Measurements
main:
000f19fb(2026-11-13)this PR: perf/accessors-once-longshot @
4e359ba2, measured as ffee45f4: the same commit on main000f19fb, the main the game folder was built from. The branch was rebased onto0edc1377since and got the docs row; it is not measured again there. Between the two,vk_draw.cppchanges log lines infallback_pipelines_readyanddispatch, and #197 adds the occlusion query todraw(), which the rebase keeps whole (it does not touch the pass). 197 lowers how many draws a frame has (8 to 14% on this Deck), so on today's main both columns would be lower; what this PR saves is a draw's cost, which 197 does not change.Hardware: Steam Deck OLED (AMD Custom APU 0932, RADV VANGOGH, Mesa 26.2.0-devel), 16 GB, SteamOS 3.9.5, kernel 7.2.7-valve2
Scene: a save standing still on a heavy view (about 2,500 draws a frame), and a save standing still in Hyrule Field (about 1,600 draws); headless, handheld profile, Minimum preset, the game's own upscaler, no frame rate cap reached
Method: host and game folder built from main
000f19fb; onlylongshot_vk.sodiffers, built alone from each side with the same flags. Runs alternated main, PR, main, PR: 4 each on the heavy view, 2 each in Hyrule Field. Each run starts the game, loads the save by script, waits 3 of the log's 120-present windows, then measures exactly 720 presents: fps over that span, the log'sreplay time (ms): submitandGPU time, and the render thread's user-space cycles and instructions byperf stat -ton that thread, which do not depend on the frequency the CPU ran at. Each cell is the median of its runs.How to reproduce:
longshot_env.txtwithHOS_HANDHELD=1 RX_VK_LOW_SPEC=1 RX_VK_MESH_OPT=1 RX_VK_VRAM_MB=2400 RX_VK_TEX_MAX_MB=128 RX_VK_TEX_MAX_DIM=1024 RX_VK_TEX_SKIP_MB=4 RX_VK_DESC_SLOTGEN=1 HOS_RESERVE_CORE=0 RX_VK_LOAD=create RX_VK_MESH_NOCULL=2c56dc1006942f74, plusHOS_HEADLESS=1; load a save, stand still, and read the log'sN presents, X fps; a frame: N drawslines whileperf stat -e cycles:u,instructions:u -t <the Presentation thread>counts between two of them 720 presents apart.000f19fbThe frame rate does not move on main, and I do not claim it does. In the heavy view a frame is about 42 ms for 31 ms of replay: the rest is the render thread waiting (the read-backs), and it waits longer when the replay ends sooner. In Hyrule Field the GPU's 24.7 ms a frame is the limit. What this gives is a render thread that does about 4.5% less work a frame.
Where the replay is the limit it shows a little. With a local patch on both sides that reads the two hot 0x3980-byte ranges without their wait (not part of this PR), same host, 3 pairs on the heavy view: 25.83 to 26.02 fps (medians; 25.41 to 25.88 against 25.95 to 26.50), replay 34.68 to 34.09 ms, instructions 77.90 to 73.51 M, cycles 88.39 to 86.58 M.
Testing
Tested: on the Steam Deck above, Longshot built from main
000f19fb: 22 headless runs across four renderers (main, this PR, and both with the local read-back patch), each loading a save and playing still for about a minute, with noFATAL,device lostor crash line in any log; draws, passes and GPU time a frame match between the sides within the runs' spread. Headless: no picture was looked at on Longshot. The same edit on Hookshot ran with the picture on the Deck for a play session, with nothing wrong seen.python -I -B tests/run_all.pyon macOS with the Vulkan headers given (LONGSHOT_VULKAN_HEADERS), run again after the rebase onto0edc1377: 471 passed, 19 failed, 36 skipped. 18 of the 19 fail the same way on main on that machine, for reasons of the machine (no/proc, noposix_fallocate, the Linux launch scripts):test_appimage_source_headers,test_bake_portability,test_collect_wide,test_control,test_display_pointer,test_gpu_hang_watch,test_longshot_launch,test_low_memory_notice,test_mem_gate,test_mem_gate_children,test_mirror_trim,test_pcache_header_read,test_pipeline_cache_file,test_pipeline_cache_save,test_pipeline_play_cache,test_prof_symbols,test_program_cache_cleanup,test_program_cache_one_variant. The 19th istest_texture_staging_publication, which fails on main since the log levels came in (#195, #202 and #215 mend it). The checks that read the changed functions pass,test_vk_occlusion_queries.pyamong them.Not tested: the picture on Longshot (the runs were headless); the suite on Linux; a Windows build and run; other GPUs.
Needs other testers:
Screenshots
Nothing on screen.
Risk and rollback
No behaviour change is intended: each function reads the same per-thread object, through a reference taken at its top instead of a call at every use. A mistake would show as wrong pass state (draws missing, a pass left open) on every platform at once. Reverting the commit is enough.
Reviewer notes
The reference is safe because
TLS_OBJECTmakes a thread's object once and frees it when the thread ends:draw_begin_recording'spass() = Pass{}andbuild_descriptors'out.reset()both reset in place. The comment at each of the six sites now says so, so a later change that makespass()per recording trips over it there.draw()still callsdw()three times (same_desc, the fallback test,FC_WRITES), on purpose: they are on the fallback and profiling paths, andtest_mesh_fallback.pymatchesdw().prog == &p.texture_view_of'shandle_hits()calls are left as they are, so this does not collide with the flat per-draw caches being ported. This is meant to add to a static TLS model for the renderer, not to replace it: static TLS makes each thread-local read cheap, this removes most of the calls.Issues
None.
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.tests/that reads changed source updated in this PR, not weakened.1d99299687e627d96bffMeasured on Longshot on the Steam Deck, and rebased onto main
dd02c6cc. Host and game folder built from main000f19fb; the renderer built alone from main and from this change on it, runs alternated. Heavy view (4 runs each, medians): the render thread does 77.04 to 73.57 M instructions and 88.30 to 86.08 M cycles a frame, replay 31.67 to 30.45 ms; fps 23.83 to 23.35, inside the spread, since on main that frame waits on read-backs. Hyrule Field (2 runs each): 47.01 to 44.70 M instructions, fps unchanged (the GPU is the limit). A row for the run is in docs/tested-configurations.md. Still open before ready: a Windows build and run.The change itself is sound. I read it as a review of the code only: I didn't build it or run the checks.
What's good
pass()anddw()in the six functions maps one to one ontopsorwr. No localpsclashes:bind_descriptors'Pushed& psis untouched and the writer there iswr. No callee can leave the reference stale or dangling.TLS_OBJECTmakes each thread's object once, on first use, throughtls_make, andtls_owned()'s~Listfrees it only when the thread ends. Nothing else writespass_tlsordw_tls. The only reset isdraw_begin_recording'spass() = Pass{}, which assigns into the same object, so the reference sees it exactly as a freshpass()call would. The things that do change mid-draw arecur(the recording slot) and the descriptor pools inbind_descriptors(cur->pool,cur->extra), and this PR doesn't cache those. The same holds for pass splits (end_pass/begin_passinsidedraw_hazards/draw), a nestedend_passfrom the dump and probe paths (openis already false), and the recursivebind_descriptorsaftersame < 0.test_pass_hazard.py's negative check now refuses both spellings. The two fixtures name the types the cut code now uses (DescWriter, andWrites& wrabove theDESCRIPTORSslice).perf staton the render thread. On the heavy view the per-run ranges don't overlap: instructions 76.67–78.38 M against 73.30–73.64 M, cycles 87.96–89.89 M against 85.64–86.17 M, replay 31.46–32.20 ms against 30.06–30.64 ms. The description says plainly that fps doesn't move and why. Measuring on000f19fbholds: between it anddd02c6cc,vk_draw.cpponly changes log lines infallback_pipelines_readyanddispatch.build_descriptorsrewrite toDescWriter& out = dw();is already in this PR's base (it came with #41, as the description says). #147 is the dev-packs script and doesn't touchvk_draw.cpp, so nothing conflicts there.vk_draw.cpphunks arehandle_hits,graphics_pipeline'smineandRecordingRes::c1_at, all far from these functions, and the two merge without conflict. It editstest_recording_admission.py, while this PR edits the fixturerecording_admission_test.cpp. The two are complementary. 231's Deck numbers come from a combined build "among others", so if this change was in that build, don't count its gain twice.Must change
cd906265) landed after this PR's basedd02c6cc. It puts theOccGuardocclusion query indraw()betweenbegin_pass(cmd, at); }andprobe_draw(s, p, pass().draws);, the line this PR changes tops.draws. A three-way merge conflicts there; Forgejo reportsmergeable: false. The fix is trivial: keep 197's block and useps.draws.OccGuarddoesn't touch the pass, sopstaken at the top stays right. Until then, "Rebased on current main" is ticked but false. Untick it, or rebase and run the checks that readdraw()again.Should change
draw_begin_recordingresets in place. That way nobody later replacespass()with something per recording or per slot without seeing this.draw()still callsdw()three times (same_desc, the fallback test,FC_WRITES). That's fine, sincetest_mesh_fallback.pymatchesdw().prog == &p, but the description could say they are left on purpose.@ -6717,6 +6722,7 @@ static bool fallback_list_held(uint64_t list) {}void draw(VkCommandBuffer cmd, NvnState& s, const DrawArgs& d) {Pass& ps = pass(); // (once: the accessor is a call and a thread-local read at each use)I checked this is safe.
TLS_OBJECTmakes the thread'sPassonce and frees it only when the thread ends, anddraw_begin_recording'spass() = Pass{}assigns in place. So no split, slot change or recording restart insidedraw()can leavepsstale. Could the comment say that too, e.g.(the same object for the thread's life: draw_begin_recording resets it in place)? Then a later change that makespass()per recording would trip over it here.@ -7108,3 +7114,3 @@begin_pass(cmd, at);}probe_draw(s, p, pass().draws); // (longshot: RX_VK_PROBE)probe_draw(s, p, ps.draws); // (longshot: RX_VK_PROBE)This conflicts with main since #197 (
cd906265). It inserts theOccGuardblock, withvkCmdBeginQueryand the guard ending it, betweenbegin_pass(cmd, at); }and this line, so a three-way merge conflicts here. To resolve it, keep 197's block and thenprobe_draw(s, p, ps.draws);.OccGuarddoesn't touch the pass, sopsfrom the top ofdraw()is still the right object.e627d96bff4e359ba2f1After the review: rebased onto main
0edc1377. The conflict in draw() with #197 is resolved as proposed: 197's OccGuard block kept whole, then probe_draw(s, p, ps.draws). The comment at each of the six sites now says why the reference is safe (the same object for the thread's life; draw_begin_recording, and build_descriptors for the writer, reset it in place). The description says the three dw() calls left in draw() are left on purpose, and why the numbers measured on000f19fbstill describe the change. The suite ran again on the rebased branch: 471 passed, 19 failed, 36 skipped, the 19 being main's on this machine; the checks that read draw() pass, test_vk_occlusion_queries.py among them. Not built or run in the game since the rebase. Still open: the Windows build. On 231: the Deck numbers here are main against this PR alone, with nothing of 231 in either build.WIP: perf(vk): a draw takes its thread's pass and descriptor writer once, not at every useto perf(vk): a draw takes its thread's pass and descriptor writer once, not at every use