Compiler Optimization Diagnostics
The Go compiler makes performance-critical decisions — does this value escape to the heap, will this call be inlined, can this bounds check be removed — silently, on code you never profiled.
go build -gcflagsplus a few environment variables expose those decisions.-gcflags=-m“prints optimization decisions” — escape-analysis and inlining verdicts in plain English;-gcflags=-Sdumps the generated assembly;GOSSAFUNCwrites an interactive HTML page showing the SSA intermediate representation evolve pass by pass (cmd/compile docs). These are the tools that turn “why is this allocating?” from a guess into a fact, and they are the right first stop before reaching for a heap profile.
Mental Model
Think of these flags as a window into the compiler’s reasoning, not as profilers. A profiler measures the runtime — where time and memory actually go. The optimization diagnostics show the compile-time decisions that determined that runtime: the heap allocation a profiler reports as a hotspot was caused by an escape-analysis verdict you can read with -m. The relationship is causal — diagnose with -m, confirm the effect with a profile.
The three diagnostics sit at three different depths in the compiler pipeline. -m reports verdicts from the middle end — escape analysis and inlining, which run on the typed IR before SSA. GOSSAFUNC opens the optimizer — the SSA passes (opt, prove, lower, nilcheckelim, …) that transform the IR into a near-machine form. -S shows the back-end output — the final architecture-specific assembly. Reaching for the right one depends on which “why” you are asking.
flowchart LR SRC["your .go source"] --> COMP["gc compiler<br/>(cmd/compile)"] COMP -->|"-gcflags=-m"| M["escape and inline verdicts<br/>'x escapes to heap'<br/>'can inline f'"] COMP -->|"GOSSAFUNC=fn"| H["ssa.html<br/>IR after every<br/>optimization pass"] COMP -->|"-gcflags=-S"| S["final assembly<br/>(Plan 9 asm + offsets)"] M -.explains.-> HEAP["heap allocation<br/>seen in a pprof profile"] H -.explains.-> WHICH["which pass did<br/>or did not fire"] S -.explains.-> CPU["instruction count<br/>seen in a CPU profile"]
Diagram: the same compiler invocation can be asked for three views — -m (high-level escape/inline decisions, middle end), GOSSAFUNC (the SSA IR as the optimizer transforms it), -S (final machine code, back end). The insight: each diagnostic answers a different “why” — -m for allocation/inlining, GOSSAFUNC for which optimization pass did or did not fire, -S for instruction-level codegen.
Mechanical Walk-through
-gcflags plumbing. go build invokes the underlying compile tool once per package. -gcflags='...' passes the quoted flags through to that tool. By default the flags apply only to the package being built; to apply them to every package in the build, use the package-pattern form -gcflags='all=-m', and to scope to one import path -gcflags='example.com/pkg=-m'. The pattern prefix is load-bearing: go build -gcflags=-m ./... annotates only the top-level packages matched by ./..., which surprises people who expect their dependencies annotated too. The general form is -gcflags='[pattern=]flag...', documented under go help build (cmd/go docs).
-m — escape analysis and inlining. The flag’s own help text is “print optimization decisions” (base/flag.go). Escape analysis runs in the compiler’s middle end: for every value it proves whether the value’s lifetime is bounded by the function (stack-allocatable) or may outlive it (must go on the heap). Inlining decides whether a call is cheap enough to splice the callee’s body into the caller. -m prints a line for each such decision with a source position. -m is a count flag — repeating it raises verbosity: -m once gives the verdicts; -m -m (or -m=2) gives the reasoning — why a value escaped (naming the data-flow edge), why a function was too complex to inline. The official phrasing: “Higher values or repetition produce more detail” (cmd/compile docs).
-S — assembly listing. -S makes the compiler “print assembly listing to standard output (code only)” in Go’s Plan 9-derived assembly syntax, annotated with source line numbers and stack-frame offsets. -S -S additionally prints the data sections — “code and data” (cmd/compile docs). This is the ground truth for “did the bounds check get eliminated”, “did this loop get unrolled”, “is this call still here”. An often-cleaner alternative for one function is go tool objdump -s FuncName binary, which disassembles the linked binary by symbol regexp.
-N and -l — turning optimizations off. -N “disables optimizations”; -l “disables inlining” (cmd/compile docs). -gcflags='all=-N -l' produces a fully un-optimized binary — exactly what Delve wants so that variables are not optimized away and line numbers map cleanly. These are diagnostic in the sense that they let you compare optimized against un-optimized behavior, and they are essential for debugging.
The -l flag is a count flag with deliberately non-obvious semantics, documented in gc/main.go: the default with no -l is inlining on (internally Flag.LowerL == 1); a single -l turns inlining off (-l: inlining off, Flag.LowerL == 0); and -l=2, -l=3 turn “inlining on again, with extra debugging.” The source does the inversion explicitly — if LowerL <= 1 { LowerL = 1 - LowerL } — so 0↔1 swap and higher values pass through. The practical takeaway: to disable inlining you want exactly one -l; piling on more -ls re-enables it with debug output.
GOSSAFUNC — the SSA dump. Setting the GOSSAFUNC environment variable to a function name makes the compiler write ssa.html (the constant ssaDumpFile in ssagen/ssa.go) in the working directory, containing that function’s SSA intermediate representation after every optimization pass, side by side (SSA README). The HTML is interactive: hovering a value highlights it across all passes, so you can watch a bounds check, a multiplication, or a nil check appear, get rewritten, and get eliminated. The name match in ssa.go is flexible — it accepts a bare name ((*Reader).Reset), a fully-qualified name (compress/gzip.(*Reader).Reset), or a sub-package form (gzip.(*Reader).Reset), and an optional ABI suffix. GOSSAFUNC='Foo:lower,opt' restricts the dump to named passes. This is the deepest of the three views — it shows not just that an optimization happened but which pass did it.
-d — the debug-flag escape hatch. -d “prints debug information about items in list; try -d help” (cmd/compile docs). go tool compile -d help lists dozens of toggles, several directly diagnostic: -d=ssa/help prints help on SSA-pass debugging; -d=escapedebug prints escape-analysis internals; -d=nil prints information about nil checks; -d=pgodebug debugs profile-guided optimizations; -d=dumpinlcallsitescores dumps the per-callsite inlining scores. The -d=ssa/<pass>/debug family raises verbosity inside one SSA pass. This surface is less documented than -m/-S and shifts between releases, but it is where finer questions (“why did the prove pass not eliminate this bound?”) are answered.
Code / Usage Examples
Reading escape analysis with -m
// file: escape.go
package main
type Point struct{ X, Y int }
func newPointHeap() *Point { // returns a pointer -> the value outlives the call
p := Point{1, 2}
return &p
}
func sumStack() int { // p never leaves -> stays on the stack
p := Point{3, 4}
return p.X + p.Y
}$ go build -gcflags='-m' escape.go
./escape.go:6:2: moved to heap: p
./escape.go:11:2: p does not escapemoved to heap: p on line 6: newPointHeap returns &p, so p’s lifetime exceeds the function and escape analysis is forced to heap-allocate it. p does not escape on line 11: sumStack’s p is read and discarded within the frame, so it lives on the stack and costs the GC nothing. Adding -m -m would explain the chain — e.g. &p escapes to heap: ... flow: ~r0 = &p: from return ... — naming the exact data-flow edge that forced the verdict. This is the canonical “why is this allocating?” workflow: -m names the allocation, then you restructure the code to keep the value on the stack.
Reading inlining decisions
// file: inline.go
package main
func add(a, b int) int { return a + b } // tiny -> inlinable
func loop() int { // larger; the for loop adds cost
s := 0
for i := 0; i < 100; i++ { s += add(s, i) }
return s
}$ go build -gcflags='-m' inline.go
./inline.go:3:6: can inline add
./inline.go:7:16: inlining call to addcan inline add means the compiler judged add cheap enough to be a candidate; inlining call to add confirms a specific call site was actually inlined. If a function you expect to be inlined is not mentioned, run -m -m — you will see a reason such as cannot inline f: function too complex: cost N exceeds budget 80, or a note that it contains a construct that blocks inlining outright. The CompilerOptimizations wiki lists those hard blockers: a function is non-inlinable if it contains a closure, defer, recover, or select, if it is annotated //go:noinline or //go:uintptrescapes, or if it has no body. The numeric budget — an AST-node-count proxy capped at 80 — is the soft limit; see Function Inlining for the cost model, and note PGO raises that budget to 2000 for hot callsites, so the same function’s verdict differs between a plain build and a PGO build.
Comparing a PGO build against a plain build
# Plain build: does the hot helper inline?
go build -gcflags='-m' ./service 2>&1 | grep 'parseRequest'
# cannot inline parseRequest: function too complex: cost 240 exceeds budget 80
# Same package, built with the production profile:
go build -pgo=default.pgo -gcflags='-m' ./service 2>&1 | grep 'parseRequest'
# can inline parseRequest with cost 240 as: ... (hot)
# inlining call to parseRequestThe plain build reports parseRequest as too complex; with the profile the identical function inlines because PGO marks its callsite hot and raises the budget. This is the practical way to confirm a PGO optimization fired — re-run -m with the same -pgo profile your release uses, rather than trusting a plain-build verdict.
Assembly and SSA
# Final assembly for the whole package, with source line annotations:
go build -gcflags='-S' ./pkg 2> asm.txt
# Disassemble one function from the built binary (often cleaner):
go build -o app . && go tool objdump -s '^main\.hotLoop$' app
# Interactive SSA dump for one function -> writes ./ssa.html:
GOSSAFUNC=hotLoop go build ./pkg
# Restrict the SSA dump to two named passes:
GOSSAFUNC='hotLoop:opt,lower' go build ./pkg
# Un-optimized build for debugging with Delve:
go build -gcflags='all=-N -l' -o app.debug .The -S output shows whether, say, a slice index still carries a CMPQ/JLS bounds-check pair or whether bounds-check elimination removed it. objdump -s takes a regexp so you can isolate one symbol, and works on any binary, not only one you just compiled. GOSSAFUNC is the tool when -m and -S disagree with your expectation and you need to see which pass (prove, lower, nilcheckelim) is responsible for an optimization firing or not firing.
Failure Modes and Common Misunderstandings
-gcflags=-m only annotating top-level packages. Without the all= (or import/path=) prefix the flag applies only to the package being built, not its dependencies. To see escape decisions inside a library, use -gcflags='all=-m' — but expect a flood of output, since every package in the dependency graph is annotated.
Build cache hiding output. go build caches compiled packages; if a package is already cached, re-running with -gcflags=-m may print nothing because the compiler never actually ran. The diagnostic flags are part of the build cache key, so changing them does trigger a rebuild — but re-running the same -gcflags=-m twice prints output only the first time. Force a recompile with go build -a (rebuild everything) or by touching the source file.
Reading -m as a profiler. -m reports decisions, not frequency. moved to heap: p means that allocation site heap-allocates; it does not say the site executes a million times — or ever. Pair -m with a heap profile to learn which escaping allocations actually matter at runtime.
Inlining is heuristic and version-dependent. The inlining cost budget and the set of inline-blocking constructs change between Go releases (Go 1.22 previewed a rewritten call-site-scoring inliner under GOEXPERIMENT=newinliner), and PGO raises the budget for hot call sites. A function that inlines under one Go version or one PGO profile may not under another. Do not hard-code expectations or write tests that assert a specific can inline line.
-S syntax is Go’s own assembly. Go’s assembler uses a Plan 9-derived syntax that is not GNU/AT&T or Intel syntax — register names, operand order, addressing modes, and pseudo-instructions all differ. Reading it requires the asm doc; it is not interchangeable with gcc -S output, and pasting it into an Intel-syntax disassembler reference will mislead you.
GOSSAFUNC overwrites ssa.html and dumps only the named function. Each run writes ssa.html in the current directory, clobbering the previous one; a build touching many functions still dumps only the one whose name matches. The match is a substring/qualified match (see ssagen/ssa.go), so an over-broad name like Read may match an unintended function — prefer the qualified form pkg.(*T).Method when ambiguity is possible.
-l count semantics bite. Because -l is a count flag whose 0↔1 values are swapped in gc/main.go, -gcflags='-l -l' does not “really disable inlining” — it sets the count to 2, which re-enables inlining with debug output. To disable inlining use exactly one -l.
Alternatives and When to Choose Them
- CPU profiles — measure what actually happened at runtime. Use
-mto explain an allocation a heap profile flagged; use-Sto explain an instruction count a CPU profile flagged. Diagnostics and profilers are complements: the profiler finds the hotspot, the diagnostic explains the codegen behind it. go tool objdump— disassembles the linked binary by symbol regexp; cleaner than-Sfor inspecting one function, and works on a binary you did not just build (a release artifact, a colleague’s binary).go build -gcflags=-d=...— finer compiler-internal toggles (escapedebug,nil,ssa/<pass>/debug,dumpinlcallsitescores) beyond-m; less documented, more advanced, version-volatile, but the right tool for “which SSA pass” questions.go test -bench=. -benchmem— reportsallocs/opandB/op; the practical “did my escape fix actually work?” check, complementary to re-reading-m. A droppedallocs/opis the empirical confirmation of adoes not escapeverdict.golang.org/x/tools/cmd/bisect— when a compiler optimization is suspected of causing a bug rather than just being inspected,bisect(e.g.-compile=variablemakeper the Go 1.25/1.26 release notes) narrows down which specific optimized site is responsible.GODEBUG=inittrace=1— for startup cost rather than steady-state codegen; see GODEBUG Diagnostics.
Production Notes
These flags are development-time tools — you run them on your workstation while optimizing a hot path, not in production. The standard tightening loop is: a heap profile flags an allocation hotspot → go build -gcflags='all=-m' on that package names the escaping value and (with -m -m) the data-flow edge that forced it → you restructure the code (return by value, accept a pre-allocated buffer, avoid storing a value into an interface) → go test -bench -benchmem confirms allocs/op dropped → optionally -S or GOSSAFUNC confirms the codegen if the win was subtle. For inlining-sensitive hot paths, remember that PGO (production-ready since Go 1.21) changes inlining decisions using a real production profile, so a function that -m reports as not-inlined in a plain build may be inlined in a PGO build — always verify with the same profile your release uses, as the worked example above shows. The Go wiki’s CompilerOptimizations page catalogs which optimizations the gc compiler performs (escape analysis since gc 1.0, inlining since gc 1.0, the []byte→string map-lookup elision since gc 1.4, memclr since gc 1.5) and is the reference for interpreting -m output. The release-note Compiler sections are the other reference: Go 1.23 added PGO-fed hot-block alignment toggleable via -d=alignhot, and Go 1.25/1.26 added stack allocation of variable-sized make results, debuggable via -d=variablemakehash — both reminders that the diagnostic surface tracks the optimizations and shifts every release.
See Also
- Escape Analysis — the analysis whose verdicts
-mprints - Function Inlining — the cost model (budget 80) behind
can inline/cannot inline - Static Single Assignment Form — the IR
GOSSAFUNCvisualizes - Bounds Check Elimination — read
-Sto confirm a bounds check was removed - Stack vs Heap Allocation — what
moved to heapversusdoes not escapedecides - Profile-Guided Optimization — changes inlining and devirtualization decisions with a real profile
- The Go Assembler — the Plan 9 syntax
-Semits - pprof and Profiling — measures the runtime effect of these compile-time decisions
- Go Compiler Architecture — the pipeline whose stages
-m,GOSSAFUNC, and-Stap - Go Internals MOC — parent map of content