Read OSS

JavaScriptCore: From Parser to Optimizing JIT Pipeline

Advanced

Prerequisites

  • Article 2: WTF and Memory Management (GC bridging concepts)
  • Compiler fundamentals: lexing, parsing, ASTs, intermediate representations
  • Basic understanding of JIT compilation and why tiered compilation exists
  • Familiarity with SSA form and control flow graphs (helpful but not required)

JavaScriptCore: From Parser to Optimizing JIT Pipeline

JavaScriptCore (JSC) is WebKit's JavaScript and WebAssembly engine. It's a remarkably sophisticated piece of compiler infrastructure — one that takes the inherently dynamic, loosely-typed JavaScript language and, through a four-tier compilation pipeline, produces optimized machine code that can rival statically-typed languages in tight loops.

The key insight behind JSC's design is that compilation speed and execution speed are competing goals. An optimizing compiler produces faster code but takes longer to compile. JSC resolves this tension through tiered compilation: start executing immediately with a fast interpreter, profile runtime behavior, and progressively compile hotter code with more aggressive optimization.

This article traces the full pipeline from source text to machine code.

Lexing and Parsing: Source to AST

The first step is turning JavaScript source text into a structured representation. This happens in two phases: lexing (tokenization) and parsing (AST construction).

flowchart LR
    SRC["JavaScript Source<br/>'function add(a,b) { return a+b; }'"] --> LEX["Lexer<T>"]
    LEX --> TOKENS["Token Stream<br/>FUNCTION, IDENT, LPAREN, ..."]
    TOKENS --> PARSE["Parser<LexerType>"]
    PARSE --> AST["AST<br/>(Nodes.h types)"]

The Lexer<T> is a template class parameterized on character type (LChar for 8-bit, UChar for 16-bit). This avoids the overhead of always using 16-bit characters for ASCII-only source files — a common optimization throughout WebKit. The CACHE_LINE_ALIGNED annotation on the class ensures the hot lexer fields don't share cache lines with other data.

The Parser<LexerType> is a recursive descent parser that builds an AST from the token stream. The AST node types are defined in Nodes.h — a large file containing the type hierarchy for every JavaScript syntactic construct (expressions, statements, declarations, patterns, etc.).

JavaScript's grammar is notoriously tricky. The parser must handle automatic semicolon insertion, the arrow function ambiguity ((a) => a vs (a) followed by => a), template literal nesting, and many other context-sensitive constructs. JSC's parser handles all of this in a single pass.

Tip: The standalone JSC shell jsc.cpp is the easiest way to experiment with JSC's internals. You can use command-line flags to dump bytecode, print JIT compilation decisions, and trace tier-up events.

Bytecode Compilation and CodeBlock

The AST isn't executed directly. Instead, the bytecompiler transforms it into bytecode stored in a CodeBlock.

flowchart TD
    AST["AST Nodes"] --> BC["BytecodeGenerator"]
    BC --> CB["CodeBlock"]
    CB --> |"contains"| INST["Bytecode instructions"]
    CB --> |"contains"| PROF["Profiling metadata"]
    CB --> |"contains"| CONST["Constant pool"]
    CB --> |"will contain"| JIT["JIT code (later)"]

CodeBlock is the central compilation artifact in JSC. It stores:

  • Bytecode instructions — A compact encoding of the program's operations.
  • Constant pool — String literals, numbers, and other constants referenced by bytecode.
  • Profiling metadata — Counters and type information collected during interpretation, used later for JIT tier-up decisions.
  • JIT code — When a function is JIT-compiled, the resulting machine code is attached to the same CodeBlock.

The bytecode instruction set is extensive, covering not just basic operations but also specialized fast paths for common patterns (e.g., op_get_by_id for property access, op_call for function calls). Each instruction includes metadata slots where the interpreter and baseline JIT can store type profiles.

LLInt: The Low-Level Interpreter

The first execution tier is the LLInt (Low-Level Interpreter). When a function is first called, its bytecode is executed by the LLInt.

The LLInt's "slow paths" live in LLIntSlowPaths.cpp, which handles complex operations that can't be inlined into the interpreter's main dispatch loop. These include property lookups that miss the cache, function calls to unknown targets, and — critically — tier-up checks.

The tier-up mechanism works by counting how many times each function and loop executes. When an execution count exceeds a threshold, the LLInt triggers compilation of the function at the next tier (Baseline JIT). This happens transparently — the function continues executing in the interpreter while the Baseline JIT compiles in the background.

flowchart TD
    START["Function call"] --> LLINT["LLInt Interpreter"]
    LLINT --> |"execution count > threshold"| BL_COMP["Baseline JIT compilation"]
    BL_COMP --> BL["Baseline JIT code"]
    BL --> |"+ profiling data"| DFG_COMP["DFG JIT compilation"]
    DFG_COMP --> DFG["DFG optimized code"]
    DFG --> |"+ more profiling"| FTL_COMP["FTL compilation (via B3)"]
    FTL_COMP --> FTL["FTL maximum optimization"]
    
    DFG --> |"speculation fails"| OSR_EXIT["OSR Exit"]
    FTL --> |"speculation fails"| OSR_EXIT
    OSR_EXIT --> |"deoptimize"| BL

