Virtual Machine
A language virtual machine is a loop over bytecode: fetch the opcode, do a tiny stack surgery, go to the next instruction. ADD is two pops and one push, not the plus in your source. Codegen wrote MUL first for 1 + 2 * 3 because 2 and 3 have to sit on the stack before this machine will multiply.
Think of a stack of plates. You cannot add until both numbers are on the counter. LOAD_CONST sets a plate down. MUL takes the top two, multiplies, and puts one plate back. The source string mentioned + first. The plates do not care.
If you are reading Python bytecode or writing a toy compiler, this ordering is the difference between 7 and Underflow.
How a language virtual machine works
The listing on the left is the program. The sibling lesson wrote it by walking the AST children-first, operator last. This lesson runs it. A language VM (CPython, the JVM, a toy you build in a weekend) does not read your source. It reads this compact instruction stream, one opcode at a time:
LOAD_CONST vpushesvLOAD_NAME xpushes the local namedxSTORE_NAME xpops into that localADD/MUL/SUBpopb, popa, pusha ⊙ bJUMP destwritesdestinto PCJZ destpops; if the value is 0, PC becomesdestHALTreturns whatever is left on the stack
The loop is always the same. op = code[pc]; pc += 1; dispatch(op). A loop in the program is not a construct the VM understands. It is a JUMP that writes a smaller number into PC.
Operand order is load-bearing. SUB pops b then a and pushes a - b, so LOAD n, LOAD 1, SUB is n - 1. Flip the pops and the step computes 1 - n: from 3 the countdown swings to -2, back to 3, and never reaches 0.
The JIT tab is an extension, not the default claim. After a backward jump has been taken enough times, the VM stops fetching those opcodes and runs a compiled body instead. Same bytecode. Different engine.
Virtual Machine Step by Step
The default run is Interpreter on 1 + 2 * 3. The listing is LOAD_CONST 1, LOAD_CONST 2, LOAD_CONST 3, MUL, ADD, HALT. The stack starts empty.
- The program is the listing. Six rows, PC at 0, no plates.
LOAD_CONST 1. Push 1. Height 1.LOAD_CONST 2. Push 2. Height 2.LOAD_CONST 3. Push 3. Three leaves, height 3. Every operand is on the counter; no operator has fired.MUL. Pop 3, pop 2, push 6. Height 3→2. The source mentioned+first; the first operator that actually runs is*, because*sat deeper in the tree and codegen wrote it first.ADD. Pop 6, pop 1, push 7. The 6, not the 2. MUL already ran.HALT. One value on the stack.- 7. Six bytecodes including HALT. MUL before ADD.
Flip the expression to (1 + 2) * 3 and the same VM writes 3 then 9, because that listing has ADD before MUL. Nothing about the machine changed. The listing did.
The ADD first scenario is the failure case: the default tree emitted operator-first. ADD fires with an empty stack, the VM raises Underflow, and the later loads never run. That is the stack-discipline claim from the other side.
The JIT tab swaps in a countdown (n = 3, threshold 2). Two interpreted trips, then compile, then one native beat that still does the remaining n = n - 1 work with no further fetches. The cold loop scenario is the same engine with threshold 5: hotness stalls at 2/5, Native never appears, and compile never fires. JIT is a heuristic, not a promise.
The Code panel highlights only the branch that fires. ADD lights the pops and the append, never raise Underflow. The underflow beat lights the raise. JUMP in Interpreter never lights compile_loop — that line is not in that snippet. On the JIT tab, THRESHOLD is the live slider value, so a cold loop with T = 5 shows THRESHOLD = 5.
Why MUL before ADD
Instruction order is the listing, and the listing is the tree. A stack VM is the reason codegen used post-order: an operator that ran first would underflow. (1 + 2) * 3 is the same numbers with + deeper, so ADD fires first and the peak height is 2 instead of 3. Nobody swapped tokens. The parser built a different tree, codegen walked it, and this machine just believes the listing.
Each opcode is O(1). A straight-line program of k bytecodes costs k fetches. A counted loop of n trips costs O(n) fetches until the JIT compiles. The remaining r trips then play as one native beat on this canvas — no more bytecode fetch or dispatch — but the arithmetic is still Θ(r). JIT does not skip the countdown. Peak stack height on the default is 3 — the number of leaves before the first operator — and space is that height, not the length of the source.
Edge Cases
- Empty stack at start. TOP is hidden. A stale
stack = 0is not printed next to an empty tape. - Underflow.
ADD(orMUL/SUB) with fewer than two cells raises and stops. The rest of the listing stays on screen and does not run. The right header stays off through the stop frame —stack = 0 · pc = 0next to the exception would look like a result. - A name is a local.
x + 2 * 3withx = 10pushes 10, then 6, then 16.LOAD_NAMEis a push from the locals panel, not a constant. - A loop is stack-balanced. The countdown's JUMP fires with an empty stack. The plates are temporary; the lasting state is
n. - A cold loop never compiles. Two trips, hotness 2, threshold 5. Status ends on
still interpreting · hot 2/5. Native never appears. - HALT is a real last row. The VM does not fall off the end. Codegen wrote the ops; this lesson appends
HALTso PC has an exit it can name. The default listing is six bytecodes including that halt, five of them stack surgeries. - PC advances before dispatch.
pc += 1happens before JUMP overwrites it. Advance-after would turnJUMP 2into a landing on 3. The CPU lesson taught the same split withJNZ.
Common Mistakes
- Reading
ADDas the plus in the source. The plus already happened at parse time, as tree shape.ADDis two pops and a push, and it runs when the listing says so. - Emitting the operator first. That is the
ADD firstscenario. A stack VM underflows; a register VM would be a different machine. - Reading the stack panel as the IR. The sibling's right-hand listing was the program being written. These cells are the runtime plates that program uses.
- Thinking JIT skips the arithmetic. The native beat still does
n = n - 1until exit. It just does not fetch. Closed-formn = 0would be a different (lying) animation. One native beat is not O(1) arithmetic. - Incrementing PC after a JUMP. The loop overwrites PC. Advance-after-dispatch turns
JUMP 2into a landing on 3.
A Note on Simplification
This is an explainer, not CPython or HotSpot. The ISA is toy stack bytecode. There are no call frames, no heap, no objects, no GC, no types. The Native strip is fused source (n = n - 1 / goto loop), not x86, and there is no deoptimization. A register VM (LuaJIT, V8, Dalvik) is a different cast. A hypervisor (KVM, type-1 vs type-2) is a different category — virtual memory already owns "the address is a lie."
The loop is the part that stays true. Fetch, tiny stack surgery, next opcode. A loop is a jump that writes PC. A JIT is that loop with a hotness check. If you are writing a bytecode interpreter, you are already on that split.
Keep exploring
A few lessons that share the same technique or lead into the next idea.
Premium players need paid access. Every Explain doc is free.
Found a mistake or something unclear? Leave feedback.