From 5507ef2f5f5a4b869470d7861ad6fec39daa1422 Mon Sep 17 00:00:00 2001 From: Roxas2712 <41802861+Roxas2712@users.noreply.github.com> Date: Mon, 26 Oct 2026 01:47:27 +0100 Subject: [PATCH] perf(P1): skip redundant software pacing after VSync presentation --- docs/frame-pacing.md | 47 +++++++++ main.lua | 23 +---- src/core/FrameCap.lua | 44 +++++++++ tests/engine/frame_pacing.lua | 153 ++++++++++++++++++++++++++++++ tools/check_frame_pacing.py | 50 ++++++++++ tools/frame-pacing-check/conf.lua | 7 ++ tools/frame-pacing-check/main.lua | 94 ++++++++++++++++++ 7 files changed, 400 insertions(+), 18 deletions(-) create mode 100644 docs/frame-pacing.md create mode 100644 tests/engine/frame_pacing.lua create mode 100644 tools/check_frame_pacing.py create mode 100644 tools/frame-pacing-check/conf.lua create mode 100644 tools/frame-pacing-check/main.lua diff --git a/docs/frame-pacing.md b/docs/frame-pacing.md new file mode 100644 index 000000000..d3b291cf2 --- /dev/null +++ b/docs/frame-pacing.md @@ -0,0 +1,47 @@ +# P1: VSync and the software deadline + +The run loop records whether it actually presented a frame. After presentation, +`FrameCap.pace` skips the software wait when VSync is active and the FPS cap is +at least the reported display refresh rate. Without presentation, with VSync +off, with an unknown rate, or with a lower FPS cap, the existing carried +deadline, one-millisecond sleep chunks and stall re-anchor remain active. + +The default-on rollback switch is independent of all other performance work: + +```lua +require("src.core.FrameCap").vsyncPacing = false +``` + +Display flags are sampled at most once per second to avoid allocating their +table each frame. Provider/VSync changes invalidate that sample; other display +rate changes are observed on the next periodic sample. Existing option values, +simulation timing, mod interfaces and callback call sites are unchanged. + +`frame pacing: sleep` in the existing F11 FrameProfile measures the helper, +including its software wait. `present (GPU wait / vsync)` retains its original +scope. Removing a redundant software wait can move wall time into present +without changing FPS. + +The owned-window probe runs the real helper and FrameProfile with the switch +off/on, each after 120 warm-up frames and over 120 measured frames. Run through +the guarded launcher: + +```sh +python3 tools/check_frame_pacing.py --love /path/to/love --report /path/to/owned/qa/report.json +``` + +An older public checkout can use `--qa-tools /path/to/fork/tools`. The launcher +pins inputs, uses a separate QA identity, retains raw profile logs and records +SHA-256 hashes of the actual measured source files alongside the Git parent. + +Observed macOS/arm64, 60-Hz display, VSync 1, cap 60: + +| Backend | Software wait off/on, ms/frame | Present off/on, ms/frame | CPU off/on, ms/frame | +| --- | ---: | ---: | ---: | +| LÖVE 11.5 OpenGL | 0.00 / 0.01 | 16.59 / 16.54 | 0.672 / 0.790 | +| LÖVE 12 Metal | 16.43 / 0.01 | 0.13 / 16.56 | 0.769 / 0.629 | + +Both enabled runs made zero extra sleep calls and stayed near 60 FPS, with no +measured interval exceeding 1.5 display periods. These short blank-window +checks establish the pacing decision and its profiler scope. They do not +establish a universal CPU/FPS gain, loaded-world behavior, or handheld results. diff --git a/main.lua b/main.lua index 18325ffdf..3b1f7c1ac 100644 --- a/main.lua +++ b/main.lua @@ -1261,12 +1261,14 @@ function love.run() closeU() end + local presented = false if love.graphics and love.graphics.isActive() then love.graphics.origin() love.graphics.clear(love.graphics.getBackgroundColor()) if love.draw then love.draw() end local closeP = FrameProfile.section("present (GPU wait / vsync)") love.graphics.present() + presented = true closeP() end -- Counted after present, so a frame in the trace means a frame that was @@ -1299,24 +1301,9 @@ function love.run() if love.timer then if paced then - -- Sleep out the remainder of the frame budget, measured from the - -- carried deadline, in small chunks so the OS timer stays - -- responsive. vsync is untouched: when it already paces slower - -- than the cap the remainder is <= 0 and this rounds to a no-op. - local budget = 1 / FrameCap.current - nextFrame = nextFrame + budget - local now = love.timer.getTime() - -- A stall (alt-tab, a GC pause, a blocked import) can leave the - -- deadline more than a full budget in the past; re-anchor to now so - -- we pace the next frame rather than burst uncapped to catch up. - if now - nextFrame > budget then - nextFrame = now - end - while true do - local remaining = nextFrame - love.timer.getTime() - if remaining <= 0 then break end - love.timer.sleep(remaining < 0.001 and remaining or 0.001) - end + local closeSleep = FrameProfile.section("frame pacing: sleep") + nextFrame = FrameCap.pace(love.timer, love.window, presented, nextFrame) + closeSleep() else love.timer.sleep(0.001) end diff --git a/src/core/FrameCap.lua b/src/core/FrameCap.lua index 2e5b36feb..09dbd7e98 100644 --- a/src/core/FrameCap.lua +++ b/src/core/FrameCap.lua @@ -25,6 +25,50 @@ FrameCap.DEFAULT = 60 -- save editor are paced before any save applies its stored option. FrameCap.current = FrameCap.DEFAULT +-- P1 rollback switch. Each frame still falls back to software pacing when +-- there was no present, VSync is off, or the display rate is unavailable. +FrameCap.vsyncPacing = true +local _refreshWindow, _refreshVsync, _refreshAt, _refreshRate +local REFRESH_POLL_SECONDS = 1 + +local function presentationPaces(window, now) + if not (window and window.getVSync and window.getMode) then return false end + local vsync = window.getVSync() + if type(vsync) ~= "number" or vsync == 0 then + _refreshVsync = vsync + return false + end + -- getMode returns a new flags table: sample it once a second rather than + -- allocate one each frame. Provider/VSync changes invalidate the sample; + -- display-rate changes are observed on the next periodic sample. + if window ~= _refreshWindow or vsync ~= _refreshVsync or not _refreshAt + or now < _refreshAt or now - _refreshAt >= REFRESH_POLL_SECONDS then + local _, _, flags = window.getMode() + local rate = type(flags) == "table" and tonumber(flags.refreshrate) + _refreshWindow, _refreshVsync, _refreshAt = window, vsync, now + _refreshRate = rate and rate > 0 and rate < math.huge and rate or nil + end + return _refreshRate ~= nil and FrameCap.current >= _refreshRate +end + +-- The run loop owns the deadline and passes back the returned value. Keeping +-- the original fallback here lets tests exercise the real pacing operation. +function FrameCap.pace(timer, window, presented, nextFrame) + local now = timer.getTime() + if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then + return now + end + local budget = 1 / FrameCap.current + nextFrame = nextFrame + budget + if now - nextFrame > budget then nextFrame = now end + while true do + local remaining = nextFrame - timer.getTime() + if remaining <= 0 then break end + timer.sleep(remaining < 0.001 and remaining or 0.001) + end + return nextFrame +end + -- Nearest valid step for an arbitrary value (a hand-edited options.lua or -- an old save with no fpsCap key), so a bad number degrades to something -- sane; nil / non-numbers fall back to the default. A value below MIN or diff --git a/tests/engine/frame_pacing.lua b/tests/engine/frame_pacing.lua new file mode 100644 index 000000000..27a96b39e --- /dev/null +++ b/tests/engine/frame_pacing.lua @@ -0,0 +1,153 @@ +local T = require("tests.harness") +local Cap = require("src.core.FrameCap") +local function clock(start) + local now, sleeps, total = start or 0, 0, 0 + return { + getTime = function() + return now + end, + sleep = function(dt) + now = now + dt + sleeps = sleeps + 1 + total = total + dt + end, + advance = function(dt) + now = now + dt + end, + sleeps = function() + return sleeps, total + end, + } +end +local function window(sync, rate) + local calls = 0 + return { + getVSync = function() + return sync + end, + getMode = function() + calls = calls + 1 + return 320, 240, { refreshrate = rate } + end, + modeCalls = function() + return calls + end, + set = function(s, r) + sync, rate = s, r + end, + } +end +Cap.current = 60 +T.eq(Cap.vsyncPacing, true, "P1 switch defaults on") +for _, sync in ipairs({ 1, -1, 2 }) do + local timer, w = clock(), window(sync, 60) + local nextFrame = 0 + for _ = 1, 60 do + timer.advance(1 / 60) + nextFrame = Cap.pace(timer, w, true, nextFrame) + end + T.eq(timer.sleeps(), 0, "active VSync at the cap receives no additional sleep") + T.eq(w.modeCalls(), 1, "display flags are not allocated each frame") +end +for _, case in ipairs({ + { sync = 0, rate = 60, presented = true }, + { sync = 1, rate = 144, presented = true }, + { sync = 1, rate = 0, presented = true }, + { sync = 1, rate = 60, presented = false }, +}) do + local timer, w = clock(), window(case.sync, case.rate) + local deadline = Cap.pace(timer, w, case.presented, 0) + T.check(timer.sleeps() > 0, "software cap remains for disabled/unknown/faster/unpresented cases") + T.check(math.abs(deadline - 1 / 60) < 1e-9, "software deadline remains one frame ahead") +end +local timer, w = clock(), window(1, 60) +Cap.vsyncPacing = false +Cap.pace(timer, w, true, 0) +T.check(timer.sleeps() > 0, "P1 switch restores software pacing") +T.eq(w.modeCalls(), 0, "disabled P1 does not query display flags") +Cap.vsyncPacing = true +timer, w = clock(), window(1, 60) +local deadline = Cap.pace(timer, w, true, 0) +w.set(0, 60) +deadline = Cap.pace(timer, w, true, deadline) +T.check(math.abs(deadline - 1 / 60) < 1e-9, "turning VSync off resumes the carried software deadline") +timer.advance(1) +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "stalls re-anchor instead of accumulating catch-up frames") +deadline = Cap.pace(timer, w, true, deadline) +T.eq(deadline, timer.getTime(), "software pacing resumes after a stall") +timer, w = clock(), window(1, 60) +Cap.pace(timer, w, true, 0) +w.set(1, 144) +timer.advance(1.01) +deadline = Cap.pace(timer, w, true, timer.getTime()) +T.check(timer.sleeps() > 0, "display rate changes refresh the software-cap decision") +T.eq(w.modeCalls(), 2, "display sample refresh is bounded") +timer = clock() +Cap.pace(timer, nil, true, 0) +T.check(timer.sleeps() > 0, "headless provider absence retains the software cap") + +-- Execute the actual run-loop body with native present/timer boundaries +-- substituted, so a correct helper wired to the wrong loop still fails. +local file = assert(io.open("main.lua", "r")) +local source = file:read("*a") +file:close() +local start = assert(source:find("function love.run()", 1, true)) +for _, active in ipairs({ true, false }) do + local fake = clock() + fake.step = function() + return 1 / 60 + end + local presents = 0 + local noop = function() end + local env = { + love = { + timer = fake, + window = window(1, 60), + graphics = { + isActive = function() + return active + end, + origin = noop, + clear = noop, + getBackgroundColor = function() + return 0, 0, 0, 1 + end, + present = function() + presents = presents + 1 + fake.advance(1 / 60 - 0.001) + end, + }, + }, + BootTrace = { mark = noop, frame = noop }, + Frames = { tick = noop }, + FrameProfile = { + section = function() + return noop + end, + }, + WindowFrame = { + draw = function(fn) + fn() + end, + }, + GraphicsStack = { drain = noop }, + pacingEnabled = function() + return true + end, + lastLogFlush = 0, + } + setmetatable(env, { __index = _G }) + local chunk + if setfenv then + chunk = assert(loadstring(source:sub(start), "@main.lua:love.run")) + setfenv(chunk, env) + else + chunk = assert(load(source:sub(start), "@main.lua:love.run", "t", env)) + end + chunk() + env.love.run()() + T.eq(presents, active and 1 or 0, "actual main loop presents only active graphics") + T.eq(fake.sleeps() > 0, not active, "actual main loop bypasses sleep only after native presentation") +end +T.finish() diff --git a/tools/check_frame_pacing.py b/tools/check_frame_pacing.py new file mode 100644 index 000000000..952e314da --- /dev/null +++ b/tools/check_frame_pacing.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Run owned-window VSync pacing measurements through the local QA guard.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--love', type=Path, required=True) + parser.add_argument('--report', type=Path, required=True) + parser.add_argument('--qa-tools', type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + sys.path.insert(0, str(args.qa_tools or root / 'tools')) + try: + from qa_storage import qa_source, qa_cache, qa_profile, qa_run + except ImportError: + parser.error('QA storage guard is required; provide --qa-tools') + qa_source(root) + qa_source(args.love.resolve()) + qa_cache(args.report.parent) + args.report.parent.mkdir(parents=True, exist_ok=True) + identity = 'oasc-gen2recomp-qa-frame-pacing-' + str(time.time_ns()) + qa_profile(Path.home() / 'Library/Application Support/LOVE' / identity) + result = qa_run([str(args.love.resolve()), str(root / 'tools/frame-pacing-check')], + cwd=root, env=dict(os.environ, POKEPORT_IDENTITY=identity, + FRAME_PACING_REPO=str(root), FRAME_PACING_QA_GUARDED='1'), + capture_output=True, text=True, timeout=90) + args.report.with_suffix('.log').write_text(result.stdout + result.stderr) + records = [line.removeprefix('P1_NATIVE_RESULT ') for line in result.stdout.splitlines() + if line.startswith('P1_NATIVE_RESULT ')] + if result.returncode or len(records) != 1: + raise RuntimeError('native pacing probe failed; see ' + str(args.report.with_suffix('.log'))) + report = json.loads(records[0]) + report['source'] = subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=root, text=True).strip() + files = ('main.lua', 'src/core/FrameCap.lua', 'src/core/FrameProfile.lua', + 'tools/frame-pacing-check/main.lua', 'tools/frame-pacing-check/conf.lua') + report['sha256'] = {name: hashlib.sha256((root / name).read_bytes()).hexdigest() for name in files} + args.report.write_text(json.dumps(report, indent=2) + '\n') + print(json.dumps(report, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/tools/frame-pacing-check/conf.lua b/tools/frame-pacing-check/conf.lua new file mode 100644 index 000000000..79551e6d7 --- /dev/null +++ b/tools/frame-pacing-check/conf.lua @@ -0,0 +1,7 @@ +function love.conf(t) + assert(os.getenv("FRAME_PACING_QA_GUARDED") == "1", "use tools/check_frame_pacing.py") + t.identity = assert(os.getenv("POKEPORT_IDENTITY")) + t.window.title = "Frame pacing QA" + t.window.width, t.window.height, t.window.vsync = 320, 180, 1 + t.modules.audio = false +end diff --git a/tools/frame-pacing-check/main.lua b/tools/frame-pacing-check/main.lua new file mode 100644 index 000000000..e280a2d74 --- /dev/null +++ b/tools/frame-pacing-check/main.lua @@ -0,0 +1,94 @@ +local root = assert(os.getenv("FRAME_PACING_REPO")) +package.path = root .. "/?.lua;" .. root .. "/?/init.lua;" .. package.path +local Cap = require("src.core.FrameCap") +local Profile = require("src.core.FrameProfile") +local Json = require("src.link.Json") +local phase, frame, deadline, last, started, cpuStarted = 1, 0, 0, 0, 0, 0 +local sleeps, intervals, results = 0, {}, {} +local WINDOW, WARMUP = 120, 120 +local timer = { + getTime = love.timer.getTime, + sleep = function(dt) + sleeps = sleeps + 1 + love.timer.sleep(dt) + end, +} +local _, _, mode = love.window.getMode() +local refresh = tonumber(mode.refreshrate) or 0 +Cap.current = refresh > 0 and refresh or 60 +local function startPhase() + Profile.setEnabled(false) + Cap.vsyncPacing = phase == 2 + frame, sleeps, intervals = 0, 0, {} + deadline = timer.getTime() +end +local function finishPhase() + local long = 0 + for _, interval in ipairs(intervals) do + if refresh > 0 and interval > 1.5 / refresh then + long = long + 1 + end + end + table.sort(intervals) + results[phase] = { + enabled = Cap.vsyncPacing, + frames = WINDOW, + seconds = timer.getTime() - started, + medianMs = intervals[math.floor(#intervals / 2)] * 1000, + p95Ms = intervals[math.ceil(#intervals * 0.95)] * 1000, + maxMs = intervals[#intervals] * 1000, + framesOver1_5Periods = long, + extraSleepCalls = sleeps, + processCpuMsPerFrame = (os.clock() - cpuStarted) / WINDOW * 1000, + } + if phase == 2 then + if refresh > 0 and love.window.getVSync() ~= 0 then + assert(sleeps == 0, "VSync phase still slept") + end + print("P1_NATIVE_RESULT " .. Json.encode({ + refresh = refresh, + vsync = love.window.getVSync(), + cap = Cap.current, + os = love.system.getOS(), + arch = jit and jit.arch or "unknown", + renderer = love.graphics.getRendererInfo(), + phases = results, + })) + return true + end + phase = 2 + startPhase() +end +function love.run() + startPhase() + return function() + love.event.pump() + for name in love.event.poll() do + if name == "quit" then + return 1 + end + end + if frame == WARMUP then + sleeps = 0 + Profile.setEnabled(true) + started, last = timer.getTime(), timer.getTime() + cpuStarted = os.clock() + end + love.graphics.clear(0.03, 0.04, 0.06) + local close = Profile.section("present (GPU wait / vsync)") + love.graphics.present() + close() + close = Profile.section("frame pacing: sleep") + deadline = Cap.pace(timer, love.window, true, deadline) + close() + frame = frame + 1 + if frame > WARMUP then + local now = timer.getTime() + intervals[#intervals + 1], last = now - last, now + Profile.frame() + end + if frame == WARMUP + WINDOW and finishPhase() then + return 0 + end + end +end