MOOR security

Dieser Eintrag ist auf Englisch verfasst.

Setting only what changed: 10000 draws from 20 ms to 7.5 ms, and a CPU clock that was 40 times too low

Entry 052837 of 941 tests pass

The D3D12-to-Metal layer now sets per draw only the GPU state that differs from the previous draw, which cut a 10000-draw benchmark frame from 20.3 to 7.5 ms, and a Rosetta clock quirk had hidden where the time went.

What changed

The layer records a game's D3D12 command lists on the Windows side and replays them into Metal commands on the macOS side ("replay"). Until this session, replay set everything for every draw (see the diagram):

  • each entry of the argument table (Metal 4's list of buffer addresses a shader reads);
  • each vertex buffer;
  • the pipeline, the depth-stencil state and the rasteriser settings;
  • 16 viewports and 16 scissor rectangles.

A game that draws 10000 small objects with the same pipeline paid for all of it 10000 times.

Replay now remembers the last draw's values and sets only what differs. A second pass removed three more per-draw costs:

  • It compares the viewports in place instead of copying a 1.3 KB struct.
  • It caches the size of the render targets for the whole submission.
  • It reuses the uploaded root arguments (the small per-draw constants D3D12 passes inline) when a draw's are the same as the last draw's.

A cache like this has one failure mode: a draw that silently uses the previous draw's state. The new test api/draw_state_reuse changes one piece of state per draw and changes it back, across 31 one-pixel columns, and checks every pixel. For each compare in the cache I broke the code on purpose and checked that the test fails. Two resets can't be observed, and the note says why.

What a replayed draw sets on the Metal encoder, before and after.After: compare with the last draw, set what differs: D3D12 draw, same as the last draw?, skip, set only that entry or state, Metal draw, other command, new encoder, indirect/mesh/GS path; Before: every draw sets everything: D3D12 draw, every argument table entry, vertex buffers, strides, pipeline, depth-stencil, refs, raster state, 16 viewports + scissors, upload root arguments, Metal draw; D3D12 draw → same as the last draw?; same as the last draw? → skip (yes (most fields)); same as the last draw? → set only that entry or state (no); skip → Metal draw; set only that entry or state → Metal draw; other command, new encoder, indirect/mesh/GS path → same as the last draw? (forget the last draw); D3D12 draw → every argument table entry, vertex buffers, strides; every argument table entry, vertex buffers, strides → pipeline, depth-stencil, refs, raster state, 16 viewports + scissors; pipeline, depth-stencil, refs, raster state, 16 viewports + scissors → upload root arguments; upload root arguments → Metal drawAfter: compare with the last draw, set what differsBefore: every draw sets everythingD3D12 drawsame as thelast draw?skipset only that entryor stateMetal drawother command, new encoder,indirect/mesh/GS pathD3D12 drawevery argument table entry,vertex buffers, stridespipeline, depth-stencil,refs, raster state,16 viewports + scissorsupload root argumentsMetal drawyes (most fields)noforget the last draw
What a replayed draw sets on the Metal encoder, before and after. (Mermaid source)

The interesting part

The benchmark harness from the previous entry reported that the game-like bgfx "drawstress" scene spent only 1.8 ms of CPU a frame in replay, out of 90 ms. That pointed away from the layer. It was wrong. The whole Wine process runs x86_64 code under Rosetta, Apple's x86-to-ARM translator. Under Rosetta, the per-thread CPU clock (CLOCK_THREAD_CPUTIME_ID) reported 24 µs for a 1 ms busy loop, while the Mach thread_info call reported 1003 µs. With the clock fixed, drawstress spends 58.7 ms of its 74.7 ms frame in replay: the layer is the bottleneck after all.

What is left per draw in the 10000-draw benchmark is about 610 ns. About 230 ns of that is Metal's own draw call (see the cost chart; measured with temporary timing marks, not committed).

Numbers

Medians on an M1 Max:

scenebeforeafter
bench-draws frame20.30 ms7.47 ms
bench-draws GPU2.10 ms0.96 ms
bgfx drawstress frame148.7 ms74.7 ms

The GPU time fell too: redundant state commands cost the GPU as well, not only the CPU.

Full test run: 837 pass, 28 fail, 76 skip of 941, against 836 of 940 in the previous entry. The one new pass is api/draw_state_reuse; nothing newly fails. The 28 failures are the same tests as before, all known and outside this work.

Frame time before and after the draw-state caches, ms (M1 Max, medians) (data as CSV)
  • before
  • set only changes
  • in-place compares

scene · ms per frame (median)

  1. bench draws (10000 draws)20.3 before, 9.39 set only changes, 7.47 in-place compares
  2. bgfx drawstress (64000 cubes)148.73 before, 90 set only changes, 74.68 in-place compares
Replay CPU per draw after the caches, ns (bench-draws, M1 Max, under Rosetta) (data as CSV)

part of cmd_draw · ns per draw

  1. Metal draw call230
  2. root argument upload93
  3. draw_bindings compares72
  4. draw_state compares63
  5. render_encoder lookup36
  6. setArgumentTable35
  7. render_area (cached)26
  8. record header21
  9. viewport loop19
  10. depth bounds / VRS check15
Tests by suite at the end of this entry: 837 of 941 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. api (+1)73 pass, 0 fail, 0 skip of 73
  5. wine-dxgi16 pass, 4 fail, 0 skip of 20
  6. samples4 pass, 0 fail, 0 skip of 4