Skip to content

vk_pipeline_cache: migrate version 53 and 54 shader caches to version 55's graphics keys - #804

Draft
Reiixan wants to merge 12 commits into
zortos293:nx2from
Reiixan:cache-v55-migration
Draft

Reiixan wants to merge 12 commits into
zortos293:nx2from
Reiixan:cache-v55-migration

Conversation

@Reiixan

@Reiixan Reiixan commented Nov 8, 2026

Copy link
Copy Markdown

Follow-up to #408, as suggested in #408 (comment). Stacked on #408: only the last commit (8ea16c7a) belongs to this PR. I'll rebase it once #408 lands.

Problem

#408 bumps the Vulkan cache to version 55 because graphics keys now keep only the vertex attribute types a program reads (KeyedAttributeTypes). Versions 53 and 54 have the same record format, but their graphics keys are never looked up under version 55, so every player starts from an empty shader cache after the update and sees the first-visit stutter again.

Changes

  • When no version 55 shader cache exists, the newest version 54 or 53 cache is rewritten as version 55 (MigrateShaderCache). Each graphics key gets KeyedAttributeTypes applied the same way ShaderCache derives the masks:
    • the native VertexB program's SPH input map;
    • none for task and mesh programs;
    • unchanged with VertexA or without dynamic vertex input.
  • Compute records are copied as they are.
  • Records whose keys become equal after masking are written once.
  • The used-pipeline list is carried over under the new key hashes.
  • The output is written to a temporary file and renamed only when complete. Source files are left untouched for the builds that still read them.
  • One log line reports the source version, graphics records (masked, merged), compute records, used pipelines and the time taken.

Testing

Ocarina of Time (0400486024DEC000), RX 6650 XT (AMD 2.0.353), Ryzen 5 5600X, 32 GB, Windows 11. The build is this commit plus #408, #463, #573, #345, #643, #621 and #259 (off), clang-cl PGO, from a fork.

  • I moved the existing version 55 cache aside, leaving only vulkan.fork-v53.bin (955 MB) and its used list.
  • First boot: Pipeline cache migrated from version 53: 11881 graphics (12375 keys masked, 6608 merged), 1808 compute, 2499 used pipelines, in 1383 ms.
  • The migrated cache came out at 627,568,354 bytes. A version 55 cache recorded by playing the same route was 627,637,370 bytes.
  • Both boots loaded Total Pipeline Count: 4304.
  • In about 11 minutes of gameplay on the migrated cache, 21 graphics pipelines were built at runtime, in three 10 s windows: 31.6 ms, 39.3 ms and 2378 ms of draws held. The last was a first visit, with ASTC decoding at the same time.
  • No device loss, clean shutdown.
  • The Uncached storage pointer classification errors (13–14 per session) show up the same with builds that don't migrate.
  • I didn't add unit tests: the migration lives with ShaderCache's key derivation in vk_pipeline_cache.cpp. I can split it out for tests if you prefer.

Notes on #591

#591 (now in nx2) starts a new version from the newest cache with the same record layout. Once #408 is rebased onto it, version 55 needs:

  • CompatibleVulkanPipelineCacheVersions empty, since a 53/54 shader cache copied as it is would never be looked up;
  • the shader cache migrated as here;
  • the driver cache (vulkan_pipelines) still copied from 54/53/52.

Removing a cache from the game list should then pass the migration sources to ClearPipelineCache, so a header-only file stays in its place and the next boot doesn't migrate the old cache back. I have that combination on top of current nx2 and can push it here once #408 is rebased.

@ZeroOneTV you offered to test it, thanks. @BrunoSilvestre00 this keeps the caches you had from before #408.

🤖 Generated with Claude Code

SmileyTatsu and others added 12 commits November 7, 2026 19:29
The guest memory section keeps every page the game touched until it is
destroyed, so host RAM stayed high after stopping a game until the app
was closed. Destroy it on stop for Switch 2 programs and create it again
in InitializeKernel, sized for the next game. Switch 1 is unchanged.

Assisted-by: Claude Sonnet 5.5 (code changes, commit message draft)
(cherry picked from commit 8ef3563)
… back submits

Two paths from zortos293#494 let a second thread use the scheduler at the same time as
the GPU back thread, which crashed in Scheduler::SubmitExecution (SIGSEGV,
SEGV_MAPERR, from GpuSplit::BackLoop -> Scheduler::FlushDeferred) in Mario
Kart World and other games.

- The back runs its idle hook (the deferred fence / list-copy flush) after it
  has published everything as consumed, so GpuSplit::Drain() could return and
  the front run work of its own (MapInOrder) while that submit was going on.
  The back now clears back_quiet before it executes entries and sets it after
  the idle hook; Drain() waits for it as well and asks the back to run the
  hook at once when it is still spinning.
- BufferCache::WriteMemory downloads the GPU-written bytes on written pages
  (b13c4c1), which records and submits work. Device memory unmaps and
  writes, ThreadManager::InvalidateRegion and FlushAndInvalidateRegion reach it
  on CPU and service threads. It now downloads only on the thread that owns
  the scheduler (GpuSplit::OwnsScheduler: the back while it runs, else the GPU
  thread). Guest CPU writes still go through TryCacheInvalidation, which sends
  such pages to the GPU thread.

Assisted-by: Claude Opus 5.5 (diagnosis from tester stack traces, fix)
(cherry picked from commit df92fc6)

Conflicts with Lichii resolved: Drain keeps Lichii's GPU profiling around the BackQuiet loop, g_back_busy (not in Lichii) is dropped, and the inline WriteMemory path is also limited to the scheduler's owner.

