Инструменты, которые могут помочь жить.


Мы поговорим о каких-то мелких вещах, которые помогут вам в некоторых ситуациях, если вы о них знаете.

Отладчики.

Первое: -g. Иначе вы проиграли.
Второе — -O0. Если дебажить код с оптимизациями, то вам могут убрать переменные, переупорядочить инструкции и подобное. Ещё в GCC есть ключ -Og, который включает некоторые слабые оптимизации, которые не должны сильно влиять на отладку. Но на самом деле это не очень правда, и если вы не разрабатываете игры и не имеете таймаутов, то лучше использовать -O0.
Вообще в GCC есть много ключей, которые тем или иным образом включают/выключают оптимизацию, от которой зависит отладка, но тут ищите их сами.

Ещё: необязательно запускать программу под отладчиком, можно присоединиться отладчиком к уже работающей программе. С Windows вообще проблем нет, в Linux по-умолчанию так делать нельзя (по соображениям безопасности), сами погуглите, как отключить эту опцию.

Ещё полезная опция — когда ваша программа аварийно завершается, все Linux'ы умеют сбрасывать на диск память. Куда конкретно — смотрите (или изменяйте) файл /proc/sys/kernel/core_pattern. И потом эту штуку можно в отладчике открыть и посмотреть. Если файл у вас не создаётся — проблемы с ulimit -c. Есть ограничения на максимальный размер core dump'а, вот вам надо поставить на unlimited.

Теперь поговорим про сервер дебажных символов. Как мы знаем, дебажные символы заливают в один файл, который отправляют на сервер. И с не так давних времён, любой дистрибутив этот сервер имеет. И вы можете скачать оттуда дебажные символы и исходники (да ещё и нужной версии), чтобы поотлаживать что-то в дистибутиве.

Продвинутые breakpoint'ы.

Представим, что у вас есть программа, которая работает, а потом падает. И вы знаете условие, когда падает (например, когда переменная достигла определённого значения). И отладчики дают вам возможность поставить на условние breakpoint: conditional breakpoint называется. Останавливается в каком-то конкретном случае. В GDB это пишется как if i == 41 после указания breakpoint'а, например.

Ещё если вы хотите отлаживать printf'ами, вам необязательно их явно писать и перекомпилировать программу. Вы можете написать breakpoint, который не останавливается, а что-то выводит. В GDB это commands printf "abacaba" end.

Ещё есть такая штука как watch point'ы. Вы можете поставить слежение за определённой переменной/полем/выражением. Для этого вы (в gdb) так и пишете: watch <expression>. Ещё watch point можно на адрес поставить при помощи -l.
За счёт чего это работает? А watch point'ы в процессоре есть. Но вообще это и программно можно эмулировать (помечаем память как то, куда нельзя писать, процессор при записи делает прерывание, которое и передаётся отладчику).

Визуализаторы структур данных.

Что отладчик из Visual Studio, что GDB, что LLDB поддерживают возможность красиво напечатать встроенные структуры данных. Чаще всего вы не хотите видеть дерево, когда смотрите на std::set или хэш-таблицу в std::unordered_set, а хотите видеть лишь элементы внутри. И вам дают такую возможность. При этом три отладчика позволяют написать свои способы красиво что-то напечатать. В отладчике из VS — это XML-ки, в GDB и LLDB — Python'овские скрипты. Причём в VS визуализатор можно в проект добавить, и VS его подхватит.

Reversible-отладчики.

Reversible-отладчики — отладчики, которые позволяют ходить не только вперёд, но и назад. Если вы под Linux, то вам нужен UndoDB, имеющий тот же интерфейс, что и GDB. Проблема — он проприетарный и платный (притом недешёвый). Но это в целом круто, работает как магия, но всё ещё дорого.
Поэтому у нас есть только инструмент для бедных, созданный людьми из Firefox'а. Но он имеет немного другую специализацию. У них была такая проблема: были тесты, которые спонтанно ломались. И хочется куда-то записать всё что было. Для этого есть rr (record-replay). Вы можете куда-то записать, как программа работает (rr record), она запишет вам всё, что было, а по rr replay вы можете ходить по этой записи. Нужные вам команды — reverse-finish (reverse-«выйти из функции»), reverse-next (reverse-«следующая строка кода») и всё остальное, что начинается с reverse-.

