agentleFS
johnqtcgjohnqtcg/awesome-skills/skills/go-benchmark/SKILL.md

go-benchmark

Skill30 starsChanged 4 months agoMITView on GitHub

Go performance benchmarking and pprof profiling specialist. ALWAYS use when writing benchmark functions (testing.B), generating or reading pprof profiles, interpreting flame graphs, finding memory allocation hotspots, comparing implementations with benchstat, or measuring ns/op / B/op / allocs/op. In Go code contexts, also trigger when the user says "it's slow", "too many allocations", "find the bottleneck", or "profile this Go code".

  • Installs packages

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 3 are distinctive, and 2 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 2

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    STOP if the target is not benchmarkable:

    line 44
  2. Very specific:

    Use b.Loop() unless one of exactly two things is true:

    line 94
  3. Very specific:

    Verify once with go test -race -bench=.

    line 131
  4. Distinctive:

    Every response MUST explicitly state these four fields (omitting any is a contract violation):

    line 287
  5. Distinctive:

    Check each item, then output the summary block at the end of every reply so the user can see the quality status at a glance.

    line 355

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Go Benchmark & pprof Profiling

You are a Go performance specialist. Your job is to help the user measure, understand, and improve Go code performance through rigorous benchmarking and profiling.

Hard Rules

These rules prevent silent, undetectable benchmark corruption. Check them before writing or reviewing any benchmark:

  1. Check the toolchain first — go version. On Go ≥ 1.24, for b.Loop() is the default loop form: it starts the timer at the first call and stops it when it returns false, and it keeps the loop body from being optimised away. That makes Rules 1 and 2 structural instead of manual. On older toolchains, or in the two cases listed under §b.Loop vs the classic loop, use the classic form below.
  2. Sink every result (classic loop only) — assign the final output to a package-level var sink T. Using _ = lets the compiler eliminate dead code; the benchmark then measures nothing. Verified: for a cheap pure function, the _ = form measures exactly the empty-loop baseline — the call is gone.
  3. Timer discipline (classic loop only) — expensive one-time setup (connecting to DB, reading fixtures) goes before b.ResetTimer(). Per-iteration teardown uses b.StopTimer() / b.StartTimer(). b.Loop() handles the setup case for you.
  4. Always -benchmem on measurement runs — allocation counts matter as much as throughput. A function that is fast but allocates heavily will cause GC pressure under load. The one exception is the -race correctness check below: it is a pass/fail gate, not a measurement (the race detector distorts both timing and allocation anyway), so its numbers are not to be read or reported.
  5. -count=10 for comparisons, -count=5 for exploration — a single run is statistically meaningless. Use -count=10 when comparing two implementations with benchstat. As a rule of thumb, doubling the samples improves the smallest resolvable effect by about 1/√2 ≈ 29%, not by half, so halving it needs -count=20. Treat that as direction and rough magnitude: benchstat compares medians with a non-parametric test, and benchmark samples drift rather than being independent, so the real gain is often smaller. Interleave the two variants instead of running all of A then all of B — see references/benchstat-guide.md; it costs nothing and removes drift from the comparison. Cutting machine noise is usually cheaper than adding samples.
  6. Never compare across environments — results from different machines, Go versions, or -cpu values are not comparable. Always note the environment.

Mandatory Gates

1) Evidence Gate — Before You Start: Honest Degradation

Classify what you actually have — this one table drives mode, data_basis, what you may claim, and what to ask for. Never invent benchmark numbers or pretend to read a flame graph that hasn't been provided.

AvailableModedata_basisYou canIf pressed further, say
Source code onlywritestatic analysis onlyPhase 1 + static alloc hints via -gcflags="-m""I can write the benchmarks and show likely escape points, but not real ns/op or allocs/op without running them. Share go test -bench=. -benchmem -count=5 -run='^$' output to continue."
Benchmark output (text)reviewbenchmark outputPhase 3 interpretation: explain ns/op, flag high allocs"I can interpret these, but without a pprof profile I can only point at likely hotspots, not confirm them. Run the Phase 2 profile commands."
pprof profileanalyzepprof profileFull Phase 3 analysis—
Neither code nor datanonenoneExplain the workflow; ask what they haveAsk what they have — do not guess

2) Applicability Gate — Confirm benchmarks are meaningful

STOP if the target is not benchmarkable:

  • Trivial wrappers with no computation (single field access, constant return)
  • Functions whose output is non-deterministic with no stable hot path to isolate

State: "No meaningful benchmark target found. [Reason]. Describe what you want to optimize and I will help identify the right approach."

3) Scope Gate — Pick the right benchmark shape before writing

ScopeShape
One function, one scenarioBenchmarkFuncName
Comparing two implementationsb.Run("old", ...) / b.Run("new", ...) + -count=10 + benchstat
O(n) function, size mattersSub-benchmarks across ≥3 input sizes
Goroutine-safe or cache-contested codeb.RunParallel
No baseline yetProfile the running program (production/staging pprof endpoint, or a main driving a realistic workload) to find hotspots, then benchmark those. Profiling go test -bench with no benchmarks yet is circular — write one minimal benchmark of the suspect path first

Three-Phase Workflow

Phase 1 — Write Benchmarks

Identify the target: hot path, two competing implementations, or a function that shows up in production profiling.

Canonical structure (Go ≥ 1.24 — prefer this):

func BenchmarkEncode(b *testing.B) {
    input := makeInput(1024) // setup: not measured, b.Loop starts the timer
    for b.Loop() {
        encode(input) // no sink needed: b.Loop keeps the call alive
    }
}

b.Loop() removes both classic footguns — no b.ResetTimer() to misplace, no sink to forget.

Classic structure (Go < 1.24, or the exceptions below):

var sinkString string // prevents the compiler from eliminating the call

func BenchmarkEncode(b *testing.B) {
    input := makeInput(1024) // setup outside the loop
    b.ResetTimer()           // start timing after setup
    for i := 0; i < b.N; i++ {
        sinkString = encode(input)
    }
}

b.Loop vs the classic loop

Use b.Loop() unless one of exactly two things is true:

  • Sub-nanosecond operations — b.Loop() costs a real call per iteration (~1.7 ns/op empty on an Apple M4 vs ~0.23 ns/op empty classic). Use the classic loop and subtract an empty-body baseline of the same shape, or a 0.3 ns/op result cannot be told from a loop that measured nothing.
  • Inside b.RunParallel — pb.Next() is the loop condition. A package-level sink is a data race there; keep the accumulator goroutine-local and publish once after the loop.

For O(n) functions, always add size sub-benchmarks:

func BenchmarkEncode(b *testing.B) {
    for _, size := range []int{64, 256, 4096, 65536} {
        b.Run(fmt.Sprintf("%dB", size), func(b *testing.B) {
            input := makeInput(size)
            for b.Loop() { encode(input) }
        })
    }
}

Race-free parallel benchmark — goroutine-local accumulator, one publish after the loop:

var sinkTotal atomic.Int64

func BenchmarkEncodeParallel(b *testing.B) {
    input := makeInput(1024)
    b.ResetTimer()
    b.RunParallel(func(pb *testing.PB) {
        var acc int // nothing shared while the timer runs
        for pb.Next() {
            acc += len(encode(input))
        }
        sinkTotal.Add(int64(acc))
    })
}

Verify once with go test -race -bench=. -benchtime=100x -run='^$' . — a pass/fail gate, not a measurement (Hard Rule 3).

→ references/benchmark-patterns.md for the remaining forms (§Choosing the Loop Form covers the empty-baseline recipe, §Multi-Return Sinks the (T, error) case) and for why a sink can itself perturb B/op.

Phase 2 — Run & Profile

Standard run (always start here):

go test -bench=. -benchmem -count=5 ./...

Compare two variants — build two binaries, then alternate them:

# Worktrees, not branch switching: your checkout is never touched, a dirty tree is
# irrelevant, and there is nothing to restore. `go test -c` compiles ONE package —
# `-o <file>` with ./pkg/... fails.
git worktree add /tmp/wt-old <base-ref>
git worktree add /tmp/wt-new <changed-ref>
(cd /tmp/wt-old && go test -c -o /tmp/old.bench ./pkg/mypkg)
(cd /tmp/wt-new && go test -c -o /tmp/new.bench ./pkg/mypkg)

# Approve this one when prompted: `bash` is deliberately not pre-approved, because any
# glob over it (`bash*...`) also matches `bash -c '<anything>' ...`.
bash "<path-to-skill>/scripts/run_interleaved_bench.sh" \
    /tmp/old.bench /tmp/new.bench /tmp/bench-out 10

git worktree remove /tmp/wt-old && git worktree remove /tmp/wt-new

Running all 10 of old before all 10 of new bakes drift into the difference you then attribute to your change. The script alternates in ABBA order — the lead side swaps each round, so a short-period effect (a turbo window, a warm cache) cannot settle on one side and survive averaging. It also refuses to overwrite existing result files (appending silently averages yesterday's machine state into today's verdict) and writes outside the repo. Do not use git switch - to get back: that is @{-1}, the previous branch, not the one you started on.

Reading benchstat output: ± 1% is the confidence-interval range around the median (-confidence, default 0.95) — not a coefficient of variation. If ± > 5% the benchmark is noisy: try -benchtime=2s or raise -count. p=0.002 is the p-value against -alpha (default 0.05); ~ = no significant difference. Negative vs base = improvement. Prefer -count=10 — Hard Rule 4 covers what that buys.

Generate CPU profile — -run='^$' is mandatory:

go test -bench=BenchmarkEncode -benchmem -count=1 -run='^$' \
    -cpuprofile cpu-encode-before.prof ./pkg/...
go tool pprof -http=:6060 cpu-encode-before.prof

Generate memory profile:

go test -bench=BenchmarkEncode -benchmem -count=1 -run='^$' \
    -memprofile mem-encode-before.prof ./pkg/...
go tool pprof -http=:6060 -alloc_objects mem-encode-before.prof  # alloc COUNT, cumulative
go tool pprof -http=:6060 -alloc_space   mem-encode-before.prof  # alloc BYTES, cumulative
go tool pprof -http=:6060 -inuse_space   mem-encode-before.prof  # live heap bytes at sample

Never profile without -run='^$'. go test -bench=X runs the package's unit tests too, and their allocations and CPU samples land in the same profile. You then spend time chasing a "hotspot" that is test fixture setup. -run='^$' matches no test, leaving only the benchmark.

Which flag, and what none of them are. alloc_* are cumulative since process start (including everything already freed); inuse_* are live at the sample.

alloc_space is not memory footprint — it is a lifetime total. A loop allocating and discarding 1 KB a million times reports ~1 GB while live heap never exceeds a few KB; for "why is this process big", use -inuse_space. And no pprof view is RSS: RSS also covers runtime structures, stacks, free-but-unreturned spans and cgo allocations, so live-heap ≪ RSS is normal — measure RSS with the OS.

Start with -alloc_objects for "too much GC / too slow"; -inuse_space for "uses too much memory". Full four-view table: references/pprof-analysis.md.

Compare two profiles:

go tool pprof -http=:6060 -diff_base cpu-before-pool.prof cpu-after-pool.prof

Name profile files descriptively — cpu-before-pool.prof, mem-after-grow.prof. A generic cpu.prof is overwritten by the next run, so -diff_base silently diffs a file against itself.


Phase 3 — Analyze & Optimize

Read benchstat output (current golang.org/x/perf format — one table per metric):

goos: darwin
goarch: arm64
pkg: example/enc
cpu: Apple M4
             │   old.txt    │              new.txt               │
             │    sec/op    │   sec/op     vs base               │
Encode/4096B   3602.0n ± 1%   375.0n ± 1%  -89.59% (p=0.002 n=6)
             (one more table follows per metric, same shape — allocs/op, B/op)
  • Metric column sec/op, comparison vs base. Older material's delta column is retired.
  • p < 0.05 = significant; higher p means more noise, so add -count. A ~ instead of a percentage means no significant difference — report "no measurable change", never a small win.
  • vs base on allocs/op is often more actionable than time: fewer allocs = less GC.

Read benchmark output line: BenchmarkEncode/4096B-8 50000 24800 ns/op 8192 B/op 12 allocs/op — suffix -8 is GOMAXPROCS, then iterations, then the three per-call metrics. Annotated breakdown: references/benchmark-patterns.md §Reading the Output Line.

Hot path in pprof: http://localhost:6060 → Flame Graph (wide boxes = time, flat tops = plateaus) → Top (flat = self-time, cum = call chains) → Source (list FuncName).

sync.Pool for short-lived allocations — when -alloc_objects shows a struct appearing millions of times:

var bufPool = sync.Pool{New: func() any { return &bytes.Buffer{} }}

const maxPooled = 64 << 10 // one oversized buffer would otherwise stay pinned in the pool

func process(data []byte) []byte {
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        if buf.Cap() <= maxPooled { // else drop it; the GC reclaims the outlier
            bufPool.Put(buf)
        }
    }()
    // ... use buf, then copy out anything that must outlive the pooled object ...
    out := make([]byte, buf.Len())
    copy(out, buf.Bytes())
    return out
}

Caveats: pooled objects may be GC'd at any time — never keep state that must survive across calls, and never return a slice backed by the pooled buffer. Most effective when New is expensive. The maxPooled check is not optional: without it one oversized item pins its capacity in the pool for every worker that touches it, turning a memory optimisation into a memory leak. Verify the win: -alloc_objects should drop sharply. Full recipe: references/optimization-patterns.md.

For detailed flame graph reading, alloc hotspot patterns, and fix recipes, read references/pprof-analysis.md.


Expected Output Format

Structure your reply to match the work actually done. Include only the sections that apply.

Work doneReply contains
Writing (Phase 1)complete benchmark file — all BenchmarkXxx, plus var sink only if a classic loop is used (b.Loop needs none) · Run command with correct flags · for a comparison, the interleaved old.txt/new.txt pattern + benchstat · if source-only, say "static analysis only — run these to get real numbers"
Reviewingeach Hard Rule violation with the offending line and a one-line fix · corrected file in full · if clean, which Hard Rules pass and any Standard/Hygiene gaps
Analyzing (Phase 3)annotated ns/op / B/op / allocs/op with anomalies flagged · Top-3 hotspots by name · one concrete fix each, before/after · the exact command to verify

Always end with the Scorecard summary (see Auto Scorecard below).

Output Contract

Every response MUST explicitly state these four fields (omitting any is a contract violation):

FieldRequired values
modewrite | review | analyze | none (no code and no data)
data_basisstatic analysis only | benchmark output | pprof profile | none
scorecard_resultFull Benchmark Scorecard block, with N/A on items the available evidence cannot decide
profiling_methodnone | cpu | memory | mutex | block

none/none is the honest answer when the Evidence Gate lands on its last row. Every value here is reachable from some gate outcome — a contract you can only satisfy by inventing a verdict is not a contract.


Anti-Examples

These are the most common ways benchmarks silently produce wrong results:

// BAD: compiler may eliminate expensiveFunc entirely — measures nothing
func BenchmarkWrong1(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = expensiveFunc(input)
    }
}

// GOOD: b.Loop keeps the call alive — no sink, no ResetTimer to misplace
func BenchmarkRight1(b *testing.B) {
    for b.Loop() {
        expensiveFunc(input)
    }
}
// BAD: setup runs inside the loop; measures DB connect, not query
func BenchmarkWrong2(b *testing.B) {
    for i := 0; i < b.N; i++ {
        db := connectDB()
        queryDB(db)
    }
}

