Как профилировать, когда perf видит не все

Article title image

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

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

Контекст

Tarantool — это расширяемая Middleware-платформа, которая состоит из In-memory-базы данных и сервера приложений. С помощью Tarantool вы можете разрабатывать сложную бизнес-логику на языке программирования Lua в непосредственной близости к данным. В качестве среды исполнения Lua здесь используется свой форк LuaJIT.

Часть компонентов Tarantool написана на C, а часть на Lua, что при исполнении приводит к многослойным «сэндвичным» переходам между этими компонентами. Миры Lua и C могут быть связаны тремя способами:

Возможности для профилирования

perf

Де-факто стандартный инструмент для профилирования в Linux, в котором есть все нужное для анализа производительности. Одна из частей perf находится в ядре и отвечает за сбор событий, которые называются -perf_events, вторая часть находится в пространстве пользователя и предоставляет интерфейс для взаимодействия и анализа.

perf_events содержат в себе разнообразную информацию. Часть из них напрямую отражает данные из PMU (Performance measurement unit) процессора, другие являются более сложными метриками, которые генерируются программно.

Различных типов perf-events очень много, есть даже для трассирования. Все это делает perf огромным комбайном, который может выжать максимум информации об исполнении программ.

jit.p

jit.p — встроенный в LuaJIT профилировщик для Lua. Он куда более приземленный, чем perf, и не содержит в себе столько разных функций. У jit.p есть несколько фронтендов: аргумент командной строки с опциями, Lua API для запуска профилирования прямо из Lua-программы и низкоуровневый C API.

При использовании Lua API или C API можно задать конфигурируемый Callback, который будет вызываться на каждый собранный образец. Это позволяет собирать дополнительную информацию, проводить агрегацию и расширенный анализ. Большим недостатком jit.p для нас является, конечно же, его ограниченность Lua-миром.

Визуализация

Для визуализации мы решили использовать FlameGraph от Брендана Грегга. Его реализация скрипта для генерирования флеймграфа очень легковесная, не завязана на конкретный инструмент профилирования и принимает на вход простой формат данных: каждый вид стеков записывается в своей строке, фреймы разделяются точкой с запятой, а напротив пишется количество таких образцов:

frame1;frame2;frame3;frame4 1401
another_fancy_frame;not_so_fancy_frame 10
<’;’-separated stack trace> <count>

Пример флеймграфов, собранных через perf и jit.p

Рассмотрим в качестве примера программу на Lua, которая поочередно вызывает реализацию вычисления n-ого числа Фибоначчи на C и Lua.

Вычисление числа Фибоначчи на Си

double c_fibonacci(double n) {
 if (n <= 1) {
   return n;
 }
 return c_fibonacci(n - 1) + c_fibonacci(n - 2);
}

Вычисление числа Фибоначчи на Lua

function lua_fibonacci(n)
 if n <= 1 then
   return n
 end
 return lua_fibonacci(n - 1) + lua_fibonacci(n - 2)
end

Бенчмарк

local start_time = os.clock()
local benchmark_time = 10
local i = 0

while os.clock() - start_time < benchmark_time do
   if i % 2 == 0 then
       c.c_fibonacci(20)
   else
       lua_fibonacci(20)
   end
   i = i + 1
end

Если выполнить этот бенчмарк с perf, то мы увидим следующую картину:

В профиле присутствует только происходящее в мире C и нет вообще ничего, что происходит в Lua-мире. Если же запустить jit.p, то мы увидим обратную ситуацию:

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

Нам необходим профилировщик, который покажет все переходы между мирами C и Lua.

Адаптация perf

На примере мы увидели, что perf может собрать стек хостовой системы и ее символы, но не может собрать символы и стек виртуальной машины интерпретатора. Эти две проблемы мы и будем решать.

Стек VM и perf

Для размотки стека perf по умолчанию использует механизм Frame pointer. Но альтернативно можно использовать размотку стека с помощью DWARF и ORC. Разберем каждый из способов подробно.

Frame pointer

