Профилировщик для бедных для бедных

Sergey, 30 December 2022

Сегодняшняя заметка будет посвящена задаче, которую мне приходилось решать буквально сегодня, и, надеюсь, она будет не очень большая. Задача формулировалась, если не вдаваться в детали, примерно так: если сравнить запуск программы X в конфигурации A и в конфигурации B (допустим, для простоты, что конфигурация B отличается только какими-то дополнительными параметрами запуска), то второй запуск выполняется ощутимо медленнее, и нужно выяснить, почему.

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

  1. Семплирование на уровне ядра. В Linux за такое семплирование отвечает утилита perf. Базово она работает так. Ядро ведёт разнообразные счётчики событий, например, счётчик выполненных процессорных команд. Как только счётчик доходит до определённого значения, программа останавливается, а текущее место в программе сохраняется. Дальше с этой статистикой уже можно делать всякие интересные вещи, например, искать самые «тяжёлые» функции. В реальности всё намного сложнее, и счётчики, и семплы могут быть разные, но суть остаётся та же. У perf есть очень хорошая вики.
  2. Также можно профилировать с помощью инструментации кода, это в целом самый очевидный и самый простой способ профилирования. Суть этого способа в том, что в «стратегические» точки выполнения программы (например, вход и выход из функции) вставляются специальные части кода, замеряющие время достижения соответствующей точки кода. Такое профилирование можно осуществлять как вручную, проставляя печать в нужных местах, так и с помощью специальных утилит вроде gprof в связке с gcc или callgrind из состава valgrind. Про gprof можно почитать например здесь.
  3. Наконец, есть третий метод (или семейство методов, есть разные его модификации), и именно он вынесен в заголовок этой заметки. Основывается он на той идее, что самый универсальный ответ на вопрос «что делает программа X в момент времени Y» это её стек вызовов. Здесь тоже всё очень просто: раз в N микросекунд к программе подсоединяется отладчик, он снимает стек вызовов, и записывает его для дальнейшей обработки. Основной источник идеи находится здесь, а вот отличная её реализация.

Есть и другие техники профилирования, но эти три самые распространённые.

Как вы уже поняли, основная часть этой заметки будет про третий способ, но для начала немного скажу о том, почему мне не подошли другие два. Профилирование с помощью семплов хорошо работает при поиске неоптимальных алгоритмов, которые много времени выполняются на процессоре. К сожалению, этот метод непригоден для поиска проблем, связанных с синхронизацией в многопоточных задачах: в них значительная (и зачастую самая интересная) часть времени проводится в точках блокировки. Кроме того, для этого метода нужна поддержка со стороны ядра. Тем не менее, что в linux, что в darwin (насчёт NT не знаю), что в BSD такие механизмы всё равно есть. Инструментация же требует пересборки проекта, что на большом проекте может делаться достаточно долго, да и не всегда возможно. «Нищебродский» же метод крайне прост и универсален: в общем случае для него не требуется никаких дополнительных условий, кроме отладчика или другой программы, умеющей снимать слепки стека.

Тем не менее, и с этим методом в классическом его варианте у меня возникли проблемы. Как я уже говорил, в исходном виде этот метод работает так, что нужно с некоторой периодичностью присоединять к уже работающему процессу отладчик, останавливать процесс, снимать стек вызовов и продолжать его выполнение. К сожалению, на системе, на которой я отлаживал проблему с производительностью, такое присоединение к любым процессам, кроме дочерних, запрещено с помощью специальной настройки, так что единственный способ снять стек вызовов с программы в какой-то точке — это запустить программу под отладчиком или другой нужной тулой изначально. У отладчика GDB есть возможность выполнения команд из скрипта в пакетном режиме, но у него есть существенное ограничение: в любой момент времени может выполняться либо только отладчик, либо только отлаживаемая программа. Это ограничение наложено из-за того, что как сама программа, так и отладчик имеют полный доступ к памяти отлаживаемой программы, так что между ними возможны состояния гонки, что может привести к очень трудно отлавливаемым и непонятным побочным эффектам.

