Skip to content

perf(vk): opt-in pipeline binary cache, lighter precompile and a wait budget for AMD Windows - #2814

Draft
ZurielAtr wants to merge 3 commits into
NotRexed:mainfrom
ZurielAtr:perf/amd-memory-stutter
Draft

ZurielAtr wants to merge 3 commits into
NotRexed:mainfrom
ZurielAtr:perf/amd-memory-stutter

Conversation

@ZurielAtr

Copy link
Copy Markdown
Contributor

On an 8 GB AMD laptop with 32 GB of RAM, v2.1.0 grows past the PC's RAM after a few area changes (33-37 GB private), so Windows pages the game out and single frames take 5-10 s. First-time cutscenes and boss fights also stop the picture for minutes while AMD's compiler builds new pipelines (10-57 s each).

This draft adds opt-in switches against both, plus a pipeline cache built on VK_KHR_pipeline_binary that turned out much faster than the pipeline cache on this AMD driver. Everything is off by default: with no new variable set, behaviour is main's. It is a draft because the rebased build has not been run in the game yet (see Status). It groups several pieces under one topic (RAM and long waits on AMD Windows); I am happy to split it into separate PRs if you prefer.

Status, and what was tested where

  • Developed and played on v2.1.0 (local builds from the tag), on one machine: AMD Radeon RX 7700S 8 GB laptop, AMD driver 2.0.416 (Vulkan 1.4), Ryzen 7 7735HS (8 cores / 16 threads), 32 GB of RAM (31.2 GB visible), Windows 11.
  • This branch is the same work rebased onto main (51da0464). It builds with llvm-mingw, and the game-free suite result is under Tests. The rebased build has not been run in the game.
  • Two pieces were left out of the rebase because main changed underneath them (details in Left out).
  • The game sessions behind the numbers below were not identical routes, so they are not a clean A/B. Where a number comes from a controlled test, I say so.

What is in it

1. A pipeline cache made of pipeline binaries: RX_VK_BINARY_CACHE=1, RX_VK_PCACHE_SKIP=1

mkw_vk_pipelines.bin is read whole at every start and the driver holds it for the whole session (2.4-2.5 GB here). With RX_VK_BINARY_CACHE=1, each pipeline's binaries are kept on their own in hookshot_pipeline_binaries.bin, with an index of keys in memory, and only the binaries of the pipeline being made are read from the file.

How it works:

  • In cached_graphics / cached_compute (every pipeline goes through them), the pipeline's key is computed with vkGetPipelineKeyKHR, using the create info without the ask-only flag.
  • The file has it: its binaries are read, vkCreatePipelineBinariesKHR is called from that data, and the pipeline is created with the binaries and a null cache.
  • The file does not have it: the pipeline is made as before (create_cached). Its binaries are then taken from the driver's own cache by probing it with the create info (pipelineBinaryInternalCache is true on this driver), so nothing is compiled twice, and they are appended to the file.
  • The file starts with the global key. A file from another driver is moved to .old and a new one is started.
  • VK_KHR_maintenance5 and VK_KHR_pipeline_binary are enabled on the device only when one of these switches is set.

RX_VK_PCACHE_SKIP=1 needs the binary cache. It does what main's RX_VK_PCACHE_LOAD=0 does (the file is not read and not saved), but meant for play: mkw_vk_pipelines.bin stays as it was, and the play files are not merged into the empty cache, only numbered on.

RX_VK_BINARY_TEST=<n> is a measurement only. On the first n pipelines made in play, it logs the key time, whether the driver's cache has the pipeline, a capturing build, a build from the captured data, and a build from the previous run's data (kept in hookshot_binary_test.bin). Everything it makes is destroyed.

Measured on the RX 7700S:

