diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4dc3258..d932964 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -54,7 +54,7 @@ jobs: make GAME="$GAME" -j3 t_perf_order ./t_perf_order --cpu 2>&1 | tee ../../../logs/t_perf_order.log make GAME="$GAME" -j3 perf-order-tsan 2>&1 | tee ../../../logs/t_perf_order-tsan.log - tests="t_dirty_slots t_dirty_race t_tex_layout t_pace t_fstep t_hmap t_cover t_pso_skey t_refl t_rt_range_ro t_present_rescue t_gpuprof" + tests="t_dirty_slots t_dirty_race t_tex_layout t_pace t_fstep t_link_ticks t_hmap t_cover t_pso_skey t_refl t_rt_range_ro t_present_rescue t_gpuprof" make GAME="$GAME" -j3 $tests 2>&1 | tee ../../../logs/gfx-build.log failed=0 for t in $tests; do diff --git a/dm/gfx/nvn/hle_nvn.c b/dm/gfx/nvn/hle_nvn.c index 222baac..4667f8d 100644 --- a/dm/gfx/nvn/hle_nvn.c +++ b/dm/gfx/nvn/hle_nvn.c @@ -1256,6 +1256,7 @@ static void sleep_until_ns(uint64_t dl); static uint64_t present_period(const win_t *o); static uint64_t rt_period_ns(const win_t *o, uint64_t period); static int present_itv(const win_t *o); +static int link_wait(const win_t *o, uint64_t timeout_ns); __attribute__((weak)) void dm_main_yield_stats(uint64_t *n, uint64_t *ns) { *n = 0; *ns = 0; } // The host's hle_thread.c provides the ground truth; 0 in graphics unit tests __attribute__((weak)) uint32_t dm_guest_work_qos(unsigned *count) { if (count) *count = 0; return QOS_CLASS_UNSPECIFIED; } __attribute__((weak)) void dm_yield_top(char *out, size_t n) { if (n) snprintf(out, n, "-"); } // hle_thread.c: the threads that yielded most since the last call; none in graphics unit tests @@ -1281,7 +1282,7 @@ NVN(nvnWindowAcquireTexture, NVNwindowAcquireTextureResult, NVNwindow *w, NVNsyn if (snap && mtl_link_active()) { if (wait && mtl_link_stalled()) sleep_until_ns(at); } // present snapshots (nvn_mtl.m): the acquire's wait for a free ring slot below paces it. // While the display link is silent (the window in the background or covered) frames are dropped as fast as they come and // nothing frees a slot later, so the texture's slot time paces the game as without snapshots: unpaced, Breath of the Wild ran 3 to 4.5 times real time there - else if (mtl_link_active()) { if (!mtl_link_wait_tick(present_itv(o), period ? 2 * period : 33333333ull) && wait) sleep_until_ns(at); } // DM_PRESENT_LINK: pacing fully follows the display link (wait for the present interval's display-link ticks when running fast, go immediately when behind); our own 60 Hz clock is only the fallback when no display-link tick arrives + else if (mtl_link_active()) { if (!link_wait(o, period ? 2 * period : 33333333ull) && wait) sleep_until_ns(at); } // DM_PRESENT_LINK: pacing fully follows the display link (wait for the present interval's display-link ticks when running fast, go immediately when behind); our own 60 Hz clock is only the fallback when no display-link tick arrives else if (wait) sleep_until_ns(at); pace_ns = now_ns() - p0; } else { // DM_DETCLOCK_PACE=1 (used by the screenshot gate): while the guest clock advances per frame, each frame's wall clock is no shorter than one step -- frame count and in-game time are deterministic, and the background loading thread has at least one step of wall clock available per frame; @@ -1595,6 +1596,21 @@ static void present_cfg(const win_t *o) { m ? "DM_PRESENT_INTERVAL" : "game profile present_interval", o->interval, scr > 0 ? "window, fixed 60 Hz" : "no window, fixed 60 Hz"); } static int present_itv(const win_t *o) { int c = fps_step_cap(), k = atomic_load(&g_fs_k); return c ? (k && fstep_on() ? k : 60 / c) : g_display_mode ? 1 : o->interval > 0 ? o->interval : 1; } // vsyncs per frame that presentation follows +// The acquire's wait for the display link: the present interval's vsyncs (60 Hz units), or with present_interval display above 60 Hz (DM_PRESENT_HZ=120 on a 120 Hz link, +// DM_PRESENT_LINK=2) one tick of the present rate, as many link ticks as that takes. Before, a 120 Hz link waited two ticks per frame there, and display mode never went past 60 fps +static int link_wait(const win_t *o, uint64_t timeout_ns) { + static int hz_was, rate_was, remainder; // acquire thread only, as with mtl_link_wait_ticks + int hz = g_display_mode && !fps_step_cap() && g_hz > 60 ? g_hz : 0, rate = mtl_link_rate(); + if (hz != hz_was || rate != rate_was) { remainder = 0; hz_was = hz; rate_was = rate; } + if (hz) { + int ticks = 1; + // Keep the fractional link tick: 90 fps on a 120 Hz link takes 1, 1, 2 ticks, rather than truncating every wait to 1 (120 fps). + // A requested rate at or above the link's rate takes one tick; changing the rate or returning from game/step pacing starts a new phase. + if (rate > hz) { remainder += rate; ticks = remainder / hz; remainder %= hz; } + return mtl_link_wait_ticks(ticks, timeout_ns); + } + return mtl_link_wait_tick(present_itv(o), timeout_ns); +} static uint64_t present_period(const win_t *o) { present_cfg(o); int was = atomic_load(&g_game_itv), want = o->interval > 0 ? o->interval : 1; diff --git a/dm/gfx/nvn/nvn_mtl.m b/dm/gfx/nvn/nvn_mtl.m index 552f4e4..5e8f433 100644 --- a/dm/gfx/nvn/nvn_mtl.m +++ b/dm/gfx/nvn/nvn_mtl.m @@ -10527,12 +10527,19 @@ int mtl_link_wait_tick(int vsyncs, uint64_t timeout_ns) { if (!mtl_link_active()) return 0; // Wait for the display link only when running faster than the display: if a display-link tick has already passed since the previous frame started work (this frame is already behind), start immediately instead of waiting in vain for the next display-link tick -- // otherwise when the frame time is slightly more than one display-link period it would "miss the tick -> wait another whole tick", locking the frame rate straight to half (measured on Mac under windowed load: runs of dropped frames) - static uint64_t started_tick; // Display-link tick number when the previous frame started work (only the game's present thread calls this function) // How many display-link ticks one frame takes: the game's present interval (vsyncs, 60 Hz) times 1 with a 60 Hz display link, times 2 with a 120 Hz one, halved and rounded up at 30 Hz. // A frame presented with interval N blocks the game for N vsyncs on the Switch, as the display callback holds it N ticks: games that scale their time step by the interval (BotW's VFR: interval / 2) // ran at double speed while they produced interval-2 frames every 60 Hz tick (iPhone log: Link sprinting with B, the interval flapping 1 ↔ 2) if (vsyncs < 1) vsyncs = 1; - const uint64_t step = g_link_mode == 3 ? (uint64_t)(vsyncs + 1) / 2 : (uint64_t)vsyncs * (g_link_mode == 2 ? 2 : 1); + return mtl_link_wait_ticks(g_link_mode == 3 ? (vsyncs + 1) / 2 : vsyncs * (g_link_mode == 2 ? 2 : 1), timeout_ns); +} +// The display link's rate: 120 Hz (DM_PRESENT_LINK=2), 60, or 30 in power-saving mode +int mtl_link_rate(void) { return g_link_mode == 3 ? 30 : g_link_mode == 2 ? 120 : 60; } +// The same wait counted in the display link's own ticks: one per frame for a frame rate as high as the link's (present_interval display at DM_PRESENT_HZ=120 on a 120 Hz link) +int mtl_link_wait_ticks(int ticks, uint64_t timeout_ns) { + if (!mtl_link_active()) return 0; + static uint64_t started_tick; // Display-link tick number when the previous frame started work (only the game's present thread calls this function) + const uint64_t step = ticks < 1 ? 1 : (uint64_t)ticks; pthread_mutex_lock(&g_link_mu); if (g_link_tick_n >= started_tick + step) { started_tick = g_link_tick_n; pthread_mutex_unlock(&g_link_mu); return 1; } struct timespec ts; clock_gettime(CLOCK_REALTIME, &ts); diff --git a/dm/gfx/nvn/nvn_obj.h b/dm/gfx/nvn/nvn_obj.h index 868ad9d..874347d 100644 --- a/dm/gfx/nvn/nvn_obj.h +++ b/dm/gfx/nvn/nvn_obj.h @@ -210,6 +210,8 @@ int mtl_fps_target(void); // target frame rate int mtl_warm_wait(int timeout_ms); int mtl_link_active(void); int mtl_link_wait_tick(int vsyncs, uint64_t timeout_ns); // vsyncs = the game's present interval +int mtl_link_wait_ticks(int ticks, uint64_t timeout_ns); // the same in the display link's own ticks +int mtl_link_rate(void); // the display link's rate: 120, 60 or 30 Hz double mtl_presented_time(uint64_t n); int mtl_link_stalled(void); // the display link is silent (background, occluded window, a stall) and queued frames are dropped instead of shown int mtl_snap_on(void); uint64_t mtl_snap_acquire_wait(void); // present snapshots (DM_PRESENT_SNAP): on, and acquire's wait for ring capacity (returns ns spent pacing) diff --git a/dm/gfx/tests/.gitignore b/dm/gfx/tests/.gitignore index 1650cb6..f5b8483 100644 --- a/dm/gfx/tests/.gitignore +++ b/dm/gfx/tests/.gitignore @@ -122,3 +122,5 @@ t_pull_inv t_pull_inv.dSYM/ t_present_snap t_present_snap.dSYM/ +t_link_ticks +t_link_ticks.dSYM/ diff --git a/dm/gfx/tests/Makefile b/dm/gfx/tests/Makefile index bf6c941..21bc1bb 100644 --- a/dm/gfx/tests/Makefile +++ b/dm/gfx/tests/Makefile @@ -82,6 +82,12 @@ check_t_perf_order: t_perf_order DM_NVN_ENC_THREAD=1 MTL_DEBUG_LAYER=1 ./t_perf_order DM_NVN_FAST_WAKE=0 DM_NVN_SLICE_MERGE=0 DM_NVN_CS_HOIST=0 MTL_DEBUG_LAYER=1 ./t_perf_order run: check_t_perf_order +t_link_ticks: t_link_ticks.m $(MTL_T_DEP) + clang $(CFLAGS) -fobjc-arc -o $@ t_link_ticks.m $(filter-out ../nvn/hle_nvn.c,$(MTL_T_SRC)) $(FW) +.PHONY: check_t_link_ticks +check_t_link_ticks: t_link_ticks + ./t_link_ticks +run: check_t_link_ticks t_cube_placeholder: t_cube_placeholder.m $(MTL_T_DEP) clang $(CFLAGS) -fobjc-arc -o $@ t_cube_placeholder.m $(MTL_T_SRC) $(FW) .PHONY: check_t_cube_placeholder diff --git a/dm/gfx/tests/t_link_ticks.m b/dm/gfx/tests/t_link_ticks.m new file mode 100644 index 0000000..1b722bf --- /dev/null +++ b/dm/gfx/tests/t_link_ticks.m @@ -0,0 +1,76 @@ +// The acquire's waits on the display link, counted in its own ticks (mtl_link_wait_ticks) or in the game's 60 Hz vsyncs (mtl_link_wait_tick): a 120 Hz link takes two of its +// ticks per vsync, so present_interval display at DM_PRESENT_HZ=120 needs the first to reach one frame per tick. The real acquire also covers fractional rates and the game/step +// pacing branches. A host thread stands in for the display link's callback; it runs faster than a real screen, and all expectations are in ticks, not wall-clock frame rates +#define nvn_game_fps nvn_game_fps_mtl_default +#include "../nvn/nvn_mtl.m" +#undef nvn_game_fps +#define g_ft g_ft_hle +#include "../nvn/hle_nvn.c" +#undef g_ft +void *front_metal_layer(void) { return NULL; } +module_t *module_of(const void *p) { (void)p; return NULL; } +module_t g_mod[MAX_MOD]; int g_nmod; +void svc_thread_dump(void) {} +uint64_t now_ns(void) { return bp_now(); } +static int checks, failed; +#define CHECK(c, label) do { checks++; if (!(c)) { failed++; fprintf(stderr, "FAIL: %s\n", label); } } while (0) +static _Atomic int g_stop; +static void *ticker(void *a) { (void)a; while (!g_stop) { usleep(2000); pthread_mutex_lock(&g_link_mu); g_link_tick_n++; pthread_cond_broadcast(&g_link_cv); pthread_mutex_unlock(&g_link_mu); } return NULL; } +static uint64_t ticks_now(void) { pthread_mutex_lock(&g_link_mu); uint64_t n = g_link_tick_n; pthread_mutex_unlock(&g_link_mu); return n; } +// Ticks per wait over 50 waits of the given kind (frames that take no time: each wait is the whole frame) +static double per_wait(int own, int n) { + for (int i = 0; i < 3; i++) own ? mtl_link_wait_ticks(n, 100000000ull) : mtl_link_wait_tick(n, 100000000ull); // settle on the tick + uint64_t t0 = ticks_now(); + for (int i = 0; i < 50; i++) own ? mtl_link_wait_ticks(n, 100000000ull) : mtl_link_wait_tick(n, 100000000ull); + return (ticks_now() - t0) / 50.0; +} +// Exercise the real acquire so the conversion from the requested frame rate to link ticks is covered too. +static double per_acquire(int hz, int display, int interval) { + nvn_present_config(hz, display); + win_t win = {.interval = interval}; int idx; + for (int i = 0; i < 5; i++) h_nvnWindowAcquireTexture((NVNwindow *)&win, NULL, &idx); + uint64_t t0 = ticks_now(); + for (int i = 0; i < 60; i++) h_nvnWindowAcquireTexture((NVNwindow *)&win, NULL, &idx); + return (ticks_now() - t0) / 60.0; +} +static void acquire_ticks(int hz, int display, int interval, double want, const char *label) { + double got = per_acquire(hz, display, interval); + printf("%s: %.2f ticks per acquire (expected %.2f)\n", label, got, want); + CHECK(fabs(got - want) < 0.08, label); +} +int main(void) { @autoreleasepool { + setenv("DM_RT", "0", 1); setenv("DM_PRESENT_SNAP", "0", 1); + g_dm_present_link = 2; g_link = (CAMetalDisplayLink *)[NSObject new]; // a link is "on" while the object is set; nothing calls it here + CHECK(mtl_link_active(), "the link counts as active"); + pthread_t th; pthread_create(&th, NULL, ticker, NULL); + g_link_mode = 2; + CHECK(mtl_link_rate() == 120, "a 120 Hz link reports 120"); + double a = per_wait(0, 1), b = per_wait(1, 1), c = per_wait(1, 2); + printf("120 Hz link: interval 1 waits %.2f ticks, one own tick %.2f, two own ticks %.2f\n", a, b, c); + CHECK(a > 1.8 && a < 2.2, "a 60 Hz vsync is two ticks of a 120 Hz link"); + CHECK(b > 0.8 && b < 1.2, "one own tick per frame: 120 frames a second"); + CHECK(c > 1.8 && c < 2.2, "two own ticks"); + acquire_ticks(120, 1, 2, 1, "display 120 Hz ignores the game's interval"); + acquire_ticks(90, 1, 1, 4. / 3, "display 90 Hz preserves fractional ticks"); + acquire_ticks(75, 1, 1, 1.6, "display 75 Hz preserves fractional ticks"); + acquire_ticks(120, 0, 2, 4, "game mode keeps interval 2 in 60 Hz vsyncs"); + acquire_ticks(60, 1, 2, 2, "display 60 Hz ignores the game's interval"); + acquire_ticks(240, 1, 1, 1, "a rate above the link's waits one tick"); + _Alignas(8) float state[2] = {60, 0}; dm_fps_state = (uint8_t *)state; + acquire_ticks(120, 1, 1, 2, "the 60 fps frame step keeps 60 Hz vsyncs"); + state[0] = 30; + acquire_ticks(120, 1, 1, 4, "the 30 fps frame step keeps two 60 Hz vsyncs"); + dm_fps_state = NULL; + g_link_mode = 1; + CHECK(mtl_link_rate() == 60, "a 60 Hz link reports 60"); + a = per_wait(0, 1); + CHECK(a > 0.8 && a < 1.2, "a 60 Hz link: one tick per vsync"); + acquire_ticks(120, 1, 1, 1, "a 60 Hz link limits display 120 Hz to one tick"); + g_link_mode = 3; + CHECK(mtl_link_rate() == 30, "the 30 Hz power-saving link reports 30"); + acquire_ticks(120, 1, 1, 1, "a 30 Hz link limits display 120 Hz to one tick"); + acquire_ticks(60, 0, 2, 1, "a 30 Hz link keeps game interval 2 at one tick"); + acquire_ticks(60, 0, 3, 2, "a 30 Hz link rounds game interval 3 up to two ticks"); + g_stop = 1; pthread_join(th, NULL); + printf("t_link_ticks: %d checks, %d failed\n", checks, failed); return failed != 0; +} } diff --git a/docs/agents/symptoms.md b/docs/agents/symptoms.md index 17561a2..72a6329 100644 --- a/docs/agents/symptoms.md +++ b/docs/agents/symptoms.md @@ -240,6 +240,7 @@ One line per symptom in the [subsystem references](../reference/): what you see - the app is active and the game keeps running, but the picture freezes while display-link callbacks stop; the log repeatedly drops queued frames. → [Present thread rescues a silent display link](../reference/frame-rate.md#present-thread-rescues-a-silent-display-link) - with the display link driving presents, a presented frame comes out black when three presents are on the GPU at once, or two after a dropped frame; on an M6 the same reuse also logs `[RED] display command buffer error` with `kIOGPUCommandBufferCallbackErrorPageFault`. → [A present command allocator is reused only after its own present completes](../reference/frame-rate.md#a-present-command-allocator-is-reused-only-after-its-own-present-completes) - "sometimes the speed increases when jumping" (Mario 1.2.1 on the iPhone): in `display` mode interval-2 frames were shown for 16.7 ms and the game visibly jumped ahead. "When pressing B, Link's move pace is speeding up" (Breath of the Wild on the iPhone); `old frames dropped by merging`. → [Frames of a dynamic present interval held on screen and at the acquire](../reference/frame-rate.md#frames-of-a-dynamic-present-interval-held-on-screen-and-at-the-acquire) +- with `present_interval display`, `DM_PRESENT_HZ=120` and `DM_PRESENT_LINK=2` (a 120 Hz display link) a game stays at 60 fps although its frame takes 8 ms; the display link logs about two ticks per present. With a requested 90 Hz, truncating the tick conversion instead allows 120 fps (reproduced through the real acquire with simulated link ticks). → [One frame per tick of a 120 Hz display link](../reference/frame-rate.md#one-frame-per-tick-of-a-120-hz-display-link) - frames miss the screen while the GPU stays at its lowest clock (444 MHz on an M2 Max); more work (a higher render scale) only makes the frames slower. → [Present snapshots: the game's swapchain texture free at once](../reference/frame-rate.md#present-snapshots-the-games-swapchain-texture-free-at-once) - a steady game whose frame-rate counter wanders between 50 and 60 on the iPhone only; the `display link up to tick` lines show about as many empty ticks as frames dropped by merging: frames arrive two in one tick and none in the next. → [Vulkan acquire and vsync event follow the display link](../reference/frame-rate.md#vulkan-acquire-and-vsync-event-follow-the-display-link) - asked for 60 Hz, the system fires the display link on every third refresh of a 165 Hz screen, 55 Hz, so games paced by it ran at 55 fps (27.5 at 30). → [Virtual 60 Hz ticks on screens that are not a multiple of 60](../reference/frame-rate.md#virtual-60-hz-ticks-on-screens-that-are-not-a-multiple-of-60) diff --git a/docs/reference/frame-rate.md b/docs/reference/frame-rate.md index 4d6c7ed..ff73bc7 100644 --- a/docs/reference/frame-rate.md +++ b/docs/reference/frame-rate.md @@ -102,6 +102,14 @@ Frame pacing: the host's frame step, engine frame limiters, present intervals an - **Check:** `DM_TEST_PRESENT_LAG=90,30` (sleep 30 ms before every 90th present) makes Mario do this on the Mac; run it with `DM_WINDOW=1` to get the display link. `DM_NVN_TSV` writes each frame's interval as `@itv`. - **Note:** the windowless Mac host was never affected by the acquire: there the acquire sleeps until the interval's slot. +### One frame per tick of a 120 Hz display link + +- **Symptom:** with `present_interval display`, `DM_PRESENT_HZ=120` and `DM_PRESENT_LINK=2` (a 120 Hz display link) a game stays at 60 fps although its frame takes 8 ms; the display link logs about two ticks per present. With a requested 90 Hz, truncating the tick conversion instead allows 120 fps (reproduced through the real acquire with simulated link ticks). +- **Cause:** the acquire waits for the present interval's vsyncs on the display link (`mtl_link_wait_tick`), counted in 60 Hz vsyncs, so two ticks of a 120 Hz link per frame; display mode's interval is 1. Dividing the link rate by the requested rate once per acquire loses fractional ticks (120 / 90 becomes 1). +- **Fix:** in display mode above 60 Hz the acquire waits as many link ticks as one tick of the present rate takes (`link_wait` in `hle_nvn.c`, `mtl_link_wait_ticks`): one at 120 Hz. Fractional ticks carry across acquires: at 90 Hz, three frames wait four link ticks (1, 1, 2); a rate or pacing-mode change starts a new phase. Rates at or above the link's rate wait one tick. +- **Found on:** Batman: Arkham Knight (`0100ACD0163D0000`; its profile and note are in a PR of their own): 119.8 fps in the opening cutscene on an M2 Max. +- **Check:** `t_link_ticks` (also run in CPU-only CI): with a stand-in for the display link's callback, a 60 Hz vsync waits two ticks of a 120 Hz link and one tick of a 60 Hz one, and a wait in the link's own ticks takes one tick per frame. The real acquire checks display pacing at 60, 75, 90, 120 and 240 Hz, game intervals and frame-step caps, including the 60 and 30 Hz link modes. These checks count ticks; they do not measure a game's frame rate on a screen. + ### Present snapshots: the game's swapchain texture free at once - **Symptom:** frames miss the screen while the GPU stays at its lowest clock (444 MHz on an M2 Max); more work (a higher render scale) only makes the frames slower.