hafen.client: Who Spent the Frame
frame() says a frame cost so many milliseconds. These verbs say who: which addon, which widget, which render pass, what the client handed the driver, and what the measuring itself cost. All armed only: with the switch off they answer an empty table.
for _, row in ipairs(hafen.client():profiling():addons()) do
local milliseconds = math.floor(row.ms * 100 + 0.5) / 100 -- two decimals by hand: %.2f prints the raw double
hafen.log():write(string.format("%s %s ms (%d%%) draw=%d events=%d",
row.id, milliseconds, (row.share or 0) * 100, row.calls.draw, row.calls.events))
end
addons()
One row per Lua owner (every loaded addon, plus (console) for the :lua prompt) for the last completed frame, most expensive first.
| Key |
Description |
id |
The addon’s manifest id. |
ms |
Its Lua time in the last completed frame. |
msAvg / msPeak |
Mean and worst frame since the switch was armed, or since reset(). |
share |
ms as a fraction of that frame. Absent until a frame has been sampled. |
calls |
How many calls, by category: events, timers, draw, hooks, widgets, exports. |
cost |
The same split in milliseconds. |
scopes |
This addon’s named scopes, keyed by name. |
| Rule |
Detail |
total |
A key after the array, {ms=, share=}, the same number frame().addons reports: both read the addon CPU watchdog’s accounting. Folded a fraction of a frame apart: frame() closes with the frame, these rows on the tick after. Lua run in between is in the frame these rows close and not yet in frame()’s. Iterate the rows with ipairs. total is not part of the array. |
| Categories describe what your Lua was doing |
draw: overlay and widget paint callbacks, a grid’s cell paint included. widgets: the rest of a widget’s life (mouse input, tick, drop, close, destroy, a container’s item events, a control’s own key). hooks: hotkeys, console commands, a mouse grab’s move and release. events: the event bus and the two message streams. timers: timer callbacks. exports: calls into this addon’s export from other addons, and callbacks they handed it. |
| Re-entrancy |
A callback that calls back into the client, which calls your Lua again, is charged to both brackets, as the watchdog charges it. So cost can add up to slightly more than ms. ms is the number to trust. |
Custom scopes
scope(name) and measure(name, fn, ...) name a section of your code so its cost shows in your row of addons().
local profiling = hafen.client():profiling()
profiling:measure("scan-gobs", function() -- the wrapper form: you cannot forget to finish
for _, gob in ipairs(hafen.session():current():world():gob():list()) do hafen.log():write(gob:id()) end
end)
local rebuild_scope = profiling:scope("rebuild") -- the explicit form, for a section you cannot wrap
rebuild_scope:begin()
-- implementation here
rebuild_scope:finish()
| Method |
Returns |
Permission |
Description |
scope:begin() / scope:finish() |
the scope |
Unprotected |
Bracket a section. |
scope:name() |
string |
Unprotected |
The scope’s name. |
profiling:measure(name, fn, ...) |
whatever fn returns |
Unprotected |
Run fn(...) inside the scope. |
| Rule |
Detail |
| Arguments |
name a string, fn a function, both required. A missing or wrong-typed one raises naming the verb and the parameter. |
| Per addon |
Two addons may both use "update". A scope map dies with its addon on :reload or disable. Each scope appears in the addon’s addons() row as {ms=, msAvg=, msPeak=, calls=}. |
| 256 names |
A name is a section of code. Naming a scope after an item id, a coordinate or a frame number grows the map without bound. It also builds a table per entry per snapshot. Past the limit profiling:scope(name) raises. |
finish() always closes |
Armed or not. The switch decides whether the time is written down. |
| This-frame figures |
ms and calls are this frame’s, so a scope that ran a moment ago reads 0 and its cost lives in msPeak and msAvg. Read ms per frame from an Update. Read msPeak for how bad it gets. |
| Leave the instrumentation in |
With profiling off begin and finish return on a single field check and measure calls fn directly: nothing allocated, nothing recorded. measure runs fn either way and closes the scope if fn errors. Only the outermost begin/finish pair of a recursive section counts. An unmatched finish() is ignored. A scope left open by an erroring handler closes at end of frame. |
frame() says the widget tree cost so many milliseconds. widgets() says who.
local widget_costs = hafen.client():profiling():widgets()
for _, row in ipairs(widget_costs.byType) do
hafen.log():write(string.format("%s x%d self %s ms", row.type, row.count, math.floor(row.selfMs * 100 + 0.5) / 100))
end
| Key |
Description |
byType |
One row per widget class, sorted by self time. |
top |
The heaviest individual widgets, same sort. |
total |
The whole tree: count, the widgets with a live measurement, and tickMs, drawMs and ms where the root itself has one. A frame the client has not finished measuring carries count alone. |
byType row key |
Description |
type |
The widget class name. An anonymous subclass reports the class it extends. |
count |
How many were measured. |
tickMs / drawMs |
Time inclusive of children. |
tickSelfMs / drawSelfMs / selfMs |
The same exclusive of children. selfMs is the sum of the two. |
A top row carries type, selfMs, tickMs and drawMs. It adds id when the widget is bound to a server id. It adds owner, that addon’s manifest id, when an addon put it in the tree.
| Rule |
Detail |
| Inclusive versus self |
A widget is timed by its parent around the call that ticks or draws its subtree (tickMs, drawMs). Self time is that minus its children’s inclusive time, so a container holding one expensive child shows a large tickMs and a near-zero tickSelfMs. Every row’s selfMs sums to total.ms, the root’s inclusive tick and draw. |
| A clock discontinuity |
One mid-frame can time a child above its parent. A negative self time reads as zero and the rows then overshoot total.ms a little. |
| Which frame |
The last in which each widget was ticked or drawn. A widget not touched since (a closed window, a hidden tab) drops out of the tables. That is why total.count shrinks when you close a window. |
owner links to addons() |
An addon’s widgets are itemised here and rolled into that addon’s row: two views of one measurement. |
| Not in it |
tickMs is the tick traversal only, while frame()’s utick phase also covers the hover query and any resize. The draw phase covers the whole 3D scene, of which the map-view row is the widget-side share. The scene’s breakdown is passes. |
passes()
Where the GPU milliseconds went, over a fixed list of named sections with CPU and GPU time side by side.
for _, row in ipairs(hafen.client():profiling():passes()) do
local cpu, gpu = math.floor(row.cpuMs * 100 + 0.5) / 100, math.floor(row.gpuMs * 100 + 0.5) / 100
hafen.log():write(string.format("%s cpu %s ms gpu %s ms", row.name, cpu, gpu))
end
| Pass |
Covers |
shadow |
The entire shadow-map render. |
scene |
The 3D draw list, the world itself, and the rings gob:outline draws while one is on an object. |
ui2d |
The widget tree. |
| Rule |
Detail |
| Row shape |
{name=, cpuMs=, gpuMs=}, always in that order. The table also carries frameno, and ms and gpuMs for the whole frame. |
| Disjoint rows |
shadow and scene run inside the widget draw (the map view is a widget), so each pass reports self time, the split widgets() uses. ui2d is the 2D UI, and the rows sum to less than the frame. |
| CPU and GPU differ |
The CPU column is the time recording the GL commands, so shadow and scene are small while ui2d is real widget work. The GPU column is when the driver did it. |
| Which frame |
The newest whose GL timestamps have come back, the rule of gpuMs in frame(). Both columns describe that frame. Empty until the first frame resolves. |
| What shadows cost |
Turn Video ▸ Shadows off and shadow falls to zero and scene drops too, since the world’s shaders stop sampling the shadow map. |
| The list is fixed |
Every boundary is a GL timestamp query, which can stall the pipeline if overused: no per-draw-call or per-material GPU attribution. |
gl()
What the client handed the driver last frame.
| Key |
Description |
drawCalls |
Draw calls submitted. |
programBinds |
Shader-program switches. |
vertices / triangles |
Geometry submitted. Point and line geometry counts vertices, not triangles. |
frameno |
The frame these describe. |
New counting rather than numbers the client already keeps, which is why they sit behind the switch while render() does not. programBinds against drawCalls measures the batching: the draw list is sorted by program, so binds far below calls means the sort works.
overhead()
What profiling itself costs, per tier, so an expensive tier can be identified and disabled. Every figure is a mean per frame since the switch was armed, or since reset().
local overhead = hafen.client():profiling():overhead()
local function round(number, places) local scale = 10 ^ places return math.floor(number * scale + 0.5) / scale end
hafen.log():write(string.format("profiling costs %s ms/frame = %s%% (%s)",
round(overhead.totalMs, 4), round(overhead.shareOfFrame * 100, 2), overhead.method))
for _, tier in ipairs(overhead.tiers) do
hafen.log():write(string.format(" %s %s ms", tier.name, round(tier.ms, 4)))
end
| Key |
Description |
totalMs / shareOfFrame |
Ms per frame, and as a fraction of the frame. The share is absent with no frameMs. |
budget / withinBudget |
The ceiling this surface holds itself to, 5% of frame time, and whether what profiling spends is inside it. Absent with no frameMs. |
method |
"control" if totalMs was measured, "model" if calibrated. |
aggregatorMs |
The end-of-frame fold, timed directly. |
gpuQueryMs |
The GL timestamp queries the named passes insert, timed directly. |
probeMs |
The modelled probe cost: hits times a per-hit cost calibrated when the switch armed. |
measuredMs / measuredErrorMs |
The measured probe cost and its error bar. Absent until enough samples. |
measuredSpreadMs |
The comparison’s noise floor. |
frameMs |
Mean frame time. Absent until a frame has been measured. |
armedFrames / controlFrames / periods / periodsNeeded |
How much evidence there is so far. |
ringFrames |
How many frames of profile tree the client holds at a time. A cost the measurement cannot see. |
tiers |
One row per tier, keyed by name and ordered as a list: {name=, ms=, share=, modelledMs=, method=}, plus hits (probe hits per frame) on the modelled ones. |
| Tier |
Probes |
frame |
The end-of-frame fold. The only tier timed rather than modelled. |
addons |
The per-addon category split behind addons(). |
widgets |
The per-widget brackets behind widgets(). |
passes |
The named-pass seams and their GL timestamp queries. |
gl |
The submission counters behind gl(). |
| Rule |
Detail |
| Two numbers, cross-checking |
The modelled one counts probe hits times a per-hit cost measured when the switch armed. It is available at once and errs high: the calibration runs cold, the probes compiled. The measured one comes from control frames. One frame per batch runs with every probe disarmed. Each period contributes the median work time of its armed frames minus its control frame (work, not frame time, which vsync or a cap pins). method says which totalMs used. |
measuredMs of zero or less is normal |
The cost is under the comparison’s noise floor, not profiling making the client faster. The measurement takes over only when it clears measuredErrorMs. Otherwise the model has the say. Once authoritative, the tiers are scaled to it in the modelled proportion so the rows sum to totalMs. modelledMs is reported alongside. |
| What the measurement cannot see |
The probes are what a control frame disarms. What the profiler holds (the client’s own per-frame trees) stays, so a retention cost cancels in the subtraction. The measured method sees what profiling spends, never what it keeps, and withinBudget is a claim about the first. ringFrames reports the second as a count, since retention is paid as a collection pause on the collector’s schedule. Read it if you leave profiling armed. |
See Also