Escape Analysis: How TurboFan Deletes an Object That Never Escapes

TurboFan tracks a freshly allocated object as a set of SSA variables, one per field, and deletes the allocation entirely when nothing outside the compilation unit can observe the pointer.

TL;DR: src/compiler/escape-analysis.cc walks the TurboFan graph and gives every Allocate node a virtual object: a bag of Variables, one per tagged slot of the object. A StoreField into a virtual object writes an SSA value into that variable and deletes the store; a LoadField reads the variable and deletes the load. If the object’s address ever reaches somewhere the analysis cannot follow, the whole virtual object is marked escaped and the allocation survives. EscapeAnalysisReducer then rewrites the deopt state so the object can be reconstructed if the frame deoptimizes. The budget for all of this is 1300 bytes of tracked allocations, and the pass runs after inlining so it sees the inlined body, not just the callee.

Consider this function:

js
function sum(a, b) {
  const p = { x: a, y: b };
  return p.x + p.y;
}

Written in TurboFan’s IR, p is an Allocate node followed by a pair of StoreField nodes and a pair of LoadField nodes. That is a heap allocation, a map write, two field writes (each with a write barrier), two field reads (each with a map check), and a young-generation object that the scavenger will have to trace and eventually copy. Every single bit of that is dead weight, because the address of p never leaves the function.

The obvious optimisation - “inline the object into registers” - is the job of TurboFan’s escape analysis, and the interesting part is not that it exists but how it decides. It does not try to prove that p is unobservable in the abstract. It tracks the pointer through the graph, slot by slot, and gives up the moment the pointer lands somewhere it cannot read.

new is not a node, it is three nodes

By the time escape analysis runs, a new expression is no longer a single node. JSCreateLowering has already expanded it into an allocation plus a run of field stores:

cpp
// src/compiler/js-create-lowering.cc:130
  // Emit code to allocate the JSObject instance for the
  // {original_constructor}.
  AllocationBuilder a(jsgraph(), broker(), effect, control);
  a.Allocate(slack_tracking_prediction.instance_size());
  a.Store(AccessBuilder::ForMap(), *initial_map);
  a.Store(AccessBuilder::ForJSObjectPropertiesOrHashKnownPointer(),
          jsgraph()->EmptyFixedArrayConstant());
  a.Store(AccessBuilder::ForJSObjectElements(),
          jsgraph()->EmptyFixedArrayConstant());
  for (int i = 0; i < slack_tracking_prediction.inobject_property_count();
       ++i) {
    a.Store(AccessBuilder::ForJSObjectInObjectProperty(*initial_map, i),
            jsgraph()->UndefinedConstant());
  }

That expansion is what makes escape analysis possible at all. The analysis does not need to understand JavaScript semantics; it only needs to understand Allocate, StoreField, LoadField and “everything else”. A JSCreate node would be opaque. An Allocate plus a linear chain of stores is a small, well-understood thing.

The virtual object: an allocation site, not an allocation

The first thing EscapeAnalysis does when it sees an Allocate is create a virtual object - a compile-time-only stand-in for the heap object:

cpp
// src/compiler/escape-analysis.cc:637
    case IrOpcode::kAllocate: {
      NumberMatcher size(current->ValueInput(0));
      if (!size.HasResolvedValue()) break;
      int size_int = static_cast<int>(size.ResolvedValue());
      if (size_int != size.ResolvedValue()) break;
      if (const VirtualObject* vobject = current->InitVirtualObject(size_int)) {
        // Initialize with dead nodes as a sentinel for uninitialized memory.
        for (Variable field : *vobject) {
          current->Set(field, jsgraph->Dead());
        }
      }
      break;
    }

The size has to be a compile-time constant for this to work at all; if the allocation size is not known, there is no fixed set of fields to track. InitVirtualObject then creates one Variable per tagged slot (VirtualObject::VirtualObject walks the size in kTaggedSize steps and pushes a fresh variable per slot).

The Dead node as the initial value is a deliberate sentinel. A Variable maps to “no value yet” as nullptr (meaning “the initialisation does not dominate this point”), but the fields of a virtual object always exist, so they start out mapped to Dead and reads of a Dead field are treated as “not ready yet” rather than as a real value. The comment in the header is explicit that this is a workaround for dead nodes not being handled everywhere yet.

