ONE LINE · FOUR RUNTIMES · 23 FORMS · ZERO CPUs THAT CAN READ IT

Interpreted vs compiled:
how one line reaches the CPU

ONE LINE OF ARITHMETIC
source · what you wrote
total = total + price * qty
bytecode · what the runtime runs
LOAD_FAST priceLOAD_FAST qtyBINARY_OP *BINARY_OP +STORE_FAST
machine code · what the CPU executes
48 0f af d148 01 d048 89 45 f8

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.

01

The gap

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.

STEP 01 / 04
THE ONLY LANGUAGE THE CPU SPEAKS
what you wrote · what the CPU can actually execute
total = total + price * qty
↓ somebody has to translate this
48 8b 05movrax, [price]
48 0f af 05imulrax, [qty]
48 01 05add[total], rax
c3ret
x86-64 vocabulary: 981 mnemonics · fixed at the factory

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.

even your machine code gets translated — x86 chips decode every instruction into internal micro-ops before executing it
STEP 02 / 04
TRANSLATE NOW OR TRANSLATE LATER
the only real question: when does the translating happen?
Go
JVM
V8
CPython
← before you shipwhile it runs →
Goall of it, at build time
JVMbytecode now, machine code when hot
V8interpret first, compile what proves itself
CPythoncompile to bytecode, then interpret forever

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.

“compiled vs interpreted” isn’t a property of a language — CPython, PyPy, Cython and GraalPy are four different answers for the very same Python
STEP 03 / 04
A CPU THAT DOESN’T EXIST
bytecode
PUSH total
PUSH price
PUSH qty
MULTIPLY
ADD
STORE total
operand stack
40
push

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.

V8’s bytecode is a quarter to a half the size of the machine code it replaced — it exists because phones ran out of RAM
STEP 04 / 04
A MILLION CALLS, FOUR CONTESTANTS
one line · four pipelines · one million calls each
total.pytotal += price * qty·····························?
total.tstotal += price * qty;·····························?
Total.kttotal += price * qty·····························?
total.gototal += price * qty·····························?
calls: 1,000,000forms counted so far: 2 of 23

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.

by the end this one line will have existed in 23 different forms — every one of them was somebody’s job to invent
deep dive: below machine code

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.

02

Python: the honest interpreter

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.

STEP 01 / 05
PYTHON COMPILES TOO
what happens before your first line runs
total.py
tokens
AST
code object
__pycache__/total.cpython-314.pyc
Assign
BinOp(Add)
Name total
BinOp(Mult) → price, qty
the compile step has output files — most people have just never looked

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.

every Python program is compiled before its first line runs — the .pyc files in __pycache__ are the receipts
STEP 02 / 05
TWO BYTES, AND TEN BYTES OF SCRATCH
dis.dis(add_line) · CPython 3.14 · offsets in bytes
2LOAD_FAST_BORROW_LOAD_FAST_BORROWtotal, price← two loads, one opcode
4LOAD_FAST_BORROWqty
6BINARY_OP*
18BINARY_OP+
30STORE_FASTtotal
6 → 18 =2 bytes of instruction + 10 blank
the whole line: 36 bytes · the blanks are reserved on purpose

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.

a BINARY_OP is 2 bytes of instruction followed by 10 bytes of deliberately blank scratch space — the whole line is 36 bytes
STEP 03 / 05
THE GIANT SWITCH
ceval.c — the loop your program actually lives inside
fetch
decode
jump to case
BINARY_OP *
your arithmetic:1 multiply· the toll to get there:fetch + decode + jump
paid on every instruction, on every one of 1,000,000 calls — this, not “scripting”, is the 50–100× gap to C

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.

the heart of CPython is one giant switch statement dispatching ~200 opcodes — your arithmetic is the cheap part of every lap
STEP 04 / 05
BYTECODE THAT REWRITES ITSELF
PEP 659 — the interpreter watches itself, then edits itself
offset 6
BINARY_OP
the 10 blank bytes:int × intint × intint × int
same instruction, no longer asking “what are these things?”guard: if a string ever arrives, fall back to the generic one

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.

Python 3.11 got ~25% faster on average by letting its bytecode rewrite itself while running — your code never changed
STEP 05 / 05
PYTHON GROWS A JIT
PEP 744 — no compiler backend, just stencils with holes
built when CPython was built
mov rax, [____]imul rax, [____]add [____], raxjmp ____
patch
filled in at runtime
mov rax, [price]imul rax, [qty]add [total], raxjmp next_op
hot loops: −10 to 30%waiting on Postgres: 0%

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.

CPython’s JIT doesn’t write machine code — it copies pre-built stencils and fills in the blanks
deep dive: the rest of the Python iceberg

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.

