TurboFan: How V8 Optimizes JavaScript

TurboFan turns a JavaScript function into a typed SSA graph, lowers it through three operator vocabularies, schedules it into basic blocks, and emits machine code with no runtime lookups except the inline caches.

TL;DR: TurboFan is a whole-function compiler with a multi-phase pipeline in src/compiler/pipeline.cc: build a graph from the bytecode, type every node, lower the typed nodes into simplified operators, then into machine operators, schedule the graph into basic blocks, and emit code. The two ideas that make it work are (a) every node’s inputs are ordered values, context, frame state, effects, control, so side effects and control flow are explicit edges rather than implicit orderings, and (b) every operator carries a bitmask of properties (kPure, kNoDeopt, kNoThrow, …) that the optimiser uses to decide what it is allowed to do to the node.

TurboFan is V8’s optimising compiler: the tier that runs when a function has been called enough times that optimising it will pay for itself, and the only tier that tries to understand the JavaScript rather than merely execute it.

Its design is old-fashioned in a good way - a classic multi-pass, graph-based, SSA compiler: a graph builder, a typer, a lowering pass, a scheduler, a register allocator and a code generator, in that order. Understanding TurboFan is mostly understanding why each phase exists.

One function, one compilation

The unit of compilation is a whole function, plus everything inlined into it. There is no runtime dispatch: the output is straight-line machine code per basic block, with jumps between blocks.

The graph is built from the bytecode by BytecodeGraphBuilder - a bytecode visitor like the one in the Ignition article, but producing graph nodes instead of bytecodes:

A trivial visitor looks like this:

cpp
// src/compiler/bytecode-graph-builder.cc:1548
void BytecodeGraphBuilder::VisitLdaSmi() {
  Node* node =
      jsgraph()->ConstantNoHole(bytecode_iterator().GetImmediateOperand(0));
  environment()->BindAccumulator(node);
}

The environment() is the bridge from the register world to the graph world. From this point on there are no registers, only values.

And the graph builder does use the feedback - including the one-byte embedded hint that lives inside the bytecode array:

cpp
// src/compiler/bytecode-graph-builder.cc:3261
void BytecodeGraphBuilder::VisitAdd() {
  const BinaryOperationHint hint =
      bytecode_iterator()
          .GetEmbeddedOperationHint<v8::internal::BinaryOperationFeedback>();
  BuildBinaryOpWithEmbeddedFeedback(javascript()->Add(hint));
}

So the byte the interpreter wrote into the bytecode array when it last ran this Add is now the type hint TurboFan uses to build the operation. The mechanism described in the Sparkplug article is the same one TurboFan reads.

Nodes and Operators

A Node is a value with an operator, which describes what the computation is, statically: its opcode, how many inputs of each kind it has, how many outputs, and a bag of properties:

cpp
// src/compiler/operator.h:38
  // Properties inform the operator-independent optimizer about legal
  // transformations for nodes that have this operator.
  enum Property {
    kNoProperties = 0,
    kCommutative = 1 << 0,  // OP(a, b) == OP(b, a) for all inputs.
    kAssociative = 1 << 1,  // OP(a, OP(b,c)) == OP(OP(a,b), c) for all inputs.
    kIdempotent = 1 << 2,   // OP(a); OP(a) == OP(a).
    kNoRead = 1 << 3,       // Has no scheduling dependency on Effects
    kNoWrite = 1 << 4,      // Does not modify any Effects and thereby
                            // create new scheduling dependencies.
    kNoThrow = 1 << 5,      // Can never generate an exception.
    kNoDeopt = 1 << 6,      // Can never generate an eager deoptimization exit.
    kFoldable = kNoRead | kNoWrite,
    kEliminatable = kNoDeopt | kNoWrite | kNoThrow,
    kKontrol = kNoDeopt | kFoldable | kNoThrow,
    kPure = kKontrol | kIdempotent
  };

Read those as a contract. kPure means the node can be moved, duplicated or deleted: it reads no memory, writes no memory, cannot throw, cannot deoptimise, and is idempotent.

kNoDeopt is subtler. A node that can throw needs an eager deoptimisation exit - a check that bails out to the interpreter at the point of the throw. A node marked kNoDeopt provably cannot produce one, so the deopt machinery can skip it. That is why the operator list is peppered with it:

