Setting only what changed: 10000 draws from 20 ms to 7.5 ms, and a CPU clock that was 40 times too low
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.
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:
| scene | before | after |
|---|---|---|
| bench-draws frame | 20.30 ms | 7.47 ms |
| bench-draws GPU | 2.10 ms | 0.96 ms |
| bgfx drawstress frame | 148.7 ms | 74.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.
- before
- set only changes
- in-place compares
scene · ms per frame (median)
- bench draws (10000 draws)20.3 before, 9.39 set only changes, 7.47 in-place compares
- bgfx drawstress (64000 cubes)148.73 before, 90 set only changes, 74.68 in-place compares
part of cmd_draw · ns per draw
- Metal draw call230
- root argument upload93
- draw_bindings compares72
- draw_state compares63
- render_encoder lookup36
- setArgumentTable35
- render_area (cached)26
- record header21
- viewport loop19
- depth bounds / VRS check15
- 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
- api (+1)73 pass, 0 fail, 0 skip of 73
- wine-dxgi16 pass, 4 fail, 0 skip of 20
- samples4 pass, 0 fail, 0 skip of 4