skip to content

How do you make `go build` print the Go compiler's inlining and escape-analysis decisions?

level: juniorimportance: should knowfreq 50%

answer

  1. the compiler can explain itself
  2. a compiler flag, passed through the go command
  3. -gcflags carries it to cmd/compile
  4. one letter, repeatable for more detail
  5. output lands on stderr, not stdout

basics

~10 s

Run go build -gcflags=-m ./... or go test -gcflags=-m. The compiler prints its inlining and escape decisions to stderr: can inline, inlining call to, cannot inline, moved to heap. Use -m=2 for more detail.

solid answer

~40 s

`-m` is a compiler flag, so you pass it through `go build` or `go test` with `-gcflags`: `go build -gcflags=-m ./... 2>&1 | grep encode.go`. The compiler then reports, per source position, what it decided: `can inline f` and `inlining call to f` for successful inlining, `cannot inline f: <reason>` when it gave up, and `moved to heap: x`, `x escapes to heap` or `leaking param: p` for values it could not keep in the frame. These are diagnostics, not errors — the build still succeeds and the binary is unchanged. `-m=2` (equivalently `-gcflags='-m -m'`) adds the inlining cost numbers and a fuller explanation of each escape. By default the flags apply only to the packages named on the command line; write `-gcflags=all=-m` to include dependencies, which is much noisier.

code

text · 4 lines
text
./encode.go:14:6: can inline (*Encoder).grow with cost 62 as: method(*Encoder) func(int)
./encode.go:22:6: cannot inline (*Encoder).encodeValue: function too complex: cost 214 exceeds budget 80
./encode.go:31:17: inlining call to (*Encoder).grow
./encode.go:38:2: moved to heap: scratch

go deeper

for a junior

Be ready to type the command from memory: go build -gcflags=-m ./... and remember the output is on stderr. Recognising the four line shapes — can inline, inlining call to, cannot inline, moved to heap — is enough at this level.

for a middle

Explain what -gcflags actually does: it forwards flags to the compiler, optionally scoped by a package pattern, and all= widens that to dependencies. Know that -m=2 adds costs and escape reasoning, and that none of it changes the generated code.

for a senior

Show that you reach for -m only after a measurement points somewhere. An interviewer wants to hear you grep the output for one hot file, correlate an escape line with an allocation you already counted, and ignore the rest.

for a principal

Frame this as evidence discipline for a team: -m output is cheap to produce and easy to over-read, so the standard you set is that no optimisation lands on the strength of a compiler diagnostic alone — a before/after measurement is what justifies the change.

## What the flag is The Go compiler (`cmd/compile`, invoked for you by the `go` command) can narrate two of its optimisation decisions: **which functions it inlined**, and **which values it could not keep on the goroutine's stack** and therefore heap-allocated. The switch that turns this narration on is `-m`. You almost never call the compiler directly, so you hand the flag down through the `go` command: ``` go build -gcflags=-m ./... go test -gcflags=-m -run XXX -bench BenchmarkEncode ``` `-gcflags` takes an optional package pattern: its full form is `-gcflags=[pattern=]flag-list`. Without a pattern, the flags apply only to the packages **named on the command line**; with `all=`, they apply to every package in the build, including the standard library and dependencies. `-gcflags=all=-m` is therefore correct but produces thousands of lines, so most of the time you want the un-prefixed form plus a `grep` for your own file. ## Where the output goes To **stderr**, one line per decision, prefixed with the source position: ``` ./encode.go:22:6: cannot inline (*Encoder).encodeValue: function too complex: cost 214 exceeds budget 80 ./encode.go:38:2: moved to heap: scratch ``` Because it is stderr, a naive `go build -gcflags=-m ./... | grep escape` silently shows you nothing; you need `2>&1 |`. Nothing here is an error: the build succeeds and the resulting binary is byte-for-byte what you would have got without the flag. `-m` only asks the compiler to explain itself. ## The message families worth recognising - `can inline f with cost N as: func(...)` — the compiler decided `f` is a candidate. This says nothing about whether any particular call to it was actually inlined. - `inlining call to f` — a specific call site was replaced by the callee's body. - `cannot inline f: <reason>` — `f` will always be a real call. The reason is on the line: a cost that exceeds the inliner's budget, a `//go:noinline` directive, or a construct the inliner does not handle. - `moved to heap: x` — a local variable whose address is taken and which outlives its frame, so the compiler allocated it. - `x escapes to heap` / `&x escapes to heap` — the same conclusion reported at the point where the value gets away. - `leaking param: p` and `leaking param content: p` — a summary of a *function*, not a call: this parameter (or what it points to) can outlive the call, so callers passing a stack value must heap-allocate it. ## Turning up the verbosity `-m=2` — or the older spelling `-gcflags='-m -m'`, since the flag is repeatable — adds the arithmetic. You get the inlining cost of every candidate, the budget it was measured against, and a chain of reasoning for each escape ("parameter p leaks to result ~r0", "escapes to heap: flow: x = &y"). Levels above 2 exist but are for compiler development, not for tuning a program. ## How this fits an optimisation loop `-m` output is **evidence about the compiler**, not evidence about your program's behaviour. The useful loop is: measure first (a benchmark with `-benchmem` gives you `B/op` and `allocs/op`), then use `-m` to explain the number you measured, change one thing, and re-measure. Reading `-m` output cold, without a measurement, produces a long list of escapes that are all completely harmless because they happen once at start-up. A value that escapes on a path executed a million times a second is worth an hour of work; the same line in a config parser is worth none. The two families are also linked, which is why one flag prints both. Inlining a call lets the compiler analyse the callee's body *in the caller's frame*, so it can often prove a value never gets away — an inlining failure and an unexpected heap allocation two lines apart are frequently the same fact reported twice. ## Practical notes - The flags are part of the build cache key, so switching them on forces the affected packages to be recompiled; you do not need `-a`. - The positions are file:line:column of the *declaration or expression*, which is what lets you jump straight to the code. - `go vet` and the race detector tell you about correctness; `-m` tells you about code generation. They are unrelated tools that happen to be reached through the same `go` command.

  • What does adding `all=` to `-gcflags=all=-m` change?
    `-gcflags` accepts an optional package pattern: `-gcflags=[pattern=]flags`. Without one, the flags reach only the packages named on the command line. `all=` applies them to every package in the build, so you also get escape and inlining lines for the standard library and every dependency. It is occasionally useful when the allocation you are chasing happens inside a dependency, but it usually just buries your own file in thousands of lines.
  • You piped the output to grep and saw nothing. What went wrong?
    The compiler writes these diagnostics to stderr, so a plain pipe only carries stdout, which is empty. Use `go build -gcflags=-m ./... 2>&1 | grep encode.go`. It is a common first stumble and worth knowing before you conclude the compiler had nothing to say.
  • Does building with `-gcflags=-m` change the program the compiler produces?
    No. `-m` only asks the compiler to print the decisions it was making anyway; code generation is identical. That is what separates it from `-l` (disable inlining) or `-N` (disable optimisation), which do change the output. It does invalidate the build cache entry for those packages, so the affected packages are recompiled once.

saying these in an interview costs you the question

  • Thinks -m is a go build flag rather than a compiler flag passed via -gcflags
  • Believes the escape lines are compile errors that must be fixed
  • Expects the output on stdout and concludes the compiler printed nothing
  • Treats every 'escapes to heap' line as a performance problem regardless of hot path
  • Confuses 'can inline f' with 'this call to f was inlined'