Repository navigation
Conversation
… 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.
da8e15b to
c92eff4
Compare
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>
|
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 |
|
Thanks. I've rebased onto
Each one builds and passes its tests on its own on current |
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>
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.
What this PR does.
DM_PRESENT_LINK=2(the 120 Hz link), which is a manual setting.Result:
Related issues and PRs
DM_PRESENT_LINK=0(presenting without the display link) removes the problem completely on an M3 Max, which matches what we measured here on Breath of the Wild. This PR makes that switch automatic once the shift is seen.Summary
Five commits:
What commit 5 does, as a flow:
Commit 5 also:
@link_run_ms,@link_idle_ms,@link_slack_ms, ...): that is how we proved the delay is on macOS's side;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 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:
t_link_tickst_present_rescue --metalt_fstepcheck_t_present_snapThe 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
scripts/regress.py check --gamescan 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).DM_PRESENT_FX_AT_SNAP=1turns it on). The hold fix in commit 5 applies on iOS too, but only withDM_PRESENT_LINK=2.docs/reference/frame-rate.md.Merge Danger
Door: two-way. Each change has an off switch:
DM_PRESENT_RESYNC=0keeps the display link;DM_FSTEP_PROBE_RAMP=0turns off the probe change;DM_PRESENT_FX_AT_SNAP=0puts MetalFX back in the present;enc_threadis one profile key.Reverting any commit is cheap.
Blast Radius: Mac presentation.
DM_PRESENT_LINK=2. Everything else is Mac-only or off by default on iOS.