AOT builds and artifacts#

A target policy decides whether AOT is disabled or required as part of the deliverable. Required builds compile and validate the artifact instead of retaining a silent source fallback.

A build selects one policy, and an artifact records the one it was built under:

targets = {
   game = {kind = "modules", entries = {"game"}, outDir = "build/game", aot = "require"},
}
  • off does nothing. It is the default.
  • require verifies the IR, compiles it into <outDir>/lib/, and fails the build when it cannot.
  • emit-wasm compiles admitted entries into content-addressed Wasm modules while retaining their ordinary Lua bodies.
  • require-wasm packages those modules and replaces the Lua bodies with calls into them.

A module with no @aot function produces no artifact at all, and a project with no @aot function anywhere builds successfully under require with no library. The policy says what to do with compiled code, not that there must be some.

Under require, calls reach the compiled code. The build replaces each @aot function with the generated wrapper where it was written, so every call in the file and every importer gets the compiled body without naming anything new. Kernel wrappers call FFI symbols. Builder wrappers call a cached registered Lua C closure, so no generated FFI code fabricates or discovers a lua_State *.

Explicit compilation modes

There is deliberately no mode that quietly mixes compiled functions with ordinary fallbacks. Disabling compilation is meant to change performance and packaging, never an answer, and a policy that silently fell back per function would make a benchmark unattributable and a numeric contract unenforceable.

The code generator#

nupp compiles AOT code itself. LLVM and its linker, lld, are part of the nupp binary: the verified IR is lowered to LLVM IR, optimized, compiled to an object for the selected target and feature tier, and linked, all in process. No C compiler, assembler, system linker or SDK is run, and none has to be installed. The programs it produces carry no LLVM of their own.

Line tables name each authored file from the project's directory, and a macOS library's debug map names its objects the same way, so one project built in two checkouts produces the same library. A source outside the project keeps its absolute path.

A nupp built from source without the code generator refuses require and says so; scripts/toolchain llvm provides the pinned LLVM a checkout links in.

The numeric contract is the code generator's too. No fast-math flag is ever set, a multiply and an add fuse only inside a function that asked for it with @relax("fp-contract"), and reassociation is allowed only for the algebraic reducers that name it. See numeric semantics.

Building for another machine#

aotTarget names the machine the compiled code is for, separately from where the Lua runs:

targets = {
   handheld = {
      kind = "modules",
      entries = {"game"},
      aot = "require",
      aotTarget = "x86_64-unknown-linux-gnu",
      aotFeatures = {minimum = "baseline", maximum = "avx2"},
   },
}

The triple decides the available tiers, how a shared library is produced and what it is called, so a Windows target gets a .dll whether or not the build is running on Windows. Each bound of the feature range is checked against that target's architecture, so asking aarch64 for avx2 is refused where it is written.

Compiling for another target needs nothing installed: the code generator has every target's backend, so require builds the other machine's library on this one. Beside it the build writes one LLVM IR unit per (source, tier), the baseline feature detector where selection is needed, and aot/units.json, which names every unit's tier and the options it compiled with. A compiler-owned unit carries a role saying which one it is; detector stays true for the feature detector alone. Static linkage adds an archive-probe unit and aot/link.json beside it, described under static AOT components.

A standalone program for another platform links from that platform's link kit; see distribution.

Code is linked, never mapped at run time. A shared library the loader already brought in needs no W^X policy, no MAP_JIT, no executable-memory budget and no code retirement, all of which exist only because code is mapped at run time.

Library dispatch#

The wrapper is ordinary Nupp. nupp aot --emit binding prints it, and what the build splices in is the same text minus the parts the source already has:

cdef function ks_scale_layout_Sample_size(): uint64 from"build/native/lib/libnative_aot.dylib"
const ks_scale_SampleLayout = layoutof(Sample)
if ks_scale_SampleLayout.size ~= ks_scale_layout_Sample_size() then
    error("native struct layout size mismatch", 0)
end

cdef function ks_scale(exclusive samples: voidptr, borrows source: voidptr, ...) from"..."

local function scale(exclusive samples: span.WriteSpan<Sample>, ...): nil
    if first < 1 or first > last + 1 or last > #samples then
        error("native precondition failed", 2)
    end
    local native_samples, native_samplesCount = samples:ref()
    ...
    @unsafe do
        ks_scale(native_samples as voidptr, ..., native_samplesCount)
    end
end

