Performance: run each job where it belongs
The sections below are in priority order. Start at the top.
1. Heavy parallel work goes on the GPU (compute shaders)
If a workload is data-parallel and not trivially small, it belongs in a compute shader: the same operation applied independently across many elements. A particle system, a physics or fluid or cellular simulation, transforming or culling thousands of instances, procedural generation, image processing as a pass, deforming a mesh's vertices, or simply running the same math over every element of a large array. A GPU runs thousands of lanes at once, so moving this work off the CPU is not a modest gain. For data-parallel load a compute pass is faster than anything you can write on the CPU by orders of magnitude. This is the default for that kind of work, not a last resort gated behind some size threshold: if the operation is independent per element and you have more than a handful of elements, reach for compute.
You write a compute shader as a .computeShader asset: a shader.wgsl body (just @compute fn main) plus a declarative bindings.yaml. You dispatch it by identity and the engine compiles it on first use. The complete authoring contract, the binding schema, dispatch, and readback all live in one place:
Authoring and dispatching compute shaders:
guides { path: "types/computeShader" }.
The CPU tiers below are for the work that genuinely cannot go to the GPU: N is small, the logic is inherently serial, or it needs CPU-only engine APIs. They are ordered by how much work each Luau-to-engine crossing carries.
2. The cost that shapes the CPU side: the Luau-to-engine crossing
Luau and the engine core run in separate worlds, and every call across that boundary has a fixed cost. One crossing is microseconds, cheap. A million crossings is not. The whole CPU-side story is granularity: do N things in one crossing instead of one thing in N crossings. For small N (a handful of entities, one-off updates) the per-entity API is exactly right. The tiers below only start to matter once N gets large.
3. Per-entity (small N)
local e = entity(id)
e.position = { x, y, z }
One crossing each. Fine until you are doing it hundreds of times a frame.
4. Batch IO (tens to thousands)
One crossing for the whole set, sourced from a Lua table:
entity.batchWrite(ids, "Transform", "position", values) -- (target, component, field, source) -> count
local ps = entity.batchRead(ids, "Transform", "position") -- (target, component?, field?, sink?)
When the same set repeats every frame, bind it once so the ids are resolved once:
local binding = ecs.bindEntities(ids) -- once
entity.batchWrite(binding, "Transform", "position", values) -- per frame
5. Typed buffers — the substrate (thousands)
substrate is the engine's one buffer primitive, and it is what makes this tier and the GPU tier the same system rather than two. A substrate buffer is a typed run of values you hold a handle to: you say what one element is and how many there are, and the engine core reads the block directly, so you skip building a Lua table per record.
local buf = substrate.createBuffer({ kind = "cpu", type = "vec3", len = #ids })
buf:write({ x1,y1,z1, x2,y2,z2 }) -- flat floats, not per-record tables
entity.batchWrite(binding, "Transform", "position", buf) -- buffer as the source
local back = buf:read():result()
type is "f32", "vec3", "vec4", "quat" or "mat4" — the stride follows from it, so a read comes back in the units it was written in. kind is "cpu" (the scripting heap, the default) or "gpu" (storage a compute shader binds), and usage adds "readback", "vertex" or "index" on top of the storage a buffer always has. "readback" is what makes a GPU-to-CPU read possible at all.
Everything a buffer does hangs off its handle, so a buffer travels as a value rather than as a name something else has to resolve: :write (numbers as floats), :writeU32 (integer bit patterns), :writeBytes (an already-packed pool — vertices, an index run), :read, :length, :type, :kind, :destroy. A "cpu" buffer also answers :bytes() for per-element access through the Luau buffer library, along with in-place arithmetic (:fill :add :scale :axpby :lerp) and indexed accessors (:getVec3 :setVec3 :getQuat :setQuat). A "gpu" buffer is transformed by dispatching a compute shader over it instead.
:read() answers with a readback rather than a value, because a GPU read takes frames to arrive: hold it and ask (:ready(), :state()), then drain it with :result() (nil until it lands), :resultU32() or :resultBytes() — the same bytes read three ways, matching the writes. A CPU read is already done by the time it returns, and answers the same shape, which is what lets code that reads a buffer not care where it lives.
This is the engine's typed buffer. Luau's built-in buffer library is a different thing — raw untyped CPU bytes — and stays exactly as it is.
Which call allocates it. A buffer a compute shader owns is made through that shader, so the two travel together: shaderRef:createBuffer(name, { type, len }). substrate.createBuffer is for a buffer nothing computes — a vertex or index run that is only ever drawn, or a CPU block feeding batchWrite. Either way you hold the handle and pass it where it is needed; kind = "gpu" is how the same data you batch onto entities reaches a compute dispatch without a second copy back through Luau.
6. nx numerical kernels
When you need math across an array (sin over every element, integrate velocity into position, normalize a list of vec3s, dot products, FFTs), nx runs the whole pass in one crossing. It has its own buffer type via nx.create / nx.fromTable.
local b = nx.create("vec3", 1024) -- also nx.zeros / nx.ones / nx.full / nx.fromTable
nx.integratePosition(posBuf, velBuf, dt) -- fused mover, one crossing for the whole array
The kernel set is large: in-place unary math (nx.applySin / applyCos / applySqrt / ...), element-wise binary ops (nx.add / mul / sub / div), reductions (nx.sum / mean / dot / normL2), fused movers (nx.axpby / lerpTo / integratePosition / normalizeVec3), and FFTs (nx.fft1d / rfft1d / ...). It is the densest namespace in the engine. Read the nx module under modules/api/engine for exact signatures.
Script components vs raw ECS: which to choose
One of the biggest performance decisions in a world, and it is orthogonal to the tiers above. The same entity behavior can be built two ways:
- Raw ECS components, driven in bulk. State lives in native components (
Transform,Mesh, and your own marker or data components). You drive it withentity.batchRead/entity.batchWriteinside a singletask.loop, or let an engine system or a job process the whole set. A thousand entities cost one or two crossings a frame no matter how many there are. This is the model in the ECS guide,core/ecs. - Script components. Each entity carries a Luau component with an
update(dt)lifecycle the engine ticks every frame. Self-contained and convenient, but every instance is one Luau-to-engine crossing per frame, so cost scales with entity count. A thousand ticking script components is a thousand crossings a frame; the same thousand driven by one batched loop is one crossing.
Which to choose:
- Many entities running the same logic (troops, projectiles, particles, anything you sweep at scale) go to raw ECS components driven by one batched loop or a job. Do not give each one its own script lifecycle.
- A handful of entities, or genuinely bespoke per-entity behavior (a boss with unique logic, a door, a one-off interactable) is what a script component is for; the per-entity cost is nothing at that count.
The tipping point is entity count times per-frame crossings. When in doubt, the scripts profiler tool (below) attributes exactly what each script component's update costs, by component and entity, so you can see when a script component has outgrown its lifecycle and should become a batched loop or job. The full model is in core/ecs (native components and batch loops), with core/components for authoring script components and core/entities for the entity itself.
Serial work that holds a frame: worker threads
Some CPU work is neither data-parallel nor a crossing problem: a pathfinding search, a level generator, a solver, a parser. It is serial Luau that runs for tens or hundreds of milliseconds, and on the engine thread every one of those milliseconds is one the frame waits for. jobs.dispatch runs one function of a module on a worker thread instead, and the frames go on while it runs:
local layout = jobs.dispatch("@root::dungeon.generate", "build", { seed = 42, rooms = 60 }):await()
spawnRooms(layout)
The module and the modules it requires run in a worker VM that boots the prelude and requires modules as the engine thread's VM does, so it has the same globals and modules/api modules, and it reads and changes the world from the worker: entities, transforms, components, colliders and physics queries are read on the worker from state the engine thread keeps current, and its spawns and writes are recorded and applied by the engine thread in order, with the job reading its own writes at once. A call the worker cannot answer itself runs on the engine thread: the job waits for it as it waits on a promise, so such a call costs at least one frame; a call whose argument holds a function, whose answer holds an object of the engine thread's VM, or that is made where the script cannot wait, raises an error naming the function and the reason. A long job is steered while it runs through jobs.shared (below): the game sets a flag the job reads between steps to pause or stop it, and the job keeps its work in progress. The entry computes from its arguments and returns a value, and the code awaiting the promise applies it to the world. Arguments and results cross as copies (nil, booleans, numbers, strings, buffers, vectors and tables of those), so large data travels best packed in a buffer. Each worker has a VM of its own, and module state stays in the VM that set it. An item dispatched without a key runs on whichever worker takes it, so what its result depends on travels in the arguments and the result; work that is cheap only with state it keeps between items runs with a key (below). The pool runs as many items at once as it has workers (the machine's hardware threads less two, from 1 to 8; /runtime/workers/pool reads max_workers), and a promise settles when the engine thread takes in finished items, once a frame, so a round trip lasts at least a frame and an item is worth sending when its work outweighs it. Code that must not wait, a component's update among it, keeps the promise and reads p:state() each frame, then takes the value with p:await() once it has settled, which returns at once (core/scripting-and-tasks covers futures). The jobs.dispatch reference states the deadline, cancellation and error contract.
A loop over many independent indices, or over the elements of a buffer, splits across the whole pool with jobs.parallelFor(module, entry, over, args): each worker runs the entry on a chunk, and the promise settles with the chunks' results in chunk order once every chunk is done. It is the tool when the whole result is needed at once. Work that should show as each piece finishes is one jobs.dispatch per piece, each read with p:state() as it settles.
Keeping state in a worker: keys. Work that is fast only because of what it keeps between calls (a pathfinder's graph, a compiled rule set, a parsed asset, a plan it reuses while its input changes a little) dispatches with a key: jobs.dispatch(module, entry, args, { key = "level3" }). The first item of a key binds the key to a worker VM, and every later item of the key runs in that VM, one at a time and in the order dispatched, so the module state one item leaves is there for the next. A key holds no worker: the VM serves other items between the key's items. A key's item waits for its VM even while other workers are idle; that wait is the price of the state it finds there, and /runtime/workers reports it per key (waited_for_vm_ms).
-- /source/paths/graph.luau
local graphs = {}
local M = {}
jobs.onRelease(function(key) graphs[key] = nil end)
function M.route(args)
local key = jobs.context().key
graphs[key] = graphs[key] or buildGraph(args.map)
return graphs[key]:route(args.from, args.to)
end
return M
local route = jobs.dispatch("@root::paths.graph", "route", { map = map, from = a, to = b }, { key = "level3" }):await()
jobs.context()answers, on a worker,{ worker, key, fresh }:freshis true for the first item a key runs in this VM since the key bound, so a module tells a cold cache from a missing entry. On the engine thread it answers nil.{ key = k, supersede = true }rejects every item of the key that has not started, naming the newer item; the running one is untouched, so an edit stream runs only its newest request.- An item awaiting an item of its own key is refused at the await: it holds the key's turn, and the awaited item starts only after it settles. Dispatching to its own key without awaiting queues a next stage, which runs after it.
jobs.release(key)ends the binding once the key's items dispatched before it have settled; the VM then runs the watchers its modules registered withjobs.onRelease(function(key) ... end), which drop what they keep for the key. A binding also ends with its VM (a world change, a reset of the scripting state, a panic in the engine, a lost browser worker); the key's next item binds anew and runs fresh. Without a release, a VM keeps each key's state for as long as it lives, and/runtime/workerslists the keys each worker holds.jobs.warm(module, entry)runs a module's one-time setup (a registry, a table it loads) once in every worker VM, so no item pays it: the pool starts all its workers, each VM up runs the entry as its next item, a VM made later runs it before its first item, and the promise settles once every VM up at the call has run it, rejecting with each failed run's worker and error. A second call for the same entry answers the same. The entry builds state in its own VM: it may wait on time and frames, its whole run is bounded at 60 seconds of wall time, and an await of a dispatch, ajobs.parallelForor ajobs.warmpromise by it or a task it started raises (dispatching without awaiting is fine); no item starts on a VM until its warm pass is over, and/runtime/workersreports each entry's milliseconds on each VM. A module calls it as it is first used in a world, so a world that never uses the module pays nothing.- A
kind = "worker"job runs every item asexecutor.key. A key is a string of 1 to 256 bytes, and the pool holds at most 65,536 keys at once.
Sharing state between a job and the game while it runs. A promise settles once, when the entry returns, so work that comes in pieces is best sent as one jobs.dispatch per piece, each shown as its promise settles. When one long job has to be seen or steered while it runs (its progress, its partial output, a flag the game sets to stop it early), or jobs hand work along to each other, that state goes through jobs.shared: tables and queues that the engine thread's code and every job read and write together.
-- the job (in a module): one long solve that reports its progress and stops when asked
function M.solve(args)
local state = jobs.shared.table(args.name)
for step = 1, args.steps do
if state.stop then return "stopped" end
advance(step)
state.progress = step / args.steps
end
return "done"
end
-- the game: start it, show its progress each frame, stop it when the player cancels
local state = jobs.shared.table("solver.state")
local p = jobs.dispatch("@root::solver", "solve", { name = "solver.state", steps = 100000 })
local seen = 0
task.loop(function()
local now = jobs.shared.revision(state) -- one number; moves when anything in the table changed
if now ~= seen then
seen = now
showProgress(state.progress or 0)
end
end)
-- when the player cancels: state.stop = true
A shared table is used like an ordinary table (t.x = 1, t.player.hp -= 5, #t, for k, v in t, pairs, table.insert, table.sort) and holds data: numbers, strings, booleans, vectors, buffers and tables of those. jobs.shared.atomic(t, key, fn) changes one key without a race (counters and flags), and jobs.shared.queue(name, { capacity }) hands values between jobs and the game: push from anywhere, pop waits (a job parks; on the engine thread only the calling task waits, so no frame is held), tryPop never waits, and a full queue raises. The store is process memory: it is not saved and not sent to other players, and it is dropped when the world changes. runtime_data and synced component fields are the stores that persist and replicate. The jobs reference states the bounds and the rest of the contract.
Running it every frame: jobs
If the work runs continuously, register it as a job so the engine scheduler owns it. The scheduler can pause, reorder, and inspect your work instead of you driving a loose loop. A job dispatches by its executor kind:
-- luau: an arbitrary closure, ticked once per frame
local job = jobs.register({
phase = "main",
executor = { kind = "luau", run = function()
nx.integratePosition(posBuf, velBuf, dt)
entity.batchWrite(binding, "Transform", "position", posBuf)
end },
})
-- kernel: a named built-in kernel over declared buffers, with no Luau in the loop.
-- Built-ins: copy_buffer, fill_buffer, gather_components, scatter_components.
-- gather/scatter are the scheduler-owned equivalents of batchRead / batchWrite.
-- (src and dst here are substrate.createBuffer handles.)
jobs.register({
phase = "main",
reads = { { resource = { kind = "buffer", id = src.id }, mode = "r" } },
writes = { { resource = { kind = "buffer", id = dst.id }, mode = "w" } },
executor = { kind = "kernel", kernel = "copy_buffer" },
})
-- compute: a compute shader dispatch the scheduler owns as a first-class node.
-- Resolving the shader is what records the dependency, so it travels with the
-- world the job lives in.
jobs.register({
phase = "main",
executor = {
kind = "compute",
shader = asset.resolve("@builtin::shaders.mySim", "computeShader"),
workgroups = { 256, 1, 1 },
},
})
-- worker: the computation runs on a worker thread every frame; the engine
-- thread pays only for input (what the item starts from) and apply (what it
-- writes back), one item in flight at a time.
jobs.register({
name = "crowd",
phase = "main",
executor = {
kind = "worker",
module = "@root::crowd.sim",
entry = "step",
input = function() return snapshot() end,
apply = function(result) write(result) end,
},
})
-- stub: a no-op node, useful purely as an ordering anchor between other jobs
jobs.register({ phase = "main", executor = { kind = "stub" } })
Every handle has job:pause(), job:resume(), job:cancel(), plus job:status() and job:info(). Introspect the registry with jobs.list("main"), jobs.find(name), and jobs.inspect(target). Mark a job pure = true (when it is a pure function of its declared reads / writes) to let the scheduler reorder it.
Measure, do not guess
Find where the milliseconds actually go before you climb any tier. The profiler toolbox attributes frame time down to the component and entity, and it is an agent-facing toolbox: call it from the shell (zero profiler hotspots), or over MCP as tool_exec { script: "return await tools.use('profiler', 'hotspots')" }.
zero profiler hotspots # ranked list of the costliest nodes by self time
zero profiler frame # the full attributed self / total / calls tree
zero profiler scripts # per-component update(dt) cost, by component @ entity
zero profiler gpu # per-pass GPU cost, from timestamp queries
zero scene cost # per-scene entrypoint tick cost, by loaded layer
hotspots and frame read a live frame; scripts lands script cost on the exact component and entity you can open and fix. scripts counts COMPONENT update loops; a scene entrypoint's update / editorUpdate runs on the scheduler instead, so it is absent there and lands in the generic Luau bucket hotspots shows — scene cost is the reading that attributes it to the layer that owns it, and it answers in edit mode as well as play. For hitches a single live frame misses, ring on keeps an always-recording history and retro reads it back after the fact, so it does not matter that a tool call lands seconds after the spike. The toolbox has more (record, watch / hits, compare, flamegraph); browse it with zero profiler, and read one verb's arguments with zero profiler <verb> --help.
When frame puts the time on gpu_wait, or under a present / idle row whose verdict names the GPU, the frame is waiting on the GPU and gpu is the one that answers where. Read its rows as a distribution rather than a cost: each is the median of that pass's per-frame span over a window of frames, with the min and max it ranged over, because a single frame's two timestamps also bracket whatever the queue was waiting on. A row that reads floor ran, and the device resolved no duration for it — its two timestamps retired within a few ticks of each other, which the header states the size of. Passes that small are measured by making them bigger: raise the resolution or the sample count until the cost lands above the floor, and scale back down.
Reading the rows: work and waits
Every row is a span the engine timed, named by where it sits: render.render_frame.present.gpu_wait is the gpu_wait span inside present, inside render_frame, in the render schedule. A row's time includes every row nested in it.
A row whose name ends in wait (gpu_wait, present_wait, process_local_wait) is time the engine thread spent blocked, not work it did. It grows with work done somewhere else (on the GPU, by the display, on another thread), so making the code around it faster does not shrink it. The perf line on an execute result reports waits apart from work: cpu is the frame's work with the waits taken out, the rows it lists after the dash are the hottest work, and a waited clause names each wait with its time, as in 78.1ms frame (cpu 16.5ms; waited 47.2ms: render.render_frame.present.gpu_wait 47.2).
In profiler frame a wait row reads (wait) after its name, with what it waits for on the line below, and the header states the frame's total wait. In profiler hotspots its kind column reads wait.
The rows at the end of a rendered frame, under render.render_frame:
| Row | What it times |
|---|---|
present_wait | Acquiring the next swapchain image: the frame parks until the display hands one back. Under the fifo present mode this is the vsync wait. |
present | Everything from the frame's last submission to its end; the rows below are its parts. |
present.surface | Copying the presented frame for captures and recordings, the pre-rotation pass, and handing the image to the window. |
present.frame_work | Submitting the work recorded ahead of the frame that had not been submitted yet. |
present.texture_cache | Closing the texture cache's frame: releasing one-frame pins and repacking sparse texture buckets. |
present.handles | Freeing the texture handles no material row holds any more. |
present.material_sync | Bringing the material rows, the texture directory and the material groups up to date on the GPU. |
present.gpu_wait | The frame pacer waiting for the GPU to finish the frames past the frames-in-flight bound (renderer.framePacing().maxFramesInFlight). It is the GPU's frame time showing up on the CPU: a large gpu_wait means the GPU is the bottleneck, renderer.framePacing().waitMs reports the same wait, and profiler gpu names the passes the GPU spends it on. It shrinks when the GPU has less to do (a lower render scale, fewer shadowed lights, cheaper volumetrics and post effects); making the frame's CPU work faster moves that time into this row. On the web the pacer declines to start a frame instead of waiting, so the row stays near zero there and renderer.framePacing().pacedFrames counts the declined frames. |
present.caches | Publishing the pacing and upload statistics, and the pipeline-cache and baked-shader-cache bookkeeping. |
present.observation | Publishing the render observation: what this frame's passes bound and what each program cost. |
The GPU cost a device can afford
Pixels, shadows and texture memory are the GPU's side of the frame, and what a
device can afford of them differs by an order of magnitude between a desktop and
a phone. The engine assigns every device a quality tier at boot and takes its
renderer defaults from it: the scene render scale (below 1 on a dense phone
screen, with the UI kept at full resolution), the spot and point shadow
budgets, the texture budget and the filtering. renderer.qualityTier()
reads the tier and what it chose; renderer.setQualityTier("low" | "medium" | "high") chooses another, which is also the call behind a player's graphics
setting. The graphics-quality guide (man topics/graphics-quality) covers
it.
The model
Two ideas carry the whole guide. First, heavy parallel work belongs on the GPU: a compute shader beats any CPU approach for GPU-suited load, so reach for it first, not last. Second, for the work that must stay on the CPU, the cost is the Luau-to-engine crossing, so make the crossings fewer and fatter: per-entity, then batch, then typed buffer, then nx. The substrate buffer is what joins the two halves — the same handle a batched write reads from is the one a compute dispatch binds, so moving work to the GPU does not mean moving the data twice. Keep uniform state in data components rather than per-entity scripts, wrap continuous work in a job, hand serial work that would hold a frame to a worker with jobs.dispatch, and let the profiler tell you where the time actually goes.