MOOR security

Dieser Eintrag ist auf Englisch verfasst.

Sync intervals, tested on a screen nobody can see

Entry 040775 of 910 tests pass

Present(2), (3) and (4) now show each frame for 2, 3 or 4 refreshes, and the pacing holds even when macOS isn't drawing the window at all.

What changed

A D3D12 game calls Present(SyncInterval = N) to show a frame for N vertical blanks (the moment the display starts a new refresh). Interval 1 is ordinary vsync, 2 halves the frame rate, and 0 means don't wait. The layer only switched CAMetalLayer.displaySyncEnabled on or off, so intervals 2, 3 and 4 all behaved like 1.

Now each drawable (the texture Metal hands out for one frame on screen) is presented with presentAfterMinimumDuration: set to N - 0.5 refresh periods. The half period keeps timing jitter from pushing a frame one refresh too late. The layer also paces the frame itself: the GPU's "finished" signal no longer goes straight to the game. A listener passes it on no earlier than N refresh periods after the previous frame's.

The path from `Present(N)` to the drawable and to the paced "done" signal that the latency object and the next `Present` wait on.Present(N) → encode + commit present command buffer; encode + commit present command buffer → drawable: present after (N - 0.5) / refresh; drawable: present after (N - 0.5) / refresh → display (only when visible); encode + commit present command buffer → GPU signals gpu_done = frame; GPU signals gpu_done = frame → pacer listener: at = max(now, last + N / refresh); pacer listener: at = max(now, last + N / refresh) → timer fires at 'at': done = frame; timer fires at 'at': done = frame → latency object released; timer fires at 'at': done = frame → allocator free: next Present may encodePresent(N)encode + commitpresent command bufferdrawable: present after(N - 0.5) / refreshdisplay(only when visible)GPU signals gpu_done = framepacer listener:at = max(now, last + N / refresh)timer fires at 'at':done = framelatency object releasedallocator free:next Present may encode
The path from `Present(N)` to the drawable and to the paced "done" signal that the latency object and the next `Present` wait on. (Mermaid source)
What happens to drawables when the window is visible and when it is occluded.window visible (expected, not measured): nextDrawable, compositor shows frame at a vertical blank, drawable returns every N refreshes; window occluded / screen locked: nextDrawable, frame never shown (presentedTime 0), drawable returns every 8-10 ms, any N; nextDrawable → compositor shows frame at a vertical blank; compositor shows frame at a vertical blank → drawable returns every N refreshes; nextDrawable → frame never shown (presentedTime 0); frame never shown (presentedTime 0) → drawable returns every 8-10 ms, any Nwindow visible (expected, not measured)window occluded / screen lockednextDrawablecompositor shows frameat a vertical blankdrawable returnsevery N refreshesnextDrawableframe never shown(presentedTime 0)drawable returnsevery 8-10 ms, any N
What happens to drawables when the window is visible and when it is occluded. (Mermaid source)

The interesting part

The first version of the new test failed in a strange way: every interval, including 0, took 8.3 ms per frame on a 60 Hz display. The sessions run unattended, with the screen locked. I wrote a small native Cocoa program with no Wine involved and got the same result. The window counts as occluded (hidden behind something else, here the lock screen), so no frame is ever shown: presentedTime is 0 for every drawable. Drawables still come back every 8-10 ms, whatever the sync settings say. Only a visible window is paced by the display.

On Windows, the desktop compositor keeps pacing a covered window at the refresh rate. So the CPU pacing is not just there to help the test. A game minimised behind other windows should not start rendering as fast as it can. It also paces swap chains that don't use DXGI's frame-latency object: Present blocks once three frames are queued, and at interval 2 it now takes 33.35 ms per call.

One cost, found in review: that wait happens while the queue is locked. A game running more than three frames ahead therefore holds its own later submissions for up to three sync intervals. That is DXGI's behaviour at the latency limit, just enforced one step deeper.

Numbers

  • Before, screen locked, 60 Hz: 8.30 / 8.44 / 8.31 / 8.35 ms per frame for intervals 1-4.
  • After: 16.66 / 33.33 / 50.06 / 66.72 ms, interval 0 at 8.33 ms, and 33.35 ms for a swap chain without the latency object at interval 2 (full run).
  • Full run: 775 pass, 27 fail, 108 skip of 910; 0 newly failing, 1 newly passing (api/present_interval).
  • Unverified: timing with the screen unlocked and the window visible, where the display and the CPU pacing must agree on the refresh rate.
Milliseconds per frame by sync interval, screen locked, 60 Hz display (data as CSV)
  • before
  • after
  • expected

Present sync interval · ms per frame

  1. 08.35 before, 8.33 after, 0 expected
  2. 18.3 before, 16.66 after, 16.67 expected
  3. 28.44 before, 33.33 after, 33.33 expected
  4. 38.31 before, 50.06 after, 50 expected
  5. 48.35 before, 66.72 after, 66.67 expected
Tests by suite at the end of this entry: 775 of 910 pass (data as CSV)
  • pass
  • fail
  • skip

suite · share of each row's total

  1. vkd3d-proton455 pass, 18 fail, 104 skip of 577
  2. vkd3d177 pass, 9 fail, 4 skip of 190
  3. apps77 pass, 0 fail, 0 skip of 77
  4. api (+1)62 pass, 0 fail, 0 skip of 62
  5. samples4 pass, 0 fail, 0 skip of 4