The second page on how a browser runs JavaScript. The Browser Event Loop shows when your code runs on the main thread; this page shows what V8 does with it.

The idea: from text to machine code, step by step

V8 never runs the source text itself. The parser turns it into a tree (the AST), the bytecode generator turns the tree into bytecode, and the Ignition interpreter runs the bytecode. While it runs, it writes down what it sees: which types meet at each +, which object shapes reach each p.x. Functions that run often are compiled again into machine code by faster and faster compilers: Sparkplug, Maglev and TurboFan. The optimising ones bet on what was written down. When a bet fails, V8 throws the code away and goes back to Ignition: a deoptimisation.

Source goes to the parser, which makes an AST; the bytecode generator makes bytecode; Ignition runs it and fills the feedback vector. Hot functions go on to Sparkplug (baseline code), Maglev and TurboFan, which read the feedback; a failed guard in Maglev or TurboFan code deoptimises back to Ignition.
Every function starts as bytecode in Ignition; hot ones climb to machine code, and a failed guess sends them back.

The canvas follows one small script:

function Point(x, y) { this.x = x; this.y = y; }
function add(a, b) { return a + b; }
function getX(p) { return p.x; }

Pick a call and press Call once (or Call ×10) to run it as a top-level statement. The top band shows the source with its tokens and AST, the bytecode or machine code that is running (with the accumulator and registers), and one card per function: its tier, its call counter and its feedback. The middle band is the pipeline: the compilers, with TurboFan on a background thread. The bottom band is the heap: objects, their hidden classes (maps) and the inline cache of p.x. Calls that teach nothing new run in a batch and are shown as one step.

The tier thresholds are scaled down so a demo stays short: Sparkplug after 4 calls, Maglev after 12, TurboFan after 30, OSR after 40 loop iterations. Real V8 counts an interrupt budget (see the tiers).

Parsing and lazy functions

