MOOR security

Dieser Eintrag ist auf Englisch verfasst.

A benchmark harness, and the GPU timestamps it caught lying

Entry 051836 of 940 tests pass

The D3D12-to-Metal layer now has a repeatable performance harness with a first baseline, and building it exposed GPU timestamps that returned a value from an earlier frame.

What changed

Until now the layer only had correctness tests. This session added a way to measure speed, so later optimisation work has a number to beat (see the harness diagram):

  • A benchmark program: a small D3D12 program with three scenes. room is an existing test scene. draws is 10000 small quads a frame. pso creates 48 pipeline state objects (compiled shader and render-state bundles). Per frame it measures recording time, submit time (the layer turns the D3D12 command list into Metal commands while the game waits), GPU time and frame time.
  • MTL12_PERF=<file>: an environment variable. With it set, the layer writes one line of counters per presented frame. The line has the CPU time spent in the layer by kind and the GPU time Metal reports. That works for real programs I can't change: here ModelViewer and three bgfx examples.
  • A runner: runs all seven scenes, prints one line per scene with the change against the last run on the same machine, and keeps a history file. A full run takes about 100 seconds. The recording shows the bench scenes compared against the baseline.
Where each number is measured: in the program, in the layer's Windows side, in its Metal side, and on the GPU.Windows program (bench.exe or an app) → d3d12core.dll (PE side) records commands (record: Reset..Close); d3d12core.dll (PE side) records commands → mtl12.so (unix side) replays into Metal (ExecuteCommandLists = submit); mtl12.so (unix side) replays into Metal → Metal 4 command buffers; Metal 4 command buffers → GPU; GPU → Windows program (bench.exe or an app) (timestamps around the list = gpu_us); mtl12.so (unix side) replays into Metal → one JSON line per Present (MTL12_PERF: thread CPU time per call, by kind); GPU → one JSON line per Present (commit feedback: GPU start/end); Windows program (bench.exe or an app) → result: line (QueryPerformanceCounter: record, submit, frame); one JSON line per Present → run script: median, p95, p99 + history file; result: line → run script: median, p95, p99 + history fileWindows program(bench.exe or an app)d3d12core.dll(PE side)records commandsmtl12.so(unix side)replays into MetalMetal 4command buffersGPUone JSON lineper Presentresult: linerun script:median, p95, p99+ history filerecord:Reset..CloseExecuteCommandLists= submittimestamps aroundthe list = gpu_usMTL12_PERF: thread CPU timeper call, by kindcommit feedback:GPU start/endQueryPerformanceCounter:record, submit, frame
Where each number is measured: in the program, in the layer's Windows side, in its Metal side, and on the GPU. (Mermaid source)
Before and after. Before, the resolve reads the counter while the write is still pending. After, the queue waits and the resolve goes into a new command buffer.After: the resolve waits for completion: write timestamp 0, render encoder: draws, write timestamp 1, commit, queue waits on the progress event, new command buffer: resolve 0..1; Before: one command buffer: write timestamp 0, render encoder: draws, write timestamp 1 (queued behind the draws), barrier encoder, resolve 0..1; write timestamp 0 → render encoder: draws; render encoder: draws → write timestamp 1; write timestamp 1 → commit; commit → queue waits on the progress event; queue waits on the progress event → new command buffer: resolve 0..1; write timestamp 0 → render encoder: draws; render encoder: draws → write timestamp 1 (queued behind the draws); write timestamp 1 (queued behind the draws) → barrier encoder; barrier encoder → resolve 0..1; resolve 0..1 → write timestamp 1 (queued behind the draws) (reads entry 1 before the write lands: last frame's value)After: the resolve waits for completionBefore: one command bufferwrite timestamp 0render encoder:drawswrite timestamp 1commitqueue waits on theprogress eventnew command buffer:resolve 0..1write timestamp 0render encoder:drawswrite timestamp 1(queued behind the draws)barrier encoderresolve 0..1reads entry 1 beforethe write lands:last frame's value
Before and after. Before, the resolve reads the counter while the write is still pending. After, the queue waits and the resolve goes into a new command buffer. (Mermaid source)

The interesting part

The first GPU times from the draws scene were absurd: 82 of 200 frames were about 7.7e17 µs. The end timestamp was older than the start. On Metal 4 a timestamp resolve (copying the counter into a buffer the program can read) does not wait for the write before it. An earlier spike had found this and worked around it with a small barrier-carrying copy placed before the resolve. After a render pass, that workaround no longer worked. The resolve still returned the value from the round before, in every round but the first.

The new test api/timestamp_after_work reproduces this. It runs 40 rounds of spinning shaders, as dispatches and as draws. In each round it checks that the end is after the start and after the previous round, and that resolving again later gives the same value. Before the fix it failed 120 checks. The fix (second diagram): the layer ends the Metal command buffer at a timestamp resolve, has the queue wait until that buffer has finished, and resolves in a new one. The test passes, and the benchmark had 0 bad frames out of 200. The cost is real: at every timestamp resolve the GPU drains, so two frames in flight no longer overlap. In the room scene that is probably the ~160 µs of the 466 µs frame that neither submit nor GPU time explains. That figure is inferred, not measured.

Numbers

First baseline (medians, M1 Max):

  • bench room: 466 µs a frame, 299 µs GPU, 142 µs submit.
  • bench draws: 20.3 ms a frame, 19.1 ms of it in submit, 2.1 ms GPU. That makes the layer CPU-bound at about 1.9 µs per draw.
  • bench pso: the first pipeline takes 12.6 ms, the rest 1.6 ms each. With Metal's shader cache cold, the first took 230 ms.
  • ModelViewer, bgfx shadowmaps and bgfx assao hold vsync (16.7 ms) with 43–64 µs of CPU in the layer.
  • bgfx drawstress: 148.7 ms a frame, but only 3.2 ms of CPU in the layer's Metal side and 10.8 ms of GPU. Where the rest goes is not measured yet.
  • Two runs in a row agreed within 3% on medians.
  • Full suite: 836 pass, 28 fail, 76 skip of 940. That is one more passing (the new test) and 0 newly failing.
First perf baseline: median per frame, ms (M1 Max) (data as CSV)
  • frame
  • GPU
  • unix-side CPU

scene · ms per frame (median)

  1. bench draws20.3 frame, 2.1 GPU, 19.1 unix-side CPU
  2. ModelViewer16.65 frame, 12.13 GPU, 0.06 unix-side CPU
  3. bgfx shadowmaps16.69 frame, 1.82 GPU, 0.04 unix-side CPU
  4. bgfx assao16.66 frame, 6.87 GPU, 0.05 unix-side CPU
  5. bench room0.47 frame, 0.3 GPU, 0.14 unix-side CPU
Tests by suite at the end of this entry: 836 of 940 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)72 pass, 0 fail, 0 skip of 72
  5. wine-dxgi16 pass, 4 fail, 0 skip of 20
  6. samples4 pass, 0 fail, 0 skip of 4