Repository navigation
Fullscreen on a ProMotion screen holds 60 fps: the 120 Hz display link in variable-refresh fullscreen - #1967
The-Inceptions wants to merge 2 commits into
Conversation
…refresh fullscreen the host runs the 120 Hz display link, a frame every other tick, where the 60 Hz link at frame latency 2 showed every present a refresh late and skipped every other tick (Ocarina of Time on an M5 Pro: 40 fps with the GPU at 10 ms of the 16.7) On a MacBook Pro's ProMotion screen, a 60 fps game in fullscreen ran at 40 fps, while the same route in a window held 60.0. The DM_NVN_TSV rows show the cause: - With the 60 Hz CAMetalDisplayLink at frame latency 2 (the variable-refresh fullscreen workaround of seedds#347), the system showed every present one 120 Hz refresh after the link's target (@pres_at_ms - @link_target_ms = 8.33 ms on every frame). - The link then skipped its next tick after every second present (@link_gap_ms 16.7, 33.3, 16.7, 33.3 ms): 40 callbacks a second. - The host paces the game by those callbacks, so the game ran at 40 fps. - At latency 1, the misses of seedds#347 came back and the frame step went to 4 vsyncs (15 fps). The fix, link_vrr_120 (nvn_mtl.m): at 60 fps in fullscreen on a variable-refresh screen whose rate is a multiple of 120 Hz, link_sync runs the existing 120 Hz link of DM_PRESENT_LINK=2, at frame latency 2 as before. - The acquire waits two ticks per vsync, and the vsync event follows every other tick. - A frame that misses its tick goes up on the next 120 Hz refresh instead of a whole 60 Hz period later. - A window, a fixed-rate screen, a screen of another rate, a 30 fps target and a set DM_PRESENT_LINK keep their link. - The link follows fullscreen in and out as the latency policy does, and the log says when it switches. Measured with scripts/bench.py on Ocarina of Time's kokiri-deck route: M5 Pro MacBook Pro, 20-core GPU, macOS 27.2, host= variants of main and this change, two runs each. - Fullscreen: 39.7 -> 59.2 fps, frame p99 33.5 -> 25.3 ms, frames over 1.5 vsyncs 914 -> 62. - Window: 59.9 / 59.95 fps, unchanged. - At latency 1, the 120 Hz link did a little worse (59.3 fps, 48 frames over 1.5 vsyncs against 35), so latency stays 2. Tested: - t_present_rescue --metal with the Metal debug layer: 100 checks. A new vrr120 group covers window and fullscreen; fixed-rate, 144 Hz, 240 Hz and unknown screens; DM_PRESENT_LINK; a 30 fps target; and leaving fullscreen. - make -C dm/gfx/tests GAME=0400486024DEC000 run: 148 result lines with 72,183 checks and 0 failed, plus 30 Python tests passing. Python 3.12 or newer must be first on PATH; the system Python 3.9 stops check_t_alpha on dmsh_side.py's `str | None`. - test_docs. Regression: Ocarina of Time only. It's the one game built on this Mac, and its profile has no smoke.tsv. iOS is unchanged, since front_fullscreen is 0 there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… link: 60.0 fps, frame p99 16.9 ms, on the 60 Hz link at latency 1 (Ocarina of Time, M5 Pro, the screen held at 60 Hz for one app) A note in the reference entry; switching a variable-refresh screen to its 60 Hz mode is left out of this change, as it changes the mode of the whole screen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…een in fullscreen switches to a fixed-rate mode at a multiple of the game's rate (120 -> 60 Hz), where the 60 Hz display link at frame latency 2 showed every present a refresh late and skipped every other tick (Ocarina of Time on an M5 Pro: 40 fps with the GPU at 10 ms of the 16.7); a screen that keeps its variable mode gets the 120 Hz link, a frame every other tick (PR #1967) On a MacBook Pro's ProMotion screen, a 60 fps game in fullscreen ran at 40 fps, while the same route in a window held 60.0. The PR author's DM_NVN_TSV rows show the cause: - With the 60 Hz CAMetalDisplayLink at frame latency 2 (the variable-refresh fullscreen workaround of #347), the system showed every present one 120 Hz refresh after the link's target (@pres_at_ms - @link_target_ms = 8.33 ms on every frame). - The link then skipped its next tick after every second present (@link_gap_ms 16.7, 33.3, 16.7, 33.3 ms): 40 callbacks a second, and the host paces the game by them. - At latency 1 the misses of #347 came back and the frame step went to 4 vsyncs (15 fps). Why the system misses a 60 Hz link's targets in variable-refresh fullscreen is open. The fix, a workaround for that: - front_mac.m screen_match_apply: in fullscreen a variable-refresh screen switches, for this app only, to a fixed-rate mode of the same size at a multiple of the game's rate, though its own rate is a multiple (the mechanism that switches 144 Hz screens). The fixed candidates leave out the current mode and any mode at its rate; the host keeps its own record of whether the mode it switched to (or back to) is variable, as the screen observer may lag. The screen is then fixed-rate: the 60 Hz link at latency 1, and a 30 fps target on the 60 Hz link with interval 2. - nvn_mtl.m link_sync asks the screen for the game's rate before it picks the link. - Fallback, link_vrr_120: a variable-refresh screen that keeps its mode (no fixed mode at a multiple, or DM_SCREEN_MATCH=0) at a multiple of 120 Hz runs the 120 Hz link of DM_PRESENT_LINK=2 at 60 fps, at frame latency 2. A set DM_PRESENT_LINK holds everywhere. Measured by the PR author with scripts/bench.py on Ocarina of Time's kokiri-deck route (M5 Pro MacBook Pro, 20-core GPU, macOS 27.2, two runs each): - Main in fullscreen: 39.7 fps, frame p99 33.5 ms, 914 frames over 1.5 vsyncs. - The screen held at its fixed 60 Hz mode: 60.0 fps, frame p99 16.9 ms, 2 frames over 1.5 vsyncs, latency 1. - The 120 Hz link: 59.2 fps, frame p99 25.3 ms, 62 frames over 1.5 vsyncs. - Window: 59.9 / 59.95 fps, unchanged. The host's own switch to the fixed mode has not run on a ProMotion screen yet (the measurement set the mode by hand); the only screen here is a fixed 120 Hz external one, where the code takes main's path. Tested: t_present_rescue --metal with the Metal debug layer, 101 checks, 0 failed (a vrr120 group: window and fullscreen, fixed-rate, 144, 240 Hz and unknown screens, DM_PRESENT_LINK, a 30 fps target, leaving fullscreen, and the rate asked of the screen); the Mac host builds; lint_c.py tidy has nothing above its baseline; test_docs. No regress run: on a screen that is not variable-refresh the code takes main's path, and none is here. Co-authored-by: Adem Rosic <83852285+The-Inceptions@users.noreply.github.com>
|
Landed as a2cfd40, with you credited as co-author. Thank you for the diagnosis and the measurements. One change from your version: your fixed 60 Hz measurement (60.0 fps, frame p99 16.9 ms, 2 frames over 1.5 vsyncs, latency 1) was clearly better than the 120 Hz link (59.2 fps, p99 25.3 ms, 62 frames). The host also already switches the screen mode in fullscreen, for this app only, on 144 Hz screens ( The host's own switch hasn't run on a ProMotion screen: the only screen here is a fixed 120 Hz external one, where the code takes main's path. Could you rerun |
|
The
The other checks here are still queued. |
|
I reran it on the M5 Pro's built-in ProMotion screen. The switch works, and fullscreen holds 60.0. Host: a2cfd40's change applied to 7974118, the base of this PR. Main's chain has moved since (26062f9 and e121931, now d7576061facd), and this Mac's shader library is at dedc9391c30b, so a host built from main itself would have needed a full library rebuild first. Say if you'd like it repeated on main after that rebuild.
Both runs logged: After the runs, the screen was back in its 120 Hz mode. Leaving fullscreen: a third run left fullscreen at 70 s, entered it again at 90 s and left it again at 110 s. The toggles came from a local test hook, a
The cost of each toggle (one run each; fps on
So entering fullscreen with the switch took about 2.5 s longer to settle. Between toggles, the fixed 60 Hz fullscreen had at most 2 frames over 25 ms, and the 120 Hz link had its usual few at 25.3 ms. Same Mac as before: MacBook Pro 16-inch, M5 Pro (20-core GPU), macOS 27.2. The |
|
Also on an M3 Pro, another game, two-player split screen: the switch runs, 30 → 58.8 fps. Mario Kart World (0400C3F00006E000, v0), two-player split screen, Grand Prix 50cc, Mario Bros. Circuit, handheld mode (1080p render, output 1440p, no MetalFX / SMAA), host 7f50222 + the two code hunks of a2cfd40 applied (front_mac.m, nvn_mtl.m; same shader chain), on a 14-inch MacBook Pro M3 Pro (18 GB), macOS 27.2 (26B123), built-in 1512x982 @ 120 Hz ProMotion screen, on AC power.
Note for others testing: the window opens on the screen that holds the key window, so with an external screen attached the game went fullscreen there first (fixed-rate path, nothing to see); the built-in screen was tested by starting in a window, moving it there and entering fullscreen. |
…een in fullscreen switches to a fixed-rate mode at a multiple of the game's rate (120 -> 60 Hz), where the 60 Hz display link at frame latency 2 showed every present a refresh late and skipped every other tick (Ocarina of Time on an M5 Pro: 40 fps with the GPU at 10 ms of the 16.7); a screen that keeps its variable mode gets the 120 Hz link, a frame every other tick (PR seedds#1967) On a MacBook Pro's ProMotion screen, a 60 fps game in fullscreen ran at 40 fps, while the same route in a window held 60.0. The PR author's DM_NVN_TSV rows show the cause: - With the 60 Hz CAMetalDisplayLink at frame latency 2 (the variable-refresh fullscreen workaround of seedds#347), the system showed every present one 120 Hz refresh after the link's target (@pres_at_ms - @link_target_ms = 8.33 ms on every frame). - The link then skipped its next tick after every second present (@link_gap_ms 16.7, 33.3, 16.7, 33.3 ms): 40 callbacks a second, and the host paces the game by them. - At latency 1 the misses of seedds#347 came back and the frame step went to 4 vsyncs (15 fps). Why the system misses a 60 Hz link's targets in variable-refresh fullscreen is open. The fix, a workaround for that: - front_mac.m screen_match_apply: in fullscreen a variable-refresh screen switches, for this app only, to a fixed-rate mode of the same size at a multiple of the game's rate, though its own rate is a multiple (the mechanism that switches 144 Hz screens). The fixed candidates leave out the current mode and any mode at its rate; the host keeps its own record of whether the mode it switched to (or back to) is variable, as the screen observer may lag. The screen is then fixed-rate: the 60 Hz link at latency 1, and a 30 fps target on the 60 Hz link with interval 2. - nvn_mtl.m link_sync asks the screen for the game's rate before it picks the link. - Fallback, link_vrr_120: a variable-refresh screen that keeps its mode (no fixed mode at a multiple, or DM_SCREEN_MATCH=0) at a multiple of 120 Hz runs the 120 Hz link of DM_PRESENT_LINK=2 at 60 fps, at frame latency 2. A set DM_PRESENT_LINK holds everywhere. Measured by the PR author with scripts/bench.py on Ocarina of Time's kokiri-deck route (M5 Pro MacBook Pro, 20-core GPU, macOS 27.2, two runs each): - Main in fullscreen: 39.7 fps, frame p99 33.5 ms, 914 frames over 1.5 vsyncs. - The screen held at its fixed 60 Hz mode: 60.0 fps, frame p99 16.9 ms, 2 frames over 1.5 vsyncs, latency 1. - The 120 Hz link: 59.2 fps, frame p99 25.3 ms, 62 frames over 1.5 vsyncs. - Window: 59.9 / 59.95 fps, unchanged. The host's own switch to the fixed mode has not run on a ProMotion screen yet (the measurement set the mode by hand); the only screen here is a fixed 120 Hz external one, where the code takes main's path. Tested: t_present_rescue --metal with the Metal debug layer, 101 checks, 0 failed (a vrr120 group: window and fullscreen, fixed-rate, 144, 240 Hz and unknown screens, DM_PRESENT_LINK, a 30 fps target, leaving fullscreen, and the rate asked of the screen); the Mac host builds; lint_c.py tidy has nothing above its baseline; test_docs. No regress run: on a screen that is not variable-refresh the code takes main's path, and none is here. Co-authored-by: Adem Rosic <83852285+The-Inceptions@users.noreply.github.com>
What and why
On a MacBook Pro's ProMotion screen, a 60 fps game in fullscreen ran at 40 fps with its GPU at 10 ms of the 16.7 ms budget. In a window the same route held a locked 60.
The cause shows in the
DM_NVN_TSVrows:@pres_at_ms − @link_target_ms= 8.33 ms on every frame).@link_gap_msran 16.7, 33.3, 16.7, 33.3 ms, which is 40 callbacks a second.on-screenline counted half its frames as held long.DM_PRESENT_LATENCY=1) the misses of Mac: on a ProMotion built-in display the frame step drops to 30 / 20 fps on frames that are not expensive; at a fixed 60 Hz it does not #347 came back and the frame step went to 4 vsyncs (15 fps).The change,
link_vrr_120innvn_mtl.m:link_syncruns the existing 120 Hz link ofDM_PRESENT_LINK=2(a frame every other tick) instead of the 60 Hz link. Frame latency stays at 2.DM_PRESENT_LINKall keep their link.DM_PRESENT_LINK=1keeps the 60 Hz link in fullscreen.display link: 120 Hz in fullscreen on a variable-refresh screen …when it switches.Docs:
docs/reference/frame-rate.md, The 60 Hz link at 40 fps in fullscreen on a ProMotion screen.DM_PRESENT_LINKrow indocs/env_switches.tsv.Checked
Tests:
make -C dm/gfx/tests GAME=0400486024DEC000 check_t_present_rescue(with--metal,MTL_DEBUG_LAYER=1): 100 checks, 0 failed. This includes a newvrr120group:DM_PRESENT_LINK=1DM_PRESENT_LINK=2in a windowmake -C dm/gfx/tests GAME=0400486024DEC000 run, on this change before its rebase onto7974118: 148 result lines, 72,183 checks, 0 failed, and 30 Python tests OK.PATH. The system Python 3.9 stopscheck_t_alphaatdmsh_side.py'sstr | None, which is unrelated to this change.check_t_present_rescueran again (100 checks, 0 failed) and the host rebuilt.python3 -m unittest dm/tools/tests/test_docs.py: 11 tests OK.python3 dm/tools/docs_symptoms.pywas run, and its output is unchanged after the rebase.Game run: The Legend of Zelda: Ocarina of Time (Switch 2),
0400486024DEC000, v131072, full shader library of chaindedc9391c30b, the bundled pipeline archive.scripts/bench.pyon thekokiri-deckroute (from a deck autosave; one extra Load press for a save list with two autosaves), withhost=variants ofmain(7a7acfe) and this branch, two runs each:main:DM_PRESENT_LINK=0(p99 25.2 ms)DM_PRESENT_LINK=2(p99 19.2 ms)For comparison, a fixed 60 Hz screen: with the screen held at its fixed 60 Hz mode for one app (as
front_screen_matchswitches a 144 Hz screen), fullscreen held 60.0 fps, frame p99 16.9 ms, with 2 frames over 1.5 vsyncs in two runs. That was the 60 Hz link at latency 1, "fixed refresh". The GPU took 14.0 ms against 10.0, at the lower clock the system chose. Switching variable-refresh screens to their 60 Hz mode in fullscreen would remove the last 25 ms frames, but it changes the whole screen's mode, so it's only noted in the reference entry (second commit). Say if you'd rather go that way.Devices and OS: MacBook Pro 16-inch, M5 Pro (20-core GPU), 24 GB, macOS 27.2, built-in ProMotion screen.
regress.py: this Mac has only this one game built, and its profile has nosmoke.tsv, soregress.py checkhas nothing to boot.regress.py impact main..HEADnames Breath of the Wild, Pokémon Scarlet and Tears of the Kingdom through the present path (link_syncruns on every present). None of them is built on this Mac, so they're unchecked. The change is limited to Mac fullscreen on variable-refresh screens at multiples of 120 Hz. iOS doesn't change, becausefront_fullscreenis 0 there.Not checked:
Docs
docs/reference/frame-rate.mdand in the game note, andpython3 dm/tools/docs_symptoms.pywas run (the symptom index)🤖 Generated with Claude Code