Perf: steadier frame rate #1109

Merged
ShroomKing merged 3 commits from perf/steadier-frame-rate into main 2026-11-09 15:15:28 +00:00
ShroomKing commented 2026-11-09 11:41:41 +00:00 (Migrated from github.com)

Hookshot PR: steadier 60 fps

On current upstream main (v2.0.8-rc.1), this PR takes Hyrule Field at 3440x1440 with 4x frame generation from 47.7 to 59.6 fps standing and from 44.2 to 57.3 fps running around. Without frame generation, running around goes from 55.2 to 58.8 fps.

Results

Standing now sits at the game's 60 fps cap, and running around keeps about 98% of frames under 22 ms.

Scenario fps before fps after Frames over 22 ms, before Frames over 22 ms, after
3440x1440, 4x, standing 47.7 59.6 28% 0.8%
3440x1440, 4x, running 44.2 57.3 40% 2.0%
2560x1080, 4x, standing 50.8–53.1 59.3–59.6 13–19% 0.8–1.3%
2560x1080, 4x, running 45.5–46.9 57.1–57.9 33–36% 1.7–2.6%
2560x1080, no frame generation, running 55.2 58.7–58.9 7.3–7.6% 1.4–1.6%

GPU time fell from ~12.9 to ~9.4 ms a frame with 4x frame generation, and from 12.3 to 8.6 ms without it. Measured on upstream main 8b2e1d3 against the same commit plus this PR: i7-14700K, RTX 5070 Ti, 48GB DDR5-7600, Windows 11, 175 Hz monitor, windowed, Hyrule Field save, scripted 20 s standing then 28 s running. Ranges are separate runs.

Changes

Two renderer changes carry the gain. Switching each off in turn on upstream main shows its share.

  • Write-back diff (gpu/vk/vk_memory.cpp, new gpu/vk/shaders/wb_diff.comp). Each frame's last submission copied every written storage-buffer range back to guest memory whole: ~69 MB, 4.4 of ~13 ms of GPU time. Pools written back now keep a VRAM shadow of guest memory, and a compute pass writes only the 16-byte vectors that differ, a subgroup's span at a time (word-by-word stores to system memory ran at ~1 GB/s). Pages the CPU wrote since are re-read into the shadow first, so the pass only ever writes fewer words than the copy did. Off: RX_VK_WB_DIFF=0. Its share: GPU 13.1 → 9.2 ms a frame; running 53.9 → 57.9 fps with 4x frame generation and 55.2 → 58.7 fps without. Readback refreshes also copy adjacent pages as one region (~530 single-page copies a frame before).
  • Presents on a compute queue (vk_main.cpp, vk_replay.cpp, vk_internal.h, vk_framegen.cpp). vkQueuePresentKHR blocked with present_mu held, and the game's submits waited behind it. Presents now go on a compute-family queue with that lock released, the swapchain's images shared CONCURRENT by both families; a second graphics queue was tried first and blocked longer. The frame-generation display thread now waits on a high-resolution timer, as sleep_for overslept by up to 20 ms once presents stopped blocking. On by default with NVIDIA's driver on Windows; RX_VK_PRESENT_QUEUE=0 or 1 forces it. Its share with 4x frame generation: running 55.4 → 57.9 fps, frames over 22 ms 8.1% → 1.7%. Without frame generation it changes nothing.
  • libopus CPU flags (nx2/build.py). libopus is built with the same -march flags as the rest of the game, so its float code can use AVX2 where the PC has it.

Commits

The branch perf/steadier-frame-rate on ShroomKing/Hookshot is three commits on upstream main at 8b2e1d3 (v2.0.8-rc.1). The renderer commits' messages carry their measurements and switches.

  1. Renderer: presents on a compute-family queue of their own (NVIDIA on Windows by default)
  2. Renderer: write back only the words that changed (the write-back diff)
  3. Build: libopus for the same CPU target as the rest of the game

Testing

No exposure flashes turned up in frame logs on upstream main with this PR, with 4x frame generation or without it.

  • tests/run_all.py: 117 pass, 15 skipped. The 3 failures (test_fps_overlay_order, test_precompile_compute_guard, nx2/test_nup_paths) also fail on unmodified main on the same machine.
  • New tests/test_wb_diff_split.py: the diff's run splitting covers every byte once, in order, with vectors aligned and at most 64 KB. It fails on a split that drops a run's tail.
  • Frame logs (RX_VK_FRAMELOG, scripts/framelog.py) in Hyrule Field: with 4x frame generation, 0 flash events in 11,300 frames. Without it, no whole-picture brightness steps; the detector's 309 small local events are foreground grass sweeping past the camera at 60 fps.

