Liftoff and TurboFan: The Two WebAssembly Tiers

Liftoff compiles wasm in a single pass with no IR at all and a budget burned down by the generated code itself; the optimizing compiler takes over in the background once the budget runs out.

TL;DR: Liftoff (src/wasm/baseline/liftoff-compiler.cc) compiles a wasm function in a single pass: the bytecode decoder is the compiler, there is no IR, no liveness analysis and no optimization. It keeps a stack of values, each either in a register or in a stack slot, and hands out registers on demand. The tier-up decision is made by a counter in the generated code itself: every backward branch subtracts the PC distance from a per-function budget, and when the budget goes negative the function calls Builtin::kWasmTriggerTierUp and the optimizing compiler is scheduled in the background.

Two compilers, one input format, two completely different philosophies.

Liftoff is the compiler you run when you want the function to exist now. It makes no attempt to produce good code; it makes an attempt to produce any code, quickly, and to make the decision about whether that was a mistake later. The optimizing compiler (Turboshaft, still called kTurbofan in the tier enum) is the compiler you run when you know the function matters.

The interesting part is the interface between them, because V8 does not use a call-count heuristic like it does for JavaScript. For wasm, the budget is burned down by the machine code the function itself executes.

Liftoff: a compiler that is also its own decoder

The entire shape of Liftoff is visible in one line:

cpp
// src/wasm/baseline/liftoff-compiler.cc:11626
  WasmFullDecoder<Decoder::NoValidationTag, LiftoffCompiler> decoder(
      &zone, env->module, env->enabled_features,
      compiler_options.detected_features ? compiler_options.detected_features
                                         : &unused_detected_features,
      func_body, call_descriptor, env, &zone,
      NewLiftoffAssemblerBuffer(func_body_size), debug_sidetable_builder.get(),
      compiler_options, function_coverage_data);
  decoder.Decode();

decoder.Decode() calls back into the LiftoffCompiler once per wasm opcode, and the compiler emits machine code for it immediately. There is no graph, no SSA, no fixpoint iteration, no intermediate representation of any kind. The module was validated before instantiation, so Decoder::NoValidationTag is safe: Liftoff trusts the bytecode it is given.

The output is written straight into an AssemblerBuffer sized from the function body length, so there is no growth-and-copy phase either.

The consequence is that Liftoff cannot optimize, but it also cannot mispredict. There is no speculation to be wrong about, which is why Liftoff code is so much easier to debug: one wasm instruction maps to one contiguous run of machine code, and locals stay in their proper stack slots. V8 leans on this hard — when DevTools attaches, the whole module is tiered down to Liftoff:

cpp
// src/wasm/module-compiler.cc:982
  if (is_in_debug_state) {
    return {ExecutionTier::kLiftoff, ExecutionTier::kLiftoff};
  }

The register allocator: a stack of values, not a graph

There is no liftoff-register-allocator.cc in V8 today, and no liftoff-value.h or liftoff-instruction.h either. The register allocation lives inside the assembler’s CacheState, and it works by keeping a stack of value states that mirrors the wasm operand stack.

cpp
// src/wasm/baseline/liftoff-assembler.h:156
    SmallZoneVector<VarState, 16> stack_state;
    LiftoffRegList used_registers;
    uint32_t register_use_count[kAfterMaxLiftoffRegCode] = {0};
    LiftoffRegList last_spilled_regs;
    Register cached_instance_data = no_reg;

Each VarState is a small tagged union:

cpp
// src/wasm/baseline/liftoff-varstate.h:13
class LiftoffVarState {
 public:
  enum Location : uint8_t { kStack, kRegister, kIntConst };

Three locations: on the stack, in a register, or — a nice touch — a compile-time integer constant that never needs a register at all.

The trick that avoids the classic linear-scan problems is that Liftoff does not compute liveness at all. A register is counted, not analysed:

cpp
// src/wasm/baseline/liftoff-assembler.h:308
    void inc_used(LiftoffRegister reg) {
      DCHECK(!frozen);
      if (reg.is_pair()) {
        inc_used(reg.low());
        inc_used(reg.high());
        return;
      }
      used_registers.set(reg);
      DCHECK_GT(kMaxInt, register_use_count[reg.liftoff_code()]);
      ++register_use_count[reg.liftoff_code()];
    }

