Puffin

The Puffin optimizer: -O 0|1|2

This document describes Puffin's optimizer: what each optimization level does, where the passes sit in the pipeline, how the -O1 rewrite layer and the -O2 abstract interpreter work, and how the whole thing is tested. It is written for anyone who wants to know what -O 2 actually buys β€” or who wants to read the sources.

The optimizer exists in both compilers, kept in lockstep: the primary implementation is puffincc-src/contract.puf, puffincc-src/aam.puf, and puffincc-src/optimize.puf (with the backend and block-level hooks in puffincc-src/middle.puf and puffincc-src/backends.puf); the Racket implementation in src/opt/ is the consistency oracle. racket src/diff-ir.rkt optimize <prog.puf> [target] [olvl] checks that both produce identical IR after the optimize pass, modulo gensym spelling.

The three levels

level what runs cost bound
-O0 nothing β€” the base pipeline, verbatim β€”
-O1 (default) cp0-style contraction + bounded inlining on the core IR, block-level optimizations at explicate-control, and open-coding of data-structure primitives in the native backends O(n Β· E) for a constant effort limit E β€” the Waddell–Dybvig discipline Chez itself uses
-O2 (opt-in) everything in -O1, plus AAM-based interprocedural flow analysis (0-CFA-class, widened global store) and its clients: super-beta inlining, interprocedural constant folding, dead-definition pruning polynomial and guaranteed terminating; a state ceiling backstops the bound

-O1 is never asymptotically worse than Chez's cp0: every transformation is metered by an effort counter charged per node visited on behalf of an inlining attempt; when the budget for an attempt is exhausted, the attempt is abandoned (residualized), exactly as in "Fast and Effective Procedure Inlining" (Waddell & Dybvig, 1997). Total optimizer time is then linear in program size times the constant limit.

Two designed boundaries on where the levels apply:

Where the optimizer sits in the pipeline

desugar β†’ shrink β†’ uniqueify β†’ [optimize] β†’ collect-globals β†’ … β†’ anf β†’ explicate β†’ backends
                        β–²
        unique names, full expression structure,
        set! still visible, closures not yet converted

The optimize pass is IR-preserving (unique-source-tree? in and out), so every downstream pass, interpreter, predicate, golden test, and the provenance/visualization machinery work unchanged; -O0 simply makes it the identity. The placement is deliberate:

The -O1 block-level transformations and the backends' open-coding live further downstream (see their sections below); they read the same optimize-level switch.

Labels and staging

Every core-IR node gets a fixnum label once, up front (label-program!); the analysis and its clients speak in labels, never in raw syntax.

The -O2 analyzer is staged against the program: before iteration begins, it walks the syntax once and compiles, for every label, a specialized transfer closure β€” the abstract step with the pattern-match on syntax already performed, so what remains is pure lattice arithmetic on the store. The worklist loop never touches syntax. This is the Racket/Puffin rendition of partially evaluating the abstract machine against the program (abstract compilation), and it removes the dominant constant factor of naive AAM.

The labeled, staged formulation is also deliberate headroom: a labeled program is exactly an EDB ((app β„“ β„“f β„“a), (lam β„“ x β„“b), …) and the transfer closures correspond one-to-one to Horn rules, so re-hosting the heavyweight analyses on a Datalog engine would be a transcription, not a redesign. Nothing in the current system depends on that; it is a shape the code keeps on purpose.

-O1: contraction and bounded inlining

One demand-driven recursive rewrite in the cp0 mold, over the core IR (puffincc-src/contract.puf, src/opt/contract.rkt):

  1. Census (one O(n) pass): for every variable β€” reference count, whether it is set!, whether its binding is a lambda, whether it escapes (is referenced other than in rator position).
  2. Contraction, applied on the way down/up:
    • constant & copy propagation: (let ([x k]) e) substitutes k when x is unassigned and k a literal/variable (never past a set! of the rhs variable);
    • if-folding on literal tests (Racket truthiness: only #f is false);
    • algebraic prim folding on literal operands ((+ 1 2) β†’ 3, eq? on literals, not chains β€” the same table the interpreters derive from the stdlib manifest);
    • dead-let elimination: unreferenced, unassigned, effect-free rhs (effect analysis is syntactic: prims from the manifest marked pure);
    • begin/let flattening.
  3. Inlining: at every application whose rator is (or propagates to) a lambda or a known top-level function:
    • Ξ²-contract single-use lambdas unconditionally (pure size win);
    • otherwise attempt an inline: copy the body with fresh names, charge every node copied against the effort counter; abandon and residualize if the size limit or effort limit trips. Recursive functions inline at most once per call site per round (no loop unrolling at -O1);
    • a per-round code-growth budget caps multi-use inlining at a quarter of the program's size (with a floor of 2000 nodes so small programs are unconstrained; single-use inlines are net-zero and exempt). Without it, a large program with many small hot functions bloats 4–5Γ— in assembly and swamps the assembler; with it, big-program compile times stay in cp0 territory.
  4. Iterate 1–3 until no change or a round limit (the effort fuel makes each round linear; the round limit is a small constant β€” currently 4).

Correctness invariants: never propagate into or past set!; never let inlining make the census misclassify a binding as dead when a copied lambda still shares an assigned variable; (read) and effectful prims are immovable anchors.

Open-coded primitives (native backends, β‰₯ -O1)

At -O0, car, cdr, vector-ref, vector-set!, vector-length, pair?, null?, … are runtime calls β€” a callq/bl per list node touched, the single largest run-time gap against Chez on list-heavy code. At β‰₯ -O1 the native backends (arm64 and x86-64) emit them inline:

The bytecode target does not open-code these prims; they remain calls through the manifest-ordered primitive table (see docs/BYTECODE.md).

Also at β‰₯ -O1: applications whose rator is a known top-level function of matching arity compile to direct calls β€” no closure fetch, no indirect jump. The analysis-free case is syntactically visible after reveal-functions; -O2's super-beta client widens it to flow-proven single-target sites.

Block-level optimizations (explicate-control, β‰₯ -O1)

Three transformations that live where control flow becomes explicit (explicate-control: src/compile.rkt on the Racket side, puffincc-src/middle.puf in puffincc) β€” downstream of the tree-level optimizer, upstream of all backends, so each is written once and every target benefits:

-O2: the AAM

An eval/apply CESK* abstract machine over the direct-style core IR β€” no pre-ANF required (puffincc-src/aam.puf, src/opt/aam.rkt):

Clients (each a labeled-facts consumer; at -O2 they run first, then the -O1 contraction rounds see the rewritten tree):

  1. Flow constant folding: references whose flow set is a singleton (const k) rewrite to k β€” interprocedural constant propagation.
  2. Super-beta: call sites whose rator's flow set is a singleton closure inline through variables (-O1 only sees syntactic rators).
  3. Dead definitions: top-level functions never read in any reachable abstract state are dropped, mutually recursive dead cliques together (subsuming and extending the parse-time prelude pruning).

Every client rewrite preserves unique-source-tree? and semantics; anything doubtful is left alone.

One dialect delta between the two implementations: Puffin has no exceptions, so puffincc's client walk cross-checks its traversal against the analyzer's node table and any label desync is a loud, fatal error rather than a silent identity fallback. The state ceiling still degrades gracefully to -O1 in both.

Performance

There is a small 14-benchmark suite (bench/) run against Racket CS (the Chez backend). Read it for what it is: a tiny, self-selected set of programs, useful for keeping the optimizer honest across -O levels, not a basis for any claim about beating Chez. Chez is a mature system with decades of tuning behind it, and this suite is not the evidence that would settle a comparison. On these programs Puffin's native code lands in the same broad ballpark β€” ahead on some, behind on others.

What the suite does show reliably is the effect of the levels themselves: -O1 is roughly a 1.5Γ— geomean improvement over -O0; -O2 adds about 1% on these workloads (its clients mostly matter on higher-order code the suite underweights).

Where Puffin is clearly slower, the causes are understood:

bench/report.html breaks every benchmark down per level, showing run time and compile time β€” the optimizer's own cost is a reported number, not a footnote.

Testing discipline

Limitations