Skip to content

Mac: steady 60 fps in fullscreen on ProMotion screens (Breath of the Wild) - #930

Closed
moretti wants to merge 5 commits into
seedds:mainfrom
moretti:mac-frame-pacing
Closed

moretti wants to merge 5 commits into
seedds:mainfrom
moretti:mac-frame-pacing

Conversation

@moretti

@moretti moretti commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

The short version

What was wrong. On a MacBook Pro with a ProMotion screen (M5 Pro, macOS 27.2, build 26B123), Breath of the Wild in fullscreen had four problems. At the start it was much worse than "a bit under 60": the game sat at 30 or even 20 fps for minutes at a time, and busy scenes ran at 48–56. Fixing those left the last problem, a slow drop to 56 that never came back.

  1. Stuck at 30 or 20 fps for minutes. The Mac turns the graphics chip's speed up slowly, over seconds. Our "can we go back to 60?" check gave up after half a second, before the chip had sped up, so the game stayed slow until you opened a menu.
  2. 48–56 fps in busy scenes. The MetalFX upscaler ran in the middle of the game's own GPU work and took 2.6 ms of every frame.
  3. More CPU time per frame than needed. On the Mac, all the Metal encoding was done on the same thread as the translation.
  4. 56 fps after a few minutes, for the rest of the session. At some random moment, macOS starts showing every frame 1/240 of a second later than it said it would. After that, macOS's frame timer (the "display link") calls us back a little late about every 12 frames, so we miss a frame each time. Our code is idle and on time when this happens; the delay is on macOS's side.

What this PR does.

  1. The "can we go back to 60?" check waits as long as frames keep getting cheaper, so the chip has time to speed up.
  2. The MetalFX upscaler runs right after the game's frame, in the snapshot copy, so the screen update only draws the finished picture.
  3. Breath of the Wild encodes Metal commands on their own thread on the Mac, as it already does on iOS.
  4. When the host sees the 1/240 s shift, it stops using macOS's frame timer and presents each frame from its own thread instead. The screen is variable-refresh, so it shows each frame as soon as it arrives. This happens only in fullscreen on variable-refresh screens, and only in the default pacing mode. Windows and fixed-rate screens (most external monitors) keep the current behaviour, and so does DM_PRESENT_LINK=2 (the 120 Hz link), which is a manual setting.
  5. A profiler (off by default) records what the GPU clock, the system and the frame timer did on every frame. That is how 1–4 were found and checked.

Result:

Before After
Heavier scenes 30 or 20 fps for minutes, until a menu probes back to 60 hold (11 of 11)
Busy scenes, MetalFX on 48–56 fps 60 fps (present GPU time 2.6 → 0.3–0.5 ms)
After a few minutes 56 fps (58.6 on the 120 Hz link) for the rest of the session stable 60 fps for a 27-minute session

Related issues and PRs

Summary

Five commits:

1 Host profiler          dm/host/sysprof.m, dm/tools/perf_report.py   per-frame GPU clock, limits, process role (DM_NVN_TSV only)
2 Frame step             nvn_fstep.h      a probe's grace renews while its frames get cheaper (up to 180 frames)
3 Breath of the Wild     game.tsv         enc_thread on; nvn_rec.h records copyFromTexture:toBuffer:options:
4 Present: MetalFX       nvn_mtl.m        scaler in the snapshot command buffer, present draws 1:1
5 Present: display link  nvn_mtl.m        shift detected -> pacing leaves the link (mode 0)

What commit 5 does, as a flow:

each presented frame (presented handler)
  late = presentedTime - link target
  log when the offset moves (first 8 moves, then a count a minute)
  count it in a window of 120 presents (windows follow each other;
    presents > 20 ms late or dropped are not counted, so up to ~240)
  if 3/4 of the window was >= 2 ms late                     # the 1/240 s shift
     and fullscreen and variable-refresh screen and 60 fps game
     and pacing mode 1 (the default)
       ask to leave the link (only a request)
