Where an Interpreter's Time Actually Goes
An interpreter runs the same algorithm as native code and takes roughly an order of magnitude longer to do it. The gap is a constant factor made of dispatch, type tests, boxing and memory traffic — and knowing which of the four is yours is the difference between a real speedup and a week spent on the wrong one.
My bytecode interpreter is about ten times slower than the same algorithm in C. Where is that time going, and how much of it can I actually get back?
The program is still the bytecode array from [[bytecode]]; what changes here is the unit of account. The representation this lesson works in is a cost budget per executed instruction — fetch, dispatch, operand access, type test, unbox, do the work, rebox, store — laid over the same instruction stream the dispatch loop walks. That budget exists to answer a question the disassembly cannot: of the tens of machine instructions standing behind one bytecode operation, which ones are the operation and which ones are the interpreter.
Every technique in this lesson replaces a general execution path with a narrower one, and each is legal only under a stated precondition. A superinstruction may replace an instruction pair only if no jump target lands between the two members, because a jump into the middle of a fused instruction has nowhere to land. A specialized opcode may execute only while the operand representations it was specialized for still hold, so it must re-check them on every execution and fall back otherwise. An inline cache may be consulted only after a guard establishes that the cached key still matches. Remove any of those three conditions and the interpreter does not get faster, it gets wrong.
Key points
- The gap between an interpreter and native code is a constant factor, not a complexity difference — the same operations happen, each one costing more.
- Four costs make up that factor: opcode dispatch, run-time type tests, boxing, and memory traffic through the operand stack and slots.
- Which cost dominates depends on the language's type discipline; in dynamically typed VMs, dispatch is frequently third, and optimizing it first wastes the effort.
- Superinstructions cut the number of dispatches and are legal only where no jump, exception or breakpoint can target the middle of the fused window.
- Specialization cuts the work inside a handler by betting on observed operand representations, and needs a per-execution guard plus a fallback — the JIT pattern at interpreter scale.
- Every technique enlarges the interpreter, and interpreter size pushes against the instruction cache, so the gains are not additive.
- None of it removes the loop. Removing the loop for hot code is what a JIT is for, and that is a different bill.
Ten times, not ten percent
Start with the honest framing, because it sets what is worth doing. A well-built bytecode interpreter is commonly quoted at somewhere between five and fifty times the runtime of equivalent optimized native code, depending overwhelmingly on the language's dynamism and on what fraction of the work happens inside library routines the interpreter merely calls. That is a constant factor. The interpreter executes the same number of logical operations as the compiled version; each one simply costs more.
Which means the constant factor is the only thing on the table, and it also means the constant factor is worth less than people expect. A quadratic algorithm written in a language with a fast interpreter still loses to a linear one written in a slow interpreter, at every input size that matters. Interpreter engineering buys a multiplier; algorithm choice buys an exponent. Spend the multiplier deliberately, and only after measurement has said which multiplier you actually have.
Our own VM makes the arithmetic concrete without any hardware claim attached to it. A hundred-iteration accumulation loop — six lines of AtlasLang — executes 2,819 bytecode instructions to produce one number. Each of those instructions is a full loop iteration in [[dispatch-loop]]: a fetch, an unpredictable indirect branch, a handler body, a back edge. The compiled equivalent of that loop is a handful of machine instructions per iteration and no dispatch at all.
status halted bytecode executed 2,819 instructions trace rows recorded 500 (the trace limit, not the work) output 4950
Four costs, and finding out which one is yours
int arithmetic, or a WebAssembly interpreter — the types were resolved at verification time and dispatch really is the dominant term. Which cost dominates is a property of the language's type discipline, so carry the measurement, not the conclusion.The per-instruction budget has four large entries and they are not equally sized in every VM, which is exactly why this lesson is about diagnosis before technique. [[dispatch-loop]] covers the first of them in detail; the other three are where a dynamically typed language usually loses.
Dispatch is the indirect branch from the loop to the opcode handler, and its cost is dominated by misprediction rather than by the branch itself. Type dispatch is asking, at run time, what the operands are before you can operate on them — a chain of tests in front of every arithmetic operation that a statically typed compiler resolved once, at compile time, and emitted nothing for. Boxing is that every value is a heap object or a tagged word rather than a machine integer, so an addition is an unbox, an add and possibly an allocation. Memory traffic is the operand stack and the slot array living in memory rather than in registers, so values move to and from cache lines on every operation.
The diagnosis is a measurement, not an argument. perf stat gives you branch misses and instructions retired; an allocation profiler gives you the boxing rate; a cycles-per-instruction figure over your bytecode counter gives you the budget itself. In dynamically typed VMs the answer is very often that dispatch is third on this list, and optimizing it first is the classic wasted week.
- Dispatch — one indirect branch per bytecode instruction. Reduced by threading; *removed* only by not interpreting. A mispredicted indirect branch costs a full pipeline refill.
- Type dispatch — "is this an int, a float, a string or an object?" asked before every operation. Reduced by specialization and inline caching; the compiler-side equivalent is
[[static-vs-dynamic-typing]]deciding the question before the program runs. - Boxing — a value that is a pointer to a heap cell rather than a machine word. Reduced by tagged representations (NaN-boxing, pointer tagging, small-integer tags) and by unboxed fast paths.
- Memory traffic — stack and slot accesses that a register machine would keep in registers. Reduced by stack caching: hold the top one or two operand-stack entries in host registers and generate one handler variant per cache state.
- Interpreter code size — the cost that pushes back on all four fixes. Every specialized handler, replicated handler and superinstruction enlarges the interpreter, and a large interpreter thrashes the instruction cache, which is why a heavily specialized VM can measure *slower* on a program that touches many opcodes.
Superinstructions: fewer, bigger instructions
If the tax is per instruction, the structural fix is to execute fewer instructions for the same work. A superinstruction is a single opcode whose handler performs what two or more adjacent opcodes would have done, chosen because that adjacency is common in real bytecode. LOAD/LOAD/ADD is one of ours; LOAD_FAST/LOAD_FAST was fused into LOAD_FAST_LOAD_FAST in CPython for the same reason.
The saving is not the arithmetic — that was always going to happen. The saving is the two dispatches and the two intermediate stack writes and reads that vanish between them. The transformation is a peephole pass over the instruction stream, the same shape as [[peephole-optimization]] on machine code, and it has the same precondition: the window being rewritten must not be entered from the middle.
LOAD 2 ; %1 LOAD 3 ; %2 ADD STORE 4 ; %3
LOAD_LOAD_ADD 2, 3 STORE 4 ; %3
Only if no jump or branch in the whole function targets the instruction indices of the second or third members of the window, and no exception, safepoint or debugger breakpoint may be delivered at an index inside the window. If either could happen, control would have to resume at an instruction that no longer exists. In a VM with a step budget or an interruption poll, the fused instruction must also count as the number of instructions it replaced, or a budget stops meaning what it meant.
A loop whose back edge targets the ADD directly — a header re-entering mid-window — has a jump target inside the fused range, and the fusion silently redirects it to the start of the superinstruction, re-executing both loads. The observable symptom is a loop that computes the right answer for one iteration and the wrong one thereafter. The same applies to a debugger setting a breakpoint on the fused range: the instruction it wants to stop at is not in the array any more.
Specialization: paying the type question once
The other structural fix attacks the type test rather than the dispatch. If a particular ADD site has seen two machine integers on every execution so far, rewrite that site — in the running bytecode — into an integer-specialized ADD_INT whose handler does one cheap check that both operands are still integers and then adds. The general handler stays available; the specialized one is a bet with a fallback.
That structure should look familiar, because it is the interpreter-sized version of everything in the next module. The observation is [[profiling-and-hotness]]. The bet is [[speculative-optimization]]. The cheap check is what [[guards]] describes. The fallback is a miniature [[deoptimization]]. Caching the resolved answer at the site rather than re-deriving it is [[inline-caches]]. An adaptive interpreter is a JIT that emits bytecode instead of machine code, and understanding it that way is worth more than any individual technique in it.
The gain is real and bounded. Specialization removes the checks; it does not remove the dispatch, the fetch, or the stack traffic between instructions. Getting those requires generating native code for a whole region at once, which is [[jit-compilation]] and the reason the next module exists.
The ceiling, and what our VM is honest about
switch. It is a structurally faithful interpreter and a useless performance model, because the host engine underneath it is doing every technique in this lesson to our own dispatch loop. Read it for instruction counts; read CPython's ceval.c or the JVM template interpreter for costs.Stack the techniques and you reach a wall. Threading reduces the cost per dispatch. Superinstructions reduce the number of dispatches. Specialization reduces the work inside a handler. Stack caching reduces the memory traffic between handlers. Each is a fraction of a fraction, each enlarges the interpreter, and the interpreter's size pushes back through the instruction cache. Past this point the remaining gap is not a technique you have not applied; it is the loop itself.
Our VM applies none of this, deliberately, and it is worth saying exactly why rather than implying otherwise. It is written in JavaScript, running on a host engine that is itself dispatching, specializing and JIT-compiling our interpreter one level down. Any timing taken from it measures the host, not the technique. What it can honestly show is the *count* — 2,819 instructions for a hundred-iteration loop, visible at /compilers/vm — and counting is where an interpreter performance investigation should start anyway.
| Technique | Removes | Charges | Ceiling |
|---|---|---|---|
| Threaded dispatchtypical | Shared indirect-branch history and the loop back edge | Portability; a compiler extension; unreadable handlers | Dispatch still happens once per instruction |
| Superinstructions | Dispatches, and the stack write/read between the fused pair | Interpreter code size; a jump-target precondition to verify | Only fuses pairs that are actually adjacent and frequent |
| Specializationimplementation | Type tests inside the handler | Code size, a guard per execution, and a de-specialization path | Dispatch, fetch and stack traffic all remain |
| Stack caching | Memory traffic for the top stack entries | One handler variant per opcode per cache state | Bounded by how many entries fit in host registers |
| Tagged valuesimplementation | Allocation and pointer chasing for small integers | Range limits, masking on every use, a harder debugger story | Objects and floats often still box |
| A JIT | The interpretation loop entirely, for hot code | Warmup, memory, non-determinism, a much larger implementation | Cold code still interprets — see [[jit-costs]] |
How it works
The steps, in the order the compiler takes them.
- Count first: instrument the dispatch loop with a per-opcode counter and record how many bytecode instructions the workload executes, which is the quantity every technique below divides.
- Attribute second:
perf statfor branch misses and retired instructions, an allocation profiler for the boxing rate, and a cycles-per-bytecode-instruction figure derived from the two. - Collect the frequent adjacent opcode pairs from the same counters, and generate a fused handler for each pair worth having.
- Run a peephole pass over each function's bytecode that rewrites those pairs, after computing the set of instruction indices that are jump targets and refusing to fuse across any of them.
- Rewrite jump operands to account for the indices the fusion removed, and keep a mapping from fused indices back to original ones for the debugger and for any stack trace.
- For specialization, give each candidate site a small mutable cache in the instruction stream; on execution, check the cached key against the actual operand representations, take the fast path on a hit and re-specialize or fall back on a miss.
- Bound the churn: count consecutive misses and de-specialize a site permanently once it has proven polymorphic, so a shape-changing site does not pay the rewrite cost forever.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A superinstruction is fused across a jump target, and a loop produces the correct value on its first iteration and wrong values afterwards — with no error, and a disassembly that no longer contains the instruction the debugger is trying to stop at.
- A specialized handler omits or weakens its guard, and the fast path runs on operands it was not specialized for. The symptom is a wrong number, or a string silently coerced, appearing only after the site has executed enough times to specialize — so it never reproduces in a short test.
- Specialization thrashes: a call site alternates between two operand shapes, re-specializing on every execution, and the interpreter measures slower than the unspecialized build on exactly the workload the feature was added for.
- Enough handlers are replicated and specialized that the interpreter no longer fits in the instruction cache, and a program touching many opcodes regresses while the microbenchmark that motivated the work improves.
- A per-instruction profiling counter is added to the dispatch loop to find the hot opcodes, and the measurement halves the interpreter's speed — so the profile describes an interpreter nobody ships.
- Instruction budgets, timeouts or fuel accounting are computed from a post-fusion instruction count, so a program that used to hit the limit now silently runs twice as long.
When it helps
- A long-lived interpreter with a stable workload, where a twenty percent constant factor is paid back over years of production runtime.
- Environments where a JIT is not available or not permitted: locked-down platforms without writable-executable memory, tiny embedded targets, or sandboxes where the extra attack surface is unacceptable — see
[[jit-costs]]. - Startup-dominated and short-lived processes, where an interpreter that is twice as fast beats a JIT that has not finished warming up.
- Any VM where measurement shows a single dominant cost — a boxing rate that explains the whole gap is a much better problem to have than a diffuse one.
When it hurts
- When the workload spends its time inside native library calls. Making the interpreter twice as fast changes a small fraction of a small fraction, and the profile said so before you started.
- When the real fix is algorithmic. A multiplier applied to the wrong complexity class is the most expensive kind of successful optimization.
- When the interpreter is also the specification of the language, as it is for many small languages. Every specialized path is another place the semantics can drift from the general one.
- When maintainability matters more than throughput. A threaded, replicated, specialized interpreter is significantly harder to modify, and adding a language feature to one is a much larger change than adding it to a
switch.
What it costs
Every one of these is paid by something.
- Superinstructions buy fewer dispatches and pay in interpreter code size, in a jump-target analysis that must be exactly right, and in a disassembly that no longer matches the instructions the compiler emitted — which the debugger and every stack trace then have to undo.
- Specialization buys the removal of type tests and pays a guard on every execution, a de-specialization path that must be tested, and non-determinism: the same program now runs at two different speeds depending on how much it has executed.
- Stack caching buys memory traffic and pays by multiplying handler count by the number of cache states, which is the fastest way there is to overflow the instruction cache.
- Tagged value representations buy allocation and pointer chasing and pay in value-range limits, masking on every access, and a debugging experience where a memory dump no longer contains recognisable integers.
- Every one of these buys a constant factor and pays in the thing that decides whether an interpreter survives: how hard it is to add the next language feature to it.
What else you could do
What a different compiler or language does instead, and when that is better.
- Do nothing to the interpreter and add a JIT for hot code, which removes the loop instead of shrinking it —
[[jit-compilation]], at the cost of warmup, memory and a much larger implementation. - Move the hot work out of the interpreted language entirely, into native extension code that the interpreter merely calls. This is how most numerical Python is actually fast, and it costs a foreign-function boundary and a build step.
- Change the instruction set instead of the loop: a register bytecode executes materially fewer instructions for the same program, which attacks the same total from the other side —
[[register-based-vm]]. - Compile ahead of time and skip interpretation, accepting the loss of portability and startup speed —
[[aot-compilation]]. - Compile to someone else's already-optimized VM. Targeting the JVM, .NET or WebAssembly inherits a decade of this work at the price of fitting your semantics to their machine —
[[wasm-vs-native]].
See it for yourself
The flag, dump or tool that shows you this directly.
- Count instructions before optimizing anything: our VM reports
stepsExecutedon every run, and/compilers/vmshows it alongside the trace. perf stat -e branches,branch-misses,instructions,cycles ./interpreter progon Linux gives the dispatch story and the cycles-per-instruction figure in one command.perf recordplusperf annotateon the interpreter binary shows how many machine instructions each opcode handler actually compiles to — the honest denominator for any claim about dispatch cost.- CPython:
python -X opcode_statson a debug build, ordis.dis(f, adaptive=True)on 3.12 and later to see which instructions specialized and into what. - The JVM:
-XX:+PrintInterpreteron a debug build prints the generated template interpreter;-Xintforces interpretation so you can measure the interpreter alone against the tiered default. - For boxing specifically, an allocation profiler is the right tool, not a CPU profiler, because the cost shows up as collector time rather than as interpreter time.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Interpreters are slow because interpretation is inherently slow." They are slow for four separable, measurable reasons, and in any given VM one of them usually dominates. "Inherently" is what people say instead of measuring.
- "Making the dispatch faster is how you speed up an interpreter." It is how you speed up an interpreter whose dispatch dominates. In a dynamically typed VM the type tests and the allocation usually cost more, and the profile will say so in about a minute.
- "A superinstruction is just an optimization, so it cannot change behaviour." It changes the instruction indices, and anything that names an instruction index — a jump, a breakpoint, a stack trace, an instruction budget — is affected. It is a transformation with a legality condition like any other.
- "JIT is always slower for short programs, so a specializing interpreter is strictly better." Warmup is a real cost and a real reason to pick an interpreter, but "strictly better" is not what a tiered system measures — the crossover point is a workload property, and
[[tiered-compilation]]exists to have both. - "If I close the gap to native, the language becomes fast." The gap you can close is a multiplier on the work you are doing. Doing less work is a different and usually larger lever.
Misconceptions
The claim, and what is actually true.
[[tiered-compilation]].Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
An interpreter does the same work as compiled code but pays overhead on every single operation: working out which operation it is, working out what the values are, unwrapping them, doing the actual arithmetic, and wrapping the answer back up. Multiply that overhead by every operation the program performs and you get the famous "roughly ten times slower". Nothing about it is mysterious, and none of it changes how many operations the program needs.
practical
Measure before you touch anything, and measure the right thing. Count bytecode instructions executed first — if the number is enormous, the fix is fewer instructions, not faster ones. Then take branch-misses and an allocation profile. If allocation dominates, work on value representation. If branch misses dominate, work on dispatch. If neither does, your time is in native calls and the interpreter is not your problem. The one thing not to do is add per-instruction instrumentation and then trust the timings it produces: the instrumentation is itself a per-instruction cost, and it changes the answer.
advanced
The unifying view is that dispatch cost, instruction granularity and operand specificity are three faces of the same variable. Threading reduces cost per dispatch; superinstructions reduce dispatch count; specialization reduces work per dispatch by removing checks that observed values made unnecessary; and a JIT takes all three to the limit at once by turning a region into straight-line native code with no dispatch, no per-operation type test and values held in registers. Each step trades interpreter code size for speed, and code size is not free — it competes for the instruction cache with the very program it is executing. That tension has a real consequence: past some point the correct move stops being "make the interpreter better" and becomes "stop interpreting this code", which is the entire argument of the next module. The interesting engineering question in a modern VM is therefore not how fast the interpreter is, but where the crossover sits and how cheaply the system can move code across it.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
src/compilers/sim/vm.ts; no timing from it appears anywhere in this lesson.If you were asked this in an interview
- Your bytecode interpreter is eight times slower than the C version. What do you measure first, and what would each possible answer tell you to do?
- You fuse two adjacent opcodes into one. What must you check before doing it, and what breaks if you skip the check?
- A colleague proposes computed-goto dispatch to fix a performance problem in a dynamically typed VM. Talk them through whether that is the right lever.
- What is the difference between a specializing interpreter and a JIT, and at what point does the distinction stop mattering?
Connections
- Programming Languages & Runtime Internals — Value representation and the allocator: tagged words, NaN-boxing, and what a boxed integer costs the collectorBoxing is one of the four costs measured here, and the interpreter decides how many boxes exist — but the object layout, the allocation path and the collector that pays for them are the runtime's half of the story. The compiler-side decision and the runtime-side cost only make sense read together.
- Testing & Reliability Engineering — Differential testing a specialized fast path against the general path it replacesEvery specialization and every superinstruction is a second implementation of an operation that already had one, and the failure mode is a silent divergence under operand shapes no unit test used. Running both paths and comparing is the technique that catches it, and it is owned there.