Go Compiler Internals: Escape Analysis, Function Inlining, and devirtualization
The difference between a handler that does 40 allocations per request and one that does four is usually not your data structures. It is whether the compiler managed to keep your temporaries on the stack. Heap allocation in Go is not slow because mallocgc is slow; it is slow because the garbage collector has to scan and reclaim every escaped object, and because the cache is cold for memory you touch once and never again. Escape analysis is the compiler pass that decides, and a staff engineer’s job is to feed it inputs it can keep on the stack.
How escape analysis decides
The rule is deceptively simple: a value lives on the stack if the compiler can prove it never outlives the function that creates it. Return a pointer to a local and the analysis says “escapes to heap.” Store a pointer in a global, pass it to a function that stores it, or leak it into an interface and it escapes. Two cases trip people up:
- Returning a struct by value keeps it on the stack. Returning
*Tdoes not, even if the caller immediately dereferences it. Return by value when the struct is small; the register ABI (Go 1.17+) passes and returns small structs in registers, so “by value” is often free. - Passing an argument to a function the compiler can see lets it reason across the boundary. That is what makes inlining a performance feature rather than a micro-optimization: when a callee is inlined, its body is fused into the caller and escape analysis gets to see the whole picture.
The second point is subtle and worth an example. A call into a function that stores its argument in a global forces an escape. After mid-stack inlining (default since Go 1.12), the store is visible to the caller’s analysis, and a value that looked like it had to go to the heap can stay on the stack.
Reading the compiler’s mind
go build -gcflags="-m -l" prints escape decisions. The -l disables inlining so you see pure escape behavior; run it again without -l and watch inlining move allocations back to the stack. For this code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
type Item struct {
Value int
}
func ByValue() Item {
return Item{Value: 10} // returned by value: stays on the stack
}
func ByPointer() *Item {
return &Item{Value: 20} // pointer escapes: heap allocation
}
func Inline(dst *int, v int) {
*dst = v * 2
}
func Caller() int {
var out int
Inline(&out, 21) // inlined: no closure, no escape
return out
}
the output looks like:
1
2
3
./main.go:8:6: can inline ByValue
./main.go:9:6: moved to heap: Item
./main.go:15:6: can inline Inline
With -m -m (double) you get the full reasoning, including why a value escaped. When I audit a hot path, I grep for moved to heap and treat every hit as a candidate for a fix, not a fact of life.
Inlining and devirtualization
Inlining is the lever that makes everything else work. Leaf functions inline trivially; mid-stack inlining goes deeper, and //go:noinline or //go:noescape are the escapes you use when the compiler’s choice is wrong for your case. Devirtualization is the newer piece: with Profile-Guided Optimization (PGO, -pgo=auto in Go 1.21+, devirtualization in 1.22+), the compiler can convert hot interface method calls into direct calls, killing the itab lookup and opening the call up to inlining. I have seen PGO shave 15-20% off a gateway whose hot path was dominated by interface dispatch – worth running on your production profiles.
A war story
Our ingestion service’s hot loop was building a small struct and passing it into a logging-style helper that took any. Every call boxed the struct into an interface, and interface boxing escapes by definition: the value lands on the heap with a type pointer. Twelve allocations per message, microseconds of CPU per message burned for zero value. The fix was two lines: change the helper signature from any to a concrete type and let the compiler inline it. Same behavior, four allocations, and the service went from pegging three cores to idling under 1.5. The code did not change; escape analysis just finally got to see what we were doing.
Production lessons
- Audit hot paths with
go build -gcflags="-m -m -l"and grep formoved to heap. Every hit is a candidate. - Return small structs by value, not pointer. The register ABI makes this nearly free.
- Be suspicious of
anyparameters: interface boxing forces escapes. Concrete types are the compiler’s friend. - Use
//go:noescapeto tell the compiler a function does not retain its pointer argument, and//go:noinlineto prevent an inline that is hurting – but only when you are certain. - Run PGO and let devirtualization turn hot interface calls into direct calls.
- Watch allocations in benchmarks with
testing.AllocsPerOp; memory pressure shows up as latency long before it shows up as RSS.