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

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

  1. Адаптация существующих инструментов профилирования (например, perf).
  2. Реализация собственного инструмента для конкретной системы, если адаптация существующего не представляется возможной.

Контекст


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

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

  • модули для Lua, написанные на C,
  • вызов Lua-функций через Lua C API,
  • вызовы С-функций из Lua через FFI.

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


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

  • Растет размер бинарного файла, поскольку теперь необходимо хранить таблицы для размотки стека.
  • На старых ядрах Linux это не работает, что может быть критично, если есть нужда работать на устаревших системах.
  • Если в интерпретаторе есть JIT или eval-подобные конструкции, то надо актуализировать информацию 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 были получены данные для целевой картинки из начала статьи:



У него есть несколько режимов:

  • Default — собирает только счетчики семплов в каждом из состояний VM,
  • Leaf — самый верхний фрейм стека + то же, что и в Default,
  • Callchain — полные стеки + то же, что и в Default.

Для 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


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

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

Выводы


В первую очередь всегда стоит попытаться интегрироваться с perf, чтобы получить доступ к огромному количеству функциональности и инструментов анализа.

Для интеграции с perf нужно:

1. Cделать маппинг символы через perf-.map;

2. Обеспечить возможность снятия стеков через один из вариантов:

  • -fno-omit-frame-pointer,
  • DWARF,
  • ORC,
  • свой модуль для Linux-ядра.

Если интегрироваться все-таки не получилось по каким-либо причинам, то для написания своего профилировщика нужно:

  • собрать символы из виртуальной машины и с хоста. С хоста это лучше всего напрямую прочитать из ELF-файла;
  • отдельно снять стеки на хосте и VM, объединить на этапе постпроцессинга.

Ссылки


  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
Скачать Tarantool можно на официальном сайте, а получить помощь — в телеграм-чате.

Комментарии (2)


  1. Gumanoid
    05.09.2023 17:16
    +3

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

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


  1. alexeibs
    05.09.2023 17:16
    +2

    Эх, эту бы статью да год назад :) Мы у себя накрутили обвязку вокруг jit.p профайлера. Он в целом хорош, если нет острой необходимости провязывать стеки С/С++ с Lua. Правда, в многопоточной среде нужно быть аккуратным. К примеру, вот тут в зависимости от дефайнов сборки может не оказаться мьютекса: https://github.com/LuaJIT/LuaJIT/blob/v2.1/src/lj_profile.c#L29

    Кстати, год-два назад Mike Pall вкоммитил полноценный dwarf unwind - External frame unwinding. И вроде как оно и для jit-кода должно работать. По крайней мере какие-то фреймы вот тут регистрируются: https://github.com/LuaJIT/LuaJIT/blob/v2.1/src/lj_err.c#L581
    Непосредственно unwind: https://github.com/LuaJIT/LuaJIT/blob/v2.1/src/lj_err.c#L496