perf(vk): a draw that has to wait for its pipeline gets one at once, not the full build #1946

Open
j64esatxzdrhfg wants to merge 1 commit from j64esatxzdrhfg/perf/pipeline-stutter into main
j64esatxzdrhfg commented 2026-11-10 21:23:40 +00:00 (Migrated from github.com)

Effects seen for the first time stutter. A draw into a target made that frame can't be left out, so it waits for the worker's full optimized pipeline build: about 65 ms each on an RTX 5080, with 236 new pipelines in a 6-minute session.

What changed

  • vk_draw.cpp quick_pipeline: such a draw first asks for the pipeline without compiling (FAIL_ON_PIPELINE_COMPILE_REQUIRED), so a cached one comes back optimized at once. Otherwise it builds one with DISABLE_OPTIMIZATION, into none of our caches. That pipeline stands in like a fast-linked one until the queued full build replaces it; a stand-in that loses the race is destroyed. The frame log counts it as a short wait. RX_VK_QUICK_WAIT=0 restores the old wait.
  • targets_persistent: a target cleared whole this frame (Texture::cleared_frame, set by clear_color_target with every channel and by clear_depth_stencil's depth, over the whole target) kept no earlier picture, the same as one made this frame. OoT clears its scene's colour and depth every frame, so on a cold pipeline cache the field's draws were left out over the cleared depth: a white wash on the first field load (thanks @hckrman101). Such a draw now gets its quick pipeline.
  • Shader compile row (Graphics):
    • Wait where needed (default) is the rule above.
    • Asynchronous (opt-in) never waits: a draw is left out until ready, so nothing stutters the first time. An effect can be missing for a few frames, a pass drawn once can look wrong until it is drawn again, and a cold cache's first loads can wash out.
    • RX_VK_PIPE_SKIP=0 still forces waiting.
    • This touches the same skip decision as #1573 (keep memory-writing draws). If that lands first, I'll rebase.
  • vk_shader.cpp: translation jobs go ahead of queued pipeline builds (urgent), keeping main's new failure callback (#1874).
  • vk_jobs.cpp: 6 workers with 16+ hardware threads (2–4 below, unchanged); RX_VK_WORKERS=<n>.
  • Tests: test_quick_pipeline.py compiles quick_pipeline beside a stand-in driver, and targets_persistent with the cleared cases. It covers cached, cold, unbuildable and a racing full build, plus no pipelineCreationCacheControl and RX_VK_QUICK_WAIT=0. test_gpl_library_cache follows the cache line.

Testing: RTX 5080 + Ryzen 7 9800X3D. An earlier form on v2.0.13 played smooth through new areas and effects; the version before the cleared-target rule ran in a build of main (8c92fe3) with this series, with Asynchronous and warm caches. The cleared-target rule and the new default are not yet run in game; game-free tests pass.

Not tested: a cold pipeline cache with Wait where needed (no numbers for stand-ins in a heavy scene yet), fewer cores, AMD/Intel, Linux.

Screenshot

Shader compile row, Asynchronous

🤖 Generated with Claude Code

Effects seen for the first time stutter. A draw into a target made that frame can't be left out, so it waits for the worker's full optimized pipeline build: about 65 ms each on an RTX 5080, with 236 new pipelines in a 6-minute session. **What changed** - `vk_draw.cpp` `quick_pipeline`: such a draw first asks for the pipeline without compiling (`FAIL_ON_PIPELINE_COMPILE_REQUIRED`), so a cached one comes back optimized at once. Otherwise it builds one with `DISABLE_OPTIMIZATION`, into none of our caches. That pipeline stands in like a fast-linked one until the queued full build replaces it; a stand-in that loses the race is destroyed. The frame log counts it as a short wait. `RX_VK_QUICK_WAIT=0` restores the old wait. - `targets_persistent`: a target cleared whole this frame (`Texture::cleared_frame`, set by `clear_color_target` with every channel and by `clear_depth_stencil`'s depth, over the whole target) kept no earlier picture, the same as one made this frame. OoT clears its scene's colour and depth every frame, so on a cold pipeline cache the field's draws were left out over the cleared depth: a white wash on the first field load (thanks @hckrman101). Such a draw now gets its quick pipeline. - Shader compile row (Graphics): - **Wait where needed** (default) is the rule above. - **Asynchronous** (opt-in) never waits: a draw is left out until ready, so nothing stutters the first time. An effect can be missing for a few frames, a pass drawn once can look wrong until it is drawn again, and a cold cache's first loads can wash out. - `RX_VK_PIPE_SKIP=0` still forces waiting. - This touches the same skip decision as #1573 (keep memory-writing draws). If that lands first, I'll rebase. - `vk_shader.cpp`: translation jobs go ahead of queued pipeline builds (urgent), keeping main's new failure callback (#1874). - `vk_jobs.cpp`: 6 workers with 16+ hardware threads (2–4 below, unchanged); `RX_VK_WORKERS=<n>`. - Tests: `test_quick_pipeline.py` compiles `quick_pipeline` beside a stand-in driver, and `targets_persistent` with the cleared cases. It covers cached, cold, unbuildable and a racing full build, plus no `pipelineCreationCacheControl` and `RX_VK_QUICK_WAIT=0`. `test_gpl_library_cache` follows the cache line. **Testing:** RTX 5080 + Ryzen 7 9800X3D. An earlier form on v2.0.13 played smooth through new areas and effects; the version before the cleared-target rule ran in a build of main (8c92fe3) with this series, with Asynchronous and warm caches. The cleared-target rule and the new default are not yet run in game; game-free tests pass. **Not tested:** a cold pipeline cache with Wait where needed (no numbers for stand-ins in a heavy scene yet), fewer cores, AMD/Intel, Linux. **Screenshot** ![Shader compile row, Asynchronous](https://raw.githubusercontent.com/j64esatxzdrhfg/Hookshot/pr-screenshots/pipeline-stutter/menu-shader-compile.jpg) 🤖 Generated with [Claude Code](https://claude.com/claude-code)
hckrman101 commented 2026-11-10 22:02:14 +00:00 (Migrated from github.com)

Nice. Quick pipelines for the draws that have to wait is a clear win. A few things from our side, since we've been chasing first-load problems on Linux and an RTX 3090:

  1. Cold first loads still wash out with Asynchronous as the default. The "can't be left out" rule only catches targets first rendered this frame. OoT clears its scene colour and depth targets every frame, so on a cold pipeline cache the field's draws pass that check and get left out. We measured up to 456–625 draws a frame left out, with the cleared depth showing through and the lighting reading it as sky: a white wash for about 6–7 s on the first field load after a fresh install. A draw into a target cleared this frame has the same problem as one into a target made this frame, so it's worth treating it the same way.
  2. A third choice that builds on this: "Bounded". Wait for draws that can't safely be left out (including the cleared-target case) up to a per-frame budget, a few ms, using quick_pipeline for each wait, and leave out the rest once the budget is spent. Lichii uses a bounded wait for the same reason (its native_async_draws=Bounded, 8 ms default). We're working on this on top of your branch and will post numbers (brightness, draws left out, worst frame, 1% low) for Bounded vs Asynchronous vs Wait where needed on the cold field load.
  3. Destroying the stand-in that loses the race: is it only destroyed once the GPU is done with every command buffer that recorded it, for example deferred to that frame's fence? If the replay can still have it in a submitted frame, destroying it at swap time would be a use-after-free on the GPU.
  4. Unoptimised pipelines in a heavy scene: with many new pipelines at once (a dungeon entrance), how long do the stand-ins stay in use before the full builds replace them, and is there a visible fps dip meanwhile? A number from your 5080 session would help.
  5. 6 workers on 16+ threads: fine on a 9800X3D. On 8-core/16-thread CPUs it competes with the game's own threads during a burst. Maybe scale with physical cores rather than threads?

We can test it on Linux (NVIDIA) here if useful.

Nice. Quick pipelines for the draws that have to wait is a clear win. A few things from our side, since we've been chasing first-load problems on Linux and an RTX 3090: 1. **Cold first loads still wash out with Asynchronous as the default.** The "can't be left out" rule only catches targets first rendered this frame. OoT clears its scene colour and depth targets every frame, so on a cold pipeline cache the field's draws pass that check and get left out. We measured up to 456–625 draws a frame left out, with the cleared depth showing through and the lighting reading it as sky: a white wash for about 6–7 s on the first field load after a fresh install. A draw into a target *cleared* this frame has the same problem as one into a target made this frame, so it's worth treating it the same way. 2. **A third choice that builds on this:** "Bounded". Wait for draws that can't safely be left out (including the cleared-target case) up to a per-frame budget, a few ms, using `quick_pipeline` for each wait, and leave out the rest once the budget is spent. Lichii uses a bounded wait for the same reason (its `native_async_draws=Bounded`, 8 ms default). We're working on this on top of your branch and will post numbers (brightness, draws left out, worst frame, 1% low) for Bounded vs Asynchronous vs Wait where needed on the cold field load. 3. **Destroying the stand-in that loses the race:** is it only destroyed once the GPU is done with every command buffer that recorded it, for example deferred to that frame's fence? If the replay can still have it in a submitted frame, destroying it at swap time would be a use-after-free on the GPU. 4. **Unoptimised pipelines in a heavy scene:** with many new pipelines at once (a dungeon entrance), how long do the stand-ins stay in use before the full builds replace them, and is there a visible fps dip meanwhile? A number from your 5080 session would help. 5. **6 workers on 16+ threads:** fine on a 9800X3D. On 8-core/16-thread CPUs it competes with the game's own threads during a burst. Maybe scale with physical cores rather than threads? We can test it on Linux (NVIDIA) here if useful.
lucasmazzoni45-del commented 2026-11-10 22:41:48 +00:00 (Migrated from github.com)

Data point for this PR on AMD: on an RX 7800 XT / 5800X3D, a 112-minute v2.0.12 session logged 19 "a draw waited 59-306 ms for a pipeline" lines, and 53 of 79 slow submits were dominated by draw. I hand-ported quick_pipeline (without the menu part) onto v2.0.12: boots and runs a scripted 150 s route at 59.7 fps mean with no regression, but that route never produced a first-seen effect, so I have not yet seen the stand-in path used. Details in #995. I will report back once I have a real-play log.

Data point for this PR on AMD: on an RX 7800 XT / 5800X3D, a 112-minute v2.0.12 session logged 19 "a draw waited 59-306 ms for a pipeline" lines, and 53 of 79 slow submits were dominated by `draw`. I hand-ported `quick_pipeline` (without the menu part) onto v2.0.12: boots and runs a scripted 150 s route at 59.7 fps mean with no regression, but that route never produced a first-seen effect, so I have not yet seen the stand-in path used. Details in #995. I will report back once I have a real-play log.
j64esatxzdrhfg commented 2026-11-11 10:39:43 +00:00 (Migrated from github.com)

@hckrman101 Thanks for the careful look, and for the Linux / RTX 3090 numbers.

  1. Cleared targets: agreed, that was a hole in targets_persistent. A target cleared whole this frame (every colour channel, or the depth, over the whole target) now counts like one made this frame (Texture::cleared_frame, set in clear_color_target / clear_depth_stencil). So under Wait where needed, the field's draws on a cold cache get their quick pipeline instead of being left out over the cleared depth. test_quick_pipeline.py now also compiles targets_persistent and checks the cases.
    Given the white wash on cold loads, I've made Wait where needed the default again and kept Asynchronous as the opt-in. With the cleared-target rule above, Wait where needed should avoid both the first-time stutter (quick pipelines) and the wash. Bounded could take over as the default once it's in.
  2. Bounded: sounds like the right third mode. Happy to see it on top of this branch; the row is a plain 0/1 today, so a third value slots in.
  3. The stand-in that loses the race: it is never recorded. It is destroyed only when its compare-and-swap into PipeEntry::pipeline fails, i.e. when the full build (or another thread's stand-in) is already in the entry, so no command buffer can hold it. A stand-in that won is never destroyed; the full build only replaces it in the entry, as with fast-linked pipelines, so draws recorded with it stay valid.
  4. Numbers for a heavy scene: I don't have them yet. In my sessions the caches were warm (shaders prepared at build time, and Asynchronous on), so not a single stand-in was built. Each quick build is logged (a waiting draw's pipeline built without the driver's optimizer in … ms (n so far, … on average)). If your cold-load runs log those lines, they would answer it better than mine; otherwise I can measure a cold run with Wait where needed.
  5. Workers: the 9800X3D is 8 cores / 16 threads, so it is exactly that class. It played smooth there, but that's one sample. I'm fine with scaling by physical cores instead (e.g. 6 from 12 physical cores, main's 2–4 below) if you prefer; RX_VK_WORKERS covers tuning either way.

Rebased onto current main.

@hckrman101 Thanks for the careful look, and for the Linux / RTX 3090 numbers. 1. **Cleared targets:** agreed, that was a hole in `targets_persistent`. A target cleared whole this frame (every colour channel, or the depth, over the whole target) now counts like one made this frame (`Texture::cleared_frame`, set in `clear_color_target` / `clear_depth_stencil`). So under Wait where needed, the field's draws on a cold cache get their quick pipeline instead of being left out over the cleared depth. `test_quick_pipeline.py` now also compiles `targets_persistent` and checks the cases. Given the white wash on cold loads, I've made **Wait where needed** the default again and kept Asynchronous as the opt-in. With the cleared-target rule above, Wait where needed should avoid both the first-time stutter (quick pipelines) and the wash. Bounded could take over as the default once it's in. 2. **Bounded:** sounds like the right third mode. Happy to see it on top of this branch; the row is a plain 0/1 today, so a third value slots in. 3. **The stand-in that loses the race:** it is never recorded. It is destroyed only when its compare-and-swap into `PipeEntry::pipeline` fails, i.e. when the full build (or another thread's stand-in) is already in the entry, so no command buffer can hold it. A stand-in that won is never destroyed; the full build only replaces it in the entry, as with fast-linked pipelines, so draws recorded with it stay valid. 4. **Numbers for a heavy scene:** I don't have them yet. In my sessions the caches were warm (shaders prepared at build time, and Asynchronous on), so not a single stand-in was built. Each quick build is logged (`a waiting draw's pipeline built without the driver's optimizer in … ms (n so far, … on average)`). If your cold-load runs log those lines, they would answer it better than mine; otherwise I can measure a cold run with Wait where needed. 5. **Workers:** the 9800X3D is 8 cores / 16 threads, so it is exactly that class. It played smooth there, but that's one sample. I'm fine with scaling by physical cores instead (e.g. 6 from 12 physical cores, main's 2–4 below) if you prefer; `RX_VK_WORKERS` covers tuning either way. Rebased onto current main.
j64esatxzdrhfg commented 2026-11-11 10:39:45 +00:00 (Migrated from github.com)

@lucasmazzoni45-del Thanks for the AMD numbers and for hand-porting it. A real-play log with the built without the driver's optimizer lines would be great to see.

@lucasmazzoni45-del Thanks for the AMD numbers and for hand-porting it. A real-play log with the `built without the driver's optimizer` lines would be great to see.
mooki000 commented 2026-11-11 13:05:07 +00:00 (Migrated from github.com)

Linux run with NVIDIA's driver: no regression, but nothing for the change to act on on this route. RTX 4090, NVIDIA 615.78.08, i9-13900K, Arch Linux. I merged 27d9136b onto main bdfe09c (the conflicts in vk_menu.cpp, vk_menu.h and readme.md are main's new Mesh shaders row and V-Sync text next to your row; I kept both sides). Headless, defaults, the menu row at its default (Asynchronous).

Both builds started from a fresh folder: the pipeline caches and the driver's shader cache deleted, then --prepare-shaders. Then a scripted first visit: Kokiri Forest from a save, then the debug teleports to Hyrule Field, Kakariko, the Market, the Lost Woods and Death Mountain Trail, about 22 s each with a camera turn. The counts are from RX_VK_FRAME_TRACE. Load average 1-5.

pipeline waits draws left out frames over 50 / 100 ms
main bdfe09c (two runs) 0, 0 0, 9 26 / 11, 27 / 11
main + this PR 0 8 36 / 19

After the prepare, no draw on this route waited for a pipeline even on main, so the quick build never ran. The other differences are within what two runs of main gave each other. The left-out draws fall in Death Mountain Trail (7-8) and the Lost Woods (1), in one run of main and in the PR's run. Both builds ran without a fault.

So on Linux with NVIDIA I have nothing to show for or against it. It would need a route with cold pipelines (no prepare, or areas the prepare doesn't cover) to show the wait it removes. Not tested: AMD, Intel, Windows, a folder without the prepare.

This run was the earlier head 27d9136b; the head has since moved to 98d6e23d, which I have not run.

**Linux run with NVIDIA's driver: no regression, but nothing for the change to act on on this route.** RTX 4090, NVIDIA 615.78.08, i9-13900K, Arch Linux. I merged 27d9136b onto main bdfe09c (the conflicts in `vk_menu.cpp`, `vk_menu.h` and `readme.md` are main's new Mesh shaders row and V-Sync text next to your row; I kept both sides). Headless, defaults, the menu row at its default (Asynchronous). Both builds started from a fresh folder: the pipeline caches and the driver's shader cache deleted, then `--prepare-shaders`. Then a scripted first visit: Kokiri Forest from a save, then the debug teleports to Hyrule Field, Kakariko, the Market, the Lost Woods and Death Mountain Trail, about 22 s each with a camera turn. The counts are from `RX_VK_FRAME_TRACE`. Load average 1-5. | | pipeline waits | draws left out | frames over 50 / 100 ms | |---|---|---|---| | main bdfe09c (two runs) | 0, 0 | 0, 9 | 26 / 11, 27 / 11 | | main + this PR | 0 | 8 | 36 / 19 | After the prepare, no draw on this route waited for a pipeline even on main, so the quick build never ran. The other differences are within what two runs of main gave each other. The left-out draws fall in Death Mountain Trail (7-8) and the Lost Woods (1), in one run of main and in the PR's run. Both builds ran without a fault. So on Linux with NVIDIA I have nothing to show for or against it. It would need a route with cold pipelines (no prepare, or areas the prepare doesn't cover) to show the wait it removes. Not tested: AMD, Intel, Windows, a folder without the prepare. This run was the earlier head 27d9136b; the head has since moved to 98d6e23d, which I have not run.
j64esatxzdrhfg commented 2026-11-11 15:07:30 +00:00 (Migrated from github.com)

@mooki000 Thanks for the careful Linux/NVIDIA run and the table. That fits: after --prepare-shaders no draw on the route had to wait, so there was nothing for the quick build to do. Your run was of 27d9136; the head since has the cleared-target rule (a draw into a target cleared this frame no longer counts as left to the last frame's picture) and Wait where needed as the default, which is where a cold route (no prepare, or areas it doesn't cover) would show the difference. Rebased onto current main.

@mooki000 Thanks for the careful Linux/NVIDIA run and the table. That fits: after `--prepare-shaders` no draw on the route had to wait, so there was nothing for the quick build to do. Your run was of 27d9136; the head since has the cleared-target rule (a draw into a target cleared this frame no longer counts as left to the last frame's picture) and Wait where needed as the default, which is where a cold route (no prepare, or areas it doesn't cover) would show the difference. Rebased onto current main.
Joey-Magnus (Migrated from github.com) requested changes 2026-11-11 20:06:20 +00:00
Joey-Magnus (Migrated from github.com) left a comment

The core idea is good, and quick_pipeline is careful. The no-compile probe goes to pcache; the unoptimized stand-in stays out of pcache and out of pcache_built_since_save, and the driver keys its own cache on the flags. A stand-in that loses the compare-and-swap was never recorded, so destroying it is safe. Nothing inside build_graphics runs a queued job, so the thread-local quick_build can't leak into another entry's full build. I compiled and ran test_quick_pipeline, test_gpl_library_cache and the 13 menu checks at this head: all pass. Note that the PR's own test workflows haven't run yet: they are waiting for a maintainer's approval (action_required).

blocking (merge conflict, and not only in text): the PR conflicts with main in gpu/vk/vk_draw.cpp, vk_menu.cpp, vk_menu.h, readme.md and docs/development.md. The vk_draw.cpp part is a change of meaning. #1573 has landed: on main a draw always waits for its translation (except with RX_VK_PIPE_SKIP=2), and a pending pipeline is left out only when no stage writes global memory, storage buffers or images. Here (gpu/vk/vk_draw.cpp:6265), may_skip = !always_wait && (opts.async_pipelines != 0 || targets_persistent(at)) predates that. If it is carried over as it is, Asynchronous would drop memory-writing draws again, which is exactly the bug #1573 fixed. When you rebase, keep main's memory-write check over both modes (Asynchronous may only widen the targets_persistent half), and send the wait in main's if (!pe->ready...) block through quick_pipeline. The row's hint and the readme ("Never waits") then need to say that a draw still waits for its shader translation and for a pipeline that writes memory.

should fix (the default changes for every player, untested in game): with the cleared-target rule (:3850, set at :4404 and :4459), a draw into OoT's scene colour and depth targets, which are cleared every frame, no longer counts as persistent. On a cold pipeline that draw now builds a stand-in on the replay thread, where main left it out. That fixes the white wash, but it trades it for serial unoptimized compiles in the frame: hckrman101 measured 456-625 draws left out per frame on a cold field load, and if even a few dozen distinct pipelines are behind them, that is a long frame. Unoptimized build times also vary much more across vendors (AMD's proprietary compiler, Intel) than on a 5080. The description says the new default and the cleared-target rule haven't been run in game, and mooki000's run had nothing cold for the change to act on. Before this becomes the default, please post one cold-cache run with Wait where needed (no --prepare-shaders, or an area the prepare doesn't cover): how many stand-ins were built, their average and worst ms from the new log line, and the worst frame time compared with main.

should fix (scope, and stale against main): the worker-count change (gpu/vk/vk_jobs.cpp:80, RX_VK_WORKERS) is a separate tuning decision from the quick pipeline, and CONTRIBUTING.md asks for one topic per PR. It also now goes against main. #2323 added usable_cpus() (the process's affinity mask), because sizing pools from hardware_concurrency() ran 24 threads on 12 usable CPUs and made translation 40% slower (gpu/vk/vk_internal.h, the half_cpus comment). #2440 already proposes the same change on its own, so the simplest fix is to drop it here and settle it there, based on usable_cpus(), ideally with a number from an 8-core/16-thread CPU under load, as hckrman101 asked. Making translation jobs urgent (vk_shader.cpp) belongs with this change and is fine to keep.

The core idea is good, and `quick_pipeline` is careful. The no-compile probe goes to `pcache`; the unoptimized stand-in stays out of `pcache` and out of `pcache_built_since_save`, and the driver keys its own cache on the flags. A stand-in that loses the compare-and-swap was never recorded, so destroying it is safe. Nothing inside `build_graphics` runs a queued job, so the thread-local `quick_build` can't leak into another entry's full build. I compiled and ran `test_quick_pipeline`, `test_gpl_library_cache` and the 13 menu checks at this head: all pass. Note that the PR's own test workflows haven't run yet: they are waiting for a maintainer's approval (`action_required`). **blocking** (merge conflict, and not only in text): the PR conflicts with main in `gpu/vk/vk_draw.cpp`, `vk_menu.cpp`, `vk_menu.h`, `readme.md` and `docs/development.md`. The `vk_draw.cpp` part is a change of meaning. #1573 has landed: on main a draw always waits for its translation (except with `RX_VK_PIPE_SKIP=2`), and a pending pipeline is left out only when no stage writes global memory, storage buffers or images. Here (`gpu/vk/vk_draw.cpp:6265`), `may_skip = !always_wait && (opts.async_pipelines != 0 || targets_persistent(at))` predates that. If it is carried over as it is, **Asynchronous** would drop memory-writing draws again, which is exactly the bug #1573 fixed. When you rebase, keep main's memory-write check over both modes (Asynchronous may only widen the `targets_persistent` half), and send the wait in main's `if (!pe->ready...)` block through `quick_pipeline`. The row's hint and the readme ("Never waits") then need to say that a draw still waits for its shader translation and for a pipeline that writes memory. **should fix** (the default changes for every player, untested in game): with the cleared-target rule (`:3850`, set at `:4404` and `:4459`), a draw into OoT's scene colour and depth targets, which are cleared every frame, no longer counts as persistent. On a cold pipeline that draw now builds a stand-in on the replay thread, where main left it out. That fixes the white wash, but it trades it for serial unoptimized compiles in the frame: hckrman101 measured 456-625 draws left out per frame on a cold field load, and if even a few dozen distinct pipelines are behind them, that is a long frame. Unoptimized build times also vary much more across vendors (AMD's proprietary compiler, Intel) than on a 5080. The description says the new default and the cleared-target rule haven't been run in game, and mooki000's run had nothing cold for the change to act on. Before this becomes the default, please post one cold-cache run with Wait where needed (no `--prepare-shaders`, or an area the prepare doesn't cover): how many stand-ins were built, their average and worst ms from the new log line, and the worst frame time compared with main. **should fix** (scope, and stale against main): the worker-count change (`gpu/vk/vk_jobs.cpp:80`, `RX_VK_WORKERS`) is a separate tuning decision from the quick pipeline, and CONTRIBUTING.md asks for one topic per PR. It also now goes against main. #2323 added `usable_cpus()` (the process's affinity mask), because sizing pools from `hardware_concurrency()` ran 24 threads on 12 usable CPUs and made translation 40% slower (`gpu/vk/vk_internal.h`, the `half_cpus` comment). #2440 already proposes the same change on its own, so the simplest fix is to drop it here and settle it there, based on `usable_cpus()`, ideally with a number from an 8-core/16-thread CPU under load, as hckrman101 asked. Making translation jobs urgent (`vk_shader.cpp`) belongs with this change and is fine to keep.
Joey-Magnus (Migrated from github.com) reviewed 2026-11-11 21:46:20 +00:00
Joey-Magnus (Migrated from github.com) left a comment

There hasn't been a push since my review: the head is still aeb130ed, committed before it. So the points there all still apply, and I'll only list them:

  • It conflicts with main in vk_draw.cpp, vk_menu.cpp/.h, readme.md, development.md and test_gpl_library_cache.py. The vk_draw.cpp conflict changes meaning: carried over as it is, may_skip (vk_draw.cpp:6265) would let Asynchronous drop memory-writing draws again, which #1573 fixed. Keep main's memory-write check over both modes.
  • The cleared-target rule changes the default for everyone, and no run with a cold pipeline cache has been posted yet. I'd want the stand-in count and their average and worst ms from the new log line, against main's worst frame.
  • Drop the RX_VK_WORKERS / worker-count change (vk_jobs.cpp:80) and settle it in #2440, sized from usable_cpus() (#2323).

quick_pipeline isn't on main in any form, so this isn't superseded. It just needs the rebase and that one cold-cache run.

There hasn't been a push since my review: the head is still aeb130ed, committed before it. So the points there all still apply, and I'll only list them: - It conflicts with main in vk_draw.cpp, vk_menu.cpp/.h, readme.md, development.md and test_gpl_library_cache.py. The vk_draw.cpp conflict changes meaning: carried over as it is, `may_skip` (vk_draw.cpp:6265) would let Asynchronous drop memory-writing draws again, which #1573 fixed. Keep main's memory-write check over both modes. - The cleared-target rule changes the default for everyone, and no run with a cold pipeline cache has been posted yet. I'd want the stand-in count and their average and worst ms from the new log line, against main's worst frame. - Drop the `RX_VK_WORKERS` / worker-count change (vk_jobs.cpp:80) and settle it in #2440, sized from `usable_cpus()` (#2323). `quick_pipeline` isn't on main in any form, so this isn't superseded. It just needs the rebase and that one cold-cache run.
hckrman101 commented 2026-11-11 22:55:41 +00:00 (Migrated from github.com)

This now conflicts with main in docs/development.md, gpu/vk/vk_draw.cpp, gpu/vk/vk_menu.cpp, gpu/vk/vk_menu.h, readme.md, tests/test_gpl_library_cache.py. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.

This now conflicts with main in `docs/development.md`, `gpu/vk/vk_draw.cpp`, `gpu/vk/vk_menu.cpp`, `gpu/vk/vk_menu.h`, `readme.md`, `tests/test_gpl_library_cache.py`. Could you merge main into the branch (or rebase) when you get a chance? I'll take another look once it's up to date.
Owner

@lucasmazzoni45-del commented on GitHub · 2026-11-12 14:44 UTC · original

I rebased this onto current main and applied the review points on a branch in my fork: fix/pr1946-quick-pipeline.

  • may_skip keeps main's #1573 memory-write check in both modes; Asynchronous only widens the targets_persistent half. The wait in the !pe->ready block goes through quick_pipeline.
  • The RX_VK_WORKERS / worker-count change is dropped (left to #2440). Urgent translation jobs in vk_shader.cpp are kept.
  • Hint, readme and docs no longer say "Never waits".
  • tests/test_draw_side_effects.py needed stubs for opts and quick_pipeline; added, plus 20 Asynchronous cases.

tests/run_all.py on Windows (llvm-mingw, .NET 8): 426 passed, 4 failed, 66 skipped. The 4 failures (test_fpx_sticky, test_input_background, test_spawn_emit, nx2/test_nup_paths) fail the same way on main here.

Not done: the cold-cache in-game run asked for in the review (stand-in count, avg/worst ms, worst frame vs main) is still missing. Nothing was run in game. Feel free to cherry-pick from the branch.

🤖 Generated with Claude Code

> [@lucasmazzoni45-del](https://github.com/lucasmazzoni45-del) commented on GitHub · 2026-11-12 14:44 UTC · [original](https://github.com/NotRexed/Hookshot/pull/1946#issuecomment-6826766713) I rebased this onto current main and applied the review points on a branch in my fork: [fix/pr1946-quick-pipeline](https://github.com/lucasmazzoni45-del/Hookshot/tree/fix/pr1946-quick-pipeline). - `may_skip` keeps main's #1573 memory-write check in both modes; Asynchronous only widens the `targets_persistent` half. The wait in the `!pe->ready` block goes through `quick_pipeline`. - The `RX_VK_WORKERS` / worker-count change is dropped (left to #2440). Urgent translation jobs in `vk_shader.cpp` are kept. - Hint, readme and docs no longer say "Never waits". - `tests/test_draw_side_effects.py` needed stubs for `opts` and `quick_pipeline`; added, plus 20 Asynchronous cases. `tests/run_all.py` on Windows (llvm-mingw, .NET 8): 426 passed, 4 failed, 66 skipped. The 4 failures (`test_fpx_sticky`, `test_input_background`, `test_spawn_emit`, `nx2/test_nup_paths`) fail the same way on main here. Not done: the cold-cache in-game run asked for in the review (stand-in count, avg/worst ms, worst frame vs main) is still missing. Nothing was run in game. Feel free to cherry-pick from the branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- github-sync:c:6826766713 -->
Owner

@lucasmazzoni45-del commented on GitHub · 2026-11-12 14:55 UTC · original

Update to my branch fix/pr1946-quick-pipeline: the quick stand-in is now default-on for AMD only (vendor 0x1002), so NVIDIA and Intel keep main's behaviour. RX_VK_QUICK_WAIT (set, non-empty) still decides either way. It is never used for task/mesh programs (the unoptimized mesh build resets the GPU on RADV, issue 1000); those wait for the full build as before. Readme, docs, the menu hint and tests/test_quick_pipeline.py (vendor/env/mesh cases in separate processes) follow. The cleared-target rule and the urgent translation jobs are unchanged and still apply to every GPU. Measured only on AMD by the earlier logs; NVIDIA/Intel are untested.

🤖 Generated with Claude Code

> [@lucasmazzoni45-del](https://github.com/lucasmazzoni45-del) commented on GitHub · 2026-11-12 14:55 UTC · [original](https://github.com/NotRexed/Hookshot/pull/1946#issuecomment-6827025584) Update to my branch [fix/pr1946-quick-pipeline](https://github.com/lucasmazzoni45-del/Hookshot/tree/fix/pr1946-quick-pipeline): the quick stand-in is now **default-on for AMD only** (vendor 0x1002), so NVIDIA and Intel keep main's behaviour. `RX_VK_QUICK_WAIT` (set, non-empty) still decides either way. It is never used for task/mesh programs (the unoptimized mesh build resets the GPU on RADV, issue 1000); those wait for the full build as before. Readme, docs, the menu hint and `tests/test_quick_pipeline.py` (vendor/env/mesh cases in separate processes) follow. The cleared-target rule and the urgent translation jobs are unchanged and still apply to every GPU. Measured only on AMD by the earlier logs; NVIDIA/Intel are untested. 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- github-sync:c:6827025584 -->
Owner

@jarynclouatre commented on GitHub · 2026-11-12 15:06 UTC · original

Windows with an NVIDIA card, areas with no recorded pipelines: with Asynchronous, about a fifth as many draws were left out as on main, with no waits. With Wait where needed, few draws were left out, but it waited 58 s over the route.

RTX 4070 Ti SUPER, NVIDIA 621.14, Ryzen 7 9800X3D, Windows 11. I tested 98d6e23 against its base d7a7306, one run each. The current head aeb130ed is the same code rebased, with one menu hint reworded.

Setup: a scripted 9-minute drive as child with 5 teleports (Hyrule Field, Kakariko, Market, Lost Woods, Gerudo Valley). RX_VK_NO_STATE_LIST=1 and RX_VK_NO_PREDICT=1 were set, so neither the shipped list nor prediction prepared those areas. Before each measured run the game booted once to the title with the driver cache kept, so start-up was warm and the areas were not. The counts are summed from the frame log's stat lines.

main d7a7306 #1946 Wait where needed #1946 Asynchronous
Draws left out (not ready) 127,572 1,680 25,524
Waits for a shader or pipeline 129 (12.3 s) 415 (58.1 s) 0
Present intervals over 40 ms 100 152 117
Present intervals 30-40 ms 364 284 384
Presents in the run 32,905 27,185 33,925
  • Wait where needed: the 415 waits averaged about 140 ms each. The run fell about 27 s behind the timed drive in its first three minutes, so later teleports landed at different points of the route than in the other two runs.
  • Asynchronous: 100 against 117 intervals over 40 ms is from one run each, so I would not read that as a difference.
> [@jarynclouatre](https://github.com/jarynclouatre) commented on GitHub · 2026-11-12 15:06 UTC · [original](https://github.com/NotRexed/Hookshot/pull/1946#issuecomment-6827310476) **Windows with an NVIDIA card, areas with no recorded pipelines: with Asynchronous, about a fifth as many draws were left out as on main, with no waits. With Wait where needed, few draws were left out, but it waited 58 s over the route.** RTX 4070 Ti SUPER, NVIDIA 621.14, Ryzen 7 9800X3D, Windows 11. I tested 98d6e23 against its base d7a7306, one run each. The current head aeb130ed is the same code rebased, with one menu hint reworded. Setup: a scripted 9-minute drive as child with 5 teleports (Hyrule Field, Kakariko, Market, Lost Woods, Gerudo Valley). `RX_VK_NO_STATE_LIST=1` and `RX_VK_NO_PREDICT=1` were set, so neither the shipped list nor prediction prepared those areas. Before each measured run the game booted once to the title with the driver cache kept, so start-up was warm and the areas were not. The counts are summed from the frame log's stat lines. | | main d7a7306 | #1946 Wait where needed | #1946 Asynchronous | |---|---|---|---| | Draws left out (not ready) | 127,572 | 1,680 | 25,524 | | Waits for a shader or pipeline | 129 (12.3 s) | 415 (58.1 s) | 0 | | Present intervals over 40 ms | 100 | 152 | 117 | | Present intervals 30-40 ms | 364 | 284 | 384 | | Presents in the run | 32,905 | 27,185 | 33,925 | - Wait where needed: the 415 waits averaged about 140 ms each. The run fell about 27 s behind the timed drive in its first three minutes, so later teleports landed at different points of the route than in the other two runs. - Asynchronous: 100 against 117 intervals over 40 ms is from one run each, so I would not read that as a difference. <!-- github-sync:c:6827310476 -->
Commenting is not possible because the repository is archived.
No description provided.