67. Escape analysis in V8
Chapter 23 left a puzzle: building an object per element cost struct of arrays dearly in one benchmark and nothing in another. The difference is whether the object escapes, and V8 can make a temporary object vanish.
- Explain why `get-objects` didn’t lose and `materialize` did, in this book’s benchmark run.
- Describe how TurboFan removes a non-escaping object, from inlining to scalar replacement.
- Explain how a removed object is rebuilt if the code deoptimizes.
- Contrast V8’s run-time escape analysis with what Viscosity must do ahead of time.
The puzzle from chapter 23
Two of this book’s benchmark suites rebuild an object per element from
struct-of-arrays columns. materialize keeps each one in an array:
for (let i = 0; i < n; i++)
out[i] = { x: x[i], y: y[i], /* … */ };
get-objects hands each one to a small function and drops it:
for (let i = 0; i < n; i++)
t += consume({ x: x[i], /* … */ });
Same allocation, same fields. Here is what this book’s benchmark run measured:
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 7.75 | 8.95 |
| struct of arrays | 157.96 | 309.43 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
Table view
| layout | median ns | p95 ns |
|---|---|---|
| array of objects | 6.15 | 6.75 |
| struct of arrays | 1.90 | 2.29 |
Measured with the repo’s harness (engine/ts/packages/bench) on Node v22.23.4, V8 12.4.254.21-node.57, Intel(R) Xeon(R) Processor @ 2.10GHz. One fresh process per case. Shared cloud VM: expect noise. Compare ratios, not absolute times.
The repo’s findings put it in one sentence: V8 inlined the consumer and removed the temporary object. This chapter takes that apart.
Step one: see every use
A courier hands you a box, you take out two things and throw the box away. If you could see that coming, you’d ask for the two things and skip the box. But you can only skip it if you know nobody else wants it.
In the loop as written, the object is passed to a call. To the optimizing compiler, a call is a door: whatever goes through might be stored anywhere. So the first move is to copy the small function’s body into the loop, which compilers call inlining (chapter 82 builds it). Then every use of the object is in view.
You can watch V8 do it. A loop like get-objects, with two fields:
function len2(p) {
return p.x * p.x + p.y * p.y;
}
function run() {
let t = 0;
for (let i = 0; i < n; i++)
t += len2({ x: xs[i], y: ys[i] });
return t;
}
Real output, Node v22.23.4 (V8 12.4), addresses shortened:
$ node --trace-turbo-inlining temp.js
…
Inlining 0x… {0x… <SharedFunctionInfo len2>}
into 0x… {0x… <SharedFunctionInfo run>}
Step two: the object that isn’t
Now TurboFan’s escape analysis looks at each allocation. Its source describes how. Each allocation becomes a virtual object that tracks a variable for each field. A store to a field of a virtual object just sets that variable, and a load is replaced by the value last stored. Anything the analysis doesn’t understand, it treats as an escape[1]. If, at the end, nothing needed the object to really exist, the allocation is removed. Step through it:
function len2(p) {return p.x * p.x + p.y * p.y;}for (let i = 0; i < n; i++)t += len2({ x: xs[i], y: ys[i] });
Scalar replacement replaces an object that doesn’t escape with its
fields as separate local values: p.x becomes p_x. Allocation
sinking moves an allocation to the only places that still need a
real object. In V8, for a non-escaping temporary, that is the deopt
path, so the fast code allocates nothing. In TurboFan’s own words, an
object has escaped if it “escaped to untracked memory or … is used
in an operation that requires materialization”[1].
The proof is in the garbage
If the objects really are gone, the garbage collector should notice.
New objects are allocated in the young generation[4],
and when it fills up V8 collects it with a scavenge (next chapter). So run the
len2 loop 50 times over a million elements and count scavenges, with
the optimization on, and with each step switched off. Real output,
Node v22.23.4:
$ node --trace-gc temp.js \
| grep -c Scavenge
3
$ node --trace-gc --no-turbo-escape \
temp.js | grep -c Scavenge
1710
$ node --trace-gc --no-turbo-inlining \
temp.js | grep -c Scavenge
4257
The count wobbles a little from run to run, but the story doesn’t. With both steps, the hot loop allocates nothing. Turn off escape analysis and every temporary is real. Turn off inlining and even more is allocated, for reasons this chapter doesn’t chase.
When a guard fails
The optimized loop still has guards (chapter 66). If one fails halfway
through, the interpreter takes over, and its bytecode still says
len2(p). It needs a real p.
So the object isn’t forgotten, only postponed. When TurboFan writes the
deopt information, it describes each virtual object that is still live
by its fields, as an ObjectState[1].
The deoptimizer can then build the object for real. V8’s concurrent
marking post lists “materialization during deoptimization of a
function” among the operations that create objects[3].
That is the sunk allocation: it happens only on the path that needs it.
materialize has no such way out. out[i] = p stores the object where
code outside the loop can find it and compare identities. It escapes,
so every allocation stays.
The current TurboFan escape analysis carries a 2017 copyright in V8’s
source[1]. Turboshaft, which has since
replaced TurboFan’s back end[5], adds a
late pass (2022) that removes allocations whose only uses are the
stores that initialize them[2].
Maglev has one too, but node --v8-options shows
--maglev-escape-analysis defaulting to off in Node 22. Viscosity must
answer the same question without these tools. It runs before the
program, with no inlining and no deoptimizer, so “might escape” has to
mean “keep the objects”. Chapter 85 builds that check, and the
get-objects result is why it matters: an object that never escapes
costs nothing, and only one that does is worth keeping.
Your turn
A tiny IR is a list of instructions:
{ op: 'new', dst, fields }:dst = { ... }, wherefieldsmaps each name to a variable{ op: 'get', dst, obj, key }:dst = obj.key{ op: 'set', obj, key, src }:obj.key = src{ op: 'add', dst, a, b },{ op: 'mov', dst, src },{ op: 'call', fn, args },{ op: 'ret', src }
Write scalarReplace(code). An object made by new escapes if its variable is ever used as a value: a field value in a new, the src of a set or mov, an argument, an add operand, or returned. Being the obj of a get or set is fine. For each new that doesn’t escape, replace it with one mov per field into dst_key (in field order), turn its gets into movs from obj_key, and its sets into movs to obj_key. Leave everything else as it is, and don’t change the input.
function scalarReplace(code) {
// 1. find objects that never escape
// 2. rewrite new / get / set on them
return code;
}After scalar replacement, a deopt frame state describes a removed object as { fields: { x: 'p_x', ... } }: each field names the variable holding its value. A field may also hold another description (an object that was inside it). Write materialize(desc, env), which builds a real object from the description, reading variables from env.
function materialize(desc, env) {
return {};
}What you learned
get-objectsdidn’t lose for SoA because V8 inlined the consumer and then removed the temporary object.materializelost because its objects escape into an array.- TurboFan treats each allocation as a virtual object with a variable per field. If it doesn’t escape, its loads become the stored values: scalar replacement.
- The allocation is sunk to the deopt path: the frame state describes the object by its fields, and the deoptimizer builds it only if a guard fails.
- On this machine, turning off escape analysis took the
len2loop from a handful of scavenges to about 1700. - Viscosity has no deoptimizer, so it must prove an object doesn’t escape before it changes the layout.
Check your understanding
0/6- 1.
Which order of steps lets TurboFan remove the object in
t += len2({ x: xs[i], y: ys[i] })? - 2.
After scalar replacement, where do
p.xandp.ylive? - 3.
A guard fails inside the optimized loop, after
pwas removed. What happens? - 4.
In this book’s run,
materializewas about 20–35× slower for SoA, whileget-objectswasn’t slower at all. Why? - 5.
In V8 12.4 (Node 22), which statement matches
node --v8-options? - 6.
Viscosity, unlike V8, refuses to change the layout when an element is passed to
move(ps[i]). Why is that the right choice for it?
References
- [1] The V8 project authors (2017). src/compiler/escape-analysis.h, escape-analysis.cc and escape-analysis-reducer.cc (V8 source code, TurboFan). V8 source repository, main branch (GitHub mirror of chromium.googlesource.com/v8/v8); files © 2017. github.com/v8/v8/blob/main/src/compiler/escape-analysis.h (checked 2026-10-31)
- [2] The V8 project authors (2022). src/compiler/turboshaft/late-escape-analysis-reducer.h (V8 source code, Turboshaft). V8 source repository, main branch (GitHub mirror of chromium.googlesource.com/v8/v8); file © 2022. github.com/v8/v8/blob/main/src/compiler/turboshaft/late-escape-analysis-reducer.h (checked 2026-10-31)
- [3] Ulan Degenbaev, Michael Lippautz, Hannes Payer (2018). Concurrent marking in V8. V8 blog (v8.dev), 11 June 2018. v8.dev/blog/concurrent-marking (checked 2026-10-31)
- [4] Peter Marshall (2019). Trash talk: the Orinoco garbage collector. V8 blog (v8.dev), 3 January 2019. v8.dev/blog/trash-talk (checked 2026-10-31)
- [5] Darius Mercadier (2025). Land ahoy: leaving the Sea of Nodes. V8 blog (v8.dev), 25 March 2025. v8.dev/blog/leaving-the-sea-of-nodes (checked 2026-10-31)