AtlasLangimplementation

AtlasLang: Bytecode and the Stack Machine

Twenty-three opcodes and an operand stack. A three-address instruction `%d = a op b` becomes "push a, push b, op", and every virtual register becomes a numbered local slot — which is the whole translation, and the whole argument for having had an IR first.

The question

What does an instruction set look like when you get to design it, and how does three-address IR become one?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

A flat array of instructions per function, each an opcode plus at most one operand, over a machine state of an operand stack and an array of numbered local slots. Control flow is absolute indices into that array rather than block structure. The representation exists to answer a question the IR cannot: what does an interpreter execute, step by step, with no graph to traverse and no names to resolve?

What this phase may assume or do

Bytecode generation may assume the IR has a fixed evaluation order — that is what lowering established, and it is why this translation is a local rewrite rather than an analysis. It must preserve exactly what AtlasLang defines as observable: the sequence of print outputs and whether a division faults. Everything else is free, including how many instructions implement an operation and how deep the stack gets. The one structural obligation is stack discipline: every instruction sequence must leave the operand stack at the depth its successors expect, on every path, or the machine is executing a different program than the one compiled.

Key points

  • Twenty-three opcodes over an operand stack and numbered local slots, with absolute jump targets rather than block labels.
  • The core translation is %d = a op b becomes push a, push b, op — the stack replaces the destination register.
  • Virtual registers become synthetic local slots, which is why the bytecode contains STORE/LOAD pairs a peephole pass would delete.
  • The translation is local because the IR already fixed an evaluation order, which is the concrete argument for having had an IR.
  • Bytecode is emitted from the pre-SSA IR, because a phi node has no execution semantics — so the Bytecode panel and the Optimized SSA panel show different programs.
  • Stack depth tracks expression nesting, not the number of live variables, which is why a stack machine needs no register allocator.
  • Frames record a stack base so a callee cannot leak operands into its caller, and there is a test for it.
  • Two stated limits: integers do not wrap, and a step budget replaces genuine non-termination.

The instruction set

Twenty-three opcodes, in five groups. PUSH a literal; LOAD slot *n* onto the stack; STORE pops into slot *n*. Arithmetic and comparison — ADD SUB MUL DIV MOD, EQ NE LT LE GT GE — each popping two and pushing one. Unary NEG and NOT. Control — JUMP to an absolute index, JUMPF which pops and jumps if false, CALL with an arity, and RETURN. And the odds and ends: PRINT, POP, HALT.

That is a stack machine. No instruction names a register, and no instruction needs to: operands come off the stack and results go back onto it. The consequence is that instructions are tiny — an opcode and at most one number — and there are more of them than a register machine would need, because every value makes a round trip through the stack. That trade is [[stack-vs-register-vm]], and it is the reason CPython, the JVM and WebAssembly are all stack machines while Lua and Dalvik are not.

Two design decisions are worth flagging because they are visible in the trace and confuse people otherwise. JUMPF exists but JUMPT does not, because a branch can always be arranged so the false case is the conditional one — one fewer opcode, one more constraint on the emitter. And jump targets are absolute instruction indices, not block labels, so the compiler emits a placeholder and patches it once the target's address is known. That patching pass is the reason a linear instruction format needs a fixup step and a graph representation does not.

  • Stack and slotsPUSH a literal, LOAD slot *n*, STORE into slot *n*, POP to discard.
  • ArithmeticADD SUB MUL DIV MOD, each popping two operands and pushing one result.
  • ComparisonEQ NE LT LE GT GE, same shape, pushing a boolean.
  • UnaryNEG and NOT, popping one and pushing one.
  • ControlJUMP to an absolute index, JUMPF popping and jumping when false, CALL with an arity, RETURN, HALT.
  • EffectsPRINT, which pops and emits, and is the only instruction whose execution is observable.

Three-address to stack, in one rewrite

The core of the translation is a single rule. A three-address instruction %d = a op b becomes three bytecode instructions: push a, push b, apply op. The operand stack replaces the destination register — the result is left on top rather than written anywhere named.

Except that AtlasLang then immediately writes it somewhere named. Every virtual register %d gets a synthetic local slot, so the actual emission is push, push, op, STORE into %d's slot — and every later use of %d becomes a LOAD from it. That is why the bytecode for print(1 + 2); is PUSH 1, PUSH 2, ADD, STORE 0, LOAD 0, PRINT, with a store immediately followed by a load of the same slot. A peephole pass would delete that pair in a moment; keeping it makes the correspondence between the IR and the bytecode exact and legible, which for a teaching implementation is worth more than six instructions.

The important claim is that this translation is *local*. Each IR instruction becomes a fixed short sequence, with no analysis and no lookahead, and the reason is that the IR already fixed an evaluation order. Emitting bytecode directly from the AST would mean deciding evaluation order during emission, which is possible but conflates two jobs — and it means there is no representation on which to have run an optimizer. That is the concrete argument for [[why-ir-exists]], and it is visible as the difference between two panels.

Bytecode is emitted from the pre-SSA IR, not from the optimized SSA. A phi node has no execution semantics — you cannot run a phi — so a real compiler leaves SSA before code generation, which is [[out-of-ssa]]. Here, leaving SSA is free: the pre-SSA form with its load/store slots is already exactly the shape a stack machine wants, because a stack machine's locals *are* named slots. The consequence to expect is that the Bytecode panel shows a different program from the Optimized SSA panel, and now you know why.

A real trace

simplifiedThis VM is a switch over opcodes in a loop, executing one instruction per iteration. Production interpreters do considerably more: computed-goto or token-threaded dispatch to help the branch predictor, superinstructions that fuse common pairs, inline caches on operations whose types repeat, and a register-based format to avoid exactly the STORE/LOAD traffic above. CPython, the JVM and LuaJIT each pick a different subset. See [[dispatch-loop]] and [[interpreter-performance]].

Below is the actual compilation and execution of let x = 4; print(x * 5);. Three slots: x for the user variable, and %0 and %1 for the two virtual registers the IR produced. Read the locals column and watch the round trips.

The pattern to notice is STORE immediately followed by LOAD of the same slot at instructions 1–2, 3–4 and 7–8. That is the virtual-register-to-slot mapping doing its work, and it is also the most obvious thing a peephole optimizer would remove — [[peephole-optimization]] in three instructions.

The other thing worth noticing is what the stack does. It never holds more than two values, because a stack machine's depth is the depth of the expression tree it is evaluating, not the number of live variables. That is a genuine property of the design: a program with a hundred locals and shallow expressions has a two-deep stack, which is why stack machines need no register allocator at all.

Bytecode and execution of let x = 4; print(x * 5);
Bytecode
  1. 0PUSH 4
  2. 1STORE 0 ; x
  3. 2LOAD 0 ; x
  4. 3STORE 1 ; %0
  5. 4LOAD 1 ; %0
  6. 5PUSH 5
  7. 6MUL
  8. 7STORE 2 ; %1
  9. 8LOAD 2 ; %1
  10. 9PRINT
  11. 10PUSH 0 ; void
  12. 11RETURN
  13. 12HALT
Operand stack (top last)
4
1 / 10The literal is pushed. Nothing is named yet.

Read it asTwelve instructions executed for what the source wrote as two statements, and the stack never exceeded depth two. Six of those instructions are STORE/LOAD pairs on the same slot — the price of mapping virtual registers onto slots without a peephole pass. Step this yourself at /compilers/vm, where the stepper highlights the IR block each instruction came from.

Calls, frames and the two honest limits

A CALL carries an arity. The VM pops that many arguments, pushes a new frame recording the callee, an instruction pointer, its own slot array, where to resume in the caller, and the stack depth to restore on return. RETURN pops a value, discards anything the callee left above its stack base, restores the caller's frame and pushes the result. That stack-base discipline is what stops a callee from leaking operands into its caller — and there is a test asserting exactly that, because a VM that leaks one value per call works fine until it does not.

Two limits are stated rather than hidden, because a simulator that conceals its limits teaches the wrong thing.

Integer arithmetic is JavaScript double arithmetic truncated toward zero. It does not wrap at 32 or 64 bits. AtlasLang therefore cannot demonstrate signed overflow, which is precisely where a real compiler's undefined-behavior reasoning lives — a C compiler may assume signed overflow does not occur and optimize on that assumption, and nothing here can show you that.

There is a step budget. A non-terminating program stops after 20,000 instructions with status steps-exhausted and an error saying so, rather than hanging the tab or reporting a plausible-looking number. Genuine non-termination is inexpressible here, which matters because it is the thing several static analyses in this domain are undecidable *about*.

The third limit is quieter: the trace is capped at 500 recorded steps while execution continues to the budget, so a loop running eight thousand times still finishes and prints, without producing a table nobody can read. The status field is the part to read; the trace is a sample.

How it works

The steps, in the order the compiler takes them.

  • Each IR function becomes a bytecode function with an arity and an ordered list of slot names — parameters first, then user locals, then one slot per virtual register.
  • Blocks are emitted in order, and each block records the instruction index it started at.
  • An operand that is a constant emits PUSH; a virtual register emits LOAD of its slot; a named variable emits LOAD of its slot.
  • A binary instruction emits its two operands then the opcode, then STORE into the destination register's slot.
  • A branch emits JUMPF to the false target followed by JUMP to the true target, both with placeholder operands.
  • After all blocks are emitted, a patch pass rewrites every placeholder with the recorded start index of its target block.
  • A CALL emits its arguments in order then the opcode with the arity; the result is stored, or popped if the callee returns nothing.
  • At run time the dispatch loop fetches the instruction at the frame's pointer, increments the pointer, switches on the opcode and executes — recording a trace step until the trace limit, and counting every step against the budget.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • An emitter that leaves a value on the stack at the end of a block, so the next block starts at the wrong depth and every subsequent operation reads the wrong operand.
  • A jump patched to the wrong index, producing control flow that is structurally plausible and semantically wrong, with no error until a value comes out wrong.
  • A CALL whose arity disagrees with what the emitter pushed, so the callee reads a caller's operand as an argument.
  • A callee that leaves operands above its stack base, leaking one value per call — invisible for a hundred calls and fatal at some depth.
  • A phi node reaching bytecode generation because out-of-SSA did not run: this VM emits PUSH 0 with a comment saying exactly that, which turns a silent wrong answer into a visible one.
  • Reading a steps-exhausted result as an answer. The program did not finish; the status field says so and the output is a prefix.
  • Assuming an arithmetic result transfers to a real target: nothing here wraps, so any program depending on overflow behaves differently everywhere else.

When it helps

  • Designing a VM instruction set, where the stack-versus-register decision and the size of the opcode set are the first two questions and this is a complete small answer to both.
  • Understanding what a disassembly is showing you — python -m dis produces recognisably the same shape, and reading one after this is straightforward.
  • Seeing why an IR is worth having, by comparing a local three-instruction rewrite against emitting from a tree.
  • Debugging interpreter bugs, where stack-depth discipline and jump patching are the two places almost all of them live.

When it hurts

  • As a performance model. This is a switch-dispatch loop with no threading, no superinstructions and no caching, and it is slower than any production interpreter by a wide margin.
  • For reasoning about integer semantics, since AtlasLang integers do not wrap and real ones do.
  • For anything involving allocation, objects or garbage collection, none of which exist here — and which is where most of a real VM's complexity actually is.

What it costs

Every one of these is paid by something.

  • A stack machine buys tiny instructions and no register allocation, and pays with more instructions executed and the STORE/LOAD traffic that a register format avoids.
  • Mapping virtual registers onto slots buys an exact, readable correspondence with the IR and pays six instructions in a twelve-instruction program.
  • Emitting from pre-SSA IR buys a VM that can actually run without an out-of-SSA pass and pays by making the bytecode panel disagree with the optimized panel, which needs explaining.
  • Absolute jump targets buy a flat array with no indirection at run time and pay with a patching pass at emission time.
  • A step budget buys a browser tab that never hangs and pays by making genuine non-termination inexpressible.
  • Capping the recorded trace at 500 steps buys a readable table for looping programs and pays by making the trace a sample rather than a record.

What else you could do

What a different compiler or language does instead, and when that is better.

  • A register-based bytecode, as Lua and Dalvik use, which removes the STORE/LOAD traffic at the cost of larger instructions and a register allocation problem in the emitter — [[register-based-vm]].
  • Emitting bytecode directly from the AST, skipping the IR, which is what many small languages do and which leaves nothing to optimize on — [[bytecode-compiler]].
  • Threaded code, where each instruction ends with an indirect jump to the next handler rather than returning to a central switch, which measurably helps branch prediction — [[dispatch-loop]].
  • Compiling to WebAssembly instead of a bespoke format, which is a stack machine someone else already specified, optimized and sandboxed — [[wasm-model]].
  • Compiling to native code and skipping the VM entirely, which is what the assembly panel gestures at and what a real backend would do — [[code-generation]].

See it for yourself

