open atlas
↑ Back to track
JavaScript Engine internals JSE · 04 · 03

TurboFan: the sea-of-nodes optimiser

TurboFan represents code as a sea-of-nodes graph — value, effect, and control edges — instead of a statement CFG, freeing the scheduler to place computation late. The full phase pipeline from bytecode and feedback to register allocation and code generation.

JSE Senior ◷ 15 min
Level
FoundationsJuniorMiddleSenior

Two questions about the same line of code, const d = a.x - b.x, expose how TurboFan thinks. A traditional compiler asks “what statement comes before this one?” TurboFan asks “what value does a.x depend on, and is there any side effect between here and where it’s used?” — and if the answer is “none”, it is free to move that subtraction anywhere, even out of a loop, even past other statements. That freedom is the whole point of a sea-of-nodes IR, and it is why TurboFan can optimise far more aggressively than a statement-by-statement compiler.

Why a graph instead of a list of statements

A traditional compiler models a function as a control-flow graph (CFG) of basic blocks, each a fixed sequence of statements. Order is baked in from the start: statement 3 comes after statement 2 because you wrote it that way. To move computation, such a compiler must prove the move is legal against that imposed order.

TurboFan inverts this. Its IR is a sea-of-nodes graph in which a node is an operation and the edges — not the source order — encode every constraint that actually matters. There is no notion of “instruction order” until the very end. The graph carries three kinds of edge:

  • Value edges (data dependencies): subtract has value edges to the two loads it consumes. This says “I need these inputs”, nothing about when.
  • Effect edges (ordering of observable side effects): a store, a call that might mutate state, a property load that could trigger a getter — these are chained on an effect chain so their relative order is preserved. Pure operations (arithmetic on known numbers) carry no effect edge and float free.
  • Control edges (branches, merges, loops): the skeleton of if/loop/return that decides which nodes execute at all.

Because a pure subtraction has only value edges and no effect or control constraint, the scheduler (a late phase) can place it anywhere its inputs are available — including hoisting it out of a loop if its inputs are loop-invariant. This is what mrale.ph’s writing means by “the scheduler places computation late”: nodes have no fixed home until scheduling assigns one, and it assigns the cheapest legal one.

The phase pipeline

Why does the order of passes matter? Because front phases can still rewrite the graph freely — once you commit to machine instructions, rewriting is too expensive. TurboFan is a sequence of well-defined passes over that graph. The shape, in order:

Graph building turns bytecode into the initial sea-of-nodes graph, attaching the feedback vector’s observations so later phases know the speculated types. Typing and typed lowering propagate types through the graph (a + of two SignedSmall-typed nodes is typed as a number in a known range) and replace abstract operations with type-specialised ones — a generic JSAdd becomes a NumberAdd, then potentially an Int32Add.

Then the optimisation passes rewrite the graph:

  • Inlining — a call node whose feedback names a small, known target is replaced by a copy of the callee’s graph, splicing its value and effect edges into the caller. This is what unlocks cross-function optimisation; without it, every call is an opaque effect.
  • Escape analysis with scalar replacement — an object whose reference never escapes the function (covered in depth in lesson 04) has its allocation deleted and its fields turned into ordinary graph nodes (registers), eliminating the heap allocation and the GC pressure.
  • Load/store elimination — a load that must read a value just stored is replaced by the stored value directly; a store overwritten before any read is deleted.
  • Redundancy elimination and constant folding — a CheckMaps that a dominating CheckMaps already proved is removed; 2 * 3 becomes 6; a branch on a known-true condition is collapsed.
  • Representation selection — decides the machine representation of each value: a tagged pointer, a 32-bit integer in a register, or a 64-bit float in an FP register, inserting conversions only where a representation boundary is actually crossed.

