Skip to content

Speculative Optimization and Feedback Vectors

advanced8 min read

The Compiler That Gambles

Here's something wild: V8's optimizing compiler is basically a gambler. It watches your code, picks up patterns, and then bets that the future looks like the past. Here's a function it can make extremely fast:

function multiply(a, b) {
  return a * b;
}

// Called 10,000 times with integers
for (let i = 0; i < 10000; i++) {
  multiply(i, i + 1);
}

And here's the same function that V8 struggles with:

function multiply(a, b) {
  return a * b;
}

// Called with integers, then suddenly with strings
for (let i = 0; i < 10000; i++) multiply(i, i + 1);
multiply("3", "4"); // "34" — string concatenation, not 12

That single string call can cause V8 to throw away the optimized machine code for multiply and start over. Just one call. The reason lies in how TurboFan decides what optimizations to apply.

The Feedback Vector: V8's Spy Network

Mental Model

Think of TurboFan as an architect redesigning a busy intersection. Instead of guessing traffic patterns, it installs cameras (feedback vectors) at every junction. After watching for a while, it sees that 99.9% of traffic turns right. So it builds a highway-grade right-turn lane — no traffic lights, no stopping. But it also installs a sensor: if a vehicle ever tries to go straight, the highway ramp collapses and traffic reroutes to the original slow intersection. The cameras are feedback vectors. The highway is optimized code. The collapse is deoptimization.

So let's see how this works. Every function in V8 has a FeedbackVector -- basically an array of slots, one per "interesting" bytecode operation. These slots quietly record type information as the function executes in Ignition:

function process(a, b) {
  const sum = a + b;     // FeedbackSlot #0: records types of a, b, and result
  const str = sum + "";  // FeedbackSlot #1: records types of sum and result
  return str.length;     // FeedbackSlot #2: records Map of str
}
Execution Trace
Call 1
process(1, 2)
Slot #0: Smi + Smi -> Smi. Slot #1: Smi + String -> String. Slot #2: Map = String
Call 2
process(3, 4)
Slot #0: still Smi + Smi -> Smi. Feedback is consistent.
Call 100
process(99, 100)
All slots still show consistent types. V8 marks function as hot.
Optimize
TurboFan reads FeedbackVector
Slot #0 says 'always integers' -> emit native integer add
Compiled
Machine code with type guards
If types match speculation -> fast path. If not -> deoptimize

What Feedback Slots Record

You might be wondering -- what exactly do these slots track? Different operations record different kinds of feedback:

OperationWhat's RecordedExample
a + bOperand types (Smi, HeapNumber, String, BigInt)"Always Smi + Smi -> Smi"
obj.xHidden class (Map) of obj, offset of x"Always Map_0x1234, offset 16"
fn(args)Call target, argument count"Always calls function at 0x5678"
a < bComparison types"Always Smi compared to Smi"
for...ofIterator protocol Map"Always Array iterator"

Here's a key insight that surprises most people: feedback is collected per call site, not per function. Two different places in your code that call the same function have independent feedback:

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

// Call site 1: feedback says "integers"
for (let i = 0; i < 1000; i++) add(i, i);

// Call site 2: feedback says "strings"
for (let i = 0; i < 1000; i++) add("hello", String(i));

Both sites call add, but the feedback for call site 1 is "integers" and call site 2 is "strings." When TurboFan optimizes add, it uses the feedback from inside the function body — not the call sites.

How TurboFan Uses Feedback

Alright, so TurboFan has all this feedback. What does it actually do with it? When it decides to optimize a function, it:

  1. Reads every FeedbackSlot to understand what types have been observed
  2. Builds a graph (the "sea of nodes" IR) with speculative type information
  3. Inserts type guards at every point where speculation could be wrong
  4. Applies optimizations based on the speculated types
  5. Emits machine code with embedded deoptimization bailout points

For function add(a, b) { return a + b; } where feedback says "Smi + Smi":

// TurboFan's optimized output (pseudocode):
add_optimized(a, b):
  // Type guard: check a is Smi
  if (!IsSmi(a)) -> Deoptimize(reason: "not a Smi")
  // Type guard: check b is Smi
  if (!IsSmi(b)) -> Deoptimize(reason: "not a Smi")
  // Speculative fast path: native integer addition
  result = SmiAdd(a, b)
  // Overflow check (Smi has limited range)
  if (overflow) -> Deoptimize(reason: "Smi overflow")
  return result

No type dispatch, no boxing, no function calls — just raw integer addition with safety checks. This is within 2x of equivalent C code.

The sea-of-nodes intermediate representation

TurboFan doesn't use a traditional sequential IR like LLVM. It uses a sea-of-nodes representation where every operation is a node in a graph, and dependencies are edges. There's no fixed ordering except where data or control flow demands it.