each frame (link_sync, the game's present thread)
  if asked: pacing mode 1 -> 0 and invalidate the link, in one step
       (the link keeps taking frames until then, so a game waiting
        for a frame is never left waiting)
  if a setting put pacing back on a link: the setting wins
  if we left the link and the window/screen/rate no longer fits
       pacing back on the link

Commit 5 also:

  • adds per-frame rows saying where the display-link callback spent its time (@link_run_ms, @link_idle_ms, @link_slack_ms, ...): that is how we proved the delay is on macOS's side;
  • fixes the link's hold so a 60 fps frame on the 120 Hz link (DM_PRESENT_LINK=2) is held for two ticks. Before, the game ran at 87 fps and 1.16× game speed there.

Evidence

All runs: Breath of the Wild 1.9.0, MacBook Pro M5 Pro (Mac17,8), macOS 27.2 (26B123), built-in ProMotion screen, fullscreen, controller only.

  • Stuck at 30/20 (commit 2).
    Before: a probe saw the clock rise 338 → 432 MHz and the GPU time fall 24.8 → 18.5 ms, failed anyway, and the game stayed at 30 or 20 fps until a menu.
    After: in a 470 s run with the rule switched on and off every 30 s, every probe with the rule held (11 in a row). The failures were all in phases with it off.

  • MetalFX (commit 4).
    Before: the present cost 2.58–2.66 ms of GPU time on the game's queue.
    After: 0.34–0.52 ms (measured in the same run, switched every 30 s).

  • Encode thread (commit 3). The present thread's CPU went from 5.9 to 3.4–4.4 ms a frame, and the submit from 6.6 to 5.0 ms.

  • 56 fps after a few minutes (commit 5). 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 (every frame shown 4.17 ms late)
    7 120 Hz link 58.6 fps
    8 120 Hz link, latency 1 60.00 fps 58.63 fps
    9 120 Hz link, timing rows 60.00 fps 58.6 fps
    10 no display link (mode 0) 60.03 fps for 15 min, game time 1.000
    11 this PR, no settings 60 fps 60 fps, stable, for the rest of a 27-minute session

    Run 11: the host switched by itself about 2 s after the shift (pacing leaves the link at the next present), with no freeze, and the game stayed at 60 fps apart from a few single-second dips.

    Run 9's timing rows on 200 late frames: our callback ran 0.16 ms (max 0.5) with no waits, each present came 8.1 ms before its deadline, and before each late callback the link thread sat idle for 20.7 ms instead of 8.33 ms. So it's macOS calling back late, not us.

  • Tests:

    Test Checks
    t_link_ticks 48
    t_present_rescue --metal 86
    t_fstep 70
    check_t_present_snap 23

    The doc, env-switch and launcher tests pass. Separately, a replay of the shift detection over the four recorded sessions found the same frame I found by hand, and switched exactly where 56 fps began.

Not checked yet

  • External monitors (60 Hz, 144/165 Hz, adaptive sync). By design they keep the display link unless they are variable-refresh and in fullscreen.
  • Other games. scripts/regress.py check --games can only cover games that are on this machine, and the other candidates by reach (Tears of the Kingdom, Mario Kart 8 Deluxe (MK8D - ProMotion fullscreen late-present problem #603), Pokémon Scarlet, Super Mario Odyssey) are not. Breath of the Wild was tested over more than 10 runs while this was built (runs 4–11 above, plus the probe and MetalFX runs).
  • iOS. The snapshot MetalFX scaler (commit 4) is on by default only on the Mac; on iOS it stays off until it is measured there (DM_PRESENT_FX_AT_SNAP=1 turns it on). The hold fix in commit 5 applies on iOS too, but only with DM_PRESENT_LINK=2.
  • Why macOS shifts the screen by 1/240 s at all. This is still open, and noted in docs/reference/frame-rate.md.

Merge Danger

Door: two-way. Each change has an off switch:

  • DM_PRESENT_RESYNC=0 keeps the display link;
  • DM_FSTEP_PROBE_RAMP=0 turns off the probe change;
  • DM_PRESENT_FX_AT_SNAP=0 puts MetalFX back in the present;
  • enc_thread is one profile key.

Reverting any commit is cheap.

Blast Radius: Mac presentation.

  • Commit 5 changes pacing only in fullscreen on variable-refresh screens, and only after the shift is seen.
  • Commit 2 changes the frame step for every game on every platform: probes can last longer while frames get cheaper. A flat cost still ends a probe after 30 frames.
  • Commit 4 only applies with present snapshots and MetalFX upscaling, on the Mac. When the canvas or output size changes (a resize, fullscreen), remaking the scaler can block the game's present thread for up to 200 ms, once.
  • iOS: the frame-step change (commit 2) applies there too, and so does the hold fix, with DM_PRESENT_LINK=2. Everything else is Mac-only or off by default on iOS.

… frame's timing rows, and a report of one session

With DM_NVN_TSV set (off by default), dm/host/sysprof.m samples every 100 ms, without root, what the
system did with the game's GPU and process (IOReport through dlopen; Mac only): the GPU clock and the
share of time it was powered (@gpu_mhz, @gpu_on; powered is not busy), its power (@gpu_w), the clock
state the boost controller asked for (@gpu_boost), the share of time a thermal or voltage-droop limit
applied (@gpu_cltm, @gpu_droop), the process role (@ROLE), the thermal state and Low Power Mode. With
DM_AB, @ab gives the switches on in the frame's phase. A change of role is logged.

dm/tools/perf_report.py turns one session into a timeline per second and gives each drop below the cap
a verdict (cpu, gpu-clock, gpu-limit, gpu, role, pacing); with DM_AB it compares the switch sets over
the same session, so one run answers what several runs used to.

Also: @late_ms no longer underflows when a display-link tick returns before the texture's slot time.

Documented in docs/development.md (Why a frame rate fell: one session's profile).
On the Mac the GPU's performance controller raises the clock for a shorter deadline by about 100 MHz a
second. A probe back to hold 1 had 30 grace frames: Breath of the Wild on an M5 Pro saw the clock rise
338 → 432 MHz and the GPU time fall 24.8 → 18.5 ms over a probe, which then failed, and the game stayed
at 30 or 20 fps for minutes until a menu. A grace window whose second half costs at most 95% of its
first (FSTEP_RAMP) and had a late frame now starts another, up to 180 frames in all (FSTEP_PROBE_MAX);
a flat cost ends the probe as before. General, in the pure controller (nvn_fstep.h);
DM_FSTEP_PROBE_RAMP=0 turns it off, ab compares it in one run.

One 470 s run with the rule switched every 30 s: after the first 80 s every probe held, 11 in a row,
each within about a second; the failures were in a phase with the rule off.

t_fstep: a falling cost holds with the rule and fails without it, a flat cost lasts 33 frames as
before, a cost that stalls ends one window later, one that never fits ends at FSTEP_PROBE_MAX.
…encode thread records texture-to-buffer copies with blit options

The game waits on the fence of the frame it just submitted, so the submit thread's translation is part
of its frame. With the profile's enc_thread on, an M5 Pro run (470 s outdoors) took the present thread's
CPU from 5.9 to 3.4–4.4 ms a frame and the submit from 6.6 to 5.0 ms; iOS has it on already.

With it, the graphics tests built for this profile reached a copy the deferred encoder had no stand-in
for: copyFromTexture:…toBuffer:…options: (one plane of a combined depth / stencil texture), and the
host stopped with "FATAL deferred encoding" (t_rt_range and t_unwritten_store under
DM_NVN_UNWRITTEN_DC=0). nvn_rec.h records it now (RO_C_COPY_TB_OPT); both tests pass.
…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.
@moretti
moretti force-pushed the mac-frame-pacing branch 2 times, most recently from da8e15b to c92eff4 Compare November 2, 2026 05:18
seedds added a commit that referenced this pull request Nov 3, 2026
With Metal encoding on its own thread (DM_NVN_ENC_THREAD=1, the default on iOS) the host stopped at the first depth plane copy: "FATAL deferred encoding: DmRecEnc has no stand-in for -copyFromTexture:...toBuffer:...options:". That copy, between Depth32Float_Stencil8 and Depth32Float through a staging buffer, is the only one that passes blit options (MTLBlitOptionDepthFromDepthStencil), and the recorder had stand-ins only for the copies without them.

nvn_rec.h now records both directions with their options (RO_C_COPY_TB_OPT, RO_C_COPY_BT_OPT) and replays them on the Metal 4 compute encoder. Every selector the host's encoder wrappers send now has a stand-in. t_rt_range (19 cases) and t_unwritten_store (66 checks), which stopped there with the encode thread on, pass in both modes; check_t_rt_range also runs with DM_NVN_ENC_THREAD=1. PR #930 found it on Breath of the Wild on the Mac and added the texture-to-buffer half; no game run here.

Co-authored-by: Paolo Moretti <1186409+moretti@users.noreply.github.com>
@seedds

seedds commented Nov 3, 2026

Copy link
Copy Markdown
Owner

The encode-thread stand-in from your third commit is on main as 7d260ee (credited as co-author), with the other half added: the depth plane copy also calls copyFromBuffer:…toTexture:…options: right after, so with only the texture-to-buffer stand-in the encode thread would still have stopped at the next line. check_t_rt_range now runs with DM_NVN_ENC_THREAD=1 too. When you rebase, drop that part of the commit; the rest of the PR (profiler, frame-step grace, the BotW key, MetalFX in the snapshot, the display-link shift) is still open, and would be easier to review as separate PRs.

@moretti

moretti commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. I've rebased onto main and split this into four PRs. The nvn_rec.h stand-in is dropped since it's on main as 7d260ee, so the Breath of the Wild PR is now just the profile key.

Each one builds and passes its tests on its own on current main. Closing this one in favour of them.

@moretti moretti closed this Nov 3, 2026
@moretti
moretti deleted the mac-frame-pacing branch November 8, 2026 11:44
Know-It-All-Brother pushed a commit to Know-It-All-Brother/nxnative that referenced this pull request Nov 9, 2026
With Metal encoding on its own thread (DM_NVN_ENC_THREAD=1, the default on iOS) the host stopped at the first depth plane copy: "FATAL deferred encoding: DmRecEnc has no stand-in for -copyFromTexture:...toBuffer:...options:". That copy, between Depth32Float_Stencil8 and Depth32Float through a staging buffer, is the only one that passes blit options (MTLBlitOptionDepthFromDepthStencil), and the recorder had stand-ins only for the copies without them.

nvn_rec.h now records both directions with their options (RO_C_COPY_TB_OPT, RO_C_COPY_BT_OPT) and replays them on the Metal 4 compute encoder. Every selector the host's encoder wrappers send now has a stand-in. t_rt_range (19 cases) and t_unwritten_store (66 checks), which stopped there with the encode thread on, pass in both modes; check_t_rt_range also runs with DM_NVN_ENC_THREAD=1. PR seedds#930 found it on Breath of the Wild on the Mac and added the texture-to-buffer half; no game run here.

Co-authored-by: Paolo Moretti <1186409+moretti@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants