---
title: "Performance: run each job where it belongs"
description: "Zero executes your logic in two very different worlds. Luau is flexible, per-object, and serial. The engine core and the GPU are bulk and parallel. Almost all performance work comes down to running…"
section: "Core"
slug: "core-performance"
canonical: "https://origozero.ai/docs/core-performance"
updated: "2026-08-13T16:32:04.321668592+00:00"
tags: ["documentation", "guide"]
---

# 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)

```lua
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:

```lua
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:

```lua
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.

```lua
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`.

```lua
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 with `entity.batchRead` / `entity.batchWrite` inside a single `task.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.

## 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`:

```lua
-- 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 },
  },
})

-- 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, or as `use_tool { toolbox = "profiler", tool = "hotspots" }` over MCP.

```bash
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 under `present / idle`, 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.

## 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, and let the profiler tell you where the time actually goes.
