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 влаштований як семплювання: під час роботи програма приблизно 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-профілі це місце, де «горить» процесорний час. У 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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