Skip to content

[Proof of concept] Shader precompile from game files: per-stage translation cache checked on every use (see #899) - #900

Draft
the-cashman wants to merge 15 commits into
zortos293:nx2from
the-cashman:pr/shaderwarm-poc
Draft

the-cashman wants to merge 15 commits into
zortos293:nx2from
the-cashman:pr/shaderwarm-poc

Conversation

@the-cashman

Copy link
Copy Markdown

Draft, proof of concept: not for merging as is. This is the code as it runs on our own Lichii-based branch, put on current nx2 so it can be read and compared. It goes with the coordination issue #899, which asks which translation cache nx2 wants and where it should live. Depending on the answer, the useful part would be redone small and on top of #781's precache (or on another cache format). This exact branch was not build-checked or re-measured on current nx2. The commits were moved from our branch with conflicts resolved by hand (mesh emulation from #569 in vk_graphics_pipeline/pipeline_helper, the cache carry-over and record lock in vk_pipeline_cache/shader_environment). The measurements below are from our branch.

What it does

Switch 2 titles stutter the first time a new shader is drawn. The emulator translates the stage on the GPU thread, then the driver compiles the pipeline. This removes both ahead of time, from the user's own copy of the game, with no recorded play:

  • Per-stage translation cache (<shader dir>/<title id>/vulkan_ir.bin, nx2_ir_cache.*). A stage's translation depends only on its own program and its environment answers, not on the pipeline key. So the IR after TranslateProgram is stored per stage, together with every answer it used.
  • Precompile (EDEN_NX2_PRECOMPILE=<pack>, RunPrecompile in vk_shaderwarm.cpp). During boot, on low-priority threads, every stage program of a pack is lowered and translated with default answers (nx2_precompile_env.*): 2D float textures, mapped storage pointers, the program's own bank-1 constants. Each stage is then checked: the stored form must give the same SPIR-V. The pack lists program blobs found by a generic scan of the game's files (outside the tree; see "Not included").
  • Checked on every use (TryCachedTranslation). A record applies only if every recorded answer is the same live: storage pointers (direct and pointer-table), texture queries asked again through the live handle's constant-buffer words, texture bound buffer, viewport transform, local memory size. Otherwise the stage is translated as today.
  • Verify switch: EDEN_NX2_IR_CACHE_CHECK=1 translates every hit live as well and compares the SPIR-V. It logs NX2PC check <hash> (vs|ps|cs|task|mesh): cached translation same as live or DIFFERS from live, and a difference uses the live result. EDEN_NX2_IR_MISMATCH_LOG=1 names the answer that rejected a record.
  • Learning (EDEN_NX2_IR_LEARN=1): a stage translated live is appended with the same checks, so it costs one translation per build, not one per session.
  • Storage-pointer variants: a record per mapped/null combination of a stage's pointers, up to 6 pointers (EDEN_NX2_PRECOMPILE_POINTER_VARIANTS, _POINTER_MAX).
  • Lookups read only the records' checks and keep an LRU of decompressed programs.
  • Driver side (EDEN_NX2_GPL=1, vk_gpl*, vk_scheduler_rendering.cpp): graphics pipeline libraries with per-stage descriptor sets and dynamic rendering. The precompile creates each stage's library once so the driver's disk cache holds it, and builds each compute pipeline with a known launch once. This is a separate GPL implementation from vulkan: pipeline libraries + shader warm-up from the game files (first-visit shader stutter) #579's and is here only because the measurements used it. nx2 should keep one (see Shader precompile: a per-stage translation cache filled from the game files, on top of #781's precache (which form do you want?) #899).

Everything is off unless an environment variable or an existing vulkan_ir.bin turns it on. Hooks in existing files are marked nx2 shaderwarm; docs/shader-precompile.md lists every switch.

Each user generates their own cache from their own game. Nothing derived from a game is part of this PR or meant to be shared. vulkan_ir.bin holds translated game code, and its tag (format, git revision, Info/Inst sizes, driver, GPU) makes it apply only to the build and driver that wrote it.

Measurements (from our branch, not from this one)

AMD Radeon RX 7900 XT 20 GB (RADV, Mesa 26.2.6), Ryzen 7 5700X3D, Linux (Fedora 45 base, kernel 7.2.15), local clang build. Ocarina of Time, scripted headless route; user settings with async shaders on.

Our branch: Lichii nx2 merged up to ad477ef1 (first-session run, 2026-11-08; the next-session run was one build earlier, 2026-11-07), plus this stack and our other, unrelated fixes. Each run started from a precompiled vulkan_ir.bin + Mesa cache and an empty vulkan.bin, with GPL and learning on:

first session after the precompile (2026-11-08) next session, learned records added (2026-11-07)
stage uses served from the cache 2,242 2,197
graphics stages translated live 16 without a record + 42 whose record didn't match (all learned) 1 (learned)
compute stages translated live 26 0
graphics pipeline builds on the GPU thread over 16.7 ms 0 (262 over 2 ms, 975 ms total) 0
crashes 0 0
  • Verify mode, same branch: 82 mesh, 48 task, 550 vertex, 642 pixel and 69 compute cache uses compared with their live translation: 0 differed. Earlier, before texture queries were asked again through live handles, 33 of 1,496 pixel uses differed. After that change, 0 of 1,453.
  • Precompile cost, 2026-11-08, 8 low-priority threads: 345.5 s for 46,877 stages. That produced 262,461 records with pointer variants (vulkan_ir.bin 6.9 GB), 225,468 stage libraries and 34,308 compute pipelines. The Mesa cache already held most libraries from earlier runs (0.13 ms each), so a cold run takes longer. Translation alone, without variants, took 14-16 s for 42,781 stages (older build); that file is ~0.55-0.65 GB.
  • Older build (Eglantine nx2 0857e8b4 + this work at the time, before the Lichii port), 2026-11-06, async off, empty caches vs. precompiled, two runs each, interleaved: the driver-compile time frames waited on fell from 17.2 to 2.8 s, and frame waits from 5.0 to 1.2 s. GPU-thread build time halved. Gameplay frames over 100 ms went from 18-22 to 9-11 (the rest are not shader builds).
  • Cold Mesa cache: the boot froze ~60 s at the game's first movie, on one compute pipeline. After the precompile there was no gap over 5 s (Precache shaders before game start (compute, from #579/#741) #781 now covers that compute case too).
  • IR cache alone, without GPL: it halves GPU-thread build time but barely changes frame times with async off, because the driver compile dominates. With async on, frames over 50 ms went from 95 to 86.

Not included / limits

Related work

#781 (@zortos293, from #579/#741 by @PantACRO4life), #895 (@zmckenney), and daniel-lynch's per-stage translated-shader cache on his fork. #899 compares them and asks which direction to take.

🤖 Generated with Claude Code

the-cashman and others added 15 commits November 8, 2026 14:53
… handle sources, per-stage descriptor set, mesh state, relative LEPC)

