Skip to content

feature(vulkan): persist validated pipeline caches across pipelines - #137

Draft
chuzcjoe wants to merge 1 commit into
masterfrom
daily/feature-vulkan-persist-pipeline-cache-20261108
Draft

chuzcjoe wants to merge 1 commit into
masterfrom
daily/feature-vulkan-persist-pipeline-cache-20261108

Conversation

@chuzcjoe

@chuzcjoe chuzcjoe commented Nov 8, 2026

Copy link
Copy Markdown
Collaborator

Problem

Every Vulkan pipeline in CORE is compiled from scratch on every launch.

  • VulkanRender::CreatePipeline passes VK_NULL_HANDLE as the pipeline cache, so graphics pipelines never use a cache.
  • VulkanCompute creates a private cache for each pipeline. It is persisted only if a subclass overrides GetPipelineCache() and calls SavePipelineCache(); today only the Gaussian blur test does this. Three problems with that path:
    • Whatever file contents exist are passed to vkCreatePipelineCache unchecked: no header, vendor, device or UUID check, and no size bound.
    • The file is written in place, so an interrupted write leaves a truncated cache.
    • Calling CreatePipeline twice overwrites and leaks the previous VkPipelineCache.
  • Caches cannot be shared, so the multi-pipeline demos (FPS shooter: 4 pipelines, car control: 3, physics: 2) recompile everything at every startup.

Measured cost of a cold start for 5 real project compute pipelines: about 0.78 ms on macOS (MoltenVK) and about 21 ms on a Snapdragon (Adreno) Android phone. See Validation.

Solution

New core::vulkan::VulkanPipelineCache (vulkan/include/VulkanPipelineCache.h, vulkan/src/VulkanPipelineCache.cpp), an RAII wrapper around one VkPipelineCache.

Loading a cache file:

  • Rejects missing files, files under 32 bytes, and files over kMaxDataSize (64 MiB).
  • Reads only the 32-byte VkPipelineCacheHeaderVersionOne first. It decodes the fields as little-endian values at fixed offsets and checks each against VkPhysicalDeviceProperties: headerSize == 32, headerVersion == ONE, vendorID, deviceID and pipelineCacheUUID.
  • Allocates and reads the rest of the file only if the header matches. Short reads and trailing data are rejected.
  • Anything rejected is replaced by an empty cache. restored() reports which case happened.

Saving (Save() / static Write()):

  • Queries the data size and refuses anything over kMaxDataSize before allocating.
  • Handles VK_INCOMPLETE if the cache grows between the size query and the data read.
  • Writes to a uniquely named temporary file, then renames it over the target. It returns false on any failure instead of throwing, so persistence stays best-effort.

VulkanBase::SetPipelineCache(const VulkanPipelineCache*) lets the caller share one cache across any number of pipelines.

  • VulkanRender now passes the shared cache to vkCreateGraphicsPipelines.
  • VulkanCompute uses the shared cache when one is set. Otherwise it keeps the existing GetPipelineCache() / SavePipelineCache() behaviour, now backed by the validated loader through an owned std::unique_ptr. This also fixes the handle overwrite and leak.

Callers: FPSShooterDemo, CarControlDemo and PhysicsDemo each create one cache at PIPELINE_CACHE_DIR/<demo>.cache, attach it to every pipeline, and Save() it once all pipelines are initialized.

Usage:

core::vulkan::VulkanPipelineCache pipeline_cache(&context, cache_dir / "app.cache");
floor->SetPipelineCache(&pipeline_cache);
cube->SetPipelineCache(&pipeline_cache);
floor->Init();
cube->Init();
pipeline_cache.Save();

The cache only has to outlive the Init() calls that create pipelines. Pipelines do not reference it afterwards. As the Vulkan spec allows, a VkPipelineCache can be used by concurrent pipeline creation without external synchronization.

