85. Escape analysis
Viscosity may only take your objects apart if nobody can notice. Escape analysis is how it checks: follow every use of every element, and stop at the first one that lets the object out.
- Explain what it means for a reference to escape a region of code.
- List the escape sites Viscosity checks for, and say why each one blocks a layout change.
- Trace a value's uses, and the uses of the values it hands out, to find an escape.
- Contrast V8's run-time escape analysis with Viscosity's ahead-of-time one.
Two suites, two verdicts
Chapter 23 ended on a puzzle. Two benchmark suites both turn struct of arrays back into objects, one per element. In this book’s benchmark run, one of them lost badly and the other didn’t lose at all:
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 only difference is what happens to the object afterwards. In
materialize it is stored in an array that outlives the loop. In
get-objects, consume(p) reads six fields and drops it. Chapter 67
showed V8 noticing that and replacing the object with its fields
(scalar replacement).
Viscosity faces the same question from the other side. Before it turns
ps into columns, it must be sure no part of the program can tell.
A reference library lets you read any book at the desk. Rearranging the shelves overnight is harmless, as long as every book is still inside. Lend one out and things change: someone at home now holds a book with a shelf mark on it, and the shelves have to stay as they were.
Escaping
Picture a value as having an inside and an outside. Inside is the code the compiler is looking at and can rewrite. A value escapes when a reference to it reaches somewhere outside: a function the compiler can’t see into, a variable or property that someone else reads, the caller.
function f(ps) {
const p = ps[0];
let s = p.x + p.vx; // stays inside
log(p); // escapes: a call
return p; // escapes: a return
}
Escape analysis decides whether a reference can leave a region of code. If it can’t, the compiler controls every use of the object and may change how it is stored, or not store it at all. The places where a value can leave are its escape sites: passing it to a call, storing it into a variable, property or array, and returning it. The analysis is conservative: “might escape” counts as “escapes”.
Tracing the uses
Viscosity works on its SSA IR, where every value has a list of the
instructions that use it. To check a collection it starts from every load
of the array and walks those lists. Some uses are fine because Viscosity
knows how to rewrite them: push of a fresh object literal, .length,
and ps[i]. But ps[i] produces a new value, the element, so its uses
must be walked too. Step through it:
const ps = [];for (let i = 0; i < 100; i++)ps.push({ x: i, vx: 1 });for (let i = 0; i < ps.length; i++)ps[i].x += ps[i].vx;console.log(ps[3]);
This is a worklist again, like the dataflow chapters: values to check go
in, and checking one can add more. Here is the element part, from
packages/core/src/analysis.ts, simplified:
for (const u of usesOf(el)) {
const i = u.inst;
const onEl = i && i.args[0] === el;
if (onEl && i.op === 'field.get') {
accesses.push(read(i));
continue;
}
if (onEl && i.op === 'field.set') {
accesses.push(write(i));
continue;
}
bailouts.push(
'an element is ' + describeUse(u));
}
Notice onEl. ps[i].x = 5 and box.last = ps[i] are both field.set.
In the first, the element is the object being written to, which is fine.
In the second, it is the value being stored, so it escapes into box.
Naming the escape site
A bailout that only said “unsafe” would be useless. describeUse turns
each escaping use into a reason a person can act on. Simplified, with the
line numbers left out:
switch (i.op) {
case 'call':
return 'passed to a function call';
case 'storeBinding':
return 'stored in another variable';
case 'field.set':
return 'stored into an object property';
case 'index.set':
return 'stored into an array';
case 'record.new':
return 'put inside another object';
case 'binop':
return 'compared or combined';
case 'opaque':
return 'used by code Viscosity ' +
"doesn't model";
case 'phi':
return 'merged with other values';
}
Returns don’t appear here, because a return is a block terminator rather
than an instruction; it gets its own message, “returned or branched on”.
Here is what viscosity explain really prints for three small changes to
the program in the diagram:
// return ps[0] from a function
· unsafe: line 5: an element is
returned or branched on in first()
// box.last = ps[99];
· unsafe: line 7: an element is stored
into an object property
// console.log(ps[1] === ps[2]);
· unsafe: line 6: an element is compared
or combined with "==="
The comparison is the subtle one. Nothing is stored anywhere, but ===
asks about identity, and after the rewrite there are no element objects
to be identical. Two more sites are there for safety. opaque is the IR’s
name for anything Viscosity doesn’t model, so every argument must be
assumed to escape. A phi merges an element with other values, as in
best = ps[i] inside an if. Viscosity doesn’t track what flows out of
a merge, so it counts the merge itself as an escape.
// Array of objects: real objects.
const aos = [{ x: 1 }, { x: 2 }];
const a = aos[0];
console.log(a === aos[0]);
// SoA: rebuilding gives a NEW object.
const soa = { x: [1, 2] };
function get(i) {
return { x: soa.x[i] };
}
console.log(get(0) === get(0));
// Same data, a different answer. A
// program that compares elements can
// tell the layout changed.Calls that aren’t escapes
A call is usually an escape, but not always. If step(qs) is a function
declared once and only ever called directly, Viscosity knows every call
site. When all of them pass ps, the parameter qs is ps, so it
joins the worklist and its uses are checked inside step:
ps (line 1) {x, vx} → struct of arrays
· loop at line 5 in step() touches
2 of 2 fields (x, vx)
That is points-to reasoning from chapter 84 on a small scale: qs points
to exactly one collection. Passing an element to a function, as in
move(ps[i]), is still reported as an escape today. Try it, and then try
removing the escape:
V8 and Viscosity ask the same question at different times. V8 compiles
optimized code while the program runs, specialized for what it has seen,
and can deoptimize if a guess turns out wrong[1]. In
this book’s run it inlined consume(p), after which p was used only by
field reads, and the temporary object was removed. That is why
get-objects didn’t lose. Nothing can do the same for materialize,
because its objects really are stored and kept. Viscosity runs before the
program does, has no deoptimization to fall back on, and doesn’t inline.
So it must prove safety on the code as written, which makes it stricter
than V8: move(ps[i]) keeps the array of objects.
Your turn
An element called el has a def-use list uses. Each use is { op, args, line }, where args are value names. Write firstEscape(el, uses). A field.get or field.set whose first argument is el is safe (it reads or writes the element). Every other use is an escape. Return op + ' at line ' + line for the first escaping use, or null if none escapes.
function firstEscape(el, uses) {
// your code here
return null;
}
console.log(firstEscape('e', [
{ op: 'field.get',
args: ['e'], line: 4 },
{ op: 'call',
args: ['log', 'e'], line: 6 },
]));uses maps a value name to its list of uses, each { op } or { op: 'call', to }. field, length and index uses are safe. A call with a to passes the value to a known function whose parameter is named to: follow the parameter’s uses instead. A call without to, or any other op, escapes. Write escapesVia(v, uses) returning true if v escapes. Functions may call themselves, so don’t loop forever.
function escapesVia(v, uses) {
// your code here
}
const uses = {
ps: [{ op: 'length' },
{ op: 'call', to: 'qs' }],
qs: [{ op: 'field' }],
};
console.log(escapesVia('ps', uses));What you learned
- Escape analysis decides whether a reference can leave a region of code. If it can’t, the compiler may change how the object is stored.
- The escape sites are calls, stores into variables, properties,
arrays or other objects, and returns. Viscosity also counts identity
comparisons, unmodelled (
opaque) operations and phis. - Checking is a worklist over def-use lists: an element produced by
ps[i]adds its own uses to the list. - Position matters: an element being written to is safe; an element being stored escapes.
- A known function whose every call passes the same collection is followed, not counted as an escape.
- V8 escapes less because it inlines first and can deoptimize. Viscosity must prove safety ahead of time, so it is more conservative.
Check your understanding
0/6- 1.
Which use of an element
p = ps[i]is not an escape site for Viscosity? - 2.
An analysis says an object “does not escape”, but in fact it does. What can go wrong?
- 3.
Why is
ps[i] === targettreated as an escape? - 4.
step(ps)is the only call to a functionstep(qs)declared once. What does Viscosity do withqs? - 5.
Why did SoA not lose in the
get-objectsbenchmark, while it lost badly inmaterialize? - 6.
Viscosity is stricter than V8 here:
move(ps[i])keeps the array of objects. Why can’t it simply do what V8 does?
References
- [1] Toon Verwaest, Leszek Swirski, Victor Gomes, Olivier Flückiger, Darius Mercadier, Camillo Bruni (2023). Maglev - V8’s Fastest Optimizing JIT. V8 blog (v8.dev), 5 December 2023. v8.dev/blog/maglev (checked 2026-10-31)