Exercises — Module 16: Performance and Profiling¶
Work through exercises in order — they're designed to build on each other. Attempt each problem genuinely before looking at the solution. Seeing the solution first might feel like progress, but it isn't.
Instructions¶
- Attempt first. Spend at least the estimated time on each problem before checking hints or solutions.
- Write your work. Don't just read the code mentally — actually write and run it.
- Check your answer against the acceptance criteria, not just the solution code.
- Score yourself honestly in the Scoring Log at the bottom.
- If you're stuck after a genuine effort, use the hints one at a time — not all at once.
Difficulty Legend¶
| Symbol | Difficulty | Expected Time | Points |
|---|---|---|---|
| 🟢 Easy | Recall and basic application | 5–10 min | 1 pt |
| 🟡 Medium | Requires combining 2+ concepts | 15–25 min | 2 pts |
| 🔴 Hard | Multi-step, requires real problem-solving | 30–60 min | 3 pts |
| ⭐ Challenge | Open-ended; more than one good answer | 60+ min | 5 pts |
Exercise 1: Spot the Broken Benchmark [🟢 Easy] [1 pt]¶
Context¶
A trustworthy benchmark requires two things many developers skip: protecting the result from dead-code elimination, and excluding setup from the measurement window with b.ResetTimer. This exercise focuses on recognizing both bugs in a realistic benchmark.
Task¶
The following benchmark is broken in two ways. Identify both bugs, explain what each causes, and write the corrected version.
package main
import (
"strings"
"testing"
)
func normalizeInput(s string) string {
return strings.TrimSpace(strings.ToLower(s))
}
func BenchmarkNormalize(b *testing.B) {
// Setup: build a realistic input
input := strings.Repeat(" Hello World ", 100)
for i := 0; i < b.N; i++ {
normalizeInput(input) // result not used
}
}
Requirements¶
- Identify Bug 1: dead-code elimination (result discarded)
- Identify Bug 2: setup is included in the measurement window (missing
b.ResetTimer) - Write a corrected version that uses a package-level sink and calls
b.ResetTimercorrectly - Explain in a comment why
_ = resultis NOT sufficient to prevent DCE
Hints¶
Hint 1 (try without this first)
The Go compiler can optimize away a function call whose return value is not used and which has no side effects. Assigning to `_` is syntactically a discard — the compiler treats it identically to not assigning at all. You need a package-level variable that "escapes" the compiler's ability to prove the value is unused.Hint 2 (only if Hint 1 wasn't enough)
The fix for DCE: declare `var sink string` at package level (outside any function). Assign `sink = result` after the loop. For ResetTimer: `b.ResetTimer()` must be called after all setup and before the `for range b.N` loop.Expected Output / Acceptance Criteria¶
The corrected benchmark should:
1. Call b.ResetTimer() after building input and before the loop
2. Assign the function result to a local variable inside the loop
3. Assign that local to a package-level sink variable after the loop
Solution¶
Show Solution (attempt first!)
package main
import (
"strings"
"testing"
)
func normalizeInput(s string) string {
return strings.TrimSpace(strings.ToLower(s))
}
// Package-level sink: the compiler cannot prove this is unused
// because it is accessible from other packages (exported) or escapes
// via package-scope. Prevents dead-code elimination of normalizeInput.
var sink string
func BenchmarkNormalize(b *testing.B) {
// Setup: build a realistic input (excluded from measurement)
input := strings.Repeat(" Hello World ", 100)
b.ResetTimer() // start measuring HERE — setup above is excluded
var result string
for range b.N {
result = normalizeInput(input) // result is used — DCE cannot eliminate call
}
sink = result // prevent DCE: force result to be "observed"
}
Exercise 2: Read Escape Analysis Output [🟢 Easy] [1 pt]¶
Context¶
go build -gcflags='-m' is the primary tool for understanding where variables escape to the heap. This exercise trains you to read that output and correlate it to source code.
Task¶
Given the following program, predict which variables will escape to the heap before running go build -gcflags='-m'. Then run the command, compare your prediction to the actual output, and explain each escape.
package main
import "fmt"
type Point struct{ X, Y float64 }
// Returns a pointer — does p escape?
func newPoint(x, y float64) *Point {
p := Point{X: x, Y: y}
return &p
}
// Returns a value — does p escape?
func makePoint(x, y float64) Point {
p := Point{X: x, Y: y}
return p
}
// Stores in interface — does val escape?
func storeInterface(val int) any {
return val
}
func main() {
p1 := newPoint(1, 2)
p2 := makePoint(3, 4)
v := storeInterface(42)
fmt.Println(p1, p2, v)
}
Requirements¶
- Predict which of
pinnewPoint,pinmakePoint, andvalinstoreInterfacewill escape, before running the command - Run
go build -gcflags='-m'and capture the output - For each escape the compiler reports, write one sentence explaining the cause
Hints¶
Hint 1
Three causes of escape to watch for here: (1) returning a pointer to a local variable forces it to outlive the function frame; (2) passing a value as `any` (interface) causes the value to be heap-copied because the interface stores a pointer to the value; (3) returning a value (not a pointer) does NOT force an escape — the caller's stack frame can hold it.Expected Output / Acceptance Criteria¶
Your explanation should correctly identify:
- p in newPoint escapes (pointer returned — outlives function frame)
- p in makePoint does NOT escape (returned by value)
- val in storeInterface escapes (stored in interface — interface boxing)
Solution¶
Show Solution
$ go build -gcflags='-m' escape_demo.go
./escape_demo.go:11:2: &p escapes to heap
./escape_demo.go:10:4: moved to heap: p
./escape_demo.go:22:17: val escapes to heap
./escape_demo.go:28:14: p1 escapes to heap # fmt.Println takes interface{}
./escape_demo.go:28:18: p2 escapes to heap # same
./escape_demo.go:28:22: v escapes to heap # same
Exercise 3: Profile a Function with pprof [🟡 Medium] [2 pts]¶
Context¶
Capturing a CPU profile during a benchmark and reading the output with go tool pprof is the core profiling workflow. This exercise walks you through the full cycle: benchmark → profile → read → identify hotspot.
Task¶
Write a function processLines(lines []string) []string that, for each line: trims whitespace, converts to lowercase, and prepends "[done] ". Then:
1. Write a benchmark for it with a realistic input (1000 lines of ~50 chars each)
2. Run the benchmark with -cpuprofile=cpu.out
3. Open the profile with go tool pprof cpu.out and run top5
4. Report what the top-3 functions are and why they appear
Requirements¶
-
processLinesproduces correct output (trim + lowercase + prefix) - Benchmark uses a package-level sink and calls
b.ResetTimercorrectly - Profile is captured with
go test -bench=. -cpuprofile=cpu.out ./... -
top5output is copied into a comment or noted in your answer - At least one hotspot is correctly explained (e.g., string allocation from
+)
Hints¶
Hint 1
Generate the 1000-line input in the benchmark setup, before `b.ResetTimer`. Each line can be `fmt.Sprintf(" Line number %04d with some extra text here ", i)`. This makes the input realistic: mixed whitespace, mixed case.Hint 2
After capturing `cpu.out`, run: `go tool pprof cpu.out` then type `top5` at the prompt. The output will likely show `runtime.mallocgc`, `strings.ToLower`, and `strings.TrimSpace` or similar. `mallocgc` appearing near the top indicates the function is allocation-heavy.Expected Output / Acceptance Criteria¶
The function should produce correct output. The profile top5 should show:
- runtime.mallocgc or allocation-related functions (from "[done] " + line string concatenation)
- strings.ToLower and/or strings.TrimSpace
Your answer should name the dominant cost and explain it (string + allocates a new string each call).
Solution¶
Show Solution
// process.go
package process
import "strings"
func processLines(lines []string) []string {
result := make([]string, len(lines))
for i, line := range lines {
trimmed := strings.TrimSpace(line)
lower := strings.ToLower(trimmed)
result[i] = "[done] " + lower // string + allocates a new string
}
return result
}
// process_test.go
package process
import (
"fmt"
"testing"
)
var sink []string
func BenchmarkProcessLines(b *testing.B) {
// Setup: 1000 realistic lines
lines := make([]string, 1000)
for i := range lines {
lines[i] = fmt.Sprintf(" Line number %04d with SOME Mixed Case Text ", i)
}
b.ResetTimer() // exclude setup from measurement
var result []string
for range b.N {
result = processLines(lines)
}
sink = result
}
Exercise 4: Reduce Allocations Using Escape Analysis [🟡 Medium] [2 pts]¶
Context¶
Escape analysis output directly shows you what to fix. This exercise requires you to read -gcflags=-m output for a function that unnecessarily allocates, redesign the function to keep values on the stack, and confirm the fix with both -gcflags=-m and -benchmem.
Task¶
The following function returns a pointer to a small struct (an Options value). It is called in a hot loop, creating one heap allocation per call.
- Run
go build -gcflags='-m'to confirm the escape - Rewrite the function to eliminate the escape (return a value instead of a pointer)
- Confirm the fix eliminates the escape with
-gcflags=-m - Write a benchmark comparing the two versions with
-benchmemto show the allocation difference
package main
type Options struct {
MaxRetries int
Timeout int
Debug bool
}
func defaultOptions() *Options {
return &Options{MaxRetries: 3, Timeout: 30, Debug: false}
}
func doWork(opts *Options) int {
// Simulates using opts
return opts.MaxRetries * opts.Timeout
}
Requirements¶
- Run
go build -gcflags='-m'and show the "escapes to heap" line for the original - Rewrite
defaultOptionsto returnOptions(not*Options) - Confirm the rewrite eliminates the heap escape with
-gcflags='-m' - Benchmark both versions with
-benchmem; the fixed version should show0 allocs/op
Hints¶
Hint 1
To return a value: change the signature to `func defaultOptions() Options { return Options{...} }`. The caller takes its address: `opts := defaultOptions(); doWork(&opts)`. The stack-allocated `opts` remains valid as long as `doWork` doesn't store the pointer somewhere that outlives the call.Hint 2
In the benchmark, call `doWork` with a pointer to the local value: `opts := defaultOptions(); result = doWork(&opts)`. This gives the compiler enough information to keep `opts` on the stack (it knows the pointer stays within the benchmark function's lifetime).Expected Output / Acceptance Criteria¶
Original -gcflags=-m output includes: &Options{...} escapes to heap
Fixed -gcflags=-m output: no escape line for Options
Original benchmark: ~1 allocs/op
Fixed benchmark: 0 allocs/op
Solution¶
Show Solution
// options.go
package main
type Options struct {
MaxRetries int
Timeout int
Debug bool
}
// Original: Options escapes to heap (pointer returned)
func defaultOptionsPtr() *Options {
return &Options{MaxRetries: 3, Timeout: 30, Debug: false}
}
// Fixed: Options stays on caller's stack (value returned)
func defaultOptions() Options {
return Options{MaxRetries: 3, Timeout: 30, Debug: false}
}
func doWork(opts *Options) int {
return opts.MaxRetries * opts.Timeout
}
// options_test.go
package main
import "testing"
var intSink int
func BenchmarkDefaultOptionsPtr(b *testing.B) {
var result int
for range b.N {
opts := defaultOptionsPtr()
result = doWork(opts)
}
intSink = result
}
func BenchmarkDefaultOptions(b *testing.B) {
var result int
for range b.N {
opts := defaultOptions() // stack allocated
result = doWork(&opts) // pass stack address — safe within function
}
intSink = result
}
Exercise 5: strings.Builder Preallocation Optimization [🔴 Hard] [3 pts]¶
Context¶
String concatenation in a loop is one of the most common Go performance antipatterns. This multi-step exercise requires you to implement three versions of a CSV row builder (naive, Builder, Builder with Grow), benchmark all three with -benchmem, and use benchstat to confirm statistical significance of the improvement.
Task¶
Write a function buildCSVRow(fields []string) string that joins fields with commas (no trailing comma). Implement three versions:
1. buildCSVRowNaive — concatenation with + in a loop
2. buildCSVRowBuilder — using strings.Builder (no Grow)
3. buildCSVRowPrealloc — using strings.Builder with Grow pre-sized to the exact output length
Then:
- Benchmark all three with -benchmem -count=10
- Capture output to before.txt (naive) and after.txt (prealloc)
- Run benchstat before.txt after.txt and include the delta in your answer
- Confirm buildCSVRowPrealloc allocates exactly 1 allocs/op
Requirements¶
- All three functions produce identical, correct output for the same input
- Each benchmark uses a 20-field input (e.g.,
"field_00"through"field_19") set up beforeb.ResetTimer -
benchstatis run on at least 10 runs per version and the delta is recorded -
buildCSVRowPreallocshows1 allocs/opin-benchmemoutput - The
Growargument in the prealloc version is the exact total byte count (sum of field lengths + len(fields)-1 commas)
Hints¶
Hint 1 (structural hint — try designing without this first)
For the naive version: `result += field; if i < len(fields)-1 { result += "," }`. For the Builder version: call `sb.WriteString(field)` and `sb.WriteByte(',')`, with an `if` to skip the trailing comma. For the prealloc version, compute `totalLen` before the Builder:Hint 2 (conceptual hint)
`strings.Builder.Grow(n)` allocates a buffer of at least `n` bytes upfront. If the total written bytes never exceeds `n`, `String()` performs a single allocation (to copy the internal buffer into an immutable string). Without `Grow`, `Builder` uses exponential doubling — typically 2–3 internal reallocations for a 20-field row.Hint 3 (near-solution hint — only if truly stuck)
The `benchstat` invocation: Expected delta: ~70–85% improvement in time/op, ~95% improvement in allocs/op.Expected Output / Acceptance Criteria¶
All three functions return "field_00,field_01,...,field_19" for a 20-field input.
Expected benchmark output (approximate):
BenchmarkCSVNaive-8 228591 5232 ns/op 5568 B/op 19 allocs/op
BenchmarkCSVBuilder-8 721034 1671 ns/op 768 B/op 3 allocs/op
BenchmarkCSVPrealloc-8 1204561 996 ns/op 176 B/op 1 allocs/op
Solution¶
Show Solution
// csv.go
package csv
import "strings"
// Naive: O(n²) allocations — new string per iteration
func buildCSVRowNaive(fields []string) string {
result := ""
for i, f := range fields {
result += f
if i < len(fields)-1 {
result += ","
}
}
return result
}
// Builder: amortized O(n) — exponential doubling, ~3 allocs
func buildCSVRowBuilder(fields []string) string {
var sb strings.Builder
for i, f := range fields {
sb.WriteString(f)
if i < len(fields)-1 {
sb.WriteByte(',')
}
}
return sb.String()
}
// Prealloc: single allocation — exact size known upfront
func buildCSVRowPrealloc(fields []string) string {
totalLen := len(fields) - 1 // commas between fields
for _, f := range fields {
totalLen += len(f)
}
var sb strings.Builder
sb.Grow(totalLen) // allocate exact buffer upfront
for i, f := range fields {
sb.WriteString(f)
if i < len(fields)-1 {
sb.WriteByte(',')
}
}
return sb.String() // single allocation: copy internal buffer to string
}
// csv_test.go
package csv
import (
"fmt"
"testing"
)
var strSink string
func makeFields(n int) []string {
fields := make([]string, n)
for i := range fields {
fields[i] = fmt.Sprintf("field_%02d", i)
}
return fields
}
func BenchmarkCSVNaive(b *testing.B) {
fields := makeFields(20)
b.ResetTimer()
var result string
for range b.N {
result = buildCSVRowNaive(fields)
}
strSink = result
}
func BenchmarkCSVBuilder(b *testing.B) {
fields := makeFields(20)
b.ResetTimer()
var result string
for range b.N {
result = buildCSVRowBuilder(fields)
}
strSink = result
}
func BenchmarkCSVPrealloc(b *testing.B) {
fields := makeFields(20)
b.ResetTimer()
var result string
for range b.N {
result = buildCSVRowPrealloc(fields)
}
strSink = result
}
Exercise 6: sync.Pool for Request Buffer Reuse [🔴 Hard] [3 pts]¶
Context¶
sync.Pool eliminates repeated allocation of large, short-lived objects. This exercise requires implementing a byte-buffer pool for a request handler, measuring the allocation improvement with -benchmem, and understanding the subtle requirement to reset the buffer before each use.
Task¶
Write a function processPayload(data []byte) []byte that appends a JSON header, the data, and a JSON footer (simulating a response envelope). Implement two versions:
1. processPayloadAlloc — allocates a new []byte buffer each call
2. processPayloadPool — uses a sync.Pool to reuse buffers
Benchmark both with -benchmem and confirm the pool version shows significantly fewer allocations per operation.
Requirements¶
- Both functions produce identical output for the same input
- The pool version uses
sync.Poolwith aNewfunction that creates a*[]byte - The pool version resets the buffer length to 0 (keeps capacity) before use
- The pool version copies the final result to a new allocation before returning (so the caller owns the result and can safely return the buffer to the pool)
- Benchmark uses a 512-byte payload, set up before
b.ResetTimer
Hints¶
Hint 1 (structural hint — try designing without this first)
The pool stores `*[]byte` (pointer to slice header). On Get: `bufPtr := pool.Get().(*[]byte); buf := (*bufPtr)[:0]`. After building, copy: `result := make([]byte, len(buf)); copy(result, buf)`. On Put: `*bufPtr = buf; pool.Put(bufPtr)`.Hint 2 (conceptual hint)
Why `*[]byte` not `[]byte`? A `[]byte` stored in the pool as `any` will be heap-copied (interface boxing) on every Get/Put. Storing a `*[]byte` means the pointer is boxed (8 bytes), but the large backing array is not re-boxed each time.Hint 3 (near-solution hint — only if truly stuck)
In the function: get → reset to `[:0]` → append header/data/footer → copy to result → put back.Expected Output / Acceptance Criteria¶
Both functions produce the same JSON envelope output.
Expected benchmark comparison:
BenchmarkPayloadAlloc-8 412034 2913 ns/op 1536 B/op 3 allocs/op
BenchmarkPayloadPool-8 1058921 1132 ns/op 512 B/op 1 allocs/op
Solution¶
Show Solution
// payload.go
package payload
import (
"sync"
)
var header = []byte(`{"status":"ok","data":`)
var footer = []byte(`}`)
// Alloc version: new buffer every call
func processPayloadAlloc(data []byte) []byte {
buf := make([]byte, 0, len(header)+len(data)+len(footer))
buf = append(buf, header...)
buf = append(buf, data...)
buf = append(buf, footer...)
return buf
}
// Pool version: reuse buffer across calls
var bufPool = sync.Pool{
New: func() any {
b := make([]byte, 0, 4096) // initial capacity
return &b
},
}
func processPayloadPool(data []byte) []byte {
// Get a buffer from pool (or allocate if pool is empty)
bufPtr := bufPool.Get().(*[]byte)
buf := (*bufPtr)[:0] // reset length, keep capacity
buf = append(buf, header...)
buf = append(buf, data...)
buf = append(buf, footer...)
// Copy result: caller owns the copy; buffer goes back to pool
result := make([]byte, len(buf))
copy(result, buf)
// Return buffer to pool
*bufPtr = buf
bufPool.Put(bufPtr)
return result
}
// payload_test.go
package payload
import (
"bytes"
"testing"
)
var bSink []byte
func BenchmarkPayloadAlloc(b *testing.B) {
payload := bytes.Repeat([]byte("x"), 512)
b.ResetTimer()
var result []byte
for range b.N {
result = processPayloadAlloc(payload)
}
bSink = result
}
func BenchmarkPayloadPool(b *testing.B) {
payload := bytes.Repeat([]byte("x"), 512)
b.ResetTimer()
var result []byte
for range b.N {
result = processPayloadPool(payload)
}
bSink = result
}
Scoring Log¶
Record your performance honestly. Include the date and whether you used hints.
| Exercise | Date | Score | Used Hints? | Notes |
|---|---|---|---|---|
| Exercise 1 — Spot the Broken Benchmark | — | —/1 | — | — |
| Exercise 2 — Read Escape Analysis Output | — | —/1 | — | — |
| Exercise 3 — Profile a Function with pprof | — | —/2 | — | — |
| Exercise 4 — Reduce Allocations (Escape Analysis) | — | —/2 | — | — |
| Exercise 5 — strings.Builder Preallocation | — | —/3 | — | — |
| Exercise 6 — sync.Pool for Buffer Reuse | — | —/3 | — | — |
| Total | —/12 |
Passing threshold: 8/12 (67%). Aim for 10/12 (83%) before taking the test.