03

JavaScript: four compilers in a trench coat

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.

STEP 01 / 05
FIRST, DELETE THE TYPES
type stripping — the annotations are painted out, not removed
.ts
function addLine(total: number, price: number): number {
.js
function addLine(total: number, price: number): number {
same length · same columns · 56 characters either way
no engine has ever seen a TypeScript type · 55 chars in, 0 types out

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.

Node runs TypeScript by replacing your types with blank spaces — the line numbers survive, the types do not
STEP 02 / 05
ELEVEN BYTES, AND AN ACCUMULATOR
node --print-bytecode · Ignition · a register machine with an accumulator
00b 05Ldar a2
241 04 01Mul a1, [1]
53f 03 00Add a0, [0]
81a 03Star a0
10b3Return
baseline machine code
bytecode · 11 bytes
CPython needed 36 bytes for the same line — stacks are chatty, accumulators are not

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.

V8 added its interpreter in 2016 to save phone RAM — Chrome’s memory for non-optimized JavaScript fell ninefold on ARM64 Android
STEP 03 / 05
THE ENGINE TAKES NOTES
the feedback vector — one slot per site, written on every call
Mul a1, [1]
SmiSmiSmiSmi
monomorphic · compile it
Mul a1, [1]
SmiDoubleStringObject
megamorphic · give up
the trailing [1] in the bytecode is the slot number — V8 compiled its own note-taking into the instruction

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.

V8 keeps notes on every multiply in your program — the slot number is right there in the bytecode
STEP 04 / 05
FOUR ENGINES, ONE FUNCTION
V8 — the same function, promoted three times
call 1Ignition
~10–100Sparkplug
~100–1,000Maglev
1,000+TurboFan
addLine()
calls1
bytecode, interpreted — running before anything is compiled

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.

one hot function gets compiled four separate times by four different compilers — and that is the fast design, not the slow one
STEP 05 / 05
THE TRAPDOOR
speed built on assumptions"3"
call 1Ignition
~10–100Sparkplug
~100–1,000Maglev
1,000+TurboFan
addLine()
calls1,000,000
✕ CheckSmi failed
assumes: total, price, qty are all small integers

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.

one unexpected string makes V8 throw away its best machine code mid-function and fall back to bytecode
deep dive: engine-room extras

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.

04

JVM: start slow on purpose

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

STEP 01 / 05
COFFEE, TWO WAYS
Total.javajavac · kotlincTotal.kt
cafebabe00000046
Total.class · magic + class file version 70
lload_0lload_2lload 4lmulladdlstore_0
the JVM cannot tell Kotlin from Java — by design, since 1995

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.

every .class file since 1995 opens with 0xCAFEBABE — chosen by grepping for hex words that could follow “CAFE”
STEP 02 / 05
THE BOUNCER
verification — run once, before instruction one
Total.classarrives from anywhere
operand stack never underflows
types agree at every merge point
every jump lands inside the method
no integer used as a reference
Evil.classVerifyError — rejected at the door, never executed
affordable only because bytecode is simple — nobody verifies machine code

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.

the JVM proves your bytecode cannot corrupt the operand stack before it runs one instruction of it
STEP 03 / 05
TWO HUNDRED CALLS, THEN FIVE THOUSAND
HotSpot tiered compilation — our 7-byte method, starting slow on purpose
call 1interpreter
200 callsC1 · tier 3
5,000 callsC2 · tier 4
addLine()
calls1
⟳ OSR — the running loop swapped onto new code, mid-iteration
interpreted, and profiling from the very first call

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.

HotSpot compiled our 7-byte method twice in the same millisecond, then discarded the first version as “not used”
STEP 04 / 05
PROFILE-GUIDED GAMBLING
what C2 buys with 5,000 calls of profile
addLine()mul()checkBounds()
three calls
one inlined block
now everything else can see inside
guard: receiver was always ArrayListelse → uncommon trap · deoptimize · re-profile

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.

C2’s escape hatch is literally named the “uncommon trap” — hit one and your optimized code evaporates
STEP 05 / 05
SKIPPING THE WARMUP
the same Spring service, compiled two ways
JVM
~2–4 s to first request
native image
< 100 ms
startupsecondsmilliseconds
memoryfull heap + JITa fraction
peak throughputthe JIT winsslightly lower
reflectionanything, anytimedeclared at build
Java can live at Go’s end of the spectrum — it just has to give up the JIT

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.

the same Spring service: seconds to start on the JVM, under 100 ms as a native binary — the price is giving up the JIT
deep dive: thirty years of JVM machinery

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.

05

Go: everything before breakfast

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.

STEP 01 / 05
BORN IN A 45-MINUTE BUILD
Google, 2007 — compile speed as a design requirement
00:00the C++ build starts
00:20three engineers start sketching a language
00:45the build finishes
our program, rebuilt:0.16 s
the bet: if builds are instant, nobody misses the interpreter’s edit-and-run loop

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.

Go was designed while a 45-minute C++ build ran — compile speed was a founding feature, not a perk
STEP 02 / 05
THE WHOLE PIPELINE, BEFORE YOU SHIP
go build — every stage runs before the binary exists
source
AST
generic SSA
prove / opt
arm64 SSA
machine code
total += price * qty
the whole function, on arm64: one fused multiply-add and a return — 8 bytes, decided at build time

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.

our whole function became one instruction — MADD, a fused multiply-add, 8 bytes including the return
STEP 03 / 05
TWO MEGABYTES OF HELLO
what is actually inside a Go hello world
your codegarbage collectorgoroutine schedulerreflection + type metadatastdlib you touched
go · 2.1 MB
c · 16.8 KB
no VM to install, no runtime to match — one file to copy

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.

a Go hello world is 2.1 MB against C’s 16.8 KB — the garbage collector and scheduler ship inside every binary
STEP 04 / 05
NO WARMUP, AND NO NOTES
what each side gets to know
JIT · at runtime· which types actually arrived· which branches actually ran· may gamble, and deoptimize
AOT · at build time· everything about the code· nothing about the run· must be right the first time
productiondefault.pgonext build· +2–7%
profile-guided optimization: warmup, submitted as homework

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.

Go 1.21’s answer to the JIT: record production profiles, compile them into the next build — warmup as homework
STEP 05 / 05
THE AHEAD-OF-TIME LANE POACHES A COMPILER
tsc leaves the lane it was written for
JIT lane · ch 03tsc, in TypeScript
tsgo
AOT lane · ch 05the same compiler, in Go
project load9.6 s1.2 s
buildbaseline~10× faster
memorybaseline~half
same language, same types, same results — only the pipeline underneath changed

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.

TypeScript’s own compiler moved to Go — ~10× faster builds by leaving the JIT lane for the ahead-of-time one
deep dive: life without a JIT

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.

06

The race: a million calls later

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.

STEP 01 / 04
FOUR STOPWATCHES
time to run an empty program · best of 15 · spawn cost subtracted
go binary
python3
node
java
an 18× spread, entirely explained by how much pipeline still has to run

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.

measured on one laptop: Go ~2 ms, Python ~21 ms, Node ~24 ms, JVM ~42 ms — before your code does anything
STEP 02 / 04
THE CROSSOVER
speed over time — there is no winner, only a crossing point
faster →
process startwarmupsteady state
a 100 ms script → the interpreter winsa 50-day server → warmup rounds to zero

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.

HotSpot’s interpreter loses the first 5,000 calls on purpose — the profile it buys wins the next billion
STEP 03 / 04
LANGUAGES DON’T HAVE SPEEDS
one language, many pipelines — pick a row, find a strategy
python
CPythoninterpPyPyJITCythonAOTGraalPyJIT
java
HotSpotJITnative imageAOT
javascript
V8JITJavaScriptCoreJITHermesAOTQuickJSinterp
go
gcAOTgccgoAOTTinyGoAOT
no row has one strategy — “Python is slow” names an implementation, not a language

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.

PyPy runs unmodified Python around 4.8× faster than CPython — the language didn’t change, the pipeline did
STEP 04 / 04
EVERYBODY MEETS IN THE MIDDLE
chapter 01’s spectrum, with the 2026 pins
Go
JVM
V8
CPython
← before you shipwhile it runs →
Go · PGOJVM · native imageV8 · Hermes, code cacheCPython · copy-and-patch JIT
ghost ticks: where each one stood in chapter 01 · below it all, wasm waits

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.

Hermes compiles JavaScript to bytecode before the app ships — “interpreted vs compiled” is a slider, not a switch
deep dive: reading benchmarks without being lied to

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.

Σ

One line, 23 forms

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.

EVERY FORM OF total = total + price * qty · IN TOUR ORDER
the two ends
01source text02machine code
01
python
03tokens04AST05CPython bytecode06specialized bytecode07copy-and-patch code
02
javascript
08TypeScript09stripped JS10JS AST11Ignition bytecode12Sparkplug code13Maglev code14TurboFan code
03
jvm
15Kotlin / Java16JVM bytecode17C1 code18C2 code
04
go
19Go source20Go AST21generic SSA22arm64 SSA23machine code
05
one line of arithmetic·······································23 forms · 1 that runs

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.

c3 · ret — x86-64 for “we are done here”