perf(vk): a render thread for the replay, behind a Threaded rendering setting (Auto: on from 12 logical processors) #588

Merged
HumanBean merged 6 commits from HumanBean/Longshot:perf/async-replay into main 2026-11-14 21:36:43 +00:00
Owner

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 (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).
  • renderer (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 and RX_VK_HOLD_MS; occlusion queries stay on.
  • fix: whole frames (gate_ready, gate_need_value, gate_on and the wait in present() in gpu/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.
  • setting (gpu/vk/threaded_rendering.h, menu_threaded_rendering() in gpu/vk/vk_menu.cpp, render_thread_mode() in gpu/vk/vk_replay.cpp, rec::g_setting_thread in gpu/vk/vk_recorder.h, HOS_OPT_RENDER_THREAD in gpu/vk/vk_api.h, the present poll in host/nvn_native.c): the Insert menu's Threaded rendering row and threaded_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 (false in this commit). Read at start; a change applies at the next start.
  • one mode (gpu/vk/vk_replay.cpp, host/nvn_native.c, docs, tests): the older replay thread that RX_VK_ASYNC=1 meant is removed (behind_on(), drain_waiters, its branches in occ_queries_on, enqueue, wait_queue_empty, event_signal_queued, replay_thread_main); the render thread is RX_VK_ASYNC=1 (any value but 0).
  • Auto on (threaded::kAutoEnabled = true in gpu/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

Flag What it does Default now Recommendation Helps on Doesn't help on Untested on
Threaded rendering (menu row, threaded_rendering=) Auto / On / Off: the render thread and the recorder thread Auto: On from 12 logical processors the process may run on where the mode is safe, Off elsewhere (threaded::kAutoEnabled = true) Auto; Off if a flash, a missing object or a device loss shows 12+ logical processors where the replay is the slower side (measured: Market, this PC) Where the game's own job chain is the limit (running: about 4 %) AMD, Intel, Windows, Steam Deck, under 12 logical processors
RX_VK_ASYNC=1 / =0 Test override of the row's render-thread half (set, it wins over the row) unset test only - - -
RX_VK_FRAME_GATE=0 A/B: the render thread replays jobs as they come, as before the whole-frames fix (it flashes) unset (whole frames on) test only - - -
RX_VK_FRAME_GATE_MS=<ms> How long a job with no present behind it waits before it goes on (1000) 1000 leave - - -
RX_VK_PRESENT_AFTER_SIGNAL=0/1 A/B: the present's half of whole frames alone unset (follows the gate) test only - - -
RX_VK_PRESENTS_AHEAD=<1-8> How many presents the game may be ahead of the render thread 1 leave - - -
RX_VK_READBACK_EXACT=1/2 Test: readbacks made after everything handed over is replayed (keeps the game in step) unset test only - - -
HOS_PRESENT_NOWAIT=0/1 Test: the present poll as before / answered at once whatever the mode unset (follows the render thread) test only - - -

All 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 in gpu/vk/vk_recorder.h, gpu/vk/vk_replay.cpp and docs/development.md, done by reading. The numbers compare the render thread off and on in one build, by its switch (RX_VK_ASYNC=2 there; =1 after this PR's last commit).

main: 5d3ecc78 (2026-11-14)
this PR: perf/async-replay @ 4f1de649
measured 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=20020 on all; frame times from RX_VK_FRAME_LOG between 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_ASYNC unset. 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):

local @ 9342d302, RX_VK_ASYNC unset local @ 9342d302, RX_VK_ASYNC=2 (this PR's render thread, whole frames) local @ 9342d302, RX_VK_ASYNC=2 RX_VK_FRAME_GATE=0
Market standing 13.58 / 14.05 / 13.79 12.38 / 12.15 / 14.41 11.71 / 12.86 / 12.64
Market turning 14.18 / 13.46 / 13.96 13.09 / 13.23 / 13.54 12.89 / 12.39 / 13.94
Market running 15.65 / 16.13 / 15.62 14.90 / 15.46 / 15.24 15.03 / 14.84 / 15.19

Frame time, p99 ms:

local @ 9342d302, RX_VK_ASYNC unset local @ 9342d302, RX_VK_ASYNC=2 local @ 9342d302, RX_VK_ASYNC=2 RX_VK_FRAME_GATE=0
Market standing 21.2 / 22.5 / 23.0 18.5 / 19.9 / 21.1 17.9 / 19.4 / 19.1
Market turning 23.2 / 22.9 / 22.5 23.8 / 21.0 / 23.4 18.3 / 18.1 / 30.6
Market running 26.6 / 28.1 / 26.2 24.6 / 26.8 / 28.0 26.4 / 27.7 / 26.1

Means 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.

scene arm sessions presents flashes
Market render thread, RX_VK_FRAME_GATE=0, meter log 3 + 2 14,198 + 8,903 33 + 7
Market render thread, whole frames, meter log 5 + 3 23,223 + 13,208 0
Kokiri Forest render thread, RX_VK_FRAME_GATE=0, meter log 2 13,866 2
Kokiri Forest render thread, whole frames, meter log 3 17,408 0
Kokiri Forest render thread, whole frames, no meter log 5 30,109 0
Market render thread, whole frames, no meter log, escape after 100 ms 5 19,763 2
Market replay on the submitting thread (earlier, same detector) 4 29,760 0
Market render thread before the fix, no meter log (earlier) 4 30,484 6

The 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.

vk: the render thread's whole frames (600 presents): 0 jobs went on before their present after 1000 ms, 0 times a game's thread waited for the work handed over, 0 times for a fence of the frame in the making
vk: presents after the CPU's signal (600): 588 waited for it, 7.84 ms each, 0 let go after RX_VK_FRAME_GATE_MS
vk: the render thread (600 presents, 19.22 ms a frame): its jobs 19.17 ms a frame (submits 1.11, presents 9.82, others 8.24), idle 0.05; ...

Testing

Tested:

  • Nothing on the stack as pushed: it has not been compiled or run (rebased onto main 5d3ecc78, with the setting and the old-mode removal merged onto main by hand).
  • On the local base 318acabe, the first three commits (RTX 3090, NVIDIA 620.70.06, CachyOS): the sessions under Measurements; the flash counts above in the Market and Kokiri Forest; the render thread's event order (0 stopped queues forced in play), no device loss in any session.
  • What the flash is not (each measured on the local build): the exposure meter and the other small readbacks the game makes (every read carried one frame's write-back, consecutive frames, at every flash); occlusion counts (RX_VK_OCC_QUERIES=0 still 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:

  • Which read of the game's state goes wrong when the replay overlaps the game's writing of the same frame is not identified. The fix closes the window (the console's order: there the presenting thread cannot get past its poll before the main thread's signal), it does not make that read safe. A game frame that stalls for over 1 s with jobs queued and no present can still be started half written, and can flash.
  • python -I -B tests/run_all.py was 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.
  • The Threaded rendering row and the old-mode removal have never been run in the game, on any base: not Auto (that it turns both threads on from 12 logical processors and leaves them off below, and off where the mode is not safe), not On (render thread and recorder thread together, which is also not measured for speed), not Off. They compiled and linked on the local base only.
  • Input-to-picture delay is not measured. By construction a frame is shown no later than with the replay on the submitting thread (game, then replay); a present is made 24 to 36 ms after its hand-over when the replay is the slower side (the detector's sessions).
  • GPU-written tables over 16 KiB that the game reads back without waiting (Hyrule Field's culling tables) can be a frame older with the render thread; whether that is what the console gives is not checked. Hyrule Field was not run.
  • Any GPU but the RTX 3090; AMD, Intel; Windows; Steam Deck; a CPU with fewer than 12 logical processors; 8 GB cards (the mode needs every pool the frame binds mirrored).
  • The Windows build of the host and the renderer (CI).

Needs other testers:

  • AMD (RADV and Windows), Intel: Threaded rendering On in the Market and Kokiri Forest, expecting no one-frame black flash, no device loss and a lower frame time than Off where the replay is the slower side (request to open)
  • Windows, NVIDIA: the same (request to open)
  • Steam Deck and CPUs under 12 logical processors: On against Off, expecting On not to be slower; it decides Auto's threshold (request to open)
  • Hyrule Field, any GPU: On, expecting no missing or late-culled draws (request to open)

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=1 mode 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, or RX_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.cpp and gate_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

  • One topic.
  • The title is a Conventional Commit (type(scope): description).
  • Rebased on current main.
  • python -I -B tests/run_all.py run and passing (any failure that also fails on main is named above). Not run on this PC (no test suites on this machine); CI runs it.
  • Tested in the game, or what wasn't is named under Testing. The pushed stack was not built or run; what was run, on the older base, is under Testing.
  • Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). Filled in; the test requests are not opened yet.
  • Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. Measured on the older local base with the switch off and on, 3 interleaved sessions each; not main against this PR at named commits, and not the stack as pushed (see Measurements).
  • No game files, dumps, build output or generated files.
  • third_party/ untouched (a new folder with a PROVENANCE.md instead).
  • The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled.

If applicable

  • AI assistance recorded with Assisted-by: / Co-Authored-By: trailers.
  • Flags noted, and the switch reference updated (docs/development.md, the README).
  • Screenshots for a change that shows on screen. The new menu row has not been run yet.
  • A file in docs/tested/ for each new measured run (format in docs/tested-configurations.md; no appended row or assembled view). Not added yet.
  • A check under 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, new tests/test_threaded_rendering.py); not run here.
  • Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Not built as pushed; Linux built on the older base only. CI builds both.
  • Docs updated.

πŸ€– Generated with Claude Code

https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn

## 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** (`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). - **renderer** (`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 and `RX_VK_HOLD_MS`; occlusion queries stay on. - **fix: whole frames** (`gate_ready`, `gate_need_value`, `gate_on` and the wait in `present()` in `gpu/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. - **setting** (`gpu/vk/threaded_rendering.h`, `menu_threaded_rendering()` in `gpu/vk/vk_menu.cpp`, `render_thread_mode()` in `gpu/vk/vk_replay.cpp`, `rec::g_setting_thread` in `gpu/vk/vk_recorder.h`, `HOS_OPT_RENDER_THREAD` in `gpu/vk/vk_api.h`, the present poll in `host/nvn_native.c`): the Insert menu's Threaded rendering row and `threaded_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` (`false` in this commit). Read at start; a change applies at the next start. - **one mode** (`gpu/vk/vk_replay.cpp`, `host/nvn_native.c`, docs, tests): the older replay thread that `RX_VK_ASYNC=1` meant is removed (`behind_on()`, `drain_waiters`, its branches in `occ_queries_on`, `enqueue`, `wait_queue_empty`, `event_signal_queued`, `replay_thread_main`); the render thread is `RX_VK_ASYNC=1` (any value but 0). - **Auto on** (`threaded::kAutoEnabled = true` in `gpu/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 | Flag | What it does | Default now | Recommendation | Helps on | Doesn't help on | Untested on | |---|---|---|---|---|---|---| | Threaded rendering (menu row, `threaded_rendering=`) | Auto / On / Off: the render thread and the recorder thread | Auto: On from 12 logical processors the process may run on where the mode is safe, Off elsewhere (`threaded::kAutoEnabled = true`) | Auto; Off if a flash, a missing object or a device loss shows | 12+ logical processors where the replay is the slower side (measured: Market, this PC) | Where the game's own job chain is the limit (running: about 4 %) | AMD, Intel, Windows, Steam Deck, under 12 logical processors | | `RX_VK_ASYNC=1` / `=0` | Test override of the row's render-thread half (set, it wins over the row) | unset | test only | - | - | - | | `RX_VK_FRAME_GATE=0` | A/B: the render thread replays jobs as they come, as before the whole-frames fix (it flashes) | unset (whole frames on) | test only | - | - | - | | `RX_VK_FRAME_GATE_MS=<ms>` | How long a job with no present behind it waits before it goes on (1000) | 1000 | leave | - | - | - | | `RX_VK_PRESENT_AFTER_SIGNAL=0/1` | A/B: the present's half of whole frames alone | unset (follows the gate) | test only | - | - | - | | `RX_VK_PRESENTS_AHEAD=<1-8>` | How many presents the game may be ahead of the render thread | 1 | leave | - | - | - | | `RX_VK_READBACK_EXACT=1/2` | Test: readbacks made after everything handed over is replayed (keeps the game in step) | unset | test only | - | - | - | | `HOS_PRESENT_NOWAIT=0/1` | Test: the present poll as before / answered at once whatever the mode | unset (follows the render thread) | test only | - | - | - | All 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 in `gpu/vk/vk_recorder.h`, `gpu/vk/vk_replay.cpp` and `docs/development.md`, done by reading. The numbers compare the render thread off and on in one build, by its switch (`RX_VK_ASYNC=2` there; `=1` after this PR's last commit). main: 5d3ecc78 (2026-11-14) this PR: perf/async-replay @ 4f1de649 measured 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=20020` on all; frame times from `RX_VK_FRAME_LOG` between 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_ASYNC` unset. 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): | | local @ 9342d302, `RX_VK_ASYNC` unset | local @ 9342d302, `RX_VK_ASYNC=2` (this PR's render thread, whole frames) | local @ 9342d302, `RX_VK_ASYNC=2 RX_VK_FRAME_GATE=0` | |---|---|---|---| | Market standing | 13.58 / 14.05 / 13.79 | 12.38 / 12.15 / 14.41 | 11.71 / 12.86 / 12.64 | | Market turning | 14.18 / 13.46 / 13.96 | 13.09 / 13.23 / 13.54 | 12.89 / 12.39 / 13.94 | | Market running | 15.65 / 16.13 / 15.62 | 14.90 / 15.46 / 15.24 | 15.03 / 14.84 / 15.19 | Frame time, p99 ms: | | local @ 9342d302, `RX_VK_ASYNC` unset | local @ 9342d302, `RX_VK_ASYNC=2` | local @ 9342d302, `RX_VK_ASYNC=2 RX_VK_FRAME_GATE=0` | |---|---|---|---| | Market standing | 21.2 / 22.5 / 23.0 | 18.5 / 19.9 / 21.1 | 17.9 / 19.4 / 19.1 | | Market turning | 23.2 / 22.9 / 22.5 | 23.8 / 21.0 / 23.4 | 18.3 / 18.1 / 30.6 | | Market running | 26.6 / 28.1 / 26.2 | 24.6 / 26.8 / 28.0 | 26.4 / 27.7 / 26.1 | Means 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. | scene | arm | sessions | presents | flashes | |---|---|---|---|---| | Market | render thread, `RX_VK_FRAME_GATE=0`, meter log | 3 + 2 | 14,198 + 8,903 | 33 + 7 | | Market | render thread, whole frames, meter log | 5 + 3 | 23,223 + 13,208 | 0 | | Kokiri Forest | render thread, `RX_VK_FRAME_GATE=0`, meter log | 2 | 13,866 | 2 | | Kokiri Forest | render thread, whole frames, meter log | 3 | 17,408 | 0 | | Kokiri Forest | render thread, whole frames, no meter log | 5 | 30,109 | 0 | | Market | render thread, whole frames, no meter log, escape after 100 ms | 5 | 19,763 | 2 | | Market | replay on the submitting thread (earlier, same detector) | 4 | 29,760 | 0 | | Market | render thread before the fix, no meter log (earlier) | 4 | 30,484 | 6 | The 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. ``` vk: the render thread's whole frames (600 presents): 0 jobs went on before their present after 1000 ms, 0 times a game's thread waited for the work handed over, 0 times for a fence of the frame in the making vk: presents after the CPU's signal (600): 588 waited for it, 7.84 ms each, 0 let go after RX_VK_FRAME_GATE_MS vk: the render thread (600 presents, 19.22 ms a frame): its jobs 19.17 ms a frame (submits 1.11, presents 9.82, others 8.24), idle 0.05; ... ``` ## Testing **Tested:** - **Nothing on the stack as pushed: it has not been compiled or run** (rebased onto main 5d3ecc78, with the setting and the old-mode removal merged onto main by hand). - On the local base 318acabe, the first three commits (RTX 3090, NVIDIA 620.70.06, CachyOS): the sessions under Measurements; the flash counts above in the Market and Kokiri Forest; the render thread's event order (0 stopped queues forced in play), no device loss in any session. - What the flash is **not** (each measured on the local build): the exposure meter and the other small readbacks the game makes (every read carried one frame's write-back, consecutive frames, at every flash); occlusion counts (`RX_VK_OCC_QUERIES=0` still 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:** - **Which read of the game's state goes wrong when the replay overlaps the game's writing of the same frame is not identified.** The fix closes the window (the console's order: there the presenting thread cannot get past its poll before the main thread's signal), it does not make that read safe. A game frame that stalls for over 1 s with jobs queued and no present can still be started half written, and can flash. - `python -I -B tests/run_all.py` was 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. - The Threaded rendering row and the old-mode removal have never been run in the game, on any base: not Auto (that it turns both threads on from 12 logical processors and leaves them off below, and off where the mode is not safe), not On (render thread and recorder thread together, which is also not measured for speed), not Off. They compiled and linked on the local base only. - Input-to-picture delay is not measured. By construction a frame is shown no later than with the replay on the submitting thread (game, then replay); a present is made 24 to 36 ms after its hand-over when the replay is the slower side (the detector's sessions). - GPU-written tables over 16 KiB that the game reads back without waiting (Hyrule Field's culling tables) can be a frame older with the render thread; whether that is what the console gives is not checked. Hyrule Field was not run. - Any GPU but the RTX 3090; AMD, Intel; Windows; Steam Deck; a CPU with fewer than 12 logical processors; 8 GB cards (the mode needs every pool the frame binds mirrored). - The Windows build of the host and the renderer (CI). **Needs other testers:** - AMD (RADV and Windows), Intel: Threaded rendering On in the Market and Kokiri Forest, expecting no one-frame black flash, no device loss and a lower frame time than Off where the replay is the slower side (request to open) - Windows, NVIDIA: the same (request to open) - Steam Deck and CPUs under 12 logical processors: On against Off, expecting On not to be slower; it decides Auto's threshold (request to open) - Hyrule Field, any GPU: On, expecting no missing or late-culled draws (request to open) ## 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=1` mode 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, or `RX_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.cpp` and `gate_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 - [x] One topic. - [x] The title is a Conventional Commit (`type(scope): description`). - [x] Rebased on current `main`. - [ ] `python -I -B tests/run_all.py` run and passing (any failure that also fails on `main` is named above). Not run on this PC (no test suites on this machine); CI runs it. - [ ] Tested in the game, or what wasn't is named under Testing. The pushed stack was not built or run; what was run, on the older base, is under Testing. - [ ] Testing filled in: what was tested, what wasn't, and what needs other testers (with test requests). Filled in; the test requests are not opened yet. - [ ] Measured: main and this PR at named commits, on the same PC, scene and settings, at least 2 interleaved runs each, the median reported. Measured on the older local base with the switch off and on, 3 interleaved sessions each; not main against this PR at named commits, and not the stack as pushed (see Measurements). - [x] No game files, dumps, build output or generated files. - [x] `third_party/` untouched (a new folder with a `PROVENANCE.md` instead). - [x] The license is OK: GPL-3.0-or-later, nothing incompatible and nothing decompiled. ### If applicable - [x] AI assistance recorded with `Assisted-by:` / `Co-Authored-By:` trailers. - [x] Flags noted, and the switch reference updated (docs/development.md, the README). - [ ] Screenshots for a change that shows on screen. The new menu row has not been run yet. - [ ] A file in `docs/tested/` for each new measured run (format in `docs/tested-configurations.md`; no appended row or assembled view). Not added yet. - [ ] A check under `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`, new `tests/test_threaded_rendering.py`); not run here. - [ ] Host code builds on both Windows (llvm-mingw) and Linux, with UTF-8 paths. Not built as pushed; Linux built on the older base only. CI builds both. - [x] Docs updated. πŸ€– Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn
OoT's presenting thread polls its frame's NVN event before it presents (0x80590500: while (nvnEventGetValue(e) != 1)
SleepThread), and the GPU signals that event at the end of the frame's work: with the replay on a thread of its own
the game slept there until the frame it had just handed over was recorded and run (19.96 of a 36.97 ms frame with
RX_VK_ASYNC=1, profiled). With RX_VK_ASYNC=2 (the render thread, the commit before) the two poll sites are answered 1,
as HOS_PRESENT_NOWAIT=1 already did as an experiment: the presenting thread goes on to its present, where the render
thread holds it while the present before is not replayed, and holds the present itself for the main thread's signal.
HOS_PRESENT_NOWAIT=0 with RX_VK_ASYNC=2 keeps the poll.

Answering the frame's release from the present's hand-over instead of the GPU's write is the idea of Hookshot PR 2910
(rx_release_due, by mimo2 and bake-spark-throwaway): https://git.notrexed.dev/Hookshot/Hookshot/pulls/2910. Here the
bound is the present's back-pressure, not a timer.

Not built and not run: a host change needs a game build. Every session of the commit before set HOS_PRESENT_NOWAIT=1,
which takes the same branch.

Co-authored-by: mimo2 <[email protected]>
Co-authored-by: bake-spark-throwaway <[email protected]>
Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn
Why: the replay on the submitting thread puts 8-13 ms of work where the console has a GPFIFO hand-over, in series with
the game's own frame. RX_VK_ASYNC=1 moved it to a thread but the game still waited for it at every frame, and was
slower (Market walk 22.5 ms against 14.4, RTX 3090, Ryzen 9 7900X, Linux, NVIDIA 620.70): its presenting thread polls
the frame's event, which the GPU signals at the frame's end, before it presents (19.96 of a 36.97 ms frame asleep at
lr 0x80590520, HOS_CHAIN, profiled); its readbacks caught the whole queue up (wait_queue_empty: ~3.7 a frame, 7.5-11.3 ms
each, synchronous 0.06-0.11) and forced the stopped queues past their event waits (29,789 of 60,000 submissions; the
synchronous replay 0 of 90,000: the flashes); and occlusion queries were off (2,006 draws a frame for 1,709).

RX_VK_ASYNC=2 is that thread with the game let run one frame ahead of it:
- a readback waits for the write-backs recorded so far, by part, never for the queue (invalidate): what has landed,
  as on the console. The read-and-reset meters stay coherent: queued work takes their pages from guest memory when
  the GPU reaches it (memory_rb_refresh). RX_VK_READBACK_EXACT=1/2 (a test) reads after everything handed over;
- nothing forces a stopped queue but its present and RX_VK_HOLD_MS: a queue finish or a texture read back waits for
  the jobs handed over before it (wait_queue_empty), a release is kept until the stopped work has gone on;
- a present that arrives while a queue is stopped at a wait for an event the CPU signals is held for that signal's
  job (OoT submits its presentation queue's work before the frame's data is all written and signals when it is; the
  present came first about every other frame and forced the whole frame: 44,259 waits gone past at 3,380 presents in
  the first prototype), and the capture for that work is taken on the game's thread at the nvnEventSignal that meets
  a wait of a submission handed over (the waits are read from the command buffers at the submit, 0.04 ms a frame);
- a readback next to a live texture's storage in its 4 KiB page, not in it, needs no catch-up (tex_live, by byte:
  4.4 readbacks a frame in the Market sat next to one; 0 are in one);
- occlusion queries stay on: a report reads "visible" until its count has landed;
- one present ahead (RX_VK_PRESENTS_AHEAD, 1 here).
The game's present poll is answered by the host (the next commit); until a game is built with it,
HOS_PRESENT_NOWAIT=1 is the same path. With the switch off nothing changes: every new branch is behind behind_on().

Measured (Castle Town Market, 2048x1152, development pack over the local merge 318acabe, RX_VK_PARALLEL=0, both sides
with HOS_RESERVE_CORE_MASK=20020, three alternating sessions a side, frame time median in ms):
  standing      synchronous 14.12 12.69 13.13    render thread 11.57 12.04 11.72   (72-77 fps -> 79-85)
  walking 60 s  synchronous 14.40 14.67 (third session void: a 6.8 s stall, off the route)   render thread 14.43 14.56 14.14
  walk p99      24.5 24.7   against 25.4 24.7 22.4; draws 1577 1712 against 1754 1721 1612
Order: 83,983-83,998 of 84,000 stopped submissions went on when their event was reached, 0-15 gone past. Readbacks
that wait: 0.05-0.13 ms. No device loss. Standing, the frame is the render thread's (its jobs 11.7 ms, idle 0.05: the
replay 8.85 ms and the present 2.6); walking, it is the game's own job chain (HOS_CHAIN, profiled: 14.06 of 17.02 ms
running on the main thread, the six BaseProcExecutor threads and the job workers; the presenting thread 0.45 ms), of
which ~13 % is the captures on the main thread. So the walk does not get faster here; what would is in the design
note (the capture read exactly, the present off the render thread).

Kokiri Forest (the full route, two sessions a side, pack as7): standing 14.79 15.93 -> 13.74 13.73, camera turning
13.86 (the other void) -> 12.63 12.48; the Market walk of those sessions 16.71 16.31 -> 15.01 16.00.

NOT CORRECT YET, and why this must stay off: the flash detector (RX_VK_FLASH=1, short Market sessions) finds one-frame
changes of the whole picture with the render thread, 4 and 7 in two sessions (and 7 with HOS_DYNRES=0, 1 with the
game's poll kept), the first as Link starts to move; the synchronous replay 0 in 5,520 presents. The frame differs
from both neighbours along every edge, at the same brightness. Likely cause, from the code, not proven by a run: a
replay's read of a page no kept capture holds (25 captures, ~4 frames) is taken from live guest memory
(memory_read_as_submitted, the mirror syncs' "from guest memory as it was at the replay": ~7 pages a frame in both
modes), which is the page as submitted only while the replay runs at the submit. The fix is in the design note: a
page's newest captured copy stays readable until the render thread has taken a newer one. Also open: a large
GPU-written table (over 16 KiB: Hyrule Field's culling tables) read without a wait can be a frame older than with
the synchronous replay; whether that is what the console gives is a TODO, and this must not become a default until
it is closed. RX_VK_READBACK_EXACT=1 did not reach the game in its one session (not looked into).
Not run: Hyrule Field, Windows, AMD, a Steam Deck, frame generation, the parallel replay under this thread. The
checks were not run locally (CI runs them); tests/test_readback_part.py's fixture and
tests/test_vk_occlusion_queries.py follow the change. Screenshots of both modes at the same phases show the same
picture.

Upstream's work on the same question, read for this: https://git.notrexed.dev/Hookshot/Hookshot/pulls/2910

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn
With RX_VK_ASYNC=2 the lit scene was drawn black for one frame, 0.2 times per 1,000 presents in the Market (2-3 per
1,000 with a slower replay). Every one sat at a present where the render thread had caught up with the game and was
replaying the frame's first submits while the game was still writing the rest of that frame.

A frame's jobs now start when the frame's present has been handed over (gate_ready), and the present is handed over
after the main thread's signal for the frame (present(): the signal the frame's work waits for; the poll answered at
once let the presenting thread come 7-8 ms before it at ~95 % of presents). What a game thread waits for goes on at
once: the work handed over before a queue finish or a texture read back (wait_queue_empty), the submit of a fence of
the frame in the making (gate_need_value); and a job with no present behind it after RX_VK_FRAME_GATE_MS (1000).
RX_VK_FRAME_GATE=0 and RX_VK_PRESENT_AFTER_SIGNAL=0/1 are test switches.

Which read of the game's state goes wrong in that window is not identified: not the render conditions, the exposure
meter, the occlusion counts, the fences or the mirror syncs (each measured).

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn
Players get one setting for the two threads, in the Insert menu (Graphics, GPU and memory) and in
longshot_options.ini (threaded_rendering=), built as V-Sync's Auto / On / Off is:

- Off: the replay on the game's submitting thread, the recorder as RX_VK_RECORDER chooses it, as before.
- On: the render thread (exactly what RX_VK_ASYNC=2 gives) and the recorder thread (RX_VK_RECORDER=thread) together.
- Auto (the default): the rule is in place (gpu/vk/threaded_rendering.h): on where the process may run on 12 or more
  logical processors (its affinity mask, usable_cpus), off with fewer, and off where the mode is not safe
  (RX_VK_CAPTURE=0, RX_VK_LOW_SPEC, RX_VK_DUMP, RX_VK_BENCH, buffer pools with no mirrors).

No default changes: all of Auto is behind one constant, threaded::kAutoEnabled, which is false, so Auto resolves to
Off on every PC. Turning it to true is what flips Auto on, once the render thread's flash fix is proven and the
culling-table TODO is closed. Not in Auto's rule yet: a pool refused its mirror, or over the mirrors' budget, while
the game runs (it needs the run-time fallback, which is not written).

The row is read once, at the start (menu_threaded_rendering; a change applies after a restart, as the row says).
RX_VK_ASYNC and RX_VK_RECORDER stay overrides for testing: set, each wins for its half; unset, the row decides.
RX_VK_ASYNC is now read in one place (vk_replay.cpp render_thread_mode), which async_on and behind_on both take; the
recorder asks the row through rec::g_setting_thread when RX_VK_RECORDER is unset; and the host's present poll
(host/nvn_native.c) asks the renderer through the existing option callback (HOS_OPT_RENDER_THREAD, 58) instead of
reading RX_VK_ASYNC itself, so the row's On answers the poll as RX_VK_ASYNC=2 does. MkwVkHost and MkwVkRenderer are
unchanged (a new option id only), so MKWVK_API_VERSION stays.

The row's texts are not translated yet (they show in English in the other languages). Nothing here was built or run.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn
refactor(vk): one replay thread: the render thread is RX_VK_ASYNC=1, the older mode is removed
Some checks are pending
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
beanstalk/approval awaiting maintainer approval
PR description / Description follows the template (pull_request_target) Successful in 16s
CI / Conventional Commit title (pull_request) Successful in 14s
CI / Workflow lint (actionlint) (pull_request) Successful in 20s
Third-party parts / As their notes say (pull_request) Successful in 29s
8825433337
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_017vw8e34avQ5cTCWow16MXn
feat(menu): Threaded rendering's Auto is on from 12 logical processors
Some checks failed
CI / Python source checks (ubuntu-24.04, part 1/2) (pull_request) Has been skipped
CI / Python source checks (ubuntu-24.04, part 2/2) (pull_request) Has been skipped
CI / SPIR-V fixtures (global narrow load/store, direct surface atomics, F2I immediate) (pull_request) Has been skipped
Third-party parts / The same as upstream (pull_request) Has been skipped
CI / Conventional Commit title (pull_request) Successful in 4s
CI / Workflow lint (actionlint) (pull_request) Successful in 6s
Third-party parts / As their notes say (pull_request) Successful in 9s
beanstalk/approval awaiting maintainer approval
PR description / Description follows the template (pull_request_target) Failing after 8s
4f1de6494d
threaded::kAutoEnabled is true: Auto, the row's default, now follows its rule,
the render thread and the recorder thread where the process may run on 12 or
more logical processors and the mode is safe, Off elsewhere. Its own commit, so
that reverting it alone makes Auto Off on every PC again.

Still open with it on: GPU-written tables over 16 KiB that the game reads back
without waiting (Hyrule Field's culling tables) can be a frame old behind the
game; not run on AMD, Intel, Windows or the Steam Deck.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
Claude-Session: https://claude.ai/code/session_017vw8e34avQ5cTCWow16MXn
HumanBean changed title from WIP: 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) 2026-11-14 21:34:46 +00:00
Author
Owner

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.

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.
HumanBean changed title from 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) 2026-11-14 21:36:37 +00:00
HumanBean deleted branch perf/async-replay 2026-11-14 21:36:44 +00:00
Contributor

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 processors in the log), RX 9070 XT 16 GB, Mesa RADV 26.2.7, Fedora 45 (Linux 7.3.3), 31 GB. Built main 6c9edffe (with this PR) as a player's (the Linux container, then the AppImage's --cli --fsr); Off against Auto by the options file's threaded_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:

Off: main @ 6c9edffe Auto (on): main @ 6c9edffe
gate: fps / 1% low 40.1 / 28.6 47.0 / 32.6
laps: fps / 1% low 34.0 / 19.4 36.7 / 18.0
laps turning the camera: fps / 1% low 38.9 / 19.6 40.6 / 21.6
fountain: fps / 1% low 38.7 / 24.3 40.6 / 26.1
fountain turning the camera: fps / 1% low 36.6 / 24.8 37.9 / 25.6

The 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.

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 processors` in the log), RX 9070 XT 16 GB, Mesa RADV 26.2.7, Fedora 45 (Linux 7.3.3), 31 GB. Built main 6c9edffe (with this PR) as a player's (the Linux container, then the AppImage's `--cli --fsr`); Off against Auto by the options file's `threaded_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`: | | Off: main @ 6c9edffe | Auto (on): main @ 6c9edffe | |---|---|---| | gate: fps / 1% low | 40.1 / 28.6 | 47.0 / 32.6 | | laps: fps / 1% low | 34.0 / 19.4 | 36.7 / 18.0 | | laps turning the camera: fps / 1% low | 38.9 / 19.6 | 40.6 / 21.6 | | fountain: fps / 1% low | 38.7 / 24.3 | 40.6 / 26.1 | | fountain turning the camera: fps / 1% low | 36.6 / 24.8 | 37.9 / 25.6 | The 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.
Contributor

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")

  • Deck: Steam Deck OLED: AMD Custom APU 0932 (4 cores, 8 threads), RADV VANGOGH, Mesa 26.2.0-devel, 16 GB shared with the GPU; SteamOS 3.9.5, kernel 7.2.7-valve2. GPU clock fixed at 1600 MHz.
  • Build: main 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).
  • Settings: no screen (headless), handheld profile, Minimum preset (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.
  • Scenes: two saves, standing still: a heavy view (about 2,150 draws a frame, where the replay limits the frame) and Hyrule Field (about 1,600 draws, where the GPU and the CPU's wait for it do).
  • Method: each run starts the game, loads the save by script, waits 3 log windows, then measures 720 presents by the clock. One run with 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).
Off (unset) RX_VK_ASYNC=1
heavy view, fps 30.27 / 29.97 20.65 (-31%)
heavy view, frame 33.9 / 34.2 ms (sd 0.3 to 1.3) 49.4 ms (sd 7.35)
Hyrule Field, fps 25.23 21.97 (-13%)
Hyrule Field, frame 40.6 ms (sd 0.33) 46.4 ms (sd 3.16)
off, heavy view       Threaded rendering: Auto: off (not safe on this run: RX_VK_LOW_SPEC)
on, heavy view        vk: the render thread (600 presents, 51.48 ms a frame): its jobs 28.85 ms a frame (submits 24.33, presents 0.17, others 4.35), idle 22.63; the game waited for room at 0 presents, 0.00 ms each (0.00 a frame; 203.97 jobs queued at a present)
on, heavy view        vk: presents after the CPU's signal (600): 58 waited for it, 87.69 ms each, 0 let go after RX_VK_FRAME_GATE_MS
on, heavy view        vk: the render thread's whole frames (600 presents): 5 jobs went on before their present after 1000 ms, 1 times a game's thread waited for the work handed over, 0 times for a fence of the frame in the making
on, Hyrule Field      vk: the render thread (600 presents, 45.50 ms a frame): its jobs 23.37 ms a frame (submits 19.82, presents 0.13, others 3.42), idle 22.14; the game waited for room at 0 presents, 0.00 ms each (0.00 a frame; 194.19 jobs queued at a present)
on, Hyrule Field      vk: presents after the CPU's signal (600): 78 waited for it, 1.36 ms each, 0 let go after RX_VK_FRAME_GATE_MS

Reading. 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=thread alone 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.

**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") - **Deck:** Steam Deck OLED: AMD Custom APU 0932 (4 cores, 8 threads), RADV VANGOGH, Mesa 26.2.0-devel, 16 GB shared with the GPU; SteamOS 3.9.5, kernel 7.2.7-valve2. GPU clock fixed at 1600 MHz. - **Build:** main 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). - **Settings:** no screen (headless), handheld profile, Minimum preset (`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. - **Scenes:** two saves, standing still: a heavy view (about 2,150 draws a frame, where the replay limits the frame) and Hyrule Field (about 1,600 draws, where the GPU and the CPU's wait for it do). - **Method:** each run starts the game, loads the save by script, waits 3 log windows, then measures 720 presents by the clock. One run with `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). | | Off (unset) | `RX_VK_ASYNC=1` | |---|---|---| | heavy view, fps | 30.27 / 29.97 | 20.65 (-31%) | | heavy view, frame | 33.9 / 34.2 ms (sd 0.3 to 1.3) | 49.4 ms (sd 7.35) | | Hyrule Field, fps | 25.23 | 21.97 (-13%) | | Hyrule Field, frame | 40.6 ms (sd 0.33) | 46.4 ms (sd 3.16) | ``` off, heavy view Threaded rendering: Auto: off (not safe on this run: RX_VK_LOW_SPEC) on, heavy view vk: the render thread (600 presents, 51.48 ms a frame): its jobs 28.85 ms a frame (submits 24.33, presents 0.17, others 4.35), idle 22.63; the game waited for room at 0 presents, 0.00 ms each (0.00 a frame; 203.97 jobs queued at a present) on, heavy view vk: presents after the CPU's signal (600): 58 waited for it, 87.69 ms each, 0 let go after RX_VK_FRAME_GATE_MS on, heavy view vk: the render thread's whole frames (600 presents): 5 jobs went on before their present after 1000 ms, 1 times a game's thread waited for the work handed over, 0 times for a fence of the frame in the making on, Hyrule Field vk: the render thread (600 presents, 45.50 ms a frame): its jobs 23.37 ms a frame (submits 19.82, presents 0.13, others 3.42), idle 22.14; the game waited for room at 0 presents, 0.00 ms each (0.00 a frame; 194.19 jobs queued at a present) on, Hyrule Field vk: presents after the CPU's signal (600): 78 waited for it, 1.36 ms each, 0 let go after RX_VK_FRAME_GATE_MS ``` **Reading.** 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=thread` alone 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.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
MagicBeanDev/Longshot!588
No description provided.