How V8 turns a + b into machine code
The event-loop page stops at "Blink hands the source to V8, which parses it, compiles it to bytecode, and executes". This page opens that box. We follow one expression through V8's scanner, its precedence-climbing parser, the bytecode generator and the Ignition interpreter, then watch V8 collect feedback, compile hot code to machine code, and throw that code away when its guesses turn out wrong.
V8 excerpts come from v8/v8 main, fetched October 2026. Every "real output" box was produced by running Node v22.22.0 (V8 12.4) with the flags shown. The live lab below is a model of V8's parser and bytecode generator; we tested it against Node's --print-bytecode on 746 expressions and it matched every one byte for byte. Small numbered tags like open reading notes.
Where V8 gets our code
On the event-loop page, step 10 was a task that "executes the script". Inside that task, Blink passes the source text of main.jsx to V8 and asks it to compile and run. In Node, the same happens when require or import loads a file. Either way, V8 receives a string of characters and has to end up executing machine instructions.
V8 does not compile everything at once. The top-level code is parsed fully, but most function bodies are only pre-parsed: scanned quickly to find where they end and which variables they capture. A function is fully parsed and compiled to bytecode the first time it is called. Since add below is called, it goes all the way through.
What it is. Our own scanner, precedence-climbing parser and bytecode generator for arithmetic over the parameters a, b, c and number literals. It drives Frames 1 to 3.
Checked against. Node 22's V8 (12.4), byte for byte: the generated bytecode, registers and feedback slots must equal node --print-bytecode (verify/compare-with-node.js). The token list and tree are what our model produces; only the bytecode is compared.
Not covered. Everything outside arithmetic expressions (statements, calls, objects, strings), and other V8 versions, whose bytecode can differ.
Characters become tokens
The scanner reads characters and groups them into tokens, the lexical-analysis phase from compiler design. V8's scanner is hand-written for speed. For ASCII characters it looks up a precomputed table, one_char_tokens, then resolves multi-character operators by peeking ahead. That is how <, <=, << and <<= are told apart.
Tokens become a tree, by precedence climbing
V8's parser is hand-written recursive descent, not generated by a tool like Bison. For binary operators it uses precedence climbing: every operator has a number (+ is 12, * is 13, ** is 14), and the parser keeps absorbing operators of the current precedence, recursing with a higher minimum precedence for the right-hand side. Associativity falls out of one line: ** is right-associative, so its right side is parsed at the same precedence instead of one higher.
Two things happen during parsing that surprise most people. When both operands are number literals, the parser folds them on the spot, so 2 * 3 + a never contains a multiplication. And a chain of the same operator, such as a - b - c, is collapsed into one n-ary node.
The tree becomes Ignition bytecode
Ignition is a register machine with an accumulator. Most bytecodes read one operand from a register and the other from an implicit accumulator, then leave the result in the accumulator. Parameters live in registers a0, a1, a2; temporaries are r0, r1 and so on. The [n] operand after an arithmetic bytecode is a feedback slot: a place where Ignition records which types it actually saw, for the optimizing compilers to use later.
The generator below walks our tree. Watch three real V8 behaviours: a Smi literal on the right becomes an immediate operand (MulSmi [2]); a literal on the left of a commutative operator is moved to the right; and a parameter used as a left operand is read straight from its register, because V8's register optimizer removes the copy.
Ignition runs the bytecode, one handler at a time
Each bytecode has a handler: a small piece of machine code generated when V8 is built. A handler does its work, then Dispatch() reads the next bytecode and jumps straight to that handler. There is no central switch statement; each handler ends by jumping to the next one. The arithmetic handlers also update the feedback slot with the types they saw.
What it is. Our small interpreter executing bytecode one instruction at a time, to show the accumulator and registers.
Checked against. The sumSquares bytecode is real Node 22 output; the step-by-step register values come from our interpreter and are not compared with V8.
Not covered. Feedback collection, handlers, dispatch and everything else that makes Ignition fast.
Two details worth noticing in real V8 output. The Node 22 bytecode writes the feedback slot as an operand (Add a0, [0]), while current V8 main has moved to embedded feedback (BinaryOpWithEmbeddedFeedback above) stored in the bytecode stream itself. And JumpLoop is where long-running loops are checked for on-stack replacement: switching to optimized code in the middle of a running loop.
Hidden classes and inline caches
JavaScript objects look like dictionaries, but V8 gives every object a hidden class it calls a Map: a description of which properties exist and at which offset. Objects created with the same properties in the same order share a Map. When getX(o) runs o.x, the property load's inline cache remembers "for this Map, x is at offset N", so the next lookup is a comparison and a load.
The cache has states. One Map seen is monomorphic. Up to four is polymorphic, a short list to check. The fifth different Map makes it megamorphic, falling back to a global lookup table. The limit 4 is a real V8 flag, shown below, and the transitions are exactly what V8 printed for us.
What it is. A model of hidden-class (Map) transitions and of the inline cache states for o.x.
Checked against. By hand, against the real %DebugPrint output shown below for the same objects, and against the limit of 4 Maps in flag-definitions.h.
Not covered. Dictionary-mode objects, elements kinds, prototype chains, and the megamorphic stub cache itself.
Tiering up: from bytecode to optimized machine code
Interpreting is cheap to start but slow to run. V8 therefore has a ladder of compilers and moves a function up the ladder as it gets hotter. Sparkplug translates bytecode to machine code almost one-to-one, without optimizing. Maglev is a fast optimizing compiler. TurboFan spends the most time and produces the fastest code, using the feedback Ignition collected to speculate about types.
The Node column comes from node --v8-options on Node 22.22.0: --sparkplug and --turbofan default on, --maglev defaults off. Chrome has shipped Maglev on by default since version 117.
When TurboFan's guess is wrong: deoptimization
TurboFan compiles add assuming the feedback stays true. If Ignition only ever saw small integers (V8 calls them Smis), the machine code uses a plain integer add plus a cheap check. When a value breaks the assumption, the check fails and V8 deoptimizes: it rebuilds the interpreter frame and continues in Ignition at the exact same bytecode, with updated feedback. Step through what Node actually printed for this program:
What it is. A replay of the real --trace-opt --trace-deopt output from Node 22 running deopt.js, in 7 events.
Checked against. It is the real log, shortened; nothing here is simulated.
Not covered. Chrome's behaviour differs in detail: Maglev is on there, and Smis are 31-bit, so the overflow happens at a different value.
Why did the first deopt say overflow? The running sum passes 231, so it no longer fits a Smi, which in Node is a 32-bit integer. Chrome uses pointer compression, which shrinks Smis to 31 bits, so the same overflow would happen earlier there. That is also why the lab above treats numbers up to 2,147,483,647 as Smis: it matches Node.
Run every experiment on this page
| What to see | Command (Node 18 or later) |
|---|---|
| Bytecode for one function | node --print-bytecode --print-bytecode-filter=add add.js |
| Optimization and deoptimization | node --trace-opt --trace-deopt deopt.js |
| Inline cache states | node --allow-natives-syntax ic.js with %DebugPrint(getX) after each call |
| Whether two objects share a Map | %HaveSameMap(a, b) under --allow-natives-syntax |
| Which tiers are enabled | node --v8-options | grep -A1 -E "^\s+--(maglev|sparkplug|turbofan) " |
| Check the lab model ourselves | node verify/compare-with-node.js in this folder compares the model with real V8 on random expressions |
Predict first
Exercises
- Run
node --print-bytecodeonfunction f(a, b) { return a + b + 1 }and onreturn 1 + a + b. Explain the difference using constant folding and n-ary collapsing, then confirm with the lab. - Write a loop that calls
getXwith 3 shapes, then 5 shapes, and time 10 million calls of each withperformance.now(). Relate the gap to the IC states. - Change
deopt.jsso the sum stays below 231. Which deopt disappears from--trace-deopt, and why? - Run
node --maglev --trace-opt deopt.js. Doesaddgo to Maglev first? ReadShouldOptimizeabove and explain what you see. - Extend the lab's grammar on paper with unary minus. Where in
ParseBinaryExpressionwould it be handled, and what bytecode do you expect for-a * b? Check with Node.
Exam-style questions, with model answers
Five questions in the style of a compilers or programming-languages exam. Write our answer on paper first, then open the model answer and mark ourselves honestly. The marks follow the usual pattern of university theory exams; they are not copied from any real paper.
function f(a, b, c) { return (a + b) * (a - c); }Ldar a1 ; acc = b Add a0, [1] ; acc = a + b feedback slot 1 Star0 ; r0 = a + b Ldar a2 ; acc = c Sub a0, [2] ; acc = a - c feedback slot 2 Mul r0, [0] ; acc = r0 * acc feedback slot 0 Return
Ignition is an accumulator machine: the right operand is in the accumulator and the left one is named, so Add a0 computes a0 + acc. The left product operand must survive while the right side is computed, so it is parked in register r0. Slot 0 belongs to the multiplication because the generator allocates the outer operation's slot before visiting its children. (Checked against node --print-bytecode.)
Marking: 3 for the instruction sequence, 1 for the register spill, 1 for the slot order.
return a - 2 * b; and explain why it uses MulSmi even though the literal is on the left, and why the subtraction is not SubSmi.Ldar a1 MulSmi [2], [1] Sub a0, [0] Return
Multiplication is commutative, so the generator may swap 2 * b into b * 2 and use the Smi-immediate form, which needs no register for the constant. Subtraction is not commutative and its right operand 2 * b is not a literal, so it uses the general Sub with a as the named operand.
Marking: 2 for the code, 1 for the commutative swap, 1 for why Sub is general.
+ 12, * 13, ** 14, right-associative), draw the AST for a + b * c ** d ** 2 and explain how the parser makes ** group to the right.AST: a + (b * (c ** (d ** 2))).
+
/ \
a *
/ \
b **
/ \
c **
/ \
d 2After reading an operator of precedence p, ParseBinaryContinuation parses the right side with minimum precedence p + 1 for left-associative operators, so a second * would not be absorbed. For ** it parses the right side with minimum precedence p itself, so another ** is absorbed into the right operand, which makes it right-associative.
Marking: 3 for the tree, 2 for the p versus p + 1 explanation.
function getX(o) { return o.x; } is called once each with {x:1}, {x:1, y:2}, {y:2, x:1}, {x:1, z:3} and {x:1, w:4}, in that order. Give the inline cache state after each call and explain why.After calls 1 to 5: monomorphic, polymorphic (2 maps), polymorphic (3), polymorphic (4), megamorphic.
All five objects have different hidden classes (maps). {x, y} and {y, x} differ because maps record the order in which properties were added. V8 keeps at most 4 maps in a polymorphic IC (DEFAULT_MAX_POLYMORPHIC_MAP_COUNT); the fifth map makes it give up on per-map handlers and use the generic megamorphic stub cache.
Marking: 2 for the sequence, 1 for "property order matters", 1 for the limit of 4.
function add(a, b) { return a + b; } is optimized by TurboFan after many calls with small integers. Then it is called with add(2147483647, 1) and later with add("x", 1). Describe what happens each time and what V8 does afterwards.The optimized code assumes both inputs are Smis and the sum fits a Smi, with checks guarding those assumptions.
add(2147483647, 1): the inputs pass the Smi check but the 32-bit add overflows, so the overflow check fails and V8 deoptimizes with reason "overflow". Execution continues in Ignition, which produces the heap number 2147483648, and the feedback slot widens to Number.
add("x", 1): the type check on the input fails and V8 deoptimizes with reason "not a Number or Oddball" (or similar). Feedback widens again.
Afterwards the function is still hot, so V8 can optimize it again using the wider feedback, producing slower but more general code. Code that sees mixed types keeps paying for these transitions.
Marking: 2 per call (correct check and reason), 1 for re-optimization with wider feedback.
Everything this page cites, by depth
V8 source is © the V8 project authors, BSD license, quoted for study. Real outputs were produced on Node v22.22.0 (V8 12.4.254.21-node.33).