Tiered JIT Compilation: Baseline → DFG → FTL

JSC has three JIT tiers above the interpreter, each trading compilation time for execution speed:

Baseline JIT — The first JIT tier. It compiles bytecode to machine code with minimal optimization. Its primary role is dual: execute faster than the interpreter AND collect type profiles. The Baseline JIT instruments property accesses, function calls, and arithmetic operations to record what types flow through each operation.

DFG (Data Flow Graph) JIT — The first optimizing tier. The DFG builds a graph-based intermediate representation and performs type inference using the profiles collected by the Baseline JIT. The DFGAbstractInterpreter performs abstract interpretation over the DFG graph, propagating type information to enable speculative optimizations.

"Speculative" is the key word. The DFG assumes that future executions will see the same types as past executions. If a + b has always operated on integers, the DFG compiles it as an integer addition with a guard (type check). If the guard fails at runtime (someone passes a string), the JIT performs an OSR exit (On-Stack Replacement), deoptimizing back to the Baseline JIT.

FTL (Faster Than Light) JIT — The maximum optimization tier. The FTL lowers the DFG graph into the B3 intermediate representation (discussed below) and applies aggressive classical compiler optimizations: loop-invariant code motion, global value numbering, constant folding, dead code elimination, and register allocation.

The entry point for FTL compilation is FTLCompile.cpp, which orchestrates the B3 compilation pipeline and installs the resulting machine code.

The B3 Backend and Air

B3 is JSC's compiler backend — a lower-level IR designed for generating efficient machine code. It's used by both the FTL JIT (for JavaScript) and the OMG tier (for WebAssembly).

flowchart TD
    DFG_IR["DFG IR (high-level)"] --> LOWER["FTL Lowering"]
    LOWER --> B3_IR["B3 IR (SSA form)"]
    B3_IR --> OPT["B3 Optimizations<br/>(strength reduction, CSE, DCE)"]
    OPT --> AIR["Air (Assembly IR)"]
    AIR --> RA["Register Allocation"]
    RA --> MC["Machine Code"]
    
    WASM["Wasm bytecode"] --> OMG["OMG tier"]
    OMG --> B3_IR

B3::BasicBlock is the fundamental unit of B3 IR — a sequence of Values (operations) with predecessor and successor lists forming a control flow graph. B3 uses SSA form, where each value is defined exactly once, making data flow analysis straightforward.

Air (Assembly IR) sits between B3 and the final machine code. It represents the program using machine-specific instructions but with virtual registers. The register allocator assigns physical registers and emits the final machine code.

The design is deliberately similar to LLVM (B3 was inspired by LLVM's IR), but much simpler and faster to compile. JSC previously used LLVM as its FTL backend, but switched to B3 for faster compilation times and tighter integration.

Garbage Collection: The Riptide Collector

JSC manages JavaScript object lifetimes with a garbage collector called Riptide — a retreating-wavefront concurrent GC.

"Retreating wavefront" means the collector doesn't need to pause all JavaScript execution to complete a collection cycle. It traces live objects concurrently while JavaScript continues running, using write barriers to track mutations that happen during the trace.

JSObject is the base class for all JavaScript objects in the GC heap. It inherits from JSCell, which provides the GC infrastructure (mark bits, structure pointer, type info).

The GC must coordinate with WebCore's reference-counted objects through the binding layer we discussed in Part 3. When a JSDocument wrapper (GC-managed) holds a reference to a Document (ref-counted), the GC must know not to collect the wrapper while the Document is still reachable from C++. The DOMWrapperWorld and opaque root mechanism handle this coordination.

stateDiagram-v2
    [*] --> Idle
    Idle --> ConstraintFixpoint : allocation threshold
    ConstraintFixpoint --> Concurrent_Mark : begin marking
    Concurrent_Mark --> Concurrent_Mark : trace objects (concurrent)
    Concurrent_Mark --> Reloop : found new objects
    Reloop --> Concurrent_Mark : continue marking
    Concurrent_Mark --> Sweep : marking complete
    Sweep --> Idle : collection done

WebAssembly: BBQ and OMG Tiers

JSC's WebAssembly support uses two compilation tiers that mirror the JavaScript pipeline:

  • BBQ (Build Bytecode Quickly) — A fast baseline compiler that produces acceptable code quickly, allowing WebAssembly modules to start executing with minimal delay.
  • OMG (Optimized Machine code Generator) — An optimizing tier that uses the same B3 backend as the JavaScript FTL tier, applying full optimizations to hot WebAssembly functions.

This sharing of the B3 backend between JavaScript's FTL and WebAssembly's OMG tier is a significant engineering advantage — improvements to B3's code generation benefit both languages.

The WebAssembly implementation lives in Source/JavaScriptCore/wasm/. Because WebAssembly is statically typed, the compilation is simpler in some ways (no speculation needed) but more complex in others (SIMD operations, memory bounds checking, table indirection).

What's Next

In the final article of this series, we'll shift from architecture to practice: how to build WebKit, run its comprehensive test suites, and contribute changes. We'll cover the dual build systems (Xcode and CMake), the unified sources optimization, the layout test infrastructure, and the git-webkit contributor workflow. Understanding these practical aspects is what turns architectural knowledge into the ability to actually work on the codebase.