open atlas
↑ Back to track
JavaScript Engine internals JSE · 01 · 03

Ignition and the bytecode

Ignition is V8's register-based bytecode interpreter. Bytecode is far more compact than machine code — its original motivation was memory, cutting V8's code memory roughly in half on mobile. A single accumulator plus a register file

JSE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

When V8 shipped Ignition in 2017, the headline was not speed — it was memory. On a low-end Android phone, the machine code V8 generated for every function was eating tens of megabytes of RAM, much of it for code that ran a handful of times. The fix was to stop generating machine code up front and run a compact bytecode instead, cutting V8’s code memory roughly in half. That bytecode, and the register machine that runs it, is what stands between your parsed AST and a fast path.

Bytecode: compact by design, and the reason was memory

After the parser builds the AST (and only when a function is actually called — recall lazy parsing), the bytecode generator lowers that tree into Ignition bytecode: a flat sequence of small instructions for a virtual machine. Bytecode sits between source and machine code. It is slower to execute than native machine code, but it is dramatically more compact — one bytecode is typically one or two bytes, versus dozens of bytes of machine code for the same operation.

That compactness was the point. Before Ignition, V8’s baseline compiler generated machine code for every function eagerly, and on memory-constrained mobile devices the accumulated code blew the RAM budget. Ignition replaced eager machine code with bytecode that V8 interprets, roughly halving V8’s code memory while keeping startup snappy. Speed came later, from the JIT tiers compiling the hot bytecode; the founding motivation was footprint.

Why bytecode, and what it costs
Original Ignition motivation (2016)
memory
V8 code-memory reduction
~50%
Typical bytecode size
1-2 bytes / op
Ignition VM style
register-based
JVM / CPython VM style
stack-based
Flag to view bytecode
--print-bytecode

Register machine, not stack machine

When you read bytecode output from d8 --print-bytecode and see unfamiliar operand patterns, the register model is the key to decoding them. There are two classic ways to build a bytecode VM. A stack machine (the JVM, CPython) keeps operands on an implicit operand stack: PUSH a; PUSH b; ADD pops two, pushes the sum. A register machine keeps operands in named registers and operations reference them directly: ADD r1, r2. Ignition is a register machine.

Register bytecode is a little harder to generate but yields fewer, denser instructions — there is no constant push/pop churn for every operand — which means fewer dispatches in the interpreter loop per unit of work. That density is part of why a register design suits a JIT-backed engine: fewer bytecodes to interpret while cold, and a cleaner stream to compile when hot.

Ignition’s register model is specifically an accumulator + register file:

  • The accumulator is a single implicit register that most bytecodes read from and write to. It threads intermediate values from one instruction to the next, so operands do not all need an explicit name. Many ops take one explicit operand and implicitly use the accumulator as the other input and as the output.
  • The register file is the set of named local registers — r0, r1, r2, … — holding the function’s locals and temporaries, allocated per function frame.

Reading real bytecode

Take the simplest function and look at what Ignition runs. Run d8 --print-bytecode add.js on:

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

You get something close to this (operands and slot indices abbreviated):

Ldar a1          // load argument b into the accumulator
Add a0, [0]      // accumulator = a0 (a) + accumulator (b); feedback slot 0
Return           // return the accumulator

Notice the accumulator-centric shape: Ldar (Load Accumulator from Register) puts b in the accumulator; Add a0, [0] adds register a0 (the argument a) to the accumulator and leaves the sum in the accumulator; Return returns the accumulator. There is no Push/Pop — the accumulator is the shared workspace.

The [slot] is where the JIT gets its type information

Look again at Add a0, [0]. That [0] is a feedback-vector slot index. Every bytecode whose behaviour depends on runtime types — arithmetic, property loads/stores, calls, comparisons — carries a slot index into the function’s feedback vector, a per-function array allocated alongside the bytecode. As the interpreter executes that bytecode, it records what it actually saw into that slot: the operands of Add were Smis, or the object at a property load had a particular hidden class, or this call site always hit one function.

This is the bridge to the optimiser. The interpreter is, in effect, profiling the program for free while it runs it. When a function gets hot, the JIT (Maglev, TurboFan — covered in unit 04) reads the feedback vector to make speculative assumptions: “this Add only ever sees integers, so emit an integer add.” Type feedback is collected here, in Ignition, one slot per type-sensitive bytecode — which is why this lesson sits directly upstream of the JIT and of type feedback.

Shared handlers, written in Torque

You might expect each bytecode to be hand-written assembly per CPU. Instead, V8 writes each bytecode’s handler — the routine that performs that opcode’s work — once, in a portable low-level DSL: originally CodeStubAssembler (CSA), now mostly Torque, which compiles down to CSA. These handlers are generated into machine code at build time and shared across all functions: there is one Add handler, one LdaNamedProperty handler, and so on. The interpreter is essentially a table of these shared handlers, and running a function means dispatching through them — the subject of the next lesson, the interpreter loop.

Quiz

Ignition replaced eagerly-generated machine code with interpreted bytecode in 2017. What was the primary motivation?

Quiz

In the bytecode `Add a0, [0]`, what is the `[0]`?

Order the steps

Order the bytecodes Ignition runs for `function add(a, b) { return a + b; }`, given a0 holds a and a1 holds b.

  1. 1 Ldar a1 — load argument b into the accumulator
  2. 2 Add a0, [0] — add register a0 to the accumulator; record types in feedback slot 0
  3. 3 Return — return the value in the accumulator
Why this works

Why an accumulator at all, instead of always naming both operands like Add r1, r2, r3? The accumulator lets most bytecodes carry one fewer operand — the result and one input are implicit — which makes the bytecode stream shorter and the dispatch loop tighter. It is a classic interpreter trade-off: a tiny bit of extra Ldar/Star (load/store accumulator) traffic in exchange for denser, faster-to-dispatch common operations. V8’s measurements found the accumulator design a net win for interpreter throughput and code size.

Recall before you leave
  1. 01
    Why does Ignition use bytecode at all, and what was the original motivation?
  2. 02
    Describe Ignition's register model and contrast it with a stack machine.
  3. 03
    What is the [slot] in a bytecode like `Add a0, [0]`, and why does it matter for performance?
Recap

Once a function is first called, V8’s bytecode generator lowers its AST into Ignition bytecode — a flat sequence of small (1-2 byte) instructions for a virtual machine. Bytecode exists primarily for memory: replacing eagerly-generated machine code with interpreted bytecode cut V8’s code memory roughly in half on mobile, which was Ignition’s founding motivation in 2016-2017; execution speed is slower than machine code and is recovered later by the JIT tiers. Ignition is a register machine rather than a stack machine (JVM/CPython), and specifically uses an accumulator plus a register file: the single accumulator threads values from one bytecode to the next, while named registers r0, r1, … hold the frame’s locals. A function as simple as add(a, b) compiles to roughly Ldar a1; Add a0, [0]; Return, showing the accumulator-centric shape and no push/pop. The [0] is a feedback-vector slot index: every type-sensitive bytecode carries one, and the interpreter records observed types there as it runs, profiling the program for free so the JIT can later specialise. Bytecode handlers are written once in Torque/CSA and shared across all functions. Now when you run d8 --print-bytecode on a slow function, you can read what the engine actually executes — and spot whether feedback slots are filling correctly.

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