LuaJIT trace checking#
LuaJIT discovers an unsupported hot path only once that path is hot. Nupp finds a smaller, deterministic set before the program runs: the operations the selected recorder refuses whenever recording reaches them.
local function sum(values: {integer}): integer
local total: integer = 0
for index = 1, #values do
total = total + values[index]
end
return total
end@jit is a contract, so every check and every build reports a refused operation the annotated body can reach. Without the annotation the same analysis is available on request, and it can also normalize the aborts observed in one profiled execution.
Result kinds#
Every finding carries a class, and the class decides what it can do to a build.
| Result | Meaning | Can fail a build? |
|---|---|---|
| blocker, must-reach | The recorder refuses the operation, and every repeatable path through the loop reaches it | bc --check exits 1; @jit is an error |
| blocker, may-reach | The recorder refuses it when that branch runs, but another path can avoid it | advisory from bc --check; @jit is an error |
| risk | Runtime types, call targets, ABI details, shapes, or recorder limits decide | warning or advice; selected conservative boundaries are errors under @jit |
| stop | Normal trace formation, such as leaving a loop or encountering recursion | informational and hidden by default |
| observed abort | The LuaJIT hook reported this event in one run | never changes whether source checks |
The operation-level answer and the reachability answer are deterministic. Runtime observation is evidence from one input and one execution, so it is never used as the static oracle.
Checking surfaces#
The same catalog answers from six places, which differ in when they run and how much of the program they see.
| Surface | When it runs | What it checks | Runtime cost in an ordinary run |
|---|---|---|---|
ordinary nupp check |
automatically | enabled trace lints | none |
@jit |
automatically on every check and build | the annotated function and statically resolved checked callees | none |
nupp bc --check FILE |
when requested | exact generated bytecode and loop control flow | none; it does not execute the chunk |
| VS Code Nupp: Check Function for JIT Trace Blockers | when requested | the method or function under the cursor and resolved callees, using unsaved text | none |
nupp lsp trace-check --json FILE LINE COLUMN |
when requested | the same one-function query, for scripts and agents | none |
nupp run --jit-aborts FILE |
when requested | recorder events observed while that program runs | the trace callback costs something only during this session |
For continuous enforcement, put @jit on a function. For investigation, use the VS Code command or lightbulb without changing source. A successful one-shot check offers Add @jit contract when the declaration can carry it. See Editors for the extension that supplies it.
Checking in CI#
Two static scopes matter to a project, and they catch different things:
nupp check
nupp bc --check src/physics.nupp
nupp bc --check --json src/physics.nupp > build/physics-bytecode.jsonnupp check enforces every @jit contract. bc --check catches blockers in exact generated loop bytecode even where no source annotation was written. Its JSON includes the generated-artifact fingerprint, trace profile, catalog version, prototype, PC, opcode, source line, reason ID, class, reachability, explanation, and repair. See bc for the command.
Agent skill#
The static-first performance workflow ejects as a focused skill:
nupp reference performance --skill \
-o .claude/skills/nupp-performance/SKILL.mdThe generated skill belongs to the compiler that produced it. Its trigger covers slow code, hot loops, JIT behavior, profiler output, and performance regressions; its body leads with trace checking before it asks the agent to measure a process.
Function construction in a loop#
FNEW constructs a Lua function, and the selected LuaJIT recorder refuses it. This capturing closure therefore breaks an @jit contract:
local function sum(values: {integer}): integer
local total: integer = 0
for index = 1, #values do
local current = function(): integer
return values[index]
end
total = total + current()
end
return total
endsrc/sum.nupp:5:33: error: NUPP2707: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles
5 | local current = function(): integer
| ^
note: trace classification: blocker (jit/loop-function-construction)
help: declare one function outside the loop and pass what varies to itMove what varies into arguments to one function declared outside the loop:
local function valueAt(values: {integer}, index: integer): integer
return values[index]
end
local function sum(values: {integer}): integer
local total: integer = 0
for index = 1, #values do
total = total + valueAt(values, index)
end
return total
endNo rewrite can safely lift every capturing closure, which is why the compiler reports this rather than repairing it. Replacing the capture with shared mutable state would change which value each returned function observes.
Configurable source lint#
Without @jit, a capturing loop closure is jit-loop-closure. It is off by default because the code is correct and some programs accept the interpreted loop deliberately. Enable it in nupp.lua where this is a project policy:
return {
include = {"src"},
lints = { ["jit-loop-closure"] = "warning" },
}The same source then says:
src/sum.nupp:4:33: warning: NUPP2515 jit-loop-closure: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles
note: trace classification: blocker (jit/loop-function-construction)
help: declare one function outside the loop and pass what varies to itA non-capturing function is more directly repairable, so loop-invariant-closure remains its automatic diagnostic:
for _, item in ipairs(items) do
register(item, function(event)
return event.kind == "click"
end)
endsrc/events.nupp:2:28: warning: NUPP2505 loop-invariant-closure: this function is built once per iteration but does not use the iteration, so every one of them is the same function, and building one is what keeps the loop from compiling
help: declare it once above the loop and pass the nameBlockers reached through checked calls#
The contract follows resolved Nupp calls, including recursive call graphs and exact exported callees. The error belongs to the annotated caller and shows a bounded path:
local function helper(values: {integer}): integer
local total: integer = 0
for index = 1, #values do
local current = function(): integer return values[index] end
total = total + current()
end
return total
end
local function sum(values: {integer}): integer
return helper(values)
endsrc/sum.nupp:12:12: error: NUPP2707: @jit call path sum -> helper reaches jit/loop-function-construction: this function is built once per iteration and reads the iteration, so it cannot be declared above the loop, and LuaJIT does not record building a function, so this loop never compiles
12 | return helper(values)
| ^~~~~~
src/sum.nupp:4:33: note: the recorder blocker is here
note: trace classification: blocker (jit/loop-function-construction)
help: declare one function outside the loop and pass what varies to itRecursive strongly connected components reach a fixed point over the reason set, so the path does not expand forever.
Explicit jit.off boundaries#
Calling a function deliberately disabled with jit.off is valid ordinary code. It is an error from an @jit body, because that body promised not to leave compiled code deliberately:
local function logValue(value: integer): nil
print(value)
end
jit.off(logValue)
local function update(value: integer): nil
logValue(value)
endsrc/update.nupp:9:5: error: NUPP2707: @jit call path update -> logValue reaches jit/disabled-callee: the resolved callee is explicitly disabled with jit.off
9 | logValue(value)
| ^~~~~~~~
note: trace classification: blocker (jit/disabled-callee)
help: remove @jit from the caller or keep the disabled call outside its checked hot pathMove the logging call outside the annotated operation, or remove @jit when the interpreter transition is intentional. Applying jit.off to the enclosing function also makes a local trace lint irrelevant: a function taken off the JIT has no trace to lose.
Variadic FFI#
A variadic C signature depends on the exact argument types and target ABI. Nupp classifies it as a conservative risk rather than as a universal opcode blocker:
cdef function printf(format: cstring, ...): int32
local function report(value: int32): nil
printf("%d", value)
endsrc/report.nupp:4:5: warning: NUPP2514 jit-boundary: a variadic FFI call cannot safely execute on a compiled trace
4 | printf("%d", value)
| ^~~~~~
note: trace classification: risk (jit/ffi-vararg-policy)
help: move the call behind an explicit jit.off boundary when it is not a hot operationUnder @jit, the same site is a non-suppressible contract error. A clear boundary makes the choice explicit:
cdef function printf(format: cstring, ...): int32
local function reportCold(value: int32): nil
printf("%d", value)
end
jit.off(reportCold)Do not call reportCold from an @jit function; that would correctly become the jit/disabled-callee error shown above. See jit-boundary for the lint, and c-interop.md for the C side of the same boundary.
Lua callbacks passed through C#
C cannot safely re-enter an ordinary Lua callback from a compiled trace. The checker uses the resolved FFI signature and callback identity rather than guessing from a generated form:
cdef function each(fn: function(int32), n: int32)
local function visit(value: int32): nil
print(value)
end
local function run(): nil
each(visit, 1)
endsrc/callback.nupp:8:10: warning: NUPP2502 jit-callback: a Lua function passed to C becomes an FFI callback and cannot run from a compiled trace
8 | each(visit, 1)
| ^~~~~
note: trace classification: risk (jit/ffi-callback)
help: disable the callback and its calling boundary with jit.offKeep the complete C-to-Lua callback boundary cold:
local function visit(value: int32): nil
print(value)
end
local function runCold(): nil
each(visit, 1)
end
jit.off(visit)
jit.off(runCold)Inside @jit, allowing the jit-callback lint does not waive the contract. It remains a contract error.
Dynamic call targets#
An unresolved call is not fabricated into a blocker. The one-shot inspection reports what is actually known:
local function dispatch(callback: any): nil
callback()
endnupp lsp trace-check --json src/dispatch.g.nupp 2 5{
"functionName": "dispatch",
"contract": "inspection",
"findings": [{
"reason": "jit/dynamic-call",
"class": "risk",
"message": "the call target is dynamic, so its bytecode recordability is unknown",
"callPath": ["dispatch"]
}],
"reasonCatalog": {"id": "nupp-trace-reasons-v1", "version": 1}
}That risk does not mean every possible target is bad. Give the call a statically resolved checked identity where continuous transport is required.
FNEW and UCLO in generated bytecode#
bc --check compiles but does not execute the exact artifact at the requested optimization level. A capturing closure commonly produces both function construction (FNEW) and upvalue closing (UCLO) in the repeatable region:
$ nupp bc --check src/sum.g.nupp
nupp: 2 instructions in a loop that cannot compile
...
0006 FNEW 6 0 <-- this loop never compiles: LuaJIT has no recorder for constructing a function [jit/loop-function-construction, must-reach]
...
0010 UCLO 5 => 0011 <-- this loop never compiles: LuaJIT has no recorder for closing an upvalue [jit/loop-upvalue-close, must-reach]
0011 FORL 2 => 0006The command exits 1 because every repeatable path reaches both blockers.
A conditional non-capturing construction can be only may-reach:
local current = function(): integer return 0 end
local first = true
for index = 1, #values do
if first then
current = function(): integer return 1 end
first = false
end
end0007 ISF 2
0008 JMP 7 => 0011
0009 FNEW 1 1 <-- this path aborts recording: jit/loop-function-construction [may-reach]
0011 FORL 3 => 0007That finding is visible, but bc --check exits 0 because it cannot truthfully claim the whole loop always fails to complete a root trace. @jit is stronger and rejects a statically reachable may-reach blocker, because its contract covers every checked path.
Manual checking in VS Code#
Place the cursor anywhere in a method or function and run Nupp: Check Function for JIT Trace Blockers from the Command Palette, editor context menu, or lightbulb. The request:
- selects the smallest enclosing function;
- reads the current unsaved editor buffer;
- follows statically resolved checked callees;
- puts findings in the temporary Nupp JIT Check diagnostic collection; and
- clears those diagnostics on edit or on the next manual check.
It does not add an annotation, run another process, execute the program, or attach a trace hook. A clean answer says:
update: no catalogued unconditional trace blockers or conditional risks.That is deliberately not "this function will compile." Choose Add @jit contract afterwards when the function should be checked continuously.
Observing real aborts#
Static checking cannot know whether a loop runs or becomes hot. Observe one workload when that is the question:
nupp run --jit-aborts app.nupp
nupp run --jit-aborts=jit-aborts.json --json app.nuppCSV remains compatible with existing consumers:
severity,count,reason,location,zone
warn,7,NYI: bytecode FNEW,app.nupp:41,frame/spawnJSON retains the raw VM detail and adds the stable identity:
{
"totalAborts": 7,
"blacklisted": 0,
"reasonCatalog": {"id": "nupp-trace-reasons-v1", "version": 1},
"sites": [{
"severity": "warn",
"count": 7,
"reason": "NYI: bytecode FNEW",
"rawReason": "NYI: bytecode FNEW",
"reasonId": "jit/loop-function-construction",
"class": "blocker",
"location": "app.nupp:41",
"zone": "frame/spawn"
}]
}The callback runs on recorder events, not on every loop iteration or table access, but it still has nonzero profiling cost. Stop the session after the interesting window. With no trace session, no callback is attached and no aggregation state is allocated. See Profiling for collecting the same events from inside a program.
Reason catalog#
Static and runtime surfaces use the same identities, published as nupp-trace-reasons-v1. Raw LuaJIT strings remain report detail rather than public identifiers, and nupp explain REASON prints the current explanation plus a repair only where Nupp has a specific semantics-preserving one.
Statically attributable reasons#
| Stable reason | Class | What the diagnostic says | Repair or interpretation |
|---|---|---|---|
jit/loop-function-construction |
blocker | LuaJIT has no recorder for constructing a function | Declare one function outside the loop and pass what varies |
jit/loop-upvalue-close |
blocker | LuaJIT has no recorder for closing an upvalue | Move the captured lifetime outside the repeated region |
jit/ffi-vararg-policy |
risk | This variadic FFI form depends on argument types and the target ABI | Put an intentionally cold call behind jit.off |
jit/ffi-callback |
risk | A C call that re-enters Lua through this callback cannot remain on a trace | Disable the callback and its calling boundary |
jit/disabled-callee |
blocker | The resolved callee is explicitly disabled with jit.off |
Keep the call outside @jit, or remove the contract |
jit/dynamic-call |
risk | The call target is dynamic, so its bytecode recordability is unknown | Resolve the target; do not assume every target is bad |
Expected stops#
These are class stop and severity info, and they are omitted unless benign events are requested. They are not warnings to fix.
| Stable reason | Typical raw VM detail | Meaning |
|---|---|---|
jit/expected-loop-leave |
leaving loop in root trace |
The recorded path left the loop |
jit/expected-inner-loop |
inner loop in root trace |
Trace formation encountered an inner loop |
jit/expected-down-recursion |
down-recursion, restarting |
The recorder restarted at a recursive callee |
jit/expected-up-recursion |
up-recursion |
The trace reached an upward recursive edge |
jit/retry-recording |
retry recording |
LuaJIT requested an ordinary retry |
jit/runtime-trace-too-short |
trace too short |
The attempted trace did not contain enough work |
Runtime risks and blockers#
These are observations from the active run. Counts and timing change with inputs; the normalized identity does not.
| Stable reason | Class | Typical raw VM detail | What the report means |
|---|---|---|---|
jit/runtime-blacklisted |
risk | blacklisted |
LuaJIT permanently demoted that trace in this process |
jit/runtime-trace-too-long |
risk | trace too long |
The recorder reached its trace-length limit |
jit/runtime-trace-too-deep |
risk | trace too deep |
Nested recording exceeded the depth limit |
jit/runtime-too-many-snapshots |
risk | too many snapshots |
Guard and snapshot capacity was exhausted |
jit/runtime-loop-unroll-limit |
risk | loop unroll limit reached |
Recording exceeded its loop-unroll allowance |
jit/runtime-call-unroll-limit |
risk | call unroll limit reached |
Recording exceeded its call-unroll allowance |
jit/runtime-disabled-callee |
blocker | JIT compilation disabled for function |
The observed path entered a disabled function |
jit/runtime-ffi-call |
risk | NYI: unsupported C function type |
This runtime C signature was unsupported |
jit/runtime-ffi-conversion |
risk | NYI: unsupported C type conversion |
This runtime C conversion was unsupported |
jit/runtime-type-instability |
risk | persistent type instability |
Repeated attempts observed incompatible runtime types |
jit/runtime-machine-code-limit |
risk | machine code too long, hit mcode limit (retrying), or failed to allocate mcode memory |
Machine-code size or allocation limits stopped compilation |
jit/runtime-recorder-error |
risk | error thrown or hook called during recording |
A language error or hook interrupted recording |
jit/runtime-unknown |
risk | the VM revision and unrecognized raw payload | The event stays visible, with no guessed explanation or repair |
An observed NYI: bytecode FNEW or UCLO maps back to the corresponding static blocker rather than receiving a second runtime-only identity.
Profiles, versions, and reproducibility#
Every answer names its TraceProfile: LuaJIT revision, architecture, operating system, enabled recorder features, bytecode schema, and reason-catalog version. Reports from different profiles are not merged. The bytecode fingerprint prevents a static result for an old artifact from being presented as though it described new code.
Adding a newly proved unconditional blocker creates a new catalog identity and version, and it can make an existing @jit contract fail after a compiler upgrade.
Dive deeper
The bar for promoting an observation to a static rule is a deterministic VM fixture, a neighboring accepted fixture, source attribution, and a specific working alternative. One application abort is evidence to investigate rather than enough to generalize: a rule added from a single measurement would fail builds for every program whose only fault was resembling that one.
Large-application workflow#
Use static checking to keep known cliffs from spreading through checked call graphs, then profile to prioritize what remains:
- Put
@jiton important subsystem boundaries, not on every tiny helper. - Run
bc --checkon release entry modules at their release optimization level. - Use the VS Code one-shot check while investigating a method, before committing to a contract.
- Sample first to find interpreted hot frames, then collect aborts over a short, representative window.
- Fix must-reach blockers first, then observed blacklists, then risks that overlap measured interpreted time.
- Keep benchmarks: removing an abort does not prove the replacement is faster.
Static checking removes the catalogued, attributable causes. It does not replace profiling for cold code, input-dependent types and shapes, side exits, recorder limits, or deciding whether a reported site matters to the workload.
FAQ#
Does a clean @jit result mean the function is compiled?#
No. It means no catalogued unconditional blocker is reachable in the checked scope for that trace profile. Whether the function runs at all, becomes hot, and stays compiled is a runtime question, answered by Profiling.
Why is a variadic FFI call a risk rather than a blocker?#
The answer depends on the exact argument types and the target ABI, so no static rule covers every form. Under @jit the conservative boundary is still an error, because the contract covers every checked path.
When is @aot the answer instead of a trace contract?#
When the loop is numeric, maps over spans, and the measured gap is codegen rather than an aborted trace. See ahead-of-time.md for the subset @aot admits and what it costs.