Измеряйте перед оптимизацией

Каждая оптимизация производительности должна начинаться с измерения. Оптимизация по интуиции производит код, выглядящий быстрее, но работающий медленнее, или быстрый код в путях вне горячего пути.

Профилировщик говорит:

  • Где реально тратится время — часто не там, где думаете
  • Почему медленно — промахи кэша, неверные предсказания переходов, конкуренция за блокировку
  • Помогло ли ваше исправление — сравнение до/после

Два инструмента покрывают большинство задач производительности C++ на Linux: perf для счётчиков аппаратной производительности и анализа на уровне CPU; valgrind и его инструменты для ошибок памяти, симуляции кэша и профилирования графа вызовов.


perf — счётчики аппаратной производительности Linux

perf читает счётчики PMU (Performance Monitoring Unit) CPU: выполненные инструкции, промахи кэша, неверные предсказания переходов. Низкие накладные расходы (< 1%) и не требует инструментирования бинарного файла.

Сборка для профилирования

Всегда профилируйте с сохранёнными отладочными символами:

1# CMake Release-сборка с символами
2cmake -DCMAKE_BUILD_TYPE=RelWithDebInfo ..
3make -j4
4
5# Или вручную
6g++ -O2 -g -fno-omit-frame-pointer -o myapp src/main.cpp

-fno-omit-frame-pointer сохраняет цепочку указателей фрейма — требуется для точного разворачивания стека вызовов в perf record.

perf stat — быстрый обзор

1perf stat ./myapp

Вывод:

 Performance counter stats for './myapp':

     3,421.45 msec task-clock           #    0.998 CPUs utilized
            12      context-switches     #    3.507 /sec
             3      cpu-migrations       #    0.877 /sec
           231      page-faults          #   67.514 /sec
 9,847,234,821      cycles               #    2.877 GHz
 7,203,441,208      instructions         #    0.73  insn per cycle
   542,112,034      cache-misses         #   14.22% of all cache refs
    38,023,112      branch-misses        #    2.31% of all branches

Ключевые числа:

  • insn per cycle (IPC): 0.73 — плохо, 1.0+ — норма для последовательного кода. Низкий IPC означает, что CPU простаивает, обычно ожидая памяти.
  • cache-misses 14%: всё выше ~2% на горячей нагрузке — существенно. Этот бинарный ограничен памятью.
  • branch-misses 2.31%: умеренно — исследуйте, если это в горячем пути.

perf record + report — найти горячие функции

1perf record -g ./myapp     # -g = граф вызовов (трассировки стека)
2perf report

perf report открывает интерактивный TUI. Навигация стрелками, Enter для раскрытия вызывающих/вызываемых функции.

Overhead  Command  Shared Object       Symbol
  38.21%  myapp    myapp               [.] processBuffer
  22.14%  myapp    myapp               [.] findNearest
  15.43%  myapp    libc.so.6           [.] memcpy
   8.22%  myapp    myapp               [.] SensorParser::parse

processBuffer занимает 38% CPU. Раскройте, чтобы увидеть, какие вызывающие её достигают и где она тратит время.

perf annotate — просмотр на уровне исходного кода

1perf annotate processBuffer

