Skip to content

[JSC] Ahead-of-time compilation - #757

Merged
Jarred-Sumner merged 470 commits into
mainfrom
claude/compile-aot
Oct 25, 2026
Merged

Jarred-Sumner merged 470 commits into
mainfrom
claude/compile-aot

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

The engine half of bun build --compile --aot. Experimental.

The Bun half is oven-sh/bun#45631. It has the type checker, the translation to IR, the linker, the executable writer, the docs, most of the tests and all of the numbers. Start there if you want to know what the feature does.

What this adds

AOT::compileProgram() (aot/image/AOTCompile.h) takes a whole program as IR and returns an image: machine code plus read-only data. An executable that carries an image runs with the JIT off.

The input is an IR in SSA form that Bun's front end makes from the syntax trees and types of its TypeScript checker. No function is compiled from bytecode. The interpreter is never a fallback: a function that the build saw and can't compile is a build error. Only text that arrives at runtime (eval, new Function, node:vm) is interpreted.

A declared type is a prediction until something proves it. A whole-program flow analysis proves what it can. What's left is tested once, where the value arrives, and a test that fails continues in a generic version of the same function. Nothing throws, converts or copies because of a type.

There's no profile of the program and no heap snapshot, and none of the program runs at build time.

It's off by default. useAOT is false, and aot/ is behind ENABLE(AOT).

The stages

Pipeline::runAllStages() in aot/pipeline/AOTPipelineStages.cpp is the order. The table of passes is in aot/pipeline/AOTPipeline.cpp: each pass with the IR form it runs on, the analyses it needs and the ones it invalidates.

Stage What it does Folder in JavaScriptCore/aot/
Read, type, verify Decodes the program, types every value, adds the type tests at function entries. The verifier checks every operand and stored value against the type of its place ir/
Flow One analysis over the whole program: which allocation sites, closures, built-ins and constants reach each position valueflow/
Optimize Function passes on all threads, with program passes between them passes/, analysis/, pipeline/
Place Where each field goes and in which form, shape numbers, the method table, cache slots place/
Select, represent Turns IR operations into the forms that the code generator has. Picks Int32, Double, Cell32 or JSValue for each value select/, represent/
Code IR to B3, then to machine code codegen/, builtins/, stubs/
Image Code layout, linking, outlining, identical code folding, RegExp and WebAssembly code, strings image/
Runtime Objects, closures, modules, coroutines, caches, slow paths object/, runtime/

aot/ir/AOTSchema.def and aot/ir/schema/ describe the types, every operation, the passes, limits and diagnostics. A generator in TypeScript (aot/ir/generator/) writes the engine's C++ and the Rust of Bun's front end from them. None of its output is checked in. The IR has a text form with a parser and a printer, and most engine tests are IR text.

Objects

 0  JSCell
 8  order word, 32 bits: key order, number of properties, flags
12  shape word, 32 bits: the number of the layout, negated
16  fields: Int32, Double, Boolean, Cell32, JSValue ..

Classes, object literals and object types get a layout that the compiler fixes: no butterfly, raw numbers and booleans, 32-bit references. To the rest of the engine an AOT::Object is a JSObject that overrides the method table. delete, a redefinition or a store that a slot can't hold costs that one slot, and the property moves to a side object.

With useAOT, each heap takes its MarkedBlocks from one reservation (heap/MarkedBlockCage.h, 32 GB by default), so a reference to a cell fits in 32 bits.

No code is patched at runtime. An access by name calls a shared stub that probes tables per VM. Compiled code has its own calling convention (aot/stubs/AOTConvention.h), with adapter stubs at the boundary with JSC's. A compiled frame has no CodeBlock, and stack walkers find the function by return address.

Outside aot/

  • RegExp literals are compiled into the image by a second form of the generator in yarr/YarrJIT.cpp (yarr/YarrCodeGenerator.cpp). A RegExp made at runtime runs in a new matcher (yarr/YarrFlatProgram*).
  • A .wasm file that the program imports is compiled by OMG at build time (wasm/WasmCodeInImage.*). Any other module runs in IPInt.
  • FFI call stubs and a pool of callback entries are in the image (ffi/FFIStubs.*).
  • With useAOT, LLInt uses the handlers of llint/LowLevelInterpreterAOT.asm, which read compiled objects through the same tables as the stubs and write no profiles.

Look closest here

Two things that every process runs, image or not.

B3 and Air come first. A B3::Procedure carries a B3::Policy (b3/B3Policy.h, b3/air/AirPolicy.h) with named switches, all off by default. Only aot/codegen/AOTB3Policy.cpp turns switches on, so FTL and OMG run with every switch off. But the shared phases were restructured to ask the policy, and some shared algorithms were replaced to keep build time down on very large functions, with no policy test: the greedy register allocator, B3ReduceStrength.cpp, AirOptimizeBlockOrder.cpp, AirAllocateStackByGraphColoring.cpp, B3LowerToAir.cpp, and five graph headers in WTF. FTL, OMG and in part DFG run that code.

The second is the collector's marking loop. SlotVisitor::drainStack() visits the common kinds of cells inline (heap/CellVisitKind.h, heap/InlineCellVisits.h). CellVisitValidator compares an inline visit with the method table's under verifyGC.

Most of the other edits to existing files (runtime/Structure.*, JSObject*, interpreter/StackVisitor.*, heap/Heap.*, the assemblers) are behind tests that are false in a process without an image, such as vm.hasCompiledCode() and Structure::isCompiledStructure(). The largest one that isn't is the fast path of JSON.stringify (runtime/JSONObject.cpp), which takes more cases.

Testing

jsc can't compile a program by itself, because the IR comes from Bun's front end. Whole programs are tested on the Bun side: tens of thousands of them, each run as plain Bun, compiled, and compiled with its types erased.

testaot (aot/tests/, with a README) is the test program of this half: IR text tests and native ones. An IR test is a small program in IR text, a command (pass <name>, pipeline <until>, compile ..) and CHECK lines. Every pass, analysis, selection rule and flow rule has tests of its own. run-javascriptcore-tests --testaot runs it.

The IR verifier runs at the form boundaries, or after every pass. With --aot-validate, compiled code checks every fact that it relies on at runtime and stops on a miss, and aot/tests/validation/ has one deliberately false fact per producer, each of which has to be caught.

There are 42 new b3/testb3_bun* files and 12 b3/air/testair_bun* files for the new and changed phases, and about 50 new files in JSTests/stress.

Building

node is now a build dependency, 24.2 or later, or 22.18 or later on the 22 line. It runs the generators in aot/ as TypeScript while CMake configures.

A lane's label says whether it has the compiler. Lanes with -aot in their label have the compiler and the runtime of its programs, and they are what bun links. The release, -lto and -lto-hardened lanes keep their labels and are compiled with ENABLE_AOT=0. CI tests Linux x64 and arm64 -lto-aot, their -lto twins, and x64 -lto-hardened-aot.

Where to start reading

Nothing generated is checked in.

  1. aot/ir/AOTSchema.def, then aot/ir/schema/operations.def and types.def: the contract with the front end.
  2. aot/pipeline/AOTPipelineStages.cpp and the table in AOTPipeline.cpp.
  3. aot/tests/README.md, then any aot/tests/passes/<pass>.ir. add-generic-version.ir shows the fallback.
  4. aot/object/AOTCellFormat.h and AOTObject.h: the object model.
  5. aot/valueflow/AOTValueFlow.h: what the parts of the flow analysis share.
  6. aot/stubs/AOTConvention.h and aot/codegen/AOTB3Policy.cpp: the calling convention, and how B3 and Air are set up.
  7. aot/image/AOTImageFormat.h: what an image is.

@github-actions

github-actions Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Preview build of 9e2cded: autobuild-preview-pr-757-9e2cded0

Type coverage counts sites. In a large program most code never runs, so the
share of sites that are specialized says little about the time spent: in one
program the second most frequent reason for an untyped property access, by
sites, accounts for 0.2% of the accesses that are executed.

With --useAOTTypeCoverageCounters, together with --aotTypeCoveragePath,
compiled code keeps two counters per operation: how often it ran, and how many
calls it made off its usual path, which shows a fast path that does not hold.
The index of a counter is the first counter of the operation's own function
plus its bytecode offset, so the copies of an operation that is inlined or in a
split loop share it. The coverage file has the index as one more column.

The counters are reached through the Instance. With
--aotTypeCoverageCountsPath=<file> they are that file mapped into memory and
shared, so no code has to run when the process ends, a crash loses nothing, a
copy of the file taken while the program runs is a snapshot, and a second run
adds to the first. Without the option they are counted and lost.

Only a build that asks for the counters has them. It executes about half as
many instructions again.

In the jsc shell aotTypeCoverage() reports both counts of each operation.
A call was reported as made off the usual path only if it was emitted in a
block created cold. Most slow paths are ordinary blocks that are branched to
with rarely(): 143 such branches against 41 cold blocks. Their calls were
reported as always made, so op_to_this or a read of a lazily made closure
variable looked like a call into the runtime each time, and the shares of
operations that call a stub or the runtime were far too high.

Counters: three per operation instead of two. How often it ran, how many calls
to stubs it made and how many to the runtime, counted at every call that is
emitted. What share of the executions is inline is now measured and depends on
no classification.

Sites: a call emitted in the block where its operation starts is always made.
Any other is rare if its block is cold, or is the target of rarely() and not
also of usually() or unsure(). Lowering has members of those names that record
the block, when coverage is on, and forward to FTL's.
    const Kind = { User: "user", Count: 2 };
    function f(x) { return x === Kind.User ? Kind.Count + 1 : 0; }

A variable with one store in the whole program, of an object literal that has
no other use, and whose value is only ever read, holds a constant object. The
only reference to it is in the variable and each use of the variable is seen,
and Object.prototype is immutable in an image, so what `X.p` gives is decided
by the literal:

- a property whose value is a number, string, boolean, null or undefined folds
  to it, and what follows is compiled with a constant: above, a comparison with
  a known string and the number 3;
- a property whose value is made at run time is a load at an offset known when
  the image is built, without a lookup or a check of the structure;
- a name that neither the literal nor Object.prototype has is undefined.

If each name that is read folds, and nothing needs the object itself, it is
never allocated: the variable holds `true` once it is assigned. What is in the
literal is still evaluated.

A use is one of four kinds. It reads a name. It only tests (null, truthiness),
which does not need the object. It only reads but needs the object: X[k], in,
for-in, ===, typeof, spread, and as the first argument of Object.keys, values,
entries, hasOwn, getOwnPropertyNames and isFrozen; named reads still fold. All
else gives the object away or changes it, and the variable is left alone: being
passed, returned, stored, written to, deleted from. So Object.defineProperty,
Object.assign, Reflect.set or Object.setPrototypeOf cannot reach one, directly
or in a function that is given it. Another argument of those built-ins is made a
property key of, which calls a toString the literal may have.

A read before the variable is assigned takes a cold path to the generic read,
which throws what the interpreter throws. It is left out where the type of the
variable has no undefined, as after the check of a const.

