From cb1e445e4671fa2f1e318c28cf2aba63d111a6bc Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Tue, 6 Oct 2026 02:14:38 -0700 Subject: [PATCH] Dynamic resolution on the render thread: recorded steps, per-frame render cost, render-thread controller Co-Authored-By: Claude Opus 5.5 --- runtime/CMakeLists.txt | 9 + runtime/include/dynamic_resolution.h | 115 ++++++++ runtime/include/gpu_gl_renderer.h | 9 + runtime/include/render_thread.h | 6 +- runtime/src/dynamic_resolution.c | 291 ++++++++++++++++++++ runtime/src/gpu_gl_renderer.c | 191 ++++++++++++- runtime/src/main.cpp | 141 +++++++++- runtime/src/render_thread.c | 1 + runtime/tests/test_dynamic_resolution_rt.c | 302 +++++++++++++++++++++ 9 files changed, 1056 insertions(+), 9 deletions(-) create mode 100644 runtime/tests/test_dynamic_resolution_rt.c diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 4bfa5ea8..40c9d0d4 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1202,6 +1202,15 @@ if(BUILD_TESTING) target_link_libraries(dynamic_resolution_test PRIVATE m) endif() add_test(NAME dynamic_resolution_test COMMAND dynamic_resolution_test) + # The render-thread controller (dynrt_*) against a synthetic pipeline. + add_executable(dynamic_resolution_rt_test + tests/test_dynamic_resolution_rt.c + src/dynamic_resolution.c) + target_include_directories(dynamic_resolution_rt_test PRIVATE include) + if(NOT MSVC) + target_link_libraries(dynamic_resolution_rt_test PRIVATE m) + endif() + add_test(NAME dynamic_resolution_rt_test COMMAND dynamic_resolution_rt_test) # Render-pass time freeze against mock devices (psx_cycles.c only). add_executable(render_pass_freeze_test diff --git a/runtime/include/dynamic_resolution.h b/runtime/include/dynamic_resolution.h index afffd026..fdc90f42 100644 --- a/runtime/include/dynamic_resolution.h +++ b/runtime/include/dynamic_resolution.h @@ -133,6 +133,121 @@ double dynres_predict(const DynresController *c, double load, int from, int to); /* Up steps into `level` blocked for this many seconds from now_s (0 = not). */ double dynres_up_blocked_s(const DynresController *c, int level, double now_s); + +/* ---- Render-thread mode ([video] render_thread on) ------------------------- + * With the render thread the guest's frame no longer pays for GL, so the + * emulation thread's wall time says nothing about whether the resolution + * fits. This controller is fed what the render thread measured instead + * (docs/RENDER_THREAD.md, "Dynamic resolution"): + * + * COST. Per replayed guest frame, max(GPU time, render-thread CPU time): GPU + * time from a GL_TIMESTAMP pair (first record of the frame .. after its + * present), CPU time the render thread spent replaying the frame minus its + * idle waits for records and its block in the swap (the display's vsync). + * load = mean cost / the guest's nominal interval (one display refresh at + * 60 Hz); the budget is 1 - margin (15 %). + * + * BOUND. The render thread is the limit when the emulation thread blocked on + * the queue bound (backpressure) or the cost is over budget. When the guest + * runs below its nominal rate, never waits on the queue and the render + * thread had slack in the actual interval, the guest is the limit: no down + * step (resolution cannot help). Backpressure for bp_strong of a window is a + * strong signal: one window suffices. + * + * RULES (DynrtParams; dynrt_default_params): + * - down: ONE level, after down_windows consecutive over-budget windows (one + * if strong). Never below the floor. + * - judge: the window after a step settles (the cost lags the queue and the + * GPU readback); the next one judges it. A down step that removed less + * than verify_fraction of the predicted cost (and did not end the + * backpressure) is a strike: the window it was decided on may have mixed + * lighter frames in. A second such step in a row (within strike_s) means + * the cost is not the pixels: both are undone and down steps are blocked + * for verify_block_s, doubling per failure up to verify_block_max_s. + * - up: one level, after up_after_s of clean windows (no backpressure, the + * next level predicted at <= up_load), up_cooldown_s after the last step, + * and the level not blocked. The prediction is load * ((1-f) + f*(S'/S)^2) + * with f, the share of the cost that scales with pixels, learned from each + * judged step. down_load > up_load is the hysteresis. + * - relapse: a level reached by an up step and left by a down step within + * relapse_s is blocked for up steps for relapse_block_s, doubling up to + * relapse_block_max_s; holding it relapse_forget_s resets that. + * - holds and gaps discard the window in progress, as in the controller + * above; so does a window in which fewer than min_coverage of the guest + * frames have a measured cost (frames the emulation thread drew itself + * at a sync point). */ +typedef struct DynrtParams { + double window_s; + double margin; /* budget = 1 - margin of the interval */ + int down_windows; /* consecutive over-budget windows */ + double strong_load; /* load at or above: one window suffices */ + double bp_strong; /* backpressure share of wall: strong */ + double bp_weak; /* below: no backpressure */ + double guest_slow; /* interval > period * this: guest slow */ + double guest_slack; /* cost < interval * this: render slack */ + double up_load; + double up_after_s; + double up_cooldown_s; + double verify_fraction; + double verify_block_s, verify_block_max_s, verify_forget_s; + double strike_s; /* a first failed judgement lapses after */ + double relapse_s; + double relapse_block_s, relapse_block_max_s, relapse_forget_s; + double prior_scaled; + double learn_rate; + double gap_factor, gap_hold_s; + double min_coverage; +} DynrtParams; + +void dynrt_default_params(DynrtParams *p); + +typedef struct DynrtSample { + double period_s; /* the guest's nominal interval */ + double wall_s; /* this guest interval's wall time */ + int frames; /* render-thread frames whose cost arrived since the last sample */ + double cost_s; /* their summed cost (see COST) */ + double bp_s; /* emulation thread blocked on the queue bound */ + int held; /* the host holds: not a sample, the window restarts */ +} DynrtSample; + +typedef struct DynrtController { + DynrtParams p; + int floor, ceiling, level, forced; + double f; + /* the window in progress */ + double win_period, win_wall, win_cost, win_bp; + int win_n, win_frames; + /* the last closed window */ + double last_load, last_bp_share, last_hz; + int last_valid, last_guest_bound, last_over; + int over_streak; + double hold_until; + double up_streak_s; + double last_step_t, last_down_t; + double up_reached_t[DYNRES_MAX_LEVEL + 1]; + double up_block_until[DYNRES_MAX_LEVEL + 1]; + double relapse_dur[DYNRES_MAX_LEVEL + 1]; + double down_block_until, verify_dur, verify_last_fail; + int post_active, post_from, post_to, post_windows, post_down; + double post_load_before, post_pred, post_bp_before; + int verify_strikes, strike_from; + double strike_t; + /* telemetry */ + unsigned long long downs, ups, undos, relapses, windows, held_windows, + guest_bound_windows, thin_windows; + const char *last_reason; + double last_decision_t; +} DynrtController; + +void dynrt_init(DynrtController *c, const DynrtParams *p, int floor_level, + int ceiling, int level); +void dynrt_hold(DynrtController *c, double now_s, double tail_s); +/* One guest interval ending at now_s; returns the level to render at. */ +int dynrt_sample(DynrtController *c, double now_s, const DynrtSample *s); +int dynrt_force(DynrtController *c, int level); +double dynrt_predict(const DynrtController *c, double load, int from, int to); +double dynrt_up_blocked_s(const DynrtController *c, int level, double now_s); + #ifdef __cplusplus } #endif diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 817a86f4..0da34dd1 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -335,6 +335,15 @@ typedef struct GlHostLedger { uint64_t interp_presents, swaps; } GlHostLedger; void gl_renderer_host_ledger(GlHostLedger *out); +/* Render thread ([video] render_thread): the cost of each guest frame it + * replayed, max(GPU time, its CPU time without idle waits and the swap), as + * running totals the emulation thread differences. Measured only while + * gl_renderer_render_thread_measure(1) (dynamic resolution on). */ +typedef struct GlRthCosts { + uint64_t frames, cost_ns, cpu_ns, gpu_ns, gpu_frames, dropped; +} GlRthCosts; +void gl_renderer_render_thread_measure(int on); +void gl_renderer_render_thread_costs(GlRthCosts *out); /* Narrow a native-wide display aspect num:den to the widest whose surface * this context can allocate at its internal scale (psx_gl_fit_wide_aspect; diff --git a/runtime/include/render_thread.h b/runtime/include/render_thread.h index 681c5ca8..d57e5772 100644 --- a/runtime/include/render_thread.h +++ b/runtime/include/render_thread.h @@ -23,7 +23,8 @@ * thread at all. * - No lock is held while exec() runs. Locks only guard sleeping/waking. * - * Every function except the rt_on_render_thread() / rt_frames_ahead() + * Every function except the rt_on_render_thread() / rt_frames_ahead() / + * rt_render_idle_ns() * queries is emulation-thread only. */ #ifndef PSX_RENDER_THREAD_H #define PSX_RENDER_THREAD_H @@ -85,6 +86,9 @@ void rt_frame_end(void); /* Render-thread query while executing: how many complete frames are queued * after the frame whose record is being executed (0 = it is the newest). */ int rt_frames_ahead(void); +/* Render-thread query: the consumer's running idle total (waiting for + * records, or parked while the emulation thread holds the context). */ +uint64_t rt_render_idle_ns(void); /* Sync point. Drain, then move the context to the calling thread (held). * reason is a static string kept in the acquire ring. No-op when already held diff --git a/runtime/src/dynamic_resolution.c b/runtime/src/dynamic_resolution.c index c1285cf3..9a7c4978 100644 --- a/runtime/src/dynamic_resolution.c +++ b/runtime/src/dynamic_resolution.c @@ -299,3 +299,294 @@ int dynres_sample(DynresController *c, double now_s, const DynresSample *s) { if (c->win_period >= c->p.window_s - 1e-9) return close_window(c, now_s); return c->level; } + +/* ---- Render-thread mode (see dynamic_resolution.h) ----------------------- */ + +void dynrt_default_params(DynrtParams *p) { + memset(p, 0, sizeof *p); + p->window_s = 0.25; + p->margin = 0.15; + p->down_windows = 2; + p->strong_load = 1.0; + p->bp_strong = 0.05; + p->bp_weak = 0.01; + p->guest_slow = 1.03; + p->guest_slack = 0.90; + p->up_load = 0.70; + p->up_after_s = 3.0; + p->up_cooldown_s = 2.0; + p->verify_fraction = 0.30; + p->verify_block_s = 20.0; + p->verify_block_max_s = 320.0; + p->verify_forget_s = 300.0; + p->strike_s = 2.0; + p->relapse_s = 10.0; + p->relapse_block_s = 10.0; + p->relapse_block_max_s = 160.0; + p->relapse_forget_s = 60.0; + p->prior_scaled = 0.8; + p->learn_rate = 0.3; + p->gap_factor = 4.0; + p->gap_hold_s = 1.0; + p->min_coverage = 0.5; +} + +static int rt_clamp(const DynrtController *c, int l) { + if (l < c->floor) l = c->floor; + if (l > c->ceiling) l = c->ceiling; + return l; +} + +void dynrt_init(DynrtController *c, const DynrtParams *p, int floor_level, + int ceiling, int level) { + memset(c, 0, sizeof *c); + if (p) c->p = *p; else dynrt_default_params(&c->p); + if (ceiling < 1) ceiling = 1; + if (ceiling > DYNRES_MAX_LEVEL) ceiling = DYNRES_MAX_LEVEL; + if (floor_level < 1) floor_level = 1; + if (floor_level > ceiling) floor_level = ceiling; + c->floor = floor_level; + c->ceiling = ceiling; + c->level = rt_clamp(c, level); + c->f = c->p.prior_scaled; + c->last_step_t = c->last_down_t = -1e9; + c->verify_last_fail = -1e9; + c->verify_dur = c->p.verify_block_s; + for (int i = 0; i <= DYNRES_MAX_LEVEL; i++) { + c->up_reached_t[i] = -1e9; + c->relapse_dur[i] = c->p.relapse_block_s; + } + c->last_reason = "start"; +} + +double dynrt_predict(const DynrtController *c, double load, int from, int to) { + if (from < 1 || to < 1) return load; + double q = ((double)to * (double)to) / ((double)from * (double)from); + return load * ((1.0 - c->f) + c->f * q); +} + +double dynrt_up_blocked_s(const DynrtController *c, int level, double now_s) { + if (level < 0 || level > DYNRES_MAX_LEVEL) return 0.0; + double r = c->up_block_until[level] - now_s; + return r > 0.0 ? r : 0.0; +} + +static void rt_window_reset(DynrtController *c) { + c->win_period = c->win_wall = c->win_cost = c->win_bp = 0.0; + c->win_n = c->win_frames = 0; +} + +static void rt_discard(DynrtController *c) { + if (c->win_n) c->held_windows++; + rt_window_reset(c); + c->over_streak = 0; + c->up_streak_s = 0.0; + c->post_active = 0; +} + +void dynrt_hold(DynrtController *c, double now_s, double tail_s) { + double until = now_s + (tail_s > 0.0 ? tail_s : 0.0); + if (until > c->hold_until) c->hold_until = until; + rt_discard(c); +} + +int dynrt_force(DynrtController *c, int level) { + if (level <= 0) { + c->forced = 0; + c->last_reason = "force released"; + rt_discard(c); + return c->level; + } + c->forced = rt_clamp(c, level); + c->level = c->forced; + c->last_reason = "forced"; + rt_discard(c); + return c->level; +} + +static void rt_step(DynrtController *c, double now, int to, int down, double load, + double bp_share, const char *why) { + const int from = c->level; + c->post_active = 1; + c->post_from = from; + c->post_to = to; + c->post_windows = 0; + c->post_down = down; + c->post_load_before = load; + c->post_bp_before = bp_share; + c->post_pred = dynrt_predict(c, load, from, to); + if (down) { + if (now - c->up_reached_t[from] < c->p.relapse_s) { + c->up_block_until[from] = now + c->relapse_dur[from]; + c->relapse_dur[from] *= 2.0; + if (c->relapse_dur[from] > c->p.relapse_block_max_s) + c->relapse_dur[from] = c->p.relapse_block_max_s; + c->relapses++; + } + c->up_reached_t[from] = -1e9; + c->last_down_t = now; + c->downs++; + } else { + c->up_reached_t[to] = now; + c->ups++; + } + c->level = to; + c->last_step_t = now; + c->over_streak = 0; + c->up_streak_s = 0.0; + c->last_reason = why; + c->last_decision_t = now; +} + +static void rt_learn(DynrtController *c, double before, double after, int from, int to) { + if (before <= 0.05 || from == to) return; + double q = ((double)to * (double)to) / ((double)from * (double)from); + double fo = (after / before - 1.0) / (q - 1.0); + if (fo < 0.1) fo = 0.1; + if (fo > 0.95) fo = 0.95; + c->f += c->p.learn_rate * (fo - c->f); +} + +static int rt_close_window(DynrtController *c, double now) { + const double dur = c->win_period; + const int n = c->win_n, frames = c->win_frames; + const double wall = c->win_wall, cost = c->win_cost, bp = c->win_bp; + rt_window_reset(c); + c->windows++; + if (frames < 1 || (double)frames < c->p.min_coverage * (double)n) { + /* Mostly frames the emulation thread drew at a sync point, or the + * costs have not arrived yet: nothing to judge on. */ + c->thin_windows++; + c->over_streak = 0; + c->up_streak_s = 0.0; + c->post_active = 0; + return c->level; + } + const double period = dur / (double)n; + const double load = (cost / (double)frames) / period; + const double bp_share = wall > 0.0 ? bp / wall : 0.0; + const double interval = wall / (double)n; + const double budget = 1.0 - c->p.margin; + c->last_load = load; + c->last_bp_share = bp_share; + c->last_hz = wall > 0.0 ? (double)n / wall : 0.0; + c->last_valid = 1; + const int strong_bp = bp_share >= c->p.bp_strong; + const int no_bp = bp_share < c->p.bp_weak; + /* The guest is the limit: slow, never held by the queue, and the render + * thread had room in the interval it was actually given. */ + const int guest_bound = no_bp && interval > period * c->p.guest_slow && + (cost / (double)frames) < interval * c->p.guest_slack; + c->last_guest_bound = guest_bound; + if (guest_bound) c->guest_bound_windows++; + + /* Judge a recent step: the first window settles, the second judges. */ + if (c->post_active && ++c->post_windows >= 2) { + c->post_active = 0; + const double want = c->post_load_before - c->post_pred; + const double fell = c->post_load_before - load; + const int bp_cleared = c->post_bp_before >= c->p.bp_strong && no_bp; + const int failed = c->post_down && want > 0.0 && + fell < c->p.verify_fraction * want && !bp_cleared; + if (failed && !c->verify_strikes) { + /* One strike: the window before the step may have mixed lighter + * frames in (a scene getting heavier, the cost's lag), so it + * says little. Keep going; a second step that also removes + * nothing is the evidence. */ + c->verify_strikes = 1; + c->strike_from = c->post_from; + c->strike_t = now; + c->last_reason = "down step removed less than predicted (one strike)"; + c->last_decision_t = now; + } else if (failed) { + c->verify_strikes = 0; + if (now - c->verify_last_fail > c->p.verify_forget_s) + c->verify_dur = c->p.verify_block_s; + c->down_block_until = now + c->verify_dur; + c->verify_dur *= 2.0; + if (c->verify_dur > c->p.verify_block_max_s) + c->verify_dur = c->p.verify_block_max_s; + c->verify_last_fail = now; + const int to = rt_clamp(c, c->strike_from > c->post_from ? c->strike_from + : c->post_from); + if (to != c->level) { + c->undos++; + c->level = to; + c->last_step_t = now; + } + c->over_streak = 0; + c->up_streak_s = 0.0; + c->last_reason = "two down steps did not lower the cost: undone, down steps blocked"; + c->last_decision_t = now; + return c->level; + } else { + if (c->post_down) c->verify_strikes = 0; + rt_learn(c, c->post_load_before, load, c->post_from, c->post_to); + } + } + /* A strike is about the steps right after it. */ + if (c->verify_strikes && !c->post_active && now - c->strike_t > c->p.strike_s) + c->verify_strikes = 0; + if (now - c->up_reached_t[c->level] >= c->p.relapse_forget_s) { + c->relapse_dur[c->level] = c->p.relapse_block_s; + c->up_reached_t[c->level] = -1e9; + } + if (c->post_active) return c->level; + + const int over = !guest_bound && (load >= budget || strong_bp); + c->last_over = over; + if (over) { + c->up_streak_s = 0.0; + c->over_streak++; + const int strong = strong_bp || load >= c->p.strong_load; + if (c->level > c->floor && now >= c->down_block_until && + (strong || c->over_streak >= c->p.down_windows)) + rt_step(c, now, c->level - 1, 1, load, bp_share, + strong_bp ? "queue full (render thread behind)" + : strong ? "render cost over the interval" + : "render cost over budget"); + else if (c->level <= c->floor) + c->last_reason = "over budget at the floor"; + return c->level; + } + c->over_streak = 0; + if (guest_bound) c->last_reason = "guest-bound (no step)"; + if (c->level >= c->ceiling) { c->up_streak_s = 0.0; return c->level; } + const int next = c->level + 1; + if (no_bp && dynrt_predict(c, load, c->level, next) <= c->p.up_load) + c->up_streak_s += dur; + else + c->up_streak_s = 0.0; + if (c->up_streak_s >= c->p.up_after_s - 1e-9 && + now - c->last_step_t >= c->p.up_cooldown_s && + now >= c->up_block_until[next]) + rt_step(c, now, next, 0, load, bp_share, "headroom"); + return c->level; +} + +int dynrt_sample(DynrtController *c, double now_s, const DynrtSample *s) { + if (c->forced) return c->level; + if (!s || s->period_s <= 0.0) return c->level; + if (s->held || now_s < c->hold_until) { + if (s->held || c->win_n) rt_discard(c); + return c->level; + } + /* A long interval the queue explains is the render thread being slow, + * not a gap. */ + const double unexplained = s->wall_s - (s->bp_s > 0.0 ? s->bp_s : 0.0); + if (unexplained > s->period_s * c->p.gap_factor) { + dynrt_hold(c, now_s, c->p.gap_hold_s); + c->last_reason = "gap"; + return c->level; + } + c->win_period += s->period_s; + c->win_wall += s->wall_s; + c->win_n++; + if (s->frames > 0 && s->cost_s >= 0.0) { + c->win_frames += s->frames; + c->win_cost += s->cost_s; + } + if (s->bp_s > 0.0) c->win_bp += s->bp_s; + if (c->win_period >= c->p.window_s - 1e-9) return rt_close_window(c, now_s); + return c->level; +} diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index 4259cb32..6f69e668 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -108,6 +108,7 @@ #include #include #include +#include #include "png_write.h" /* png_write_rgb — present_shot readback */ #ifndef GL_BGRA @@ -407,9 +408,13 @@ enum { RTH_OFFSET, RTH_WIDE_CONFIGURE, RTH_WIDE_VIEW, RTH_WIDE_TARGET, RTH_WIDE_DISABLE, RTH_WIDE_CLEAR, RTH_WIDE_CLEAR_MARGINS, RTH_PROJ_TRI, RTH_WIDE_RECOVERY, RTH_INTERP_SUSPENDED, RTH_PRESENT_VRAM, - RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE + RTH_PRESENT_WIDE, RTH_STATE, RTH_PEEK, RTH_RING_CAPTURE, RTH_DYN_STEP, + RTH_FRAME }; static int rth_record_mode(void); +/* Render-thread frame cost (dynamic resolution; defined with GL_RT_BACKEND). */ +static uint64_t s_rthf_swap_ns = 0; /* render thread: time in the swap */ +static uint64_t host_now_ns_rthf(void); static int rth_rec_ints(uint16_t op, uint16_t flags, int n, const int32_t *v); static uint16_t rth_prim_flags(void); static int rth_record_present(uint16_t op, int n, const int32_t *v); @@ -8184,7 +8189,9 @@ static void gl_swap_with_osd(void) { /* Dynamic resolution's ledger: time blocked in the swap is the driver's * vsync wait unless the frame was late (main.cpp). Keep one owned swap. */ uint64_t t0 = s_dyn_on ? SDL_GetPerformanceCounter() : 0; + const uint64_t rthf_t0 = rth_replaying() ? host_now_ns_rthf() : 0; SDL_GL_SwapWindow(s_win); + if (rthf_t0) s_rthf_swap_ns += host_now_ns_rthf() - rthf_t0; if (s_dyn_on) { s_dyn_last_swap_ticks = SDL_GetPerformanceCounter() - t0; s_dyn_ledger.swap_ticks += s_dyn_last_swap_ticks; @@ -8803,6 +8810,7 @@ static void dyn_context_ready(void) { } void gl_renderer_set_dynamic_resolution(int on) { + if (s_ctx) GL_RT_SYNC("set_dynamic_resolution"); s_dyn_on = on ? 1 : 0; if (!s_ctx || !s_raster_ok) return; /* dyn_context_ready decides */ if (s_dyn_on) { @@ -8895,6 +8903,15 @@ static void dyn_rescale_in_place(GLuint fbo, int surf_w, const int (*cols)[2], i p_glBindFramebuffer(PSXGL_DRAW_FRAMEBUFFER, 0); } +/* Render thread: the displayed rect a recorded step keeps at full detail, + * captured when the step was recorded (gpu.c's display state is the + * emulation thread's). */ +static int s_dyn_rth_disp_valid = 0, s_dyn_rth_disp[5]; +static atomic_flag s_dyn_stats_lock = ATOMIC_FLAG_INIT; /* s_dyn_stats: render thread writes */ +static void dyn_stats_lock(void) { while (atomic_flag_test_and_set_explicit(&s_dyn_stats_lock, memory_order_acquire)) {} } +static void dyn_stats_unlock(void) { atomic_flag_clear_explicit(&s_dyn_stats_lock, memory_order_release); } +static int s_rthm_scale = 1; /* emulation-side level (recording) */ + static int dyn_apply(int snew) { if (!s_alloc_scale || !s_raster_ok || !s_ctx) { s_dyn_stats.refused++; return 0; } if (snew < 1) snew = 1; @@ -8930,7 +8947,14 @@ static int dyn_apply(int snew) { int qn = 0, qr[2][4]; { GpuDisplayInfo di; - gpu_get_display_info(&di); + if (rth_replaying()) { + memset(&di, 0, sizeof di); + di.disabled = !s_dyn_rth_disp_valid || s_dyn_rth_disp[0]; + di.display_x = (uint32_t)s_dyn_rth_disp[1]; di.display_y = (uint32_t)s_dyn_rth_disp[2]; + di.width = (uint32_t)s_dyn_rth_disp[3]; di.height = (uint32_t)s_dyn_rth_disp[4]; + } else { + gpu_get_display_info(&di); + } if (!di.disabled && di.width > 0 && di.height > 0) { qr[qn][0] = (int)di.display_x; qr[qn][1] = (int)di.display_y; qr[qn][2] = (int)di.width; qr[qn][3] = (int)di.height; @@ -9032,6 +9056,7 @@ static int dyn_apply(int snew) { pass_gens_invalidate(); for (int r = 0; r < PRES_ROWS; r++) s_present_dirty[r] = ~0ull; const uint64_t t6 = SDL_GetPerformanceCounter(); + dyn_stats_lock(); s_dyn_stats.steps++; s_dyn_stats.last_from = sold; s_dyn_stats.last_to = snew; @@ -9041,6 +9066,7 @@ static int dyn_apply(int snew) { s_dyn_stats.last_rects_ms = dyn_ms(t3, t4); s_dyn_stats.last_wide_ms = dyn_ms(t4, t5); s_dyn_stats.last_rects = qn; + dyn_stats_unlock(); if (s_dyn_timing) fprintf(stdout, "psxrecomp: dynamic resolution %dx -> %dx in %.2f ms (drain %.2f, " "1x+rects %.2f, reseed %.2f, rects back %.2f, wide %.2f)\n", sold, snew, @@ -9050,16 +9076,37 @@ static int dyn_apply(int snew) { } static void dyn_after_present(void) { + /* Recording: the present was queued, not run; the step it would follow + * is recorded after it (gl_renderer_step_internal_scale_now). */ + if (s_rth_on && !rt_on_render_thread() && !rt_held()) return; if (!s_dyn_pending) return; int s = s_dyn_pending; s_dyn_pending = 0; (void)dyn_apply(s); /* inside a pass: re-queued */ } +/* Render thread: record the step in stream order (after the frame just + * closed, before the next one's first draw); the emulation side's level + * moves at once (s_rthm_scale), the backend's when the step is replayed. + * s_alloc_scale only changes under a sync point. 1 when recorded. */ +static int dyn_record_step(int scale) { + if (!(s_rth_on && !rt_on_render_thread() && !rt_held())) return 0; + if (gpu_display_is_depth24()) { gl_rth_acquire("depth24"); return 0; } + if (scale == s_rthm_scale) return 1; + GpuDisplayInfo di; + gpu_get_display_info(&di); + int32_t a[6] = { scale, di.disabled ? 1 : 0, (int32_t)di.display_x, + (int32_t)di.display_y, (int32_t)di.width, (int32_t)di.height }; + if (!rth_rec_ints(RTH_DYN_STEP, 0, 6, a)) { gl_rth_acquire("dyn_step"); return 0; } + s_rthm_scale = scale; + return 1; +} + int gl_renderer_request_internal_scale(int scale) { if (!s_alloc_scale || !s_raster_ok) return 0; if (scale < 1) scale = 1; if (scale > s_alloc_scale) scale = s_alloc_scale; + if (dyn_record_step(scale)) return 1; s_dyn_pending = scale == s_hr_scale ? 0 : scale; return 1; } @@ -9068,16 +9115,21 @@ int gl_renderer_step_internal_scale_now(int scale) { if (!s_alloc_scale || !s_raster_ok) return 0; if (scale > s_alloc_scale) scale = s_alloc_scale; if (scale < 1) scale = 1; + if (dyn_record_step(scale)) return 1; s_dyn_pending = 0; return dyn_apply(scale) && s_hr_scale == scale; } void gl_renderer_dynres_stats(GlDynresStats *out) { if (!out) return; + dyn_stats_lock(); *out = s_dyn_stats; - out->level = s_raster_ok ? s_hr_scale : 0; + dyn_stats_unlock(); + /* While recording, the level the emulation thread has asked for. */ + const int rec = s_rth_on && !rt_on_render_thread() && !rt_held(); + out->level = !s_raster_ok ? 0 : rec ? s_rthm_scale : s_hr_scale; out->ceiling = gl_renderer_dynamic_resolution_ceiling(); - out->pending = s_dyn_pending; + out->pending = rec ? 0 : s_dyn_pending; } void gl_renderer_host_ledger(GlHostLedger *out) { @@ -9145,6 +9197,7 @@ static uint64_t s_rth_presents = 0, s_rth_presents_stale = 0; #define RTHF_BD_SHIFT 2 /* bits 2-3: psx_ws_prim_in_backdrop() 0..3 */ static void rth_mirror_resync(void) { + s_rthm_scale = s_out_scale; s_rthm_wide_w = g_wide_w; s_rthm_wide_off = g_wide_off; s_rthm_cur = g_wide_cur != 0; @@ -9161,8 +9214,8 @@ static int rth_mirror_wide_for(int base_x) { for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_rthm_base[i] == base_x) return 1; if (s_gl_max_dim > 0 && - ((int64_t)s_rthm_wide_w * s_out_scale > s_gl_max_dim || - (int64_t)VRAM_H * s_out_scale > s_gl_max_dim)) + ((int64_t)s_rthm_wide_w * alloc_scale_for(s_rthm_scale) > s_gl_max_dim || + (int64_t)VRAM_H * alloc_scale_for(s_rthm_scale) > s_gl_max_dim)) return 0; for (int i = 0; i < WIDE_MAX_SURF; i++) if (s_rthm_base[i] < 0) { s_rthm_base[i] = base_x; return 1; } @@ -9322,6 +9375,114 @@ static void rth_replay_present(const RtCmd *c, const uint8_t *p) { s_rth_ov.valid = 0; } + +/* ---- render-thread frame cost (dynamic resolution) ------------------------- + * docs/RENDER_THREAD.md, "Dynamic resolution". Measured on the render thread + * per replayed guest frame, between its first record and its RTH_FRAME + * marker (recorded at the frame boundary): CPU = wall time replaying it minus + * the render thread's idle waits for records and its time in the swap (the + * display's vsync); GPU = a GL_TIMESTAMP pair around the same span, read back + * when available (never waited on). cost = max(CPU, GPU). A frame during + * which the context went to the emulation thread (a sync point) is dropped: + * its span holds the emulation thread's own drawing. Published as running + * totals for the emulation thread (gl_renderer_render_thread_costs). */ +#define RTHF_Q 8 +static _Atomic int s_rthf_want = 0; /* host: measure (dynres active) */ +static int s_rthf_qok = -1; /* render thread: queries usable */ +static GLuint s_rthf_q[RTHF_Q][2]; +static uint64_t s_rthf_slot_cpu[RTHF_Q]; +static unsigned s_rthf_head = 0, s_rthf_tail = 0; +static int s_rthf_open = 0, s_rthf_taint = 0, s_rthf_has_q = 0; +static uint64_t s_rthf_t0 = 0, s_rthf_idle0 = 0, s_rthf_swap0 = 0; +static _Atomic uint64_t s_rthf_frames, s_rthf_cost_ns, s_rthf_cpu_ns, s_rthf_gpu_ns, + s_rthf_gpu_frames, s_rthf_dropped; + +static uint64_t host_now_ns_rthf(void) { + return (uint64_t)((double)SDL_GetPerformanceCounter() * 1.0e9 / + (double)SDL_GetPerformanceFrequency()); +} + +static void rthf_publish(uint64_t cpu, uint64_t gpu, int has_gpu) { + uint64_t cost = has_gpu && gpu > cpu ? gpu : cpu; + atomic_fetch_add(&s_rthf_cpu_ns, cpu); + if (has_gpu) { + atomic_fetch_add(&s_rthf_gpu_ns, gpu); + atomic_fetch_add(&s_rthf_gpu_frames, 1); + } + atomic_fetch_add(&s_rthf_cost_ns, cost); + atomic_fetch_add(&s_rthf_frames, 1); /* last: the totals above are in */ +} + +/* Read every finished query pair, oldest first, without waiting. */ +static void rthf_poll(void) { + while (s_rthf_tail != s_rthf_head) { + const unsigned i = s_rthf_tail % RTHF_Q; + GLuint64 avail = 0, a = 0, b = 0; + p_glGetQueryObjectui64v(s_rthf_q[i][1], GL_QUERY_RESULT_AVAILABLE, &avail); + if (!avail) break; + p_glGetQueryObjectui64v(s_rthf_q[i][0], GL_QUERY_RESULT, &a); + p_glGetQueryObjectui64v(s_rthf_q[i][1], GL_QUERY_RESULT, &b); + rthf_publish(s_rthf_slot_cpu[i], b > a ? (uint64_t)(b - a) : 0, 1); + s_rthf_tail++; + } +} + +static void rthf_begin(void) { + if (s_rthf_qok < 0) { + s_rthf_qok = (p_glGenQueries && p_glQueryCounter && p_glGetQueryObjectui64v && + !(getenv("PSX_DYNRES_GPU_TIMER") && getenv("PSX_DYNRES_GPU_TIMER")[0] == '0')) ? 1 : 0; + if (s_rthf_qok) p_glGenQueries(RTHF_Q * 2, &s_rthf_q[0][0]); + } + s_rthf_open = 1; + s_rthf_taint = 0; + s_rthf_t0 = host_now_ns_rthf(); + s_rthf_idle0 = rt_render_idle_ns(); + s_rthf_swap0 = s_rthf_swap_ns; + s_rthf_has_q = s_rthf_qok && s_rthf_head - s_rthf_tail < RTHF_Q; + if (s_rthf_has_q) p_glQueryCounter(s_rthf_q[s_rthf_head % RTHF_Q][0], GL_TIMESTAMP); +} + +static void rthf_end(void) { + if (!s_rthf_open) return; + s_rthf_open = 0; + if (s_rthf_taint) { + /* The query pair is still issued so the ring stays in order; the + * frame is not published. */ + if (s_rthf_has_q) { + p_glQueryCounter(s_rthf_q[s_rthf_head % RTHF_Q][1], GL_TIMESTAMP); + s_rthf_tail = ++s_rthf_head; /* drop it and anything older */ + } + atomic_fetch_add(&s_rthf_dropped, 1); + return; + } + const uint64_t now = host_now_ns_rthf(); + const uint64_t idle = rt_render_idle_ns() - s_rthf_idle0; + const uint64_t swap = s_rthf_swap_ns - s_rthf_swap0; + const uint64_t span = now - s_rthf_t0; + const uint64_t cpu = span > idle + swap ? span - idle - swap : 0; + if (s_rthf_has_q) { + const unsigned i = s_rthf_head % RTHF_Q; + p_glQueryCounter(s_rthf_q[i][1], GL_TIMESTAMP); + s_rthf_slot_cpu[i] = cpu; + s_rthf_head++; + } else { + rthf_publish(cpu, 0, 0); + } + if (s_rthf_qok > 0) rthf_poll(); +} + +void gl_renderer_render_thread_measure(int on) { atomic_store(&s_rthf_want, on ? 1 : 0); } + +void gl_renderer_render_thread_costs(GlRthCosts *out) { + if (!out) return; + out->frames = atomic_load(&s_rthf_frames); + out->cost_ns = atomic_load(&s_rthf_cost_ns); + out->cpu_ns = atomic_load(&s_rthf_cpu_ns); + out->gpu_ns = atomic_load(&s_rthf_gpu_ns); + out->gpu_frames = atomic_load(&s_rthf_gpu_frames); + out->dropped = atomic_load(&s_rthf_dropped); +} + /* ---- the recording vtable ------------------------------------------------ */ #define RTH_DIRECT_OR(rec) do { if (rth_record_mode()) { rec; return; } } while (0) static void rtb_init(uint16_t *vram) { @@ -9331,7 +9492,11 @@ static void rtb_init(uint16_t *vram) { if (s_rth_on && !rt_held()) sw_renderer_rebind_vram(s_rth_vram_priv); } static void rtb_set_scale(int sc) { GL_RT_SYNC("set_scale"); glb_set_scale(sc); } -static int rtb_scale(void) { return s_out_scale; } /* changes only under a sync point */ +/* The scale changes under a sync point, or by a recorded dynamic-resolution + * step: while recording, the level as of the recording position. */ +static int rtb_scale(void) { + return (s_rth_on && !rt_on_render_thread() && !rt_held()) ? s_rthm_scale : s_out_scale; +} static void rtb_set_texture_filter(int b) { GL_RT_SYNC("texture_filter"); glb_set_texture_filter(b); } static int rtb_texture_filter(void) { return s_tex_filter; } static void rtb_set_semi_transparency(int e, int m) { @@ -9541,6 +9706,8 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { s_rthx_prim = (c->flags & RTHF_PRIM) != 0; s_rthx_tagged = (c->flags & RTHF_TAGGED) != 0; s_rthx_backdrop = (c->flags >> RTHF_BD_SHIFT) & 3; + if (c->op == RTH_FRAME) { rthf_end(); return; } + if (!s_rthf_open && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) rthf_begin(); switch (c->op) { case RTH_SEMI: glb_set_semi_transparency(v[0], v[1]); break; case RTH_MASK: glb_set_mask_bits(v[0], v[1]); break; @@ -9611,6 +9778,12 @@ static void gl_rth_exec(void *user, const RtCmd *c, const void *payload) { break; #ifndef PSX_NO_DEBUG_TOOLS case RTH_RING_CAPTURE: rth_ring_capture_now(v); break; + case RTH_DYN_STEP: + s_dyn_rth_disp_valid = 1; + memcpy(s_dyn_rth_disp, v + 1, sizeof s_dyn_rth_disp); + (void)dyn_apply(v[0]); + s_dyn_rth_disp_valid = 0; + break; #endif case RTH_PEEK: { uint64_t ptr = (uint64_t)(uint32_t)v[4] | ((uint64_t)(uint32_t)v[5] << 32); @@ -9629,6 +9802,7 @@ static void gl_rth_ctx(void *user, int current) { } else { glFlush(); SDL_GL_MakeCurrent(s_win, NULL); + if (rt_on_render_thread() && s_rthf_open) s_rthf_taint = 1; } } @@ -9695,6 +9869,9 @@ void gl_renderer_render_thread_frame_boundary(void) { } else if (!gl_rth_eligible()) { gl_rth_acquire("ineligible"); } + /* The render thread's per-frame cost (dynamic resolution) ends here. */ + if (!rt_held() && atomic_load_explicit(&s_rthf_want, memory_order_relaxed)) + RTH_REC(RTH_FRAME, 0, 0); rt_frame_end(); } diff --git a/runtime/src/main.cpp b/runtime/src/main.cpp index 1b284237..74e04073 100644 --- a/runtime/src/main.cpp +++ b/runtime/src/main.cpp @@ -12,6 +12,7 @@ #include "window_size.h" /* default game-window size */ #include "internal_resolution.h" /* Settings -> Display -> Internal resolution */ #include "dynamic_resolution.h" /* [video] dynamic_resolution: the step controller */ +#include "render_thread.h" /* rt_get_stats: queue backpressure (dynres) */ #include "psx_scheduler.h" /* psx_scheduler_run — deterministic TCB scheduler */ #include "parity_trace.h" /* general two-process control-flow parity ring */ #include "device_trace.h" /* general two-process device-event cycle ring */ @@ -1319,6 +1320,17 @@ struct DynresHost { bool step_measure_next = false; FILE *trace = nullptr; double t0_s = 0.0; + /* Render-thread mode (dynres_tick_rt): the render thread's per-frame + * costs and the queue's backpressure replace the wall-time model. */ + bool rt_mode = false; + DynrtController rt{}; + GlRthCosts last_costs{}; + uint64_t last_bp_ns = 0; + unsigned long long rt_last_windows = 0; + bool rt_trace_header = false; + double rt_win_cpu_ms = 0.0, rt_win_gpu_ms = 0.0; /* last window's means */ + double rt_acc_cpu = 0.0, rt_acc_gpu = 0.0; + uint64_t rt_acc_frames = 0, rt_acc_gpu_frames = 0; }; static DynresHost g_dynres; /* A savestate load re-stages VRAM and re-anchors pacing: hold for a while. */ @@ -9239,12 +9251,16 @@ static void dynres_setup(void) { DynresParams params; dynres_default_params(¶ms); dynres_init(&g_dynres.ctl, ¶ms, floor_s, ceiling, ceiling); + DynrtParams rtp; + dynrt_default_params(&rtp); + dynrt_init(&g_dynres.rt, &rtp, floor_s, ceiling, ceiling); g_dynres.active = floor_s < ceiling; if (const char* e = std::getenv("PSX_DYNRES_FORCE")) { const int n = std::atoi(e); if (n > 0) { g_dynres.active = true; const int l = dynres_force(&g_dynres.ctl, n); + (void)dynrt_force(&g_dynres.rt, n); (void)gl_renderer_step_internal_scale_now(l); } } @@ -9278,6 +9294,57 @@ static void dynres_apply_level(int level) { } } +/* Render-thread mode: one sample per guest VBlank from what the render + * thread measured since the last one (its frames' costs arrive a few frames + * late) and the time the emulation thread spent blocked on the queue. */ +static void dynres_tick_rt(double now_s, double wall, double period, int held, + double tail, const char *why) { + DynrtController &c = g_dynres.rt; + if (tail > 0.0) dynrt_hold(&c, now_s, tail); + if (why) g_dynres.hold_reason = why; + else if (now_s >= c.hold_until) g_dynres.hold_reason = ""; + GlRthCosts co; + gl_renderer_render_thread_costs(&co); + RtStats rs; + rt_get_stats(&rs); + const GlRthCosts &c0 = g_dynres.last_costs; + const int frames = (int)(co.frames - c0.frames); + const double cost = (double)(co.cost_ns - c0.cost_ns) * 1e-9; + const uint64_t bp_ns = rs.backpressure_ns + rs.ring_full_ns; + const double bp = (double)(bp_ns - g_dynres.last_bp_ns) * 1e-9; + g_dynres.rt_acc_cpu += (double)(co.cpu_ns - c0.cpu_ns) * 1e-6; + g_dynres.rt_acc_gpu += (double)(co.gpu_ns - c0.gpu_ns) * 1e-6; + g_dynres.rt_acc_frames += co.frames - c0.frames; + g_dynres.rt_acc_gpu_frames += co.gpu_frames - c0.gpu_frames; + g_dynres.last_costs = co; + g_dynres.last_bp_ns = bp_ns; + DynrtSample smp{ period, wall, frames, cost, bp, held }; + const int level = dynrt_sample(&c, now_s, &smp); + if (c.windows != g_dynres.rt_last_windows) { + g_dynres.rt_last_windows = c.windows; + g_dynres.rt_win_cpu_ms = g_dynres.rt_acc_frames + ? g_dynres.rt_acc_cpu / (double)g_dynres.rt_acc_frames : 0.0; + g_dynres.rt_win_gpu_ms = g_dynres.rt_acc_gpu_frames + ? g_dynres.rt_acc_gpu / (double)g_dynres.rt_acc_gpu_frames : 0.0; + g_dynres.rt_acc_cpu = g_dynres.rt_acc_gpu = 0.0; + g_dynres.rt_acc_frames = g_dynres.rt_acc_gpu_frames = 0; + if (g_dynres.trace) { + if (!g_dynres.rt_trace_header) { + g_dynres.rt_trace_header = true; + std::fprintf(g_dynres.trace, "# render thread: t_s,level,load,bp_share," + "guest_hz,cpu_ms,gpu_ms,guest_bound,decision\n"); + } + std::fprintf(g_dynres.trace, "%.3f,%d,%.3f,%.3f,%.2f,%.2f,%.2f,%d,%s\n", + now_s - g_dynres.t0_s, c.level, c.last_load, c.last_bp_share, + c.last_hz, g_dynres.rt_win_cpu_ms, g_dynres.rt_win_gpu_ms, + c.last_guest_bound, + c.last_decision_t == now_s ? c.last_reason : ""); + std::fflush(g_dynres.trace); + } + } + dynres_apply_level(level); +} + /* One guest VBlank, after its present. */ static void dynres_tick(void) { if (!g_dynres.active || !g_gl_active) return; @@ -9351,6 +9418,38 @@ static void dynres_tick(void) { why = "savestate load"; g_dynres.hold_request_s = 0.0; } + /* The render thread on: its own model (dynres_tick_rt). The level carries + * over when the mode changes (the render thread starts at the first + * VBlank, after dynres_setup). */ + const bool rt = gl_renderer_render_thread_active() != 0; + if (rt != g_dynres.rt_mode) { + g_dynres.rt_mode = rt; + gl_renderer_render_thread_measure(rt ? 1 : 0); + GlDynresStats st; + gl_renderer_dynres_stats(&st); + if (rt) { + const DynrtParams rp = g_dynres.rt.p; + const int forced = g_dynres.rt.forced; + dynrt_init(&g_dynres.rt, &rp, g_dynres.ctl.floor, g_dynres.ctl.ceiling, st.level); + if (forced) (void)dynrt_force(&g_dynres.rt, forced); + gl_renderer_render_thread_costs(&g_dynres.last_costs); + RtStats rs; + rt_get_stats(&rs); + g_dynres.last_bp_ns = rs.backpressure_ns + rs.ring_full_ns; + } else { + const DynresParams cp = g_dynres.ctl.p; + const int forced = g_dynres.ctl.forced; + const int fl = g_dynres.ctl.floor, ce = g_dynres.ctl.ceiling; + dynres_init(&g_dynres.ctl, &cp, fl, ce, st.level); + if (forced) (void)dynres_force(&g_dynres.ctl, forced); + } + if (tail < 1.0) tail = 1.0; + if (!why) why = rt ? "render thread started" : "render thread stopped"; + } + if (rt) { + dynres_tick_rt(now_s, wall, period, held, tail, why); + return; + } if (tail > 0.0) dynres_hold(&g_dynres.ctl, now_s, tail); if (why) g_dynres.hold_reason = why; else if (now_s >= g_dynres.ctl.hold_until) g_dynres.hold_reason = ""; @@ -9380,7 +9479,8 @@ extern "C" void psx_dynres_summary(int *enabled, int *level, int *floor_s, int * extern "C" int psx_dynres_force(int scale) { if (!g_dynres.active) return -1; - const int l = dynres_force(&g_dynres.ctl, scale); + const int l = g_dynres.rt_mode ? dynrt_force(&g_dynres.rt, scale) + : dynres_force(&g_dynres.ctl, scale); dynres_apply_level(l); return l; } @@ -9388,6 +9488,45 @@ extern "C" int psx_dynres_force(int scale) { extern "C" int psx_dynres_status_json(char *out, int cap) { GlDynresStats st; gl_renderer_dynres_stats(&st); + if (g_dynres.rt_mode) { /* render-thread mode (dynres_tick_rt) */ + const DynrtController &r = g_dynres.rt; + const double now_s = (double)SDL_GetPerformanceCounter() / + (double)SDL_GetPerformanceFrequency(); + char blocked[256]; + int bp = 0; + blocked[0] = 0; + for (int l = r.floor; g_dynres.active && l <= r.ceiling && bp < (int)sizeof blocked - 24; l++) { + double b = dynrt_up_blocked_s(&r, l, now_s); + if (b > 0.0) + bp += std::snprintf(blocked + bp, sizeof blocked - (size_t)bp, "%s\"%d\":%.1f", + bp ? "," : "", l, b); + } + GlRthCosts co; + gl_renderer_render_thread_costs(&co); + const double hold = r.hold_until - now_s; + return std::snprintf(out, (size_t)cap, + "\"mode\":\"render_thread\",\"enabled\":%d,\"requested\":%d,\"ceiling\":%d," + "\"floor\":%d,\"level\":%d,\"internal_lines\":%d,\"forced\":%d,\"load\":%.3f," + "\"budget\":%.3f,\"bp_share\":%.3f,\"guest_hz\":%.2f,\"cpu_ms\":%.3f," + "\"gpu_ms\":%.3f,\"guest_bound\":%d,\"over\":%d,\"scaled_share\":%.3f," + "\"hold\":\"%s\",\"hold_s\":%.2f,\"last_reason\":\"%s\",\"downs\":%llu," + "\"ups\":%llu,\"undos\":%llu,\"relapses\":%llu,\"windows\":%llu," + "\"held_windows\":%llu,\"guest_bound_windows\":%llu,\"thin_windows\":%llu," + "\"steps\":%llu,\"last_from\":%d,\"last_to\":%d,\"last_ms\":%.3f," + "\"frames_measured\":%llu,\"gpu_frames\":%llu,\"frames_dropped\":%llu," + "\"down_blocked_s\":%.1f,\"up_blocked\":{%s}", + g_dynres.active ? 1 : 0, dynres_requested(), st.ceiling, + g_dynres.active ? r.floor : 0, st.level, st.level * g_video_ref_lines, r.forced, + r.last_load, 1.0 - r.p.margin, r.last_bp_share, r.last_hz, g_dynres.rt_win_cpu_ms, + g_dynres.rt_win_gpu_ms, r.last_guest_bound, r.last_over, r.f, + g_dynres.hold_reason ? g_dynres.hold_reason : "", hold > 0.0 ? hold : 0.0, + r.last_reason ? r.last_reason : "", r.downs, r.ups, r.undos, r.relapses, + r.windows, r.held_windows, r.guest_bound_windows, r.thin_windows, + (unsigned long long)st.steps, st.last_from, st.last_to, st.last_ms, + (unsigned long long)co.frames, (unsigned long long)co.gpu_frames, + (unsigned long long)co.dropped, + r.down_block_until > now_s ? r.down_block_until - now_s : 0.0, blocked); + } const DynresController &c = g_dynres.ctl; const double now_s = (double)SDL_GetPerformanceCounter() / (double)SDL_GetPerformanceFrequency(); diff --git a/runtime/src/render_thread.c b/runtime/src/render_thread.c index dd7fa91c..7ec395f6 100644 --- a/runtime/src/render_thread.c +++ b/runtime/src/render_thread.c @@ -126,6 +126,7 @@ static __thread int t_render; int rt_running(void) { return atomic_load(&R.running); } int rt_on_render_thread(void) { return t_render; } int rt_held(void) { return R.held; } +uint64_t rt_render_idle_ns(void) { return atomic_load(&R.idle_ns); } static inline uint64_t align_up(uint64_t n) { return (n + (RT_ALIGN - 1)) & ~(uint64_t)(RT_ALIGN - 1); diff --git a/runtime/tests/test_dynamic_resolution_rt.c b/runtime/tests/test_dynamic_resolution_rt.c new file mode 100644 index 00000000..9d425bfc --- /dev/null +++ b/runtime/tests/test_dynamic_resolution_rt.c @@ -0,0 +1,302 @@ +/* The render-thread dynamic-resolution controller (dynamic_resolution.c, + * dynrt_*) against a synthetic two-stage pipeline: the guest takes E per + * frame, the render thread R(S) = a + b * S^2 (S the level), at most two + * frames in flight. A frame's interval is max(period, E, R); when R is the + * slowest stage the guest blocks on the queue for the difference + * (backpressure). Costs reach the controller `lag` frames late, at the level + * the frame was rendered at, like the GPU timestamp readback. + * Checks: overrun -> down one level at a time, headroom -> up only after the + * window, hysteresis (no oscillation), guest-bound -> no step, a down step + * that does not lower the cost -> undone and backed off, queue full -> + * fast down, floor and ceiling, holds. + * Build/run: ctest -R dynamic_resolution_rt_test */ +#include +#include +#include + +#include "dynamic_resolution.h" + +static int failures, checks; +#define CHECK(cond, ...) do { checks++; if (!(cond)) { \ + fprintf(stderr, "FAIL: "); fprintf(stderr, __VA_ARGS__); fprintf(stderr, "\n"); \ + failures++; } } while (0) + +static const double kPeriod = 1.0 / 59.94; +#define LAG 4 + +typedef struct Scene { + double a, b; /* render cost a + b*S^2 (seconds) */ + double emu; /* guest time per frame */ + double flat; /* > 0: render cost independent of S (seconds) */ + double under; /* report cost * (1 - under): a meter that underreads */ + double heavy_from, heavy_to, heavy_a; /* optional time-limited extra cost */ +} Scene; + +static double render_cost(const Scene *sc, int s, double t) { + double r = sc->flat > 0.0 ? sc->flat : sc->a + sc->b * (double)s * (double)s; + if (sc->heavy_to > sc->heavy_from && t >= sc->heavy_from && t < sc->heavy_to) + r += sc->heavy_a; + return r; +} + +typedef struct Run { + double t; + int level, steps, downs, ups; + int min_level, max_level; + double first_down_t, first_up_t; + double time_at[DYNRES_MAX_LEVEL + 1]; + double lagq[LAG]; + int lagn; + double frames; /* guest frames */ +} Run; + +static void run_init(Run *r, int level) { + memset(r, 0, sizeof *r); + r->level = level; + r->min_level = r->max_level = level; + r->first_down_t = r->first_up_t = -1.0; +} + +static void drive(DynrtController *c, Run *r, const Scene *sc, double seconds, + double held_until) { + const double end = r->t + seconds; + while (r->t < end) { + const double R = render_cost(sc, r->level, r->t); + double base = kPeriod > sc->emu ? kPeriod : sc->emu; + double interval = R > base ? R : base; + double bp = R > base ? R - base : 0.0; + r->t += interval; + r->frames += 1.0; + r->time_at[r->level] += interval; + /* the cost of the frame rendered LAG frames ago */ + int frames = 0; + double cost = 0.0; + if (r->lagn == LAG) { + frames = 1; + cost = r->lagq[0] * (1.0 - sc->under); + memmove(r->lagq, r->lagq + 1, sizeof(double) * (LAG - 1)); + r->lagn--; + } + r->lagq[r->lagn++] = R; + DynrtSample s = { kPeriod, interval, frames, cost, bp, r->t < held_until }; + int l = dynrt_sample(c, r->t, &s); + if (l != r->level) { + r->steps++; + if (l < r->level) { r->downs++; if (r->first_down_t < 0) r->first_down_t = r->t; } + else { r->ups++; if (r->first_up_t < 0) r->first_up_t = r->t; } + r->level = l; + if (l < r->min_level) r->min_level = l; + if (l > r->max_level) r->max_level = l; + } + } +} + +static void ctl(DynrtController *c, int floor_l, int ceil_l, int level) { + DynrtParams p; + dynrt_default_params(&p); + dynrt_init(c, &p, floor_l, ceil_l, level); +} + +/* Highest level whose cost fits the 15 % margin. */ +static int fit_level(const Scene *sc, int floor_l, int ceil_l) { + int best = floor_l; + for (int s = floor_l; s <= ceil_l; s++) + if (render_cost(sc, s, 0.0) <= kPeriod * 0.85) best = s; + return best; +} + +/* 1. Overrun at the ceiling: down one level at a time to the highest level + * that fits, fast, then no oscillation. */ +static void test_overrun_steps_down_and_settles(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030 }; /* 9x: 27.3 ms, 6x: 13.8 ms, 7x: 17.7 ms */ + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 20.0, 0.0); + int want = fit_level(&sc, 3, 9); + CHECK(r.level == want, "overrun: settled at %d, want %d", r.level, want); + CHECK(r.first_down_t >= 0.0 && r.first_down_t < 0.6, + "overrun: first down at %.2f s, want < 0.6 s", r.first_down_t); + CHECK(r.ups == 0 && r.min_level == want, "overrun: never below %d (min %d, ups %d)", + want, r.min_level, r.ups); + /* every step a single level */ + CHECK(r.downs == 9 - want, "overrun: %d downs for %d levels", r.downs, 9 - want); + int steps = r.steps; + drive(&c, &r, &sc, 120.0, 0.0); + CHECK(r.steps == steps, "overrun: %d steps after settling (oscillation)", r.steps - steps); + CHECK(r.time_at[want] > 115.0, "overrun: %.1f s at the settled level", r.time_at[want]); +} + +/* 2. Headroom: up one level, never before up_after_s, up to the ceiling. */ +static void test_headroom_steps_up_after_window(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.001, .b = 0.00005 }; /* 9x: 5 ms */ + ctl(&c, 3, 9, 4); + run_init(&r, 4); + drive(&c, &r, &sc, 2.9, 0.0); + CHECK(r.ups == 0, "headroom: up within %.1f s (%d ups)", 2.9, r.ups); + drive(&c, &r, &sc, 60.0, 0.0); + CHECK(r.first_up_t >= 3.0, "headroom: first up at %.2f s", r.first_up_t); + CHECK(r.level == 9 && r.downs == 0, "headroom: level %d, downs %d", r.level, r.downs); + CHECK(r.ups == 5, "headroom: %d ups for 5 levels (one at a time)", r.ups); +} + +/* 3. Hysteresis band: a level at 0.80 of the interval stays (under the 0.85 + * budget) and the next one (predicted over up_load) is never tried. */ +static void test_hysteresis(void) { + DynrtController c; Run r; + /* 5x: 0.80 * period */ + double target = 0.80 * kPeriod; + Scene sc = { .a = 0.0, .b = target / 25.0 }; + ctl(&c, 3, 9, 5); + run_init(&r, 5); + drive(&c, &r, &sc, 120.0, 0.0); + CHECK(r.steps == 0, "hysteresis: %d steps at a level inside the band", r.steps); +} + +/* 4. Guest-bound: the guest takes 25 ms per frame, the render thread 15.5 ms + * (0.93 of the nominal interval, over the budget). The guest never waits + * on the queue and the render thread has slack in the 25 ms it is given: + * resolution cannot help, so no down step. */ +static void test_guest_bound_no_step(void) { + DynrtController c; Run r; + Scene sc = { .flat = 0.0155, .emu = 0.025 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 60.0, 0.0); + CHECK(r.downs == 0, "guest-bound: %d down steps", r.downs); + CHECK(c.guest_bound_windows > 100, "guest-bound: %llu guest-bound windows", + c.guest_bound_windows); + /* The render cost scales with S here, the guest is still the limit. */ + DynrtController c2; Run r2; + Scene sc2 = { .a = 0.004, .b = 0.00014, .emu = 0.026 }; /* 9x: 15.3 ms */ + ctl(&c2, 3, 9, 9); + run_init(&r2, 9); + drive(&c2, &r2, &sc2, 60.0, 0.0); + CHECK(r2.downs == 0, "guest-bound (scaling): %d down steps", r2.downs); +} + +/* 5. A down step that does not lower the cost (render-thread submission + * bound, not pixels): undone, and down steps back off (doubling). */ +static void test_undo_and_backoff(void) { + DynrtController c; Run r; + Scene sc = { .flat = 0.0175 }; /* 1.05 of the period at every level */ + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 3.0, 0.0); + CHECK(c.undos == 1 && r.level == 9, "undo: undos %llu, level %d after 3 s", c.undos, r.level); + drive(&c, &r, &sc, 117.0, 0.0); + /* two steps per try, blocks of 20, 40, 80 s: at most 3 tries in 120 s */ + CHECK(r.downs <= 6, "back-off: %d down steps in 120 s", r.downs); + CHECK(r.time_at[9] > 112.0, "back-off: %.1f s at the ceiling", r.time_at[9]); + CHECK(r.min_level >= 7, "back-off: one level at a time (min %d)", r.min_level); +} + +/* 6. The queue is full but the meter underreads (load 0.75 reported): the + * backpressure alone steps down, one window each. */ +static void test_queue_full_steps_down(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030, .under = 0.45 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 0.4, 0.0); + CHECK(r.downs >= 1, "queue full: no down step within 0.4 s"); + drive(&c, &r, &sc, 20.0, 0.0); + CHECK(r.level <= fit_level(&sc, 3, 9) + 1 && r.level >= 3, + "queue full: level %d", r.level); + /* No backpressure left at the level it settled at. */ + CHECK(render_cost(&sc, r.level, 0.0) <= kPeriod + 1e-9, + "queue full: settled at %d still behind", r.level); +} + +/* 7. Floor and ceiling. */ +static void test_floor_and_ceiling(void) { + DynrtController c; Run r; + Scene heavy = { .a = 0.010, .b = 0.0010 }; /* over budget at every level */ + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &heavy, 60.0, 0.0); + CHECK(r.min_level == 3 && r.level == 3, "floor: min %d level %d", r.min_level, r.level); + DynrtController c2; Run r2; + Scene light = { .a = 0.001 }; + ctl(&c2, 3, 9, 9); + run_init(&r2, 9); + drive(&c2, &r2, &light, 60.0, 0.0); + CHECK(r2.max_level == 9 && r2.steps == 0, "ceiling: max %d steps %d", r2.max_level, r2.steps); +} + +/* 8. A heavy stretch: down, then back up once it is over (one level at a + * time, after the window), and no relapse churn. Its first window mixes + * light and heavy frames, so the first step looks like it removed + * nothing: one strike, not an undo. */ +static void test_heavy_stretch_recovers(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.002, .b = 0.00012, /* 9x: 11.7 ms (fits) */ + .heavy_from = 10.0, .heavy_to = 30.0, .heavy_a = 0.008 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 30.0, 0.0); + CHECK(r.level < 9, "stretch: still at %d during the heavy part", r.level); + drive(&c, &r, &sc, 90.0, 0.0); + CHECK(r.level == 9, "stretch: level %d after recovery", r.level); + CHECK(r.downs <= 4, "stretch: %d downs", r.downs); +} + +/* 9. Holds: nothing is sampled or decided while held. */ +static void test_holds(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030 }; + ctl(&c, 3, 9, 9); + run_init(&r, 9); + drive(&c, &r, &sc, 5.0, 5.0); + CHECK(r.steps == 0, "holds: %d steps while held", r.steps); + dynrt_hold(&c, r.t, 1.0); + drive(&c, &r, &sc, 0.9, 0.0); + CHECK(r.steps == 0, "holds: %d steps in the hold tail", r.steps); + drive(&c, &r, &sc, 2.0, 0.0); + CHECK(r.downs >= 1, "holds: no step after the hold"); +} + +/* 10. Missing costs (frames drawn at sync points): thin windows, no step. */ +static void test_thin_windows(void) { + DynrtController c; + ctl(&c, 3, 9, 9); + double t = 0.0; + for (int i = 0; i < 600; i++) { + t += 0.02; + DynrtSample s = { kPeriod, 0.02, 0, 0.0, 0.0, 0 }; + (void)dynrt_sample(&c, t, &s); + } + CHECK(c.level == 9 && c.downs == 0 && c.thin_windows > 10, + "thin: level %d downs %llu thin %llu", c.level, c.downs, c.thin_windows); +} + +/* 11. Force pins the level; release resumes. */ +static void test_force(void) { + DynrtController c; Run r; + Scene sc = { .a = 0.003, .b = 0.00030 }; + ctl(&c, 3, 9, 9); + CHECK(dynrt_force(&c, 7) == 7, "force: level"); + run_init(&r, 7); + drive(&c, &r, &sc, 10.0, 0.0); + CHECK(r.steps == 0, "force: %d steps while pinned", r.steps); + (void)dynrt_force(&c, 0); + drive(&c, &r, &sc, 10.0, 0.0); + CHECK(r.level == fit_level(&sc, 3, 9), "force: released to %d", r.level); +} + +int main(void) { + test_overrun_steps_down_and_settles(); + test_headroom_steps_up_after_window(); + test_hysteresis(); + test_guest_bound_no_step(); + test_undo_and_backoff(); + test_queue_full_steps_down(); + test_floor_and_ceiling(); + test_heavy_stretch_recovers(); + test_holds(); + test_thin_windows(); + test_force(); + printf("dynamic_resolution_rt_test: %d checks, %d failures\n", checks, failures); + return failures ? 1 : 0; +}