Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions dm/gfx/nvn/hle_nvn.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -2272,7 +2287,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;
Expand Down
23 changes: 23 additions & 0 deletions dm/host/hle_thread.c
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
#include <sys/mman.h>
#include <time.h>
#include <mach/mach.h>
#include <mach/mach_time.h>
#include <pthread/qos.h>
#include <dlfcn.h>
#include <stdatomic.h>
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -832,7 +832,10 @@ 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).
- **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.
- **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=<id> 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`.
Expand Down
Loading
Loading