Hooks for the per-stage translation cache and graphics pipeline libraries (docs/shader-precompile.md,
added with the video_core part). All default to the old behaviour:
- Environment::AdoptAnswers / NoteTextureHandleSource: no-ops unless an environment overrides them
  (the precompile records which constant buffer words a texture handle came from).
- RuntimeInfo::descriptor_set: 0 (the old hard-coded set) unless a library stage asks for 1.
- Maxwell::ApplyMeshState: TranslateProgram's mesh-state code as a function (same result), so a
  cached task/mesh translation can take the live mesh state.
- nx2_relative_lepc.h: EDEN_NX2_RELATIVE_LEPC=1 lowers a dead geometry-prologue LEPC address
  program-relative; off by default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…acy vertex attributes, only with EDEN_NX2_GPL=1

Without EDEN_NX2_GPL=1 none of VK_EXT_graphics_pipeline_library, VK_KHR_pipeline_library or
dynamic rendering is enabled (as before). With it, all three when the device has fast linking;
VK_EXT_legacy_vertex_attributes in addition on RADV with dynamic vertex input and null
descriptors (EDEN_NX2_GPL_ATTRS=0 turns that off). vkCmdBeginRendering/EndRendering in the wrapper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rom game files, graphics pipeline libraries (docs/shader-precompile.md)

New files hold the code; existing files get short hooks marked "nx2 shaderwarm":
- nx2_ir_cache.{h,cpp}: <title>/vulkan_ir.bin, translated stages (IR + the environment answers
  to check), tagged with the build and driver. Used whenever the file exists
  (EDEN_NX2_IR_CACHE=0: ignored); graphics VS/PS, compute (EDEN_NX2_IR_CACHE_COMPUTE=0 off; a
  record with bounded storage write extents only serves its own work group), task/mesh only with
  EDEN_NX2_IR_CACHE_TASKMESH=1.
- nx2_precompile_env.{h,cpp}, vk_shaderwarm.{h,cpp}: EDEN_NX2_PRECOMPILE=<pack> translates every
  stage of a pack (tools/s2warm) into the cache on low-priority threads, builds each compute
  pipeline with a known launch once (driver cache; EDEN_NX2_PRECOMPILE_COMPUTE_BUILD=0 off) and,
  with EDEN_NX2_GPL=1, each stage's library. EDEN_NX2_PRECOMPILE_ONLY=1: the boot builds no
  pipelines of its own.
