agentleFS
h0rn3th0rn3t/golang-skills/skills/go-performance/SKILL.md

go-performance

Skill7 starsChanged 10 days agoApache-2.0View on GitHub

Use when optimizing slow or performance-critical Go code, allocations, string concatenation in loops, or benchmarks. Also use when writing or reviewing a cache, including implementing one from a stub — its key, its expiry, and how a miss loads. Concurrent code patterns belong to go-concurrency.

3 other projects use a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

5 rules. About 0 are common, 1 is distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

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

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Freeze the baseline: after step 1 and before the first production edit, one package at a time.

    line 163
  2. Very specific:

    Run this and the verdict from the project root, each alone in its own Bash call: no cd, &&, shell variable, or echo $?

    line 171
  3. Distinctive:

    Do less: work repeated per call moved to where its input changes, a quadratic scan replaced by a set, one round trip per batch.

    line 235
  4. Very specific:

    Keep benchmarks in a file of their own beside the source, ordered to mirror the functions they measure.

    line 281
  5. Very specific:

    Check the standard library first — the iterator variants (strings.SplitSeq Go 1.24+, maps.Keys Go 1.23+) skip the intermediate slice …

    line 291

≈ “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 Performance Patterns

Compatibility: Baseline Go 1.27 (see COMPATIBILITY.md). b.Loop() requires Go 1.24+; encoding/json/v2 Go 1.27+; cgroup-aware GOMAXPROCS Go 1.25+.

Resource Routing

  • scripts/bench-compare.sh - Run when comparing benchmark results, saving baselines, or producing JSON benchmark metadata.
  • scripts/bench-verdict.go - The statistics helper bench-compare.sh --verdict builds and calls; scripts/bench-verdict_test.go holds its unit tests. Neither is run or read directly.
  • references/BENCHMARKS.md - Read when writing benchmarks, using benchstat, or profiling with pprof.
  • references/STRING-OPTIMIZATION.md - Read when optimizing concatenation or memory retained by substrings.
  • references/RUNTIME.md - Read when setting GOMAXPROCS, GOGC, GOMEMLIMIT, or a PGO profile, or removing automaxprocs.

Apply performance-specific guidance to measured bottlenecks, including retained memory. Don't add allocations or complexity without evidence that it helps.


Prefer strconv over fmt

For direct primitive conversions, prefer strconv by default; choosing it needs no benchmark:

s := strconv.Itoa(n)

Avoid Repeated String-to-Byte Conversions

For a measured repeated-conversion cost, reuse the bytes outside the loop. The compiler may already eliminate the conversion allocation for a concrete writer, so compare the actual call site:

data := []byte("Hello world")
for b.Loop() { // Go 1.24+
    w.Write(data)
}

Prefer Specifying Container Capacity

Preallocate when the final size is known at the call site (len(files), a fixed loop bound); otherwise measure before guessing a capacity — an oversized estimate retains memory the workload never uses.


Pass Values

A pointer parameter is not a speed fix: value versus pointer parameters belong to go-functions.


String Concatenation

Build in a loop with strings.Builder, calling Grow(n) first when the final size is known.


Caching

Normative: Cache what a profile shows is expensive and read far more often than it changes. A cached fast call buys nothing and adds a staleness bug.

  • The key is every input the answer depends on: the tenant, caller, locale, or permissions it was computed for, including those that arrive in ctx. A key of the user ID alone serves one tenant's record to another. A loader that dedups within one request (a GraphQL DataLoader) is built per request; shared by the process, it is a cache with no tenant in its key.
  • One expiry rule, stated. The TTL is the staleness the caller accepts. A balance, a permission, or stock at checkout is not cached. A failed load is returned and not kept. A write deletes the key after its transaction commits and never stores the value it was handed: two writers that commit in one order and set the cache in the other keep the older value until the TTL. Only a load fills.
  • Concurrent misses share one load, or a cold hot key sends every waiting request to the origin at once. Use golang.org/x/sync/singleflight where the module already requires golang.org/x/sync, otherwise the in-flight entry below. The lock guards the map and is never held across the load, so one slow key does not stall the rest. The load takes the first caller's ctx values but not its cancellation (context.WithoutCancel), so a caller that leaves fails no other waiter, and load sets its own deadline; the entry is finished in a defer, so a panicking load does not leave its key loading for good.
  • Bounded: a cap on the entry count, or a sweep of expired entries; a key space the code does not bound (user IDs, query strings) needs one, since replacing an expired entry on its next read never frees a key that is not read again. A map that only grows is a leak, and a swept one keeps its peak table: go-data-structures. Interning repeated values is unique.Make (Go 1.23); a map of weak.Pointer (Go 1.24) is not a cache, since its entries die at the next GC and its keys stay unless runtime.AddCleanup deletes them.
