Log inGet started

profiler

Updated 5 September 2026

The profiler namespace — 63 functions.

globals/profiler/begin

profiler.begin(name: string)

Start a named profiling block. Call profiler.finish(name) to record the duration. Blocks appear in profiler.stats() under "script.<name>" and inside captures.

Parameters

  • name string — Block name (e.g. "MyComponent.update").
profiler.begin("MyComponent.update"); ...; profiler.finish()

globals/profiler/disableRing

profiler.disableRing()

Disable the ring buffer and clear its history.

profiler.disableRing()

globals/profiler/enableRing

profiler.enableRing(seconds: number?) -> boolean

Enable the always-recording ring buffer, retaining the last seconds of per-frame data (default 20). Query it AFTER the fact with profiler.retro() — latency-immune, since the data is historical. Editor profile only: returns false in the runtime profile. The enable is the gate; the ring costs nothing until on.

Parameters

  • seconds number (optional) — Seconds of history to retain (default 20).

Returns boolean — True if enabled, false if refused (runtime profile).

if profiler.enableRing(30) then ... end

globals/profiler/finish

profiler.finish(name: string?) -> number?

Finish a profiling block and record the elapsed duration as "script.<name>". Without an argument, closes the most-recently- begun block (LIFO stack). With a name, closes the most recent block whose name matches — useful when blocks of different names are nested.

Parameters

  • name string (optional) — Block name to finish. Omit to pop the top of the stack.

Returns number? — Elapsed milliseconds, or nil if no matching block was active.

local ms = profiler.finish("MyComponent.update")

globals/profiler/gpuFrame

profiler.gpuFrame() -> GpuFrameReport

Label-aggregated GPU pass timings over the last window_frames resolved frames, measured with GPU timestamp queries. supported is false when the device lacks timestamp queries — spans stays empty. Each span covers every render/compute pass recorded under one label — compute.<shader> per compute dispatch, scene.* for the scene passes, post.<effect> per post-process effect, feature.* for render-feature passes: ms is the median of its per-frame totals, min_ms/max_ms the range that median sits in, count the passes per frame and frames how much of the window carried it. at_floor marks a label whose every sample landed within a few ticks of the device's timestamp counter (tick_ms) — those passes ran and the device resolved no duration for them, which is not the same as a measured zero. ran is whether the label recorded a measured pass in the newest resolved frame, and last_frame the newest frame that did. The window outlives the work it describes, so a pass that stops being recorded leaves a row standing for up to window_frames frames carrying the median of the frames it did run in: read ran to answer whether a pass is running, frame - last_frame for how many resolved frames ago it last did, and ms as the cost of the frames it ran in. frame_span_ms (first pass begin to last pass end) and total_ms are medians too, so rows do not sum to total_ms, and the GPU may overlap passes so total_ms can exceed frame_span_ms. The readback is asynchronous: the window lags the live frame by a few frames.

Returns GpuFrameReport — GPU timing window, spans ranked by median ms descending.

local g = profiler.gpuFrame(); print(g.frame_span_ms, g.spans[1] and g.spans[1].label)

globals/profiler/hits

profiler.hits(label: string?) -> string?

Drain the watchdog's recorded hit frames into a capture stored under label (default "watch_hits") and clear the buffer. Returns the capture JSON (same shape as stopCapture), or nil if there were no hits.

Parameters

  • label string (optional) — Capture label to store under (default "watch_hits").

Returns string? — Capture JSON of the hit frames, or nil if none.

local json = profiler.hits()

globals/profiler/isCapturing

profiler.isCapturing() -> boolean

Check if a profiler capture is currently active.

Returns boolean — True if a capture is in progress.

if profiler.isCapturing() then ... end

globals/profiler/lastCapture

profiler.lastCapture() -> string?

Get the most recent completed capture result as a JSON string. Same shape as profiler.stopCapture(). Returns nil if no capture has been completed yet.

Returns string? — JSON string of the last capture, or nil.

local last = profiler.lastCapture()

globals/profiler/retro

profiler.retro(seconds: number?, label: string?) -> { [string]: any }?

