Skip to main content

From Interpretation to Compilation: Staging, Intermediate Representation

What This Concept Is​

An interpreter walks the source expression every time it runs. A compiler does the walking once, up front, and emits a lower-level program that a simpler runtime (or a CPU) can execute directly.

Between the two extremes live a small family of standard moves:

  • Syntactic analysis separated from execution (SICP §4.1.7). Instead of eval exp env, produce analyze(exp) -> λenv. result. The first phase walks the syntax; the second phase takes an environment and runs. Repeated runs skip the syntactic phase.
  • Intermediate representations (IRs). The AST is one IR; a core language (desugared subset) is another; a control-flow graph or SSA form is another; bytecode is another; machine code is the last. Each is a deliberate choice about what is easy and what is hard at that level.
  • Staging / partial evaluation. When some inputs are known earlier than others, bake the early ones in. (analyze exp) stages the interpreter: what depends only on exp runs now; what needs env runs at call time.
  • Compilation proper (SICP §5.5). Emit machine (or register-machine) code directly from the AST. No more tree-walking at run time.

This concept is SUPPORTING because the module's centre of gravity is writing the interpreter; compilation is the natural next step, not a demand for this module.

Why It Matters Here​

Three reasons to see the gradient now:

  1. Performance intuition. The speedup from tree-walking to bytecode is often 10-100×; from bytecode to native is another 2-10×. Knowing which level you are at explains the cost.
  2. Language design. Many real systems sit at a middle rung: CPython compiles to bytecode and interprets that; the JVM interprets bytecode and JIT-compiles hot paths. Knowing the rungs is how you read architecture docs of any mature runtime.
  3. Engineering leverage. Almost every production interpreter eventually moves toward "compile once, run many" -- template engines pre-compile templates; SQL engines build plans; regex libraries compile patterns. The staging move is universal.

Concrete Example​

SICP §4.1.7 contrasts the straightforward evaluator with an analyzed one:

;; straightforward
(define (eval exp env)
(cond ((if? exp) (eval-if exp env)) ...))
(define (eval-if exp env)
(if (eval (if-test exp) env)
(eval (if-conseq exp) env)
(eval (if-alt exp) env)))

;; analyzed / staged
(define (analyze exp)
(cond ((if? exp) (analyze-if exp)) ...))
(define (analyze-if exp)
(let ((pproc (analyze (if-test exp)))
(cproc (analyze (if-conseq exp)))
(aproc (analyze (if-alt exp))))
(lambda (env)
(if (pproc env) (cproc env) (aproc env)))))

Now (run prog) is ((analyze prog) env). Each time you re-run a program you pay only for the closures returned by analyze, not for re-dispatching on syntax. That single change makes SICP's evaluator many times faster for loops and recursive functions.

For a concrete compiler step, the same (+ 1 (* 2 x)) might progress through four IRs:

  • Source: (+ 1 (* 2 x))
  • AST: node(+, lit(1), node(*, lit(2), var(x)))
  • Three-address IR: t1 = 2 * x; t2 = 1 + t1;
  • Register machine / bytecode: LOAD_CONST 2; LOAD x; MUL; LOAD_CONST 1; ADD
  • x86: mov eax, 2; imul eax, [x]; add eax, 1

Each step drops a responsibility: the AST has lost the parentheses, the IR has lost the nesting, the bytecode has lost the names, the x86 has lost the platform.

Common Confusion / Misconception​

  1. "Compilation = fast, interpretation = slow." A well-engineered interpreter beats a poorly-engineered compiler. V8 interprets first, then JITs. Compilation buys specific things (elimination of dispatch, better codegen); it is not magic.
  2. "IR is one thing." Real compilers use several IRs back-to-back, each chosen for one optimization. LLVM alone goes through multiple SSA passes.
  3. "Once compiled, no more eval." Most real languages keep an eval-like fallback (eval in Python/JS/Lisp, reflection, plugins). The boundary is usually gradient, not binary.
  4. Confusing staging with caching. Caching memoizes results; staging commits to a cheaper residual program. The staged program still runs -- it just does less work.

How To Use It​

When optimizing an interpreter-shaped system:

  1. Profile first. Most eval-heavy systems spend their time in hot paths you can cache or pre-compile.
  2. Identify the earliest input. Anything that depends only on that can be lifted out of the inner loop. This is exactly SICP §4.1.7.
  3. If you still need speed, introduce a simple IR (e.g. bytecode) and move dispatch from strings to small ints.
  4. Consider a compile-to-host-closure strategy (like the analyzed evaluator): each source node becomes a Python/Scheme closure that can be invoked directly at run time.

When reading docs for a new language runtime, ask: what's the IR? Is there a bytecode? Is there a JIT? The answers tell you most of its performance story.

Check Yourself​

  1. What do you gain by running analyze once and eval many times?
  2. Name three common intermediate representations in a production compiler.
  3. Why is "separating syntactic analysis from execution" a form of partial evaluation?

Mini Drill or Application​

Take the evaluator from Concept 11. Add a one-pass analyze that returns a Python callable taking only env, mirroring SICP §4.1.7. Benchmark both versions on (fact 10) in a tight loop (say, 100 000 runs). Report the speedup. Then sketch -- without implementing -- what bytecode for (fact 10) might look like and where the next speedup would come from.

Read This Only If Stuck​