Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
6.7 KiB
Shader compilation stutter
Where the time goes when a game compiles its shaders and pipelines, mainly on the Mac (Vulkan through MoltenVK), and how to measure it. Measured in October 2026 on an M1 with MoltenVK 1.4.2.
The path a shader takes
- The shader generators (
GPU/Common/VertexShaderGenerator.cpp,FragmentShaderGenerator.cpp) write GLSL from aVShaderID/FShaderID. - glslang compiles it to SPIR-V (
GLSLtoSPV()inCommon/GPU/Vulkan/VulkanContext.cpp), on a dedicated thread per shader (CompileShaderModuleAsync()inGPU/Vulkan/ShaderManagerVulkan.cpp). vkCreateGraphicsPipelines()runs on the thread pool, whichVulkanRenderManager::CompileThreadFunc()feeds, one task per vertex/fragment shader pair. On MoltenVK this is where the rest happens: SPIRV-Cross turns SPIR-V into MSL, Metal compiles the MSL into anMTLLibrary, and then builds theMTLRenderPipelineState. MoltenVK'svkCreateShaderModule()only copies the SPIR-V.- The render thread waits for the pipeline in
VulkanQueueRunner(BlockUntilReady()) when it reaches the first draw that uses it. That wait is the stutter.
Where the time goes
Wipeout Pure from a savestate, starting with no PPSSPP cache and an empty Metal cache. MoltenVK's own counters (see below):
| Stage | Time each | Count |
|---|---|---|
| glslang, GLSL to SPIR-V | ~1 ms | 27 |
| SPIRV-Cross, SPIR-V to MSL | 0.5 ms | 40 |
Metal, MSL to MTLLibrary |
85-110 ms (max ~470) | 43 |
Metal, MTLFunctions to a pipeline state |
5 ms | 63 |
Nearly all the cost is Metal compiling MSL, and it happens about once per distinct shader, not once per pipeline. Pipelines that differ only in blend, cull, depth or stencil state cost about 5 ms each once their shaders' libraries exist. On the Mac, then:
- Speeding up the shader generators or glslang gains nothing worth having.
- Cutting the pipeline count (more dynamic state, e.g. cull mode and depth/stencil through
VK_EXT_extended_dynamic_state) helps little. In this run it would have taken 58 pipelines down to 43 that differ in anything Metal bakes in, and the expensive step doesn't repeat for those anyway. - Fewer distinct shaders (moving ID bits into uniforms) is the lever that matters, with the runtime cost that ubershaders bring, mostly in fragment shaders.
The shared VkPipelineCache
We used to create every pipeline with the same VkPipelineCache, and MoltenVK serializes the MSL
compiles of pipelines that share one: its "retrieve shader library from the cache" time, which includes
waiting for another thread's compile, averaged 142 ms. The 3.7 s of compiling blocked the render thread
for 2.9 s in total, so there was almost no parallelism. The cache is only held in memory (we don't save
its data), and on MoltenVK all it saves is the SPIR-V to MSL step, so on Apple platforms we now pass
VK_NULL_HANDLE (CreateVulkanPipeline() in GPU/Vulkan/PipelineManagerVulkan.cpp).
| Cold Wipeout Pure, two runs each | With the cache | Without |
|---|---|---|
| Render thread blocked on pipelines, total | 2.9 s | 1.2 s |
| Longest single stall | 1.0-1.1 s | 0.3 s |
| Average wait inside MoltenVK's cache lookup | 142 ms | 11 ms |
MVK_CONFIG_SHOULD_MAXIMIZE_CONCURRENT_COMPILATION=1 made no measurable difference.
The caches
- PPSSPP's per-game cache,
PSP/SYSTEM/CACHE/<DISCID>.vkshadercache. It holds the shader IDs and pipeline keys the game used, plus their SPIR-V, andGPU_Vulkan::LoadCache()recompiles all of it while the game boots. With it, Wipeout's 62 pipelines took 140 ms in total. - macOS's Metal cache, keyed by the MSL source. With it warm, even a run without PPSSPP's cache took about 100 ms for all 62 pipelines. So on the Mac, a second boot is cheap with or without our cache, and a shader one game compiled is cheap in the next game too. Only the first time a shader is ever seen (and possibly after an OS or app update) is it slow.
How game-specific are the shaders?
Less than expected. Across the 37 .vkshadercache files of one Mac's memory stick (at the current
CACHE_VERSION), for the seven biggest:
| Game | VS | Also in another game | FS | Also in another game |
|---|---|---|---|---|
| UCES00002 (Ridge Racer) | 12 | 92% | 47 | 51% |
| UCES01250 | 13 | 85% | 36 | 67% |
| UCES00001 (Wipeout Pure) | 14 | 93% | 25 | 80% |
| UCES00465 | 12 | 100% | 22 | 86% |
| UCES00842 (God of War: Chains of Olympus) | 11 | 82% | 21 | 76% |
| ULES00502 (GTA: Vice City Stories) | 10 | 90% | 20 | 70% |
| ULES01376 (Tekken 6) | 11 | 73% | 20 | 65% |
All 37 games together use only 48 distinct vertex shader IDs. The IDs depend on the device's GPU features and the user's settings, and some of those caches come from short runs, so a list shipped with the app would match less well than one gathered on the device. A per-device list of shaders seen in several games, precompiled at boot, looks worth trying.
Ideas not done yet
- Don't block the render thread on a pipeline that isn't ready: skip the draw, or keep the previous pipeline, for the few frames until it is. Visible (objects pop in), so probably an option, but it would remove the remaining stalls on every backend.
- Precompile cross-game shaders at boot, from the list above.
- A native Metal backend could use
MTLBinaryArchive. Not measured. See metal-backend.md.
Measuring
--shader-cache=false turns off loading and saving PPSSPP's per-game cache for one run (headless or
the app; it isn't saved to the ini). On the Mac that isn't a cold start, since Metal's cache still
hits. Headless is an unbundled binary and shares Metal's cache with other command-line tools, in
$(getconf DARWIN_USER_CACHE_DIR)com.apple.metal/. For a real cold start, move the GPU's folders
there aside (on the M1, 16777235_322 and 32024, the two big ones) and put them back afterwards.
MTL_SHADER_CACHE_DISABLE and similar environment variables don't turn the cache off.
build-release/PPSSPPHeadless "$HOME/PSP/Wipeout Pure.cso" --memstick=$HOME/.config/ppsspp \
--state=$HOME/.config/ppsspp/PSP/PPSSPP_STATE/UCES00001_1.01_0.ppst \
--graphics=vulkan --timeout-wall=30 --timeout-emulated=20 --log --shader-cache=false
- Each pipeline's creation time is logged at INFO level, as
Pipeline (x/N) time on .... - MoltenVK's breakdown: set
MVK_CONFIG_PERFORMANCE_TRACKING=1andMVK_CONFIG_ACTIVITY_PERFORMANCE_LOGGING_STYLE=2. The summary prints when the device is destroyed, at the end of the run. - The render thread's stalls aren't logged. Time
BlockUntilReady()inVulkanQueueRunner.cpptemporarily to see them; the sum of pipeline times overstates the stall, since compiles overlap. - Run each configuration at least twice. Single cold runs varied by about 15%.