Pace high-refresh NVN display mode in display-link ticks (#324)
Display mode at 120 Hz previously waited two display-link ticks per frame and stayed at 60 fps. Count its acquire waits in the link's own ticks so 120 fps takes one tick. Carry fractional ticks across frames so requests such as 90 fps on a 120 Hz link wait four ticks per three frames. Keep game-interval and frame-step pacing in 60 Hz vsync units. Extend t_link_ticks through the production acquire path, add it to CPU-only CI, and document the behavior in the frame-rate reference and symptom index. Validation: 20 link-tick checks, 154 NVN integration checks, pacing and frame-step tests, Metal snapshot and display-link rescue tests, Mac host build, iOS source compilation, and documentation checks passed. Physical display gameplay and iPhone gameplay were not retested. Co-authored-by: v0lumehi <aljic.zijad@gmail.com>
This commit is contained in:
1 parent
f8642c480f
commit
fb34a93858
9 files changed
+122
-4
No files matched your search
@@ -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
|
||||
|
||||
+17
-1
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
} }
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user