Viscosity
Review
Part V · Inside real JavaScript engines · Day 17

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.

About 14 minutes, including the exercises and quiz
By the end you will be able to
  • 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:

materialize: keep one object per element
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 7.75 ns, p95 8.95 ns per elementarray of objects7.8struct of arrays: median 157.96 ns, p95 309.43 ns per elementstruct of arrays158.0 20.4× slower
Table view
layoutmedian nsp95 ns
array of objects7.758.95
struct of arrays157.96309.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.

Every object is stored and kept, so every allocation is real.
get-objects: pass each element to consume(p)
Median ns per element, n = 100,000 (shorter is faster)
array of objects: median 6.15 ns, p95 6.75 ns per elementarray of objects6.1struct of arrays: median 1.90 ns, p95 2.29 ns per elementstruct of arrays1.9 3.2× faster
Table view
layoutmedian nsp95 ns
array of objects6.156.75
struct of arrays1.902.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 object is only read inside consume. SoA didn't lose at all.

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

Intuition

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:

Step throughRemoving a temporary object
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] });
The temporary object p with fields x and y, its uses, and the allocations per iterationTHE OBJECT pheap objectx: xs[i]y: ys[i]USES OF ppassed to len2(p)ALLOCATIONS1 per iteration
Each iteration builds a fresh object and passes it to len2. Seen from the loop alone, the object escapes into a call: the compiler can’t know what len2 does with it.
1/6
Definition

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.

Under the hood

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

ExerciseScalar-replace a small object

A tiny IR is a list of instructions:

  • { op: 'new', dst, fields }: dst = { ... }, where fields maps 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;
}
Extra practiceRebuild the object on deopt

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-objects didn’t lose for SoA because V8 inlined the consumer and then removed the temporary object. materialize lost 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 len2 loop 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. 1.

    Which order of steps lets TurboFan remove the object in t += len2({ x: xs[i], y: ys[i] })?

  2. 2.

    After scalar replacement, where do p.x and p.y live?

  3. 3.

    A guard fails inside the optimized loop, after p was removed. What happens?

  4. 4.

    In this book’s run, materialize was about 20–35× slower for SoA, while get-objects wasn’t slower at all. Why?

  5. 5.

    In V8 12.4 (Node 22), which statement matches node --v8-options?

  6. 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. [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. [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. [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. [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. [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)