- vk_gpl.{h,cpp}, vk_gpl_pipeline.cpp, vk_scheduler_rendering.cpp: EDEN_NX2_GPL=1 links native
  VS+PS pipelines from per-stage libraries (made without a render pass, so 3D draws use dynamic
  rendering), with an optimized link in the background (EDEN_NX2_GPL_LTO=0 off).
- nx2_pipeline_stats.h: EDEN_NX2_PIPELINE_STATS=1|2 timings and counters; EDEN_NX2_PIPELINE_LOG=1
  one line per runtime pipeline; EDEN_NX2_ASYNC_SHADERS=0|1 overrides the setting.
Off (no vulkan_ir.bin, no variables): the old paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ronment variables, precompile and play)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… inputs and SPIR-V hash (precompile and run time)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er that rejects a cached stage

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ters

The check route's EDEN_NX2_IR_MISMATCH_LOG showed most rejected cached stages (34 of 57, including
16 compute programs whose live builds then missed the driver cache for 100-700 ms) failing on
'storage pointer unmapped live, mapped cached': games first use programs while a constant-buffer
pointer is still null, and the runtime rebuilds once it is mapped. The precompile now also
translates every stage that asked with the pointers unmapped and stores that record too (the
lookup takes the record whose answers match). EDEN_NX2_PRECOMPILE_POINTER_VARIANTS=0 turns it off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…OMPILE_POINTER_VARIANTS=1)

Measured on the OoT check route: 32,988 extra records, the rejected stages unchanged (their live
pointers are a mix of mapped and null, which an all-unmapped record doesn't match).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ant records; lazy record decompression

- The precompile makes, for each stage that asked whether constant-buffer storage pointers are
  mapped, a record per combination (all 2^n up to EDEN_NX2_PRECOMPILE_POINTER_MAX=6 pointers,
  else each one null and all null). Live, games first use many programs with some pointers still
  null; the lookup takes the record whose answers match. EDEN_NX2_PRECOMPILE_POINTER_VARIANTS=0
  turns it off.
- StageCache::Find keeps programs compressed; LoadProgram decompresses only the record used, so a
  lookup among many variants stays cheap.
- A lookup counts and logs one mismatch, and only when no record matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…_LEARN=1)

A native vertex, pixel or compute stage that the precompile missed, or whose
records don't fit the live environment (a storage pointer null, another
texture type), was translated again in every session. With
EDEN_NX2_IR_LEARN=1 the live translation is appended to vulkan_ir.bin with
the same checks a precompiled record carries: the storage pointer answers,
the texture queries with their handle's constant buffer words (logged by
GenericEnvironment while armed, plus kind 2: exact pixel format for direct
format reads), the viewport transform when read, the texture bound buffer
and the local memory size. The next session finds it like any other record.

Task/mesh stages aren't stored (their records hold the program before the
live mesh state is applied). Records are deduplicated per session by their
checks; compression and the write run on the serialization thread. The
NX2STATS lines count them (learned N).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…SKMESH=1)

Stored under the program's own hash like the precompiled task/mesh records;
the live mesh state is put in when the record is used (ApplyMeshState).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ronment)

The build has no RTTI, so dynamic_cast doesn't compile. Shader::Environment
gets IsLiveEnvironment() (true only for GraphicsEnvironment and
ComputeEnvironment); FileEnvironment and the precompile's environment are
never stored as learned records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…only for the one used

StageCache::Find read every record of a stage in full, compressed program
included, on the GPU thread. With the pointer variants a stage has dozens of
records (~10 KB of compressed program each, the checks and answers ~140
bytes), so loading cached stages took 3.7 ms on average and up to 17 ms in
play (the remaining 40-75 ms frames when new geometry came into view).
Find now reads 2 KB per record (the whole record only when its checks are
longer) and keeps where the program is; StageCache::LoadProgram reads and
decompresses it for the record whose answers match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ation cache in memory

A stage that several pipelines use (27-37 % of the stage loads logged in OoT
play and the check route) read its records from vulkan_ir.bin and
decompressed its program again for every pipeline, on the GPU thread.
StageCache now keeps Find's parsed records (checks and answers, no program)
per unique hash for the session (Append drops the hash's entry) and up to
256 MiB of recently decompressed programs, least recently used dropped first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cache is per user; no off-tree references)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Nov 8, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

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