perf(vk): cheaper replay: uniform-only descriptor rebuilds and flat program picks #2644

Open
hckrman101 wants to merge 3 commits from hckrman101/perf/replay-cheaper into main
hckrman101 commented 2026-11-12 03:59:07 +00:00 (Migrated from github.com)

After #2602 the frame is serial and the replay of the game's command stream is its longest stretch (~7 ms a frame in Hyrule Field on an RTX 3090 / 7900X), so CPU time taken off the replay thread is frame time. This takes three pieces of per-draw work off it; none changes what is drawn.

Not tested in the game yet (code reading, existing profiles, a build and the checks only): see the last section for what to measure.

Where the time goes (existing profile, Hyrule Field, replay thread)

HOS_SAMPLE_ALL profile of the #2602 build, 951 samples under replay (~6.4 ms a frame, one sample ~6.7 us):

samples ms a frame
draw 623 4.2
of it build_descriptors 178 1.19
of it program_for 108 0.72
of it bind_descriptors (50 in the driver's push) 68 0.46
dispatch 119 0.80
  • The hottest instructions are not computation but loads of cold per-program data: the picks' hash node, each answer's Stage and descriptor (answer_handle), the stages' descriptor lists in build_descriptors.
  • 124 draws kept their neighbour's descriptors of 1262: OoT binds a uniform buffer for nearly every draw, which moves bind_gen, so nine draws in ten build their whole set again. Yet the picks' own log says a third of the graphics picks are by the last picks' textures, a uniform buffer bound since (2.36 of 7.2 million), and another quarter with other textures.

The changes

1. A draw after a uniform buffer's bind builds its uniform ranges alone (RX_VK_DESC_PARTIAL=0: off)

  • What: when a draw uses the program whose set was last built and bound, and only uniform buffers were bound since, rebuild_uniform_ranges writes the uniform ranges (through uniform_range, as the full build does) over that build; bind_descriptors then pushes what differs, as it already does. The storage buffers, textures, samplers and the list of sampled textures are the last build's.
  • Why it is safe: it is taken only when nothing the rest was made from can have moved since that build (desc_rest_stands):
    • same recording, same program, the last build is this program's and reached bind_descriptors (desc_built_prog; a dispatch's build over it is seen through dw().prog);
    • the program reads its storage buffers through c0 and its texture handles through c2 of its own graphics stages, writes none of them, and has no storage images, texel buffers, storage buffer tables or handle programs (uniform_only_of, asked once a program). Those words are the stages' ssbo_gen and tex_gen and nothing else of the state, so no uniform buffer's contents can change them;
    • those generations for the five graphics stages, the slots' versions, the samplers' and the destroy generations (desc_slots_kept, as for a draw that keeps its whole set), the texture epoch, gpu_bind_stamp() (pools and mirrors: what a buffer binding resolves to) and a new generation that moves when a texture becomes GPU-written (what the build kept for the pass hazards). The last three are read before the build begins, so a build that moves one itself is followed by a whole build.
    • off in a dump, with RX_VK_DESC_REUSE=0/never, and with the switches that log, count or widen each binding (RX_VK_DRAW_STATS, RX_VK_WATCH_PROG, RX_VK_SSBO_STRETCH, ...).
  • Expected: 0.15-0.25 ms a frame off descriptors (1.35-1.4 ms today). Up to a third of the graphics draws qualify by the picks' log; for those the build skips texture_view_of (28 samples), the storage buffers' gpu_buffer_bind, the handle reads and most of the function's own 67 samples. Honest low end: an earlier per-stage texture memo measured nothing above the noise, and RX_VK_PROF_FINE's large "textures" time is mostly its own clock reads.
  • The log line counts them: a frame: N draws kept their neighbour's descriptors (...), M more built their uniform ranges alone.

