nupp.profile

Two profiling channels: one for where the time went, one for whether it went there compiled.

local profile = nupp.profile

with session = profile.sample({intervalMs = 5}) do
    render()
    local report = session:stop("profile.out")
    print(report.samples, report.stacks)
end

profile.sample is the statistical sampler. A timer interrupts the program and writes down where it was; what comes back is collapsed-stack text, the format speedscope.app, FlameGraph.pl and inferno all read.

profile.trace watches the JIT give up. It aggregates trace aborts, so blacklisted hot code, a bytecode the compiler will not record, and traces that grew past a limit all become rows rather than silence. That is the question a sampler cannot answer and the one that usually matters here: code the JIT refused is an order of magnitude slower than code it took, and nothing says so out loud.

Both channels attribute work through nupp.profile.zone, so a sample or an abort carries the zone path that was open when it happened. Both are process-wide rather than per-coroutine, and at most one session of each kind runs at a time.

Neither 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 at every abort, inside the compiler. A session is a nupp.Closeable, so a with ends it where its block does: stop returns the report, and a session nothing stopped is closed without one.

See Profiling for reading a report, and LuaJIT trace checking for finding the same aborts without running the program.

Submodules

ModuleDescription
nupp.profile.traceStable identities shared by static trace checking and the opt-in runtime collector.
nupp.profile.zoneGated LuaJIT profiler zones: the stack work is skipped until a profiler asks for it.

Module contents

Types

TypeKindDescription
AbortSiterecordA place the JIT gave up, and how often it did.
SamplerecordA distinct stack, and what landed on it.
SampleOptionstypeWhat profile.sample collects, and how much of it.
SampleReportrecordWhat a sample session saw, as SampleSession:stop returns it.
SampleSessionrecordA running sampler, as profile.sample returns it.
SessioninterfaceA running profiling session whose declaration chooses the report stop returns.
SeveritytypeHow much an abort is worth reading.
SourceSampletypeLua leaf samples attributed to a source line.
TraceOptionstypeWhat profile.trace counts.
TraceProfilerecordThe exact recorder configuration used to normalize a trace report.
TraceReportrecordWhat a trace session saw, as TraceSession:stop returns it.
TraceSessionrecordA running trace-abort collector, as profile.trace returns it.

Functions

FunctionKindDescription
samplefunctionStarts sampling.
tracefunctionStarts collecting trace aborts.

Types#

AbortSiterecord#

record profile.AbortSite ...

A place the JIT gave up, and how often it did.

A row of a TraceReport, built at stop.

Members

NameKindDescription
severityfieldHow much it is worth reading.
countfieldTimes this exact severity, reason, location and zone fired.
reasonfieldThe reason, from jit.vmdef.traceerr.
reasonIdfieldStable compiler identity and classification for the raw VM reason above.
reasonClassfieldWhat the identity is worth acting on: a blocker cannot record at all, a risk may or may not sit on a hot path, and a...
rawReasonfieldThe VM's own text, before the registry mapped it.
locationfield":" of the function being recorded when it aborted.
rootLocationfieldWhere the root trace began, including when recording entered a library.
zonePathfieldThe zone path that was open, "" when none was.

severityfield#

How much it is worth reading.

countfield#

count: integer

Times this exact severity, reason, location and zone fired.

reasonfield#

reason: string

The reason, from jit.vmdef.traceerr. An unrecordable bytecode is rendered with the opcode's name.

reasonIdfield#

reasonId: string

Stable compiler identity and classification for the raw VM reason above.

reasonClassfield#

reasonClass: 'blocker' | 'risk' | 'stop'

What the identity is worth acting on: a blocker cannot record at all, a risk may or may not sit on a hot path, and a stop is trace formation working as designed.

rawReasonfield#

rawReason: string

The VM's own text, before the registry mapped it.

locationfield#

location: string

":" of the function being recorded when it aborted.

rootLocationfield#

rootLocation: string?

Where the root trace began, including when recording entered a library.

zonePathfield#

zonePath: string

The zone path that was open, "" when none was.

Samplerecord#

record profile.Sample ...

A distinct stack, and what landed on it.

A row of a SampleReport, built at stop. The five state counts sum to count.

Members

NameKindDescription
zonePathfieldThe zone path the samples were taken under, "" when none was open.
stackfieldThe stack as dumpstack rendered it, ";" between frames, outermost first.
countfieldSamples on this stack, in every VM state.
compiledfieldSamples running compiled machine code.
interpretedfieldSamples in the interpreter.
cCodefieldSamples inside a C function.
collectingfieldSamples in the garbage collector.
compilingfieldSamples inside the JIT compiler itself.

zonePathfield#

zonePath: string

The zone path the samples were taken under, "" when none was open.

stackfield#

stack: string

The stack as dumpstack rendered it, ";" between frames, outermost first.

countfield#

count: integer

Samples on this stack, in every VM state.

compiledfield#

compiled: integer

Samples running compiled machine code.

interpretedfield#

interpreted: integer

Samples in the interpreter.

cCodefield#

cCode: integer

Samples inside a C function.

collectingfield#

collecting: integer

Samples in the garbage collector.

compilingfield#

compiling: integer

Samples inside the JIT compiler itself.

SampleOptionstype#

type profile.SampleOptions = {
    --- Milliseconds between samples; 10 by default, which is 100 a second. Must be
    --- between 1 and 2147483647, the range LuaJIT's profiler accepts. Below about 10
    --- the timer starts taking real time away from the thread it is measuring, so
    --- lower it for a short window and read the result knowing that it was paid for.
    intervalMs: integer?,

    --- How many frames to walk per sample; 16 by default. Must be between 1 and
    --- 2147483647, the range LuaJIT's stack dumper accepts. The walk is linear in
    --- this and it happens on the interrupted thread, so raise it only when a
    --- specific question needs the depth.
    stackDepth: integer?,

    --- Keep only the samples taken under a zone path starting with this, so
    --- "frame/render" reads as that subtree alone.
    ---
    --- Applied at `stop` rather than while sampling: narrowing it costs nothing at
    --- runtime, and widening it afterwards is not possible, because the prefix is fixed
    --- when the session starts.
    zone: string?,

    --- The module the program starts at, as a stack frame names it. Everything below
    --- the outermost frame from it is dropped.
    ---
    --- A profiler samples the whole stack it is embedded in, and what is under the
    --- program, the loader that read it and the pcall that guards it, is not the
    --- program. Naming its module cuts the report back to it.
    ---
    --- Frames read "<module>:<name>", so the module is the part to give. A stack with
    --- no frame from it is kept whole rather than emptied, and two stacks that differ
    --- only below the root become one, their counts summed.
    root: string?
}

What profile.sample collects, and how much of it. Every field is optional.

SampleReportrecord#

record profile.SampleReport ...

What a sample session saw, as SampleSession:stop returns it.

tostring on it is the collapsed-stack text, so it prints and pipes directly.

Members

NameKindDescription
intervalMsfieldThe interval the session ran at, so the sample counts can be read as time.
samplesfieldSamples recorded, after the zone filter.
sourceSamplesfieldLua leaf locations, ordered by file and line.
stacksfieldDistinct stacks they fell on.
textfieldOne line per stack: semicolon-separated frames, a space, then the sample count.
__tostringmetamethod

intervalMsfield#

intervalMs: integer

The interval the session ran at, so the sample counts can be read as time.

samplesfield#

samples: integer

Samples recorded, after the zone filter.

sourceSamplesfield#

sourceSamples: {profile.SourceSample}

Lua leaf locations, ordered by file and line. Their sum can be less than samples because native VM states have no reliable Lua source location.

stacksfield#

stacks: integer

Distinct stacks they fell on.

textfield#

text: string

One line per stack: semicolon-separated frames, a space, then the sample count. Ordered by count descending. Empty when nothing was sampled, which a short session and a zone prefix that matched nothing both produce.