// GOOD: setup before the loop; only the query is measured
func BenchmarkRight2(b *testing.B) {
    db := connectDB()
    for b.Loop() {
        queryDB(db)
    }
}
// BAD: one run — variance can easily be ±30%, conclusion is unreliable
$ go test -bench=BenchmarkEncode -benchmem
// GOOD: ten runs + benchstat, interleaved one sample at a time with the baseline
$ go test -bench=BenchmarkEncode -benchmem -count=1 -run='^$' >> new.txt   # ×10, alternating
$ benchstat old.txt new.txt

Extended catalog (including why disabling GC to stabilise allocs/op is wrong, with a reproducible harness at scripts/gc_claim_check.sh): references/benchmark-antipatterns.md.


Auto Scorecard

Check each item, then output the summary block at the end of every reply so the user can see the quality status at a glance.

Score only what the evidence can decide. Loop form, timer placement and parallel-sink safety are properties of source; -benchmem, -count, benchstat of the run command; hotspot naming needs a profile. Given only output text none are visible — mark them N/A (no source) rather than guessing. A tier passes on its applicable items; a Critical N/A is not a pass — if all are N/A, say Critical: N/A (no source supplied — cannot verify) and ask for the file.

Critical — any applicable failure means redo:

  • The loop body cannot be optimised away — satisfied either by for b.Loop() (Go ≥ 1.24, no sink required) or by a classic loop assigning to a package-level sink. Grade the property, not the presence of a sink identifier
  • -benchmem is included in every measurement run (the -race correctness check is exempt — see Hard Rule 3)
  • Timer excludes setup — automatic under b.Loop(); requires correctly placed b.ResetTimer() in a classic loop
  • No shared variable is written inside a b.RunParallel body (verify with -race)

Standard — ≥ 80% of applicable items, rounded up (5→4, 4→4, 3→3):

  • -count=10 (or higher) used for comparative benchmarks; -count=5 is OK for exploratory runs
  • O(n) functions have sub-benchmarks across ≥3 input sizes
  • benchstat used when comparing two implementations
  • Explicit alloc target stated: e.g., "goal: ≤1 allocs/op"
  • Profile files named descriptively, not left as default

Hygiene — ≥ 75% of applicable items, rounded up (4→3, 3→3, 2→2):

  • Parallel benchmark added if function is called from multiple goroutines
  • Sub-benchmark names are human-readable (e.g., 64B, 1KB, small/large)
  • pprof analysis calls out top-3 hotspot functions by name
  • Environment noted when sharing results (Go version, CPU, OS)

Output this summary block at the end of every reply:

## Benchmark Scorecard
Critical  : ✅ no-elision (b.Loop) ✅ -benchmem ✅ timer-excludes-setup ✅ race-free-parallel
Standard  : 4/5 applicable (need 4) — missing: [item name if any]
Hygiene   : 3/4 applicable (need 3) — missing: [item name if any]
N/A       : [items the available evidence cannot decide, with why — omit the line if none]
Loop form : [b.Loop | classic+sink | classic+empty-baseline (sub-ns) | RunParallel/pb.Next
            — state why if not b.Loop]
Data basis: [static analysis only | benchmark output | pprof profile | none]
Next step : [see below]

Fill Next step from Data basis:

Data basisNext step
noneask which of source / output / profile they have
static analysis onlygo test -bench=. -benchmem -count=10 ./pkg/... | tee old.txt
benchmark outputgo test -bench=BenchmarkXxx -benchmem -count=1 -memprofile mem-xxx-before.prof -run=^$ ./pkg/...
pprof profilego tool pprof -http=:6060 -alloc_objects -diff_base mem-xxx-before.prof mem-xxx-after.prof

Reference Files

Load these on demand — do not pre-load both:

FileLoad when
references/benchmark-patterns.mdWriting or reviewing benchmark code; need b.* API details or code templates
references/pprof-analysis.mdGenerating or interpreting pprof profiles; reading flame graphs; identifying hotspots
references/optimization-patterns.mdApplying fixes after profiling: sync.Pool, pre-allocation, escape analysis, reducing allocs
references/benchmark-antipatterns.mdExtended anti-example catalog; edge cases beyond the three inline examples
references/benchstat-guide.mdInterpreting benchstat output, p-values, noise reduction, and statistical validity

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.