Retroactively aggregate the last seconds of the ring (default: the whole ring). The full per-frame capture is retained under label (default "retro") for in-engine drill-down (the frame / hotspots tools); this RETURNS a compact structured aggregate table (frame-time distribution

  • per-system summary), never the raw per-frame array — bounded, so it is safe over the ZeroMind bridge. Code-facing primitive; the retro tool renders the agent-facing report. Latency-immune: the data is historical.

Parameters

  • seconds number (optional) — How many seconds back to include (default: whole ring).
  • label string (optional) — Capture label to store under (default "retro").

Returns { [string]: any }? — A compact aggregate { label, source, frames, seconds, exclude_agent, agent_frames, dt = { avg, p50, p90, p99, max, min }, summary = {...} }, or nil if the ring holds nothing.

local agg = profiler.retro(8, "collapse")

globals/profiler/ringStatus

profiler.ringStatus() -> string

Ring buffer status as a JSON string: { enabled, frames, capacity, span_seconds }.

Returns string — JSON status string.

local s = profiler.ringStatus()

globals/profiler/startCapture

profiler.startCapture(label: string?) -> boolean

Start recording per-frame profiler data. Each frame's system timings are captured until stopCapture() is called. Results are accessible via profiler.lastCapture() and VFS at /zero/runtime/profiler/<label>.json.

Parameters

  • label string (optional) — Capture label (default "capture").

Returns boolean — True if capture started, false if a capture is already active.

if profiler.startCapture("frame-spike") then ... end

globals/profiler/stats

profiler.stats(pattern: string?) -> { ProfilerStat }

Get current EMA profiling statistics from the SystemProfiler. Optional glob pattern filters by metric name (supports * and ? wildcards). Each entry carries two averages: avg_ms averages one RUN of the block and is folded when the block runs, so a block that has stopped running keeps the last value it saw; avg_frame_ms averages one FRAME and is folded every frame, including the frames the block did not run in, so it is the block's share of the current frame and falls back to zero once the block stops running.

Parameters

  • pattern string (optional) — Filter pattern (e.g. "schedule.", "system.schedule.render.").

Returns { ProfilerStat } — Array of profiler block stats.

for _, s in ipairs(profiler.stats("schedule.*")) do print(s.name, s.avg_frame_ms) end

globals/profiler/stopCapture

profiler.stopCapture() -> string?

Stop the active profiler capture and return its result as a JSON string. The capture is also saved to VFS at /zero/runtime/profiler/<label>.json. Top-level fields: label, frame_count, started_at, ended_at, frames, summary. Compute duration as ended_at - started_at.

Returns string? — JSON capture result, or nil if no capture was active.

local json = profiler.stopCapture()

globals/profiler/unwatch

profiler.unwatch()

Disarm the watchdog. Recorded hits are kept for a final profiler.hits().

profiler.unwatch()

globals/profiler/watch

profiler.watch(ceilingMs: number, mode: string?, excludeAgent: boolean?, maxHits: number?) -> boolean

Arm the frame-time watchdog. When a frame's EFFECTIVE time (total minus agent-injected execute cost) crosses ceilingMs, mode "record" logs every offending frame (read with profiler.hits()), and mode "pause" pauses gameplay ONCE to freeze the bad state, then disarms. Editor profile only: returns false in the runtime profile. excludeAgent (default true) keeps the agent's own calls from tripping it.

Parameters

  • ceilingMs number — Effective frame-time ceiling in ms.
  • mode string (optional) — "record" (default) or "pause".
  • excludeAgent boolean (optional) — Subtract agent cost before comparing (default true).
  • maxHits number (optional) — Max frames retained in record mode (default 240).

Returns boolean — True if armed, false if refused (runtime profile).

if profiler.watch(50, "pause") then ... end

globals/profiler/watchStatus

profiler.watchStatus() -> string

Watchdog status as a JSON string: { armed, ceiling_ms, mode, exclude_agent, hits, dropped_hits, tripped }.

Returns string — JSON status string.

local s = profiler.watchStatus()

modules/profiler/README

require("@builtin/modules/api/engine/profiler") -- profiler (also available as global 'profiler')

Frame-level profiler capture, EMA stats, and named profiling blocks. Public Luau surface over the __profiler Internal FFI namespace.

Usage: local profiler = require("@builtin/modules/api/engine/profiler") Also available as global: profiler

modules/profiler/begin

begin(name: string)

