Введение: почему профилирование — это не роскошь, а необходимость
Каждый разработчик C++ сталкивался с ситуацией, когда программа работает, но делает это медленно. Интуитивные догадки о том, какой участок кода «тормозит», часто оказываются ошибочными. Без точных данных вы рискуете оптимизировать то, что и так работает быстро, упуская из виду настоящие «бутылочные горлышки». Профилирование — это единственный научный способ измерить производительность и найти узкие места.
В этой статье мы разберем три мощных инструмента для профилирования C++ кода: gprof (статистический), Perf (аппаратный) и Valgrind/Callgrind (симуляция). Вы узнаете, как их применять на практике, интерпретировать результаты и быстро находить проблемные участки.
1. Статистическое профилирование с gprof: быстрый старт
gprof — это классический профилировщик GNU, который работает на основе статистической выборки. Он не требует специальных прав и идеально подходит для первого анализа.
Как подготовить код
Для работы gprof нужно скомпилировать программу с флагами -pg и -g:
g++ -pg -g main.cpp -o my_program
./my_program
После выполнения программы в текущей папке появится файл gmon.out. Чтобы получить читаемый отчет, выполните:
gprof my_program gmon.out > report.txt
Пример: находим медленный алгоритм
Рассмотрим простую программу, которая сортирует массив двумя способами:
#include <iostream>
#include <vector>
#include <algorithm>
#include <chrono>
void bubble_sort(std::vector<int>& arr) {
for (size_t i = 0; i < arr.size(); ++i)
for (size_t j = 0; j < arr.size() - i - 1; ++j)
if (arr[j] > arr[j+1])
std::swap(arr[j], arr[j+1]);
}
void quick_sort_wrapper(std::vector<int>& arr) {
std::sort(arr.begin(), arr.end());
}
int main() {
std::vector<int> data(50000);
std::generate(data.begin(), data.end(), std::rand);
auto copy1 = data;
auto copy2 = data;
bubble_sort(copy1);
quick_sort_wrapper(copy2);
return 0;
}
Запустите профилирование. В отчете gprof вы увидите таблицу вызовов. bubble_sort будет занимать 95-99% времени. Это очевидное узкое место — замена пузырьковой сортировки на std::sort даст мгновенный прирост.
Интерпретация результатов
В отчете ищите колонку % time — она показывает долю времени, проведенную в функции. Функции с высоким процентом — ваши кандидаты на оптимизацию.
2. Аппаратное профилирование с Perf: точность до такта
Perf — это современный инструмент для Linux, использующий аппаратные счетчики CPU. Он дает минимальные накладные расходы и позволяет замерять не только время, но и кеш-промахи, промахи ветвлений и другие метрики.
Установка и базовое использование
sudo apt install linux-tools-common linux-tools-generic # Ubuntu/Debian
perf stat ./my_program # Общая статистика
perf record ./my_program # Запись профиля
perf report # Просмотр отчета
Пример: находим промахи кеша
Предположим, у нас есть функция, которая обрабатывает матрицу построчно:
const int N = 4096;
int matrix[N][N];
void process_rows() {
for (int i = 0; i < N; ++i)
for (int j = 0; j < N; ++j)
matrix[i][j] *= 2;
}
void process_cols() {
for (int j = 0; j < N; ++j)
for (int i = 0; i < N; ++i)
matrix[i][j] *= 2;
}
Запустите perf stat для каждой функции. Вы увидите, что process_cols имеет на порядок больше cache-misses. Причина — доступ к памяти по столбцам разрушает локальность данных. Оптимизация: транспонировать матрицу или изменить порядок циклов.
Горячие точки (hotspots)
Команда perf report покажет, какие строки кода потребляют больше всего CPU. Ищите аннотированный код с процентом времени рядом с каждой строкой.
3. Симуляция с Valgrind/Callgrind: профилирование без прав root
Если вам нужно не только время, но и детальный анализ кеша, ветвлений и утечек памяти, используйте Valgrind с инструментом Callgrind. Он симулирует выполнение программы, что дает огромную точность, но замедляет работу в 5-20 раз.
Запуск и визуализация
valgrind --tool=callgrind ./my_program
# Создастся файл callgrind.out.XXXX
Для просмотра используйте kcachegrind (графическая утилита):
sudo apt install kcachegrind
kcachegrind callgrind.out.XXXX
Пример: анализ рекурсивного алгоритма
Рассмотрим вычисление чисел Фибоначчи с рекурсией и мемоизацией:
int fib_rec(int n) {
if (n <= 1) return n;
return fib_rec(n-1) + fib_rec(n-2);
}
int fib_memo(int n, std::vector<int>& memo) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n];
memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo);
return memo[n];
}
Запустите Callgrind для обеих версий. В kcachegrind вы увидите, что fib_rec вызывает себя миллионы раз (экспоненциальная сложность), а fib_memo — всего 2N раз. Это наглядный пример, как профилировщик показывает не только время, но и количество вызовов.