__tostringmetamethod#

__tostring: function(self): string
Arguments
NameTypeDescription
?self
Returns
TypeDescription
string

SampleSessionrecord#

A running sampler, as profile.sample returns it.

Live until stop or close, and there is at most one at a time. Held by a with, it is closed where the block ends, so the timer never outlives the question.

Members

NameKindDescription
Reportfield
intervalMsfieldThe interval it was started at.
zoneFilterfieldThe zone prefix stop will filter by, or nil for all of them.
rootfieldThe module stop will cut the stacks back to, or nil to keep them whole.
pausedfieldWhether recording is suspended.
stoppedfieldWhether stop has run.
aggregatefieldSamples so far, by zone path and then by stack.
sourceSamplesfieldLua leaf locations counted separately in each zone, before filtering.
pausemethodStops recording without ending the session.
resumemethodResumes recording.
stopmethodEnds the session and reports what it saw.
closemethodEnds the session without a report, which is what leaving its with does.

Reportfield#

Report: associatedDecl

intervalMsfield#

intervalMs: integer

The interval it was started at.

zoneFilterfield#

zoneFilter: string?

The zone prefix stop will filter by, or nil for all of them.

rootfield#

root: string?

The module stop will cut the stacks back to, or nil to keep them whole.

pausedfield#

paused: boolean

Whether recording is suspended. The timer keeps firing.

stoppedfield#

stopped: boolean

Whether stop has run.

aggregatefield#

aggregate: {[string]: {[string]: profile.Sample}}

Samples so far, by zone path and then by stack. Two levels rather than one joined key, so a sample taken in a zone that has not changed since the last one costs a single table lookup.

sourceSamplesfield#

sourceSamples: {[string]: {[string]: integer}}

Lua leaf locations counted separately in each zone, before filtering.

pausemethod#

pause: function(self)

Stops recording without ending the session. The timer keeps firing, at the cost of one test per sample, and what was recorded on either side of the pause is kept, which is how a benchmark leaves its setup out.

Idempotent.

Arguments
NameTypeDescription
?self
Raises
TypeCondition
string

once the session has stopped

resumemethod#

resume: function(self)

Resumes recording. Idempotent.

Arguments
NameTypeDescription
?self
Raises
TypeCondition
string

once the session has stopped

stopmethod#

stop: function(self, filename: string?): self.Report

Ends the session and reports what it saw.

Arguments
NameTypeDescription
?self
filenamestring?
Returns
TypeDescription
self.Report
Raises
TypeCondition
string

once the session has stopped, so it cannot be called twice

closemethod#

close: function(takes self: profile.SampleSession): nil

Ends the session without a report, which is what leaving its with does.

Does nothing once stop has run, so a block that stopped its session to read the report closes it for free.

Arguments
NameTypeDescription
takes selfprofile.SampleSession
Returns
TypeDescription
nil

Sessioninterface#

interface profile.Session ...

A running profiling session whose declaration chooses the report stop returns.

Sampling and trace-abort sessions share this lifecycle. Generic helpers can use S.Report to preserve the concrete report chosen by a session declaration, and take the session borrows. Each session is also a nupp.Closeable: close, which leaving a with calls, ends one that nothing stopped without building a report, and does nothing after stop. The two are separate so a helper bounded by this one can still name S.Report.

Members

NameKindDescription
ReportfieldWhat stop returns, chosen by the session declaration rather than here.
pausemethodStops recording without ending the session.
resumemethodResumes recording.
stopmethodEnds the session and returns what it saw, optionally writing it to a file.

Reportfield#

Report: associatedDecl

What stop returns, chosen by the session declaration rather than here.

pausemethod#

pause: function(self)

Stops recording without ending the session. Idempotent.

Arguments
NameTypeDescription
?self

resumemethod#

resume: function(self)

Resumes recording. Idempotent.

Arguments
NameTypeDescription
?self

stopmethod#

stop: function(self, filename: string?): self.Report

Ends the session and returns what it saw, optionally writing it to a file.

