Viscosity
Review
Part V · Inside real JavaScript engines · Day 17

66. Speculation and deoptimization

V8’s fastest code is built on guesses. Each guess is checked, and when one turns out wrong the engine quietly throws the fast code away and carries on in the interpreter, with the same answer.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • Explain what type feedback is and read it from real V8 output.
  • Describe how speculative code guards its assumptions.
  • Trace a real deoptimization, from the failed guard back to the interpreter.
  • Explain why feedback only widens, and how that prevents deoptimization loops.

What does + mean?

Here is the smallest function that keeps an engine busy:

function add(a, b) {
  return a + b;
}

+ adds numbers, joins strings, and calls valueOf on objects. When Ignition runs add, its bytecode has one generic Add, and every call has to work out which of those cases it is in. Your Drip VM’s ADD does a smaller version of this: two strings, or two numbers, checked on every single add.

But in a real program, add is almost always called the same way. If the engine could see that, it could compile a version that only adds integers. It would just need a way out for the day someone passes a string.

Type feedback

Intuition

A good barista remembers what you usually order. She isn’t told. She noticed, cup after cup. That memory is worth nothing on its own, but it lets her start your flat white the moment you walk in.

While unoptimized code runs, V8 keeps track of how the program behaves, including the shapes and types each operation sees[1]. It lives in a feedback vector attached to the function, with a slot for operations such as +. You can read that slot with %DebugPrint(add) after three phases: 20 calls with integers, one with 1.5, one with strings. Real output, Node v22.23.4 (V8 12.4):

$ node --allow-natives-syntax fb.js \
    | grep 'slot #0'
 - slot #0 BinaryOp BinaryOp:SignedSmall {
 - slot #0 BinaryOp BinaryOp:Number {
 - slot #0 BinaryOp BinaryOp:Any {

Small integers, then any number, then anything. Like elements kinds, the feedback only ever widens.

Definition

Type feedback is what unoptimized code records about the values it actually sees: the shapes at a property access, the kinds of operand at an Add, the functions called at a call site. The optimizing tiers read it to decide what to specialize for[1].

Speculation

Intuition

The barista starts your usual as you come through the door, but she still glances up to check it’s really you. If it’s your twin, she puts that cup aside and takes the order properly.

Once add is hot, the optimizing compiler builds code from the feedback. It doesn’t prove the feedback will stay true. It speculates, and puts a check in front of each guess. Maglev’s post describes the pattern for o.x: if feedback says o always had one shape, it emits a node that checks the shape at run time, then a cheap load at a fixed offset[1]. For arithmetic, TurboFan speculates that small integers stay small, using a checked add that deoptimizes if the result overflows[3].

Some guesses need no check in the code at all. A global that has never changed can be built into Maglev’s machine code as a constant. Maglev registers a dependency instead, and if the global is ever changed, the runtime invalidates and deoptimizes that code[1].

Step throughSpeculate, guard, bail out
function add(a, b) {
return a + b;
}
for (let i = 0; i < 1e5; i++)
add(i, 1);
add('a', 'b');
Two tiers, Ignition and TurboFan, with the feedback slot for + and the optimized code's guard between themIgnitionbytecode, generic Addrecords feedbackTurboFanoptimized machine codenone for addFEEDBACK FOR +none yetDEOPTS0OPTIMIZED add (A SKETCH)check a, b are Smis, else deoptr = a + b (integer add)check no overflow, else deoptreturn r
Every function starts in Ignition, the interpreter. Its bytecode for a + b is one generic Add, which must work for numbers, strings, objects, anything.
1/6
Definition

Speculation is compiling code that is only correct if what the feedback says stays true. Each assumption is protected, either by a guard (a run-time check such as CheckMaps) or by a dependency that invalidates the code when the assumption breaks. Past the guards, the code can skip every other case.

Deoptimization

Here is the real thing. Warm up add with 100 000 integer calls, then call add('a', 'b'). Real output, Node v22.23.4, add lines only, wrapped and shortened with …:

$ node --trace-opt --trace-deopt d2.js
[marking … <JSFunction add …> for
  optimization to TURBOFAN, …,
  reason: hot and stable]
[completed optimizing … add …
  (target TURBOFAN)]
[bailout (kind: deopt-eager,
  reason: not a Smi): begin.
  deoptimizing … add …,
  bytecode offset 2, …]

--trace-deopt-verbose adds where it happened and what the deoptimizer built:

;;; deoptimize at <…/d2.js:2:12>
…
translating interpreted frame add
  => bytecode_offset=2, …

