all_lessons/SICP JavaScript/22 · Compilationlesson 22 / 22

Compilation

Lesson 21 lowered the evaluator onto real hardware — pairs in finite vectors, a garbage collector, and the eval/apply cycle running as a controller that saves exactly the registers it needs. And it exposed the last waste: to run a loop a thousand times, that machine re-examines the same fixed syntax, re-takes the same branches, and re-walks the same environment frames a thousand times over. This is the capstone. We stop interpreting. Instead of traversing an expression's syntax every time it runs, a compiler traverses it once, ahead of time, and emits a sequence of register-machine instructions that, when run, produce the same value — with all the dispatching already decided. We will build that compiler, see how it computes register saves at compile time and resolves variables by their lexical position, and then close the entire 22-lesson loop: what survives unchanged from the substitution model of lesson 01, and what gets precomputed away.

Book source
Maps to SICP §5.5 (compilation) — the structure of the compiler, instruction sequences annotated with the registers they need and modify, register preservation, linkage descriptors, compiling self-evaluating expressions / names / applications / returns, lexical addressing, and compiled-code/evaluator interoperability. We follow that arc but the emitted instruction sequences, the preservation walk-through, the lexical-address trace, and the closing synthesis of the whole series are original. Language is JavaScript throughout.
Linear position
Prerequisite: Lesson 21 (the explicit-control evaluator — registers, the stack, save/restore, the cost of re-dispatch) and Lesson 15 (the analyzing evaluator — separate analysis done once from execution done many times).
New capability: compile an expression into a register-machine instruction sequence — annotated with the registers it needs and modifies — instead of interpreting it; compute register preservation and resolve variable positions (lexical addressing) at compile time; and articulate exactly what compilation preserves from the evaluator (semantics) versus what it precomputes (analysis) to run fast.
The plan
Seven moves. (1) The thesis: analyze once, emit instructions, never re-dispatch. (2) An instruction sequence as a value annotated with needs / modifies. (3) Compiling the easy cases — self-evaluating, names, returns. (4) Compiling an application, and register preservation — save/restore computed at compile time. (5) Linkage: next / return / a label — how a compiled fragment knows where to go when it finishes. (6) Lexical addressing — resolving a variable to a (frame, position) pair at compile time, and why that beats the lesson-21 runtime frame search. (7) Interop with the evaluator. Then we close the loop: the whole arc from the substitution model down to here.

1 · The thesis: do the dispatch once

The explicit-control evaluator of lesson 21 is a single fixed machine that, fed any expression, walks its syntax at run time: eval-dispatch tests "is this a literal? a name? a conditional? an application?" on every visit. For a loop body that runs a thousand times, that test is taken a thousand times — yet the answer never changes, because the program text never changes. Lesson 15's analyzing evaluator made the first cut: split the analysis (done once) from the execution (done many times), returning an execution function. Compilation is the same idea taken to the metal. Where the analyzer returned a JavaScript closure, the compiler returns register-machine instructions — concrete assign, save, restore, branch, goto — specialized to this exact expression. The dispatch happened at compile time and left no trace in the running code.

interpret: for each run, traverse syntax + dispatch + execute compile: traverse syntax + dispatch ONCE → emit instructions → each run just executes them

2 · An instruction sequence as a value: needs and modifies

The compiler is a function compile(exp, target, linkage) returning an instruction sequence: a list of register-machine instructions, but annotated with two register sets that let sequences be combined safely. target is the register where the value should land; linkage is where to go when done (§5).

needs
The set of registers whose values must already be set before the sequence runs — registers it reads. If a later step needs env, an earlier step had better not have clobbered it.
modifies
The set of registers whose values the sequence changes — registers it may clobber. If this sequence modifies env and a following sequence needs env, somebody must save and restore it.
statements
The actual instruction list. Combining two sequences concatenates their statements and unions/adjusts their needs and modifies — this is the algebra that makes compositional code generation possible.
// an instruction sequence is a value: which regs it reads, writes, and its instructions
const seq = (needs, modifies, statements) => ({ needs, modifies, statements });
const EMPTY = seq([], [], []);