Start a named profiling block. Call profiler.finish(name) to record the duration. Blocks appear in profiler.stats() under "script.<name>" and inside captures.

Parameters

  • name string — Block name (e.g. "MyComponent.update").
profiler.begin("MyComponent.update"); ...; profiler.finish()

modules/profiler/disableRing

disableRing()

Disable the ring buffer and clear its history.

profiler.disableRing()

modules/profiler/enableRing

enableRing(seconds: number?): boolean

Enable the always-recording ring buffer, retaining the last seconds of per-frame data (default 20). Query it AFTER the fact with profiler.retro() — latency-immune, since the data is historical. Editor profile only: returns false in the runtime profile. The enable is the gate; the ring costs nothing until on.

Parameters

  • seconds number? (optional) — Seconds of history to retain (default 20).
if profiler.enableRing(30) then ... end

modules/profiler/finish

finish(name: string?): number?

Finish a profiling block and record the elapsed duration as "script.<name>". Without an argument, closes the most-recently- begun block (LIFO stack). With a name, closes the most recent block whose name matches — useful when blocks of different names are nested.

Parameters

  • name string? (optional) — Block name to finish. Omit to pop the top of the stack.
local ms = profiler.finish("MyComponent.update")

modules/profiler/gpuFrame

gpuFrame(): GpuFrameReport

Label-aggregated GPU pass timings over the last window_frames resolved frames, measured with GPU timestamp queries. supported is false when the device lacks timestamp queries — spans stays empty. Each span covers every render/compute pass recorded under one label — compute.<shader> per compute dispatch, scene.* for the scene passes, post.<effect> per post-process effect, feature.* for render-feature passes: ms is the median of its per-frame totals, min_ms/max_ms the range that median sits in, count the passes per frame and frames how much of the window carried it. at_floor marks a label whose every sample landed within a few ticks of the device's timestamp counter (tick_ms) — those passes ran and the device resolved no duration for them, which is not the same as a measured zero. ran is whether the label recorded a measured pass in the newest resolved frame, and last_frame the newest frame that did. The window outlives the work it describes, so a pass that stops being recorded leaves a row standing for up to window_frames frames carrying the median of the frames it did run in: read ran to answer whether a pass is running, frame - last_frame for how many resolved frames ago it last did, and ms as the cost of the frames it ran in. frame_span_ms (first pass begin to last pass end) and total_ms are medians too, so rows do not sum to total_ms, and the GPU may overlap passes so total_ms can exceed frame_span_ms. The readback is asynchronous: the window lags the live frame by a few frames.

local g = profiler.gpuFrame(); print(g.frame_span_ms, g.spans[1] and g.spans[1].label)

modules/profiler/hits

hits(label: string?): string?

Drain the watchdog's recorded hit frames into a capture stored under label (default "watch_hits") and clear the buffer. Returns the capture JSON (same shape as stopCapture), or nil if there were no hits.

Parameters

  • label string? (optional) — Capture label to store under (default "watch_hits").
local json = profiler.hits()

modules/profiler/isCapturing

isCapturing(): boolean

Check if a profiler capture is currently active.

if profiler.isCapturing() then ... end

modules/profiler/lastCapture

lastCapture(): string?

Get the most recent completed capture result as a JSON string. Same shape as profiler.stopCapture(). Returns nil if no capture has been completed yet.

local last = profiler.lastCapture()

modules/profiler/measure<T...>

measure<T...>(name: string, fn: () -> T...): T...

Run a function inside a profiling block. Equivalent to a begin/finish pair but handles errors correctly. Returns the function's return values.

Parameters

  • name string — Block name.
  • fn () -> T... — Function to profile.
local count = profiler.measure("walk", function() return walk() end)

modules/profiler/retro

retro(seconds: number?, label: string?): { [string]: any }?

Retroactively aggregate the last seconds of the ring (default: the whole ring). The full per-frame capture is retained under label (default "retro") for in-engine drill-down (the frame / hotspots tools); this RETURNS a compact structured aggregate table (frame-time distribution

  • per-system summary), never the raw per-frame array — bounded, so it is safe over the ZeroMind bridge. Code-facing primitive; the retro tool renders the agent-facing report. Latency-immune: the data is historical.

Parameters

  • seconds number? (optional) — How many seconds back to include (default: whole ring).
  • label string? (optional) — Capture label to store under (default "retro").