2. Program picks read their answers' handles from a flat list, once

  • What: a pick now keeps where each of its program's answers reads its handle (AnswerSource: the stage and the c2 offsets, Eden's TexturePair shifts) and its answer_stage_mask. pick_handles reads the handles straight from the state; anything that is not a c2 word of the answer's own stage is still asked of the program. A pick "with other textures" checked each handle twice (answer_handles, then still_valid); still_valid_handles judges the handles already read.
  • Why it is safe: the same words through the same function (driver_bank_word(s, 2, stage, offset) is what stage_cbuf_word calls for c2), the same comparison, the same verdict. tests/test_program_answer_domains.py holds pick_handles and still_valid_handles to answer_handles and still_valid on the real descriptor types for every kind of answer. No switch: identical output.
  • Expected: 0.1-0.2 ms a frame off programs (0.7-1.1 ms). answer_handle is 21 samples, 17 of them on two loads of the stage's descriptor; still_valid 17; the mask walked the answers a third time.

3. build_descriptors asks for its writer once

  • dw() is a call and a thread-local read; the build asked it about ten times a descriptor (12 samples under build_descriptors). Expected: ~0.07 ms. Identical output.

In all: an estimated 0.3-0.5 ms of the ~6.8 ms replay (5-8 %). Estimates from sample counts, not measurements.

Switches

RX_VK_DESC_PARTIAL=0: every draw builds its whole set, as before (in docs/development.md). Changes 2 and 3 have no switch: the same output by construction.