cpp
// src/compiler/js-operator.cc:906
  V(AsyncFunctionAwait, Operator::kNoDeopt, 2, 1)                        \
  ...
  V(FulfillPromise, Operator::kNoDeopt | Operator::kNoThrow, 2, 1)       \

Value, effect, control: three kinds of input

This is the structural idea that makes the whole graph work. A node’s inputs are not a flat list of values; they are five ordered groups:

cpp
// src/compiler/node-properties.h:28
  // Inputs are always arranged in order as follows:
  //     0 [ values, context, frame state, effects, control ] node->InputCount()

  static int FirstValueIndex(const Node* node) { return 0; }
  static int FirstContextIndex(Node* node) { return PastValueIndex(node); }
  static int FirstFrameStateIndex(Node* node) { return PastContextIndex(node); }
  static int FirstEffectIndex(Node* node) { return PastFrameStateIndex(node); }
  static int FirstControlIndex(Node* node) { return PastEffectIndex(node); }
  • values are the data dependencies.
  • context is the lexical environment, for nodes that close over variables.
  • frame state is the deoptimisation state - what the interpreter frame would look like at this point.
  • effects is a chain of side effects. Every node that can write to memory or call out takes the current effect node as an input and produces a new one, so two nodes that write to the same location are ordered by the effect chain, not by the data flow.
  • control is a chain of control flow - branches, merges, loops.

A JSCall shows the convention. Its operator declares one value input per argument plus the target, one effect input, one control input, one value output, one effect output and two control outputs:

cpp
// src/compiler/js-operator.cc:1043
const Operator* JSOperatorBuilder::Call(
    size_t arity, CallFrequency const& frequency,
    FeedbackSource const& feedback, ConvertReceiverMode convert_mode,
    SpeculationMode speculation_mode, CallFeedbackRelation feedback_relation) {
  CallParameters parameters(arity, frequency, feedback, convert_mode,
                            speculation_mode, feedback_relation);
  return zone()->New<Operator1<CallParameters>>(   // --
      IrOpcode::kJSCall, Operator::kNoProperties,  // opcode
      "JSCall",                                    // name
      parameters.arity(), 1, 1, 1, 1, 2,           // inputs/outputs
      parameters);                                 // parameter
}

The 1, 1, 1, 1, 2 at the end is value-out, effect-in, control-in, effect-out, control-out. Making the effect and control chains explicit means the scheduler never has to guess the order of two memory writes.

Phi is the other structural node, and it is a value node with a representation attached:

cpp
// src/compiler/common-operator.cc:918
  template <MachineRepresentation kRep, int kInputCount>
  struct PhiOperator final : public Operator1<MachineRepresentation> {
    PhiOperator()
        : Operator1<MachineRepresentation>(     //--
              IrOpcode::kPhi, Operator::kPure,  // opcode
              "Phi",                            // name
              kInputCount, 0, 1, 1, 0, 0,       // counts
              kRep) {}                          // parameter

A phi is kPure - it has no side effects and can be duplicated - but it does have a control input, because which input it takes depends on which branch you came from.

Three operator vocabularies

The graph passes through three levels of abstraction, and JSGraph holds a builder for each:

cpp
// src/compiler/js-graph.h:106
  JSOperatorBuilder* javascript() const { return javascript_; }
  SimplifiedOperatorBuilder* simplified() const { return simplified_; }

JavaScript operators are the source-level ones: JSAdd, JSLoadNamed, JSCall, JSConstruct. They know about JavaScript semantics and nothing about representations.

Simplified operators are the “generic but type-aware” ones: NumberAdd, StringAdd, LoadField, CheckBounds, ToNumber. They carry a MachineRepresentation but know nothing about the specific machine.

Machine operators are the actual instructions: Int32Add, Float64Add, TaggedSmi, LoadField. These are what the instruction selector matches against.

The “simplified” vocabulary is the interesting middle: small, fixed, and deliberately imprecise about representation - a NumberAdd might end up as an Int32Add or a Float64Add depending on what the typer concluded. That is what makes the representation selection phase possible.

Phase by phase

The whole pipeline is in PipelineImpl::OptimizeTurbofanGraph:

cpp
// src/compiler/pipeline.cc:1826
bool PipelineImpl::OptimizeTurbofanGraph(Linkage* linkage) {
  DCHECK(!v8_flags.turbolev);
  TFPipelineData* data = this->data_;

  data->BeginPhaseKind("V8.TFLowering");

  // Trim the graph before typing to ensure all nodes are typed.
  RUN_MAYBE_ABORT(EarlyGraphTrimmingPhase);
  RunPrintAndVerify(EarlyGraphTrimmingPhase::phase_name(), true);

  // Type the graph and keep the Typer running such that new nodes get
  // automatically typed when they are created.
  RUN_MAYBE_ABORT(TyperPhase, data->CreateTyper());
  RunPrintAndVerify(TyperPhase::phase_name());

  RUN_MAYBE_ABORT(TypedLoweringPhase);
  RunPrintAndVerify(TypedLoweringPhase::phase_name());
  ...
  RUN_MAYBE_ABORT(SimplifiedLoweringPhase, linkage);
  RunPrintAndVerify(SimplifiedLoweringPhase::phase_name(), true);
  ...
  RUN_MAYBE_ABORT(GenericLoweringPhase);
  RunPrintAndVerify(GenericLoweringPhase::phase_name(), true);

  data->BeginPhaseKind("V8.TFBlockBuilding");

  data->InitializeFrameData(linkage->GetIncomingDescriptor());

  // Run early optimization pass.
  RUN_MAYBE_ABORT(EarlyOptimizationPhase);
  RunPrintAndVerify(EarlyOptimizationPhase::phase_name(), true);
  ...
  if (!ComputeScheduledGraph()) return false;

There is one subtlety worth pausing on: the typer is a decorator. It is installed on the graph before typing starts and stays installed, so that nodes created by later phases are typed as they are created rather than needing a second pass. The comment says so explicitly.

The same section contains a wonderful piece of engineering hygiene. After the lowering phases, the types on the nodes are no longer meaningful, because representation selection may have changed them:

cpp
// src/compiler/pipeline.cc:1901
  // From now on it is invalid to look at types on the nodes, because the
  // types on the nodes might not make sense after representation selection
  // due to the way we handle truncations; if we'd want to look at types
  // afterwards we'd essentially need to re-type (large portions of) the
  // graph.

  // In order to catch bugs related to type access after this point, we now
  // remove the types from the nodes (currently only in Debug builds).
#ifdef DEBUG
  RUN_MAYBE_ABORT(UntyperPhase);
  RunPrintAndVerify(UntyperPhase::phase_name(), true);
#endif

Rather than trusting every future contributor to remember, V8 deletes the types in debug builds. If any later phase reads a type, it crashes.

The Typer

The typer is a reducer that visits every node and attaches a Type:

cpp
// src/compiler/turbofan-typer.cc:57
class Typer::Visitor : public Reducer {
 public:
  explicit Visitor(Typer* typer, LoopVariableOptimizer* induction_vars)
      : typer_(typer),
        induction_vars_(induction_vars),
        weakened_nodes_(typer->zone()) {}

  const char* reducer_name() const override { return "Typer"; }

  Reduction Reduce(Node* node) override {
    if (node->op()->ValueOutputCount() == 0) return NoChange();
    return UpdateType(node, TypeNode(node));
  }

TypeNode is a giant switch over opcodes that computes the type of a node from the types of its inputs. The type lattice is not a simple Smi/Number/String hierarchy: it is a set of properties (Any, Smi, HeapNumber, String, JSReceiver, …), because JavaScript’s types do not nest the way C++’s do.

The typer must run before lowering because lowering is a function of the type. A JSAdd on two Smis is a NumberAdd that becomes an Int32Add; a JSAdd on a String and anything is a StringAdd; a JSAdd on a Number and a String is a runtime call.

Typed lowering

JSTypedLowering is the reducer that does this. It is a long list of ReduceJS* methods, one per JavaScript operator, each of which looks at the input types and emits simplified operators.

A small example, the “optimise length on a string” case:

cpp
// src/compiler/js-typed-lowering.cc:1403
Reduction JSTypedLowering::ReduceJSLoadNamed(Node* node) {
  JSLoadNamedNode n(node);
  Node* receiver = n.object();
  Type receiver_type = NodeProperties::GetType(receiver);
  NameRef name = NamedAccessOf(node->op()).name();
  NameRef length_str = broker()->length_string();
  // Optimize "length" property of strings.
  if (name.equals(length_str) && receiver_type.Is(Type::String())) {
    Node* value = graph()->NewNode(simplified()->StringLength(), receiver);
    ReplaceWithValue(node, value);
    return Replace(value);
  }
  return NoChange();
}

ReduceJSHasInPrototypeChain shows the other kind of work: it walks the prototype chain in the graph, building a loop, a phi and a bunch of LoadField nodes, and proves the answer is false for primitives:

cpp
// src/compiler/js-typed-lowering.cc:1428
  // If {value} cannot be a receiver, then it cannot have {prototype} in
  // it's prototype chain (all Primitive values have a null prototype).
  if (value_type.Is(Type::Primitive())) {
    value = jsgraph()->FalseConstant();
    ReplaceWithValue(node, value, effect, control);
    return Replace(value);
  }

That is TurboFan doing what it is for: not just lowering, but proving things about the program and deleting the code that tests them.

Scheduling

After the graph is lowered, it has to be turned into basic blocks with a linear order inside each. This is the Scheduler, and it is a two-pass algorithm:

cpp
// src/compiler/scheduler.cc:1482
void Scheduler::ScheduleEarly() {
  if (!special_rpo_->HasLoopBlocks()) {
    TRACE("--- NO LOOPS SO SKIPPING SCHEDULE EARLY --------------------\n");
    return;
  }
  ...
  // Compute the minimum block for each node thereby determining the earliest
  // position each node could be placed within a valid schedule.
  ScheduleEarlyNodeVisitor schedule_early_visitor(zone_, this);
  schedule_early_visitor.Run(&schedule_root_nodes_);
}

and then:

cpp
// src/compiler/scheduler.cc:1855
void Scheduler::ScheduleLate() {
  ...
  // Schedule: Places nodes in dominator block of all their uses.
  ScheduleLateNodeVisitor schedule_late_visitor(zone_, this);
  schedule_late_visitor.Run(&schedule_root_nodes_);
}

The two passes bracket the answer. “Early” computes, for each node, the earliest block it could legally live in - the innermost dominator of all its inputs, roughly. “Late” computes the latest - the dominator block of all its uses. A node scheduled early and scheduled late into the same block is unambiguous and goes there. A node that differs has been moved between the two, and there is real work to do: hoisting it out of a loop, or splitting it.

The classic case is loop-invariant code motion. A node whose inputs are all outside a loop but whose use is inside can be hoisted - but only if it is safe, which means it must not write to memory that the loop reads. Which is exactly what the effect chain is for.

The scheduler is not a classic list scheduler. It does not build a priority queue of ready nodes; it computes a block assignment and then a linearisation within the block, because the graph has explicit control edges and the “ready set” is determined by the control structure rather than by data dependencies.

Instruction selection, register allocation, code generation

The final stages are the classic backend. InstructionSelector walks the scheduled graph and matches each node against a machine operator, producing an Instruction; RegisterAllocator assigns physical registers; CodeGenerator emits the bytes.

The register allocator is a graph-colouring allocator with live ranges and spill slots, and it has to deal with the “gap” problem: between two uses of a virtual register there may be a region where it is not live, and the allocator can use that region for something else. GapResolver and MoveOptimizer exist purely to make those gaps small, because on a modern CPU the moves are often more expensive than the work.

The Turboshaft twist

Here is the part that is not in the textbooks. In the current tree, the frontend is still TurboFan’s, but the backend is a different compiler. Look at the top-level job:

cpp
// src/compiler/pipeline.cc:761
PipelineCompilationJob::Status PipelineCompilationJob::ExecuteJobImpl(
    RuntimeCallStats* stats, LocalIsolate* local_isolate) {
  ...
  turboshaft::Pipeline turboshaft_pipeline(&turboshaft_data_, linkage_);
  ...
  if (V8_UNLIKELY(v8_flags.turbolev)) {
    if (!turboshaft_pipeline.CreateGraphWithMaglev(linkage_)) {
      return AbortOptimization(BailoutReason::kTurbofanGraphBuildingFailed);
    }
  } else {
    if (!pipeline_.CreateGraph(linkage_)) {
      return AbortOptimization(BailoutReason::kTurbofanGraphBuildingFailed);
    }

    // We selectively Unpark inside OptimizeTurbofanGraph.
    if (!pipeline_.OptimizeTurbofanGraph(linkage_)) return FAILED;

    // We convert the turbofan graph to turboshaft.
    if (!turboshaft_pipeline.CreateGraphFromTurbofan(&data_, linkage_)) {
      data_.EndPhaseKind();
      return FAILED;
    }
  }

  if (!turboshaft_pipeline.OptimizeTurboshaftGraph(linkage_)) {
    return FAILED;
  }

  const bool success = GenerateCodeFromTurboshaftGraph(
      linkage_, turboshaft_pipeline, &pipeline_, data_.osr_helper_ptr());
  return success ? SUCCEEDED : FAILED;
}

So the flow is: TurboFan builds a graph, types it, lowers it, schedules it - and then hands the schedule to Turboshaft, which builds its own graph from that schedule:

cpp
// src/compiler/turboshaft/pipelines.cc:181
bool Pipeline::CreateGraphFromTurbofan(compiler::TFPipelineData* turbofan_data,
                                       Linkage* linkage) {
  ...
  // Save the schedule before releasing the graph zone.
  Schedule* schedule = turbofan_data->schedule();
  turbofan_data->reset_schedule();

  data_->InitializeGraphComponentWithGraphZone(
      turbofan_data->ReleaseGraphZone(), source_positions, node_origins,
      Graph::Origin::kCreatedFromTurbofan);

  if (std::optional<BailoutReason> bailout =
          Run<turboshaft::BuildGraphPhase>(schedule, linkage)) {

Turboshaft then does its own machine lowering, load elimination, memory optimisation, dead code elimination, and finally instruction selection and register allocation - reusing TurboFan’s InstructionSelector and CodeGenerator, but with its own LinearScanAllocator.

Why? Turboshaft is a cleaner IR: operations are not nodes in a graph with explicit effect and control edges but values in a sequence, and the representation is part of the operation rather than a separate attribute. That makes the backend passes much easier to write, and it is why the --turbolev flag exists - a mode where the frontend is Maglev’s and the backend is Turboshaft’s.

Deoptimisation

A TurboFan function that guesses wrong must be able to bail out to the interpreter. That is what the Deoptimize node is: a control node that materialises the interpreter’s frame from the graph’s values and jumps back.

The frame state input to every deoptimisable node is what makes this possible: it records, for every live bytecode register, which graph value should be in it. Deoptimize then walks that state, materialises the values (including any virtual objects escape analysis had scalar-replaced) and hands the frame to the interpreter.

There are two kinds. An eager deopt happens at the check itself: the code tests a condition, the test fails, and the code falls into the deoptimise block. A lazy deopt happens later, at a safepoint: the condition is recorded, the code keeps running optimistically, and the deopt fires when the next safepoint is reached. Lazy deopts are cheaper when they do not fire and much more expensive when they do.

Trade-offs

What it buys. Everything. TurboFan inlines, specialises on maps, reasons about types, proves properties of the program, keeps values in registers, and emits machine code with no interpretation overhead at all.

What it costs.

  • Compile time. A TurboFan compilation is orders of magnitude more expensive than a Sparkplug one, which is exactly why there is a middle tier.
  • Code size. Aggressive inlining and specialisation produce large code.
  • Deoptimisation storms. A function that deoptimises repeatedly is compiled repeatedly. V8 mitigates this by tracking invocation_count_before_stable.
  • Guessing. Every specialisation is a guess backed by a check, and the checks cost something even when they succeed.
  • Complexity. The pipeline is long, the phases interact, and the “remove the types in debug builds” hack exists because the interactions are hard to reason about.

The bottom line

TurboFan is a classic SSA compiler with an explicit effect and control model. The graph is built from bytecode, typed, lowered through three operator vocabularies, scheduled into basic blocks by a dominator-based algorithm, and turned into machine code in one pass.

The mental model to keep: the effect chain and the control chain are edges in the graph. That is the single design decision that makes the rest work - it is what lets the optimiser know that two stores are ordered, what lets the scheduler hoist a load out of a loop, and what lets the register allocator know that a value is still live.

The thing people get wrong

The common belief is that “TurboFan is the optimiser” and that the tiering system is the interesting part. The tiering system is bookkeeping; the compiler is the product. And the compiler is not exotic - it is a straightforward graph compiler with a well-separated phase structure, and almost every clever thing in it comes from one of two places: the operator property bitmask, or the effect/control edge convention.

The second misconception is that TurboFan is the only real compiler in V8. In the current tree, the machine code V8 ships for optimised JavaScript is emitted by Turboshaft, not by TurboFan’s own backend. The src/compiler/backend/ directory you have probably read about - the instruction selector, the register allocator, the code generator - is still there and still does the final emitting, but the graph it works on is now Turboshaft’s, and the graph TurboFan built is the input to it rather than the thing that gets scheduled into blocks.

Related reading:

V8InternalsCompilerJitTurbofan