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.ccwalks the TurboFan graph and gives everyAllocatenode a virtual object: a bag ofVariables, one per tagged slot of the object. AStoreFieldinto a virtual object writes an SSA value into that variable and deletes the store; aLoadFieldreads 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.EscapeAnalysisReducerthen 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:
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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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.
Related
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
ObjectStatenode with one input per field. That is a lot of deoptimizer work for a function that deoptimizes often, and it is why theObjectStateis 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.