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 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):
| Code | Allocs | Why |
|---|---|---|
return &Point{x, y} | 1 | the pointer outlives the call |
return Point{x, y} | 0 | copied to the caller |
sink = n (n an int, sink an interface{}) | 1 | boxing: the interface points at a heap copy |
a counter() returning a closure over local n | 2 | n 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.:
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:
| Version | Allocs |
|---|---|
name + "#" + strconv.Itoa(id) | 2 |
strings.Builder, three writes | 3 |
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
0for code that allocates. Trust a zero only after storing the result. - Asserting an exact count. A preallocated
[]byteplusstring()measured 2, not 1, andBuildergrowth differs by version. Compare growth classes instead. - Preallocating the wrong thing.
make([]byte, n)holdsnzero bytes andappendadds after them: too long. Usemake([]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…