Как работает эта чёрная магия.

Вариант «запоминать всё вообще» не подходит (слишком много памяти). Кстати, такое есть и в GDB (он тоже поддерживает обратный ход, но жрёт бесконечность памяти). А какой вариант подходит?
Давайте запомним начало программы и будем эмулировать получение данных из внешнего мира. А чтобы не работало бесконечно долго (чтобы при шаге назад не запускалась вся программа полностью с начала), сделаем несколько snapshot'ов в разных местах программы, и будем запускаться от них.

А как остановиться в определённом месте? Ну, у нас есть счётчики в процессоре (см. ниже в разделе про профилировщики), только тут мы не профилируем что-то, а задаём счётчик и говорим «исполни N инструкций». Есть проблема с этим, кстати. Поскольку процессор работает спекулятивно, посчитать N инструкций процессора — это не совсем то, что вы хотите. Для сэмплирования разницы никакой, а тут rr долгое время с трудом работал на AMD, потому что там не могли найти детерминированный счётчик.

Ещё проблема — недетерминированные инструкции. Скажем «сколько времени прошло с некоторого момента» или генерация случайных чисел. Но вообще на процессорах есть возможность и на этих инструкциях тоже прерваться.

Ещё проблема есть с shared-памятью. Если вы отлаживаете программу, а кто-то извне пишет в её память, вы проиграли. Чтобы с этим справится, UndoDB делает нечто похожее на механизмы valgrind.

Профилировщики.

Программы, которые помогают вам ответить на вопрос, где больше всего времени проводит программа. Понятно, что вам нужны дебажные символы (-g), иначе вам максимум машинные инструкции покажут, а не строки программы.

  • Из самых известных — perf, встроенный в ядро Linux.
  • В Visual Studio есть профилировщик, использующий встроенные в Windows механизмы.
  • Самый навороченный и крутой — Intel VTune Amplifier. Долгое время он был платный (причём достаточно дорогой), сейчас, кажется, есть какие-то варианты, но это всё сами включите VPN и посмотрите.

При этом всё, что мы расскажем, в основном относится с CPU-профилированию, но вообще perf, например, может и записи на диск профилировать, работу с сетью да и вообще кучу разных событий, которую можно сэмплировать.

Базовые возможности perf.

В профилировщике Visual Studio разберётесь сами, а мы проговорим о том, что умеет perf. Он запускается командой perf и дальше имеет кучу опций.

  • Самая простая — perf stat, с этой опцией показывается не только время, но и всякая другая фигня (branch-miss'ы, cpu-миграции, разные другие штуки). Что-то из этого поставляется ОС, что-то — процессором.
  • Если вы хотите посмотреть список доступных событий — perf list. Чем дальше вниз прокручивать этот список — тем более редко используемые параметры там будут.
  • Окей, это всё хорошо, но хочется понять, на какую функцию грешить, если долго работает. Это делается при помощи perf record+perf report.

Первый собирает статистику о функциях, второй — показывает её вам. Причём статистика — не обязательно время. Можно написать perf record -e cache-misses <программа>, и тогда вам будут давать статистику по промахам в кэше. Чтобы самому узнать какие-то опции инструкций — perf help <инструкция>.

Каким образом работает perf? Зависит от процессора, на самом деле. Процессор предоставляет возможность, скажем, каждый тысячный заход в команду давать прерывание (по которому perf будет что-то считать). Кстати, есть проблема: процессор имеет ограниченное количество счётчиков, и в таком случае perf будет сначала сэмплировать один набор, потом другой, затем третий, чтобы собрать статистику.

General exploration.

Хорошо, вот нашли вы долго работающее место, но совершенно не знаете, в чём там проблема. Тут уже perf вам не поможет, но может помочь VTune. У него есть режим «general exploration». Он базируется на упрощённой модели, что инструкция сначала передаётся в какую-то front-end-часть процессора, а потом — в back-end. И потом в зависимости от того, поступают ли инструкции из front'а в back и простаивают ли front и back, вам говорят, где проблема (собственно, во front'е ли, в back'е ли, восстанавливаетесь ли вы от branch miss'а или что). И вот в VTune эту идею довели до ума, в связи с чем вам говорят не только тормозите ли вы на front'е или back'е, но и упираетесь ли вы в память, а если да, то в L1, L2, L3 или RAM. Так что VTune сразу говорит вам, в каких функциях какие проблемы. В perf'е это только в зачаточном состоянии есть.

