ONE LINE · FOUR RUNTIMES · 23 FORMS · ZERO CPUs THAT CAN READ IT
Your CPU cannot read a single character of the code you write. Everything between the two is translation — and “interpreted vs compiled” is really one question about when that translation happens. We follow one line of arithmetic, called a million times, through Python, JavaScript, the JVM and Go, counting every form it takes on the way down. The scenes play themselves — watch, or just scroll. are tappable.
Start with the uncomfortable fact at the bottom of the whole stack: the processor in the machine you are reading this on cannot read code. It reads numbers that mean “multiply these two registers.” Between the line you write and those numbers sits every compiler, interpreter, virtual machine and JIT ever built — and the only thing that really distinguishes them is when they do their work. This chapter sets the board: the destination, the spectrum, the bytecode trick, and the million-call race. Forms so far: 2.
Here is our line: total = total + price * qty. And here is the entire problem — no CPU ever made can read it. What a processor executes is : numbered operations on numbered registers, four of them for this line, encoded as the bytes 48 0f af d1 and friends. The vocabulary is fixed in silicon at the factory. Researchers cataloguing x86-64 found 981 distinct mnemonics across 3,684 instruction variants, and not one program in history has ever added a 3,685th. Everything in this tutorial happens in the gap between that line and those bytes.
So somebody has to translate. The only real question is when — and that question has a whole spectrum of answers, not two. You can translate everything before you ship (, like Go). You can translate to an intermediate form at build time and finish the job at runtime (the JVM). You can translate at startup and then interpret forever (classic CPython). Or you can start interpreting immediately and compile only the parts that prove they are worth it (, like V8). Each choice buys one of startup speed, peak speed, or portability, and sells another.
Three of our four runtimes reach for the same trick, so it is worth meeting early: — instructions for a CPU that was never manufactured. You get to design the instruction set around your language instead of around a 1978 chip, and the result is compact, portable, and easy to check. Most of them are : no register names to encode, so instructions shrink to a byte or two. Our line becomes five of them — push, push, multiply, add, store. Watch the values move.
Now the event. The same line goes into four files — Python, TypeScript, Kotlin, Go — each wrapped in a function, and each function is called one million times. That number is not decoration: it is chosen to trip every threshold in this story. Somewhere in those million calls V8 promotes the function three separate times, HotSpot promotes it twice, CPython rewrites its own bytecode, and Go does absolutely nothing, because Go finished before the program started. We count every distinct form the line takes along the way. Two are already banked: the text you typed, and the machine code it must become.
The hardware is interpreting too. An x86 chip decodes each instruction into one or more micro-ops and caches the results, because the instruction set is a 1978 interface nobody wants to execute literally. Your “native” code is itself a portable format being translated by a translation layer you cannot see — the same idea as bytecode, one floor down.
CISC, RISC, and why ARM decodes faster. x86 instructions vary from 1 to 15 bytes, so finding where the next one starts is real work. ARM64 instructions are all 4 bytes, which is why ARM chips can decode many in parallel cheaply — one reason the same JIT tiers behave differently on a phone.
Microcode updates. The mapping from instructions to micro-ops is itself patchable firmware — Spectre and Meltdown mitigations shipped partly as microcode. The CPU’s interpreter gets bug fixes, just like CPython’s.
Assembly is a translation too. Nobody writes the bytes. Assemblers turn mnemonics into encodings, resolve labels into offsets, and leave relocations for the linker to fill in — a small compiler that everybody forgets to count.
Python is the honest one: it never pretended to be fast, so it never had to hide the machinery. Which makes it the best place to see the machinery. Follow our line through CPython 3.14 exactly as it happens: the compile step everybody forgets, the real disassembly with its strange twelve-byte gaps, the dispatch loop that costs you 50×. Then the two things that have happened since 2022, which are turning the world’s favourite interpreted language into something else. Forms entering: 2. Leaving: 7.
Python is the language everyone calls interpreted, so start by disproving half of it. Before your first line runs, CPython tokenizes the file, parses it into an , and compiles that tree into packed into a . This is not a metaphor — the step has output files. Import a module and Python writes the compiled result to __pycache__/name.cpython-314.pyc so the next run can skip straight to execution. Three forms banked in a step nobody notices: tokens, tree, bytecode.
Here is the real disassembly of our line on CPython 3.14 — and it holds two surprises. The first: our two variable loads were fused into one instruction, LOAD_FAST_BORROW_LOAD_FAST_BORROW, a superinstruction that does the work of two. The second is the offsets. Every instruction is exactly 2 bytes — opcode plus argument — yet the first BINARY_OP sits at offset 6 and the second at offset 18. The twelve-byte gap is not code. It is blank scratch space reserved inline, and it is where the next two steps of this chapter happen.
Now the part that really is interpretation. At the heart of CPython is a loop in ceval.c that fetches the next instruction, decodes it, jumps to the code that implements it, and comes back for the next — around 200 opcodes, dispatched forever. Each lap costs real work that has nothing to do with your arithmetic, and it is this dispatch toll, not some vague slowness, that puts pure-Python number crunching roughly 50–100× behind C. An pays it on every instruction, every time; a compiler pays it once, at build time, and never again.
This is where that blank scratch space gets used. Since 3.11 and PEP 659, CPython watches its own instructions run: a BINARY_OP that keeps being handed two integers gets rewritten in place into an integer-only version, with a guard in case a string shows up later. The generic instruction that had to ask “what are these things?” becomes one that already knows. This is why 3.11 arrived 1.25× faster on average — up to 60% on some benchmarks — with nobody changing a line of their code. Form 6: bytecode that is no longer the bytecode that was compiled.
And in 3.13 the interpreted language grew a (PEP 744), by refusing to write a compiler backend at all. The approach pre-compiles a machine-code stencil for each bytecode operation when CPython itself is built, leaving holes where the operands go; at runtime the JIT copies stencils and fills in the blanks. Almost no compile time, almost no memory, and real machine code at the end — 10–30% on hot CPU-bound loops. It is off by default and it will do precisely nothing for the Django endpoint that spends its life waiting on Postgres. Form 7, and Python has crossed to the other end of the spectrum.
Why NumPy is fast and your loop is not. NumPy does not make Python faster — it makes the interpreter get out of the way. One dispatch enters a C loop that runs a million multiplications without ever coming back. Same trick as a JIT: amortize the toll over as much work as possible.
PyPy takes the other road. A meta-tracing JIT that records hot loops and compiles them, running unmodified Python around 4.8× faster than CPython on its own benchmark suite. Same language, same source file, different pipeline — the cleanest proof that “Python is slow” is a statement about an implementation.
The 3.14 tail-call interpreter. Replacing the big switch with one function per opcode that tail-calls the next gave the compiler a much better shot at register allocation. Early headlines said 10–15%; careful re-measurement against a properly built baseline put it nearer 3–5%, because the original baseline had tripped over an LLVM regression. A useful reminder about interpreter benchmarks.
.pyc invalidation. By default a cached .pyc is trusted if the source’s mtime and size match — which is why editing a file inside the same second can, very rarely, run stale bytecode. Hash-based invalidation exists for build systems that cannot trust timestamps.
The GIL, briefly. None of this chapter is about threads, but the free-threaded builds shipping since 3.13 change what the specializing interpreter is allowed to assume — self-modifying bytecode is much harder when two threads might execute the same instruction at once.
JavaScript has no build step, ships as source over a network, and must start doing useful work before the user notices — a set of constraints that should produce a simple interpreter and nothing else. Instead it produced the most elaborate execution pipeline in this tutorial: one interpreter and three compilers, each earning its keep on a different slice of the million calls. This chapter walks all of it, using the real bytecode Node prints for our line. Forms entering: 7. Leaving: 14.
TypeScript’s contribution to runtime performance is exactly zero, and it is important to understand why: the types never arrive. happens at emit, and no JavaScript engine has ever seen an annotation. Node makes this completely literal — its built-in type stripping does not re-print your program, it overwrites the annotations with spaces, so every remaining character keeps the exact line and column it had and stack traces stay honest without a source map. Two forms banked: what you wrote, and the JavaScript that is left after the types are painted out.
V8 will not even parse a function until somebody calls it — a cheap pre-parse finds where it ends and moves on. On the first call our function is fully parsed and compiled to Ignition , 11 bytes of it. Where CPython pushes and pops, Ignition is a built around an accumulator: Ldar a2 loads a register into it, Mul a1 multiplies in place, Star a0 stores it back. Fewer, fatter instructions. Ignition was added in 2016 for an unglamorous reason: baseline machine code was eating phone memory, and bytecode is a quarter to a half the size.
Look again at that disassembly and you will see something CPython does not have: Mul a1, [1] and Add a0, [0] carry a trailing index. Those are slots in a feedback vector — the note-taking is compiled into the instruction itself. Every arithmetic site, every property access, every call records what actually turned up: which types, and which the objects had. A site that has only ever seen one shape is monomorphic and can be compiled to a fixed memory offset; a site that has seen four gives up and goes megamorphic. These are not bookkeeping. They are the entire fuel supply for everything in the next two steps.
Now our million calls. V8 does not have a compiler; it has four, and our function will visit all of them. Call 1 runs in Ignition. After roughly a hundred calls Sparkplug compiles the bytecode straight to machine code — no intermediate representation at all, just a walk over the bytecode emitting the interpreter’s own moves inline, which is why it compiles in the time TurboFan spends thinking. Somewhere in the hundreds, Maglev (shipped in Chrome 117) produces properly optimized code quickly. Past a thousand, TurboFan takes the and generates code that assumes they will hold. Three more forms, one function, four compilers — and this is not waste. It is the only way to be fast on call one and fast on call a million.
TurboFan’s output is a bet: these are small integers, this object has that shape, this call goes there. The bet is checked on every entry, never assumed — and when a guard fails, throws the optimized code away and drops the function back into bytecode mid-execution, rebuilding the interpreter’s stack frame from the optimized one as it falls. Hand our function the string "3" once, after a million clean integer calls, and down it goes. This is the deal every speculative JIT makes: enormous speed on the assumption that your program keeps doing what it has been doing, and a trapdoor for the moment it does not. Same trick the runtimes use everywhere: optimize for the common case, keep an exit.
Why Maglev had to exist. Sparkplug is nearly free but barely faster than the interpreter; TurboFan is transformative but expensive enough that a function must be very hot to deserve it. That left a hole exactly where most page-visit code lives — hot enough to matter, not hot enough to justify TurboFan. Maglev fills it, and it is the newest tier for a reason: it was the last gap anyone found.
Small integers, and why 0.1 changes everything. V8 represents small integers as tagged values inside a pointer rather than heap objects. Stay in that range and arithmetic is register-speed; introduce one fractional value and the site’s feedback changes, the assumptions shift, and previously-optimized code may be discarded.
On-stack replacement, again. A function called a million times gets promoted between calls. A function called once containing a million-iteration loop would never be promoted at all — so V8, like HotSpot, can swap a running loop onto optimized code without waiting for the call to return.
Code caching across page loads. Compiling the same framework bundle on every visit is pure waste, so V8 caches compilation results and reuses them on later loads — build-time compilation smuggled into a runtime that officially has no build step.
Everybody does this. JavaScriptCore runs four tiers of its own; SpiderMonkey runs three. Independent teams, same conclusion, same shape.
“Write once, run anywhere” shipped a specific technical bargain in 1995: the compiler you run does almost no optimizing, and everything real is deferred to a runtime that will know things the compiler could not. Thirty years of that deferral is why warmed-up Java is within sight of C, why the JVM became a platform for languages that are not Java at all, and why your microservice takes eight seconds to reach full speed. Kotlin walks in through the same door and gets the same treatment. Forms entering: 14. Leaving: 18.
Compile our line with javac and then with kotlinc, and the JVM cannot tell which is which: both emit the same stack-machine — load, load, load, multiply, add, store — into a .class file whose first four bytes are ca fe ba be. That magic number is not an acronym. Gosling needed a marker, was grepping for four-letter hex words that could follow CAFE, found BABE, and used it; the team’s lunch spot was a Grateful Dead haunt they called Café Dead, and CAFEDEAD marked the object format that no longer exists. Thirty years later every class file on Earth still opens with the joke.
Before a single instruction executes, the JVM proves things about the bytecode it was handed. walks every path through every method and checks that the operand stack can never underflow or overflow, that types line up at every merge point, that jumps land inside the method, and that an integer is never used as a reference. Code that fails is rejected at the door rather than crashing later. This is the applet era’s paranoia — arbitrary code arriving over a network from strangers — surviving as plain defense in depth, and it is only affordable because bytecode is simple enough to reason about. Nobody verifies machine code.
Then the JVM does something Python never does: it starts slow deliberately. Our method runs interpreted at first, profiling as it goes, until roughly 200 invocations earn it C1 — a fast compiler producing decent code that keeps collecting profile data. Around 5,000 it earns C2, which compiles slowly and produces code that has been near C for two decades. Ask HotSpot to show its work and you can watch it: our method (all 7 bytes of it) is compiled at tier 3 and then at tier 4 within the same millisecond of a million-call loop, after which the C1 version is marked made not entrant: not used — thrown away the moment something better existed. And a loop that is entered once and runs forever would never trigger a promotion at all, so swaps the running loop onto new code without waiting for it to return.
What C2 buys with those 5,000 calls is knowledge no ahead-of-time compiler can have. It knows this call site only ever received one concrete class, so it can turn a virtual call into a direct one and then it — and inlining is the optimization that makes the others possible, because it hands them one big block of code instead of ten small ones. It knows this branch is never taken, so it need not compile it. It knows this bound check always passes, so it hoists it out of the loop. Every one of these is a bet, and when a bet fails C2 takes the exit it calls, with total honesty, the uncommon trap — , re-profile, recompile. Same playbook as TurboFan, twenty-five years more tuning.
All of which is a magnificent deal for a server that runs for a month and a terrible one for a command-line tool that runs for 80 milliseconds. So Java grew the other end of the spectrum. GraalVM native image compiles into a native binary with no JIT inside it: a Spring service that takes two to four seconds to start on the JVM starts in well under a hundred milliseconds as a native image, using a fraction of the memory. The price is everything the JIT knew — slightly lower peak throughput on long runs, a closed world where reflection must be declared at build time, and build times measured in minutes. Java, when it wants to, can live exactly where Go lives.
Why a stack machine. Stack bytecode needs no register allocation in the compiler that emits it and no register names in the encoding, so class files stay small on a 1995 network link and verification stays tractable. The cost — that the JVM must map the stack onto real registers at runtime — is exactly the cost a JIT was going to pay anyway.
invokedynamic. Added in Java 7 for languages that are not Java, then used by Java itself for lambdas and string concatenation: a call site that bootstraps itself on first execution and then behaves like a normal, inlinable, optimizable call. It is how one bytecode set serves Kotlin, Scala, Clojure and Groovy without growing new instructions for each.
Project Leyden and the AOT cache. The newest attempt to keep the JIT and skip the warmup: record what happened in a training run — loaded classes, method profiles — and ship it so the next start begins partly warm. Go’s answer to the same problem is in the next chapter, and the two are converging from opposite directions.
Graal: a JIT written in Java. The compiler that compiles Java is itself Java, which means it JIT-compiles itself before it gets fast at compiling your code. Delightfully circular, and entirely practical.
Kotlin’s other backends. The same Kotlin source compiles through LLVM to a native binary, or to JavaScript, or to WebAssembly. The clearest possible demonstration that the language and the execution pipeline are separate choices.
Go is the control group. No bytecode, no virtual machine, no interpreter, no JIT, no tiers, no warmup, no deoptimization — every drop of translation happens before the binary exists, and what ships is machine code with a runtime bolted to it. Which makes this the chapter where you can finally see what all that runtime machinery was buying, by watching what it costs to go without. Forms entering: 18. Leaving: 23 — the last of them is the one the CPU actually runs.
Go started, by its authors’ own account, while a C++ build was running — a build long enough that three engineers had time to start designing a language instead of waiting for it. Compilation speed was therefore not an optimization anybody added later; it was a founding requirement that shaped the type system, the dependency model and the refusal of a preprocessor. The bet was that if builds are effectively instant, you stop missing the interpreter’s edit-and-run loop. Rebuilding our program after a one-character edit takes about 0.16 seconds, and that is with the entire pipeline in this chapter running from scratch.
No bytecode, no VM, no tiers. go build parses to an , type-checks, lowers to form, runs dozens of optimization passes over it — inlining, escape analysis, bounds-check elimination — lowers again to architecture-specific SSA, and emits machine code. Five forms, all of them gone by the time the binary exists, and the payoff is worth seeing: on Apple silicon our entire function compiles to a single instruction, MADD R1, R0, R2, R0 — one fused multiply-add — followed by a return. Eight bytes. This is the same work a JIT does at three in the morning in production, done once, on your laptop, where a slow answer costs nobody a request.
There is a bill for this. A Go hello-world binary here is 2.1 MB; the equivalent C binary is 16.8 KB — 128 times smaller. The difference is not bloat, it is the runtime: the garbage collector, the goroutine scheduler, growable stacks and reflection metadata all ship inside every executable you build, because there is no interpreter to install and no VM to find. That is precisely the trade Go wanted. One file to copy, no runtime to match, containers built FROM scratch, and the same two environment variables cross-compile it for a machine you do not own. Deployment is the feature you paid two megabytes for.
Go runs at full speed from its first instruction — there is no because there is nothing left to compile. But it also arrives knowing nothing. Where C2 and TurboFan can see that an interface call has only ever received one concrete type and turn it into an inlined direct call, Go’s compiler has to be honest: it never watched the program run. Since 1.21 the answer is — collect a CPU profile from production, drop it next to the source, and the next build inlines what actually turned out to be hot, typically 2–7% for the trouble. It is warmup, submitted as homework, and it is the ahead-of-time camp openly admitting the JIT camp had something worth stealing.
One last twist, and it closes a loop. Go’s own compiler is written in Go — bootstrapped in 2015 by machine-translating the original C. And in 2025 Microsoft rewrote the TypeScript compiler in Go: builds roughly 10× faster, editor project loads about 8× faster (9.6 seconds down to 1.2 in their own example), memory roughly halved, same type-checking results, shipped as TypeScript 7. Chapter 03’s toolchain now runs in chapter 05’s lane. The tool that checks your JavaScript types decided, on the evidence, that it would rather be compiled ahead of time — which is as good a summary of this whole tutorial’s argument as anything: these are engineering trade-offs, and they get re-made. Forms: 23. The line is finally machine code, and finally running.
Escape analysis. The compiler proves whether a value can outlive the function that made it. If it cannot, it lives on the stack and the garbage collector never hears about it — an optimization worth more than most micro-tuning, decided entirely at build time.
The prove pass. Go propagates known facts over the dominator tree, which is how a bounds check inside a loop over a slice of known length gets deleted rather than executed a million times. A JIT reaches the same conclusion from observation; Go reaches it from proof.
Cross-compiling is two variables. GOOS and GOARCH, and you have a Linux ARM binary from a Mac with no toolchain to install. This is the quiet superpower of having no VM: there is nothing to port to the target, because the target gets finished code.
Why no JIT, ever. The team’s position has been consistent: predictable performance, a simple deployment story, and no compiler running in your production process are worth more than the last few percent a speculative JIT could win. PGO is the compromise they were willing to make.
Other Go pipelines exist too. gccgo puts Go’s frontend on GCC’s optimizer; TinyGo puts it on LLVM to target microcontrollers and WebAssembly. Even the control group has more than one implementation.
All four runtimes have now run our line a million times. This chapter reads the stopwatches — and then takes the question apart, because “which one is fastest” has at least three correct answers depending on which clock you look at and how long you plan to stay. No new forms here: the count stopped at 23. What is left is the verdict, and the reason the verdict keeps moving.
Start with the clock nobody optimizes for and everybody feels. Timing an empty program on one laptop — best of fifteen runs, with the cost of spawning a process subtracted out — the Go binary is running in about 2 ms, python3 -c pass takes about 21, node -e 0 about 24, and a bare JVM with an empty main about 42. Roughly an eighteenfold spread before a single line of anybody’s actual program has run. Every millisecond of it is explained by this tutorial: Go has nothing left to do, CPython must build its objects and import its modules, Node must initialize V8, and the JVM must load, verify and link classes before it will execute one instruction.
Now let them run. Past the first few thousand calls the podium inverts: our line under C2 or TurboFan is doing the same fused arithmetic the Go compiler emitted, occasionally better, because the JIT knew things at runtime that no build-time compiler could prove. CPython, if you leave its JIT off, stays 50–100× back on pure arithmetic and never catches up. So there is no winner, only a crossing point. For a script that lives 100 milliseconds, is the entire cost and the interpreter wins. For a server that lives 50 days, warmup rounds to zero and the JIT wins. HotSpot’s interpreter is deliberately losing the first 5,000 calls to buy a profile that wins the next billion — a trade that is either brilliant or absurd depending only on how long you intend to stay.
Which is the moment to retire the sentence “Python is slow.” Python is a language specification. CPython interprets it; PyPy traces and JIT-compiles it about 4.8× faster on its own suite; Cython compiles a dialect of it to C; GraalPy runs it on the JVM’s optimizer. Java is HotSpot’s two JITs or GraalVM’s ahead-of-time binary. JavaScript is V8’s four tiers, or JavaScriptCore’s four, or QuickJS interpreting in a few hundred kilobytes. Every one of those is a different point on chapter 01’s spectrum, running the same source. Speed is a property of an implementation — and of what you measured, on which day.
And the pins on that spectrum will not hold still. Python, the interpreted one, grew a in 3.13. Java, the bytecode one, grew native images and is now shipping method profiles so warmup can start warm. JavaScript, the one with no build step, gets compiled to bytecode before shipping on every React Native app that uses Hermes. Go, the one that refuses to compile at runtime, now reads production profiles at build time. Four camps, four moves, all toward the centre — and underneath them waits as a bytecode designed for everybody rather than for one language. The title of this tutorial poses a dichotomy. The correct answer is that it was always a dial, and everyone is still turning it.
The number one way JIT benchmarks lie. Include warmup and the JIT looks terrible; exclude it and the JIT looks unbeatable. Both numbers are real and neither is the answer on its own — the honest report is a curve over time, plus how long your process actually lives.
Why microbenchmarks flatter JITs. A tight loop over one type is the most monomorphic code that can exist: every inline cache stays clean, every speculation holds. Real applications hand the same call site four shapes and a null, and production throughput lands well below the microbenchmark.
Three podiums, not one. Startup, peak throughput and memory are separate races with different winners. A serverless function that runs for 80 ms cares only about the first; a trading system cares only about the second and about tail latency, where a compile pause at the wrong moment is worse than being slightly slower everywhere.
How the numbers here were measured. One Apple silicon laptop, best of fifteen runs, with the cost of spawning a process measured separately and subtracted. Good enough to compare orders of magnitude on this machine; not a benchmark suite, and not a claim about your hardware. Re-run it on yours — the four commands are in the step above.
Here is everything our line was on the way down. Four pipelines, four sets of trade-offs, one destination — and in every case the last chip is the only one the hardware has ever been able to read. Every other chip on this board exists because somebody decided that translating later was worth more than translating sooner, or the exact reverse.
Same four runtimes, different question: how Java, Node, Go and Python hold 10,000 clients — this one was about how they run, that one is about how they wait.