Содержание урока · 14 разделов
- Бенчмарки в Go: перестаньте угадывать — начните измерять
- func BenchmarkXxx: контракт, который вы уже знаете
- go test -bench: читаем отчёт по столбцам
- -benchmem: B/op и allocs/op — аллокации под подозрением
- ResetTimer, StopTimer, StartTimer: чистое окно замера
- pprof профилирование: куда ушла секунда
- Чтение профилей: top, list и flame graph
- Кейс: строковая конкатенация против strings.Builder
- Шпаргалка: флаги go test -bench и API testing.B
- Правила гигиены бенчмаркинга
- Что дальше: от «быстро ли» к «как это устроено»
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Бенчмарки в Go: перестаньте угадывать — начните измерять#
Шестнадцатый урок закрыл вопрос «правильно ли работает код»; этот урок закрывает вопрос «насколько быстро». Разница между ними принципиальная, потому что у скорости есть коварное свойство: она почти всегда не там, где вы думаете. Программист трижды переписывает парсер — а тормозит печать в лог; команда переезжает на новый фреймворк — а девяносто процентов времени съедает сериализация, которая была и в старом. По запросу «бенчмарки go» обычно ищут инструмент, который поставит точку в спорах, и он действительно есть, причём в той же стандартной библиотеке: пакет testing умеет не только проверять, но и измерять.
Бенчмарка — это функция в файле с суффиксом _test.go (да, рядом с тестами, в том же каталоге), имя которой начинается с префикса Benchmark и продолжается с заглавной буквы: BenchmarkConcat, BenchmarkNormalizePhone. Единственный параметр — указатель на testing.B, «руль» измерения: через него фреймворк сообщает число итераций (b.N), управляет таймером (ResetTimer, StopTimer, StartTimer) и просит печатать аллокации (ReportAllocs). Внутри — цикл по b.N, тело которого и есть измеряемая операция. Запускается всё той же командой go test, только с флагом -bench: никакой отдельной инфраструктуры, второй мир живёт в первом.
Что будет в уроке. Сначала контракт: сигнатура, файл, и главное — кто и зачем подбирает b.N, потому что именно здесь новички теряются (число в отчёте меняется от запуска к запуску, и это нормально). Затем чтение отчёта go test -bench по столбцам: ns/op, флаг -benchmem и его подарки B/op и allocs/op. Потом гигиена замера: ResetTimer, StopTimer и StartTimer для честного окна измерения. Затем pprof профилирование — снятие CPU-профиля и профиля памяти, чтение top и list, базовый flame graph. Финал — живой кейс: строковая конкатенация против strings.Builder, где квадратичный алгоритм на глазах становится линейным, а отчёт -benchmem объясняет почему.
Оговорка о песочнице, как в прошлом уроке. Настоящий бенчмарк — это работа команды go test: она собирает пакет, находит Benchmark-функции и прогоняет их с подобранным b.N. Ни один движок песочницы этого не делает (у резервного пакета testing нет вовсе, у основного нет команды test), поэтому Benchmark-функции урока — листинги для локального запуска, а интерактивы честно гоняют то, что движкам по силам: ручные счётчики операций, симуляцию подгонки b.N, фальшивые часы для ResetTimer и битву конкатенаций. Принцип тот же, что в шестнадцатом уроке: смотрите на настоящие листинги — тут же трогайте их логику руками.
func BenchmarkXxx: контракт, который вы уже знаете#
Хорошая новость: половину контракта вы уже сдали. Файл с суффиксом _test.go — тот же, go build его молча пропускает, go test — компилирует. Префикс имени Benchmark с заглавным продолжением — по образцу префикса Test; флаг -bench фильтрует бенчмарки регуляркой так же, как -run фильтрует тесты. Возвращать функция ничего не должна — измерение выражается не return, а самим фактом прогонки цикла. Меняется только руль: вместо testing.T приходит testing.B, и у него своя задача — не фиксировать провалы, а крутить счётчик итераций.
Центральное действующее лицо — b.N. Это число повторов измеряемой операции, и его задаёт НЕ программист: его подбирает фреймворк по ходу запуска. Механика честная и простая: testing запускает бенчмарку с малым N, смотрит на затраченное время, затем удваивает (и повторяет этот подгон, отрезая неудачные прогоны), пока суммарное время не станет достаточно долгим для надёжного измерения — по умолчанию около секунды на бенчмарку (управляется флагом -benchtime). Финальный прогон с последним N и попадает в отчёт: поэтому число итераций в выводе каждый раз разное — 4908816, 5102311, 4782195 — и это не шум, а следствие подгонки. Писать b.N = 1000 руками бессмысленно и не нужно: фреймворк сам обеспечит и достаточную длительность, и деление на N при расчёте наносекунд на операцию.
Отсюда и классическая форма, которую вы увидите в миллионах строк по всему миру:
for i := 0; i < b.N; i++ { // измеряемая операция }
Честности ради: начиная с Go 1.24 у testing.B появился метод b.Loop() — новый стиль, где ручной счётчик не нужен (for b.Loop() { ... }), а компилятору проще не выбрасывать неиспользуемый результат. Но классическая форма с b.N остаётся универсальной: она работает во всех версиях, встречается в любом старом коде и документации, и именно её мы разбираем по буквам. Выучив её, вы прочитаете b.Loop за минуту — это просто оболочка над тем же контрактом.
Единственная ловушка внутри цикла — компилятор и мёртвый код. Если результат операции никак не используется, компилятор теоретически вправе выкинуть работу (в старых версиях так и было), и бенчмарк начнёт измерять пустоту. Лечится чтением результата: присвойте его внешней переменной и добавьте _ = result после цикла — как в листинге ниже. Новый b.Loop решает это автоматически, но в классической форме дисциплина на вас.
Листинг: первая бенчмарка — классическая форма с b.N
GO// concat_test.go — бенчмарки живут рядом с тестами, в файле *_test.go.
package concat
import "testing"
// Concat склеивает кусочки через += — так НЕ надо, скоро увидим почему.
func Concat(parts []string) string {
s := ""
for _, p := range parts {
s += p // каждая итерация копирует всю накопленную строку заново
}
return s
}
// BenchmarkConcat — префикс Benchmark + имя с заглавной буквы; руль *testing.B.
func BenchmarkConcat(b *testing.B) {
parts := []string{"a", "b", "c", "d", "e"}
// Классическая форма: testing сам подберёт b.N — руками не задаём.
for i := 0; i < b.N; i++ {
Concat(parts)
}
// Читаем результат, чтобы компилятор не выбросил работу как мёртвый код.
_ = Concat(parts)
}Листинг — для настоящего go test: в песочнице пакет testing движкам недоступен. Обратите внимание на три вещи: prepare-данные (parts) создаются ОДИН раз до цикла — иначе вы измерите и их создание; b.N нигде не присваивается — это только входное число итераций; и хвост _ = Concat(parts) — страховка от мёртвого кода. Начиная с Go 1.24 тот же код можно записать как for b.Loop() { Concat(parts) } — метод сам сбрасывает таймер перед первым вызовом и защищает от выкидывания результата, но b.N-форма остаётся классикой, читаемой в любом репозитории.
Проверьте себя
Откуда берётся число итераций b.N в бенчмарке func BenchmarkXxx(b *testing.B)?
go test -bench: читаем отчёт по столбцам#
Запуск — та же команда go test с флагом -bench и регуляркой: -bench=. запускает все бенчмарки пакета, -bench=Concat — только совпавшие с именем. Первая тонкость: обычные тесты при этом всё равно выполнятся, и их время разбавит прогон; если они вам сейчас не нужны, отключите их фильтром, которому не совпадает ни один тест: go test -bench=. -run='^$'. Это каноническая команда бенчмаркинга — запомните её парой с флагом -benchmem, о котором ниже.
Отчёт печатается столбцами, и каждый столбец имеет точный смысл. Строка BenchmarkConcat-8 4908816 245.3 ns/op читается так: имя бенчмарки и суффикс -8 — это GOMAXPROCS, число логических процессоров, при котором шёл прогон (полезно, когда сравниваете машины); 4908816 — подобранное b.N, число итераций финального прогона; 245.3 ns/op — средняя стоимость ОДНОЙ итерации в наносекундах, главный столбец урока. Перед данными go test печатает паспорт окружения — goos, goarch, pkg, cpu — и это не украшение: абсолютные наносекунды сравнивают только на одном железе, поэтому паспорт нужен в каждом отчёте.
Флаги управления прогоном продолжают знакомую логику. -benchtime=2s заставит фреймворк крутить подгон дольше — полезно для шумных сред, где секунды не хватает; -benchtime=100x задаёт фиксированное число итераций — быстро проверить, что бенчмарка вообще живая. -count=5 повторяет весь прогон пять раз: ОДИН прогон — это анекдот, пять — статистика. Между повторами разброс неизбежен (планировщик, кэш, соседние процессы), поэтому всерьёз выводы делают не по одному числу, а по медиане нескольких прогонов; у benchstat — маленькой утилиты из golang.org/x/perf — для этого есть готовый отчёт с процентами и вердиктом «отличие статистически значимо или нет».
И последнее: бенчмарка, которая печатает в stdout, портит и прогон, и отчёт. Печать — самая дорогая операция на свете относительно арифметики, и ваши 245.3 ns/op мгновенно станут 4200 ns/op от одного fmt.Println внутри цикла. Правило то же, что у тестов: молчи, пока здоров — печать только в prepare-фазе или за пределами измеряемого окна.
Подгон b.N: как testing нащупывает число итераций
Это устройство подгонки b.N без времени: вместо секунд — условные такты, вместо наращивания по факту замера — чистая функция n*n. Запустите и посмотрите на траекторию: 1, 2, 4, 8, 16, 32 — удвоение нащупывает цель за log2 шагов, поэтому даже миллиарды итераций подбираются за пару десятков прогонов. Замените цель 1000 на 100 — цепочка укоротится: тот же алгоритм, что у настоящей подгонки, только цель в тактах.
Запустите код, чтобы увидеть вывод.
-benchmem: B/op и allocs/op — аллокации под подозрением#
Наносекунды говорят «сколько», но не говорят «почему». Для «почему» в go test есть флаг -benchmem, и это самый информативный символ на весь бенчмаркинг: go test -bench=. -benchmem=-run='^$' дописывает к отчёту два столбца. 10 B/op — сколько БАЙТ аллокировал один проход измеряемой операции; 4 allocs/op — сколько РАЗ за один проход вызвался аллокатор. Это средние значения по всем итерациям, и читать их нужно в связке: allocs/op показывает количество походов к аллокатору (каждый — фиксированная цена и потенциальный pressure на GC), B/op — объём трафика.
Почему аллокации — главный подозреваемый любого торможения в Go? Потому что память в куче дорогая не сама по себе, а последствиями: аллокатор должен найти свободный блок (а при фрагментации — и поискать), GC со временем обязан эти объекты собрать, а кэш процессора — промахнуться по разбросанным данным. Две функции с одинаковым ns/op «здесь и сейчас» могут вести себя совершенно по-разному под нагрузкой: у первой аллокаций ноль, вторая каждую итерацию мусорит — и именно вторая утопит сервис под процентами GC. Отсюда правило бенчмаркинга: сравнивая варианты, смотрите на allocs/op РАНЬШЕ, чем на ns/op — столбец аллокаций почти всегда называет виновника быстрее, чем наносекунды.
Аллокации считают и без флага: метод b.ReportAllocs() внутри бенчмарки включает столбцы только для неё — удобно, когда флаг для всего пакета не нужен. А чтобы увидеть не только количество, но и ЛИЦА виновников, аллокации подключаются к профилированию памяти: флаг -memprofile, о котором через два раздела, сохранит по стекам, КТО именно аллокирует эти байты. ns/op отвечает «медленно ли», allocs/op — «кто виноват», flame graph — «где именно»: три вопроса, три инструмента, один go test.
И честная оговорка про кэш и warming-up: первый прогон бенчмарки греет кэши, подгружает страницы кода и словари, и потому всегда медленнее стабильных. Фреймворк знает это и неудачные короткие прогоны при подгоне отбрасывает, но при -count=5 первый повтор может выбиваться — не паникуйте и не выбрасывайте его руками без причины: сначала посмотрите на медиану.
ResetTimer, StopTimer, StartTimer: чистое окно замера#
Бенчмарка измеряет ВСЁ, что происходит между началом и концом цикла — и туда легко попадает мусор, который к операции отношения не имеет. Классическая ловушка: подготовка данных. parts := prepareParts(1000000) внутри бенчмарки до цикла кажется безобидным — но если prepareParts аллокирует и считает десятки миллисекунд, а сама операция стоит микросекунды, то ns/op будет измерять в основном подготовку... нет, стоп: подготовка ДО цикла в суммарное время не попадает — таймер стартует с началом замера. Попадает в замер она тогда, когда вы делаете её ВНУТРИ цикла, когда каждый кейс требует свежих данных, или когда таймер уже тикает из-за подгоночных прогонов. Точнее и опаснее всего второй случай: любое одноразовое дорогое действие после старта замера — подключение к базе, чтение файла, генерация входа — размазывается по всем итерациям и врёт вам в каждом ns/op.
b.ResetTimer() — лечение по умолчанию: обнуляет таймер (и счётчики аллокаций) в точке вызова. Подготовили данные — вызовите ResetTimer — крутите цикл: замер начнётся с чистого листа, подгоночные прогоны и подготовка не попадут в отчёт. Это стандартный хвост любой бенчмарки с setup-фазой, и его отсутствие — самая частая причина «почему у меня в отчёте 8000 ns/op, а операция-то копеечная».
Когда подготовка нужна КАЖДОЙ итерации (свежий буфер, чистое состояние), флагов «до цикла» недостаточно — и на сцену выходит пара b.StopTimer() / b.StartTimer(): первый замирает счёт времени, второй запускает снова. Подготовка внутри итерации оборачивается в StopTimer-подготовку-StartTimer, и в ns/op попадает только операция. Цена честности — сама пара вызовов: они не бесплатные, и при копеечных операциях их накладные расходы могут быть заметны. Поэтому иерархия такая: подготовка вне цикла и без таймеров — идеал; ResetTimer после одноразового setup — норма; StopTimer/StartTimer на каждую итерацию — приём с оговоркой, для дорогих операций. Отдельный бонус ResetTimer: он сбрасывает и счётчики аллокаций, так что B/op и allocs/op после него тоже чистые.
Листинг: ResetTimer в бою — подготовка вне измерения
GO// benchmark_setup_test.go — замер с подготовкой и без неё.
package concat
import "testing"
// prepareParts строит входные данные — ДОРОГО, но один раз на прогон.
func prepareParts(n int) []string {
parts := make([]string, 0, n)
for i := 0; i < n; i++ {
parts = append(parts, "кусочек")
}
return parts
}
// Вариант 1: подготовка до цикла — таймер ещё не тикает, всё честно само.
func BenchmarkConcatPrepared(b *testing.B) {
parts := prepareParts(1000) // setup вне измеряемого окна
b.ResetTimer() // страховка: часы и аллокации на нуль
for i := 0; i < b.N; i++ {
Concat(parts)
}
_ = Concat(parts)
}
// Вариант 2: подготовка НУЖНА каждой итерации — StopTimer/StartTimer.
func BenchmarkConcatFresh(b *testing.B) {
for i := 0; i < b.N; i++ {
b.StopTimer() // часы замерли
parts := prepareParts(10) // подготовка НЕ попадёт в замер
b.StartTimer() // часы пошли: измеряется только это
Concat(parts)
}
}Два паттерна на одном листинге: одноразовый setup закрывает ResetTimer (в вариант 1 он формально избыточен — подготовка до старта замера — но дисциплина «setup → ResetTimer → цикл» спасает, когда setup переедет или появится второй), а per-итерационный — парой StopTimer/StartTimer. Настоящий go test -bench по этому файлу покажет у BenchmarkConcatFresh ns/op заметно выше, чем у Prepared: готовить вход каждой итерации дорого даже с замороженными часами — сам вызов prepareParts не исчез, он просто не считается в замере. В песочнице листинг не исполняется: пакет testing движкам недоступен, запуск — локальным go test -bench=ConcatPrepared -benchmem.
Фальшивые часы: грязный замер и ResetTimer
Операция стоит один такт, подготовка — девятьсот; в замере десять итераций. Запустите: без сброса часы показывают 910 тактов — девяносто один такт на операцию, в девяносто один раз вранья. Сброс часов перед циклом — и отчёт честен: ровно 10 тактов. Поменяйте местами строки сброса и подготовки — вранье вернётся: ResetTimer обязан стоять ПОСЛЕ setup-работы и ДО измеряемого цикла.
Запустите код, чтобы увидеть вывод.
pprof профилирование: куда ушла секунда#
Бенчмарка отвечает «сколько стоит операция», но когда операция дороже ожиданий, начинается поиск виновника — и вот здесь начинается pprof профилирование. Профайлер — это инструмент, который в ходе работы программы периодически записывает, в какой функции она находится (CPU-профиль), и кто сколько памяти аллокирует (профиль памяти). Результат — не средние наносекунды, а РАЗЛОЖЕНИЕ времени и памяти по стекам вызовов: конкретные функции с конкретными долями. Философия простая: бенчмарк нашёл боль, профиль назвал её адрес.
Снять профиль с бенчмарок проще всего: go test -bench=Concat -cpuprofile=cpu.out соберёт бенчмарки как обычно и попутно запишет CPU-профиль в файл cpu.out; флаг -memprofile=mem.out тем же манером сохранит профиль памяти (аллокации по стекам). Файл называется профилем и хранит семплы; читать его будет утилита go tool pprof, о ней ниже. Тонкость, которую стоит знать: CPU-профиль собирается семплированием — примерно сто раз в секунду рантайм прерывает программу и записывает текущий стек; поэтому в профиле попадают только функции, которые реально выполнялись достаточно долго, а микроскопические вызовы тонут в шуме. Это не баг, а дизайн: профайлер показывает, где программа ЖИВЁТ, а не где она побывала мимоходом.
Если профилировать нужно не бенчмарку, а обычную программу или сервер, на сцену выходит пакет runtime/pprof из стандартной библиотеки: pprof.StartCPUProfile(f) включает запись семплов в файл f, defer pprof.StopCPUProfile() выключает по выходу; pprof.WriteHeapProfile(mf) снимает снимок кучи. HTTP-серверы получают то же самое в подарок: import _ "net/http/pprof" добавляет к default serve mux эндпоинт /debug/pprof/, с которого профиль живого сервера снимается по требованию — но это тема про HTTP, а здесь важно другое: источник профиля может быть любым, формат один, и go tool pprof читает все.
Осторожность одна: профиль без нагрузки — пустой профиль. CPU-профиль снимается только с того кода, который РЕАЛЬНО крутится во время записи: запустите бенчмарку под -cpuprofile — и семплы соберутся; снимите профиль с программы, которая спит в ожидании сети, — и top покажет рантайм-ожидания, а не вашу логику. Правило: сначала нагрузка (бенчмарка или реальный трафик), потом запись, потом чтение.
Листинг: runtime/pprof в обычной программе
GO// main.go — снимаем профили вручную через runtime/pprof.
package main
import (
"os"
"runtime/pprof"
)
// hot — «горячая» функция: её и увидим в top профиля.
func hot() {
total := 0
for i := 0; i < 100000000; i++ {
total += i % 7
}
_ = total
}
func main() {
// CPU-профиль: семплы стека пишутся в файл, пока профиль активен.
f, _ := os.Create("cpu.out")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile() // выключение при любом исходе — как defer
hot() // под профилем крутится только это
// Профиль памяти: снимок кучи — кто и сколько аллокировал по стекам.
mf, _ := os.Create("heap.out")
pprof.WriteHeapProfile(mf)
mf.Close()
}Для бенчмарок этот танец не нужен — go test -cpuprofile=cpu.out -bench=Concat делает то же самое автоматически. Листинг — для программ и утилит: запустили с профилем, отработали, прочитали cpu.out утилитой go tool pprof. Ошибки os.Create в листинге проглочены для краткости — в боевом коде их обязаны проверять (как в пятнадцатом уроке: ошибка — значение, а не заглушка). В песочнице не исполняется: пакеты os и runtime/pprof движкам недоступны, это листинг для локального запуска.
Чтение профилей: top, list и flame graph#
Файл профиля читает go tool pprof: go tool pprof cpu.out открывает интерактивную сессию, где три команды закрывают девяносто процентов потребностей. top — таблица функций-рекордсменов: top5 покажет пять функций, на которые ушло больше всего времени, столбцом flat — время непосредственно в теле функции, столбцом cum (cumulative) — время в ней и во всём, что она вызывала. Первая строка top с большим flat — это и есть виновник: в нашем кейсе строка вида 1.31s 72.78% 72.78% example.com/concat.Concat скажет всё — три четверти времени прогона сидит в Concat. Если flat маленький, а cum большой — время живёт в callee, спускайтесь по стеку.
list — второй уровень точности: list Concat печатает исходник функции построчно с процентом времени на каждую строку. Это уже не «функция дорогая», а «вот эта строка с конкатенацией — 70%, а вот эта проверка — 2%»: уровень детализации, после которого оптимизация перестаёт быть гаданием. Третья команда — flame graph: go tool pprof -http=:8080 cpu.out откроет браузерный интерфейс, где вкладка VIEW → Flame Graph нарисует стеки столбиками: ширина столбика пропорциональна доле времени, глубина — стеку вызовов. Читается базово за минуту: широкий плоский столбик наверху — дорогая функция, широкая башня — дорогая ветка вызовов; ищите самый широкий столбик, который ВЫ контролируете (не рантайм и не syscall), — это и есть точка приложения усилий.
flame graph любит два вопроса: «широкий столбик наверху» и «странный хребет посередине». Первый — прямая дорога к оптимизации; второй (узкая, но глубокая башня одинаковых вызовов) — часто признак рекурсии или бесконечных обёрток, и он тоже виден мгновенно. Не углубляйтесь дальше первого широкого столбика: профилирование — не коллекционирование, а поиск ДВЕРИ, за которой сидит половина времени; найдя её, закрывайте сессию и идите писать бенчмарк для проверки правки.
И держите в голове честную иерархию инструментов, чтобы не путаться: ns/op из go test -bench отвечает «дорого ли»; allocs/op из -benchmem отвечает «сколько мусора»; top и list из pprof отвечают «кто и в какой строке»; flame graph — «как через стек это пришло». Четыре ответа, четыре команды — весь бенчмаркинг стандартной библиотеки.
Ни один движок песочницы не запускает бенчмарки — это работа настоящего go test, собирающего пакет и подбирающего b.N. Резервный движок на import "testing" отвечает отказом: пакет "testing" недоступен в песочнице (доступны: fmt, strings, strconv, math, sort, errors, time); основной движок (yaegi) импорт testing допускает и даже компилирует Benchmark-функции с b.N, но тестовый прогон в браузере отсутствует в принципе. Пакеты runtime/pprof и os в фолбэке тоже не поддержаны — pprof-листинги только для локального запуска. Поэтому интерактивы урока честно гоняют симуляции: счётчик копирований вместо наносекунд, фальшивые часы вместо таймера testing, подгон n по удвоению вместо -benchtime. А настоящие листинги скопируйте в каталог с go.mod и запустите локально: go test -bench=. -benchmem -run='^$' — только так вы увидите живые ns/op на своём железе.
Кейс: строковая конкатенация против strings.Builder#
Всё из этого урока сходится в один классический кейс, который стоит пройти целиком: строковая конкатенация в цикле. Строки в Go неизменяемы — это фундаментальное свойство, а не реализация: строка — пара (указатель, длина) на непрозрачные байты, и «изменить» её нельзя, можно только построить новую. Поэтому каждый s += p делает честную работу: аллокирует НОВЫЙ байтовый блок нужной длины, копирует туда ВСЁ старое содержимое и добавляет кусочек. На пяти кусочках это незаметно; на тысячах — суммарно копируется квадратичное число байтов: 1 + 2 + 3 + ... + n, то есть n(n-1)/2.
Противопоставим ему strings.Builder — тип из стандартной библиотеки, созданный ровно для этой задачи (Go 1.10). Builder держит внутренний []byte, который растёт с запасом (удвоением ёмкости, как срезы из ранних уроков), метод WriteString дописывает в конец без копирования всего накопленного, а финальный String() отдаёт результат. Итог: одна цепочка растущего буфера вместо n новых строк — линейное число копируемых байтов вместо квадратичного, и в разы меньше походов к аллокатору.
Как это доказать, а не постулировать? Тремя шагами этого урока. Шаг первый: счётчик копирований — ручной раннер ниже посчитает суммарные байты, скопированные при +=, и вы увидите квадратичный рост своими глазами: удвоили вход — работа выросла вчетверо. Шаг второй: настоящий go test -bench=. -benchmem по листингам BenchmarkConcat и BenchmarkBuilder покажет столбцы вида 245.3 ns/op 10 B/op 4 allocs/op против 20.6 ns/op 8 B/op 1 allocs/op (числа с одного ноутбука; у вас абсолюты будут другими — разрыв сохранится): allocs/op упал вчетверо, ns/op — на порядок. Шаг третий: cpuprofile того же прогона — и top покажет, что в первой версии почти всё время сидело в runtime.memmove и mallocgc, то есть в копировании и аллокациях, а не в вашей «логике». Подозрение → измерение → адрес виновника → правка → повторный замер: полный цикл, который вы теперь умеете.
Мораль кейса шире строк. Когда бенчмарк называет виновником аллокации, лечение почти всегда одно из трёх: переиспользовать буфер (Builder, bytes.Buffer, срез с запасом через make с capacity), вытянуть работу за пределы горячего цикла, или сменить структуру данных. Пере-читайте этот абзац после каждого профиля, где top упрётся в mallocgc — это самый частый диагноз в реальных сервисах, и лечится он без экзотики.
Бенчмаркаем без testing: счётчик копирований
Вот ручной эквивалент того, что -benchmem покажет столбцом B/op: сколько байтов скопировал += за весь цикл. Запустите: десять итераций, 45 скопированных байтов — сумма 0+1+2+...+9. Теперь замените 10 на 20: байты вырастут до 190, а на 40 — до 780: вход удвоился, работа выросла в четыре раза. Это и есть квадратичное поведение, которое в настоящем бенчмарке выглядит как взлетающий ns/op на больших входах.
Запустите код, чтобы увидеть вывод.
Что напечатает счётчик при удвоениях входа?
Прочитайте код и выберите, что он выведет:
package main
import "fmt"
// copied считает суммарные байты-копии при += на n итерациях.
func copied(n int) int {
total := 0
s := ""
for i := 0; i < n; i++ {
total += len(s) // += копирует всю строку заново
s += "x"
}
return total
}
func main() {
fmt.Println("n=10:", copied(10))
fmt.Println("n=20:", copied(20))
fmt.Println("n=40:", copied(40))
}strings.Builder против +=: решающий бой
Резервный движок не знает strings.Builder — при отказе «у пакета strings нет члена Builder» переключитесь на основной движок: раннер нативный. Сравните два способа на одинаковом результате: у += счётчик копирований 45, у Builder ноль промежуточных копий — буфер растёт удвоением ёмкости, и каждый WriteString дописывает только новый кусочек. В настоящем бенчмарке это же покажет -benchmem: 4 allocs/op у += против 1 allocs/op у Builder.
Запустите код, чтобы увидеть вывод.
Столбцы -benchmem
Что означает столбец 4 allocs/op в отчёте go test -bench=. -benchmem?
Соберите отчёт бенчмарки
Восстановите форматирование строк: имя в поле шириной 20, целое в поле шириной 12, дробное с двумя знаками в поле шириной 10.
Соберите каркас ручного замера
Соберите программу: функция-«операция» со счётчиком копирований, вызов в main, проверка ожидания и ранний выход — скелет, вокруг которого в настоящем бенчмарке встал бы цикл по b.N.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Шпаргалка: флаги go test -bench и API testing.B#
| Инструмент | Что делает | Как выглядит | Осторожно |
|---|---|---|---|
| func BenchmarkXxx | бенчмарка: руль testing.B вместо testing.T | func BenchmarkConcat(b *testing.B) | файл *_test.go; имя с заглавной после Benchmark |
| b.N | число итераций, подобранное фреймворком | for i := 0; i < b.N; i++ { … } | не присваивайте его руками; Go 1.24+ — b.Loop() |
| go test -bench=. | запуск бенчмарок пакета (регулярка по имени) | go test -bench=Concat -run='^$' | -run='^$' отключает тесты, чтобы не мешали |
| ns/op | средняя стоимость одной итерации | BenchmarkConcat-8 245.3 ns/op | сравнивайте на одном железе; -8 — GOMAXPROCS |
| -benchmem | столбцы аллокаций в отчёте | go test -bench=. -benchmem | B/op и allocs/op — средние на итерацию |
| b.ReportAllocs | включить аллокации для одной бенчмарки | b.ReportAllocs() первой строкой | флаг -benchmem охватывает весь прогон |
| b.ResetTimer | обнулить таймер и счётчики аллокаций | setup(); b.ResetTimer(); цикл | ставить ПОСЛЕ подготовки и ДО цикла |
| b.StopTimer / StartTimer | заморозить и возобновить часы | b.StopTimer(); готовим; b.StartTimer() | не бесплатные: для копеечных операций шумят |
| -count / -benchtime | повторы и длительность подгона | go test -bench=. -count=5 -benchtime=2s | один прогон — анекдот; смотрите медиану |
| -cpuprofile / -memprofile | записать профиль CPU/памяти из бенчмарок | go test -bench=. -cpuprofile=cpu.out | профиль имеет смысл только под нагрузкой |
| go tool pprof | чтение профиля: top, list, -http | go tool pprof cpu.out → top5, list Concat | flat — в теле функции, cum — с вызываемыми |
| flame graph | стеки столбиками: ширина = доля времени | go tool pprof -http=:8080 cpu.out | ищите самый широкий столбик, который вы контролируете |
Правила гигиены бенчмаркинга#
- Подготовка — вне измеряемого окна: данные до цикла, ResetTimer после setup, StopTimer/StartTimer — только для per-итерационной подготовки дорогих операций.
- Один прогон — не вывод: гоняйте -count=5 и сравнивайте медиану; разброс между повторами — норма, а не сигнал переписывать код.
- Сравнивайте варианты на одном железе и в одном окружении: ns/op без паспорта goos/goarch/cpu — просто число; переносимое — только отношение вариант/вариант.
- Смотрите на allocs/op раньше, чем на ns/op: количество аллокаций называет виновника быстрее наносекунд и почти не зависит от шума машины.
- Не давайте компилятору выбросить работу: читайте результат измеряемой операции после цикла (_ = result) или переходите на b.Loop() в Go 1.24+.
- Никакой печати внутри измеряемого окна: fmt.Println в цикле стоит дороже всего остального и превращает отчёт в сказку.
- Профиль снимайте только под реальной нагрузкой: cpuprofile на спящей программе покажет рантайм-ожидания, а не вашу логику.
- Оптимизируйте самое широкое место профиля, а не то, что приятно переписывать: top5 и flame graph говорят, где сидит половина времени — начинайте оттуда.
Что дальше: от «быстро ли» к «как это устроено»#
Соберём урок в одну картину. Бенчмарка — функция func BenchmarkXxx(b testing.B) в файле _test.go: то же семейство, что тесты из шестнадцатого урока, только руль другой. Число итераций b.N подбирает фреймворк удвоениями до достаточной длительности, поэтому в отчёте оно меняется от запуска к запуску — и это честное поведение. Отчёт go test -bench читается по столбцам: подобранное N, ns/op — цена одной итерации; флаг -benchmem добавляет B/op и allocs/op, а ResetTimer после setup и пара StopTimer/StartTimer держат окно замера чистым. pprof профилирование раскладывает то же время по стекам: -cpuprofile и -memprofile при бенчмарках, runtime/pprof в обычных программах, чтение через top, list и flame graph. Кейс конкатенации прошёл весь путь: счётчик копирований показал квадратичность, -benchmem назвал виновником аллокации, strings.Builder свёл работу к линейной.
Тесты проверяют «правильно ли», бенчмарки — «быстро ли», а следующий вопрос звучит как «как программа видит саму себя в рантайме?». Восемнадцатый урок — рефлексия: reflect.TypeOf и reflect.ValueOf, как код осматривает чужие структуры во время выполнения, где это оправдано (энкодеры, драйверы баз, мапперы), и главное — цена, которую рефлексия берёт за гибкость. Цену вы теперь умеете измерять: напишите BenchmarkReflect и BenchmarkDirect рядом и сравните allocs/op — предсказываю, цифры вас неприятно удивят, и это лучший мотиватор выучить границы приёма.
До встречи на странице курса: а если понадобится освежить каркас проверок, вокруг которого встали сегодняшние бенчмарки, — вернитесь к уроку про тестирование и перечитайте раздел про table driven tests: бенчмарки с prepare-данными собираются по той же схеме «данные отдельно, каркас один». А при чтении чужих профилей пригодится привычка из урока про оборачивание ошибок: сначала личность причины (errors.Is к логу, top к стеку), потом лечение.
Карточки урока
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Бенчмарка — функция func BenchmarkXxx(b *testing.B) в файле *_test.go рядом с кодом: то же семейство, что TestXxx, но руль измерения — testing.B, а не testing.T.
- Число итераций b.N подбирает фреймворк: малый старт и удвоения до достаточной длительности (-benchtime, по умолчанию 1s); меняющееся N в отчёте — норма, присваивать b.N руками нельзя.
- Классическая форма — for i := 0; i < b.N; i++ с чтением результата после цикла; с Go 1.24 есть b.Loop(), но b.N-форма остаётся универсальной классикой.
- go test -bench=. -run='^$' — канонический запуск без тестов; отчёт читается по столбцам: имя-GOMAXPROCS, подобранное N, ns/op; паспорт goos/goarch/cpu обязателен для сравнений.
- -benchmem добавляет B/op и allocs/op — средние байты и число аллокаций на итерацию; аллокации — главный подозреваемый торможений, их смотрите раньше наносекунд.
- ResetTimer после setup обнуляет таймер и счётчики аллокаций; StopTimer/StartTimer замораживают часы вокруг per-итерационной подготовки — но сами не бесплатные.
- Один прогон — анекдот: -count=5 и медиана; печать внутри измеряемого окна портит отчёт сильнее любой оптимизации.
- pprof профилирование: -cpuprofile/-memprofile у бенчмарок, runtime/pprof в программах; профиль имеет смысл только под реальной нагрузкой.
- Чтение профиля: top5 (flat/cum), list Func — проценты по строкам, flame graph через -http=:8080 — ширина столбика равна доле времени; начинайте с самого широкого своего столбика.
- Кейс урока: += в цикле копирует n(n-1)/2 байтов (квадратично), strings.Builder растёт буфером удвоением (линейно); -benchmem показывает это как 4 allocs/op против 1 — разрыв, который повторяется на любом железе.
Домашнее задание
Часть первая — первые бенчмарки: создайте пакет с функцией Concat(parts []string) string на += и двумя Benchmark-функциями — BenchmarkConcat и BenchmarkBuilder (вариант на strings.Builder с WriteString). Запустите go test -bench=. -benchmem -run='^$' -count=5 и выпишите столбцы ns/op, B/op, allocs/op для обеих; посчитайте разрыв и объясните письменно, почему allocs/op отличается в четыре раза. Часть вторая — гигиена замера: добавьте в бенчмарку prepare-фазу с генерацией входных данных и проверьте на себе разницу: без b.ResetTimer() и с ним; затем заведите бенчмарку с per-итерационной подготовкой через b.StopTimer()/b.StartTimer() и сравните ns/op с чистым вариантом. Часть третья — профилирование: снимите go test -bench=Concat -cpuprofile=cpu.out, откройте go tool pprof cpu.out, выполните top5 и list Concat; найдите функцию с максимальным flat и объясните, почему это runtime.memmove или mallocgc, а не ваша строка кода. Часть четвёртая — счётчик: перенесите ручной счётчик копирований из урока на вход из 100 кусочков и убедитесь, что n(n-1)/2 сходится с 4950; затем замените += на срез с append и strings.Join и посчитайте копии снова. Комментарии в коде — по-русски.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем бенчмарка отличается от теста и как они уживаются в одном файле?
Оба живут в файлах *_test.go и запускаются go test, но тест — func TestXxx(t *testing.T) проверяет корректность и завершается вердиктом, а бенчмарка — func BenchmarkXxx(b *testing.B) измеряет скорость: фреймворк подбирает число итераций b.N и печатает ns/op. Обычный go test бенчмарки не запускает (нужен флаг -bench), а go test -bench всё равно выполняет тесты — их отключают фильтром -run='^$'. Рядом они уживаются естественно: один файл описывает и поведение, и стоимость.
Почему ns/op меняется от запуска к запуску и как сравнивать честно?
Разброс дают планировщик, кэши процессора, соседние процессы и сама подгонка b.N — число итераций каждый раз новое. Честное сравнение: одно железо, один паспорт окружения (goos/goarch/cpu в шапке отчёта), несколько повторов (-count=5) и сравнение медиан или утилитой benchstat, которая скажет, значимо ли отличие. Абсолютные числа переносятся между машинами плохо — переносимы отношения вариант/вариант на одном прогоне.
Зачем b.ResetTimer, если подготовка стоит до цикла?
Подготовка до цикла действительно не попадает в замер — таймер стартует с началом измеряемой фазы. Но ResetTimer нужен как дисциплина и страховка: при подгоночных прогонах и при любом переносе setup-кода (внутрь функции, после первого замера, между вариантами) дорогое одноразовое действие размазывается по итерациям и завышает ns/op. Вызов обнуляет и таймер, и счётчики аллокаций, поэтому B/op и allocs/op тоже остаются чистыми.
Что выбрать: классический цикл по b.N или новый b.Loop()?
Классическая форма for i := 0; i < b.N; i++ работает во всех версиях Go, читается в любом старом коде и документации — с неё и начинают. b.Loop() появился в Go 1.24: сам сбрасывает таймер перед первым вызовом, сам подбирает итерации и мешает компилятору выбросить неиспользуемый результат, поэтому новый код на современных версиях может писать for b.Loop() { ... }. Семантика замера одинакова — это один и тот же контракт, просто без ручного счётчика.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Fiber: экспресс-подобный веб-фреймворк на Fasthttp
Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.
- Поднимать HTTP-сервер на Fiber за минуты
- Проектировать маршруты, группы и версии API
- Писать middleware: логи, CORS, лимиты, recovery
- Принимать и отдавать JSON с валидацией