Day 66 — Benchmarks & allocations

Updated

July 30, 2026

Day 66 — Benchmarks & allocations

Stage VII · ~3h
Goal: Write honest microbenchmarks with testing.B, report allocations, compare before/after with benchstat, and land one evidence-backed improvement from Day 65’s hypothesis.

Why this day exists

pprof told you where time and allocations go. Benchmarks answer:

  • Is change X faster or smaller?
  • By how much, across noise?
  • Did I accidentally make the common path worse?

Without discipline, benchmarks lie: they optimize the benchmark, not the product. Today you produce a paper trail (BENCH.md) a reviewer would accept.


Theory 1 — Anatomy of a Go benchmark

func BenchmarkName(b *testing.B) {
    // setup (not timed after ResetTimer)
    data := prepare()
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        sink = work(data) // prevent compiler eliminating work
    }
}
Rule Why
Loop b.N times Framework scales N until timing is stable
Setup outside timed region Or call b.ResetTimer() after setup
ReportAllocs() Shows B/op and allocs/op
Avoid dead-code elimination Assign to package-level sink or use result
var sink string

func BenchmarkProcess(b *testing.B) {
    raw := mustFixture(b)
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        s, err := Process(raw)
        if err != nil {
            b.Fatal(err)
        }
        sink = s
    }
}

Run flags

go test -bench=BenchmarkProcess -benchmem ./...
go test -bench=. -benchmem -count=10 ./pkg > old.txt
# after change:
go test -bench=. -benchmem -count=10 ./pkg > new.txt

-count matters: one run is noise. Quiet the machine (close browsers, pin GOMAXPROCS if comparing laptops).

go test -bench=. -benchmem -count=10 -cpu=1,4 ./pkg

Theory 2 — Reading the output

BenchmarkProcess-10   123456   9612 ns/op   4096 B/op   24 allocs/op
Field Meaning
-10 GOMAXPROCS used
iterations How many times b.N landed
ns/op Nanoseconds per iteration
B/op Bytes allocated per iteration
allocs/op Distinct allocations per iteration

Latency vs throughput: microbenchmarks are usually “time per op.” Real services also care about p99 under concurrency (Day 80).

Watch for timer bugs: if ns/op is absurdly small, work may be optimized away or setup is wrong.


Theory 3 — benchstat for honest comparison

go install golang.org/x/perf/cmd/benchstat@latest
benchstat old.txt new.txt

Example output shape:

name       old time/op    new time/op    delta
Process-10   9.61µs ± 2%   6.20µs ± 3%  -35.5%  (p=0.000 n=10+10)

If delta is within noise, do not claim a win. Ship clarity, not theater. benchstat reports geometric mean and confidence—use it.


Theory 4 — Sub-benchmarks and inputs

func BenchmarkConcat(b *testing.B) {
    sizes := []int{16, 256, 4096}
    for _, n := range sizes {
        b.Run(fmt.Sprintf("n=%d", n), func(b *testing.B) {
            s := strings.Repeat("x", n)
            b.ReportAllocs()
            b.ResetTimer()
            for i := 0; i < b.N; i++ {
                sink = s + s
            }
        })
    }
}

Always state input shape. “Faster” without size is meaningless for parsers, JSON, crypto, compression.

Parallel benchmarks

func BenchmarkHandler(b *testing.B) {
    h := newHandler()
    b.ReportAllocs()
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            // concurrent work — use independent buffers if needed
            _ = h
        }
    })
}

Use when the real path is concurrent and you care about scaling—not for every micro-op.


Theory 5 — Common micro-optimizations that sometimes pay

Only after measurement:

Technique When
strings.Builder / bytes.Buffer Repeated concatenation
sync.Pool Short-lived buffers under high churn
Pre-size slices (make([]T, 0, n)) Known final length
Avoid encoding/json reflection on hot path After pprof shows it dominates
Reuse []byte / scanners Streaming parsers
strconv.AppendInt into buf Formatting hot loops

Do not pool prematurely. Pooling bugs (retaining large buffers, races) are worse than a few allocs.


