The Go Compiler Pipeline: From Source Text to SSA to Machine Code
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-mflags (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=YourFunctionNamewhen runninggo buildto 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.