Viscosity
Review
Part VI · The compiler middle-end · Day 22

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.

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

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.

The objects are stored in a result array, so every one must really exist.
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.

consume only reads fields. V8 inlined it and removed the temporary object.

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.

Intuition

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
}
Definition

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:

Step throughTracing uses to an escape site
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]);
The uses of ps and of its elements, each marked safe, follow or escapeUSEVERDICTps.push({...})push…ps.lengthlength…ps[i]index.get…ps[i].x (read, write)field…ps[i].vxfield.get…ps[3]index.get…console.log(ps[3])call…no verdict yet
Start with the array. Viscosity collects every use of ps (its def-use list) and checks them one by one. Nothing is decided yet.
1/8

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.

Try itWhy === is an escape site
// 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:

PlaygroundFind the escape
Under the hood

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

ExerciseClassify the uses

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 },
]));
Extra practiceFollow it into functions

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. 1.

    Which use of an element p = ps[i] is not an escape site for Viscosity?

  2. 2.

    An analysis says an object “does not escape”, but in fact it does. What can go wrong?

  3. 3.

    Why is ps[i] === target treated as an escape?

  4. 4.

    step(ps) is the only call to a function step(qs) declared once. What does Viscosity do with qs?

  5. 5.

    Why did SoA not lose in the get-objects benchmark, while it lost badly in materialize?

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