1. Введение в pprof
Если бенчмарк — это секундомер, то pprof — это очень въедливый бухгалтер: он не просто говорит «операция заняла 120 мс», а пытается показать, в каких функциях эти миллисекунды «сгорели». В Go pprof живёт как инструмент go tool pprof, который интерпретирует и показывает профили программ. Базовый сценарий звучит просто: у вас есть бинарник и файл профиля, и вы их скармливаете pprof.
Важно правильно настроиться психологически: pprof не обещает «сделать быстрее», он обещает «показать, где болит». А лечить будете вы: убрать лишние аллокации, переделать горячий цикл, использовать более подходящую структуру данных. (Да, иногда “лечится” удалением одного fmt.Sprintf в цикле — это как найти камень в ботинке после двухчасовой прогулки.)
2. Снятие профилей через go test
Самый приятный момент: чтобы начать, не нужно внедрять профилирование в main и писать служебный код. Для тестов и бенчмарков поддержка уже встроена в go test. Существуют флаги профилирования, которые пишут профили, пригодные для анализа через go tool pprof.
Минимальная «справочная» команда выглядит так: вы запускаете бенчмарки и параллельно просите сохранить CPU- и memory-профили в файлы. Пример из документации runtime/pprof:
go test -bench . -cpuprofile cpu.prof -memprofile mem.prof
Здесь важно понимать два тонких (но очень практичных) момента.
Первый: профили пишутся на том прогоне, который реально выполнялся. Если бенчмарк слишком короткий, профиль может быть «шумным» и почти бесполезным.
Второй: go test умеет переписывать флаги и управлять запуском тестового бинарника, а ещё флаги профилирования (кроме coverage) обычно оставляют тестовый бинарник рядом, чтобы его можно было использовать при анализе профиля. Это полезно, потому что pprof часто хочет видеть и профиль, и бинарник, чтобы показывать функции и строки кода.
CPU-профиль и -cpuprofile
CPU-профиль отвечает на вопрос «куда ушло процессорное время». В Go CPU profiling устроен как sampling: во время работы программа примерно 100 раз в секунду «останавливается на микромомент» и записывает стек текущей выполняющейся goroutine (считайте: “фотографирует, где мы находимся”). Поэтому CPU-профиль — статистический: он не гарантирует идеальной точности до последней наносекунды, но отлично показывает горячие места.
Чтобы было на что смотреть, привяжем это к нашему учебному приложению. Допустим, у нас есть список задач, и мы хотим красиво отрендерить их в табличку для CLI (мы уже делали табличный вывод раньше, поэтому сейчас возьмём упрощённый формат строк). Сделаем сначала намеренно «не очень умный» рендеринг — через fmt.Sprintf в цикле. Да, это как забивать гвозди микроскопом: формально можно, но потом не жалуйтесь на затраты.
package taskfmt
import "fmt"
type Task struct {
ID int
Title string
Done bool
}
func FormatLineSprintf(t Task) string {
return fmt.Sprintf("%d\t%v\t%s\n", t.ID, t.Done, t.Title)
}
Пока выглядит невинно. Но если таких строк тысячи и это в «горячем» месте, fmt.Sprintf может стать довольно дорогим удовольствием (и по CPU, и по памяти). Мы это не “угадываем по ауре”, мы это профилируем.
Memory-профиль и -memprofile
Memory-профиль (в нашем сегодняшнем минимуме — heap-профиль) отвечает на вопрос «где мы выделяем память и/или где она удерживается». Это особенно важно в Go, потому что лишние аллокации — это не только «память заняли», но ещё и потенциальная дополнительная работа для GC. А GC, как вы уже подозреваете, бесплатным не бывает (он просто старается быть “достаточно дешёвым”).
go test умеет писать memory profile в файл через -memprofile, а ещё есть подсказка, что pprof имеет режимы представления аллокаций (например, по количеству объектов или по объёму) — это уже на стороне pprof как инструмента отображения.
Чтобы у нас был пример, добавим ещё одну функцию: соберём весь вывод по задачам в одну большую строку (для CLI это нормально — выводим в stdout одним куском). Сначала сделаем «в лоб» — конкатенацией строк в цикле. Это классика жанра: работает, но в больших объёмах может создавать много временных строк.
package taskfmt
func FormatAllConcat(tasks []Task) string {
out := ""
for _, t := range tasks {
out += FormatLineSprintf(t)
}
return out
}
Если задач много, out += ... часто означает: «создать новую строку, скопировать старую, дописать новую часть». Иногда компилятор помогает, но в общем случае это кандидат на лишние аллокации. И вот тут memory-профиль часто особенно нагляден: он показывает, кто именно стал фабрикой мусора.
Бенчмарк для воспроизводимой нагрузки
Нам нужен воспроизводимый сценарий нагрузки. Идеально подходит бенчмарк: он управляемый, повторяемый и не требует «нажимать кнопки в ручном режиме». В *_test.go добавим BenchmarkFormatAllConcat. Здесь же применим трюк с “sink-переменной”: результат нужно куда-то записать, чтобы компилятор не решил, что вычисление «никому не нужно» и его можно выбросить.
package taskfmt
import (
"strconv"
"testing"
)
var sink string
func BenchmarkFormatAllConcat(b *testing.B) {
tasks := make([]Task, 1000)
for i := range tasks {
tasks[i] = Task{ID: i + 1, Title: "task " + strconv.Itoa(i+1)}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
sink = FormatAllConcat(tasks)
}
}
Теперь у нас есть «площадка», на которой и CPU, и память проявят себя достаточно ярко. А дальше начинается самое вкусное: снимаем профили и читаем их.
3. Анализ профилей в go tool pprof
Базовое использование pprof формулируется очень просто: go tool pprof binary profile. То есть обычно вы даёте инструменту бинарник и файл профиля.
Когда мы профилируем тесты/бенчмарки, процесс всё равно делится на два уровня.
На первом уровне мы просим go test записать профиль, например так (как справка по форме команды):
go test -bench . -cpuprofile cpu.prof -memprofile mem.prof
На втором уровне мы открываем профиль в pprof. Очень распространённый сценарий — открыть CPU-профиль вместе с тестовым бинарником (который go test может оставить рядом), чтобы pprof показывал названия функций и позволял переходить к строкам кода. Общая идея остаётся той же: go tool pprof <binary> <profile>.
Когда pprof стартует, он попадает в интерактивный режим: вы увидите приглашение (pprof) и дальше вводите команды. Самые первые команды, которые реально дают пользу почти всегда: top, top -cum, и иногда list <func>.
Как читать top и top -cum: hotspots, flat и cum
Слово “hotspot” здесь означает простую вещь: место, которое даёт существенный вклад в стоимость. В CPU-профиле это место, где «горит время CPU». В memory-профиле это место, где «горит память» (выделяется много или удерживается много).
Команда topN (или просто top, в зависимости от режима) показывает функции с наибольшим вкладом. В классической статье про профилирование Go показано, что top сортируется по «само-времени» функции, а для сортировки по накопленному времени есть режим -cum (cumulative).
Чтобы не читать вывод как древние руны, давайте разложим смысл колонок в маленькую таблицу (названия могут слегка отличаться, но смысл обычно одинаковый):
| Колонка | Как читать | Интуитивный смысл |
|---|---|---|
|
«сколько времени или памяти прямо в этой функции» | функция сама “жрёт” ресурс |
|
«сколько времени или памяти в этой функции и всех вызванных ею» | функция — “вход в мясорубку” |
|
доля от общего | насколько это важно в общей картине |
Теперь — практическая логика чтения. Если в top у вас наверху runtime.* или fmt.*, это не значит, что «надо оптимизировать runtime». Это значит: ваш код заставил рантайм или fmt делать много работы. Часто правильный следующий шаг — найти вашу функцию, которая зовёт fmt.Sprintf в цикле, и уже её переписать.
Мини-сессия pprof: top, top -cum, list
Интерактивность pprof сначала выглядит как «ещё одна консоль», но на самом деле это очень удобная штука: вы можете быстро “приближаться” к проблеме. Например, логика может быть такой (команды — как иллюстрация сценария, не как “задание”):
go tool pprof <binary> cpu.prof
(pprof) top
(pprof) top -cum
(pprof) list FormatAllConcat
Почему это работает. top показывает кандидатов на hotspots. top -cum помогает найти “родительские” функции, через которые проходит много времени. А list показывает аннотированный исходник: какие строки внутри функции чаще попадали в сэмплы. Ровно этот подход “topN и затем -cum” описан в официальной статье: topN показывает верх по сэмплам, а -cum пересортировывает по накопленному времени.
И да, у pprof команд и опций больше, чем у средней видеоигры, но в нашем базовом наборе это уже даёт 80% пользы.
4. Улучшения по профилю и правильная интерпретация
Оптимизация ради оптимизации — плохая привычка (как пить энергетик, чтобы успеть поспать). Но оптимизация по профилю — нормальная инженерия: мы меняем код там, где реально горячо.
В нашем примере подозреваемые уже очевидны: конкатенация строк в цикле и fmt.Sprintf. Самый типичный фикс для «собрать большую строку из маленьких» — strings.Builder. Мы этот инструмент уже знаем по теме строк и пакета strings, так что сейчас просто применяем его по делу.
package taskfmt
import "strings"
func FormatAllBuilder(tasks []Task) string {
var b strings.Builder
for _, t := range tasks {
b.WriteString(FormatLineSprintf(t))
}
return b.String()
}
Это уже может снизить число промежуточных строк. Но мы всё ещё внутри цикла делаем fmt.Sprintf. Если профили покажут, что значительная доля уходит в fmt, тогда следующий шаг — отказаться от Sprintf в горячем месте и писать в builder более прямолинейно (например, через strconv.AppendInt в []byte), но это уже тоньше и не всегда нужно в учебном проекте сразу. Важно другое: pprof помогает принять решение не на уровне «мне кажется», а на уровне «вот где вклад».
CPU vs memory: как не перепутать смысл профилей
Когда вы впервые видите два профиля, есть соблазн думать, что они “про одно и то же”. На практике они отвечают на разные вопросы.
CPU профиль показывает, где программа реально выполнялась (по сэмплам выполнения).
Memory профиль помогает увидеть, где происходят аллокации или использование heap (а значит, где может расти давление на GC).
Очень жизненный сценарий: CPU hotspot и memory hotspot могут оказаться разными местами. Например, CPU уходит в парсинг и сравнение строк, а память улетает в логирование или форматирование. Или наоборот: CPU вроде «норм», но память выделяется безумно много, GC начинает чаще работать, и в итоге CPU тоже начинает гореть — но уже вторично.
Поэтому правильная привычка такая: если вас волнует «медленно» — начните с CPU. Если вас волнует «жрёт память» или «GC шумит» — смотрите memory. Если волнует всё сразу — поздравляю, вы почти в продакшене.
Почему важно, чтобы go tool pprof соответствовал версии Go
На сладкое — важная ремарка про совместимость. В исходниках Go прямо отмечается, что “гарантированно работает то, что поставляется вместе с конкретным релизом Go”: то есть go tool pprof из поставки Go тестируется на совместимость с профилями программ той же версии.
Это не означает, что «нельзя иначе». Это означает, что если вы начнёте устанавливать случайный pprof из интернета (или тянуть напрямую github.com/google/pprof) и получите странные проблемы, то это будет не «Go сломался», а «вы ушли с рекомендованной дорожки». Для учебного проекта держаться go tool pprof — самый спокойный путь.
5. Типичные ошибки при работе с pprof
Ошибка №1: снимать профиль на слишком коротком прогоне и удивляться шуму.
CPU-профиль в Go — это sampling. Если ваш бенчмарк отработал «почти мгновенно», статистика будет бедной, а top может показывать случайные всплески. В таких случаях обычно помогает сделать нагрузку более длительной (например, чтобы бенчмарк выполнялся заметное время), и только потом сравнивать профили.
Ошибка №2: мерить не то, что вы думаете, потому что setup попал в профиль.
Если вы внутри измеряемого цикла создаёте тестовые данные, читаете файлы, печатаете лог или делаете rand.New(...), то профиль честно покажет именно это. А потом начинается классика: «pprof говорит, что у меня горячий strconv.Itoa, но я же оптимизировал сортировку!» Поэтому подготовку данных отделяйте от измеряемой части так же строго, как мы уже делали это в бенчмарках.
Ошибка №3: открыть профиль без подходящего бинарника и потерять контекст.
pprof особенно полезен, когда видит бинарник: тогда он может соотнести сэмплы с функциями и строками кода. Бонус в том, что go test для профилей (кроме покрытия) обычно оставляет тестовый бинарник, чтобы вы могли анализировать профили вместе с ним. Если же бинарник не совпадает (другой build, другая версия, другой код), вывод может стать менее понятным.
Ошибка №4: зациклиться на runtime.* в top и пытаться “оптимизировать рантайм”.
Когда наверху списка runtime.mallocgc или что-то из fmt, это почти всегда симптом вашего кода: много аллокаций, много форматирования, слишком частые конверсии, неудачные структуры данных. Лечится обычно не патчем рантайма, а снижением давления на него: меньше временных объектов, проще цикл, аккуратнее работа со строками.
Ошибка №5: смотреть только top (flat) и пропустить “вход в проблему”.
Иногда функция сама почти не делает работы, но вызывает цепочку дорогих операций. В top по flat она может быть низко, зато в top -cum — высоко. Режим -cum как раз и нужен, чтобы увидеть «через кого проходит основная стоимость». Этот подход (и смысл cumulative-сортировки) показан в официальной статье про профилирование Go.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