Skip to content

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

Draft
hckrman101 wants to merge 3 commits into
NotRexed:mainfrom
hckrman101:perf/replay-cheaper
Draft

hckrman101 wants to merge 3 commits into
NotRexed:mainfrom
hckrman101:perf/replay-cheaper

Conversation

@hckrman101

Copy link
Copy Markdown
Collaborator

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

hckrman101 and others added 3 commits November 11, 2026 22:54
dw() is a call and a thread-local read, and the descriptor build asked it
about ten times a descriptor: 12 of build_descriptors' 178 samples in a
Hyrule Field profile of the replay thread (~0.07 ms a frame). The build
takes the reference once, after the two lines the mesh fallback's check
reads. Nothing else changes.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
… once

A pick that the pair of last picks does not answer compared its program's
answers with the bound textures through the program: each answer went to
its stage and the stage's descriptor list for where its handle is read,
cold cache lines every time (17 of answer_handle's 20 samples in a Hyrule
Field profile are those two loads), and a pick with other textures of the
same types, a quarter of all picks there, read every handle a second time
in still_valid. answer_stage_mask walked the answers once more for each.

A pick now keeps where each answer's handle is read (AnswerSource: the NVN
stage and the c2 offsets, with Eden's TexturePair shifts) and its mask.
pick_handles reads the handles from the state through driver_bank_word,
which is what stage_cbuf_word calls for c2; an answer read anywhere else
(a handle program, another bank) is still asked of the program.
still_valid_handles judges the handles already read.

The same words, comparison and verdict as before: the answer domains'
check holds pick_handles and still_valid_handles to answer_handles and
still_valid on the real descriptor types. Expected: 0.1-0.2 ms a frame of
the replay's "programs" time; not measured in the game yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
…ges alone

OoT binds a uniform buffer for nearly every draw, which moves bind_gen, so
nine draws in ten built their whole descriptor set again (124 of 1262 kept
their neighbour's in Hyrule Field) although a third of the picks there are
"the same program, the same textures, a uniform buffer bound since".

Such a draw now writes its uniform ranges (uniform_range, as the whole
build does) over the last build, which still holds the storage buffers,
textures and samplers, and bind_descriptors pushes what differs as before.
It is taken only when nothing the rest was made from can have moved since
that build (desc_rest_stands):
- the same recording and program, and the last build is this program's
  and was bound;
- 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
  images, texel buffers, tables or handle programs (uniform_only_of), so
  the rest is made from those stages' ssbo_gen and tex_gen alone;
- those generations, the slots' versions, the samplers' and the destroy
  generations, the texture epoch, the pools' and mirrors' stamp, and a new
  generation that moves when a texture becomes GPU-written, the last three
  as they were before the build began.

RX_VK_DESC_PARTIAL=0 turns it off; it is off too with RX_VK_DESC_REUSE=0
or never, in a dump, and with the switches that log, count, widen or copy
each binding. The frame log counts the draws that take it. Expected:
0.15-0.25 ms a frame of the replay's "descriptors" time; not measured in
the game yet.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
@hckrman101

Copy link
Copy Markdown
Collaborator Author

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.

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>
@Waryxxful

Copy link
Copy Markdown
Contributor

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.

@PedroFlaminio

Copy link
Copy Markdown

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 perf(vk): back-to-back passes and dispatches ordered by the game's barriers (NVIDIA by default) #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.

@corentinbonjour

Copy link
Copy Markdown
Contributor

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.

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