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

Inside the interpreter loop

Ignition runs bytecode with a dispatch loop: fetch the opcode, jump to its handler, do the work, advance the bytecode pointer, dispatch the next. The accumulator threads values between handlers, feedback slots fill in as types are observed

JSE Middle ◷ 14 min
Level
FoundationsJuniorMiddleSenior

You have the bytecode. Now something has to run it — one instruction after another, millions of times a second, while quietly keeping a tally of how hot each function is getting. There is no magic here: it is a loop. Fetch the next opcode, jump to the code that handles it, do the work, advance, repeat. Understanding that loop explains both why interpreted JavaScript is predictable-but-not-fast and how the engine knows the exact moment to hand a function to the JIT.

The dispatch loop

The previous lesson left us with a function’s bytecode and a set of shared handlers (one per opcode, written in Torque). Execution is the act of running those handlers in sequence. Conceptually:

loop:
  opcode  = bytecode[pc]        // fetch the current opcode
  handler = handlerTable[opcode] // look up its handler
  jump handler                   // do the work; handler advances pc, then dispatches the next

Each iteration fetches the opcode at the current bytecode pointer (pc), looks up the handler for that opcode in a table, and jumps to it. The handler reads its operands (from the register file and the accumulator), does the operation, writes the result (usually back to the accumulator), advances the pc past this instruction’s operands, and then dispatches the next instruction. Run that loop until Return, and you have executed the function.

How the jump is made fast: threaded dispatch

A naive interpreter would write this as a giant switch (opcode) inside a while loop. That works, but it has a hidden cost: after every handler the control flow returns to the top of the loop and re-enters the switch, and the CPU’s branch predictor sees one single indirect branch shared by all opcodes — so it predicts poorly and stalls.

Ignition uses threaded dispatch instead: each handler ends by directly dispatching to the next handler — historically via a computed goto (one indirect jump per handler), and in Ignition specifically via a tail call from one handler’s machine code straight into the next handler’s. The win is for branch prediction: each opcode’s dispatch site is its own indirect branch, so the predictor can learn the common successor of each opcode (e.g. a comparison is usually followed by a branch), instead of muddling one shared branch. Fewer mispredicts means a tighter, faster interpreter loop.

The accumulator is what makes the handoff between handlers clean: rather than passing operands explicitly, most handlers read and write the one shared accumulator register, so a value produced by one bytecode is sitting right where the next bytecode expects it. The dispatch loop and the accumulator are two halves of the same design.

Profiling while interpreting: feedback fills in

The loop is not just executing — it is learning. Recall that every type-sensitive bytecode carries a feedback-vector slot. As those handlers run, they fill in the slots with the maps (hidden classes) and types they actually observe: this LdaNamedProperty saw an object with map M; this Add saw two Smis; this Call always targeted function F. By the time a function has run a while, its feedback vector is a compact profile of how it is really used — exactly the information the optimiser needs. The interpreter is priming the JIT as a side effect of running the program.

The interrupt budget: how the loop decides to tier up

How does V8 know a function is hot enough to compile? It does not literally count every instruction. Each function carries an interrupt budget (a “feedback cell budget” / invocation-and-loop counter). The loop decrements that budget on the events that correlate with heat:

  • Back-edges — every time control jumps backward to the top of a loop (one iteration of a for/while). A tight loop chews through the budget fast.
  • Function calls / invocations — entering the function again.

When the budget hits zero, the loop raises a tier-up request: compile this function with the next tier (Sparkplug, then Maglev, then TurboFan — unit 04). For a loop that is still running when it goes hot, V8 can even swap the running code mid-loop via on-stack replacement (OSR), jumping from the interpreter into freshly compiled code without waiting for the loop to finish. Back-edges are the key signal: a long-running loop is the canonical “this is hot, optimise it now” trigger.

The interpreter loop's moving parts
Dispatch style in Ignition
tail-call / threaded
Value handed between handlers via
the accumulator
Budget decremented on
back-edges + calls
Budget hits zero ⇒
tier-up request
Hot loop swapped mid-flight by
OSR
Interpreting vs optimised: speed
slower but predictable

Why interpreting first is the safe default

When you profile a function and see it spending time in the interpreter, that is not a bug — it is the engine doing exactly what it is supposed to do before it has enough data to speculate. Interpretation is slower per instruction than optimised machine code — there is a fetch-and-dispatch overhead on every bytecode. But it is predictable: there is no compile pause (the bytecode is ready immediately), and there is no deopt — the interpreter makes no speculative type assumptions, so it cannot be wrong about types and forced to bail out. Optimised code is faster but can deopt back to this loop when an assumption breaks (a deopt always lands you back in the interpreter). So Ignition is both the cheap starting point and the safety net the JIT falls back to. That is why every function begins its life right here, in the dispatch loop, and why this lesson sits at the seam between “how JS runs” and the JIT machinery of unit 04.

Quiz

Why does Ignition use threaded (tail-call) dispatch instead of one big switch statement over the opcode?

Quiz

A function contains a tight loop that runs millions of iterations. What in the interpreter triggers V8 to compile it, and via which event?

Order the steps

Order one iteration of the Ignition dispatch loop for a single bytecode.

  1. 1 Fetch the opcode at the current bytecode pointer (pc)
  2. 2 Look up that opcode's handler in the handler table
  3. 3 Jump to the handler; it reads operands and updates the accumulator
  4. 4 The handler advances the pc and dispatches the next bytecode
Why this works

Why does a deopt always land back in the interpreter rather than a lower JIT tier? Because the interpreter is the one tier that makes no speculative assumptions — it handles every type, every shape, every edge case the spec defines, just slowly. When optimised code discovers its bet was wrong (the value it assumed was a Smi turned out to be a HeapNumber), the only place guaranteed to run correctly from that exact bytecode offset is Ignition. So the dispatch loop is the universal fallback: slow, but always right. The JIT can re-optimise later once the feedback stabilises.

Recall before you leave
  1. 01
    Walk one iteration of the Ignition dispatch loop and name the role of the accumulator.
  2. 02
    What is threaded dispatch and why is it faster than a switch-based interpreter?
  3. 03
    How does the interpreter loop decide a function is hot, and what happens then?
Recap

Running bytecode is a dispatch loop: fetch the opcode at the bytecode pointer, look up its handler in the shared handler table, jump to the handler (which reads operands from the register file and the accumulator, computes, and writes the result back to the accumulator), advance the pc, and dispatch the next — repeating until Return. Ignition does not use a naive switch; it uses threaded, tail-call dispatch so each opcode has its own indirect-branch site, which the CPU branch predictor learns far better than one shared branch, cutting mispredicts. The accumulator is the clean handoff between handlers, the other half of the dispatch design. While interpreting, the loop also fills in the feedback-vector slots with the hidden classes and types it actually observes, profiling the program for free so the JIT can later specialise. To decide when to optimise, each function carries an interrupt budget decremented on back-edges and calls; when it hits zero the loop raises a tier-up request (and a still-running hot loop can be swapped via on-stack replacement). Interpreting is slower per instruction but predictable — no compile pause and no deopt — and it is the universal fallback a deopt always returns to, because the interpreter makes no speculative type assumptions. Now when you see a function stuck in interpreted mode longer than expected, you know exactly why: not enough back-edges have fired to exhaust the interrupt budget and trigger the JIT.

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