Skip to content

Present on the Mac: MetalFX scales at the snapshot, and pacing leaves the display link when the screen shifts under it - #1045

Open
moretti wants to merge 3 commits into
seedds:mainfrom
moretti:present-metalfx-display-link
Open

moretti wants to merge 3 commits into
seedds:mainfrom
moretti:present-metalfx-display-link

Conversation

@moretti

@moretti moretti commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Split out of #930 (4 of 4). This one has two commits. The display-link commit is built on the MetalFX one (both change the present path in nvn_mtl.m), so they go together.

Summary

1. MetalFX scales the picture at the snapshot. The MetalFX scaler ran on the game's queue at each display-link tick, between or right after the frame's segments, and cost about 2.6 ms of every frame. With present snapshots it now runs in the snapshot's command buffer, right after the frame's last segment, and the present only draws the result 1:1.

 snap_take (after the frame's last segment)
   copy the game's texture into the ring slot
+  MetalFX: scale the presented rectangle into the slot's `up` texture
 present_now (display-link tick, game's queue)
-  MetalFX scaler -> drawable
+  draw `up` 1:1 (any mismatch: the old path for that frame)

On by default on the Mac and off on iOS, where it hasn't been measured (DM_PRESENT_FX_AT_SNAP=1 turns it on). DM_PRESENT_FX_AT_SNAP=0 restores the old placement, and ab compares the two. @present_gpu_ms and @snap_gpu_ms give each command buffer's GPU time.

2. Pacing leaves the display link when the screen shifts under it. At some point in a fullscreen session on a ProMotion Mac, macOS starts showing every frame 1/240 s after the display link's target. From then on the link calls back a tick late about every 12 frames (56 fps). Presenting without the link (mode 0) holds 60.

each presented frame
  late = presentedTime - link target
  if 3/4 of 120 presents were >= 2 ms late          # the 1/240 s shift
     and fullscreen, variable-refresh screen, 60 fps game, pacing mode 1 (default)
       request: leave the link
each frame (link_sync, the game's present thread)
  if requested: pacing mode 1 -> 0 and invalidate the link, in one step
  if those conditions stop holding: back to the link

Also in this commit: @link_* timing rows for the link callback, and a hold fix. A 60 fps frame on the 120 Hz link (DM_PRESENT_LINK=2) is now held for two ticks; before, the game ran at 87 fps and 1.16× speed there. DM_PRESENT_RESYNC=0 keeps the link.

Related: #603 (Mario Kart 8 Deluxe: DM_PRESENT_LINK=0 removes the same late presents on an M3 Max), #347 / #348, #822.

Evidence

Breath of the Wild 1.9.0, MacBook Pro M5 Pro, macOS 27.2, built-in ProMotion screen, fullscreen.

  • MetalFX. Before: the present cost 2.58–2.66 ms of GPU time on the game's queue (48–56 fps in busy scenes).
    After: 0.34–0.52 ms, measured in the same run with the placement switched every 30 s.

  • Display link. The shift started at 87, 251, 342 and 355 s in four sessions.

    Run Pacing Before the shift After the shift
    7 60 Hz link 60.00 fps 56 fps
    8 120 Hz link, latency 1 60.00 fps 58.63 fps
    10 no link (mode 0) 60.03 fps for 15 min, game time 1.000
    11 this PR, no settings 60 fps 60 fps for the rest of a 27-minute session

    In run 11 the host switched about 2 s after the shift, with no freeze. Timing rows on 200 late frames: our callback ran 0.16 ms (max 0.5) with no waits, and the link thread sat idle 20.7 ms before each late callback instead of 8.33 ms. So the lateness comes from macOS, not from our code.

  • Tests: t_link_ticks 48, t_present_rescue --metal 86, check_t_present_snap 23 checks.

  • Builds and tests on its own on current main: host and CPU gfx tests, sanitizers, tool tests, clang-tidy and ruff/pyright pass locally.

Merge Danger

Door: two-way. DM_PRESENT_FX_AT_SNAP=0 and DM_PRESENT_RESYNC=0 turn each part off.

Blast Radius: Mac presentation.

  • Leaving the link happens only in fullscreen on variable-refresh screens, and only after the shift is seen. Windows, fixed-rate screens and DM_PRESENT_LINK=2 keep the current behaviour.
  • When the canvas changes (a resize, fullscreen), remaking the scaler can block the game's present thread once, for up to 200 ms.
  • iOS gets only the hold fix, and only with DM_PRESENT_LINK=2.
  • Not checked yet: external monitors, other games, and why macOS shifts the screen at all (open in docs/reference/frame-rate.md).

regress.py impact: Breath of the Wild, Pokémon Scarlet and Tears of the Kingdom are reached, and Hades through the present rule. None of them is on this machine, so regress.py check --games was not run.

Update: rebased on main, and the shift at 30 fps

This branch is rebased on today's main. The conflicts with the probe-ramp change (now on main) are resolved, so queued and fxsnap both run in t_present_snap.

A new commit makes pacing leave the shifted display link at 30 fps too. Before, the leave condition required mtl_fps_target() == 60. Donkey Kong Bananza shifted during an interval-2 scene on the 30 Hz link and showed 33 and 58 ms frames, 26-27 fps for 33 s. Mode 0 now presents each frame for its period at 60 and at 30 fps alike, and a change of rate no longer brings pacing back to the link.

  • t_present_rescue: the hand-off now runs at 60 and at 30 fps. Without the fix 6 checks fail; with it, 100 pass.
  • t_present_snap: 166 checks pass in both encode modes, with Metal validation.

@github-actions github-actions Bot added nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) tools Tools and scripts (dm/tools, scripts) docs Documentation game: 01007EF00011E000 The Legend of Zelda: Breath of the Wild labels Nov 3, 2026
@moretti
moretti force-pushed the present-metalfx-display-link branch from a897dd5 to 0a4610f Compare November 3, 2026 06:54
…n the game's queue only draws it

The present command buffer ran the MetalFX spatial scaler (1920x1080 → the fullscreen canvas) on the
game's queue at each display-link tick, inside or right after the frame's segments: about 2.6 ms of
GPU time a frame at the low clock macOS keeps for Breath of the Wild, which took frames from about 15
to 17–18 ms (48–56 fps). With present snapshots, snap_take now scales the presented rectangle into an
upscaled texture of the slot, in the snapshot's command buffer right after the frame's last segment, and
the present draws it 1:1 (0.3–0.5 ms). A canvas size or format change remakes the scaler and the slot's
texture (the old one stays resident until the slot's next use), and any mismatch takes the old path for
that frame. DM_PRESENT_FX_AT_SNAP=0 restores it, ab compares both; DM_PRESENT_UPSCALE takes ab too.
@present_gpu_ms and @snap_gpu_ms give both command buffers' GPU time per frame.

M5 Pro run, the two placements switched every 30 s: the present 2.58–2.66 → 0.34–0.52 ms; the user
reports a cleaner picture. t_present_snap fxsnap (both encode modes, Metal validation): exact scaled
pictures, a refused resized or reformatted canvas, a canvas change remaking the scaler, residency of the
replaced textures.
…it in variable-refresh fullscreen

Breath of the Wild in fullscreen on a ProMotion Mac (M5 Pro): at one frame of a session (87, 251, 342
and 355 s in four of them) the system starts showing every frame exactly 4.17 ms (1/240 s) after the
display link's target and keeps that offset. Nothing on the host side changes at that frame. From then
on the link calls back a tick late about every 11-12 frames: 56 fps on the 60 Hz link, 58.6 on the
120 Hz link at frame latency 2 and 1 alike. A display link made again keeps the offset. Without the
link (mode 0, the present thread presenting each frame for its period) the game held 60.03 fps for 15
minutes at game time 1.000.

- link_late_note checks each frame's presentation time against its link target. When 120 presents in
  a row were three quarters or more 2 ms or more late, a 60 fps game leaves the link for mode 0, where
  it fits (link_leave_ok): fullscreen on a variable-refresh screen, at any rate. Fixed-rate screens and
  windows keep the link; leaving those conditions takes pacing back to it. DM_PRESENT_RESYNC=0 turns the
  switch off.
- Each move of the frames' offset against the link's targets is logged with its first present, the
  callback's time after the flip two frames back, the screen's rate and refresh kind, fullscreen,
  latency and pacing mode (the first 8, then a count a minute).
- DM_NVN_TSV gains where the link callback that took each frame spent its time: @link_slack_ms
  (update.targetTimestamp minus the present call), @link_wait_ms (snapshot and external-producer
  waits), @link_alloc_ms (present_now's allocator wait), @link_run_ms (entry to exit), @link_idle_ms
  (since the previous callback exited), @link_held_ms (the longest callback that took no frame since
  the previous present), @link_lock_ms (the wait for g_pq_mu). On 200 late frames on the 120 Hz link
  our callbacks ran 0.16 ms (max 0.5) with no wait, each present came 8.1 ms before its deadline, and
  the link thread sat idle 20.7 ms before each late callback where it waits 8.33 ms otherwise: the
  system called back late, not the host.
- The link callback's hold counts the link's own ticks (link_hold_s): a frame stays on screen for its
  whole period when that covers two or more ticks. The fixed "period > 25 ms" let every tick of the
  120 Hz link (DM_PRESENT_LINK=2) take a 60 fps frame, and with present snapshots the game ran at 87 fps
  and 1.16 of real time. The 60 and 30 Hz links hold exactly as before.

t_link_ticks 48 checks (the hold on 120, 60 and 30 Hz and virtual ticks; the shift judge;
link_leave_ok; the offset tracker); t_present_rescue --metal; check_t_present_snap. A replay of the
judge and the tracker over four sessions' frame rows dated the shift to the frame found by hand and
fired where 56 fps began. Not checked: the switch itself in a run; external screens.
Donkey Kong Bananza in fullscreen on a variable-refresh screen: the screen's 1/240 s shift came while the
game had set interval 2, on the 30 Hz link. The switch that leaves the link took only 60 fps games, so frames
came 33 and 58 ms apart, 26-27 fps for 33 s, until the game went back to interval 1 and pacing left the link
at 60.

- link_late_note asks to leave the link at any frame rate: mode 0 presents each frame for its period, 30
  and 60 alike.
- link_sync no longer takes pacing back to the link when the rate changes after the move; only a screen or
  window that no longer fits does.
- t_present_rescue runs the handoff at 60 and at 30 fps and checks that a change of rate keeps mode 0
  (6 checks fail without the change).
@moretti
moretti force-pushed the present-metalfx-display-link branch from 0a4610f to ab0e4ef Compare November 8, 2026 07:32
@github-actions github-actions Bot added the game: 040074A01BF12000 Donkey Kong Bananza label Nov 8, 2026
@sleepyeyedjoe

Copy link
Copy Markdown
Contributor

Independent data point for the display-link shift, on another game and chip: Ocarina of Time (0400486024DEC000 v131072), MacBook Pro M4 Pro (Mac16,8), built-in ProMotion screen, macOS 27.0.1, fullscreen (display link: frame latency 2 (fullscreen, variable refresh)), present_snap 1. Host: main 1794a35 with #1245, #1707, #1742 and #1752.

In a 38-minute session, long stretches had every 11th frame at 25 ms (three 120 Hz ticks), frame p95 about 24.7 ms and 27–30 frames dropped per 300 on screen: about 56 fps. During those stretches the CPU was near idle (106–148 % of one core, CPU energy 1.5–3 W) and GPU p50 was 9–12 ms, so as you describe, the lateness isn't ours. The stretches came and went through the session.

Same build Session Dropped on screen
ProMotion, default pacing 38 min 27–30 per 300 in the stretches
Display fixed at 60 Hz in System Settings (frame latency 1, fixed refresh) about 2 min (8,100 frames) 62 in all, 0–12 per 300
ProMotion with DM_PRESENT_LINK=0 about 3 min (10,200 frames) 16 in all, 0–4 per 300

The two short sessions include area loads. I'll add a longer DM_PRESENT_LINK=0 session. I haven't run this PR itself; mode 0 is the state it switches to.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Documentation game: 01007EF00011E000 The Legend of Zelda: Breath of the Wild game: 040074A01BF12000 Donkey Kong Bananza nvn NVN on Metal and its tests (dm/gfx/nvn, dm/gfx/tests) tools Tools and scripts (dm/tools, scripts)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants