Profiling#
Nupp ships a profiler, so finding out where a program spends its time does not mean adding a dependency first.
There are two channels, and they answer different questions:
- Sampling (
--profile) says where the time went. A timer interrupts the program, writes down the stack, and the result is collapsed-stack text that speedscope.app, FlameGraph.pl and inferno all read. - Trace aborts (
--jit-aborts) says how the time was spent there — compiled or interpreted. It records every place LuaJIT tried to compile something and gave up.
The second one is the one a conventional profiler cannot answer, and on LuaJIT it is usually the one that matters. Code the compiler refused runs an order of magnitude slower than code it took, and nothing says so out loud: the function looks the same, it just is not fast.
Both handles satisfy profile.Session. Its associated Report preserves which channel produced the handle, so shared lifecycle helpers retain the concrete report:
Sampling a program#
nupp run --profile app.nupp
That writes profile.out and prints a summary:
nupp: 2043 samples on 61 stacks every 10ms, written to profile.out
Drop profile.out on speedscope.app and you have a flame graph. --profile=2 samples every 2 ms instead of the default 10; below about 10 the timer begins taking real time from the thread it is measuring, so treat a fine interval as something you spend for a short window. --profile-out puts the text somewhere other than profile.out.
Each line is one stack: frames separated by semicolons, then the sample count.
frame;physics;app.nupp:0;app.nupp:step_[N] 431
The leaf carries the VM state most of its samples were in:
_[N] running compiled machine code
_[I] in the interpreter
_[C] inside a C function
_[G] in the garbage collector
_[J] inside the JIT compiler
_[I] on something hot is the finding. It means the compiler is not running that code, and --jit-aborts will say why.
The stacks start at your program. The frames underneath — the loader that read it, the pcall that guards it — belong to nupp run, not to what you asked about, so they are cut.
One thing to know about reading the frames: LuaJIT inlines a compiled call chain into a single trace, and inlined frames are not on the stack to be walked. A hot call chain therefore arrives shorter than it reads in the source. That is the compiler doing its job, not the profiler losing frames.
Naming the parts of a program#
Frames tell you which function ran. Zones tell you which phase it ran in, and that is usually the question — the same sort called from loading and from rendering is two different problems.
nupp.zone is a stack of names that the profiler reads:
Samples taken inside those pushes lead with the zone path, so the flame graph opens on your phases and drills into the code under each:
frame;physics;app.nupp:stepWorld_[N] 812
frame;render;app.nupp:drawWorld_[N] 233
Pushing and popping costs nothing while no profiler is listening — the module checks one boolean and returns. What it used to cost, when a session is running, was a function call in the code being measured, and a call on the hottest path can stop a trace forming.
push and a pop whose result is discarded are compiler intrinsics for exactly this reason: written in statement position on a local zone =
require("nupp.zone") receiver, they are generated inline against the module's own state rather than called at all, so there is no call left for a hot path to pay for.
Spelling Lowered Why not
──────────────────────────────────── ──────── ─────────────────────────────
zone.push("frame") yes
zone.pop() yes result discarded
local name = zone.pop() no the popped name is kept
holder.zone.push("frame") no receiver is not a bare name
other.push("frame") no other is not nupp.zoneMark warm paths, not the innermost loop, regardless: push/pop still call through the ordinary API in every other spelling, and enter/leave below always do.
Use zone.enter and zone.leave instead of push/pop when the two halves might not run in the same session — a coroutine resumed after the profile stopped, say. enter hands back a token that a late leave discards rather than popping somebody else's zone.
Finding what the compiler refused#
nupp run --jit-aborts app.nupp
That writes jit-aborts.csv:
severity,count,reason,location,zone
warn,7,NYI: bytecode FNEW,app.nupp:41,frame/spawn
Each row is one place the compiler gave up, how often, and which zone was open. severity orders the file:
blacklist— always worth fixing. The trace is permanently demoted to the interpreter for the rest of the process. It will not be retried.warn— a refusal. Whether it matters depends on whether it is hot, which is what the sampling channel is for.info— trace formation working as designed: a loop was left, recursion was found. Left out unless you ask for it.
NYI: bytecode FNEW above is a closure being created inside a loop, which LuaJIT will not record. Hoisting the closure out of the loop is the fix, and running again is how you find out whether it was the only one.
From a program rather than the command line#
The flags are a thin wrapper over nupp.profile, which is worth using directly when the interesting window is not the whole run — a single frame, one request, the part after warm-up.
stop returns the report and ends the session; tostring on it is the text that was written. pause and resume leave a window out without ending anything, which is how a benchmark keeps its own setup out of the numbers.
The zone option filters at stop rather than while sampling, so narrowing it costs nothing at runtime — but it also means you cannot widen it afterwards. What fell outside the prefix was still collected; the prefix is fixed when the session starts.
The trace channel works the same way:
One session of each kind runs at a time — both are process-wide, because the VM hooks they attach to are — and starting a second while one is live is an error rather than a silent replacement. A handle dropped without stopping leaves the timer running or the hook attached for the rest of the process.
Neither channel is free. A sample session pays a timer interrupt, a stack walk and a table write at every interval; a trace session pays a callback inside the compiler at every abort. Stop a session once the question it was opened for has an answer.