Перейти к содержимому

Benchmarks, trace и базовая оптимизация Go-кода

Зачем знать на Middle 1. Прямой компаньон к pprof. pprof говорит «где», benchmark говорит на сколько лучше стало, trace говорит что именно происходило по событиям. Без бенчмарков любая оптимизация — гадание; с ними вы доказываете, что версия 2 быстрее версии 1 на 35% при 95%-ой confidence. Без trace вы видите только агрегаты; с trace — точный таймлайн scheduler/GC/syscall, и понимаете, что p99 ваш виноват в 50 ms STW. Этот файл закрывает мост между профилированием и реальной оптимизацией: что мерить, как мерить, как не обмануться, и какие микро-оптимизации Go действительно работают.


  1. Базовая концепция: testing.B и go tool trace
  2. Под капотом: b.N calibration, sub/parallel benchmarks, trace events
  3. Gotchas: deadcode, cache state, microbench-ловушки
  4. Production-практики: benchstat, allocation elimination, base optimization
  5. Вопросы на собесе (20–25)
  6. Practice
  7. Источники

Профайлер говорит «90% времени в parseLine». Вы написали parseLineFast. Без бенчмарка вы не знаете:

  • Действительно ли быстрее?
  • На сколько?
  • Не ломает ли это что-то на других входах?
  • Стабильно ли улучшение, или иногда хуже?

Бенчмарк = воспроизводимый эксперимент. Без него optimization превращается в карго-культ.

package mypkg
import "testing"
func BenchmarkParseLine(b *testing.B) {
line := "foo,bar,baz,42"
for i := 0; i < b.N; i++ {
_ = parseLine(line)
}
}

Запуск:

Окно терминала
go test -bench=. -benchmem

Вывод:

goos: linux
goarch: amd64
pkg: mypkg
cpu: AMD EPYC 7B12
BenchmarkParseLine-8 5000000 245 ns/op 48 B/op 2 allocs/op
PASS
ok mypkg 1.467s

Расшифровка:

  • BenchmarkParseLine-8 — имя и GOMAXPROCS.
  • 5000000b.N (сколько итераций было сделано).
  • 245 ns/op — среднее время на операцию.
  • 48 B/op — байт аллокаций на операцию.
  • 2 allocs/op — количество аллокаций.

testing.B подбирает b.N автоматически:

  1. Запускает с b.N = 1.
  2. Измеряет время. Если < 1 секунды, увеличивает b.N (по геометрической прогрессии).
  3. Достигает цели «benchtime» (по умолчанию 1 сек).
  4. Финальный замер — на этом b.N.

Можно зафиксировать:

Окно терминала
go test -bench=. -benchtime=5s # 5 секунд
go test -bench=. -benchtime=10x # ровно 10 итераций (для очень дорогих)
go test -bench=. -count=10 # 10 повторов для статистики

Если в бенчмарке есть expensive setup:

func BenchmarkSearch(b *testing.B) {
data := loadBigDataset() // 5 секунд — НЕ должно входить в timing
b.ResetTimer() // сбрасываем счётчик
for i := 0; i < b.N; i++ {
_ = search(data, "key")
}
}

ResetTimer обнуляет timer и memory counters. Используется один раз после setup.

Если setup нужен для каждой итерации:

func BenchmarkInsert(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer()
tree := newTree() // не учитываем в timing
b.StartTimer()
tree.Insert(rand.Int())
}
}

StopTimer/StartTimer дорогие (атомарные операции), не злоупотребляйте — лучше переписать через batch.

func BenchmarkXxx(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = process()
}
}

Эквивалентно -benchmem на CLI. Если хотите всегда мерить аллокации для этого бенчмарка — поставьте в коде.

go tool trace записывает все scheduler/GC/syscall события за период (несколько мс или секунд) и визуализирует их в браузере. В отличие от pprof (sampling), trace — exact event recording.

