agentsclimarketplace

Go perf escape analysis

Skill ctoth/golang-skills-plugin/plugins/performant-golang/skills/go-perf-escape-analysis

Guides reading Go escape analysis via go build -gcflags=-m to know what heap-allocates and how to keep it on the stack — interpreting moved to heap / escapes to heap / does not escape and the triggers that force the heap (returned pointers, interface/fmt boxing, escaping closures, escaping slices/maps, []byte(string), channel pointers), with the refactor for each. Fires on why is this allocating, does this escape, keep this on the stack, reduce heap allocations. Routes inlining to go-perf-inlining, allocator cost to go-perf-allocator-internals.From its SKILL.md

Install
npx -y skills add ctoth/golang-skills-plugin --skill go-perf-escape-analysis

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

SKILL.md

12.7 KB, ~3.3k tokens by cl100k_base, as published. Nobody here has run it

Go Escape Analysis

"If the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap to avoid dangling pointer errors." — Go FAQ, stack or heap

A stack allocation is reclaimed for free when the frame returns; a heap allocation is GC work. Which one a value gets is decided by the compiler's escape analysis, and it will print its decisions. The one-paragraph intro — "stack is free, heap costs GC," the moved to heap line — belongs to go-performance §4; read it first. This skill is its deep peer: it owns reading the full -gcflags=-m output, the complete taxonomy of what forces the heap, and the refactor back to the stack for each trigger. Every claim below is backed by real -m output from Go 1.26.

All Go below builds clean under gofmt, go vet, and go test on Go 1.26.


1. The Question Escape Analysis Answers

From a correctness standpoint you never need to know — "The storage location chosen by the implementation is irrelevant to the semantics of the language" (FAQ). It matters only for efficiency. The rule the compiler enforces: "if a variable has its address taken, that variable is a candidate for allocation on the heap. However, a basic escape analysis recognizes some cases when such variables will not live past the return from the function and can reside on the stack" (FAQ).

Escape analysis is why the heap profile looks the way it does: a heap profile tells you where allocations land; -m tells you why a specific line escapes and whether you can stop it.


2. Running It — -gcflags=-m

-m is documented as "Print optimization decisions. Higher values or repetition produce more detail" (cmd/compile).

go build -gcflags=-m ./...          # decisions
go build -gcflags='-m -m' ./...     # add the flow that explains WHY it escaped

-gcflags takes an optional package pattern that "restricts the use of that argument list to the building of packages matching that pattern" (go help build). So -gcflags='example.com/pkg=-m' hits only that package; -gcflags=all=-m hits it and its dependencies (pages of stdlib noise). Scope it to your package.

The compiler is conservative: it "gives up" in many cases, because "anything assigned to any kind of indirection (*p = ...) is considered escaped" and analysis is inhibited by "function calls, package boundaries, slice literals, subslicing and indexing, etc. Full rules are too complex to describe, so check the -m output" (CompilerOptimizations). The -m output is the ground truth — do not assert "this doesn't allocate" without it.


3. Reading the Messages

The lines that matter, with the exact wording the compiler emits:

MessageMeans
moved to heap: xa named local (often address-taken) is forced to the heap
&p escapes to heap / ... escapes to heapthis value/expression must be heap-allocated
x does not escapestays on the stack — the good case
leaking param: p to result ~r0a parameter flows out (to a result/global); the caller's value may escape
make([]T, n) does not escapethe backing array is stack-allocated
can inline F / inlining call to Finlining decisions (route → go-perf-inlining)

-m -m adds the flow chain that proves the escape. For fmt.Sprintf("%d", n):

./esc.go:21:27: n escapes to heap in Show:
./esc.go:21:27:   flow: {storage for ... argument} ← &{storage for n}:
./esc.go:21:27:   flow: {heap} ← {storage for ... argument}:
./esc.go:21:27:     from fmt.Sprintf("%d", ... argument...) (call parameter) at ./esc.go:21:20