The scanner cuts the text into tokens (function, add, (, …) and the parser builds the AST from them. Parsing a big script fully would cost start-up time for functions that may never run, so V8 parses lazily: the top-level code is parsed fully, but each function body is only pre-parsed. The pre-parser checks the syntax and notes which outer variables the function uses, and keeps no AST. The function becomes a SharedFunctionInfo marked lazy. Its first call parses the body again, fully, and compiles it to bytecode. Demo 1: first run, lazy parsing.

So a function that is called is parsed twice. V8 guesses which functions will run at once (for example a function wrapped in parentheses, (function () { … })()) and parses those fully the first time.

Ignition: a register machine with an accumulator

Ignition's bytecode works on registers (r0, r1, … for locals, a0, a1, … for arguments) and one implicit accumulator: most bytecodes read it or write it, which keeps them short. This is the real output of node --print-bytecode (node 25, V8 13) for add:

[generated bytecode for function: add]
Bytecode length: 6
Parameter count 3          // the receiver (this), a, b
Register count 0
   @    0 : 0b 04       Ldar a1           // acc = b
   @    2 : 40 03 00    Add a0, [0]       // acc = a + acc, feedback slot [0]
   @    5 : b7          Return            // return acc

The [0] is the index of a feedback slot. getX is GetNamedProperty a0, [0], [0] (constant pool entry 0 is the name "x", feedback slot 0) and Return; Point is two pairs of Ldar and SetNamedProperty <this>, …, then LdaUndefined and Return. Ignition itself is machine code generated by V8: one small handler per bytecode, and a dispatch from each handler to the next.

Feedback vectors and speculation

JavaScript says nothing about types: a + b can add two small integers, two doubles, or join two strings. Each function has a feedback vector with one slot per operation that cares. Every time Ignition (or Sparkplug code) runs the operation, it records what it saw:

  • Add: the operand types, on a lattice none → Smi → Number → Any (strings: String, and Smi with String gives Any). A Smi is a small integer stored in the pointer itself, with no heap object.
  • Property loads and stores: the maps of the objects (next section).

The feedback only widens. The optimising compilers read it and speculate: "a and b have always been Smis, so compile an integer add". Each guess is protected by a guard (a type or map check) that jumps to a deoptimisation exit when it fails. Optimised code records no feedback.

The tiers

TierOn the pageV8 13 defaultCompilesCodeThread
Ignitionfirst callfirst callbytecode, very fastinterpreted, slowestmain
Sparkplug4 callssoon after the feedback vector (8 calls)one pass over the bytecode, no IRbaseline: no dispatch, same framesmain (batched)
Maglev12 callsabout 400 callsSSA graph from bytecode + feedbackoptimised, guardsbackground
TurboFan30 callsabout 3000 callssea of nodes, inlining, escape analysisbestbackground

V8 does not count calls directly. Each function has an interrupt budget that the bytecode it executes uses up, so a long function gets hot after fewer calls. When the budget runs out, V8 checks whether to tier up. The defaults behind the third column are flags: --invocation-count-for-feedback-allocation=8, --invocation-count-for-maglev=400, --invocation-count-for-turbofan=3000. A TurboFan compile takes long, so it runs on a background thread; the function keeps running its old code until the new code is installed, at its next call. Demo 2: getting hot.

On-stack replacement. A function that is called once but loops for a long time never gets a "next call". Back-edges (JumpLoop) use up the budget too, and when they do V8 compiles the function for an OSR entry at the loop header, then moves the running frame into the new code in the middle of the loop (--invocation-count-for-osr=500). On the page, the top-level loop s = add(s, i) switches at i = 40, with add inlined, and ends with s = 4950. Demo 6: OSR, a hot loop.

Hidden classes (maps) and inline caches

A JavaScript object can get new properties at any time, so V8 can't use fixed struct layouts. Instead every object points to a map (also called a hidden class or shape): the list of its properties and where each one is stored. Adding a property moves the object to another map along a transition. Objects that get the same properties in the same order follow the same transitions and share a map. new Point(1, 2) and new Point(3, 4) both end at M2 {x, y}; {y: 1, x: 2} takes another branch and ends at M4 {y, x}, with x at another offset. Demo 4: hidden classes.

Map transition tree: M0 {} goes to M1 {x} then M2 {x, y}; another branch M0 to M3 {y} to M4 {y, x}. Two Points share M2 and a load of p.x stays monomorphic; the object {y: 1, x: 2} has map M4, so the same load sees a second map and becomes polymorphic.
Same properties in the same order give the same map; another order gives another map, and the load of p.x has to handle both.

An inline cache (IC) remembers, per load or store, the maps it has seen and the offset of the property in each:

IC stateMaps seenWhat optimised code does
monomorphic1compare the map, load at a fixed offset: cmp [p].map, M2, mov eax, [p + 12]
polymorphic2 to 4compare with each map in turn
megamorphicmore than 4call the generic load, a hash lookup in the global stub cache, on every access

The offsets are 12, 16, … because the object header holds three 4-byte compressed pointers (map, properties, elements) before the in-object fields. Demo 5: inline cache states passes five shapes to getX.

Deoptimisation, and how to write code V8 likes

After demo 2, add runs TurboFan code that assumes Smis. add("a", "b") fails the first guard. V8 deoptimises: it reads the deopt data the compiler saved for that exit, rebuilds an interpreter frame at the same bytecode (offset 2, Add a0, [0]), and Ignition finishes the call. The result is "ab", the feedback widens from Smi to Any, the optimised code is dropped and the counter starts again. The next calls run the Sparkplug code, which was kept; once add is hot again it is re-optimised with a generic add that has no guard and can't fail. Demo 3: deoptimisation. This is a real trace (node --trace-deopt, shortened):

bailout (kind: deopt-eager, reason: not a Smi): begin. deoptimizing add, <Code MAGLEV>, bytecode offset 2
bailout (kind: deopt-eager, reason: wrong map): begin. deoptimizing getX, <Code MAGLEV>, bytecode offset 0

A few deopts are normal. A function that deopts again and again, or a site that goes megamorphic, stays slow. What helps:

  • Create objects of one kind the same way: in a constructor or class, all properties set, in the same order.
  • Don't add properties later or delete them: both change the map.
  • Keep each function's argument types stable: don't pass numbers to it on one call and strings on the next.

V8 vs HotSpot

V8 (JavaScript)HotSpot (Java)
Inputsource text, parsed in the browserbytecode from javac
InterpreterIgnition (registers + accumulator)template interpreter (operand stack)
CompilersSparkplug, Maglev, TurboFanC1 (tiers 1 to 3), C2 (tier 4)
Profilefeedback vectorMethodData (MDO)
Types to guessall values (no static types), object mapsreceiver classes (static types known)
Bail-outdeoptimisationuncommon trap, deoptimization

See also HotSpot JIT tiers, which runs the same story (counters, profile, speculation, deopt, OSR) for Java, and Compiler vs Interpreter for why both exist at all.

See it yourself

node --print-bytecode --print-bytecode-filter=add app.js   # Ignition bytecode of add
node --trace-opt --trace-deopt app.js                      # tier-ups and deopts with reasons
node --allow-natives-syntax app.js                         # %OptimizeFunctionOnNextCall(add)

In Chrome, the Performance panel shows "Compile code" and "Optimize code" entries, and chrome --js-flags="--trace-deopt" passes the same flags to V8.

What the page leaves out

The tier thresholds are call counts scaled down from an interrupt budget, and Sparkplug and Maglev compile at once on the main thread here; in V8, Sparkplug compiles in batches and Maglev on a background thread like TurboFan. V8 makes the feedback vector after a few calls (8), not at the first call. All objects here start from one empty map M0; V8 gives each constructor and each object literal its own root map, and literals get their map at once from a boilerplate. OSR compiles concurrently and enters at a later back-edge. The page also leaves out garbage collection (Orinoco), bytecode flushing, the code cache, WebAssembly (Liftoff and TurboFan), eval, field representations (Smi, double and tagged fields) and inlining across more than one call.