The Compilation Pipeline: From Source Text to Speculative Machine Code
"Interpreted or JIT-compiled" undersells it: a modern engine is four compilers and a deoptimiser negotiating over type feedback.
V8 runs source through a lazy parser, a bytecode interpreter (Ignition), a non-optimising baseline compiler (Sparkplug), a fast mid-tier optimiser (Maglev), and a full optimising compiler (TurboFan/Turboshaft). Each tier trades compile latency for execution speed, and all optimisation is speculation on recorded feedback that can be invalidated by deoptimisation. Understanding the tiers explains warm-up curves, performance cliffs, and why microbenchmarks lie.
In this chapter
Parsing is lazy, and the pre-parser decides what gets compiled
Parsing is on the critical path of page load, so V8 does as little of it as possible. The pre-parser scans function bodies without building an AST: it checks syntax, computes the set of variables each inner function references (needed for scope analysis, chapter 5), and records the byte range of the function. Full parsing and bytecode generation happen lazily the first time a function is called. Top-level code and functions that are called during script evaluation are compiled eagerly; everything else waits. The heuristic can be steered: a function expression wrapped in parentheses, (function () { ... }), is treated as a Possibly-Invoked Function Expression (PIFE) and eagerly compiled, because bundlers historically emitted IIFEs in that form. Tools such as optimize-js exploited exactly this; modern engines also stream and parse scripts on a background thread as bytes arrive (streaming compilation).
The cost of lazy parsing is that every function is scanned twice (pre-parse, then parse). The benefit is that functions never called are never fully compiled, and that the inner-function pre-parse data lets V8 skip re-scanning inner functions when the outer function is finally compiled. Compiled bytecode and metadata are also cached: Chrome stores a code cache keyed by script URL after a script has been executed twice within 72 hours, and Node's v8.compileCache / module.enableCompileCache() and the vm.Script cachedData API expose the same mechanism.
Four execution tiers
| Tier | Input → Output | Uses feedback? | When it runs | Design goal |
|---|---|---|---|---|
| Ignition (interpreter) | AST → register-machine bytecode with an accumulator register | Collects it | First execution of every function | Small code size, fast startup. Bytecode is the canonical representation; all tiers can fall back to it. |
| Sparkplug (baseline) | Bytecode → machine code, one bytecode at a time, no IR | No | After a few hundred invocations or loop iterations ("budget" interrupts) | Remove interpreter dispatch overhead in microseconds of compile time. Uses the same stack frame layout as Ignition so OSR is trivial. |
| Maglev (mid-tier) | Bytecode + feedback → SSA graph → machine code | Yes | Warmer functions, before TurboFan is justified | Most of TurboFan's win at a fraction of its compile time: inlining of small functions, representation selection, feedback-guided property access, no aggressive scheduling. |
| TurboFan / Turboshaft (top tier) | Bytecode + feedback → sea-of-nodes (TurboFan) then CFG-based Turboshaft → highly optimised machine code | Yes, aggressively | Hot functions, compiled on a background thread | Peak throughput: deep inlining, escape analysis, load elimination, range analysis, loop peeling, typed lowering of every operation. |
Tier-up decisions are driven by an interrupt budget that is decremented on function entry and loop back-edges; when it is exhausted V8 consults the function's feedback and decides whether to compile the next tier. Optimised code is compiled concurrently and installed on the next call; a running loop can be switched mid-execution through on-stack replacement (OSR), which is why a hot for loop in a function called once still gets optimised. SpiderMonkey's pipeline is structurally identical with different names (Baseline Interpreter → Baseline JIT → WarpMonkey/Ion), and JavaScriptCore's has one more tier (LLInt → Baseline → DFG → FTL with the B3 backend). The architecture has converged because the constraints did.
Speculative optimisation and deoptimisation
JavaScript has no static types, so an optimising compiler cannot prove that a + b is an integer addition. It can, however, observe that at this site a and b have only ever been Smis (the FeedbackVector says so) and speculate: emit an int32 add preceded by a check that both inputs are still Smis and that the result did not overflow. If the check fails, the compiled code cannot continue, because everything after it was compiled under the assumption. It deoptimises: it reconstructs the interpreter's frame (registers, accumulator, bytecode offset) from the optimised frame using a deoptimisation table recorded at each check (a "deopt point"), discards or marks the optimised code, and resumes in Ignition at the exact bytecode. Feedback is updated (the site is now polymorphic or megamorphic), and later re-optimisation will generate more general, slower code, or, if the function deoptimises too often, V8 stops optimising it.
- Eager deopt: a check inside the function fails while it is running; the function itself is deoptimised at that point.
- Lazy deopt: an assumption the code depends on is invalidated elsewhere (a prototype was modified, a Shape was deprecated, a global constant was reassigned, a function was redefined). The code is marked for deoptimisation and unwinds the next time control returns into it, without an explicit check in the hot path. This is how prototype validity cells (chapter 3) reach into optimised code.
- Soft deopt: the compiler had insufficient feedback (an IC never executed), so it emits an unconditional deopt to the interpreter to collect some rather than guessing.
// Phase 1: monomorphic integer addition; the function tiers up quickly.
// Phase 2: a single string argument invalidates the speculation; the site becomes polymorphic.
// Phase 3: re-optimised with a more general (slower) add.
function add(a, b) { return a + b; }
function measure(label, fn) {
const t0 = performance.now();
let acc = 0;
for (let i = 0; i < 3_000_000; i++) acc += fn(i, 1);
console.log(label.padEnd(36), (performance.now() - t0).toFixed(1).padStart(7), "ms", typeof acc === "number" ? "" : "");
}
measure("cold -> warm (tier-up happens here)", add);
measure("hot, int32 speculation", add);
add("x", "y"); // one call with strings: feedback generalises, optimised code deopts
measure("just after deopt (re-tiering)", add);
measure("re-optimised, generic add", add);
console.log("Run in Node with --trace-opt --trace-deopt to see the events by name.");Timings vary widely by engine and machine; the shape of the curve (fast, dip, then a plateau slightly above the original) is the point.
What the top tier actually does
Given bytecode and feedback, TurboFan builds a graph, lowers it in phases, and schedules it. The transformations that matter most for JavaScript performance intuition:
- Inlining of callees based on call-site feedback (the IC recorded which function object was called). Inlining is what makes higher-order code (
arr.map(x => x * 2)) approach the speed of a loop: the callback andArray.prototype.map(which V8 implements as a Torque/CSA builtin that TurboFan can inline as a graph) are both inlined at the call site. A megamorphic call site cannot be inlined. - Representation selection: each value is given a machine representation (tagged, int32, float64, word) and conversions are inserted only where needed. A loop counter stays int32; a coordinate stays float64 unboxed across the whole loop body. Values that must flow into a generic location (an unknown-Shape property, a call to unoptimised code) are boxed at the boundary.
- Escape analysis and scalar replacement: an object allocated in the function whose reference never escapes (never stored to the heap, passed to a non-inlined call, or returned) is not allocated at all; its fields become local variables.
const p = {x, y}; return p.x + p.ycosts zero allocations after optimisation. Destructuring and small temporary objects are usually free for this reason. - Load elimination and check elimination: repeated
o.xreads with no intervening store are merged; repeated Shape checks on the same object are deduplicated. This is why manual caching ofobj.propin a local rarely helps in optimised code. - Range analysis and bounds-check elimination:
for (let i = 0; i < arr.length; i++) arr[i]needs no per-element bounds check once the compiler knowsi < arr.lengthandarr.lengthis loop-invariant. - Typed lowering: with known input types, generic operations become single machine instructions;
Math.flooron a float64 becomes one instruction rather than a builtin call;String.prototype.charCodeAtwith a known one-byte sequential string becomes a load.
Known performance cliffs and their mechanism
| Pattern | Mechanism | Status in current engines |
|---|---|---|
| Functions above a size threshold | Optimising compilers cap the bytecode size they will inline or even optimise; giant functions stay in lower tiers. | Still true; the cap is large (tens of KB of bytecode) but bundled/generated code can hit it. |
arguments leaking | Materialising the arguments object (passing it, storing it) forces allocation and blocks inlining; sloppy-mode arguments aliases parameters. | Rest parameters ...args are fully optimised. arguments.length and arguments[i] alone are fine. |
try/catch in hot functions | Historically prevented optimisation in Crankshaft. | Not a problem since TurboFan (2017). Do not restructure code to avoid it. |
delete, dictionary-mode objects | No Shape ⇒ no IC ⇒ no speculation. | Still true. Chapter 3. |
| Megamorphic property/call sites | IC gives the compiler nothing; every access goes through the stub cache; no inlining. | Still true; the polymorphic limit is 4 in V8. |
| Mixing Smi/double/object in one array or field | Elements-kind and field-representation generalisation; every read must handle every representation. | Still true. Chapter 2, chapter 3. |
| Prototype mutation after warm-up | Validity cells invalidate every dependent IC and lazily deoptimise every dependent optimised function. | Still true. |
| Redefining a global function/constant | Optimised code embeds global property cells as constants and depends on them not changing. | Still true; hot-swapping code at runtime triggers lazy deopts. |
Where WebAssembly fits
WebAssembly is compiled by the same engines through a different pipeline (V8: Liftoff baseline → TurboFan/Turboshaft), and it never speculates because its types are static. Its performance advantage over JavaScript is therefore not "native code versus interpreted"; optimised JavaScript is also native code. The advantage is predictability: no deoptimisation cliffs, no Shape transitions, no GC pauses for linear-memory data, and compile time that does not depend on warm-up. The cost is the boundary: every JS ↔ Wasm call crosses a calling-convention adapter, and data must be copied into or out of linear memory unless it lives there permanently. Code that is numerically heavy and stays inside the module wins; code that calls back into JavaScript or the DOM per element loses.
Where these ideas come from
The mechanisms in this chapter, traced to the paper, standard, or system that introduced them, and how each reached JavaScript.
| Mechanism | Origin | Path into JavaScript |
|---|---|---|
| Just-in-time compilation with type feedback | John McCarthy (1960, Lisp); James Gosling (1984, Emacs); L. Peter Deutsch & Allan Schiffman, Efficient Implementation of the Smalltalk-80 System (1984) | Compiling bytecode to native code lazily at run time and caching it; the baseline tier's ancestor. |
| Speculative optimisation and deoptimisation | Urs Hölzle, Craig Chambers & David Ungar, Debugging Optimized Code with Dynamic Deoptimization (1992) | Optimising on assumptions recorded from execution and falling back to unoptimised code when they fail; the mechanism behind every V8 deopt. |
| Adaptive multi-tier compilation | Urs Hölzle & David Ungar, Reconciling Responsiveness with Performance in Pure Object-Oriented Languages (1996) | Self-93's two compilers with tier-up on hotness; HotSpot, then V8 (Crankshaft, then Ignition/TurboFan, then Sparkplug and Maglev) generalised to more tiers. |
| Sea of nodes | Cliff Click & Michael Paleczny, A Simple Graph-Based Intermediate Representation (1995) | TurboFan's original IR; V8 moved its back half to the CFG-based Turboshaft in 2023, the same direction HotSpot's C2 successors took. |
| Escape analysis | Bruno Blanchet; Jong-Deok Choi et al., Escape Analysis for Object-Oriented Languages (1999); Escape Analysis for Java (1999) (1999) | Proving an allocation does not escape so it can be replaced by scalars; why small temporary objects are free in optimised JavaScript. |
| Lazy parsing | Google V8 team, V8 pre-parser and lazy function compilation (2010) | Pre-parsing function bodies for syntax only and compiling on first call, to make script start-up proportional to code executed rather than code shipped. |
Primary sources
- V8 blog: Firing up the Ignition interpreter
- V8 blog: Sparkplug — a non-optimizing JavaScript compiler
- V8 blog: Maglev — V8's fastest optimizing JIT
- V8 blog: Land ahoy: leaving the Sea of Nodes (Turboshaft)
- V8 blog: Blazingly fast parsing, part 1 (pre-parser, lazy parsing, PIFE)
- V8 blog: Code caching for JavaScript developers
- WebKit blog: Speculation in JavaScriptCore (a comprehensive treatment of speculative compilation)
- Mozilla Hacks: Warp — improved JS performance in Firefox 83