perf(prepare): build fewer predicted pipelines, size the pools to the usable CPUs, and keep translated programs across cache-code changes #2323

Merged
hckrman101 merged 19 commits from perf/faster-prepare into main 2026-11-11 18:30:57 +00:00
hckrman101 commented 2026-11-11 15:03:48 +00:00 (Migrated from github.com)

What changes

A fresh prepare (first start after a build or an update, or the Builder's --prepare-shaders) is mostly the driver compiling pipelines. This PR makes it build fewer pipelines that no draw can use, run its thread pools at the right size, and stop throwing away every player's translated programs when only the cache code changes. It also fixes a gap where some predicted pipelines were never built, and adds clearer log lines.

  1. Skip predicted pipelines no draw can ask for. The seed builds every skinned program three times: with float bone inputs, with unsigned integer bone inputs at attribute 6, and at 6 and 7. All three were given the vertex layout that their signature's notes used most. A draw with that layout resolves to exactly one of those three program keys (graphics_key puts the input types in the key), so the other two pipelines could never be looked up. Each of them was still a full driver compile with its own modules.
    • The predictor now reads the types of each module's input and output locations from its SPIR-V.
    • It drops a candidate layout or target set only when the types definitely differ: an integer where the module has a float, a float where it has an integer, or the other signedness. Scaled types count as integers of their signedness, because make_profile turns support_scaled_attributes off on every GPU.
    • A program whose module has notes of its own keeps every class those notes had that fits. A program that falls back on its signature (the notes of other modules with the same input or output locations) now gets the most common class that fits it, so each bone-input variant gets a layout of its own types instead of none.
    • As a self-check, the log counts the notes (real draws) whose own types fail the rule. It should be 0, and it is 0 on all 15,330 notes of the current shipped list.
  2. Build each predicted pipeline once. predict_run skips a prediction whose pipeline_key (with the program's mesh or mesh-fallback form) was already built for that program key. Some vertex classes differ only in dynamic state (exact topology, stride), and mesh programs have no vertex layout, so those predictions were the same pipeline.
  3. Coverage fix. A borrowed program (program_cache_load(..., keep=true)) now also includes the records already read into memory (disk_taken). Before, the predicted pipelines of programs that the state list had loaded were silently never built: 64,797 predicted, 64,325 made, and nothing in the log said why. Now "made" plus skipped duplicates equals "predicted".
  4. The deciding pass reads the program cache on up to 16 threads, each with its own file handle, in runs of 64 records (as program_cache_load_some does). It was one record at a time on one shared handle under a lock.
  5. Thread pools follow the CPUs the process may use. The pools counted every CPU of the PC (hardware_concurrency), not the ones its affinity mask allows: started on 12 of 24 CPUs, the prediction and the precompile ran 24 threads each. usable_cpus() counts the process's affinity mask (GetProcessAffinityMask on Windows, sched_getaffinity through host/posix on Linux). The prediction, the precompile and the program cache readers use it. The translation, the state list and the stage libraries keep half the PC's CPUs, but never more than usable_cpus() (half_cpus()); halving the usable count instead made the translation about 30% slower in a test.
  6. The translated programs survive changes to the cache code. hookshot_programs.bin (6.9 GB with the whole game) is only valid for the build's translator stamp, and nx2/build.py hashes all of vk_shader.cpp for it. The cache's reads, the prediction's reads and the pool sizes lived in that file too, so any change to how the cache is read or scheduled (items 3-5 above, for example) made every player translate the whole game again.
    • What a record writes byte for byte from vk_shader.h (Program::Answer, the questions, Stage::c1, the head's words), which the translator stamp does not hash, is pinned by static_asserts beside kProgramCacheFormat: a layout change stops the build until the format is raised.
    • The cache code moves to gpu/vk/vk_progcache.cpp / .h, outside the hashed files: disk_init, the record and index format, program_cache_save, _read, _load, _load_some, _borrow, _keys, _each, usable_cpus, half_cpus.
    • The cache, its index, the pipeline notes and hookshot_prepared.bin are now stamped with program_cache_stamp(): the translator's stamp (translator_stamp() in vk_shader.cpp, the same inputs disk_stamp had) mixed with a new kProgramCacheFormat. A change to what a record holds, or to how the file or its index is laid out, must raise kProgramCacheFormat. The old cache is then not read, and the next start prepares again, as after a translator change.
    • nx2/build.py's stamp block and its mirrors in nx2/bake.py / nx2/bake_mimo.py are unchanged (same file list). The seed reader (seed_open) and the seed's translation drivers stay in vk_shader.cpp: they parse stage code and decide what is translated.
    • Keying each program by its own translation (so that only the programs a translator change affects are redone) is not done here. The program keys are already content keys of the stage code and inputs. What is missing is knowing, without translating again, whether a translator change alters a given program's SPIR-V.
  7. An unfinished prediction is finished at the next start. The predicted pipelines are built after the programs are translated and hookshot_prepared.bin says so. A start closed (or ended) during the prediction left the rest unbuilt for good: the next start found the programs prepared and skipped the prediction, so play compiled those pipelines when they were first drawn. predict_run now keeps hookshot_pipelines.bin.predicting from its start to its end, and the Builder's run, the start-up screen and the console start run the prediction again when it is there. What the driver kept from the first try comes back from its cache quickly. After two starts that ended inside it (a driver that hangs or ends the process on one of these pipelines), the marker is let go and the rest are left to play, as precompile_run does with its notes. Closing the window during the prediction does not count toward those two: the marker keeps its count, and the next start goes on.
  8. Check that the driver kept the predicted pipelines. They are built into the driver's own disk cache only. On a driver without one, or with one too small (NVIDIA on Windows takes its size from the control panel), that work leaves nothing, and nothing said so. predict_run keeps a sample of 32 of them (hookshot_pipelines.bin.predicted), and the next game start asks the driver for each on an idle thread behind the game, without compiling and as play asks: vk: the driver's shader cache kept N of M predicted pipelines asked. It needs pipelineCreationCacheControl (as play's own asks do) and does not run in a --prepare-shaders run, which exits right after. The driver cache folders' size is logged at the start and after the prediction. RX_VK_NO_PREDICT_CHECK=1 turns the check off.
  9. Log which program fails and why. The prepare's summary said "1 failed" and nothing else. A seed stage that does not parse now logs the program, its NVN stage and the reason. On the current seed it is one compute program, which no local play folder has ever dispatched: vk: program 79116b290d46542f not translated: its NVN stage 5 (code 79116b290d46542f) the branch at instruction 16404 (+0x40140) goes outside the program.
  10. Progress bar guess. prepare_guess counted one predicted pipeline per seed program (38,809). The prediction now makes 56,588 with the shipped list, so the guess is one and a half per program. Before, the bar ran ahead through the translation and then slowed down once the real count replaced the guess.

The log line of the prediction reports the new counts, for example: 56168 made, 420 the same pipeline as another ... left out as no draw could ask for them: 8209 by their vertex input types, 0 by their colour output types (7555 program variants with none left; 0 notes whose own types do not fit; 0 took a less common layout or targets that fit).

New tests: tests/test_predict_types.py (the SPIR-V type reader and fit rule, compiled on hand-made SPIR-V, plus source checks for items 2-3), tests/test_prepare_cpus.py (usable_cpus / half_cpus against stand-in masks, and which pools use them), tests/test_program_cache_format.py (runs nx2/build.py's real stamp block: an edit to vk_progcache.cpp/.h keeps the stamp, an edit to vk_shader.cpp changes it; and the stamps' sources), tests/test_predict_resume.py and tests/test_predicted_check.py (source checks for items 7 and 8). The tests that read the cache code from vk_shader.cpp now read vk_progcache.cpp/.h beside it.

Note for anyone working on the stamp or the bake (#1707): the translator stamp's file list did not change. HOOKSHOT_SHADER_STAMP still covers vk_shader.cpp, vk_bank.cpp, vk_state.h, vk_api.h and the recompiler folders. What changed is the runtime stamp that the program cache, the notes and hookshot_prepared.bin carry: program_cache_stamp() = mix(translator_stamp(), kProgramCacheFormat), where translator_stamp() is the old disk_stamp(). The record layout (put_head/get_head, put_info/get_info, program_cache_save/_read) now lives in vk_progcache.cpp and is versioned by kProgramCacheFormat (1).

Because this PR changes vk_shader.cpp and the cache stamp, existing installs prepare once more after updating. Later changes to vk_progcache.cpp or the scheduling in vk_draw.cpp no longer cause that.

Relation to #2248

#2248 (klimovich008) and item 1 fix the same bug, and they edit the same lines of predict_pipelines and Face in vk_predict.cpp, so only one of them should land. The bug: the prediction's fallback gave a program the vertex layout most common among shaders with the same input locations, whatever their types, so some predicted pipelines had vertex attribute formats of another numeric type than the shader's inputs (VUID-08733, which #2248 reports on an RX 9070 XT).

  • #2248 (head 5d34f63e) keys the fallback by a signature of locations and numeric types, and gives an input interface it cannot classify (grouped decorations, component-packed inputs, other widths) only its exact module's notes. On their AMD subset that took the predictions from 276 to 4, so most of the prediction goes.
  • This PR drops the same wrong-type guesses and keeps the rest. On the shipped list it leaves out the 8,209 predicted pipelines whose vertex inputs are of another numeric type than the shader reads (scaled formats are kept) and builds the other 56,588: about 87% of what main predicted. It applies the same check to the colour outputs (0 left out today), and none of the 15,330 real notes fails it.
  • Items 2-10 do not depend on #2248. If #2248 lands first, this branch keeps its version of those lines on top. #2248's fixture in tests/test_predict_sees_loaded.py compiles vk_predict.cpp with stand-ins and would need stand-ins for what this branch adds there (Locations, PredictCounts, attribute_type_of, note_color_types).

Measured

Setup: RTX 3090, Ryzen 9 7900X, Linux, NVIDIA driver. Each timed run was a cold hookshot --prepare-shaders in a reflinked copy of a game folder built from main: no program cache, notes, pipeline cache or computes list, an empty __GL_SHADER_DISK_CACHE_PATH, headless. The shipped list was 25,251 pipeline states (15,330 notes). Every timed run held the exclusive timing lock, which gives it 12 logical CPUs (6 cores) and moves builds to the other CPUs.

The PC was shared with other work all night and the next day (other test games and prepares running beside some runs, load averages of 20-50), so the seconds are noisy; base and new runs were interleaved. The translation step runs below normal priority, so it suffers most from that load. The main numbers are from the night's runs on main fab8cb37 and were not taken again for the final head. Main's later commits merged here (#2042 switches, #2195 the prediction's stall guard, #1823 the driver-fault quarantine, #2363 the driver cache folder) change neither the work the prepare does nor its thread counts, but the final head's run is a single run on a loaded PC.

Whole prepare (wall), seconds:

runs
main (fab8cb37) 733.6, 712.0 (earlier, quieter), 728.7
items 1-4 only 666.3, 665.3 (earlier, quieter), 708.0, 708.1
items 1-5, 9 and 10 699.6, 659.5, 679.4 (each with another test game running)
final branch before the last main merge (62711af6, items 1-10 and the review fixes) 661.1

By step (the same runs; main / items 1-4 / items 1-5):

step main items 1-4 items 1-5 62711af6
translation 187-205 s 190-213 s 223-248 s (contended) 198.2 s
prediction 379-384 s on 24 threads (64,797 pipelines, 64,325 built) 307-315 s on 24 threads (56,588: 56,168 built + 420 duplicates) 271-280 s on 12 threads 279.0 s on 12 threads (56,168 built + 420 duplicates)
precompile (noted) 145-147 s 166-180 s 159-172 s 178.2 s
  • The prediction is about 105 s shorter than on main (about 28%). About 70 s of that is building fewer pipelines (items 1-2), and about 35 s is running 12 threads instead of 24 on the 12 CPUs the process may use (item 5).
  • The precompile got 15-30 s longer. The dropped float bone-input variants shared fragment modules with noted programs, and building them used to put those modules in the driver's cache before the precompile. NVIDIA's driver cache appears to work per shader stage, so a pipeline's cost is mostly its not-yet-compiled modules.
  • Whole prepare: about 675 s on average for this branch (659.5-699.6 s, and 661.1 s for the final code) against about 725 s for main, roughly 7-9%, with a large spread.

Predicted pipelines, shipped list (the prediction's log line; the same in every run):

main this branch
predicted 64,797 56,588
built 64,325 (472 silently never built: item 3) 56,168
left out: same pipeline_key as another (item 2) - 420
left out: a vertex input of another numeric type (item 1) - 8,209
left out: a colour output of another numeric type (item 1) - 0
program variants left with no prediction - 7,555
program variants whose signature fallback took a less common class that fits - 0
notes (real draws) that fail the type rule - 0 of 15,330

The 8,209 left out are the kind of pipeline VUID-08733 is about: their vertex attribute formats are of another numeric type than the shader reads (scaled formats not counted), so no draw can ask for them (graphics_key puts the input types in the program key). This branch builds none of them; main built all of them.

The 7,555 program variants left with no prediction are the bone-input variants whose signature's notes all have the other input types: no note anywhere has a layout of their own types (the fallback asks the fit before it picks, and found nothing to take for any of them: the "less common class" count is 0). Main gave each a pipeline that no draw can look up. What that warmed in the driver's cache was the variant's own vertex module, which no noted draw has used; a draw that ever does needs a layout of these types, which no note has to predict from.

Thread count for the prediction alone (programs kept, cold driver cache, same 12 CPUs): 6 threads 302.0 s, 12 threads 271.0 s, 24 threads 313-315 s. One thread per logical CPU is best here, so the prediction and precompile use usable_cpus(). Grouping the predicted pipelines by fragment module on one worker (to reuse the driver's per-stage cache) took 283.3 s against 271.0 s: no gain, not kept.

Program cache stamp (item 6), each a run of --prepare-shaders on a folder prepared by this branch:

  • A build with a change only in vk_progcache.cpp: 46676 program keys kept, 6790077 KB, nothing translated, no prediction; the whole run took 28 s.
  • A build with a change only in vk_shader.cpp: 0 program keys kept, the translation starts again.
  • This branch on a folder prepared by the build before it (the old stamp): 0 program keys kept, a full preparation with the same counts as a cold one (38,809 programs, 46,666 records, 56,588 predicted pipelines).

Play after the prepare: a headless start from a Kokiri Forest save, A pressed until play, then the Debug teleports to areas the shipped list covers less (Hyrule Field, Dodongo's Cavern, Jabu-Jabu's Belly, Bottom of the Well), about 40 s in each. This branch (final code, with main merged) ran with its own prepare's driver cache; main (fab8cb37) ran from its prepared folder (the 15,330 shipped notes) with an empty driver cache, so for main only the counts compare, not the milliseconds:

area pipelines built in play, this branch / main programs translated in play waits for shaders or pipelines
Kokiri Forest 0 / 0 0 / 0 0 ms / 0 ms
Hyrule Field 0 / 0 0 / 0 0 ms / 0 ms
Dodongo's Cavern 0 / 0 0 / 0 0 ms / 0 ms
Jabu-Jabu's Belly 21 / 21 (374 ms from the driver's cache / 2,563 ms compiled) 0 / 0 0 ms / 0 ms
Bottom of the Well 0 / 0 0 / 0 0 ms / 0 ms

Both had draws left out in one 2-second window, at Jabu-Jabu's load. The 21 pipelines there are neither noted nor predicted by main or this branch. The start's check said the driver's shader cache kept 32 of 32 predicted pipelines asked (... in 216 ms).

Items 7 and 8 at the next start (final code, main merged):

  • Resume (item 7): a prepare on kept programs (RX_VK_PREPARE_AGAIN=1) was ended with kill -9 40 s into the prediction (9,247 of 56,588 built), which left hookshot_pipelines.bin.predicting at 1. The next --prepare-shaders run translated nothing (0 new), logged the last start did not finish building the predicted pipelines (it ended in them): they are built again, built all of them again (56168 made, 420 the same pipeline as another, 120.6 s, with the first run's driver cache, 263 MB at its start) and removed the marker.
  • Driver-cache check (item 8): at a game start with the prepare's driver cache, kept 32 of 32 predicted pipelines asked (... in 216 ms). At a start with an empty driver cache, kept 1 of 32 (the start's precompile had just compiled the 15,330 noted pipelines into it, and one sampled pipeline matched one of them: same modules and state under another program variant).

Possible future improvement (not in this PR)

About 77% of the translation step is the driver compiling compute pipelines: 9,925 of them, for 4,127 compute programs and their texture-answer variants (each program is tried with every texture answer that changes its SPIR-V, up to 64 variants). Across 39 local play folders, only 606 of the 4,127 compute programs were ever dispatched. Building every variant only for programs on a shipped "dispatched" list, and the base variant alone for the rest, would remove about 4,800 compute compiles: roughly 70 s of a fresh prepare and 0.45 GB of program cache. The risk is a hitch in play if an unlisted program is used with a non-base variant. This PR keeps compiling everything.

Also for later, from review: the driver-cache check runs at idle priority but borrows programs through the program cache's lock, which play's threads take too, so a busy PC could hold play up behind it; driver_cache_log walks the whole driver cache folder twice per prepare (cheap on NVIDIA's few large files, maybe not on Mesa's); and on Windows PCs with more than 64 logical CPUs usable_cpus() reads one processor group only.

Also for later: stage libraries (RX_VK_GPL) would cover every vertex layout and target set for two compiles per program. The program cache holds 17,247 distinct vertex and 23,155 distinct fragment SPIR-V modules for about 43,000 graphics programs; how many of those share a descriptor layout (which a library also depends on) was not counted here. Translating the seed on all usable logical CPUs instead of half (the start-up screen holds the game anyway) needs a measurement on a whole PC, which the shared timing slot (6 cores) could not give.

Not tested

  • AMD, Intel and Windows drivers. The thread counts and the program cache move are plain code, but the timings above are NVIDIA on Linux only. On Windows, usable_cpus() reads GetProcessAffinityMask, which covers one processor group: on PCs with more than 64 logical CPUs it falls back to all of them.
  • Item 7 was run with the process ended by force (kill -9) during the prediction, not with the window closed. Closing the window (the marker keeps its count) is covered by the source tests only.
  • The VUID-08733 count with the Khronos validation layer: not taken on the NVIDIA PC. A before/after count, with prepare timings, is running on an AMD RX 580 (Mesa RADV) and will be added here. The prediction's own type check (table above) is the same comparison: 8,209 predicted pipelines on main had a vertex input of another numeric type than their shader; this branch builds none of them.
  • A full playthrough. The play check above covers five areas for about 40 s each, not every area or cutscene.
  • Hard disks and 16 GB PCs: the multithreaded deciding pass and the program cache readers were only measured with the program cache in memory.
  • The driver cache folder sizes on Windows (%LOCALAPPDATA%\NVIDIA\GLCache, AMD\VkCache, Intel\ShaderCache) are a best guess of where those drivers keep it.
  • Not done: the driver's shader cache size on Windows (driver_cache_room stays Linux-only: NVIDIA on Windows takes it from the control panel, and nothing here could check an environment variable there) and __GL_SHADER_DISK_CACHE_SKIP_CLEANUP (it stops the driver from ever trimming its cache, which would grow by about 2 GB with every update's preparation).
## What changes A fresh prepare (first start after a build or an update, or the Builder's `--prepare-shaders`) is mostly the driver compiling pipelines. This PR makes it build fewer pipelines that no draw can use, run its thread pools at the right size, and stop throwing away every player's translated programs when only the cache code changes. It also fixes a gap where some predicted pipelines were never built, and adds clearer log lines. 1. **Skip predicted pipelines no draw can ask for.** The seed builds every skinned program three times: with float bone inputs, with unsigned integer bone inputs at attribute 6, and at 6 and 7. All three were given the vertex layout that their signature's notes used most. A draw with that layout resolves to exactly one of those three program keys (`graphics_key` puts the input types in the key), so the other two pipelines could never be looked up. Each of them was still a full driver compile with its own modules. - The predictor now reads the types of each module's input and output locations from its SPIR-V. - It drops a candidate layout or target set only when the types definitely differ: an integer where the module has a float, a float where it has an integer, or the other signedness. Scaled types count as integers of their signedness, because `make_profile` turns `support_scaled_attributes` off on every GPU. - A program whose module has notes of its own keeps every class those notes had that fits. A program that falls back on its signature (the notes of other modules with the same input or output locations) now gets the most common class *that fits it*, so each bone-input variant gets a layout of its own types instead of none. - As a self-check, the log counts the notes (real draws) whose own types fail the rule. It should be 0, and it is 0 on all 15,330 notes of the current shipped list. 2. **Build each predicted pipeline once.** `predict_run` skips a prediction whose `pipeline_key` (with the program's mesh or mesh-fallback form) was already built for that program key. Some vertex classes differ only in dynamic state (exact topology, stride), and mesh programs have no vertex layout, so those predictions were the same pipeline. 3. **Coverage fix.** A borrowed program (`program_cache_load(..., keep=true)`) now also includes the records already read into memory (`disk_taken`). Before, the predicted pipelines of programs that the state list had loaded were silently never built: 64,797 predicted, 64,325 made, and nothing in the log said why. Now "made" plus skipped duplicates equals "predicted". 4. **The deciding pass reads the program cache on up to 16 threads**, each with its own file handle, in runs of 64 records (as `program_cache_load_some` does). It was one record at a time on one shared handle under a lock. 5. **Thread pools follow the CPUs the process may use.** The pools counted every CPU of the PC (`hardware_concurrency`), not the ones its affinity mask allows: started on 12 of 24 CPUs, the prediction and the precompile ran 24 threads each. `usable_cpus()` counts the process's affinity mask (`GetProcessAffinityMask` on Windows, `sched_getaffinity` through host/posix on Linux). The prediction, the precompile and the program cache readers use it. The translation, the state list and the stage libraries keep half the PC's CPUs, but never more than `usable_cpus()` (`half_cpus()`); halving the usable count instead made the translation about 30% slower in a test. 6. **The translated programs survive changes to the cache code.** `hookshot_programs.bin` (6.9 GB with the whole game) is only valid for the build's translator stamp, and `nx2/build.py` hashes all of `vk_shader.cpp` for it. The cache's reads, the prediction's reads and the pool sizes lived in that file too, so any change to how the cache is read or scheduled (items 3-5 above, for example) made every player translate the whole game again. - What a record writes byte for byte from `vk_shader.h` (`Program::Answer`, the questions, `Stage::c1`, the head's words), which the translator stamp does not hash, is pinned by `static_assert`s beside `kProgramCacheFormat`: a layout change stops the build until the format is raised. - The cache code moves to `gpu/vk/vk_progcache.cpp` / `.h`, outside the hashed files: `disk_init`, the record and index format, `program_cache_save`, `_read`, `_load`, `_load_some`, `_borrow`, `_keys`, `_each`, `usable_cpus`, `half_cpus`. - The cache, its index, the pipeline notes and `hookshot_prepared.bin` are now stamped with `program_cache_stamp()`: the translator's stamp (`translator_stamp()` in `vk_shader.cpp`, the same inputs `disk_stamp` had) mixed with a new `kProgramCacheFormat`. A change to what a record holds, or to how the file or its index is laid out, must raise `kProgramCacheFormat`. The old cache is then not read, and the next start prepares again, as after a translator change. - `nx2/build.py`'s stamp block and its mirrors in `nx2/bake.py` / `nx2/bake_mimo.py` are unchanged (same file list). The seed reader (`seed_open`) and the seed's translation drivers stay in `vk_shader.cpp`: they parse stage code and decide what is translated. - Keying each program by its own translation (so that only the programs a translator change affects are redone) is not done here. The program keys are already content keys of the stage code and inputs. What is missing is knowing, without translating again, whether a translator change alters a given program's SPIR-V. 7. **An unfinished prediction is finished at the next start.** The predicted pipelines are built after the programs are translated and `hookshot_prepared.bin` says so. A start closed (or ended) during the prediction left the rest unbuilt for good: the next start found the programs prepared and skipped the prediction, so play compiled those pipelines when they were first drawn. `predict_run` now keeps `hookshot_pipelines.bin.predicting` from its start to its end, and the Builder's run, the start-up screen and the console start run the prediction again when it is there. What the driver kept from the first try comes back from its cache quickly. After two starts that ended inside it (a driver that hangs or ends the process on one of these pipelines), the marker is let go and the rest are left to play, as `precompile_run` does with its notes. Closing the window during the prediction does not count toward those two: the marker keeps its count, and the next start goes on. 8. **Check that the driver kept the predicted pipelines.** They are built into the driver's own disk cache only. On a driver without one, or with one too small (NVIDIA on Windows takes its size from the control panel), that work leaves nothing, and nothing said so. `predict_run` keeps a sample of 32 of them (`hookshot_pipelines.bin.predicted`), and the next game start asks the driver for each on an idle thread behind the game, without compiling and as play asks: `vk: the driver's shader cache kept N of M predicted pipelines asked`. It needs `pipelineCreationCacheControl` (as play's own asks do) and does not run in a `--prepare-shaders` run, which exits right after. The driver cache folders' size is logged at the start and after the prediction. `RX_VK_NO_PREDICT_CHECK=1` turns the check off. 9. **Log which program fails and why.** The prepare's summary said "1 failed" and nothing else. A seed stage that does not parse now logs the program, its NVN stage and the reason. On the current seed it is one compute program, which no local play folder has ever dispatched: `vk: program 79116b290d46542f not translated: its NVN stage 5 (code 79116b290d46542f) the branch at instruction 16404 (+0x40140) goes outside the program`. 10. **Progress bar guess.** `prepare_guess` counted one predicted pipeline per seed program (38,809). The prediction now makes 56,588 with the shipped list, so the guess is one and a half per program. Before, the bar ran ahead through the translation and then slowed down once the real count replaced the guess. The log line of the prediction reports the new counts, for example: `56168 made, 420 the same pipeline as another ... left out as no draw could ask for them: 8209 by their vertex input types, 0 by their colour output types (7555 program variants with none left; 0 notes whose own types do not fit; 0 took a less common layout or targets that fit)`. New tests: `tests/test_predict_types.py` (the SPIR-V type reader and fit rule, compiled on hand-made SPIR-V, plus source checks for items 2-3), `tests/test_prepare_cpus.py` (`usable_cpus` / `half_cpus` against stand-in masks, and which pools use them), `tests/test_program_cache_format.py` (runs `nx2/build.py`'s real stamp block: an edit to `vk_progcache.cpp`/`.h` keeps the stamp, an edit to `vk_shader.cpp` changes it; and the stamps' sources), `tests/test_predict_resume.py` and `tests/test_predicted_check.py` (source checks for items 7 and 8). The tests that read the cache code from `vk_shader.cpp` now read `vk_progcache.cpp`/`.h` beside it. **Note for anyone working on the stamp or the bake (#1707):** the translator stamp's file list did not change. `HOOKSHOT_SHADER_STAMP` still covers `vk_shader.cpp`, `vk_bank.cpp`, `vk_state.h`, `vk_api.h` and the recompiler folders. What changed is the runtime stamp that the program cache, the notes and `hookshot_prepared.bin` carry: `program_cache_stamp() = mix(translator_stamp(), kProgramCacheFormat)`, where `translator_stamp()` is the old `disk_stamp()`. The record layout (`put_head`/`get_head`, `put_info`/`get_info`, `program_cache_save`/`_read`) now lives in `vk_progcache.cpp` and is versioned by `kProgramCacheFormat` (1). Because this PR changes `vk_shader.cpp` and the cache stamp, existing installs prepare once more after updating. Later changes to `vk_progcache.cpp` or the scheduling in `vk_draw.cpp` no longer cause that. ## Relation to #2248 #2248 (klimovich008) and item 1 fix the same bug, and they edit the same lines of `predict_pipelines` and `Face` in `vk_predict.cpp`, so only one of them should land. The bug: the prediction's fallback gave a program the vertex layout most common among shaders with the same input *locations*, whatever their types, so some predicted pipelines had vertex attribute formats of another numeric type than the shader's inputs (VUID-08733, which #2248 reports on an RX 9070 XT). - #2248 (head 5d34f63e) keys the fallback by a signature of locations *and* numeric types, and gives an input interface it cannot classify (grouped decorations, component-packed inputs, other widths) only its exact module's notes. On their AMD subset that took the predictions from 276 to 4, so most of the prediction goes. - This PR drops the same wrong-type guesses and keeps the rest. On the shipped list it leaves out the 8,209 predicted pipelines whose vertex inputs are of another numeric type than the shader reads (scaled formats are kept) and builds the other 56,588: about 87% of what main predicted. It applies the same check to the colour outputs (0 left out today), and none of the 15,330 real notes fails it. - Items 2-10 do not depend on #2248. If #2248 lands first, this branch keeps its version of those lines on top. #2248's fixture in `tests/test_predict_sees_loaded.py` compiles `vk_predict.cpp` with stand-ins and would need stand-ins for what this branch adds there (`Locations`, `PredictCounts`, `attribute_type_of`, `note_color_types`). ## Measured Setup: RTX 3090, Ryzen 9 7900X, Linux, NVIDIA driver. Each timed run was a cold `hookshot --prepare-shaders` in a reflinked copy of a game folder built from main: no program cache, notes, pipeline cache or computes list, an empty `__GL_SHADER_DISK_CACHE_PATH`, headless. The shipped list was 25,251 pipeline states (15,330 notes). Every timed run held the exclusive timing lock, which gives it 12 logical CPUs (6 cores) and moves builds to the other CPUs. The PC was shared with other work all night and the next day (other test games and prepares running beside some runs, load averages of 20-50), so the seconds are noisy; base and new runs were interleaved. The translation step runs below normal priority, so it suffers most from that load. The main numbers are from the night's runs on main fab8cb37 and were not taken again for the final head. Main's later commits merged here (#2042 switches, #2195 the prediction's stall guard, #1823 the driver-fault quarantine, #2363 the driver cache folder) change neither the work the prepare does nor its thread counts, but the final head's run is a single run on a loaded PC. Whole prepare (wall), seconds: | | runs | |---|---| | main (fab8cb37) | 733.6, 712.0 (earlier, quieter), 728.7 | | items 1-4 only | 666.3, 665.3 (earlier, quieter), 708.0, 708.1 | | items 1-5, 9 and 10 | 699.6, 659.5, 679.4 (each with another test game running) | | final branch before the last main merge (62711af6, items 1-10 and the review fixes) | 661.1 | By step (the same runs; main / items 1-4 / items 1-5): | step | main | items 1-4 | items 1-5 | 62711af6 | |---|---|---|---|---| | translation | 187-205 s | 190-213 s | 223-248 s (contended) | 198.2 s | | prediction | 379-384 s on 24 threads (64,797 pipelines, 64,325 built) | 307-315 s on 24 threads (56,588: 56,168 built + 420 duplicates) | 271-280 s on 12 threads | 279.0 s on 12 threads (56,168 built + 420 duplicates) | | precompile (noted) | 145-147 s | 166-180 s | 159-172 s | 178.2 s | - The prediction is about 105 s shorter than on main (about 28%). About 70 s of that is building fewer pipelines (items 1-2), and about 35 s is running 12 threads instead of 24 on the 12 CPUs the process may use (item 5). - The precompile got 15-30 s longer. The dropped float bone-input variants shared fragment modules with noted programs, and building them used to put those modules in the driver's cache before the precompile. NVIDIA's driver cache appears to work per shader stage, so a pipeline's cost is mostly its not-yet-compiled modules. - Whole prepare: about 675 s on average for this branch (659.5-699.6 s, and 661.1 s for the final code) against about 725 s for main, roughly 7-9%, with a large spread. Predicted pipelines, shipped list (the prediction's log line; the same in every run): | | main | this branch | |---|---|---| | predicted | 64,797 | 56,588 | | built | 64,325 (472 silently never built: item 3) | 56,168 | | left out: same `pipeline_key` as another (item 2) | - | 420 | | left out: a vertex input of another numeric type (item 1) | - | 8,209 | | left out: a colour output of another numeric type (item 1) | - | 0 | | program variants left with no prediction | - | 7,555 | | program variants whose signature fallback took a less common class that fits | - | 0 | | notes (real draws) that fail the type rule | - | 0 of 15,330 | The 8,209 left out are the kind of pipeline VUID-08733 is about: their vertex attribute formats are of another numeric type than the shader reads (scaled formats not counted), so no draw can ask for them (`graphics_key` puts the input types in the program key). This branch builds none of them; main built all of them. The 7,555 program variants left with no prediction are the bone-input variants whose signature's notes all have the other input types: no note anywhere has a layout of their own types (the fallback asks the fit before it picks, and found nothing to take for any of them: the "less common class" count is 0). Main gave each a pipeline that no draw can look up. What that warmed in the driver's cache was the variant's own vertex module, which no noted draw has used; a draw that ever does needs a layout of these types, which no note has to predict from. Thread count for the prediction alone (programs kept, cold driver cache, same 12 CPUs): 6 threads 302.0 s, 12 threads 271.0 s, 24 threads 313-315 s. One thread per logical CPU is best here, so the prediction and precompile use `usable_cpus()`. Grouping the predicted pipelines by fragment module on one worker (to reuse the driver's per-stage cache) took 283.3 s against 271.0 s: no gain, not kept. Program cache stamp (item 6), each a run of `--prepare-shaders` on a folder prepared by this branch: - A build with a change only in `vk_progcache.cpp`: `46676 program keys kept, 6790077 KB`, nothing translated, no prediction; the whole run took 28 s. - A build with a change only in `vk_shader.cpp`: `0 program keys kept`, the translation starts again. - This branch on a folder prepared by the build before it (the old stamp): `0 program keys kept`, a full preparation with the same counts as a cold one (38,809 programs, 46,666 records, 56,588 predicted pipelines). Play after the prepare: a headless start from a Kokiri Forest save, A pressed until play, then the Debug teleports to areas the shipped list covers less (Hyrule Field, Dodongo's Cavern, Jabu-Jabu's Belly, Bottom of the Well), about 40 s in each. This branch (final code, with main merged) ran with its own prepare's driver cache; main (fab8cb37) ran from its prepared folder (the 15,330 shipped notes) with an empty driver cache, so for main only the counts compare, not the milliseconds: | area | pipelines built in play, this branch / main | programs translated in play | waits for shaders or pipelines | |---|---|---|---| | Kokiri Forest | 0 / 0 | 0 / 0 | 0 ms / 0 ms | | Hyrule Field | 0 / 0 | 0 / 0 | 0 ms / 0 ms | | Dodongo's Cavern | 0 / 0 | 0 / 0 | 0 ms / 0 ms | | Jabu-Jabu's Belly | 21 / 21 (374 ms from the driver's cache / 2,563 ms compiled) | 0 / 0 | 0 ms / 0 ms | | Bottom of the Well | 0 / 0 | 0 / 0 | 0 ms / 0 ms | Both had draws left out in one 2-second window, at Jabu-Jabu's load. The 21 pipelines there are neither noted nor predicted by main or this branch. The start's check said `the driver's shader cache kept 32 of 32 predicted pipelines asked (... in 216 ms)`. Items 7 and 8 at the next start (final code, main merged): - **Resume (item 7):** a prepare on kept programs (`RX_VK_PREPARE_AGAIN=1`) was ended with `kill -9` 40 s into the prediction (9,247 of 56,588 built), which left `hookshot_pipelines.bin.predicting` at 1. The next `--prepare-shaders` run translated nothing (`0 new`), logged `the last start did not finish building the predicted pipelines (it ended in them): they are built again`, built all of them again (`56168 made, 420 the same pipeline as another`, 120.6 s, with the first run's driver cache, 263 MB at its start) and removed the marker. - **Driver-cache check (item 8):** at a game start with the prepare's driver cache, `kept 32 of 32 predicted pipelines asked (... in 216 ms)`. At a start with an empty driver cache, `kept 1 of 32` (the start's precompile had just compiled the 15,330 noted pipelines into it, and one sampled pipeline matched one of them: same modules and state under another program variant). ## Possible future improvement (not in this PR) About 77% of the translation step is the driver compiling compute pipelines: 9,925 of them, for 4,127 compute programs and their texture-answer variants (each program is tried with every texture answer that changes its SPIR-V, up to 64 variants). Across 39 local play folders, only 606 of the 4,127 compute programs were ever dispatched. Building every variant only for programs on a shipped "dispatched" list, and the base variant alone for the rest, would remove about 4,800 compute compiles: roughly 70 s of a fresh prepare and 0.45 GB of program cache. The risk is a hitch in play if an unlisted program is used with a non-base variant. This PR keeps compiling everything. Also for later, from review: the driver-cache check runs at idle priority but borrows programs through the program cache's lock, which play's threads take too, so a busy PC could hold play up behind it; `driver_cache_log` walks the whole driver cache folder twice per prepare (cheap on NVIDIA's few large files, maybe not on Mesa's); and on Windows PCs with more than 64 logical CPUs `usable_cpus()` reads one processor group only. Also for later: stage libraries (`RX_VK_GPL`) would cover every vertex layout and target set for two compiles per program. The program cache holds 17,247 distinct vertex and 23,155 distinct fragment SPIR-V modules for about 43,000 graphics programs; how many of those share a descriptor layout (which a library also depends on) was not counted here. Translating the seed on all usable logical CPUs instead of half (the start-up screen holds the game anyway) needs a measurement on a whole PC, which the shared timing slot (6 cores) could not give. ## Not tested - AMD, Intel and Windows drivers. The thread counts and the program cache move are plain code, but the timings above are NVIDIA on Linux only. On Windows, `usable_cpus()` reads `GetProcessAffinityMask`, which covers one processor group: on PCs with more than 64 logical CPUs it falls back to all of them. - Item 7 was run with the process ended by force (`kill -9`) during the prediction, not with the window closed. Closing the window (the marker keeps its count) is covered by the source tests only. - The VUID-08733 count with the Khronos validation layer: not taken on the NVIDIA PC. A before/after count, with prepare timings, is running on an AMD RX 580 (Mesa RADV) and will be added here. The prediction's own type check (table above) is the same comparison: 8,209 predicted pipelines on main had a vertex input of another numeric type than their shader; this branch builds none of them. - A full playthrough. The play check above covers five areas for about 40 s each, not every area or cutscene. - Hard disks and 16 GB PCs: the multithreaded deciding pass and the program cache readers were only measured with the program cache in memory. - The driver cache folder sizes on Windows (`%LOCALAPPDATA%\NVIDIA\GLCache`, `AMD\VkCache`, `Intel\ShaderCache`) are a best guess of where those drivers keep it. - Not done: the driver's shader cache size on Windows (`driver_cache_room` stays Linux-only: NVIDIA on Windows takes it from the control panel, and nothing here could check an environment variable there) and `__GL_SHADER_DISK_CACHE_SKIP_CLEANUP` (it stops the driver from ever trimming its cache, which would grow by about 2 GB with every update's preparation).
hckrman101 commented 2026-11-11 20:32:15 +00:00 (Migrated from github.com)

Results on AMD, after the merge: RX 580 (Polaris, Mesa 26.2.6 RADV, no mesh shaders), i7-9700K, 15 GB. Main 7759a6f3 against this PR's final head 12f835d0, one run each.

Cold prepare

main this PR
Translation 390.7 s 393.5 s
Prediction (8 threads) 1,704 s 1,113 s (−35%)
Predicted / built 64,797 / 64,325 56,588 / 56,168 (+420 duplicates)

On RADV the dropped pipelines were also the expensive ones: 26.4 ms per pipeline on main, 19.8 ms here.

VUID-VkGraphicsPipelineCreateInfo-Input-08733, Khronos validation layer 1.4.363, counted during the prediction:

main this PR
9,601 0

All of main's are vertex attributes of an integer format read as float, or the other way round, at locations 6 and 7: the skinned-bone variants. Both builds also report the same 2 × VUID-VkShaderModuleCreateInfo-pCode-08737, which this PR doesn't touch.

Play (Kokiri, then a teleport to Hyrule Field, about 60 s each; the precompile was skipped in both, see below):

  • pipelines built in play: main 1,355, this PR 1,372. This PR's run drew about 5% more frames, so that's within noise;
  • 0 programs translated in play in both;
  • the same picture.

Separate finding, not from this PR: on this card the precompile runs out of memory on main and here alike, past 24 GB. One or two mesh-fallback compute pipelines never finish compiling in RADV/ACO on Polaris. That's being looked into separately.

Results on AMD, after the merge: RX 580 (Polaris, Mesa 26.2.6 RADV, no mesh shaders), i7-9700K, 15 GB. Main 7759a6f3 against this PR's final head 12f835d0, one run each. **Cold prepare** | | main | this PR | |---|---|---| | Translation | 390.7 s | 393.5 s | | Prediction (8 threads) | 1,704 s | **1,113 s (−35%)** | | Predicted / built | 64,797 / 64,325 | 56,588 / 56,168 (+420 duplicates) | On RADV the dropped pipelines were also the expensive ones: 26.4 ms per pipeline on main, 19.8 ms here. **VUID-VkGraphicsPipelineCreateInfo-Input-08733**, Khronos validation layer 1.4.363, counted during the prediction: | main | this PR | |---|---| | 9,601 | **0** | All of main's are vertex attributes of an integer format read as float, or the other way round, at locations 6 and 7: the skinned-bone variants. Both builds also report the same 2 × VUID-VkShaderModuleCreateInfo-pCode-08737, which this PR doesn't touch. **Play** (Kokiri, then a teleport to Hyrule Field, about 60 s each; the precompile was skipped in both, see below): - pipelines built in play: main 1,355, this PR 1,372. This PR's run drew about 5% more frames, so that's within noise; - 0 programs translated in play in both; - the same picture. **Separate finding, not from this PR:** on this card the precompile runs out of memory on main and here alike, past 24 GB. One or two mesh-fallback compute pipelines never finish compiling in RADV/ACO on Polaris. That's being looked into separately.
Commenting is not possible because the repository is archived.
No description provided.