Read OSS

The Go Compiler Pipeline: From Source Text to SSA to Machine Code

Advanced

Prerequisites

  • Articles 1-2: Repository Structure and Go Command Architecture
  • Basic compiler theory (lexing, parsing, ASTs, SSA form)
  • Understanding of register allocation concepts

The Go Compiler Pipeline: From Source Text to SSA to Machine Code

When the go command invokes cmd/compile, it kicks off a pipeline that transforms Go source text into architecture-specific machine code. The compiler is organized as a series of well-defined phases — parsing, type checking, escape analysis, SSA construction, optimization, and code generation — each building on the output of the previous one. This article traces that pipeline from beginning to end, with particular attention to the SSA pass system, one of the most elegant pieces of engineering in the codebase.

Compiler Entry and Architecture Dispatch

As we saw in Part 1, the compiler's main.go is a thin dispatcher:

src/cmd/compile/main.go#L28-L59

The archInits map selects an architecture-specific initialization function based on GOARCH. Each architecture's Init function populates an ssagen.ArchInfo struct with details like register sets, instruction selectors, and ABI conventions. After initialization, control passes to gc.Main, the actual compiler driver.

flowchart TD
    A["main()"] --> B["archInits[GOARCH]"]
    B --> C["e.g., amd64.Init(&ssagen.Arch)"]
    C --> D["gc.Main(archInit)"]
    D --> E["Parse & IR Construction"]
    E --> F["Type Checking"]
    F --> G["Inlining & Devirtualization"]
    G --> H["Escape Analysis"]
    H --> I["SSA Construction"]
    I --> J["SSA Optimization Passes"]
    J --> K["Code Generation & Object File"]

gc.Main is a large function that orchestrates the entire compilation:

src/cmd/compile/internal/gc/main.go#L64-L97

It begins by initializing the linker context, setting up debugging infrastructure, and configuring DWARF generation. One subtle detail: it adjusts the starting heap size for the compiler's own GC to 128MB when environment variables don't override it, reducing GC pressure during compilation of large packages.

Parsing and IR Construction