    // Returns whether this was the last use.
    void dec_used(LiftoffRegister reg) {
      ...
      int code = reg.liftoff_code();
      DCHECK_LT(0, register_use_count[code]);
      if (--register_use_count[code] == 0) used_registers.clear(reg);
    }

A register stays reserved exactly as long as some value on the operand stack still needs it. The use count is incremented when a value is pushed into a register and decremented when the value is popped or dropped; when it reaches zero, the register is free again. There is no interference graph, no live range computation, no spilling decision that requires looking ahead. The cost is that a register can be held longer than strictly necessary (the value stays on the stack until it is popped), and the benefit is that the whole thing is O(1) per operation.

When no register is free, one is spilled. The policy has two stages, and the first one is the interesting one:

cpp
// src/wasm/baseline/liftoff-assembler.cc:1088
LiftoffRegister LiftoffAssembler::SpillOneRegister(LiftoffRegList candidates) {
  // Before spilling a regular stack slot, try to drop a "volatile" register
  // (used for caching the memory start or the instance itself). Those can be
  // reloaded without requiring a spill here.
  if (cache_state_.has_volatile_register(candidates)) {
    return cache_state_.take_volatile_register(candidates);
  }

  LiftoffRegister spilled_reg = cache_state_.GetNextSpillReg(candidates);
  SpillRegister(spilled_reg);
  return spilled_reg;
}

Liftoff keeps two “volatile” registers: one caches the instance data pointer and one caches the current memory’s base pointer. Both are recomputable, so evicting them costs nothing but a reload. Only when there is no volatile register to drop does it spill a real value — and then it picks round-robin, so that no register is spilled twice in a row while others are never spilled:

cpp
// src/wasm/baseline/liftoff-assembler.h:374
    LiftoffRegister GetNextSpillReg(LiftoffRegList candidates) {
      DCHECK(!frozen);
      DCHECK(!candidates.is_empty());
      // This method should only be called if none of the candidates is free.
      DCHECK(candidates.MaskOut(used_registers).is_empty());
      LiftoffRegList unspilled = candidates.MaskOut(last_spilled_regs);
      if (unspilled.is_empty()) {
        unspilled = candidates;
        last_spilled_regs = {};
      }
      LiftoffRegister reg = unspilled.GetFirstRegSet();
      return reg;
    }

Control flow merges are handled by a ParallelMove, so a value that lives in register rax on one path and in a stack slot on another can be reconciled without materializing everything to the stack first.

The budget: bytes of machine code, not calls

Here is the part that differs from JavaScript tiering. V8 does not count invocations of a wasm function. It counts the machine code bytes the function has executed.

Each function gets a budget array entry at module creation:

cpp
// src/wasm/wasm-code-manager.cc:1044
    // The tiering budget is accessed directly from generated code.
    static_assert(sizeof(*tiering_budgets_.get()) == sizeof(uint32_t));

