skip to content

`go build -gcflags=-m` shows a scratch buffer in your encoder escaping to the heap. How do you find out why?

level: seniorimportance: should knowfreq 40%

answer

  1. one line names the value, another names the cause
  2. read the inlining lines beside the escape line
  3. a summary is all the caller gets
  4. leaking param is the compiler's verdict on a parameter
  5. prove it with a directive, confirm it with -benchmem

basics

~20 s

Read the escape line together with the inlining lines around it: look for cannot inline on the helper the buffer is passed to, and a leaking param note on its parameter. Then split that helper and re-measure allocs/op.

solid answer

~60 s

Start from the measurement, not the flag: a benchmark run with `-benchmem` says how many allocations per operation you are chasing. Then read `-gcflags=-m` output for that file and correlate two kinds of line. The `moved to heap: scratch` line tells you which value; the `cannot inline` and `leaking param` lines nearby usually tell you why. When a helper is inlined, the compiler analyses its body in your frame and can often prove the buffer never outlives the call; when the helper stays a real call, escape analysis falls back to that function's summary, and a parameter marked `leaking param` forces the argument onto the heap. A call through an interface value or a function variable is worse still — there is no known callee, so the compiler must assume the argument escapes. Fix by splitting the over-budget helper into a small inlinable hot path plus a cold function, or by giving the buffer an owner that outlives the loop. Then confirm: the escape line is gone from `-m`, and `allocs/op` dropped.

code

text · 3 lines
text
./encode.go:38:2:  moved to heap: scratch
./encode.go:52:6:  cannot inline (*Encoder).writeField: function too complex: cost 137 exceeds budget 80
./encode.go:52:27: leaking param content: dst

go deeper

for a junior

Know that go build -gcflags=-m prints both escape and inlining lines, and that moved to heap names the variable that got allocated along with the line it is on.

for a middle

Explain the mechanism: escape analysis crosses function boundaries only through per-function summaries, so an inlined callee is analysed in the caller's frame while a real call falls back to its leaking param verdict.

for a senior

Walk the whole loop out loud — measure, narrow, hypothesise, test with a directive, change one thing, re-measure — and name at least one case where the escape is honest and the right answer is to leave it. That judgment is what is being assessed.

for a principal

Decide how much of a shared codec's shape may be dictated by the compiler. Hot/cold splits and concrete-type parameters buy allocations back but cost readability and API flexibility, and on a package every team imports that tradeoff is yours to set and to document.

