Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions docs/frame-pacing.md
Original file line number Diff line number Diff line change
@@ -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.
51 changes: 51 additions & 0 deletions docs/gc-frame-budget.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# P2: incremental GC in unused deadline time

`Performance.gcBudget` defaults to `true`. At boot the collector uses pause 120
and step multiplier 300. Each concrete `Performance.CAPS` row additionally has
`gcPause`, `gcStepMul` and `gcBudgetSeconds`; tiers may reduce the default
0.0005-second idle allowance. No options are rewritten.

The ordinary engine's fixed `step(1)` moves out of `Game:update`. The run loop
uses no-argument incremental steps before its remaining software wait. It starts
no further step after the earlier of the actual deadline and the half-millisecond
allowance, and stops on completion of a cycle. An expired deadline or a stopped
collector receives no work. VSync never gets an invented future budget; only
the actual remainder of its carried deadline is eligible, and no extra sleep
is added after VSync.

The budget is cooperative: a single indivisible collector step can finish past
the boundary. It is not a preemption guarantee for an arbitrary heap/finalizer.
The F11 child section `frame pacing: gc budget` is nested in the existing
`frame pacing: sleep` section, so the parent includes collection plus waiting.

The fork's pre-existing Gen3 `Game3.stepGC` delegation stays at its original
update boundary with the original arguments/behavior. A frame owned by that
public seam gets no second idle GC service. Its delegated/custom work remains
outside the engine idle allowance. This preserves consumers that replace it.

The independent rollback switch is:

```lua
require("src.core.Performance").gcBudget = false
```

The next frame restores the previous pause/multiplier and uses the old native
update step. The separate P1 switch and callback interfaces are unchanged.

`tools/check_frame_pacing.py --gc --love /path/to/love --report /owned/qa/gc.json`
runs the guarded native comparison. P1 stays enabled for both phases; only P2
changes. The workload allocates 512 short tables per frame, with 120 warm-up
and 120 measured frames per phase. Raw FrameProfile logs, original tuning,
heap samples and measured-source SHA-256 hashes are retained.

Observed composed fork, macOS/arm64, 60 Hz, VSync 1, cap 60:

| Backend | Original fixed step, ms/frame | New idle mean / max, ms | Heap peak before / after, KiB |
| --- | ---: | ---: | ---: |
| LÖVE 11.5 OpenGL | 0.00 (rounded) | 0.098 / 0.499 | 918 / 775 |
| LÖVE 12 Metal | 0.00 (rounded) | 0.120 / 0.278 | 1,179 / 692 |

Short-workload CPU means were 1.012 → 0.920 ms/frame and 0.717 → 0.651 ms/frame,
respectively. Both retained approximately 60 FPS and restored the original
200/200 tuning when disabled. These are bounded synthetic allocation checks,
not full-game heap, long-session, arbitrary-finalizer or handheld measurements.
27 changes: 9 additions & 18 deletions main.lua
Original file line number Diff line number Diff line change
Expand Up @@ -1208,6 +1208,9 @@ end

function love.run()
BootTrace.mark("love.run enter")
local Performance = require("src.core.Performance")
local GCFrameBudget = require("src.core.GCFrameBudget")
GCFrameBudget.configure(Performance)
if love.load then love.load(love.arg.parseGameArguments(arg), arg) end
BootTrace.mark("love.load returned")

Expand Down Expand Up @@ -1245,6 +1248,7 @@ function love.run()

-- update dt
if love.timer then dt = love.timer.step() end
GCFrameBudget.beginFrame(Performance)

-- call update and draw
--
Expand All @@ -1261,12 +1265,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
Expand Down Expand Up @@ -1299,24 +1305,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, GCFrameBudget.idle)
closeSleep()
else
love.timer.sleep(0.001)
end
Expand Down
49 changes: 49 additions & 0 deletions src/core/FrameCap.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,55 @@ 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, idleWork)
local now = timer.getTime()
local budget = 1 / FrameCap.current
nextFrame = nextFrame + budget
if FrameCap.vsyncPacing and presented and presentationPaces(window, now) then
if idleWork then
idleWork(timer, nextFrame)
return timer.getTime()
end
return now
end
if now - nextFrame > budget then nextFrame = now end
if idleWork then idleWork(timer, nextFrame) 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
Expand Down
86 changes: 86 additions & 0 deletions src/core/GCFrameBudget.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
-- Cooperative incremental collection in otherwise unused frame time.
local Profile = require("src.core.FrameProfile")
local Budget = {}
local MAX_SECONDS = 0.0005
local _collector, _priorPause, _priorMul, _pause, _mul
local _enabled, _seconds, _canQueryRunning, _facadeStepped = false, 0, false, false

local function nonnegative(value, fallback)
if type(value) ~= "number" or value ~= value or value < 0 or value == math.huge then
return fallback
end
return value
end

