perf(vk): a render thread for the replay, behind a Threaded rendering setting (Auto: on from 12 logical processors) #588
No reviewers
Labels
No labels
area/accessibility
area/audio
area/input
area/install
area/menu
area/renderer
area/saves
area/upscaling
beanstalk: approved @ 1697603
beanstalk: approved @ 2c5a62e
beanstalk: approved @ 4fd82b7
beanstalk: approved @ 64d6d09
beanstalk: approved @ 6f91fae
beanstalk: approved @ 70a78f9
beanstalk: approved @ 8d361dc
beanstalk: approved @ 9d8356e
beanstalk: approved @ abf2227
beanstalk: approved @ d82f202
beanstalk: approved @ e5f1e40
beanstalk: approved @ ee8ab5f
ci: approved
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/macos
platform/steam-deck
platform/windows
preview
priority
critical
priority
high
priority
low
regression
status
blocked
status
confirmed
status
duplicate
status
invalid
status
needs-info
status
wontfix
testing wanted
ci: approved
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
MagicBeanDev/Longshot!588
Loadingβ¦
Reference in a new issue
No description provided.
Delete branch "HumanBean/Longshot:perf/async-replay"
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 game's frame and the renderer's replay of it run one after the other on the same thread, so a frame costs their sum. This adds a render thread that replays a frame while the game builds the next one, and one setting for it, Threaded rendering: Auto / On / Off. Auto, the default, turns it on where the game has 12 or more logical processors to run on, so those PCs get it by default once this merges.
What changed
Six commits, to be read in order:
host/nvn_native.c,nvnEventGetValue): the game's present poll is answered at once when the render thread is on, instead of holding the game until the frame's GPU work is done. The idea of answering the poll from the present's hand-over is upstream's, https://git.notrexed.dev/Hookshot/Hookshot/pulls/2910 (credited in the commit).gpu/vk/vk_replay.cpp:replay_thread_main,enqueue,submit_q,present,event_signal_queued,invalidate,wait_queue_empty;gpu/vk/vk_memory.cpp): the render thread. Submits, presents, registrations, releases and CPU event signals are jobs in one ordered queue; the capture of memory is taken on the game's thread at the submit and at the CPU's signal; the game is held at its present while the present before it is not replayed (one frame ahead at most); a readback waits for the write-backs recorded so far, never for the queue; nothing forces a stopped queue but its present andRX_VK_HOLD_MS; occlusion queries stay on.gate_ready,gate_need_value,gate_onand the wait inpresent()ingpu/vk/vk_replay.cpp): the render thread starts a frame's jobs when the frame's present has been handed over, and the present is handed over after the main thread's signal for the frame. Without it the lit scene was drawn black for one frame at some of the moments the render thread caught up with the game (numbers below). What a game thread waits for goes on at once (a queue finish, a texture read back, a fence of the frame in the making); a job with no present behind it goes on after 1 s.gpu/vk/threaded_rendering.h,menu_threaded_rendering()ingpu/vk/vk_menu.cpp,render_thread_mode()ingpu/vk/vk_replay.cpp,rec::g_setting_threadingpu/vk/vk_recorder.h,HOS_OPT_RENDER_THREADingpu/vk/vk_api.h, the present poll inhost/nvn_native.c): the Insert menu's Threaded rendering row andthreaded_rendering=in the options file. Off: the replay on the submitting thread, as today. On: the render thread and the recorder thread. Auto: On from 12 logical processors the process may run on, Off below, and Off where the mode is not safe (RX_VK_LOW_SPEC,RX_VK_CAPTURE=0, a frame dump or benchmark, no mirrors); all of it behind one constant,threaded::kAutoEnabled(falsein this commit). Read at start; a change applies at the next start.gpu/vk/vk_replay.cpp,host/nvn_native.c, docs, tests): the older replay thread thatRX_VK_ASYNC=1meant is removed (behind_on(),drain_waiters, its branches inocc_queries_on,enqueue,wait_queue_empty,event_signal_queued,replay_thread_main); the render thread isRX_VK_ASYNC=1(any value but 0).threaded::kAutoEnabled = trueingpu/vk/threaded_rendering.h, the menu's line for Auto, its log line,tests/test_threaded_rendering.py, the README and the development guide): Auto follows its rule. Its own commit, at the maintainer's decision, so that reverting it alone makes Auto Off on every PC again.Flags and settings
threaded_rendering=)threaded::kAutoEnabled = true)RX_VK_ASYNC=1/=0RX_VK_FRAME_GATE=0RX_VK_FRAME_GATE_MS=<ms>RX_VK_PRESENT_AFTER_SIGNAL=0/1RX_VK_PRESENTS_AHEAD=<1-8>RX_VK_READBACK_EXACT=1/2HOS_PRESENT_NOWAIT=0/1All are in
docs/development.md; the row is in the README's option table.Measurements
The stack as pushed (rebased onto main
5d3ecc78) has NOT been compiled or run, and it turns the mode on by default for PCs with 12 or more logical processors. The flash counts and the speed table below come from the same commits on an older local base, 318acabe (main at an earlier commit plus ten open ports, never pushed), where the first three commits were built and run (development packs as17 and as18). The setting and the old-mode removal compiled and linked on that base (pack stk1) but were never run in the game, and their port onto main needed hand-merging ingpu/vk/vk_recorder.h,gpu/vk/vk_replay.cppanddocs/development.md, done by reading. The numbers compare the render thread off and on in one build, by its switch (RX_VK_ASYNC=2there;=1after this PR's last commit).main:
5d3ecc78(2026-11-14)this PR: perf/async-replay @
4f1de649measured build: local @ 9342d302 (this PR's first three commits on the local base 318acabe)
Hardware: RTX 3090, NVIDIA 620.70.06, Ryzen 9 7900X (24 logical processors), 32 GB, CachyOS (Linux 7.3)
Scene: Hyrule Castle Town Market by day, from a save at the gate: standing 10 s, running in circles with the camera turning 60 s, turning on the spot 10 s. 2048x1152 in a headless sway, no upscaler, no frame generation, cap 240.
Method: 3 sessions a column, interleaved (S B BU B BU S BU S B), one at a time,
RX_VK_PARALLEL=0 HOS_RESERVE_CORE_MASK=20020on all; frame times fromRX_VK_FRAME_LOGbetween the phase marks; median and p99 per session. Every session swapped (1,000 to 138,000 pages in during a phase: other work shares this PC), so the spread between sessions is wider than the differences between the last two columns.How to reproduce:
RX_VK_FRAME_LOG=<file>with the switches of the column header, the same save, the same route.Baseline: local @ 9342d302,
RX_VK_ASYNCunset. Main wasn't the comparison because the measured build is not main's (above); the columns differ by the switch alone.Frame time, median ms (three sessions):
RX_VK_ASYNCunsetRX_VK_ASYNC=2(this PR's render thread, whole frames)RX_VK_ASYNC=2 RX_VK_FRAME_GATE=0Frame time, p99 ms:
RX_VK_ASYNCunsetRX_VK_ASYNC=2RX_VK_ASYNC=2 RX_VK_FRAME_GATE=0Means of the medians: standing 13.81 β 12.98 ms (β6 %), running 15.80 β 15.20 ms (β4 %). Whole frames cost 0.2 to 0.6 ms against the gate off, which is inside these sessions' noise. Only the render thread was measured (
RX_VK_ASYNC); the row's On also starts the recorder thread, and the two together are not measured yet.The one-frame flash (the lit scene black for a frame, everything drawn after it as it should be), counted by the flash detector (
RX_VK_FLASH=1, a screenshot every present, a frame that differs from both neighbours over the whole picture), in play only, one session at a time. "Meter log" is a diagnostic that slows the render thread and makes the flash 5 to 10 times more frequent; it is not in this PR.RX_VK_FRAME_GATE=0, meter logRX_VK_FRAME_GATE=0, meter logThe two numbers in a cell are two session scripts (a short Market session; a session through Kokiri Forest first), each compared with its own control. At the controls' rates the Market sessions with the fix and the meter log would have shown about 64 flashes (54 with the first script, 10 with the second) and showed none; Kokiri would have shown 2 to 3 and showed none, which alone is weak: Kokiri hardly flashes without the fix either. The two flashes in the sixth row are both in one session that stalled on swap (frames of over 3 s): the escape for a job with no present behind it was 100 ms then, fired inside those stalled frames and started them half written. It is 1 s in this PR; since that change, 0 in 30,616 presents over both scenes.
Testing
Tested:
5d3ecc78, with the setting and the old-mode removal merged onto main by hand).RX_VK_OCC_QUERIES=0still flashed); the fence path (every real wait returned with its value reached and recorded); the render-condition words the game writes (their on/off pattern is identical in each flash frame and its four neighbours); mirror syncs from guest memory (0 pages); dynamic resolution; the order of present and signal alone.Not tested:
python -I -B tests/run_all.pywas not run on this PC (its owner's rule for this machine); the checks that read the reshaped functions were updated by reading them, and CI runs them.Needs other testers:
Screenshots
The setting adds a row, Threaded rendering, to the Insert menu (Graphics, GPU and memory). No screenshot: the row has not been run yet.
Risk and rollback
This changes the default for every PC where the game has 12 or more logical processors to run on, on Linux and Windows, on every GPU maker: Auto turns the render thread and the recorder thread on there, and it has been run on one PC (RTX 3090, Linux) and never as pushed. Below 12, and where the mode is not safe, the replay's path is the one on main, except that the older
RX_VK_ASYNC=1mode no longer exists. With it on: a one-frame flash if a game frame stalls for over 1 s; GPU results the game reads without waiting are up to a frame older; a pool that cannot have a mirror is read where it lies (Auto is to resolve Off there; On is the player's choice). To turn it off: the row's Off, orRX_VK_ASYNC=0. Reverting the last commit alone (Auto on) makes Auto Off on every PC again and leaves the setting; reverting the fifth brings the old mode back; reverting all six removes the feature.Open while it is on by default, each a reason to revert the last commit if it shows: the culling-table readback check in Hyrule Field; runs on AMD, Intel, Windows, Steam Deck and under 12 logical processors; the input-to-picture delay measured; the render thread and the recorder thread measured together; and the read that goes wrong in the overlap either identified or shown unreachable with whole frames on more hardware.
Reviewer notes
Start with the header comment of the replay thread section in
gpu/vk/vk_replay.cppandgate_ready(). The fifth commit (one mode) and the sixth (Auto on) are each separate so that they can be reviewed and reverted alone. Left for follow-ups on purpose: the run-time fallback when a pool is refused its mirror while the game runs; translations of the new row (it shows in English in the other languages); making captured copies of every page the replay reads on the CPU outlive their capture, which would make the overlap safe rather than avoided.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). Not run on this PC (no test suites on this machine); CI runs it.third_party/untouched (a new folder with aPROVENANCE.mdinstead).If applicable
Assisted-by:/Co-Authored-By:trailers.docs/tested/for each new measured run (format indocs/tested-configurations.md; no appended row or assembled view). Not added yet.tests/that reads changed source updated in this PR, not weakened. Updated by reading (tests/test_vk_occlusion_queries.py,tests/test_readback_part.py,tests/test_menu_sections.py, newtests/test_threaded_rendering.py); not run here.π€ Generated with Claude Code
https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn
The owner's decision ("basically make 2 be 1"): RX_VK_ASYNC=1 used to be an older replay thread, on which the game still waited for the replay at every frame, and RX_VK_ASYNC=2 the render thread. Now any value but 0 is the render thread and there is no =2. Unset or 0 stays the default, the replay on the submitting thread, unchanged. Removed, gpu/vk/vk_replay.cpp: - behind_on(): it is async_on() now. What it guarded is guarded by async_on() (gate_on, tex_pages_add's live textures, invalidate, resume_queue's capture, which stays the submitting thread's) or, inside the render thread's own code (replay_thread_main, enqueue, wait_queue_empty's queue part, submit_q, present, event_signal_queued), unconditional. - occ_queries_on(): the occlusion queries are no longer turned off by RX_VK_ASYNC. - wait_queue_empty(): the catch-up that forced the stopped queues (drain_waiters), with replay_thread_main's drain(2), its wait's drain_waiters predicate and its q_idle.notify_all() with no job done. - presents_ahead(): the default of 2; it is 1. - enqueue(): the second wait for room, without the count. - event_signal_queued(): the signal handed over without its capture. host/nvn_native.c: the present poll is answered at once with any RX_VK_ASYNC but 0 (it read == 2). docs/development.md: one RX_VK_ASYNC row; RX_VK_PRESENTS_AHEAD and the occlusion queries' paragraph. tests: test_vk_occlusion_queries.py (the queries stay on with RX_VK_ASYNC=1; no async_on/behind_on stubs), test_readback_part.py (a comment). Not built and no check run here. Co-Authored-By: Claude Fable 5.2 <[email protected]> Claude-Session: https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXnWIP: perf(vk): a render thread for the replay, behind a Threaded rendering setting (Auto is off for now)to WIP: perf(vk): a render thread for the replay, behind a Threaded rendering setting (Auto: on from 12 logical processors)Auto is now on: a sixth commit (
4f1de649) sets threaded::kAutoEnabled to true, at the maintainer's decision, so PCs where the game has 12 or more logical processors get the render thread and the recorder thread by default. It is its own commit: reverting it alone makes Auto Off everywhere again. Still not compiled or run as pushed; the culling-table check in Hyrule Field and runs on other hardware are still open. The description also names the measured commit (local @ 9342d302) in the tables.WIP: perf(vk): a render thread for the replay, behind a Threaded rendering setting (Auto: on from 12 logical processors)to perf(vk): a render thread for the replay, behind a Threaded rendering setting (Auto: on from 12 logical processors)Threaded rendering on AMD and Linux at Auto's cutoff: pass, Auto ahead in every part, one number behind (the 1% low while running).
Hardware: Ryzen 5 5600X (6 cores, 12 threads: Auto on,
12 logical processorsin the log), RX 9070 XT 16 GB, Mesa RADV 26.2.7, Fedora 45 (Linux 7.3.3), 31 GB. Built main6c9edffe(with this PR) as a player's (the Linux container, then the AppImage's--cli --fsr); Off against Auto by the options file'sthreaded_rendering=, the same build.What I checked: the Market (child), Link steered by position through the control bridge, five 60 s parts per run (standing at the gate, laps to the fountain and back, the same laps turning the camera, standing by the fountain, standing there turning the camera), the clock set to 12:00 before each part; two warm-ups, then four runs interleaved Off, Auto, Auto, Off. Medians of two runs a side, from
RX_VK_FRAME_LOG:6c9edffe6c9edffeThe replay's submit time is 3 to 4 ms lower with Auto in every part, the median and p99 frame times lower in every part. No flash, missing object or device loss was seen in the Auto runs. A sampled pair: no thread saturated (busy cores 3.1 Off, 3.6 Auto of 12; render thread 36%, recorder 30% of a core), so at the cutoff the two threads do not look short of cores here. Not run: Hyrule Field's culling tables.
The numbers per part and run, the method and the log lines: #688.
Steam Deck OLED, the render thread forced on against off: slower (p0lux's Deck; the test this PR asks for under "Steam Deck and CPUs under 12 logical processors")
82886e89, unaltered: the game and host built on the Deck (v2.1.0-rc.3-481-g82886e89), the back end built alone from the same commit with the committed play profile (x86-64-v3, LLVM 23.1.5). The shaders prepared again for it (38,809 programs, 264 s).RX_VK_LOW_SPEC=1), the game's own upscaler, no frame generation, no cap,HOS_RESERVE_CORE=0,RX_VK_READBACK_LATE=newest,3980. No VRAM mirrors (an integrated GPU), mesh programs on the compute path.RX_VK_ASYNC=1; without it, two runs in the heavy view and one in the Field. The difference is far outside what two runs of one arm differ by here (1 fps).RX_VK_ASYNC=1Reading. The render thread is idle 22 ms of every frame in both scenes: it waits for the game, which hands over a frame every 51 ms in the heavy view, about the replay (29 ms) plus the frame's GPU time (17.6 ms). So on this machine the frame is still the sum, and a longer one: the game seems to wait for the GPU work of the frame before, which now starts only once the whole frame is handed over. I did not look for which wait it is. The replay itself costs the same (26 to 29 ms a frame either way).
Auto is off here as it should be (
RX_VK_LOW_SPEC, no mirrors, 8 logical processors): this run forced the mode with the switch. It supports keeping Auto off on a Steam Deck.Not tested: the picture (no screen, no flash detector run, since the mode was slower); the row's On (the recorder thread with it;
RX_VK_RECORDER=threadalone was no faster on this Deck on 13 and 14 November, on an earlier main);HOS_RESERVE_CORE=1;RX_VK_FRAME_GATE=0; a Steam Deck LCD. No fatal line, no device loss and no GPU timeout in the kernel's log in the two runs with it on.