Arguments
NameTypeDescription
?self
filenamestring?
Returns
TypeDescription
self.Report

Severitytype#

type profile.Severity = "blacklist" | "warn" | "info"

How much an abort is worth reading.

blacklist is always actionable: the code is demoted to the interpreter for the rest of the process. warn is a refusal that may or may not sit on a hot path. info is trace formation working as designed.

SourceSampletype#

type profile.SourceSample = {
    file: string,
    line: integer,
    samples: integer
}

Lua leaf samples attributed to a source line. Native VM states have no reliable Lua location and are excluded.

TraceOptionstype#

type profile.TraceOptions = {
    --- Include the aborts that are ordinary trace formation rather than a refusal:
    --- leaving a loop, recursion, an inner loop. False by default; turn it on when the
    --- question is why a particular trace never formed.
    includeBenign: boolean?
}

What profile.trace counts. Every field is optional.

TraceProfilerecord#

record profile.TraceProfile ...

The exact recorder configuration used to normalize a trace report.

Two reports are comparable when their id matches. A report taken under an unsupported recorder still reads, but its reasons were mapped from a VM the registry was not written against.

Members

NameKindDescription
idfieldEvery field below joined, which is what two reports are compared on.
luajitRevisionfieldThe pinned LuaJIT commit, or "external:" for any other build.
luajitVersionfieldThe snapshot timestamp from the full version string.
architecturefieldjit.arch.
operatingSystemfieldjit.os.
enabledRecorderFeaturesfieldThe recorder flags jit.status reported, sorted.
bytecodeSchemafieldA digest of the VM's bytecode name table, so a renumbered opcode set is visible rather than silently remapped.
supportedfieldWhether this is the recorder the reason registry was written against.

idfield#

id: string

Every field below joined, which is what two reports are compared on.

luajitRevisionfield#

luajitRevision: string

The pinned LuaJIT commit, or "external:" for any other build.

luajitVersionfield#

luajitVersion: integer

The snapshot timestamp from the full version string.

architecturefield#

architecture: string

jit.arch.

operatingSystemfield#

operatingSystem: string

jit.os.

enabledRecorderFeaturesfield#

enabledRecorderFeatures: {string}

The recorder flags jit.status reported, sorted.

bytecodeSchemafield#

bytecodeSchema: string

A digest of the VM's bytecode name table, so a renumbered opcode set is visible rather than silently remapped.

supportedfield#

supported: boolean

Whether this is the recorder the reason registry was written against.

TraceReportrecord#

record profile.TraceReport ...

What a trace session saw, as TraceSession:stop returns it.

tostring on it renders sites as RFC 4180 CSV, so it prints, sorts and diffs directly.

Members

NameKindDescription
durationSecfieldWallclock seconds the session was active, in whole seconds: it comes from os.time, so a session shorter than one...
totalAbortsfieldAbort events recorded.
blacklistedfieldBlacklist events among them.
sitesfieldOne row per distinct severity, reason, location and zone.
traceProfilefieldThe recorder and registry identities under which the events were interpreted.
reasonCatalogIdfieldThe reason registry the identities came from.
reasonCatalogVersionfieldIts version, so a stored report can be reread under a later one.
__tostringmetamethod

durationSecfield#

durationSec: integer

Wallclock seconds the session was active, in whole seconds: it comes from os.time, so a session shorter than one reads as zero and dividing by it is the caller's problem.

totalAbortsfield#

totalAborts: integer

Abort events recorded. Excludes the benign ones unless includeBenign was set.

blacklistedfield#

blacklisted: integer

Blacklist events among them. Always actionable.

sitesfield#

One row per distinct severity, reason, location and zone. Ordered by severity, then by count descending.

traceProfilefield#

traceProfile: profile.TraceProfile

The recorder and registry identities under which the events were interpreted.

reasonCatalogIdfield#

reasonCatalogId: string

The reason registry the identities came from.

reasonCatalogVersionfield#

reasonCatalogVersion: integer

