Repository navigation
[JSC] Ahead-of-time compilation - #757
Conversation
|
Preview build of 9e2cded: |
e643415 to
8646a6e
Compare
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.
…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.
There was a problem hiding this comment.
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() {} }andnew 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.
| """#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) \\ |
There was a problem hiding this comment.
🟡 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.
…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.
…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.
### 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>
…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>
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.
useAOTis false, andaot/is behindENABLE(AOT).The stages
Pipeline::runAllStages()inaot/pipeline/AOTPipelineStages.cppis the order. The table of passes is inaot/pipeline/AOTPipeline.cpp: each pass with the IR form it runs on, the analyses it needs and the ones it invalidates.JavaScriptCore/aot/ir/valueflow/passes/,analysis/,pipeline/place/Int32,Double,Cell32orJSValuefor each valueselect/,represent/codegen/,builtins/,stubs/image/object/,runtime/aot/ir/AOTSchema.defandaot/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
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::Objectis aJSObjectthat 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 noCodeBlock, and stack walkers find the function by return address.Outside
aot/yarr/YarrJIT.cpp(yarr/YarrCodeGenerator.cpp). A RegExp made at runtime runs in a new matcher (yarr/YarrFlatProgram*)..wasmfile that the program imports is compiled by OMG at build time (wasm/WasmCodeInImage.*). Any other module runs in IPInt.ffi/FFIStubs.*).useAOT, LLInt uses the handlers ofllint/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::Procedurecarries aB3::Policy(b3/B3Policy.h,b3/air/AirPolicy.h) with named switches, all off by default. Onlyaot/codegen/AOTB3Policy.cppturns 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).CellVisitValidatorcompares an inline visit with the method table's underverifyGC.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 asvm.hasCompiledCode()andStructure::isCompiledStructure(). The largest one that isn't is the fast path ofJSON.stringify(runtime/JSONObject.cpp), which takes more cases.Testing
jsccan'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..) andCHECKlines. Every pass, analysis, selection rule and flow rule has tests of its own.run-javascriptcore-tests --testaotruns 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, andaot/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 12b3/air/testair_bun*files for the new and changed phases, and about 50 new files inJSTests/stress.Building
nodeis now a build dependency, 24.2 or later, or 22.18 or later on the 22 line. It runs the generators inaot/as TypeScript while CMake configures.A lane's label says whether it has the compiler. Lanes with
-aotin their label have the compiler and the runtime of its programs, and they are whatbunlinks. The release,-ltoand-lto-hardenedlanes keep their labels and are compiled withENABLE_AOT=0. CI tests Linux x64 and arm64-lto-aot, their-ltotwins, and x64-lto-hardened-aot.Where to start reading
Nothing generated is checked in.
aot/ir/AOTSchema.def, thenaot/ir/schema/operations.defandtypes.def: the contract with the front end.aot/pipeline/AOTPipelineStages.cppand the table inAOTPipeline.cpp.aot/tests/README.md, then anyaot/tests/passes/<pass>.ir.add-generic-version.irshows the fallback.aot/object/AOTCellFormat.handAOTObject.h: the object model.aot/valueflow/AOTValueFlow.h: what the parts of the flow analysis share.aot/stubs/AOTConvention.handaot/codegen/AOTB3Policy.cpp: the calling convention, and how B3 and Air are set up.aot/image/AOTImageFormat.h: what an image is.