## The situation You own a serialisation codec that sits on every service's hot path. A benchmark says one allocation per encode call. You were sure the small `[]byte` scratch area the encoder appends into lives on the stack, and `go build -gcflags=-m ./...` disagrees: ``` ./encode.go:38:2: moved to heap: scratch ./encode.go:52:6: cannot inline (*Encoder).writeField: function too complex: cost 137 exceeds budget 80 ./encode.go:52:27: leaking param content: dst ``` Those three lines are one story, and reading them together is the skill this question is about. ## Why an inlining failure shows up as an allocation Escape analysis asks a single question per value: can the compiler prove this value's lifetime ends when the frame does? If yes, it lives in the frame and costs nothing to reclaim. If it cannot prove that, it must allocate. The analysis is interprocedural, but only through **summaries**. For each function, the compiler works out what happens to its parameters — does this pointer get stored somewhere that outlives the call, returned, or passed on to something opaque? — and records the verdict as `leaking param: dst` or `leaking param content: dst`. When a caller passes a stack value to a parameter that leaks, the caller must heap-allocate it, because the summary is all the caller knows. **Inlining changes what the caller knows.** Once the callee's body is substituted into your frame, there is no summary and no boundary: the compiler sees exactly what happens to the buffer, and very often that is nothing that outlives the call. So a helper that drifts over the inlining cost budget can silently convert a stack value into a heap allocation, with no change to the code you wrote — only to the code you called. This is the failure mode worth recognising: the allocation is in your function, the cause is a `cannot inline` line on somebody else's. There is a third pattern behind the same symptom. If the call is made through an **interface value or a variable of function type**, the compiler has no statically known callee at all, so it has no summary, and must conservatively assume the argument escapes. The `-m` output will not print a `cannot inline` line for that — there is nothing to name — which is why the absence of an explanation is itself a clue to look at the call's shape. ## The workflow 1. **Measure before you read anything.** `go test -bench=BenchmarkEncode -benchmem` gives `B/op` and `allocs/op`. Without that number you cannot tell whether the escape you are about to find is worth removing, and most escapes are not. 2. **Narrow the compiler output.** `go build -gcflags=-m ./... 2>&1 | grep encode.go` — remembering that these lines go to stderr. Add `-m=2` when you want the escape reasoning chain and the inlining cost numbers rather than the verdicts alone. 3. **Find the value.** `moved to heap: scratch` or `&scratch escapes to heap` names it and gives you the line. 4. **Look outward from that line.** Which call does the value flow into? Is that callee reported `cannot inline`? Is its parameter reported `leaking param`? Is the call made through an interface or a func value? 5. **Form one hypothesis and test it.** If you think the inlining of a helper is what normally keeps the buffer on the stack elsewhere, prove it: put `//go:noinline` on that helper, re-run the benchmark, and see the allocation appear. That converts an inference into an experiment, and it costs a minute. 6. **Change one thing.** Either split the over-budget helper into a small hot path that inlines and a cold function that does not; or restructure so the buffer is owned by something whose lifetime obviously covers the call, and pass a slice of it; or, if the leak is genuine — the callee really does retain what you pass — accept the allocation and stop. 7. **Confirm both ways.** The `moved to heap` line should be gone from `-m` output, and `allocs/op` should have dropped in the benchmark. The compiler diagnostic alone is not proof of a win: it is proof of a code-generation change. ## The traps - **Chasing escapes without a measurement.** A large program produces hundreds of `escapes to heap` lines. Almost all of them are on paths that run once. The output is a lookup table, not a to-do list. - **Assuming inlining is always the cause.** A parameter can leak for an honest reason — the callee stores it in a struct that survives, or hands it to something the compiler cannot see through. Inlining that callee would not help; you would just move the same conclusion into your frame. - **Fixing the symptom in the caller.** Preallocating a bigger buffer or hoisting it out of the loop may remove the per-call allocation, but if the callee genuinely retains what you pass, you have now created aliasing across calls. Understand the `leaking param` note before you change ownership. - **Reading `-m` after your change and stopping there.** The escape line disappearing tells you the compiler decided differently. Whether the program got faster is a separate question, and only the benchmark answers it. ## Why this is worth knowing On a codec that every service imports, one allocation per call is a real cost — it is proportional to total request volume, and it lands in GC work rather than in your own CPU time, so it shows up as latency somewhere other than where it was caused. The value of `-gcflags=-m` here is not that it makes things fast; it is that it turns "I think this is on the stack" into something the compiler will state plainly, one line per decision, with a reason attached.

  • How would you prove that inlining, and not something else, is what keeps the buffer on the stack?
    Run the experiment in reverse. Put `//go:noinline` on the helper that is currently inlined, rebuild, and re-run the benchmark with `-benchmem`. If `allocs/op` goes from zero to one and the `-m` output gains a `moved to heap` line, the causal link is established rather than assumed. Then remove the directive.
  • The callee's parameter is reported as `leaking param`. Does inlining it still help?
    Only if the leak is an artefact of the summary rather than the behaviour. If the callee genuinely stores the pointer somewhere that outlives the call, inlining moves the same conclusion into your frame and the value still escapes. Read what the callee does with the parameter first; `-m=2` prints the flow that led to the verdict, which usually settles it in a few seconds.
  • Why can a call through an interface value cause an argument to escape?
    Because there is no statically known callee, so the compiler has no summary to consult and must assume the worst: that the implementation retains whatever it is handed. Nothing appears on a `cannot inline` line, since there is no specific function to name. If that call is hot and the concrete type is fixed in practice, changing the parameter to the concrete type is what removes the uncertainty.
  • After the change, `-m` no longer prints the escape. Is that enough to call it a win?
    No. It proves the compiler now generates different code, not that the program is faster. Re-run the benchmark with `-benchmem` and compare `allocs/op`, `B/op` and time per operation. A change that removes an allocation but adds work — copying more, or defeating a bounds-check elimination — can easily be a net loss, and only a measurement shows it.

saying these in an interview costs you the question

  • Works through -m output top to bottom without a benchmark pointing anywhere
  • Assumes every escape is caused by an inlining failure
  • Ignores the leaking param line and blames the caller's code
  • Declares victory because the escape line disappeared, with no re-measurement
  • Adds //go:noinline as the fix rather than as the experiment
  • Hoists a shared buffer out of the loop without checking whether the callee retains it