A variable may be in a module or in a closure. One that is exported is left
alone unless the bundler says it is only imported statically and the links of
the graph do not read it through a namespace. One in a scope that direct eval
sees is left alone. An access whose scope cannot be told, as after an await,
touches the slot of that offset in the scopes its function makes or one of that
name around it. A copy to a register in the frame counts if it may be read from
there before the register is written again.

A string constant is referred to from another function by the code block that
owns it: Node::ownerOfConstant. Literals of up to 4096 properties are planned.

--verboseAOTCompilation says of each literal that is stored in a variable why it
is not constant.
On by default:
- A local, a known function or a scope register that a generator or an async
  function restores after it resumes keeps its type. The generator frame is a
  scope like any other: its slots are variables with one identity, and their
  initial value is never read.
- A variable that is read and only used by operations that no empty value
  reaches is not empty: the bytecode generator left the TDZ check out.

Behind options that are off, to be decided or removed before this lands:
- useAOTScopeAsCallee: a nested function that is only called by name has no
  function object. Its scope is passed in the callee slot.
- useAOTEnvironmentsOnStack: the environments of a function none of whose
  closures outlive it are in its frame.
- useAOTCapturesByValue: a variable that is not assigned after the closures
  that read it are made is copied into them (JSFunctionWithCaptures). A scope
  with only such variables is in no scope chain; whether its function keeps the
  variables in registers or in an environment of its own is a local choice.
  Covers closures in closures, async functions and their bodies, generators,
  and functions with handlers. --verboseAOTCompilation says why each other
  scope is still made.
- useAOTNamelessScopes: a scope that no name is looked up in shares a symbol
  table that only says how many variables there are.
- useAOTWholeFunctionSplitting: two copies of a function, not only of its
  loops. A check that fails in the first continues in the second, which does
  not come back.

Known problems: a large application computes a wrong value with
useAOTCapturesByValue and crashes in the collector with useAOTNamelessScopes;
aotDissolvedScopesFrom/Below and aotNamelessScopeKinds exist to bisect those.
With useAOTWholeFunctionSplitting several block-local optimizations stop
applying. Looking through a phi for the location of a scope has no small test.
Loops (on by default)

A split loop over a Uint8Array whose type is not known ran in its generic
copy: 160 to 207 instructions a byte for a search for a newline, now 34 to 39.

- The guard of a length read knows typed arrays when the type of the base is
  not known. "length" is an inherited getter that an own property, a subclass
  or a change to a prototype can shadow, so the guard checks what shows that
  it is the built-in one: the structures of the array, of its prototype and
  of %TypedArray%.prototype, and the accessor in the slot of the latter. The
  inline cache records them (Instance::typedArraysWithBuiltinLength). The
  guard for a base that is known to be a typed array lacked the check, and
  the stub took any getter on %TypedArray%.prototype for the built-in one.
- A strict comparison has no effects if one side cannot be a string or a
  BigInt. It kept guards from being hoisted out of such loops.
- A guard of an element read with the base and index of an earlier one is
  redundant in a loop that writes no elements.
- At the entry of a split loop, a register that the loop increments or
  decrements and that is not known to be a number is checked to be an int32
  (GuardKind::Entry); otherwise the generic copy runs.
- For an invariant base of unknown type the pre-header loads the vector and
  length if it is a Uint8Array that cannot be resized, and a length of 0
  otherwise; in the loop, index < that length means: load the byte.

- The GetLength stub checks the same proof, for two structures of each type
  (the original one and another, such as that of a subclass) and up to two
  prototypes before %TypedArray%.prototype. The length of an object of a
  subclass of a subclass of %TypedArray% was looked up in C++ at every read:
  1,170 instructions, now about 40.
- A callee with a loop of its own is not inlined into a loop, unless it is a
  builtin or is passed a callback: its loop was split less well there.

- The proof is recorded with and without data stubs, and the slow path of a
  read of a well-known property answers from it.

The description of useAOTDataStubs and the test runner said that only ARM64
has data stubs. x86-64 has had them since "data stubs, front ends and
intrinsic calls on x86-64". run-tests.py can run a test as a module ("-m").

Likely callees (on by default)

A call of a module variable that is initialized with a function expression
and may be assigned something else (let hook = () => undefined) has that
function inlined under a check that the callee is an object made from it
(GuardKind::IsLikelyFunction); otherwise the call is made. The site caches
the object. The image keeps the table from function index to function number
for this. 121 to 78 instructions for a function that calls such a hook.

A RegExp literal whose only use is as the first argument of replace,
replaceAll, match, search or split of a value known to be a string is one
object for the site, and does not take away the result type of the call.

Scopes and generator frames (off by default)

Fixes a miscompile of capture by value: a lookup that starts from a scope
register restored after a suspension walked one hop too far for each scope
above it that is in no chain (Graph::dissolvedScopesAbove).

useAOTSavesAtDefinitions: a register with exactly one definition outside
loops is stored to its frame slot there, not at every suspension. Arguments
do not qualify: the body is entered again with other arguments at each
resumption. Consecutive stores to one scope share a write barrier.

useAOTClearsDeadFrameSlots: at a suspension, a slot that was live at the last
suspension, is not any more and may hold a cell is cleared.

useAOTScopesInFrames: the variables of a scope of the body that is made once,
outside loops, and lives across a suspension are more slots of the frame.
- A restored scope register is the environment that its own suspension saved.
- Where a scope is lexically is found through any restored register, and
  coinductively through phis (a loop whose body pushes and pops a scope).
- Every name of a promoted environment is lowered as the nearest real scope
  above it, so phis of such names need no object, in any function.
- An extended frame takes its symbol table, by size, from the instance.
It does not compile every program yet.

Removes useAOTNamelessScopes: ProgramData already drops the names that no
lookup can ask for and shares the tables that are left without names.
… on x86-64

With useAOTCapturesByValue, a lookup from an inlined closure that walks the
scope chain past a scope that is never made went one hop too far for each
such scope: the walk was only corrected for code that is not inlined. It read
a slot of the wrong environment. tsc crashed, unless useAOTScopeAsCallee took
the functions in question out of reach.

Several return values are handed back in the argument registers, up to 8 of
them. x86-64 has 4, so compiling a function that returns an object literal
with more properties hit an assertion in B3. The limit is the number of
argument registers.

The callee of inlined code may be replaced after inlining (a read of a method
of an object literal that is not allocated is replaced by the function). The
lowering followed a pointer to the node that was replaced.
Neither brings anything that can be measured once variables are captured by
value: tsc on a large project takes 93.1 billion instructions with them and
93.2 without, another large program 698.1 million a unit of work either way.

Both are wrong in corner cases. A function whose environments are in its
frame may be inlined into code with a direct eval (7 crashes in JSTests/stress),
and the copy that sloppy mode makes of a function declared in a block was not
seen as a use of its value.
…not options

What useAOTCapturesByValue, useAOTSavesAtDefinitions and
useAOTClearsDeadFrameSlots turned on is what the compiler does:

- A variable that is not assigned after the closures that read it are made is
  copied into them, and a scope with only such variables is never made.
- A register of a generator or an async function with one definition outside
  loops is stored to its frame slot there, not at every suspension.
- At a suspension, a frame slot that was live at the last suspension, is not
  any more and may hold a cell is cleared.

On a large program with many async functions: 9% fewer instructions for a unit
of work. tsc on a large project: 2% fewer instructions, 8% less memory at peak.

Removes the two options for bisecting over scopes and the diagnostic output of
the prototypes.
Only loops have two copies.

useAOTWholeFunctionSplitting made two copies of a whole function, so that a
check that fails continues in a generic copy and nothing merges back. tsc took
5 to 8% more instructions and 500 MB more memory with it, with up to twice the
code: about 25 optimizations leave guarded operations alone. Guarding only what
would otherwise call a stub leaves a bet that a site sees one structure, and
reads with that check inline were measured before: no gain on a large program,
where half of the time in the read stub is on its polymorphic path.

useAOTFunctionSplitting did the same for functions with typed property
accesses, and miscompiled tsc. GuardKind::Field goes with it.
…e useAOTScopesInFrames

Which function a new_func or new_func_exp makes was looked up by the function
number in the type of the node. A type has 18 bits for it. For a function with
a larger number nothing was found, so the closure was made without the
variables it captures while the scope they were in was not made either: it
read an empty slot. The function is looked up by its executable
(Graph::functionMadeBy). Two tests run once more with
--maxAOTFunctionNumberInTypesForTesting=3.

useAOTScopesInFrames put the variables of a scope that lives across a
suspension into the frame of the generator. It did not compile every program,
what it could bring is small, and every instance made 256 symbol tables for
it whether it was on or not. With it go the look through restored scope
registers in the promoter, stand-ins for promoted environments and
SymbolTable::setScopeSize.
A walk up the scope chain leaves out the scopes that are never made. Three
things were wrong with how they were counted, all in functions with exception
handlers, where the scope register is reloaded from the frame.

The count asked which scope the walk starts from. For a reloaded register, or a
phi of one, that is unknown, and then nothing was left out: the walk ended too
far up. The count now comes from the lexical chain of the code block and the
number of local scopes that the bytecode gives, which needs no node. This also
replaces the special case for inlined closures.

A reloaded register may hold the scope the function was called with. If the
scope at that lexical position is never made, the register holds the next one
that is, so that position is part of the count.

The count included the scope that is walked to. A variable is never looked up
in a scope that is not made, but op_get_parent_scope may name one: a loop with
a scope for each iteration then made the previous iteration's scope the parent
of the next, and later lookups were off by one more each time around.

fuzz.py: a text without tokens is left as it is.
The collector turns the stack trace of an Error into text when a cell of one of
its frames has died. That needs the names of the functions in the trace, and
FunctionExecutable::ecmaNameWithoutGC() exists for it. For an executable of a
compiled program it made the name's identifier on first use, which adds to the
atom table, and the thread has none in that phase: a null dereference.

An error made after an await is enough. Its trace has frames for the functions
that await, these hold the function objects, and closures die.

The name is now copied out of the program data in that case, as it already is
for a name still in the bytecode cache. tryGetEcmaNameConcurrently() made the
identifier too, on whatever thread asked; it now answers only if there is one.
The instance that a compiled frame belongs to is kept in the adapter through
which the engine entered compiled code, so finding it means walking up to that
frame. Every StackVisitor did so once, and a throw makes two: below 20000
frames a throw took 73 us instead of 3, and a program that threw at every level
of a deep recursion took time quadratic in the depth.

A VM nearly always has one instance, and then it is that one.
fuzz-programs.py generates programs and compares the interpreter with compiled
code. One generator is about scopes and closures, the other about objects,
their shapes and aliases. The first found four bugs in its first two hours,
where twenty minutes of fuzz.py found none; the second finds a compiler in which
a property read is reused regardless of what happened since in 4% of its
programs, and nothing in the compiler as it is.

