Go Compiler Architecture

The standard Go compiler — cmd/compile, historically nicknamed gc (“Go compiler”, not “garbage collector”) — turns the source of one package into one object file through an eight-phase pipeline: parse, type-check, build the compiler’s own intermediate representation (IR), run middle-end optimizations, walk (desugar) the IR, lower it into Static Single Assignment (SSA) form, generate machine code, and export type/inline data (cmd/compile README). Each phase is an internal Go package; the whole compiler has been written in Go and assembly since Go 1.5, when it was mechanically translated from C (Go 1.5 notes). Understanding this pipeline is what makes escape analysis, inlining, and bounds-check elimination legible rather than magic.

Mental Model

The single most important framing: cmd/compile compiles one package at a time, in isolation, and the linker stitches the per-package object files together afterward. The compiler never sees the whole program. What it does see of its dependencies is their export data — a compact serialized summary of exported types and inlinable function bodies that earlier compilations wrote. So the pipeline is best pictured as a funnel narrowing from human text to machine code, with export data flowing out the side for downstream packages and in the top from upstream ones.

flowchart TD
    SRC["source files (.go) of one package"] --> P1
    P1["1. Parsing — internal/syntax<br/>tokens → syntax tree"] --> P2
    P2["2. Type checking — internal/types2<br/>typed AST"] --> P3
    P3["3. IR construction (noding) — internal/noder, ir, types<br/>Unified IR → compiler IR"] --> P4
    P4["4. Middle end — internal/inline, devirtualize, escape<br/>dead code, devirt, inline, escape analysis"] --> P5
    P5["5. Walk — internal/walk<br/>desugar high-level constructs"] --> P6
    P6["6. Generic SSA — internal/ssagen, ssa<br/>IR → SSA, arch-independent opts"] --> P7
    P7["7. Machine code — internal/ssa (lower), cmd/internal/obj<br/>regalloc, arch-specific code"] --> OBJ["object file (.o)"]
    P3 -.import.-> EXP
    P7 -.->|8. Export data| EXP["export data: types + inlinable bodies"]
    EXP -.consumed by.-> OTHER["downstream package compilations"]
    style P4 fill:#7a3b8c,color:#fff
    style P6 fill:#235a8c,color:#fff

Diagram: the eight-phase cmd/compile pipeline (phase numbering follows the cmd/compile README). The insight is that the funnel has a side channel — export data — which is how a per-package compiler still manages whole-program-quality inlining and devirtualization across package boundaries.

Mechanical Walk-through

The README organizes the compiler into eight phases (cmd/compile README). Walking them in order:

Phase 1 — Parsing (cmd/compile/internal/syntax)

The source of each file is tokenized by the lexer and parsed by a recursive-descent parser into a per-file syntax tree. This front-end stage is covered in depth in Lexical Analysis and Parsing in Go; here it is enough to know that its output is a tree of syntax-package nodes, one per source file, with positions attached for error reporting.

Phase 2 — Type checking (cmd/compile/internal/types2)

types2 is a port of the standard library’s go/types retargeted to consume the syntax package’s tree instead of go/ast (cmd/compile README). It resolves identifiers, infers types, checks assignability and method sets, and — since Go 1.18 — performs generic type inference and constraint satisfaction. go/types and types2 are deliberately kept in lockstep (they share test data) so that the compiler and tools agree on what Go means. The output is the syntax tree annotated with full type information. The detail of this phase lives in Go Type Checking.

Phase 3 — IR construction / “noding” (internal/noder, internal/ir, internal/types)

The middle and back end of the compiler do not work on the syntax/types2 representation; they work on the compiler’s own IR (internal/ir) and type representation (internal/types). The noder converts one into the other. Modern Go uses Unified IR: the type-checked program is serialized into a compact binary object-graph format, and the noder deserializes that into IR nodes (cmd/compile README; golang/go noder/unified.go). The same Unified IR format is what gets written as export data and re-read when this package is later imported — which is why import, export, and inlining all share one mechanism. Generics are handled here: a generic function’s IR can be stenciled (instantiated) for concrete type arguments. See Intermediate Representation in the Go Compiler and Go Abstract Syntax Tree.

Phase 4 — Middle end (internal/inline, internal/devirtualize, internal/escape)

A sequence of architecture-independent optimization passes runs over the IR (cmd/compile README):

  • Dead code elimination removes unreachable IR.
  • Devirtualization (Devirtualization) replaces an interface method call with a direct call when the concrete type is statically known.
  • Function inlining (Function Inlining) substitutes a small callee’s body into the caller — and, crucially, can inline across packages because the callee’s body travels in export data. Profile-Guided Optimization feeds this pass: a CPU profile makes the inliner more aggressive on hot call sites.
  • Escape analysis (Escape Analysis) decides, per allocation, whether a value can live on the goroutine stack or must go to the heap — the decision that drives Stack vs Heap Allocation and ultimately GC pressure.

Phase 5 — Walk (internal/walk)

The final IR pass desugars: it rewrites high-level Go constructs into primitive ones, and decomposes complex statements into simpler ones (cmd/compile README). A range loop becomes an index loop; a map access becomes a call to a runtime map function; append becomes a call into the runtime’s slice-growth code; a channel send becomes runtime.chansend. After walk, the IR is low-level enough to translate mechanically into SSA. This is the phase that makes the runtime visible: walk is where m[k] turns into runtime.mapaccess.

Phase 6 — Generic SSA (internal/ssagen, internal/ssa)

ssagen converts the walked IR into Static Single Assignment form — an IR where every variable is assigned exactly once, which makes data-flow analysis tractable (cmd/compile README). On SSA the compiler runs a long series of architecture-independent optimization passes: constant folding, common-subexpression elimination, bounds-check elimination, nil-check elimination, loop optimizations. SSA is the heart of the optimizer — see Static Single Assignment Form.

