MOOR security

Dieser Eintrag ist auf Englisch verfasst.

Three message sends per draw, and what they cost under Rosetta

Entry 060839 of 943 tests pass

Calling three Metal methods per draw through cached function pointers instead of Objective-C message sends, plus skipping bindings a repeated draw already has, cut the CPU time of a 64,000-draw scene by a fifth.

What changed

mtl12's unix side, the part that turns recorded D3D12 command lists into Metal 4 calls, runs as x86_64 code under Rosetta. bgfx's drawstress scene issues about 64,000 draws a frame, nearly all with the same state block (the bundle of pipeline, heaps and vertex buffers a draw uses).

Two changes this session:

  • The render argument table (Metal 4's list of buffer addresses a shader reads) is no longer re-checked for a draw of the block it was last bound for. Such a draw only sets its root arguments, which are copied and compared with plain word loops instead of calls to libc's memcmp and memcpy.
  • The three Objective-C methods every ordinary draw calls (set the root address, bind the table, draw) are looked up once per encoder and then called through their function pointers, the IMPs, instead of through objc_msgSend.
What a draw of a repeated state block still does.draw of state block B (same block as the last draw) → bindings_valid and B == bindings_of?; bindings_valid and B == bindings_of? → set heaps (7 slots), sampler heap, root block, vertex buffers (no); bindings_valid and B == bindings_of? → root block address changed? (yes); root block address changed? → table setAddress at index 6 (cached IMP) (yes); root block address changed? → encoder setArgumentTable (cached IMP) (no); table setAddress at index 6 (cached IMP) → encoder setArgumentTable (cached IMP); set heaps (7 slots), sampler heap, root block, vertex buffers → encoder setArgumentTable (cached IMP); encoder setArgumentTable (cached IMP) → drawIndexedPrimitives (cached IMP); rect clear, barrier, copy, dispatch... → bindings_valid and B == bindings_of? (resets bindings_valid)draw of state block B(same block as the last draw)bindings_valid andB == bindings_of?set heaps (7 slots), sampler heap,root block, vertex buffersroot block addresschanged?table setAddress at index 6(cached IMP)encoder setArgumentTable(cached IMP)drawIndexedPrimitives(cached IMP)rect clear, barrier, copy, dispatch...noyesyesnoresets bindings_valid
What a draw of a repeated state block still does. (Mermaid source)
Why only a test that reads slot 0 catches a stale binding after a rect clear.Participants: command list, render argument table, pixel shader. 1. command list → render argument table: draw 1 binds heap at index 0 (SRVs) and heap+8 at index 5 (CBVs); 2. pixel shader → render argument table: SRV read through index 0: correct; 3. command list → render argument table: rect clear writes its constants at index 0; 4. command list → render argument table: draw 2, same state block: index 0 must be bound again; 5. Note (render argument table, pixel shader): a CBV test reads index 5 and cannot notice, a typed-buffer SRV reads index 0 and fails 12 checkscommand listrender argument tablepixel shaderdraw 1 binds heap at index 0 (SRVs) and heap+8 at index 5 (CBVs)SRV read through index 0: correctrect clear writes its constants at index 0draw 2, same state block: index 0 must be bound againa CBV test reads index 5 and cannot notice,a typed-buffer SRV reads index 0 and fails 12 checks
Why only a test that reads slot 0 catches a stale binding after a rect clear. (Mermaid source)

The interesting part

A calibration loop injected into a Wine process measured 4.4 ns per message send and 1.6 ns per IMP call under Rosetta. For three sends and 64,000 draws that predicts about 0.5 ms. The A/B run saved about 2 ms, or roughly 10 ns per send. Why sends to Metal's objects cost that much more than the warm loop is not measured yet.

The test for the first change took two tries. Dropping its safety condition passed all 75 API tests. Even a new test that read a constant buffer through a descriptor table passed. Constant buffers are read at table slot 5, and the operation that overwrites a slot behind the cache's back, a rectangle clear, writes slot 0. Reading a typed buffer, which goes through slot 0, made the broken build fail 12 checks.

Numbers

  • drawstress unix CPU, medians of 3 alternating rounds: 16.86 -> 15.07 -> 13.19 ms; frame 28.70 -> 26.86 -> 25.07 ms; bench-draws submit 2.66 -> 2.44 -> 2.09 ms
  • Full suite: 839 pass, 28 fail, 76 skip of 943, the same as entry 059.
  • Unexplained: drawstress GPU time went up 6-16% in the runs with the new code.
drawstress and bench-draws times over this entry's two changes (medians of 3 A/B rounds, ms) (data as CSV)
  • before
  • + bindings_of
  • + cached IMPs

measurement · ms

  1. drawstress unix CPU16.86 before, 15.07 + bindings_of, 13.19 + cached IMPs
  2. drawstress frame28.7 before, 26.86 + bindings_of, 25.07 + cached IMPs
  3. bench-draws submit2.66 before, 2.44 + bindings_of, 2.09 + cached IMPs
Tests by suite at the end of this entry: 839 of 943 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. api75 pass, 0 fail, 0 skip of 75
  5. wine-dxgi16 pass, 4 fail, 0 skip of 20
  6. samples4 pass, 0 fail, 0 skip of 4