Pipeline key 0.002-0.010 ms
Pipeline made from its binary 0.03-0.15 ms (through the caches: 3-680 ms, even with the driver's cache warm)
Binaries per pipeline / size 1 / 6-400 KB (the boss-area ones ~340-400 KB)
Binaries from one start, used in the next 23 of 23 (same global key)
One-time migration (a start with RX_VK_PRECOMPILE_GRAPHICS=release, see 2) 19,025 pipelines added, 0 the driver's cache did not have, 0 failed; the file is 774 MB, against 2.5 GB for mkw_vk_pipelines.bin
In play afterwards (17 minutes, Hyrule Castle) 5,550 pipelines made from the file at 0.34 ms each (disk read included), 257 added

This is the opposite of what #2760 found on NVIDIA (1.2-1.7 ms median against 0.4-0.6 with the pipeline cache, 2.2 MB a pipeline, 159 compute pipelines), so it looks driver-dependent. Numbers from NVIDIA and Intel with this branch would tell.

A finding that may help others: AMD's own Vulkan cache is one file of a fixed 896 MiB (%LOCALAPPDATA%\AMD\VkCache\*.parc, exactly 939,524,096 bytes) and evicts. Pipelines that came from the driver's cache fell from 3,245 in one session to 11 two sessions later.

2. How much the precompile keeps: RX_VK_PRECOMPILE_GRAPHICS=keep|release|none

The start builds every noted graphics pipeline (15,000-17,000 here) and keeps all of them, and their programs, for the whole session.

  • keep (default): as main.
  • release: each pipeline is built (so it is in the driver's cache) and destroyed.
  • none: the notes are not built and their programs not read. Play makes them on first use.

A controlled test at the title screen, same install, RX_VK_PRECOMPILE=0 against the default:

At the title screen Precompile on Precompile off
Private memory 23.9-24.5 GB 13.8-15.0 GB -9.5 GB
VRAM in use, same VMA blocks 1,938 MB 1,029 MB -0.9 GB (the driver's code for the pipelines)

With release, the start was at 22.7 GB. So the programs are ~8 GB of that, and the pipelines themselves ~1.2 GB.

Caveat: with none on v2.1.0, programs were then read from hookshot_programs.bin on the replay thread at their first draw. That gave 100-1,700 ms frames: micro-stutter that the player noticed. Locally I had a switch that read them on a worker (left out here, see below). On main, none will most likely stutter the same way unless programs are loaded ahead of their first draw, which is what #2760's load-at-create does better. none probably belongs with #2760, and release is the safe one.

3. A per-frame wait budget: RX_VK_PIPE_WAIT_MS=<ms>

A frame waits at most this long, in all, for the pipelines of draws that main already allows to be left out (none of their stages writes memory). Past the budget they are left out until a worker has them. jobs_wait_for moves the waited job to the front of the queue and waits with a timeout; it does not run the job inline, where an AMD compile can take seconds.

  • Translations and memory-writing pipelines are still waited for whole, by main's rules.
  • On v2.1.0, a first-time cutscene showed a black screen with music for ~2 minutes: ten new pipelines at 12-14 s each, compiled one after another on the replay thread through jobs_wait's steal. With the budget (50 ms) the game kept presenting while they compiled, at a low frame rate with missing parts.
  • On v2.1.0 the budget also covered translations. That part is gone here, because main now waits for them whole.

This overlaps #1946, which gives a waited draw an unoptimized stand-in. On this AMD card, an earlier benchmark found VK_PIPELINE_CREATE_DISABLE_OPTIMIZATION_BIT did not make compiles faster (47 compute shaders, 182 s against 181 s), so stand-ins may help less on AMD. The two could live together.

4. Cache hits made at the draw: RX_VK_PIPE_TRY_CACHED=<ms>

A pipeline the driver's cache or ours already has is made at the draw, asked without compiling (FAIL_ON_PIPELINE_COMPILE_REQUIRED), up to that many ms a frame. Only pipelines that must be compiled go to the workers.

In a boss area on v2.1.0 the queue reached 286 jobs for 4 workers. Pipelines the caches had sat behind compiles of 10-44 s each, and the boss model was not drawn for minutes. In a later 30-minute session with the switch at 8 ms, 4,000 pipelines were made at the draw and 824 went to the workers.

5. A log line for slow pipelines in play

Each graphics pipeline built in play that takes 50 ms or more is logged (the first 60, then every 100th), with the program key and the time.

Left out of this rebase (tested on v2.1.0 only)

  • RX_VK_PROGRAM_ASYNC: programs read on a worker, with a stand-in for the draw, and translations moved ahead of pipeline builds. Main now waits for translations whole, so the draw would wait for the read anyway. It also touched vk_shader.cpp, which changes the translator stamp, and without it this PR does not change the stamp: no new prepare for anyone. feat(vk): load shader programs when the game creates them (RX_VK_LOAD=create) #2760 covers this better.
  • A worker-count switch: main has RX_VK_WORKERS and 6 workers with 16+ threads now.

Sessions, for context (v2.1.0, local builds; real fps = presents / time)

Official v2.1.0 renderer (21 min) With these switches (17 min, Hyrule Castle)
Max private memory 36.9 GB 26.4-26.7 GB
Worst frame 7.5 s (Windows paged the game out at 33.9 GB) under 1 s
VRAM at the driver's limit 28 % of the samples 6 %
Real fps in play 53.6 57.5

Different routes, so read this as context, not an A/B. In every session I also had RX_VK_KEEP_RETIRED=0 in the environment (retired imports given back at once: 0.9-3.7 GB freed in a session, no device loss in about ten sessions). Main has #1906's graceful version of this now, so it is not part of this PR.

Private memory still grows during long, varied sessions. The game window's committed memory went from 7.8 to 15.2 GB in one session, more than the game's 9.8 GB, and I have not found why.

The frame rate in busy places

Fps drop in busy places such as Kakariko Village and Castle Town. In one 30-minute session it went from 62 fps (first two thirds) to 46 fps (last third): draws from ~930 to ~1,600 a frame, replay from 8.8 to 13.8 ms, GPU from 9.7 to 12.9 ms. I don't think these switches cause it: the cost rises with what the scene draws, and the replay thread (CPU) is the limit here. It has not been A/B tested in those places.

Other findings on this machine

Not tested

  • NVIDIA, Intel, Linux (RADV), other AMD cards (desktop, RDNA2 and older), other AMD drivers.
  • The rebased build in the game.
  • PCs with 16 GB of RAM; a new install with empty caches; the binary cache together with RX_VK_GPL=1; pipelines that come back with more than one binary (all of mine had one); a binary file damaged by a crash mid-write (records cut short are written over, but this was not forced).
  • No new game-free checks yet. I would add them for the binary cache's file format and the wait budget if the direction is welcome.

Tests

  • Builds with llvm-mingw (Windows, RX_VK_ONLY=1 RX_RELEASE=1 nx2/build.py --standalone).
  • python -I -B tests/run_all.py on Windows: 449 passed, 11 failed, 35 skipped, the same as clean main (51da0464) on the same PC, with the same 11 failures (builder, portable archive, updater digest, test_input_background, test_gpu_hang_watch, test_spawn_emit, test_fpx_sticky, nx2/test_nup_paths.py: environment ones, nothing these changes touch).
  • Two checks that read the source needed updating: tests/test_draw_side_effects.py (its fixture gets stubs for texture_frame_no, jobs_wait_for and logf, which the budgeted wait uses; with the budget unset the decisions it checks are unchanged, under RX_VK_PIPE_SKIP unset, 0 and 2), and tests/test_precompile_pipeline_cleanup.py (no change to the test: the release branch was rewritten so the block it extracts still compiles).

See #993, #995. Related: #2760, #1946, #2649, #2759, #1906.

This was developed with AI assistance (Claude Code). The measurements come from my own runs of the game.

🤖 Generated with Claude Code

ZurielAtr and others added 3 commits November 12, 2026 08:55
… budget for AMD Windows

All off by default:
- RX_VK_BINARY_CACHE=1: pipelines kept one by one as VK_KHR_pipeline_binary data in
  hookshot_pipeline_binaries.bin (index in memory, data read per pipeline); RX_VK_PCACHE_SKIP=1
  then leaves mkw_vk_pipelines.bin unread and unwritten. RX_VK_BINARY_TEST=<n>: a measurement.
- RX_VK_PRECOMPILE_GRAPHICS=keep|release|none: whether the precompiled graphics pipelines and
  their programs stay resident.
- RX_VK_PIPE_WAIT_MS=<ms>: a per-frame budget for waits on pipelines of draws that may be left
  out (jobs_wait_for: the job goes to the queue's front, no inline run).
- RX_VK_PIPE_TRY_CACHED=<ms>: cache hits made at the draw instead of queued behind compiles.
- In-play graphics pipelines over 50 ms are logged.

Measured on an RX 7700S (AMD 2.0.416, Windows 11) on v2.1.0; see the PR for numbers and what
is not tested.

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e check's fixture on Linux

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cleanup check extracts, which stays as main's

Assisted-by: Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.

1 participant