Theory 6 — Fairness rules

  1. Same machine, same go version, same GOMAXPROCS story
  2. Same fixture data (check into testdata/)
  3. No logging / no network in the timed loop
  4. Do not compare Debug vs optimized mixed builds—use go test defaults consistently
  5. Document wall-clock environment briefly in BENCH.md
## Environment
- go version go1.26.x darwin/arm64
- quiet laptop, AC power
- count=10

Worked example — before / after with evidence

Before (chatty concat):

func JoinTags(tags map[string]string) string {
    var s string
    for k, v := range tags {
        s += k + "=" + v + ";"
    }
    return s
}

After:

func JoinTags(tags map[string]string) string {
    var b strings.Builder
    // optional: estimate size
    for k, v := range tags {
        b.WriteString(k)
        b.WriteByte('=')
        b.WriteString(v)
        b.WriteByte(';')
    }
    return b.String()
}
func BenchmarkJoinTags(b *testing.B) {
    tags := map[string]string{"a": "1", "b": "2", "c": "3", "d": "4"}
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        sink = JoinTags(tags)
    }
}
go test -bench=BenchmarkJoinTags -benchmem -count=10 > new.txt
benchstat old.txt new.txt

Document in BENCH.md: hypothesis from Day 65, command lines, benchstat snippet, decision (merge / revert).


Worked example — allocation-sensitive JSON path

When Day 65 pointed at JSON, compare encode strategies fairly:

func BenchmarkMarshalItem(b *testing.B) {
    it := Item{ID: "01H", Name: "widget", Tags: []string{"a", "b", "c"}}
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        buf, err := json.Marshal(it)
        if err != nil {
            b.Fatal(err)
        }
        sink = string(buf)
    }
}

func BenchmarkEncodeItem(b *testing.B) {
    it := Item{ID: "01H", Name: "widget", Tags: []string{"a", "b", "c"}}
    b.ReportAllocs()
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        var buf bytes.Buffer
        if err := json.NewEncoder(&buf).Encode(it); err != nil {
            b.Fatal(err)
        }
        sink = buf.String()
    }
}

Do not declare a winner from folklore—benchstat decides for your struct shape. Sometimes Marshal wins on small objects; sometimes pooled buffers + Encoder win under concurrency (measure b.RunParallel only if production is parallel).

Template for BENCH.md

# Day 66 bench notes

## Hypothesis
(from Day 65 pprof) …

## Commands
go test -bench=BenchmarkJoinTags -benchmem -count=10 ./... > old.txt
# change code
go test -bench=BenchmarkJoinTags -benchmem -count=10 ./... > new.txt
benchstat old.txt new.txt

## Result
(paste benchstat)

## Decision
merge | revert — why

Lab

Suggested workspace: ~/lab/90daysofx/01-go/day66 (or the package you profiled).

  1. Write at least two benchmarks: baseline hot path + one alternative.
  2. Use -benchmem and -count=10 (or at least 5).
  3. Compare with benchstat (install if needed).
  4. Land one improvement or write why you rejected the change.
  5. Keep old.txt / new.txt / BENCH.md (results can be committed as text).
  6. Note go version and machine briefly.

Stretch

  • Add sub-benchmarks for small vs large inputs.
  • Correlate: does the win show up in a second pprof capture?
  • b.SetBytes(n) for throughput-style reporting on parsers.
b.SetBytes(int64(len(raw)))

Common gotchas

Gotcha Fix
Setup inside the loop Move out; ResetTimer
Compiler eliminates work Use sink / check errors
One-shot timing noise -count + benchstat
Benchmarking the mock not the code Keep fixtures realistic
“Allocs went up but ns down” Product decision; document trade-off
Global mutable state across iterations Reset or use local copies
Including I/O in microbench Split pure CPU vs I/O benches
Claiming wins on ±2% noise Require clear benchstat delta

Checkpoint

  • Benchmarks run with -benchmem
  • Multiple counts recorded
  • benchstat (or careful manual compare) documented
  • One change merged or rejected with evidence
  • BENCH.md links hypothesis → result
  • go version noted

Commit

git add .
git commit -m "day66: benchmarks allocs evidence"

Write three personal gotchas before continuing.


Tomorrow

Day 67 — GC, GOGC, GOMEMLIMIT: understand the garbage collector knobs and measure one workload under different memory policies.