Сервис на C++ после безобидного релиза начинает отвечать на самые тяжёлые запросы втрое дольше, дежурный подозревает сеть, автор изменений клянётся, что трогал только конфигурацию. Через час спор заканчивается числом: семьдесят процентов процессорного времени уходит на копирование строк в новой функции логирования. Вопросы производительности решаются не аргументами, а измерениями, и ниже два инструмента, которые делают измерение рутиной: perf для живого кода под реальной нагрузкой и Valgrind для точного разбора ошибок памяти на стенде. Разбор идёт на примере сервиса обработки запросов, от первых чисел до исправления и повторного замера.
Почему догадки о медленном месте почти всегда ошибаются
Интуиция о "тормозящем" коде подводит по трём причинам сразу. Первая: стоимость абстракций невидима в исходнике, копирование строки, скрытое в возврате значения, стоит дороже видимого цикла. Вторая: процессор живёт не инструкциями, а ожиданиями, код может исполняться быстро и всё равно простаивать на промахах кэша. Третья: узкое место гуляет, сегодня это разбор, завтра выделение памяти, послезавтра системный вызов.
Начинать надо не с догадок, а с perf stat. Утилита perf входит в поставку ядра Linux, ставится пакетом linux-tools с номером, совпадающим с версией ядра, и первым шагом показывает базовые числа:
perf stat -e task-clock,cycles,instructions,cache-misses,branch-misses ./search_server
В выводе видны такты, инструкции, промахи кэша и промахи ветвлений. Отношение инструкций к тактам, IPC, сразу разделяет два мира: значение ниже единицы означает, что процессор ждёт память, и искать надо структуру обращений, а не сложные вычисления; значение выше двух-трёх означает вычислительный предел, и лечится он сменой алгоритма. В нашем примере спор о "тяжёлом парсере" закончился уже на этом шаге: IPC 0.4, процессор почти не считал, он стоял.
Важная деталь режима: профиль холостого прогона показывает не тот код, что живёт в бою. Без нагрузки горячим оказывается чтение конфигурации или инициализация кэшей, и часы оптимизации потрачены впустую. Поэтому первое правило стенда простое: нагрузка должна быть максимально похожа на боевую, тот же объём входных запросов и та же их интенсивность.
perf record и perf report как основной цикл охоты за горячим кодом
Второй шаг это семплирование стека вызовов:
perf record -F 99 -g -- ./search_server --replay requests.bin
perf report --no-children
perf annotate parse_header
Частота 99 герц выбрана не случайно: не сто, чтобы семплы не попали в такт системных таймеров и не снимали каждый раз один и тот же стек. На такой частоте накладные расходы незаметны на фоне нагрузки, поэтому метод годится и для боевого узла. Флаг -g включает запись стеков, и здесь есть засада: без указателей фрейма стеки выходят рваными, поэтому код собирают с -fno-omit-frame-pointer. Дистрибутивы Fedora и Ubuntu вернули сохранение указателей фрейма в сборках по умолчанию именно ради нормального профилирования.
perf report показывает интерактивное дерево, где каждая строка это функция с долей семплов. Ключ --no-children убирает агрегацию, в которой функция считается дорогой только потому, что дорогие её вызовы. perf annotate спускается до строк исходника и отдельных инструкций.
Для полной картины строится флеймграф, картинка, где ширина блока равна доле времени:
perf script | stackcollapse-perf.pl | flamegraph.pl > profile.svg
В нашем примере флеймграф был безжалостен: сорок процентов ширины съел блок operator new, вызванный из vector::push_back в цикле накопления результатов. Одна строка с reserve до цикла превратила 120 миллисекунд в 45. Ни один участник спора не указал на это место до измерения, все называли парсер и сеть.
Когда воспроизвести проблему на стенде не выходит, perf подключается к живому процессу: perf record -p с номером процесса пишет профиль прямо на боевом узле, а perf top показывает живой верх функций без записи файла. Это крайний случай, дежурный снимает профиль в момент деградации и в спокойные минуты, потом сравнивает две картины.
Чтение счётчиков perf stat и толкование чисел процессора
Счётчики производительности считываются с аппаратного модуля процессора, PMU, поэтому числа честные для конкретного железа, а не модельные. Кроме IPC полезны ещё три семейства.
Промахи кэша последнего уровня показывают, как укладывается рабочий набор в память. Хаотичный доступ к массиву размером в гигабайт даёт миллионы промахов, замена хэш-таблицы компактным вектором ключей снижает их на порядок. Промахи ветвлений выдают код, с которым предсказатель не справляется, классический пример это switch по типу пакета с редкими значениями. Команда perf c2c ищет ложное разделение кэш-линий между ядрами, когда два потока пишут в соседние поля одной структуры и перегоняют линию между процессорами.
Отдельный жанр это системные вызовы. perf trace или счётчики переключений в perf stat показывают, где программа болтается между ядром и пользователем. Буря вызовов futex в трассе означает борьбу потоков за мьютексы, и лечится такое не оптимизацией кода, а сменой блокировки или дроблением структуры.
После правки картина сверяется числами: perf diff накладывает два файла профилей и показывает, что именно изменилось. Хороший тон это снять профиль до и после с одной и той же нагрузкой и тем же числом семплов, иначе разница объясняется шумом, а не починкой.
Valgrind memcheck для поиска утечек и ошибок работы с памятью
Второй инструмент работает иначе: Valgrind запускает программу на синтетическом процессоре, инструментируя каждую инструкцию, включая код всех подключённых библиотек. Цена известна заранее: memcheck, проверяющий каждое обращение к памяти и каждое вычисляемое значение, замедляет программу в 10-50 раз, и даже пустой инструмент без проверок даёт четырёхкратное замедление. Поэтому Valgrind живёт на стенде, а не в бою. Свежий релиз 3.27.1 поддерживает x86, ARM, POWER и RISC-V под Linux, а также FreeBSD, Solaris и macOS вплоть до версии 13.
Запуск типового разбора утечек выглядит так:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./search_server
Итоговый отчёт делит потерянную память на четыре категории. Definitely lost означает, что указатель на блок потерян, освободить нечем, это утечка в чистом виде. Indirectly lost потерян через недоступный родительский блок. Possibly lost означает, что указатель сдвинут внутрь блока, обычно это кривая арифметика указателей. Still reachable означает, что память держится до самого выхода, формально не утечка, но растущий кэш без вытеснения выглядит именно так и убивает сервис так же надёжно.
Помимо утечек memcheck ловит использование неинициализированных значений, чтение и запись за границами кучи, двойное освобождение и несоответствие new с free. Флаг --track-origins добавляет к отчёту о неинициализированном значении место, где оно родилось, без него отчёт показывает лишь место использования. Ошибки в чужих библиотеках давятся файлом подавлений, который удобно генерировать опцией --gen-suppressions=yes.
На большом сервисе отчёт с полной проверкой утечек печатает сотни строк, и пугаться не стоит: опция --num-callers расширяет стеки до нужной глубины, а разбор начинают с категории definitely lost и самой тяжёлой записи, остальное часто следствия одной корневой ошибки. Для категории possibly lost от контейнеров стандартной библиотеки есть эвристики --leak-check-heuristics, отсеивающие ложные срабатывания от внутренних указателей строк и массивов.
callgrind и massif как точный профиль вызовов и роста кучи
У Valgrind есть и профилировщики. callgrind считает каждую инструкцию и каждое обращение к памяти, моделируя кэш и предсказатель ветвлений:
valgrind --tool=callgrind ./search_server
callgrind_annotate --auto=yes callgrind.out.12345
В отличие от семплирования точность здесь абсолютная: инструмент не угадывает по выборке, а складывает полную стоимость каждого ребра графа вызовов. Результат просматривается в текстовом виде или в графической среде kcachegrind, где граф вызовов виден целиком. Плата соответствующая, замедление в десятки раз, поэтому callgrind включают для коротких и точных прогонов.
Второй профилировщик, massif, делает снимки кучи по мере роста:
valgrind --tool=massif --stacks=yes ./search_server
ms_print massif.out.12345
По умолчанию massif учитывает только кучу, стек включается флагом --stacks=yes и сильно замедляет прогон. График роста в отчёте показывает, какая цепочка аллокаций держит максимум памяти. В нашем примере сервис выедал восемь гигабайт за неделю, massif нашёл кэш десериализованных запросов без вытеснения: ступеньки на графике совпадали с волнами соединений, и перед глазами инженера причина стала очевидной за минуту.
Есть и третий профиль кучи, DHAT: он смотрит не на объём, а на жизнь каждого блока, кто его трогал и как скоро после выделения он освобождался. Для охоты на короткоживущие аллокации, из-за которых шумит аллокатор и остывает кэш процессора, он полезнее massif.
Типичные ошибки измерения, которые обнуляют работу профилировщика
Даже с правильными инструментами измерение легко испортить, и чаще всего виноват не инструмент, а условия опыта. Самая частая ошибка это измерение не того кода: сборка с -O0 для отладки показывает совсем другое распределение времени, чем релизная, инлайнер выключен, циклы не развёрнуты, и оптимизировать надо именно релиз. Вторая ошибка, короткое окно: тридцать секунд семплирования на частоте 99 герц дают всего около трёх тысяч семплов, для редких веток исполнения этого мало, окно надо расширять до минут. Третья, шумный фон: если на стенде параллельно крутится сборка или браузер, цифры принадлежат не сервису. Четвёртая, потерянные отладочные символы: без флага -g при сборке отчёт показывает адреса вместо имён функций. Пятая, вера в мелочь: правка функции с долей 1.5 процента не видна на графиках, лечить надо блоки с верхушки списка, а не те, что подсветились в любимом редакторе.
Отдельная ловушка это момент запуска. Профиль, снятый в первые секунды после старта, показывает разогрев кэшей и аллокаторов, а не устоявшийся режим. Сервису дают выйти на рабочий режим, и только потом начинают запись.
Разделение труда между боевым узлом и стендом при профилировании
Правило простое: perf работает на живом узле, Valgrind на стенде. У семплирования накладные расходы незаметны на фоне нагрузки, у memcheck замедление в десятки раз, смешивать эти режимы нельзя. Иногда ядро ограничивает сбор семплов для обычного пользователя, параметр kernel.perf_event_paranoid стоит в двойке, и для снятия стеков с вызовами ядра его правят до единицы или нуля.
Рабочий маршрут диагностики медленного сервиса выглядит так:
- воспроизвести проблему на стенде под нагрузкой и зафиксировать базовые метрики времени ответа, чтобы сравнивать изменения числами;
- снять perf stat и посмотреть IPC, промахи кэша и ветвлений, отделив ожидания памяти от вычислительного предела;
- записать профиль командой perf record с частотой 99 герц и стеками вызовов, открыть отчёт и флеймграф, найти верхний блок по времени;
- при подозрении на память снять профиль кучи massif и найти цепочку аллокаций, на которой растёт расход;
- для точного разбора одного модуля прогнать его сценарий под callgrind и построить граф вызовов;
- внести одну правку, повторить замер на той же нагрузке и сравнить числа до и после, а не ощущения.
Каждый шаг здесь дешёвый, час на весь маршрут, и он окупается первым же найденным блоком. Измерять надо до правки и после неё, правка без повторного замера не лечение, а самообман.
Догадка о причине медленной работы бесплатна и почти всегда указывает на чужой код. Профиль стоит пять минут и почти всегда указывает на ваш. В этом и есть секрет метода: числа не спорят, не обижаются и не уходят в отпуск, они просто лежат в отчёте и ждут, пока их прочитают.