Итак, применить этот метод «втупую» не получается. Так что же, это значит, что никаких нам стеков? Оказывается, что стеки всё же можно получить, но для этого нужно поработать чуть больше. В UNIX есть концепция «сигналов» — заранее заданного набора сообщений, которыми могут между собой обмениваться программы, а так же которые система может посылать программам. Общая логика работы с этими сигналами такова:

  1. Программа специальным образом устанавливает обработчик сигнала - выделенную функцию, которая будет вызываться по пришествии сигнала (такой обработчик можно установить не для всех сигналов, но это отдельная тема);
  2. Другая программа или операционная система посылает первой сигнал;
  3. Независимо от того, что выполнялось в программе на момент прихода сигнала, её выполнение приостанавливается. Дальше возможны два исхода:
    1. Если обработчик сигнала не был установлен, то программа завершается;
    2. Если же он всё-таки был установлен, то вызывается этот обработчик. Когда его выполнение завершается, программа продолжает работать с того момента, на котором её остановили на момент прихода сигнала.

Чтобы применить это к нашей задаче, посмотрим, какие инструменты по работе с сигналами предоставляет GDB. А предоставляет он их достаточно: можно, например, остановить выполнение программы и что-то напечатать, можно передать этот сигнал в неё, можно его просто проигнорировать. Кроме того, это настраивается независимо для каждого сигнала, если есть такая необходимость. Когда я посмотрел на эту функциональность, у меня возникла следующая идея относительно того, как решить мою задачу:

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

Для программы-сигнальщика нам понадобятся всего два системных вызова kill и nanosleep (можно посмотреть например man 2 kill, man 2 nanosleep), сам сигнальщик будет выглядеть примерно так:

    int pid = atoi(argv[1]);
    int running = 1;
    struct timespec sp = { 0, 200 }; // будем просыпаться раз в 200 наносекунд
    while (running) {
      running = kill(pid, SIGUSR1) == 0;
      nanosleep(&sp, NULL);
    }

Здесь есть часть, которой мы ещё не касались - это pid, своеобразный «адрес» процесса в системе. Он нужен для того, чтобы система понимала, кому слать сигнал. Этот pid можно получить от отладчика, об этом чуть позже.

Вторая часть этого решения это примерно такой скрипт для GDB:

    set logging redirect on
    set logging overwrite on
    set logging enabled on
    
    handle SIGUSR1 ignore stop
    break main
    run
    
    info inferior
    while (1)
      thread apply all bt
      continue
    end

Первые три строчки здесь задают настройки логирования GDB: отладчик будет писать весь свой вывод в файл, этот файл при каждом запуске отладчика будет перезаписываться, и на консоль ничего выводиться не будет. Строка с handle указывает отладчику, чтобы он обрабатывал сигнал SIGUSR1 специальным образом: останавливал программу, но не передавал его дальше. Что касается цикла, то с ним, наверное, всё понятно: он будет печатать стек вызовов для каждого треда каждый раз, как отладчик получит управление.

Теперь об оставшихся трёх командах, с ними связана ещё одна техническая тонкость. Как я уже сказал, чтобы сигнальщик правильно работал, ему нужно знать процесс, куда свой сигнал посылать. Отладчик этот идентификатор знает, так как это его дочерний процесс, он показывает его, в числе других данных, по команде info inferior. К сожалению, мы не можем позвать эту команду в самом начале этого скрипта, т. к. на момент его запуска дочернего процесса ещё нет, он запустится только после команды run. Поэтому мы ставим точку останова на функцию main (которая есть в любой программе, использующей libc) и запускаем программу.

Общий алгоритм работы с этими кусками кода примерно таков:

  1. Собираем сигнальщик:
        gcc killer.c -o killer
    
  2. Запускаем отлаживаемую программу под GDB в batch-режиме:
        gdb -x init.gdb --batch --args ./program args
    
  3. Смотрим в логе gdb.txt на строчку Process <id> и запускаем наш сигнальщик с этим id:
        ./killer <id>
    
  4. В том же самом логе будут записаны стеки вызовов, которые уже можно обрабатывать дальше.

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