Окно терминала
# Из benchmark:
go test -bench=BenchmarkXxx -trace=trace.out
# Из running app:
curl http://localhost:6060/debug/pprof/trace?seconds=5 > trace.out
# Анализ:
go tool trace trace.out
# Откроется в браузере, ~5 разделов.
pprof (sampling):
┌────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ S │ │ S │ │ S │ │ S │ │ S │ S = sample (раз в 10ms)
└────┴────┴────┴────┴────┴────┴────┴────┴────┘
Что: статистика, агрегаты, % времени.
Cost: ~1-5%.
trace (exact events):
┌──────────────────────────────────────────────┐
│ G1: run ─ block ── run ── syscall ── run │
│ G2: run ── block ──── run ──── block │
│ GC: ───── mark ────── sweep ───── │
└──────────────────────────────────────────────┘
Что: точный таймлайн, что когда происходило.
Cost: 10-20%, файл огромный.

2. Под капотом: b.N calibration, sub/parallel benchmarks, trace events

Заголовок раздела «2. Под капотом: b.N calibration, sub/parallel benchmarks, trace events»

Для tables / parameterization:

func BenchmarkSearch(b *testing.B) {
sizes := []int{10, 100, 1000, 10000}
for _, size := range sizes {
data := makeData(size)
b.Run(fmt.Sprintf("n=%d", size), func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = search(data, 42)
}
})
}
}

Вывод:

BenchmarkSearch/n=10-8 50000000 30 ns/op
BenchmarkSearch/n=100-8 5000000 280 ns/op
BenchmarkSearch/n=1000-8 500000 3100 ns/op
BenchmarkSearch/n=10000-8 50000 30500 ns/op

Видно линейность.

Запуск конкретного:

Окно терминала
go test -bench='BenchmarkSearch/n=1000$'

Для concurrent code:

func BenchmarkCounter(b *testing.B) {
var counter atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
counter.Add(1)
}
})
}

b.RunParallel:

  • Запускает GOMAXPROCS горутин.
  • Каждая горутина крутит цикл, пока pb.Next() (это аналог проверки i < b.N, но per-goroutine).
  • Итого все горутины суммарно делают b.N итераций.

Полезно для:

  • Проверки lock contention (atomic vs Mutex).
  • Throughput-замеров на multi-core.

b.SetParallelism(2) — увеличить количество горутин в 2 раза от GOMAXPROCS.

Для throughput замеров (bytes/sec):

func BenchmarkCopy(b *testing.B) {
src := make([]byte, 1<<20) // 1 MB
dst := make([]byte, 1<<20)
b.SetBytes(int64(len(src)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
copy(dst, src)
}
}

Вывод добавит MB/s:

BenchmarkCopy-8 50000 25000 ns/op 41943 MB/s
// Bad: компилятор может удалить вычисление "Hash(x)":
func BenchmarkHash(b *testing.B) {
for i := 0; i < b.N; i++ {
Hash("foo") // результат не используется → может быть deadcode!
}
}

Если Hash чистая функция (нет side effects), Go компилятор (с inlining) может удалить вызов. Бенчмарк покажет 0 ns.

Правильно:

var sink uint64
func BenchmarkHash(b *testing.B) {
var local uint64
for i := 0; i < b.N; i++ {
local = Hash("foo")
}
sink = local // используем результат вне цикла
}

sink — пакетная переменная, компилятор не может доказать, что её никто не читает (она экспортная или передаётся в noinline).

В Go 1.21+ есть runtime.KeepAlive, но он не отменяет deadcode elimination. Лучший способ — sink или прямая запись в b.Logf (хотя это медленно).

2.5. Benchmark на разных входах: t.Run + параметризация

Заголовок раздела «2.5. Benchmark на разных входах: t.Run + параметризация»
type tc struct {
name string
input string
}
func BenchmarkParse(b *testing.B) {
cases := []tc{
{"short", "a,b,c"},
{"medium", strings.Repeat("a,", 100)},
{"long", strings.Repeat("a,", 10000)},
}
for _, c := range cases {
b.Run(c.name, func(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = parse(c.input)
}
})
}
}