Variables: the SSA form of a field

Variable is a tiny integer wrapper, and the VariableTracker is what turns “the value of field 2 of this object, right now” into an SSA value. The tracker’s state is a map from variable to node, and the map is attached to every node in the effect chain, so the tracker knows what every field holds at every point in the effect chain:

cpp
// src/compiler/escape-analysis.cc:91
// A VariableTracker object keeps track of the values of variables at all points
// of the effect chain and introduces new phi nodes when necessary.
// Initially and by default, variables are mapped to nullptr, which means that
// the variable allocation point does not dominate the current point on the
// effect chain. We map variables that represent uninitialized memory to the
// Dead node to ensure it is not read.
// Unmapped values are impossible by construction, it is indistinguishable if a
// PersistentMap does not contain an element or maps it to the default element.

The phi creation is the interesting part. When the effect chain reaches a merge or a loop header, the tracker has to combine the values coming in from each predecessor. If they differ, it creates a Phi node and adds it as a new root of the reduction:

cpp
// src/compiler/escape-analysis.cc:516
          // We only create a phi if the values are different.
          if (identical_inputs) {
            result.Set(var, value);
          } else {
            TRACE("Creating new phi\n");
            buffer_.push_back(control);
            Node* phi = graph_->graph()->NewNode(
                graph_->common()->Phi(MachineRepresentation::kTagged, arity),
                arity + 1, &buffer_.front());
            // TODO(turbofan): Computing precise types here is tricky, because
            // of the necessary revisitations. If we really need this, we should
            // probably do it afterwards.
            NodeProperties::SetType(phi, Type::Any());
            reducer_->AddRoot(phi);
            result.Set(var, phi);
          }

The Type::Any() and the TODO are honest: the analysis runs after typing, and the phi it creates has to be re-visited to get a proper type. This is one reason the analysis is a fixed-point iteration rather than a single pass.

The store: how a field becomes an SSA value

Now the payoff. A StoreField into a virtual object does not touch memory; it writes the value into the variable and deletes itself:

cpp
// src/compiler/escape-analysis.cc:668
      // BoundedSize fields cannot currently be materialized by the deoptimizer,
      // so we must not dematerialze them.
      if (vobject && !vobject->HasEscaped() &&
          vobject->FieldAt(OffsetOfFieldAccess(op)).To(&var) &&
          !FieldAccessOf(op).is_bounded_size_access) {
        current->Set(var, value);
        current->MarkForDeletion();
      } else {
        current->SetEscaped(object);
        current->SetEscaped(value);
      }

MarkForDeletion sets the node’s replacement to the Dead node, which means the store is dropped from the graph. The else branch is the bailout: the object escapes and so does the value being stored into it.

Note the is_bounded_size_access guard. A bounded-size field stores a shifted length that the deoptimizer knows how to materialise, but the deoptimizer cannot reconstruct such a field from a plain SSA value. If you deleted the store, a deoptimization would produce a wrong object. So the analysis refuses to fold those fields, even though folding them would be perfectly correct in the steady state. This is the escape analysis being forced to respect what the deoptimizer can rebuild.

The load: how a field load becomes that SSA value

The mirror image. A LoadField from a virtual object is replaced by whatever the variable currently holds:

cpp
// src/compiler/escape-analysis.cc:699
    case IrOpcode::kLoadField: {
      Node* object = current->ValueInput(0);
      const VirtualObject* vobject = current->GetVirtualObject(object);
      Variable var;
      Node* value;
      if (vobject && !vobject->HasEscaped() &&
          vobject->FieldAt(OffsetOfFieldAccess(op)).To(&var) &&
          current->Get(var).To(&value)) {
        current->SetReplacement(value);
      } else {
        current->SetEscaped(object);
      }
      break;
    }

So p.x becomes the SSA value that was stored into slot 0, and p.y becomes the SSA value in slot 1. When the whole thing works out, the object is nothing but a bundle of registers.

LoadElement is more clever still. If the analysis knows the object has exactly two elements, it turns the load into a Select on the index being zero - and then marks both elements as escaping, because the values now flow out of the object:

cpp
// src/compiler/escape-analysis.cc:754
          if (value0 && value1) {
            // The {object} has exactly two elements, so the LoadElement
            // must return one of them (i.e. either the element at index
            // 0 or the one at index 1). So we can turn the LoadElement
            // into a Select operation instead (still allowing the {object}
            // to be scalar replaced). We must however mark the elements
            // of the {object} itself as escaping.
            Node* check =
                jsgraph->graph()->NewNode(jsgraph->simplified()->NumberEqual(),
                                          index, jsgraph->ZeroConstant());
            NodeProperties::SetType(check, Type::Boolean());
            Node* select = jsgraph->graph()->NewNode(
                jsgraph->common()->Select(access.machine_type.representation()),
                check, value0, value1);
            NodeProperties::SetType(select, access.type);
            current->SetReplacement(select);
            current->SetEscaped(value0);
            current->SetEscaped(value1);

The bailouts, precisely

The default arm of the switch is where most of the conservatism lives:

cpp
// src/compiler/escape-analysis.cc:912
    default: {
      // For unknown nodes, treat all value inputs as escaping.
      int value_input_count = op->ValueInputCount();
      for (int i = 0; i < value_input_count; ++i) {
        Node* input = current->ValueInput(i);
        current->SetEscaped(input);
      }
      if (OperatorProperties::HasContextInput(op)) {
        current->SetEscaped(current->ContextInput());
      }
      break;
    }

Any operator the analysis does not understand kills the object. That gives the three bailouts the brief asks about, in slightly different forms:

A stored address. obj.field = p where obj is not a virtual object (a real heap object, or one that already escaped) takes the else branch of the StoreField case: SetEscaped(value). The address of p is now in a place the analysis cannot see, so p escapes.

A captured closure. The same mechanism, one level up. A closure that captures p stores p into a context slot. The context is not a virtual object, so the store escapes p. If the closure creation is not recognised at all, the default arm escapes its context input instead. Either way, p escapes.

A store to an array. StoreElement only folds when the index is a compile-time constant that maps to a slot inside the object:

cpp
// src/compiler/escape-analysis.cc:681
    case IrOpcode::kStoreElement: {
      Node* object = current->ValueInput(0);
      Node* index = current->ValueInput(1);
      Node* value = current->ValueInput(2);
      const VirtualObject* vobject = current->GetVirtualObject(object);
      int offset;
      Variable var;
      if (vobject && !vobject->HasEscaped() &&
          OffsetOfElementsAccess(op, index).To(&offset) &&
          vobject->FieldAt(offset).To(&var)) {
        current->Set(var, value);
        current->MarkForDeletion();
      } else {
        current->SetEscaped(value);
        current->SetEscaped(object);
      }
      break;
    }

OffsetOfElementsAccess insists that the index type is OrderedNumber and that min == max == index, i.e. a single constant. arr[i] = p with a variable i therefore escapes p immediately. (Double elements are excluded outright: OffsetOfElementAt returns Nothing for kFloat64, with a chromium:1237821 reference.)

The budget

There is a hard cap on how much the analysis will track:

cpp
// src/compiler/escape-analysis.cc:302
  static constexpr int kTrackingBudget = 1300;

  VirtualObject* NewVirtualObject(int size) {
    if (number_of_tracked_bytes_ + size >= kTrackingBudget) {
      if (V8_UNLIKELY(v8_flags.trace_turbo_bailouts)) {
        std::cout
            << "Bailing out in Escape Analysis because of kTrackingBudget\n";
      }
      return nullptr;
    }

1300 bytes of tracked allocations per function. This is a space budget on the analysis, not a size budget on the code: the variables themselves are what cost memory. The failure mode is graceful - InitVirtualObject returns nullptr, no virtual object is created, the allocation is simply left alone.

The deopt state is the hard part

Deleting the allocation is only valid if the frame can be reconstructed if it deoptimizes. The deopt state records the live values at each point; for a virtual object, the values are the field values, and TurboFan introduces a dedicated ObjectState node to hold them:

cpp
// src/compiler/escape-analysis-reducer.cc:175
  } else if (const VirtualObject* vobject = analysis_result().GetVirtualObject(
                 SkipValueIdentities(node))) {
    if (vobject->HasEscaped()) return node;
    if (deduplicator->SeenBefore(vobject)) {
      return ObjectIdNode(vobject);
    } else {
      std::vector<Node*> inputs;
      for (int offset = 0; offset < vobject->size(); offset += kTaggedSize) {
        Node* field =
            analysis_result().GetVirtualObjectField(vobject, offset, effect);
        CHECK_NOT_NULL(field);
        if (field != jsgraph()->Dead()) {
          inputs.push_back(ReduceDeoptState(field, effect, deduplicator));
        }
      }
      int num_inputs = static_cast<int>(inputs.size());
      NodeHashCache::Constructor new_node(
          &node_cache_,
          jsgraph()->common()->ObjectState(vobject->id(), num_inputs),
          num_inputs, &inputs.front(), NodeProperties::GetType(node));
      return new_node.Get();
    }

ObjectState(vobject->id(), num_inputs) is the deoptimizer’s instruction to rebuild an object with num_inputs fields, in order, from the given values. The Deduplicator handles the case where the same virtual object appears twice in one deopt state: the second occurrence becomes an ObjectId node instead of a second copy of the fields, and the deoptimizer knows to reuse the object it already built.

The reducer also has to be careful about types. If the replacement node has a narrower type than the original, simply replacing it would widen the type of the original’s users, and “widening a type by constant folding” is exactly the kind of thing that produces inconsistent representations later on:

cpp
// src/compiler/escape-analysis-reducer.cc:38
  Type const replacement_type = NodeProperties::GetType(replacement);
  Type const original_type = NodeProperties::GetType(original);
  if (replacement_type.Is(original_type)) {
    RelaxEffectsAndControls(original);
    return Replace(replacement);
  }

  // We need to guard the replacement if we would widen the type otherwise.

So it inserts a TypeGuard instead of replacing outright.

Running it, and proving it worked

The phase itself is short:

cpp
// src/compiler/pipeline.cc:1140
struct EscapeAnalysisPhase {
  DECL_PIPELINE_PHASE_CONSTANTS(EscapeAnalysis)

  void Run(TFPipelineData* data, Zone* temp_zone) {
    EscapeAnalysis escape_analysis(data->jsgraph(),
                                   &data->info()->tick_counter(), temp_zone);
    escape_analysis.ReduceGraph();

It runs after LoopPeeling, LoadElimination and the typed lowering (src/compiler/pipeline.cc:1852-1862), which is exactly where you want it: after inlining, so the analysis sees the inlined body.

The last thing the phase does is a verification pass:

cpp
// src/compiler/escape-analysis-reducer.cc:202
void EscapeAnalysisReducer::VerifyReplacement() const {
  AllNodes all(zone(), jsgraph()->graph());
  for (Node* node : all.reachable) {
    if (node->opcode() == IrOpcode::kAllocate) {
      if (const VirtualObject* vobject =
              analysis_result().GetVirtualObject(node)) {
        if (!vobject->HasEscaped()) {
          FATAL("Escape analysis failed to remove node %s#%d\n",
                node->op()->mnemonic(), node->id());
        }
      }
    }
  }
}

An Allocate that the analysis decided was non-escaping but that is still in the graph is a fatal error. The analysis is not allowed to be approximately right.

Late escape analysis: the cheap second pass

There is a second, much simpler escape analysis in the tree, in src/compiler/late-escape-analysis.cc. It works on a different graph level - on AllocateRaw nodes, after lowering - and it does not track fields at all. It only asks: “is this allocation’s address used by anything other than a store that initialises it?” The witness function is almost a one-liner:

cpp
// src/compiler/late-escape-analysis.cc:27
bool IsStore(Edge edge) {
  DCHECK_EQ(edge.to()->opcode(), IrOpcode::kAllocateRaw);
  DCHECK(NodeProperties::IsValueEdge(edge));

  switch (edge.from()->opcode()) {
    case IrOpcode::kInitializeImmutableInObject:
    case IrOpcode::kStore:
    case IrOpcode::kStoreElement:
    case IrOpcode::kStoreField:
    case IrOpcode::kStoreToObject:
      return edge.index() == 0;
    default:
      return false;
  }
}

bool IsEscapingAllocationWitness(Edge edge) {
  if (edge.to()->opcode() != IrOpcode::kAllocateRaw) return false;
  if (!NodeProperties::IsValueEdge(edge)) return false;
  return !IsStore(edge);
}

edge.index() == 0 is the base of the store, not the stored value: a store into the object is fine, a store of the object is an escape. The reducer then removes the allocation and every store that initialised it, and - this is the neat part - if one of those stores was storing another allocation, that allocation is now dead too and gets revisited:

cpp
// src/compiler/late-escape-analysis.cc:111
void LateEscapeAnalysis::RemoveAllocation(Node* node) {
  DCHECK_EQ(node->opcode(), IrOpcode::kAllocateRaw);
  for (Edge edge : node->use_edges()) {
    if (!NodeProperties::IsValueEdge(edge)) continue;
    Node* use = edge.from();
    if (use->IsDead()) continue;
    // The value stored by this Store node might be another allocation which has
    // no more uses. Affected allocations are revisited.
    if (std::optional<Node*> stored_value = TryGetStoredValue(use);
        stored_value.has_value() &&
        stored_value.value()->opcode() == IrOpcode::kAllocateRaw &&
        stored_value.value() != node) {
      RemoveWitness(stored_value.value());
      revisit_.push_back(stored_value.value());
    }
    ReplaceWithValue(use, dead());
    use->Kill();
  }

An honest note: in this checkout, src/compiler/late-escape-analysis.cc is not referenced by any phase in src/compiler/pipeline.cc - the file is included but the class is never instantiated. The live implementation is the Turboshaft one in src/compiler/turboshaft/late-escape-analysis-reducer.cc, which does the same job with the same idea, expressed in terms of StoreOp:

cpp
// src/compiler/turboshaft/late-escape-analysis-reducer.cc:66
bool LateEscapeAnalysisAnalyzer::EscapesThroughUse(OpIndex alloc,
                                                   OpIndex using_op_idx) {
  if (ShouldSkipOperation(graph_.Get(alloc))) {
    // {using_op_idx} is an Allocate itself, which has been removed.
    return false;
  }
  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 runtime-call-stats counter is named TurboshaftCsaLateEscapeAnalysis (src/logging/runtime-call-stats.h:299), which is the one that actually fires.

Trade-offs

What it buys. The allocation, the map store, the field stores with their write barriers, the field loads with their map checks, and the young-generation object all disappear. The object never reaches the scavenger. For a function that allocates one small object per iteration, this is the difference between a bump-pointer allocation and nothing at all.

What it costs.

  • Analysis time. The analysis is a fixed-point iteration over the whole graph, with a variable tracker that creates phis and re-visits nodes. It is not free, and it is paid on every optimised compilation.
  • The 1300-byte budget. Past that, objects are simply not analysed. A function that allocates a lot gets no benefit, and the bailout is silent unless you pass --trace-turbo-bailouts.
  • Deopt-state bloat. Every non-escaping object in a deopt state becomes an ObjectState node with one input per field. That is a lot of deoptimizer work for a function that deoptimizes often, and it is why the ObjectState is only created for objects that are genuinely non-escaping.
  • Bounded-size fields. A field the deoptimizer cannot rebuild blocks folding for that field, and in practice for the whole object.
  • The default arm is brutal. Any operator the analysis does not know about escapes everything it touches. Adding a new IR node is therefore a change to the escape analysis, not just to the lowering.

The bottom line

TurboFan’s escape analysis is not a proof system. It is a very literal simulation: a virtual object is a bundle of variables, a store writes a variable, a load reads a variable, and the moment the pointer goes somewhere the simulation cannot follow, the object is declared real again and the whole optimisation is abandoned for it. The conservatism is total, but it is also cheap to give up: once an object escapes, the analysis stops caring and the graph goes back to being a normal graph.

The cleverest part is not the analysis but the rewrite. Deleting an allocation is only sound if the frame can be rebuilt, so the deopt state is rewritten from “here is a pointer to an object” into “here are the object’s fields, in order” - and the fields that the deoptimizer cannot rebuild are exactly the ones the analysis refuses to fold.

The thing people get wrong

People assume escape analysis is about “the object is local, so it can go on the stack”. It is not, and TurboFan does not do stack allocation of JavaScript objects. It is about scalar replacement: the object’s identity is irrelevant, and the only thing that matters is whether the pointer is observable. An object that is stored into an array, captured by a closure, or passed to an unknown function is fully allocated, fully map-checked, fully write-barriered, and fully in the young generation - even though it never leaves the function.

The other common misconception is that escape analysis is a property of the source code. It is a property of the graph after inlining.

V8InternalsCompilerJitEscape analysis