Alternatives considered:

  • Have VulkanContext own a global cache. This is less code at call sites, but it couples device setup to file I/O and takes away the caller's choice of which pipelines share a cache. Rejected.
  • Patch only the legacy per-pipeline compute path. This would still give graphics pipelines no cache and give pipelines no way to share one. Rejected.
  • Trust the driver to reject stale data. The spec requires drivers to tolerate incompatible data, but checking the header is standard practice, avoids driver robustness bugs, and keeps oversized or corrupt files from being loaded at all.

Validation

All results come from the final commit e9783d0 (tree 58283bc). The review gate below closed before the final runs, and no source changed afterwards.

macOS (local): ./scripts/run.sh -t macos -r tests, exit 0.

  • 6 executables, 24 GoogleTest suites, 65 passed, 0 failed, 0 skipped: mat 3, threadpool 4, stb 4, opencl 8, vulkan 45, metal 1.
  • Apple M6, macOS 27.0.1, MoltenVK, Vulkan SDK 1.4.350.0, core build variant (-Werror), no compiler warnings.
  • Khronos validation layer enabled by the tests; no validation messages.

Android (physical device): ./scripts/run.sh -t arm64-v8a -r tests, exit 0.

  • 7 executables actually ran on the device, 26 suites, 67 passed, 0 failed, 0 skipped: mat 3, threadpool 4, stb 4, opencl 8, vulkan 45, gles 2, ahardwarebuffer 1.
  • Device: Xiaomi 2210132C (nuwa), Snapdragon kalama, Android 13 / API 33. adb devices -l showed exactly one authorized device, pinned with ANDROID_SERIAL.
  • NDK 28.2.13676358 (r28c, the NDK installed on this workstation; CI pins 26.1), target API 34, no compiler warnings.

New tests (tests/vulkan/VulkanPipelineCacheTest.cpp, 6 tests), passing on both platforms:

  • Shared and restored: compute and graphics pipelines share one cache. It saves to exactly one file with a compatible header, restores on a new VkDevice, and the restored pipelines run; the compute dispatch output is checked. A shared cache takes precedence over the legacy path, which is never queried.
  • Incompatible data rejected: the loader rejects empty, truncated and garbage files, and files with a single byte flipped at each header field offset (0, 1, 4, 8, 12, 16, 31) or a zeroed headerSize. For each case the pipeline still builds and runs, and Save() replaces the file with valid data. Sparse files of kMaxDataSize + 1 bytes are rejected with both a valid and an invalid header.
  • Graphics gets the cache: the test executable wraps vkCreateGraphicsPipelines, records the cache argument, and forwards to vkGetDeviceProcAddr. The test asserts VK_NULL_HANDLE when no cache is set and the exact shared handle when one is. Call counts confirm the wrapper is in the call path on both platforms. A build whose VulkanRender passed VK_NULL_HANDLE made this test fail.
  • Legacy path: a garbage legacy file is rejected; SavePipelineCache then writes a compatible file that restores.
  • Save failures: an in-memory cache, a missing directory and a null handle all return false and leave no files behind.

Benchmark: vulkan_pipeline_cache_benchmark <cache-path> 21.

  • Each sample creates a fresh VulkanContext/VkDevice and builds 5 real project compute pipelines: Gaussian blur, sum, copy, FMA, barycentric rasterizer.
  • Cold uses an empty cache; warm restores the cache file primed beforehand. Restore time (file read plus vkCreatePipelineCache) is included in warm.
  • 2 warmup rounds, modes alternated, 21 samples each. Each sample's copy pipeline output is checked.
Platform Cold median (p10–p90) Warm median (p10–p90) Speedup Cache size
macOS M6 / MoltenVK 777.4 µs (741.7–812.5) 309.4 µs (301.2–316.3) 2.51x 25,431 B
Android Adreno (kalama) 20,867 µs (19,201–25,165) 520.7 µs (494.5–603.5) 40.1x 18,708 B

