Translating each shader once: a disk cache in front of SPIR-V and MSL
The layer now stores every shader translation on disk, so a game started a second time skips the DXIL to Metal Shading Language step that cost about 70% of creating a pipeline.
What changed
When a D3D12 program creates a pipeline (a bundle of shaders plus fixed-function state), this layer translates each shader from DXIL (Direct3D's shader bytecode) to SPIR-V with dxil-spirv, and from SPIR-V to MSL (Metal Shading Language) with SPIRV-Cross. Then Metal compiles the MSL. Metal already caches its own compile on disk. The translation in front of it was not cached anywhere, so every run of a game translated every shader again.
Now each translation's results are written to ~/Library/Caches/mtl12/<build>/<key>. The results are the MSL of each stage and everything else the translator works out, such as threadgroup sizes, tessellation layout and vertex attribute locations. The key is a SHA-256 hash of everything the translator reads: the shader bytecode, the root signature's binding ranges, static samplers, the input layout, the stream-output declaration and the fixed-function fields. The hash also covers the UUID of the translator library itself, so a rebuilt translator never reads an older one's output.
The interesting part
A cache like this has only one dangerous failure: a wrong hit, where two different pipelines share a key and one gets the other's shaders. A miss only costs time. So the checks this session aimed at wrong hits:
api/translation_cachecreates pairs of pipelines that differ only in what the key must tell apart: a sampler's address mode, a root constant against a root constant buffer, the order of root parameters, the order of input elements. With the static samplers taken out of the key, the test fails, as it should.- The whole conformance suite ran twice over one empty cache directory. The cold run left 1,805 entries. The warm run, where nearly every pipeline was a hit, gave the same 837 passing tests and added no entries. A pointer hashed by its address instead of its contents would have shown up as new entries, because addresses change between processes.
- To check that the warm side really loads entries, the api suite ran twice over a fresh cache. The second run rewrote none of its 161 entries, so every translation was a hit.
One timing test, api/timestamp_after_work, failed in both runs. Its precondition is that a round of draws lasts at least 0.5 ms, and some rounds measured 0.05 to 0.3 ms. It fails just as often with the cache turned off (8 of 10 runs passed), so the cache isn't the cause. I don't yet know whether the draws or the timestamps are at fault.
Numbers
From the bench-pso scene (48 pipelines that need 16 distinct translations):
- A pipeline whose translation misses takes 1.9 to 5.5 ms. With the cache filled, no pipeline after the first takes more than 0.82 ms.
- Pipelines 2 to 48 together: 52.5 ms with an empty cache, 20.6 ms with a filled one.
- The first pipeline, which also carries one-time setup: 15.3 ms to 10.7 ms.
- empty cache
- filled cache
pipeline · ms
- 22.96 empty cache, 0.82 filled cache
- 32.43 empty cache, 0.47 filled cache
- 45.51 empty cache, 0.44 filled cache
- 51.33 empty cache, 0.42 filled cache
- 60.87 empty cache, 0.51 filled cache
- 70.47 empty cache, 0.43 filled cache
- 80.42 empty cache, 0.41 filled cache
- 90.5 empty cache, 0.48 filled cache
- 100.41 empty cache, 0.43 filled cache
- 110.4 empty cache, 0.42 filled cache
- 120.41 empty cache, 0.41 filled cache
- 132.58 empty cache, 0.44 filled cache
- 142.6 empty cache, 0.43 filled cache
- 151.99 empty cache, 0.51 filled cache
- 161.92 empty cache, 0.51 filled cache
- 170.43 empty cache, 0.47 filled cache
- 180.42 empty cache, 0.47 filled cache
- 190.4 empty cache, 0.43 filled cache
- 200.47 empty cache, 0.47 filled cache
- 210.4 empty cache, 0.44 filled cache
- 220.41 empty cache, 0.43 filled cache
- 230.4 empty cache, 0.42 filled cache
- 240.4 empty cache, 0.42 filled cache
- 252.78 empty cache, 0.44 filled cache
- 261.99 empty cache, 0.41 filled cache
- 271.91 empty cache, 0.41 filled cache
- 281.9 empty cache, 0.41 filled cache
- 290.41 empty cache, 0.41 filled cache
- 300.39 empty cache, 0.4 filled cache
- 310.39 empty cache, 0.4 filled cache
- 320.44 empty cache, 0.44 filled cache
- 330.39 empty cache, 0.41 filled cache
- 340.38 empty cache, 0.39 filled cache
- 350.39 empty cache, 0.39 filled cache
- 360.37 empty cache, 0.42 filled cache
- 373.02 empty cache, 0.43 filled cache
- 381.98 empty cache, 0.41 filled cache
- 391.89 empty cache, 0.42 filled cache
- 401.89 empty cache, 0.42 filled cache
- 410.43 empty cache, 0.41 filled cache
- 420.4 empty cache, 0.41 filled cache
- 430.4 empty cache, 0.42 filled cache
- 440.41 empty cache, 0.41 filled cache
- 450.62 empty cache, 0.41 filled cache
- 460.65 empty cache, 0.41 filled cache
- 470.46 empty cache, 0.41 filled cache
- 480.47 empty cache, 0.41 filled cache
- pass
- fail
- skip
suite · share of each row's total
- vkd3d-proton490 pass, 15 fail, 72 skip of 577
- vkd3d177 pass, 9 fail, 4 skip of 190
- apps77 pass, 0 fail, 0 skip of 77
- api73 pass, 1 fail, 0 skip of 74
- wine-dxgi16 pass, 4 fail, 0 skip of 20
- samples4 pass, 0 fail, 0 skip of 4