Go Internals - Compiler
Introduction
Nine articles in, almost everything we opened in Essentials lived on the runtime side: stacks and escape in Memory, mallocgc in Allocator, tri-color marking in Garbage Collector, Swiss-table maps in Built-in Types, itab dispatch in Interfaces, reflect.Value over abi.Type in Reflection, and channel parking in Concurrency.
This is the toolchain half of the same map. When you run go build, cmd/compile does not just emit instructions — it decides which values escape, which calls disappear into callees, where bounds checks can be dropped, and what metadata the GC and scheduler need in every stack frame. The runtime articles assume that work already happened.
We stay on Go 1.26 and read $GOROOT/src/cmd/compile/ alongside small reproducers. The goal is not to memorize every pass name, but to know where to look when profile or -gcflags output points at the compiler instead of runtime.
What go build runs
go build is an orchestrator. For a normal package it roughly does:
flowchart LR
A[".go files"] --> B["compile (per file)"]
B --> C[".o / archive"]
C --> D["link"]
D --> E["executable"]
Each .go file is compiled independently into an object file. The linker (cmd/link) resolves symbols, lays out read-only data (type descriptors, itabs, strings), patches relocations, and pulls in runtime.
Assembly files (.s) go through cmd/asm. C files in the standard library go through cmd/cgo or the platform C compiler when needed. Your application code is almost entirely the Go compiler path.
Inside cmd/compile, one file walks through a long but stable pipeline. Names shift between releases; the shape has held for years:
flowchart TB
P["parse"] --> TC["types2 typecheck"]
TC --> IR["noder → IR (intermediate representation)"]
IR --> W["walk (desugar)"]
W --> S["ssagen → SSA"]
S --> SP["SSA passes"]
SP --> G["genssa → machine code"]
G --> O["obj → object file"]
Parsing and type checking (types2) reject ill-typed programs before any optimization runs. walk lowers language constructs the backend does not handle directly — range, certain switch forms, select setup — into simpler IR. ssagen then lowers IR to SSA, where most performance-oriented optimization runs.
IR (intermediate representation): between source and SSA
After type checking, noder in cmd/compile/internal/noder/ builds the compiler's IR — a typed tree of nodes (ir.Func, ir.AssignStmt, ir.CallExpr, …) that still mirrors Go syntax but already carries resolved types and symbol links.
walk rewrites constructs the SSA backend does not consume directly. A for range over a slice becomes an indexed loop; certain switch and select forms expand into simpler control flow. Escape analysis and a few other passes run on this tree before ssagen lowers each function to SSA.
IR is the last representation that still looks like "Go with types attached." Once ssagen runs, you are in basic blocks and SSA values — the graph that prove, inlining, and register allocation actually rewrite.
SSA: the middle end
After ssagen, the function lives in Static Single Assignment form in cmd/compile/internal/ssa/. Each value is assigned exactly once; control flow is explicit blocks with predecessors and successors.
That representation is what optimization passes consume. The compiler can prove facts about a value's range, merge redundant nil checks, rewrite loads and stores, and delete dead blocks without re-walking a mutable AST.
A toy slice access lowers to something like this conceptually (real SSA is larger and platform-specific):
b1:
v1 = LocalAddr { .X }
v2 = Load v1
v3 = Const64 [0]
v4 = IsInBounds v2, v3 // len check
v5 = PtrIndex v1, v3
v6 = Load v5
...
Passes rewrite that graph. prove may learn that v3 is always less than v2, so v4 becomes constant true and later passes remove the branch. deadcode drops blocks that cannot execute. regalloc and genssa turn the result into amd64/arm64/etc. instructions.
You do not need to read every pass file. When debugging codegen, GOSSAFUNC (below) dumps the graph after each pass for one function — that is the fastest way to see which pass changed behavior.
Inlining: pay call overhead upfront
Function call overhead in Go is small but not free: stack frame setup, spill slots, sometimes write barriers on arguments that escape. Inlining copies the callee's body into the caller when the inliner in cmd/compile/internal/inline/ judges it profitable.
Heuristics weigh size, recursion, and whether the callee is marked //go:noinline. Tiny wrappers around hot paths — sync/atomic helpers, bytes accessors — often disappear entirely at call sites.
func add(a, b int) int {
return a + b
}
func sum(xs []int) int {
total := 0
for _, x := range xs {
total = add(total, x) // likely inlined
}
return total
}
Inlining interacts with everything else. An inlined body exposes escape and bounds facts to the caller's SSA. It also grows the caller's frame, which can prevent inlining of the caller itself — the inliner budget is real.
To see decisions:
go build -gcflags="-m=2" ./...
Look for lines like can inline add and inlining call to add. -m=2 adds the budget and reason when a function is rejected. Large functions, indirect calls, and recursive cycles are the usual blockers.
//go:noinline and //go:inline (Go 1.22+) override defaults when you are benchmarking or stabilizing a stack trace. Use them sparingly — they fight the compiler's main lever for removing call-site overhead.
Escape analysis: the heap decision
Memory covered escape from the application side. Here the same pass lives in the pipeline: cmd/compile/internal/escape/ runs on IR before or alongside SSA generation (the exact hook moves; the graph is the same).
Escape analysis builds a graph of assignments and pointer flows. If a pointer to a local can outlive its frame — return, global store, go closure, send on channel, assignment to an interface{} — the compiler emits a heap allocation (newobject, newarray, etc.) instead of stack slots.
The runtime never "promotes" a stack variable later. If -gcflags="-m" says moved to heap, that allocation is unconditional at run time.
Inlining merges graphs across functions. A callee that would escape in isolation may stay on the stack when inlined into a caller that does not leak the pointer — and the opposite happens too. That is why escape output changes when you refactor through small helper functions.
BCE (bounds-check elimination)
Go inserts bounds checks on slice and array index operations unless the compiler can prove them redundant. The prove pass in SSA tracks relationships between lengths, caps, and indices.
func first(xs []int) int {
if len(xs) == 0 {
return 0
}
return xs[0] // prove: index 0 < len after guard
}
func sum(xs []int) int {
total := 0
for i := 0; i < len(xs); i++ {
total += xs[i] // often BCE'd (bounds-check eliminated): i bounded by len(xs)
}
return total
}
When prove fails, you pay the check — a compare and conditional branch on every iteration in the loop case. Hot loops with checks still showing up in assembly are a signal to restructure (early length bind, separate for i := range xs, or a [:n] subs slice with known length).
Assembly inspection:
go build -gcflags="-S" ./...
Search for bounds-check helpers (panicIndex, panicSliceB, etc.) in the dumped output for the function you care about. -d=ssa/prove/debug=1 (via GOSSAFUNC) shows what prove learned when you need more than -S.
Devirtualization and interface calls
Interfaces showed the run-time itab path: load itab, load function pointer, indirect call. When the concrete type is known at compile time, the compiler can devirtualize — call the concrete method directly or inline it — and skip the itab lookup.
type Counter interface {
Inc()
}
type N int
func (n *N) Inc() { *n++ }
func bump(c Counter) {
c.Inc() // may devirtualize when bump's callers pass *N consistently
}
Devirtualization is fragile across package boundaries and interface-shaped APIs. Generic code and concrete-type call sites optimize more predictably. Profile with go tool pprof — time in runtime.getitab or indirect calls in hot loops often means the compiler lost track of the concrete type.
What the compiler emits for the runtime
Machine code is only half the object file. The rest is metadata the runtime reads without interpreting Go source.
Stack maps
The GC must find pointers in every stack frame while goroutines run. The compiler records stack maps — bitmaps or slot lists saying which stack words are pointers at each safe point (call, loop back-edge, function entry).
That is how Garbage Collector scans stacks concurrently without conservative guessing. If a slot is not marked live in the map, the GC ignores it even if bits look like an address.
Stack maps also feed stack copying in Memory: when newstack copies a goroutine stack, it uses the same maps to adjust interior pointers.
Type descriptors
Every concrete type used at run time gets an abi.Type descriptor (rodata). The linker deduplicates them; reflection and interfaces read the same tables (Reflection, Interfaces).
The compiler emits these from type-checked IR — field layouts, pointer bitmaps for GC, method lists, equal/hash functions where needed. map and channel operations reference type descriptors for element size and pointer-ness; Built-in Types showed the run-time side.
Write barriers
When Garbage Collector runs concurrently, mutator stores into heap pointers need write barriers. The compiler inserts barrier calls (or fused sequences) on pointer stores that might create new edges in the heap graph.
Not every store gets a barrier — stores to stack slots, nil stores, and some known-non-heap targets are skipped. Escape analysis matters here too: fewer heap pointers often means fewer barrier sites in hot paths.
Stack checks and morestack
Every function prologue compares the stack pointer against the guard slot the runtime maintains. Failure jumps to morestack, which may call newstack (Memory).
That check is compiler-inserted; the growth logic is runtime. You see the split clearly in profiles: runtime.morestack without corresponding heap allocs usually means deep recursion or large frames, not GC pressure.
Panic and defer tables
defer, panic, and recover compile to tables and run-time calls (deferproc, _panic, etc.) rather than zero-cost exceptions. The compiler lays out which defers belong to which function and where to unwind.
Heavy defer in tight loops still shows up in profiles for this reason — not because defer is broken, but because the fast path is a compiled call into the runtime defer chain.
Observing the compiler
| Flag / env | What you get |
|---|---|
-gcflags="-m" |
Escape decisions and inlining summary |
-gcflags="-m=2" |
More detail on why inline failed |
-gcflags="-S" |
Assembly listing per function |
GOSSAFUNC=pkg.Func go build |
SSA dump after each pass for one function |
-gcflags="-d=ssa/check/on" |
SSA sanity checks (compiler dev; slow) |
Run these on a small package or single file. Whole-module builds produce pages of output.
Example workflow for a suspicious loop:
GOSSAFUNC=main.sum go build -gcflags="-S" .
Compare SSA before and after prove, then read the assembly for leftover panicIndex. Cross-check escape with -gcflags="-m" if allocs appear without obvious source.
Compiler vs runtime: who owns what
| Question | Answered at compile time | Answered at run time |
|---|---|---|
| Stack vs heap for a local | escape analysis | — |
| Slice bounds safe? | BCE (bounds-check elimination) / prove |
panic if check remains and fails |
| Which method on an interface? | devirtualization when possible | itab + indirect call |
| Goroutine scheduling | — | scheduler (Scheduler) |
| Channel wait / wake | emits chansend/chanrecv calls |
Concurrency |
| When is heap memory reclaimed? | emits allocation + barriers | GC |
| Stack too small? | inserts stack check | morestack / newstack |
Production debugging usually bounces between both columns. alloc_space in a profile points at compiler escape decisions; syscall time points at the scheduler and netpoller; mutex profiles point at sync we covered in Concurrency — but the call sites and inlining choices came from here.
Conclusion
The Go compiler is the runtime's advance team. SSA passes reshape your functions into graphs it can prove about; inlining, escape analysis, bounds-check elimination, and devirtualization decide whether hot paths stay lean; stack maps, type descriptors, barriers, and stack checks land in the binary so the machinery from the rest of this series can run at all.
That closes Go Internals. You now have the full vertical slice — from go build through goroutine scheduling, memory, GC, language representations, sync, and the toolchain that wires them together. When Go 1.27 moves a pass or renames a struct, open the same paths under $GOROOT/src and diff against what these articles describe; the map should still point you to the right layer.