// append two sequences whose register interactions are already safe
function appendSeqs(a, b) {
  return seq(
    union(a.needs, difference(b.needs, a.modifies)), // b needs what a didn't already set
    union(a.modifies, b.modifies),
    [...a.statements, ...b.statements]);
}

The needs/modifies annotation is the whole reason a compiler can decide save/restore without running the program — it is a static summary of the dynamic register effects that the lesson-21 evaluator only discovered at run time.

3 · Compiling the easy cases: literals, names, returns

The simplest expressions compile to a single assign. A self-evaluating literal just loads its constant into the target; a name loads the result of looking it up. Both then attach the linkage code that sends control onward (§5).

// 42  -->  put the constant 42 into the target register
function compileLiteral(exp, target, linkage) {
  return endWithLinkage(linkage,
    seq([], [target], [["assign", target, ["const", exp]]]));
}
// x   -->  look up x in env, put result in target
function compileName(exp, target, linkage) {
  return endWithLinkage(linkage,
    seq(["env"], [target],                       // reads env, writes target
        [["assign", target, ["lookup", exp, ["reg", "env"]]]]));
}

Notice compileName declares needs = ["env"]: it cannot run until env holds the right environment. That single annotation is what will let the compiler protect env across a subexpression that would otherwise overwrite it.

4 · Compiling an application — and preservation, computed at compile time

An application f(a, b) is the hard, central case, and it is where the lesson-21 stack discipline reappears — but moved from run time to compile time. To run it the machine must: evaluate f into fun; evaluate a and b into argl; then apply. The danger is the same one the explicit-control evaluator faced: evaluating the arguments uses and overwrites env and fun, but the operator needs env and the apply step needs fun. In lesson 21 the controller blindly saved those registers every run. The compiler does better: it asks, statically, does the argument code actually modify a register the later code needs? — and emits a save/restore pair only if so. This is register preservation.

// wrap `seq` so that `reg` is preserved across it ONLY if seq modifies it
// and the surrounding code needs it. The decision is made now, at compile time.
function preserving(regs, before, after) {
  if (regs.length === 0) return appendSeqs(before, after);
  const r = regs[0], rest = regs.slice(1);
  const mustSave = before.modifies.includes(r) && after.needs.includes(r);
  if (mustSave) {
    return preserving(rest,
      seq(union(before.needs, [r]), difference(before.modifies, [r]),
          [["save", r], ...before.statements, ["restore", r]]),
      after);
  }
  return preserving(rest, before, after);   // no clash -> emit NO save/restore
}
Worked example — compiling f(x), and the save the compiler omits
Compile the operator into fun, then the argument into argl, preserving env across the operator and fun across the operands:
compile  f(x)   target=val   linkage=next

  ; operator f -- needs env, modifies fun
  assign fun  <- lookup(f, env)

  ; PRESERVE env across the operand code, because:
  ;   operand code MODIFIES env? no (just a lookup) ... and fun across operands?
  ;   operand 'x' is a name: needs env, modifies argl -- it does NOT modify fun,
  ;   so the compiler emits NO save/restore around fun. Contrast lesson 21,
  ;   which saved fun unconditionally on every single call.
  assign argl <- list(lookup(x, env))

  ; apply: branch on primitive vs compound (decided here once)
  test    primitive?(fun)
  branch  prim-branch
compiled-branch:
  assign val <- compound-apply(fun, argl)   ; (set up env, jump to body)
  goto    (after-call)
prim-branch:
  assign val <- apply-primitive(fun, argl)
after-call:
The payoff is the absent instructions. The lesson-21 controller, being one fixed machine, could not know that evaluating a simple variable argument would not clobber fun, so it saved defensively. The compiler analyzes this argument, sees modifies = [argl], proves there is no clash with fun or env, and emits the bare minimum — frequently zero stack traffic where the interpreter spent two instructions and a stack slot per call. Multiply by a million calls and you have the difference between interpreted and compiled speed.