Полезно для catching algorithmic regressions.

Открыв go tool trace trace.out, увидите главное меню:

РазделЧто показывает
View traceГлавный таймлайн (Chrome-style). Все горутины, GC, syscalls.
Goroutine analysisПо каждому виду горутины — статистика.
Network blocking profile (pprof)Где блокировались на network.
Synchronization blocking profile (pprof)Где блокировались на mutex/chan.
Syscall blocking profile (pprof)Где блокировались на syscall.
Scheduler latency profile (pprof)Latency: _Grunnable → _Grunning.
User-defined tasksЕсли используете runtime/trace.NewTask.
User-defined regionsЕсли используете runtime/trace.WithRegion.
Goroutines:
G1: ─ Running ──── Block ──── Running ── Wait ────── End
G2: ──── Running ──── Block ─ Running ──── End
G3: ──── Syscall ──── Running ── Block
G4: ──── Running ──── Block ─────── Running ── End
GC: ──── Mark ────── Sweep ──
Procs:
P0: G1 ──── G3 ── G2 ──── G1 ──── G2 ──── G1
P1: G2 ─── G4 ─── G3 ─── G4 ──── G3 ─── G2
Timeline: →→→→→→→→→→→→→→→→→→ →→→→→→→→→→→→→

Управление в браузере:

  • w / s — zoom in/out.
  • a / d — pan left/right.
  • Click — детали события.
  • Stats panel — состояние горутины в этот момент.

Если у вас есть main.worker, кликнув на него увидите:

  • Total time.
  • Time spent in: Execution / Network / Sync block / Syscall / Block / Schedule wait.
  • Per-goroutine breakdown.

Полезно: если sync block большой → mutex contention. Если schedule wait большой → CPU starvation.

Распределение задержки _Grunnable → _Grunning. Высокий p99 здесь → горутины долго ждут CPU. Возможные причины:

  • Слишком мало P (GOMAXPROCS низкий).
  • Tight loops занимают P (в Go 1.13- это была проблема).
  • Длинный syscall блокирует P (handoff не сработал быстро).
import "runtime/trace"
func handle(ctx context.Context, req *Request) {
ctx, task := trace.NewTask(ctx, "handleRequest")
defer task.End()
trace.WithRegion(ctx, "parse", func() {
parse(req)
})
trace.WithRegion(ctx, "process", func() {
process(req)
})
trace.Log(ctx, "user", req.UserID)
}

В trace view увидите эти регионы как coloured bars. Очень полезно для прикладных сервисов: «где конкретно мой handler проводит время?».

В Go 1.23 появился flight recorder — это rolling trace buffer:

  • Постоянно записывает trace в кольцевой буфер (последние N секунд).
  • При триггере (panic, p99 spike, manual call) — сохраняем буфер.
  • Cost: ~1% overhead (намного меньше, чем full trace).
import "runtime/trace"
// pre-1.23 примерно так концептуально:
// в Go 1.23+:
fr := trace.NewFlightRecorder()
fr.Start()
defer fr.Stop()
// Когда что-то плохое случилось:
buf := bytes.NewBuffer(nil)
fr.WriteTo(buf)
saveToS3(buf.Bytes())

API стабилизируется в 1.24+. Это game changer для post-mortem analysis.

ВопросИнструмент
Где CPU тратится?pprof CPU
Куда уходит память?pprof heap
Goroutine leakpprof goroutine
Почему p99 latency = 100 ms иногда?trace
Сколько STW pause?trace, gctrace
Lock contention?pprof mutex / trace sync block
Кто блокирует scheduler?trace view
Что в моём handler по фазам?trace + runtime/trace.WithRegion

Самая частая ошибка микробенчмарка:

// BAD:
func BenchmarkHash(b *testing.B) {
for i := 0; i < b.N; i++ {
Hash(input) // если результат не используется → может быть удалён
}
}

Реальный пример: однажды я писал бенчмарк Hash, и Go показывал 0.5 ns/op (быстрее, чем CMP инструкция!). Оказалось, компилятор inline-нул Hash, увидел, что результат deadcode, и удалил весь цикл.

Fix: sink variable.

func BenchmarkSearch(b *testing.B) {
data := makeArray(1e7) // 80 MB
for i := 0; i < b.N; i++ {
_ = binSearch(data, i % len(data))
}
}
  • На первой итерации data не в кэше → cache miss.
  • На последующих — L2/L3 уже горячий.
  • Бенчмарк замерит «warm cache scenario».

Если в проде каждый запрос идёт с холодным кэшем (например, шардирование), бенчмарк врёт.

Fix: в бенчмарке делать “trash cache” между итерациями (например, читать большой неиспользуемый массив).

// Sorted vs unsorted массив:
for _, v := range arr {
if v > 100 {
sum += v
}
}
  • Если arr отсортирован → branch predictor работает идеально → быстро.
  • Если случайный порядок → mispredictions → медленнее в 3 раза.

В микробенчмарке b.N итераций по одинаковому массиву → branch predictor «выучит» паттерн → результат не репрезентативен.

Fix: перемешивать input между итерациями (но осторожно с timing).

func BenchmarkProcess(b *testing.B) {
for i := 0; i < b.N; i++ {
result := make([]int, 1000) // аллокация в каждой итерации
process(result)
}
}

Это нормально, если в проде так и есть. Но если вы хотите измерить именно process, аллокация добавит шум:

func BenchmarkProcess(b *testing.B) {
result := make([]int, 1000) // один раз
b.ResetTimer()
for i := 0; i < b.N; i++ {
process(result)
}
}

На многосокетных серверах latency может скакать в зависимости от того, где запустился benchmark.

Для стабильности:

Окно терминала
taskset -c 0-7 go test -bench=. -count=10

И запускать на dedicated machine, не в shared CI.

Окно терминала
go test -bench=. -count=10 > result.txt
benchstat result.txt
# Покажет stddev, p-value.

benchstat — must-have утилита.

Окно терминала
go install golang.org/x/perf/cmd/benchstat@latest

GC может «запутать» замер. Микро-бенчмарки на >5 ns/op обычно включают GC time.

Для стабильности:

  • runtime.GC() перед b.ResetTimer().
  • Или GOGC=off (но тогда heap растёт).

Go 1.24 добавил b.Loop():

func BenchmarkXxx(b *testing.B) {
for b.Loop() {
// hot code
}
}

Преимущества над for i := 0; i < b.N; i++:

  • b.Loop гарантирует, что компилятор не оптимизирует содержимое (нет deadcode).
  • Внутренне делает ResetTimer на первой итерации.
  • Не нужно вручную считать b.N.

С Go 1.24 рекомендуется именно b.Loop.

Если включить trace на большой нагрузке на 60 секунд — файл будет gigabytes. go tool trace загружает его в браузер, который потребует много RAM.

Совет: для prod использовать flight recorder (Go 1.23+) — кольцевой буфер.

Если вы залезли в CGO, runtime не управляет потоком, и trace не видит, что там происходит. Будет «syscall» гэп.


Окно терминала
# Базовый:
go test -bench=. -count=10 > old.txt
# Изменения в коде...
go test -bench=. -count=10 > new.txt
benchstat old.txt new.txt

Вывод:

goos: linux
goarch: amd64
│ old.txt │ new.txt │
│ sec/op │ sec/op vs base │
ParseLine-8 245.0n ± 2% 180.0n ± 1% -26.53% (p=0.000 n=10)
│ B/op │ B/op vs base │
ParseLine-8 48.00 ± 0% 16.00 ± 0% -66.67% (p=0.000 n=10)
│ allocs/op │ allocs/op vs base │
ParseLine-8 2.000 ± 0% 1.000 ± 0% -50.00% (p=0.000 n=10)
  • ±2% — стандартное отклонение.
  • p=0.000 — p-value Mann-Whitney U-test. < 0.05 = статистически значимо.

Без benchstat бенчмарки бесполезны для серьёзной оптимизации.

.github/workflows/bench.yml
- name: Bench main
run: |
git checkout main
go test -bench=. -count=10 -benchmem ./pkg/critical > old.txt
- name: Bench PR
run: |
git checkout pr-branch
go test -bench=. -count=10 -benchmem ./pkg/critical > new.txt
- name: Compare
run: |
go install golang.org/x/perf/cmd/benchstat@latest
benchstat old.txt new.txt

Можно автоматически блокировать PR при регрессии > 5%.

// Bad — 14 аллокаций для 10000 элементов (growslice):
xs := []int{}
for i := 0; i < 10000; i++ {
xs = append(xs, i)
}
// Good — 1 аллокация:
xs := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
xs = append(xs, i)
}

growslice doubles capacity, потом на больших — растёт в 1.25×.

// Bad:
m := map[string]int{}
for _, x := range data {
m[x.Key] = x.Value // rehashing
}
// Good:
m := make(map[string]int, len(data))
// Bad O(n²):
s := ""
for _, w := range words {
s += w
}
// Good O(n):
var b strings.Builder
b.Grow(estimatedSize) // optional preallocate
for _, w := range words {
b.WriteString(w)
}
s := b.String()

То же, но с []byte. Полезно для io.Writer-style API.

// io.Copy уже использует 32KB buffer внутри:
io.Copy(dst, src)
// Если src — небуферизированный io.Reader, и читает мелкими порциями,
// добавление bufio.Reader/Writer ускоряет:
br := bufio.NewReader(src)
bw := bufio.NewWriter(dst)
defer bw.Flush()
io.Copy(bw, br)
// Bad (boxing int в interface):
m := map[string]any{"x": 42}
// Good (typed):
m := map[string]int{"x": 42}

Boxing = heap allocation + escape.

В Go 1.14+ defer open-coded (компилятор inline-ит) для типичных случаев. Overhead ~0.5 ns/op.

Но если defer в горячем цикле:

// Bad (миллионы defer'ов):
for i := 0; i < 1e7; i++ {
func() {
f, _ := os.Open(...)
defer f.Close()
// ...
}()
}
// Better (один defer вне цикла):
files := make([]*os.File, 0, 1e7)
defer func() {
for _, f := range files {
f.Close()
}
}()
for i := 0; i < 1e7; i++ {
f, _ := os.Open(...)
files = append(files, f)
// ...
}
// Если N < ~10 — slice + linear search быстрее:
type kv struct { k string; v int }
var s []kv
for i := range s { if s[i].k == key { return s[i].v } }
// Если N > 10 — map.

Map имеет overhead (~50 ns на lookup). Linear scan на 10 элементах = ~20 ns.

// Bad:
s := "hello"
b := []byte(s)
n := process(b)
s2 := string(b)
// Good (если можно использовать []byte везде):
b := []byte(s)
n := process(b)

В Go 1.20+ unsafe.String / unsafe.StringData дают zero-copy конверсию, но только если уверены, что не модифицируете.

Окно терминала
# Отключить inline для дебага:
go build -gcflags='-l'
# Увеличить агрессивность inline:
go build -gcflags='-l=4'
# Disable bounds check (опасно):
go build -gcflags='-B'

В проде НЕ отключайте bounds check — это catch для багов.

Если компилятор может доказать, что индекс безопасен, он удалит проверку. Помогите ему:

// Bad (bounds check каждый раз):
for i := 0; i < len(a); i++ {
x[i] = a[i] + b[i] // 2 bounds check (a, b)
}
// Good (compiler видит, что b того же размера):
_ = b[len(a)-1] // hint: b достаточно длинный
for i := 0; i < len(a); i++ {
x[i] = a[i] + b[i]
}

Это эзотерика, но в horse-race-code (DSP, crypto) даёт 10–20%.

Я повторюсь, потому что это самая частая оптимизация:

var bufPool = sync.Pool{
New: func() any { return make([]byte, 4096) },
}
func handle() {
buf := bufPool.Get().([]byte)
defer bufPool.Put(buf[:0]) // reset slice length, оставляем capacity
// ...
}

Бенчмарк pool vs no pool — обычно 5–10× ускорение на горячем пути.

// Cache-unfriendly:
type Bad struct {
a byte // 1
b int64 // 8 — но из-за padding отделён от a
c byte // 1
d int64 // 8 — padding опять
}
// Size: 32 bytes (3× int64 alignment + padding)
// Cache-friendly:
type Good struct {
b int64 // 8
d int64 // 8
a byte // 1
c byte // 1
}
// Size: 24 bytes

Используйте golang.org/x/tools/go/analysis/passes/fieldalignment для автообнаружения:

Окно терминала
go install golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment@latest
fieldalignment ./...

Допустим, ваш сервис тратит 40% CPU на encoding/json.Unmarshal.

Замеры (benchstat):

encoding/json goccy/go-json sonic
ParseRequest-8 8.5 µs ± 1% 3.2 µs ± 2% 1.1 µs ± 3%
- -62% -87%

goccy/go-json — pure Go, drop-in. sonic — байткод-генерация, на ~3× быстрее, но cgo и assembly. Trade-off — портируемость.

Сценарий: «у нас раз в час p99 latency 500 ms».

Подход:

  1. Поставьте flight recorder (Go 1.23+) с буфером в 60 секунд.
  2. На триггер (latency > 200 ms) сохраните trace.
  3. Загрузите в go tool trace и смотрите вокруг события.

Чаще всего обнаруживаются:

  • Длинный GC pause (большой heap, малый GOMEMLIMIT).
  • Lock contention пик.
  • Syscall спайк (file IO, DNS lookup).
  • Schedule starvation (одна горутина забрала P).
Окно терминала
go tool trace -http=:8080 trace.out
# Откроется в браузере.
# Только PNG/SVG для отчёта:
go tool trace -pprof=sched trace.out > sched.pprof
go tool pprof -svg sched.pprof > sched.svg

90% оптимизаций — премативная (Donald Knuth: «root of all evil»). Перед началом:

  1. Снимите pprof.
  2. Найдите 1–3 функции, занимающие ≥ 10%.
  3. Оптимизируйте их (с benchmark + benchstat).
  4. Повторите.

