Skip to content

perf(vk): with frame generation, the replay no longer waits behind each present, and a capture waits for less of the frame before - #2633

Draft
hckrman101 wants to merge 3 commits into
NotRexed:mainfrom
hckrman101:perf/framegen-present
Draft

hckrman101 wants to merge 3 commits into
NotRexed:mainfrom
hckrman101:perf/framegen-present

Conversation

@hckrman101

@hckrman101 hckrman101 commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

Draft, not yet run in the game. With frame generation on, the game's replay spends several milliseconds of every frame waiting on things that are not its own work: a lock the display thread holds while the driver waits inside each present, and a wait for the whole of the frame before's GPU work. This removes the first and narrows the second.

Measured on main with DLSS, frame interpolation 4x and NVIDIA Reflex on, cap 120, Hyrule Field (RTX 3090, Ryzen 9 7900X, Linux, NVIDIA 620.70.06): the replay's submit is 11.5 ms a frame against 6.8 ms with default options in the same place. 3.8 ms of the difference is the lock, 2.3 ms the capture's wait.

1. A present on the replay's queue waits for its copy with the queue's lock let go

143b5e54

  • NVIDIA's driver on Linux waits inside vkQueuePresentKHR, on the CPU, until the frame's copy into the window is done on the GPU.
  • A present made on the replay's queue held dev.present_mu through that wait. Every submit of the replay takes the same lock.
  • With frame interpolation the display thread presents four times a game frame. In an all-thread sample the game's presentation thread was in submit_recording's wait for the lock at 17.2 % of all instants, while the display thread was inside present_out, in the driver's wait, at 34.5 %.
  • Now present_out waits for the copy's fence first, with the lock let go, and takes it again for the present itself. The present goes out when the driver would have let it: it waited for the same copy.
  • It covers frame generation's presents (both the paced ones and present-ahead's) and the game's own present.
  • Switch: RX_VK_PRESENT_COPY_WAIT=0 / =1. On by default only with NVIDIA's driver on Linux, where the wait was seen. Off on Windows, where a note in the code says the same idea was tried and was worse, and off for other Linux drivers, which were not seen to wait for the copy in the present.

2. A capture's late pages wait for the part that writes them back