Per pipeline on Android, Gaussian blur went from 6.37 ms to 0.15 ms. An exploratory run before the change also measured one Gaussian blur pipeline per process (12 processes per mode) with the raw Vulkan API: macOS about 347 µs to 53 µs, Android about 4.8 ms to 0.13 ms.

Demo smoke test: vk_physics_demo ran twice from the repo root for 5 s each. The first run wrote physics_demo.cache (10,994 B, gitignored via *.cache); the second ran with it. No errors or validation messages.

Review gate: independent GPT reviews (gpt-6.1-sol, high effort, read-only), 4 rounds. Findings and fixes:

  • R1-1: load size bound and header check before allocation.
  • R1-2: explicit little-endian header decoding.
  • R1-3: graphics cache argument observed directly.
  • R2-1: save size checked before allocation.
  • R3-1: added comments removed.

The final round reported all findings resolved, no new findings, and GATE: PASS.

Limits:

  • The benchmark measures compute pipelines; graphics pipeline gains are covered by tests and the demo wiring, but not benchmarked.
  • The samples run in one process with a fresh device each, so in-process driver caching (visible on MoltenVK) makes the macOS cold numbers lower than a true cold launch.
  • No test exercises the save-side branch for data over 64 MiB: it would need a driver reporting that much cache data. The reviewer accepted inspection for it.
  • Windowed demos were not checked visually.

Changes

git diff --numstat origin/master: 15 files, +872 / −59 = 931 lines.

Implementation, 277 lines:

  • vulkan/include/VulkanPipelineCache.h +43
  • vulkan/src/VulkanPipelineCache.cpp +151
  • vulkan/include/VulkanBase.h +5
  • vulkan/src/VulkanBase.cpp +4
  • vulkan/include/VulkanCompute.h +4/−3
  • vulkan/src/VulkanCompute.cpp +9/−54
  • vulkan/src/VulkanRender.cpp +2/−2

Demo callers, 24 lines: FPSShooterDemo/main.cpp +9, CarControlDemo/main.cpp +8, PhysicsDemo/main.cpp +7.

Tests, 396 lines: tests/vulkan/VulkanPipelineCacheTest.cpp +379, PipelineCacheProbe.vert +9, PipelineCacheProbe.frag +8.

Benchmark, 234 lines: tests/vulkan/benchmarks/PipelineCacheBenchmark.cpp +218, tests/vulkan/CMakeLists.txt +16 (benchmark target).

Docs: 0.

Risk

  • API: existing public and protected APIs keep their signatures. SetPipelineCache and the new class are additions.
    • The private VulkanCompute::CreatePipelineCache and LoadPipelineCache helpers are removed.
    • The VulkanCompute object layout changes (one new unique_ptr), so dependent code must be rebuilt.
  • Behaviour:
    • Legacy compute cache files whose header does not match the device are now ignored instead of handed to the driver; the next save rewrites them.
    • SavePipelineCache now writes through temp-and-rename.
  • Files: the demos create <demo>.cache in PIPELINE_CACHE_DIR (the repo root when built with run.sh on macOS); these files are covered by .gitignore. A stale or foreign cache is detected by the header check, and pipelines are rebuilt.
  • Test wrapper: the vkCreateGraphicsPipelines wrapper in vulkan_tests forwards transparently through the loader. It relies on the executable's definition taking precedence over the loader's, which the call-count assertions confirm on macOS and Android.
  • Merge overlap: a small textual overlap with open PR arch(vulkan): reuse descriptor slots when rebinding resources #136 in VulkanBase.h/.cpp (an added member and method) is possible. There is no functional overlap.

🤖 Generated with Claude Code

Add VulkanPipelineCache, an RAII VkPipelineCache that restores a cache
file only after validating its header against the physical device and
saves through a temporary file and rename. VulkanBase::SetPipelineCache
shares one cache across compute and graphics pipelines; VulkanRender now
passes it to vkCreateGraphicsPipelines and VulkanCompute keeps its legacy
per-pipeline path on top of the validated loader. The FPS shooter, car
control and physics demos persist one shared cache each.

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