High-Performance Go
June 30, 2026 · View on GitHub
mdsmith's hot path is the rule set running over every file
in the workspace. The Large gate parses 600 files through
the full rule set; one extra alloc per Check means tens
of thousands per run, and one accidental O(n) rescan turns
a fast run into a slow one. This page is the playbook.
This page is the methodology behind the project's budgets. The ≤ 10 alloc ceiling lives in Allocation Budget; the corpus gates live in the benchmark notes; the session narrative lives in perf-parity-session.
Process
Apply best-practice patterns first. Then measure. Then
fix what is still hot. The patterns in
Patterns to apply are known wins
across the Go core — pre-size a slice, hoist a regex,
return nil for an empty result. Use them by default;
they cost nothing.
Past that, profile before you rewrite — a flat profile in your suspected hot path stops a useless fix. The loop:
-
State the goal numerically. "Function X under N allocs/call on representative input" — not "make it faster". Name the symbol (function, rule, package) and the input that hits its hot frame.
-
Lock in a baseline by running the package's existing benchmarks multiple times:
go test -run=^$ -bench=. -count=10 -benchmem \ ./path/to/package > old.txt -
Profile the baseline. CPU profile if you don't know the bottleneck; alloc profile if
b.ReportAllocsshows allocations; trace if latency is bad but CPU is idle. -
Change one thing. Re-run the same benchmark, same count.
-
Decide with
benchstat, not eyeballs. Re-run the benchmark intonew.txt, then:benchstat old.txt new.txt~in the delta column means no significant change. p < 0.05 with a meaningful effect size is the bar.
Write benchmarks that always run. Put the bench next
to the code. Pin its budget inline with b.Fatalf on
overshoot, as BenchmarkRule_MDS024 does. CI then catches
the next slip on its own.
Opt-in rules (those returning
EnabledByDefault() == false) skip BenchmarkCheckCorpus*,
so perrule_bench_test.go is their only time gate. It pins
each a perRuleBenchBudget row — Time$ \text{near} 5 \times \text{the} \text{logged} \text{baseline}, $Allocs near baseline plus max(20%, 4).
optInRules finds new opt-in rules from rule.All(), so
the gate fails with "no pinned budget" until you add the
row. It times parse+Check together: parse dwarfs Check, but
constant parse cost lets the sum still catch a regression.
Allocs stay the tight gate (subtracted, deterministic).
To decide whether a rule needs the AST,
testdata/rule_walk_audit.json records each rule's class
(plan 2606022126). Category A rules scan f.Lines with no
AST. Category B rules drive f.ProseRanges() instead
of re-implementing fences. AST-required rules keep the
tree. No AST-walking rule is cleanly Category A today.
Which profile answers which question
| Profile | Source | Question |
|---|---|---|
| CPU | -cpuprofile cpu.out | Where is time going? |
| Memory | -memprofile m.out | What allocates and what is live |
| Block | runtime.SetBlockProfileRate(1) | Where do goroutines wait? |
| Mutex | runtime.SetMutexProfileFraction(1) | Who holds contended locks? |
| Trace | -trace trace.out | Scheduler / GC / syscall timeline |
View memory profiles with go tool pprof -alloc_objects
(every allocation, including freed) or -inuse_objects
(what is resident now).
mdsmith ships a profile hook for the CLI
(internal/profiling/profiling.go):
MDSMITH_CPUPROFILE=cpu.out mdsmith check .
go tool pprof -http=:8080 cpu.out
No CLI flag on purpose — the command line stays
byte-identical to production. For diffs, use
go tool pprof -base=old.prof new.prof.
Escape analysis
Before adding sync.Pool, read what the compiler already
does:
go build -gcflags="-m=2" ./pkg/markdown 2>&1 | grep escape
Common causes of escape: returning a pointer to a local;
storing a value in interface{} / any; capturing a
variable in a closure that outlives the frame; slice or
map growth past a compile-time-known size. The stack costs
nothing; the heap costs an alloc plus future GC scan.
Profile-guided optimization
PGO has been GA since Go 1.21 and lands 2–14% wins on real binaries. mdsmith commits no profile — a tracked binary artifact burdens every merge. See PGO and the uncommitted profile for the local-generation commands and the plan that moves generation into the release build.
Patterns to apply
The list below extends the project's existing Allocation Budget rules. Reach for these first.
Allocations
Each removed alloc in a per-file Check saves one alloc
per workspace file.
- Pre-size slices.
make([]T, 0, n)whennis known.appenddoubles capacity up to ~1024 then grows ~25%, copying each step. - Reuse loop-local buffers.
buf = buf[:0]clears length, keeps capacity. SeeextractTextBufPoolininternal/mdtext/mdtext.go. sync.Poolfor transient state. Always reset beforePut; entries can be reaped by GC without notice. Examples:internal/punkt/tokenizer.go; the parse-arena pool behindlint.NewFileFromSourcePooled(~40% of allcheckallocation untilengine.lintFilebecame its release boundary). Pool only where the release point provably outlives every reference.- Return
nil, not[]T{}. Project convention.niland a non-nil empty slice are distinguishable in tests, JSON, andreflect; sticking tonilfor "no result" keeps callers uniform. - Compile regexes at package scope.
var foo = regexp.MustCompile(…). Compiling inside a hot function builds the NFA every call.
Strings and bytes
- Stay in
[]byte. Eachstring(b)allocates and copies.bytes.IndexByteandbytes.Containsare SIMD-accelerated on amd64; faster thanstrings.*once you already have bytes. bytes.IndexByteover a hand-rolled byte loop or a regex. The compiler does not vectorizefor i := range b { if b[i]==c };bytes.IndexByteis SIMD assembly, so use it to scan[]byteforward for one byte.lineIndex(behindLineOfOffset) was ~5% of a parity check on prose as a manual loop;IndexByteerased it. Worth it on bigSourcescans, not short per-line or[]runeloops.strings.Builderover+. Concatenation in a loop allocates a new backing array each time. CallGrow(n)first if you know the final size.strconvoverfmt.Sprintf.strconv.Itoa(n)$ \text{is} ~3 \times \text{faster} \text{than} $fmt.Sprintf("%d", n)and skips reflection.strings.EqualFoldfor case-insensitive compare. One pass, no allocation; beatsToLower+==.unsafe.String/unsafe.Slice(Go 1.20+) for zero-copy[]byte↔string. The caller must guarantee the source isn't mutated and outlives the view. Use sparingly, with a comment naming the invariant.
Data structures
- Fixed-size arrays beat slices when the size is known — no header, no escape.
map[K]struct{}for sets — zero-byte value type.- Sorted slice + binary search beats a map for n < ~100, thanks to cache locality. Benchmark at your real n.
- Swiss tables in Go 1.24+. Free 30–60% map speedup and up to 70% map-memory reduction; no code change needed.
Struct layout
- Order fields large-to-small to minimize padding.
The
fieldalignmentanalyzer flags and rewrites waste. - Hot/cold split. Frequently-read fields in one struct, rarely-read fields in another, behind a pointer.
- Prefer
[]Fooover[]*Foo. A value slice is one GC-scanned allocation with zero internal pointers; the pointer slice forces N pointer scans every cycle. - Group pointer fields first, scalars last — GC
ptrdataspans through the last pointer field.
Skip work you don't need
The cheapest call is the one you never make. Two real mdsmith wins live here:
- Memoize per-input computations. When a helper
recurs over one
*lint.File, cache the result on the File —LineOfOffset's newline index (~24% ofcheckCPU before the fix) andRunCache.GlobMatchesfollow it. - Gate expensive analyzers behind a cheap pre-check. MDS024 skips the sentence tokenizer when no paragraph can violate a limit; byte-needles gate regex paths.
- Declare interest instead of filtering inside.
rule.KindScopedCheckerlifts "is this node mine?" into a per-kind dispatch table. Related: never exec a subprocess per item (git rev-parseonce did).
Inlining
The inliner has a budget (~80 nodes per function). Keep
hot functions tiny so they inline; outline the slow path
into a separate function. The canonical model is
sync.Mutex.Lock: the uncontended CAS inlines; the
contended slow path is a separate function.
Inspect with go build -gcflags="-m=2" and look for
can inline foo / inlining call to foo.
Concurrency
sync/atomicfor one-word flags and counters (atomic.Bool,atomic.Int64,atomic.Pointer[T]).sync.Oncefor lazy init. After the first call, it costs a single atomic load.sync.Mutexfor >1-word critical sections. Default choice. Cheaper thansync.RWMutexunder low contention.- Channels for handoff or backpressure, not for protecting a single variable — under contention a channel is orders of magnitude slower than a mutex.
errgroup.SetLimit(n)for bounded fan-out. Size by bottleneck:runtime.NumCPU()for CPU-bound, much higher for I/O-bound.- Every goroutine must exit on
ctx.Done(). Test withgo.uber.org/goleak.
Patterns to avoid
Every Patterns to apply rule has an
inverse anti-pattern. Reaching for fmt.Sprintf, + in a
loop, an un-maked append, []T{}, any, regexp for
a literal, goroutine-per-item, or a channel for one
variable are the obvious ones. A few more are below.
| Avoid | Why | Use instead |
|---|---|---|
defer in a tight loop | falls off the open-coded fast path | hoist or inline cleanup |
reflect in hot paths | type-info walks, allocations | code-gen or hand-roll |
log.Printf per item in a hot loop | format + lock + I/O | sample, or batch outside the loop |
time.Now() in a tight loop | wall + monotonic read each call | read once, use time.Since |
os.ReadFile on huge inputs | one giant alloc, all resident | bufio.Reader with a tuned buffer |
context.Background() deep in calls | loses cancellation | propagate caller's ctx |
Tooling
See Process for the benchstat, pprof, and
goleak workflow. go tool -modfile=tools/go.mod golangci-lint run is the lint gate. Its perfsprint,
prealloc, and gocritic performance group are worth
enabling on top of .golangci.yml.
References
- Dave Cheney — High Performance Go Workshop
- Damian Gryski — go-perfbook
- Go blog — Profile-Guided Optimization in Go 1.21
- Go blog — Faster Go maps with Swiss Tables
- Go blog —
testing.B.Loop - Filippo Valsorda — Efficient Go APIs with the inliner
- Eli Bendersky — Common pitfalls in Go benchmarking
- PlanetScale — Generics can make your Go code slower