d533cd18

  • A capture finds pages the CPU wrote that work still on the GPU writes back (OoT's exposure meter page), and waits for that work before the replay uses the capture.
  • The wait was for the page's submission value, which is done only with that submission's last part: all of the frame before's GPU work.
  • With frame generation the game runs ahead of the GPU, so this wait happens every frame: 97 of 600 takes, 2.4 ms each. With default options it is 0 of 600, because the present has already waited for the frame.
  • It also stalls. The same run has 7 "slow submit" lines whose whole time is this wait (16 to 240 ms, the limit is 250 ms); a run with RX_VK_CAPTURE_WAIT=0 has none.
  • Now a page whose part is already done on the GPU is not late at all, and the others wait for the newest of their parts, using the page-to-part table perf(vk): a readback waits for the part that writes its range back, and no game thread waits behind a present for nothing #2602 added for readbacks. A late page that was written in place (no mirror) keeps the wait for the submission's value, for the whole capture.
  • Switch: RX_VK_CAPTURE_PART=0 restores the old wait.
  • The 600-take log line now also counts the takes that waited for a whole submission and the pages a part had already written back. RX_VK_LOG_CAPTURE_LATE=1 logs each late wait.

What change 2 does and does not do (measured on the #2622 stack, which makes the game run ahead in the same way; default options, Hyrule Field and Kokiri Forest, RX_VK_LOG_CAPTURE_LATE=1):

  • It reduces the pages waited for: about 125 pages a frame that used to count as late are now taken without a wait.
  • The exposure page's wait stays. One take a frame, on the game's main thread, still waits for one page, 1.4-1.6 ms on average, because that page's part is the last part of the frame before. The stalls in that wait (16 ms and more, 5-8 a 30 s run) are still there.
  • So this commit alone will not remove the 2.4 ms a frame or the stalls described above. Removing them needs the next frame's submits held until that last part is done, instead of a wait at the capture. That is a separate change, in progress on perf(vk): the next frame's CPU work overlaps the GPU's end of the current frame (presenter thread, fixed and on by default on Linux) #2622.

Tests

  • New tests/test_present_copy_wait.py: both callers give present_out their copy's fence and take it away; a failed submit gives none; the lock is let go for the wait and taken again before the present; the wait has a limit and sees a lost device; the default per platform and vendor; the presenter thread's hand-off is unchanged.
  • New tests/test_capture_part.py: the choice of a wait, cut from the source and run (a page whose part is done; the newest part; a page in place; a part not known; the switch), and the order in capture_make and take_capture.
  • tests/test_present_semaphores.py updated for the changed function.
  • tests/test_present_device_loss.py now covers the new fence wait (ten sites).
  • The whole suite on Linux (python -I -B tests/run_all.py): every failure it has is also a failure on main itself at 7297d93d, checked by running each one there: test_readback_part, test_dlss_checks, test_framegen_preset, test_hdr_encode, test_audio_biquad, test_audio_aux, test_mods_size_table, test_rumble_menu, test_mouse_native, test_linux_docker_packages, test_wayland_libdecor_hint, test_display_startup_failure, test_texel_buffer_ownership, and three known on this PC (test_bake_portability, test_builder_log_read_only, test_env_on).
  • Build: hookshot_vk.so builds on main at a016b7a6 with both commits and passes the ABI check.

Not yet tested in the game

Nothing here has been run with frame generation yet. For testers with frame generation on (any multiplier, DLSS-G or the renderer's own), NVIDIA on Linux first:

  • fps of the game's own frames and of the shown frames, against the same build with RX_VK_PRESENT_COPY_WAIT=0 RX_VK_CAPTURE_PART=0, same place, same settings.
  • Hitches: frames over 16.7 ms, 20 ms and 33 ms, and the log's slow submit lines (how many, and how many say memory sync).
  • Frame pacing of the shown frames (even spacing, no judder), with and without frame pacing, V-Sync Auto and On.
  • Flashes or flicker, above all exposure steps (the picture brightening or darkening for one frame) in Kokiri Forest and Hyrule Field.
  • Input feel with Reflex on.
  • The log lines vk: presents on the replay's queue ... and vk: a capture taken (ms): ... (the late pages' numbers).
  • On Windows and on AMD or Intel the first change is off; a run with RX_VK_PRESENT_COPY_WAIT=1 there would say whether it helps.

hckrman101 and others added 3 commits November 11, 2026 22:34
… queue's lock let go

NVIDIA's driver on Linux waits inside vkQueuePresentKHR until the frame's
copy into the window is done on the GPU. A present made on the replay's
queue held dev.present_mu through that wait, and every submit of the
replay waited behind it. With frame interpolation the display thread
presents four times a game frame: the game's presentation thread was in
submit_recording's wait for the lock at 17 % of all instants, 3.8 ms a
frame (Hyrule Field, DLSS + interpolation 4x + Reflex, RTX 3090).

present_out now waits for the copy's fence first, with the lock let go,
and takes it again for the present. This covers frame generation's
presents and the game's own. The present goes out when the driver would
have let it: it waited for the same copy. On by default only with
NVIDIA's driver on Linux, where the wait was seen;
RX_VK_PRESENT_COPY_WAIT=0 / 1 over it.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
A capture finds pages the CPU wrote that work still on the GPU writes
back (OoT's exposure meter page) and waits for them before the replay
uses it. The wait was for the page's submission value, which is done
only with that submission's last part. With frame generation on the
game's threads run ahead of the GPU, so the wait happens every frame
(97 of 600 takes, 2.4 ms each, Hyrule Field with DLSS + interpolation 4x)
and at times runs for tens of ms up to its 250 ms limit, when the
submission waited for is stopped at an event that the waiting game
thread's own work would let on.

A page written back from a mirror is in guest memory once the part that
copies it back is done, and the page table keeps that part (wb_parts, as
the readbacks use it since NotRexed#2602). So a page whose part is done is not
late at all, and the others wait for the newest of their parts. A late
page written in place keeps the wait for the submission's value, for the
whole capture. RX_VK_CAPTURE_PART=0 restores the value always.

The 600-take log line now counts the takes that waited for a whole
submission and the pages a part had already written back;
RX_VK_LOG_CAPTURE_LATE=1 logs each late wait.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jabbaxipil

Copy link
Copy Markdown
Contributor

Tested in the game on NVIDIA Linux with frame generation, A/B against the same build with RX_VK_PRESENT_COPY_WAIT=0 RX_VK_CAPTURE_PART=0.

Setup: RTX 4070, NVIDIA 615.71.09, Ryzen 7 5800X3D, Ubuntu 26.04.1 (kernel 7.0), GNOME Wayland, 2560x1440 at 144 Hz. CI AppImage v2.1.0-rc.3-237-g6be69c79. DLSS on, Reflex on, frame pacing on, V-Sync Auto.

Log lines asked for: with the PR, vk: presents on the replay's queue wait for their copy first, the queue's lock let go; with both switches at 0, ...made with the queue's lock held. So change 1 engages on this machine.

2x, DLSS Frame Gen making every frame, uncapped, 60 s standing still, same spot and facing. Run order: PR on, PR off, PR on.

PR on (3rd run) PR off PR on (1st run, cold)
Hyrule Field fps (avg / low) 58.0 / 49.6 57.8 / 55.3 52.8 / 51.6
Field late waits 78 of 36600 takes, 0 whole-submission 2291 of 25800, all whole-submission 536 of 29400, 0 whole
Kokiri fps (avg / low) 51.9 / 50.3 51.7 / 49.5 46.5 / 45.2
Kokiri late waits 2474 of 22800, 4.3 ms each, 0 whole 1656 of 25200, 4.2 ms each, all whole 2778 of 21000, 4.6 ms each, 0 whole

The first run was about 10% slower in both places, and the PR-on repeat matched PR off, so I read that as warm-up. Standing still, the fps is the same with and without the PR. Change 2 took whole-submission waits to 0 and removed almost all late waits in Hyrule Field. In Kokiri the count and the time per wait stayed about the same, which matches your note about the exposure page.

Free play, 2x uncapped (about 19 min PR off, about 14 min PR on; Kokiri, Hyrule Field, load zones):

PR off PR on
Game fps mean / median / 5th percentile 51.3 / 50.9 / 41.8 52.2 / 52.0 / 45.0
10 s windows with a frame over 33 ms 65 of 94 21 of 51
...over 100 ms 10 3
Late waits 2.9% of takes, all whole-submission 0.8% of takes, 1 whole-submission

The routes weren't identical, but every measure leaned slightly toward the PR. The PR-on run's one slow submit (387.5 ms, a texture upload, not memory sync) and its two windows over 250 ms came during a pipeline warm-up at start-up that the PR-off run didn't have.

Also seen: about one a capture's wait ... write-back timed out per run, early in the run, with the PR on and off (on: 0 to 2 a run, off: 1), each with an 80 to 176 ms sync wait.

No slow submit lines in any standing run. Shown-frame spacing was 90 to 99% within 7 to 12 ms with and without the PR. No flashes or exposure steps I could tie to the PR in Kokiri or Hyrule Field, and input with Reflex felt the same throughout.

4x, cap 144: the game held 36.0 fps with and without the PR, with no slow submit lines. 4x doesn't measure the PR on this machine: DLSS-G's runtime here makes at most 1 frame, so the renderer's own generator makes them, but V-Sync Auto still picks FIFO, which holds the game at 144 / 4. Opened as #2695.

Not tested: frame pacing off, V-Sync On, Windows, AMD/Intel.

@hckrman101

Copy link
Copy Markdown
Collaborator Author

Timing result with frame generation on: no gain in the first pair, so this stays a draft.

One pair, standing in Hyrule Field, RTX 3090 + Ryzen 9 7900X, Linux (NVIDIA, Wayland), 2560x1440 window, DLSS, frame generation 4x, Reflex on, cap 120. DLSS, 4x interpolation and Reflex were active in both logs:

main this PR
game fps (before interpolation) 69.1 63.2
  • Both sides still wait for late pages, 2.4-2.75 ms a frame, so the wait this PR removes behind the present is not what limits the frame here.
  • This is one pair in one order; the pair in the opposite order is still to run, and I will post it here. Until then, read it as "no gain shown", not as a measured loss.
  • My earlier estimate of about +20 % was wrong.

@Brett-D

Brett-D commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

Steam Deck data for part 2 (a capture's late pages wait for the part that writes them back). It probably doesn't change much on GPUs without mirrors.

Setup: Steam Deck OLED (Galileo, APU 0932, RADV, Game Mode, handheld profile, docked), main 6749ba21 plus the Deck patches, Kokiri Forest standing scene, 30 s frame logs per start.

  • Without mirrors, every late page comes from note_direct_write (part 0, written in place). Your late_page() sends those to the whole-submission wait (whole = true), so this PR leaves the Deck's waits as they are: 177-195 of 600 takes wait, ~3.5-4.5 ms each.
  • I also tried removing that wait entirely on the Deck: defer the late pages and copy them again at a later take, or when a read needs one (local branch, not a PR). The take waits went to 0 of 600, but FPS didn't move: about 29.0 with it against 28.9 without, over 6 and 3 starts. The frame is bound by a serial CPU chain there (HOS_CHAIN: the render thread with the inline replay, then the main thread), and the deferred pages are read again soon (40-70 reads per 600 takes wait ~13-15 ms each), so the wait just moves. p50 got worse (30-31 ms against 26-28), and one start hitched 1.07 s.

So on mirrorless iGPUs, the late-page wait isn't where the Deck's frame time goes. I won't open a PR for the deferral. Your part-based wait looks right for the mirrored case it targets.

sheepbun added a commit to sheepbun/Bunny-Hood that referenced this pull request Nov 12, 2026
…he capture's late-page wait

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PedroFlaminio

Copy link
Copy Markdown

Tested in the game on Linux + NVIDIA with frame interpolation on: no change in frame rate here.

Hardware: RTX 3060 12 GB, NVIDIA 615.71.09, Manjaro (kernel 6.12), Core Ultra 7 265KF (8 P-cores + 12 E-cores), Wayland. Built on main 51da0464: each PR merged onto it, only hookshot_vk.so built (container build, RX_MARCH=x86-64-v3 RX_RELEASE=1) and swapped into the same game folder; main's hookshot_vk.so built the same way as the baseline.
Settings: 3440x1440 monitor, picture 2576x1080 (Aspect ratio: monitor's), frame interpolation 2x, Reflex on, V-Sync off, no frame rate cap; otherwise unchanged between runs.
Scene: Hyrule Field from the same save, standing, no input, 3.5 to 7 minutes a run; only the 5 s windows after loading (3,370-3,760 draws in every run) are counted. One run each, in the order main, #2633, #2644, #2559.

renderer fps median (min-max) replay submit draw descriptors memory sync
main 42.7 (41.4-43.4) 21.4 ms 9.4 3.5 9.4
#2633 42.8 (40.6-43.8) 19.9 ms 7.6 2.7 9.4
#2644 42.6 (40.2-43.5) 21.2 ms 8.9 2.9 9.5
#2559 43.0 (42.3-43.8) 20.1 ms 7.7 2.7 9.6

(replay columns: medians of the last 20 a frame's replay time lines; GPU-bound wait was 0.0 ms in all runs)

  • The log confirms the switch was on: presents on the replay's queue wait for their copy first, the queue's lock let go.
  • Frame interpolation stayed even: 424 of 426 frames 9-12 ms apart (main: 426 of 426).
  • The lower draw time is within run-to-run noise: perf(vk): back-to-back passes and dispatches ordered by the game's barriers (NVIDIA by default) #2559, which changes only GPU barriers, shows the same drop. With 2x interpolation, the present lock does not seem to be what limits the frame on this machine; memory sync (~9.5 ms) is the largest part of the replay in every run.
  • No device lost in any run, and no visual problem seen while playing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants