Skip to content

display: fix 60 Hz cap on ProMotion screens - #7983

Merged
osy merged 1 commit into
utmapp:mainfrom
dragoshont:display/metal-display-link
Nov 12, 2026
Merged

osy merged 1 commit into
utmapp:mainfrom
dragoshont:display/metal-display-link

Conversation

@dragoshont

@dragoshont dragoshont commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

On my MacBook Pro with an external 60 Hz main display, a VM window on the built-in 120 Hz ProMotion panel never shows more than 60 fps.

The display is drawn from MTKView's own timer, and on that panel the timer stayed at 60 callbacks per second even with preferredFramesPerSecond = 120 (confirmed in lldb). A CAMetalDisplayLink on the same layer runs at the rate it requests, and its frames reach the screen at 120 Hz. I haven't tried a ProMotion Mac without an external display.

On macOS 14+ this drives the renderer from a CAMetalDisplayLink. VMMetalView hands the renderer the drawable from each update, so CSMetalRenderer doesn't change. Behaviour:

  • The rate follows the screen the window is on.
  • The link pauses while the VM is stopped or paused.
  • macOS 12/13 keep the MTKView timer.

Related: #7820. With #7982 (merged) the guest itself can also run at 120 Hz.

Testing: macOS 27.0.1, MacBook Pro M5 Max. Debug builds with and without this change, and a disposable Alpine VM whose console scrolls constantly. With the VM window on the 120 Hz panel, I counted distinct frames per second with ScreenCaptureKit and checked them against the Metal HUD:

Build Window fps (3 runs) Metal HUD
main 59.9 / 60.0 / 60.1 59.8
this PR 119.6 / 119.9 / 120.1 120.0

Also with this PR:

  • Moving the window to the 60 Hz display and back: 59.6, then 119.5 fps.
  • Pause and resume work.
  • Fullscreen scales correctly at 119.2 fps.
  • Closing the window is clean.
  • Idle cost rises from 1.5% to 2.1% of a core, because the link ticks at 120 Hz.

Windows 11 Pro ARM64 (build 26300, viogpu3d, D3DMetal), window on the 120 Hz panel and the guest at 120 Hz through #7982. A D3D11 test presenting ~560 fps in the guest:

  • Window: 76 fps over 10 s (ScreenCaptureKit, median frame 11.9 ms); Metal HUD 95 fps.
  • UTM 5.0.8 on the same panel stayed at or below 60 (58.5 fps in an earlier Hollow Knight run).

The Mac was busy with another app using 3+ cores during the Windows run, so those numbers are noisy. The point is that the window gets past 60.

Not tested yet: macOS 12/13, secondary displays.

The numbers above are from the revision before the rebase onto main (after #7982). The rebased revision has not been retested yet.

An AI agent ran these tests on my Mac.

I have read the AI contribution guidelines and have followed them to the best of my ability.

@dragoshont
dragoshont force-pushed the display/metal-display-link branch from dde2e47 to 47b96f7 Compare October 18, 2026 10:00
@dragoshont
dragoshont marked this pull request as ready for review October 18, 2026 13:04
On a MacBook Pro with an external 60 Hz main display, a VM window on the
built-in 120 Hz ProMotion panel never showed more than 60 frames per
second. MTKView's own timer stayed at 60 callbacks per second there even
with preferredFramesPerSecond = 120. A CAMetalDisplayLink on the same
layer runs at the rate it requests, and its frames reach the screen at
120 Hz.

On macOS 14 and later, drive the renderer from a CAMetalDisplayLink and
hand it the drawable from each update. The link follows the screen the
window is on and the FPS limit, and stops while the VM is stopped or
paused. Earlier macOS versions keep the view's own timer.

Related: utmapp#7820

Assisted-by: GitHub Copilot:claude-opus-5.5
Assisted-by: Claude:claude-opus-5-5
@osy
osy force-pushed the display/metal-display-link branch from 47b96f7 to 216660f Compare November 12, 2026 01:59
@osy osy changed the title display(macOS): pace the VM display with CAMetalDisplayLink display: fix 60 Hz cap on ProMotion screens Nov 12, 2026
@UTMapp-bot

Copy link
Copy Markdown
Collaborator

Retested 216660f (the rebased revision) on a MacBook Pro M2 Max (macOS 27) with its built-in 120 Hz panel and an external 1080p 60 Hz display. Debug builds of this PR and of its parent 531daed. The guest was a disposable Alpine 3.24 VM (virtio-gpu-gl-pci) with a console that scrolls constantly. I counted distinct frames per second in the VM window with ScreenCaptureKit, in 10 s runs.

Main display Window on main this PR
60 Hz external 120 Hz built-in 60.2 / 60.0 120.1 / 119.7
60 Hz external moved to 60 Hz external 60.1 / 60.1 60.1 / 60.2
60 Hz external moved back to built-in 60.1 120.1
120 Hz built-in 120 Hz built-in 119.6 / 119.7 120.0 / 119.5
120 Hz built-in moved to 60 Hz external 60.1 / 60.2 60.0 / 60.1
120 Hz built-in moved back to built-in 120.0 120.0
  • This reproduces the cap on main with an external 60 Hz main display. With only the built-in panel (no external display), main already reaches 120.
  • On main, restarting the VM with the window already on the 120 Hz panel still gives 60.1 fps, so it isn't a frame rate left over from the window starting on the 60 Hz screen. That matches the MTKView timer staying at 60.
  • Also with this PR:
    • Pause and resume work, and it is back at 120 fps after resuming.
    • CPU while paused is 0.0% (main: 0.6%).
    • An FPS limit of 30 set while the VM runs takes effect at the next VM start, in the same window (30.1 fps). Clearing it brings it back to 120.
    • Fullscreen runs at 120 fps and scales correctly.
    • Closing the window is clean, with no crash reports and no new errors in the log.
  • Idle cost: with a static guest screen, UTM uses 2.9% of a core with this PR against 0.4% on main, so the jump is larger than the 1.5% → 2.1% in the description. While the console scrolls, both use about 12%.

Not tested: macOS 12/13.

An AI agent ran these tests.

@osy
osy merged commit 17de443 into utmapp:main Nov 12, 2026
31 checks passed
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.

3 participants