open atlas
↑ Back to track
JavaScript Engine internals JSE · 04 · 02

Type feedback: the fuel for the optimiser

An optimiser is useless without runtime type information. How the feedback vector records, per IC site, the maps, primitive-type lattice, call targets, and binary-op types the interpreter observes — and why garbage feedback produces generic code or no optimisation at all.

JSE Senior ◷ 14 min
Level
FoundationsJuniorMiddleSenior

TurboFan compiles a function that does a + b and produces, for that one operation, a single integer-add machine instruction with no boxing, no type check beyond a cheap guard. How does a compiler for a dynamically-typed language know a and b are integers? It doesn’t — it asks the interpreter what it has seen. Strip that observation away and the same compiler can only emit the generic, branch-heavy “add anything to anything” routine. The fuel that turns a generic optimiser into a specialising one is type feedback.

Why an optimiser is blind without feedback

JavaScript has no static types the engine can trust. At the point function add(a, b) { return a + b; } is compiled, the engine cannot prove a is a number — it could be a string, an object with a valueOf, a BigInt. A naively-compiled + must therefore handle every case: check tags, branch on string-vs-number-vs-object, possibly call a user valueOf, box the result. That generic path is correct but slow.

The way out is speculation: assume the types you have actually observed, emit fast code for exactly those, and protect it with a cheap guard that bails if the assumption breaks (lesson 04). But speculation needs evidence — and the evidence comes from the only tier that runs every operation while watching its operands: Ignition, the interpreter. As Ignition runs, it writes down what it sees at each “interesting” location. That record is the feedback vector.

The feedback vector: structure and ownership

A feedback vector is a fixed-size array allocated in the GC heap. The crucial detail people get wrong is what owns it. The bytecode and the layout of the feedback slots are shared: they live on the function’s SharedFunctionInfo (SFI), the engine’s deduplicated record of “this function’s code”, one per source definition. But the feedback vector itself — the array holding the observed data — is allocated per closure. Two closures created from the same function source share bytecode and slot layout, but each has its own feedback vector, so their runtime observations don’t pollute each other.

Each slot corresponds to one inline cache (IC) site in the bytecode — a place where the type of the operands matters:

  • Property access (obj.x, obj.x = v) — records the map (hidden class) of the object seen, plus a handler describing how to load/store at that map.
  • Calls (fn(), obj.method()) — records the call target(s) observed, enabling inlining.
  • Binary operations (a + b, a < b) — records the type lattice of the operands: were they always Smi (small integer)? Smi-or-HeapNumber? String? Any?
  • Element access (arr[i]) — records the elements kind (packed Smi, packed double, holey, dictionary).

The primitive-type lattice

For arithmetic, the feedback is not just “number”. V8 tracks operand types on a small lattice that it widens monotonically as it sees more cases. A useful mental model for a + slot:

None  ->  SignedSmall (Smi)  ->  Number (Smi or HeapNumber)  ->  NumberOrOddball  ->  Any
                       \-> String (if a string operand appears) -> Any

Each new observation can only move up the lattice (widen), never narrow. A slot that has only ever seen small integers stays at SignedSmall, and TurboFan can emit a 32-bit integer add guarded by a single “is Smi” check. The first time that slot sees a value that overflows Smi range, it widens to Number, and the next optimisation emits float64 arithmetic instead. If it ever sees a string, it widens toward Any, and arithmetic falls back to the fully generic routine. This monotonic widening is exactly why mixing types is so corrosive: one off-type value permanently pollutes the slot for the lifetime of that feedback vector.

Garbage in, generic out

Here is the practical question: if you call a hot function with five different object shapes in its first dozen invocations, what does the optimiser see when it finally compiles? The optimiser is only as good as the feedback. Three states matter for any IC slot:

  • Monomorphic — one map / one stable type observed. The optimiser specialises hard: direct offset load, inlined call, integer add. Fastest.
  • Polymorphic — a small handful (V8’s cap is 4) of maps/types. The optimiser can still inline with an upfront map check that branches between the known cases. Slower, still optimised.
  • Megamorphic — more than the cap. The slot is marked with a megamorphic sentinel; the optimiser gives up on specialising it and emits a generic dispatch (a runtime lookup). It may decline to optimise the surrounding function at all.

Together these three states form the whole story: warming with one shape gives you the fast path, letting two or three shapes in still keeps you optimised, but blasting more than four through the same slot permanently degrades it to generic dispatch.

Feedback states and what the optimiser does
Monomorphic
1 map / type -> specialise
Polymorphic
2-4 maps -> inline w/ check
Megamorphic threshold
more than 4 maps
Megamorphic
generic dispatch / no opt
Lattice widening
monotonic, one-way
Feedback vector ownership
per closure
Slot layout / bytecode owner
SharedFunctionInfo
d8 inspector
%DebugPrintFeedback(fn)

The practical rule that falls out of this: warm a function with consistent types before it tiers up. If a hot function will mostly process integers, do not let the first dozen calls feed it strings; if a call site will mostly receive one object shape, do not route five shapes through it. The feedback vector is filled during the interpreted phase, and whatever lattice and map set it accumulates is exactly what the optimiser will (or won’t) be able to specialise on.

Quiz

Two closures are created from the same function source. What do they share, and what is per-closure?

Quiz

A `+` site processes 9,999 integer pairs, then one pair where one operand is a string, then a million more integer pairs. What feedback does TurboFan see, and what code does it emit?

Order the steps

Order the lifecycle of a single binary-op feedback slot from a cold function to specialised machine code.

  1. 1 The slot starts at None (the function is cold, never run)
  2. 2 Ignition executes the op and records the observed operand type (e.g. SignedSmall)
  3. 3 Repeated calls keep the slot monomorphic at SignedSmall
  4. 4 The budget trips; the optimiser reads the slot and emits a Smi-guarded integer add
Edge cases

A subtle consequence: feedback vectors are GC-heap objects and can be collected under memory pressure, then re-allocated empty. A long-lived process that suffers a feedback-vector flush effectively re-cools the affected functions — they must re-observe types before they can re-optimise. This is rare, but it explains the occasional “why did this hot function deopt to interpreter speed after an hour of uptime with no code change?” mystery: the vector was reclaimed and had to refill.

Recall before you leave
  1. 01
    What is the feedback vector, what does each slot record, and who owns it?
  2. 02
    Why is the operand-type lattice monotonic, and what is the practical cost?
  3. 03
    Explain monomorphic vs polymorphic vs megamorphic feedback and what the optimiser does in each case.
Recap

A JavaScript optimiser cannot read your types statically, so it specialises by speculation on what was actually observed at runtime. Those observations live in the feedback vector: a fixed-size GC-heap array with one slot per inline-cache site. Each slot records what matters at that site — the map for a property access, the call target for a call, the elements kind for an array access, and a monotonically-widening type lattice for a binary op. Ownership is split between the SharedFunctionInfo (which holds the deduplicated bytecode and the slot layout, one per source function) and the per-closure feedback vector (which holds that closure’s own observations). Only Ignition fills the vector; Maglev and TurboFan only read it. Feedback quality determines code quality: a monomorphic slot is specialised hard, a polymorphic slot (up to 4 maps) is inlined behind a check, and a megamorphic slot collapses to generic dispatch and can block optimisation entirely. Because the lattice only widens, one off-type value pollutes a slot for the life of the vector — so warming a function with consistent types before it tiers up is the whole game, and TypeScript annotations, being erased, contribute nothing. Now when you profile a function and wonder why it runs generic add instead of a single integer instruction, check the feedback first: one stray string five minutes ago may still own that slot.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons
appears again in184

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.