Performance#
Nupp uses type and effect information to simplify generated Lua:
local point = {}
point.x = 1
point.y = 2local point = {x = 1, y = 2}Always-on lowerings implement language features. Optimization passes require -O1:
nupp build -O1
nupp run -O1 --remarks app.nuppGenerated examples normalize whitespace and omit the module prelude. Temporary names may change.
Always-on lowerings#
Typed call projection#
{a, b} = path passes fields as positional arguments. Shared paths are read once, without argument tables or closures.
local record Vec2
x: number
y: number
end
local record Velocity
dx: number
dy: number
end
local record Body
position: Vec2
velocity: Velocity
end
function m.step(entity: Entity, delta: number): nil
update(delta, {x, y} = entity.body.position, {dx, dy} = entity.body.velocity)
endfunction m.step(entity, delta)
const __nuppT1 = delta
const __nuppT2 = entity.body
const __nuppT3 = __nuppT2.position
const __nuppT4 = __nuppT2.velocity
update(__nuppT1, __nuppT3.x, __nuppT3.y, __nuppT4.dx, __nuppT4.dy)
endBoth operands share the entity.body read. See named and plucked arguments for syntax and field requirements.
Safe calls and short-circuit expressions evaluate plucked paths only when the call runs:
local moved = enabled and update(delta, {x, y} = entity.body.position)local __nuppT3 = enabled
if __nuppT3 then
const __nuppT7 = update
const __nuppT4 = delta
const __nuppT5 = entity.body
const __nuppT6 = __nuppT5.position
__nuppT3 = __nuppT7(__nuppT4, __nuppT6.x, __nuppT6.y)
end
local moved = __nuppT3Table intrinsics#
table.new and table.clear get private module bindings so their LuaJIT builtins are available:
const __nuppNew = require("table.new")
const __nuppClear = require("table.clear")
function m.build()
local cache = __nuppNew(128, 8)
cache.ready = true
__nuppClear(cache)
return cache
endUnder @aot, table construction and initialization can run in one native call. See building ordinary Lua values.
string.buffer#
string.buffer needs no source-level require:
function m.pair(): string
local b = string.buffer.new()
b:put("a", "b")
return b:tostring()
endconst __nuppBuffer = require("string.buffer")
function m.pair()
local b = __nuppBuffer.new()
b:put("a", "b")
return b:tostring()
endThe private binding leaves the runtime string table untouched. An explicit require also works; a shadowed string remains ordinary table access.
Switch dispatch#
A switch writes directly to its destination when scope permits, without a closure. Computed selectors are saved once; these examples use local scalar selectors.
Computed results keep an if/elseif chain:
local text
if status == 200 then text = formatStatus(status)
elseif status == 301 then text = "redirect"
else text = "other"
endStatic result maps#
Enough static scalar cases and results, including else, become one table read. Closely spaced integers use an offset into a dense array:
const __nuppSwitchMap1 = {"tab", "newline", "vertical tab", "form feed", "return"}
local label = __nuppSwitchMap1[byte - (9) + 1]
if label == nil then label = "other" endWidely spaced integers use a hash-keyed map:
local again = switch status do
case 408 -> true
case 409 -> true
case 421 -> true
case 423 -> true
case 425 -> true
case 426 -> true
case 428 -> true
case 429 -> true
case 500 -> true
case 501 -> true
case 502 -> true
case 503 -> true
case 504 -> true
case 505 -> true
case 506 -> true
case 507 -> true
else -> false
endconst __nuppSwitchMap1 = {
[408] = true, [409] = true, [421] = true, [423] = true,
[425] = true, [426] = true, [428] = true, [429] = true,
[500] = true, [501] = true, [502] = true, [503] = true,
[504] = true, [505] = true, [506] = true, [507] = true,
}
local again = __nuppSwitchMap1[status]
if again == nil then again = false endString cases use the same lookup, including cases that return nil:
const __nuppSwitchNil2 = {}
const __nuppSwitchMap3 = {
["and"] = "operator", ["break"] = "statement", ["do"] = "block",
["else"] = __nuppSwitchNil2, ["end"] = "block", ["false"] = "literal",
["for"] = "loop", ["function"] = "declaration",
}
local kind = __nuppSwitchMap3[word]
if kind == nil then kind = "name"
elseif kind == __nuppSwitchNil2 then kind = nil endMaps are allocated once per module.
A guarded case forfeits every map plan. A map looks up a value by key alone, so it has nowhere to put a predicate and no way to reach the next arm after one declines; a switch with any where guard is always lowered to ordered branches that jump to a shared exit label.
AOT can emit a native C switch for exact-width selectors. See scalar switches and do blocks.
Optimization passes#
nupp build -O1
nupp run -O1 app.nupp-O0, the default, disables optimization passes. -O1 enables all current passes; -O2 is equivalent today. Changing levels triggers a cold build.
Each pass has a stable OPT-n code:
| Code | Name | Level | Rewrite |
|---|---|---|---|
OPT-1 |
presize |
-O1 | Size an empty table for the writes about to follow |
OPT-2 |
numeric-ipairs | -O1 | Use a numeric loop for a proved stable dense array |
OPT-3 |
constant-fold | -O1 | Fold exact primitives, branches, dead loops, and immutable paths |
OPT-4 |
static-callable | -O1 | Bind repeated immutable dotted callees at first use |
OPT-5 |
concat-buffer | -O1 | Append to a string.buffer instead of rebuilding a string each pass |
OPT-6 |
indexed-range | -O1 | Remove repeated bounds checks and temporary view objects |
OPT-7 |
inline-return-helper | -O1 | Inline a module's own single-return local helpers where they are called |
OPT-8 |
const-monomorphize | -O1 | Specialize functions for known constant arguments |
OPT-1, presizing#
Consecutive named writes into an empty table become a constructor, which LuaJIT sizes directly:
function m.make(): {string:integer}
local point = {}
point.x = 1
point.y = 2
point.z = 3
return point
endfunction m.make()
local point = {
x = 1,
y = 2,
z = 3,
}
return point
endfunction m.make()
local point = {}
point.x = 1
point.y = 2
point.z = 3
return point
endOther write patterns use table.new to reserve capacity and avoid growth and copying. Reads, reassignment, conditional writes, or passing the table elsewhere limit how much capacity can be reserved in advance. Array slots are reserved only for a run of keys 1, 2, 3, ... whose values are never nil. An integer key after a gap leaves the table as written, because where a key of a table with holes is placed decides which border # reports.
OPT-2, numeric ipairs#
A loop over a dense literal becomes numeric when the compiler can prove the array and its length stay fixed. Dense means every item is one value that is never nil: a literal, a table or function, or a name whose type excludes nil. nil, ..., a call, and a name typed T? or any each keep ipairs, which stops at the first nil and counts every value an expansion produces:
function m.total()
local xs = {10, 20, 30}
local sum = 0
for index = 1, 3 do
local value = xs[index]
sum = sum + index * value
end
return sum
endfunction m.total()
local xs = {10, 20, 30}
local sum = 0
for index, value in ipairs(xs) do
sum = sum + index * value
end
return sum
endOPT-3, constant folding#
Exact integer arithmetic, strings, comparisons, boolean selection, and primitive const values fold:
const prefix = "nu"
const answer = (2 + 3) * 4
function m.show(): nil
print((true and prefix) .. "pp", answer, "nupp" < "zulu")
endconst prefix = "nu"
const answer = 20
function m.show()
print("nupp", 20, true)
endconst prefix = "nu"
const answer = (2 + 3) * 4
function m.show()
print((true and prefix) .. "pp", answer, "nupp" < "zulu")
endUnchanged locals#
Ordinary scalar locals also propagate when their bindings are never reassigned:
function m.answer(): number
local size = 6
return size * 7
endfunction m.answer()
return 42
endfunction m.answer()
local size = 6
return size * 7
endUnused inert scalar declarations disappear after folding.
Short-circuit expressions#
A known left operand simplifies and, or, and ??. Selected calls run once and produce one value; unselected operands do no work. Unknown left operands still run.
function m.choose(read)
return false, true, (read())
endfunction m.choose(read)
return false and read(), true or read(), true and read()
endInteger division and the bit operators#
// folds using math.floor((a) / (b)). Bit operators use BitOp semantics: operands normalize to 32 bits and results are signed.
const CACHE = 64
const RAW = 40
const STRIDE = (RAW + CACHE - 1) // CACHE * CACHE
const FLAGS = 1 << 3 | 1
const WRAP = 1 << 32 -- a shift count is taken modulo 32
const LOG = -8 >> 1 -- the plain shift is logical
const AR = -8 ~>> 1 -- the tilde shift is arithmetic
function m.show(): nil
print(STRIDE, FLAGS, WRAP, LOG, AR)
endconst STRIDE = 64
const FLAGS = 9
const WRAP = 1
const LOG = 2147483644
const AR = -4
function m.show()
print(64, 9, 1, 2147483644, -4)
endconst CACHE = 64
const RAW = 40
const STRIDE = math.floor(((RAW + CACHE - 1)) / (CACHE)) * CACHE
const FLAGS = 1 << 3 | 1
const WRAP = 1 << 32
const LOG = -8 >> 1
const AR = -8 ~>> 1
function m.show()
print(STRIDE, FLAGS, WRAP, LOG, AR)
endDivision by zero stays at runtime.
Dead loops#
Loops with no possible first iteration disappear. The remaining empty do compiles to nothing:
function m.run(unreachable, alsoUnreachable): nil
while false do
unreachable()
end
for index = 1, 0 do
alsoUnreachable(index)
end
endfunction m.run(unreachable, alsoUnreachable)
do
end
do
end
endfunction m.run(unreachable, alsoUnreachable)
while false do
unreachable()
end
for index = 1, 0 do
alsoUnreachable(index)
end
endA zero step remains unchanged because the loop does not terminate.
Constant branches#
False arms disappear; a true arm becomes the final fallback. Remaining conditions preserve evaluation order, and do preserves scope:
function m.pick(active: boolean): nil
if false then
error("unreachable")
elseif active then
print("active")
elseif 2 < 3 then
print("reachable")
else
error("also unreachable")
end
endfunction m.pick(active)
do
if active then
print("active")
else
print("reachable")
end
end
endfunction m.pick(active)
if false then
error("unreachable")
elseif active then
print("active")
elseif 2 < 3 then
print("reachable")
else
error("also unreachable")
end
endNested immutable paths#
const M fixes the binding, const M.field fixes one field, and const...
M.field recursively fixes fresh named fields. Reads through fully immutable paths fold:
-- settings.nupp
module settings
export const mixed = {
const NAME = "nupp",
count = 0,
}
export const deep = {
const nested = {const VERSION = 1},
}
-- app.nupp
module app
const Settings = require("settings")
export function show(): nil
print(Settings.mixed.NAME, Settings.mixed.count, Settings.deep.nested.VERSION)
end-- app.lua
const Settings = require("settings")
function m.show()
print("nupp", Settings.mixed.count, 1)
end-- app.lua
const Settings = require("settings")
function m.show()
print(Settings.mixed.NAME, Settings.mixed.count, Settings.deep.nested.VERSION)
endA mutable field or parent keeps the read intact. require stays in place because module loading may have effects. These are checked guarantees, not runtime freezing; see const binders.
OPT-4, static callable binding#
Repeated statement-position calls through an immutable path share a local bound at first use:
-- service.nupp
module service
export const x = {
const y = function(): nil
print("call")
end,
}
-- app.nupp
module app
const service = require("service")
export function show(): nil
service.x.y()
service.x.y()
end-- app.lua
const service = require("service")
function m.show()
const __nupp_call_1 = service.x.y
__nupp_call_1()
__nupp_call_1()
end-- app.lua
const service = require("service")
function m.show()
service.x.y()
service.x.y()
endThe root and every field must be const. Calls share the binding within the same block.
Call binding limits
The binding is created at first use to preserve lookup order and error locations. Labels and goto prevent reuse, as do calls with special handling for FFI, ownership, constructors, or output parameters. A single call needs no shared binding.
OPT-5, concat buffer#
A loop that appends to a string can use a nupp.text buffer:
function m.join(items: {string}): string
local out = ""
for _, item in ipairs(items) do
out = out .. item .. ","
end
return out
endconst __nuppBuffer = require("nupp.text")
function m.join(items)
local __nuppBuf_1 = __nuppBuffer.newBuffer()
for _, item in ipairs(items) do
__nuppBuf_1:put(item, ",")
end
return __nuppBuf_1:tostring()
endfunction m.join(items)
local out = ""
for _, item in ipairs(items) do
out = out .. item .. ","
end
return out
endRepeated concatenation copies the growing string and costs O(n²). The loop has to be the only place the string is touched, and it has to end at its end: a goto to a label outside the loop jumps past the point where the buffer becomes the string again, so such a loop keeps its concatenation.
OPT-6, indexed views#
nupp.mem.indexed.range validates an inclusive range for trusted Span or SoA views. Matching accesses in the numeric loop become non-raising at every level, including in @noraise code. At -O1, they also become direct FFI accesses:
function m.dot(leftView: span.Span<Value>, rightView: span.Span<Value>): integer
const left = leftView
const right = rightView
const indexes = indexed.range(1, #left, left, right)
local total: integer = 0
for index = indexes.first, indexes.last do
total = total + left[index].n * right[index].n
end
return total
endfunction m.dot(leftView, rightView)
const left = leftView.count
const right = rightView.count
const indexes = __nuppModule._rangeCounts(1, left, left, right)
local total = 0
for index = indexes.first, indexes.last do
total = total
+ leftView.pointer[leftView.offset + index - 1].n
* rightView.pointer[rightView.offset + index - 1].n
end
return total
endfunction m.dot(leftView, rightView)
const left = leftView
const right = rightView
const indexes = indexed.range(1, left.count, left, right)
local total = 0
for index = indexes.first, indexes.last do
total = total + left:get(index).n * right:get(index).n
end
return total
endViews must be bound to const names. Validation runs once and physical offsets are preserved.
SoA columns#
For a const-bound SoA view, for index
= 1, #rows proves row accesses in bounds and enables direct column loads and stores:
function m.advance(view, delta)
const rows = view.count
const columns = view.columns
const base = view.offset - 1
const xs = columns[1]
const ys = columns[2]
const dxs = columns[3]
const dys = columns[4]
for index = 1, rows do
const physical = base + index
xs[physical] += dxs[physical] * delta
ys[physical] += dys[physical] * delta
end
endfunction m.advance(view, delta)
const rows = view
for index = 1, rows.count do
rows.columns[1][rows:checkedIndex(index)] +=
rows.columns[3][rows:checkedIndex(index)] * delta
rows.columns[2][rows:checkedIndex(index)] +=
rows.columns[4][rows:checkedIndex(index)] * delta
end
endSupported views#
Supported views come from span.fromString, shared and writable C arrays, heap.Array:read()/write(), and soa.Array:read()/write(). Slices and SoA column projections are supported too. Arbitrary indices keep bounds checks.
See SIMD programming for vector lanes in AOT code.
Removing view allocations
The pass can remove temporary view objects: left and right in the earlier example become counts, while accesses use the original views. Slices, shared downgrades, and SoA column projections combine their offsets without creating wrapper objects. The source owner stays alive for the accesses.
Direct, nonrecursive local calls may pass or return views as flattened state. An exact imported helper over borrowed, read-only scalar spans can use a private body with the same flattened arguments. The exported function keeps its checked entry point. Private copies are limited to four per caller module and 256 syntax nodes per body; unsupported operations, captures, stores, recursive calls, foreign calls, and any boundaries keep view objects. Optimization remarks identify the imported helper and the reason a boundary remains.
OPT-7, single-return helpers#
A local helper with one return expression can be inlined at its call sites:
local function rotateRight(value, right, left)
return (value >> right) | (value << left)
end
function m.spun(word)
return ((word >> 7) | (word << 25))
endThe declaration remains for other callers. Exact imported helpers with a closed scalar return expression can inline too. Their eligibility and body have separate cache keys: checking does not depend on an optimization body, and a caller which uses one records its exact digest. Imported expressions have a 48-node budget and use the same call-site growth limits as local helpers. Captures and arguments with effects keep their ordinary calls.
Inlining also lets constants and branches simplify:
local function add(left: number, right: number): number
return left + right
end
function m.answer(): number
local size = add(2, 3)
return size * 4
endlocal function add(left, right)
return left + right
end
function m.answer()
return 20
endlocal function add(left, right)
return left + right
end
function m.answer()
local size = add(2, 3)
return size * 4
endAOT applies the same helper eligibility rules.
Inlining limits
Inlining requires:
- A nonrecursive, nongeneric local helper with one return expression.
- Stable bindings: no reassignment or duplicate module declarations, a callee that is the helper's own binding, and every free name of the body bound at the call to what it was bound to in the helper. A loop variable, parameter, or later local of the same name keeps the call.
- Exactly one argument per parameter, using names, literals, or arithmetic over numbers, whose evaluation can safely repeat or disappear.
- No argument naming a variable the module writes when the body makes a call, since the body reads the argument after that call rather than before it.
- No
errorwith a level,debug,getfenvorsetfenvin the body, which would see the caller's frame instead of the helper's. - Name arguments for parameters used as field, index, method, or call receivers.
- A call in expression position.
An inlined body runs on the caller's line, so an error raised inside it names the line of the call rather than the line of the helper.
The compiler limits code growth and repeated argument computation so inlining does not make the caller excessively large.
OPT-8, const monomorphization#
Calls with known scalar const arguments can get specialized versions that fold constants and unroll bounded loops. The original function remains available; -O0 uses only that version.
local function accumulate(value, count)
local total = value
for offset = 1, count do
total = total + offset
end
return total
end
local function __nuppConst_accumulate_7f57b563(value)
local total = value
do
do total = total + 1 end
do total = total + 2 end
do total = total + 3 end
do total = total + 4 end
end
return total
end
function m.answer()
return __nuppConst_accumulate_7f57b563(10)
endlocal function accumulate(value, count)
local total = value
for offset = 1, count do
total = total + offset
end
return total
end
function m.answer()
return accumulate(10, 4)
endThe constant must be an integer, boolean, or string and be passed directly as an argument, as count is above.
See const-specialized families.
Cost heuristics
The compiler limits specialization per module to control code growth; additional optional specializations stay generic. Calls with equivalent constant arguments share a specialized version in the function's declaring module. AOT uses the same limit.
--remarks reports calls that stay generic, including those whose checked bodies are unavailable.
Rejected rewrites#
Define a function outside the loop when every iteration can reuse it. loop-invariant-closure suggests eligible cases:
local isClick = |event| -> event.kind == "click"
for _, item in ipairs(items) do
register(item, isClick)
endSuppress intentional cases with @allow("loop-invariant-closure"). Closures that depend on the iteration still block tracing; jit-loop-closure reports them when enabled or inside @jit functions.
Benchmark details
Measure your own workload with benchmarks before choosing an optimization level or disabling a pass.
Recorded local medians, measuring generated code rather than checker time. Rows use LuaJIT unless marked AOT:
| Pass and scenario | Before | After | Change |
|---|---|---|---|
| OPT-1, 200,000 tables, four named fields | 0.0159s | 0.0053s | 3.02x faster |
| OPT-1, 200,000 tables, eight hash fields | 0.0262s | 0.0106s | 2.47x faster |
| OPT-1, 200,000 tables, four array slots | 0.0168s | 0.0024s | 7.06x faster |
| OPT-2, eight million visits, 4-element arrays | 0.0126s | 0.0087s | 1.44x faster |
| OPT-2, eight million visits, 32-element arrays | 0.0053s | 0.0050s | 1.06x faster |
| OPT-2, eight million visits, 256-element arrays | 0.0051s | 0.0047s | 1.07x faster |
| OPT-3, 20,000 primitive expressions, load and run | 0.0039s | 0.0024s | 1.64x faster |
| OPT-3, 20,000 nested paths, load only | 0.0095s | 0.0025s | 3.82x faster |
| OPT-3, 20,000 nested paths, load and run | 0.0099s | 0.0025s | 3.95x faster |
| OPT-4, 20,000 dotted calls, load only | 0.0027s | 0.0011s | 2.54x faster |
| OPT-4, 20,000 dotted calls, load and run | 0.0030s | 0.0012s | 2.53x faster |
| OPT-6, 8 million struct element updates | 0.01075s | 0.00735s | 1.46x faster |
| OPT-6, SoA projected update vs handwritten columns | 0.00296s | 0.00307s | 1.037x of direct |
| OPT-6, 500,000 slice constructions | 0.12183s | 0.00425s | 28.7x faster |
Primitive folding, nested propagation, and static callable binding reduced source size by 32.1%, 60.8%, and 63.6%. Warmed speedups were 0.99x, 2.01x, and 1.06x: hot-loop gains depend on workload and trace shape.
luajit bench/presize.lua
luajit bench/numeric-ipairs.lua
luajit bench/constant-folding.lua
luajit bench/constant-propagation.lua
luajit bench/static-callable.lua
luajit bench/concat.lua
bench/span-range-lowering/run.sh
bench/kernel-subset-spike/mandelbrot.sh const-monomorph-ceiling
luajit bench/kernel-subset-spike/const-monomorph-lua_main.luaThe OPT-6 measurements used an arm64 Apple host after warmup, comparing disabled and enabled passes. Slice results cover derived-view replacement, not general escape analysis. See bench/span-range-lowering/README.md for the full matrix and trace.sh for IR comparisons.
const-monomorph-lua_main.lua times a runtime round count against a literal four and against four rounds written straight-line through the ordinary Lua path; see bench/kernel-subset-spike/README.md for its recorded ratios.
Inspecting, controlling, and measuring#
nupp build -O1 --remarks
nupp build -O1 -Zno-opt=OPT-2build --remarks and run --remarks report successful and declined rewrites with source locations. Add --remarks-for src/work.nupp to select one source, and --remarks-out to write build/remarks.json (--remarks-out=PATH names another file). Each machine-readable remark has status (fired, declined, or unavailable). Static decisions carry hotness: "unknown". With run --profile --remarks-out, Lua leaf samples are joined to each decision's source range: hotness: "sampled", hotnessSamples, and hotnessRange identify the measured count and its scope. --remarks prints the count after a profiled run. Zero means no sample landed there, not proof that it never ran. Native AOT decisions stay unknown because the Lua sampler cannot attribute C work. Files with no Lua source samples also stay unknown. The artifact's sampling preserves the source samples, interval, and attributed/unattributed totals. -O0 reports that optimization is unavailable instead of silently producing no decisions. Explicit-file builds write beneath build/ by default; -o DIR changes that directory.
Remarks never fail a build; check does not optimize. Use -Zno-opt=CODE to disable one pass or -O0 to disable all passes. OPT-n codes are stable; -Z flags are debugging interfaces.
FAQ#
Does -O1 change how a program works?#
No. Optimization preserves results; other observable guarantees require explicit @relax permission to change.
How do I debug why an optimization didn't fire?#
Use --remarks to see why the compiler left the code unchanged. Common blockers are mutable bindings and calls whose effects are unknown. A helper call that Nupp leaves unchanged may still be inlined by LuaJIT or the native compiler.
Why does my ipairs loop still use an iterator?#
An array type alone does not prove that the loop can safely use numeric indexing. Writes that change the array's shape, calls with unknown effects, yields, metatable effects, or a shadowed ipairs keep the iterator. See numeric ipairs for an eligible loop.
Why wasn't my loop unrolled?#
A known loop bound alone does not request unrolling. Use const monomorphization for specialization through explicit const arguments, or comptime blocks for compile-time evaluation.
Why is this expression still evaluated at runtime?#
Constant folding leaves floating-point arithmetic, cdata, calls, and allocations at runtime. Reassigned bindings also stay at runtime, including those written by nested functions; a separate binding with the same name does not prevent folding.
Why wasn't my concatenation loop converted to a buffer?#
The string must start as "", with one primitive out = out .. ... accumulation and no intervening uses. Reads or captures inside the loop, prepends, multiple accumulations, and possible __concat effects prevent the rewrite. Concat buffering applies to loops; straight-line concatenation is unchanged.
Why do my indexed accesses still have bounds checks?#
At -O0, accesses keep checked helpers. At -O1, held frames, computed indices, other spans, and accesses outside the validated loop still use those helpers; the range proof applies only within its function. See indexed views for the supported loop pattern.