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

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 17 из 25 ~22 мин +50 XP

Бенчмарки и профилирование

«Бенчмарки go» — запрос, который приходит сразу после первого замедления: программа работает, тесты зелёные, но где-то внутри прячется секунда, которую никто не заказывал. В шестнадцатом уроке мы научились проверять, что код делает правильные вещи; теперь научимся измерять, как быстро он это делает. Пакет testing хранит второй инструментарий рядом с первым: бенчмарка — та же функция в том же файле *_test.go, только сигнатура func BenchmarkXxx(b *testing.B) и внутри цикл по b.N, число итераций которого подбирает сам фреймворк. Команда go test -bench напечатает наносекунды на операцию, флаг -benchmem добавит байты и аллокации, а pprof покажет flame graph, внутри которого подозрения превращаются в факты. Разберём всё по столбцам: как testing нащупывает b.N, зачем нужен ResetTimer, как читать ns/op, B/op и allocs/op, и как один кейс со строковой конкатенацией ускоряется в десять раз на глазах. Бенчмарки здесь — честные листинги для настоящего go test: движки песочницы тестовый прогон не заменяют, зато ручные симуляции замеров гоняются прямо на странице.

Содержание урока · 14 разделов

Бенчмарки в 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 — «как через стек это пришло». Четыре ответа, четыре команды — весь бенчмаркинг стандартной библиотеки.

Кейс: строковая конкатенация против 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.

package main import "fmt" func main() { // Отчёт в духе go test -bench: столбцы выравнивает Printf. name := "BenchmarkConcat" n := 1000000 nsPerOp := 245.5 fmt.Printf("%s\n", name) // имя в поле шириной 20 fmt.Printf("%d итераций\n", n) // целое в поле шириной 12 fmt.Printf("%f ns/op\n", nsPerOp) // дробное: 2 знака, поле 10 fmt.Println("4 allocs/op, 10 B/op") }

Соберите каркас ручного замера

Соберите программу: функция-«операция» со счётчиком копирований, вызов в main, проверка ожидания и ранний выход — скелет, вокруг которого в настоящем бенчмарке встал бы цикл по b.N.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Шпаргалка: флаги go test -bench и API testing.B#

ИнструментЧто делаетКак выглядитОсторожно
func BenchmarkXxxбенчмарка: руль testing.B вместо testing.Tfunc 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=. -benchmemB/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, -httpgo tool pprof cpu.out → top5, list Concatflat — в теле функции, 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 к стеку), потом лечение.

Карточки урока

Термин

func BenchmarkXxx(b *testing.B)

нажмите, чтобы перевернуть
Определение

Бенчмарка — функция в файле *_test.go с префиксом Benchmark и заглавным продолжением; единственный параметр *testing.B, возврата нет. Запускается go test -bench=<регулярка>; классическая форма тела — цикл for i := 0; i < b.N; i++. Подготовка данных — до цикла, результат после него стоит прочитать (_ = ...), чтобы компилятор не выбросил работу.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +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 и посчитайте копии снова. Комментарии в коде — по-русски.

Личные заметкиПусто
Хранятся только в вашем браузере (Go Pro · Бенчмарки и профилирование)

Вопросы и ответы по уроку

Чем бенчмарка отличается от теста и как они уживаются в одном файле?

Оба живут в файлах *_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() { ... }. Семантика замера одинакова — это один и тот же контракт, просто без ручного счётчика.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Fiber25 уроков~13 чпрактика25 уроков уже доступны

Fiber: экспресс-подобный веб-фреймворк на Fasthttp

Курс Fiber для Go: маршруты, middleware, JSON, CORS, WebSocket, шаблоны, лимитеры и тестирование. 25 уроков с примерами кода и итоговым REST API.

  • Поднимать HTTP-сервер на Fiber за минуты
  • Проектировать маршруты, группы и версии API
  • Писать middleware: логи, CORS, лимиты, recovery
  • Принимать и отдавать JSON с валидацией