Viscosity
Review
Part VII · Building Viscosity · Day 27

106. Identity and materialization

Once elements become slots in columns, an element that escapes needs an object again. Here is what it would take to fake one honestly, and why Viscosity says no instead.

About 16 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why materializing a fresh object per access breaks aliasing and identity.
  • Design a handle that keeps aliasing correct, and a handle cache that keeps === correct.
  • List what a handle still gets wrong, and what each fix costs.
  • Explain why Viscosity refuses collections whose elements escape, using its real explanations and this book’s measurements.

Two names, one particle

Here is aliasing_identity.js from Viscosity’s test corpus:

const ps = [];
for (let i = 0; i < 100; i++)
  ps.push({ x: i });
const keep = [];
for (let i = 0; i < ps.length; i++)
  keep.push(ps[i]);
keep[0].x = 42;
console.log(ps[0].x, keep[0] === ps[0]);

It prints 42 true. keep[0] and ps[0] are two references to one object (chapter 4), so a write through one is seen through the other, and === says they are the same[1]. Now imagine ps as columns. There is no object for keep to hold. What could the compiler put there?

Intuition

A hotel stores luggage in numbered cubbies, not in the guests’ rooms. A guest who wants their bag can get a copy of everything in it (but then changes don’t reach the cubby), or a claim ticket that says “cubby 0”. Tickets work, as long as everyone holding the same bag holds the same ticket.

Copies break aliasing

The easy answer is chapter 23’s materialization: build a fresh object from the columns every time the program asks for ps[i]. Run both versions:

Try itReal objects vs copies
// Original: real objects.
const ps = [];
for (let i = 0; i < 4; i++)
  ps.push({ x: i });
const k = [ps[0]];
k[0].x = 42;
console.log(ps[0].x, k[0] === ps[0]);

// SoA, materializing copies.
const s = { x: [0, 1, 2, 3] };
const get = (i) => ({ x: s.x[i] });
const k2 = [get(0)];
k2[0].x = 42;
console.log(s.x[0], k2[0] === get(0));

The copy is a snapshot. The write k2[0].x = 42 changes the snapshot, not the column, so the next read sees 0. And every call makes a new object, so === is false. The program can tell the layout changed, twice.

Handles

The fix for aliasing is to hand out something that refers to the element instead of copying it. Step through it:

Step throughA handle for one element
const h1 = at(0);
keep.push(h1);
keep[0].x = 42;
console.log(ps[0].x);
const h2 = at(0);
console.log(h1 === h2);
console.log(Object.keys(h1));
A struct-of-arrays container, a handle object pointing at index 0, and the identity checkps (container)x5678
After the SoA rewrite, ps is a container of columns. Element 0 isn’t an object any more: it is slot 0 of each column. Here is the x column.
1/8
class H {
  constructor(c, i) {
    this.c = c;
    this.i = i;
  }
  get x() { return this.c.x[this.i]; }
  set x(v) { this.c.x[this.i] = v; }
}

get x() and set x(v) are accessors: reading h.x calls the first, and assigning h.x = v calls the second. So every read and write goes to the column, and the value lives in one place.

Definition

A handle is a small object that stands for one element of a collection. It holds the collection and an index, and its fields are accessors that read and write the columns. Handles keep aliasing correct: writes through any handle are seen by every other path to the element.

Notice what the handle holds: the container c, not the column c.x. Chapter 105’s grow() replaces each column with a bigger one. A handle that had saved the old column would keep reading an array that nothing writes to any more.

Keeping identity

Two handles for element 0 are still two objects. To make === behave, the container must hand out the same handle every time, so it keeps a cache: one slot per index, filled the first time that index is asked for.

Try itCached handles
class H {
  constructor(c, i) {
    this.c = c;
    this.i = i;
  }
  get x() { return this.c.x[this.i]; }
  set x(v) { this.c.x[this.i] = v; }
}
const c = { x: [0, 1, 2, 3], hs: [] };
function at(i) {
  let h = c.hs[i];
  if (!h) h = c.hs[i] = new H(c, i);
  return h;
}
const k = [at(0)];
k[0].x = 42;
console.log(c.x[0], k[0] === at(0));
console.log(Object.keys(at(0)));
try {
  JSON.stringify(at(0));
} catch (e) {
  console.log(e.name);
}
Definition

Identity preservation means keeping === and aliasing exactly as the original program had them after a layout change. For handles that takes a cache with at most one handle per element, so that asking for the same element twice gives the same object.

The run also shows what is still wrong. Object.keys sees c and i, not x. JSON.stringify doesn’t print {"x":42}: here it throws, because the handle points at the container, which points back at its cache. Spreading ({ ...h }), console.log and the object’s prototype all differ too. A real handle mode would have to emulate each one, with a different handle class for every record shape.