local function restoreTuning()
if _collector and _priorPause ~= nil then
_collector("setpause", _priorPause)
_collector("setstepmul", _priorMul)
end
_collector, _priorPause, _priorMul, _pause, _mul = nil, nil, nil, nil, nil
end

function Budget.configure(performance, collector)
collector = collector or collectgarbage
if not performance.gcBudget or type(collector) ~= "function" then
restoreTuning()
_enabled = false
return
end
if _collector ~= collector then
restoreTuning()
end
local caps = performance.CAPS[performance.tier] or {}
local pause = math.floor(nonnegative(caps.gcPause, performance.GC_PAUSE))
local mul = math.floor(nonnegative(caps.gcStepMul, performance.GC_STEP_MUL))
if not _collector then
_collector = collector
_priorPause = collector("setpause", pause)
_priorMul = collector("setstepmul", mul)
_canQueryRunning = pcall(collector, "isrunning")
else
if pause ~= _pause then
collector("setpause", pause)
end
if mul ~= _mul then
collector("setstepmul", mul)
end
end
_pause, _mul = pause, mul
_seconds = math.min(MAX_SECONDS, nonnegative(caps.gcBudgetSeconds, performance.GC_BUDGET_SECONDS))
_enabled = true
end

function Budget.beginFrame(performance, collector)
_facadeStepped = false
Budget.configure(performance, collector)
end

-- An existing public compatibility method retains ownership of its frame.
-- Its call, arguments and return behavior are not replaced by idle collection.
function Budget._facadeHandled()
_facadeStepped = true
end

function Budget.idle(timer, deadline)
if not _enabled or _facadeStepped or _seconds <= 0 then
return 0
end
local start = timer.getTime()
local stop = math.min(deadline, start + _seconds)
if stop <= start or (_canQueryRunning and not _collector("isrunning")) then
return 0
end
local close = Profile.section(" frame pacing: gc budget")
local steps = 0
while timer.getTime() < stop do
steps = steps + 1
if _collector("step") then
break
end
end
close()
return steps
end

return Budget
19 changes: 8 additions & 11 deletions src/core/Game.lua
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ local Renderer = require("src.render.Renderer")
local SaveData = require("src.core.SaveData")
local StateStack = require("src.core.StateStack")
local FrameProfile = require("src.core.FrameProfile")
local Performance = require("src.core.Performance")
local GCFrameBudget = require("src.core.GCFrameBudget")
local TouchControls = require("src.core.TouchControls")
local ModLoader = require("src.mods.Loader")
local ModRuntime = require("src.mods.Runtime")
Expand Down Expand Up @@ -635,19 +637,14 @@ function Game:update(dt)
pcall(function() require("src.core.DiscordPresence").update(dt) end)
close()
end
-- Steady-state memory backstop: advance the incremental collector one
-- small step every rendered frame. The heavy GPU objects are now freed
-- explicitly (map eviction, battle exit, canvas/renderer swaps), so this
-- only has to keep ordinary Lua-heap garbage (per-frame tables/closures)
-- from drifting upward over a long session, and to spread collection out
-- so the default lazy schedule never batches it into a visible pause.
-- ONE INCREMENTAL GC STEP PER FRAME. Cheap on a small heap and not
-- obviously cheap under a mod that allocates a world's worth of geometry,
-- which is exactly the case being measured -- so it is named rather than
-- assumed.
-- Keep an already-loaded native GC delegation at its update boundary.
-- Ordinary engine collection moves to spare deadline time in love.run.
if collectgarbage then
local close = FrameProfile.section(" update: gc step")
collectgarbage("step", 1)
local facade = package.loaded["src.core.Game3"]
local handled = type(facade) == "table" and type(facade._stepGC) == "function" and facade._stepGC(self)
if handled then GCFrameBudget._facadeHandled() end
if not handled and not Performance.gcBudget then collectgarbage("step", 1) end
close()
end
end
Expand Down
12 changes: 12 additions & 0 deletions src/core/Performance.lua
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@

local Performance = {}

-- P2 is independently reversible. Tier rows can reduce the idle budget or
-- tune the collector without changing the persisted presentation options.
Performance.gcBudget = true
Performance.GC_PAUSE = 120
Performance.GC_STEP_MUL = 300
Performance.GC_BUDGET_SECONDS = 0.0005

-- Option-row order (auto first, then most to least capable).
Performance.TIERS = { "auto", "high", "balanced", "low" }

Expand All @@ -60,6 +67,11 @@ Performance.CAPS = {
balanced = { tilt = false, gbcfx = false, survey = true, fpsMax = false },
low = { tilt = false, gbcfx = false, survey = false, fpsMax = 60 },
}
for _, caps in pairs(Performance.CAPS) do
caps.gcPause = Performance.GC_PAUSE
caps.gcStepMul = Performance.GC_STEP_MUL
caps.gcBudgetSeconds = Performance.GC_BUDGET_SECONDS
end

-- Live resolved tier (never "auto"); Game:applyOptions sets it and the
-- renderer / options row read it back. Defaults to the unclamped tier so
Expand Down
Loading