local agg = profiler.retro(8, "collapse")

modules/profiler/ringStatus

ringStatus(): string

Ring buffer status as a JSON string: { enabled, frames, capacity, span_seconds }.

local s = profiler.ringStatus()

modules/profiler/startCapture

startCapture(label: string?): boolean

Start recording per-frame profiler data. Each frame's system timings are captured until stopCapture() is called. Results are accessible via profiler.lastCapture() and VFS at /zero/runtime/profiler/<label>.json.

Parameters

  • label string? (optional) — Capture label (default "capture").
if profiler.startCapture("frame-spike") then ... end

modules/profiler/stats

stats(pattern: string?): { ProfilerStat }

Get current EMA profiling statistics from the SystemProfiler. Optional glob pattern filters by metric name (supports * and ? wildcards). Each entry carries two averages: avg_ms averages one RUN of the block and is folded when the block runs, so a block that has stopped running keeps the last value it saw; avg_frame_ms averages one FRAME and is folded every frame, including the frames the block did not run in, so it is the block's share of the current frame and falls back to zero once the block stops running.

Parameters

  • pattern string? (optional) — Filter pattern (e.g. "schedule.", "system.schedule.render.").
for _, s in ipairs(profiler.stats("schedule.*")) do print(s.name, s.avg_frame_ms) end

modules/profiler/stopCapture

stopCapture(): string?

Stop the active profiler capture and return its result as a JSON string. The capture is also saved to VFS at /zero/runtime/profiler/<label>.json. Top-level fields: label, frame_count, started_at, ended_at, frames, summary. Compute duration as ended_at - started_at.

local json = profiler.stopCapture()

modules/profiler/unwatch

unwatch()

Disarm the watchdog. Recorded hits are kept for a final profiler.hits().

profiler.unwatch()

modules/profiler/watch

watch(ceilingMs: number, mode: string?, excludeAgent: boolean?, maxHits: number?): boolean

Arm the frame-time watchdog. When a frame's EFFECTIVE time (total minus agent-injected execute cost) crosses ceilingMs, mode "record" logs every offending frame (read with profiler.hits()), and mode "pause" pauses gameplay ONCE to freeze the bad state, then disarms. Editor profile only: returns false in the runtime profile. excludeAgent (default true) keeps the agent's own calls from tripping it.

Parameters

  • ceilingMs number — Effective frame-time ceiling in ms.
  • mode string? (optional) — "record" (default) or "pause".
  • excludeAgent boolean? (optional) — Subtract agent cost before comparing (default true).
  • maxHits number? (optional) — Max frames retained in record mode (default 240).
if profiler.watch(50, "pause") then ... end

modules/profiler/watchStatus

watchStatus(): string

Watchdog status as a JSON string: { armed, ceiling_ms, mode, exclude_agent, hits, dropped_hits, tripped }.

local s = profiler.watchStatus()

tools/profiler/compare

profiler.compare(before?: string, after?: string) -> string

Diff two recordings (made with record): the change in avg/p90 frametime, and the systems that moved the most between them. Record a baseline, make a change, record again, then compare to see whether the change helped and which system it moved. A positive frametime delta means B is slower than A.

Parameters

  • before string (optional)
  • after string (optional)

Returns string

"baseline", "optimized"  -- did 'optimized' beat 'baseline', and where

tools/profiler/flamegraph

profiler.flamegraph(seconds?: number, mode?: ("run" | "start" | "stop" | "snapshot"), label?: string) -> string

Sample the Luau call stack to find the hottest code paths — the depth view under a hot component or task_scheduler. flamegraph(seconds) runs the sampler for that long (resetting first) and returns the top stacks plus a folded-stack file for flamegraph rendering. mode picks a manual phase instead: "start" / "stop" a long session, or "snapshot" the current top stacks without stopping.

Parameters

  • seconds number (optional)
  • mode ("run" | "start" | "stop" | "snapshot") (optional)
  • label string (optional)

Returns string

-- 3s sample, top stacks + folded file
5          -- 5s sample
0, "snapshot" -- top stacks right now without stopping a session

tools/profiler/frame

profiler.frame(source?: string, which?: ("worst" | "typical"), minMs?: number) -> string