Не пытайтесь сразу делать unsafe.Pointer magic — обычно достаточно make([]T, 0, n).

  • Есть benchmark с b.ResetTimer(), b.ReportAllocs().
  • Запущено -count=10 (или больше).
  • benchstat vs main показывает значимое улучшение (p < 0.05).
  • Аллокации не выросли (B/op, allocs/op).
  • Unit tests проходят (тесты на корректность важнее).
  • Race detector чист (go test -race).
  • Профиль (pprof) подтверждает, что hot path изменился.

  1. Что такое b.N в testing.B? Как он подбирается?
  2. Зачем нужен b.ResetTimer? Когда вызывать?
  3. Чем отличается b.StopTimer/b.StartTimer от ResetTimer?
  4. Что показывает b.ReportAllocs?
  5. Как параметризовать benchmark? (sub-benchmarks).
  6. Что такое b.RunParallel? Когда нужен?
  7. Что такое sink variable, зачем?
  8. Почему микробенчмарк может показать 0 ns/op? (Deadcode.)
  9. Что такое benchstat и зачем?
  10. Какой p-value считается значимым? (< 0.05.)
  11. Чем go tool trace отличается от pprof?
  12. Какой overhead у trace? (10-20%.)
  13. Что показывает раздел Goroutine analysis в trace?
  14. Что такое scheduler latency profile?
  15. Как добавить annotations в trace? (runtime/trace.WithRegion.)
  16. Что такое flight recorder? (Go 1.23+, кольцевой буфер.)
  17. Чем strings.Builder отличается от s += ""? (O(n) vs O(n²).)
  18. Как preallocate slice / map?
  19. Когда defer дорогой? (В горячем цикле; обычно почти бесплатный с Go 1.14+.)
  20. Slice vs map для N=10 — что быстрее? (Slice + linear scan.)
  21. Что такое bounds check elimination?
  22. Какие compile flags для disable inline? (-gcflags='-l'.)
  23. Что такое interface boxing и когда это плохо?
  24. Как избежать string ↔ []byte allocation? (unsafe.String/unsafe.SliceData в Go 1.20+.)
  25. Чем sync.Pool полезен в hot path?

package mypkg
import "testing"
func Hash(s string) uint64 {
var h uint64 = 14695981039346656037
for i := 0; i < len(s); i++ {
h ^= uint64(s[i])
h *= 1099511628211
}
return h
}
// BAD: result not used → may be deadcoded
func BenchmarkHashBad(b *testing.B) {
for i := 0; i < b.N; i++ {
Hash("hello, world")
}
}
// GOOD: sink prevents deadcode
var sink uint64
func BenchmarkHashGood(b *testing.B) {
var s uint64
for i := 0; i < b.N; i++ {
s = Hash("hello, world")
}
sink = s
}

Запустить:

Окно терминала
go test -bench=. -benchmem

BenchmarkHashBad может показать неправдоподобно быстро (или одинаково с GoodHash в зависимости от компилятора).

package mypkg
import "testing"
func BenchmarkAppendNoPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
xs := []int{}
for j := 0; j < 1000; j++ {
xs = append(xs, j)
}
_ = xs
}
}
func BenchmarkAppendPrealloc(b *testing.B) {
for i := 0; i < b.N; i++ {
xs := make([]int, 0, 1000)
for j := 0; j < 1000; j++ {
xs = append(xs, j)
}
_ = xs
}
}
BenchmarkAppendNoPrealloc-8 200000 6200 ns/op 16312 B/op 9 allocs/op
BenchmarkAppendPrealloc-8 400000 2800 ns/op 8192 B/op 1 alloc/op
package mypkg
import (
"strings"
"testing"
)
const N = 1000
func BenchmarkStringConcat(b *testing.B) {
for i := 0; i < b.N; i++ {
s := ""
for j := 0; j < N; j++ {
s += "x"
}
_ = s
}
}
func BenchmarkStringBuilder(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
for j := 0; j < N; j++ {
sb.WriteByte('x')
}
_ = sb.String()
}
}
func BenchmarkStringBuilderGrow(b *testing.B) {
for i := 0; i < b.N; i++ {
var sb strings.Builder
sb.Grow(N)
for j := 0; j < N; j++ {
sb.WriteByte('x')
}
_ = sb.String()
}
}
BenchmarkStringConcat-8 1000 1500000 ns/op 522184 B/op 999 allocs/op
BenchmarkStringBuilder-8 1000000 1200 ns/op 2096 B/op 5 allocs/op
BenchmarkStringBuilderGrow-8 2000000 800 ns/op 1024 B/op 1 allocs/op

Видно: += создаёт квадратичное количество B/op.

package mypkg
import (
"sync"
"sync/atomic"
"testing"
)
func BenchmarkMutexCounter(b *testing.B) {
var mu sync.Mutex
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
counter++
mu.Unlock()
}
})
}
func BenchmarkAtomicCounter(b *testing.B) {
var counter atomic.Int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
counter.Add(1)
}
})
}

