[JSC] Ahead-of-time compilation

Merged
JA
14d ago
+1004933-5222

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

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

What this adds

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

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

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

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

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

The stages

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

StageWhat it doesFolder in JavaScriptCore/aot/
Read, type, verifyDecodes the program, types every value, adds the type tests at function entries. The verifier checks every operand and stored value against the type of its placeir/
FlowOne analysis over the whole program: which allocation sites, closures, built-ins and constants reach each positionvalueflow/
OptimizeFunction passes on all threads, with program passes between thempasses/, analysis/, pipeline/
PlaceWhere each field goes and in which form, shape numbers, the method table, cache slotsplace/
Select, representTurns IR operations into the forms that the code generator has. Picks Int32, Double, Cell32 or JSValue for each valueselect/, represent/
CodeIR to B3, then to machine codecodegen/, builtins/, stubs/
ImageCode layout, linking, outlining, identical code folding, RegExp and WebAssembly code, stringsimage/
RuntimeObjects, closures, modules, coroutines, caches, slow pathsobject/, runtime/

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

Objects

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

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

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

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

Outside aot/

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

Look closest here

Two things that every process runs, image or not.

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

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

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

Testing

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

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

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

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

Building

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

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

Where to start reading

Nothing generated is checked in.

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