The current frame's time as a self-accounting tree: schedule -> system -> sub-timing, each row showing SELF (own time excluding children), TOTAL, calls, and % of frame. Time no schedule covers (the frame limiter / vsync wait, GPU present, event loop) shows as present / idle; each expensive component's update(dt) is listed by component + entity under lua_update.vm_call. Read the live frame with source "now", or a stopped recording by its label (its worst or typical frame). Read from top: the row with the largest SELF time is where the frame time actually goes.

Parameters

  • source string (optional)
  • which ("worst" | "typical") (optional)
  • minMs number (optional)

Returns string

-- live frame, full attributed tree
"combat", "worst"     -- the worst frame of the 'combat' recording
"combat", "typical"   -- the typical (average) frame of that recording

tools/profiler/gpu

profiler.gpu(n?: number) -> string

GPU pass timings over the last resolved frames, from GPU timestamp queries. Each row is one label: the median of its per-frame total, the min/max that median sits in, the passes per frame, and how many of the window's frames carried it. A row marked floor ran but the device resolved no duration for it — its two timestamps retired within a few ticks of each other. The last column is how many resolved frames ago the label last recorded a pass: now is a pass running in the frame the table describes, and anything else is a row the window still holds after the work under it stopped. The readback is asynchronous, so the window lags the live frame by a few frames. Use this when frame shows the time under present / idle (GPU-bound) and you need to know which passes the GPU spends it on.

Parameters

  • n number (optional)

Returns string

-- top 20 GPU spans of the window
40     -- top 40

tools/profiler/hits

profiler.hits(label?: string, spikeMs?: number) -> string

Drain the frames a record-mode watch caught and report them as a spike-cluster distribution — every caught frame grouped by its dominant hotspot, so repeated hitches collapse to their handful of causes instead of a wall of individual frames. Draining clears the buffer. The snapshot is stored under label — drill into any cluster with frame <label> / hotspots <label>. Arm the watchdog first with watch <ceilingMs>.

Parameters

  • label string (optional)
  • spikeMs number (optional)

Returns string

-- review everything the watchdog caught
"collapse_hits"    -- store under a name for later drill-down

tools/profiler/hotspots

profiler.hotspots(n?: number, source?: string, which?: ("worst" | "typical")) -> string

Rank the frame's costs by SELF time (a node's own cost, excluding its children) and return the top n as a flat table. Because it ranks by SELF, the top rows are the actual expensive leaves — a system's own work or a single heavy component's update(dt) — not the schedules that merely contain them. This is the "just tell me what's slow" tool; follow a hit down with frame. Reads the live frame ("now") or a stopped recording's worst/typical frame (pass its label).

Parameters

  • n number (optional)
  • source string (optional)
  • which ("worst" | "typical") (optional)

Returns string

-- top 12 costs in the live frame
20           -- top 20
12, "combat" -- top 12 in the worst frame of the 'combat' recording

tools/profiler/memory

profiler.memory(n?: number) -> string

The Luau VM's memory: total heap + GC state, then the components retaining the most memory (per instance). Use it to catch a growing script — take it, play/test, take it again, and watch which component's retained bytes climb. Complements the frame-time tools: memory pressure shows up as GC cost in frame (the gc node) and as crashes under load, not as one slow system.

Parameters

  • n number (optional)

Returns string

-- VM total + top 20 components by retained memory
10    -- top 10

tools/profiler/record

profiler.record(action?: ("start" | "stop" | "status"), label?: string, spikeMs?: number) -> string

Start / stop / check a background profiling recording that spans a play session. record("start", label) begins capturing every frame; play or test the game across as many turns as you want, then record("stop", label) returns the windowed breakdown: avg / p50 / p90 / p99 / max frametime, spike count, the single WORST frame's attributed tree, and a typical-frame tree. record("status") reports whether a recording is running. A stopped recording is kept under its label — analyse it later with frame, hotspots, or scripts (pass the label as their source), or diff two of them with compare.

Parameters

  • action ("start" | "stop" | "status") (optional)
  • label string (optional)
  • spikeMs number (optional)

Returns string

"start", "combat"      -- begin recording a combat encounter
"stop", "combat"       -- end it, get the windowed breakdown
"status"               -- is a recording running right now?

tools/profiler/retro