Because it is Nupp rather than generated Lua, it goes through the checker like anything else: the ownership annotations, the precondition checks and the one-statement-wide @unsafe do are all checked, not trusted. Those checks are the relations the source's own guards stated, so the wrapper holds a caller to what the function was written to require and not to a weaker restatement of it. A substitution cannot smuggle in something the language would refuse.

It is written where the declaration was, which is necessarily after the struct it reifies, and under the same name with the same signature. The struct layout is compared against what the compiled object reports before the module finishes loading, so an object that laid the struct out differently is a load error rather than a silent misread.

The module is hashed on the text that was compiled rather than the file on disk, so a rebuild never reuses an artifact built from a different body. nupp check does none of this: it answers a question about the source as written. It lowers each @aot function the target reaches, as the build would, and reports what lowering refuses as NUPP2905 through NUPP2908, but it compiles, links and substitutes nothing, and needs no code generator. Under off it lowers nothing, because the build does not either.

Shipping a shared artifact#

This section describes the default aotLinkage = "shared" route. A static component ships its archive to the host build rather than carrying a lib/ sidecar; see Static AOT components.

The wrapper names the library with a leading @, which means beside the module that loads me rather than at this path:

__nuppLib("@lib/libgame_aot.dylib")

VM-aware builders resolve the same @lib/ reference before passing the path and registrar symbol to package.loadlib.

At load, that is resolved against the chunk the wrapper was compiled into, walking up until it finds the directory. Whatever path the loader used to open the module is a path that works from wherever the program was started, so a sibling of it does too, which makes the output tree relocatable. The walk is also why one form serves every layout: a module named a.b.kernel sits two directories down and the same module inlined at the root of a bundle sits at the top, and the library is in one place either way.

A single-artifact target, meaning bundle, binary or component, gets a copy of the library beside whatever it wrote, because that artifact is what someone carries somewhere and the build directory is not going with it:

 dist/
   app.lua
   lib/libgame_aot.dylib

Copy the output tree, move it, hand it to someone: it runs. Copy it without the lib/ directory and the load fails by name, saying what it looked for and where, rather than with a missing symbol later on. See Distribution for the artifact kinds.

Relocatable library paths

A path decided at build time could not be relocatable: an absolute path pins the program to one machine, and a relative one pins it to one directory. Resolving against the loaded chunk is the only form that survives being copied, because the loader has already proved that path works from wherever the program started.

Artifact cache key#

Each artifact is recorded under a key covering everything that can change its bytes: the verified IR, the version of the IR vocabulary, the numeric-contract version, the target triple and feature tier, the code generator's identity and the facts it states, and the compiler's own fingerprint. A rebuild that computes the same key leaves the file alone.

The key is over the IR rather than the source, so two sources that lower to one program share one artifact and a comment edit is not a rebuild. The numeric-contract version is separate from the IR version on purpose: two compilers can agree about every field of a program and disagree about whether an operator contracts, and an artifact built under one contract must not be reused under another.

The key is evidence, never authority. A build compares it, then checks that the file it describes is still on disk with the bytes it claims; a deleted or edited artifact is written again rather than believed because a digest agreed. Losing the record costs one rebuild and changes no answer.

The linked library gets its own key, over every tier's artifact key and the options each compiled with, plus the detector and the linkage. Each unit is compiled to an object for its own tier, then those objects are linked. A new code generator relinks; rebuilding an unchanged project does not. The library is validated the same way and is just as disposable, so deleting it costs one relink.

Object reuse#

Every object file carries a key of its own, over its unit's artifact key, its feature tier, the code generator and the options it compiles that tier with. The library key says a link is owed; the object keys say which objects it is owed for. Editing one @aot body therefore compiles that body's objects -- one per feature tier where a target has several -- and relinks, rather than compiling everything the library contains. A Wasm policy keys its modules the same way and compiles only the ones that moved.

Objects are validated the way artifacts are: the key is compared and then the file it names has to still be there, so a deleted object is compiled again rather than believed.

Objects that have to be compiled are compiled side by side, at most one per processor, each in a process of its own, so a unit the code generator cannot handle fails that unit's build with a message rather than taking nupp down. NUPP_AOT_JOBS sets that bound directly, for a machine that is sharing its processors with something else. Order does not depend on the width: a build reports the first failing unit in emission order, whichever process happened to finish first.

An unchanged build compiles and links nothing. build --json reports what a build reused, what it compiled, and how many external processes its ahead-of-time policy started, which is zero.