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.
- 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
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.
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
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].
function add(a, b) {return a + b;}for (let i = 0; i < 1e5; i++)add(i, 1);add('a', 'b');
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].
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].
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
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 tohot. Whenhotreaches 3, optimize: mode becomes'int'if the feedback is'int', else'generic'. - **
'int'**: the guard. If both are integers, returna + b. Otherwise deopt: add 1 todeopts, set mode to'interp'andhotto 0, then handle this same call as the interpreter would. - **
'generic'**: just returna + 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;
}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 fromSignedSmalltoNumbertoAny. - 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.
Where does the type feedback that TurboFan and Maglev use come from?
- 2.
--trace-deoptprintsbailout (kind: deopt-eager, reason: not a Smi)foradd. What happened? - 3.
Why does optimized code need a frame state at each point that can deoptimize?
- 4.
Maglev compiles about 10× faster than TurboFan. Why does that make speculation cheaper?
- 5.
Why does
addnot deopt again on strings after it is optimized the second time? - 6.
Which statement about speculation is false?
References
- [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] 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] 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)