Ignition: How V8's Interpreter Dispatches Bytecode
Ignition is a register machine whose registers live in the stack frame, dispatched by a 768-entry table of machine-code handlers that tail-call each other.
TL;DR: Ignition is not a C++ switch statement any more. In current V8 every bytecode has a machine-code handler generated by the CodeStubAssembler in
src/interpreter/interpreter-generator.cc, and dispatch is a table load followed by a tail call throughdispatch_table_insrc/interpreter/interpreter.cc. The “registers” of the register machine are not machine registers at all - they are slots in the interpreted frame, addressed by negative offsets from the frame pointer, and the accumulator is a value in flight between two tail calls rather than a slot.
Every JavaScript engine needs a fast way to run code it has not yet optimised. V8’s answer is called Ignition, the tier that runs every function the first time it is called. Everything above it - Sparkplug, Maglev, TurboFan
- is an optimisation of Ignition’s output.
The interesting part is not that it is a bytecode interpreter. That is a 1960s idea. It is the set of tricks V8 uses to make the interpreter fast enough that the tiers above it only have to worry about semantics.
Two different things are called a “handler table”
Before anything else, a warning. V8 uses the phrase “handler table” for two
unrelated structures. The first is the dispatch table: an array of code
entry points, one per (bytecode, operand-scale) pair, and the thing that
makes dispatch fast. The second is the exception handler table: a
range-indexed table of try/catch regions attached to the bytecode array,
with nothing to do with dispatch.
This article is mostly about the first. The second shows up in
src/codegen/handler-table.h, where the layout is spelled out:
// src/codegen/handler-table.h:27
// 1) Based on ranges: Used for unoptimized code. Stored in a
// {TrustedByteArray} that is attached to each {BytecodeArray}. Contains one
// entry per exception handler and a range representing the try-block covered
// by that handler. Layout looks as follows:
// [ range-start , range-end , handler-offset , handler-data ]
// 2) Based on return addresses: Used for turbofanned code. Stored directly in
// the instruction stream of the {InstructionStream} object.
Unoptimized code is a range of bytecodes that share a handler, so that table is compact; optimised code is a set of return addresses that can throw, so that table is precise but bigger.
The dispatch table is 768 raw code pointers
The dispatch table is a flat array of code entry addresses, one per bytecode, replicated for the three operand scales:
// src/interpreter/interpreter.h:108
static const int kNumberOfWideVariants = BytecodeOperands::kOperandScaleCount;
static const int kDispatchTableSize = kNumberOfWideVariants * (kMaxUInt8 + 1);
static const int kNumberOfBytecodes = static_cast<int>(Bytecode::kLast) + 1;
Isolate* isolate_;
Address dispatch_table_[kDispatchTableSize];
Address interpreter_entry_trampoline_instruction_start_;Three scales times 256 entries is 768 pointers, 6 KiB on a 64-bit build. The index arithmetic is deliberately trivial - a shift and an add - so dispatch is a load, a multiply and a jump:
// src/interpreter/interpreter.cc:117
// static
size_t Interpreter::GetDispatchTableIndex(Bytecode bytecode,
OperandScale operand_scale) {
static const size_t kEntriesPerOperandScale = 1u << kBitsPerByte;
size_t index = static_cast<size_t>(bytecode);
return index + BytecodeOperands::OperandScaleAsIndex(operand_scale) *
kEntriesPerOperandScale;
}The table is not a table of Code objects with metadata. It is a bare
Address array of instruction start pointers.
The initialisation has a security comment worth reading carefully:
// src/interpreter/interpreter.cc:343
// Initialize the dispatch table with pointers to IllegalHandler.
// This way, if we ever run into a situation where an invalid/unknown opcode
// is executed, this will be reported as a sandbox violation. This is
// important as such crashes are typically the symptom of a bug that could
// lead to arbitrary, attacker-controlled bytecode execution, which would
// allow breaking out of the sandbox.
Tagged<Code> illegal_bytecode_handler =
builtins->code(Builtin::kIllegalHandler);
for (int i = 0; i < kDispatchTableSize; ++i) {
dispatch_table_[i] = illegal_bytecode_handler->instruction_start();
}Every unused slot points at a handler that deliberately triggers a sandbox check. A corrupted opcode does not fall off the end of a switch; it lands on a handler that traps.
Dispatch is a tail call between generated handlers
Here is the part that surprises most people. src/interpreter/interpreter.cc
is 381 lines long and contains no dispatch loop at all - only the
compilation job, the dispatch table and the table initialisation. The
dispatch itself lives in the CodeStubAssembler, in
src/interpreter/interpreter-assembler.cc:
// src/interpreter/interpreter-assembler.cc:1402
void InterpreterAssembler::Dispatch() {
Comment("========= Dispatch");
DCHECK_IMPLIES(Bytecodes::MakesCallAlongCriticalPath(bytecode_), made_call_);
TNode<IntPtrT> target_offset = Advance();
TNode<WordT> target_bytecode = LoadBytecode(target_offset);
DispatchToBytecodeWithOptionalStarLookahead(target_bytecode);
}
void InterpreterAssembler::DispatchToBytecode(
TNode<WordT> target_bytecode, TNode<IntPtrT> new_bytecode_offset) {
TNode<RawPtrT> target_code_entry = Load<RawPtrT>(
DispatchTablePointer(), TimesSystemPointerSize(target_bytecode));
DispatchToBytecodeHandlerEntry(target_code_entry, new_bytecode_offset);
}
void InterpreterAssembler::DispatchToBytecodeHandlerEntry(
TNode<RawPtrT> handler_entry, TNode<IntPtrT> bytecode_offset) {
TailCallBytecodeDispatch(
InterpreterDispatchDescriptor{}, handler_entry, GetAccumulatorUnchecked(),
bytecode_offset, BytecodeArrayTaggedPointer(), DispatchTablePointer());
}Read that bottom call carefully. TailCallBytecodeDispatch does not call a
handler and return; it replaces the current frame with the handler’s
frame. Dispatch is therefore a chain of tail calls, not a loop with a switch
in it. The state that has to survive is passed explicitly in the
InterpreterDispatchDescriptor: the accumulator, the bytecode offset, the
bytecode array and the dispatch table pointer. The last two are the same for
every handler in a function, so passing them each time is a little wasteful
- but it makes the handler entry sequence a fixed ABI, and the code generator can keep them in whatever registers the architecture likes.
The handlers themselves are generated from a big macro in
src/interpreter/interpreter-generator.cc, and each one is a small
assembler routine. LdaSmi is the whole implementation:
// src/interpreter/interpreter-generator.cc:80
IGNITION_HANDLER(LdaSmi, InterpreterAssembler) {
TNode<Smi> smi_int = BytecodeOperandImmSmi(0);
SetAccumulator(smi_int);
Dispatch();
}Load a Smi immediate into the accumulator, then tail-call the next handler. That is the whole body of the most common bytecode in the language.
The accumulator is a value in flight, not a slot
Notice what the accumulator is not: not a stack slot, and not a machine
register that has to be preserved across the tail call. It is a
TNode<Object> - a compile-time value in the CodeStubAssembler’s graph -
that gets passed to the next handler as a parameter:
// src/interpreter/interpreter-assembler.cc:176
TNode<Object> InterpreterAssembler::GetAccumulatorUnchecked() {
return accumulator_.value();
}
TNode<Object> InterpreterAssembler::GetAccumulator() {
DCHECK(Bytecodes::ReadsAccumulator(bytecode_));
implicit_register_use_ =
implicit_register_use_ | ImplicitRegisterUse::kReadAccumulator;
return GetAccumulatorUnchecked();
}
void InterpreterAssembler::SetAccumulator(TNode<Object> value) {
DCHECK(Bytecodes::WritesAccumulator(bytecode_));
...
accumulator_ = value;
}This is the most important design decision in the interpreter. Because the
accumulator is a value rather than a location, the register allocator
for the handler code can keep it in a machine register for free, and spill it
explicitly when a handler is about to make a call. The
GetAccumulator() versus GetAccumulatorUnchecked() split exists so the
code generator can assert the bytecode’s declared accumulator behaviour:
bytecodes.h lists, for every opcode, whether it reads, writes or clobbers
the accumulator. ImplicitRegisterUse::kClobberAccumulator on JumpLoop is
a good example - JumpLoop does not care what was in the accumulator, and
says so, which lets the handler skip preserving it.
The register file is the stack frame, addressed backwards
The other registers are the real register file of the register machine - and they are not in the accumulator’s world. They live in the interpreted function’s stack frame, and the interpreter computes their addresses relative to the frame pointer:
// src/interpreter/interpreter-assembler.cc:250
TNode<Object> InterpreterAssembler::LoadRegister(TNode<IntPtrT> reg_index) {
return LoadFullTagged(GetInterpretedFramePointer(),
RegisterFrameOffset(reg_index));
}
TNode<Object> InterpreterAssembler::LoadRegister(Register reg) {
return LoadFullTagged(GetInterpretedFramePointer(),
IntPtrConstant(reg.ToOperand() * kSystemPointerSize));
}The addressing is negative because of the frame layout. The register file starts at the bottom of the frame and the frame pointer points at the top, so register 0 is at a negative offset from the frame pointer:
// src/interpreter/bytecode-register.h:20
constexpr int OffsetFromFPToRegisterIndex(int offset) {
return (InterpreterFrameConstants::kRegisterFileFromFp - offset) /
kSystemPointerSize;
}and the operand encoding inverts that back:
// src/interpreter/bytecode-register.h:74
constexpr int32_t ToOperand() const {
return kRegisterFileStartOffset - index_;
}So the bytecode’s register operand is a byte-sized negative number: 0xFF means “the register one slot below the frame pointer”. A register operand fits in one byte, and the whole frame is addressed with a single negative displacement off the frame pointer, so register access needs no extra base register.
The flip side is that the register file is in the frame, so the garbage
collector scans it, and the frame has to be sized before the function runs.
BytecodeArray::frame_size carries that size, and register_count() is
derived from it rather than stored separately:
// src/objects/bytecode-array.h:70
// Note: The register count is derived from frame_size.
inline int register_count() const;Temporary registers come from BytecodeRegisterAllocator, a bump allocator
that hands out contiguous indices and releases them in LIFO order.
Sixteen opcodes squeezed into the opcode space
There is a family of sixteen bytecodes, Star0 through Star15, that do
exactly one thing: copy the accumulator into a register. The opcode is the
register number.
// src/interpreter/bytecodes.h:24
#define SHORT_STAR_BYTECODE_LIST(V) \
V(Star15, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \
V(Star14, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \
V(Star13, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \
... \
V(Star1, ImplicitRegisterUse::kReadAccumulatorWriteShortStar) \
V(Star0, ImplicitRegisterUse::kReadAccumulatorWriteShortStar)Star is one of the most common bytecodes in real code, and the plain form
costs two bytes (opcode plus register operand). The short form costs one.
The catch is that the handler has to know which register to write, and that register number is the opcode. V8 exploits the fact that the dispatch table has 256 slots per scale, of which the top sixteen are these short stars, and does the write inline in the dispatch sequence rather than in a separate handler. This is the “star lookahead”:
// src/interpreter/interpreter-assembler.cc:1344
void InterpreterAssembler::StarDispatchLookahead(TNode<WordT> target_bytecode) {
Label do_inline_star(this), done(this);
// Check whether the following opcode is one of the short Star codes. All
// opcodes higher than the short Star variants are invalid, and invalid
// opcodes are never deliberately written, so we can use a one-sided check.
// This is no less secure than the normal-length Star handler, which performs
// no validation on its operand.
static_assert(static_cast<int>(Bytecode::kLastShortStar) + 1 ==
static_cast<int>(Bytecode::kIllegal));
static_assert(Bytecode::kIllegal == Bytecode::kLast);
TNode<Int32T> first_short_star_bytecode =
Int32Constant(static_cast<int>(Bytecode::kFirstShortStar));
TNode<BoolT> is_star = Uint32GreaterThanOrEqual(
TruncateWordToInt32(target_bytecode), first_short_star_bytecode);
Branch(is_star, &do_inline_star, &done);
BIND(&do_inline_star);
{
InlineShortStar(target_bytecode);
// Rather than merging control flow to a single indirect jump, we can get
// better branch prediction by duplicating it. This is because the
// instruction following a merged X + StarN is a bad predictor of the
// instruction following a non-merged X, and vice versa.
DispatchToBytecode(LoadBytecode(BytecodeOffset()), BytecodeOffset());
}
BIND(&done);
}Two things worth noticing. First, the check is one-sided - “is this opcode
at or above kFirstShortStar?” - which is safe only because every opcode
above the short stars is illegal and illegal opcodes are never deliberately
emitted. Second, the comment about branch prediction: the lookahead is
deliberately duplicated rather than merged, because a single merged
indirect jump predicts far worse than two separate ones.
The bytecode array is four things in one object
Everything a function needs at runtime lives in one object:
// src/objects/bytecode-array.tq:7
@cppObjectLayoutDefinition
extern class BytecodeArray extends ExposedTrustedObject {
const length: Smi;
wrapper: BytecodeWrapper;
source_position_table: ProtectedPointer<TrustedByteArray>;
handler_table: ProtectedPointer<TrustedByteArray>;
constant_pool: ProtectedPointer<TrustedFixedArray>;
frame_size: int32;
parameter_size: uint16;
max_arguments: uint16;
incoming_new_target_or_generator_register: int32;
@if(TAGGED_SIZE_8_BYTES) optional_padding: uint32;
bytes[length]: uint8;
}Note the bytes[length]: uint8 tail: the bytecode stream is stored inline
at the end of the object, so fetching the next opcode is a load at
bytecode_array + offset, with no pointer chase.
The constant_pool is a TrustedFixedArray of Objects - names, Smis,
strings, jump tables. ConstantArrayBuilder partitions it into three slices
by operand width, so an 8-bit constant index and a 32-bit one share one
array without wasting space:
// src/interpreter/constant-array-builder.h:41
// Capacity of the 8-bit operand slice.
static const size_t k8BitCapacity = 1u << kBitsPerByte;
// Capacity of the 16-bit operand slice.
static const size_t k16BitCapacity = (1u << 2 * kBitsPerByte) - k8BitCapacity;
// Capacity of the 32-bit operand slice.
static const size_t k32BitCapacity =
kMaxUInt32 - k16BitCapacity - k8BitCapacity + 1;So the low 256 entries hold constants whose index fits in one byte, the next
65 280 need two bytes, the rest need four. A switch statement’s jump table
lives here too, as a run of Smis holding byte offsets, so a dense integer
range can be dispatched without a second data structure.
From AST to bytecode
Bytecode generation is a plain AST visitor. BytecodeGenerator::GenerateBytecode
sets up a context scope, a control scope and a register allocation scope, then
walks the body:
// src/interpreter/bytecode-generator.cc:1698
void BytecodeGenerator::GenerateBytecode(uintptr_t stack_limit) {
InitializeAstVisitor(stack_limit);
...
ContextScope incoming_context(this, closure_scope());
ControlScopeForTopLevel control(this);
RegisterAllocationScope register_scope(this);
AllocateTopLevelRegisters();
...The interesting work happens after generation, in
BytecodeRegisterOptimizer, which coalesces registers: it tracks
equivalence classes, notices when two registers always hold the same value,
and rewrites the bytecode to use one of them. That is why Ignition’s
register numbering in the disassembly does not match the source.
Feedback slots: the interpreter’s window on the future
Some bytecodes carry a kFeedbackSlot operand - the index of a slot in the
function’s FeedbackVector, whose kind is an enum:
// src/objects/feedback-vector.h:48
enum class FeedbackSlotKind : uint8_t {
kInvalid,
// Sloppy kinds come first, for easy language mode testing.
kStoreGlobalSloppy,
kSetNamedSloppy,
kSetKeyedSloppy,
kLastSloppyKind = kSetKeyedSloppy,
// Strict and language mode unaware kinds.
kCall,
kLoadProperty,
...
kBinaryOp,
kCompareOp,
...
kJumpLoop,
kLast = kJumpLoop // Always update this if the list above changes.
};The interpreter does not consume the type feedback - it does not read the map out of the slot and specialise on it. It uses the slot purely as a key: the index tells the inline cache machinery which cell to update. That is why Sparkplug can compile the same bytecode without knowing anything about types, and why the tiers above can recompile the function later and find real type information already waiting.
OSR: the interpreter’s escape hatch
The only place the interpreter can leave for compiled code is the backward
branch. JumpLoop carries three operands - the jump distance, the loop
nesting depth, and a feedback slot:
// src/interpreter/bytecodes.h:412
V(JumpLoop, ImplicitRegisterUse::kClobberAccumulator, OperandType::kUImm, \
OperandType::kImm, OperandType::kFeedbackSlot) \
The handler for it is where the OSR decision lives, and it is a three-stage funnel:
// src/interpreter/interpreter-assembler.cc:1516
TNode<Context> context, TNode<FeedbackVector> feedback_vector,
TNode<IntPtrT> relative_jump, TNode<Int32T> loop_depth,
TNode<IntPtrT> feedback_slot, TNode<Int8T> osr_state,
OnStackReplacementParams params) {
// Three cases may cause us to attempt OSR, in the following order:
//
// 1) Presence of cached OSR Turbofan/Maglev code.
// 2) The OSR urgency exceeds the current loop depth - in that case, trigger
// a Turbofan/Maglev OSR compilation.
// 3) Presence of cached OSR Sparkplug code.
Case 1 is the fast path: if the feedback slot already holds an OSR code object, jump straight into it. The code even checks whether that code has been marked for deoptimization and, if so, clears the slot and falls through to the slow path.
Case 2 compares the loop depth against the OSR urgency, a 3-bit field in the feedback vector:
// src/objects/feedback-vector.h:314
struct OsrState : BitFieldGroup<OsrState, uint8_t> {
using BitFieldGroup::BitFieldGroup;
using OsrUrgencyBits = First<uint32_t, 3>;
using MaybeHasTurbofanOsrCodeBit = OsrUrgencyBits::Next<bool, 1>;
using MaybeHasMaglevOsrCodeBit = MaybeHasTurbofanOsrCodeBit::Next<bool, 1>;
using DontUseTheseBitsUnlessBeneficialBits =
MaybeHasMaglevOsrCodeBit::Next<uint32_t, 3>;Only three bits, so urgency saturates at 7, and kMaxOsrUrgency is 6. The
generator caps the emitted loop depth at that value:
// src/interpreter/control-flow-builders.cc:82
// Pass the proper loop depth to the backwards branch for triggering OSR.
// For purposes of OSR, the loop depth is capped at `kMaxOsrUrgency - 1`.
// Once that urgency is reached, all loops become OSR candidates.
Case 3, the Sparkplug case, is the cheapest and is a straight tail call:
// src/interpreter/interpreter-assembler.cc:1602
BIND(&osr_to_sparkplug);
{
// We already compiled the baseline code, so we don't need to handle failed
// compilation as in the Ignition -> Turbofan case. Therefore we can just
// tailcall to the OSR builtin.
SaveBytecodeOffset();
TailCallBuiltin(Builtin::kInterpreterOnStackReplacement_ToBaseline,
context);
}The TurboFan case, by contrast, has to request a compilation and keep interpreting until the code exists - the budget is charged up front and refunded if the compilation is still running:
// src/interpreter/interpreter-assembler.cc:1587
BIND(&osr_to_opt);
{
TNode<BytecodeArray> bytecode = BytecodeArrayTaggedPointer();
TNode<Uint32T> length = LoadAndUntagBytecodeArrayLength(bytecode);
TNode<Uint32T> weight =
Uint32Mul(length, Uint32Constant(v8_flags.osr_to_tierup));
DecreaseInterruptBudget(Signed(weight), kDisableStackCheck);
TNode<Smi> expected_param_count =
SmiFromInt32(LoadBytecodeArrayParameterCount(bytecode));
CallBuiltin(Builtin::kInterpreterOnStackReplacement, context,
maybe_target_code.value(), expected_param_count);
UpdateInterruptBudget(Int32Mul(Signed(weight), Int32Constant(-1)));
JumpBackward(relative_jump);
}The JumpBackward at the end is the “keep interpreting” path: the budget
was spent, and the interpreter goes round the loop again to see whether the
code has landed.
Trade-offs
What it buys. The interpreter is fast enough that V8 can afford to start every function here. A tail-called dispatch costs roughly an indirect branch plus a load; the short-star lookahead removes a whole dispatch from the most common operation in the language; the accumulator-as-value trick keeps the hottest piece of state in a machine register for free.
What it costs.
- The register file is on the stack. Every bytecode register is a GC-scanned stack slot, so a frame with many live values is expensive.
- Handlers are code, so they are cold. The generated builtins list 202
handler entry points (
src/gen/intl-wasm/bytecodes-builtins-list.h:543), and a program using a handful of them pays for the icache misses anyway. - The tail-call chain is a long dependency chain. Every bytecode’s performance depends on the branch predictor guessing the next handler, and the handler sequence is data-dependent.
- The accumulator must be spilled around calls. Because it is a value in flight, a handler that makes a call has to put it somewhere.
The bottom line
Ignition is a register machine with a stack-resident register file and a machine-code dispatch loop. The registers are negative byte offsets from the frame pointer; the accumulator is not a register at all but a value passed between tail calls; and the dispatch table is 768 raw instruction pointers into a set of generated handlers, one per opcode, each of which ends by tail-calling the next.
The mental model to keep: Ignition does not interpret in C++. It interprets in machine code V8 generates for itself at isolate startup. Everything else in the tiering story - Sparkplug, Maglev, TurboFan - is about taking the bytecodes this tier produced and doing better with them.
The thing people get wrong
The common belief is that “V8’s interpreter is a switch statement in a
C++ loop”. That was true of the old Ignition implementation and is still how
most blog posts describe it. In the current tree it is not: the C++ file
src/interpreter/interpreter.cc contains no dispatch loop at all.
The second common belief is that Ignition’s registers are machine registers. They are not, and it matters: because the register file is in the frame, an Ignition function cannot keep a hot value in a register across a call into JavaScript - one of the reasons the tier above it exists.
The third is that Ignition uses type feedback. It does not. It only carries feedback slot indices so the inline caches have somewhere to write. The type information itself is collected by the IC machinery and read by the compilers.
Related reading: