| title | High-Performance Go |
|---|---|
| summary | Process and patterns for keeping mdsmith's Go core fast: the benchmark→profile→fix loop, the patterns to reach for, and the anti-patterns that have already cost the project real CPU and GC time. |
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.
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 near 5× the logged
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.
| 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.outNo CLI flag on purpose — the command line stays
byte-identical to production. For diffs, use
go tool pprof -base=old.prof new.prof.
Before adding sync.Pool, read what the compiler already
does:
go build -gcflags="-m=2" ./pkg/markdown 2>&1 | grep escapeCommon 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.
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.
The list below extends the project's existing Allocation Budget rules. Reach for these first.
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.
- 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)is ~3× faster thanfmt.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.
- 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.
- 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.
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).
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.
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.
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 |
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.
- 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