Bytecode
An intermediate executable representation: a flat array of instructions over an instruction set you designed, sitting between the syntax tree the frontend produced and the machine code you decided not to emit.
Why compile to bytecode instead of just running the syntax tree, or going all the way to machine code?
A linear, index-addressable array of instructions over a fixed opcode set, plus a table of constants and a table of local slots. The program is no longer a graph reached by pointer; it is a tape, and "what happens next" is answered by an integer — the instruction pointer — rather than by a traversal. That is the question this representation exists to answer, and it is the question a tree answers worst.
Emitting bytecode is legal only if executing the emitted sequence under the VM's defined semantics produces the same observable behavior as the source program under the language's rules — the same output, in the same order, with the same faults raised at the same points. Concretely: the linearization must preserve any evaluation order the language fixed for side-effecting operands, and every value the source could observe must still be reachable from some slot or stack position at the moment it is observed.
Key points
- Bytecode is an instruction set you designed, for a machine you wrote — the freedom is real, and so is the obligation to define its semantics precisely enough to be reimplementable.
- The core win over a tree is that "what happens next" becomes an integer instead of a traversal, which shrinks the runtime state model to something you can write down.
- Compactness comes from implicit operands: a stack machine's
ADDneeds no operand fields because its inputs are already located by convention. - Linearity is a cache argument, not an aesthetic one: instructions that execute in sequence are fetched in sequence.
- Bytecode discards structure. Anything a debugger, profiler or JIT needs about the source has to be carried in a side table, deliberately.
- A bytecode format is an interface. Whether it is stable is a policy decision — the JVM says yes, CPython says no — and it changes what people can build on top of it.
A tree has no instruction pointer
A syntax tree says what is applied to what. It does not say what happens next, because "next" is not a property a tree has — you can only get it by walking, and walking means the traversal's own call stack is the real program counter. That works, and [[tree-walk-interpreter]] is a completely legitimate implementation strategy, but it means the state of the running program is smeared across the interpreter's own recursion rather than held anywhere you can point at.
Bytecode replaces the traversal with an index. The whole program becomes an array; execution becomes ip = ip + 1 with occasional assignments to ip; and the machine's state becomes small enough to write down: an instruction pointer, an operand stack, some slots. That compression is what makes [[vm-state-model]] a short lesson and a tree-walker's state model a long one.
print(1 + 2); — three nodes and no orderRead it asNothing in this tree says the literal 1 is touched before the literal 2. That ordering is a decision the language makes and the code generator writes down; the tree merely permits it. Linearizing the tree is precisely the act of committing to one of the orders the language allows — which is why [[bytecode-compiler]] is a lesson about evaluation order and not only about opcodes.
The same program as a tape
LOAD_CONST/ADD/RETURN spelling is CPython's, and CPython's bytecode is explicitly not stable: opcodes are added, removed and renumbered between minor versions, 3.11 added inline caches and 3.12 changed the frame layout again, so a .pyc from one minor version is not loadable by another. The JVM is at the other extreme — its instruction set is specified and versioned, and a class file from 1998 still verifies. Both are "bytecode"; only one is a stable interface.The textbook shape of bytecode for 1 + 2 is four instructions: push a constant, push another constant, add them, return. Most stack VMs you will meet spell it roughly that way, and the names differ more than the ideas do — CPython says LOAD_CONST, the JVM says iconst, ours says PUSH.
The right-hand listing below is not a sketch. It is exactly what our compiler emits for print(1 + 2);, including the STORE 0 / LOAD 0 pair in the middle, which exists because the bytecode is generated from three-address IR where the addition had a named destination register — see [[three-address-code]]. A hand-written code generator would not emit that pair. Ours does, and showing the real output rather than the tidy one is the difference between teaching a VM and teaching a diagram.
LOAD_CONST 1 LOAD_CONST 2 ADD RETURN
▸ 0 PUSH 1▸ 1 PUSH 2▸ 2 ADD▸ 3 STORE 0 ; %0▸ 4 LOAD 0 ; %0▸ 5 PRINT▸ 6 PUSH 0▸ 7 RETURN▸ 8 HALT
Read it asThe two highlighted instructions are the seam where an IR-driven code generator shows through: %0 was the destination register of the IR's add, and the stack machine has no registers, so it becomes a local slot that is immediately stored and immediately reloaded. It is dead weight, and a peephole pass over the bytecode would delete the pair — see [[peephole-optimization]]. We leave it in so that the mapping from IR to bytecode stays one-to-one and readable.
What an instruction is made of
{ op: 'PUSH', operand: 1 }. Nothing is encoded, nothing is decoded, and the operand is a JavaScript value of whatever type the literal had. A real format packs an opcode byte and its operands into a byte array, which forces decisions we get to skip — operand width, endianness, alignment, and what happens when a jump target exceeds the operand field. Those decisions are [[machine-code-encoding]] at a smaller scale, and skipping them is why our dispatch loop is honest about structure and useless about instruction size.An instruction is an opcode plus zero or more operands. In our set, six opcodes take an operand and seventeen do not: PUSH carries a literal, LOAD and STORE carry a slot index, JUMP and JUMPF carry an absolute instruction index, and CALL carries an argument count plus the callee. Everything else — ADD, LT, NEG, PRINT, POP, RETURN, HALT — is a bare byte, because its inputs are already on the stack.
Jump targets being *absolute indices* rather than relative offsets is a real design choice with a real cost. Absolute targets make the disassembly readable and the patch step trivial, and they make the code array non-relocatable: you cannot concatenate two functions' code without rewriting every jump. Relative offsets are the opposite trade, which is why most production formats use them.
PUSH 1— operand is the literal itself, so no separate constant pool is needed. Real formats use a pool, because the same literal recurs and an index is smaller than a value.LOAD 0/STORE 0— operand is an index into the function's slot array. The *name* is a comment for the disassembler, not something the VM reads: at runtime a local is a number.JUMPF 33— pop one value, jump to instruction 33 if it is falsy. One opcode does the test and the transfer, so a conditional costs one dispatch rather than two.CALL add / 2— arity is the operand; the callee is resolved by name in this VM, which is why one of its failure statuses isunknown-function.HALT— emitted at the end of every function as a backstop. In practice it is often unreachable, because a well-formed function returns first.
Why linear beats a tree, and loses to machine code
The three arguments for bytecode over a tree are all about locality and repetition. It is compact, because the operands are implicit. It is linear, so the next instruction is usually the next array element and the hardware prefetcher gets it right — the same property that makes cache lines matter for data makes them matter for an instruction array. And it is decoded once per operation rather than once per node visit plus a virtual dispatch to find out which kind of node it was.
The arguments against are equally plain. Bytecode is still interpreted, so every operation pays a dispatch that native code does not pay at all; that gap is the subject of [[interpreter-performance]]. And the linearization discards the tree, which means anything a later stage wanted to know about program *structure* has to be recovered or carried separately. A bytecode format that wants a debugger needs a side table mapping instruction indices back to source spans — see [[debug-information]].
| Representation | Finding the next operation | Cost per operation | What it gives up |
|---|---|---|---|
| AST | Recursive traversal; the host language's call stack is the program counter | A pointer chase, a type test and a virtual dispatch per node | Nothing — it is the richest form. It is simply the slowest to run. |
| Bytecodetypical | ip + 1, or an assignment to ip | One decode and one dispatch per instruction | Structure. Spans, scopes and types survive only in side tables. |
| Machine codetarget | The hardware instruction fetcher | Whatever the pipeline charges; no software dispatch at all | Portability, and the ability to be generated cheaply. |
How it works
The steps, in the order the compiler takes them.
- The frontend produces a typed AST, and lowering produces three-address IR over virtual registers with a fixed evaluation order.
- The code generator walks the IR in block order, emitting a fixed instruction sequence for each IR instruction and recording where each block starts in the growing code array.
- Jumps are emitted with a placeholder target and recorded in a patch list, because a forward branch names a block whose address is not yet known.
- After the last block, every patch is resolved to the recorded start index of its target block, turning symbolic block names into absolute instruction indices.
- Virtual registers become synthetic local slots; parameters occupy slots 0 through arity-1 so that a call can store arguments into them positionally.
- A
HALTis appended so that a function whose control flow runs off the end stops rather than reading past the array.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A jump target is patched to the wrong block and the program silently executes the wrong branch — no error, just an answer that is wrong for some inputs and right for others.
- The generator emits a push without a matching consume, and the operand stack grows by one on every loop iteration. Nothing fails for the first ten thousand iterations; then memory climbs and a long-running program dies far from the bug.
- A slot index is off by one and a function reads another variable's value. The observable symptom is a variable that "changes on its own" between two adjacent statements.
- The bytecode format changes and previously cached compiled artifacts are loaded by a VM that decodes them differently, producing garbage behavior rather than a clean version error — the reason every real format carries a magic number and a version word.
- Source spans are not carried alongside the instructions, so every runtime error reports an instruction index and the engineer cannot map it back to a line.
When it helps
- You want an implementation that starts fast and runs everywhere, and you are willing to be an order of magnitude off native speed to get it.
- You need a portable distribution format: bytecode is the artifact you can ship once and run on any host with the VM, which is the whole argument of
[[wasm-vs-native]]in a different setting. - You want a stable surface for tooling. A profiler, a coverage tool and a debugger can all be written against the instruction stream without touching the compiler.
- You intend to add a JIT later. A bytecode interpreter is the natural bottom tier, and
[[tiered-compilation]]assumes one exists.
When it hurts
- Numeric or tight-loop code where the dispatch overhead dominates the actual work. Ten instructions of interpreter bookkeeping to perform one addition is a bad ratio and no amount of opcode design fixes it.
- Very short programs, where the time spent generating bytecode is not repaid by executing it. A tree-walker would have finished already.
- Cases where you needed the tree. Once you have linearized, source-level transformations are much harder, which is why refactoring tools work on the AST and not on the bytecode.
What it costs
Every one of these is paid by something.
- Bytecode buys portability and a small state model, and pays a software dispatch on every single operation — a cost native code does not pay at all, and one that shows up as a constant factor across the whole program.
- Implicit operands buy compact instructions and pay in instruction count: a stack machine needs a separate instruction to move each operand into position, so the same work takes more dispatches.
- Adding a compiler stage buys a faster execution loop and pays in implementation surface — a code generator, a disassembler, a verifier and a test suite that a tree-walking implementation simply does not need.
- A stable bytecode format buys an ecosystem of third-party tools and pays by freezing your instruction set: every future optimization has to fit inside instructions you defined before you knew you needed them.
What else you could do
What a different compiler or language does instead, and when that is better.
- Walk the AST directly. Simpler, no code generator, no format, and slower per operation by a large factor —
[[tree-walk-interpreter]]. - Compile ahead of time to machine code and skip the VM. Fastest steady state, no portability, and a much larger backend to write —
[[aot-compilation]]. - Use a register bytecode instead of a stack bytecode: fewer, larger instructions, and a more complicated code generator —
[[register-based-vm]]. - Emit closures rather than instructions — "closure compilation", where each AST node becomes a host-language function called by its parent. It keeps the tree's simplicity while removing the per-node type dispatch, and is a common middle option in host languages with cheap closures.
- Target someone else's bytecode. Emitting JVM class files, .NET IL or WebAssembly gives you a mature VM, a JIT and a debugger for free, at the cost of your semantics having to fit their machine —
[[webassembly]].
See it for yourself
The flag, dump or tool that shows you this directly.
- Step our real bytecode instruction by instruction at
/compilers/vm: type any AtlasLang program and the disassembly, the operand stack and the local slots update together. - CPython:
python -m dis script.py, ordis.dis(f)on a function object. Compare the same function on two minor versions to see how unstable the format is. - JVM:
javap -c -p Foo.classprints the verified instruction stream, andjavap -vadds the constant pool and the line-number table that maps instructions back to source. - Lua:
luac -l -l file.luaprints the register-based instruction listing, which is the cleanest way to see the contrast with a stack machine. - .NET:
ildasmordotnet-ildasmfor IL; the ECMA-335 specification defines the instruction set formally, which is worth reading once for what a specified bytecode looks like.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Bytecode is machine code for a fake CPU." It is an instruction set designed for a software interpreter, and that changes what is worth including: complex, high-level opcodes are cheap for a VM and expensive for hardware, which is why VM instruction sets look nothing like an ISA.
- "Compiling to bytecode makes the program fast." It makes execution faster than walking the tree. It leaves you an order of magnitude behind native code, and closing that gap is what
[[jit-compilation]]is for. - "Bytecode means the source is protected." It is usually easier to decompile than machine code, because it kept names, structure and types that a native backend would have discarded.
- "One bytecode instruction is one machine instruction." One bytecode instruction is a whole interpreter iteration: fetch, decode, branch to a handler, execute, loop. Tens of machine instructions is normal.
Misconceptions
The claim, and what is actually true.
Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Bytecode is a middle landing between the syntax tree and machine code. The compiler flattens the tree into a numbered list of simple instructions, and a program you wrote — the virtual machine — reads that list one instruction at a time and does what each one says. It is more work than walking the tree and much less work than generating machine code, and it runs anywhere the VM runs.
practical
When you design one, the questions that actually bite are: how are operands located (stack or registers), how are jumps expressed (absolute or relative), where do constants live (inline or in a pool), and what is the unit of code (a function with its own slot array, or one flat program). Get those four right and the interpreter writes itself. Get the third wrong and every literal costs you an operand field; get the second wrong and you cannot relocate code, which you will want the moment you have a JIT.
advanced
The deep decision is how much semantics to put in one opcode. A tiny orthogonal instruction set is easy to implement, easy to verify and slow, because every high-level operation becomes many dispatches. A large instruction set with fused and specialized opcodes runs faster for the same reason superinstructions do — fewer dispatches per unit of work — but every opcode is a permanent commitment in a format other people may depend on, and a verifier has to be written for each. CPython has drifted steadily toward specialization; the JVM, which has to keep old class files valid, has drifted much less. That difference is not technical taste, it is a consequence of whether the format is a public interface.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
If you were asked this in an interview
- Why would you add a bytecode stage to an interpreter that already works?
- Your bytecode has an
ADDwith no operands. Where do its inputs come from, and what did that choice cost you? - What has to be in the artifact besides instructions before a debugger can be useful?
Connections
- Programming Languages & Runtime Internals — What the VM does with the values the instructions push around — object headers, boxing, allocation and collectionOur instruction set moves numbers and booleans, which lets us skip object representation entirely. A real VM's
ADDhas to ask what the operands are before it can add them, and that question is owned by the runtime, not by the compiler that emitted the opcode.