MOOR security

Dieser Eintrag ist auf Englisch verfasst.

Translating each shader once: a disk cache in front of SPIR-V and MSL

Entry 055837 of 942 tests pass

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.

Where the cache sits between pipeline creation and Metal's compiler.Create*PipelineState → key: SHA-256 of build UUID, shader bytecode, root signature, static samplers, input layout, fixed-function state; key: SHA-256 of build UUID, shader bytecode, root signature, static samplers, input layout, fixed-function state → entry in ~/Library/Caches/mtl12/&lt;build&gt;/&lt;key&gt;?; entry in ~/Library/Caches/mtl12/&lt;build&gt;/&lt;key&gt;? → restore MSL and translation outputs (hit); entry in ~/Library/Caches/mtl12/&lt;build&gt;/&lt;key&gt;? → translate: DXIL → SPIR-V → MSL (about 70% of a warm pipeline) (miss); translate: DXIL → SPIR-V → MSL (about 70% of a warm pipeline) → store entry (temp file, then rename); store entry (temp file, then rename) → Metal: MSL → AIR, pipeline state (Metal's own disk cache); restore MSL and translation outputs → Metal: MSL → AIR, pipeline state (Metal's own disk cache)Create*PipelineStatekey: SHA-256 of build UUID, shader bytecode,root signature, static samplers, input layout, fixed-function stateentry in~/Library/Caches/mtl12/&lt;build&gt;/&lt;key&gt;?restore MSL and translation outputstranslate: DXIL → SPIR-V → MSL(about 70% of a warm pipeline)store entry(temp file, then rename)Metal: MSL → AIR, pipeline state(Metal's own disk cache)hitmiss
Where the cache sits between pipeline creation and Metal's compiler. (Mermaid source)
What one cache entry holds, and the checks a load makes before it counts as a hit.header MTLC, version, region and MSL counts → SHA-256 key (checked against the file name); SHA-256 key (checked against the file name) → regions size + bytes: args struct, input layout locations, null attributes; regions size + bytes: args struct, input layout locations, null attributes → MSL outputs source size, entry point, threads, source; MSL outputs source size, entry point, threads, source → every size matches and the length is exact?; every size matches and the length is exact? → hit: outputs written (yes); every size matches and the length is exact? → miss: translate again (no)headerMTLC, version,region and MSL countsSHA-256 key(checked againstthe file name)regionssize + bytes:args struct, inputlayout locations,null attributesMSL outputssource size, entry point,threads, sourceevery size matchesand the length is exact?hit: outputs writtenmiss: translate againyesno
What one cache entry holds, and the checks a load makes before it counts as a hit. (Mermaid source)

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_cache creates 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.
bench-pso, time per pipeline (ms), first pipeline left out (data as CSV)
  • empty cache
  • filled cache

pipeline · ms

  1. 22.96 empty cache, 0.82 filled cache
  2. 32.43 empty cache, 0.47 filled cache
  3. 45.51 empty cache, 0.44 filled cache
  4. 51.33 empty cache, 0.42 filled cache
  5. 60.87 empty cache, 0.51 filled cache
  6. 70.47 empty cache, 0.43 filled cache
  7. 80.42 empty cache, 0.41 filled cache
  8. 90.5 empty cache, 0.48 filled cache
  9. 100.41 empty cache, 0.43 filled cache
  10. 110.4 empty cache, 0.42 filled cache
  11. 120.41 empty cache, 0.41 filled cache
  12. 132.58 empty cache, 0.44 filled cache
  13. 142.6 empty cache, 0.43 filled cache
  14. 151.99 empty cache, 0.51 filled cache
  15. 161.92 empty cache, 0.51 filled cache
  16. 170.43 empty cache, 0.47 filled cache
  17. 180.42 empty cache, 0.47 filled cache
  18. 190.4 empty cache, 0.43 filled cache
  19. 200.47 empty cache, 0.47 filled cache
  20. 210.4 empty cache, 0.44 filled cache
  21. 220.41 empty cache, 0.43 filled cache
  22. 230.4 empty cache, 0.42 filled cache
  23. 240.4 empty cache, 0.42 filled cache
  24. 252.78 empty cache, 0.44 filled cache
  25. 261.99 empty cache, 0.41 filled cache
  26. 271.91 empty cache, 0.41 filled cache
  27. 281.9 empty cache, 0.41 filled cache
  28. 290.41 empty cache, 0.41 filled cache
  29. 300.39 empty cache, 0.4 filled cache
  30. 310.39 empty cache, 0.4 filled cache
  31. 320.44 empty cache, 0.44 filled cache
  32. 330.39 empty cache, 0.41 filled cache
  33. 340.38 empty cache, 0.39 filled cache
  34. 350.39 empty cache, 0.39 filled cache
  35. 360.37 empty cache, 0.42 filled cache
  36. 373.02 empty cache, 0.43 filled cache
  37. 381.98 empty cache, 0.41 filled cache
  38. 391.89 empty cache, 0.42 filled cache
  39. 401.89 empty cache, 0.42 filled cache
  40. 410.43 empty cache, 0.41 filled cache
  41. 420.4 empty cache, 0.41 filled cache
  42. 430.4 empty cache, 0.42 filled cache
  43. 440.41 empty cache, 0.41 filled cache
  44. 450.62 empty cache, 0.41 filled cache
  45. 460.65 empty cache, 0.41 filled cache
  46. 470.46 empty cache, 0.41 filled cache
  47. 480.47 empty cache, 0.41 filled cache
Tests by suite at the end of this entry: 837 of 942 pass (data as CSV)
  • pass
  • fail
  • skip

suite · share of each row's total

  1. vkd3d-proton490 pass, 15 fail, 72 skip of 577
  2. vkd3d177 pass, 9 fail, 4 skip of 190
  3. apps77 pass, 0 fail, 0 skip of 77
  4. api73 pass, 1 fail, 0 skip of 74
  5. wine-dxgi16 pass, 4 fail, 0 skip of 20
  6. samples4 pass, 0 fail, 0 skip of 4