profiler.retro(seconds?: number, label?: string, spikeMs?: number) -> string

Retroactively read the ring buffer's last seconds of frames (default: the whole ring) as a spike-cluster report — the latency-immune profiler. Enable the ring first (ring on), drive the scene, then call this AFTER the spike: the data is historical, so your call's timing does not matter. The report groups every spike frame by its dominant hotspot (so one call shows the full distribution of what's slow, not one anecdote), on EFFECTIVE frame time (your own execute cost excluded). The snapshot is stored under label — drill into any cluster with frame <label> / hotspots <label>.

Parameters

  • seconds number (optional)
  • label string (optional)
  • spikeMs number (optional)

Returns string

-- the whole ring, clustered
8               -- just the last 8 seconds
8, "collapse"   -- last 8s, stored as 'collapse' for drill-down

tools/profiler/ring

profiler.ring(action?: ("on" | "off" | "status"), seconds?: number) -> string

Control the retroactive ring buffer — an always-recording, bounded history of the last N seconds of per-frame data you query AFTER the fact with retro. ring("on", seconds) starts it (default 20s); ring("off") stops and clears it; ring("status") reports whether it's on, how many frames and seconds it holds. Editor profile only — a no-op in the runtime profile. The ring is off until you turn it on, so it costs nothing until then. This is the fix for "I can't profile a spike I only see afterwards".

Parameters

  • action ("on" | "off" | "status") (optional)
  • seconds number (optional)

Returns string

"on", 30      -- keep the last 30 seconds, always
"status"      -- is the ring on? how much does it hold?
"off"         -- stop recording and clear the history

tools/profiler/scripts

profiler.scripts(n?: number, minMs?: number, type_?: string) -> string

Rank components by update(dt) cost — the content view of where the frame's script time goes. Rolled up per component type by default (many instances of a type collapse to Type xN with summed cost; a lone instance keeps its @ entity), so it stays readable whether a world has three scripts or three hundred. Pass a type to drill into that one type's individual instances (which entity is the heavy one). Use it after hotspots/frame point at lua_update.vm_call. Counts COMPONENT update loops; a scene entrypoint's per-frame update / editorUpdate runs on the scheduler, and scene.cost ranks the loaded scenes by what theirs costs.

Parameters

  • n number (optional)
  • minMs number (optional)
  • type_ string (optional)

Returns string

-- every component type, heaviest first
10       -- the 10 heaviest types
20, 0.5, "MyMover" -- instances of MyMover costing >= 0.5ms, by entity

tools/profiler/tasks

profiler.tasks(n?: number, minMs?: number) -> string

Rank components by the time the scheduler spent resuming their coroutines this frame — the content breakdown of task_scheduler. Rolled up per component type (many instances collapse to Type xN; a lone instance keeps its @ entity). Use this when frame/hotspots show task_scheduler hot and you need to know whose task.spawn / task.wait work is behind it.

Parameters

  • n number (optional)
  • minMs number (optional)

Returns string

-- every component's coroutine cost, heaviest first
10       -- the 10 heaviest

tools/profiler/watch

profiler.watch(ceilingMs?: (number | "off" | "status"), mode?: ("record" | "pause"), excludeAgent?: boolean) -> string

Arm a frame-time watchdog that catches bad frames without you having to poll (which always lands seconds late). Call with a number to arm: watch(50) records every frame whose EFFECTIVE time (agent cost excluded) is >= 50ms; watch(50, "pause") instead pauses gameplay the first time the ceiling is crossed, freezing the bad state for you to inspect (then read it with retro). watch("off") disarms; watch("status") (or no arg) reports state and hit count. Read recorded hits with the hits tool. Editor profile only — returns a refusal in the runtime profile. excludeAgent (default true) keeps your own execute/write frames from tripping it.

Parameters

  • ceilingMs (number | "off" | "status") (optional)
  • mode ("record" | "pause") (optional)
  • excludeAgent boolean (optional)

Returns string

50                 -- record every frame over 50ms effective
50, "pause"        -- pause gameplay the first time a frame exceeds 50ms
"status"           -- armed? how many hits so far?
"off"              -- disarm

typed/builtin//modules/api/engine/profiler/profiler/begin

profiler.begin(name: string)

Start a named profiling block. Call profiler.finish(name) to record the duration. Blocks appear in profiler.stats() under "script.<name>" and inside captures.

Parameters

  • name string — Block name (e.g. "MyComponent.update").
profiler.begin("MyComponent.update"); ...; profiler.finish()

typed/builtin//modules/api/engine/profiler/profiler/disableRing

profiler.disableRing()

Disable the ring buffer and clear its history.

profiler.disableRing()

typed/builtin//modules/api/engine/profiler/profiler/enableRing

profiler.enableRing(seconds: number?) -> boolean

Enable the always-recording ring buffer, retaining the last seconds of per-frame data (default 20). Query it AFTER the fact with profiler.retro() — latency-immune, since the data is historical. Editor profile only: returns false in the runtime profile. The enable is the gate; the ring costs nothing until on.

Parameters

  • seconds number (optional) — Seconds of history to retain (default 20).

Returns boolean — True if enabled, false if refused (runtime profile).

if profiler.enableRing(30) then ... end

typed/builtin//modules/api/engine/profiler/profiler/finish

profiler.finish(name: string?) -> number?

Finish a profiling block and record the elapsed duration as "script.<name>". Without an argument, closes the most-recently- begun block (LIFO stack). With a name, closes the most recent block whose name matches — useful when blocks of different names are nested.

Parameters

  • name string (optional) — Block name to finish. Omit to pop the top of the stack.

Returns number? — Elapsed milliseconds, or nil if no matching block was active.

local ms = profiler.finish("MyComponent.update")

typed/builtin//modules/api/engine/profiler/profiler/gpuFrame

profiler.gpuFrame() -> GpuFrameReport

Label-aggregated GPU pass timings over the last window_frames resolved frames, measured with GPU timestamp queries. supported is false when the device lacks timestamp queries — spans stays empty. Each span covers every render/compute pass recorded under one label — compute.<shader> per compute dispatch, scene.* for the scene passes, post.<effect> per post-process effect, feature.* for render-feature passes: ms is the median of its per-frame totals, min_ms/max_ms the range that median sits in, count the passes per frame and frames how much of the window carried it. at_floor marks a label whose every sample landed within a few ticks of the device's timestamp counter (tick_ms) — those passes ran and the device resolved no duration for them, which is not the same as a measured zero. ran is whether the label recorded a measured pass in the newest resolved frame, and last_frame the newest frame that did. The window outlives the work it describes, so a pass that stops being recorded leaves a row standing for up to window_frames frames carrying the median of the frames it did run in: read ran to answer whether a pass is running, frame - last_frame for how many resolved frames ago it last did, and ms as the cost of the frames it ran in. frame_span_ms (first pass begin to last pass end) and total_ms are medians too, so rows do not sum to total_ms, and the GPU may overlap passes so total_ms can exceed frame_span_ms. The readback is asynchronous: the window lags the live frame by a few frames.

Returns GpuFrameReport — GPU timing window, spans ranked by median ms descending.

local g = profiler.gpuFrame(); print(g.frame_span_ms, g.spans[1] and g.spans[1].label)

typed/builtin//modules/api/engine/profiler/profiler/hits

profiler.hits(label: string?) -> string?

Drain the watchdog's recorded hit frames into a capture stored under label (default "watch_hits") and clear the buffer. Returns the capture JSON (same shape as stopCapture), or nil if there were no hits.

Parameters

  • label string (optional) — Capture label to store under (default "watch_hits").

Returns string? — Capture JSON of the hit frames, or nil if none.

local json = profiler.hits()

typed/builtin//modules/api/engine/profiler/profiler/isCapturing

profiler.isCapturing() -> boolean

Check if a profiler capture is currently active.

Returns boolean — True if a capture is in progress.

if profiler.isCapturing() then ... end

typed/builtin//modules/api/engine/profiler/profiler/lastCapture

profiler.lastCapture() -> string?

Get the most recent completed capture result as a JSON string. Same shape as profiler.stopCapture(). Returns nil if no capture has been completed yet.

Returns string? — JSON string of the last capture, or nil.

local last = profiler.lastCapture()

typed/builtin//modules/api/engine/profiler/profiler/retro

profiler.retro(seconds: number?, label: string?) -> { [string]: any }?

Retroactively aggregate the last seconds of the ring (default: the whole ring). The full per-frame capture is retained under label (default "retro") for in-engine drill-down (the frame / hotspots tools); this RETURNS a compact structured aggregate table (frame-time distribution

  • per-system summary), never the raw per-frame array — bounded, so it is safe over the ZeroMind bridge. Code-facing primitive; the retro tool renders the agent-facing report. Latency-immune: the data is historical.

Parameters

  • seconds number (optional) — How many seconds back to include (default: whole ring).
  • label string (optional) — Capture label to store under (default "retro").

Returns { [string]: any }? — A compact aggregate { label, source, frames, seconds, exclude_agent, agent_frames, dt = { avg, p50, p90, p99, max, min }, summary = {...} }, or nil if the ring holds nothing.

local agg = profiler.retro(8, "collapse")

typed/builtin//modules/api/engine/profiler/profiler/ringStatus

profiler.ringStatus() -> string

Ring buffer status as a JSON string: { enabled, frames, capacity, span_seconds }.

Returns string — JSON status string.

local s = profiler.ringStatus()

typed/builtin//modules/api/engine/profiler/profiler/startCapture

profiler.startCapture(label: string?) -> boolean

Start recording per-frame profiler data. Each frame's system timings are captured until stopCapture() is called. Results are accessible via profiler.lastCapture() and VFS at /zero/runtime/profiler/<label>.json.

Parameters

  • label string (optional) — Capture label (default "capture").

Returns boolean — True if capture started, false if a capture is already active.

if profiler.startCapture("frame-spike") then ... end

typed/builtin//modules/api/engine/profiler/profiler/stats

profiler.stats(pattern: string?) -> { ProfilerStat }

Get current EMA profiling statistics from the SystemProfiler. Optional glob pattern filters by metric name (supports * and ? wildcards). Each entry carries two averages: avg_ms averages one RUN of the block and is folded when the block runs, so a block that has stopped running keeps the last value it saw; avg_frame_ms averages one FRAME and is folded every frame, including the frames the block did not run in, so it is the block's share of the current frame and falls back to zero once the block stops running.

Parameters

  • pattern string (optional) — Filter pattern (e.g. "schedule.", "system.schedule.render.").

Returns { ProfilerStat } — Array of profiler block stats.

for _, s in ipairs(profiler.stats("schedule.*")) do print(s.name, s.avg_frame_ms) end

typed/builtin//modules/api/engine/profiler/profiler/stopCapture

profiler.stopCapture() -> string?

Stop the active profiler capture and return its result as a JSON string. The capture is also saved to VFS at /zero/runtime/profiler/<label>.json. Top-level fields: label, frame_count, started_at, ended_at, frames, summary. Compute duration as ended_at - started_at.

Returns string? — JSON capture result, or nil if no capture was active.

local json = profiler.stopCapture()

typed/builtin//modules/api/engine/profiler/profiler/unwatch

profiler.unwatch()

Disarm the watchdog. Recorded hits are kept for a final profiler.hits().

profiler.unwatch()

typed/builtin//modules/api/engine/profiler/profiler/watch

profiler.watch(ceilingMs: number, mode: string?, excludeAgent: boolean?, maxHits: number?) -> boolean

Arm the frame-time watchdog. When a frame's EFFECTIVE time (total minus agent-injected execute cost) crosses ceilingMs, mode "record" logs every offending frame (read with profiler.hits()), and mode "pause" pauses gameplay ONCE to freeze the bad state, then disarms. Editor profile only: returns false in the runtime profile. excludeAgent (default true) keeps the agent's own calls from tripping it.

Parameters

  • ceilingMs number — Effective frame-time ceiling in ms.
  • mode string (optional) — "record" (default) or "pause".
  • excludeAgent boolean (optional) — Subtract agent cost before comparing (default true).
  • maxHits number (optional) — Max frames retained in record mode (default 240).

Returns boolean — True if armed, false if refused (runtime profile).

if profiler.watch(50, "pause") then ... end

typed/builtin//modules/api/engine/profiler/profiler/watchStatus

profiler.watchStatus() -> string

Watchdog status as a JSON string: { armed, ceiling_ms, mode, exclude_agent, hits, dropped_hits, tripped }.

Returns string — JSON status string.

local s = profiler.watchStatus()
  • api
  • reference