На многоядерной машине:

BenchmarkMutexCounter-8 20000000 120 ns/op
BenchmarkAtomicCounter-8 200000000 8 ns/op

15× разница.

package mypkg
import (
"sync"
"testing"
)
var pool = sync.Pool{
New: func() any { return make([]byte, 4096) },
}
func BenchmarkAllocBuf(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf := make([]byte, 4096)
_ = buf
}
}
func BenchmarkPoolBuf(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf := pool.Get().([]byte)
pool.Put(buf)
}
}
package main
import (
"context"
"os"
"runtime/trace"
"time"
)
func parse(ctx context.Context) {
defer trace.StartRegion(ctx, "parse").End()
time.Sleep(10 * time.Millisecond)
}
func process(ctx context.Context) {
defer trace.StartRegion(ctx, "process").End()
time.Sleep(20 * time.Millisecond)
}
func handle(ctx context.Context, id int) {
ctx, task := trace.NewTask(ctx, "handle")
defer task.End()
trace.Log(ctx, "request_id", string(rune(id+'0')))
parse(ctx)
process(ctx)
}
func main() {
f, _ := os.Create("trace.out")
defer f.Close()
trace.Start(f)
defer trace.Stop()
ctx := context.Background()
for i := 0; i < 5; i++ {
handle(ctx, i)
}
}
Окно терминала
go run main.go
go tool trace trace.out
# Откроется в браузере. "User-defined tasks" покажет 5 задач,
# каждая с регионами parse и process.
package mypkg
import "testing"
import "unsafe"
type Bad struct {
a byte
b int64
c byte
d int64
}
type Good struct {
b int64
d int64
a byte
c byte
}
func TestSizes(t *testing.T) {
t.Logf("Bad: %d bytes", unsafe.Sizeof(Bad{}))
t.Logf("Good: %d bytes", unsafe.Sizeof(Good{}))
}

Запустите:

Окно терминала
go test -v -run TestSizes
# Bad: 32 bytes
# Good: 24 bytes
package mypkg
import "testing"
func BenchmarkCopy(b *testing.B) {
src := make([]byte, 1<<20) // 1 MB
dst := make([]byte, 1<<20)
b.SetBytes(int64(len(src)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
copy(dst, src)
}
}
BenchmarkCopy-8 50000 25000 ns/op 41943 MB/s
package mypkg
import "testing"
func BenchmarkLoopNew(b *testing.B) {
for b.Loop() {
// hot code; не выпиливается компилятором
}
}

  1. testing package: https://pkg.go.dev/testing
  2. Benchmarking in Go: How To Write Better Benchmarks (Dave Cheney): https://dave.cheney.net/2013/06/30/how-to-write-benchmarks-in-go
  3. High Performance Go Workshop (Dave Cheney): https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html
  4. golang.org/x/perf/cmd/benchstat: https://pkg.go.dev/golang.org/x/perf/cmd/benchstat
  5. runtime/trace package: https://pkg.go.dev/runtime/trace
  6. go tool trace docs: https://github.com/golang/go/wiki/Performance#tracing
  7. Diagnostics guide: https://go.dev/doc/diagnostics
  8. GopherCon 2017: Understanding Go Trace (Rhys Hiltner): https://www.youtube.com/watch?v=ySy3sR1LFCQ
  9. Go 1.23 Flight Recorder proposal: https://github.com/golang/go/issues/63185
  10. fieldalignment tool: https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/fieldalignment
  11. goccy/go-json: https://github.com/goccy/go-json
  12. bytedance/sonic: https://github.com/bytedance/sonic
  13. Inside the Go Playground / compile flags: https://go.dev/doc/go1.compile
  14. The Go Compiler optimization notes: https://github.com/golang/go/wiki/CompilerOptimizations
  15. Awesome Go performance: https://github.com/golang/go/wiki/Performance