Когда-то давно регистров у процессоров было мало и каждый свободный был на вес золота. Регистр, использовавшийся как указатель стекового фрейма, перестали использовать для этих целей и стали использовать как регистр общего назначения. Вычисления благодаря этому стали быстрее, но пришлось вводить более сложные механизмы размотки стека. Для профилирования через perf с использованием указателей фрейма программу на C можно собрать с флагом -fno-omit-frame-pointer, но такого же флага для VM вашего интерпретатора может и не быть. Если вы собираетесь воспользоваться таким механизмом размотки стека, то такой флаг вам необходимо реализовать самостоятельно. В JVM так и сделали: разработчики убрали регистр rbp из пула регистров общего назначения:

--- openjdk8clean/hotspot/si>rc/cpu/x86/vm/x86_64.ad 2014-03-04 02:52:11.000000000 +0000
+++ openjdk8/hotspot/src/cpu/x86/vm/x86_64.ad 2014-11-08 01:10:49.686044933 +0000
@@ -166,10 +166,9 @@
// 3) reg_class stack_slots( /* one chunk of stack-based "registers" */ )
//

-// Class for all pointer registers (including RSP)
+// Class for all pointer registers (including RSP, excluding RBP)
reg_class any_reg(RAX, RAX_H,
RDX, RDX_H,
- RBP, RBP_H,
RDI, RDI_H,
RSI, RSI_H,
RCX, RCX_H,

А затем добавили выставление frame pointer в прологи функций:

--- openjdk8clean/hotspot/src/cpu/x86/vm/macroAssembler_x86.cpp 2014-03-04 02:52:11.000000000 +0000
+++ openjdk8/hotspot/src/cpu/x86/vm/macroAssembler_x86.cpp 2014-11-07 23:57:11.589593723 +0000
@@ -5236,6 +5236,7 @@
// We always push rbp, so that on return to interpreter rbp, will be
// restored correctly and we can correct the stack.
push(rbp);
+ mov(rbp, rsp);
// Remove word for ebp
framesize -= wordSize;

Это и дало им заветную картинку:

Здесь видны стеки JVM, но они пока еще не символизированы.

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

DWARF

DWARF — широко используемый формат отладочных данных, в котором есть нужная информация — от номера строки исходного файла c операцией до специальных таблиц для размотки стеков. Именно эти таблицы нас и интересуют больше всего.

Идея DWARF-таблиц для размотки достаточно проста. Каждому положению в вашей программе (LOC в таблице) можно сопоставить CFA (Canonical Frame Address). Компилятор создает аннотацию для нужных значений регистров и сохраняет их в память. Впоследствии, если нужно размотать стек, значение регистра в определенном фрейме можно восстановить, прочитав его из памяти по адресу CFA, смещенному на значение из соответствующей строки в таблице.

Такая таблица может быть очень большой и может даже превышать размеры самой программы, поэтому в таком виде ее не записывают. Вместо этого записывают байткод, который отражает отличия одной строки от другой, а машина состояний в DWARF исполняет этот байткод, чтобы получить информацию в нужной строке. Хранится эта информация вместе с некоторым другими данными в секции .eh_frame в виде двух сущностей: CIE (Common Information Entry) и FDE (Frame Description Entry). FDE содержит информацию, специфичную для размотки конкретного фрейма, в CIE — информацию, которую могут разделять несколько фреймов. Делается это для экономии занимаемого объема памяти. 

Также есть индекс FDE, который располагается в .eh_frame_hdr, для быстрого поиска нужного FDE по PC.

Значения восстанавливаются так:

  1. По индексу в `.eh_frame_hdr` ищется подходящий FDE.
  2. По указателю в FDE находится соответствующий CIE.
  3. Выполняется разделяемая часть DWARF-байткода в CIE.
  4. Выполняется специфичная часть DWARF-байткода в FDE.
Вот так это может выглядеть в случае реализации функции `backtrace`: ``` void backtrace() {  unw_cursor_t cursor = {};  size_t rip = 0, rsp = 0;  do {    unw_get_reg(&cursor, UNW_X86_64_RIP, &rip);    unw_get_reg(&cursor, UNW_X86_64_RSP, &rsp);    printf("rip: %zx rsp: %zx\n", rip, rsp);  } while (unw_step(&cursor) > 0); } ``` Функция `unw_step` выполняет описанные выше действия. Недостатки DWARF Отдельную сложность в случае LuaJIT представляют собой трассы. Предположим, что у нас есть такой код на Lua: ``` local function inline_me(arg)   return arg + 1 end local function my_fancy_func(counter)   for _ = 1, 4 do     counter = inline_me(counter)   end   return counter end my_fancy_func(5) ``` LuaJIT-байткод, сгенерированный для этой программы, выглядит вот так: Красным прямоугольником выделен вызов функции `inine_me`, на этом этапе его еще отчетливо видно. Когда этот участок будет переведен во внутреннее представление и оптимизирован при компиляции трассы, то мы получим несколько иную картину: На этом этапе у нас больше нет возможности узнать, что там когда-либо был вызов в функцию, теперь там просто инкремент. Тем не менее эти проблемы решаемы. Для обновления информации есть функции `__register_frame` и `__deregister_frame`, которые позволяют задавать необходимую информацию в процессе исполнения. Для решения проблемы с Inlining’ом можно либо его запретить, либо использовать дополнительную отладочную информацию, чтобы отслеживать подобные ситуации и задавать для них фрейм.

ORC

DWARF unwinding устроен достаточно сложно: свой байткод, стейт-машина. Естественно, это не могло устраивать всех. Вот что писал Линус Торвальдс, когда DWARF unwinding хотели сделать для компонентов ядра: ``` Who actually ends up using this? Because from the last time we had fancy unwindoers, and all the problems it caused for oops handling with absolutely _zero_ upsides ever, I do not ever again want to see fancy unwinders with complex state machine handling used by the oopsing code. ``` Разработчики вняли его словам и создали ORC. Он устроен значительно проще DWARF, нацелен исключительно на размотку и работает в среднем в 20 раз быстрее, занимая в памяти в полтора раза больше места. Информация о фрейме для ORC хранится в виде следующей структуры: ``` struct orc_entry {    s16     sp_offset;    s16     bp_offset;    unsigned    sp_reg:4;    unsigned    bp_reg:4;    unsigned    type:2; }; ``` А значение можно вычислить следующим образом: ``` reg = [sp_reg] + sp_offset ``` ORC совсем новый относительно других способов размотки, поэтому на старых ядрах тоже не работает, что в случае Tarantool критично. #### Свой размотчик Обычно не представляет проблемы отдельно собрать хостовый стек, так же как и не представляет проблемы отдельно собрать стек виртуальной машины. Зная устройство стека виртуальной машины, можно написать модуль ядра, который будет способен снимать оба стека, попутно объединяя их. Единственным ограничением здесь становятся требования к безопасности у клиентов, которые не позволят использовать такой механизм. ### Символы Если запустить perf script, то можно обнаружить, что perf ищет специальный файл, в котором описаны дополнительные символы. ``` $ perf record -F 200 -g tarantool payload.lua $ perf script perf script Failed to open /tmp/perf-9605.map, continuing without symbols … ``` Действительно, чтобы perf смог соотнести символы из виртуальной машины с соответствующими адресами, достаточно создать файл /tmp/perf-.map и расположить в нем данные следующей структуры: ``` [start address] [size] [symbol name] 0x00007f557d10c19f 0xfc my_fancy_symbol ``` В JVM так и поступили, после чего у них получилась уже символизированная картинка: LuaJIT уже умеет генерировать map-файлы для трасс, но не для всего остального. ``` 55c4c138cfbf 261 TRACE_38::builtin/box/schema.lua:1995 55c4c138cdff 1b9 TRACE_39::builtin/tarantool.lua:134 55c4c138cce9 10f TRACE_40::builtin/tarantool.lua:89 ``` Создать такую таблицу символов несложно, но надо помнить о нескольких проблемах:
  1. Если у вас есть JIT или Eval-подобные конструкции, то сопоставление будет устаревать и его надо поддерживать в актуальном состоянии.
  2. Иногда интерпретатор может перекладывать буферы с исполняемым кодом, в этом случае адреса в таблице тоже надо обновлять.
Для таких ситуаций для JVM раньше был агент, сейчас это вынесено в саму JVM. В конечном итоге мы так и не смогли найти устраивающее нас решение проблемы со стеками, поэтому пришлось отказаться от идеи интеграции с perf и начать писать свой профилировщик внутри LuaJIT.

Свой профилировщик

Проблемы надо решить те же: собрать стеки и символы хоста и виртуальной машины.

Стеки

Тут вспоминается идея о написании собственного размотчика, который умел бы объединять стеки хоста и виртуальной машины. Теперь мы можем реализовать такой подход, поскольку можем унести релевантный код внутрь виртуальной машины. После сохранения хостового стека и стека VM их остается только объединить, причем делать это можно на этапе постпроцессинга. У VM есть конкретные точки входа и выхода. В случае LuaJIT это `lua_call/lua_pcall/lua_cpcall` в качестве точек входа и `BC_FUNCC` в качестве точки выхода. Еще одним важным фактом является то, что вызываемая С-функция находится одновременно и в Lua-стеке, и в хостовом. Теперь есть все необходимое для объединения стеков. Мы итерируемся по фреймам хостового стека и добавляем их в результирующий. Если встретили конец стека, то завершаемся, если же встретили точку входа в VM, начинаем итерироваться по Lua-стеку. По Lua-стеку поднимаемся, пока не дойдем до его конца или до C-функции, в обоих случаях возвращаемся на хостовый стек. Пример такого объединения приведен на схеме.

Символы

Последней нерешенной проблемой остаются символы. Внутри VM способ их получения будет специфичен для каждой конкретной VM, поэтому этот этап мы опустим. На хосте самым лучшим способом будет напрямую прочитать полную таблицу из секций `.symtab`и `.strtab` из Linking view соответствующих ELF-файлов. В `.symtab` хранится метаинформация для символов, в `.strtab` — текстовые имена. Если же это невозможно по каким-либо причинам, то можно воспользоваться механизмом, предоставляемым функцией `dl_iterate_phdr`, и прочитать таблицу `.dynsym` из Execution view ELF-файлов. Она может содержать не все символы, поэтому использовать такой способ как основной не стоит. ## Sysprof — профилировщик платформы Tarantool Описанный способ создания собственного профилировщика мы использовали при реализации Sysprof - профилировщика производительности Tarantool. Во время разработки мы также вдохновлялись идеями из профилировщика LuaVela. И именно с помощью Sysprof были получены данные для целевой картинки из начала статьи: У него есть несколько режимов: Для Sysprof есть Lua API и низкоуровневый C API, позволяющий задавать собственную функцию для размотки и функцию записи сэмплов. Пример использования Lua API: ``` misc.sysprof.start{   mode = ‘C’,    interval = 10,   path = ‘/path/to/file.bin’, } payload() misc.sysprof.stop() counters = misc.sysprof.report() ``` Посмотрим на более сложный пример. Внутри команды у нас есть небольшой скрипт, выполняющий миллион операций Replace в Tarantool, который мы использовали для грубой оценки производительности. Профилирование такого исполнения дает следующий Flame Graph: Здесь становится очень наглядной значимость нагрузки на GC LuaJIT и необходимость оптимизации скрипта, чтобы эту нагрузку снизить.

Недостатки Sysprof

Естественно, получившийся профилировщик не идеален. Из остальных недостатков можно отметить:

Выводы

В первую очередь всегда стоит попытаться интегрироваться с perf, чтобы получить доступ к огромному количеству функциональности и инструментов анализа. Для интеграции с perf нужно: 1. Cделать маппинг символы через perf-.map; 2. Обеспечить возможность снятия стеков через один из вариантов: Если интегрироваться все-таки не получилось по каким-либо причинам, то для написания своего профилировщика нужно:

Ссылки

  1. C-модули для Lua: https://www.lua.org/pil/26.2.html
  2. Lua C API: https://www.lua.org/pil/24.html
  3. LuaJIT FFI: http://luajit.org/ext_ffi.html
  4. jit.p-профилировщик: https://blast.hk/moonloader/luajit/ext_profiler.html
  5. Личный блог Брендана Грегга с материалами о профилировании: https://www.brendangregg.com/linuxperf.html
  6. FlameGraph: https://github.com/brendangregg/FlameGraph
  7. Запись в блоге Netflix о профилировании Java: https://netflixtechblog.com/java-in-flames-e763b3d32166
  8. Стандарт DWARF: https://dwarfstd.org/doc/DWARF5.pdf
  9. Описание ORC: https://lwn.net/Articles/728339/
  10. ujit.rtfd.io