public/js/stations/engine.js
// Station 4: inside V8 (§49, §81). The tier and shape diagrams are models
// (V8's real thresholds depend on feedback and budgets and change between
// versions); the benchmark at the bottom is real.
import { h, station, panel, button, ms } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";
const TIERS = [
["parse", "Parser", "source → AST", "lazy: inner functions are only pre-parsed until first called"],
["ignition", "Ignition", "interpreter", "runs compact bytecode and collects type feedback"],
["sparkplug", "Sparkplug", "baseline JIT", "bytecode → machine code with no optimization; very fast to compile"],
["maglev", "Maglev", "mid-tier JIT", "quick optimizing compiler (Chrome 117+) that uses the feedback"],
["turbofan", "TurboFan", "top-tier JIT", "speculative, heavily optimized code for the hottest functions"],
];
// Illustrative call counts for the animation only.
const STEPS = [[0, "ignition"], [500, "sparkplug"], [5000, "maglev"], [50000, "turbofan"]];
const BYTECODE = `// node --print-bytecode --print-bytecode-filter=add (V8 12.4)
function add(a, b) { return a + b; }
[generated bytecode for function: add]
Parameter count 3 // a, b, and the receiver (this)
Register count 0
21 S> @ 0 : 0b 04 Ldar a1 // accumulator = b
30 E> @ 2 : 3b 03 00 Add a0, [0] // accumulator = a + accumulator; [0] = feedback slot
34 S> @ 5 : ae Return // return the accumulator`;
function tiersPanel() {
let calls = 0;
let tier = "ignition";
let deopts = 0;
const add = (a, b) => a + b; // a real function, really called
const tierEls = TIERS.map(([id, name, short, desc]) => h("div", { class: "tier", "data-tier": id, title: desc }, name, h("small", {}, short)));
const heat = h("span");
const status = h("p", { "aria-live": "polite" });
const render = (msg) => {
tierEls.forEach((t) => t.classList.toggle("on", t.dataset.tier === tier));
heat.style.width = `${Math.min(100, (Math.log10(calls + 1) / Math.log10(60000)) * 100)}%`;
status.textContent = msg ?? `add() called ${calls.toLocaleString()} times · running in ${TIERS.find((t) => t[0] === tier)[1]} · deopts: ${deopts}`;
};
const call = (n) => {
let s = 0;
for (let i = 0; i < n; i++) s = add(s, 1);
calls += n;
tier = STEPS.filter(([c]) => calls >= c).at(-1)[1];
render();
};
const deopt = () => {
add("oops", 1); // a string where feedback said "always small integers"
const was = tier;
if (was === "maglev" || was === "turbofan") {
deopts++;
tier = "ignition";
calls = 0;
render(`💥 Deoptimized! ${TIERS.find((t) => t[0] === was)[1]}'s code assumed numbers; a string broke the assumption, so V8 threw that code away and went back to Ignition. It can re-optimize later with wider feedback.`);
} else render("Called with a string. Still in an unoptimized tier, so nothing to throw away: the feedback just gets more general.");
};
render();
return panel("From source to machine code",
h("div", { class: "tiers" }, tierEls.flatMap((t, i) => (i ? [h("span", { class: "arrow" }, "→"), t] : [t]))),
h("div", { class: "heat", "aria-hidden": "true" }, heat),
status,
h("div", { class: "row" }, button("Call add() ×1,000", () => call(1000)), button("×10,000", () => call(10000)), button("×100,000", () => call(100000), { class: "primary" }), button("Call add('oops', 1)", deopt, { class: "danger" })),
h("p", { class: "note" }, "Tier-up thresholds here are illustrative. Try it for real: node --trace-opt --trace-deopt file.js prints every optimization and bailout."),
h("pre", { class: "code" }, BYTECODE.split("\n").map((l) => h("span", {}, l))));
}
function shapesPanel() {
// A transition tree: each node is a hidden class (V8 calls them Maps).
const root = { props: [], children: new Map(), count: 0, id: 0 };
let nextId = 1;
const dictionary = { count: 0 };
const seenByIC = new Set();
const tree = h("div", { class: "shapes" });
const ic = h("p", { "aria-live": "polite" });
const shapeFor = (props) => {
let node = root;
for (const p of props) {
if (!node.children.has(p)) node.children.set(p, { props: [...node.props, p], children: new Map(), count: 0, id: nextId++ });
node = node.children.get(p);
}
return node;
};
const icState = (n) => (n === 0 ? "uninitialized" : n === 1 ? "monomorphic" : n <= 4 ? "polymorphic" : "megamorphic");
const draw = () => {
const walk = (node) =>
h("li", {},
h("span", { class: "shape" }, `Map${node.id} {${node.props.join(", ")}}`, node.count ? h("span", { class: "count" }, `×${node.count}`) : null),
node.children.size ? h("ul", {}, [...node.children.values()].map(walk)) : null);
tree.replaceChildren(h("ul", {}, walk(root)),
dictionary.count ? h("p", {}, h("span", { class: "shape" }, "dictionary mode (hash table) ", h("span", { class: "count" }, `×${dictionary.count}`))) : "");
const state = icState(seenByIC.size);
ic.replaceChildren("Inline cache at ", h("code", {}, "getX(obj)"), ` has seen ${seenByIC.size} shape(s): `, h("span", { class: `ic ${state}` }, state));
const shapes = [];
const collect = (n) => (n.count && shapes.push(n), n.children.forEach(collect));
collect(root);
if (shapes.length >= 2) missions.complete("engine", "shapes");
if (state === "megamorphic") missions.complete("engine", "megamorphic");
};
const make = (props, label) => () => {
const node = shapeFor(props);
node.count++;
if (props.includes("x")) seenByIC.add(node.id);
draw();
log.prepend(h("li", {}, label));
};
const log = h("ol", { class: "log", "aria-label": "Objects created" });
let extra = 0;
draw();
return panel("Hidden classes and inline caches",
h("p", {}, "V8 gives every object a hidden class describing its layout. Objects built the same way share one, so property access compiles to a fixed-offset load. The order you add properties matters."),
h("div", { class: "row" },
button("{ x, y }", make(["x", "y"], "const o = { x: 1, y: 2 }")),
button("{ y, x }", make(["y", "x"], "const o = { y: 2, x: 1 } // different order → different Map")),
button("{ x } then o.y = …", make(["x", "y"], "const o = { x: 1 }; o.y = 2 // same transitions as { x, y }")),
button("{ x, y, z }", make(["x", "y", "z"], "const o = { x, y, z }")),
button("random extra prop", () => make([`p${++extra}`, "x"], `const o = { p${extra}: 0, x: 1 } // yet another shape`)()),
button("delete o.x", () => {
dictionary.count++;
draw();
log.prepend(h("li", {}, "delete o.x // the object drops to slow dictionary mode"));
}, { class: "danger" })),
h("div", { class: "grid-2" }, tree, h("div", {}, ic, log)),
h("p", { class: "note" }, "Verify in Node: node --allow-natives-syntax -e \"const a={x:1};a.y=2;const b={y:2};b.x=1;console.log(%HaveSameMap(a,b))\" prints false."));
}
// Two identical function bodies, so each keeps its own type feedback.
function sumMono(objs, n) {
let s = 0;
for (let i = 0; i < n; i++) s += objs[i & (objs.length - 1)].x;
return s;
}
function sumMega(objs, n) {
let s = 0;
for (let i = 0; i < n; i++) s += objs[i & (objs.length - 1)].x;
return s;
}
function benchPanel() {
const out = h("div", { class: "readouts" });
const run = () => {
const N = 20_000_000;
const same = Array.from({ length: 8 }, () => ({ x: 1, y: 2 }));
const mixed = Array.from({ length: 8 }, (_, i) => {
const o = {};
o[`p${i}`] = 0; // a different first property → 8 different hidden classes
o.x = 1;
return o;
});
sumMono(same, 10_000); // warm up both
sumMega(mixed, 10_000);
let t = performance.now();
sumMono(same, N);
const mono = performance.now() - t;
t = performance.now();
sumMega(mixed, N);
const mega = performance.now() - t;
out.replaceChildren(
h("div", { class: "readout" }, h("small", {}, "1 shape (monomorphic)"), h("b", {}, ms(mono))),
h("div", { class: "readout" }, h("small", {}, "8 shapes (megamorphic)"), h("b", {}, ms(mega))),
h("div", { class: "readout" }, h("small", {}, "slowdown"), h("b", {}, `${(mega / mono).toFixed(1)}×`)));
};
return panel("Benchmark: the cost of megamorphic access (real)",
h("p", {}, "Same loop, 20 million reads of .x. Left: 8 objects with one hidden class. Right: 8 objects with 8 different hidden classes."),
button("Run benchmark (blocks ~1s)", run, { class: "primary" }), out,
h("p", { class: "note" }, "Microbenchmarks lie a little: run it a few times. The lesson holds: hot code that sees many object shapes can't use a fast inline cache."));
}
function timeline() {
const items = [
["2017", "TurboFan + Ignition replace Crankshaft/Full-codegen in V8 5.9"],
["2018", "Site Isolation ships in Chrome after Spectre; SharedArrayBuffer gated behind cross-origin isolation (§39, §51)"],
["2019", "await gets faster: awaiting a native promise takes 1 microtask tick instead of 3 (spec change + V8 7.2)"],
["2021", "Sparkplug baseline compiler (Chrome 91)"],
["2023", "Maglev mid-tier compiler (Chrome 117); Turboshaft begins replacing TurboFan's backend"],
["2024", "INP replaces FID as a Core Web Vital; Long Animation Frames API (Chrome 123); scheduler.yield() (Chrome 129)"],
["2025", "Iterator helpers, Set methods, Promise.try, RegExp.escape, Float16Array in ES2025"],
["2025–26", "Temporal ships (Firefox 139, Chrome 144) and using/await using ships (Chrome 134, Firefox 141); both finished, slated for ES2027"],
["2026", "ES2026: Array.fromAsync, Error.isError, Math.sumPrecise, Uint8Array base64/hex, Iterator.concat, Map getOrInsert"],
];
return panel("Recent engine and event-loop milestones",
h("table", {}, h("tbody", {}, items.map(([y, t]) => h("tr", {}, h("th", {}, y), h("td", {}, t))))));
}
export function mount() {
return {
el: station(
"Inside V8",
"JavaScript is compiled at runtime, several times. V8 starts fast with an interpreter, watches what types actually flow through your code, and recompiles the hot parts with assumptions built in. When an assumption breaks, it bails out.",
missionList("engine"),
tiersPanel(),
shapesPanel(),
benchPanel(),
timeline(),
),
};
}