The compiler uses a hand-written recursive-descent parser in the syntax package (not Go's go/parser from the standard library — the compiler has its own optimized implementation). Source files are parsed into syntax trees, which are then transformed into the compiler's internal IR through the noder package.

src/cmd/compile/internal/gc/main.go#L217

noder.LoadPackage(flag.Args())

This single line hides considerable complexity. The noder uses a "unified" export format that serves double duty: it both imports pre-compiled package information from dependencies and processes source files for the current package. This unified approach replaced an older system where import data was handled separately, reducing code duplication and improving consistency.

The output is a set of IR nodes — the compiler's central data structure. Every subsequent phase operates on these nodes.

Type Checking and the IR Node System

The IR node system is defined in cmd/compile/internal/ir/node.go. It uses Go interfaces to represent the rich type hierarchy of Go constructs — expressions, statements, declarations, and functions:

src/cmd/compile/internal/ir/node.go

The compiler actually has two type systems: the original types package (used throughout the compiler backend) and types2 (a newer, more complete implementation that shares code with go/types in the standard library). The types2 checker runs during noding and produces type information that's mapped back into the types representation for the backend.

classDiagram
    class Node {
        +Op() Op
        +Type() *types.Type
        +Pos() src.XPos
    }
    class Name {
        +Sym() *types.Sym
        +Class byte
    }
    class CallExpr {
        +Fun Node
        +Args []Node
    }
    class FuncExpr {
        +Body []Node
        +Type() *types.Type
    }
    Node <|-- Name
    Node <|-- CallExpr
    Node <|-- FuncExpr

This dual type system is a pragmatic engineering choice. The newer types2 provides better error messages and handles generics natively, while the legacy types package is deeply embedded in the backend's optimization passes. Rather than rewriting the entire backend, the compiler translates between the two.

Escape Analysis and Inlining

Before SSA construction, two critical IR-level optimizations occur: inlining and escape analysis. Their ordering matters — inlining happens first because it exposes more optimization opportunities for escape analysis.

src/cmd/compile/internal/gc/main.go#L257-L293

Inlining replaces function calls with the function body, reducing call overhead and enabling further optimizations. The interleaved devirtualization and inlining pass analyzes the call graph, considering function size, complexity, and PGO (profile-guided optimization) data to make inlining decisions:

interleaved.DevirtualizeAndInlinePackage(typecheck.Target, profile)

Escape analysis determines whether a variable's lifetime exceeds its declaring function's scope. If it does, the variable "escapes" and must be heap-allocated. If it doesn't, it can live on the stack — dramatically cheaper because stack allocation is just a pointer bump and deallocation is free.

escape.Funcs(typecheck.Target.Funcs)
flowchart TD
    A["Variable declared in function"] --> B{"Does it escape?"}
    B -->|"Address taken &<br/>stored in heap object"| C["Heap allocated<br/>(runtime.newobject)"]
    B -->|"Address not taken,<br/>or only passed down"| D["Stack allocated<br/>(free on return)"]
    B -->|"Returned to caller"| C
    B -->|"Stored in closure<br/>that escapes"| C

Tip: Use go build -gcflags='-m' to see escape analysis decisions. Adding more -m flags (up to -m=3) increases verbosity. This is invaluable for performance-critical code where heap allocations matter.

SSA Construction and Optimization Passes

The SSA (Static Single Assignment) compiler is the heart of Go's optimization infrastructure. After IR-level passes complete, each function is converted to SSA form by the ssagen package, then run through a pipeline of optimization passes.

The pass pipeline is defined as a static array in compile.go:

src/cmd/compile/internal/ssa/compile.go#L457-L517

This is one of the most remarkable sections of the codebase. The passes array contains ~60 entries, each a pass struct:

src/cmd/compile/internal/ssa/compile.go#L200-L211

type pass struct {
    name     string
    fn       func(*Func)
    required bool
    disabled bool
    time     bool
    mem      bool
    stats    int
    debug    int
    test     int
    dump     map[string]bool
}

The required flag distinguishes mandatory passes (like lower and regalloc) from optional optimizations (like nilcheckelim and prove). When optimization is disabled (-N), only required passes run.

Key passes include:

Pass Purpose
early deadcode Remove dead code before optimization
opt Pattern-matching rewrite rules
generic cse Common subexpression elimination
nilcheckelim Remove redundant nil checks
prove Range analysis and bounds check elimination
dse Dead store elimination
lower Architecture-specific instruction selection
regalloc Register allocation
schedule Instruction scheduling

The Compile function iterates through the passes:

src/cmd/compile/internal/ssa/compile.go#L30-L97

When checking is enabled, the compiler randomizes value order within blocks between passes to catch bugs where passes incorrectly depend on iteration order. This is a defensive technique that's caught real bugs.

Architecture-Specific Lowering and Code Generation

The lower pass is where generic SSA operations become architecture-specific instructions. Before lowering, an addition might be represented as OpAdd64. After lowering on amd64, it becomes OpAMD64ADDQ. This translation is driven by declarative rewrite rules defined in .rules files that are compiled into Go code.

The ordering constraints between passes are documented explicitly:

src/cmd/compile/internal/ssa/compile.go#L527-L569

var passOrder = [...]constraint{
    {"dse", "insert resched checks"},
    {"insert resched checks", "lower"},
    {"generic cse", "prove"},
    {"prove", "generic deadcode"},
    // ...
}

These constraints are checked at init time, ensuring that the pass array respects all ordering requirements. This is a pattern worth studying: declarative ordering constraints with runtime verification, rather than implicit ordering that could break silently.

flowchart TD
    A["Generic SSA<br/>(OpAdd64, OpLoad, etc.)"] --> B["lower pass"]
    B --> C["Arch-specific SSA<br/>(OpAMD64ADDQ, etc.)"]
    C --> D["addressing modes"]
    D --> E["late lower"]
    E --> F["regalloc"]
    F --> G["schedule"]
    G --> H["Assembly emission"]
    H --> I["Object file (.o)"]

After register allocation and scheduling, the SSA values are converted to assembly instructions by the ssagen package, which emits them through the obj library. The final output is an object file that the linker will combine with other packages' objects to produce an executable.

The compilation loop in gc.Main processes functions concurrently:

src/cmd/compile/internal/gc/main.go#L315-L362

Multiple goroutines compile functions through the SSA pipeline in parallel, writing the final object data to disk.

Tip: Set GOSSAFUNC=YourFunctionName when running go build to generate an HTML visualization of your function's SSA at every pass stage. This is the single best tool for understanding what the compiler does to your code.

From Compiler to Runtime

We've now traced Go source from text through the compiler's pipeline to object code. But a compiled Go binary is much more than user code — it includes the Go runtime, which manages goroutines, memory, and garbage collection. In the next article, we'll follow what happens when a Go binary starts: from the first assembly instruction through runtime initialization to the user's main.main.