Co-authored-by: Cursor <cursoragent@cursor.com>
With dynamic vertex input, a graphics pipeline key carried the type of all 32
vertex attribute registers, including attributes the vertex program never
reads. Ocarina of Time leaves different values in those registers from draw to
draw, so the same shaders got up to 20 keys, and each new key was built again
on the GPU thread in the next session.

The key now keeps only the types of attributes in the native vertex program's
input map (two bits per attribute, as in attribute_types). Task and mesh
programs have no vertex input, so their keys keep none (Ocarina: 191 keys for
31 sets of shaders). Refresh only rewrites the types when the vertex input
changes, so the unmasked types are kept apart and restored before the next
Refresh.

The rules live in video_core/pipeline_cache_policy.h and are covered by
src/tests/video_core/pipeline_cache_policy.cpp.

Co-authored-by: Cursor <cursoragent@cursor.com>
Every graphics pipeline unused for 9000 frames was destroyed. In Ocarina of
Time an area coming back then needs hundreds of them at once: each takes 2-3 ms
to build again even from the driver's pipeline cache, and the draws wait for
them (2-3 s of draws held per 10 s right after loading a save).

Pipelines are now evicted only while the cache holds more than 1/16 of host RAM
worth of them (~0.45 MB each: 2386 pipelines with 16 GiB, 4772 with 32 GiB),
least recently used first, and still only those unused for 9000 frames. Memory
stays bounded, unlike keeping every pipeline (Eglantine PR 696, reverted by
PR 698). zortos293#313 takes the same approach with a 1/8 budget.

Prebuilt pipelines no draw has used yet are still kept from eviction up to the
cap from zortos293#550; past it they are candidates like the rest.

Co-authored-by: Cursor <cursoragent@cursor.com>
Each global load, store and atomic calls a helper that scans every global
storage buffer for the address. Inlined at every call site, a compute shader
with many global accesses takes NVIDIA's compiler seconds: Ocarina of Time's 68
such compute shaders took 161.8 s to build together (up to 7.6 s each), and a
new one stalled the game for 0.7-0.9 s. With DontInline they take 63.5 s
(up to 4.3 s each), and a new one in game took 163 ms. Frame times did not
change.

src/tests/video_core/global_memory_coherence.cpp checks that every helper a
shader calls is DontInline and that the entry point is not.

Co-authored-by: Cursor <cursoragent@cursor.com>
…etting

zortos293#250 and zortos293#319, ported above, came without tests. The page
filter tests check marks across pages and 64-page words, empty marks, and the
conservative answer outside its 64 GiB range. The bypass tests check the
tonemap shader hashes and that the bypass follows the setting and the title.

Co-authored-by: Cursor <cursoragent@cursor.com>
…fetimes, deferred page writes

- Bump the Vulkan pipeline cache to version 55: graphics keys now mask unread vertex attribute types, so older shader caches are no longer copied forward; the driver cache still is, from 54, 53 or 52.
- Test that a remembered rejection marks its page, so filtered invalidations still clear it.
- Close the main process and the general channel's event before the kernel shuts down; queued channel data survives a restart.
- HostMemory: tests for the move operations nx2 now has.
- Buffer cache: partial page writes from threads that don't own the scheduler wait for the owner instead of hiding the page's GPU bytes; synchronous GPU keeps the old behaviour.
- Tests for each, and for the K=16 software HMMA loop's operand halves and rounded stores; docstrings on the touched functions.

Co-authored-by: Cursor <cursoragent@cursor.com>
…ry move tests

- Deferred page writes get their own lock (OpenGL compute dispatches update buffers without the buffer cache mutex) and an atomic flag, so the empty case stays lock-free. Overlapping or touching ranges merge; ranges with a gap don't, since the inline path uploads the whole range from guest memory.
- The owner thread in the buffer cache tests rethrows its job's exceptions and always unmarks itself.
- The HostMemory move tests use 64 MiB buffers: Windows commits each backing buffer in full, and they hold two at once.
- Tests: merging, concurrent queueing from several threads while the owner drains, and owner job failures.

Co-authored-by: Cursor <cursoragent@cursor.com>
The helpers that scan the global storage buffers were emitted as DontInline
in every stage, to cut NVIDIA's compile time for compute shaders with many
global accesses. In a graphics stage this miscompiles on NVIDIA 580.x
(RTX 3080 Ti): in Ocarina of Time's far-tree colour mesh, a helper call
between the two uses of an earlier helper call's result replaces that
result with its own, so the far hills render as sky.

Compute keeps DontInline. Every other stage emits the helpers inlinable
(MaskNone), as before. The coherence test checks the function control per
stage.

Reported and fixed by Anthony Loukinas in anthonyloukinas/Lichii@9848739903.

Co-authored-by: Anthony Loukinas <anthony.loukinas@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Alias copy extents of compressed images are in blocks, but CopyImage passes them on to vkCmdCopyImage as texels, so such a copy writes only part of the image. The join upload shortcut took it for a whole-image write and skipped the upload that fills the rest. It now keeps the upload for every compressed format, not only rescaled ones.

Found by CodeRabbit in the review of zortos293#408.

Co-authored-by: Cursor <cursoragent@cursor.com>
… 55's graphics keys

Version 55 masks the vertex attribute types a program doesn't read out of graphics keys, so an
older shader cache was no longer looked up and every player started from an empty one. When
this version has no shader cache yet, the newest of version 54 or 53 (same record format) is
rewritten with KeyedAttributeTypes applied to each graphics key, as ShaderCache derives the
masks: a native VertexB program's SPH input map, none for task and mesh programs, all
attributes otherwise, unchanged with VertexA or without dynamic vertex input. Records whose
keys become equal are written once, and the used-pipeline list is carried over under the new
key hashes. The source files are left for the builds that still read them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NKd76R3kE75apnmZZuQhUr
@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.

5 participants