Scheduling is the phase that finally assigns nodes to basic blocks and an order within them — placing pure nodes as late and as un-loopy as legal. Instruction selection matches graph patterns to the target ISA’s machine instructions. Register allocation (TurboFan uses a sophisticated allocator, not the linear scan Maglev uses) assigns physical registers and inserts spills. Code generation emits the final bytes. The output is a blob of native machine code installed on the function.

TurboFan vs the lighter tiers
IR form
sea-of-nodes graph
Edge kinds
value / effect / control
Register allocator
graph-based (not linear scan)
Inlining cap (polymorphic)
~4 known targets
Compile time
tens-hundreds of ms / fn
Runs on
background thread
Maglev quality vs TurboFan
~50-70%
Trace flag
--trace-turbo / --trace-opt

Where it runs and how to inspect it

TurboFan compiles on a background thread (concurrent compilation, lesson 01): the main thread keeps running the function on its current tier and the optimised code is swapped in when ready. This is the source of the most common mental error about it (see below). To inspect the graph, --trace-turbo dumps the IR at each phase into files you can open in the Turbolizer visualiser; --trace-opt logs the higher-level decisions. mrale.ph and the v8.dev/blog posts are the canonical deep references for the graph and its phases.

Quiz

In TurboFan's sea-of-nodes IR, a pure integer subtraction inside a loop has only value edges (no effect or control edge tying it down). What can the scheduler do with it?

Quiz

Why does typed lowering happen before scheduling and register allocation rather than after?

Order the steps

Order TurboFan's phases from input to native code.

  1. 1 Build the sea-of-nodes graph from bytecode + feedback
  2. 2 Typing and typed lowering (specialise abstract ops)
  3. 3 Optimisation passes (inlining, escape analysis, folding)
  4. 4 Representation selection (tagged / int32 / float64)
  5. 5 Scheduling (assign nodes to blocks and order)
  6. 6 Instruction selection + register allocation + code generation
Why this works

Why does separating value, effect, and control edges matter so much in practice? Because most performance-critical JS is numeric or array work where the heavy operations are pure — arithmetic, comparisons, index computations. In a CFG, those are pinned to their basic block and only loop-invariant code motion can move them, with conservative aliasing assumptions. In a sea-of-nodes graph they have no home until scheduling, so the compiler can hoist them out of loops, common-subexpression them across branches, and fold redundant ones with far less ceremony. The cost is real — the compiler is more complex and harder to debug — but for specialising a dynamic language it is worth it.

Recall before you leave
  1. 01
    What are the three edge kinds in TurboFan's sea-of-nodes IR, and why does the distinction enable aggressive optimisation?
  2. 02
    Walk the TurboFan pipeline from bytecode to native code.
  3. 03
    Why is 'TurboFan runs my code' wrong, and where does its cost actually show up?
Recap

TurboFan is V8’s full optimising compiler, and its defining choice is the sea-of-nodes intermediate representation. Instead of a control-flow graph of ordered statements, it models a function as a graph whose edges — value (data dependencies), effect (observable side-effect ordering), and control (branches, loops, merges) — encode the only constraints that matter; source order does not. A pure operation carries only value edges, so it has no fixed position until the scheduling phase places it as late and as cheaply as legal, which is what makes loop hoisting, common-subexpression elimination, and redundancy folding so natural. The pipeline runs in order: graph building from bytecode and feedback, typing and typed lowering that specialise abstract operations, the optimisation passes (inlining, escape analysis with scalar replacement, load/store and redundancy elimination, constant folding), representation selection (tagged vs int32 vs float64), scheduling, instruction selection, register allocation with a graph-based allocator, and code generation. The whole compile runs once on a background thread, so TurboFan never executes your code and never blocks the main thread — it produces native code the CPU then runs directly. Inspect it with —trace-turbo and Turbolizer; the deep references are mrale.ph and the v8.dev blog. Now when you see —trace-turbo output and wonder why a loop-body subtraction moved outside the loop, you have the answer: no effect edge, so the scheduler placed it wherever the data flow allowed.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.