Сбор стека вызовов.

Следующая возможность perf'а. Вот выяснили вы, что долго работает. А кто вызывает то, что долго работает? perf record -g --call-graph dwarf. Тогда вам будут показывать стеки вызовов, что довольно полезно. Опция -g их сборкой и занимается, а опция --call-graph задаёт то, каком образом они собираются.
Чтобы не сильно замедляться при сборке стека, perf работает в ядре. Но чтобы ядро очень долго не работало, собираются верхушки стеков, а perf report пытается их раскрутить. Это и есть режим --call-graph dwarf. Ещё есть режим --call-graph fp, который собирает стек вызовов по frame-pointer'ам, что делается быстро и честно, но тогда совершенно всю программу надо собрать с ключом -fno-omit-frame-pointer, а стандартная библиотека с ним не собрана.
Ещё perf можно натравливать на уже работающие программы при помощи perf -p . Более того, perf может профилировать больше одной программы. Хоть вообще все (sudo perf record -a).

Альтернативные методы профилирования.

Сто́ит сказать, что профилировщики (perf, VS profiler, VTune) полагаются на процессор. А ещё есть tracing-профилировщики, работающие на базе компиляторов. Они вставляют инструкции на моменте входа и выхода из функции. Примером таковых является gprof. Они имеют преимущества и недостатки. Преимущество — могут показать число вызовов функции (честно, а не по количество сэмплов), что бывает полезно, чтобы увидеть, что вы могли посадить где-то квадратичную сложность. Недостаток — tracing-профилировщики врут, увеличивая время работы маленьких функций. В связи с этим они используются очень редко.

Что ещё сто́ит упомянуть — valgrind имеет встроенный профилировщик. Он в целом адекватный, но у него просмотрщик не очень. Так вот, valgrind — это набор инструментов. Если вы не пишете ничего, запускается инструмент memcheck. А когда вы используете инструмент cachegrind, то valgrind эмулирует работу процессора (сколько времени он бы исполнял заданную инструкцию). С виду вообще бред какой-то (все системные вызовы, например, занимают 0 времени, что это вообще), но и тут есть свои ништяки.
Есть история про библиотеку SQLite для работы с базами данных. В один момент её решили оптимизировать и микрооптимизациями ускорили её на $61%$. В качестве профилировщика они использовали cachegrind, который даёт вам воспроизводимые данные без шума. И если вы сделали микрооптимизацию, в cachegrind вы увидите ускорение на $0.1%$, и его не съест шум.

Проверка покрытия.

Если тесты не заходят в некоторую функцию или на определённую строку, значит на тех местах может быть написан полный бред. Поэтому следует проверить, что тесты проходят в вашей программе вообще везде. Это называется coverage, и запускать его так. Сначала надо компилировать программу с ключом --coverage. Тогда при запуске создадутся файлы расширения .gcda и .gcno. Чтобы их посмотреть, вам нужна программа gcov с ключиками, это сами разберётесь.

Полезные ключи компиляции.

LTO.

Linking-time optimizations-flto (ключ передаётся компилятору и линковщику). Это мы уже обсуждали. Не обсуждали, как работает. А работает так: компилятор откладывает генерацию кода до этапа линковки, а в файл записывает что-то промежуточное. Это промежуточное представление даже глазами читать можно, это просто какой-то императивный код. В Clang'е это представление — LLVM-IR, в GCC — gimple. Второй вообще похож на C.

Какие плюсы и минусы у LTO? С одной стороны, бенчмарки говорят, что ускорение на несколько процентов. С другой, на бенчмарках и так всё сильно оптимизировано, даже то, что LTO мог бы ускорить. Ещё LTO сильно уменьшает размер программы. Например, если параметры в функцию передаются всегда одинаковые, или if всегда в одну сторону. Это позволяет компилятору (если он видит программу целиком), уменьшить размер. Ещё LTO хорошо с шаблонами взаимодействует. Поскольку шаблоны подставляются в каждой единице трансляции, если вы сгенерировали что-то здоровенное, то без LTO оптимизироваться это будет везде, а с LTO — только один раз. И самое интересное — LTO может показать вам ODR, что в C++ очень круто. Недостатки — LTO убивает возможность пересобрать один файл, самое долгое время занимает линковка, вне зависимости от того, один файл вы изменили или все.

