From d0cdcfa81d8e170dafb63e0af0ff30256053f565 Mon Sep 17 00:00:00 2001 From: Shokunin <30949000+tetrisgm@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:34:12 -0700 Subject: [PATCH] Pace bounded HiW output on the existing presenter clock --- docs/internal/FAITHFUL_TIMING_PLAN.md | 16 +++++ runtime/CMakeLists.txt | 6 +- runtime/include/frame_interpolation.h | 3 + runtime/include/gpu_gl_renderer.h | 8 +++ runtime/src/frame_interpolation.c | 2 +- runtime/src/gpu_gl_renderer.c | 71 +++++++++++++++++-- runtime/src/main.cpp | 49 +++++++++++-- runtime/tests/run_gl_render_thread.py | 3 +- .../test_frame_interpolation_flip_source.py | 4 +- .../tests/test_frame_interpolation_schedule.c | 19 +++++ runtime/tests/test_gl_render_thread.c | 16 +++++ 11 files changed, 180 insertions(+), 17 deletions(-) diff --git a/docs/internal/FAITHFUL_TIMING_PLAN.md b/docs/internal/FAITHFUL_TIMING_PLAN.md index 580674e0..a71aa36e 100644 --- a/docs/internal/FAITHFUL_TIMING_PLAN.md +++ b/docs/internal/FAITHFUL_TIMING_PLAN.md @@ -2055,3 +2055,19 @@ R4 recorded-route production probe improves 53.371 to 57.472 guest Hz; zero-gap acceptance remains open. Matched GPU completion probes also reject a separate depth shader (no improvement); that experiment was removed. See title analysis/render-60fps/SLOWDOWNS.md for receipts and limitations. + + +### 2026-10-21: Bounded asynchronous presentation pacing + +Publish drawable sizes from SDL3 pixel-size events instead of querying window +properties per frame. Batch SPSC queue consumption/accounting on separated +producer/consumer cache lines. For HiW hold output at guest rate, move deadline +waits to the existing presenter; its bounded queue paces the guest without a +second independent timer. Queued scheduling preserves early producer anchors. +Guest device timing and quality settings are unchanged. Hardware image/mask, +resize and scheduling controls pass. R4 Release default Ultra recorded drives +cover 60 seconds at 60.000 guest Hz, p99 emulation busy 52.3% idle / 56.4% with +23.61% total host CPU background load; zero presented gaps >25 ms, maxima +17.286/17.567 ms. Internal callback/completion gaps are separately reported and +are not claimed to pass that limit. See title SLOWDOWNS.md for attribution, +receipts, and clock definitions. No release or main-branch landing. diff --git a/runtime/CMakeLists.txt b/runtime/CMakeLists.txt index 9ca7b180..e8dd9946 100644 --- a/runtime/CMakeLists.txt +++ b/runtime/CMakeLists.txt @@ -365,7 +365,7 @@ if(BUILD_TESTING) --output "${CMAKE_CURRENT_BINARY_DIR}/gl-render-thread-results") set_tests_properties(gl_render_thread_test PROPERTIES LABELS "gpu;opengl;hardware" TIMEOUT 300 SKIP_RETURN_CODE 77) - foreach(_interp_mode normal hiw wide_quality hiw_wide_quality) + foreach(_interp_mode normal hiw wide_quality hiw_wide_quality hiw_wide_quality_present) set(_interp_args --interpolation) if(_interp_mode MATCHES "wide_quality") list(APPEND _interp_args --wide-present --quality) @@ -384,6 +384,10 @@ if(BUILD_TESTING) set_tests_properties(gl_render_thread_interpolation_${_interp_mode}_test PROPERTIES ENVIRONMENT "PSX_GL_HIRES_WINDOW=1") endif() + if(_interp_mode MATCHES "_present$") + set_tests_properties(gl_render_thread_interpolation_${_interp_mode}_test + PROPERTIES ENVIRONMENT "PSX_GL_HIRES_WINDOW=1;PSX_GL_TEST_PRESENT_THREAD=1") + endif() endforeach() # Frame generation ([video] frame_generation): the real presented # images are identical with it off and on (VRAM and native-wide diff --git a/runtime/include/frame_interpolation.h b/runtime/include/frame_interpolation.h index 4e80efc5..57b92049 100644 --- a/runtime/include/frame_interpolation.h +++ b/runtime/include/frame_interpolation.h @@ -17,6 +17,9 @@ typedef struct FrameInterpolationSchedule { * plans the whole 0..1 window, which is the historical behaviour. */ double phase_lo; double phase_hi; + /* Bounded asynchronous producer: preparing several frames ahead is normal, + * so an early arrival must not reset the presentation clock. */ + int queued; } FrameInterpolationSchedule; /* Plan one stock guest-frame interval. Presentation deadlines remain anchored diff --git a/runtime/include/gpu_gl_renderer.h b/runtime/include/gpu_gl_renderer.h index 29502940..fd40b8c3 100644 --- a/runtime/include/gpu_gl_renderer.h +++ b/runtime/include/gpu_gl_renderer.h @@ -157,6 +157,8 @@ void gl_renderer_stereo_diag(GLRenderStereoDiag *out); uint64_t gl_renderer_perf_ticks(void); uint64_t gl_renderer_perf_frequency(void); int gl_renderer_interpolation_owns_cadence(void); +/* Nonblocking, atomically published readiness for bounded queued pacing. */ +int gl_renderer_interpolation_async_cadence(void); void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, double *host_hz, double *target_hz, @@ -576,6 +578,12 @@ typedef struct { uint64_t prior_ticks[8], prior_max_ticks[8]; } GlRenderWorkStats; void gl_renderer_work_stats(GlRenderWorkStats *out); +/* Opt-in replay receipt: actual SwapWindow return times, process-clock us. + * Query outside measurement: drains the render/present queues. */ +#define GL_SWAP_TIMING_CAP 65536 +typedef uint64_t (*GlSwapClockFn)(void); +void gl_renderer_swap_timing_start(GlSwapClockFn clock); +int gl_renderer_swap_timestamps(uint64_t *out, int cap); /* Native-wide mirror ablation (perf attribution, debug cmd gl_ws_ablate): * 0 = normal, 1 = skip the whole mirror pass (incl. wide_clear), 2 = full mirror diff --git a/runtime/src/frame_interpolation.c b/runtime/src/frame_interpolation.c index 0b4576ed..de760e07 100644 --- a/runtime/src/frame_interpolation.c +++ b/runtime/src/frame_interpolation.c @@ -45,7 +45,7 @@ int frame_interpolation_schedule_begin_phase(FrameInterpolationSchedule *schedul * Normal guest work lands after the prior deadline but before the next one; * retaining the old anchor makes that work consume the pacing budget. */ if (schedule->source_deadline <= 0.0 || - now_d + source_period < schedule->source_deadline || + (!schedule->queued && now_d + source_period < schedule->source_deadline) || now_d > schedule->source_deadline + source_period * 4.0) { schedule->frame_start = now_d; schedule->source_deadline = now_d + source_period; diff --git a/runtime/src/gpu_gl_renderer.c b/runtime/src/gpu_gl_renderer.c index a6a40cf6..6fbfbd26 100644 --- a/runtime/src/gpu_gl_renderer.c +++ b/runtime/src/gpu_gl_renderer.c @@ -746,6 +746,8 @@ static double s_interp_host_hz = 0.0; static double s_interp_target_hz = 0.0; static double s_interp_source_hz = 0.0; static FrameInterpolationSchedule s_interp_schedule; +static uint64_t s_interp_pending_deadline; +static _Atomic int s_interp_async_cadence; /* Blend source (psx_mod_set_frame_interpolation_source). VBLANK (0, the * default) treats every guest VBlank present as a new frame, exactly as * before. FLIP (1) rotates history only on a real flip -- the display origin @@ -8063,6 +8065,7 @@ void gl_renderer_set_ws_ablate(int mode) { int gl_renderer_get_ws_ablate(void) { return s_ws_ablate; } static void interp_reset_history_unlocked(void) { + atomic_store_explicit(&s_interp_async_cadence, 0, memory_order_release); s_interp_valid = 0; s_interp_w = s_interp_h = 0; s_interp_src_w = s_interp_src_h = 0; @@ -8158,6 +8161,10 @@ int gl_renderer_interpolation_owns_cadence(void) { return s_ctx && s_interp_enabled && !interp_suspended_now(); } +int gl_renderer_interpolation_async_cadence(void) { + return atomic_load_explicit(&s_interp_async_cadence, memory_order_acquire); +} + void gl_renderer_interpolation_diag(int *enabled, int *suspended, int *history_frames, double *host_hz, double *target_hz, @@ -8332,7 +8339,7 @@ static int interp_present(float alpha) { s_interp_blend_mode); } -static void interp_wait_until(uint64_t deadline, uint64_t frequency) { +static void interp_wait_until_ex(uint64_t deadline, uint64_t frequency, int render_idle) { uint64_t now, start; if (!deadline || !frequency) return; start = SDL_GetPerformanceCounter(); @@ -8340,8 +8347,10 @@ static void interp_wait_until(uint64_t deadline, uint64_t frequency) { now = SDL_GetPerformanceCounter(); if (now >= deadline) { /* Idle host time feeds the render-pass budget. */ - (void)s_idle_ticks_accum_fwd(now - start); - if (s_dyn_on) s_dyn_ledger.idle_ticks += now - start; + if (render_idle) { + (void)s_idle_ticks_accum_fwd(now - start); + if (s_dyn_on) s_dyn_ledger.idle_ticks += now - start; + } return; } uint64_t remain = deadline - now; @@ -8354,8 +8363,28 @@ static void interp_wait_until(uint64_t deadline, uint64_t frequency) { } } +static void interp_wait_until(uint64_t deadline, uint64_t frequency) { + interp_wait_until_ex(deadline, frequency, 1); +} +static int hiw_async_pacing(void) { + static int enabled = -1; + if (enabled < 0) { + const char *e = getenv("PSX_GL_HIW_ASYNC_PACE"); + enabled = !(e && e[0] == '0'); + } + /* HiW refuses temporal render transactions already. Hold at guest rate + * needs one composed output per VBlank, with its wait on the presenter so + * the renderer can prepare the next scene. Other modes retain their render + * pass/idle-credit schedule unchanged. */ + return enabled && s_hiw && s_interp_hold && s_pt_on && s_rth_on && + rt_on_render_thread() && s_interp_target_hz <= s_interp_source_hz + 1e-6; +} + static void interp_present_source_interval_timed_impl(void) { static uint64_t diag_start, diag_swaps, diag_captures; + const int asynchronous = hiw_async_pacing(); + s_interp_schedule.queued = asynchronous; + atomic_store_explicit(&s_interp_async_cadence, asynchronous, memory_order_release); uint64_t frequency = SDL_GetPerformanceFrequency(); uint64_t now = SDL_GetPerformanceCounter(); uint64_t deadline; @@ -8371,14 +8400,16 @@ static void interp_present_source_interval_timed_impl(void) { while (frame_interpolation_schedule_next( &s_interp_schedule, SDL_GetPerformanceCounter(), &deadline, &alpha)) { - interp_wait_until(deadline, frequency); + if (!asynchronous) interp_wait_until(deadline, frequency); + s_interp_pending_deadline = asynchronous ? deadline : 0; latency_ring_mark(LAT_SWAP_BEGIN); if (!pass_gen_present(deadline)) (void)interp_present(alpha); latency_ring_mark(LAT_SWAP_END); + s_interp_pending_deadline = 0; } - interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule), - frequency); + if (!asynchronous) + interp_wait_until(frame_interpolation_schedule_end(&s_interp_schedule), frequency); now = SDL_GetPerformanceCounter(); if (!diag_start) { @@ -10113,6 +10144,7 @@ static int s_pt_want = 0, s_pt_slots = 3; static GLenum s_pt_format = GL_RGBA8; /* the window's: no alpha -> GL_RGB8 */ static SDL_GLContext s_pt_ctx = NULL; static GLuint s_pts_tex[PT_MAX_SLOTS], s_pts_rb[PT_MAX_SLOTS], s_pts_fbo[PT_MAX_SLOTS]; +static uint64_t s_pts_deadline[PT_MAX_SLOTS]; static int s_pts_w[PT_MAX_SLOTS], s_pts_h[PT_MAX_SLOTS]; static unsigned s_pts_gen[PT_MAX_SLOTS]; /* present thread's own objects (FBOs are per-context) */ @@ -10181,6 +10213,8 @@ static int pt_cb_ctx(void *user, int current) { /* The one owned swap call: the composing thread's (direct) or the present * thread's. */ +static uint64_t s_swap_times[GL_SWAP_TIMING_CAP], s_swap_times_n; +static _Atomic(GlSwapClockFn) s_swap_clock; static void gl_window_swap(void) { #if defined(PSX_SDL3) psx_video_telemetry_present(SDL_GL_SwapWindow(s_win) ? 1 : 0); @@ -10188,6 +10222,27 @@ static void gl_window_swap(void) { SDL_GL_SwapWindow(s_win); psx_video_telemetry_present(-1); // SDL2 exposes no swap result. #endif + GlSwapClockFn clock = atomic_load_explicit(&s_swap_clock, memory_order_acquire); + if (clock) s_swap_times[s_swap_times_n++ % GL_SWAP_TIMING_CAP] = clock(); +} + +void gl_renderer_swap_timing_start(GlSwapClockFn clock) { + GL_RT_SYNC("swap_timing_start"); + if (s_pt_on) pt_drain(); + s_swap_times_n = 0; + atomic_store_explicit(&s_swap_clock, clock, memory_order_release); +} + +int gl_renderer_swap_timestamps(uint64_t *out, int cap) { + if (!out || cap <= 0) return 0; + GL_RT_SYNC("swap_timestamps"); + if (s_pt_on) pt_drain(); + /* Context handoff plus presenter drain leave the single swap writer idle. */ + uint64_t n = s_swap_times_n < GL_SWAP_TIMING_CAP ? s_swap_times_n : GL_SWAP_TIMING_CAP; + if (n > (uint64_t)cap) n = (uint64_t)cap; + uint64_t first = s_swap_times_n - n; + for (uint64_t i=0; i g_replay_work; +static void input_replay_frame_completed(uint64_t frame) { + if (g_replay_work.empty()) return; + auto &sample = g_replay_work[frame % HOST_FRAME_STAMP_CAP]; + if (sample.frame == frame) sample.completed = host_clock_us(); +} static void input_replay_frontend_tick(uint64_t frame) { uint64_t relative = input_replay_frame(frame); if (relative == UINT64_MAX) return; static uint64_t stop = 0, seconds = 0, deadline_us = 0; static const char *report = nullptr, *shots = nullptr; static bool initialized = false, finished = false; - struct WorkSample { uint64_t frame, guest, pacer, queue; }; - static std::vector work; + auto &work = g_replay_work; if (!initialized) { if (const char *e = std::getenv("PSX_INPUT_REPLAY_STOP")) stop = std::strtoull(e, nullptr, 10); if (const char *e = std::getenv("PSX_INPUT_REPLAY_SECONDS")) { @@ -5011,7 +5028,10 @@ static void input_replay_frontend_tick(uint64_t frame) { } report = std::getenv("PSX_INPUT_REPLAY_REPORT"); shots = std::getenv("PSX_INPUT_REPLAY_SHOTS"); - if (report && *report) work.resize(HOST_FRAME_STAMP_CAP); + if (report && *report) { + work.resize(HOST_FRAME_STAMP_CAP); + if (g_gl_active) gl_renderer_swap_timing_start(host_clock_us); + } initialized = true; } if (finished) return; @@ -5024,7 +5044,7 @@ static void input_replay_frontend_tick(uint64_t frame) { const uint64_t hz = SDL_GetPerformanceFrequency(); work[frame % HOST_FRAME_STAMP_CAP] = {frame, g_runtime_perf.guest_work_ticks * 1000000 / hz, g_runtime_perf.pacer_ticks * 1000000 / hz, - (rs.backpressure_ns + rs.ring_full_ns + rs.acquire_ns) / 1000}; + (rs.backpressure_ns + rs.ring_full_ns + rs.acquire_ns) / 1000, 0}; } if (shots && (relative == 800 || (stop > 0 && relative == stop+2))) { char path[512]; @@ -5036,7 +5056,7 @@ static void input_replay_frontend_tick(uint64_t frame) { if (report && *report) { FILE *f = std::fopen(report, "w"); if (!f) throw std::runtime_error("cannot write input replay frame report"); - std::fputs("frame,relative,us,interp,checks,faults,io_kb,guest_us,pacer_us,queue_us\n", f); + std::fputs("frame,relative,us,interp,checks,faults,io_kb,guest_us,pacer_us,queue_us,completed_us\n", f); uint64_t total = host_frame_stamp_total(); uint64_t first = total > HOST_FRAME_STAMP_CAP ? total-HOST_FRAME_STAMP_CAP : 0; for (uint64_t i=first; i swaps(GL_SWAP_TIMING_CAP); + const int swap_count = gl_renderer_swap_timestamps(swaps.data(), (int)swaps.size()); + std::string swap_path = std::string(report) + ".present.csv"; + f = std::fopen(swap_path.c_str(), "w"); + if (!f) throw std::runtime_error("cannot write input replay presentation report"); + std::fputs("us\n", f); + for (int i=0; i= 2 && st.acquires <= (uint64_t)frames + 4, "acquires bounded"); check(st.bytes > ((uint64_t)32 << 20), "ring wrapped (more than its size recorded)"); } + { + uint64_t times[256], tail[2]; + int count = gl_renderer_swap_timestamps(times, 256); + check(count > 2, "swap completion timestamps captured"); + for (int i=1; i= times[i-1], "swap clock is monotonic"); + check(gl_renderer_swap_timestamps(tail, 2) == 2 && + tail[0] == times[count-2] && tail[1] == times[count-1], + "bounded swap query returns newest entries"); + } if (drawable_events_enabled()) check(s_drawable_queries == drawable_queries_before, "no per-frame drawable queries"); /* Pixel-size events publish resizes without querying the window on a