This representation enables aggressive reordering, dead code elimination, and redundancy elimination. For example, if two branches both load obj.x, and obj has the same Map in both branches, TurboFan can hoist the load above the branch.

The sea-of-nodes also makes it natural to represent speculative optimizations: a speculative operation is a node that produces a value AND a potential deoptimization exit. If the speculation is proven unnecessary (e.g., by a dominating type check), the exit is removed.

Speculative Types in Practice

Now let's see how this plays out with real types. V8's type speculation follows a lattice:

        None (no info)
       /     |      \
     Smi  HeapNumber  String   BigInt  ...
       \     |      /
       Number (Smi | HeapNumber)
            |
        Numeric (Number | BigInt)
            |
          Any

Feedback starts at None and gets more specific as V8 observes types. TurboFan speculates at the most specific observed type:

function compute(x) {
  return x * 2 + 1;
}

// Phase 1: feedback collects "x is always Smi"
for (let i = 0; i < 1000; i++) compute(i);
// TurboFan optimizes: emit SmiMul + SmiAdd (integer-only)

// Phase 2: now pass a float
compute(3.14);
// Guard fails: x is not Smi -> DEOPTIMIZE
// V8 recompiles with wider speculation: "x is Number (Smi or HeapNumber)"

After deoptimization, the feedback vector now contains both Smi and HeapNumber. The next optimization will use the wider Number type, generating slightly less optimal code (floating-point operations instead of integer) but code that won't deopt on floats.

Production Scenario: The Config Parse Deoptimization Storm

This one is sneaky, and you'll see it everywhere once you know to look for it. A Node.js service has a config parser that runs at startup:

function parseValue(value) {
  if (typeof value === 'string') return value;
  if (typeof value === 'number') return value;
  if (typeof value === 'boolean') return value;
  if (Array.isArray(value)) return value.map(parseValue);
  if (typeof value === 'object') {
    const result = {};
    for (const key in value) result[key] = parseValue(value[key]);
    return result;
  }
  return String(value);
}

During startup, parseValue processes hundreds of config entries with every possible type — strings, numbers, booleans, objects, arrays. The FeedbackVector records wildly inconsistent types at every operation.

Later, a hot API handler calls parseValue on user input (always strings). But parseValue's feedback is already polluted with every type from the config parse. TurboFan either produces poorly-optimized code (wide type speculation) or deoptimizes repeatedly.

The fix: isolate the cold startup path from the hot runtime path:

// Cold path: used only during startup — V8 won't bother optimizing
function parseConfigValue(value) {
  // ... same complex logic
}

// Hot path: separate function with clean feedback
function parseUserInput(value) {
  // Always a string from HTTP body
  return value.trim();
}

By splitting the function, the hot path builds clean, consistent feedback, and TurboFan generates optimal string-handling code.

Common Trap

Function feedback pollution is invisible. You won't see it in any profiler or debugger. The only clues are: (1) a function that should be fast isn't, (2) --trace-deopt shows unexpected deoptimizations, or (3) the function was called with diverse types early in its life before the hot path kicked in. Always consider whether a function's early callers might pollute its feedback.

Common Mistakes

What developers doWhat they should do
Using one function for both initialization (diverse types) and hot-path (consistent types)
Feedback pollution from startup/initialization code degrades optimization of the hot path
Split cold and hot paths into separate functions so each builds clean type feedback
Passing different types to the same function across different call sites
The FeedbackVector records all observed types. Mixed types force wider speculation and slower code
Ensure each function receives consistent types. Create specialized variants if needed
Assuming V8 analyzes your source code to determine types
TurboFan doesn't read your typeof checks or TypeScript types. It only trusts the FeedbackVector
Understand that V8 is purely empirical — it only knows what it has observed at runtime
Not considering the 'warm-up' phase when benchmarking
V8 needs enough calls to fill the FeedbackVector and trigger TurboFan optimization
Run the function with realistic data for several thousand iterations before measuring

Quiz: Feedback Vector Behavior

Quiz
A function is called 5,000 times with integers, then TurboFan optimizes it. On call 5,001, a string is passed. What happens?
Quiz
Function process() is called with type A 99% of the time and type B 1% of the time. How does TurboFan handle this?

Key Rules

Key Rules
  1. 1TurboFan optimizes based on observed runtime types (FeedbackVector), not source code analysis or TypeScript annotations.
  2. 2Every operation in a function has its own feedback slot. Type pollution at any slot degrades the entire function's optimization.
  3. 3Splitting cold (diverse types) and hot (consistent types) code into separate functions prevents feedback pollution.
  4. 4Type stability is the #1 predictor of optimization quality. Consistent types = narrow speculation = fast code.
  5. 5After deoptimization, V8 may reoptimize with wider types. This second-generation code is functional but slower than the original.
  6. 6TypeScript types don't help V8 — they're erased at compile time. Only actual runtime type consistency matters.