Показывает строки источника с процентом семплов:

       │   for (size_t i = 0; i < count; ++i) {
 38.2  │       float v = data[i];               ← 38% времени здесь
  1.2  │       v = filter(v);
 52.1  │       output[i] = v * scale;           ← 52% здесь

Если 90% времени приходится на загрузки из памяти (data[i], output[i]), узкое место — пропускная способность памяти или промахи кэша, а не вычисления.

perf stat для конкретных событий

1# Анализ кэша
2perf stat -e cache-references,cache-misses,L1-dcache-misses ./myapp
3
4# Анализ переходов
5perf stat -e branches,branch-misses ./myapp
6
7# Пропускная способность памяти (Intel)
8perf stat -e mem_inst_retired.all_loads,mem_inst_retired.all_stores ./myapp

valgrind — ошибки памяти и симулированный кэш

valgrind запускает бинарный файл в инструментированной виртуальной машине. Накладные расходы значительны (10–50×), но ловит ошибки, которые аппаратные профилировщики пропускают.

memcheck — ошибки памяти

1valgrind --leak-check=full --track-origins=yes ./myapp
==12345== Invalid read of size 4
==12345==    at 0x401234: processBuffer (main.cpp:47)
==12345==    by 0x401500: main (main.cpp:112)
==12345==  Address 0x5204080 is 0 bytes after a block of size 64 alloc'd
==12345==    at 0x4C2FB0F: malloc (in /usr/lib/valgrind/vgpreload_memcheck.so)
==12345==    by 0x401100: Buffer::Buffer(int) (buffer.cpp:12)

Это чтение за пределами буфера — чтение 4 байт ровно в конце выделения 64 байт. Показаны номера строк; следуйте стеку для поиска ошибки.

Типичные находки memcheck:

  • Invalid read/write: переполнение буфера, ошибка на единицу
  • Use of uninitialised value: чтение до записи (условные переходы на неинициализированных данных)
  • Definitely lost / still reachable: утечки памяти

cachegrind — симуляция кэша

1valgrind --tool=cachegrind ./myapp
2cg_annotate cachegrind.out.<pid>

Симулирует L1/L2/L3 кэш (настраиваемые размеры) и сообщает о попаданиях/промахах на строку источника. Детальнее, чем события кэша perf stat; работает без root.

--------------------------------------------------------------------------------
          Ir   I1mr  ILmr          Dr     D1mr    DLmr          Dw  D1mw  DLmw
--------------------------------------------------------------------------------
 700,000    0     0  350,000  25,000  25,000   350,000       0     0

D1mr = промахи L1 при чтении данных. 25,000 промахов на 350,000 чтений = 7% промахов в этой функции. Если каждый промах обращается к DRAM — это ~10 мкс простоев.

callgrind — граф вызовов + количество инструкций

1valgrind --tool=callgrind ./myapp
2callgrind_annotate callgrind.out.<pid>
3# Или визуализировать с KCachegrind
4kcachegrind callgrind.out.<pid>

KCachegrind показывает граф вызовов с количеством инструкций и промахами кэша на ребро вызова. Полезен для определения, какой вызывающий отвечает за горячий путь.


AddressSanitizer — быстрее valgrind для ошибок памяти

Для отладочных сборок AddressSanitizer находит те же классы ошибок, что memcheck, но с 2× накладными расходами вместо 50×:

1# Компиляция с ASan
2g++ -fsanitize=address -g -O1 -fno-omit-frame-pointer -o myapp src/main.cpp
3./myapp
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x602000000090
READ of size 4 at 0x602000000090 thread T0
    #0 0x401234 in processBuffer main.cpp:47
    #1 0x401500 in main main.cpp:112

Используйте ASan в CI при каждой сборке. Используйте valgrind memcheck для производственных бинарных файлов (перекомпиляция не нужна) или когда ASan недоступен.


Рабочий процесс

1. Сборка с -O2 -g -fno-omit-frame-pointer

2. perf stat — ограничен CPU или памятью?
   IPC < 0.7 или cache-misses > 5% → ограничен памятью → смотреть организацию данных
   IPC > 1.5, мало промахов кэша → ограничен вычислениями → смотреть горячие функции

3. perf record -g + perf report — какая функция?
   Найти 1-3 функции с наибольшими накладными

4. perf annotate — какая строка?
   Найти конкретный кластер инструкций

5. Определить первопричину:
   Загрузка из памяти на каждой итерации → промах кэша → реструктурировать данные (AoS→SoA)
   Ветвление внутри цикла → неверное предсказание → убрать ветвление или отсортировать данные
   Блокировка в горячем пути → конкуренция → lock-free структура или уменьшить область видимости

6. Исправить, пересобрать, снова perf stat — сравнить IPC и процент промахов кэша

Типичные находки и исправления

Находка perf Вероятная причина Исправление
IPC < 0.5, cache-misses > 10% Случайный паттерн доступа к памяти SoA, prefetch, пул-аллокатор
IPC < 0.5, cache-misses низкие Инструкция с большой задержкой Уменьшить деление, sqrt, невыровненные загрузки
Branch-misses > 5% в горячем цикле Непредсказуемый переход Сортировка входных данных, безветвительная арифметика
memcpy/memmove в топе Ненужное копирование std::move, span, reserve()
Блокировка/мьютекс в топ-10% Конкуренция потоков Lock-free очередь, уменьшить критическую секцию
malloc/new в горячем пути Выделение на каждой итерации Пул-аллокатор, арена, предварительное выделение

Итоги

  • Собирайте с -O2 -g -fno-omit-frame-pointer для профилирования
  • perf stat сначала: IPC и процент промахов кэша говорят, ограничены ли вы вычислениями или памятью
  • perf record -g + perf report: какая функция, какой вызывающий
  • perf annotate: какая строка источника
  • valgrind --tool=cachegrind: симуляция промахов кэша с аннотацией источника
  • valgrind memcheck / AddressSanitizer: ошибки памяти — запускать в CI, не в цикле профилирования
  • Исправляйте, измеряйте снова — никогда не угадывайте, всегда проверяйте

Что дальше