Repository navigation
[JSC] Ahead-of-time compilation - #757
Jarred-Sumner wants to merge 459 commits into
Conversation
|
Preview build of d2e6890: |
e643415 to
8646a6e
Compare
What a node can do to properties is said in one place, Graph::propertyEffectOf. A function is summarized by the names it stores to, itself or through the known functions it calls. A call to such a function forgets only the reads of those names. Calls through the thunks for Map, Set, String and Array methods forget none: the exits of the thunks count in Instance::effectEpoch. Reads are available where paths meet, as a phi if the paths disagree, and in a loop that does not store to the name. A read that is lowered on one path only, as the read of a builtin method is, recorded a value that does not dominate what follows. It records nothing now. A property is looked for inline in the slots that constructors and literals of the program put a property of that name in: the Structure says which name is in a slot, and the offset is an immediate, so the load does not wait for the cache. The pointer to a function's caches is computed where it is needed in cold code, so that a function that needs it nowhere else does not compute it on entry. A constructor whose objects get properties outside their inline storage makes the next objects with more room, up to the slots whose names a Structure records.
Looking for a property first in the slots where the program's constructors and literals put a property of that name does not pay. Measured in four forms: inline with IDs learned at run time, in one shared stub, inline with IDs fixed when the image is built, and the last only for names whose slot is the same at 70% or 95% of the places that create them. On a large application every form retires as many instructions per unit of work as none or up to 4% more, and grows the code by 5 to 36 MB of 122, which a cold start pays for. On the TypeScript compiler every form is within noise of none in cycles. With verboseAOTCompilation, the phases of compiling an image say how long they took.
"Type check failed: property "x": expected number, got string" instead of a sentence that names neither. The name is at hand where the check fails and the kinds are in the field's record, so nothing is added to the image. The slow path of a planned construction with typed fields dropped a store that the field refuses and left the field empty. A read of it returned the empty value, which crashed whatever looked at it next. It throws now. The definition of a declared field with undefined, which precedes the constructor's own store, is still a placeholder.
A sample in the first instructions of the trampoline for native calls has a pc outside the image and the frame pointer of the compiled caller. The profiler took the frame for one of the interpreter's and read its callee, which compiled frames do not keep: the slot has whatever an earlier call through the generic path left there. Asked for its executable while a collection processes the samples, a function that never needed one trapped in JSFunction::materializeAOTExecutable(). AOT::innermostFrame() looks at the link register in this case too, as it does for a C function that makes no frame, so the sample goes to the function that is running. Where there is no link register, a callee that still has its function word is resolved after the collection like any other compiled frame.
With --compileMainScriptAheadOfTime the shell compiles in a child process and, if that fails, runs the script without an image. That is right for a script that cannot be compiled, but a child killed by a signal was treated the same way, so a crash in the compiler looked like a passing test. The shell now prints the signal and exits with 128 plus its number. On Windows it does the same for an exit status that is an exception code.
A cached resolution of a name to the global object or the global lexical environment is stamped with JSGlobalObject::globalLexicalBindingEpoch(). When the epoch reaches its threshold it starts again at 1 and every CodeBlock of the realm is told to drop its caches. Compiled code was not told, so a cache stamped with an epoch that comes round again was valid again: a function kept reading a property of the global object after a `const` of the same name had shadowed it. Instance::forgetResolvedGlobalScopes() clears those caches. They are the slots with no structure whose pointer is one of the two global scopes. Fixes stress/scope-operation-cache-global-property-bump-counter.js.
…ng loop hints A program that runs from an image has code blocks whose instruction stream has a size and no bytes. Two options walked it anyway: - --validateBytecode validated a code block that runs code from the image. - --returnEarlyFromInfiniteLoopsForFuzzing keeps a counter per op_loop_hint, registered when the bytecode generator finishes and removed by ~UnlinkedCodeBlock(). A decoded code block was never registered, so its destructor trapped in VM::removeLoopHintExecutionCounter(). Upstream has the same flaw, which does not show because fuzzing does not decode cached bytecode, while the image compiler always does. CachedCodeBlock::decode() registers the counters of a code block that has bytes. Registration and removal skip one that has none, and a code block made from the metadata in an image is marked as having none.
op_debug, op_profile_type, op_profile_control_flow and the two op_log_shadow_chicken opcodes report to a debugger or a profiler. The compiler declined every function that had one, and since such a program has no bytecode either, it crashed when it started. Compiled code has nothing to report, so: - The five opcodes compile to nothing, and code generated for the debugger or a profiler is no longer declined. - The key under which a module's top-level code is found leaves out the code generation mode. It changes when an inspector attaches or a profiler is enabled, and the program would then not find its own code. - ShadowChicken::update() empties its log when there is an image instead of walking frames that have no bytecode index. Interpreted code, from eval or the Function constructor, still writes to the log. - ProgramExecutable::initializeGlobalProperties() does not register unexecuted ranges for function declarations of a compiled program, which are not kept as unlinked executables.
BytecodeLinkEncoder roots the code blocks of functions until the link ends, which was at the end of Encoder::encodeLinkedCold(), before the image is compiled from the same code blocks. Where UnlinkedFunctionExecutable's edge to its code may be weak (mini VM mode, or --useUnlinkedCodeBlockJettisoning) a collection between the two freed them, and the compiler crashed in AOT::conventionOf(). The roots are now released after the image is compiled. The jsc shell also defers collection from the generation of the bytecode until then: a collection before the encoder took the code made the build fail for lack of bytecode. Fixes stress/collect-continuously-should-not-wake-concurrent-collector-after-prevent-collection-is-called.js.
Remarks say which optimizations applied somewhere in a function. They do not say where, they say nothing about what did not apply, and nothing relates them to the source. Finding out why one property read is slow meant reading the graph or the disassembly of its function. --aotTypeCoveragePath=<file> makes the compiler write one line for every instruction of every function it compiles: the position in the module's text, the opcode, the property name, the function it was compiled into, every remark made while it was lowered, whether it is in a loop, inlined, elided or in the generic copy of a split loop, and, where the type table gives an access no type, the number of the table's reason. A call emitted in a cold block is marked as rare, so an operation with an inline fast path can be told from one that always calls. Stores that an allocation absorbs and instructions that never enter the graph are reported as such. The file ends with the number of instructions per opcode, so a reader can check that nothing is missing. Tools/Scripts/aot/type-coverage.py reports from it like a code coverage tool, an operation being covered if it compiled to inline code: by kind of operation, by file (through a source map), by reason, property, function or opcode, for one line (--explain), as an lcov tracefile, and as an exit status for CI. The option only records decisions that are made anyway. Images are identical with and without it, and with it off building a large program takes the same number of instructions as before; with it on, 10% more. In the jsc shell an empty path means a temporary file, which aotTypeCoverage() reads.
The inliner makes helper nodes that take over the instruction of the call they stand in for, such as the op_unreachable after a callee that is known not to be initialized. Such a node belongs to the graph of the block it is put in, which need not be the graph the call came from, and asking that graph's code block for the offset of the instruction trapped in UnlinkedCodeBlock::bytecodeOffset(). A large program hit this; no test did. A node is reported only if it is the instruction it points at: the same opcode, inside the code block of its own graph. What a helper node compiles to is part of another operation. run-tests.py compiles with type coverage on in its validated mode, so every test exercises the reporting. Also: a module whose provider has no URL is named by its origin, which is where Bun puts the name, and the README describes the tools for seeing what the compiler did.
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.
…lt constructor - `ValueSets::Table` and `ValueFlowSolver::IndexOfMadeNodes::Table` are nested classes that the enclosing class holds in a `std::atomic`. A defaulted constructor with an initializer on the member can't be used there with every standard library.
- `AOTBuiltinIDs.h` names `BuiltinID` and `NamedTableID`, which `AOTBuiltinIDTypes.h` declares. The hardened build reaches them by no other header.
…es through JavaScriptCore, the run time's own forms of a few operations - `SelectTypedResults` goes by the schema's set of operations that carry an expected result, so a call with a spread argument is validated too. `ResolveCalls` leaves a call whole until its validation stands behind it. A call that still awaits validation at the end of the pipeline is an internal error of the build. - A validator can be made from the text of a plan, for a run without an image. - `export *` over names that the build did not see is resolved by `AbstractModuleRecord::resolveExportImpl()`. - A nested evaluation that ends well inside an import cycle that fails keeps what it evaluated. - A member of a built-in that a program may define through an object of unknown origin counts as written and stays unlocked. - A field is expected to hold the layouts that have every name the program asks of it. Receivers with getters of their own are tested for together. - `NewObject` says whether the values that it lists prove the types of its fields. - Stack capture, BigInt arithmetic, the conversion of an Int32 array to Double, `replace`, private names, lookups in a table by the parts of a key, stores to global bindings and the comparison of a long string with names have their own forms in `aot/runtime`. - `JSObject::anyObjectInChainMayInterceptIndexedAccesses()` asks the realm only at a typed array in the chain. - Objects that the constructor of a built-in class makes for a compiled class are ordinary objects: `BrandedStructure` has no word for the compiled class. - The words of the compile options are generated from `compile-call.def`. - `aot/passes` and `aot/pipeline`: files of one job are joined, the entry points of the program passes are declared in one header. - In a validating build a read of a module variable that a loop pass moves in front of the loop may come before its store. - `Closure::state()` and `Object::fieldNamed()` are inline. - Tests.
…s bytecode, unions that multiply remember verdicts - `aot/validator`: a built-in function can get its bytecode from native code instead of from source text, and every tier runs it like any other function. A validation plan compiles to such functions. Nothing calls this yet. - A union whose arms would look at one part of a value more than 64 times uses the path that remembers verdicts. - A BigInt literal that is first evaluated while the heap is full takes its cell from the reserve. - `matchesPattern()` throws a TypeError when the pattern is not a string. - `Object.assign` into a record reads every value before it changes the target. - The inliner's size estimate counts a chain of three or more integer comparisons like a switch, and constant expressions as free. - `replaceAll` with a string pattern and `Object.assign` into an empty record use their own fast paths. - Tests.
Bun's build compares the generated engine half of the table of built-ins with the copy in the prebuilt release.
…ve up, constants compile at a full heap - New validation nodes and rules for JSON Schema keywords. `Schema.FromJSONSchema` types are checked against their schema at build time. - `RegExp::searchFromStart()` tells "no match" from a search that ended at the match limit or without memory, so a rule that passes on "no match" does not pass on a search without an answer. - `matchesPattern()` checks the types of all its arguments. - A constant array literal and a BigInt constant compile while the heap is full. - `aot/validator`: plain names and comments, a cap on generated code, linear analysis of unions, unknown keys in a plan give no bytecode. `AOTValidatorBytecode.h` is exported. - 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/WTF/wtf/PlatformEnable.h:778— nit: a maintainer who configures JavaScriptCore without -DUSE_BUN_JSC_ADDITIONS=ON (the CMake default) now gets a build that fails to compile, where the base compiled. ENABLE_AOT at Source/WTF/wtf/PlatformEnable.h:773-778 defaults to 1 wherever the FTL is on, with no USE(BUN_JSC_ADDITIONS) condition, unlike ENABLE_FFI just above it. The AoT code it turns on uses Bun-only declarations unguarded. Fix: make the ENABLE_AOT default depend on USE(BUN_JSC_ADDITIONS) as ENABLE_FFI does, or guard every such use, so that a plain configure compiles again. Examples: JSMicrotask.cpp:646-650 uses Bun__reportUnhandledError, declared only at JSMicrotask.cpp:76-78; ProgramExecutable.cpp:170 uses withAOTErrorNote, declared only under USE(BUN_JSC_ADDITIONS) at Error.h:88-89.Why this was flagged
Trigger: a cmake configure of the JSCOnly port without -DUSE_BUN_JSC_ADDITIONS=ON; Source/cmake/OptionsJSCOnly.cmake:21 defaults that option to OFF, and only the Dockerfiles pass it ON. Source/WTF/wtf/PlatformEnable.h:773-778 then still defines ENABLE_AOT 1 on any FTL-capable x86_64/arm64 Linux, Darwin, FreeBSD or Windows build. Code the diff adds then references declarations that exist only under USE(BUN_JSC_ADDITIONS): Source/JavaScriptCore/runtime/JSMicrotask.cpp:646-650 defines reportErrorOfMicrotask outside any guard and calls Bun__reportUnhandledError, which is declared at JSMicrotask.cpp:76-78 inside #if USE(BUN_JSC_ADDITIONS); Source/JavaScriptCore/runtime/ProgramExecutable.cpp:168-171 calls withAOTErrorNote under #if ENABLE(AOT), which Source/JavaScriptCore/runtime/Error.h:88-89 declares only under USE(BUN_JSC_ADDITIONS). The compile fails on undeclared identifiers. On the base commit every use of those names in JSMicrotask.cpp sat inside #if USE(BUN_JSC_ADDITIONS), and no ENABLE_AOT existed, so the same configure compiled. No CI lane is affected: every Dockerfile passes -DUSE_BUN_JSC_ADDITIONS=ON.
Verification: Triggered when JavaScriptCore is configured without -DUSE_BUN_JSC_ADDITIONS=ON. Source/WTF/wtf/PlatformEnable.h:773-779 defines ENABLE_AOT 1 with no USE(BUN_JSC_ADDITIONS) term. Source/JavaScriptCore/aot/stubs/AOTStubsCoroutineJobs.cpp:22-23 uses QueuedTask::offsetOfDispatcher(), which Source/JavaScriptCore/runtime/MicrotaskQueue.h:140-143 declares only under #if USE(BUN_JSC_ADDITIONS), so the library fails to compile.
…older of generated files - The pass that laid a nested object or array literal inside the cell of its parent is deleted, with its tests, counters and test switches. - `AOTBuiltinTable.cmake` lists the generated files that a release ships and copies them into one folder. The six Dockerfiles copy that folder. - The test shell answers the module "bun" with the global `Bun`.
…bject's cell, `bind` in a constructor calls its method directly - The passes, the bytecode generator and the run-time operations behind `Bun.Validator`, `validate<T>` and `JSON.parse<T>` are out of this branch. A call whose result has a declared type still gets a parser compiled for that type, with the generic parser behind it. - The cell format, the collector and the code generator have no form for an object that lies inside another object's cell. `CellAttributes` and `VerifierSlotVisitor` are as on main. `--aot-sabotage=StoresNeedNoBarrier` gives the collector's verifier something to report. - A built-in is called directly only if its feature stays in the executable: `Program::mayBeTrimmed`, from flow's feature records, the removed features and the features that are kept only when named. The resolver, the effects of a call and the verifier ask it. - `this.m = this.m.bind(this)` makes a closure of a bound function that calls the method directly. Where the target is not proven, the bind tests for the one function that flow saw. - `[...xs]` of an array of numbers copies boxed numbers without a call. `Number(b)`, `+b` and `b++` of a boolean are integers. A constructor made by a factory gets its base class from the program's own `Object.setPrototypeOf`. - An accessor field survives a store of the same name to an unknown object. In a very large program the code behind a branch of a loop that runs once gets the short forms again. - A field declared without a value may be read as undefined where `super()` may call a constructor that the class does not name. A bound `process.dlopen` counts as a call of it. `replace()` collects all matches before it calls a compiled callback. - The test for array storage that a store may write says no while another thread holds the lock of the cell. A validating build relies on its yes alone, like compiled code. The test shell has `lockCell`, `unlockCell` and `callWhileAnotherThreadLocksCell`. - Under ASan the collection of a full cage runs twice. - `aot/README.md` says how to work on this folder. `Tools/Scripts/aot/check-syntax.py`. - Tests.
…ath travels
- Flow follows the path of a `with { type: "file" }` import to `new Worker(path)`. The compile call returns the entries it found: `ResultSection::WorkerEntries`, `WorkerEntryRecord`, the operand role `WorkerEntryPath`. A data module counts as a path if its single export is a string.
- `AOTFlowReport.h` includes `AOTBuiltinIDTypes.h`.
- In a very large program a loop that runs once keeps the long inline forms behind a branch, as before the last commit.
- The counter `TypedVersionsRejected` is gone: nothing counts it.
- Tests.
… and what says which runtime this is are locked - `TryPutDoubleIndex` asks what `putInBounds()` asks and answers false to a no, so library code may use it on an array that the program holds. The sort of doubles writes its result back with it and takes the ordinary store where it is refused. - The global `process`, `process.isBun`, `process.versions` and `process.release` are locked in an executable, so the build can answer `typeof process`, `process.versions.bun` and the like. - Tests.
…opy, ARM64 answers more in stubs, typed callees in loops are inlined by their likely code - `CopyClosureFactories` runs before flow. A function that only returns a closure it creates, called in the body of a module, is copied for each call, with the functions it creates closures of and the layouts of its environments. Flow keeps one answer per function, so `once(loadA)` and `once(loadB)` no longer answer for each other. The copier is the one that `AllocateTaggedLayouts` had. - ARM64: a read at a site asks the site first, answers an absent name in the stub and asks the front table in the same stub. The write barrier remembers a black owner in the stub. A slim closure is entered by its word alone, and so is a getter that is a closure. The bound-name probe has the order of x64. - The inliner counts the code of a typed callee that is expected to run, not the copy behind each type test, where the call stands in a loop. - `view.subarray(n)` that only goes into `encodeInto()` is left unmade inside a loop too. - A field that lies out of line may hold undefined without storage: it hides a method above it. - `JSON.parse` into a record with booleans, copies from a frozen source, lists that keep their storage kind through `find` and `at`, and ten more small changes, each with its test. - `MaxGuessedWorkerFiles` is a declared limit. - Two IR tests that name entries of Bun's table move to oven-sh/bun. - Tests.
…ns can take bytes and characters, a path that only starts Workers is a Worker file - Heat: code is cold if it runs less than once, and hot if a loop or the host repeats it, while that fits in 2,048 values. The hottest fifth and the coldest quarter of a program decide only above that, so code that is added elsewhere does not change how a handler is compiled. - The table of built-ins can say that a native function takes the bytes of a view, or the characters of a string, as an address and a count, and that it returns the fields of its result in registers. The generator writes the entry from the row. It refuses the form for an operand that the function keeps, returns, calls or reads after it has returned. - A path that flow follows to `new Worker()` and that no other native function looks at names a Worker file. A path that another native function looks at too is a guess, and a build takes four guesses at most. `WorkerEntryRecord` has a witness, so the build can say where such a Worker is started. `EngineRuns` is a statistic. - `CopyClosureFactories`: the one call of a copy is inlined. Calls in the text of a CommonJS module and in a function that is called where it is written get copies too, and the factory may be held by a field of an environment that is stored once. - Where a value nobody knows may be called, the species protocol of each escaped built-in runs on the unknown object. `JSON.parse` that may have been replaced takes the plain parse. - A slot test in front of a method arm asks the key order, so a field that holds undefined without storage hides the method above it. The arm serves fields that lie out of line again. - A call of `bind` in code that runs once tests its callee where flow has seen the target, and creates the compiled bound function. - One piece of 1 MB is kept beside the reserve for one large cell at a full heap. An evaluated constant array that gets no large cell is built by code. - The inliner counts all the code of a typed callee again. - About forty small changes, each with its test. - IR tests declare every layout and key that they name, and name no registers of one CPU.
… view that is made only where a view is needed - Heat: a function of the application that the host calls once for each request, message or tick, and the body behind an entry that the engine has put in front of it, is hot in every block that runs at least every second time. The thresholds are as they were. The message of the previous commit describes another change to heat, which that commit does not hold. - `view.subarray(start, end)` of a typed array of a known class clamps its bounds and becomes `TypedArrayRange`, an allocation without conditions that `SinkAllocations` moves to whoever needs the view, or removes. A row of the table says `rangeOf`. - A row whose native function takes nothing but the bytes of an operand is declared once more by the generator, with the bounds of a part of the view as its last operands. Both overloads enter the same C function. `encodeInto` has such a row, and returns its two counts in registers. - `AOTSimplifyBuiltinCallsByteRanges.cpp` and its four helpers are gone. - The table for Bun's front end lists the names that native code stores on objects. - The stored value of `list[i] = "text"` is no atom, so its first use stays in the stub. ARM64 has that stub too. - A store of an object into itself is planned for the layouts that its type still lists. - A nested dispatch node no longer keeps the outer test. - Tests.
… accessors, a stub tests the whole indexing mode but for IsArray - Where the own values of an object are listed, a field that is not enumerable or is an accessor is left to the general path again. - With `IndexingShapeAndWritabilityMask` a stub tests every bit of the indexing mode but `IsArray` again. - `ResumeClosureRequest` and the description of slim closures are back. These six files are as they were in ae1234f.
- The summary of a function counts what it reads through a parameter again, not only what it writes. - A call through the method table may enter the lookup of its selector, whatever the layouts prove. - `==` may hand either operand to code of the program, whatever the other operand is. - The generic entrypoint of the body of the entry module is judged by itself, not by its target. - A record has no second stamp for its callers. - The three tests of these go with them.
…a function, typed callees in loops are inlined by their likely code - `AddGenericVersion`: a function that runs once gets a generic version for the checks that its loops rely on. A check between the read of a method and its call gets no exit, so the call is still resolved by its receiver. - `AOTArrays`: a read of an array that the same function allocates gets its test for a present element, unless `SinkAllocations` will take the array apart. - `AOTRingPasses`: where the cases of a switch list all that its subject may be, the last case takes the place of the otherwise arm. - `InlineHeads`: the tests in front of a body take in its head wherever the call is not cold. - `PlanArrayCapacities` reserves no room for the turns that are left where the loop also takes elements away (`list.length = 0`, `pop`, `splice`). - The inliner counts the code of a typed callee that is expected to run, where the call stands in a loop. What folds is taken off once, bets count for no more than the budget, and a callee with a loop stays out of a caller that runs once. - A call of `encodeInto` that keeps its result and writes into a whole view makes the object in the function itself. A part of a view keeps the Structure of the last result at hand. Bounds are looked at only behind the choice between a view and a part. - A host function that has escaped may be handed anything else that has escaped, and treats it as its row says. - Effects: a call through the method table enters the lookup of its selector only where the layouts allow it, and `==` converts an object only to compare it with a primitive. A nested dispatch node keeps the outer test. - `AOTTypeSubset.cpp` and `AOTObjectBuilder.cpp` are as they were in 13db273. - Tests.
…effects are as they were in 30bbb0b - `AOTAddGenericVersion.cpp`, `AOTArrays.cpp`, `AOTRingPasses.cpp`, `AOTArrayCapacities.cpp`, `AOTInlining.cpp` and `AOTSummarizeForInlining.h` are as they were in 30bbb0b. - So are `AOTEffectsOfCalls.cpp`, `AOTEffectsOfOperands.cpp`, `AOTEffectsOfOperators.cpp` and `AOTValueFlowBuiltins.cpp`. - The tests of those changes go with them. - What the previous commit says about `encodeInto`, about host functions that have escaped, and about `AOTTypeSubset.cpp` and `AOTObjectBuilder.cpp` stays.
…n replaced, a call takes bounds only while its operands fit its row - The change log marks a value whose type a pass wrote and whose users have not been retyped yet. A substitution that takes a user off such a value no longer says that the user's operands kept their types, so the user is retyped. - The form of a built-in that takes the bounds of a view accepts any object for the view. A call moves to that form only while the types say that each operand fits the row that takes whole views, and the generator checks that the parameter holds only objects. - Tests.
… then() on a fulfilled promise queues its job in the stub - The form of a built-in that takes the bounds of a view has its row's own parameter types again. The generator rejects a form whose reads, writes, capability, result or parameters differ from its row's. - `Select`: a call of a compiler helper whose operands no longer fit keeps its form, and each such operand is assumed to be what the parameter takes. - A validating build checks that a view passed with bounds is a view and that the bounds lie inside it. `PartsOfViewsEndOneLater` is the sabotage for that check. - `PlaceTypeTests`: while uses are rewritten, a use that names the first definition of a condition by itself keeps that value's own type through a cast. - `then(f)` on a fulfilled promise queues its job in the stub, while no async context is to be captured. - `InlineHeads`: a function of entry checks is entered for its body, so the head of that body is copied in unless the call is cold, in a loop or not. - Flow: the rule that looks a name up on `Bun` where the receiver may be unknown also runs for a read that a built-in makes for its caller, such as `Reflect.get()`. - `--explain`: no line of the program makes a built-in object, so where one arrives the access is the place that is shown. - A request to terminate that arrives while a module is linked or evaluated waits for the next check. - The set of values whose type was written doubles where it grows. The check of the result rules' laws tries types without logging them. - Tests.
…ard or of an operand that must name a definition - `PlaceTypeTests`: `keepOwnTypesOfOperands()` leaves guard operands and operands that must name a definition as they are. No rule plans from their types.
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.