Looked at and left for later

  • A quarter of the draws push their whole set (a program change is a layout change), which is ~70 % of all descriptor writes pushed: programs with the same bindings could share a pipeline layout so those become pushes of what changed. Touches the pipeline caches; its own PR.
  • Mesh draws take mesh_program_for every time (two locks, three hash lookups). Only ~25-40 of the field's 1262 draws are mesh draws, so a picks cache for them is worth ~0.01 ms there; written and dropped from this PR.
  • A compact per-program descriptor plan (the build's remaining cost is cold loads of Shader::Info), and a flat table for the picks.
  • memory_end_submission (0.38 ms: cpu_words_merge, the sort in shadows_forget_written): left alone, next to the capture work of #2622.

Tests

  • New: tests/test_desc_uniform_only.py: which programs may, what refuses a draw, what is written, and the conditions in draw(), on the real Program / Eden descriptor types.
  • Extended: tests/test_program_answer_domains.py (the flat sources against the program's own reads), tests/test_pick_tex_gen.py (the mask kept with the pick).
  • The new checks fail when the code is broken on purpose (seven mutations tried: a written storage buffer allowed, a generation not compared, a storage range overwritten, a shift dropped, ...).
  • hookshot-build --vk-only: builds, ABI check ok.
  • python -I -B tests/run_all.py (Linux): 444-446 pass, 25 skipped; 21 fail, the same 21 on unchanged main (a016b7a6) with the same headers, none in files this PR touches (audio, control, docker packages, menu i18n, vk_memory.cpp fixtures, ...). tests/test_release_scripts.py failed once in a full run (its two-uploads-at-once case) and passes alone.

Not yet tested in the game

Nothing here was run in the game. What testers should measure, Hyrule Field and Kokiri Forest, default options, prepared shaders:

  • The replay split, before and after (the replay(N): submit, draw, descriptors, programs, commands line of oot-status --timing runs, or vk: a frame's replay time (ms) in the log): descriptors should fall by 0.2-0.3 ms and programs by 0.1-0.2 ms; submit by 0.3-0.5 ms. Alternate runs: two runs of one build differ by 10 %.
  • How many draws take the new path: M more built their uniform ranges alone in the log. If M is near 0, change 1 does nothing here and the eligibility (uniform_only_of) is too narrow for this game's materials.
  • A/B by switch: RX_VK_DESC_PARTIAL=0, and RX_VK_DESC_REUSE=never as the reference for descriptors.
  • Correctness: RX_VK_FRAMELOG / the flash detector in Kokiri and the field for wrong or missing textures and one-frame flashes; scene changes, the pause menu, a texture streaming burst (riding across the field), DLSS/FSR and frame generation on (their passes push descriptors of their own between draws); an AMD card (RADV) and a card whose sets go over the push limit (allocated sets instead of pushes).
  • Parallel replay (RX_VK_PARALLEL, off by default): the new state is per recording thread like the state beside it, but it was not run.
After #2602 the frame is serial and the replay of the game's command stream is its longest stretch (~7 ms a frame in Hyrule Field on an RTX 3090 / 7900X), so CPU time taken off the replay thread is frame time. This takes three pieces of per-draw work off it; none changes what is drawn. **Not tested in the game yet** (code reading, existing profiles, a build and the checks only): see the last section for what to measure. ## Where the time goes (existing profile, Hyrule Field, replay thread) `HOS_SAMPLE_ALL` profile of the #2602 build, 951 samples under `replay` (~6.4 ms a frame, one sample ~6.7 us): | | samples | ms a frame | |---|---|---| | `draw` | 623 | 4.2 | | of it `build_descriptors` | 178 | 1.19 | | of it `program_for` | 108 | 0.72 | | of it `bind_descriptors` (50 in the driver's push) | 68 | 0.46 | | `dispatch` | 119 | 0.80 | - The hottest instructions are not computation but loads of cold per-program data: the picks' hash node, each answer's `Stage` and descriptor (`answer_handle`), the stages' descriptor lists in `build_descriptors`. - `124 draws kept their neighbour's descriptors` of 1262: OoT binds a uniform buffer for nearly every draw, which moves `bind_gen`, so nine draws in ten build their whole set again. Yet the picks' own log says a third of the graphics picks are `by the last picks' textures, a uniform buffer bound since` (2.36 of 7.2 million), and another quarter `with other textures`. ## The changes ### 1. A draw after a uniform buffer's bind builds its uniform ranges alone (`RX_VK_DESC_PARTIAL=0`: off) - **What:** when a draw uses the program whose set was last built and bound, and only uniform buffers were bound since, `rebuild_uniform_ranges` writes the uniform ranges (through `uniform_range`, as the full build does) over that build; `bind_descriptors` then pushes what differs, as it already does. The storage buffers, textures, samplers and the list of sampled textures are the last build's. - **Why it is safe:** it is taken only when nothing the rest was made from can have moved since that build (`desc_rest_stands`): - same recording, same program, the last build is this program's and reached `bind_descriptors` (`desc_built_prog`; a dispatch's build over it is seen through `dw().prog`); - the program reads its storage buffers through c0 and its texture handles through c2 of its own graphics stages, writes none of them, and has no storage images, texel buffers, storage buffer tables or handle programs (`uniform_only_of`, asked once a program). Those words are the stages' `ssbo_gen` and `tex_gen` and nothing else of the state, so no uniform buffer's contents can change them; - those generations for the five graphics stages, the slots' versions, the samplers' and the destroy generations (`desc_slots_kept`, as for a draw that keeps its whole set), the texture epoch, `gpu_bind_stamp()` (pools and mirrors: what a buffer binding resolves to) and a new generation that moves when a texture becomes GPU-written (what the build kept for the pass hazards). The last three are read before the build begins, so a build that moves one itself is followed by a whole build. - off in a dump, with `RX_VK_DESC_REUSE=0/never`, and with the switches that log, count or widen each binding (`RX_VK_DRAW_STATS`, `RX_VK_WATCH_PROG`, `RX_VK_SSBO_STRETCH`, ...). - **Expected:** 0.15-0.25 ms a frame off `descriptors` (1.35-1.4 ms today). Up to a third of the graphics draws qualify by the picks' log; for those the build skips `texture_view_of` (28 samples), the storage buffers' `gpu_buffer_bind`, the handle reads and most of the function's own 67 samples. Honest low end: an earlier per-stage texture memo measured nothing above the noise, and `RX_VK_PROF_FINE`'s large "textures" time is mostly its own clock reads. - The log line counts them: `a frame: N draws kept their neighbour's descriptors (...), M more built their uniform ranges alone`. ### 2. Program picks read their answers' handles from a flat list, once - **What:** a pick now keeps where each of its program's answers reads its handle (`AnswerSource`: the stage and the c2 offsets, Eden's TexturePair shifts) and its `answer_stage_mask`. `pick_handles` reads the handles straight from the state; anything that is not a c2 word of the answer's own stage is still asked of the program. A pick "with other textures" checked each handle twice (`answer_handles`, then `still_valid`); `still_valid_handles` judges the handles already read. - **Why it is safe:** the same words through the same function (`driver_bank_word(s, 2, stage, offset)` is what `stage_cbuf_word` calls for c2), the same comparison, the same verdict. `tests/test_program_answer_domains.py` holds `pick_handles` and `still_valid_handles` to `answer_handles` and `still_valid` on the real descriptor types for every kind of answer. No switch: identical output. - **Expected:** 0.1-0.2 ms a frame off `programs` (0.7-1.1 ms). `answer_handle` is 21 samples, 17 of them on two loads of the stage's descriptor; `still_valid` 17; the mask walked the answers a third time. ### 3. `build_descriptors` asks for its writer once - `dw()` is a call and a thread-local read; the build asked it about ten times a descriptor (12 samples under `build_descriptors`). **Expected:** ~0.07 ms. Identical output. **In all: an estimated 0.3-0.5 ms of the ~6.8 ms replay (5-8 %).** Estimates from sample counts, not measurements. ## Switches `RX_VK_DESC_PARTIAL=0`: every draw builds its whole set, as before (in `docs/development.md`). Changes 2 and 3 have no switch: the same output by construction. ## Looked at and left for later - A quarter of the draws push their whole set (a program change is a layout change), which is ~70 % of all descriptor writes pushed: programs with the same bindings could share a pipeline layout so those become pushes of what changed. Touches the pipeline caches; its own PR. - Mesh draws take `mesh_program_for` every time (two locks, three hash lookups). Only ~25-40 of the field's 1262 draws are mesh draws, so a picks cache for them is worth ~0.01 ms there; written and dropped from this PR. - A compact per-program descriptor plan (the build's remaining cost is cold loads of `Shader::Info`), and a flat table for the picks. - `memory_end_submission` (0.38 ms: `cpu_words_merge`, the sort in `shadows_forget_written`): left alone, next to the capture work of #2622. ## Tests - New: `tests/test_desc_uniform_only.py`: which programs may, what refuses a draw, what is written, and the conditions in `draw()`, on the real `Program` / Eden descriptor types. - Extended: `tests/test_program_answer_domains.py` (the flat sources against the program's own reads), `tests/test_pick_tex_gen.py` (the mask kept with the pick). - The new checks fail when the code is broken on purpose (seven mutations tried: a written storage buffer allowed, a generation not compared, a storage range overwritten, a shift dropped, ...). - `hookshot-build --vk-only`: builds, ABI check ok. - `python -I -B tests/run_all.py` (Linux): 444-446 pass, 25 skipped; 21 fail, the same 21 on unchanged main (`a016b7a6`) with the same headers, none in files this PR touches (audio, control, docker packages, menu i18n, `vk_memory.cpp` fixtures, ...). `tests/test_release_scripts.py` failed once in a full run (its two-uploads-at-once case) and passes alone. ## Not yet tested in the game Nothing here was run in the game. What testers should measure, Hyrule Field and Kokiri Forest, default options, prepared shaders: - **The replay split**, before and after (the `replay(N): submit, draw, descriptors, programs, commands` line of `oot-status --timing` runs, or `vk: a frame's replay time (ms)` in the log): `descriptors` should fall by 0.2-0.3 ms and `programs` by 0.1-0.2 ms; `submit` by 0.3-0.5 ms. Alternate runs: two runs of one build differ by 10 %. - **How many draws take the new path:** `M more built their uniform ranges alone` in the log. If M is near 0, change 1 does nothing here and the eligibility (`uniform_only_of`) is too narrow for this game's materials. - **A/B by switch:** `RX_VK_DESC_PARTIAL=0`, and `RX_VK_DESC_REUSE=never` as the reference for descriptors. - **Correctness:** `RX_VK_FRAMELOG` / the flash detector in Kokiri and the field for wrong or missing textures and one-frame flashes; scene changes, the pause menu, a texture streaming burst (riding across the field), DLSS/FSR and frame generation on (their passes push descriptors of their own between draws); an AMD card (RADV) and a card whose sets go over the push limit (allocated sets instead of pushes). - **Parallel replay** (`RX_VK_PARALLEL`, off by default): the new state is per recording thread like the state beside it, but it was not run.
github-sync changed title from WIP: perf(vk): cheaper replay: uniform-only descriptor rebuilds and flat program picks to perf(vk): cheaper replay: uniform-only descriptor rebuilds and flat program picks 2026-11-12 07:30:19 +00:00
Owner

@hckrman101 commented on GitHub · 2026-11-12 07:13 UTC · original

Timing result: no measurable gain, so this stays a draft.

One alternating pair, standing in Hyrule Field, RTX 3090 + Ryzen 9 7900X, Linux (NVIDIA, Wayland), uncapped, pipeline cache loading off on both sides:

main this PR
median frame 11.93 ms 11.83 ms
replay submit 6.69 ms 6.75 ms
  • The new path is taken by 569 of 1,262 draws a frame, and saves about 0.1-0.15 ms in all.
  • That is inside the noise of a pair: the frame does not move.
  • My earlier estimate of 5-10 % was wrong. I am leaving the PR open as a draft in case the path matters more in another scene, but nobody needs to test it.
> [@hckrman101](https://github.com/hckrman101) commented on GitHub · 2026-11-12 07:13 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2644#issuecomment-6817275719) Timing result: no measurable gain, so this stays a draft. One alternating pair, standing in Hyrule Field, RTX 3090 + Ryzen 9 7900X, Linux (NVIDIA, Wayland), uncapped, pipeline cache loading off on both sides: | | main | this PR | |---|---|---| | median frame | 11.93 ms | 11.83 ms | | replay submit | 6.69 ms | 6.75 ms | - The new path is taken by 569 of 1,262 draws a frame, and saves about 0.1-0.15 ms in all. - That is inside the noise of a pair: the frame does not move. - My earlier estimate of 5-10 % was wrong. I am leaving the PR open as a draft in case the path matters more in another scene, but nobody needs to test it. <!-- github-sync:c:6817275719 -->
Owner

@Waryxxful commented on GitHub · 2026-11-12 11:21 UTC · original

Setup: RX 5600 XT 6 GB (RDNA1, no mesh shaders), Adrenalin 26.10.1, Windows 11, Ryzen 5 5600X, 24 GB, 1920x1080, frame generation off. Every run starts from the same copied save and follows a scripted route through the control bridge (Kokiri Forest and Hyrule Field: settle 15 s, then 60 s standing); fps are the medians of the log's N presents lines. Run-to-run noise on main de866017 (4 runs): Kokiri 31.5-34.6 fps, Hyrule Field 32.1-34.8.

Merged on main de866017. Because this PR changes vk_shader.cpp (the translator stamp), both builds ran with RX_VK_PRECOMPILE=0 and a cache set of their own. Each set was warmed by a first run, then the order was base, PR, base, PR. Same save and same route (Kokiri Forest, Hyrule Field, 60 s standing).

The mechanism works on this card: a frame: 127 draws kept their neighbour's descriptors ..., 381 more built their uniform ranges alone (of ~890-1,040 draws).

replay time, ms a frame Kokiri base Kokiri PR Field base Field PR
draw 4.13 / 4.07 3.74 / 3.92 3.92 / 4.22 3.84 / 3.71
descriptors 1.35 / 1.42 1.17 / 1.37 1.13 / 1.33 1.17 / 1.08
programs 0.61 / 0.59 0.52 / 0.51 0.53 / 0.54 0.48 / 0.45
submit 7.43 / 7.29 6.92 / 7.12 9.81 / 10.41 9.97 / 9.36

programs is down 12-15% in both places. draw and descriptors are down about 0.2-0.3 ms together, a little more than the 0.1-0.15 ms you saw on the RTX 3090. The frame rate doesn't show it at this card's ~34 fps: Kokiri 35.95 / 34.4 against 35.1 / 35.2, and Hyrule Field swings 33.9-42.2 even between base runs. The replay isn't this card's limit (GPU 17 ms, submit 7-10 ms). The screenshots (one a place) look the same as main's; no frame log was taken for flashes.

> [@Waryxxful](https://github.com/Waryxxful) commented on GitHub · 2026-11-12 11:21 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2644#issuecomment-6822205434) **Setup:** RX 5600 XT 6 GB (RDNA1, no mesh shaders), Adrenalin 26.10.1, Windows 11, Ryzen 5 5600X, 24 GB, 1920x1080, frame generation off. Every run starts from the same copied save and follows a scripted route through the control bridge (Kokiri Forest and Hyrule Field: settle 15 s, then 60 s standing); fps are the medians of the log's `N presents` lines. Run-to-run noise on main `de866017` (4 runs): Kokiri 31.5-34.6 fps, Hyrule Field 32.1-34.8. Merged on main `de866017`. Because this PR changes `vk_shader.cpp` (the translator stamp), both builds ran with `RX_VK_PRECOMPILE=0` and a cache set of their own. Each set was warmed by a first run, then the order was base, PR, base, PR. Same save and same route (Kokiri Forest, Hyrule Field, 60 s standing). The mechanism works on this card: `a frame: 127 draws kept their neighbour's descriptors ..., 381 more built their uniform ranges alone` (of ~890-1,040 draws). | replay time, ms a frame | Kokiri base | Kokiri PR | Field base | Field PR | |---|---|---|---|---| | draw | 4.13 / 4.07 | 3.74 / 3.92 | 3.92 / 4.22 | 3.84 / 3.71 | | descriptors | 1.35 / 1.42 | 1.17 / 1.37 | 1.13 / 1.33 | 1.17 / 1.08 | | programs | 0.61 / 0.59 | 0.52 / 0.51 | 0.53 / 0.54 | 0.48 / 0.45 | | submit | 7.43 / 7.29 | 6.92 / 7.12 | 9.81 / 10.41 | 9.97 / 9.36 | `programs` is down 12-15% in both places. `draw` and `descriptors` are down about 0.2-0.3 ms together, a little more than the 0.1-0.15 ms you saw on the RTX 3090. The frame rate doesn't show it at this card's ~34 fps: Kokiri 35.95 / 34.4 against 35.1 / 35.2, and Hyrule Field swings 33.9-42.2 even between base runs. The replay isn't this card's limit (GPU 17 ms, submit 7-10 ms). The screenshots (one a place) look the same as main's; no frame log was taken for flashes. <!-- github-sync:c:6822205434 -->
Owner

@PedroFlaminio commented on GitHub · 2026-11-12 14:33 UTC · original

Tested in the game: the partial rebuild is taken, but 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 partial path runs: 112 draws kept their neighbour's descriptors ..., 505 more built their uniform ranges alone (504-510 per frame in the stable part).
  • descriptors 2.9 ms against main's 3.5, but #2559, which does not touch descriptors, shows 2.7 ms: within run-to-run noise. 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.
> [@PedroFlaminio](https://github.com/PedroFlaminio) commented on GitHub · 2026-11-12 14:33 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2644#issuecomment-6826489367) Tested in the game: the partial rebuild is taken, but 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 partial path runs: `112 draws kept their neighbour's descriptors ..., 505 more built their uniform ranges alone` (504-510 per frame in the stable part). - `descriptors` 2.9 ms against main's 3.5, but #2559, which does not touch descriptors, shows 2.7 ms: within run-to-run noise. 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. <!-- github-sync:c:6826489367 -->
Owner

@corentinbonjour commented on GitHub · 2026-11-12 14:39 UTC · original

Some data from another machine, in case it helps (RTX 5070 Laptop, Core Ultra 9 275HX, v2.0.14, two 30 s HOS_SAMPLE_ALL profiles in gameplay at 20-40 fps):

  • replay submit 23-28 ms a frame for 2300-3000 draws (GPU ~12 ms), so the frame is replay-bound here too;
  • build_descriptors 10.5% inclusive of the render thread, program_for 5.5%, texture_view_of 3.6%, gpu_buffer_bind 3.4%;
  • only 63-118 draws a frame kept their neighbour's descriptors; the picks log had 18-36% of picks "by the last picks' textures, a uniform buffer bound since".

I also tried the same idea independently, with a wider eligibility (texture and image descriptors kept across uniform buffer binds, handles re-read and compared when they are not plain c2 words, so handle programs and c3+ handles qualify too). A differential test cut from the real code (38k draws over random streams with UBO rebinds and updates, slot re-registrations, texture destroys, handle programs, captures and recordings) matched a whole build at every draw, and dropping any of its 24 conditions made it fail. But on a frame-shaped synthetic stream it was neutral: keeping saved about 22-34 ns a draw, while recording what is kept cost every whole build 17-38 ns, because the draws that qualify reuse their neighbour's handles and their lookups are already warm. So the gain may be at the low end of your estimate, and an in-game A/B (RX_VK_DESC_PARTIAL=0 against the default) is what will tell.

Two things from that attempt that could be useful here: a check switch that runs a whole build after each partial one and logs any difference (so a player can verify it in game), and the differential test. Happy to share either.

> [@corentinbonjour](https://github.com/corentinbonjour) commented on GitHub · 2026-11-12 14:39 UTC · [original](https://github.com/NotRexed/Hookshot/pull/2644#issuecomment-6826641055) Some data from another machine, in case it helps (RTX 5070 Laptop, Core Ultra 9 275HX, v2.0.14, two 30 s HOS_SAMPLE_ALL profiles in gameplay at 20-40 fps): - replay `submit` 23-28 ms a frame for 2300-3000 draws (GPU ~12 ms), so the frame is replay-bound here too; - `build_descriptors` 10.5% inclusive of the render thread, `program_for` 5.5%, `texture_view_of` 3.6%, `gpu_buffer_bind` 3.4%; - only 63-118 draws a frame kept their neighbour's descriptors; the picks log had 18-36% of picks "by the last picks' textures, a uniform buffer bound since". I also tried the same idea independently, with a wider eligibility (texture and image descriptors kept across uniform buffer binds, handles re-read and compared when they are not plain c2 words, so handle programs and c3+ handles qualify too). A differential test cut from the real code (38k draws over random streams with UBO rebinds and updates, slot re-registrations, texture destroys, handle programs, captures and recordings) matched a whole build at every draw, and dropping any of its 24 conditions made it fail. But on a frame-shaped synthetic stream it was neutral: keeping saved about 22-34 ns a draw, while recording what is kept cost every whole build 17-38 ns, because the draws that qualify reuse their neighbour's handles and their lookups are already warm. So the gain may be at the low end of your estimate, and an in-game A/B (`RX_VK_DESC_PARTIAL=0` against the default) is what will tell. Two things from that attempt that could be useful here: a check switch that runs a whole build after each partial one and logs any difference (so a player can verify it in game), and the differential test. Happy to share either. <!-- github-sync:c:6826641055 -->
Commenting is not possible because the repository is archived.
No description provided.