compare-execution-counts.py checks that two builds of a program do the same
work before their speed is compared. A build that walked one scope too far in
functions with handlers ran a large program to the end with the same number of
lines of output and 12% fewer instructions, most of which were work that it no
longer did.
No constant in compiled code may be an address, and the check is by magnitude:
a 64-bit constant from 4 GB up that is not registered as an integer is taken for
one, and the function is not compiled. In a loop an element is accessed inline,
and for a constant index the zero-extended index times the size of an element is
folded into one constant. For a negative index, or one from 2^29 up, that is
large enough: a function with a[-1] in a loop had no code, and calling it threw a
SyntaxError about the missing source text.

The scaled values of an integer constant are now registered.
A function that the compiler declined was left out of the image, which was
reported only with verboseAOTCompilation. That dates from when its bytecode was
there to be interpreted. A program now has neither bytecode nor source text, so
such a function threw a SyntaxError when it was first called.

Every reason to decline is a limitation of the compiler, not a feature that is
left out on purpose, and none occurs in JSTests/stress (6013 tests). The function
and the reason are now printed and the build stops.
Loops of a dozen forms over arrays of every indexing type, typed arrays and their
subclasses, views of resizable buffers, array-likes and strings, which change
while the loop runs. One program in ten found that a constant index out of range
left a function without code.
Expressions of every operator over values at the edges of int32, uint32 and
double, conversions, counters that run past those edges, and a few small
expressions over every pair of the edge values in a loop, where arithmetic is
compiled inline. The last part is what finds a compiler in which the product of
two integers is never -0, which the tests do not.
With the check for -0 taken out of the inline product of two integers every test
passed. This one does not: it has the pairs of operands that overflow or give -0
for each operator, as elements of arrays and as arguments, in loops, where
arithmetic is compiled inline, with and without data stubs.
…ed for testing

The lowering now emits through AOT::Output, a subclass of FTL::Output, for two
things.

FTL::Output folds an addition or a shift of two constants when it is emitted. The
sum of two wide integers is a wide integer that nothing had registered, so it was
taken for an address and the function could not be compiled: b + b, with b known
to be 4294967296. The result of a fold is now registered. That hides no address:
the constants that were folded stay in the procedure, where the check sees them.

Nearly every check that compiled code makes is a branch to a rarely taken block.
aotGuardToDropForTesting=<file>:<line> leaves out the one that is emitted at that
place in the source of the compiler. drop-guards.py does so for each of the 148,
runs the tests and then the fuzzers, and lists the guards that nothing misses.
Taking the check for -0 out of the inline product by hand had shown that no test
covered it; this needs no rebuilding.
… its structures

That arrays of a structure inherit the built-in length getter is remembered by the
ID of the structure and those of its prototypes' structures. Nothing kept these
structures alive and nothing forgot the entry when one died. An ID is an address:
the next structure to be made there took over what was known about the last, and
a typed array whose prototype had been replaced still had a length.

Instance::finalizeUnconditionally() now clears such an entry, as it does for the
inline caches.
Classes with members of every kind, super, subclasses of built-in classes, the
iterator protocol with early exits, destructuring, generators, async functions,
tagged templates, and operands that log when they are evaluated.
preferUnsplitAOTLoops, aotShapeOptimizations and aotLoopSplittingPolicy chose
between ways of compiling that were compared once; the one in use is now the only
one, and what only the other policies for splitting loops looked at is gone. The
five limits for inlining, the two numbers of cache misses before a function gets
data of its own and the block size for quotes are constants.
useGuardPagesForShortFunctionExecutables was read nowhere.

No test, tool or embedder set any of them. The images of all tests are the same
byte for byte.
Vector::grow() does not initialize bytes. The padding ahead of a table of a
compiled regular expression, and ahead of the text of a 16-bit pattern, was
whatever had been there, so that compiling a program twice gave two images.
Whole-program type inference starts from nothing and only ever adds, on several
threads at once. The outcome does not depend on the order if every step is
monotone. A call was not: while the type of the callee was still empty, because
the thread that analyzes the caller had not got there, it counted as a call of an
unknown function. Its result was anything and its arguments escaped, for good.
With four threads or more, more than half of the builds of a small program lost
the return types of its functions that way. With one thread none did.

Like the other operations, a call now has no type as long as an operand has none.

check-reproducible.py compiles the tests with one thread and with sixteen and
compares the images: 9 of 102 differed, 1 still does at times.
A variable has its initial value before anything is known to be assigned to it.
A call of a function in a variable, analyzed before the thread that analyzes the
assignment had got there, saw undefined: not nothing, and not a known function,
so the call counted as one of an unknown function after all.

Calling what is not an object throws. Such a call has no result and passes its
arguments to nobody. All 102 tests now compile to the same image with one thread
and with sixteen.

drop-guards.py: no fuzzing for a guard that is in none of forty generated
programs.
Compiling a large program on several threads still gave another image each time.
About one function in a hundred had more parameters that escape than with one
thread, and a few had wider parameter types.

- The escape analysis runs after the types of a function are final, when what
  nothing reached has been given the type of everything. It took a call of such a
  callee for a call of an unknown function. It now asks whether the callee was
  inferred to be unreachable.
- Narrowing a tested value to nothing was not worth a node. So undefined went down
  the edge for "is not undefined" as long as only callers that leave the argument
  out had been looked at, past the default value of a parameter and into the
  parameters of what is called with it.
- A parameter that is narrowed counted as merged with something else, and whether
  it is narrowed depends on its type at the time. It is the same value.

With more code that is known never to run, the inliner has to agree with the
analysis: a closure that is made and called in such code is never reached and has
no code, so it cannot be inlined either. The build stopped at the inline frame
that named it.
A function holds at most eight captured variables. When it reads more than that
from several scopes, the scope that stays is the one whose variable came ninth,
and variables came in the order of a hash table of pointers. What a function
holds was sorted by address too. Both differed from one build to the next, with
one thread as well.

A scope that may be dissolved has one maker, which lists the scopes it makes in
the order of its code.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

…tures, a store by name into an object made here runs no other code

- `SinkAllocations`: a capture of a closure that is built late gets the capture's declared type, as listed fields and elements already do. A list one of whose elements may be an unspecified value is not split by a test of its elements.
- `planFieldStore()`: where only plans without tests are wanted, a receiver that is named by the allocation that lists the field is planned as any object of its layout, so a store by name runs the same operations however its receiver is named.
- `SSARepair`: a definition that is an unspecified value gets a guard that proves nothing.
- Tests.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 Source/JavaScriptCore/runtime/JSObjectFrozenElements.cpp:65 — nit: in a compiled program, a class with private members can no longer adopt a frozen array through the return-override pattern; it throws a TypeError where the base and node succeed. tryFreezeArrayInPlace at JSObjectFrozenElements.cpp:65 moves the array to a FreezeElementsInPlace structure, which makesAllPropertiesImmutable marks hasImmutableProperties. definePrivateField and setPrivateBrand then refuse with "Cannot define private field on object with immutable properties" / "Cannot add private method to object with immutable properties". Fix: let private fields and brands be added to an array frozen in place (spec PrivateFieldAdd ignores extensibility), e.g. by moving its elements to the sparse map first as JSArray::pop does, while keeping ordinary stores refused.

    Why this was flagged

    Trigger: a process where vm.hasCompiledCode() is true (or Options::keepDenseArraysDense), so vm.arrayStoragePolicy() at VM.h:522 is KeepsDenseArraysDense; Object.freeze(array) reaches ObjectConstructor.cpp:1332 and tryFreezeArrayInPlace at JSObjectFrozenElements.cpp:49-75 returns true. The new structure gets setHasImmutableProperties(true) at Structure.cpp:1308-1309 because makesAllPropertiesImmutable at StructureTransitionTable.h:152-157 lists FreezeElementsInPlace. Later class Base { constructor(o) { return o; } } class Derived extends Base { #x = 1; #m() {} } and new Derived(frozenArray) reaches definePrivateField at JSObjectInlines.h:1048-1050 or setPrivateBrand at JSObjectInlines.h:1161-1163, which throw a TypeError on hasImmutableProperties. On the base branch Object.freeze(array) takes setIntegrityLevel, the private field and brand are added, and the construction succeeds. The hasImmutableProperties checks were meant for objects locked by makePropertiesImmutable, not for user arrays frozen by script, and nothing distinguishes the two cases there.

    Verification: Triggers when vm.arrayStoragePolicy() is KeepsDenseArraysDense and bytecode-interpreted code defines a private field or brand on an array Object.freeze() froze in place. Structure.cpp:1308-1309 sets hasImmutableProperties for FreezeElementsInPlace, so JSObjectInlines.h:1048-1050 and :1161-1163 throw. At 680ef47 a frozen array never had hasImmutableProperties and definePrivateField succeeded.

- Takes back the rule that a list one of whose elements may be an unspecified value is not split: a pair that a function returns was made on every call again.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment on lines 73 to +89
"""#define DECLARE_BUILTIN_GENERATOR(codeName, functionName, overriddenName, argumentCount) \\
JSC::FunctionExecutable* codeName##Generator(JSC::VM&);
JSC::BuiltinCode codeName##Generator(JSC::VM&);

${macroPrefix}_FOREACH_BUILTIN_CODE(DECLARE_BUILTIN_GENERATOR)
#undef DECLARE_BUILTIN_GENERATOR""")

SeparateHeaderStaticMacros = (
"""#define DECLARE_BUILTIN_GENERATOR(codeName, functionName, overriddenName, argumentCount) \\
JSC::FunctionExecutable* codeName##Generator(JSC::VM&);
JSC::BuiltinCode codeName##Generator(JSC::VM&);

${macroPrefix}_FOREACH_${objectMacro}_BUILTIN_CODE(DECLARE_BUILTIN_GENERATOR)
#undef DECLARE_BUILTIN_GENERATOR""")