Notes for reviewers

Measured on NVIDIA only (RTX 5070 Ti, driver 621.14, Windows 11). The write-back diff is on everywhere, with RX_VK_WB_DIFF=0 as the escape hatch; the compute-queue present is on by default only with NVIDIA's driver on Windows.

  • wb_diff_comp_spv.h comes from glslangValidator -V --target-env vulkan1.3 --vn WB_DIFF_COMP_SPV -o wb_diff_comp_spv.h wb_diff.comp.
  • VRAM: shadows take at most 1/8 of VRAM (RX_VK_WB_SHADOW_MB) and give way above 85–95% use. The town at night, with ~10 GB of textures, runs without them.
  • Small extras: a vk: write-back diff stats line every 600 submits and a vk: present failed log line.

🤖 Generated with Claude Code

# Hookshot PR: steadier 60 fps On current upstream main (v2.0.8-rc.1), this PR takes Hyrule Field at 3440x1440 with 4x frame generation from 47.7 to 59.6 fps standing and from 44.2 to 57.3 fps running around. Without frame generation, running around goes from 55.2 to 58.8 fps. ## Results Standing now sits at the game's 60 fps cap, and running around keeps about 98% of frames under 22 ms. | Scenario | fps before | fps after | Frames over 22 ms, before | Frames over 22 ms, after | | --- | --- | --- | --- | --- | | 3440x1440, 4x, standing | 47.7 | 59.6 | 28% | 0.8% | | 3440x1440, 4x, running | 44.2 | 57.3 | 40% | 2.0% | | 2560x1080, 4x, standing | 50.8–53.1 | 59.3–59.6 | 13–19% | 0.8–1.3% | | 2560x1080, 4x, running | 45.5–46.9 | 57.1–57.9 | 33–36% | 1.7–2.6% | | 2560x1080, no frame generation, running | 55.2 | 58.7–58.9 | 7.3–7.6% | 1.4–1.6% | GPU time fell from \~12.9 to \~9.4 ms a frame with 4x frame generation, and from 12.3 to 8.6 ms without it. Measured on upstream main 8b2e1d3 against the same commit plus this PR: i7-14700K, RTX 5070 Ti, 48GB DDR5-7600, Windows 11, 175 Hz monitor, windowed, Hyrule Field save, scripted 20 s standing then 28 s running. Ranges are separate runs. ## Changes Two renderer changes carry the gain. Switching each off in turn on upstream main shows its share. - **Write-back diff** (`gpu/vk/vk_memory.cpp`, new `gpu/vk/shaders/wb_diff.comp`). Each frame's last submission copied every written storage-buffer range back to guest memory whole: \~69 MB, 4.4 of \~13 ms of GPU time. Pools written back now keep a VRAM shadow of guest memory, and a compute pass writes only the 16-byte vectors that differ, a subgroup's span at a time (word-by-word stores to system memory ran at \~1 GB/s). Pages the CPU wrote since are re-read into the shadow first, so the pass only ever writes fewer words than the copy did. Off: `RX_VK_WB_DIFF=0`. Its share: GPU 13.1 → 9.2 ms a frame; running 53.9 → 57.9 fps with 4x frame generation and 55.2 → 58.7 fps without. Readback refreshes also copy adjacent pages as one region (\~530 single-page copies a frame before). - **Presents on a compute queue** (`vk_main.cpp`, `vk_replay.cpp`, `vk_internal.h`, `vk_framegen.cpp`). `vkQueuePresentKHR` blocked with `present_mu` held, and the game's submits waited behind it. Presents now go on a compute-family queue with that lock released, the swapchain's images shared CONCURRENT by both families; a second graphics queue was tried first and blocked longer. The frame-generation display thread now waits on a high-resolution timer, as `sleep_for` overslept by up to 20 ms once presents stopped blocking. On by default with NVIDIA's driver on Windows; `RX_VK_PRESENT_QUEUE=0` or `1` forces it. Its share with 4x frame generation: running 55.4 → 57.9 fps, frames over 22 ms 8.1% → 1.7%. Without frame generation it changes nothing. - **libopus CPU flags** (`nx2/build.py`). libopus is built with the same `-march` flags as the rest of the game, so its float code can use AVX2 where the PC has it. ## Commits The branch `perf/steadier-frame-rate` on ShroomKing/Hookshot is three commits on upstream main at 8b2e1d3 (v2.0.8-rc.1). The renderer commits' messages carry their measurements and switches. 1. Renderer: presents on a compute-family queue of their own (NVIDIA on Windows by default) 2. Renderer: write back only the words that changed (the write-back diff) 3. Build: libopus for the same CPU target as the rest of the game ## Testing No exposure flashes turned up in frame logs on upstream main with this PR, with 4x frame generation or without it. - `tests/run_all.py`: 117 pass, 15 skipped. The 3 failures (`test_fps_overlay_order`, `test_precompile_compute_guard`, `nx2/test_nup_paths`) also fail on unmodified main on the same machine. - New `tests/test_wb_diff_split.py`: the diff's run splitting covers every byte once, in order, with vectors aligned and at most 64 KB. It fails on a split that drops a run's tail. - Frame logs (`RX_VK_FRAMELOG`, `scripts/framelog.py`) in Hyrule Field: with 4x frame generation, 0 flash events in 11,300 frames. Without it, no whole-picture brightness steps; the detector's 309 small local events are foreground grass sweeping past the camera at 60 fps. ## Notes for reviewers Measured on NVIDIA only (RTX 5070 Ti, driver 621.14, Windows 11). The write-back diff is on everywhere, with `RX_VK_WB_DIFF=0` as the escape hatch; the compute-queue present is on by default only with NVIDIA's driver on Windows. - `wb_diff_comp_spv.h` comes from `glslangValidator -V --target-env vulkan1.3 --vn WB_DIFF_COMP_SPV -o wb_diff_comp_spv.h wb_diff.comp`. - VRAM: shadows take at most 1/8 of VRAM (`RX_VK_WB_SHADOW_MB`) and give way above 85–95% use. The town at night, with \~10 GB of textures, runs without them. - Small extras: a `vk: write-back diff` stats line every 600 submits and a `vk: present failed` log line. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
nickyfactz commented 2026-11-09 12:53:15 +00:00 (Migrated from github.com)

At 228d35b, synthetic tests using the submitted SPIR-V and source-derived bookkeeping found a conditional stale-shadow case: after a CPU write, a later GPU result equal to the old shadow can be skipped, leaving the CPU-written value where the existing whole-copy path writes the GPU value. This is a compositional reproduction, not evidence that Ocarina reaches the sequence. It affects capture-off and a possible in-stream write path; I can provide the test details.

At 228d35b, synthetic tests using the submitted SPIR-V and source-derived bookkeeping found a conditional stale-shadow case: after a CPU write, a later GPU result equal to the old shadow can be skipped, leaving the CPU-written value where the existing whole-copy path writes the GPU value. This is a compositional reproduction, not evidence that Ocarina reaches the sequence. It affects capture-off and a possible in-stream write path; I can provide the test details.
ShroomKing commented 2026-11-09 13:24:22 +00:00 (Migrated from github.com)

At 228d35b, synthetic tests using the submitted SPIR-V and source-derived bookkeeping found a conditional stale-shadow case: after a CPU write, a later GPU result equal to the old shadow can be skipped, leaving the CPU-written value where the existing whole-copy path writes the GPU value. This is a compositional reproduction, not evidence that Ocarina reaches the sequence. It affects capture-off and a possible in-stream write path; I can provide the test details.

Thanks for this, I believe I have seen the effect of this in-game once.
I think it has to do with area-transitions and maybe cut-scenes but I have no way of reproducing it yet. Loading a save will properly reload the shadows in the area when the issue happens.

I would appreciate some more details about your test so i can narrow it down.
Will try to debug and submit a fix by tonight.

Here is how the issue looks in-game if you're curious:
1
2

> At [228d35b](https://git.magicbean.dev/MagicBeanDev/Hookshot/commit/228d35bf10ee2731498289ae161e328c414cdaac), synthetic tests using the submitted SPIR-V and source-derived bookkeeping found a conditional stale-shadow case: after a CPU write, a later GPU result equal to the old shadow can be skipped, leaving the CPU-written value where the existing whole-copy path writes the GPU value. This is a compositional reproduction, not evidence that Ocarina reaches the sequence. It affects capture-off and a possible in-stream write path; I can provide the test details. Thanks for this, I believe I have seen the effect of this in-game once. I think it has to do with area-transitions and maybe cut-scenes but I have no way of reproducing it yet. Loading a save will properly reload the shadows in the area when the issue happens. I would appreciate some more details about your test so i can narrow it down. Will try to debug and submit a fix by tonight. Here is how the issue looks in-game if you're curious: <img width="3439" height="1439" alt="1" src="https://github.com/user-attachments/assets/e38f367a-a153-417e-95ce-edb914a3fe52" /> <img width="3439" height="1439" alt="2" src="https://github.com/user-attachments/assets/e8754851-7678-4457-b60f-5b2c8e15a6f6" />
nickyfactz commented 2026-11-09 13:54:53 +00:00 (Migrated from github.com)

Thanks for looking into this. We haven’t reproduced the sequence in-game, so I can’t tell whether it explains your missing shadows. At 228d35b, we combined two synthetic tests:

  1. Start with the write-back shadow and GPU mirror both at 0. The CPU writes 1 to guest memory without invalidating the shadow; the GPU later writes 0 to the mirror. The existing whole-copy path writes 0 back, but the submitted diff sees the unchanged shadow and leaves 1 in guest memory. Calling shadow_written before write-back produces 0. We tested both word and vector paths of the submitted SPIR-V on llvmpipe.
  2. Extracted production bookkeeping showed two possible missed notifications: with RX_VK_CAPTURE=0, capture_make returns before shadow_written while memory_collect_written can consume the dirty page; with capture enabled, a small NOP_CLEAR_BUF can write during recording after collection without calling memory_cpu_owned or shadow_written for that write.

These were separate synthetic fixtures, not a full Hookshot replay; we haven’t established that real gameplay reaches the combined sequence or that it explains the screenshots. It may be useful to inspect whether every guest CPU write between shadow refresh and diff is reported before shadows_forget_written, including in-stream clears.

Thanks for looking into this. We haven’t reproduced the sequence in-game, so I can’t tell whether it explains your missing shadows. At 228d35b, we combined two synthetic tests: 1. Start with the write-back shadow and GPU mirror both at 0. The CPU writes 1 to guest memory without invalidating the shadow; the GPU later writes 0 to the mirror. The existing whole-copy path writes 0 back, but the submitted diff sees the unchanged shadow and leaves 1 in guest memory. Calling `shadow_written` before write-back produces 0. We tested both word and vector paths of the submitted SPIR-V on llvmpipe. 2. Extracted production bookkeeping showed two possible missed notifications: with `RX_VK_CAPTURE=0`, `capture_make` returns before `shadow_written` while `memory_collect_written` can consume the dirty page; with capture enabled, a small `NOP_CLEAR_BUF` can write during recording after collection without calling `memory_cpu_owned` or `shadow_written` for that write. These were separate synthetic fixtures, not a full Hookshot replay; we haven’t established that real gameplay reaches the combined sequence or that it explains the screenshots. It may be useful to inspect whether every guest CPU write between shadow refresh and diff is reported before `shadows_forget_written`, including in-stream clears.
blushOFF commented 2026-11-09 14:15:03 +00:00 (Migrated from github.com)

Measured this PR on Windows / NVIDIA, in case it helps: RTX 3070 8 GB, i7-14700KF, Windows 11, 32 GB.

Build: origin/main 8b2e1d3b with this PR applied on top of the build I play (it also carries #1035, #1063 and a
rebased #895, left Off). Both arms are the same build except for this PR; it applied cleanly.

Kokiri Forest, A/B in ABBA order (same save and settings, Frame generation 2x with Frame pacing; 30 s standing, then
40 s walking; the game's own present intervals from HOS_PRESENT_TRACE):

Pair Standing: main / this PR Walking: main / this PR
1 46.8 / 60.0 40.2 / 44.2
2 47.9 / 60.0 44.3 / 47.2
  • Standing sits at the game's 60 fps cap in both pairs: the shown frames are all 7-9 ms apart, against 97-99% at
    9-12 ms without it.
  • Walking: +3.5 fps on average; the slowest 1% of frames are about the same (p99 42.5 / 37.5 ms with it, 43.7 / 41.2 ms
    without).
  • No device lost, and no flash or dark frames in the frame log with it.
Measured this PR on Windows / NVIDIA, in case it helps: RTX 3070 8 GB, i7-14700KF, Windows 11, 32 GB. **Build:** origin/main 8b2e1d3b with this PR applied on top of the build I play (it also carries #1035, #1063 and a rebased #895, left Off). Both arms are the same build except for this PR; it applied cleanly. **Kokiri Forest, A/B in ABBA order** (same save and settings, Frame generation 2x with Frame pacing; 30 s standing, then 40 s walking; the game's own present intervals from `HOS_PRESENT_TRACE`): | Pair | Standing: main / this PR | Walking: main / this PR | |---|---|---| | 1 | 46.8 / 60.0 | 40.2 / 44.2 | | 2 | 47.9 / 60.0 | 44.3 / 47.2 | - Standing sits at the game's 60 fps cap in both pairs: the shown frames are all 7-9 ms apart, against 97-99% at 9-12 ms without it. - Walking: +3.5 fps on average; the slowest 1% of frames are about the same (p99 42.5 / 37.5 ms with it, 43.7 / 41.2 ms without). - No device lost, and no flash or dark frames in the frame log with it.
Commenting is not possible because the repository is archived.
No description provided.