Day 66 — Benchmarks & allocations
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 ./pkgTheory 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.txtExample 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
- Same machine, same
go version, same GOMAXPROCS story
- Same fixture data (check into
testdata/)
- No logging / no network in the timed loop
- Do not compare Debug vs optimized mixed builds—use
go testdefaults consistently
- Document wall-clock environment briefly in
BENCH.md
## Environment
- go version go1.26.x darwin/arm64
- quiet laptop, AC power
- count=10Worked 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.txtDocument 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 — whyLab
Suggested workspace: ~/lab/90daysofx/01-go/day66 (or the package you profiled).
- Write at least two benchmarks: baseline hot path + one alternative.
- Use
-benchmemand-count=10(or at least 5).
- Compare with
benchstat(install if needed).
- Land one improvement or write why you rejected the change.
- Keep
old.txt/new.txt/BENCH.md(results can be committed as text).
- Note
go versionand 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.mdlinks hypothesis → result
go versionnoted
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.