type rateKey struct{ tenant, pair string } // every input the rate depends on
type entry struct {
	done chan struct{} // closed once rate and err are set
	rate float64
	err  error
	at   time.Time // zero while loading
}
type Rates struct {
	load    func(ctx context.Context, key rateKey) (float64, error)
	ttl     time.Duration
	mu      sync.Mutex         // guards entries; never held across load
	entries map[rateKey]*entry // bounded: tenants times currency pairs
}
func (r *Rates) Get(ctx context.Context, key rateKey) (float64, error) {
	r.mu.Lock()
	e, ok := r.entries[key]
	if ok && (e.at.IsZero() || time.Since(e.at) < r.ttl) {
		r.mu.Unlock()
		select {
		case <-e.done:
			return e.rate, e.err
		case <-ctx.Done():
			return 0, ctx.Err()
		}
	}
	e = &entry{done: make(chan struct{}), err: errors.New("rates: load panicked")}
	r.entries[key] = e
	r.mu.Unlock()
	defer func() { // also after a panic in load: waiters wake, the key is retried
		r.mu.Lock()
		e.at = time.Now()
		if e.err != nil {
			delete(r.entries, key) // a failed load is not kept
		}
		r.mu.Unlock()
		close(e.done)
	}()
	e.rate, e.err = r.load(context.WithoutCancel(ctx), key) // its values, not its cancel
	return e.rate, e.err
}

Benchmarking and Profiling

A request to make Go code faster runs these steps in order, from the project. The defaults above (strconv for a lone value, a capacity known at the call site, strings.Builder in a loop) need none of them.

  1. A benchmark of the path the caller runs, before any production edit: an existing Benchmark* that covers it, or a new one in *_bench_test.go. Its inputs have production's size and variety — a cache benchmark with one key measures only hits — and it uses b.RunParallel when requests run concurrently. The benchmark is a sample of the workload, not the specification: nothing the change adds is keyed to its inputs or kept between its iterations.
  2. Freeze the baseline after step 1 and before the first production edit, one package at a time; the snapshot keeps the test binary and the test files as they are now. --filter names the step 1 benchmark:

    bash "<installed-skill-dir>/scripts/bench-compare.sh" --snapshot .perf-snap/1 --filter '^BenchmarkX$' ./path/to/pkg

    Run this and the verdict from the project root, each alone in its own Bash call: no cd, &&, shell variable, or echo $? — the host asks approval for those, and the script prints its own verdict: line. .perf-snap/ is ignored by go patterns; delete it after the report.

  3. Profile the benchmark and name where the time is, as Read the Profile says. The function a request names ("X looks slow", "speed up X") is a guess this step checks, not the target: under 5% of the profile it stays as it is however wasteful it reads — making it free saves less than 5% — and the report gives its share and where the time went instead.
  4. One change, from the cheapest class in Fix Order.
  5. The verdict decides, run serially, never beside another benchmark:

    bash "<installed-skill-dir>/scripts/bench-compare.sh" --verdict .perf-snap/1 --filter '^BenchmarkX$' ./path/to/pkg

    With the same --filter, the verdict judges the path the request is about: every other benchmark of the package costs about 25 s at the defaults and raises the bar each row must clear. keep (exit 0) keeps the change; revert reverts it; noisy raises --count or --benchtime and asks once more, and a change is never kept on noisy. A second noisy is reported as no measurable change: rerunning until a keep appears picks the noise, not the change. A keep on allocs/op or B/op alone is a speedup only where the profile spent its time in allocation or GC; otherwise it is reported as fewer allocations. After a kept change, profile again and name the next hotspot; chase it only within the request, and snapshot the kept code into a new directory (.perf-snap/2) before that next change: against the first snapshot, a second change that is slower still reads keep on the first one's gain. A last verdict against the first snapshot gives the total.

  6. Guard an allocation win with an allocation test when the change exists to remove allocations.
  7. Report each kept change with its verdict line and the production lines it added, and each reverted attempt with its reason, so the next change does not try a dead idea again. "Faster" needs a keep on sec/op; without a verdict the comparison is reported as skipped.

A behavior-preserving refactor asks a different question — did anything get slower — and keeps --save/--baseline with benchstat (go-code-refactor). BENCHMARKS.md reads benchstat output and profiles.

Read the Profile

go test -run '^$' -bench <name> -o "${TMPDIR:-/tmp}/pkg.test" -cpuprofile "${TMPDIR:-/tmp}/cpu.prof" ./path/to/pkg (-o and absolute paths keep the test binary and the profile out of the tree), then go tool pprof -top -cum "${TMPDIR:-/tmp}/cpu.prof"; the row decides the class of fix:

The profile showsThe time goes toFirst move
a package function and what it calls: slices.Index, strings.Fields, regexp.Compile, a parse per callrepeated or superlinear workdo it once where its input changes; a set in place of a scan
runtime.mallocgc above about 15% cumulative, or gcBgMarkWorker and gcAssistAlloc above about 5%allocation and GC-memprofile, then pprof -sample_index=alloc_space; fewer values on the heap per call
runtime.scanobject or gcDrain high while allocs/op is lowmarking a large, pointer-dense live heapvalues instead of *T in long-lived slices and maps, indexes instead of pointer links
sync.(*Mutex).Unlock at the top of a -mutexprofile, or sync.(*Mutex).Lock in a -blockprofile, of a RunParallel benchmarkcontentiona shorter critical section; per-key state behind a read-mostly map (go-concurrency)
total samples far below the profile's durationwaiting on I/O, a round trip per item, or a lockan existing batch API; go-database for a query per row
a syscall (syscall.Write, Syscall6) at the topone system call per small read or writecount calls per op: a bufio.Writer/bufio.Reader or one batched call is class 1; decline only when each unit of work already makes one call
crypto/* or compress/* doing the payload's own work, set up oncework the platform already does at speednothing in this code: report the share and decline

Fix Order

Take the first class that reaches the speedup asked for, and stop:

  1. Do less: work repeated per call moved to where its input changes, a quadratic scan replaced by a set, one round trip per batch.
  2. Allocate less with what ships: a struct in place of map[string]any, one reused Encoder or buffer, strconv.Append*, a capacity known at the call site.
  3. Contend less: a shorter critical section, atomics for independent counters.
  4. More CPUs per request, only where CPUs sit idle under the real load — a batch job, not a server where every CPU already serves a request: there a fan-out is faster in a one-call benchmark and no faster in RunParallel.
  5. Hand-written replacements for the standard library (an encoder, an escaper, a hash table), shards, cache-line padding, unsafe: each takes its own verdict against the best change from the classes above, and the report names the lines it added.

Allocation Guard

A kept allocation win stays won through a test of the count, not of time. AllocsPerRun counts every goroutine's allocations, panics under parallel tests, and the race detector drops pooled objects, so the test runs alone and outside -race:

//go:build !race

package ledger

import (
	"io"
	"testing"
)

func TestWriteReportAllocs(t *testing.T) {
	rows := sampleRows(t, 100)
	got := testing.AllocsPerRun(20, func() {
		if err := WriteReport(io.Discard, rows); err != nil {
			t.Fatal(err)
		}
	})
	if got > 1 {
		t.Errorf("WriteReport(100 rows) = %v allocs/op, want <= 1: one buffer, not one per row", got)
	}
}

Benchmark discipline

  • Keep benchmarks in a file of their own beside the source, ordered to mirror the functions they measure. Go only requires some _test.go file; the *_bench_test.go suffix is this pack's convention, and an existing project layout outranks it.
  • Implement competing variants in isolation, so each run measures one of them.
  • A comparison that straddles a toolchain bump measures the toolchain, not the code — re-run the baseline on the new toolchain first.

Before reaching for a faster library

Check the standard library first — the iterator variants (strings.SplitSeq Go 1.24+, maps.Keys Go 1.23+) skip the intermediate slice without a new dependency. JSON is not such a lever on Go 1.27: encoding/json already runs on the v2 implementation (unmarshal about a third faster; marshal 15–70% slower than on 1.26, and encoding/json/v2.Marshal runs at the same speed), so moving an import to encoding/json/v2 measures nothing, and a comparison across the 1.26 → 1.27 bump measures the new engine. The JSON wins are in the call site: pass a pointer (json.Marshal(&v), enc.Encode(&v)) — a struct passed by value costs one more allocation on 1.27 — one reused Encoder or buffer, a struct in place of map[string]any. See go-packages for the dependency ladder.


Runtime Settings

Process-wide knobs (GOMAXPROCS under a cgroup limit, GOGC/GOMEMLIMIT, default.pgo) are in RUNTIME.md; library code never calls debug.SetGCPercent or the other process-wide setters.


Related Skills

  • go-data-structures: slices, maps, arrays and their allocation semantics.
  • go-concurrency: parallel work, sync.Pool for buffers.
  • go-database: a query per row, pools, and batching when the time is a round trip.
  • go-http: client reuse and timeouts when the time is waiting on a service.
  • go-style-core: whether an optimization is worth its readability cost.
  • go-troubleshooting: a slow, leaking, or hanging program with no named hot path yet; profile capture and the symptom catalog live there.

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.