PGO.

Profile-guided optimizations. Идея в следующем. Иногда изменение компилятора даёт эффект только в некотором случае. Если у нас есть цикл, мы можем за'if'ать случай aliasing'а, использовать SIMD, сделать кучу ещё разных интересных вещей. Но если всё это мы будем делать с каждым циклом, мы проиграем, потому что нужно это отнюдь не всегда, а если мы всё применим, увеличится размер программы, а значит будет хуже работать программный кэш, что может даже к замедлению привести.
Или с if'ами процессору хочется знать, какая ветка более вероятна.

Так вот, вы можете запустить компиляцию с ключом --profile-generate, запустить программу, после чего заново скомпилировать с --profile-use. Это также даёт ускорение, а из минусов — вам нужно иметь репрезентативный набор тестов. Впрочем, иметь набор тестов и так очень полезно, о каком ускорении можно говорить, если тестов нет.

Кстати, в случае с GCC то, что не попало в профиль, считается «холодным кодом» и оптимизируется на размер, а не на скорость. Поэтому в GCC, если у вас не репрезентативный профиль, вы можете проиграть. В Clang такого нет.

BOLT.

BOLT — это не ключ компилятора, это инструмент, которому вы даёте уже скомпилированный бинарник и профиль, после чего он оптимизирует. Есть статистика, согласно которой, BOLT работает лучше чем PGO на $15%$. Дело в том, что он группирует горячие данные вместе, тем самым эффективно используя кэш для инструкций. Почему это не используется в PGO — непонятно. LLVM пытались создать свой аналог (названный LLVM-propeller), но он, барабанная дробь, не взлетел. Поэтому теперь сам BOLT есть внутри LLVM четырнадцатой версии.

Статические анализаторы.

Это компьютерный сеньор, который стоит рядом и пользуется методом пристального взгляда.

    char* s = static_cast<char*>(malloc(N));
    // ...
    delete[] s;

Статический анализатор от Microsoft скажет вам, что вы дурак. Почему так не делают все компиляторы? Потому что чисто по математическим причинам нельзя в Тьюринг-полном языке проверить, достижима ли строка или нет, а значит тем более нельзя гарантированно найти все ошибки (не имея ложных срабатываний). Впрочем, иногда ошибки статического анализатора переезжали в предупреждения компилятора. Например, код

    printf("%p\n", 42);

В VS2017 давал ошибку статического анализатора, VS2019 — предупреждение.

GCC PR18501.

void f() {
    bool first;

    for (; !finish;) {
        if (!first)
        something();
        first = false;
    }
}

void g() {
    int value;
    if (flag)
        value = 42;

    something();

    if (flag)
        consume(value);
}

Пользователям хочется, чтобы первый пример был некорректным, второй — корректным. Но есть проблема. Статический анализатор можно запускать сразу, а можно после некоторых оптимизаций. Если мы статически анализируем в самом начале, мы не можем во втором примере узнать, что if (flag) два раза — это одно и то же, нужно узнать, что something его не меняет. Если оптимизировать, то после оптимизации мы не увидим ошибки во втором случае, но не увидим и в первом, так как утратим информацию о том, что first не был проинициализирован.

Поэтому GCC пытается минимизировать ложные срабатывания, а Clang максимизировать истинные. А чтобы сделать всё хорошо, нужно иметь другую логику, нежели имеют компиляторы.

SAL-аннотации.

Хорошо, статические анализаторы GCC и Clang междпроцедурные и даже могут смотреть сквозь единицы трансляции. В Visual Studio, увы, не так, поэтому вот такой код:

void f(int* val) {
    printf("%d", *val);
}

Непонятно, корректный ли. Хочется знать, какой у функции контракт. Можно ли передавать туда указатель на неинициализированную память, можно ли NULL и т.д. Поэтому есть SAL-аннотации.

Например — _Out_ говорит, что вы передаёте туда выделенную, но не обязательно проинициализированную память. Или _Out_writes_bytes_(size * sizeof(int)) говорит, что вы передали указатель, в котором можно записать size * sizeof(int) байт.

У Clang есть то же самое, например,

    int a __attribute__((guarded_by(m)));

В этом примере при попытке записать в переменную не заблокировав мьютекс m, вам дадут предупреждение.

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