From 0cdf95957c3e34916aef5ff5b0b4e2b77fdc8790 Mon Sep 17 00:00:00 2001 From: Yves Date: Fri, 23 Oct 2026 03:24:01 +0800 Subject: [PATCH] Bound stereo shedding and recover at the display deadline Co-authored-by: Yves --- docs/STEREO_RENDERING.md | 21 +- docs/internal/upstream/vr-bounded-shedding.md | 17 ++ runtime/CMakeLists.txt | 8 + runtime/include/mod_plugins.h | 10 + runtime/include/render_pass.h | 11 + runtime/include/stereo_shed_policy.h | 95 ++++++++ runtime/runtime.cmake | 1 + runtime/src/debug_server.c | 8 + runtime/src/psx_openxr.c | 10 + runtime/src/render_pass.c | 128 +++++++++-- runtime/src/stereo_shed_policy.c | 145 ++++++++++++ runtime/tests/test_render_pass_sandbox.c | 17 ++ runtime/tests/test_stereo_shed_policy.c | 210 ++++++++++++++++++ 13 files changed, 660 insertions(+), 21 deletions(-) create mode 100644 docs/internal/upstream/vr-bounded-shedding.md create mode 100644 runtime/include/stereo_shed_policy.h create mode 100644 runtime/src/stereo_shed_policy.c create mode 100644 runtime/tests/test_stereo_shed_policy.c diff --git a/docs/STEREO_RENDERING.md b/docs/STEREO_RENDERING.md index c8536b87..87ade4da 100644 --- a/docs/STEREO_RENDERING.md +++ b/docs/STEREO_RENDERING.md @@ -16,9 +16,18 @@ that must be cleared after watchdog rollback. `period_vblanks` describes the game's draw cadence (1..8). An EMA of complete-pair host cost is compared with 80% of that cadence, using the runtime's live NTSC/PAL -period. Expensive work is shed as a complete pair; every thirtieth request retries -to recover a stale estimate. This is a conservative cadence budget, not a measured -headset idle budget. Allocating or slow initial pairs may exceed it; completed +period, unless `PSX_STEREO_SHED_BUDGET_MS` supplies the real headset deadline (the +VBlank cadence is a guest-budget fallback, not an OpenXR display deadline). +Expensive work is shed as a complete pair, never as a single eye, and the previous +complete pair is retained. Shedding is bounded both by wall clock +(`PSX_STEREO_SHED_PROBE_MS`, default 250 ms) and by a consecutive count +(`PSX_STEREO_SHED_MAX_STREAK`, default 8), so a stale estimate cannot suppress +indefinitely: whichever bound trips first forces a probe, and the probe's +measurement replaces the estimate rather than blending into it, so a single spike +ends after one bounded streak. Recovery uses a hysteresis band +(`PSX_STEREO_SHED_HYSTERESIS`, default 0.9) and scene/mode transitions reset the +estimate. `PSX_STEREO_SHED=0` disables shedding entirely for a baseline +diagnostic. Allocating or slow initial pairs may exceed the budget; completed guest work cannot be preempted by a host-time budget. Guest-cycle watchdogs remain the runaway bound. @@ -59,8 +68,10 @@ sees it. `test_render_pose` covers the override together with `fov_scale`. TCP inspection: - `stereo_stats`: pair outcomes, eye entry cycles/state hashes under VERIFY, - sampled view offsets, latched failed attempt/eye/retained pair, and published - pair metadata. `render_pass_stats` continues to + sampled view offsets, latched failed attempt/eye/retained pair, published + pair metadata, and the shedding policy (`shedding`, `shed_streak`, + `shed_streak_ms`, `shed_streak_max`, `stale_frames`, `budget_resets`, + `last_pair_age_ms`). `render_pass_stats` continues to expose shared transaction checks, aborts, leaks and dropped stores; its eye transactions also increment pass counters. - `stereo_dump path= count=N`: arm dumps of the next N diff --git a/docs/internal/upstream/vr-bounded-shedding.md b/docs/internal/upstream/vr-bounded-shedding.md new file mode 100644 index 00000000..a52c58df --- /dev/null +++ b/docs/internal/upstream/vr-bounded-shedding.md @@ -0,0 +1,17 @@ +# Bound stereo shedding and recover at the display deadline + +Source: [FractalEngineer/psxrecomp](https://github.com/FractalEngineer/psxrecomp). +- [cae62891](https://github.com/FractalEngineer/psxrecomp/commit/cae62891e9236e9252e43b80aa9dd47f7554aea4) +- [b6a671c1](https://github.com/FractalEngineer/psxrecomp/commit/b6a671c1db4ba15d02369ce5561754d485881a55) + +Original author: Yves ; retained as commit author +and Co-authored-by on this reconstructed port. + +Adapted: this feature and its tests. Excluded: other VR features, game policy, +assets and chronological investigation notes. +Base: `8400b55dc02d3fa787df1280a9fb474901adfcf4` (current upstream master or the preceding feature). + +Validation: Windows/GCC 13.2 Release: policy, sandbox and abort tests pass (3/3). Sustained overload remains expensive; the MoH launcher disables shedding by default. + +Consumer: [Medal of Honor v0.1.6](https://github.com/FractalEngineer/Medal-Of-Honor-PSX-Recomp/tree/1957e28a5dc7f5daa79e2b9b6ed867eb06b41ace). +Requires a framework pin bump and rebuild; no game/BIOS regeneration. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 833731f5..b0347d69 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -1634,6 +1634,12 @@ if(BUILD_TESTING) add_executable(frame_trace_test tests/test_frame_trace.c src/frame_trace.c) target_include_directories(frame_trace_test PRIVATE include) add_test(NAME frame_trace_test COMMAND frame_trace_test) + # Stereo shedding/recovery policy (docs/STEREO_RENDERING.md): bounded streaks, + # authoritative probes, hysteresis, pair age separate from the estimate. + add_executable(stereo_shed_policy_test tests/test_stereo_shed_policy.c + src/stereo_shed_policy.c) + target_include_directories(stereo_shed_policy_test PRIVATE include) + add_test(NAME stereo_shed_policy_test COMMAND stereo_shed_policy_test) # Render thread core ([video] render_thread, docs/RENDER_THREAD.md): # ordering across ring wraps, the frame bound, stale-frame detection and # the drain + context hand-off sync point, against a fake executor. @@ -1780,6 +1786,7 @@ if(BUILD_TESTING) tests/test_render_pass_abort.c src/render_pass.c src/frame_trace.c + src/stereo_shed_policy.c src/psx_cycles.c) target_include_directories(render_pass_abort_test PRIVATE include) add_test(NAME render_pass_abort_test COMMAND render_pass_abort_test) @@ -1792,6 +1799,7 @@ if(BUILD_TESTING) tests/test_render_pass_sandbox.c src/render_pass.c src/frame_trace.c + src/stereo_shed_policy.c src/render_pass_plan.c src/psx_cycles.c src/timers.c diff --git a/runtime/include/mod_plugins.h b/runtime/include/mod_plugins.h index bf458abc..07f87920 100644 --- a/runtime/include/mod_plugins.h +++ b/runtime/include/mod_plugins.h @@ -752,6 +752,16 @@ void psx_mod_frame_trace_note(const char *tag); * inside the guest traversal it is part of (game_select in game_traverse). */ void psx_mod_frame_trace_stage_begin(uint32_t stage); void psx_mod_frame_trace_stage_end(uint32_t stage); +/* Drop the stereo shedding policy's pair-time estimate and derived budget. Call + * on a scene/mode transition so a new scene does not inherit the previous one's + * timing; cheap and idempotent. Shedding itself is unchanged. */ +void psx_mod_stereo_reset_budget(void); +/* Set the stereo pair budget from a real headset deadline in milliseconds, + * pushed by the OpenXR layer from the target display period (11.1 ms at 90 Hz, + * 8.3 ms at 120 Hz). 0 clears it. The guest-VBlank-derived value is only a + * fallback: a PSX redraw budget is not an OpenXR display deadline, and the two + * disagree on every non-60 Hz headset. PSX_STEREO_SHED_BUDGET_MS still wins. */ +void psx_mod_stereo_set_budget_ms(double ms); /* 0 disables stereo output; 1 shows a complete pair side by side. Capture * itself does not enable presentation. Default 0; applies to this session. */ int psx_mod_set_stereo_presentation(uint32_t mode); diff --git a/runtime/include/render_pass.h b/runtime/include/render_pass.h index 13594b9e..a8be5f7f 100644 --- a/runtime/include/render_pass.h +++ b/runtime/include/render_pass.h @@ -91,6 +91,17 @@ typedef struct RenderStereoStats { uint64_t last_failed_attempt, retained_pair_id; int last_failed_eye; const char *last_failed_reason; /* latched across later successes */ + /* Shedding/recovery policy (stereo_shed_policy.c). A shed streak is bounded + * by time and count; these report how the last one ended and how stale the + * retained pair got. */ + uint64_t shed_streak, shed_streak_max; /* consecutive sheds now / worst seen */ + uint32_t shed_streak_ms; /* wall clock of the current streak */ + uint64_t stale_frames; /* frames that published no new pair */ + uint64_t budget_resets; /* estimates reset on scene/mode change */ + double budget_ms; /* pair budget actually in use */ + uint32_t budget_source; /* 0 vblank (fallback), 1 headset, 2 env */ + uint64_t last_pair_age_ms; /* age of the retained pair, this frame */ + int shedding; /* policy is currently suppressing */ } RenderStereoStats; void render_stereo_get_stats(RenderStereoStats *out); int render_pass_netplay_enabled(void); diff --git a/runtime/include/stereo_shed_policy.h b/runtime/include/stereo_shed_policy.h new file mode 100644 index 00000000..82c66d55 --- /dev/null +++ b/runtime/include/stereo_shed_policy.h @@ -0,0 +1,95 @@ +#ifndef PSX_STEREO_SHED_POLICY_H +#define PSX_STEREO_SHED_POLICY_H + +/* Whole-pair stereo shedding policy, extracted so it can be reasoned about and + * tested without a GPU, a headset or a guest. + * + * WHY THIS EXISTS + * --------------- + * The original rule was `avg_pair_ms > budget_ms && ++retry < 30` — an + * attempt counter with no notion of time. One short overload latched the + * average above budget and then silently suppressed the next 29 pair requests: + * a single heavy frame produced a long run of stale frames. There was also no + * way to say "stop shedding and just measure", and no bound on how long + * suppression could last. + * + * THIS POLICY + * ----------- + * * Enters shedding when the pair-time EMA is over budget, and leaves it only + * when a fresh measurement is clearly under `hysteresis * budget` (so noise + * at the boundary does not flap). + * * While shedding, suppression lasts at most `probe_ms` of wall clock and at + * most `max_streak` consecutive sheds, whichever comes first. Whichever way + * it ends, the next frame is rendered, which takes a fresh measurement. + * A stale average can therefore never suppress indefinitely: recovery is + * bounded in TIME, not in attempt count. + * * A forced probe is authoritative: it REPLACES the running estimate rather + * than blending into it. That is what makes a one-off spike cheap — one + * good probe ends the streak, instead of the EMA having to decay over + * several probes while the headset keeps showing stale pairs. + * * `enabled = 0` disables shedding entirely (baseline diagnostic: measure + * what happens with no suppression at all). + * + * The policy decides only whether to skip a pair. It never decides which eyes + * to publish; the caller keeps whole-pair publish (a stale frame retains the + * previous complete pair, never an unmatched eye). + * + * All time is passed in by the caller (host monotonic ms), so there is no clock + * dependency here and the interesting cases are unit-testable. + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct StereoShedPolicy { + /* configuration */ + int enabled; /* 0 = never shed (diagnostic) */ + double budget_ms; /* per-pair budget; set each frame from the cadence */ + double probe_ms; /* bound on one shedding streak (wall clock) */ + uint32_t max_streak; /* bound on one shedding streak (count) */ + double hysteresis; /* resume threshold as a fraction of budget (0..1) */ + /* state */ + double avg_ms; /* EMA of measured pair time */ + int shedding; /* currently suppressing */ + int probing; /* the last decision forced a probe; the next + * measurement is authoritative */ + uint32_t streak; /* consecutive sheds in this streak */ + uint64_t streak_start_ms;/* when this streak began */ + uint64_t last_pair_ms; /* when the last pair was published */ + int have_pair; + uint64_t pairs_seen; /* measurements folded into the average */ + uint64_t shed_total; /* pairs skipped since reset */ +} StereoShedPolicy; + +void stereo_shed_policy_init(StereoShedPolicy *p); +/* Reads PSX_STEREO_SHED (0 disables), PSX_STEREO_SHED_PROBE_MS, + * PSX_STEREO_SHED_MAX_STREAK and PSX_STEREO_SHED_HYSTERESIS, clamping each to a + * safe range. Called once per session. */ +void stereo_shed_policy_configure_from_env(StereoShedPolicy *p); +void stereo_shed_policy_set_budget(StereoShedPolicy *p, double budget_ms); +/* Clears the timing estimate and streak but keeps configuration. Call on a + * scene/mode transition: a new scene must not inherit the previous one's + * estimate, and a cadence change invalidates the budget. */ +void stereo_shed_policy_reset(StereoShedPolicy *p); + +/* 1 = skip this frame's pair, 0 = render it. */ +int stereo_shed_policy_should_shed(StereoShedPolicy *p, uint64_t now_ms); +/* Fold a measured pair attempt into the estimate. `ok` = a complete pair was + * published (so the retained pair is now fresh). */ +void stereo_shed_policy_note_pair(StereoShedPolicy *p, double pair_ms, + uint64_t now_ms, int ok); + +uint32_t stereo_shed_policy_streak_sheds(const StereoShedPolicy *p); +uint32_t stereo_shed_policy_streak_ms(const StereoShedPolicy *p, uint64_t now_ms); +double stereo_shed_policy_avg_ms(const StereoShedPolicy *p); +/* Age of the retained pair, or 0 when no pair has been published yet. */ +uint64_t stereo_shed_policy_pair_age_ms(const StereoShedPolicy *p, uint64_t now_ms); + +#ifdef __cplusplus +} +#endif + +#endif /* PSX_STEREO_SHED_POLICY_H */ diff --git a/runtime/runtime.cmake b/runtime/runtime.cmake index 1236eea2..bfaa49cb 100644 --- a/runtime/runtime.cmake +++ b/runtime/runtime.cmake @@ -487,6 +487,7 @@ set(PSXRECOMP_RUNTIME_SOURCES ${PSXRECOMP_ROOT}/runtime/src/guest_cycle_profile.c ${PSXRECOMP_ROOT}/runtime/src/latency_ring.c ${PSXRECOMP_ROOT}/runtime/src/frame_trace.c + ${PSXRECOMP_ROOT}/runtime/src/stereo_shed_policy.c ${PSXRECOMP_ROOT}/runtime/src/present_image_ring.c ${PSXRECOMP_ROOT}/runtime/src/gpu_timeline.c ${PSXRECOMP_ROOT}/runtime/src/data_shards.c diff --git a/runtime/src/debug_server.c b/runtime/src/debug_server.c index 341dee89..f814a288 100644 --- a/runtime/src/debug_server.c +++ b/runtime/src/debug_server.c @@ -9292,6 +9292,10 @@ static void handle_stereo_stats(int id, const char *json) { "\"last_eye\":%d,\"last_failure\":\"%s\"," "\"last_failed_attempt\":%llu,\"last_failed_eye\":%d," "\"last_failed_reason\":\"%s\",\"retained_pair_id\":%llu," + "\"shedding\":%d,\"shed_streak\":%llu,\"shed_streak_ms\":%u," + "\"shed_streak_max\":%llu,\"stale_frames\":%llu,\"budget_resets\":%llu," + "\"budget_ms\":%.3f,\"budget_source\":%u," + "\"last_pair_age_ms\":%llu," "\"last_pair_ms\":%.3f,\"avg_pair_ms\":%.3f," "\"published\":{\"valid\":%u,\"pair_id\":%llu,\"guest_cycle\":%llu," "\"width\":%u,\"height\":%u,\"staged_mask\":%u,\"mode\":%u,\"presents\":%llu},\"captures\":%s}", @@ -9307,6 +9311,10 @@ static void handle_stereo_stats(int id, const char *json) { (unsigned long long)s.last_failed_attempt, s.last_failed_eye, s.last_failed_reason ? s.last_failed_reason : "", (unsigned long long)s.retained_pair_id, + s.shedding, (unsigned long long)s.shed_streak, s.shed_streak_ms, + (unsigned long long)s.shed_streak_max, (unsigned long long)s.stale_frames, + (unsigned long long)s.budget_resets, s.budget_ms, s.budget_source, + (unsigned long long)s.last_pair_age_ms, s.last_pair_ms, s.avg_pair_ms, g.valid, (unsigned long long)g.pair_id, (unsigned long long)g.guest_cycle, g.width, g.height, g.staged_mask, g.mode, (unsigned long long)g.presents, captures); diff --git a/runtime/src/psx_openxr.c b/runtime/src/psx_openxr.c index 1cf463ba..3404dedf 100644 --- a/runtime/src/psx_openxr.c +++ b/runtime/src/psx_openxr.c @@ -581,6 +581,10 @@ int psx_openxr_begin(int width,int height,double units) { XrFrameWaitInfo wait={XR_TYPE_FRAME_WAIT_INFO};XrFrameState fs={XR_TYPE_FRAME_STATE}; if(!check(xrWaitFrame(s_session,&wait,&fs),"wait_frame")){ psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); + /* Session-level submission failure: the stereo cadence context is gone, + * so drop any stale pair-time estimate rather than carrying it into the + * recovery attempt. */ + psx_mod_stereo_reset_budget(); (void)service_loss();psx_frame_trace_commit();return 0;} psx_frame_trace_add(PSX_TRACE_XR_WAIT_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); s_stats.waits++;s_time=fs.predictedDisplayTime;s_stats.predicted_time=(uint64_t)s_time; @@ -594,12 +598,18 @@ int psx_openxr_begin(int width,int height,double units) { } s_prev_display_time=s_time;s_prev_display_valid=1; /* Record the runtime display period with its name for cadence analysis. */ + /* What the runtime wants us to hit is the only true deadline for the pair. + * Hand it to stereo shedding (PSX VBlank is a guest redraw budget, not a + * compositor deadline) and record it with the runtime name so a trace can be + * read without guessing the headset's refresh. */ + psx_mod_stereo_set_budget_ms(fs.predictedDisplayPeriod>0?(double)fs.predictedDisplayPeriod/1e6:0.0); psx_frame_trace_session(s_stats.runtime, fs.predictedDisplayPeriod>0?(uint32_t)(fs.predictedDisplayPeriod/1000):0u); trace_t = gl_renderer_perf_ticks(); XrFrameBeginInfo begin={XR_TYPE_FRAME_BEGIN_INFO}; if(!check(xrBeginFrame(s_session,&begin),"begin_frame")){ psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); + psx_mod_stereo_reset_budget(); (void)service_loss();psx_frame_trace_commit();return 0;} psx_frame_trace_add(PSX_TRACE_XR_BEGIN_FRAME, xr_trace_us(gl_renderer_perf_ticks()-trace_t)); s_stats.frame_open=1;s_stats.tracking=0; diff --git a/runtime/src/render_pass.c b/runtime/src/render_pass.c index e25ab04c..a4617f08 100644 --- a/runtime/src/render_pass.c +++ b/runtime/src/render_pass.c @@ -38,6 +38,7 @@ #include "timers.h" #include "psx_video_timing.h" #include "gte_view.h" +#include "stereo_shed_policy.h" extern uint8_t *memory_get_ram_ptr(void); extern uint8_t *memory_get_scratchpad_ptr(void); @@ -283,7 +284,16 @@ static RenderPassFailure s_attempt; static const char *s_checkpoint_failure; static RenderStereoStats s_stereo; static int s_pair_busy; -static uint32_t s_pair_retry; +/* Whole-pair shedding/recovery policy (bounded in time and count; see + * stereo_shed_policy.c). Replaces the old attempt counter, which could suppress + * many pairs after a single over-budget frame. */ +static StereoShedPolicy s_shed; +/* Headset deadline pushed by the OpenXR layer (target display period, ms). + * 0 = no headset: fall back to the guest-VBlank redraw budget. */ +static double s_shed_xr_budget_ms; +static uint32_t s_shed_period; +static int32_t s_shed_w, s_shed_h; +static int s_shed_geom_valid; /* A HUD-only capture transaction reuses eye 0's slot but is not an eye draw; * frame tracing must not attribute its cost to the left eye. */ static int s_capture_only; @@ -322,6 +332,32 @@ static int verify_on(void) { return s_verify; } +/* PSX_STEREO_SHED_BUDGET_MS, when set, replaces the guest-VBlank-derived budget + * with a real headset deadline (e.g. 11.1 at 90 Hz, 8.3 at 120 Hz). The VBlank + * cadence is only a fallback: it is a guest redraw budget, not an OpenXR + * display deadline, and conflating the two is wrong on every non-60 Hz headset. + * Returns 0 when unset so the caller derives the fallback. */ +static double shed_budget_override_ms(void) { + static double v = -1.0; + if (v < 0.0) { + const char *e = getenv("PSX_STEREO_SHED_BUDGET_MS"); + double d = e ? atof(e) : 0.0; + v = (d > 0.0 && d < 1000.0) ? d : 0.0; + } + return v; +} + +/* The pair budget actually used, in priority order: the explicit env override, + * the headset display deadline the OpenXR layer pushed, then the guest-VBlank + * redraw budget as a last resort. */ +static double shed_budget_ms(uint32_t period_vblanks, uint32_t *source) { + double ov = shed_budget_override_ms(); + if (ov > 0.0) { if (source) *source = 2; return ov; } + if (s_shed_xr_budget_ms > 0.0) { if (source) *source = 1; return s_shed_xr_budget_ms; } + if (source) *source = 0; + return period_vblanks * (double)g_psx_vblank_cycles / 33868.8 * 0.8; +} + void render_pass_get_stats(RenderPassStats *out) { if (!out) return; *out = s_stats; @@ -342,7 +378,12 @@ void render_pass_reset_session(void) { memset(&s_stereo, 0, sizeof s_stereo); s_stereo.last_eye = -1; s_pair_busy = 0; - s_pair_retry = 0; + /* Shedding policy: defaults, then env overrides (PSX_STEREO_SHED=0 gives the + * baseline diagnostic with shedding fully disabled). */ + stereo_shed_policy_init(&s_shed); + stereo_shed_policy_configure_from_env(&s_shed); + stereo_shed_policy_reset(&s_shed); + s_shed_geom_valid = 0; /* Frame tracing is opt-in per session: re-read the env, install the host * clock, and clear any previous session's records. */ psx_frame_trace_configure_from_env(); @@ -1029,27 +1070,55 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, * previous traced frame. */ int trace_on = psx_frame_trace_enabled(); int trace_opened = trace_on ? psx_frame_trace_open() : 0; + uint64_t now_ms = psx_host_mono_ms(); if (trace_on) { static uint64_t prev_ms; static int prev_valid; - uint64_t now_ms = psx_host_mono_ms(); if (prev_valid) psx_frame_trace_add(PSX_TRACE_GUEST_SIM, (now_ms - prev_ms) * 1000u); prev_ms = now_ms; prev_valid = 1; psx_frame_trace_attempt(s_stereo.attempts, 0, 0); } - budget_ms = frame->period_vblanks * (double)g_psx_vblank_cycles / - 33868.8 * 0.8; - if (s_stereo.avg_pair_ms > budget_ms && ++s_pair_retry < 30u) { - s_stereo.shed++; - if (trace_on) { - psx_frame_trace_attempt(s_stereo.attempts, s_pair_retry, 0); - psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR); - if (trace_opened) psx_frame_trace_commit(); - } - return 0; + budget_ms = shed_budget_ms(frame->period_vblanks, &s_stereo.budget_source); + s_stereo.budget_ms = budget_ms; + /* A scene/mode transition changes cadence and workload, so the previous + * average and budget do not carry over. */ + if (!s_shed_geom_valid || frame->period_vblanks != s_shed_period || + (int32_t)frame->w != s_shed_w || (int32_t)frame->h != s_shed_h) { + if (s_shed_geom_valid) s_stereo.budget_resets++; + s_shed_period = frame->period_vblanks; + s_shed_w = (int32_t)frame->w; s_shed_h = (int32_t)frame->h; + s_shed_geom_valid = 1; + stereo_shed_policy_reset(&s_shed); } - s_pair_retry = 0; + stereo_shed_policy_set_budget(&s_shed, budget_ms); + { + int was_shedding = s_shed.shedding; + if (stereo_shed_policy_should_shed(&s_shed, now_ms)) { + s_stereo.shed++; + s_stereo.stale_frames++; + s_stereo.shed_streak = stereo_shed_policy_streak_sheds(&s_shed); + if (s_stereo.shed_streak > s_stereo.shed_streak_max) + s_stereo.shed_streak_max = s_stereo.shed_streak; + s_stereo.shed_streak_ms = stereo_shed_policy_streak_ms(&s_shed, now_ms); + s_stereo.shedding = 1; + if (trace_on) { + psx_frame_trace_attempt(s_stereo.attempts, s_stereo.shed_streak, + s_stereo.shed_streak_ms); + psx_frame_trace_flag(PSX_FRAME_TRACE_STALE_PAIR | PSX_FRAME_TRACE_SHED); + if (trace_opened) psx_frame_trace_commit(); + } + return 0; + } + /* Log once per streak (not per frame), with both the skipped count and + * the elapsed wall clock the old attempt counter never tracked. */ + if (was_shedding) + fprintf(stderr, "psxrecomp: stereo shed streak ended after %llu " + "skipped, %u ms\n", + (unsigned long long)s_stereo.shed_streak, + s_stereo.shed_streak_ms); + } + s_stereo.shedding = s_shed.shedding; if (g_overlay_flush_pending_cycles) g_overlay_flush_pending_cycles(); cycle = psx_cycle_count; stores = g_guest_store_count; mismatches = s_stats.verify_mismatch; aborted = s_stats.aborted; @@ -1085,8 +1154,6 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, elapsed = (double)(gl_renderer_perf_ticks() - t0) * 1000.0 / (double)gl_renderer_perf_frequency(); s_stereo.last_pair_ms = elapsed; - s_stereo.avg_pair_ms = s_stereo.avg_pair_ms > 0.0 - ? s_stereo.avg_pair_ms * 0.9 + elapsed * 0.1 : elapsed; if (ok && !gl_renderer_stereo_publish(s_stereo.attempts, cycle, s_stereo.eye_view)) { ok = 0; s_stereo.last_failure = "pair_publish"; } @@ -1102,6 +1169,17 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, s_stereo.retained_pair_id = s_stereo.last_pair_id; gl_renderer_stereo_stage_reset(); } + /* Fold the attempt into the policy's estimate (it owns the EMA now). A + * failed or aborted attempt still counts as a measurement, so a stale high + * average cannot outlive the condition that produced it; only a published + * pair refreshes the retained-pair age. Pair age is tracked separately from + * the compute-time estimate so "how old is the picture" never hides behind + * "how fast was the last attempt". */ + stereo_shed_policy_note_pair(&s_shed, elapsed, now_ms, ok); + s_stereo.avg_pair_ms = stereo_shed_policy_avg_ms(&s_shed); + s_stereo.last_pair_age_ms = stereo_shed_policy_pair_age_ms(&s_shed, now_ms); + s_stereo.shedding = s_shed.shedding; + if (!ok) s_stereo.stale_frames++; if (trace_on) { psx_frame_trace_add(PSX_TRACE_PAIR_TOTAL, (uint64_t)(elapsed * 1000.0)); psx_frame_trace_result(ok, ok ? s_stereo.last_pair_id : 0, @@ -1113,6 +1191,24 @@ int psx_mod_render_stereo(CPUState *cpu, const PSXModStereoFrame *frame, return ok; } +/* Game hook: a scene or mode transition invalidates the pair-time estimate and + * the derived budget, so the new scene does not inherit the old one's. Cheap + * and idempotent; safe to call on every transition. */ +void psx_mod_stereo_reset_budget(void) { + stereo_shed_policy_reset(&s_shed); + s_shed_geom_valid = 0; + /* The deadline came from a session that is now gone or whose cadence just + * changed; the OpenXR layer re-pushes it on the next frame it runs. */ + s_shed_xr_budget_ms = 0.0; + s_stereo.budget_resets++; +} + +void psx_mod_stereo_set_budget_ms(double ms) { + /* Push the headset deadline. Ignore nonsense rather than letting a bad + * value widen or collapse the budget. */ + s_shed_xr_budget_ms = (ms > 0.0 && ms < 1000.0) ? ms : 0.0; +} + int psx_mod_render_capture_rgba(CPUState *cpu,const PSXModRenderPass *rect, PSXModRenderPassFn fn,void *user, uint8_t *rgba,uint32_t bytes) { diff --git a/runtime/src/stereo_shed_policy.c b/runtime/src/stereo_shed_policy.c new file mode 100644 index 00000000..dd3621a2 --- /dev/null +++ b/runtime/src/stereo_shed_policy.c @@ -0,0 +1,145 @@ +/* stereo_shed_policy.c — bounded whole-pair shedding. See stereo_shed_policy.h. */ + +#include "stereo_shed_policy.h" + +#include +#include + +#define SHED_EMA_ALPHA 0.1 +#define SHED_DEFAULT_PROBE_MS 250.0 +#define SHED_DEFAULT_MAX_STREAK 8u +#define SHED_DEFAULT_HYSTERESIS 0.9 + +static double clamp_double(double v, double lo, double hi) { + if (!(v == v)) return lo; /* NaN */ + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +void stereo_shed_policy_init(StereoShedPolicy *p) { + if (!p) return; + memset(p, 0, sizeof *p); + p->enabled = 1; + p->probe_ms = SHED_DEFAULT_PROBE_MS; + p->max_streak = SHED_DEFAULT_MAX_STREAK; + p->hysteresis = SHED_DEFAULT_HYSTERESIS; +} + +void stereo_shed_policy_configure_from_env(StereoShedPolicy *p) { + const char *e; + if (!p) return; + e = getenv("PSX_STEREO_SHED"); + if (e) p->enabled = (e[0] && e[0] != '0') ? 1 : 0; + e = getenv("PSX_STEREO_SHED_PROBE_MS"); + if (e) p->probe_ms = clamp_double(strtod(e, NULL), 10.0, 5000.0); + e = getenv("PSX_STEREO_SHED_MAX_STREAK"); + if (e) { + long v = strtol(e, NULL, 0); + p->max_streak = (uint32_t)(v < 1 ? 1 : (v > 240 ? 240 : v)); + } + e = getenv("PSX_STEREO_SHED_HYSTERESIS"); + if (e) p->hysteresis = clamp_double(strtod(e, NULL), 0.5, 1.0); +} + +void stereo_shed_policy_set_budget(StereoShedPolicy *p, double budget_ms) { + if (!p) return; + p->budget_ms = budget_ms > 0.0 ? budget_ms : 0.0; +} + +void stereo_shed_policy_reset(StereoShedPolicy *p) { + if (!p) return; + p->avg_ms = 0.0; + p->shedding = 0; + p->probing = 0; + p->streak = 0; + p->streak_start_ms = 0; + p->pairs_seen = 0; +} + +int stereo_shed_policy_should_shed(StereoShedPolicy *p, uint64_t now_ms) { + if (!p || !p->enabled) return 0; + + if (p->avg_ms > p->budget_ms) { + if (!p->shedding) { /* enter shedding at the first over-budget sample */ + p->shedding = 1; + p->streak = 0; + p->streak_start_ms = now_ms; + } + } else if (p->avg_ms < p->budget_ms * p->hysteresis) { + p->shedding = 0; /* clearly recovered */ + p->streak = 0; + } else { + /* hysteresis band: hold whatever state we are in */ + } + + if (!p->shedding) return 0; + + /* Bounded recovery: neither an elapsed-time nor an attempt-count rule may + * suppress indefinitely. Whichever bound trips first forces a probe, whose + * result note_pair() treats as authoritative. The streak's timer restarts + * here so a persistent overload probes again after the same bound rather + * than every frame. */ + uint64_t elapsed = now_ms >= p->streak_start_ms ? now_ms - p->streak_start_ms : 0; + if (p->streak >= p->max_streak || elapsed >= (uint64_t)p->probe_ms) { + p->probing = 1; + p->streak = 0; + p->streak_start_ms = now_ms; + return 0; /* render this one: take a fresh sample */ + } + p->streak++; + p->shed_total++; + return 1; +} + +void stereo_shed_policy_note_pair(StereoShedPolicy *p, double pair_ms, + uint64_t now_ms, int ok) { + if (!p) return; + if (pair_ms < 0.0) pair_ms = 0.0; + if (p->probing) { + /* A forced probe is a deliberate fresh measurement: use it directly + * instead of blending it away. Otherwise one spike would keep the + * estimate high for many probes, and the headset would keep showing + * stale pairs long after the spike ended. */ + p->probing = 0; + p->avg_ms = pair_ms; + /* Exit on the deadline itself, not on the hysteresis threshold. A pair + * time that settles between the two (here 10.0 to 11.111 ms) would + * otherwise sit in the band forever: the band holds whatever state it + * is in, so a streak entered during a heavy moment never ends and + * shedding starves rendering while the pair is in fact meeting its + * deadline. A latched streak is bounded by the probe interval, which is + * still the recovery guarantee this policy promises. */ + if (pair_ms <= p->budget_ms) { + p->shedding = 0; /* the overload is over */ + p->streak = 0; + } + } else { + p->avg_ms = p->pairs_seen ? p->avg_ms * (1.0 - SHED_EMA_ALPHA) + pair_ms * SHED_EMA_ALPHA + : pair_ms; + } + p->pairs_seen++; + if (ok) { + p->last_pair_ms = now_ms; + p->have_pair = 1; + } +} + +uint32_t stereo_shed_policy_streak_sheds(const StereoShedPolicy *p) { + return p ? p->streak : 0; +} + +uint32_t stereo_shed_policy_streak_ms(const StereoShedPolicy *p, uint64_t now_ms) { + if (!p || !p->shedding || now_ms < p->streak_start_ms) return 0; + uint64_t d = now_ms - p->streak_start_ms; + return d > 0xFFFFFFFFu ? 0xFFFFFFFFu : (uint32_t)d; +} + +double stereo_shed_policy_avg_ms(const StereoShedPolicy *p) { + return p ? p->avg_ms : 0.0; +} + +uint64_t stereo_shed_policy_pair_age_ms(const StereoShedPolicy *p, uint64_t now_ms) { + if (!p || !p->have_pair || now_ms < p->last_pair_ms) return 0; + return now_ms - p->last_pair_ms; +} diff --git a/runtime/tests/test_render_pass_sandbox.c b/runtime/tests/test_render_pass_sandbox.c index caedab1c..ed260bf5 100644 --- a/runtime/tests/test_render_pass_sandbox.c +++ b/runtime/tests/test_render_pass_sandbox.c @@ -780,6 +780,23 @@ static void test_stereo(void) { f.period_vblanks = 0; CHECK(!psx_mod_render_stereo(&cpu, &f, pair_draw, NULL), "invalid cadence refused"); + /* The headset deadline the OpenXR layer pushes outranks the guest-VBlank + * fallback, and dropping it restores the fallback. */ + f.period_vblanks = 2; + psx_mod_stereo_reset_budget(); + psx_mod_stereo_set_budget_ms(11.1); + CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, + "pair renders under a headset deadline"); + render_stereo_get_stats(&stats); + CHECK(stats.budget_source == 1 && stats.budget_ms > 11.0 && stats.budget_ms < 11.2, + "headset deadline drives the pair budget"); + psx_mod_stereo_reset_budget(); + CHECK(psx_mod_render_stereo(&cpu, &f, pair_draw, NULL) == 1, + "pair renders after the deadline is dropped"); + render_stereo_get_stats(&stats); + CHECK(stats.budget_source == 0 && stats.budget_ms > 0.0, + "guest-VBlank fallback restored when no headset deadline is set"); + /* Tracing is observation only: the same pair must publish the same id and * reach the same guest-state hashes with capture on as with it off. */ { diff --git a/runtime/tests/test_stereo_shed_policy.c b/runtime/tests/test_stereo_shed_policy.c new file mode 100644 index 00000000..dfb87f72 --- /dev/null +++ b/runtime/tests/test_stereo_shed_policy.c @@ -0,0 +1,210 @@ +/* test_stereo_shed_policy.c — PR2 shedding/recovery policy. + * + * Covers the plan's PR2 test list at the policy level: short spike, persistent + * overload, alternating load (hysteresis), stale estimate, no unbounded streak, + * transient measurement gaps, and the disable switch. Both-eye integrity and + * guest-cycle accounting are structural (the policy has no eye or guest state); + * the render-pass side already asserts eye-cycle equality. + */ + +#include "stereo_shed_policy.h" + +#include +#include + +static int g_fail; +#define CHECK(cond) do { \ + if (!(cond)) { printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); g_fail = 1; } \ +} while (0) + +/* Drive `budget`/`probe`/`streak` config the way a session would, without env. */ +static void configure(StereoShedPolicy *p, double avg_ms, uint64_t probe_ms, + uint32_t max_streak, double hysteresis) { + stereo_shed_policy_init(p); + p->budget_ms = 16.0; + p->probe_ms = (double)probe_ms; + p->max_streak = max_streak; + p->hysteresis = hysteresis; + if (avg_ms > 0.0) { p->avg_ms = avg_ms; p->pairs_seen = 1; } +} + +/* 1: disabled means never shed, whatever the timing. */ +static void test_disabled(void) { + StereoShedPolicy p; + configure(&p, 40.0, 250, 8, 0.9); + p.enabled = 0; + for (uint64_t t = 0; t < 2000; t += 8) + CHECK(stereo_shed_policy_should_shed(&p, t) == 0); +} + +/* 2: a single spike sheds, but the streak is bounded and one good probe ends it + * (the probe replaces the estimate instead of decaying into it). */ +static void test_short_spike_bounded(void) { + StereoShedPolicy p; + uint32_t sheds = 0; + configure(&p, 0.0, 250, 8, 0.9); + uint64_t t = 0; + for (int i = 0; i < 20; i++) { /* establish a fast baseline (~12 ms) */ + stereo_shed_policy_note_pair(&p, 12.0, t, 1); + t += 16; + CHECK(stereo_shed_policy_should_shed(&p, t) == 0); + } + stereo_shed_policy_note_pair(&p, 80.0, t, 1); /* one heavy frame */ + for (int i = 0; i < 200; i++) { + if (stereo_shed_policy_should_shed(&p, t)) { sheds++; t += 16; continue; } + stereo_shed_policy_note_pair(&p, 12.0, t, 1); /* the probe measures fast again */ + t += 16; + } + CHECK(sheds > 0); /* the spike did suppress something */ + CHECK(sheds <= 8u); /* ...but never more than one bounded streak */ + CHECK(stereo_shed_policy_avg_ms(&p) < 16.0); /* and recovery was immediate */ +} + +/* 3: sustained overload keeps shedding but must still probe periodically (so a + * stale average can never suppress forever), and each streak stays bounded. */ +static void test_persistent_overload_probes(void) { + StereoShedPolicy p; + uint32_t sheds = 0, attempts = 0; + uint32_t run = 0, max_run = 0; + configure(&p, 30.0, 250, 8, 0.9); + uint64_t t = 0; + for (int i = 0; i < 500; i++) { /* 500 * 8 ms = 4 s of sustained overload */ + if (stereo_shed_policy_should_shed(&p, t)) { sheds++; run++; } + else { attempts++; if (run > max_run) max_run = run; run = 0; + stereo_shed_policy_note_pair(&p, 30.0, t, 1); } /* still slow */ + t += 8; + } + CHECK(sheds > 0); + CHECK(attempts > 0); /* overload does not suppress everything */ + CHECK(max_run <= 8u); /* every streak bounded by max_streak */ + CHECK(sheds <= 500u - attempts); +} + +/* 4: alternating load must not flap: a value inside the hysteresis band holds + * the current state instead of toggling every frame. */ +static void test_alternating_load_hysteresis(void) { + StereoShedPolicy p; + int transitions = 0, prev = 0; + configure(&p, 20.0, 100000, 240, 0.9); /* wide bounds: isolate hysteresis */ + uint64_t t = 0; + for (int i = 0; i < 200; i++) { + int s = stereo_shed_policy_should_shed(&p, t); + if (i && s != prev) transitions++; + prev = s; + /* alternate just under budget and inside the band (14.4..16) */ + stereo_shed_policy_note_pair(&p, (i & 1) ? 15.8 : 15.0, t, 1); + t += 8; + } + CHECK(transitions <= 2); /* entered once, at most one exit: no flapping */ +} + +/* 5: stale estimate — a long gap with no measurement must still be interrupted + * by a probe within probe_ms, and must not suppress indefinitely. */ +static void test_stale_estimate_recovers(void) { + StereoShedPolicy p; + configure(&p, 40.0, 250, 1000, 0.9); /* high avg, count bound far away */ + CHECK(stereo_shed_policy_should_shed(&p, 0) == 1); /* enter */ + CHECK(stereo_shed_policy_should_shed(&p, 8) == 1); + CHECK(stereo_shed_policy_should_shed(&p, 260) == 0); /* >= probe_ms: probe */ + CHECK(stereo_shed_policy_should_shed(&p, 260) == 1); /* fresh streak begins */ + CHECK(stereo_shed_policy_should_shed(&p, 520) == 0); /* bounded again */ +} + +/* 6: a measurement gap (e.g. OpenXR failing so render_stereo never runs) leaves + * the average untouched, but the time bound still forces a probe. */ +static void test_transient_measurement_gap(void) { + StereoShedPolicy p; + uint64_t first_probe = 0; + configure(&p, 30.0, 250, 8, 0.9); + uint64_t t = 0; + for (int i = 0; i < 200; i++) { + if (stereo_shed_policy_should_shed(&p, t) == 0) { first_probe = t; break; } + t += 8; /* no note_pair: the average is never refreshed */ + } + CHECK(first_probe > 0 && first_probe <= 256u); +} + +/* 7: a failure that aborts the attempt early is still a measurement, so the + * average falls and shedding ends (no unbounded failure loop). */ +static void test_failure_ends_streak(void) { + StereoShedPolicy p; + configure(&p, 30.0, 100000, 240, 0.9); + CHECK(stereo_shed_policy_should_shed(&p, 0) == 1); + stereo_shed_policy_note_pair(&p, 3.0, 8, 0); /* aborted early, not published */ + for (int i = 0; i < 40; i++) + stereo_shed_policy_note_pair(&p, 3.0, (uint64_t)(16 + i * 8), 0); + CHECK(stereo_shed_policy_should_shed(&p, 1000) == 0); +} + +/* 8: pair age tracks the retained pair independently of the compute estimate. */ +static void test_pair_age_separate(void) { + StereoShedPolicy p; + configure(&p, 0.0, 250, 8, 0.9); + CHECK(stereo_shed_policy_pair_age_ms(&p, 100) == 0); /* nothing published yet */ + stereo_shed_policy_note_pair(&p, 12.0, 1000, 1); + CHECK(stereo_shed_policy_pair_age_ms(&p, 1000) == 0); + CHECK(stereo_shed_policy_pair_age_ms(&p, 1050) == 50); + /* a later failed attempt must not refresh the age */ + stereo_shed_policy_note_pair(&p, 12.0, 1100, 0); + CHECK(stereo_shed_policy_pair_age_ms(&p, 1100) == 100); +} + +/* 9: reset clears the estimate so a new scene does not inherit it. */ +static void test_reset_on_transition(void) { + StereoShedPolicy p; + configure(&p, 40.0, 250, 8, 0.9); + CHECK(stereo_shed_policy_should_shed(&p, 0) == 1); + stereo_shed_policy_reset(&p); + CHECK(stereo_shed_policy_avg_ms(&p) == 0.0); + CHECK(stereo_shed_policy_should_shed(&p, 10) == 0); /* fresh, under budget */ +} + +/* 11: a pair time that settles *inside* the hysteresis band must not latch the + * shedding state. Measured case (SteamVR null HMD at 90 Hz): deadline + * 11.111 ms, pair 10.4 ms, so the band is 10.0 to 11.111. Shedding entered + * during a heavy moment and then never ended, because the band holds whatever + * state it is in and the old probe exit test was the band's lower edge. That + * cost 26 sheds/s against 3.7 published pairs/s. The probe is authoritative and + * must exit on the deadline itself. */ +static void test_band_latch_recovers(void) { + StereoShedPolicy p; + uint32_t sheds = 0; + configure(&p, 0.0, 250, 8, 0.9); + p.budget_ms = 11.111; + uint64_t t = 0; + stereo_shed_policy_note_pair(&p, 15.0, t, 1); /* heavy moment: enter shedding */ + for (int i = 0; i < 60; i++) { /* 2 s at a 33 ms frame */ + if (stereo_shed_policy_should_shed(&p, t)) { sheds++; t += 33; continue; } + stereo_shed_policy_note_pair(&p, 10.4, t, 1); /* steady state inside the band */ + t += 33; + } + CHECK(sheds <= 12u); /* one bounded streak, then rendering resumes */ + CHECK(stereo_shed_policy_avg_ms(&p) <= 11.111); +} + +/* 10: env parsing clamps to safe ranges (and PSX_STEREO_SHED=0 disables). */ +static void test_env(void) { + StereoShedPolicy p; + stereo_shed_policy_init(&p); + CHECK(p.enabled == 1 && p.max_streak >= 1u && p.probe_ms > 0.0); + stereo_shed_policy_configure_from_env(&p); /* no env set in CI: defaults hold */ + CHECK(p.max_streak >= 1u && p.max_streak <= 240u); + CHECK(p.hysteresis >= 0.5 && p.hysteresis <= 1.0); +} + +int main(void) { + test_disabled(); + test_short_spike_bounded(); + test_persistent_overload_probes(); + test_alternating_load_hysteresis(); + test_stale_estimate_recovers(); + test_transient_measurement_gap(); + test_failure_ends_streak(); + test_pair_age_separate(); + test_reset_on_transition(); + test_band_latch_recovers(); + test_env(); + if (g_fail) { printf("stereo_shed_policy: FAILED\n"); return 1; } + printf("stereo_shed_policy: all checks passed\n"); + return 0; +}