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, produceanalyze(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 onexpruns now; what needsenvruns 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:
- 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.
- 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.
- 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
- "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.
- "IR is one thing." Real compilers use several IRs back-to-back, each chosen for one optimization. LLVM alone goes through multiple SSA passes.
- "Once compiled, no more eval." Most real languages keep an eval-like fallback (
evalin Python/JS/Lisp, reflection, plugins). The boundary is usually gradient, not binary. - 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:
- Profile first. Most eval-heavy systems spend their time in hot paths you can cache or pre-compile.
- 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.
- If you still need speed, introduce a simple IR (e.g. bytecode) and move dispatch from strings to small ints.
- 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
- What do you gain by running
analyzeonce andevalmany times? - Name three common intermediate representations in a production compiler.
- 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.