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.
- 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?
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:
// 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:
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));
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.
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.
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);
}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:
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.
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:
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.
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:
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
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));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.
A SoA rewrite makes
ps[i]return{ x: ps.x[i] }, a fresh object. Which original behaviour breaks? - 2.
Why does
keep[0].x = 42reach the column whenkeep[0]is a handle? - 3.
A handle class saves
this.col = c.xin its constructor. What goes wrong? - 4.
What does a handle cache cost?
- 5.
viscosity explainsays “line 8: an element is compared or combined with "==="”. What did Viscosity do? - 6.
In this book’s benchmark run,
get-objectsdidn’t lose with SoA, butmaterializelost badly. Why?
References
- [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] 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)