Skip to content
Lesson 7 of 7

Step 1 of 7 · Reading · ~4 min

Learn

Errors, Testing, and Performance

Performance and Escape Analysis

You will read where the compiler puts a value, count heap allocations with testing.AllocsPerRun, and remove the ones that matter. Fundamentals said + copies and strings.Builder exists; Testing taught allocs/op. This lesson ties both to what the compiler does with memory.

Stack or heap: the compiler decides

A stack value vanishes when its function returns. A heap value is freed by the garbage collector (GC), the runtime part that finds unreferenced heap values, at a CPU cost. Escape analysis moves a value to the heap only when its address might outlive the call, or when the compiler cannot prove otherwise.

go

go build -gcflags=-m prints each decision: &Point{...} escapes to heap for the first function, &Point{...} does not escape for the second.

Allocations per call (Go 1.13):

CodeAllocsWhy
return &Point{x, y}1the pointer outlives the call
return Point{x, y}0copied to the caller
sink = n (n an int, sink an interface{})1boxing: the interface points at a heap copy
a counter() returning a closure over local n2n moves to the heap, plus the closure

Values sent on a channel, and make with an unknown size, escape too.

Measure, do not guess

testing.AllocsPerRun(runs, f) makes one warm-up call of f, then runs more, and returns the average heap allocations per call as a float64, rounded down to a whole number.:

go

The first reports 0 because newPoint is inlined: the compiler pastes the small function's body into its caller, sees the pointer is thrown away, and keeps the Point on the stack. Assign the result to a package-level variable such as keep (a sink), or the compiler may delete the work you are measuring.

Where allocations come from

One function builds a label such as alice#7123, written four ways. Allocations per call:

VersionAllocs
name + "#" + strconv.Itoa(id)2
strings.Builder, three writes3
the same Builder after sb.Grow(len(name) + 21)2
fmt.Fprintf(&sb, "%s#%d", name, id)4

Itoa allocates a string and + the joined result. A Builder starts with a small buffer and reallocates it as writes pile up; Grow(k) takes a byte count and reserves room for k more, so the writes land in one buffer. Fprintf boxes both arguments first.

+= in a loop is worse: each step copies everything built so far, so the copying grows quadratically. A Builder stops the copying, but its buffer still doubles as it fills: more than a fixed count, far fewer than one allocation per item. Writing into a []byte made with make([]byte, 0, k) and calling string(buf) once costs 2: the buffer and the final copy, because strings are immutable. 1000 appends to a nil slice measured 11 allocations; make([]int, 0, 1000) measured 1.

Profile, then stop

To find which function allocates, run go test -bench . -memprofile mem.out, then go tool pprof -sample_index=alloc_objects mem.out. pprof is Go's profile viewer; its top command ranks functions by objects allocated. Use sync.Pool only after a profile shows allocation is the bottleneck.

Common mistakes

  • Discarding the measured result. The first call above printed 0 for code that allocates. Trust a zero only after storing the result.
  • Asserting an exact count. A preallocated []byte plus string() measured 2, not 1, and Builder growth differs by version. Compare growth classes instead.
  • Preallocating the wrong thing. make([]byte, n) holds n zero bytes and append adds after them: too long. Use make([]byte, 0, n).

Your exercise

Implement four ways of building a repeated string, measure each, and classify the allocation count against the input size. The starter reads and prints; the builders, measure and classify are stubs. The len(build(n)) in main is a separate, unmeasured call. The likeliest mistake is giving the plain Builder strategy a Grow, which changes its class.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…