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

Closures, feedback vectors, and call-site polymorphism

Every closure instance has its own feedback vector but shares bytecode via SharedFunctionInfo. A call through a variable forms a call IC recording targets: monomorphic for one callee, megamorphic for many. A hot dispatcher fed many function identities can't be inlined.

JSE Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

You built a clean dispatcher: one hot loop that calls handler(event), where handler is whichever of forty callbacks the registry returns. It is elegant, and it is slow — slower than forty separate loops would be. The reason is not your logic; it is that V8 watches which function flows through that one call site, and when it sees forty different function identities, it gives up trying to predict and inline, falling back to a generic indirect call on the hottest line in your program.

Shared code, private feedback

Why does the same function body sometimes optimise well in one place and stay slow in another? The answer is that each closure instance maintains its own private record of what it has seen at runtime — and that record is what TurboFan compiles against. When you create many instances of the same function — every arrow returned from a factory, every closure pushed in a loop — they do not each carry their own copy of the bytecode. They share one SharedFunctionInfo (SFI): the bytecode, the ScopeInfo, the source metadata. What each closure instance carries separately is its feedback vector: a per-instance array where the engine records the runtime type observations for that instance’s call sites and property accesses.

This split matters for performance. Sharing the SFI keeps memory low and lets the optimiser compile once. The per-instance feedback vector means two closures from the same factory can specialise differently if they are fed different types — and it means a closure only starts collecting useful feedback once that instance has run enough times.

Call sites form inline caches too

You already know property reads form inline caches keyed on hidden class (see the hidden-classes unit’s mono/poly/mega lesson). Calls do the same. A call expression like fn(x) — where fn is a variable or property holding a function — is a call site with its own IC slot in the feedback vector. That slot records which function (which callee identity) has been called here:

  • Monomorphic — the site has only ever called one function identity. V8 can speculate hard: it knows the exact target and can inline its body.
  • Polymorphic — a small number (up to ~4) of distinct callees. V8 keeps a little table and branches among them.
  • Megamorphic — too many distinct callees. V8 stops tracking identities and emits a generic indirect call through whatever fn currently holds.

Why inlining is the whole game

Inlining is the optimisation that unlocks the others. When TurboFan inlines a monomorphic callee into the caller, it can then constant-fold across the boundary, eliminate the call’s argument-passing overhead, and specialise the merged code on the observed types. A megamorphic call site blocks all of that: TurboFan does not know which function will run, so it must emit an indirect call — push arguments, jump through a function pointer, no inlining, no cross-boundary optimisation. On a hot dispatch loop that overhead lands on every iteration.

// Megamorphic: one site, many callee identities.
const registry = { click: onClick, hover: onHover, /* …38 more */ };
function dispatch(type, ev) {
  return registry[type](ev);   // this call site sees dozens of identities -> megamorphic
}

// Monomorphic-friendly: route to a few stable, separately-compiled paths.
function dispatchFast(type, ev) {
  switch (type) {
    case 'click': return onClick(ev);  // each call site has ONE identity
    case 'hover': return onHover(ev);  // monomorphic, inlinable
    default:      return onOther(ev);
  }
}

The switch version has many call sites, each monomorphic, instead of one megamorphic site. Each becomes individually inlinable. The “elegant” single-dispatch version concentrates all the identity variety onto one line and poisons it.

Higher-order functions: keep callbacks uniform

The same effect bites map/filter/reduce and any higher-order helper. A generic helper called with a different callback each time has, internally, one call site (cb(item)) that sees many identities — megamorphic. It still works, but it cannot inline the callback. Patterns that keep it fast:

  • Pass the same callback identity when you can, rather than a fresh inline arrow every call. A fresh arr.map(x => f(x)) literal creates a new function identity each call; arr.map(f) reuses one.
  • Specialise hot helpers. A truly hot reduction over numbers can be a hand-written loop with the operation inlined, rather than a generic reduce(fn) whose fn(acc, x) site is megamorphic.
  • Keep argument shapes uniform too. Even monomorphic-callee sites deopt if the arguments change hidden class wildly; uniform callees and uniform argument shapes both matter.
Call-site IC states and inlining
Distinct callees: monomorphic
1
Distinct callees: polymorphic
2–4
Distinct callees: megamorphic
~5+
Monomorphic call
inlinable
Megamorphic call
indirect, not inlined
Shared per function family
SharedFunctionInfo
Private per closure instance
feedback vector
Quiz

A single hot line `registry[type](ev)` dispatches to ~40 different functions. What IC state does that call site reach, and what does TurboFan do?

Quiz

Two closures come from the same factory. What do they share, and what is private to each?

Order the steps

Order what happens at one call site as more distinct function identities flow through it over time.

  1. 1 First call: the IC slot is uninitialised, records the first callee identity
  2. 2 Same identity repeats: site is monomorphic, TurboFan can inline the callee
  3. 3 A few more identities appear: site becomes polymorphic with a small dispatch table
  4. 4 Many identities flow through: site goes megamorphic — generic indirect call, no inlining
Why this works

You can watch this directly. Run with --trace-ic and grep for CallIC; a degrading dispatcher shows transitions 0 -> 1 -> P -> N (uninitialised → monomorphic → polymorphic → megamorphic). Confirm the inlining loss with --trace-turbo-inlining: a monomorphic callee appears as inlined, a megamorphic site does not. For a quick local experiment, %GetOptimizationStatus(fn) under --allow-natives-syntax in d8 tells you whether the dispatcher itself optimised.

Recall before you leave
  1. 01
    What do sibling closures share, and what is private to each, and why does it matter?
  2. 02
    What are the call-site IC states, and what does each mean for inlining?
  3. 03
    How do you keep a hot dispatcher or higher-order helper from going megamorphic?
Recap

Many instances of one function share a single SharedFunctionInfo — bytecode, ScopeInfo, metadata — but each closure instance carries its own feedback vector recording the runtime observations at its call sites and property accesses, so sibling closures specialise independently and do not pool feedback. A call expression whose callee lives in a variable or property is itself a call site with an IC slot keyed on callee identity: monomorphic when only one identity is seen (TurboFan can inline the callee), polymorphic for a few (a small dispatch table), and megamorphic for many (a generic indirect call that cannot be inlined). Because inlining is what unlocks constant folding and cross-boundary specialisation, a megamorphic call on a hot dispatch line is a genuine performance cliff: the indirect-call overhead lands every iteration. The remedies concentrate identity variety away from the hot line — replace one many-target dispatch with several single-target call sites via switch, pass stable callback identities into higher-order helpers rather than fresh inline arrows, specialise truly hot reductions as explicit loops, and keep argument shapes uniform. This is the closure-and-scope view of the same mono/poly/mega machinery the hidden-classes unit introduced for property access, now applied to which function flows through a call site — observable with —trace-ic and —trace-turbo-inlining. Now when you see a hot dispatch loop that benchmarks slower than expected, check how many distinct function identities flow through its one call site — if the answer is more than four, you have a megamorphic bottleneck and a switch to write.

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 8 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.