Register-Based VMs and VM Design Trade-offs
A stack machine works like a calculator with a pile of numbers: push, push, add.
Learning Objectives
Compare stack and register machines: virtual registers, three-address instructions, instruction count versus complexity, operand encoding and size, dispatch overhead.
Explain register allocation in a VM compiler, temporaries and liveness, basic blocks, control-flow graphs, SSA and lowering, and translation between stack and register forms.
Weigh implementation complexity, optimisation potential and benchmark fairness when designing a VM.
Plain-Language Explanation
A stack machine works like a calculator with a pile of numbers: push, push, add. A register machine works like a calculator with numbered memory slots: "add slot 1 and slot 2 into slot 0". The register version needs fewer instructions, but each instruction is bigger and the compiler must decide which values go in which slots.
Precise Technical Explanation
Stack machine versus register machine Stack: operands implicit at the top of the stack; short instructions; more of them (every operand is moved by a push). Register: operands named explicitly; longer instructions; fewer of them; values stay in registers across instructions.
Numbered slots in the current frame (r0, r1…), usually unlimited per function up to an encoding limit (Lua: 250; Dalvik: 65,536), stored in memory — not CPU registers.
op dest, src1, src2 — e.g. ADD r0, r1, r2. Two-address forms (ADD r0, r1 meaning r0 += r1) and accumulator machines (V8's Ignition: an implicit accumulator plus registers) are intermediate designs.
Instruction count versus instruction complexity (a + b) * c is 5 stack instructions or 3 register instructions when a, b, c are already in registers. Each dispatch costs time; each decoded operand costs time too.
Operand encoding and bytecode size Register instructions carry 2–3 operands; fixed 32-bit encodings (Lua) decode fast but waste space; variable encodings save space.
Fewer instructions mean fewer dispatches, the main reason register VMs are often faster in interpreters (Shi et al. measured roughly a 47% reduction in executed instructions for a register version of the JVM, with a modest increase in code size).
Register allocation in a VM compiler The compiler maps locals and temporaries to virtual registers. Simple schemes: one register per local plus a stack-like allocator for temporaries (Lua's freereg); reuse a register once its value is dead.
Temporary values and liveness A value is live from its definition to its last use; registers holding dead values can be reused. MiniLang's comparison compiler reuses the left operand's register for the result.
Maximal straight-line instruction sequences with one entry (the first instruction, a leader) and one exit (the last instruction). Leaders: the first instruction, every jump target, and every instruction after a jump.
Nodes are basic blocks; edges are possible transfers (fall-through and jumps). Optimisers and JITs work on CFGs.
SSA form and lowering Converting register code to SSA renames every assignment uniquely and inserts φ-functions at merges, making data flow explicit for optimisation.
Simulate the stack at compile time: each push allocates a register, each pop names one — Dalvik's dx tool converted JVM stack bytecode to register bytecode this way.
Emit loads of source registers, the operation, and a store to the destination — easy, but loses the instruction-count advantage.
Interpreter implementation complexity Register VMs need operand decoding for every instruction and a register allocator in the compiler; stack VMs have a simpler compiler.
Register form exposes data flow, enabling copy propagation and register reuse; JITs convert either form into SSA anyway.
Examples of stack-based and register-based instruction formats
These formats are illustrative and not the exact encoding of a named production VM.
Benchmark design and fairness Compare VMs that differ only in instruction format (same value representation, same dispatch technique, same compiler optimisations), count instructions and measure time separately, and use realistic programs.
Design trade-offs for a custom VM Choose a stack VM for a simple compiler, small code and easy verification (WebAssembly, JVM); choose registers for interpreter speed (Lua, Dalvik); choose an accumulator design for a balance (V8's Ignition).
; Stack-style
LOAD_CONST 4
LOAD_CONST 5
MUL
STORE_LOCAL 0
; Register-style, illustrative
MUL r0, r1, r2 ; r0 = r1 * r2Why It Matters in Real Systems
Android moved from JVM stack bytecode to Dalvik's register bytecode for interpreter speed on phones; V8 chose an accumulator-register design for Ignition; WebAssembly chose a stack machine for compact, quickly validated code.
Key Terms
Numbered frame slot named by instructions op dest, a, b Range during which a value may still be used
Straight-line code with one entry and one exit Graph of basic blocks and control transfers
Syntax or Instruction Format
Stack form (MiniLang-style): CONST k, LOAD name, ADD. Register form (illustrative): LOADK r, k, MOVE r, name, ADD rd, ra, rb.
Complete Example
The course's expression compiler generates both forms from the same AST and evaluates them (Node.js 24, MiniLang module):
Output (instruction counts are for this toy compiler, not universal performance evidence):
A real production register machine: V8's Ignition bytecode for (a + b) * c (Node.js 24.18 / V8; printed with --print-bytecode, memory addresses removed — this listing is engine- and version-specific):
Basic blocks found in real MiniLang bytecode with the leader algorithm (Node.js 24):
import { exprForms } from "file:///D:/Projects/EngineerHub/src/cs/tracks/runtimes/minilang/minilang.ts";
const env = { a: 2, b: 3, c: 4, d: 5 };
for (const src of ["(a + b) * c", "a * b + c * d", "a - b - c - d", "-(a + b) * (c - d) / 2"]) {
const f = exprForms(src, env);
console.log(`${src} = ${f.value}`);
console.log(` stack (${f.stack.length} instructions, max depth ${f.stackMax}): ${f.stack.join("; ")}`);
const reg = f.register.filter((x) => !x.startsWith(";"));
console.log(` register (${reg.length} instructions, ${f.registersUsed} registers): ${reg.join("; ")}`);
}(a + b) * c = 20
stack (5 instructions, max depth 2): LOAD a; LOAD b; ADD; LOAD c; MUL
register (5 instructions, 2 registers): MOVE r0, a; MOVE r1, b; ADD r0, r0, r1; MOVE r1, c; MUL r0, r0, r1
a * b + c * d = 26
stack (7 instructions, max depth 3): LOAD a; LOAD b; MUL; LOAD c; LOAD d; MUL; ADD
register (7 instructions, 3 registers): MOVE r0, a; MOVE r1, b; MUL r0, r0, r1; MOVE r1, c; MOVE r2, d; MUL r1, r1, r2; ADD r0, r0, r1
a - b - c - d = -10
stack (7 instructions, max depth 2): LOAD a; LOAD b; SUB; LOAD c; SUB; LOAD d; SUB
register (7 instructions, 2 registers): MOVE r0, a; MOVE r1, b; SUB r0, r0, r1; MOVE r1, c; SUB r0, r0, r1; MOVE r1, d; SUB r0, r0, r1
-(a + b) * (c - d) / 2 = 2.5
stack (10 instructions, max depth 3): LOAD a; LOAD b; ADD; NEG; LOAD c; LOAD d; SUB; MUL; CONST 2; DIV
register (10 instructions, 3 registers): MOVE r0, a; MOVE r1, b; ADD r0, r0, r1; NEG r0, r0; MOVE r1, c; MOVE r2, d; SUB r1, r1, r2; MUL r0, r0, r1; LOADK r1, 2; DIV r0, r0, r1const { execFileSync } = require("node:child_process");
const { writeFileSync } = require("node:fs");
writeFileSync("calc_child.js", "function calc(a, b, c) { return (a + b) * c; }\nconsole.log(calc(2, 3, 4));\n");
const out = execFileSync(process.execPath, ["--print-bytecode", "--print-bytecode-filter=calc", "calc_child.js"], { encoding: "utf8" });
for (const line of out.split(/\r?\n/)) {
if (/^\[generated|^Parameter count|^Register count/.test(line)) console.log(line.replace(/ \(0x[0-9a-f]+ .*\)/, ""));
const m = line.match(/@\s+(\d+) : ([0-9a-f ]+?)\s{2,}(.+)$/);
if (m) console.log(` @${m[1].padStart(2)} ${m[2].padEnd(10)} ${m[3]}`);
if (/^\d+$/.test(line.trim())) console.log("program output:", line.trim());
}[generated bytecode for function: calc]
Parameter count 4
Register count 1
@ 0 0b 04 Ldar a1
@ 2 3f 03 01 Add a0, [1]
@ 5 ce Star0
@ 6 0b 05 Ldar a2
@ 8 41 f9 00 Mul r0, [0]
@11 b3 Return
program output: 20import { compile, disassemble } from "file:///D:/Projects/EngineerHub/src/cs/tracks/runtimes/minilang/minilang.ts";
const fn = compile("let n = 3; while (n > 0) { if (n == 2) print(\"two\"); n = n - 1; } print(\"done\");").script;
const ins = disassemble(fn);
const leaders = new Set([0]);
ins.forEach((d, i) => {
if (d.target !== null) { leaders.add(d.target); if (ins[i + 1]) leaders.add(ins[i + 1].offset); }
});
let block = -1;
for (const d of ins) {
if (leaders.has(d.offset)) console.log(`B${++block} (leader @${d.offset})`);
console.log(` ${String(d.offset).padStart(2, "0")} ${d.op}${d.operands ? " " + d.operands : ""}${d.target !== null ? " → " + d.target : ""}`);
}B0 (leader @0)
00 CONSTANT 0
02 DEFINE_GLOBAL 0
B1 (leader @4)
04 GET_GLOBAL 0
06 CONSTANT 1
08 GREATER
09 JUMP_IF_FALSE 28 → 40
B2 (leader @12)
12 POP
13 GET_GLOBAL 0
15 CONSTANT 2
17 EQUAL
18 JUMP_IF_FALSE 7 → 28
B3 (leader @21)
21 POP
22 CONSTANT 3
24 PRINT
25 JUMP 1 → 29
B4 (leader @28)
28 POP
B5 (leader @29)
29 GET_GLOBAL 0
31 CONSTANT 4
33 SUBTRACT
34 SET_GLOBAL 0
36 POP
37 LOOP 36 → 4
B6 (leader @40)
40 POP
41 CONSTANT 5
43 PRINT
44 HALTLine-by-Line Explanation
exprForms builds both forms from one AST. The stack form pushes every operand; the register form reuses r0 for the left operand and result, and frees temporaries after use.
Ignition: Ldar loads a register into the accumulator, Add a0, [1] adds argument register a0 to the accumulator (the [1] is a feedback-vector slot used for type feedback — Module 16), Star0 stores the accumulator into r0, Mul r0, [0] multiplies. V8 evaluates a + b as acc = b; acc = a + acc, preserving left-to-right semantics.
Leaders: offset 0, each jump target and each instruction after a jump; each leader starts a basic block.
Expected Output
As shown; the V8 listing is specific to this Node.js/V8 version.
Step-by-Step Runtime Behavior
(a + b) * c on the register form: r0 ← a (2), r1 ← b (3), r0 ← r0 + r1 (5), r1 ← c (4), r0 ← r0 × r1 (20).
Common Misconceptions
"Virtual registers are CPU registers." They are memory slots in the frame (a JIT may later map them to CPU registers).
"Register VMs are always faster." Fewer dispatches but bigger instructions; the net effect depends on the implementation.
"V8 bytecode is stable." It changes between V8 versions.
Common Bugs and Failure Modes
Reusing a register whose value is still live; operand-order mistakes in three-address code; register limits exceeded in large functions without a spill strategy.
Corrected or Safer Patterns
Track liveness; allocate temporaries stack-like (last allocated, first freed); verify register bounds like stack bounds.
Debugging and Inspection Techniques
Dump both forms; luac -l (Lua), node --print-bytecode (V8), dexdump -d (Android) show register bytecode.
Performance Considerations
Register forms reduce dispatches and memory traffic for operands; stack forms are smaller and simpler to validate and compile; JITs erase most differences for hot code.
Security Considerations
Register indices must be verified against the frame size, just as stack depths are.
Portability and Version Caveats
Ignition's instruction set, register naming and feedback slots are V8-version specific.
Real-World Use Cases
Lua, Dalvik/ART, V8 Ignition, Parrot, Erlang BEAM (register machine) — versus JVM, CLR, CPython and WebAssembly (stack machines).
Best Practices
Choose by your constraints (compiler simplicity, code size, interpreter speed), and benchmark fairly.
Exercises
E1 · Beginner. Write stack and register code for a * b + c.
Answer: Stack: LOAD a; LOAD b; MUL; LOAD c; ADD (5). Register: MOVE r0, a; MOVE r1, b; MUL r0, r0, r1; MOVE r1, c; ADD r0, r0, r1 (5 here because operands are loaded; with a, b, c already in registers: MUL r3, r0, r1; ADD r3, r3, r2 — 2).
E2 · Intermediate. Identify the leaders in 0 CONST; 2 JUMP_IF_FALSE → 9; 5 PRINT; 6 JUMP → 10; 9 PRINT; 10 HALT.
Answer: 0 (first), 5 (after a jump), 9 (a target and after a jump), 10 (a target).
E3 · Advanced challenge. Sketch stack-to-register translation for LOAD a; LOAD b; ADD; LOAD c; MUL.
Answer: Keep a compile-time stack of register names: LOAD a → push r_a; LOAD b → push r_b; ADD → pop r_b, r_a, emit ADD t0, r_a, r_b, push t0; LOAD c → push r_c; MUL → pop r_c, t0, emit MUL t0, t0, r_c — two register instructions.
Quiz
? Why do register VMs often execute fewer instructions than stack VMs?
+ Operands are named directly, so values are not pushed and popped around each operation. :: Fewer dispatches.
- They have no constant pool. :: They do.
- They skip type checks. :: Unrelated.
- They run on real CPU registers. :: Virtual registers are memory slots.
? What is a basic block?
+ A straight-line sequence with one entry and one exit. :: Jumps only enter at the top and leave at the bottom.
- A block statement in source code. :: Unrelated to `{ }`.
- A memory page. :: No.
- A function. :: Functions contain many blocks.
? Which designs use stack-based bytecode? (select all that apply)
+ JVM :: Operand stack.
+ WebAssembly :: Structured stack machine.
- Lua 5 :: Register-based.
+ CPython :: Stack-based.Summary and Key Takeaways
Stack VMs have compact code and simple compilers; register VMs execute fewer, larger instructions and need register allocation. Basic blocks and CFGs organise code for optimisation in either design; JITs convert both into SSA.
Further Reading
Shi, Casey, Ertl & Gregg, "Virtual Machine Showdown: Stack Versus Registers" (VEE 2005).
Ierusalimschy, de Figueiredo & Celes, "The Implementation of Lua 5.0" (2005).
V8 blog, "Ignition: an interpreter for V8".