Profiling
Tecs provides two methods for performance profiling games:
- A LuaJIT sampling profiler that writes collapsed-stack output: "What are the slow parts of my code?"
- LuaJIT trace aborts: "Is the JIT working on my code?"
Sampling profiler
The sampling profiler tags every collected stack with the active zone path, so you can attribute time to phases ("afterFixed/Render"), systems ("gfx.sprite"), and arbitrary sub-regions you push yourself. Output is the standard collapsed-stack format consumed by speedscope.app, FlameGraph.pl, inferno, pyroscope, and most other flamegraph tools.
MCP client
The easiest way to profile is to connect to a running game via the built-in MCP server and ask your AI agent to do it.
You: My game feels sluggish, profile it for 5 seconds.
Agent: *starts profiler*
...5 seconds later
*saves profile to /tmp/tecs.collapsed*
gfx.GPURender/gBuffer dominates at 83.8% of frame time.Timed session
Start a session, schedule a one-shot system to stop it after a delay. tecs.runif.after fires once and removes itself from the pipeline. Pass a path to :stop() to write the collapsed-stack text directly.
local profile = require("tecs.utils.profile")
local session = profile.sample()
world:addSystem({
phase = tecs.phases.First,
runIf = tecs.runif.after(5),
run = function()
session:stop("/tmp/tecs.collapsed")
end
})Saving in Love2D
In a LOVE game, get a path inside the save directory with:
local filename = love.filesystem.getSaveDirectory() .. "/tecs.collapsed"Custom zones
Push a jit.zone("name") to tag any region you want samples attributed to:
local zone = require("jit.zone")
world:addSystem({
name = "myGame.Render",
phase = tecs.phases.Render,
run = function(dt, world)
zone("uploadBuffers")
uploadBuffers()
zone()
zone("drawScene")
drawScene()
zone()
zone("postProcess")
postProcess()
zone()
end
})Where to put zones
- Zones are no-ops when no profile session is active, so leaving
zone("foo")calls sprinkled in shipped builds costs essentially nothing. - Don't push/pop inside hot inner loops, but do fence them around systems and major logic blocks.
Trace abort tracker
LuaJIT compiles hot loops into traces. When recording fails (NYI bytecode, IR overflow, trace too long, blacklisting after repeated failures, etc.), the trace is abandoned and that code falls back to the slower interpreter. Many aborts are harmless, but aborts in hot code are worth investigating. A sampled flamegraph will show the slow code as slow, but won't tell you the JIT gave up on it.
profile.trace attaches to LuaJIT's trace events and aggregates aborts into a report sorted by severity:
| Severity | What it means | What to do |
|---|---|---|
blacklist | LuaJIT permanently demoted this trace to the interpreter. | Investigate. |
warn | NYI bytecode (not yet implemented), FastFunc bailout, or a trace size limit. | If hot, consider rewriting the offending construct |
info | Benign trace formation events ("leaving loop", "down-recursion"). | Nothing; filtered out unless you opt in. |
Starting and stopping
Start a session, do something with the report when you stop it:
local profile = require("tecs.utils.profile")
local session = profile.trace()
-- ...later...
local report = session:stop()
print(report)Or pass a path to write the formatted report to disk:
session:stop("/tmp/aborts.txt")Periodic reporting via a system, restarting after each report:
local session = profile.trace()
world:addSystem({
name = "profile.traceReport",
phase = tecs.phases.First,
runIf = tecs.runif.every(10), -- run every 10 seconds
run = function()
local report = session:stop()
if report.blacklisted > 0 then
print(report)
end
session = profile.trace()
end
})Sample report
tostring(report) (and the file written by :stop(filename)) is RFC 4180 CSV, suitable for spreadsheets, sort, diff, and most tools:
severity,count,reason,location,zone
blacklist,1,blacklisted,src/enemy/ai.lua:142,update/enemyAi
warn,312,"NYI: FastFunc string.format",src/hud/score.lua:38,render/hud
warn,87,"NYI: bytecode CAT",src/dialog.lua:64,render/dialog
warn,37,trace too long,src/inventory.lua:201,update/inventoryRows are sorted by severity (blacklist > warn > info) then count descending, so the most actionable rows are at the top.
The top-level durationSec, totalAborts, and blacklisted summary values live on the returned TraceReport object (not in the CSV). Read them directly:
local report = session:stop("/tmp/aborts.csv")
print(string.format("%ds, %d aborts, %d blacklisted",
report.durationSec, report.totalAborts, report.blacklisted))API
profile.sample(opts?)
function profile.sample(opts?: SampleOptions): SampleSessionStarts a sampling session and returns a handle. Errors if a sample session is already active.
SampleOptions:
| Field | Default | Description |
|---|---|---|
intervalMs | 1 | Sampler interval in ms. 1 is the practical minimum; use 5 or 10 for long sessions. |
zone | nil | Restrict output to samples whose zone path starts with this prefix (e.g. "afterFixed/Render"). |
Leaf frames always carry a _[N]/_[I]/_[C]/_[G]/_[J] marker reflecting the dominant VM state (Native, Interpreter, C, GC, JIT compiler).
You can start a sample with no options:
local session = profile.sample()Or pass options to make sampling coarser or restrict to a single zone subtree:
local session = profile.sample({
intervalMs = 5,
zone = "afterFixed/Render",
})SampleSession:stop(filename?)
function SampleSession:stop(filename?: string): stringStops the session and returns the collapsed-stack text. If filename is given, the text is also written to that path as a side effect. Errors if called twice or if the file cannot be written.
Stop and inspect the text in memory:
local session = profile.sample()
-- ...later...
local text = session:stop()Stop and write the text to disk in one call (still returned, so you can also inspect it):
local text = session:stop("/tmp/tecs.collapsed")SampleSession:pause() / SampleSession:resume()
function SampleSession:pause()
function SampleSession:resume()Use :pause() and :resume() to skip recording samples. For example, this might be useful for excluding setup / teardown from a benchmark harness. Both methods are idempotent and error if the session has been stopped.
local s = profile.sample()
for _, case in ipairs(cases) do
setupCase(case)
s:resume()
runCase(case)
s:pause()
teardownCase(case)
end
local text = s:stop()profile.trace(opts?)
function profile.trace(opts?: TraceOptions): TraceSessionStarts a trace abort tracker and returns a handle. Errors if a trace session is already active.
TraceOptions:
| Field | Default | Description |
|---|---|---|
includeBenign | false | Include "leaving loop", "down-recursion", "up-recursion", and "inner loop" events. Useful when debugging trace formation. |
You can start a trace with no options:
local session = profile.trace()Or include the benign trace-formation events that are filtered out by default, useful when investigating why a trace failed to form:
local session = profile.trace({includeBenign = true})TraceSession:stop(filename?)
function TraceSession:stop(filename?: string): TraceReportStops the session and returns the TraceReport. If filename is given, the formatted report is also written to that path as a side effect. Errors if called twice or if the file cannot be written.
The returned TraceReport has a __tostring metamethod that renders RFC 4180 CSV, so print(report) works directly. The on-disk format is the same CSV you'd get from tostring(report).
TraceSession:pause() / TraceSession:resume()
function TraceSession:pause()
function TraceSession:resume()Use :pause() and :resume() to skip recording traces. Event registration stays active (cost: one boolean check per abort); counts captured on either side of the pause window are preserved. Calls are idempotent and error if the session has been stopped.
You can stop and inspect the report:
local session = profile.trace()
-- ...later...
local report = session:stop()
if report.blacklisted > 0 then
print(report)
endYou can stop and write the formatted report to disk in one call (the report is still returned):
local report = session:stop("/tmp/aborts.txt")TraceReport fields:
| Field | Description |
|---|---|
durationSec | Wallclock seconds the session was active. |
totalAborts | Total abort events recorded. Excludes benign trace-formation events unless includeBenign was set. |
blacklisted | Count of trace blacklist events. Always actionable. |
sites | List of AbortSite records, sorted by severity then count desc. |
AbortSite fields:
| Field | Description |
|---|---|
severity | "blacklist", "warn", or "info". |
count | Times this exact (severity, reason, location, zone) combination fired. |
reason | Human-readable reason from jit.vmdef.traceerr. NYI bytecode aborts are rendered with the opcode name. |
location | "<file>:<line>" of the function being recorded at abort time. |
zone | Active zone path at abort time (empty when no zone was on the stack). |