Line 2, column 12 is the +. To get back into Ignition, V8 needs to know, at that exact point, where every interpreter register’s value lives in the optimized code: in a machine register, on the stack, or not computed at all. So optimized code carries that map with it. Maglev attaches the interpreter’s frame state to every node that can deoptimize, and the deoptimizer uses it to fill in a fresh interpreter frame; TurboFan shares the same mechanism[1].

Definition

Deoptimization leaves optimized code when an assumption fails, and continues the same call in unoptimized code. The deoptimizer reads the saved frame state for that point, rebuilds the interpreter frame from the optimized code’s registers and stack, and resumes at the same bytecode. The result is the same as if the code had never been optimized. Only speed changes.

After the bailout

A deopt also cleans up. The optimized code is no longer valid, so it is unlinked from the function, and the next call goes back to unoptimized code[2]. The failed call ran in Ignition, which updated the feedback to Any. If add gets hot again, the new code is compiled from that wider feedback, with no Smi guess to fail. In a longer run of the same idea, --trace-opt shows add marked and compiled for TurboFan a second time after the bailout.

Getting this wrong is expensive. Crankshaft, V8’s optimizing compiler before TurboFan, too often reoptimized a function with the same assumptions that had just failed, giving endless optimization–deoptimization loops[3].

Under the hood

The cheaper compiling is, the cheaper a wrong guess. Maglev compiles roughly 10× faster than TurboFan, so V8 can use it earlier: if the feedback wasn’t stable yet, deoptimizing and recompiling costs little[1]. (In Node 22, node --v8-options lists Maglev as off by default, which is why every trace here says TURBOFAN.) Viscosity has no such safety net. It compiles before the program runs, with no feedback and no deoptimizer, so it can’t guess and check. It must prove a change is safe, which is why Part VI is about analysis.

Your turn

ExerciseA speculative add with a deopt counter

Write makeAdd(). It returns a function add(a, b) that always returns a + b, plus a state object at add.st, starting as { mode: 'interp', feedback: 'none', hot: 0, deopts: 0 }. Model three tiers:

  • **'interp'**: record feedback. If both arguments are integers (Number.isInteger) and the feedback isn’t already 'any', it becomes 'int'; otherwise 'any'. Add 1 to hot. When hot reaches 3, optimize: mode becomes 'int' if the feedback is 'int', else 'generic'.
  • **'int'**: the guard. If both are integers, return a + b. Otherwise deopt: add 1 to deopts, set mode to 'interp' and hot to 0, then handle this same call as the interpreter would.
  • **'generic'**: just return a + b.
function makeAdd() {
  const st = {
    mode: 'interp',
    feedback: 'none',
    hot: 0,
    deopts: 0,
  };
  function add(a, b) {
    return a + b;
  }
  add.st = st;
  return add;
}
Extra practiceFeedback that only widens

Model the feedback slot for +. Write widen(fb, a, b), which returns the new feedback after a call a + b, given the old feedback fb. The states, from narrowest to widest, are 'None', 'SignedSmall', 'Number' and 'Any'. A call where both arguments are integers counts as 'SignedSmall', both numbers as 'Number', anything else as 'Any'. The result is the wider of the old state and the call’s. (V8’s real lattice has more states, for example for strings.)

function widen(fb, a, b) {
  return fb;
}

What you learned

  • Type feedback is what unoptimized code observes: shapes, operand types, call targets. In V8 12.4, the slot for + widened from SignedSmall to Number to Any.
  • Speculation compiles for the feedback seen so far. Each assumption has a guard or a dependency.
  • Deoptimization happens when a guard fails. The deoptimizer uses the saved frame state to rebuild an interpreter frame, and the call finishes in Ignition with the same result.
  • After a deopt the code is unlinked and the feedback is wider, so the next optimization doesn’t repeat the guess. Repeating it is a deoptimization loop.

Check your understanding

0/6
  1. 1.

    Where does the type feedback that TurboFan and Maglev use come from?

  2. 2.

    --trace-deopt prints bailout (kind: deopt-eager, reason: not a Smi) for add. What happened?

  3. 3.

    Why does optimized code need a frame state at each point that can deoptimize?

  4. 4.

    Maglev compiles about 10× faster than TurboFan. Why does that make speculation cheaper?

  5. 5.

    Why does add not deopt again on strings after it is optimized the second time?

  6. 6.

    Which statement about speculation is false?

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)
  2. [2] Juliana Franco (2017). An internship on laziness: lazy unlinking of deoptimized functions. V8 blog (v8.dev), 4 October 2017. v8.dev/blog/lazy-unlinking (checked 2026-10-31)
  3. [3] Darius Mercadier (2025). Land ahoy: leaving the Sea of Nodes. V8 blog (v8.dev), 25 March 2025. v8.dev/blog/leaving-the-sea-of-nodes (checked 2026-10-31)