Performance#

Nupp uses type and effect information to simplify generated Lua:

local point = {}
point.x = 1
point.y = 2
local point = {x = 1, y = 2}

Always-on lowerings implement language features. Optimization passes require -O1:

nupp build -O1
nupp run -O1 --remarks app.nupp

Generated 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)
end
function 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)
end

Both 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 = __nuppT3

Table intrinsics#

table.new and table.clear get private module bindings so their LuaJIT builtins are available:

function m.build(): {string:boolean}
    local cache = table.new(128, 8)
    cache.ready = true
    table.clear(cache)
    return cache
end
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
end

Under @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()
end
const __nuppBuffer = require("string.buffer")

function m.pair()
    local b = __nuppBuffer.new()
    b:put("a", "b")
    return b:tostring()
end

The 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 = switch status do
    case 200 -> formatStatus(status)
    case 301 -> "redirect"
    else -> "other"
end
local text
if status == 200 then text = formatStatus(status)
elseif status == 301 then text = "redirect"
else text = "other"
end

Static result maps#

Enough static scalar cases and results, including else, become one table read. Closely spaced integers use an offset into a dense array:

local label = switch byte do
    case 9 -> "tab"
    case 10 -> "newline"
    case 11 -> "vertical tab"
    case 12 -> "form feed"
    case 13 -> "return"
    else -> "other"
end
const __nuppSwitchMap1 = {"tab", "newline", "vertical tab", "form feed", "return"}

local label = __nuppSwitchMap1[byte - (9) + 1]
if label == nil then label = "other" end

Widely 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
end
const __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 end

String cases use the same lookup, including cases that return nil:

local kind = switch word do
    case "and" -> "operator"
    case "break" -> "statement"
    case "do" -> "block"
    case "else" -> nil
    case "end" -> "block"
    case "false" -> "literal"
    case "for" -> "loop"
    case "function" -> "declaration"
    else -> "name"
end
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 end

Maps 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
end
function m.make()
    local point = {
        x = 1,
        y = 2,
        z = 3,
    }
    return point
end
function m.make()
    local point = {}
    point.x = 1
    point.y = 2
    point.z = 3
    return point
end

Other 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(): integer
    local xs: {integer} = {10, 20, 30}
    local sum: integer = 0
    for index, value in ipairs(xs) do
        sum = sum + index * value
    end
    return sum
end
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
end
function m.total()
    local xs = {10, 20, 30}
    local sum = 0
    for index, value in ipairs(xs) do
        sum = sum + index * value
    end
    return sum
end

OPT-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")
end
const prefix = "nu"
const answer = 20

function m.show()
    print("nupp", 20, true)
end
const prefix = "nu"
const answer = (2 + 3) * 4

function m.show()
    print((true and prefix) .. "pp", answer, "nupp" < "zulu")
end

Unchanged locals#

Ordinary scalar locals also propagate when their bindings are never reassigned:

function m.answer(): number
    local size = 6
    return size * 7
end
function m.answer()
    return 42
end
function m.answer()
    local size = 6
    return size * 7
end

Unused 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: function(): boolean): (boolean, boolean, boolean)
    return false and read(), true or read(), true and read()
end
function m.choose(read)
    return false, true, (read())
end
function m.choose(read)
    return false and read(), true or read(), true and read()
end

Integer 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)
end
const STRIDE = 64

const FLAGS = 9
const WRAP = 1
const LOG = 2147483644
const AR = -4

function m.show()
    print(64, 9, 1, 2147483644, -4)
end
const 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)
end

Division 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
end
function m.run(unreachable, alsoUnreachable)
    do
    end
    do
    end
end
function m.run(unreachable, alsoUnreachable)
    while false do
        unreachable()
    end
    for index = 1, 0 do
        alsoUnreachable(index)
    end
end

A 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
end
function m.pick(active)
    do
        if active then
            print("active")
        else
            print("reachable")
        end
    end
end
function m.pick(active)
    if false then
        error("unreachable")
    elseif active then
        print("active")
    elseif 2 < 3 then
        print("reachable")
    else
        error("also unreachable")
    end
end

Nested 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)
end

A 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()
end

The 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
end
const __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()
end
function m.join(items)
    local out = ""
    for _, item in ipairs(items) do
        out = out .. item .. ","
    end
    return out
end

Repeated 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
end
function 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
end
function 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
end

Views 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: soa.WriteSpan<Particle>, delta: float): nil
    const rows = view
    for index = 1, #rows do
        rows[index].x += rows[index].dx * delta
        rows[index].y += rows[index].dy * delta
    end
end
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
end
function 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
end

Supported 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: int32, right: int32, left: int32): int32
    return (value >> right) | (value << left)
end

function m.spun(word: int32): int32
    return rotateRight(word, 7, 25)
end
local function rotateRight(value, right, left)
   return (value >> right) | (value << left)
end

function m.spun(word)
   return ((word >> 7) | (word << 25))
end

The 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
end
local function add(left, right)
    return left + right
end

function m.answer()
    return 20
end
local function add(left, right)
    return left + right
end

function m.answer()
    local size = add(2, 3)
    return size * 4
end

AOT 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 error with a level, debug, getfenv or setfenv in 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<const N: integer>(value: number, count: N): number
    local total = value
    for offset = 1, count as integer do
        total = total + offset
    end
    return total
end

function m.answer(): number
    return accumulate(10.0, 4)
end
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)
end
local 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)
end

The 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)
end

Suppress 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.lua

The 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-2

build --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.