VMtypical

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.

The question

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?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

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.

What this phase may assume or do

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

typicalThe five-to-fifty range is what published interpreter comparisons generally report for numeric and control-flow-heavy microbenchmarks, and it is close to meaningless for programs that spend their time inside native library calls — NumPy-heavy Python, or a script whose real work is a regex engine written in C, can sit within a few percent of native. The ratio is a property of the workload at least as much as of the interpreter, so quote it from your own measurement or not at all.

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.

Real counts from `src/compilers/sim/vm.ts`, for `while (n < 100) { total = total + n; n = n + 1; }`
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

implementationThat type dispatch and allocation typically outweigh opcode dispatch is a statement about dynamically typed VMs such as CPython, Ruby and pre-JIT JavaScript engines. In a statically typed stack VM with unboxed primitives — the JVM interpreter running 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.

Fusing a load pair, on our own instruction set
Before
LOAD 2   ; %1
LOAD 3   ; %2
ADD
STORE 4  ; %3
After
LOAD_LOAD_ADD 2, 3
STORE 4  ; %3
Legal only when

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.

Illegal when

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

implementationCPython 3.11 introduced an adaptive specializing interpreter (PEP 659): instructions are "quickened" into specialized forms with inline caches embedded in the instruction stream, and de-specialize when the assumption stops holding. The specific opcodes, the cache layout and the specialization thresholds have all changed again in 3.12 and 3.13, and none of it is part of any stable interface. Ruby's YARV, LuaJIT's interpreter and the JVM template interpreter all attack the same costs by different means. Name the version whenever you quote any of this.

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

simplifiedThe AtlasLang VM has no superinstructions, no specialization, no stack caching and no tagged representation — its values are ordinary JavaScript values and its dispatch is a 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.

What each technique removes, and what it charges for ittypical
TechniqueRemovesChargesCeiling
Threaded dispatchtypicalShared indirect-branch history and the loop back edgePortability; a compiler extension; unreadable handlersDispatch still happens once per instruction
SuperinstructionsDispatches, and the stack write/read between the fused pairInterpreter code size; a jump-target precondition to verifyOnly fuses pairs that are actually adjacent and frequent
SpecializationimplementationType tests inside the handlerCode size, a guard per execution, and a de-specialization pathDispatch, fetch and stack traffic all remain
Stack cachingMemory traffic for the top stack entriesOne handler variant per opcode per cache stateBounded by how many entries fit in host registers
Tagged valuesimplementationAllocation and pointer chasing for small integersRange limits, masking on every use, a harder debugger storyObjects and floats often still box
A JITThe interpretation loop entirely, for hot codeWarmup, memory, non-determinism, a much larger implementationCold 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 stat for 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 stepsExecuted on every run, and /compilers/vm shows it alongside the trace.
  • perf stat -e branches,branch-misses,instructions,cycles ./interpreter prog on Linux gives the dispatch story and the cycles-per-instruction figure in one command.
  • perf record plus perf annotate on 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_stats on a debug build, or dis.dis(f, adaptive=True) on 3.12 and later to see which instructions specialized and into what.
  • The JVM: -XX:+PrintInterpreter on a debug build prints the generated template interpreter; -Xint forces 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.

JIT is always slower because compilation happens at runtime.
Compilation at runtime is a cost paid once per hot method and amortized over every subsequent execution of it, and the code produced can be *better* than a static compiler's because it knows the actual types and the actual branch bias. For a long-running program the JIT wins; for a script that exits in fifty milliseconds the interpreter wins. That is why real systems run both — see [[tiered-compilation]].
A faster interpreter makes the language competitive with C.
It closes part of a constant factor. Interpreters that have had decades of this work remain several times off optimized native code on the workloads where the difference is visible, and the ones that got close did it by adding a JIT.
Boxing is a garbage-collector problem, not an interpreter problem.
The interpreter is what decides how many boxes exist. Representation choices — tagging, unboxed fast paths, specialized handlers that keep values unboxed across an expression — are made in the VM, and the collector only ever sees the consequence.

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.

typicalThe five-to-fifty-times range for interpreters against optimized native code comes from numeric and control-flow microbenchmarks. Programs dominated by native library calls sit far closer to native; deeply dynamic programs with heavy allocation sit further away. It is a range to justify measuring, not a number to quote.
implementationCPython's adaptive specializing interpreter arrived in 3.11 (PEP 659) and its opcodes, caches and thresholds have changed in every release since; the JVM template interpreter, LuaJIT's assembly interpreter and Ruby's YARV each attack the same four costs differently. Anything asserted about "how the interpreter specializes" needs a runtime and a version attached, and will be wrong two releases later.
simplifiedOur VM implements none of these techniques and runs on a host that implements all of them one level down, so it can honestly report instruction counts and cannot honestly report costs. The instruction counts quoted here are real outputs of 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

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Value representation and the allocator: tagged words, NaN-boxing, and what a boxed integer costs the collector
    Boxing 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 replaces
    Every 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.