5 · Linkage: where a compiled fragment goes when it is done

Every compiled sequence must end by transferring control somewhere. The linkage descriptor says where, and there are exactly three kinds:

LinkageEmitted codeUsed for
"next"nothing — fall through to the following instructionsa subexpression whose result feeds the next step in the same sequence
"return"goto (reg continue)the body of a function — jump to wherever the caller said via the continue register
a labelgoto (label L)joining control flow, e.g. both branches of a conditional jumping to the same after-label

Linkage is the compile-time counterpart of the continue register the explicit-control evaluator managed at run time. A function body compiled with "return" linkage ends in goto (reg continue) — and because that is a tail position with nothing left to do, a tail call compiles to a plain goto with no stack growth, exactly mirroring lesson 21's constant-space tail recursion, now baked into the emitted code rather than discovered each run.

6 · Lexical addressing — the payoff that beats the interpreter

Here is the sharpest win, and the one that most directly answers lesson 21's complaint. In the explicit-control evaluator, lookup(x, env) searches the environment at run time: scan the current frame's names for x; if absent, follow the parent link and scan that frame; repeat. For a variable bound three frames out, that is three frame scans every single time the variable is read — pure re-derivation of something the program text fixes once and for all.

But the compiler knows the lexical structure — the nesting of scopes — at compile time, because scope is a property of the text, not of any run. So it can resolve each variable to a lexical address: a (frame-number, position-in-frame) pair telling exactly where the value will sit. The emitted code skips straight there — follow N parent links, take the Mth slot — with no name comparisons at all.

Worked example — lexical address replaces the runtime search
function outer(a, b) {        // frame depth 1: { a:0, b:1 }
  function inner(c) {         // frame depth 0 (innermost): { c:0 }
    return a + c;             // a is 1 frame out, position 0; c is here, position 0
  }
  return inner;
}
INTERPRETED (lesson 21) lookup of `a` inside inner, every call:
  scan inner's frame {c}   -- not found
  follow parent link
  scan outer's frame {a,b} -- found at position 0  -> return it
  (two frame scans + name comparisons, REPEATED on every read of `a`)

COMPILED: `a` resolves at COMPILE TIME to lexical address (1, 0):
  assign val <- lexical-ref(env, frame=1, pos=0)   ; follow 1 parent, take slot 0
  (zero name comparisons; the search was done ONCE by the compiler)
The interpreter pays the search on every read; the compiler pays it once and emits a direct access. This is precisely the speed gap lesson 21's "Where this points next" promised — and it is possible only because variable scope, like syntax type, is fixed by the program text and therefore precomputable.

7 · Interoperating with the evaluator

Compiled code and the explicit-control evaluator are not rivals; they share the same machine, the same registers, and the same calling convention (a function is applied via fun/argl, returns through continue). So a compiled function can call an interpreted one and vice versa: apply-dispatch simply checks whether a function is primitive, interpreted-compound, or compiled, and dispatches accordingly. This is why a real system can compile hot code while still interpreting freshly typed expressions at a REPL — they meet at the shared register-machine interface. The compiler did not invent a new semantics; it produced a faster encoding of the same one.

Common mistakes / failure modes

Saving registers unconditionally
Copying the interpreter's defensive save/restore into the compiler throws away the entire advantage. The compiler must emit a save only when a subexpression's modifies actually clashes with a later needs; otherwise the generated code is no faster than interpreting.
Confusing lexical address with runtime value
A lexical address (frame, position) is fixed by the source text and computed at compile time; it is not the variable's value, which still lives in the runtime environment. The compiler precomputes the path, never the contents.
Getting tail-call linkage wrong
If a function body is compiled with anything but "return" linkage in tail position, tail calls stop being plain gotos and start growing the stack — losing the constant-space property that lesson 21 established.
Thinking compilation changes the meaning
Compiled code must produce exactly what the evaluator would. The compiler precomputes dispatch and addressing for speed but preserves semantics; if the compiled and interpreted results ever differ, the compiler is wrong, not faster.