CombinedJSCImplementationStaticMacros = (
"""
#define DEFINE_BUILTIN_GENERATOR(codeName, functionName, overriddenName, argumentCount) \\
JSC::FunctionExecutable* codeName##Generator(JSC::VM& vm) \\
JSC::BuiltinCode codeName##Generator(JSC::VM& vm) \\

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nit (optional): a maintainer running Tools/Scripts/run-builtins-generator-tests gets failures after merging, where the base passes. The templates at Source/JavaScriptCore/Scripts/wkbuiltins/builtins_templates.py:73-127 now emit JSC::BuiltinCode codeName##Generator and codeFor(JSC::BuiltinCodeIndex::codeName, ...), but every reference file under Source/JavaScriptCore/Scripts/tests/builtins/expected/ still holds the old JSC::FunctionExecutable* codeName##Generator text. Fix: regenerate the reference files (run-builtins-generator-tests --reset-results) so they match the new templates, which covers all the *-Combined and *-Separate .js-result files.

Why this was flagged

Trigger: any run of Tools/Scripts/run-builtins-generator-tests (Tools/Scripts/webkitpy/codegen/main.py:170 reports the mismatch). The diff changes builtins_templates.py:74, 81, 89, 100, 111 and 123 so the generator declares and defines JSC::BuiltinCode codeName##Generator(JSC::VM&) and the combined JSC body calls vm.builtinExecutables()->codeFor(JSC::BuiltinCodeIndex::codeName, s_##codeName##Intrinsic). No file under Source/JavaScriptCore/Scripts/tests/builtins/expected/ changes in this diff; for example JavaScriptCore-Builtin.Promise-Combined.js-result:81 and :167 still read JSC::FunctionExecutable* codeName##Generator(JSC::VM&). On the base these reference files matched the templates, so the test passed. This repository's own ci.yml does not run the script, so the only consequence is the broken generator test for whoever runs it.

Verification: nit. Trigger: anyone runs Tools/Scripts/run-builtins-generator-tests after merge. The diff rewrites Source/JavaScriptCore/Scripts/wkbuiltins/builtins_templates.py. Reference files under Source/JavaScriptCore/Scripts/tests/builtins/expected/ still contain the old text. Tools/Scripts/webkitpy/codegen/main.py:170 prints "Some tests FAIL!" and returns -1. The base passes and the head fails.

…hose body was left out answers a function

- Value flow, `copyProperties()`: a copy of a host object takes the enumerable members that the table of built-ins lists for it.
- New operation `NewFunctionThatThrows`: reading a property whose getter's body was left out does not throw. Calling what was read does.
- Value flow, `passAllocatedInstances()`: where `new.target` may be the unknown object, it may be any constructor that has escaped. A constructor function makes an instance of that class when the class is its own or below it.
- `FuseGeneratorLoops`: the run that begins at the creation ends at the initial yield, so the code between the creation and the first step is on no cycle.
- `SinkAllocations`: a list that may hold an unspecified value on a way that reaches it is not split by a test of its elements.
- `PendingTyping::compact()`: the entries of removed blocks move to the reachable blocks that they led to. `VerifyEachPass` does not compare types where typing ran out of rounds.
- The verifier's analyses keep the usual work limit where a build lowers it.
- Private fields and brands can be added to an array whose elements were frozen in place.
- CI: `lanes.mjs build --fail-if-slow`, which CI passes: a lane gives up when `icu/compress-data.ts` has taken 240 seconds (it takes 67 to 115) or the whole docker build twice as long as the lane's longest. "Re-run failed jobs" then builds it on another runner.
- Tests.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

@Jarred-Sumner
Jarred-Sumner merged commit 3d5b192 into main Oct 25, 2026
82 checks passed
Jarred-Sumner added a commit that referenced this pull request Oct 25, 2026
…lt-in modules (#903)

Follows #757. Fixes and build-time work in `aot/`, one change in Air, and one in CI.

## Behavior

- **Iterating a `String` object.** The flow analysis sorts the receivers of an iteration by the built-in iterator method each one finds. The set for a method is an upper bound, and the receivers left for an ordinary call were taken outside all of those sets together. They are now taken for each method by itself, so a `String` object that arrives where a `Set` arrives too finds the method of strings.
- **`await generator.next()`** gives an object with the layout of iterator results. A branch on a test of that result passes it on as a block parameter, and what reads the parameter now counts as a read of the result, so the object is made where a pattern takes it apart or copies it.
- **`require()` of a name computed at run time** finds a module that is another entry point of the program.
- **A loader that only ever gets the names of built-in modules** loads no other code, so the rest of the program is not treated as open to unseen code.
- **A closure that a branch on a test passes on** as a block parameter is not placed in the frame.
- **`SplitFreshResults`:** a function stays whole when the analysis of its worker, which has more blocks, reaches a limit that the analysis of the function stayed under.
- **Outlining of cold operations** leaves allocations where they are when the escape analysis gives up.

## Build time

- **Deferred closures.** Candidates are sorted by key once, and stores by name into any object are summed up by key. No store walks the candidates.
- **Long object literals.** A literal with more than 4,095 members is allocated first, and each member is defined as soon as its value is made, so the values are not all live at once.
- **Air, `allocateStackByGraphColoring`.** From 65,535 spill slots on, slots get their places by their live ranges in layout order, in n log n time. Below that nothing changes. `testair` has three new tests.

## Verifier

- States that disagree about a value defined in another block stay separate at a join, so a later branch on that value sends each one down its own edge.

## CI

- A build of a branch keeps its draft release when lanes failed. "Re-run failed jobs" builds those lanes onto the same draft, and the `release` job publishes it. A run that was cancelled still deletes its draft.

## Testing

Seven new IR tests under `aot/tests`, one changed. The programs that go with these changes are in the Bun repository.
Jarred-Sumner added a commit that referenced this pull request Oct 26, 2026
…er, stack traces, build time of large functions (#906)

Follows #757 and #903. Most of this is in `aot/` or under `ENABLE(AOT)`. The two sections before Testing cover the files that are shared with the JIT tiers and the runtime.

## IR and verifier

- **Proven and expected types are separate C++ types.** `ProvenType` is true of a value. `ExpectedType` is a guess, and nothing may rely on it without a test. A proven type also says whether it holds in every version of a function or only behind the checks in front of the value. `DeclaredType` keeps its type and flags private. The `Trust` enum is gone.
- **`PredictedValues`** is one analysis that says which values depend on which checks. Passes ask it instead of walking the body themselves.
- **Flow writes its claims at stores.** Where a stored value depends on a check, the flow analysis writes the type of the position in front of the store. This replaces `assumeTypesOfProvenPositions`, `typeWithoutChecks` and `letEverythingThrough`.
- **Operand kinds come from the front end.** What the front end says about an operand of a library operation is marked as its claim and holds in every version. This replaces `assumeOperandKinds`, `typeOfKind`, `insertAssumedTypes` and `OperandOfOtherKind`.
- **New verifier rules.** A type that holds behind checks needs a check in front of the value. A stored type and an operand's kind can't depend on a check that may fail. A pass that removes an operation that may throw or run code has to name its reason.
- **Suspensions list what they hold.** `await`, `yield` and a step of an async generator carry the values that their function holds as their last operands. A separate pass fills them in before typing.
- **One bit for tail calls.** A call's origin says that it stood in tail position, and whatever replaces the call keeps the bit. The flag on nine operations is gone.
- **More is generated from the schema:** the writer and reader of the call-site table, handler rows, the steps of the null-check table, four columns of `Representation`, and the columns that say which operations take a key, which run before their callee and which flow rule an operation runs.

## Flow analysis

- **Proofs and predictions read different lists.** `possibleValuesOf()` answers proofs and `seenValuesOf()` answers predictions.
- **Callees and receivers.** A callee that a set of receivers finds gets only the receivers that find it. Each bound function enters its own target with its own receiver and arguments. `new` on a bound function enters the target with that target as `new.target`. `instanceof` on a bound function reaches its target, also where unknown values arrive at the same site.
- **`Bun` stays findable after it escapes.** A mark follows `Bun` from a read under its name through promises, awaits, proxies and copies. A lookup on a value that may be anything searches `Bun` only where the mark is, so a member that's read late is still compiled in. A host namespace counts as escaped when an argument, a callback's result or an escaped object carries it out, not when it's only a receiver.
- **Loaders are found through copies.** `createRequire`, a loader of a built-in module and the members of `Bun` that load code are found by name on a copy of the module object, on an object filled under the module's keys, through a proxy, and under a computed key of a public built-in module.
- **Copies follow their sources.** What a spread, a rest pattern or `Object.assign` puts on a new object comes from the source objects, not from their layout. An object literal gets fields for the names that every source of its spread starts with.
- **Writes through unknown values.** A write under an unknown key through an unknown value counts as a write to the language's prototype objects that have escaped, and to the closed constructors and namespaces that hold a replaceable member.
- **Fields.** A field is expected to hold what the program stores in it. A static field takes the integer range that's really stored, like a module variable.
- **Class members under computed keys.** Methods, getters and static methods under a computed key that flow proves to be one key are members of the class.
- **`--trim`.** A built-in function that doesn't list all its members counts as used once it's read. A built-in that may be trimmed matches no overload, so no pass calls it directly.

## Modules

- **The loader runs on demand.** Compiled code starts modules itself. A module goes through `JSModuleLoader` only where the program needs that (`ModuleFlags::IsLoadedByLoader`), for example for an `import()` or a `require()` of an ES module. Modules, their imports and `import()` run in spec order.
- **A graph is planned, marked and linked once.** The plan leaves out modules with nothing to link or run, and the ES module wrapper of a CommonJS file. `import()` of an evaluated module skips the loader where nothing below the module awaits.
- **`require()` of an ES module that awaits.** The module's body resumes in the queue of the synchronous load.
- **Failed loads.** A module that imports a name that a library module lacks fails by itself. When a load fails early, none of its files has run. When a graph's load is rejected, the jobs held for it run before the steps of the rejection.
- **Mixed graphs** of ES modules and CommonJS files run in the order `bun run` runs them, including cycles between files and a CommonJS entry point.
- A module record costs less at the start of a program with many modules. `jsc`'s resolve and fetch hooks hand out the compiled records of an image.
- **Records are given up.** An ES module that went through the loader gives up its record, its registry entry and its promises once no load is in flight and it has run to its end with its cycle.
- `import()` hands its second argument to the loader as a value.

## Async functions and generators

- **Saved frames are smaller.** Air knows which patchpoints suspend and which spill slots live across one. The stack allocator assigns those slots first and gives them whole words. The other slots lie at the bottom of the frame and aren't saved, and the resume stub clears them.
- **Dead words are cleared.** A per-site map says which words of a waiting frame no later code reads, and the end of a collection clears them. A word that nothing has stored to yet is cleared before the first suspension, or left out by the frame's plan.
- **A suspended function keeps alive** what its parameter names hold, the variables that closures share where evaluated code can read their scope, and the members of objects that were never allocated.
- **Fewer promises.** An awaited direct call makes no promise in cold and untyped versions either. Where `await generator.next()` is answered with a promise, compiled code gets the value and the status of the step.
- **Delegation.** Spread and `yield*` read and call `next` of whatever the iterator method returned.
- **Async context.** A job that compiled code runs directly restores the async context afterwards.

## Stack traces and errors

- **One unwinder** walks frame records and the call-site cache.
- **Positions match `bun run`:** calls, reads, `new` and tagged templates through a key, binding patterns, folded constants, constructions, the argument of `import()`, compound assignments under a key. A value can carry the position where it's written (`WrittenPositionFollows`).
- **Frames match `bun run`:** a class called without `new`, default constructors, `Reflect.construct`, `flatMap()`, `Array.from()`, own-key listings over a proxy, direct `eval`, every member of a fused chain of array methods, the native function above a conversion that throws, nested async generators, and the function that holds the `using` declaration behind a `SuppressedError`.
- **Uncaught errors print the frames of the throw,** also after a `finally`, a `using`, an iteration cleanup or native code has passed the value on. Two new run-time operations: `AddressOfThrow` and `CatchValueAndException`.
- **Frames show the name of the function object that ran,** including a `displayName` or a `name` that's set at run time, and after `delete f.name`. Callees are recorded in a table indexed by the caller's stack pointer. A call by value records its callee only once a display name has been written.
- **Every conversion of a built-in call has its own call site,** so each one finds its handler and its position.
- **Tail calls leave no frame** through a `Proxy` without an `apply` trap, out of interpreted code into a compiled function and back, and in `return require(..)`. A tail call whose result carries an unproven test stays a tail call: the result is expected, not tested.
- **Messages.** A call of something that can't be called names the callee in every position. A host function describes the operand or the receiver that it refuses.
- **`CallFrame::callerFrameUnlessCompiled()`** is the step that `VM::topJSCallFrame()`, the debugger and `getBytecodeIndex()` take across an entry frame.

## Collector

- **A collection reads no stack frame for recorded callees.** A recorded callee is visited until its call has returned.
- **Structures with fast indexing are kept in a list,** pruned at each collection. Putting a proxy above a prototype reads that list instead of every live Structure.
- The three catch operations publish their frame record, like every operation that can collect.
- `copyDataPropertiesInFrame()` is `NOT_TAIL_CALLED`, because it's handed its caller's frame record.

## Objects and properties

- **Key order.** Spread, rest and `Object.assign` walk a compiled source in the order of creation, with symbols between names, also where the source has elements or once had an accessor.
- **Copies of own properties take fewer instructions.** The fast path is a prelude in the head of the call. Where the structures differ, it looks up a mapped copy and does the moves itself, without a frame.
- **`delete` is cached per site,** like additions (`DeleteByName`, `DeleteByNameSloppy`).
- **Objects filled by key.** The table that remembers first additions is eight-way set associative and hashes the name itself, so what it remembers doesn't depend on where strings are allocated. A store that may add a field takes no more code in a larger program.
- **Reads in stubs.** A read by name reads an own inline value at the offset that the site cache names, on x86-64 and ARM64. A read or `in` of an element in an array's sparse map stays in the stub.
- **`Object.assign`** onto a compiled target stores, so an existing property keeps its attributes.
- **Private names for library modules.** A library module can keep state on an object under a private name that no key listing shows and that passes through a `Proxy` to its target.
- **Lazy members of built-ins.** A member that the host creates on first read is still created where the read folds to a constant (`InitializeBuiltin`).
- **`super()` in the text of a direct `eval`** constructs the parent.
- **Fields that are added to** (`this.n += x`, `n++`) hold the integers from -2^52 to 2^52 - 1 in eight bytes, where every cell that holds them keeps its size class.
- **Copies that nobody keeps.** `{ ...o, b }` isn't allocated where the escape analysis says that nobody keeps the literal.
- **Accessors of object literals.** One plan lays getters and setters in any order into slots, one `GetterSetter` per name.
- **Private names on objects that a built-in made** are defined by a prelude that compares the Structure and stores.

## Numbers, strings, RegExp, JSON

- **Two more number representations:** doubles read from the bytes of a buffer, and doubles that may carry a NaN payload. A plain double is boxed without a NaN test. Every NaN is one key of a `Map` or `Set`.
- **NaN results.** `x % 1`, `x % -1` and `x ** 0.5` call the library function where the result is NaN and its sign differs from the processor's. A remainder by zero is computed at run time.
- **String builders.** An append of several parts rolls back when one of them throws and the handler keeps using the builder. A string that a handler reads stays in its builder. A string that grows around a loop gets a builder only if it stays in it all the way around.
- **Strings that are too long.** A join, `concat`, the HTML methods, `Object.prototype.toString` and `JSON.stringify` count as operations that may throw unless the types of their parts show that the result fits in one string, so an unused one isn't removed.
- **RegExp literals.** A pattern that can't be built throws its `SyntaxError` where the literal stands, and one nested deeper than 256 is made by a call at run time. A literal that nobody inspects is shared between its copies.
- **`JSON.stringify`.** Compiled code keeps the list of the objects it's inside of, and the engine's cycle test and depth limit read that list.

## Optimizer

- **`PlaceTypeTests`.** In code that can run twice, the later failing tests share one entry into the generic version, so a function's code grows linearly with its tests.
- **`CopyClosureFactories`.** A method of a helper object that defines members under constant names gets a copy per call, three calls deep. A loop over constant names has a version per name. A call in tail position is left alone.
- **`PromoteAggregateParameters`** has a second form. A function that can return before it touches a rest array or an object literal that the caller makes for the call is split in front of the first use, so the allocation happens only on the path that goes on.
- **Loops.** A type test that comes out the same on every iteration moves in front of the loop. Short counted loops are unrolled.
- **Closures.** A closure over one value is that value.
- **Cold code.** A small body without a loop moves to its one call site. Equal cold conversions share one outlined body.
- **Calls into interpreted code.** Compiled code enters a function that has bytecode without the trampolines, and `op_ret` has a form without the count of returns.
- **Limits.** `MaxHandlerArguments` and `MaxHandlerArgumentsPerBlock` bound what one `try` passes to its handler.
- **Heat** is estimated twice in an ordinary program: by ranks, then by entries per class and event. What seen code doesn't reach is cold, and of the library only what a loop calls is hot.
- **Direct calls behind one test.** A function whose calls by value record their callee only once a function object was renamed is called directly behind one test of the word that the first such rename writes. The callee's body takes the place of the call only where the copy is no larger than the call.
- **Calls by value** take fewer instructions in their stub. A callee whose type is an object enters behind the test for a cell, and no register is filled where the count says that every argument is passed.
- **Generic entry points.** One that nothing enters is rare and carries no name, so all such entries share two stubs. In hot code, a shared body isn't copied into an entry that is nothing but a tail call.

## Build time

- Long functions that suspend build in near-linear time.
- A function gets no generic version where that would take more than 16 parameters per value of the function.
- A heap location with more reads than the limit is solved in parts, with the same result.
- A very long literal of computed numbers is appended to an empty array.
- Liveness: past a number of visited blocks, the sets at block heads share the parts they have in common.
- Flow records a read by name on more than eight receivers under the receivers whose prototype chain can answer the name. It makes its tables of built-ins on first use, and it holds fewer bytes at the end of its rounds.
- The pass runner skips a schedule whose passes all have nothing to do. `SimplifyCasts`, `GuardTaintedPositions` and `PredictedValues` walk a body fewer times.
- A build that can't map memory or start a thread ends with a one-line error, or goes on with fewer threads.

## Address space limits

- Under a limit on the address space, every VM shares the first range of cells, a new worker takes its table pages in one piece, and threads share the C library's heap. The flow analysis reserves an eighth of what's free for each of its two lists.
- An image checks once, before its first allocation, that the limit leaves room for what startup maps, and prints an error if it doesn't.
- The front table of by-name lookups starts at 1.5 MB and grows in place. `MALLOC_ARENA_MAX` and `GLIBC_TUNABLES` win over the default arena count.
- `ReservedRangeHeap` keeps a freed run between two accessible neighbors accessible once the process has 16,384 borders, which bounds the number of mappings.

## B3, Air, Yarr and WTF

These files are shared with the JIT tiers.

- **B3 `fixSSA`** finds where a variable needs a Phi one variable at a time from the dominator tree, instead of keeping a set of live variables per block. Its index of frontier edges takes linear space.
- **B3 `foldPathConstants`** builds an index past 32 scanned path constants, with the same result, and skips a run of entries of one comparison in one step.
- **B3 `threadJumps`** finds repeated tests in one walk of the dominator tree. It leaves a block alone when its values stay live across more than 128 later copied blocks, so the number of Phis stays linear in the number of locals.
- **B3 and Air `eliminateDeadCode`** visit a value when something it defines becomes live, instead of iterating over the procedure.
- **B3 `eliminateRedundantPhiCycles`** sweeps only where something changed. `MemoryNumbering` keeps a block's recorded writes once.
- **B3 `reduceStrength`, with ranges.** `Add<Double>(IToD, IToD)` within 2^52 becomes `IToD(Add<Int64>)`, and a truncating patchpoint over `IToD(x)` becomes `x`. `sqrtConstant()` doesn't fold a NaN.
- **B3 `LowerToAir`.** A load isn't folded into its user across a write to `Procedure::heapThatLoadsStayInFrontOf()`, which is empty unless a client sets it. With the extended selection policy on x86-64, an add or subtract of a word in memory followed by a branch on the carry is one instruction.
- **Air liveness in slices** visits only the blocks that mention an index of the slice. A block that mentions none shares its successors' row. Gen and kill are short lists, rows live in chunks that don't move, and the liveness of stack slots is computed once.
- **Air stack allocation.** `StackmapValue::setIsSuspension()` marks a patchpoint behind which the function may go on in another frame. Spill slots that live across one are assigned first and get whole words.
- **Air spills.** `fixObviousSpills` shares alias lists between block heads above 256 stack slots. `fixSpillsAfterTerminals` asks liveness only about the Tmps that terminals define. A move of a spill slot to itself is dropped.
- **Air, large patchpoints.** The use and def lists say where a `Patch` with more than 64 arguments mentions a Tmp, and `Code` keeps the forms of large patches.
- **Air validation.** The search for a Tmp that's used before it's defined solves liveness in slices and keeps a set for the entry blocks only.
- **Yarr.** In the bytecode interpreter, a group that's repeated a fixed number of times and has nothing to backtrack into keeps the contexts of its first and last iteration only, and none if it has no captures. In a flat program such a group keeps no frame per iteration. It leaves its loop after the first iteration that consumes no input.
- **`WTF::SSACalculator`.** The walk for a dominance frontier stops where the walk from an earlier predecessor came through.

## Runtime, interpreter and assembler

These apply without an image too.

- `Array.prototype.join` and `toLocaleString` share one slow loop. It counts the separators of empty elements and appends them together, and it skips the tail of an `ArrayStorage` array that has no elements there.
- `JSGlobalObject::haveABadTime(VM&, JSObject*)` walks the heap at most once per realm. After that, a prototype object that could start to intercept indexed accesses is treated as one that does (`isTakenToInterceptIndexedAccesses()`).
- `SymbolImpl::s_flagPassesThroughProxies` marks a private name that a `Proxy` hands to its target without a trap, in all six property methods. `Object.freeze` and `Object.seal` treat such a name as an ordinary property.
- Without the JIT, one weakly held table keeps the executables of host functions. Named ones enter it from a VM's second realm on.
- `JSONParse()` and `streamingJSONParse()` of UTF-8 bytes parse in place when the bytes are all ASCII, and otherwise leave the bytes to be parsed as a string. A long string parsed from 16-bit JSON text stays 16-bit.
- The HTML methods of strings add up the lengths of their parts before they build anything.
- `ClonedArguments`: a native callee counts as strict.
- `Error.captureStackTrace` defines its properties on an array whose elements are frozen in place.
- `JSModuleLoader::loadModule()` and `requestImportModuleForKey()` can take a record in place of a fetch. `VM::SynchronousModuleQueueScope` opens and closes the queue of a synchronous load.
- `JSModuleLoader::removeEntryOfRecord()` drops a registry entry.
- `NativeExecutable::isNamedAsBound()`: the own `name` of such a host function is created when it's looked for, so the function keeps the structure of host functions.
- `MarkedBlockCage::tryEnsureRoomForNewVMs()` asks for the page sizes that a VM takes as it starts, and `ReservedRangeHeap::tryAddSegment()` leaves address space free beside a new segment.
- LLInt's prologue zeroes the frame 32 bytes per iteration on x86-64.
- `MacroAssemblerARM64::branchTest64` puts its mask in the memory temp register when the tested register is the data temp register.

## Testing

341 new IR tests under `aot/tests`, 518 with new expectations, new native tests, and new cases in `testair` and `testb3`. On ARM64, a test decodes every stub of every option variant and checks its uses of the scratch registers. `Tools/Scripts/aot/find-frame-records-handed-to-jumps.py` lists the functions that hand the address of their frame record to a jump. Four new files in `JSTests/stress`: three for fixed-count groups in regular expressions, one for arrays that become prototypes. The programs that go with these changes are in the Bun repository.
dylan-conway added a commit to oven-sh/bun that referenced this pull request Oct 27, 2026
### What does this PR do?

Moves `WEBKIT_VERSION` from `680ef47cec2c` to `982277c9dd3e`, the head
of oven-sh/WebKit `main`, and updates the places in Bun that the new
engine needs changed.

The range is oven-sh/WebKit#757, #903, #905, #906 and #907. This PR does
not add or enable JavaScriptCore's ahead-of-time compiler. That is
#45631.

**Build scripts**

- **Prebuilt names:** oven-sh/WebKit#757 renamed the release assets.
`release`, `lto` and `lto-hardened` keep their names and are built with
`ENABLE_AOT=0`. Every other variant now ends in `-aot`, so
`prebuiltSuffix()` appends that. In CI this changes one lane,
`linux-x64-asan`. Locally it changes `bun bd`.
- **Debug warnings:** `AssemblyHelpers.h` captures `this` in generic
lambdas, which `-Werror=unused` rejects in debug builds, so
`-Wno-unused-lambda-capture` is added there.

**Code**

- **Removable features:** JavaScriptCore now calls
`Bun__removableFeatureState` and `Bun__throwForRemoved*`, in every
variant. `RemovedFeature.cpp` defines them the way WebKit's own
`AOTNoFeatureRemoved.cpp` does: nothing is ever removed.
- **Table of built-ins:** the `-aot` libraries leave 35 symbols
(`JSC::AOT::builtinTable*` and `describedBuiltinTable`) for the program
that links them to define. Only the compiler and the code it compiles
read that table, and `Options::useAOT()` is off, so `RemovedFeature.cpp`
defines an empty one under `#if ENABLE(AOT)`.
- **Built-in functions:** `BuiltinGenerator` returns `JSC::BuiltinCode`,
and `BuiltinsSourceProvider::create` takes the id of its first function.
- **`JSType`:** five new types moved the numbers mirrored in
`JSType.rs`. A `static_assert` now pins them.
- **Static strings:** `StringImpl::ref()` and `deref()` no longer count
static strings, which may be in read-only memory. The Rust port of the
two does the same. Three `ASSERT(refCount() > 0)` in `BunString.cpp`
accept a static string, whose count is now 0.

`RemovedFeature.cpp` has the path #45631 uses, so that PR gets a merge
conflict instead of duplicate symbols at link time. `JSType.rs`,
`bun_alloc/lib.rs` and `BunString.cpp` are the same hunks as in #45631.

### How did you verify your code works?

Bun was not built locally; CI is the build.

- **CI build [#133439](https://buildkite.com/bun/bun/builds/133439)**,
on an earlier commit of this PR with the same engine changes: all 13
`build-bun` jobs passed, ASAN included. That commit generated a full
table of built-ins. The empty table that replaced it has not linked in
CI yet.
- **New test:** the three cases of `webkit-upgrade-982277c9dd.test.ts`
fail on main's CI binary at the merge base (old engine) and pass on the
linux-x64 binary of #133439 (new engine).
- **Bytecode snapshot:** `BytecodeList.rb` changed, so six values in
`bundler_bytecode_portable.test.ts` moved. They come from the linux-x64
binary of #133439, and linux-aarch64-musl reported the same six.

Checks done without building:

- **Embedder symbols:** `llvm-nm` over the old and new `linux-amd64`
archives and the new `linux-amd64-asan-aot` archives gave the 40 symbols
Bun has to define. `RemovedFeature.cpp`, compiled alone with Bun's
flags, defines all 40.
- **The table is not read:** in the engine outside `aot/`, the AOT hooks
in VM, heap and realm setup that I read are behind
`vm.hasCompiledCode()` or `Options::runsCompiledImage()`, and only `$vm`
names the table.
- **Assets:** all 30 asset names Bun can ask for exist at the new tag.
- **Headers:** I diffed the 225 changed headers and searched `src/` for
every removed or changed name.
- **`JSType.rs`:** a script compared all 88 constants with `JSType.h` at
the pinned commit.
- **Syntax check:** `clang++ -fsyntax-only` with the flags from
`compile_commands.json` passes on `RemovedFeature.cpp` (with and without
`ENABLE_AOT`), the generated `WebCoreJSBuiltins.cpp` and 29 hand-written
files that use the changed APIs.
- **arm64 allowlist:** the outline-atomics helpers the arm64 archives
use are the same set as before, so `allowlist-aarch64.txt` does not
change.
- **Lint:** `test/internal/source-lints` passes (184 tests), `tsc`
passes for `scripts/build` and `src`, and Prettier, clang-format and
`cargo fmt` are clean.

Not covered:

- `--webkit=local`, Windows and macOS debug builds, and `--hardened=on`
are untested, and CI builds none of them.

**Binary size:** the binaries grow by 0.75 to 1.64 MB (linux-x64 +1.39
MB, darwin-aarch64 +1.17 MB, windows-x64 +1.08 MB), over the 0.5 MB
limit. It is JavaScriptCore's own growth: release builds already link
the prebuilt without the ahead-of-time compiler, and Bun's diff adds a
few hundred bytes. The head commit carries `[skip size check]`.


---
_Generated by [Claude Code](https://claude.ai/code)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Jarred-Sumner added a commit to oven-sh/bun that referenced this pull request Oct 27, 2026
…e --aot` (#45631)

```sh
bun build --compile --aot ./app.ts
./app
```

This adds `--aot` to `bun build --compile`. It compiles TypeScript and
JavaScript to machine code at build time. The executable starts as
machine code and has no JIT in it. `bun compile ./app.ts` is short for
the same command.

It's still JavaScript. npm packages, CommonJS, `eval`, proxies and `any`
all work. There's no new syntax and nothing to annotate. The new part is
that the compiler reads your TypeScript types and uses them.

```ts
class Vec {
  constructor(
    public x: number,
    public y: number,
  ) {}
}

function dot(a: Vec, b: Vec): number {
  return a.x * b.x + a.y * b.y;
}
```

A `Vec` is a 16-byte header and two raw doubles. Where the compiler can
prove that only `Vec`s reach `a.x`, the read is one load. No
hidden-class check, no inline cache, no boxed number. Where it can't
prove that, it tests once and keeps a slow path.

It's experimental, and it [loses in places](#where-it-loses). The engine
half is oven-sh/WebKit#757. The docs start at `docs/bundler/aot.mdx`.

## The TypeScript compiler, compiled

TypeScript 6 is written in TypeScript. TypeScript 7 is the TypeScript
team's port of it to Go. We built the `v6.0.3` tag from its own source
with `--aot` and type checked
[effect](https://github.com/Effect-TS/effect) with it, 370,839 lines.
Not a line of TypeScript's source was changed.

| | wall, s | peak RSS, MB | CPU, s |
| --- | ---: | ---: | ---: |
| TypeScript 7 (Go) | 2.68 | 1,256 | 10.8 |
| **TypeScript 6, `--aot`** | **4.25** | **963** | **5.3** |
| TypeScript 7 (Go), `--singleThreaded` | 5.08 | 933 | 6.2 |
| TypeScript 6 on Bun 1.4.3 | 14.00 | 2,581 | 29.2 |

`tsc -p` on effect's `packages/effect`, 0 errors in every row. Linux
x64, AMD EPYC 9R14, pinned to 16 cores that nothing else uses. LTO
build, median of five runs, 2026-10-24.

The two TypeScript 6 rows are the same compiler at the same tag.
Compiled ahead of time it's 3.3x faster than on Bun 1.4.3, in 2.7x less
memory and 5.5x less CPU time.

Go checks in parallel, which TypeScript 6 doesn't, and that wins on the
clock. Take the parallelism away with `--singleThreaded` and the
original TypeScript finishes first, on less CPU time and about the same
memory. Both still collect garbage on other threads in that comparison.

<details>
<summary>How to run it yourself</summary>

Bun 1.4.3, TypeScript 7.0.2 (`@typescript/typescript-linux-x64`),
TypeScript 6.0.3 (tag `v6.0.3`, sources and `lib/` unmodified), effect
at `8b0eac6a`, `@types/node` 26.6.4.

```sh
git clone --depth 1 -b v6.0.3 https://github.com/microsoft/TypeScript
git clone https://github.com/Effect-TS/effect && git -C effect checkout 8b0eac6ac46a689a7208500e2ccf9e3b80fc3898
npm install typescript@7.0.2 @types/node@26.6.4

# --aot type checks what it compiles, and a bare checkout lacks two of TypeScript's
# dev dependencies (@types/node, source-map-support). This supplies them.
echo 'declare module "source-map-support";' > missing.d.ts
cat > aot-tsconfig.json <<X
{
  "extends": "$PWD/TypeScript/src/tsconfig-base.json",
  "compilerOptions": {
    "rootDir": "$PWD/TypeScript/src", "types": ["node"], "typeRoots": ["$PWD/node_modules/@types"],
    "composite": false, "declaration": false, "declarationMap": false, "emitDeclarationOnly": false,
    "isolatedDeclarations": false, "noEmit": true
  },
  "files": ["$PWD/TypeScript/src/tsc/tsc.ts", "./missing.d.ts"]
}
X
bun build --compile --aot --production --format=esm --tsconfig-override=aot-tsconfig.json \
  --define "__filename=\"$PWD/TypeScript/lib/tsc.js\"" --define "__dirname=\"$PWD/TypeScript/lib\"" \
  TypeScript/src/tsc/tsc.ts --outfile tsc-aot

go=node_modules/@typescript/typescript-linux-x64/lib/tsc
run="taskset -c 0-15 /usr/bin/time -v"
$run $go -p tsconfig.json --pretty false
$run ./tsc-aot -p tsconfig.json --pretty false
$run $go -p tsconfig.json --pretty false --singleThreaded
$run bun TypeScript/lib/tsc.js -p tsconfig.json --pretty false
```

`tsconfig.json` is effect's own options for `packages/effect`, with
absolute paths:

```json
{
  "include": ["<here>/effect/packages/effect/src"],
  "compilerOptions": {
    "noEmit": true, "incremental": false, "composite": false,
    "rootDir": "<here>/effect/packages/effect/src",
    "target": "ES2022", "module": "NodeNext", "moduleDetection": "force", "verbatimModuleSyntax": true, "allowJs": false,
    "rewriteRelativeImportExtensions": true, "erasableSyntaxOnly": true,
    "strict": true, "exactOptionalPropertyTypes": true, "noUnusedLocals": true, "noUnusedParameters": true,
    "noImplicitOverride": true, "noFallthroughCasesInSwitch": true,
    "stripInternal": false, "skipLibCheck": true, "noErrorTruncation": true, "jsx": "react-jsx",
    "types": ["node"], "typeRoots": ["<here>/node_modules/@types"], "preserveSymlinks": true
  }
}
```

</details>

## A real CLI

Claude Code's CLI ships as a `bun build --compile` executable. This is
the released build against the same app built with `--aot`, in a
scripted session: start, first prompt, five turns, with the API stubbed
out locally.

| | released | `--aot` |
| --- | ---: | ---: |
| time to first prompt, ms | 318 | **141** |
| peak private memory, MB | 185.4 | **44.7** |
| private memory at the first prompt, MB | 112.0 | **28.7** |
| private memory at the end, MB | 150.1 | **37.5** |
| peak RSS, MB | 308.5 | **180.8** |
| CPU time, ms | 1,542 | **512** |
| instructions, millions | 5,530 | **1,117** |

Linux x64, AMD EPYC 9R14, 16 cores that nothing else uses. LTO build,
default flags, median of 16 sessions per column, alternating,
2026-10-24. CPU time and instructions count all threads.

The first prompt is up in under half the time, on a third of the CPU
time and a quarter of the private memory.

## Servers

One small API, written seven times (`test/aot/servers/app/api-*.ts`): by
hand on `Bun.serve` and on `node:http`, and on Hono, Elysia, Express,
Fastify and Koa. It has three middlewares (a request id, response
headers, a bearer token), route parameters, a query string, JSON in and
out, an error handler and a 404.

Each server is built twice by the same `bun`: with `--aot`, and with
plain `--compile`, which runs it on the JIT. Same runtime, same built-in
modules, same native code. Only the compiler differs.

Requests per second in the first second after the process starts:

| | `--aot` | JIT | ratio |
| --- | ---: | ---: | ---: |
| Koa | **33,109** | 17,180 | 1.93x |
| Express | **46,562** | 24,181 | 1.93x |
| Fastify | **57,976** | 37,822 | 1.53x |
| `node:http` | **69,085** | 46,693 | 1.48x |
| Hono | **58,978** | 48,029 | 1.23x |
| `Bun.serve` | **84,599** | 77,867 | 1.09x |
| Elysia | **63,556** | 61,560 | 1.03x |

Requests per second once the JIT is warm, over requests 100,001 to
400,000 of the same process:

| | `--aot` | JIT | difference |
| --- | ---: | ---: | ---: |
| Hono | 59,737 | 58,151 | +2.7% |
| `Bun.serve` | 85,553 | 83,490 | +2.5% |
| `node:http` | 70,205 | 70,383 | -0.3% |
| Express | 47,935 | **51,587** | -7.1% |
| Elysia | 65,110 | **75,268** | -13.5% |
| Fastify | 59,656 | **71,796** | -16.9% |
| Koa | 34,194 | **44,362** | -22.9% |

The same program built twice differs by up to 1.9%, so we call anything
under 3% level. That's the top three. The other four lose. Counting
every request since the process started, the JIT has caught up within 2
seconds on Elysia and within 5 on Fastify and Koa. Express is still
ahead at 5 seconds.

Startup and memory:

| | first response, ms, `--aot` | first response, ms, JIT | peak RSS,
MB, `--aot` | peak RSS, MB, JIT |
| --- | ---: | ---: | ---: | ---: |
| `Bun.serve` | 8.3 | **7.7** | **32.1** | 45.7 |
| Hono | **9.3** | 11.5 | **34.4** | 51.8 |
| Elysia | **11.5** | 27.5 | **40.8** | 56.6 |
| `node:http` | **11.5** | 21.4 | **43.8** | 61.8 |
| Fastify | **17.0** | 63.4 | **51.1** | 73.1 |
| Express | **18.3** | 50.4 | **54.4** | 85.9 |
| Koa | **20.1** | 66.7 | **57.2** | 85.3 |

All three tables: Linux x64, AMD EPYC 9R14, each server pinned to one
core and its load generator to another, both columns of a row at the
same time. LTO build, 64 connections, six routes in turn, every response
compared byte for byte, median of 7 starts, 2026-10-24. Memory is read
at request 400,000. `test/aot/servers/measure/requests_a_second.py`.

There's no source to parse, no bytecode, no JIT-compiled code and no
profiling data to keep around. The machine code is file-backed pages of
the executable, so the kernel shares it between processes. Private
memory is 13 to 25 MB compiled and 15 to 47 MB on the JIT. The
executables are 69 to 82 MB, against 141 to 144 MB.

At a fixed 5,000 requests per second, the 99th percentile of latency is
54 to 91 µs compiled and 77 to 477 µs on the JIT. The medians are 21 to
40 µs and 21 to 34 µs.

## Where it loses

A warm JIT still wins on untyped npm code. That's the second servers
table. Koa, Fastify and Express are untyped JavaScript in many small
packages. A JIT learns which objects reach a property access by watching
the code run. The compiler has to prove it at build time, and where it
can't, the access is a lookup by name. [Full types](#full-types) are the
way out we're betting on.

Code that a package generates at runtime is interpreted, for as long as
the program runs. zod 4, ajv, fast-json-stringify and Elysia generate
their hot code with `new Function`. That's most of Elysia's loss above.
For validation there's a way that needs no generated code:
[`JSON.parse<T>` and `validate<T>`](#what-it-does-with-types).

Old, untyped JavaScript is slower than on the JIT: constructor
functions, class helpers, asm.js output, arithmetic on whatever comes
out of a plain array. A JIT watches `a + b | 0`, sees two int32s every
time and compiles an add. The compiler has to prove that they're int32s,
and where it can't, the `+` is a call to a generic add. JetStream2, the
benchmark that JavaScriptCore's JIT is tuned on, is mostly code like
this, and most of its tests are slower compiled.

Everyday idioms are mixed. `test/aot/perf` has 1,814 of them. Each is
compiled twice, as written and with its types erased, and the slower of
the two is compared with the same `bun`'s warm JIT, by instructions.

| | ahead | within 5% | behind | median ratio |
| --- | ---: | ---: | ---: | ---: |
| each idiom as a small program | 916 | 223 | 675 | 0.94 |
| compiled as in a very large program | 467 | 118 | 1,229 | 1.44 |

`bun test/aot/perf/idioms-against-the-jit.ts`, and again with
`--in-a-very-large-program`. Linux x64, AMD EPYC 9R14, 2026-10-24.
User-mode instructions per iteration, with startup and warm-up
subtracted.

In the first row over a third are behind, 180 of them by more than 2x.
And that row flatters the compiler. Each idiom is a program of its own,
so its loop counts as hot and the idiom is inlined into it. A very large
program can't have everything hot, and with no profile the compiler has
to guess from loops and the call graph. The second row compiles the same
idioms the way they'd be compiled there, and calls each through a
function that's found at runtime, on the JIT too. Two thirds are behind,
687 by more than 2x.

Builds are slow. The build type checks the whole program and compiles
every function that can be reached. There's no cache between builds, so
a one-line change builds everything again. A large application takes
tens of seconds and gigabytes of memory.

Executables are big. A small server is 69 MB. That's half of what
`--compile` makes, and it's still 69 MB.
[`--trim`](#smaller-executables) helps. `bun` itself [grows by about 30
MB](#what-plain-bun-pays).

Go and Rust still win on small programs. A hello world in either starts
sooner, holds a few megabytes and is a file of a few megabytes. Ours
carries Bun's runtime.

## Why

When a JavaScript tool gets too slow, the usual fix is to port it to Go
or Rust. esbuild, SWC, Biome, oxc, Rolldown, Turbopack and now
TypeScript itself went that way. It works, but it takes years, and
afterwards most of the tool's users can't read or patch it.

JavaScript engines were designed for browsers. Code arrives as text from
somewhere you don't trust, has to start right away, and can do anything.
So the engine interprets it, watches which types show up, guesses,
compiles the guess, and throws the compiled code away when the guess was
wrong. All of that happens on the machine that runs the program, every
time it starts.

A server or a CLI isn't in that position. You have all the source before
you ship it. If it's TypeScript, you've also written down what
everything is. Then the build erases that, and every process spends its
first seconds working it out again.

`--aot` keeps the types and does the work once, at build time.

## What it does with types

TypeScript is unsound on purpose. `any`, `as`, `!`, `JSON.parse` and
`.d.ts` files all let you call something a `number` when it isn't. So
the compiler reads a type in two ways.

For what your program makes, the type is a fact. `new Vec(1, 2)`, or an
object literal of a declared type, gets the layout of that type.

For what your program is given, the type is a prediction. An argument
from a caller that the build can't see, or the result of `JSON.parse`,
is tested once where it comes in. A value that fails the test takes a
slow path.

```ts
function double(n: number): number {
  return n * 2;
}

const body: any = JSON.parse('{"visits":"3"}');
console.log(double(body.visits));
```

```
$ bun app.ts
6

$ bun build --compile --aot app.ts && ./app
6
```

`double` takes a raw number, so it can't take the string `"3"`. A guard
at its entry sees the string and sends the call to a second, slow
version of `double`, which multiplies the way plain JavaScript does. The
string stays a string. The next call with a number takes the fast
version again.

The rules we hold the compiler to:

1. A program that runs under `bun run` does the same thing compiled.
2. Nothing throws at runtime because of a type. `as`, `any` and `!`
never convert a value and never throw.
3. New errors show up at build time, with a message. The exceptions are
listed under [What changes](#what-changes).

JavaScript files are compiled the same way. A class has a fixed layout
there too, and a flow analysis over the whole program follows objects
from where they're created to where they're used, with or without
annotations. Every property access ends up in one of three states:

| A property access is | When | It costs |
| --- | --- | --- |
| free | The compiler proved which objects get here: from the types,
from following every object through the program, or from a test earlier
in the function | One load or store |
| tested | A value that nobody checked can get in | A shape test, then
the load. If the test fails, the slow path |
| by name | Nothing is known | A lookup at runtime, through a cache |

The build prints the split, and `--explain` lists what's behind the slow
ones, worst first, with a fix for each.

```
92.4% of property accesses are one load: 1,204 free, 61 tested, 38 by name (1,303)
```

An object with a fixed layout is still an object. `Object.keys`,
`JSON.stringify`, spread and `for...in` see it like any other. You can
add properties that the class didn't declare, delete the ones it did,
and store a string in a `number` field through an `any`. That costs
speed and nothing else.

`JSON.parse<T>(text)` (`JSON` from `"bun"`) and `validate<T>(value)`
(from `"bun:bundle"`) check data against a type you've already declared,
and make objects with the layout of `T`. These two do throw. That's what
they're for.

`import type { int32 } from "bun"` is a 4-byte integer field. To
TypeScript and under `bun run` it's a `number`. The build has to prove
every store into one, and stops with an error where it can't.

## What changes

Each of these is there because the compiler can't generate good code
without it. `docs/bundler/aot-compatibility.mdx` has the smaller
differences and a list of packages that are known not to work.

| Change | What it buys |
| --- | --- |
| Type errors stop the build. They're TypeScript 7's errors, from the
checker behind `bun check` | The types that the compiler starts from
agree with each other |
| Existing members of built-in objects can't be replaced.
`Array.prototype.push = fn` throws a `TypeError`, in sloppy mode too.
Adding is fine: `Array.prototype.last = fn`. `Object.prototype`,
`RegExp.prototype`, `Promise.prototype` and the generator prototypes
can't be changed at all. The prototypes of `Request`, `Response` and
five other Bun classes are read-only | `arr.push(x)`, `map.get(k)` and
`Math.floor(x)` compile to the operation, without checking that nobody
replaced it |
| A program with no call to `eval` or `new Function` is closed. The
compiler finds those calls by following the functions as values, so
`const run = eval` counts. A call that it couldn't follow, like
`globalThis[name](code)`, throws an `EvalError` | Every caller of a
function is known. Calls are direct, guards go away, and what nothing
reaches isn't compiled |
| The executable holds what your program can reach and nothing else.
`import()` and `require()` of a computed path find the files that match
its constant parts, and the entry points. Other code throws, like a file
that doesn't exist. Data files still load | One file, with no
`node_modules` beside it. Less code to compile, map and page in |
| A module in the executable runs at most once and isn't in
`require.cache` | An import is a direct reference to a variable or a
function |
| Each thread can have 32 GB of objects. `--aot-huge-heap` lifts that |
References between objects are 32 bits |

You shouldn't have to find any of this in production:

| | your program |
| --- | --- |
| `bun check --aot` | isn't run. Reports everything the build would
report. With `--explain=guards` it lists every guard that can fail |
| `bun run --aot`, `bun test --aot` | is checked the way the build
checks it, then runs from source with built-in objects read-only |
| `--aot-log-type-mismatches` | is compiled, and the executable logs
every guard that does fail |

## What it doesn't do

There's no JIT in the executable. All machine code is in the file,
mapped read-only and executable like the `.text` of a C program. On
Linux 6.7 and later the executable calls `prctl(PR_SET_MDWE)` once its
code is mapped, and from then on the kernel refuses the process any new
executable memory. `dlopen()`, `bun:ffi` calls and up to 256 open
`JSCallback`s keep working. `cc()` from `bun:ffi` and native addons that
generate machine code don't work there.

Text that shows up at runtime is interpreted. `eval`, `new Function` and
`node:vm` run in JavaScriptCore's interpreter, in the same process, on
the same objects as the compiled code. A `RegExp` built from a string at
runtime is matched without a JIT. WebAssembly is compiled if it's a
`.wasm` file that the program imports, and interpreted otherwise.
`--disallow-code-generation-from-strings` turns `eval` off, like Node's
flag.

The interpreter is never a fallback for code that the build saw. A
function that can't be compiled is a build error.

Code from disk is opt-in. By default a dynamic `import()` or `require()`
only loads code that's in the executable. `--aot-allow-code-from-disk`
lets it load a plugin or a config file from disk, which is then
interpreted. `--external <package>` does that for one package. The flag
has a price, because the compiler can prove less about anything such a
file can get hold of: with it, warm Express serves 13% fewer requests
per second than in the table above and Koa 16% fewer. The other five
don't move. A program with no dynamic import is the same executable
either way.

There's no profile and no heap snapshot. The compiler never sees your
program run. What's hot is estimated from loops and the call graph. None
of your program runs at build time, except macros.

There's no cross-compilation, and breakpoints don't work. The build
makes code for the platform it runs on. In an executable built with
`--compile-inspector`, the console, heap snapshots and CPU profiles
work. Breakpoints and stepping don't.

| Platform | State |
| --- | --- |
| Linux x64, glibc | Tested |
| Linux arm64, Linux musl, macOS arm64, macOS x64 | Tested. Some tests
still fail |
| Windows x64, Windows arm64 | Tested. Many tests still fail |
| FreeBSD, Android | Bun builds there. Not tested yet |

## Full types

Publishing a TypeScript library to npm strips the types out of its code.
The `.d.ts` keeps only the ones on its exports. So when you compile an
app, your code has types and `node_modules` doesn't, and that's where a
lot of programs spend their time.

Full types is one more file in the package: `index.js` with the types
left in.

| | code | types | built |
| --- | --- | --- | --- |
| `src/index.ts` | all of it | all of them | no |
| `index.js` | all of it | none | yes |
| `index.d.ts` | none | exports only | yes |
| **`index.full.ts`** | **all of it** | **all of them** | **yes** |

It sits behind a new condition in `exports`. Tools skip conditions they
don't ask for, so Node, `tsc`, editors, Vite, webpack, esbuild, `bun
run` and `bun build` resolve what they resolve today.

```json
"exports": {
  ".": {
    "types": "./index.d.ts",
    "full-types": "./index.full.ts",
    "default": "./index.js"
  }
}
```

There's one rule: strip the types and you get `index.js`, exactly. The
build checks that every time, against the `index.js` that's installed.
So the file can't hide code and can't go stale, and if you patch
`index.js`, your patch wins. A full-types file that doesn't match or has
a type error gets a warning, and the package is compiled without types.
A dependency's types never fail your build.

`bun compile --library` builds the whole package from your TypeScript:
`index.js`, the `.d.ts` files, `index.full.ts` and a `package.json` with
the condition in it. `packages/full-types` has full types for packages
that don't ship their own, such as react-dom, Express, Fastify and Koa:
annotations added, nothing else changed.
`docs/bundler/aot-libraries.mdx` has the rest.

## Smaller executables

A `--compile` executable is a copy of `bun` with your program attached,
so hello world ships a package manager, a test runner, a type checker
and several JIT compilers. Nothing in a compiled program can call those,
so `--aot` always leaves them out.

`--trim` also leaves out the APIs that your program never uses:
`bun:sqlite`, `Bun.Image`, `HTMLRewriter`, `Intl` data by locale,
WebAssembly, the bundler, the debugger. It removes code, not names.
Every global, class and property of a removed feature is still there, so
`typeof`, `in` and `instanceof` answer as before. A call into one throws
`ERR_FEATURE_REMOVED` and says how to keep it. `--explain=trim` lists
each feature, kept or removed, and why.

You can't delete code from a linked binary, because dead functions are
scattered all through `.text`. So the work happens when `bun` itself is
linked. It's linked once, and the reference graph is read out of the
object files' relocations, C++, C and Rust alike.
`src/trim/features/*.def` name the features and the functions you have
to pass through to reach each one. Then it's linked again with the
removable sections grouped into page-aligned ranges of their own, and a
table of them goes into `bun`. After that, `bun build --compile` just
doesn't map those ranges. Nothing that stays moves, so you don't need a
linker or a toolchain.

## How it works

Under `--aot` nothing is bundled. Each module stays a module, a CommonJS
body is a function, and Bun's own `node:` and `bun:` modules are
compiled from their TypeScript sources along with your program.

1. `src/aot/load` walks the imports from the entry points with Bun's
resolver, loaders, macros and `Bun.build` plugins, and parses each file
once.
2. `src/aot/modules` works out the module graph: ES links, which names a
CommonJS file exports, the order of evaluation. `src/sema/fold` folds
`process.platform` and the like, and the code behind them is dropped.
3. `src/sema`, the type checker behind `bun check`, checks each file.
`src/aot/lower` turns its syntax tree and types into an IR in SSA form,
file by file, on all threads.
4. `src/aot/link` merges the files into one program. `src/aot/emit`
writes it in a format that both repos generate from one schema
(`aot/ir/AOTSchema.def` in the engine).
5. JavaScriptCore runs a flow analysis over the whole program, optimizes
the IR, generates machine code through B3 and writes an image: code plus
read-only data.
6. `src/bundler/aot`, `src/exe_format` and `src/trim` put the image into
a copy of `bun`, with symbols for the compiled functions and without
what's trimmed.

One table says what every built-in takes, returns, reads, writes and may
call back. `src/codegen/builtins` makes it from the engine's
descriptions and from Bun's own class and module definitions. Bun
compiles it, so a change to one of Bun's native classes needs no new
build of WebKit.

To start reading: `src/aot/compile.rs` and `src/aot/compile/*_stage.rs`
are the driver, stage by stage. `test/aot/ir/README.md` and the files
under `test/aot/ir/lower/` show a small program and the IR it has to
lower to. `src/aot/lower/convert` and `src/aot/lower/layouts` are where
types turn into IR.

## What plain Bun pays

There's one `bun`, and the compiler is in it. Everyone who installs Bun
gets it, whether they compile or not. That's about 30 MB more on disk,
and about 23 MB on musl. An executable from plain `bun build --compile`
is a copy of `bun`, so each of those grows by as much.

This PR also changes code that runs without `--aot`: `node:http`,
streams, events and `process.nextTick` in `src/js`, the checker in
`src/sema`, the executable format, and shared parts of JavaScriptCore.
Review matters most there.

## Testing

Most of the added lines are tests. `test/aot/README.md` lists every
group of tests, the command that runs it and where a new test goes.

| | |
| --- | --- |
| `test/aot/programs` | Programs, and `test/aot/generate` writes more
when the tests run. Each runs three ways that have to agree: regular
Bun, compiled, and compiled with its types erased |
| `test/aot/programs/nothing-throws-because-of-a-type`,
`test/aot/values-against-their-types` | Values that don't fit their
types, everywhere a type can appear |
| The `variant()` rows of `test/aot/parts.mts` | The programs again with
every pass verified, every function hot, every function cold, no
inlining, no flow results, 64-bit references, another code layout |
| `--aot-validate` | Compiled code checks every fact it relies on at
runtime and stops on a miss. The programs run under it too |
| `test/aot/collector-stress` | The programs again with a garbage
collection at every third slow-path allocation |
| `test/aot/other-suites` | test262, JavaScriptCore's tests, Node's
tests and Bun's own `test/js`, each file as an executable against
regular Bun on the same file |
| `test/aot/frameworks` | Builds and serves Next, Nuxt, Astro,
SvelteKit, Angular and React Router apps |
| `test/aot/servers`, `test/aot/perf` | The servers and idioms above |
| `test/aot/analysis-scaling`, `build-scaling`, `startup-scaling` |
Build time and startup can't grow faster than the program |
| `test/aot/trimmed-against-whole` | Each program built with and without
`--trim`. Both have to print and exit the same |
| `test/aot/fuzz` | Generators and mutators, against the shipping build,
a build with assertions and the validating build |
| `test/aot/engine-tests.test.ts` | The engine's own native and IR text
tests, linked with Bun's table of built-ins |
| `test/cli/aot-docs.test.ts` | Runs every command in the docs that's
followed by its output |

Of test262's 47,536 files, 33,467 can run as module code, and 31,231 of
those pass. Most of the rest try to change a built-in.

All of it takes 130 hours on one core. So each run takes a share: one
program in ten, picked by the commit's hash, so every commit runs a
different tenth. CI's blocking steps keep the pick fixed, so the same
tree always runs the same tests. `AOT_SHARE=all` runs everything.

Fuzzers still find programs that it compiles wrong. Known failures are
`todo`s in `test/aot`, with the reason next to each.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant