Custom COOL Compiler

Completed Stanford CS143 Compilers Systems

I built a custom COOL compiler for Stanford's CS143! The project is an end-to-end systems build that moves from parsing and semantic analysis toward backend and code generation work.

Code Generation

Code generation takes the typed AST from semantic analysis and lowers it into MIPS assembly that the COOL runtime can execute. By this point, the frontend has already rejected malformed programs, so the backend's job is to implement COOL's behavior and emit code that matches the runtime system's expectations.

A big part of this work is deciding how objects and methods live at runtime. That means laying out attributes in memory, emitting global constants and prototype objects, building tables like the class name table, object table, and dispatch tables, and then generating initializer code plus method bodies for every class. It is a much more architectural phase than the earlier passes because small design choices around stack frames, register usage, and attribute offsets affect every expression the compiler emits.

The implementation strategy I followed is a two-pass approach. First, the code gen layer has to figure out the layout and offsets each class will use. Then, it must recursively walk the annotated AST and emit MIPS for expressions, dispatches, and initializers while preserving the calling conventions expected by the COOL runtime. This part also includes runtime checks for situations like dispatch on void, so the final compiler is not just parsing valid COOL, but actually turning it into executable behavior.

Backend Trace

Watching the typed AST become runtime-aware MIPS

This is a compact walkthrough of the backend pass. It starts from the annotated let expression, locks in runtime layout decisions, and progressively emits the MIPS structures and instructions the COOL runtime expects.

Current Stage

Ready

Focus

ClassTable

Emission Detail

collect class tags, object sizes, and method slots

Pipeline

  1. 01
    Compute object layout Assign class tags, object sizes, and attribute offsets.
    pending
  2. 02
    Emit prototype objects Prebuild the object shape that allocations can copy at runtime.
    pending
  3. 03
    Build dispatch tables Lay out inherited and overridden method slots.
    pending
  4. 04
    Generate initializers Call parent init code and store attribute defaults.
    pending
  5. 05
    Emit method bodies Recursively lower typed expressions into MIPS instructions.
    pending
  6. 06
    Insert runtime checks Guard dynamic operations like dispatch on void before aborting.
    pending

Backend Snapshot

Typed Input Annotated AST
  • let : SELF_TYPE
  • x : Int <- 42
  • out_string(...) : SELF_TYPE
Runtime Shape Object Layout
  • tag | size | disp_ptr
  • x @ +12
  • prototype copied on alloc
Method Map Dispatch Slots
  • 0: Object.abort
  • 1: IO.out_string
  • 2: Main.main
Machine Code MIPS Emission
  • $s0 = self
  • $a0 = receiver/result
  • stack frames preserve calls

Emitted Assembly

