From abbcbc975cb76beb1c7f3dfb2508b51c285356bf Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Sat, 7 Nov 2026 12:31:55 -0500 Subject: [PATCH 1/3] DM_PCORE=1 (opt-in): the busiest guest threads get a time-constraint declaration, to keep them on the performance cores Off by default and not yet shown to help: a Mario Kart World run on an iPhone left the performance cores about a minute in with thermal state nominal (mean clock 3.8 to 1.65 GHz, 55% to 12% of CPU time on performance cores). iOS has no core affinity; a time-constraint declaration is what the scheduler places on a fast core. The three busiest guest threads without one get a 60 Hz declaration of 1.5x their measured per-frame CPU, once each, from the CPU sampler's window. --- dm/host/hle_thread.c | 23 +++++++++++++++++++++++ docs/development.md | 1 + docs/env_switches.tsv | 1 + 3 files changed, 25 insertions(+) diff --git a/dm/host/hle_thread.c b/dm/host/hle_thread.c index 50b2f7d3a3..8a8906acb5 100644 --- a/dm/host/hle_thread.c +++ b/dm/host/hle_thread.c @@ -17,6 +17,7 @@ #include #include #include +#include #include #include #include @@ -78,6 +79,7 @@ typedef struct thr { uint64_t nspin, nspin_seen, spin_site; const char *spin_mod; // Spin wait passes (dm_spin_wait; those that yield count in nyield too) and where the last one was uint64_t srun_svc, srun_t0, spin_ret; // Current spin wait: nsvc at its passes, when it began, when the last pass returned uint64_t cpu_seen_us; // CPU time (µs) at the last power report (dm_thread_cpu) + uint8_t pcore; // DM_PCORE: its time-constraint declaration was considered (pcore_declare) uint64_t yrun_svc, yrun_t0, yrun_sig, yrun_sig2, poll_ret; // Current run of yields: nsvc of the last yield, when the run began, and the caller's x19–x28 at the last two yields (a run lasts while no other SVC comes between two yields and those registers match one of the last two, thr_yield); when the last yield or short timed-out wait returned (poll_wait_begin) uint64_t yrun_clk; // dm_clock_reads at the last yield: a pass that read the clock since makes the yield a clock wait (thr_yield) uint64_t cw_sig, cw_run, cw_last, cw_end, cw_per, cw_per2, cw_wake, ncwpred, ncwpred_seen; // The clock wait followed (clock_wait): its x19–x28, the run it was last seen in, when its last yield returned, @@ -950,6 +952,26 @@ void dm_yield_top(char *out, size_t n) { pthread_mutex_unlock(&G); if (!o && n) snprintf(out, n, "none"); } +// DM_PCORE=1: keep the busiest guest threads on the performance cores. iOS has no core affinity, and every guest thread already runs at user-interactive QoS, yet a Mario Kart World +// run on the iPhone fell off the performance cores about a minute in (mean clock 3.8 -> 1.65 GHz, 55% -> 12% of CPU time on performance cores, thermal state 0, Low Power Mode off, the +// main thread's real-time declaration still in effect). A thread with a time-constraint declaration is what the scheduler places on a fast core and what the performance controller +// raises the clock for, so the three busiest threads that have none (the main and acquire threads declare their own) get one: a 60 Hz period and 1.5x their measured per-frame CPU. +// Declared once per thread, from the CPU sampler's window (dm_thread_cpu, every 300 frames). Off by default until a phone A/B shows the run holds its clock +static int pcore_on(void) { static int v = -1; if (v < 0) { const char *e = getenv("DM_PCORE"); v = e && atoi(e) != 0; if (v) LOG("DM_PCORE=1: the busiest guest threads get a time-constraint declaration"); } return v; } +static void pcore_declare(thr_t *t, double ms_per_s) { // holding G + if (!pcore_on() || t->pcore || ms_per_s < 60) return; + thread_time_constraint_policy_data_t p; mach_msg_type_number_t n = THREAD_TIME_CONSTRAINT_POLICY_COUNT; boolean_t def = 1; + if (thread_policy_get(t->mport, THREAD_TIME_CONSTRAINT_POLICY, (thread_policy_t)&p, &n, &def) != KERN_SUCCESS) return; + t->pcore = 1; + if (!def) return; // it declares its own + static mach_timebase_info_data_t tb; if (!tb.denom) mach_timebase_info(&tb); + double comp_ms = ms_per_s / 60.0 * 1.5; if (comp_ms < 1) comp_ms = 1; if (comp_ms > 6) comp_ms = 6; + uint64_t period = 16666667ull; + p.period = (uint32_t)(period * tb.denom / tb.numer); p.constraint = p.period; p.preemptible = 1; + p.computation = (uint32_t)((uint64_t)(comp_ms * 1e6) * tb.denom / tb.numer); + kern_return_t kr = thread_policy_set(t->mport, THREAD_TIME_CONSTRAINT_POLICY, (thread_policy_t)&p, THREAD_TIME_CONSTRAINT_POLICY_COUNT); + LOG("DM_PCORE: thread %#x declared %.2f ms per 16.67 ms (%.0f ms/s measured) %s", t->handle, comp_ms, ms_per_s, kr == KERN_SUCCESS ? "set" : "failed"); +} // CPU time of the guest threads since the last call, by Switch priority band (<= 44: the main thread's level and above; 45-59; >= 60) and the three busiest threads with their priority. // For the power line (hle_nvn.c power_line): it shows which threads a priority-to-QoS mapping would move and how much CPU they use. secs = the window length void dm_thread_cpu(char *out, size_t n, double secs) { @@ -968,6 +990,7 @@ void dm_thread_cpu(char *out, size_t n, double secs) { for (int i = 0; i < 3 && top[i] && o < n; i++) o += (size_t)snprintf(out + o, n - o, "%s%#x '%s' priority %d %.0f ms/s%s", i ? ", " : "", top[i]->handle, thr_name(top[i], nb, sizeof nb), top[i]->prio, d[i], top[i]->host_owned ? " (host-created)" : ""); if (!top[0] && o < n) snprintf(out + o, n - o, "none"); + for (int i = 0; i < 3 && top[i]; i++) pcore_declare(top[i], d[i]); pthread_mutex_unlock(&G); } // Sleep until dl (host monotonic ns) on time: macOS nanosleep / mach_wait_until carry timer slack; measured, 33 ms sleeps take about 36.4 ms, 5 ms take 6.1 ms, 1 ms take 1.26 ms (the same at any QoS). diff --git a/docs/development.md b/docs/development.md index a4522692b9..4c8909fa8a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -833,6 +833,7 @@ The game's own work and its frame rate stay as they are; the host avoids work of - **Waits block instead of polling:** the in-process audio renderer, upload batches, `nvnQueueFinish`. A signalled event wakes only the threads waiting on it; leaving an SVC takes the global lock only while a pause (IL2CPP's garbage collector) is pending. - **Real-time policy.** The thread that acquires the window texture declares a Mach time-constraint policy, which tells the performance controller the frame deadline. `DM_RT=0` leaves it on its QoS, for measuring. - **Frame-step probes.** A game that waits out a longer hold (`nvn_fstep.h`, 30 fps after late frames) gets parked on the efficiency cores even though the declared period stays at 60 fps. Tears of the Kingdom on an iPhone ran 6% on performance cores at 1.6 GHz, thermal 0, where its frames never fit 60 again. While under 30% of the process's CPU time runs on performance cores, in two windows of 30 presents in a row (thermal state nominal or fair, Low Power Mode off), the frame step therefore tries 60 again after its hold-off even when the cost does not fit. The first 30 frames of such a probe may be late, so the controller has time to raise the clock. A probe whose run at 60 then ends within 8 s counts as failed, so the next one waits twice as long (1 → 8 s). The GPU clock follows the hold too: Breath of the Wild on an iPhone went from 14.4 to about 25 ms of GPU time a frame at 30 fps with the same 4,900 draws, its CPU still on the performance cores, and never tried 60 again. A GPU time at the longer hold of at least 1.3 times its time at 60 fps that alone does not fit 60 (`nvn_fstep_gpu_slowed`; the reference is not updated during a probe) also allows probes, under the same gates. A hitch starts probes too. A step up after 8 s or more at 60 fps keeps what that run's frames cost (frames on time only), and a cost at the longer hold of at most 1.1 times it means the scene costs what it did (`nvn_fstep_same`; measured, so no thermal gate). The fit trial alone did not bring such a game back: the iPhone lowers its GPU clock until the work fills most of the frame, so a GPU-bound game's cost sits near the fit line (0.85 of a vsync) at either hold. Breath of the Wild, stepped up by pipeline compiles after a cutscene, took 12.8–15.2 ms of GPU time a frame at 30 fps (p50), where 60 had held for minutes, and stayed at 30 for 36 and 46 s until the player switched out and back (the same 4,900 draws took 5.8 ms right after a trip to the background and 13.5 ms half a minute later). A run under 8 s keeps no cost, so a device that steps up every few seconds stays at 30 rather than flap. Log: `frame step: hold 2 → 1 … (probe, CPU N% on performance cores)`, `(probe, GPU 1.70× its time at hold 1)` or `(probe, cost 1.04× the cost that held hold 1)`, `the probe held / failed`, and on a step up `hold 1 held at 0.86`. `DM_TEST_PRESENT_LAG=1,10;1200,30,3` imitates such a hitch on the Mac. +- **Real-time declarations for the busiest threads (`DM_PCORE=1`, off by default, untested as a fix).** iOS has no core affinity and every guest thread already runs at user-interactive QoS, yet a Mario Kart World run on an iPhone fell off the performance cores about a minute in (mean clock 3.8 to 1.65 GHz, 55% to 12% of CPU time on performance cores, thermal 0, Low Power Mode off, the main thread's real-time declaration still in effect). A thread with a time-constraint declaration is what the scheduler places on a fast core and what the performance controller raises the clock for, so with `DM_PCORE=1` the three busiest guest threads that have none (the CPU sampler's window, every 300 frames, at 60 ms/s or more) get a 60 Hz declaration of 1.5 times their measured per-frame CPU (1 to 6 ms), once per thread (`pcore_declare` in `hle_thread.c`, log line `DM_PCORE: thread … declared`). The first phone run with it followed a hot start (thermal 2, 1.5 to 2.4 GHz before the declarations took effect) and logged 25 GPU error discards, so the effect is not established either way. The two tiers on that phone (2 performance, 4 efficiency cores) are logged at start by the power line. - **Thread classes.** Every guest thread runs at QoS user-interactive, whatever its Switch priority. `DM_QOS_MAP=1` (off by default, for a phone A/B) maps priority ≤ 44 to user-interactive, 45–59 to user-initiated and the rest to utility (`DM_QOS_EDGES=44,59` moves the edges). `SetThreadPriority` moves a thread's class too, and a thread waiting on a lock lends its class to the owner until the lock is handed over. Host helper threads then set their own class (audio notify, silent sink and vsync user-interactive, movie decode user-initiated) instead of inheriting it from the guest thread that made them. - **Replay CPU.** Memory-pool addresses are cached per thread; per-encoder draw state is sent only when it changes; texture IDs of GPU-only textures stay cached when the game writes other memory; upload staging blocks are reused (Breath of the Wild used to allocate, make resident and free a 32 MB block for each re-upload of a small CPU-written texture, about 3 GB/s) and the residency set is committed only when it changed. - **Vertex bindings.** Normal and transform-feedback draws reuse resolved stream addresses until the guest address or memory-pool generation changes. The vertex argument table skips unchanged addresses, including slot 30's zero buffer; converted streams and shader-argument writes update the same table cache. `DM_NVN_VBUF_CACHE=0` restores repeated lookups and table writes for an A/B run. Every 300 frames, `vertex bindings` reports per-frame lookups/reuse and table writes/skips (the table counts include vertex shader arguments). Compare the existing submit CPU/instruction and onscreen interval logs as well: fewer binding calls alone do not establish a frame-rate gain. `make -C dm/gfx/tests GAME= t_vbuf` builds the GPU-readback regression; run it with `MTL_DEBUG_LAYER=1`, both with the cache enabled and with `DM_NVN_VBUF_CACHE=0`. diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index e47883db1a..91b96ee0c1 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -247,6 +247,7 @@ DM_PAD_IDENTICAL switch `0` stops reading identical pads here when GameControlle DM_PAD_MERGE switch `=1` merges every controller into player 1, as before, instead of giving each controller a player of its own (Mac and iOS) DM_PAD_PRESS_TO_JOIN player `1`: a controller is no player until one of its buttons is pressed, then it is the lowest player free, whatever its port, link or model, identical pads included (`front_mac.m`, `DmGamepad.m`, `pad_slots.h`); not with `DM_PAD_MERGE`. Launcher setting DM_PATCH_FRONT switch `=`: the size of the patch area right before the module base for a long text (`BIG_TEXT`); set only by the rewriter's own rerun with the size it needs +DM_PCORE diagnostic `=1` (off by default, effect not measured): the three busiest guest threads that have no time-constraint declaration (60 ms/s or more in the CPU sampler's 300-frame window) get a 60 Hz declaration of 1.5 times their measured per-frame CPU (1 to 6 ms), once each, to keep them on the performance cores (`pcore_declare` in `hle_thread.c`). A phone A/B has not shown an effect either way. DM_PHONE_MOTION switch On iOS, whether the phone's own device motion drives player 1's six-axis sensor: `auto` (default) while no controller supplies fresh motion, `1` phone only, `0` never; each source keeps its own orientation DM_PICTURE_FIT player `fill` scales the 16:9 picture on the iPhone and iPad until it covers the screen in landscape and crops the edges (a portrait or tabletop split and a TV stay fitted); `fit` or unset, the largest 16:9 rectangle with black bars. Launcher and in-game menu setting DM_PREEMPT switch `0` / `1` overrides the profile's `core_preempt`: also check already-runnable higher-priority peers on the caller's core after every thread SVC From c25475c6b09089d912291fd982161a11645997e9 Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Sat, 7 Nov 2026 12:33:30 -0500 Subject: [PATCH 2/3] DM_RT60=1 (opt-in): the scheduler declaration keeps the base vsync period when the game flips its present interval Off by default. On Mario Kart World 1.8.0 on an iPhone 19,4 it made no visible difference to the slowdown (2026-11-07). Mario Kart World on an iPhone flips its present interval 1 and 2 about 350 times in 20 s after a late frame, re-declaring 16.7 / 33.3 ms to the scheduler each time, and around those flips the process left the performance cores (mean clock 3.5 to 1.6 GHz, thermal fair). --- dm/gfx/nvn/hle_nvn.c | 11 +++++++++++ docs/development.md | 1 + docs/env_switches.tsv | 1 + 3 files changed, 13 insertions(+) diff --git a/dm/gfx/nvn/hle_nvn.c b/dm/gfx/nvn/hle_nvn.c index 66b591af62..dc306b249a 100644 --- a/dm/gfx/nvn/hle_nvn.c +++ b/dm/gfx/nvn/hle_nvn.c @@ -2272,7 +2272,18 @@ static uint64_t present_period(const win_t *o) { // The period the acquire thread declares to the scheduler (set_time_constraint): the present period, except that a hold the frame step raised for late frames keeps the cap's deadline. // Declaring the longer hold let the performance controller move the game to the efficiency cores at half the clock (TOTK on an iPhone: 3.4 → 1.5 GHz and 65% → 5% on performance // cores within 10 frames of a step to 30), where 60 fps no longer fits and every trial back to it fails. A step the game fixes itself (BotW's VFR override) is declared as it is +// DM_RT60=1 (opt-in): the declaration always uses the base vsync period (1 / g_hz). A game that flips its own present interval between 1 and 2 (Mario Kart World 1.8.0, where the frame step +// stub is not placed, after a late frame: about 350 flips in 20 s on an iPhone) re-declared 16.7 / 33.3 ms at each flip, and around those flips the process left the performance cores (mean clock 3.5 → 1.6 GHz, 56% → 8% +// on performance cores, thermal fair): the same demotion the frame-step comment above this function describes. On Mario Kart World 1.8.0 on an iPhone 19,4 it made no visible difference to the +// slowdown (2026-11-07) +static int rt60_on(void) { static int v = -1; if (v < 0) { const char *e = getenv("DM_RT60"); v = e && atoi(e) != 0; if (v) LOG("[nvn] DM_RT60=1: the scheduler declaration keeps the base vsync period"); } return v; } +static uint64_t rt_period_step(const win_t *o, uint64_t period); static uint64_t rt_period_ns(const win_t *o, uint64_t period) { + uint64_t r = rt_period_step(o, period); + if (rt60_on() && g_hz > 0 && r > 1000000000ull / (uint64_t)g_hz) r = 1000000000ull / (uint64_t)g_hz; + return r; +} +static uint64_t rt_period_step(const win_t *o, uint64_t period) { int c = fps_step_cap(); if (!period || !c || !fstep_on() || (dm_fps_state && __atomic_load_n((uint32_t *)(dm_fps_state + 0x24), __ATOMIC_RELAXED))) return period; int kmin = fstep_hz() / c; diff --git a/docs/development.md b/docs/development.md index 4c8909fa8a..4a915c8408 100644 --- a/docs/development.md +++ b/docs/development.md @@ -832,6 +832,7 @@ The game's own work and its frame rate stay as they are; the host avoids work of Results, on the Rogueport route on the Mac, side by side with the committed build under the same load: frame p95 29–37 ms → 18–22 ms, CPU 93–129% → 38–57% of a core, the workers 576–885 → 65–94 ms/s, CPU energy 22–32 → 11–17 mJ per frame. Loops found: TTYD 17 in `main`, 5 in `sdk`, 8 in `subsdk0`; Wonder's `main` 175, Breath of the Wild's 308, Tears of the Kingdom's 843. In Tears of the Kingdom, 701 of them are one inlined atomic decrement (a compare-and-swap retried after another thread changed the count), which yields only on a second retry in a row. The x18 scan now decodes in blocks without details, so the rewrite takes less time than before even with the search: TTYD `main` 20.5 → 11.7 s of CPU, Tears of the Kingdom's 101.5 → 64.2 s, Monster Hunter Rise's 507 → 342 s. - **Waits block instead of polling:** the in-process audio renderer, upload batches, `nvnQueueFinish`. A signalled event wakes only the threads waiting on it; leaving an SVC takes the global lock only while a pause (IL2CPP's garbage collector) is pending. - **Real-time policy.** The thread that acquires the window texture declares a Mach time-constraint policy, which tells the performance controller the frame deadline. `DM_RT=0` leaves it on its QoS, for measuring. +- **The scheduler declaration at the base period (`DM_RT60=1`, opt-in).** A game that flips its own present interval between 1 and 2 re-declares 16.7 and 33.3 ms to the scheduler at each flip (Mario Kart World 1.8.0, where the frame step stub is not placed, after a late frame: about 350 flips in 20 s on an iPhone), and around those flips the process left the performance cores (mean clock 3.5 to 1.6 GHz, 56% to 8% on performance cores, thermal fair), the demotion the frame-step entry below describes for a longer hold. With `DM_RT60=1` the declaration never exceeds `1 / g_hz` (`rt_period_ns` in `hle_nvn.c`). On Mario Kart World 1.8.0 on an iPhone 19,4 it made no visible difference to the slowdown (2026-11-07). - **Frame-step probes.** A game that waits out a longer hold (`nvn_fstep.h`, 30 fps after late frames) gets parked on the efficiency cores even though the declared period stays at 60 fps. Tears of the Kingdom on an iPhone ran 6% on performance cores at 1.6 GHz, thermal 0, where its frames never fit 60 again. While under 30% of the process's CPU time runs on performance cores, in two windows of 30 presents in a row (thermal state nominal or fair, Low Power Mode off), the frame step therefore tries 60 again after its hold-off even when the cost does not fit. The first 30 frames of such a probe may be late, so the controller has time to raise the clock. A probe whose run at 60 then ends within 8 s counts as failed, so the next one waits twice as long (1 → 8 s). The GPU clock follows the hold too: Breath of the Wild on an iPhone went from 14.4 to about 25 ms of GPU time a frame at 30 fps with the same 4,900 draws, its CPU still on the performance cores, and never tried 60 again. A GPU time at the longer hold of at least 1.3 times its time at 60 fps that alone does not fit 60 (`nvn_fstep_gpu_slowed`; the reference is not updated during a probe) also allows probes, under the same gates. A hitch starts probes too. A step up after 8 s or more at 60 fps keeps what that run's frames cost (frames on time only), and a cost at the longer hold of at most 1.1 times it means the scene costs what it did (`nvn_fstep_same`; measured, so no thermal gate). The fit trial alone did not bring such a game back: the iPhone lowers its GPU clock until the work fills most of the frame, so a GPU-bound game's cost sits near the fit line (0.85 of a vsync) at either hold. Breath of the Wild, stepped up by pipeline compiles after a cutscene, took 12.8–15.2 ms of GPU time a frame at 30 fps (p50), where 60 had held for minutes, and stayed at 30 for 36 and 46 s until the player switched out and back (the same 4,900 draws took 5.8 ms right after a trip to the background and 13.5 ms half a minute later). A run under 8 s keeps no cost, so a device that steps up every few seconds stays at 30 rather than flap. Log: `frame step: hold 2 → 1 … (probe, CPU N% on performance cores)`, `(probe, GPU 1.70× its time at hold 1)` or `(probe, cost 1.04× the cost that held hold 1)`, `the probe held / failed`, and on a step up `hold 1 held at 0.86`. `DM_TEST_PRESENT_LAG=1,10;1200,30,3` imitates such a hitch on the Mac. - **Real-time declarations for the busiest threads (`DM_PCORE=1`, off by default, untested as a fix).** iOS has no core affinity and every guest thread already runs at user-interactive QoS, yet a Mario Kart World run on an iPhone fell off the performance cores about a minute in (mean clock 3.8 to 1.65 GHz, 55% to 12% of CPU time on performance cores, thermal 0, Low Power Mode off, the main thread's real-time declaration still in effect). A thread with a time-constraint declaration is what the scheduler places on a fast core and what the performance controller raises the clock for, so with `DM_PCORE=1` the three busiest guest threads that have none (the CPU sampler's window, every 300 frames, at 60 ms/s or more) get a 60 Hz declaration of 1.5 times their measured per-frame CPU (1 to 6 ms), once per thread (`pcore_declare` in `hle_thread.c`, log line `DM_PCORE: thread … declared`). The first phone run with it followed a hot start (thermal 2, 1.5 to 2.4 GHz before the declarations took effect) and logged 25 GPU error discards, so the effect is not established either way. The two tiers on that phone (2 performance, 4 efficiency cores) are logged at start by the power line. - **Thread classes.** Every guest thread runs at QoS user-interactive, whatever its Switch priority. `DM_QOS_MAP=1` (off by default, for a phone A/B) maps priority ≤ 44 to user-interactive, 45–59 to user-initiated and the rest to utility (`DM_QOS_EDGES=44,59` moves the edges). `SetThreadPriority` moves a thread's class too, and a thread waiting on a lock lends its class to the owner until the lock is handed over. Host helper threads then set their own class (audio notify, silent sink and vsync user-interactive, movie decode user-initiated) instead of inheriting it from the guest thread that made them. diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index 91b96ee0c1..e78fced2a8 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -316,6 +316,7 @@ DM_ROMFS path The game's RomFS folder, mounted read-only as `rom` (default `$DM_ DM_ROMFS_OVERLAY path `=[:…]`: RomFS reads look in these folders first, in order, and fall back to `DM_ROMFS` (for a modified copy of some files) DM_ROOT path Workspace root (default `~/dm-work`): the host's and tools' default paths (RomFS, temporary storage, shader libraries under `build/cache/metal_shader_cache`, probe dumps) are under it DM_RT diagnostic `0` leaves the thread that acquires the window on its QoS only, with no Mach time-constraint policy declaring the frame deadline (on by default; for power A/B) +DM_RT60 diagnostic `=1` (off by default; no visible difference to the slowdown of Mario Kart World 1.8.0 on an iPhone 19,4): the acquire thread's scheduler declaration never exceeds the base vsync period (1 / the tick rate), whatever present interval the game sets, so a game that flips its interval between 1 and 2 does not re-declare 16.7 and 33.3 ms (`rt_period_ns` in `hle_nvn.c`). DM_RT_COMP switch `=` fixes the computation the present thread's time-constraint policy declares; default `auto` re-declares it every 120 frames from that thread's per-frame CPU time (p90 and peak) DM_RUMBLE player `0` turns game rumble off (on by default); launcher setting DM_RUMBLE_GAIN player `=0..3` scales the rumble strength (default 1, `0` is silent); launcher setting From 5da0a12819b2533130a81fb8e5454a1b4f3d5471 Mon Sep 17 00:00:00 2001 From: Oliver Ames Date: Sat, 7 Nov 2026 12:33:31 -0500 Subject: [PATCH 3/3] DM_ITV_LATCH=1 (opt-in): hold present interval 2 while the game oscillates between 1 and 2 Off by default. On Mario Kart World 1.8.0 on an iPhone 19,4 it cut the interval flips (12 against 58 in comparable stretches) and did not change the GPU's slowdown (2026-11-07). Mario Kart World on an iPhone flips its present interval about 350 times in 20 s after a late frame, which shows the screen at alternating 16.7 / 33.3 ms periods. The latch holds interval 2 until the game has asked for 1 for DM_ITV_LATCH_MS (3000), so the screen runs one steady 30 fps. --- dm/gfx/nvn/hle_nvn.c | 19 +++++++++++++++++-- docs/development.md | 1 + docs/env_switches.tsv | 2 ++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/dm/gfx/nvn/hle_nvn.c b/dm/gfx/nvn/hle_nvn.c index dc306b249a..5be1195540 100644 --- a/dm/gfx/nvn/hle_nvn.c +++ b/dm/gfx/nvn/hle_nvn.c @@ -2228,7 +2228,21 @@ static void present_cfg(const win_t *o) { LOG("[nvn] present tick %d Hz, interval mode %s (%s; present interval set by the game %d; %s)", g_hz, g_display_mode ? "display (one frame per tick, a cut)" : "game (original semantics)", m ? "DM_PRESENT_INTERVAL" : "game profile present_interval", o->interval, scr > 0 ? "window" : "no window"); } -static int present_itv(const win_t *o) { int c = fps_present_cap(), k = atomic_load(&g_fs_k); return c ? (k && fstep_on() ? k : fstep_hz() / c) : g_display_mode ? 1 : o->interval > 0 ? o->interval : 1; } // vsyncs per frame that presentation follows +// DM_ITV_LATCH=1 (opt-in): a game that flips its present interval between 1 and 2 every few frames (Mario Kart World 1.8.0, where the frame step stub is not placed, on an iPhone: 350 flips in 20 s around its 60 → 30 fps step, 258 in one +// 10 s window) was shown at alternating 16.7 / 33.3 ms periods, a judder, while each flip also moved the display link and the scheduler declaration. The latch holds interval 2 until the game has +// asked for 1 continuously for DM_ITV_LATCH_MS (3000), so the screen runs one steady 30 fps instead. On Mario Kart World 1.8.0 on an iPhone 19,4 it cut the interval flips (12 against 58 in +// comparable stretches) and did not change the GPU's slowdown (2026-11-07) +static int itv_latch_ms(void) { static int v = -1; if (v < 0) { const char *e = getenv("DM_ITV_LATCH_MS"); v = e && atoi(e) > 0 ? atoi(e) : 3000; } return v; } +static _Atomic uint64_t g_itv_latch_until; // now_ns until which the interval stays >= 2 (0 = no latch) +static int itv_latch_on(void) { static int v = -1; if (v < 0) { const char *e = getenv("DM_ITV_LATCH"); v = e && atoi(e) != 0; if (v) LOG("[nvn] DM_ITV_LATCH=1: interval 2 is held until the game asks for 1 for %d ms", itv_latch_ms()); } return v; } +static int present_itv(const win_t *o) { + int c = fps_present_cap(), k = atomic_load(&g_fs_k); + if (c) return k && fstep_on() ? k : fstep_hz() / c; + if (g_display_mode) return 1; + int itv = o->interval > 0 ? o->interval : 1; + if (itv < 2 && itv_latch_on() && now_ns() < atomic_load(&g_itv_latch_until)) return 2; + return itv; +} // vsyncs per frame that presentation follows // Present interval 0 swaps without waiting for a vsync on Horizon: the window's acquire waits only for a texture the display has let go of, the display shows the newest frame at each // vsync, and the game paces itself (or runs as fast as it can; the Switch's unlocked-frame-rate mods set interval 0 to go past 60). The acquire took it as 1 and waited a display // tick each frame. Ori and the Blind Forest: Definitive Edition sets Unity's vSyncCount to 0 only for its shader prewarm behind the first loading screen and back to 1 after it: the @@ -2261,9 +2275,10 @@ static int link_wait(const win_t *o, uint64_t 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; + if (want == 2 && itv_latch_on()) atomic_store(&g_itv_latch_until, now_ns() + (uint64_t)itv_latch_ms() * 1000000ull); if (want != was) { atomic_store(&g_game_itv, want); - if (want < 2 || want % 2) atomic_store(&g_itv2_since, 0); // an odd interval needs the 60 Hz link: 3 vsyncs on a 30 Hz link were 2 ticks, 15 fps for 20 + if ((want < 2 && !(itv_latch_on() && now_ns() < atomic_load(&g_itv_latch_until))) || (want >= 2 && want % 2)) atomic_store(&g_itv2_since, 0); // an odd interval needs the 60 Hz link: 3 vsyncs on a 30 Hz link were 2 ticks, 15 fps for 20 else if (was < 2 || was % 2) atomic_store(&g_itv2_since, now_ns() | 1); // | 1: the start is never 0 } int itv = present_itv(o), hz = g_hz; diff --git a/docs/development.md b/docs/development.md index 4a915c8408..42bb7c2e4e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -833,6 +833,7 @@ The game's own work and its frame rate stay as they are; the host avoids work of - **Waits block instead of polling:** the in-process audio renderer, upload batches, `nvnQueueFinish`. A signalled event wakes only the threads waiting on it; leaving an SVC takes the global lock only while a pause (IL2CPP's garbage collector) is pending. - **Real-time policy.** The thread that acquires the window texture declares a Mach time-constraint policy, which tells the performance controller the frame deadline. `DM_RT=0` leaves it on its QoS, for measuring. - **The scheduler declaration at the base period (`DM_RT60=1`, opt-in).** A game that flips its own present interval between 1 and 2 re-declares 16.7 and 33.3 ms to the scheduler at each flip (Mario Kart World 1.8.0, where the frame step stub is not placed, after a late frame: about 350 flips in 20 s on an iPhone), and around those flips the process left the performance cores (mean clock 3.5 to 1.6 GHz, 56% to 8% on performance cores, thermal fair), the demotion the frame-step entry below describes for a longer hold. With `DM_RT60=1` the declaration never exceeds `1 / g_hz` (`rt_period_ns` in `hle_nvn.c`). On Mario Kart World 1.8.0 on an iPhone 19,4 it made no visible difference to the slowdown (2026-11-07). +- **Holding interval 2 while the game oscillates (`DM_ITV_LATCH=1`, opt-in).** The same flips show the screen alternating 16.7 and 33.3 ms periods, a judder (258 flips in one 10 s window), while the display link follows each one. With `DM_ITV_LATCH=1` interval 2 is held until the game has asked for 1 continuously for `DM_ITV_LATCH_MS` (3000), so the screen runs one steady 30 fps; the game's own logic still sees interval 1 asked for and runs at its step, which is the cost to check. On Mario Kart World 1.8.0 on an iPhone 19,4 it cut the interval flips (12 against 58 in comparable stretches) and did not change the GPU's slowdown (2026-11-07). `DM_PRESENT_INTERVAL=display` (one frame per tick, ignoring the interval) is the other way; it was tried on this game on 2026-11-07 and did not change the slowdown. On a build with the host's frame step (the base game since main `7d8dcdf82`) `DM_ITV_LATCH` and `DM_RT60` do nothing, because `present_itv` returns the step and `rt_period_ns` keeps the cap's deadline. - **Frame-step probes.** A game that waits out a longer hold (`nvn_fstep.h`, 30 fps after late frames) gets parked on the efficiency cores even though the declared period stays at 60 fps. Tears of the Kingdom on an iPhone ran 6% on performance cores at 1.6 GHz, thermal 0, where its frames never fit 60 again. While under 30% of the process's CPU time runs on performance cores, in two windows of 30 presents in a row (thermal state nominal or fair, Low Power Mode off), the frame step therefore tries 60 again after its hold-off even when the cost does not fit. The first 30 frames of such a probe may be late, so the controller has time to raise the clock. A probe whose run at 60 then ends within 8 s counts as failed, so the next one waits twice as long (1 → 8 s). The GPU clock follows the hold too: Breath of the Wild on an iPhone went from 14.4 to about 25 ms of GPU time a frame at 30 fps with the same 4,900 draws, its CPU still on the performance cores, and never tried 60 again. A GPU time at the longer hold of at least 1.3 times its time at 60 fps that alone does not fit 60 (`nvn_fstep_gpu_slowed`; the reference is not updated during a probe) also allows probes, under the same gates. A hitch starts probes too. A step up after 8 s or more at 60 fps keeps what that run's frames cost (frames on time only), and a cost at the longer hold of at most 1.1 times it means the scene costs what it did (`nvn_fstep_same`; measured, so no thermal gate). The fit trial alone did not bring such a game back: the iPhone lowers its GPU clock until the work fills most of the frame, so a GPU-bound game's cost sits near the fit line (0.85 of a vsync) at either hold. Breath of the Wild, stepped up by pipeline compiles after a cutscene, took 12.8–15.2 ms of GPU time a frame at 30 fps (p50), where 60 had held for minutes, and stayed at 30 for 36 and 46 s until the player switched out and back (the same 4,900 draws took 5.8 ms right after a trip to the background and 13.5 ms half a minute later). A run under 8 s keeps no cost, so a device that steps up every few seconds stays at 30 rather than flap. Log: `frame step: hold 2 → 1 … (probe, CPU N% on performance cores)`, `(probe, GPU 1.70× its time at hold 1)` or `(probe, cost 1.04× the cost that held hold 1)`, `the probe held / failed`, and on a step up `hold 1 held at 0.86`. `DM_TEST_PRESENT_LAG=1,10;1200,30,3` imitates such a hitch on the Mac. - **Real-time declarations for the busiest threads (`DM_PCORE=1`, off by default, untested as a fix).** iOS has no core affinity and every guest thread already runs at user-interactive QoS, yet a Mario Kart World run on an iPhone fell off the performance cores about a minute in (mean clock 3.8 to 1.65 GHz, 55% to 12% of CPU time on performance cores, thermal 0, Low Power Mode off, the main thread's real-time declaration still in effect). A thread with a time-constraint declaration is what the scheduler places on a fast core and what the performance controller raises the clock for, so with `DM_PCORE=1` the three busiest guest threads that have none (the CPU sampler's window, every 300 frames, at 60 ms/s or more) get a 60 Hz declaration of 1.5 times their measured per-frame CPU (1 to 6 ms), once per thread (`pcore_declare` in `hle_thread.c`, log line `DM_PCORE: thread … declared`). The first phone run with it followed a hot start (thermal 2, 1.5 to 2.4 GHz before the declarations took effect) and logged 25 GPU error discards, so the effect is not established either way. The two tiers on that phone (2 performance, 4 efficiency cores) are logged at start by the power line. - **Thread classes.** Every guest thread runs at QoS user-interactive, whatever its Switch priority. `DM_QOS_MAP=1` (off by default, for a phone A/B) maps priority ≤ 44 to user-interactive, 45–59 to user-initiated and the rest to utility (`DM_QOS_EDGES=44,59` moves the edges). `SetThreadPriority` moves a thread's class too, and a thread waiting on a lock lends its class to the owner until the lock is handed over. Host helper threads then set their own class (audio notify, silent sink and vsync user-interactive, movie decode user-initiated) instead of inheriting it from the guest thread that made them. diff --git a/docs/env_switches.tsv b/docs/env_switches.tsv index e78fced2a8..6b6ad7d161 100644 --- a/docs/env_switches.tsv +++ b/docs/env_switches.tsv @@ -81,6 +81,8 @@ DM_HOME_QUIT_TEXT switch The texts of the Home button's quit prompt in the playe DM_HTML path Folder of the offline HTML pages (default: `html` beside the RomFS) DM_ICON path `=` gives `build_ipa.sh` another app icon (default the `icon.jpg` beside the RomFS) DM_IMPORTS diagnostic When set, logs each import the loader binds to a host function and each forced takeover +DM_ITV_LATCH diagnostic `=1` (off by default; on Mario Kart World 1.8.0 on an iPhone 19,4 it cut the interval flips and did not change the GPU's slowdown): present interval 2 is held until the game has asked for 1 continuously for `DM_ITV_LATCH_MS`, so a game that flips between 1 and 2 every few frames runs one steady 30 fps instead of alternating 16.7 and 33.3 ms periods (`present_itv` in `hle_nvn.c`). +DM_ITV_LATCH_MS diagnostic `=` how long the game must ask for interval 1 before `DM_ITV_LATCH` lets go of interval 2 (default 3000). DM_JCMS_LOG diagnostic `=1` logs the game's Joy-Con mouse sensor and pointer calls (JcmsState reads, Nmouse handles, states and positions), at most once a second each DM_JIT_CAPTURE path Folder the Mac host writes what a game's `nn::jit` plugin makes to, one `.jit` per request, for `dm/tools/jit_capture.py`; the NSP script sets it DM_JIT_LOG diagnostic `1` logs every `nn::jit` `GenerateCode` request