Как измерять и улучшать производительность кода?
Производительность кода измеряют с помощью профилирования и целевых замеров времени и потребления памяти. Сначала находят «горячие точки», затем точечно оптимизируют их за счёт более подходящих алгоритмов, структур данных и работы с памятью. После каждой оптимизации снова измеряют, чтобы убедиться, что изменения действительно ускорили программу.
Оптимизацию производительности всегда нужно начинать с измерений, а не с интуитивных правок. Основные инструменты: профилировщики (CPU, память, аллокации) и целевые бенчмарки на типичных для системы входных данных.
В C++ для профилирования часто используют такие инструменты, как Valgrind/Callgrind, gprof, Linux perf, а также встроенные профилировщики в IDE (Visual Studio, CLion и др.). Они показывают, в каких функциях и участках кода тратится основное время, сколько аллокаций и кэшемиссов происходит, где есть блокировки потоков и т.п.
Практически рабочий подход выглядит так: вы компилируете программу с отладочной информацией и оптимизацией (например, -O2 -g), гоняете её под профилировщиком на реалистичных сценариях и получаете отчёт с «горячими» функциями. Затем выбираете действительно критичные по времени участки и только их пытаетесь ускорить.
Для локальных проверок и сравнения разных реализаций нередко используют простые микробенчмарки с измерением времени выполнения, например с помощью std::chrono:
После того как узкие места найдены, применяют типичные техники оптимизации: замену алгоритмов на асимптотически более эффективные, выбор более подходящих структур данных (например, std::vector вместо std::list), уменьшение числа выделений памяти и копирований, улучшение локальности данных для кэша, использование оптимизаций компилятора (-O2/-O3, LTO), а при необходимости — параллелизации и векторизации.
Важно не увлекаться микроскопическими оптимизациями в случайных местах: гораздо больший выигрыш обычно даёт смена алгоритма или устранение лишних аллокаций. Всегда нужно проверять эффект каждой оптимизации повторным профилированием или бенчмарком на тех же самых данных.
Пример: есть задача «слить» два вектора, просто дописав элементы второго в конец первого. Наивная реализация в цикле может многократно перевыделять память из-за поэлементного push_back:
В этом конкретном примере основной выигрыш даёт именно явное резервирование памяти и вставка целого диапазона, а не использование std::merge или отдельного алгоритма std::move. std::merge вообще предназначен для слияния отсортированных диапазонов в третий, а std::move просто поэлементно перемещает элементы и сам по себе не убирает необходимость проходить по всем элементам и выделять память под результат.
Отметьте свой прогресс