main.s

    Optimization Passes

    Once the backend was generating correct MIPS, I started tightening the emitted code with a few local optimizations. The goal was simple: preserve program output while reducing instruction count and related runtime work on the same benchmark script.

    Backend Optimization

    Cutting redundant runtime work out of the generated assembly

    I measured a baseline with the unoptimized PA4 compiler, then compared each optimization pass against the same program. With all four optimizations enabled, the compiler still produces the same arithmetic output while doing substantially less work at runtime.

    Baseline 6855 instructions 1476 reads, 1022 writes, 1360 branches
    Final 3891 instructions 844 reads, 604 writes, 788 branches
    Impact 43% fewer instructions same COOL output, less generated runtime work

    What changed

    The first pass adds compile-time constant evaluation directly inside code generation, which lets the compiler collapse constant integer and boolean expressions before they ever become runtime instructions.

    From there, I layered on local algebraic simplifications such as x + 0, x - 0, x * 1, and x / 1. For cases like x * 0, the compiler still evaluates the non-constant side first so side effects and runtime checks are preserved before returning zero.

    I also optimized dispatch. Static dispatch can emit a direct jal because the target class is already known, and dynamic dispatch can be conservatively devirtualized when the receiver's static type is a leaf class with no subclasses.

    Optimized build

    ./cgen -O -o tests/optimizer.opt.s /tmp/optimizer.sem
    01

    Constant folding

    Constant expressions are evaluated during code generation instead of at runtime, removing unnecessary arithmetic and object traffic for foldable subtrees.

    6855 to 5587 instructions
    02

    Algebraic identities

    Local rewrites like x + 0, 1 * x, and x / 1 cut stack movement and arithmetic while preserving evaluation order where it matters.

    5587 to 3923 instructions
    03

    Static dispatch direct calls

    When the program explicitly names the dispatch type, the backend resolves the target method ahead of time and emits a direct call instead of loading through the dispatch table.

    3923 to 3917 instructions
    04

    Leaf-class devirtualization

    If a receiver's static class has no subclasses, the method binding is fixed, so dynamic dispatch can safely become a direct call in optimized builds.

    3917 to 3891 instructions

    Semantic Analyzers

    Once the parser produces an AST, the next question is whether that tree actually describes a legal COOL program. That is the semantic analyzer's job. This is the pass where syntax turns into meaning: the analyzer builds the class hierarchy, rejects malformed inheritance, tracks scopes, resolves dispatches, and enforces COOL's type rules before any code is generated.

    For each class, my semantic analyzer walks the AST, checks every expression against the current environment, and records the static type that later backend stages will rely on. If a node violates the language rules, the compiler can stop here with a precise semantic error; otherwise, it produces an annotated AST that is safe to hand off to code generation.

    Semantic Pass

    Watching the analyzer verify and annotate the AST

    This trace picks up from the parser demo's AST for let x : Int <- 42 in out_string("Hello, COOL!\n"). The analyzer checks the current environment, validates each subtree, and stamps expressions with the static types that code generation will consume.

    Current Pass

    Ready

    Node In Focus

    Global

    Rule / Annotation

    build class + method environments

    Checks

    1. 01
      Validate inheritance graph Make sure the class hierarchy is legal, acyclic, and rooted correctly.
      pending
    2. 02
      Open and extend scope Introduce x : Int before checking the body of the let.
      pending
    3. 03
      Check initializer conformance Verify the inferred type of 42 conforms to the declared binding type.
      pending
    4. 04
      Resolve dispatch and arguments Match the method signature and confirm the actual argument types fit.
      pending
    5. 05
      Annotate expression types Record the final static types that later compiler stages can trust.
      pending

    Annotated AST

    Expression let : SELF_TYPE
    Binding x : Int scope + x : Int
    Initializer 42 : Int
    Body out_string : SELF_TYPE
    Argument "Hello, COOL!\n" : String

    Environment

    The symbol and method context the current semantic check is using.

    Parsers

    Once I had a lexical analyzer, I could go on to build a parser using a generator called bison and a package for manipulating trees. The purpose of the parser is to ingest tokens output by the lexical analyzer and to use context free grammars to determine whether a sequence of tokens forms a valid program. A CFG defines a language structure via a set of production rules describing how smaller components (i.e., expressions, structures, declarations) can be composed into larger constructs. As the parser recognizes valid patterns in the token stream, it builds up an abstract syntax tree (AST). The AST is a simplified, structured representation of the program that captures its barest, logical organization.

    Parser Trace

    Watching a bottom-up LR trace build an AST

    This simplified trace follows a parser as it shifts tokens onto a stack, reduces handles into grammar symbols, and uses those reductions to grow the AST for let x : Int <- 42 in out_string("Hello, COOL!\n").

    Current Action

    Ready

    Lookahead

    LET

    Grammar Move

    stack = [$]

    Input Buffer

    Parse Stack

    Top of stack is highlighted below.

      AST Build

      Root let
      Binding x : Int
      Initializer 42
      Body out_string
      Argument "Hello, COOL!\n"

      Lexical Analyzer

      As the first component, I built a lexical analyzer in C++ with Flex. The objective is to parse Cool files and output an appropriate set of tokens which are used to generate code. Flex allows us to implement a lexical analyzer by defining rules that match user-defined regexes and perform specific actions for each matched pattern. It compiles the rule file into C source code implementing a finite automaton recognizing the defined regexes. r

      Lexer Trace

      Watching a tiny COOL snippet become tokens

      This is a small representative scan of a COOL program fragment. The highlight shows the lexeme currently being matched, while the token stream on the right fills in as the lexer emits tokens.

      Source

      class Main inherits IO {
        main(): Object {
          let x: Int <- 42 in
            out_string("Hello, COOL!\n");
        };
      };

      Tokens

        Back to Home