One budget: passes consume what dynamic resolution leaves

With dynamic resolution on, the leftover planner's admission share and
probe minimum follow measured GPU pressure (fence refusals, pace-guard
cuts) instead of thresholds on the live scale, which would be a second
resolution controller. Off, they stay keyed on the scale as before.

Plans add the estimated cost of passes wanted but shed (and of passes not
started before their deadline) to the host ledger, which also carries the
pace guard's cut count, so the controller can learn pass demand.

Pass costs are selected per image size through the cost cache, so a
dynres step or a Fit-to-Window aspect change no longer resets them. A step
resets the present-time guard and the picture-on-screen record alongside
the pass generations, drops the reserve sample it spans, leaves the two
stalled VBlanks out of the pace guard and holds probes and re-measures for
1 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Shokunin
2026-10-05 20:47:02 -07:00
parent b217891bd8
commit ea2bfca17d
3 changed files with 121 additions and 23 deletions
+23 -2
View File
@@ -295,7 +295,8 @@ use only time the game leaves free, so they never delay its frame:
first presented (`frame_start`), less the work the emulation thread still
has to do for it (the reserve) and a margin of max(1 ms, 1/32 VBlank),
times `render_pass_admission_pct` (65% up to 3x internal scale, 50% above;
`PSX_RENDER_PASS_BUDGET` overrides it). The reserve is measured every
`PSX_RENDER_PASS_BUDGET` overrides it; with dynamic resolution, see
below). The reserve is measured every
frame -- the busy time between the end of the frame's passes and its
start -- and errs late: a larger sample is taken at once, a smaller one
moves it down by 3% of the difference. A frame that resumed more than
@@ -324,7 +325,9 @@ use only time the game leaves free, so they never delay its frame:
smoothed deviation. A cut pass is a lower bound. A pass of unknown cost is
tried only when the budget is at least the thread's own work this
interval plus the reserve (twice that above 3x), and at least a quarter
VBlank. An estimate that prices every plan out is probed with one pass
VBlank. Costs are kept per presented image size (up to 8 sizes, least
recently used replaced): returning to a size restores its estimate instead
of measuring from nothing. An estimate that prices every plan out is probed with one pass
after 30 such plans that had that much leftover time; a probe that
confirms it, or is cut, doubles the wait up to 960 (`probes`).
- **Holds.** No passes while the GPU is still on work queued before the
@@ -335,6 +338,24 @@ use only time the game leaves free, so they never delay its frame:
timing does not show it) halves the passes allowed per frame; plans that
keep pace let it grow by one every 8 plans, a growth that fails soon
doubles that wait (up to 1024).
- **Dynamic resolution (one budget).** With `[video] dynamic_resolution`
on, the dynamic-resolution controller is the only thing that changes the
internal scale; passes are a per-frame consumer of the time it leaves.
The scale moves, so the two thresholds above that are keyed on it would
be a second resolution controller: instead the GPU counts as the limit
(50% share, twice the probe minimum) from the start and after any plan
the GPU fence refused or the pace guard cut, until 60 plans in a row saw
neither. Each plan reports the estimated cost of the passes it wanted but
shed (and of passes not started before their deadline) to the
controller, and the plans in which the pace guard cut passes (the swap's
block in that window is then counted as work, not vsync wait). With
`dynamic_resolution_priority = "frame_rate"` the controller steps down a
level after 2 s of shedding (never below `dynamic_resolution_min`) and
steps up only when the game's and the passes' whole demand fit at the next
level; the default `"resolution"` leaves passes what the resolution
leaves. A step resets the present-time guard and the pass generations,
skips the reserve sample and pace judgement it stalls, and holds cost
probes and re-measures for 1 s.
`render_pass_stats` reports all of it under `leftover`: `skipped`, `cut`,
`budget_ms_avg`, `reserve_ms`, `probes`, how late the emulation thread
+6 -1
View File
@@ -342,10 +342,15 @@ void gl_renderer_dynres_stats(GlDynresStats *out);
* ticks (only kept while dynamic resolution is on): waits for the frame
* blend's next present, render passes, blend presents' own work, and time
* blocked in the swap. The dynamic-resolution controller differences them per
* guest interval. */
* guest interval. pass_shed_ticks: the estimated cost of the render passes
* plans wanted but shed (for time, the pace guard, the GPU fence) or that
* were not started before their deadline -- what the passes would still
* need. pace_cuts: plans whose pace guard found the guest slipping behind
* its schedule (the swap's block was then the GPU, not vsync). */
typedef struct GlHostLedger {
uint64_t idle_ticks, pass_ticks, interp_work_ticks, swap_ticks;
uint64_t interp_presents, swaps;
uint64_t pass_shed_ticks, pace_cuts;
} GlHostLedger;
void gl_renderer_host_ledger(GlHostLedger *out);
+92 -20
View File
@@ -404,6 +404,11 @@ static void dyn_after_present(void); /* a step's post-present point *
static int s_dyn_on = 0; /* gl_renderer_set_dynamic_resolution */
static GlHostLedger s_dyn_ledger; /* host time totals (s_dyn_on only) */
static uint64_t s_dyn_last_swap_ticks = 0;
/* After a step, pass-cost probes and re-measures wait until this host tick:
* the step's tail (first touches, the stencil rebuild) is not the size's
* cost. 0 = no hold. */
static uint64_t s_dyn_probe_hold_until = 0;
#define DYN_PROBE_HOLD_S 1.0
/* Netplay: SW@1× + GPU@s_out_scale dual write; CPU VRAM always authoritative. */
static int s_cpu_auth_dual = 0;
static int s_req_scale = 1; /* requested before context init */
@@ -6281,8 +6286,8 @@ static int s_pb_last_dx = 0, s_pb_last_dy = 0, s_pb_last_dw = 0, s_pb_last_
/* Per-pass host cost (render_pass_plan.h RenderPassCost), and the presented
* image size it was measured at. Another size (an aspect or internal-
* resolution change) starts it over: pass cost does not scale with pixels
* alone, and while it is unknown a plan asks for one pass. */
* resolution change) has its own (pass_cost_select): pass cost does not
* scale with pixels alone, and while it is unknown a plan asks for one pass. */
static RenderPassCost s_pass_cost;
static int s_pass_cost_w = 0, s_pass_cost_h = 0;
static uint64_t s_pass_cost_rewarms = 0; /* stale estimates re-measured */
@@ -6309,6 +6314,35 @@ static uint64_t s_plan_idle_mark = 0; /* s_idle_total then */
static int s_plan_pending = 0; /* reserve sample due at its start */
static uint32_t s_plan_passes_run = 0; /* passes started for it */
static double s_pass_reserve = 0.0; /* ticks of work due after passes */
/* Costs of the other image sizes seen (render_pass_cost_cache_switch): a
* dynamic-resolution step or a Fit-to-Window aspect change switches size,
* and a size seen before keeps what was learnt there. */
static RenderPassCostCache s_pass_cost_cache;
static void pass_cost_select(void) {
if (s_interp_w <= 0 || s_interp_h <= 0) return;
(void)render_pass_cost_cache_switch(&s_pass_cost_cache, &s_pass_cost_w,
&s_pass_cost_h, &s_pass_cost, &s_pass_lcost,
s_interp_w, s_interp_h);
}
/* Whether the GPU is the limit, for the leftover planner's admission share
* and probe minimum (render_pass_admission_pct_for). With dynamic resolution
* the scale moves, and a threshold on it would be a second resolution
* controller: what the fence and the pace guard measured decides. Otherwise
* the internal scale, as before (above 3x). */
static RenderPassGpuPressure s_gpu_pressure;
static int pass_gpu_heavy(void) {
if (s_dyn_on && s_alloc_scale)
return render_pass_gpu_pressure_heavy(&s_gpu_pressure);
return s_hr_scale > 3;
}
/* Pass-cost probes wait out a dynamic-resolution step's tail. */
static int pass_probes_held(void) {
return s_dyn_probe_hold_until &&
SDL_GetPerformanceCounter() < s_dyn_probe_hold_until;
}
/* Diagnostics (render_pass_stats). */
static uint64_t s_pass_skipped_time = 0; /* passes not started: too late */
static uint64_t s_pass_cuts = 0; /* passes stopped at the deadline */
@@ -6350,10 +6384,14 @@ static uint64_t s_pace_cuts = 0; /* plans that found the guest s
static uint64_t s_pace_vblanks = 0, s_pace_late = 0; /* VBlanks; late ones */
static double s_late_min = 0.0; /* earliest VBlank since the plan */
static int s_late_seen = 0;
/* VBlanks not judged: a dynamic-resolution step stalls the thread once, which
* says nothing about what passes cost. */
static int s_pace_skip_vblanks = 0;
/* A guest VBlank arrived `late` host ticks after its interval's end
* (negative: early). */
static void pass_note_vblank_late(double late, double vblank_ticks) {
if (s_pace_skip_vblanks > 0) { s_pace_skip_vblanks--; return; }
if (!s_late_seen || late < s_late_min) s_late_min = late;
s_late_seen = 1;
s_pace_vblanks++;
@@ -6501,7 +6539,9 @@ static uint32_t pass_plan_leftover(uint32_t period_vblanks,
RenderPassPlanInput in;
double freq, sp, now, margin, leftover;
uint32_t cap, want = 0, n, pace_cap;
int live, same_size, force;
int live, same_size, force, heavy, gpu_event = 0;
uint64_t pace_cuts_before;
double est;
if (wanted) *wanted = 0;
s_idle_ticks_accum = s_pass_ticks_accum = s_present_ticks_accum = 0;
@@ -6520,9 +6560,13 @@ static uint32_t pass_plan_leftover(uint32_t period_vblanks,
}
freq = (double)SDL_GetPerformanceFrequency();
sp = freq / s_interp_source_hz;
pace_cuts_before = s_pace_cuts;
pace_cap = pass_pace_cap(sp);
if (s_pace_cuts != pace_cuts_before) gpu_event = 1;
heavy = pass_gpu_heavy();
cap = pass_slot_cap(s_interp_w, s_interp_h);
if (cap < 2) return 0;
pass_cost_select();
now = (double)SDL_GetPerformanceCounter();
memset(&in, 0, sizeof in);
in.next_deadline = s_interp_schedule.next_present_deadline;
@@ -6533,19 +6577,19 @@ static uint32_t pass_plan_leftover(uint32_t period_vblanks,
same_size = s_pass_cost_w == s_interp_w && s_pass_cost_h == s_interp_h;
in.pass_cost = same_size ? render_pass_leftover_cost_estimate(&s_pass_lcost)
: 0.0;
est = in.pass_cost;
/* Until the first frame has measured it, assume the work still due
* before the frame's start takes half a VBlank. */
if (!(s_pass_reserve > 0.0)) s_pass_reserve = 0.5 * sp;
margin = freq * 0.001;
if (margin < sp / 32.0) margin = sp / 32.0;
leftover = render_pass_leftover(now, in.frame_start, s_pass_reserve, margin);
/* A share of it (render_pass_admission_pct): full-resolution VRAM copies
* can exhaust the GPU between guest frames even when the CPU reports
* spare time. Re-evaluated at every plan, so it follows a change of the
* internal resolution. */
/* A share of it (render_pass_admission_pct_for): full-resolution VRAM
* copies can exhaust the GPU between guest frames even when the CPU
* reports spare time. Re-evaluated at every plan (pass_gpu_heavy). */
in.budget = leftover > 0.0 ? leftover *
(double)render_pass_admission_pct(s_hr_scale,
s_pass_budget_pct) / 100.0
(double)render_pass_admission_pct_for(heavy,
s_pass_budget_pct) / 100.0
: 0.0;
{
/* A pass whose cost is not known (the first at this image size, or a
@@ -6557,8 +6601,8 @@ static uint32_t pass_plan_leftover(uint32_t period_vblanks,
double busy = s_interval_resume && now > (double)s_interval_resume
? now - (double)s_interval_resume : sp;
if (busy > sp) busy = sp;
in.probe_min = render_pass_probe_min(busy, s_pass_reserve, sp,
s_hr_scale);
in.probe_min = render_pass_probe_min_for(busy, s_pass_reserve, sp,
heavy);
}
in.max = max < cap - 1u ? max : cap - 1u;
force = pass_force_measure();
@@ -6569,6 +6613,7 @@ static uint32_t pass_plan_leftover(uint32_t period_vblanks,
}
n = render_pass_plan_phases(&in, alpha_q16, &want);
if (n == 0 && want && in.pass_cost > 0.0 && in.budget >= in.probe_min &&
!pass_probes_held() &&
render_pass_leftover_cost_probe_due(&s_pass_lcost)) {
/* The estimate prices every plan out: try one pass in this leftover
* time. Like any pass it is stopped at the deadline. */
@@ -6602,12 +6647,17 @@ static uint32_t pass_plan_leftover(uint32_t period_vblanks,
* own frame, its presents): a pass would first wait for it, in time
* the game may need. Not now. */
s_pass_gpu_busy++;
gpu_event = 1;
if (s_pass_plan_probe) {
s_pass_plan_probe = 0;
s_pass_lcost.probing = 0;
}
n = 0;
}
render_pass_gpu_pressure_note(&s_gpu_pressure, gpu_event);
/* Dynamic resolution learns what the passes wanted but did not get. */
if (s_dyn_on && !force && want > n && est > 0.0)
s_dyn_ledger.pass_shed_ticks += (uint64_t)((double)(want - n) * est);
if (wanted) *wanted = want;
/* Measure the work due after this plan's passes at its frame's start. */
s_plan_frame_start = in.frame_start;
@@ -6649,6 +6699,10 @@ int gl_renderer_pass_may_start(void) {
if (s_pass_plan_probe || !(need > 0.0)) need = s_pass_probe_min;
if (now + need > s_pass_deadline) {
s_pass_skipped_time++;
if (s_dyn_on && s_pass_cost_w == s_interp_w && s_pass_cost_h == s_interp_h) {
double est = render_pass_leftover_cost_estimate(&s_pass_lcost);
if (est > 0.0) s_dyn_ledger.pass_shed_ticks += (uint64_t)est;
}
return 0;
}
s_plan_passes_run++;
@@ -6707,6 +6761,7 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks,
sp = freq / s_interp_source_hz;
cap = pass_slot_cap(s_interp_w, s_interp_h);
if (cap < 2) return 0;
pass_cost_select();
memset(&in, 0, sizeof in);
in.next_deadline = s_interp_schedule.next_present_deadline;
in.target_period = s_interp_schedule.target_period;
@@ -6727,28 +6782,28 @@ uint32_t gl_renderer_pass_plan(uint32_t period_vblanks,
}
in.max = max < cap - 1u ? max : cap - 1u;
{
const double est = in.pass_cost;
uint32_t want = 0, n = render_pass_plan_phases(&in, alpha_q16, &want);
if (wanted) *wanted = want;
/* An estimate no pass has confirmed for a while is measured again
* (render_pass_cost_note_plan): its plans then ask for one pass. */
* (render_pass_cost_note_plan): its plans then ask for one pass. Not
* in a dynamic-resolution step's tail. */
if (want && s_pass_cost_w == s_interp_w && s_pass_cost_h == s_interp_h &&
render_pass_cost_note_plan(&s_pass_cost)) {
!pass_probes_held() && render_pass_cost_note_plan(&s_pass_cost)) {
s_pass_cost_rewarms++;
in.pass_cost = 0.0;
n = render_pass_plan_phases(&in, alpha_q16, NULL);
}
/* Dynamic resolution learns what the passes wanted but did not get. */
if (s_dyn_on && want > n && est > 0.0)
s_dyn_ledger.pass_shed_ticks += (uint64_t)((double)(want - n) * est);
return n;
}
}
void gl_renderer_pass_note_cost(uint64_t ticks, int cut) {
int allocated = s_pass_allocs != s_pass_allocs_begin;
if (s_pass_cost_w != s_interp_w || s_pass_cost_h != s_interp_h) {
memset(&s_pass_cost, 0, sizeof s_pass_cost);
memset(&s_pass_lcost, 0, sizeof s_pass_lcost);
s_pass_cost_w = s_interp_w;
s_pass_cost_h = s_interp_h;
}
pass_cost_select();
s_pass_ticks_accum += ticks;
if (s_dyn_on) s_dyn_ledger.pass_ticks += ticks;
if (!s_pass_leftover) {
@@ -8982,6 +9037,21 @@ static int dyn_apply(int snew) {
if (s_mask_check) rebuild_mask_stencils();
s_pb_valid = 0;
pass_gens_invalidate();
/* What the presenter and the passes hold from the old scale, reset the
* way a history reset does (interp_reset_history_unlocked) without
* re-anchoring the schedule: the present-time guard starts over (a
* generation dropped above must not make the next image look older than
* the screen and be refused), the picture on screen is unknown, the open
* plan's reserve sample spans the step and is not taken, the pace guard
* does not judge the stalled VBlanks, and cost probes wait out the
* step's tail (the new size's costs come from the cost cache, or one pass
* a plan measures them). */
frame_interpolation_present_time_reset(&s_present_time);
s_last_choice.kind = 0;
s_plan_pending = 0;
s_pace_skip_vblanks = 2;
s_dyn_probe_hold_until = SDL_GetPerformanceCounter() +
(uint64_t)(DYN_PROBE_HOLD_S * (double)SDL_GetPerformanceFrequency());
for (int r = 0; r < PRES_ROWS; r++) s_present_dirty[r] = ~0ull;
const uint64_t t6 = SDL_GetPerformanceCounter();
s_dyn_stats.steps++;
@@ -9033,7 +9103,9 @@ void gl_renderer_dynres_stats(GlDynresStats *out) {
}
void gl_renderer_host_ledger(GlHostLedger *out) {
if (out) *out = s_dyn_ledger;
if (!out) return;
*out = s_dyn_ledger;
out->pace_cuts = s_pace_cuts;
}
void gl_renderer_present_vram(int disp_x, int disp_y, int w, int h, int linear,