Phase 7 — Generating machine code (internal/ssa lower pass, cmd/internal/obj)

A “lower” pass rewrites generic SSA values into their machine-specific variants for the target GOARCH, followed by architecture-specific SSA optimizations, register allocation, and instruction scheduling (cmd/compile README). The result is handed to cmd/internal/obj — the assembler library shared with cmd/asm — which emits the final machine instructions and produces the object file. The Plan 9-derived assembly that obj understands is covered in Go Plan 9 Assembly and The Go Assembler.

Phase 8 — Export

The compiler writes export data for the package: type information for every exported declaration, plus the IR of functions deemed inlinable, in the Unified IR format (cmd/compile README). This is what a downstream package’s phase 3 reads when it imports this one — closing the loop and enabling cross-package inlining and devirtualization despite per-package compilation.

Historical context — the C-to-Go translation

Through Go 1.4 the compiler was written in C (the per-architecture 6g/8g/5g programs). Go 1.5 mechanically translated the C compiler into Go with custom tooling — “in effect the same program in a different language,” explicitly not a rewrite, to avoid introducing new bugs (Go 1.5 notes). That is why building Go now requires a pre-existing Go toolchain to bootstrap (see Go Release Cycle and Versioning). The SSA back end was added later (Go 1.7+), gradually replacing the older code-generation path.

Configuration / Code examples

Inspecting each phase

# See the AST after parsing/type-checking, and IR dumps per function:
$ go build -gcflags='-W' ./...           # dump the IR (the "walk" form)
$ go build -gcflags='-S' ./...           # dump generated assembly
 
# Escape-analysis decisions (middle end, phase 4):
$ go build -gcflags='-m' ./...
./main.go:12:13: inlining call to fmt.Println
./main.go:10:2: moved to heap: buf
 
# Inlining decisions, verbose:
$ go build -gcflags='-m=2' ./...
  • -gcflags passes flags to cmd/compile. -m reports phase-4 escape and inline decisions; -S dumps phase-7 output; -W/-w dump IR. These are the developer-facing windows into the otherwise invisible pipeline — see Compiler Optimization Diagnostics.

Watching SSA itself

$ GOSSAFUNC=Fib go build foo.go      # writes ssa.html
  • GOSSAFUNC=<name> makes the compiler emit an interactive ssa.html showing the SSA of that function after every phase-6 pass — the canonical way to see optimization happen.

Disabling optimization to read the pipeline’s “raw” output

$ go build -gcflags='all=-N -l' ./...
  • -N disables SSA optimizations, -l disables inlining. The all= prefix applies the flags to every package, not just the main one. This is the standard build for debugging with Delve, because optimized code’s variables and line numbers do not map cleanly to source.

Failure Modes / Common Misunderstandings

gc means garbage collector.” In compiler context, gc is the Go compiler (cmd/compile). The garbage collector is a separate subsystem (Go Garbage Collector). The collision is unfortunate and constant.

“The compiler optimizes my whole program.” It compiles one package at a time. Cross-package optimization (inlining, devirtualization) is possible only because export data carries inlinable function bodies and type info between compilations. A function too large to be marked inlinable is opaque across the package boundary.

“SSA is the only IR.” There are two IRs. The middle end (phases 3–5) works on the tree-shaped internal/ir representation; only phase 6 onward uses SSA. Escape analysis and inlining happen on IR, before SSA exists.

“Walk is a minor cleanup step.” Walk is where Go’s high-level surface meets the runtime: it is the phase that turns range, map ops, append, channel ops, and defer into runtime calls and primitives. Most “why is this allocating / calling the runtime” questions are answered by understanding walk.

“gccgo / TinyGo go through this pipeline.” They do not. gccgo is a GCC front end with GCC’s back end; TinyGo uses LLVM. Only cmd/compile has this exact eight-phase structure — see gc Compiler vs gccgo vs TinyGo.

Alternatives and When to Choose Them

cmd/compile is one of three production Go compilers, and the architectural contrast is the point:

  • gccgo — a Go front end bolted onto the GCC back end. It reuses GCC’s mature, aggressive optimizers and its breadth of target architectures, at the cost of slower compilation and lagging the language spec. Choose it for an exotic target GCC supports but cmd/compile does not.
  • TinyGo — an LLVM-based compiler aimed at microcontrollers and WebAssembly, producing tiny binaries by using a different runtime and GC. Choose it for embedded/Wasm size constraints; it is not a drop-in replacement for the full standard library.
  • cmd/compile (gc) — the reference compiler. It optimizes for fast compilation and tight integration with the Go runtime and the six-month release cadence, accepting a less aggressive optimizer than GCC/LLVM. It is the right default for essentially all server and CLI Go.

The decision framework lives in gc Compiler vs gccgo vs TinyGo.

Production Notes

For day-to-day work the architecture matters in two concrete ways. First, compilation speed is a design goal: the per-package model plus the build cache (which keys cached object files on inputs) is why large Go codebases rebuild quickly — a property worth protecting by not defeating the cache with non-deterministic build inputs.

Second, the phases are the map for performance debugging. “Why is this allocating?” is a phase-4 escape-analysis question, read with -gcflags=-m. “Why wasn’t this inlined?” is a phase-4 inliner question, read with -m=2, and potentially fixable with Profile-Guided Optimization, which feeds a real CPU profile back into the inliner so hot paths get inlined more aggressively (PGO blog). “Why is there a bounds check in my hot loop?” is a phase-6 SSA question, inspected with GOSSAFUNC. Knowing which phase owns a decision tells you which flag to reach for.

See Also