Log inGet started
World

Builtin Library

Branchmain
Branchesmainpublish
Commit3bb1da2647

README.md

profiler Module

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

Purpose

Wrap the raw __profiler.* FFI namespace in a typed Luau table that gets auto-injected as _G.profiler via the prelude. Captures persist to VFS at /zero/runtime/profiler/<label>.json; EMA stats and named blocks land back in the engine's SystemProfiler resource so they're visible in captures and in the in-engine profiler panel.

Usage

-- Capture a window of frames. One recording runs at a time engine-wide,
-- and several agent sessions share one engine, so a refused start is
-- answered with who holds it rather than with the bare fact that
-- something does.
if not profiler.startCapture("hitch") then
    print("held by", profiler.recordingHold())
    return
end
task.wait(2)
local json = profiler.stopCapture()
print(json)

-- Read live EMA stats. `avg_frame_ms` is the block's share of the
-- current frame; `avg_ms` is what one run of it costs.
for _, s in ipairs(profiler.stats("schedule.*")) do
    print(s.name, s.avg_frame_ms, s.avg_ms, s.max_ms)
end

-- Named blocks
profiler.begin("ai.tick")
runTick()
profiler.finish("ai.tick")

-- Or scope it with measure() (handles errors)
local result = profiler.measure("ai.tick", function() return runTick() end)

Exports

  • profiler.startCapture(label?) -> boolean. Takes the one engine-wide recording slot for the calling caller. False when another recording holds it; profiler.recordingHold() then names what does.
  • profiler.recordingHold() -> string?. JSON { label, owner, started_at, held_seconds } for the live recording, or nil when none runs. owner is the caller key that took the slot, the same value vfs.currentAuthor().id reads for the calling caller, so a caller refused the slot tells its own earlier recording from a sibling session's.
  • profiler.stopCapture() -> string?. JSON capture summary. callers carries each caller's share of the window's queued Luau, and each frame carries its own split as agent_by_caller.
  • profiler.isCapturing() -> boolean
  • profiler.lastCapture() -> string?
  • profiler.stats(pattern?) -> { ProfilerStat } — live EMA stats. Each entry carries two averages: avg_ms averages one RUN of the block, folded when the block runs, so a block that has stopped running keeps the last value it saw; avg_frame_ms averages one FRAME, 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.
  • profiler.counters(pattern?) -> { ProfilerCount } — unitless per-frame tallies kept apart from the millisecond timings of stats, for example the engine thread's waits on the process-wide lock under system.schedule.frame.process_local_waits, and the queue submissions the renderer and the UI made under system.schedule.frame.queue_submissions (each one is a fixed cost to the GPU driver, whatever it carries), and the uploads the renderer's device staged: the buffer and texture writes under system.schedule.frame.queue_buffer_writes and system.schedule.frame.queue_texture_writes, and the staging buffers the device created to carry them under system.schedule.frame.staging_buffer_creations (the device stages a frame's writes in chunks it reuses, so a frame whose writes fit the chunks it holds creates none; in a browser's WebGPU the browser stages the writes and the three read zero), and the maps of the renderer's statistic readbacks under system.schedule.frame.gpu_readbacks, with each readback's own maps under that key followed by its name: hiz.occlusion_counts and instanced_compaction.submitted (the counts renderer.cullStats().occlusionTested / occlusionCulled and renderer.drawStats().compactedDrawn answer, read back on the two rendered frames after each read and one frame in 60 otherwise), cluster_cull.counters, visibility.resolve_counters and gpu_timing.readback (so system.schedule.frame.gpu_readbacks.hiz.occlusion_counts counts the occlusion counts' maps), and the profiler names resolved by hashing them under system.profiler.name_lookups: a measurement's name is resolved the first time it is recorded and found by its key after that, so a frame recording only measurements it has recorded before counts none (a profiler.begin / profiler.measure block finds its key by the Luau string naming it, so a name a script builds again after its last string was collected is resolved again). frame is the tally of the frame in progress, prev_frame the last complete frame's, max_frame the highest complete-frame tally and total the tally since the profiler started.
  • profiler.begin(name) / profiler.finish(name?) -> number? — paired named blocks, LIFO by default.
  • profiler.measure(name, fn) -> ... — scoped block around a function.