Builtin Library
README.md
frame_budget
The share of a frame that cooperative engine work spends before it hands the frame back.
Some engine work is one long sequence of small units run on a task: a scene
load applies one record after another, and the world-load onRegister sweep
fires one instance's hook after another. Run back to back in one frame, a long
sequence holds that frame for its whole length, and nothing else the engine
does (answering a call, drawing, input) happens until it is through. Asking
due() before each unit and yielding when it answers true runs the same work
across as many frames as it needs, a slice of each.
local FrameBudget = require("@builtin::modules.frame_budget")
for _, item in ipairs(items) do
FrameBudget.yield()
process(item)
end
- One slice per frame, shared. Each caller is charged the time it runs
between its readings of the slice in a frame (
task.runTime), whoever runs it, and the charges of every caller add up to the frame's slice. What the rest of the frame runs (systems, components, other tasks) is not charged, so a busy frame still gives the slice to the work. Two long pieces of work running at once share a frame's slice rather than taking one each. - A unit is never split. The first unit of a frame always runs, so a frame holds at most the slice plus the one unit that crossed it. A unit that is itself long stays one piece of work.
- Code that cannot yield runs in its frame.
due()answers false outside a coroutine that can yield, so a caller called from such code does all its units in the frame it was called in, as it would without the budget.
A caller that does work of its own around the yield asks due() and calls
wait(), which yields to the next frame and opens that frame's slice as the
caller resumes; yield() is the two together.
Work whose unit is a native call that bounds itself by time is told how long
it may run: remainingMs() answers what is left of the current frame's slice,
or nil for code that cannot yield, which then runs the whole of its work in the
frame it was called in. The publish gate's script check runs this way:
local block, checking = __spacetime_vcs.scriptPublishBlock(FrameBudget.remainingMs())
while checking do
FrameBudget.wait()
block, checking = __spacetime_vcs.scriptPublishBlock(FrameBudget.remainingMs())
end
The background lane
Some work is started early so it is ready when asked for, and no picture waits
on it, such as the search text an editor reads ahead of the first
tools.search, the skills and procedural packs an agent or a build reads
later, the procedural nodes loaded ahead of a build's first plan, and the
language server's re-check of the files a play flip reads. background(fn)
runs fn on the background lane: every yield, due and wait its work
makes on that coroutine hands the frame back for as long as a hold on the lane
stands, and takes its slice as usual once none does.
FrameBudget.background(function()
for _, path in ipairs(paths) do
FrameBudget.yield()
warm(path)
end
end)
Work the picture waits on holds the lane while it runs: holdBackground(key)
until releaseBackground(key). A world's entry holds it under "world entry"
from the moment the world is ready (its onRegister sweep) until the world
load reads visually_complete on the boot timeline (its scene drained, the
content it draws resident, nothing owed to its picture and no loading screen
over it), so the frames of the entry carry the world's load and its first
picture, and the background work runs in the frames before and after them. A
hold no one releases lapses after BACKGROUND_HOLD_SECONDS (or the seconds it
was taken for), with a warning naming its key, so work on the lane always runs
in the end. backgroundHolds() lists the keys holding the lane now, and
backgroundWaits() answers whether the calling code's next unit waits for it
(a walk with a clock of its own asks it between its pieces).
FrameBudget.DEFAULT_MS is the slice a call that names none spends. spent()
answers how much of the current frame's slice is gone.
A caller with a budget of its own reads mark() where its work starts.
ranMs(mark) answers how long it has run since, none of the time it spent
suspended; cpuMs(mark) answers the CPU time of its work since: those
stretches and the stretches of every coroutine it resumes, and every one they
resume, while it waits on them, which a machine whose cores are taken by other
work does not stretch, so a bound that refuses work for running too long
refuses the same work on a loaded machine as on an idle one, and work handed to
a coroutine as work run in place. A task it starts and waits for runs while it
is suspended, which cpuMs does not count.