What Viscosity actually does

Viscosity has no handle mode. When an element escapes, it keeps the array of objects and says why. Here is viscosity explain on the program above:

ps (line 1) {x} → kept as array of objects
  · unsafe: line 8: an element is compared
    or combined with "==="
  · unsafe: line 6: an element is used by
    push

(The real lines are longer; they are wrapped here.) Viscosity does use one handle, a very small one: when you write const p = ps[i] and only touch p.x, the compiled code says const p = i. The index is the handle. That is only safe because p never escapes and is never compared, which is exactly what the safety analysis proved.

Why refuse instead of emulating? Look at the cost of the snapshot route, the cheapest kind of object to rebuild:

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.

Building and keeping a real object per element: about 20 times slower than AoS here.

In this book’s benchmark run, SoA was about 20 times slower than AoS at 100,000 elements, and about 33 times at a million. A handle cache would bring back one object per element handed out, plus an accessor call per field. Nobody has measured a handle mode in this repository, so we can’t quote its numbers, but it starts from the same kind of allocation.

The other suite is the exception that proves the rule:

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(p) only reads fields. V8 inlined it and removed the temporary object.

Here the object didn’t escape consume, so V8 inlined it and replaced the object with its fields (scalar replacement, chapter 67). An object that doesn’t escape costs nothing. One that escapes costs a lot. So Viscosity lets only the first kind through, and turns the second kind into a message.

Try it. This is the corpus program escapes_log.js: printing a whole element makes it escape. Change the last line to console.log(ps[3].x, ps[3].y) and watch the verdict change:

PlaygroundAn element escapes
Under the hood

V8 can afford to be optimistic. It compiles while the program runs, from what it has seen so far, and can deoptimize back to slower code if a guess turns out wrong[2]. That is how its escape analysis removed the object in get-objects. Viscosity compiles before the program runs and has no slow path to fall back to, so a handle mode would have to be right about every ===, every key and every printout, forever. A refusal is always correct, and viscosity explain names the line to change. Viscosity chose the simple rule it can prove.

Your turn

ExerciseA handle cache that preserves identity

A container c has a column c.x and an empty array c.hs. The class H is a handle. Write at(c, i) so that it returns a handle for element i, and returns the same handle every time it is asked for the same element of the same container. Writes through a handle must reach the column.

class H {
  constructor(c, i) {
    this.c = c;
    this.i = i;
  }
  get x() { return this.c.x[this.i]; }
  set x(v) { this.c.x[this.i] = v; }
}

function at(c, i) {
  // your code here
  return new H(c, i);
}

const c = { x: [5, 6, 7], hs: [] };
console.log(at(c, 0) === at(c, 0));
Extra practiceHandles on demand

Most elements may never be handed out. Write at(c, i) with a Map cache in c.hs (so untouched indexes cost nothing), and count every handle you create in c.made.

class H {
  constructor(c, i) {
    this.c = c;
    this.i = i;
  }
  get x() { return this.c.x[this.i]; }
  set x(v) { this.c.x[this.i] = v; }
}

function at(c, i) {
  // your code here
}

const c = {
  x: [1, 2, 3], hs: new Map(), made: 0,
};
at(c, 0); at(c, 0); at(c, 2);
console.log(c.made);

What you learned

  • Materializing a fresh copy per access breaks both aliasing (writes hit the copy) and identity (=== is false).
  • A handle holds the collection and an index, with accessors that use the columns. It keeps aliasing correct.
  • Identity preservation needs a cache with one handle per element, which brings back an object per element.
  • Handles still differ in keys, JSON, spreading and printing.
  • Viscosity has no handle mode: it refuses collections whose elements escape, and explains why. Its only “handle” is a bare index, used when nothing escapes.

Check your understanding

0/6
  1. 1.

    A SoA rewrite makes ps[i] return { x: ps.x[i] }, a fresh object. Which original behaviour breaks?

  2. 2.

    Why does keep[0].x = 42 reach the column when keep[0] is a handle?

  3. 3.

    A handle class saves this.col = c.x in its constructor. What goes wrong?

  4. 4.

    What does a handle cache cost?

  5. 5.

    viscosity explain says “line 8: an element is compared or combined with "==="”. What did Viscosity do?

  6. 6.

    In this book’s benchmark run, get-objects didn’t lose with SoA, but materialize lost badly. Why?

References

  1. [1] MDN contributors. Equality comparisons and sameness. MDN Web Docs. developer.mozilla.org/en-US/docs/Web/JavaScript/Equality_comparisons_and_sameness (checked 2026-10-31)
  2. [2] 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)