    std::fill_n(tiering_budgets_.get(), module_->num_declared_functions,
                v8_flags.wasm_tiering_budget);

The comment matters: the array is uint32_t, not a tagged value, and it is not in the JS heap. It is allocated in the sandbox and read and written by the generated code itself, with no runtime call on the fast path.

The default budget is 13 000 000:

cpp
// src/flags/flag-definitions.h:2091
DEFINE_INT(wasm_tiering_budget, 13'000'000,
           "budget for dynamic tiering (rough approximation of bytes executed")

Note “rough approximation of bytes executed”. The unit is the PC distance of a backward branch — the number of bytes of machine code between the branch and its target. A tight loop therefore burns budget fast (many backward branches, each costing the loop body size), and a function that is called many times but does little work burns it slowly.

Liftoff emits a check at every backward branch. The amount charged is the loop’s own size:

cpp
// src/wasm/baseline/liftoff-compiler.cc:3978
  void BrImpl(FullDecoder* decoder, Control* target) {
    if (dynamic_tiering()) {
      if (target->is_loop()) {
        DCHECK(target->label.get()->is_bound());
        int jump_distance = __ pc_offset() - target->label.get()->pos();
        TierupCheck(decoder, decoder->position(), jump_distance);
      } else {

Forward branches are deliberately not charged, and the comment says why:

cpp
// src/wasm/baseline/liftoff-compiler.cc:3985
        // To estimate time spent in this function more accurately, we could
        // increment the tiering budget on forward jumps. However, we don't
        // know the jump distance yet; using a blanket value has been tried
        // and found to not make a difference.

The check itself is two machine instructions plus a conditional branch. On x64:

cpp
// src/wasm/baseline/x64/liftoff-assembler-x64-inl.h:374
void LiftoffAssembler::CheckTierUp(int declared_func_index, int budget_used,
                                   Label* ool_label,
                                   const FreezeCacheState& frozen) {
  Register instance_data = cache_state_.cached_instance_data;
  if (instance_data == no_reg) {
    instance_data = kScratchRegister;
    LoadInstanceDataFromFrame(instance_data);
  }

  Register budget_array = kScratchRegister;  // Overwriting {instance_data}.
  movq(budget_array,
       FieldMemOperand(instance_data,
                       WasmTrustedInstanceData::kTieringBudgetArrayOffset));

  int offset = kInt32Size * declared_func_index;
  subl(Operand{budget_array, offset}, Immediate(budget_used));
  j(negative, ool_label);
}

Load the budget array out of the instance data, subtract the amount spent, branch if it went negative. Three instructions on the hot path, no call, no branch misprediction in the common case (the branch is perfectly predicted until it is taken exactly once).

Liftoff adds a fixed surcharge for the cost of the check itself, and caps how much a single check can charge:

cpp
// src/wasm/baseline/liftoff-compiler.cc:1193
  void TierupCheck(FullDecoder* decoder, WasmCodePosition position,
                   int budget_used) {
    if (for_debugging_ != kNotForDebugging) return;
    SCOPED_CODE_COMMENT("tierup check");
    budget_used += kTierUpCostForCheck;
    // We never want to blow the entire budget at once.
    const int max_budget_use = std::max(1, v8_flags.wasm_tiering_budget / 4);
    if (budget_used > max_budget_use) budget_used = max_budget_use;

The cap matters: a function whose loop body is enormous must not be able to consume the whole 13 MB budget in one backedge, or the tier-up would be decided by a single branch rather than by how long the function ran.

The constants are documented in the compiler itself:

cpp
// src/wasm/baseline/liftoff-compiler.cc:712
  // Some constants for tier-up checks.
  // In general we use the number of executed machine code bytes as an estimate
  // of how much time was spent in this function.
  // - {kTierUpCostForCheck} is the cost for checking for the tier-up itself,
  //   which is added to the PC distance on every tier-up check. This cost is
  //   for loading the tiering budget, subtracting from one entry in the array,
  //   and the conditional branch if the value is negative.
  // - {kTierUpCostForFunctionEntry} reflects the cost for calling the frame
  //   setup stub in the function prologue (the time is spent in another code
  //   object and hence not reflected in the PC distance).
  static constexpr int kTierUpCostForCheck = 20;
  static constexpr int kTierUpCostForFunctionEntry = 40;

Twenty bytes for the check, forty for the function prologue’s call into the frame setup stub. It is an estimate of machine code bytes, and the estimates are calibrated by hand.

When the budget runs out

The negative budget branches to out-of-line code that calls a builtin:

cpp
// src/wasm/baseline/liftoff-compiler.cc:862
    static OutOfLineCode* TierupCheck(
        Zone* zone, ZoneVector<OutOfLineCode*>* list, WasmCodePosition pos,
        ...
      OutOfLineCode* ool = zone->New<OutOfLineCode>();
      ool->builtin = Builtin::kWasmTriggerTierUp;

The out-of-line code is used because the tier-up path needs to spill every live register, and doing that inline would slow down every function that has a loop. The out-of-line block pushes the registers it needs and then calls the builtin.

That builtin ends up in Runtime_WasmTriggerTierUp:

cpp
// src/runtime/runtime-wasm.cc:659
    } else {
      wasm::TriggerTierUp(isolate, trusted_data, func_index);
    }

and TriggerTierUp is where the decision to compile is actually made — and it is a decision with hysteresis, not a simple “compile now”:

cpp
// src/wasm/module-compiler.cc:1526
    trusted_instance_data->tiering_budget_array()[array_index].store(
        v8_flags.wasm_tiering_budget, std::memory_order_relaxed);
    int& stored_priority =
        module->type_feedback.feedback_for_function[func_index].tierup_priority;
    if (stored_priority < kMaxInt) ++stored_priority;
    priority = stored_priority;
  }
  // Only create a compilation unit if this is the first time we detect this
  // function as hot (priority == 1), or if the priority increased
  // significantly. The latter is assumed to be the case if the priority
  // increased at least to four, and is a power of two.
  if (priority == 2 || !base::bits::IsPowerOfTwo(priority)) return;

The budget is reset to full, and the function’s “tier-up priority” is incremented. A compilation unit is only created when the priority hits a power of two. The effect: the first tier-up request compiles the function; a second request is ignored; the fourth and the eighth do compile it again (which matters when the optimizing compiler’s code was discarded for outdated assumptions); the third, fifth, sixth and seventh do not.

That is a hysteresis band, and it exists because the budget being exhausted is a proxy for “hot”, and proxies are noisy. A function whose budget hits zero once may just have had an unusual loop.

The optimizing compiler

The top tier is Turboshaft. ExecuteTurboshaftWasmCompilation is a one-liner that hands off to the general pipeline:

cpp
// src/compiler/turboshaft/wasm-turboshaft-compiler.cc:14
wasm::WasmCompilationResult ExecuteTurboshaftWasmCompilation(
    wasm::CompilationEnv* env, compiler::WasmCompilationData& data,
    wasm::WasmDetectedFeatures* detected,
    DelayedCounterUpdates* counter_updates) {
  wasm::WasmCompilationResult result =
      Pipeline::GenerateWasmCode(env, data, detected, counter_updates);

Pipeline::GenerateWasmCode builds a Turboshaft graph from the wasm bytecode and then runs a wasm-specific list of phases:

cpp
// src/compiler/pipeline.cc:3019
  CHECK(turboshaft_pipeline.Run<turboshaft::WasmLoweringPhase>());

  // TODO(14108): Do we need value numbering if wasm_opt is turned off?
  if (v8_flags.wasm_opt) {
    CHECK(turboshaft_pipeline.Run<turboshaft::WasmOptimizePhase>());
  }

The optimize phase is a CopyingPhase — each reducer rewrites the graph into a new one:

cpp
// src/compiler/turboshaft/wasm-optimize-phase.cc:22
void WasmOptimizePhase::Run(PipelineData* data, Zone* temp_zone) {
  UnparkedScopeIfNeeded scope(data->broker(),
                              v8_flags.turboshaft_trace_reduction);
  CopyingPhase<LateEscapeAnalysisReducer, MachineOptimizationReducer,
               MemoryOptimizationReducer, BranchEliminationReducer,
               LateLoadEliminationReducer, WasmCodeCoverageReducer,
               ValueNumberingReducer>::Run(data, temp_zone);
}

Escape analysis, wasm flavour

The escape analysis reducer looks for allocations whose only uses are stores into other objects. If an allocation never escapes, it does not need to be materialized on the heap at all — it can live in a register or a stack slot.

cpp
// src/compiler/turboshaft/late-escape-analysis-reducer.cc:66
bool LateEscapeAnalysisAnalyzer::EscapesThroughUse(OpIndex alloc,
                                                   OpIndex using_op_idx) {
  ...
  const Operation& op = graph_.Get(using_op_idx);
  if (const StoreOp* store_op = op.TryCast<StoreOp>()) {
    // A StoreOp only makes {alloc} escape if it uses {alloc} as the {value} or
    // the {index}. Put otherwise, StoreOp makes {alloc} escape if it writes
    // {alloc}, but not if it writes **to** {alloc}.
    return store_op->value() == alloc;
  }
  return true;
}

The subtlety is in the comment: storing to the allocation is fine, storing the allocation escapes. alloc.foo = 1 keeps alloc on the stack; other = alloc forces it into the heap.

There is also an older, TurboFan-era version of this reducer still in the tree, for the cases where wasm code is compiled by the TurboFan machine-graph pipeline (JS-to-wasm wrappers, and wasm inlined into JS):

cpp
// src/compiler/wasm-escape-analysis.cc:29
  for (Edge edge : node->use_edges()) {
    if (NodeProperties::IsValueEdge(edge)) {
      if ((edge.from()->opcode() == IrOpcode::kPhi &&
           edge.from()->use_edges().empty()) ||
          (edge.index() == 0 &&
           (edge.from()->opcode() == IrOpcode::kStoreToObject ||
            edge.from()->opcode() == IrOpcode::kInitializeImmutableInObject))) {
        // StoreToObject, InitializeImmutableInObject and phis without uses can
        // be replaced and do not require the allocation.

The wasm-specific load elimination (src/compiler/wasm-load-elimination.cc) lives next to it and does the mirror-image job: it removes redundant loads from wasm locals and struct fields once the type feedback says they cannot have changed.

Tiering up in the background

The whole tier-up dance happens without blocking the caller. When the budget runs out, the runtime function schedules a top-tier compilation unit; the actual compilation happens on a background job, and the Liftoff code keeps running in the meantime.

That is not free of consequences. There is no on-stack replacement for wasm. V8’s own architecture document is explicit:

Tier-Up: V8 monitors execution frequency. When a function becomes “hot”, it triggers a tier-up to Turboshaft. Note though that we don’t do on-stack-replacement for Wasm. This means that if Turboshaft code becomes available after the function was called, the function call will complete its execution with Liftoff code.

So a long-running wasm function keeps running Liftoff code until it returns. The next call gets the optimized code.

The compilation units themselves live in a queue with work stealing:

cpp
// src/wasm/module-compiler.cc:342
  std::optional<WasmCompilationUnit> GetNextUnitOfTier(Queue* public_queue,
                                                       int tier) {

and top-tier units have a priority queue in front of the ordinary one, so a function that just got hot jumps the queue.

Trade-offs

What Liftoff buys. Compilation speed, and predictability. Because there is no IR and no fixpoint, Liftoff’s compile time is roughly linear in the size of the function, with a small constant. The V8 architecture document puts it at “tens of megabytes per second”. That is what makes lazy compilation viable: if the first call to a function costs a few microseconds, deferring the compile is nearly free.

What Liftoff costs.

  • Bad code. A value that is used once but computed three times is computed three times. A local that is live across a call but not across the loop is spilled on every iteration. Liftoff’s register allocator keeps values in registers longer than it needs to, because it cannot prove they are dead.
  • No constant folding, no inlining, no bounds-check elimination. The optimizing compiler does all of that; Liftoff does none of it.
  • A real risk of a permanent 2-3× slowdown for code that never triggers the budget. A function called ten times a second in a tight loop will trigger it; a function called ten times a second with a shallow call graph might not.

What the byte-counting budget buys over a call counter. It measures work, not frequency. A function called a million times that does almost nothing stays in Liftoff, which is correct. A function called three times that each spins for a second gets promoted, which is also correct. A pure invocation counter would get both of those wrong.

What it costs. The budget is a proxy, and proxies are noisy. That is why there is a hysteresis band with powers of two. It also means the tier-up decision depends on the shape of the generated code, which depends on the target architecture — the same wasm function has a different budget on x64 and on arm64. And the check is three instructions on every backward branch, including in functions that will never be promoted.

The bottom line

The mental model is: Liftoff is a code generator, not a compiler, and it does not try to be a good one. It decodes an opcode, emits the machine code, and moves on, keeping a stack of values and a counter of how many times each register is still needed.

The tier-up is not driven by a profiler or a call counter. It is driven by the program itself: every backward branch charges the size of the loop against a per-function budget of 13 million machine-code bytes, and when the budget goes negative the function asks to be recompiled. The recompilation happens in the background, and the caller does not wait.

And when the optimizing compiler does run, it is not TurboFan any more. It is Turboshaft, with its own wasm-specific phase list, its own escape analysis reducer, and its own load elimination.

The thing people get wrong

The belief that “Liftoff is just a simplified TurboFan” — a cheaper version of the same compiler with the optimizations switched off. It is not. Liftoff has no IR. TurboFan and Turboshaft build a graph, run reducers over it, and schedule the result onto a register allocator; the TurboFan-era wasm escape analysis reducer works on Nodes in a MachineGraph. Liftoff emits instructions as it decodes them, which is why it can compile tens of megabytes per second and why its output is debuggable instruction-by-instruction.

The second belief is that wasm tiering works like JavaScript tiering — call counts, feedback vectors, an invocation budget scaled by bytecode size. That is the JavaScript mechanism (see The Interrupt Budget), and wasm shares almost none of it. Wasm has no feedback vector, no bytecode array, and no interpreter. Its budget is a plain uint32_t array indexed by function, decremented by the generated code, and its unit is machine code bytes.

V8InternalsWasmCompilerJit