Read it bottom-up: n is spilled into the ...any slice, which is passed to fmt.Sprintf, which the compiler cannot prove keeps it on-stack — so n escapes. That chain is how you locate the real cause when the top-level line is terse.


4. The Taxonomy of What Forces the Heap

Each item below pairs the wrong shape (with its real -m line) against the stack-safe refactor.

4.1 Returning a pointer to a local

type Point struct{ X, Y int }
func NewPoint(x, y int) *Point { p := Point{x, y}; return &p } // ./esc.go:12:2: moved to heap: p

The local p outlives the frame, so it must live on the heap. Refactor: return the value when the struct is small — the copy is cheaper than a GC-tracked allocation.

func NewPointVal(x, y int) Point { return Point{x, y} } // no heap line

(Inlining can rescue the pointer form — §5.)

4.2 Interface boxing — concrete value into any/interface{}

Assigning a concrete value to an interface boxes it on the heap. The single biggest source is fmt, whose ...any parameter boxes every argument:

func Show(n int) string { return fmt.Sprintf("%d", n) } // ./esc.go:21:27: n escapes to heap
func Box(n int) any { return n }                        // ./esc.go:31:9: n escapes to heap

Refactor: use the typed, non-boxing path. strconv.Itoa takes an int, not any, so nothing escapes:

func ShowFast(n int) string { return strconv.Itoa(n) } // no "escapes to heap" for n

The same applies to strconv.AppendInt(buf, n, 10) into a reused buffer (depth → go-perf-strings-bytes-zerocopy). Reach for the typed API before any %v.

4.3 Closures capturing variables that outlive the frame

A variable captured by reference by a closure that escapes is itself moved to the heap:

func Counter() func() int {
	c := 0                              // ./esc.go:36:2: moved to heap: c
	return func() int { c++; return c } // ./esc.go:37:9: func literal escapes to heap
}

-m -m spells it out: c escapes to heap ... from c (captured by a closure). Refactor: if you don't need the closure to outlive the call, don't return it — pass state explicitly, or make the captured value a method receiver on a stack-allocated struct. A closure used and discarded within the frame does not force its captures to the heap.

4.4 Slices and maps the compiler can't bound

A make whose result stays local with a compiler-visible size stays on the stack; one that is returned, or grown unboundedly, escapes:

func SumLocal() int { s := make([]int, 3); /* ... */ return sum(s) }
//   ./esc.go:42:11: make([]int, 3) does not escape
func MakeSlice(n int) []int { return make([]int, n) } // ./esc.go:53:13: make([]int, n) escapes to heap
func Grow(n int) (s []int) {
	for i := 0; i < n; i++ { s = append(s, i) } // ./more.go:20:13: append escapes to heap
	return s
}

Refactor: keep scratch slices local and reuse them (s = s[:0]); when a slice must be returned, preallocate (make([]T, 0, n)) so it grows once (slice growth depth → go-perf-slices). A very large local also escapes even when it doesn't outlive the frame — "if a local variable is very large, it might make more sense to store it on the heap" (FAQ).

4.5 []byte(string) and string([]byte) conversions

A conversion that escapes copies onto the heap, but the compiler elides the copy in specific read-only cases — a map lookup keyed by string(b) does not allocate:

func Bytes(s string) []byte { return []byte(s) }              // ./more.go:11:45: ([]byte)(s) escapes to heap
func Lookup(m map[string]int, b []byte) int { return m[string(b)] } // ./more.go:14:63: string(b) does not escape

for range string(b) is elided the same way. Refactor: stay in one representation on the hot path; exploit the elision cases; zero-copy unsafe.String/unsafe.Slice with their safety rules → go-perf-strings-bytes-zerocopy.

4.6 Pointers sent on a channel

A pointer placed on a channel escapes — the runtime cannot prove the receiver stays in-frame:

func SendPtr(ch chan *Point, x, y int) { p := &Point{x, y}; ch <- p }
//   ./esc.go:58:7: &Point{...} escapes to heap   (./esc.go:57:14: ch does not escape)

Refactor: send the value (chan Point) when the struct is small and you don't need shared mutation; the channel copies it regardless.

4.7 Storing a concrete value into an interface-typed field

Same boxing as §4.2, but via a struct field rather than a parameter:

type Box2 struct{ v any }
func StoreIface(b *Box2, n int) { b.v = n } // ./more.go:8:41: n escapes to heap (b does not escape)

Refactor: give the field a concrete type when the set of stored types is known; an int field never boxes.


5. leaking param and the Interaction with Inlining

leaking param is distinct from escapes to heap: it describes a parameter whose reference flows out of the function (to a result or a global). It does not, by itself, allocate — it tells the caller's analysis that the argument may escape there:

func Sink(p *Point) any { return p } // ./more.go:26:11: leaking param: p to result ~r0 level=0
func Add(p *Point) int { return p.X + p.Y } // ./esc.go:63:10: p does not escape (kept in-frame)

This is the hook into inlining. Inlining can remove an escape: when NewPoint (§4.1) is inlined into a caller that never lets the pointer escape, the local can stay on the caller's stack and the moved to heap line disappears at the call site. So "a returned *T always allocates" is false — it depends on whether the callee inlines and whether the result escapes in the caller. The inlining budget, what blocks inlining, and //go:noinline are owned by go-perf-inlining; the cost of a heap allocation once it happens (size classes, GC scan) is go-perf-allocator-internals.


6. Don't

  • Don't claim "this doesn't allocate" without -m evidence. The rules are too complex to eyeball — "check the -m output" (CompilerOptimizations).
  • Don't forget that fmt.Sprint/Sprintf/Print box every argument into ...any; prefer strconv.Append*/Itoa on a hot path (§4.2).
  • Don't assume a returned *T always escapes — inlining can keep it on the caller's stack (§5).
  • Don't assume small structs never heap-allocate — taking their address, boxing them into an interface, or sending them on a channel all escape them (§4.1, §4.2, §4.6).
  • Don't ignore interface-typed fields and any parameters — storing a concrete value into either boxes it (§4.2, §4.7).
  • Don't re-make a scratch slice per call when it could be a local that "does not escape" and is reused with s = s[:0] (§4.4).
  • Don't reach for unsafe to dodge a copy the compiler already elides (m[string(b)], for range string(b) — §4.5).

7. Routing to Related Skills

This plugin (go-perf-* depth):

  • go-perf-inlining — the inlining budget, blockers, //go:noinline, and how inlining removes an escape (§5).
  • go-perf-allocator-internals — what a heap allocation costs: size classes, the tiny allocator, scan vs no-scan spans.
  • go-perf-slices / go-perf-maps — growth internals and preallocation that keep backing storage from escaping.
  • go-perf-strings-bytes-zerocopystrconv.Append*, unsafe.String/unsafe.Slice, conversion-elision cases (§4.5).
  • go-perf-pprof-profiling — the heap profile that says where allocations land; pair it with -m's why.
  • go-perf-methodology — whether reducing this allocation is worth it (measure first).

Parent / sibling marketplace (go-* correctness):

  • go-performance — the measure-first overview and the one-paragraph escape intro this skill deepens (§4 there).
  • go-version-feature-map — version floors for the runtime/GC behavior an escape feeds.

8. Reference Files

High-frequency escape-analysis mistakes in LLM-generated Go, each with wrong/right code, the real -m line, and citations:

${CLAUDE_SKILL_DIR}/references/common-mistakes.md

Source provenance for every claim in this skill:

${CLAUDE_SKILL_DIR}/references/sources.yaml

What ships with it: 2 files

13.1 KB alongside SKILL.md

references/

Keep looking

Skills are one crate of 326,645. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.