Files
gen1recomp/tests/engine/worker_lane_test.lua
T
Shane McGovern 7e2307f58c Use spare CPU cores to remove in-game hitches on low-power devices (#2872)
* Use spare CPU cores to remove in-game hitches on low-power devices

Per-frame game logic and drawing are cheap; the remaining stalls on
low-power handhelds are one-off main-thread hitches from chip SFX/cry
synthesis, RED++ atlas recolouring, image decodes and the require shim.
Move those onto background threads, each with a synchronous fallback so
behaviour is identical when threads are unavailable (POKEPORT_NO_THREAD=1,
no love.thread, worker failure).

Also adds an F3 frame-time overlay and POKEPORT_GAME_PROF headless
profiling to measure these costs.

* Address code review: shared WorkerLane, F3 gating, decode/epoch fixes

- Extract src/core/WorkerLane.lua (worker lifecycle, prioritised lanes,
  per-lane epochs, bounded LRU) and rebuild the SFX and atlas/decode
  prefetchers on it
- Keep decoded sprite sheets in every colour mode (the OBJ palette bake
  re-reads them), skip oversized decodes, and let palette changes drop
  only atlas bakes
- Only bump the SFX render epoch when the channel mix actually changes
- F3 overlay toggle no longer steals the key from rebinding screens or
  player bindings; profilers stop only when a quit really proceeds
- Require fast path: test hook instead of a hot-path counter
- Shared tests/drivers/perf_util.lua; drivers restore their patches

* WorkerLane: no budget leak on duplicate sends, keep priority on requeue

- Lane:pump only counts a job against the in-flight budget once per key
- requeueInflight puts entries back at the head of their own queue, so
  background hints no longer overtake front requests after a bump
- shutdown() keeps a fatally failed worker off
- Hiding the F3 overlay logs one summary line instead of a full block

* F3 overlay: last-120-frame stats, font-sized box, scale on large screens

* Prefetch: skip useless hints, no main-thread fallback, mix check vs worker config

- Don't queue decodes for sprite sheets whose Image and OBJ bake are cached
- Atlas hint is dropped (sync bake at build time) when the worker can't
  decode the tileset itself, instead of decoding on the main thread
- SFX epoch bump compares the live mix with what the worker was last
  configured with, replacing the load-time snapshot

* Sprite decode skip: raw-path cache check, never under RED++

* Gen 2 battles prefetch both parties' cries; sprite skip keyed by raw path

* Prefetch: skip cached tilesets outside RED++, log hint failures once

* Profiler: bench survives F3 hide, frame total excludes vsync; safer SFX pump, ordered requeue, tileset skip respects pending variants

* Profiler counts GPU stalls in present; water void-fill keeps tileset hint; README + test coverage

* Profiler vsync cap: measured cadence via RefreshRate, no per-frame allocation

* Use upstream's chip-worker prewarm for SFX/cries; drop our separate SFX worker

Our extra coverage (Gen 1 common SFX + party cries, both parties' battle
cries, lead move sounds, hit and battle SFX, Gen 2 bench cries) now goes
through ChipAudio.prewarmSfx/prewarmCry and a new Sound.prewarmMove.

* Skip decode hints for composed assets; share move cache key; drop dead demoteAll

* Drop dev-only profiling extras: POKEPORT_JITP hook and perf drivers

* README: name the worker threads POKEPORT_JIT leaves alone

* Profiler: count the pre-present vblank wait as present, log on NX quit

- time PresentSync.waitBeforePresent as "present" (the X11 GLX vblank wait
  lands there), so it is excluded like the wait inside present
- NX quit now emits the PROF summary / overlay line too
- love.quit's scripted check reads FrameProfiler.benchRequested, so
  POKEPORT_GAME_PROF=0 no longer counts as a bench run
- prefetchAtlas skips composed tileset sheets (Assets.isComposed)
- hit sounds defined once in EffectRegistry.HIT_SOUNDS, shared with the
  battle prewarm (each hit still gets its own copy)
- drop redundant obpPaths, dead FrameProfiler.visible and the unused
  Lane:bump requeue mode; restore applyOptions' doc comment
2026-10-12 08:38:45 +01:00

306 lines
11 KiB
Lua

-- The shared background-worker machinery (src/core/WorkerLane.lua): Store,
-- Lane and Worker, driven with fake threads / channels.
-- luajit tests/engine/worker_lane_test.lua
package.path = "./?.lua;./?/init.lua;" .. package.path
local T = require("tests.harness")
local check, eq = T.check, T.eq
love = require("tests.love_stub")
local WorkerLane = require("src.core.WorkerLane")
-- ---- fake threads / channels ------------------------------------------------
local channels = {}
local Channel = {}
Channel.__index = Channel
function Channel:push(msg)
if self.reject and self.reject(msg) then error("not transferable") end
self.queue[#self.queue + 1] = msg
end
function Channel:pop() return table.remove(self.queue, 1) end
function Channel:clear() self.queue = {} end
function Channel:getCount() return #self.queue end
local function channel(name)
channels[name] = channels[name] or setmetatable({ queue = {} }, Channel)
return channels[name]
end
local started, waited, threadError = 0, 0, nil
local function installThread()
love.thread = {
newThread = function()
return { start = function() started = started + 1 end,
getError = function() return threadError end,
wait = function() waited = waited + 1 end }
end,
getChannel = channel,
}
end
installThread()
local stops = 0
local function newWorker(extra)
local opts = { name = "test", script = "x.lua", cmd = "t_cmd", out = "t_out",
onStop = function() stops = stops + 1 end }
for k, v in pairs(extra or {}) do opts[k] = v end
channel("t_cmd"):clear()
channel("t_out"):clear()
return WorkerLane.newWorker(opts)
end
-- ---- Store -------------------------------------------------------------------
do
local s = WorkerLane.newStore(3)
s:set("a", 1); s:set("b", 2); s:set("c", 3)
eq(s:count(), 3, "store holds up to its cap")
s:peek("a") -- a is now the freshest
s:set("d", 4)
eq(s:count(), 3, "store stays bounded")
check(s:get("b") == nil, "least recently used entry evicted")
check(s:get("a") == 1 and s:get("c") == 3 and s:get("d") == 4, "others kept")
s:get("c") -- get does not refresh
s:set("e", 5)
check(s:get("c") == nil, "get() does not count as a use")
s:set("a", 10)
eq(s:count(), 3, "overwriting does not grow the store")
eq(s:take("a"), 10, "take returns the value")
check(s:get("a") == nil and s:count() == 2, "...and removes it")
s:setCap(1); s:set("z", 1)
eq(s:count(), 1, "setCap shrinks on the next set")
s:clear()
eq(s:count(), 0, "clear")
end
-- ---- Worker start and fallbacks ---------------------------------------------
do
local w = newWorker()
check(w:ensure(), "worker starts")
eq(started, 1, "one thread started")
check(w:ensure(), "ensure is idempotent")
eq(started, 1, "no second thread")
-- channels are process-global: leftovers from a previous run are dropped
local w2 = newWorker({ cmd = "t2_cmd", out = "t2_out" })
channel("t2_cmd"):push({ cmd = "stale" }); channel("t2_out"):push({ stale = true })
w2:ensure()
eq(channel("t2_cmd"):getCount() + channel("t2_out"):getCount(), 0,
"start clears both channels")
local before = started
local w3 = newWorker(); w3.noThread = "1"
check(not w3:ensure(), "POKEPORT_NO_THREAD=1: off")
eq(started, before, "...without starting a thread")
check(not w3:push({}), "push is a no-op when off")
local saved = love.thread
love.thread = nil
check(not newWorker():ensure(), "no love.thread: off")
installThread()
check(not newWorker({ capable = function() return false end }):ensure(),
"missing love module: off")
love.thread.newThread = function() error("boom") end
check(not newWorker():ensure(), "newThread failure: off")
installThread()
love.thread.newThread = function()
return { start = function() error("no start") end }
end
check(not newWorker():ensure(), "start failure: off")
love.thread = saved
installThread()
end
-- ---- Lane queueing -----------------------------------------------------------
do
local w = newWorker()
w:ensure()
local lane = w:newLane({ inflight = 2 })
local sentKeys = {}
local function send(entry)
sentKeys[#sentKeys + 1] = entry.key
return w:push({ cmd = "job", key = entry.key, epoch = lane.epoch })
end
lane:push("a", {}, false)
lane:push("b", {}, false)
lane:push("c", {}, false)
check(lane:promote("a", false), "an already queued key is found")
check(not lane:promote("zzz", false), "an unknown key is not")
check(lane:promote("c", true), "front promotes a low-priority entry")
lane:push("d", {}, true)
local queued, flying = lane:pending()
eq(queued, 4, "four queued") ; eq(flying, 0, "none in flight")
lane:pump(send)
eq(table.concat(sentKeys, ","), "c,d", "front entries go first, in order, up to the budget")
queued, flying = lane:pending()
eq(queued, 2, "two still queued"); eq(flying, 2, "two in flight")
lane:pump(send)
eq(#sentKeys, 2, "a full in-flight budget sends nothing more")
check(not lane:idle(), "not idle")
check(lane:complete("c", lane.epoch) ~= nil, "a current result retires its entry")
check(lane:complete("c", lane.epoch) == nil, "a duplicate result is ignored")
check(lane:complete("d", lane.epoch - 1) == nil, "a result from another epoch is ignored")
lane:pump(send)
eq(table.concat(sentKeys, ","), "c,d,a", "a retired slot is refilled in queue order")
-- forget: a dead queued entry is skipped
lane:forget("b")
lane:complete("a", lane.epoch)
lane:pump(send)
eq(#sentKeys, 3, "a forgotten entry is never sent")
check(lane:queued("b") == nil, "and no longer queued")
end
-- ---- send results, caps ------------------------------------------------------
do
local w = newWorker()
w:ensure()
local lane = w:newLane({ inflight = 10, sendCap = 2 })
for i = 1, 5 do lane:push("k" .. i, {}, false) end
local calls = 0
lane:pump(function(entry) calls = calls + 1 return true, "tracked:" .. entry.key end)
eq(calls, 2, "sendCap bounds jobs sent per pump")
check(lane.inflight["tracked:k1"] ~= nil, "a send may name the in-flight key")
check(lane:complete("tracked:k1", lane.epoch) ~= nil, "and complete() finds it there")
local lane2 = w:newLane({ inflight = 10, examineCap = 3 })
for i = 1, 8 do lane2:push("e" .. i, {}, false) end
calls = 0
lane2:pump(function() calls = calls + 1 return false end)
eq(calls, 3, "examineCap bounds entries looked at, sent or not")
eq((lane2:pending()), 5, "the rest stay queued")
lane2:pump(function() calls = calls + 1 return false end)
eq(calls, 6, "the next pump examines more")
local lane3 = w:newLane({ inflight = 10 })
lane3:push("x", {}, false)
lane3:push("y", {}, false)
lane3:pump(function(entry) return entry.key == "y" end)
eq(lane3.inflightCount, 1, "a dropped entry is not counted in flight")
eq((lane3:pending()), 0, "and is not kept queued")
end
-- ---- epochs -------------------------------------------------------------------
do
local w = newWorker()
w:ensure()
local a = w:newLane({ inflight = 4 })
local b = w:newLane({ inflight = 4 })
local function send(lane)
return function(entry)
return w:push({ cmd = "job", key = entry.key, epoch = lane.epoch })
end
end
a:push("a1", {}, false); a:push("a2", {}, false)
b:push("b1", {}, false); b:push("b2", {}, false)
a:pump(send(a)); b:pump(send(b))
a:push("a3", {}, false)
local cmdCh = channel("t_cmd")
eq(cmdCh:getCount(), 4, "four jobs at the worker")
-- a bump drops this lane's jobs; the OTHER lane re-sends what it lost
local e = a.epoch
a:bump()
check(a.epoch == e + 1, "bump advances the lane epoch")
eq(b.epoch, 0, "...and only that lane's")
eq(cmdCh:getCount(), 0, "the inbox is cleared")
eq((a:pending()), 0, "bump drops queued entries")
eq(a.inflightCount, 0, "and in-flight ones")
check(a:idle(), "lane is idle")
check(a:complete("a1", e) == nil, "a result under the old epoch is dropped")
eq(b.inflightCount, 0, "the OTHER lane's lost jobs are queued again")
eq((b:pending()), 2, "so a bump never strands them")
a:pump(send(a)); b:pump(send(b))
eq(cmdCh:getCount(), 2, "the other lane re-sends them")
eq(cmdCh.queue[1].epoch, b.epoch, "under its current epoch")
end
-- ---- Worker drain, fail, shutdown ---------------------------------------------
do
stops = 0
local w = newWorker()
w:ensure()
local lane = w:newLane({ inflight = 8 })
local out = channel("t_out")
for i = 1, 6 do out:push({ key = "r" .. i }) end
local seen = {}
check(w:drain(3, function(r) seen[#seen + 1] = r.key return r.key ~= "r2" end),
"drain reports a healthy worker")
eq(table.concat(seen, ","), "r1,r2,r3,r4", "only counted results use the cap")
eq(out:getCount(), 2, "the rest wait for the next drain")
out:push({ fatal = "kaput" })
local ok = w:drain(10, function() return false end)
check(not ok, "a fatal report fails the worker")
check(w.state == false, "worker is off")
eq(stops, 1, "onStop ran")
local quit = false
for _, c in ipairs(channel("t_cmd").queue) do if c.cmd == "quit" then quit = true end end
check(quit, "quit was pushed")
check(not w:ensure(), "a failed worker stays off for the process")
check(not w:drain(1, function() end), "and drain is a no-op")
-- a thread that died
local w2 = newWorker(); w2:ensure()
local lane2 = w2:newLane({})
lane2:push("q", {}, false)
threadError = "died"
check(not w2:drain(1, function() end), "getError fails the worker")
check(lane2:idle() and w2.state == false, "lanes cleared")
threadError = nil
-- clean shutdown: inbox cleared, quit left, thread waited for, restartable
local w3 = newWorker(); w3:ensure()
local lane3 = w3:newLane({})
w3:push({ cmd = "job" }); w3:push({ cmd = "job" })
lane3:push("x", {}, false)
local waitedBefore, stopsBefore = waited, stops
w3:shutdown()
eq(channel("t_cmd"):getCount(), 1, "shutdown clears pending work")
eq(channel("t_cmd").queue[1].cmd, "quit", "and leaves only quit")
eq(waited, waitedBefore + 1, "and waits for the thread")
eq(stops, stopsBefore + 1, "onStop ran")
check(lane3:idle(), "lanes cleared")
check(w3.state == nil, "state reset so the next request restarts")
local startedBefore = started
check(w3:ensure(), "restarts after shutdown")
eq(started, startedBefore + 1, "with a fresh thread")
eq(channel("t_cmd"):getCount(), 0, "and the leftover quit is cleared")
w3:shutdown(); w3:shutdown() -- repeatable
-- a message that cannot cross a channel
local w4 = newWorker(); w4:ensure()
channel("t_cmd").reject = function(msg) return msg.bad end
check(not w4:push({ bad = true }), "push reports an untransferable message")
check(w4:push({ good = true }), "and a good one")
channel("t_cmd").reject = nil
end
-- ---- requeueInflight keeps the original send order ------------------------------
do
local w = newWorker()
w:ensure()
local lane = w:newLane({ inflight = 10 })
local function send() return true end
lane:push("l1", {}, false)
lane:push("f1", {}, true)
lane:push("l2", {}, false)
lane:push("f2", {}, true)
lane:pump(send) -- f1, f2, l1, l2 go out in that order
lane:push("queued", {}, false)
lane:requeueInflight()
local function keys(q)
local t = {}
for _, e in ipairs(q) do t[#t + 1] = e.key end
return table.concat(t, ",")
end
eq(keys(lane.high), "f1,f2", "front entries return to high in send order")
eq(keys(lane.low), "l1,l2,queued", "others return ahead of newer low work, in order")
end
T.finish()