ClojureTV
Breaking monolithic compilers into dozens of formally verified micro-passes eliminates cascading defects without sacrificing commercial throughput, as individual algorithmic complexity rather than pass count dictates compiler speed.
AI-assisted summary of Andy Keep - Writing a Nanopass Compiler — original source. Check the original for full context.
Source published:
Key ideas
- The Nanopass framework decomposes monolithic compiler passes into dozens of small, single-task transformations with strictly validated intermediate grammars to prevent subtle bugs from propagating downstream. (2:17)
- Refuting academic criticism that the technique was only viable for education, Andy Keep and R. Kent Dybvig rewrote the commercial Chez Scheme compiler's backend into 52 distinct passes, retaining high compilation speeds (self-compiling in ~10 seconds). (9:50)
- In a live demonstration, an ad-hoc 27-pass compiler built in days successfully lowered Scheme code down to low-level C with pointer tagging and flat closures, compiling and executing native binaries end-to-end directly from the REPL. (16:41)
- Porting Nanopass to Clojure is viable either by implementing passes via core.match or by porting the entire meta-framework using Clojure macros, though reproducing Scheme's syntax-case metaprogramming is non-trivial. (35:33)
Detailed notes
## Background Published by ClojureTV on YouTube on January 6, 2014, this talk features Andy Keep presenting his research on nanopass compiler development. Based on his doctoral work at Indiana University replacing the Chez Scheme compiler, Keep explains how breaking compilers into dozens of tiny, single-task passes with formally defined intermediate languages can tame architectural complexity and run at commercial performance levels.
## TL;DR Keep traces the evolution of compiler architectures from early, I/O-constrained monolithic passes to fine-grained nanopass frameworks that define formal grammars between intermediate stages. He recounts how the technique transitioned from Dan Friedman and Kent Dybvig's pedagogical exercises at Indiana University to production-grade implementation, exploding Chez Scheme's backend from 5 passes to 52 while maintaining high throughput. Demonstrating a brand-new 27-pass compiler targeting C built on an airplane just days prior, Keep walks through language specifications, catamorphism transformations, tagged pointer schemes, and flat closures before discussing the possibilities of porting the paradigm into Clojure.
## Atmosphere & style The presentation carries a candid, affectionate, and caffeine-fueled hacker vibe, punctuated by self-deprecating humor about sleep deprivation, airplane coding, and uncompleted to-do comments. Keep speaks with contagious enthusiasm for the craft of compilation, drawing warmth and laughter from the Clojure audience despite openly joking about demonstrating purely Scheme code. The mood shifts from engaging technical history to suspenseful live REPL demos, culminating in genuine crowd applause as his freshly assembled 27-pass pipeline successfully compiles and executes programs on the fly.
## Bulletin - [02:17](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=137s) The Nanopass framework decomposes monolithic compiler passes into dozens of small, single-task transformations with strictly validated intermediate grammars to prevent subtle bugs from propagating downstream. - [09:50](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=590s) Refuting academic criticism that the technique was only viable for education, Andy Keep and R. Kent Dybvig rewrote the commercial Chez Scheme compiler's backend into 52 distinct passes, retaining high compilation speeds (self-compiling in ~10 seconds). - [16:41](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1001s) In a live demonstration, an ad-hoc 27-pass compiler built in days successfully lowered Scheme code down to low-level C with pointer tagging and flat closures, compiling and executing native binaries end-to-end directly from the REPL. - [35:33](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=2133s) Porting Nanopass to Clojure is viable either by implementing passes via core.match or by porting the entire meta-framework using Clojure macros, though reproducing Scheme's syntax-case metaprogramming is non-trivial.
## Key takeaways - [00:11](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=11s) Historical compilers were constrained to very few passes because intermediate representations had to be read from and written to tape drives or paper tape, whereas modern memory enables many in-memory passes. - [02:17](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=137s) Traditional compilers bundle unrelated optimizations into monolithic passes, making it difficult to insert new transformations without risking delicate invariants. - [03:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=202s) The Nanopass methodology originated from Dan Friedman's 'playing the game' pedagogy at Indiana University, where students verified transformations by evaluating both input and output Scheme expressions. - [06:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=382s) Relying on generic S-expression list matching caused slow compilation and delayed bug detection, leading to the creation of the Nanopass framework with formal grammars and record-based representations. - [06:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=382s) The Nanopass framework automates boilerplate AST traversals, requiring developers to write logic only for the grammar productions being explicitly transformed. - [09:50](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=590s) To disprove reviewer skepticism regarding production viability, Keep and Dybvig revamped Chez Scheme's backend from 5 passes to 52 individual passes, including a graph-coloring register allocator. - [10:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=622s) Compile times are driven by the computational cost of specific passes, such as register allocation loops, rather than the raw number of passes. - [16:41](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1001s) For the demonstration, Keep implemented an ad-hoc compiler targeting C across 27 single-task passes and 23 intermediate language specifications. - [21:19](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1279s) Within passes, unquote with catamorphisms syntax triggers recursive traversal on smaller AST components, while quasiquoting is overridden to instantiate strongly typed records instead of raw lists. - [25:48](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1548s) The runtime memory model uses 3-bit pointer tagging on 64-bit aligned addresses to differentiate immediate fixnums, pairs, boxes, vectors, and closures without type structures. - [25:48](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1548s) Closures are implemented as flat contiguous memory blocks containing a code pointer in slot 0 followed by captured free variables. - [29:02](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1742s) The end-to-end compiler driver shells out to GCC and parses binary output back into the Scheme REPL, allowing intermediate AST state tracing across all 27 passes. - [38:17](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=2297s) The Nanopass framework is not limited to tree-like ASTs; passes can emit and analyze explicit control flow graphs before reconstructing intermediate ASTs. - [38:17](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=2297s) The framework permits large, monolithic passes when optimizations are deeply interdependent, as demonstrated by Chez Scheme's unified source optimizer.
## Detailed walkthrough ### [00:11](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=11s) The Evolution of Compiler Architectures
Andy Keep opens by joking that, keeping with the tradition set by his office mate Will Byrd, he will not be showing any Clojure code during the presentation, offering Scheme code instead. Expressing his sheer enthusiasm for the craft, Andy explains: “I love writing compilers and doing things with compilers I just I think it's a lot of fun so I wanted to start out with a little bit of compiler history uh just to talk about what the idea of the nanopass stuff was and what the Genesis for it is.”
He recounts how early compilers were historically structured as merely a small handful of passes—often illustrated as three passes, though practical compilers had slightly more. This architecture was dictated purely by strict hardware constraints: machines lacked the memory to perform all necessary transformations in a single pass, and they could not even hold the intermediate representations in memory. Each pass read the program representation from storage and wrote the transformed program back out to disk, which at the time often meant physical tape drives or paper tape. Minimizing passes was vital when intermediate I/O was that punishingly slow.
As computer hardware evolved, memory became plentiful enough to keep intermediate representations entirely in RAM across passes. Yet even with that leap, the architectural paradigm largely remained frozen in place: compilers still grouped transformations into a handful of coarse-grained passes. In modern interactive environments—such as the REPL in Scheme, Clojure, Haskell, or ML—compilers often skip disk I/O entirely, compiling directly in memory and executing immediately.
- Early compilers minimized passes because swapping intermediate representations to tape drives or paper tape imposed massive I/O penalties. - Modern REPL environments build on in-memory compilation, executing generated machine code without touching the file system.
```mermaid flowchart LR accTitle: Traditional Compiler Pipeline vs In-Memory REPL accDescr: Diagram illustrating early disk-bound compilation passes versus modern in-memory compilation. subgraph Early["Early Compilers (I/O Bound)"] direction TB E1["Pass 1"] -->|"Tape / Paper Tape"| E2["Pass 2"] E2 -->|"Tape / Paper Tape"| E3["Pass 3"] end subgraph Modern["Modern Compilers & REPLs"] direction TB M1["Frontend Pass"] -->|"In-Memory AST"| M2["Optimizer"] M2 -->|"In-Memory AST"| M3["Codegen / Run"] end ```
---
[02:17](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=137s) Monolithic Passes and the Problem of Interleaving Optimizations
The major downside of having only a handful of passes is that each pass becomes an overloaded, monolithic beast. Looking back at the compiler he previously worked on, Andy illustrates how much disparate work was crammed together: a single pass simultaneously performed closure conversion, loop recognition, multiple return values handling, foreign callable handling, foreign function calls, and direct call optimizations, while closure optimizations were awkwardly split across that pass and the subsequent one.
This bundling becomes an engineering nightmare when actively developing and extending a compiler. If an engineer needs to insert a new optimization pass that logically must execute after one specific task but before another, and both tasks reside inside the same monolithic pass, clean separation is impossible. Describing this bind, Andy notes: “well now I'm sort of in trouble because I have to find a way to kind of weave that into the existing path without breaking anything that's already there and so that's pretty tricky.” The overarching goal of the nanopass philosophy is to dismantle these monolithic structures, decomposing them into dozens of tiny, single-task passes.
- A single traditional pass in Chez Scheme bundled closure conversion, loop recognition, multiple return value processing, foreign function interfaces, and direct call optimizations. - Tightly bundled tasks make inserting new optimization phases risky, as developers must surgically weave code into complex existing passes without breaking delicate invariants.
---
[03:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=202s) The Educational Roots at Indiana University and "Playing the Game"
The nanopass approach originated in the compiler design course at Indiana University. When Andy's advisor, R. Kent Dybvig, went on sabbatical, Dan Friedman stepped in to teach the class. Friedman wanted to move away from the traditional model of building big passes and focus students' attention on the core conceptual ideas of compilation. He introduced the concept of tiny, single-task passes implemented with pattern matchers, alongside an pedagogical technique called "playing the game." In this setup, a pass took an input expression that looked like Scheme and produced a transformed output that also looked like Scheme. Because both forms were executable Scheme expressions, students could evaluate the input and the output and verify that both yielded the identical semantic result. As Andy observes: “And he called this playing the game and so we still do this in the IU compiler class.”
However, during that initial semester, the curriculum produced disconnected passes rather than a working end-to-end compiler. When Kent Dybvig returned, he embraced the conceptual elegance of Friedman’s approach but insisted that students must construct a complete compiler. Dybvig restructured the class so that students wrote roughly 40 micro-passes over the semester, compiling a subset of Scheme down to native assembly and implementing sophisticated algorithms like a Chaitin-style graph-coloring register allocator.
While this pedagogical compiler was successful, it suffered from two key engineering defects. First, the compiler was slow, primarily due to pattern-matching overhead across raw S-expression lists. Second, passes lacked strict structural verification: an early pass might produce a malformed intermediate AST, but intermediate passes that did not inspect that specific construct would silently pass it along. An error would eventually explode five to seven passes later in an unrelated pass. Students would spend hours debugging the pass where the failure manifested, unaware that the defect originated far upstream in an earlier pass.
- Dan Friedman designed "playing the game" so students could evaluate both the input and transformed output in Scheme to verify semantic equivalence. - Kent Dybvig adapted Friedman's ideas into a semester-long course where students built an end-to-end compiler of roughly 40 passes that emitted assembly with graph-coloring register allocation. - Relying on generic list matching led to slow compilation and delayed error reporting, masking bugs until downstream passes failed.
---
[06:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=382s) The Nanopass Framework: Design, Rejection, and Core Forms
To eliminate pattern-matching slowness and surface malformed ASTs immediately, Kent Dybvig, Oscar Waddell, and Dipanwakar Sarkar set out to build a formalized framework. When they submitted their initial paper describing a technique suited for both educational and production compilers to ICFP (the International Conference on Functional Programming), the reviewers pushed back with skepticism: “and they sent it to the uh icfp the International Conference on functional programming and they said no we don't believe that you can do a commercial compiler with this we think you have to refocus the paper to just say educational compilers.”
The resulting framework solved the list-matching and validation problems by introducing formally defined intermediate language grammars between passes. Instead of untyped lists, the framework uses Scheme records behind the scenes. This allows the system to determine match branches in constant time without traversing full lists, dramatically improving compilation speed. More importantly, every pass specifies an explicit input language and output language. If a pass emits code that violates the output grammar, the framework throws an error immediately at the boundary rather than allowing corrupt ASTs to pollute downstream passes.
The framework also drastically reduces compiler boilerplate. Because the system tracks both the input and output grammars and knows precisely what syntactic forms differ between them, it automatically generates the traversal and reconstruction boilerplate for all forms that remain identical. The programmer only writes code for the specific language forms being transformed. The core language primitives include: - `define-language`: Defines the syntactic grammar of an intermediate language, allowing incremental extensions and subtractions from previous languages. - `define-pass`: Specifies an explicit transformation pass, declaring its formal input language, output language, and matching clauses. - `with-output-language`: Constructs language AST forms outside of a pass body. - `nanopass-case`: Deconstructs and pattern-matches language AST forms outside of an official pass.
---
[09:50](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=590s) Taking on the Commercial Challenge in Chez Scheme
Spurred by the ICFP review asserting that nanopass architectures were unfit for production environments, Andy and Kent Dybvig set out to prove the reviewers wrong by replacing the existing commercial Chez Scheme compiler with a fully nanopass-based implementation.
Andy explains that the existing production Chez Scheme compiler had around 11 passes when he began the project. This included five or six front-end passes, followed by an optional source optimizer pass positioned directly in the middle of the compilation pipeline, which handled a dense variety of optimizations before handing code off to the back-end stages. Emphasizing their ambition, Andy recalls: “so as an aside uh we decided that we wanted to take up this challenge of a commercial nanopass compiler and we did this in the context of Chez Scheme uh which is my advisor's uh scheme uh compiler and we took his compiler and we decided we were going to write a commercial nanopass compiler.”
- The project aimed to directly refute academic claims that nanopass compilers could not perform at commercial scale and speed. - The baseline Chez Scheme compiler consisted of approximately 11 passes, featuring 5 to 6 front-end passes and an optional, heavy-duty middle-end source optimizer.
[10:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=622s) Exploding the Backend: 52 Passes and Nanopass Compile Times
Andy points out that the revamped Chez Scheme compiler retained essentially the same front end, but they “kind of exploded the back end just a bit.” The back end expanded from only 5 passes all the way to 52 individual passes. Crucially, they introduced an optimization loop in the back end to handle graph-coloring register allocation. While this addition improved the runtime performance of generated code, it came at the cost of slowing compiler throughput down.
```mermaid flowchart TD accTitle: Backend Pass Expansion in Chez Scheme accDescr: Diagram showing the transition from the legacy 5-pass backend to the 52-pass Nanopass backend with graph-coloring register allocation.
subgraph Legacy["Original Chez Scheme"] L1["Frontend"] --> L2["5-Pass Backend"] end
subgraph Nanopass["Nanopass Architecture"] N1["Frontend"] --> N2["52-Pass Backend"] N2 --> N3["Graph Coloring Register Allocator Loop"] end ```
To put compiler speed in perspective, Andy emphasizes just how blindingly fast Chez Scheme originally was. The initial compiler could self-compile tens of thousands of lines of code in roughly 3 seconds. The new 52-pass nanopass-based compiler self-compiles in about 10 seconds. Andy stresses that despite the massive increase in passes, “it’s not the number of passes that impacts it as much as it is that you have some passes in there that are just more expensive and they tend to dominate the amount of time that you spend.” The overall compile time is only slower by roughly half, but because the framework's language- and pass-defining macros generate vastly more code under the hood, the compiler is doing considerably more work.
- Original Chez Scheme self-compile time: ~3 seconds for tens of thousands of lines. - Nanopass Chez Scheme self-compile time: ~10 seconds. - Back-end pass count grew from 5 passes to 52 distinct passes.
---
[11:55](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=715s) Building an Ad-Hoc Compiler for the Talk
To demonstrate the nanopass technique, Andy initially considered using the class compiler from Indiana University. However, he decided against sharing that implementation publicly on GitHub because current students might download it and submit it as homework, robbing them of the learning experience and forcing the course instructor to hunt down academic integrity violations. Instead, Andy took an impromptu approach: “I decided Monday afternoon that I would write a new compiler.” He spent the ensuing days and a cross-country plane ride writing the codebase, admitting that he had not slept much leading up to the presentation.
The toy compiler targets a subset of Scheme with concrete language choices: - Variables are represented as Scheme symbols. - Immediate constants include booleans (`#t`, `#f`), empty list (`nil` or `()`), and 61-bit signed integers (a bit-width chosen specifically for tagging schemes). - List and pair primitives include `car`, `cdr`, `cons`, `pair?`, and `null?`. Notably, `set-car!` and `set-cdr!` are omitted, guaranteeing that user lists remain immutable. - Vector primitives are supported: `make-vector`, `vector-ref`, `vector-set!`, `vector?`, and `vector-length`, using vector literal syntax like `#(1 2 3)` to represent fixed-length arrays. - Memory mutation primitives include `box`, `unbox`, and `box-set!`. - Arithmetic and relational operators (`+`, `-`, `*`, `/`, `=`, comparisons) operate over 61-bit signed integers. - Identity comparison is provided via `eq?`. - Quoted data (`quote <datum>`) supports constant symbols, quoted pairs, nested structures, and vector literals.
---
[14:15](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=855s) Formally Defining the Intermediate Language Grammar
The Nanopass Framework uses formal language specifications with explicit terminal types and productions. Language terminals are verified via Scheme predicates: `symbol?` checks variable names, while custom predicates check primitives, constants, and structured datums. The framework uses these predicates to enforce strict dynamic or static validation whenever forms are constructed: “this is going to allow the nanopass framework to check to make sure that when we construct a new language form all of the parts of it are of the correct type.”
```mermaid flowchart LR accTitle: Nanopass AST Verification Pipeline accDescr: Pipeline showing raw S-expressions being parsed and checked against terminal predicates to generate validated AST records.
A["S-Expressions (Input Source)"] --> B["Nanopass Predicate Validation"] subgraph Preds["Predicates"] P1["symbol? (Variables)"] P2["primitive?"] P3["constant? / datum?"] end B --> Preds Preds --> C["Typed Internal Record Forms"] ```
Although the language specifications look syntactically like S-expressions, the framework generates strongly typed internal records for every compound syntactic construct. For instance, a `(quote datum)` node generates a dedicated single-field record. The grammar includes: - Core expressions: standalone primitives, variable references, constants, and quoted data. - Branching forms: single-armed `(if test then)`, two-armed `(if test then else)`, as well as `or`, `and`, and `not`. Because the toy language lacks a user-level macro system, forms like `and` and `or` (defined using `...` to denote zero or more arguments) are handled as primitive language forms to be expanded away by compiler passes. - State and sequencing: `set!` (included because Andy finds assignment conversion fun, despite having no particular fondness for `set!` itself) and `begin` for sequenced operations. - Bindings and functions: `let`, `letrec`, `lambda`, and function application. Square brackets are treated identically to round parentheses, used solely to make binding pairs visually distinct for readability.
---
[16:41](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1001s) Lowering Through 27 Passes to C
While Andy usually prefers targeting raw assembly, the time pressure of writing the compiler between Monday and the talk led him to target C. Setting up graph-coloring register allocation from scratch without reference materials on an airplane was impractical, so targeting C allowed him to delegate machine register allocation to the underlying C compiler.
The full compilation pipeline consists of 27 distinct passes:
```mermaid flowchart TD accTitle: 27-Pass Scheme to C Nanopass Pipeline accDescr: Flowchart illustrating the sequence of lowering passes from Scheme S-expressions down to C emission.
subgraph Front["1. Parsing & Normalization"] P1["parse-and-rename"] --> P2["remove-one-armed-if"] P2 --> P3["remove-and-or-not"] P3 --> P4["make-begins-explicit"] P4 --> P5["eta-inverse-primitives"] end
subgraph Mid["2. Desugaring & Closures"] Front --> P6["quote-constants & remove-complex"] P6 --> P7["identify-assigned & convert-assignments"] P7 --> P8["purify-letrec & optimize-direct-calls"] P8 --> P9["uncover-free & convert-closures"] P9 --> P10["optimize-known-calls"] end
subgraph Back["3. Lowering to C"] Mid --> P11["lift-lambdas & flatten-nesting"] P11 --> P12["expose-allocation-primitives"] P12 --> P13["reintroduce-set! & drop-let"] P13 --> P14["Emit C Code"] end ```
The pipeline methodically decomposes complex high-level features into primitive operations: - **Reader parsing & renaming**: Standard Scheme `read` converts text to S-expressions, after which the compiler parses them into internal records and renames all variables to globally unique symbols. - **Syntactic simplification**: Passes remove one-armed `if` expressions, desugar `and`, `or`, and `not`, and ensure explicit `begin` sequences inside the bodies of `let`, `letrec`, and `lambda`. - **Inverse η-expansion (eta inversion)**: Standalone primitive references are wrapped in lambdas with matched arities (e.g., bare `+` becomes `(lambda (x y) (+ x y))`). This guarantees every primitive appears directly in operator call position, enabling them to be open-coded directly into inline C statements without function-call overhead. - **Constant & assignment processing**: Complex constants have their runtime memory allocations made explicit. Assigned variables are detected and converted via assignment conversion (wrapping mutated variables in single-element heap boxes), eliminating `set!` so that every variable is strictly immutable. - **Binding and closure conversion**: Applied lambdas are converted to `let` bindings; `let`-bound lambdas are promoted into `letrec`s; anonymous lambdas are eliminated. Free variables are identified to enable closure conversion, packaging procedures into explicit environments suitable for C (bypassing newer C lambda standards). - **Lowering to C-level constructs**: Known function calls bypass closure lookups to call direct function pointers. Nested expressions are flattened, lambdas are lifted to global top-level C functions, allocation primitives are exposed, and imperative statements with reassigned C variables (`set!`) are reintroduced to model low-level assignment while dropping high-level `let` blocks.
[20:11](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1211s) Pipeline Overview and Intermediate Languages
Andy Keep continues outlining the compilation pipeline leading down to C generation. Before diving into the concrete passes, he details the structural simplifications remaining: flattening assignments so that a `set!` is never nested on the right-hand side of another `set!`, pushing `if` expressions inward so that all predicates end up in the expected positions for C, establishing a concrete runtime representation for constants (which directly explains the 61-bit signed integers in the source specification), expanding primitives, and finally generating C code.
Across the entire compiler, there are 23 intermediate languages defined. Keep jokes about the naming convention: > “L Source the the only admittedly the only named language and then a whole bunch of unnamed languages that have been conveniently enumerated as L1 through L22 yeah sometimes it's hard to come up with meaningful names for these things.”
```mermaid flowchart LR accTitle: Nanopass compiler pipeline progression accDescr: High-level compilation pipeline showing passes from L_src through flattening, tagging, and C generation. subgraph Frontend ["AST Normalization"] L0["L_src"] -->|"remove-one-armed-if"| L1["L1"] L1 --> L_mid["... Flatten set! & push ifs..."] end subgraph Backend ["Lowering & Emission"] L_mid --> L_rep["Specify Constant Rep (61-bit)"] L_rep --> L_prim["Expand Primitives"] L_prim --> L22["L22 -> Generate C"] end ```
The second language in the system, `L1`, embodies the philosophy of making minuscule, verifiable modifications to the grammar. It differs from `L_src` in only two respects: - The primitive set is extended to include `void`. - The grammar drops the one-armed `(if e0 e1)` form, requiring all subsequent conditionals to have an explicit alternative branch.
---
[21:19](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1279s) Anatomy of a Nanopass: `remove-one-armed-if`
Keep presents the implementation of the very first pass, `remove-one-armed-if`. The entire pass is remarkably concise—essentially a one-line transformation wrapped in standard pass-definition scaffolding. Using the `define-pass` form, the developer declares: - The pass name: `remove-one-armed-if`. - The input signature: an input language (`L_src`) and argument list (a single input form). - The output signature: the target language (`L1`) and any extra return values (expressed as empty parentheses `()` since Scheme supports multiple return values, but none are needed here). - A nested transformer: by convention named after the non-terminal it operates on (in this case, `Expr`). The framework automatically infers which transformer to invoke based on grammar rules, tracking the input non-terminal, argument count, output non-terminal, and extra return values.
Within the transformer body, pattern matching checks for the one-armed `(if,[e0],[e1])` form. Keep highlights two critical pieces of syntax:
1. **Unquote and Catamorphisms:** The comma `,` is Scheme's standard unquote (analogous to `~` in Clojure syntax-quote). When combined with square brackets around an expression variable—`[e0]`—it denotes a *catamorphism*. Keep quips: > “The idea behind the catamorphism is really fancy category Theory stuff. I think of it as I recur on something that's smaller than the thing that I had before, which I think is more or less the definition.” Operationally, it causes the framework to generate a recursive call to `Expr` on the subform, binding the processed result to `e0` before entering the clause body.
2. **Quasiquote Overriding:** On the emission side, the pass reconstructs the conditional as an explicit two-armed form: `` `(if,e0,e1 (void)) ``. Keep warns that this quasiquote syntax is deceiving: > “Quasi quote does not do what you expect it to do here because it's not building a list it's actually building a record for this particular type.” By leveraging Scheme's hygienic macro system, the Nanopass framework hijacks quasiquote inside passes to instantiate strongly typed AST records rather than linked lists of symbols.
The developer only writes code for the specific clause being eliminated or changed. For every other AST production in the grammar, the framework automatically synthesizes the recursive traversal and reconstruction boilerplate. If a clause cannot be matched or constructed, the generated code raises a runtime error preserving original source locations: > “Because I got sick of trying to debug this while we were working on the Chez Scheme compiler is it puts an error message in there that remembers the source location of where things may have gone wrong and reports back, ‘Hey, something went wrong at this source location,’ which is kind of nice when you're trying to debug things, especially if you spend about 18 months working on a compiler and it turns out you run into these things more frequently than you would like.”
---
[25:48](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1548s) Low-Level Runtime Representations: Pointer Tagging and Flat Closures
Keep explains the concrete memory representation chosen for Scheme values in the generated C target. Because all runtime heap objects are aligned to 64-bit word boundaries (multiples of 8 bytes), the low three bits of any pointer address are guaranteed to be zeros (`000`).
Rather than waste those bits, the compiler employs a traditional tagged-pointer scheme to encode types within a single 64-bit word (`uintptr_t`): - **`000` (tag 0):** Used for fixnums (immediate 61-bit signed integers). This choice allows arithmetic operations to execute efficiently with minimal shifting and provides fast zero-checks. - **Other tags (1 through 7):** Distinguish heap-allocated pairs, vectors, boxes, and closures.
When lowering high-level lambdas to C, the compiler must also resolve closures, since C lacks first-class closures. Keep implements a standard **flat closure** model: - Each closure is represented as a contiguous block of heap memory. - Slot 0 holds a function pointer pointing to the emitted top-level C function containing the compiled code. - Each subsequent slot stores one of the closure's captured free variables, computed via free-variable analysis.
```mermaid flowchart TD accTitle: Flat Closure Memory Layout accDescr: Diagram showing a tagged closure pointer pointing to a header slot with a code pointer followed by data slots for captured free variables. Ptr["Closure Pointer (low 3 bits tagged)"] --> Layout["Flat Closure Block in Heap"] subgraph Layout ["Memory Slots"] S0["Slot 0: Code Pointer (*fn)"] S1["Slot 1: Captured Var X"] S2["Slot 2: Captured Var Y"] end ```
For instance, in a nested lambda where an inner lambda references `x` and `y` defined in enclosing scopes, free variable analysis identifies `x` and `y` as free. The closure instantiated at runtime contains exactly three slots: one code pointer and two data slots holding the bindings of `x` and `y`.
Rather than declaring rich C `struct` types or safe wrappers for pairs, boxes, and closures, Keep opts for raw, untyped pointer manipulation: > “Because, you know what, C really doesn't care. C is happy to do whatever you want with a pointer, and that's what we're going to do. So maybe not the safest C code; we're going to have to trust our compiler is going to generate the right stuff.”
---
[29:02](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1742s) Compiler Driver and Live Implementation
Transitioning to a live code demonstration, Keep mirrors his laptop displays to walk through the actual Chez Scheme source files available in the public GitHub repository. The project is released under a permissive open-source license: > “Yeah, copyright... you know, I put that in there mostly just so that I can put a like an MIT copyright on these things, MIT-style or BSD-style copyright.”
He walks through the exported interface of the compiler library: - **`my-tiny-compile`:** The primary driver function. It accepts an S-expression, runs the Nanopass compilation pipeline, emits raw C code, invokes GCC in the background to build an executable or dynamic library, and executes the compiled binary while reading back the return value directly into Scheme. This end-to-end driver allows testing and development to stay entirely inside the Scheme REPL without manually dealing with the shell or C tooling. - **Pass Tracing:** Built-in instrumentation enabling developers to inspect the AST intermediate representation between any arbitrary intermediate language step (`L_src` through `L22`). - **Boehm GC Integration:** Keep mentions an experimental runtime option to hook into the Boehm garbage collector for automatic heap reclamation, joking that he spent about ten minutes reading the Boehm collector documentation and ran out of time to thoroughly test it, so it remains optional in the flags. - **Tag Constants:** The source explicitly defines constant bindings for runtime tagging, starting with a fixnum tag of `0` and a pair tag of `1`.
[30:32](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1832s) Implementation Details: Tagging, Environments, and S-Expression Exports
Andy walks through the underlying runtime representations laid out in the compiler’s source code. He highlights the tag values used for low-level value differentiation: two for box tags, three for vector tags, and four for closure tags, noting that additional tag space remains available if needed. The runtime defines distinct representations for boolean values (`#t` and `#f`), including a dedicated boolean tag intended to simplify the implementation of the `boolean?` predicate. Integers use fixnum tagging, tracked via a record that stores the fixnum shift: “a record for our fix num shift here so that we know that we need to shift the thing three bits up when we want to make a change to it or three bits down after we've done operations like multiply and divide.”
He also points out an explicit word size of eight bytes (64-bit words), helpers for handling unique variable generation, and a list of primitives. Spotting an unfinished comment in his editor, he laughs at an uncompleted task: “my to-do here which didn't actually get done o and my misspelling of should sad.”
Moving on to intermediate language definitions, Keep explains that while the file contains a full grammar reference as a comment, the intermediate languages themselves are generated automatically. Users can inspect or regenerate complete grammatical representations at any time by loading the compiler library along with the Nanopass framework and running `language->s-expression`, which outputs the full language specification.
When discussing the compiler passes, Andy pauses briefly on the first pass, `parse-and-rename`. He chose not to discuss it earlier because of its sheer length, noting that it acts somewhat like a macro expander by setting up an initial environment. In Scheme, core syntactic forms and keywords can be shadowed or rebound. To prevent user code from breaking compiler semantics—for instance, if user code binds a variable named `quote`, subsequent function invocations using that name should not be misinterpreted as literal quotation forms—the pass references an initial environment and continuously extends it as new bindings enter scope.
- **Tag Bit Assignments**: Box (`2`), Vector (`3`), Closure (`4`). - **Fixnum Shift**: 3 bits (left-shift before operations or right-shift after multiplication/division). - **Architecture Word Size**: 8 bytes (64 bits). - **Grammar Introspection**: `language->s-expression` regenerates the expanded intermediate language definitions directly from the Nanopass framework.
---
[32:40](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=1960s) Live Compiler Demo: Compiling to C and End-to-End Execution
Facing a ticking clock, Andy switches over to a terminal demonstration. He jokes that he deliberately pruned his command history down to just a few entries so he could easily cycle through them without getting lost. After importing the compiler library—which experiences a brief pause due to loading several thousand lines of compiler logic, the Nanopass framework code itself, and all the macro-generated output—he proceeds to test running actual programs.
His initial target was modest: get two programs working properly end-to-end, specifically the constant `5` and recursive `factorial`. Testing the trivial literal `5`, the compiler successfully runs through all 27 distinct passes, generates a C source file on disk, shells out to the host C compiler to produce an executable binary, executes that binary, writes the stdout to the filesystem, and uses Scheme’s native `read` function to parse the printed output back into the REPL. The room breaks into applause at the successful pipeline execution, to which Keep remarks: “It compiled five through all 27 passes it wrote A C file to the file system it told the C compiler to compile it it ran the program the program output was written to the file system and then we use a schem r to read that back in that all that's what happened.”
```mermaid flowchart TD accTitle: Nanopass End-to-End Compilation Pipeline accDescr: Pipeline showing Scheme code moving through 27 passes, emitting C, compiling, executing, and reading results back into Scheme.
subgraph FrontEnd["Nanopass Pipeline"] SRC["Scheme Source Code"] --> P1["Pass 1: parse-and-rename"] P1 --> P2["Middle Passes (2 to 26)"] P2 --> P27["Pass 27: C Code Generation"] end
subgraph Native["Host System Execution"] P27 -->|"write"| CFILE["Output.c File"] CFILE -->|"invoke"| CC["Host C Compiler"] CC --> BIN["Native Binary"] BIN -->|"exec & stdout"| OUT["Output File"] end
OUT -->|"Scheme read"| REPL["REPL Result"] ```
Andy then runs `(factorial 10)`, proving that the system handles recursion, arithmetic shifts, and conditionals quickly. Anticipating audience skepticism about whether actual compilation took place under the hood, he invokes `(trace-passes)` and reruns the program. The terminal flashes with intermediate forms as each pass logs its progress. While early passes such as `remove-one-armed-if` simply recur down the AST without altering structures that lack single-branch conditionals, later passes rewrite terms into low-level representations until printing raw C code via numerous nested `printf` calls. He laments the ergonomics of emitting C without high-level C abstractions, noting that pointer arithmetic and tagged references force awkward casting constructs such as casting a pointer to a `long`, performing the byte math, and casting it back again.
---
[35:33](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=2133s) Future Work and Porting Nanopass to Clojure
Reflecting on next steps for the project, Keep notes that having a working compiler opens up numerous avenues for expansion. Beyond targeting C, alternative backends could be written for JavaScript, Clojure, the JVM,.NET, or LLVM (which he expresses strong interest in exploring). He also highlights the possibility of introducing richer data structures inspired by Clojure into Scheme, adding a garbage collector, supporting foreign function interfaces (FFI), and implementing deeper optimization passes, driven by his personal motivation: “part of the reason why I love compilers is I love fast and these are to make things fast.”
```mermaid flowchart LR accTitle: Potential Compiler Backend and Target Paths accDescr: Diagram illustrating potential backends and Clojure porting avenues for the Nanopass compiler.
subgraph Targets["Target Backends"] C["C Backend (Current)"] LLVM["LLVM"] JVM["JVM / JavaScript /.NET"] end
subgraph ClojurePort["Porting Strategies to Clojure"] CM["Option 1: Port Compiler via core.match"] MAC["Option 2: Port Framework via Clojure Macros"] end ```
He then explores the challenges and approaches for porting Nanopass ideas into the Clojure ecosystem: 1. **Porting the Compiler Using `core.match`**: Translating the compiler passes directly to Clojure using `clojure.core.match` over internal data representations. Keep suspects this would be the easier path and likely provide solid execution speed. 2. **Porting the Nanopass Framework Itself**: Reimplementing the meta-framework using native Clojure macros to provide idiomatic, Clojure-style syntax and workflow.
He warns that porting the framework itself is far more challenging because the Scheme implementation relies heavily on complex `syntax-case` macros. Explaining the metaprogramming heavy lifting involved, he jokes: “part of the reason why I know that we can do arbitrary computation with syntax case cuz believe me I do arbitrary computation.” Even so, he emphasizes that there are no fundamental technical roadblocks preventing a Clojure implementation.
Andy concludes his slides by listing project links, his email, his Twitter handle, and his website. He mentions that the current codebase runs on Chez Scheme and should run on other R6RS-compliant systems like Icarus and Vicare, though platform-specific differences mean he has not yet verified them.
---
[38:17](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=2297s) Q&A: Relational Passes, Control Flow Graphs, and Monolithic Passes
Opening the floor for questions, Andy addresses an inquiry regarding whether passes could be structured relationally using miniKanren or logic programming principles. He shares that his lab neighbor is Will Byrd (co-author of *The Reasoned Schemer*), and that they have often discussed related concepts: “I do sit next to will bird uh so we we have actually talked about uh some of the ideas somewhat somewhat in the context of actually like uh writing a type Checker that you write with a pass like this uh that is actually written in a relational style.”
He points out that passes performing simple AST transformations—like eliminating single-branch `if` statements—are fundamentally term rewriting operations, and any term rewriting system can be expressed relationally.
An audience member asks how the Nanopass approach handles stages where the representation diverges from a straightforward AST, such as control flow graphs (CFGs) for register allocation. Keep explains that passes in the framework are not restricted to consuming and outputting strictly hierarchical ASTs:
```mermaid flowchart LR accTitle: Non-AST Control Flow Graph Pipeline accDescr: Illustrates how an AST transforms into a Control Flow Graph, undergoes graph passes, and returns to an AST.
AST1["Abstract Syntax Tree"] -->|"generate"| CFG["Control Flow Graph"] CFG --> GP1["Graph Pass: Register Allocation"] GP1 --> GP2["Graph Analysis Passes"] GP2 -->|"reconstruct"| AST2["Target AST"] ```
Earlier compiler iterations attempted to embed control flow metadata directly inside the AST nodes, but this proved clumsy for register allocation. The updated compiler explicitly converts the AST into an explicit graph structure, routes that graph through dedicated graph-oriented passes, and reconstructs an AST once those passes conclude.
Finally, Andy clarifies that the Nanopass philosophy does not mandate breaking every compiler stage into atomic nano-passes if doing so degrades optimization quality. Chez Scheme's source optimizer, for instance, is built as a single, large pass within the framework. It bundles multiple mutually dependent optimizations together because decoupling them would compromise compiler output efficiency. As Keep summarizes: “this the idea behind this framework is to write these little passes because we can write these little passes but we can't always write these little passes.”
With time expired, Andy thanks the audience to closing applause.
## Notable quotes > [10:22](https://www.youtube.com/watch?v=Os7FE3J-U5Q&t=622s) it's not the number of passes that impacts it as much as it is that you have some passes in there that are just more expensive and they tend to dominate the amount of time that you spend. — Andy Keep
## Mentioned Andy Keep, Will Byrd, Dan Friedman, R. Kent Dybvig, Oscar Waddell, Dipanwakar Sarkar, Matt Might, Chez Scheme, Nanopass Framework, Scheme, Clojure, Haskell, ML, Scala, miniKanren, C, GCC, LLVM, Boehm GC, Icarus, Vicare, Indiana University, University of Utah, International Conference on Functional Programming (ICFP), The Reasoned Schemer
## Video description Contemporary compilers are among the most complex of software systems, typically being required to handle sophisticated high-level source languages, generate efficient code for multiple hardware architectures and operating systems, and support source-level debuggers, profilers, and other program development tools. Nanopass frameworks are designed to help manage this complexity. A nanopass compiler is comprised of many single-task passes with formally defined intermediate languages. The nanopass compiler technique evolved from a methodology for teaching a compiler course at Indiana University, into a full-fledged framework, capable of implementing a replacement for the Chez Scheme compiler that runs on par with the original compiler. This talk will discuss the idea of a nanopass compiler and the implementation of a simple language using the framework in Chez Scheme, along with the challenges and opportunities of using these techniques in Clojure.
Andy is a postdoc at the University of Utah, currently working on malware detection in Android Apps, using both Scala and miniKanren to write abstract analyzers in Matt Might's lab. Andy completed his Ph.D. at Indiana University, defending his dissertation, "A Nanopass Framework for Commercial Compiler Development", in December 2012 and will be presenting a paper related to this work at this year's International Conference on Functional Programming. This project focused on replacing Chez Scheme's compiler with a nanopass compiler, along with his adviser R. Kent Dybvig. Before returning to graduate school in 2007, Andy worked in industry as a web developer, first for the consulting firms Razorfish and Gotham Broadband, along with some independent consulting work for Viacom, and later worked full time at Teach For America, maintaining both front-end and back-end web applications.
[Source](https://www.youtube.com/watch?v=Os7FE3J-U5Q)