Checkpoint exercise

Try it
(a) Compile the literal expression 7 with target = val and linkage = "return"; write the instruction sequence and its needs/modifies. (b) Compile the variable reference y with target = val, linkage = "next"; what does it need and modify? (c) For g(z) where g and z are both simple names, decide with the §4 rule whether the compiler must save env across the operand code, and whether it must save fun. (d) Given function f(p) { function h(q) { return p; } ... }, give the lexical address of p as used inside h.

Answers: (a) needs=[], modifies=[val], statements assign val <- const 7 then goto (reg continue) (the return linkage). (b) needs=[env], modifies=[val], statement assign val <- lookup(y, env); "next" linkage emits nothing. (c) the operand z is a name with modifies=[argl]; it does not modify env or fun, so the compiler emits no save/restore for either — zero stack traffic (the interpreter would have saved fun anyway). (d) p is bound in f's frame, one level out from h, at position 0 → lexical address (1, 0).

Where this points next — the whole arc

There is no next lesson; there is the synthesis. Twenty-two lessons were a single forcing-chain, and every link was a model breaking under a named pressure. We began in lesson 01 with the substitution model — to run a program, replace expressions with their values — valid only while functions stayed pure. Lesson 09's assignment detonated it: a call now depended on history, so substitution lied, and the environment model (10) replaced it. To make that model executable we wrote it as the metacircular evaluator (14), which re-traversed syntax on every run; so the analyzing evaluator (15) split analysis from execution. Owning the evaluator let us change the rules — lazy (16), nondeterministic (17), logic (18) — but all of them silently borrowed the host's recursion and memory. To expose the true cost of control we descended to register machines (19), made them executable with a simulator (20), then made memory finite with storage and garbage collection and lowered the evaluator into the explicit-control machine (21). That machine re-dispatched the same fixed syntax forever — and that final waste forced this lesson. Now stand back and see what the compiler does to the ladder. It preserves the semantics every model up the chain agreed on — the same value the substitution model would have computed for a pure expression, the same environment-model meaning for variables, the same eval/apply structure of lesson 14. It precomputes everything that depends only on the fixed program text: which syntactic branch applies (the dispatch of 14), the analysis done once (15), which registers must be saved (the save/restore of 21), and where each variable lives (lexical addressing). Semantics is what stays; analysis is what moves to compile time. That single distinction — preserve meaning, precompute structure — is the bottom of the ladder. Human-readable language has become machine code, and the whole journey from "replace an expression with its value" to "emit an instruction that follows one parent link and takes slot zero" is one unbroken argument that programming is the control of complexity through abstraction — including, at the very end, abstraction over evaluation itself.

Takeaway
A compiler stops re-interpreting by traversing an expression's syntax once and emitting register-machine instructions that, when run, just execute — no run-time dispatch left. compile(exp, target, linkage) returns an instruction sequence annotated with the registers it needs and modifies; that static summary lets preservation emit a save/restore pair only when a subexpression's modifies clashes with a later needs — the compile-time analogue of lesson 21's run-time save/restore, but usually emitting far fewer (often zero) stack operations. Linkage (next / return / label) is the compile-time form of the continue register, and "return" linkage in tail position compiles to a plain goto, keeping tail calls constant-space. The sharpest win is lexical addressing: because scope is fixed by the text, the compiler resolves each variable to a (frame, position) address and emits a direct access, replacing the interpreter's repeated run-time frame search — that is the speed gap. Compiled and interpreted code interoperate through the shared register interface. And the closing synthesis of the whole 22-lesson arc: the compiler preserves the semantics every evaluation model agreed on (substitution → environment → eval/apply) while it precomputes the structure (dispatch, analysis, register saves, variable positions) that depends only on the program text — preserve meaning, precompute structure — bringing human-readable language all the way down to machine code.

Interview prompts