The flag, dump or tool that shows you this directly.

  • /compilers/vm — step the bytecode one instruction at a time, watching the operand stack and the locals, with the source IR block highlighted.
  • /compilers/pipeline — the Bytecode panel next to the Optimized SSA panel, which is the fastest way to see that they are different programs and to remember why.
  • Type print(1 + 2); and read the whole function: nine instructions, one slot, and the STORE/LOAD pair in the middle.
  • Write a while (true) { } loop and watch the status become steps-exhausted rather than the tab hanging.
  • python -m dis -c "x = 4; print(x * 5)" prints CPython's version of this same program, in a recognisably similar instruction set.
  • src/compilers/sim/vm.ts — the opcode list is at the top and the dispatch loop is one switch.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "The bytecode is the optimized program." It is emitted from the pre-SSA IR, so it reflects the unoptimized form. The optimized SSA panel is a different program, and a phi node is why.
  • "The STORE then LOAD is a bug." It is the virtual-register-to-slot mapping, kept deliberately so the IR and the bytecode line up instruction for instruction. A peephole pass would remove it.
  • "Stack machines are slow because of the stack." They execute more instructions than a register machine, and the stack itself is an array with a top index. The cost is instruction count and dispatch, not the data structure.
  • "The step budget is a bug I should raise." It is a design decision that makes a non-terminating program report steps-exhausted instead of hanging. The status field is the result.

Misconceptions

The claim, and what is actually true.

Bytecode is a low-level form of the source.
It is an instruction set for a machine that does not exist, designed by whoever wrote the VM. Nothing constrains it except what the interpreter agrees to execute.
A stack machine has no registers, so it is simpler and slower.
Simpler to emit, yes — no allocation problem. Slower is about instruction count and dispatch, and a well-implemented stack VM comfortably outruns a badly implemented register one.
The VM executes the optimized IR.
It executes bytecode emitted from the pre-SSA IR, because a phi node cannot be executed. Leaving SSA before code generation is a real step in real compilers for the same reason.
A step budget means the VM cannot run long programs.
It means it will not run forever. The budget is 20,000 instructions, the default program uses 157, and a program that exceeds it reports why rather than producing a number.

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Bytecode is a list of simple instructions for a made-up machine. AtlasLang's machine has an operand stack and numbered slots for variables: to compute x * 5 it loads x, pushes 5, and multiplies, leaving the answer on top of the stack. Each three-address IR instruction turns into a short fixed sequence like that, which is possible only because the IR had already decided what order things happen in.

practical

Step a program at /compilers/vm and watch the stack. Two habits pay off in any VM you build: keep the stack depth an invariant you check at block boundaries, because a leak is invisible until it is fatal; and record which IR instruction each bytecode instruction came from, because without that mapping every interpreter bug is debugged by reading numbers. If you find yourself emitting STORE immediately followed by LOAD of the same slot, that is where a peephole pass earns its first keep.

advanced

The choice worth thinking hardest about is not stack versus register but *who the bytecode is for*. If it is private to one interpreter, as CPython's is, it can change every release and be optimized for whatever the interpreter currently wants — which is why CPython reshuffles opcodes freely and why pinning to them is a mistake. If it is a distribution format consumed by implementations you do not control, as JVM bytecode and WebAssembly are, it becomes a specification: it must be validatable by an untrusting consumer, stable enough that old artifacts still run, and expressive enough that future implementations can compile it well. Those requirements pull hard towards a stack format, because stack code is compact and its verification is a straightforward abstract interpretation of stack depth and types — which is exactly the property WebAssembly leans on. AtlasLang's bytecode is in the first category, which is why it can afford STORE followed by LOAD and a step budget.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

implementationThe opcode set, the slot layout and the emission strategy are AtlasLang's, in src/compilers/sim/vm.ts at this revision. CPython's instruction set is larger, changes every minor version and is explicitly not stable; the JVM's is fixed by specification and cannot change; WebAssembly's is a standard with a validation pass. Which of those postures a bytecode takes determines almost everything about its evolution.
simplifiedOne switch per instruction, no threaded dispatch, no superinstructions, no inline caches, no garbage collector, no exceptions and integers that do not wrap. Each omission is a real part of a production VM: dispatch technique and value representation together account for most of an interpreter's performance, and the absence of a heap is why this VM has no memory management at all.
targetThe stack-versus-register choice interacts with the host. A stack VM interpreted on a real CPU pays for its extra instructions in dispatch overhead, which is why register formats won for Lua and Dalvik; but a stack format is more compact and much easier to validate, which is why WebAssembly chose it for a format shipped over a network to an untrusted consumer. The right answer depends on what the bytecode is for.

If you were asked this in an interview

  • How does %2 = %0 * %1 become stack bytecode, and why is that translation local?
  • Why is AtlasLang's bytecode emitted from the pre-SSA IR rather than from the optimized SSA?
  • What invariant must every emitted block satisfy, and what is the symptom when it does not?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Interpreter dispatch technique, value representation and the memory management this VM does without
    How fast a bytecode interpreter runs is decided mostly by dispatch and by how values are represented — threaded code, tagged pointers, NaN boxing, inline caches. AtlasLang stops at emitting a correct instruction stream; making one fast, and giving it a heap to manage, is that domain's work.