Its version, so a stored report can be reread under a later one.

__tostringmetamethod#

__tostring: function(self): string
Arguments
NameTypeDescription
?self
Returns
TypeDescription
string

TraceSessionrecord#

A running trace-abort collector, as profile.trace returns it.

Live until stop or close, and there is at most one at a time. Held by a with, it is closed where the block ends, so the event hook never outlives the question.

Members

NameKindDescription
Reportfield
includeBenignfieldWhether the benign trace-formation events are being counted.
startedAtfieldos.time when the session started.
pausedfieldWhether aggregation is suspended.
stoppedfieldWhether stop has run.
sitesfieldAborts so far, by severity, reason, location and zone joined.
totalAbortsfieldAborts counted so far.
blacklistedfieldBlacklist events among them.
traceProfilefield
callbackmethodThe handler to hand back to jit.attach to detach it.
pausemethodStops counting without ending the session.
resumemethodResumes counting.
stopmethodEnds the session and reports what it saw.
closemethodEnds the session without a report, which is what leaving its with does.

Reportfield#

Report: associatedDecl

includeBenignfield#

includeBenign: boolean

Whether the benign trace-formation events are being counted.

startedAtfield#

startedAt: integer

os.time when the session started.

pausedfield#

paused: boolean

Whether aggregation is suspended. The hook stays attached.

stoppedfield#

stopped: boolean

Whether stop has run.

sitesfield#

sites: {[string]: profile.AbortSite}

Aborts so far, by severity, reason, location and zone joined.

totalAbortsfield#

totalAborts: integer

Aborts counted so far.

blacklistedfield#

blacklisted: integer

Blacklist events among them.

traceProfilefield#

traceProfile: profile.TraceProfile

callbackmethod#

callback: function(...: any)

The handler to hand back to jit.attach to detach it. Dropping the last reference to a handler does not remove it.

Arguments
NameTypeDescription
...any

pausemethod#

pause: function(self)

Stops counting without ending the session. The hook stays attached, at the cost of one test per abort, and what was counted on either side of the pause is kept.

Idempotent.

Arguments
NameTypeDescription
?self
Raises
TypeCondition
string

once the session has stopped

resumemethod#

resume: function(self)

Resumes counting. Idempotent.

Arguments
NameTypeDescription
?self
Raises
TypeCondition
string

once the session has stopped

stopmethod#

stop: function(self, filename: string?): self.Report

Ends the session and reports what it saw.

Arguments
NameTypeDescription
?self
filenamestring?
Returns
TypeDescription
self.Report
Raises
TypeCondition
string

once the session has stopped, so it cannot be called twice

closemethod#

close: function(takes self: profile.TraceSession): nil

Ends the session without a report, which is what leaving its with does.

Does nothing once stop has run, so a block that stopped its session to read the report closes it for free.

Arguments
NameTypeDescription
takes selfprofile.TraceSession
Returns
TypeDescription
nil

Functions#

profile.samplefunction#

function profile.sample(options: profile.SampleOptions?): profile.SampleSession

Starts sampling.

with session = profile.sample({intervalMs = 5, zone = "frame/render"}) do
    render()
    local report = session:stop("render.collapsed")
    print(report.samples, report.stacks)
end

Arguments

NameTypeDescription
optionsprofile.SampleOptions?

omitted samples every zone at 10 ms to a depth of 16

Returns

TypeDescription
profile.SampleSession

the handle whose stop produces the report

Raises

TypeCondition
string

when a sample session is already running or an option is out of range

profile.tracefunction#

function profile.trace(options: profile.TraceOptions?): profile.TraceSession

Starts collecting trace aborts.

with session = profile.trace() do
    render()
    local report = session:stop("aborts.csv")
    print(report.blacklisted, report.totalAborts)
end

Arguments

NameTypeDescription
optionsprofile.TraceOptions?

omitted leaves the benign trace-formation events out

Returns

TypeDescription
profile.TraceSession

the handle whose stop produces the report

Raises

TypeCondition
string

when a trace session is already running