Lab 10: The Compiler (Milestone 7)
Background
You will write src/compiler.rs: AST in, Chunk out. Names become slot numbers, literals become
constant indices, and control flow becomes jump targets.
Nothing executes this lab. You read the disassembly. That is deliberate — the compiler is one new subsystem, and debugging it against a VM that is also new is debugging two things at once.
Why This Lab Matters
- This is where
Claim 3becomes code. The scope-chain search the tree walker did on every variable read happens here, once, at compile time. Everything Section 3 claims about performance reduces to that. - Jump patching is the highest-density bug region in the curriculum. Four control-flow shapes, each with its own way to be off by one.
verify_stack_depthfrom Lab 9 is your compiler test suite. Run it on every chunk you produce, and most of your bugs are caught before you ever execute anything.
Prerequisites
- Lab 9 complete: disassembler works,
validate()passes on hand-written chunks. - Code Generation read.
docs/architecture.mdopen — you are implementing it a second time.
Predict First
local a = 1 do local b = 2 end do local c = 3 end— which slot doescget?1 + 1— how many constants in the pool? What about1 + 1.0?if a then b() endwith noelse— how many jumps?while a do break end— how many jumps, and where does each go?local a = false and f()— what is the stack depth at the instruction after the whole expression, on each path?- A
breakinsidedo local x = 1 ... break ... end— what must be emitted before the jump? local function f() return f() end— isfa local, an upvalue, or a global inside the body?
Step 1: The Compiler and Its Invariants
#![allow(unused)] fn main() { pub struct Compiler<'p> { chunk: Chunk, locals: Vec<Local>, scope_depth: usize, breaks: Vec<Vec<usize>>, // one pending list per enclosing loop stack_depth: i32, // current, tracked at every emit max_stack: u16, parent: Option<&'p Compiler<'p>>, // Lab 14: upvalue resolution walks this name: Option<String>, } }
Enforce the two invariants with debug assertions, not comments:
#![allow(unused)] fn main() { fn compile_expr(&mut self, e: &Expr) -> Result<()> { let before = self.stack_depth; self.compile_expr_inner(e)?; debug_assert_eq!(self.stack_depth, before + 1, "compile_expr must leave exactly one value: {e:?}"); Ok(()) } fn compile_stmt(&mut self, s: &Stmt) -> Result<()> { let before = self.stack_depth; self.compile_stmt_inner(s)?; debug_assert_eq!(self.stack_depth, before, "compile_stmt must be stack-neutral: {s:?}"); Ok(()) } }
Tip: Write these two wrappers first, before any code generation. They will fire dozens of times over the next few hours and each firing names the exact node that is wrong. Without them the same bugs appear as "the VM returns nil sometimes", four hours later, in Lab 11.
stack_depth is tracked in emit, using the same documented_effect table from Lab 9 — so the
compiler and the validator agree by construction:
#![allow(unused)] fn main() { fn emit(&mut self, op: Op, span: Span) -> usize { if let Some((pops, pushes)) = documented_effect(op) { self.stack_depth += pushes as i32 - pops as i32; } // variable-effect opcodes adjust stack_depth explicitly at their call sites self.max_stack = self.max_stack.max(self.stack_depth.max(0) as u16); self.chunk.emit(op, span) } }
Step 2: Expressions
#![allow(unused)] fn main() { fn compile_expr_inner(&mut self, e: &Expr) -> Result<()> { match e { Expr::Nil { span } => self.emit(Op::LoadNil, *span), Expr::Bool { value: true, span } => self.emit(Op::LoadTrue, *span), Expr::Bool { value: false, span }=> self.emit(Op::LoadFalse, *span), Expr::Int { value, span } => { // The hybrid from the encoding chapter: small ints go INLINE. if let Ok(v) = i32::try_from(*value) { self.emit(Op::LoadInt(v), *span) } else { let k = self.chunk.add_constant(Value::Integer(*value))?; self.emit(Op::LoadConst(k), *span) } } Expr::Float { value, span } => { let k = self.chunk.add_constant(Value::Float(*value))?; self.emit(Op::LoadConst(k), *span) } Expr::Str { value, span } => { let s = self.intern(value); // interned, so the pool dedups by handle let k = self.chunk.add_constant(Value::Str(s))?; self.emit(Op::LoadConst(k), *span) } Expr::Name { name, span } => match self.resolve_local(name) { Some(slot) => self.emit(Op::GetLocal(slot), *span), // Lab 14 inserts an upvalue lookup here, between local and global. None => { let k = self.name_constant(name)?; self.emit(Op::GetGlobal(k), *span) } }, // and/or are NOT ordinary binary operators — they compile to jumps. Expr::Binary { op: BinOp::And, lhs, rhs, .. } => return self.compile_and(lhs, rhs), Expr::Binary { op: BinOp::Or, lhs, rhs, .. } => return self.compile_or(lhs, rhs), Expr::Binary { op, lhs, rhs, span } => { self.compile_expr(lhs)?; // LEFT first: Ember's guaranteed order self.compile_expr(rhs)?; self.emit(binop_code(*op), *span); } Expr::Unary { op, operand, span } => { self.compile_expr(operand)?; self.emit(unop_code(*op), *span); } // Call, Index, Function, Table: Steps 5–6 and Labs 13–14 _ => return Err(compile_error_at(e.span(), "not supported yet")), }; Ok(()) } }
The and/or arms must come before the general Binary arm, exactly as they did
in the tree walker. Same
rule, second implementation, and it is the kind of thing Lab 12 exists to check.
Checkpoint question. Expr::Name resolves local-then-global. What is the third case it will
need in Lab 14, and where does it go in that match?
Step 3: Scopes and Slots
Write begin_scope, end_scope, declare_local, and resolve_local from
the concept chapter. Then:
#![allow(unused)] fn main() { Stmt::Local { names, exprs, span } => { // ORDER: compile all initializers FIRST (they see the OUTER scope), // then declare. This is `local x = x` reading the outer x — the same // ordering rule as the tree walker's, implemented in a different place. self.compile_exprlist(exprs, names.len())?; for name in names { self.declare_local(name, *span)?; } // No SET_LOCAL is emitted! The values are ALREADY in the right slots, // because slots are allocated at the current stack top. That is the whole // trick of a stack machine's local allocation, and it surprises everyone. } }
Warning: That last comment is the single most confusing thing in Lab 10.
local a, b = 1, 2emitsLOAD_INT 1; LOAD_INT 2and nothing else — because slots 0 and 1 are, by definition, the two stack positions those values now occupy.SET_LOCALexists only for assignment to an existing local (a = 5), not for declaration. Check it in the disassembly before moving on; if you emit aSET_LOCALhere you will have an extra value on the stack and every subsequent slot number will be wrong.
Step 4: Control Flow
Write compile_if, compile_while, compile_break, compile_and, compile_or, and
compile_numeric_for from
the concept chapter.
The numeric for is the fiddly one, because
the four control values live in hidden slots:
#![allow(unused)] fn main() { Stmt::NumericFor { var, start, stop, step, body, span } => { self.begin_scope(); self.compile_expr(start)?; // hidden slot i self.compile_expr(stop)?; // hidden slot limit match step { Some(e) => self.compile_expr(e)?, None => self.emit(Op::LoadInt(1), *span) }; self.declare_hidden("(for i)")?; // three hidden locals so that self.declare_hidden("(for limit)")?; // end_scope pops them correctly self.declare_hidden("(for step)")?; let prep = self.emit_jump(Op::ForPrep, *span); // pushes the count; jumps past the body let top = self.chunk.code().len() as u32; self.begin_scope(); self.declare_local(var, *span)?; // the VISIBLE loop variable — a fresh self.breaks.push(Vec::new()); // binding, copied by FOR_LOOP each pass self.compile_block(body)?; self.end_scope(*span); self.emit(Op::ForLoop(top), *span); self.patch_jump(prep); for b in self.breaks.pop().unwrap() { self.patch_jump(b); } self.end_scope(*span); } }
The hidden locals exist so that end_scope accounts for them. Lua does the same thing and even
gives them those names — luac -l -l on a for loop shows (for state) entries in the locals
table. Go look.
Step 5: Functions and Protos
#![allow(unused)] fn main() { Expr::Function { name, params, is_vararg, body, span } => { let mut sub = Compiler::new_child(self, name.clone()); sub.begin_scope(); for p in params { sub.declare_local(p, *span)?; } // params are slots 0..n sub.compile_block(body)?; sub.emit(Op::LoadNil, *span); // implicit `return` for a body sub.emit(Op::Return(1), *span); // that falls off the end let proto = sub.finish(*params.len() as u8, *is_vararg)?; validate(&proto)?; // ← validate EVERY proto you build let idx = self.chunk.add_proto(proto)?; self.emit(Op::Closure(idx), *span); } }
Three notes:
- Every function ends in a
RETURN, always, even if the body already returned. Validator rule 8 requires it and it removes a "what happens ifipruns off the end" case from the VM entirely. validateruns on every proto as it is built, in debug builds at minimum. This is your compiler's test suite and it costs microseconds.new_childcarries aparentpointer that is unused until Lab 14. Putting it in now is cheaper than threading it through later.
The three definition forms desugar as they did in Lab 7 — and local function f must
declare f before compiling the body, or f inside the body resolves as a global:
#![allow(unused)] fn main() { Stmt::LocalFunction { name, func, span } => { let slot = self.declare_local(name, *span)?; // FIRST — so the body can see it self.compile_expr(func)?; // the CLOSURE lands in that slot debug_assert_eq!(slot as i32, self.stack_depth - 1); } }
Step 6: Compile-Time Errors
Every limit produces a diagnostic with a span. Test each with a generated file:
| Limit | Message | Test |
|---|---|---|
| > 256 locals in a function | too many local variables in function | generate 300 local statements |
| > 65,536 constants | too many constants in one function | generate 70,000 distinct strings |
| assignment to a non-target | cannot assign to this expression | (parser, Lab 5) |
break outside a loop | 'break' outside a loop | (parser, Lab 6) |
| AST deeper than the parser allowed | cannot occur | assert the reasoning in a comment |
The Trace
Four programs. Run each, read each, check each by hand.
$ ember disassemble -e 'local x = 10 + 20 * 3 return x'
constants: (none — all three fit in LOAD_INT)
0000 1 LOAD_INT 10
0001 | LOAD_INT 20
0002 | LOAD_INT 3
0003 | MUL
0004 | ADD ← slot 0 is now x, by position
0005 | GET_LOCAL 0 ; x
0006 | RETURN 1
Notice there is no SET_LOCAL. x is stack position 0. And notice MUL before ADD — the
tree shape from Lab 2 became instruction order.
$ ember disassemble -e 'local a = 1 do local b = 2 end do local c = 3 end return a'
0000 1 LOAD_INT 1 ; a → slot 0
0001 | LOAD_INT 2 ; b → slot 1
0002 | POP 1 ← end of first block
0003 | LOAD_INT 3 ; c → slot 1 ← REUSED
0004 | POP 1
0005 | GET_LOCAL 0 ; a
0006 | RETURN 1
Slot reuse, visible. b and c are both slot 1. If yours gives c slot 2, end_scope is not
popping locals.
$ ember disassemble -e '
local n = 0
while n < 3 do
local doubled = n * 2
if doubled > 2 then break end
n = n + 1
end
return n'
0000 2 LOAD_INT 0 ; n → slot 0
0001 3 GET_LOCAL 0 ; n ← 0001 is the loop TOP
0002 | LOAD_INT 3
0003 | LT
0004 | JUMP_IF_FALSE 0016 ; exit
0005 4 GET_LOCAL 0 ; n
0006 | LOAD_INT 2
0007 | MUL ; doubled → slot 1
0008 5 GET_LOCAL 1 ; doubled
0009 | LOAD_INT 2
0010 | GT
0011 | JUMP_IF_FALSE 0014
0012 | POP 1 ← THE BREAK'S POP: discards `doubled`
0013 | JUMP 0016 ; break
0014 6 GET_LOCAL 0 ; n
0015 | LOAD_INT 1
0016 | ADD
0017 | SET_LOCAL 0 ; n
0018 | POP 1 ← end of loop body scope: `doubled`
0019 | JUMP 0001 (back)
0020 8 GET_LOCAL 0 ; n
0021 | RETURN 1
Line 0012 is the whole lab. Without that POP 1, the break leaves doubled on the stack, the
stack is one deeper than the compiler believes, and GET_LOCAL 0 after the loop reads the wrong
slot. It is silent, it needs a loop body with a local and a break to trigger, and
verify_stack_depth catches it instantly — because 0016 would be reached at two different depths.
(The offsets above are illustrative; yours will differ if your POP placement or for lowering
differs. What must match is the structure: a POP before every break jump that leaves a scope,
a back-edge to the condition, and consistent depths at each join.)
$ ember disassemble -e 'local a = false and f() return a'
0000 1 LOAD_FALSE depth 0 → 1
0001 | JUMP_IF_FALSE_KEEP 0005 depth 1 → 1
0002 | POP 1 depth 1 → 0
0003 | GET_GLOBAL 0 ; f
0004 | CALL 0 1 depth 1 → 1
0005 | GET_LOCAL 0 ; a ← depth 1 from BOTH paths ✔
0006 | RETURN 1
Write the depths in the margin yourself for one of these. Doing it by hand once is what makes
verify_stack_depth's failures legible for the rest of the curriculum.
Expected Output
$ cargo test --lib compiler
test compiler::every_golden_program_compiles_and_validates ... ok
test compiler::constants_are_deduplicated ... ok
test compiler::sibling_blocks_reuse_slots ... ok
test compiler::break_pops_the_scopes_it_leaves ... ok
test compiler::and_or_balance_on_both_paths ... ok
test compiler::too_many_locals_is_a_clean_error ... ok
$ ember disassemble tests/golden/programs/fizzbuzz.ember | head -5
$ for f in tests/golden/**/*.ember; do ember disassemble "$f" >/dev/null || echo "FAILED $f"; done
Debugging Steps
compile_expr must leave exactly one value fires
Read the node it names. Usually an arm that forgot to emit, or an and/or reaching the general
binary path.
Slot numbers are off by one after a local
You emitted a SET_LOCAL for a declaration. Declarations do not need one — see the warning in
Step 3.
The loop runs one iteration too many
JUMP_IF_FALSE's target is the back-edge instead of the instruction after it.
The loop never exits
The back-edge targets the body instead of the condition, or a jump is still u32::MAX and
validate was not run.
verify_stack_depth reports an inconsistent join in a loop with a break
The break did not pop the locals declared since the loop started. This is the bug the trace above
is about.
A nested function sees the enclosing function's locals
Compiler::new_child is sharing locals instead of starting a fresh vector. Each function has its
own slot space.
local function f — f inside the body compiles to GET_GLOBAL
You compiled the function expression before declaring the local.
Experiment
CLAIM. The compiler does the tree walker's scope search once instead of once per execution, and you can count the difference exactly.
METHOD. Instrument resolve_local with a counter. Compile tests/golden/programs/fib25.ember
and record how many times it ran. Then take the tree walker's probe counter from
Lab 5's experiment and
record how many times it ran on the same program.
PREDICTION. Write the ratio before you look. (For fib(25), the compiler resolves each name a
handful of times; the tree walker resolves them once per call, and there are ~243,000 calls.)
RESULT. Record both numbers in docs/learning/05-bytecode.md. This ratio is the honest answer to
"why is a bytecode VM faster?", and it has nothing to do with the word "compiled".
Test
#![allow(unused)] fn main() { #[test] fn every_golden_program_compiles_and_validates() { for case in corpus() { let ast = parse(&case.src).expect(&case.name); let proto = Compiler::compile_chunk(&ast).expect(&case.name); validate(&proto).unwrap_or_else(|e| panic!("{}: {e:?}", case.name)); } } #[test] fn constants_are_deduplicated() { let c = compile("return 1000000 + 1000000 + 1000000"); // too big for LOAD_INT? no — i32 fits let c = compile("return 1e300 + 1e300"); // floats DO go in the pool assert_eq!(c.constants.len(), 1, "the same float literal must pool once"); } #[test] fn sibling_blocks_reuse_slots() { let c = compile("local a=1 do local b=2 end do local d=3 end"); let slots: Vec<u8> = c.code().iter().filter_map(|op| match op { Op::SetLocal(s) | Op::GetLocal(s) => Some(*s), _ => None }).collect(); assert!(c.max_stack <= 2, "sibling blocks must share a slot, got max_stack={}", c.max_stack); } #[test] fn break_pops_the_scopes_it_leaves() { // The bug this catches is SILENT without the validator. let p = compile_proto("while true do local x = 1 break end"); validate(&p).expect("break must pop `x` before jumping"); } #[test] fn and_or_balance_on_both_paths() { for src in ["local a = f() and g()", "local a = f() or g()", "local a = 1 and 2 or 3"] { validate(&compile_proto(src)).unwrap_or_else(|e| panic!("{src}: {e:?}")); } } #[test] fn local_function_can_see_itself() { let c = compile("local function f() return f() end"); // `f` inside the body must be a LOCAL access, not a global. assert!(!c.code().iter().any(|op| matches!(op, Op::GetGlobal(_))), "local function must resolve its own name as a local"); } #[test] fn too_many_locals_is_a_clean_error() { let src: String = (0..300).map(|i| format!("local v{i} = {i}\n")).collect(); let e = try_compile(&src).unwrap_err(); assert_eq!(e.kind, ErrorKind::Compile); assert!(e.message.contains("too many local")); assert!(e.span.is_some(), "limit errors must point at the offending declaration"); } }
Challenge Extensions
- Constant folding. Fold
Binary(op, literal, literal)at compile time. Then find the case where folding is wrong:1 // 0. Lua folds and refuses to fold anything that would raise — readconstfoldinginlcode.c. Measure the effect on your corpus's instruction count. - Loop rotation. Compile
whilewith the condition at the bottom and a single jump into it, saving one jump per iteration. Measure it in Lab 11 and report whether the disassembly became harder to read. - Peephole:
LOAD_INT 1; ADD→ADD_IMM 1. Lua hasOP_ADDI. Implement it, then hold the change until Section 7 gives you a benchmark to justify it — and note in your journal how it felt to write an optimization you were not allowed to keep. - Slot-liveness reporting. Emit a warning for a local that is declared and never read. This is a real linter feature and it falls out of the information the compiler already has.
- Compile-time
assertof the two invariants in release too. Measure the cost. Is it small enough to keep on always? (For a compiler that runs once per script, almost certainly yes — and that is a different answer from the VM's hot loop, which is worth noticing.)
Deliverables
-
compile_expr(+1) andcompile_stmt(0) wrappers with debug assertions, written first. -
stack_depth/max_stacktracked inemitusing the same table as the validator. -
All of Section 2's language compiles: locals, assignment, blocks,
if/elseif/else,while, numericfor,break,return,and/or, functions, calls, recursion. - Every golden program compiles and validates.
-
local x = xreads the outerx;local function fsees itself. Both tested. -
Sibling blocks reuse slots;
breakpops the scopes it leaves. - All four compile-time limits produce diagnostics with spans.
-
validate()is called on every proto in debug builds. -
The
resolve_local-vs-tree-walker-probes ratio recorded indocs/learning/05-bytecode.md. - The four traces above reproduced and read by hand.
Validation / Self-check
- State the two stack invariants and the exact assertion that enforces each.
- Why does
local a, b = 1, 2emit noSET_LOCAL? What doesSET_LOCALexist for? - How does the compiler make
local x = xread the outerx? Where is the same rule in the tree walker? - Draw the instruction layout of
while c do body endand mark the two jump targets. - What must
compile_breakemit before its jump? Which validator rule catches its absence? - Trace the stack depth through
a and bon both paths and show they agree at the join. - Where do the numeric
for's hidden control values live, and why are they declared as locals? - Give the ratio from the experiment, and use it to explain why a bytecode VM is faster — without using the word "compiled".
Next: Lab 11 — The Virtual Machine.