Полёт свиньи, или Оптимизация интерпретаторов байт-кода
«No matter how hard you try, you can’t make a racehorse out of a pig. You can, however, make a faster pig» (комментарий в исходном коде Емакса)
Всем известен тот факт, что свиньи не летают. Не менее популярно мнение о том, что интерпретаторы байт-кодов как техника исполнения языков высокого уровня не поддаются ускорению без применения трудоёмкой динамической компиляции.
Во второй части серии статей об интерпретаторах байт-кодов я на примере небольшой стековой виртуальной машины ПВМ («Поросячья Виртуальная Машина») постараюсь показать, что не всё потеряно для трудолюбивых поросят с амбициями и что в рамках (в основном) стандартного C вполне возможно ускорить работу таких интерпретаторов по меньшей мере в полтора раза.
Давайте знакомиться.
«ПоросёнокВМ» — заурядная стековая машина, основанная на примере из первой части серии статей. Наша свинка знает только один тип данных — 64-битное машинное слово, а все (целочисленные) вычисления производит на стеке максимальной глубиной в 256 машинных слов. Помимо стека, у этого поросёнка имеется рабочая память объёмом 65 536 машинных слов. Результат выполнения программы — одно машинное слово — можно либо поместить в регистр-результат, либо просто вывести в стандартный вывод (stdout).
Всё состояние в машине «ПоросёнокВМ» хранится в единственной структуре:
static struct {
/* Current instruction pointer */
uint8_t *ip;
/* Fixed-size stack */
uint64_t stack[STACK_MAX];
uint64_t *stack_top;
/* Operational memory */
uint64_t memory[MEMORY_SIZE];
/* A single register containing the result */
uint64_t result;
} vm;
Вышеперечисленное позволяет отнести эту машины к низкоуровневым виртуальным машинам, почти все накладные расходы в которых приходятся на обслуживание главного цикла программы:
interpret_result vm_interpret(uint8_t *bytecode)
{
vm_reset(bytecode);
for (;;) {
uint8_t instruction = NEXT_OP();
switch (instruction) {
case OP_PUSHI: {
/* get the argument, push it onto stack */
uint16_t arg = NEXT_ARG();
PUSH(arg);
break;
}
case OP_ADD: {
/* Pop 2 values, add 'em, push the result back to the stack */
uint64_t arg_right = POP();
*TOS_PTR() += arg_right;
break;
}
/*
* ...
* Lots of other instruction handlers here
* ...
*/
case OP_DONE: {
return SUCCESS;
}
default:
return ERROR_UNKNOWN_OPCODE;
}
}
return ERROR_END_OF_STREAM;
}
Из кода видно, что на каждый опкод поросёнок должен:
- Извлечь опкод из потока инструкций.
- Убедиться, что опкод входит в допустимый интервал значений опкодов (эту логику добавляет компилятор C при генерации кода switch).
- Перейти к телу инструкции.
- Извлечь аргументы инструкции из стека или декодировать аргумент инструкции, размещённый непосредственно в байт-коде.
- Выполнить операцию.
- Если есть результат вычисления, поместить его в стек.
- Передвинуть указатель с текущей инструкции на следующую.
Полезная нагрузка здесь только в пятом пункте, всё остальное — накладные расходы: декодирование или извлечение из стека аргументов инструкции (пункт 4), проверка значения опкода (пункт 2), многократное возвращение в начало главного цикла и последующий труднопредсказуемый условный переход (пункт 3).
Словом, у хрюшки явно превышен рекомендованный индекс массы тела, и если мы хотим привести её в форму, то придётся со всеми этими излишествами бороться.
Для начала определимся с правилами игры.
Писать программы для виртуальной машины прямо в C — моветон, но и создавать язык программирования — долго, поэтому мы с поросёнком решили ограничиться свинским языком ассемблера.
Программа, вычисляющая сумму чисел от 1 до 65 536, на этом ассемблере выглядит примерно так:
# sum numbers from 1 to 65535
# init the current sum and the index
PUSHI 1
PUSHI 1
# stack s=1, i=1
STOREI 0
# stack: s=1
# routine: increment the counter, add it to the current sum
incrementandadd:
# check if index is too big
LOADI 0
# stack: s, i
ADDI 1
# stack: s, i+1
DUP
# stack: s, i+1, i+1
GREATER_OR_EQUALI 65535
# stack: s, i+1, 1 or 0
JUMP_IF_TRUE done
# stack: s, i+1
DUP
# stack: s, i+1, i+1
STOREI 0
# stack: s, i+1
ADD
# stack: s+i+1
JUMP incrementandadd
done:
DISCARD
PRINT
DONE
Не Python, конечно, но всё необходимое для поросячьего счастья тут есть: комментарии, метки, условные и безусловные переходы по ним, мнемоники для инструкций и возможность указывать непосредственные аргументы инструкций.
В комплекте с машиной «ПоросёнокВМ» идут ассемблер и дизассемблер, которые смелые духом и имеющие много свободного времени читатели могут самостоятельно опробовать в бою.
Числа суммируются очень быстро, поэтому для тестирования производительности я написал другую программу — наивную реализацию решета Эратосфена.
На самом деле поросёнок и так бегает довольно быстро (его инструкции близки к машинным), поэтому для получения внятных результатов каждый замер я буду делать для ста запусков программы.
Первая версия нашей неоптимизированной свиньи бегает примерно так:
> ./pigletvm runtimes test/sieve-unoptimized.bin 100 > /dev/null
PROFILE: switch code finished took 545ms
Полсекунды! Сравнение, безусловно, нечестное, но тот же алгоритм на Python сто пробежек делает чуть медленнее:
> python test/sieve.py > /dev/null
4.66692185402
4,5 секунды, или в девять раз медленнее. Надо отдать должное поросёнку — способности у него есть! Ну, а теперь давайте посмотрим, может ли наша свинья накачать пресс.
Первое правило быстрого кода — не делать лишней работы. Второе правило быстрого кода — не делать лишней работы никогда. Так какую лишнюю работу делает «ПоросёнокВМ»?
Наблюдение первое: профилирование нашей программы показывает, что есть последовательности инструкций, встречающиеся чаще других. Не будем сильно мучить нашу свинью и ограничимся только парами инструкций:
- LOADI 0, ADD — положить в стек число из памяти по адресу 0 и прибавить его к числу на вершине стека.
- PUSHI 65536, GREATER_OR_EQUAL — положить в стек число и сравнить его с числом, бывшим до этого на вершине стека, положив результат сравнения (0 или 1) обратно в стек.
- PUSHI 1, ADD — положить в стек число, прибавить его к числу, бывшему до этого на вершине стека, и положить результат сложения обратно в стек.
В машине «ПоросёнокВМ» чуть больше 20 инструкций, а для кодирования используется целый байт — 256 значений. Внесение новых инструкций не проблема. Что мы и сделаем:
for (;;) {
uint8_t instruction = NEXT_OP();
switch (instruction) {
/*
* Other instructions here
* */
case OP_LOADADDI: {
/* get immediate argument as an memory address , add it to value from the address to the top
* of the stack */
uint16_t addr = NEXT_ARG();
uint64_t val = vm.memory[addr];
*TOS_PTR() += val;
break;
}
case OP_GREATER_OR_EQUALI:{
/* get the immediate argument, compare it with the value from the address to the top of the stack */
uint64_t arg_right = NEXT_ARG();
*TOS_PTR() = PEEK() >= arg_right;
break;
}
case OP_ADDI: {
/* Add immediate value to the top of the stack */
uint16_t arg_right = NEXT_ARG();
*TOS_PTR() += arg_right;
break;
}
/*
* Other instructions here
* */
}
Ничего сложного. Давайте посмотрим, что из этого получилось:
> ./pigletvm runtimes test/sieve.bin 100 > /dev/null
PROFILE: switch code finished took 410ms
Ого! Кода всего-то на три новых инструкции, а выиграли мы полторы сотни миллисекунд!
Выигрыш здесь достигается благодаря тому, что наш поросёнок при выполнении таких инструкций не делает лишних движений: поток исполнения не вываливается в главный цикл, ничего дополнительно не декодируется, а аргументы инструкций не проходят лишний раз через стек.
Это называется статическими суперинструкциями, поскольку дополнительные инструкции определяются статически, то есть программистом виртуальной машины на этапе разработки. Это простая и эффективная техника, которую в той или иной форме используют все виртуальные машины языков программирования.
Главная проблема статических суперинструкций заключается в том, что без конкретной программы невозможно определить, какие именно инструкции стоит объединить. Разные программы пользуются разными последовательностями инструкций, и узнать эти последовательности можно только на этапе запуска конкретного кода.
Следующим шагом могла бы стать динамическая компиляция суперинструкций в контексте конкретной программы, то есть динамические суперинструкции (в 90-е и в начале 2000-х этот приём играл роль примитивной JIT-компиляции).
В рамках обычного С создавать инструкции на лету невозможно, и наш поросёнок совершенно справедливо не считает это честным соревнованием. К счастью, у меня для него есть пара упражнений получше.
Следуя нашим правилам быстрого кода, ещё раз зададимся вечным вопросом: что можно не делать?
Когда мы знакомились с устройством машины «ПоросёнокВМ», я перечислял все действия, которые виртуальная машина выполняет для каждого опкода. И пункт 2 (проверка значения опкода на вхождение в допустимый интервал значений switch) вызывает больше всего подозрений.
Присмотримся к тому, как GCC компилирует конструкцию switch:
- Строится таблица переходов, то есть таблица, отображающая значение опкода на адрес исполняющего тело инструкции кода.
- Вставляется код, который проверяет, входит ли полученный опкод в интервал всех возможных значений switch, и отправляет к метке default, если для опкода нет обработчика.
- Вставляется код, переходящий к обработчику.
Но зачем делать проверку интервала значений на каждую инструкцию? Мы считаем, что опкод бывает либо правильный — завершающий исполнение инструкцией OP_DONE, либо неправильный — вышедший за пределы байт-кода. Хвост потока опкодов отмечен нулём, а ноль — опкод инструкции OP_ABORT, завершающей исполнение байт-кода с ошибкой.
Выходит, эта проверка вообще не нужна! И поросёнок должен уметь доносить эту мысль до компилятора. Попробуем немного поправить главный switch:
uint8_t instruction = NEXT_OP();
/* Let the compiler know that opcodes are always between 0 and 31 */
switch (instruction & 0x1f) {
/* All the instructions here */
case 26 ... 0x1f: {
/*Handle the remaining 5 non-existing opcodes*/
return ERROR_UNKNOWN_OPCODE;
}
}
Зная, что инструкций у нас всего 26, мы накладываем битовую маску (восьмеричное значение 0×1f — это двоичное 0b11111, покрывающее интервал значений от 0 до 31) на опкод и добавляем обработчики на неиспользованные значения в интервале от 26 до 31.
Битовые инструкции — одни из самых дешёвых в архитектуре x86, и они уж точно дешевле проблемных условных переходов вроде того, который использует проверка на интервал значений. Теоретически мы должны выигрывать несколько циклов на каждой исполняемой инструкции, если компилятор поймёт наш намёк.
Кстати, способ указания интервала значений в case — не стандартный C, а расширение GCC. Но для наших целей этот код подходит, тем более что переделать его на несколько обработчиков для каждого из ненужных значений несложно.
Пробуем:
> ./pigletvm runtimes test/sieve.bin 100 > /dev/null
PROFILE: switch code finished took 437ms
PROFILE: switch code (no range check) finished took 383ms
Ещё 50 миллисекунд! Поросёнок, ты будто бы в плечах раздался!…
Какие ещё упражнения могут помочь нашему поросёнку? Самую большую экономию времени мы получили благодаря суперинструкциям. А они уменьшают количество выходов в главный цикл и позволяют избавиться от соответствующих накладных расходов.
Центральный switch — главное проблемное место для любого процессора с внеочередным выполнением инструкций. Современные предсказатели ветвлений научились неплохо прогнозировать даже такие сложные непрямые переходы, но «размазывание» точек ветвлений по коду может помочь процессору быстро переходить от инструкции к инструкции.
Другая проблема — это побайтовое чтение опкодов инструкций и непосредственных аргументов из байт-кода. Физические машины оперируют 64-битным машинным словом и не очень любят, когда код оперирует меньшими значениями.
Компиляторы часто оперируют базовыми блоками, то есть последовательностями инструкций без ветвлений и меток внутри. Базовый блок начинается либо с начала программы, либо с метки, а заканчивается концом программы, условным ветвлением или прямым переходом к метке, начинающей следующий базовый блок.
У работы с базовыми блоками много преимуществ, но нашу свинью интересует именно её ключевая особенность: инструкции в пределах базового блока выполняются последовательно. Было бы здорово как-нибудь выделять эти базовые блоки и выполнять инструкции в них, не теряя времени на выход в главный цикл.
В нашем случае можно даже расширить определение базового блока до трассы. Трасса в терминах машины «ПоросёнокВМ» будет включать в себя все последовательно связанные (то есть при помощи безусловных переходов) базовые блоки.
Помимо последовательного выполнения инструкций, неплохо было бы ещё заранее декодировать непосредственные аргументы инструкций.
Звучит всё это довольно страшно и напоминает динамическую компиляцию, которую мы решили не использовать. Поросёнок даже немного засомневался в своих силах, но на практике всё оказалось не так плохо.
Давайте сначала подумаем, как можно представить входящую в трассу инструкцию:
struct scode {
uint64_t arg;
trace_op_handler *handler;
};
Здесь arg — заранее декодированный аргумент инструкции, а handler — указатель на функцию, выполняющую логику инструкции.
Теперь представление каждой трассы выглядит так:
typedef scode trace[MAX_TRACE_LEN];
То есть трасса — это последовательность s-кодов ограниченной длины. Сам кеш трасс внутри виртуальной машины выглядит так:
trace trace_cache[MAX_CODE_LEN];
Это просто массив из трасс длиной, не превышающей возможную длину байт-кода. Решение ленивое, практически для экономии памяти имеет смысл использовать хеш-таблицу.
В начале работы интерпретатора первый обработчик каждой из трасс будет сам себя компилировать:
for (size_t trace_i = 0; trace_i < MAX_CODE_LEN; trace_i++ )
vm_trace.trace_cache[trace_i][0].handler = trace_compile_handler;
Главный цикл интерпретатора теперь выглядит так:
while(vm_trace.is_running) {
scode *code = &vm_trace.trace_cache[vm_trace.pc][0];
code->handler(code);
}
Компилирующий трассу обработчик чуть сложнее, и, помимо сборки трассы, начинающейся от текущей инструкции, он делает следующее:
static void trace_compile_handler(scode *trace_head)
{
scode *trace_tail = trace_head;
/*
* Trace building here
*/
/* now, run the chain that has a trace_compile_handler replaced with proper instruction handler
* function pointer */
trace_head->handler(trace_head);
}
Нормальный обработчик инструкции:
static void op_add_handler(scode *code)
{
uint64_t arg_right = POP();
*TOS_PTR() += arg_right;
/*
* Call the next trace handler
* */
/* scodes are located in an array so we can use pointer arithmetic to get the next handler */
code++;
code->handler(code);
}
Завершает работу каждой трассы обработчик, не делающий никаких вызовов в хвосте функции:
static void op_done_handler(scode *code)
{
(void) code;
vm_trace.is_running = false;
vm_trace.error = SUCCESS;
}
Всё это, конечно, сложнее, чем добавление суперинструкций, но давайте посмотрим, дало ли это нам что-нибудь:
> ./pigletvm runtimes test/sieve.bin 100 > /dev/null
PROFILE: switch code finished took 427ms
PROFILE: switch code (no range check) finished took 395ms
PROFILE: trace code finished took 367ms
Ура, ещё 30 миллисекунд!
Как же так? Вместо простых переходов по меткам мы делаем цепочки вызовов обработчиков инструкций, тратим время на вызовы и передачу аргументов, но наш поросёнок всё равно бегает по трассам быстрее простого switch с его метками.
Такой выигрыш в производительности трасс достигается благодаря трём факторам:
- Предсказать ветвления, разбросанные по разным местам кода, легко.
- Аргументы обработчиков всегда предекодированы в полное машинное слово, и делается это только один раз — во время компиляции трассы.
- Сами цепочки функций компилятор превращает в единственный вызов первой функции-обработчика, что возможно благодаря оптимизации хвостового вызова.
Прежде чем подвести итоги наших тренировок, мы с поросёнком решили испробовать ещё одну древнюю технику интерпретации программ — шитый код.
Любая интересующаяся историей интерпретаторов свинья слышала про шитый код (англ. threaded code). Вариантов этого приёма множество, но все они сводятся к тому, чтобы вместо массива опкодов идти по массиву, например, указателей на функции или метки, переходя по ним непосредственно, без промежуточного опкода.
Вызовы функций — дело дорогое и особого смысла в наши дни не имеющее; большая часть других версий шитого кода нереализуема в рамках стандартного C. Даже техника, о которой речь пойдёт ниже, использует широко распространённое, но всё же нестандартное расширение C — указатели на метки.
В версии шитого кода (англ. token threaded code), которую я выбрал для достижения наших свинских целей, мы сохраняем байт-код, но перед началом интерпретации создаём таблицу, отображающую опкоды инструкций на адреса меток обработчиков инструкций:
const void *labels[] = {
[OP_PUSHI] = &&op_pushi,
[OP_LOADI] = &&op_loadi,
[OP_LOADADDI] = &&op_loadaddi,
[OP_STORE] = &&op_store,
[OP_STOREI] = &&op_storei,
[OP_LOAD] = &&op_load,
[OP_DUP] = &&op_dup,
[OP_DISCARD] = &&op_discard,
[OP_ADD] = &&op_add,
[OP_ADDI] = &&op_addi,
[OP_SUB] = &&op_sub,
[OP_DIV] = &&op_div,
[OP_MUL] = &&op_mul,
[OP_JUMP] = &&op_jump,
[OP_JUMP_IF_TRUE] = &&op_jump_if_true,
[OP_JUMP_IF_FALSE] = &&op_jump_if_false,
[OP_EQUAL] = &&op_equal,
[OP_LESS] = &&op_less,
[OP_LESS_OR_EQUAL] = &&op_less_or_equal,
[OP_GREATER] = &&op_greater,
[OP_GREATER_OR_EQUAL] = &&op_greater_or_equal,
[OP_GREATER_OR_EQUALI] = &&op_greater_or_equali,
[OP_POP_RES] = &&op_pop_res,
[OP_DONE] = &&op_done,
[OP_PRINT] = &&op_print,
[OP_ABORT] = &&op_abort,
};
Обратите внимание на символы && — это указатели на метки с телами инструкций, то самое нестандартное расширение GCC.
Для начала выполнения кода достаточно прыгнуть по указателю на метку, соответствующую первому опкоду программы:
goto *labels[NEXT_OP()];
Никакого цикла здесь нет и не будет, каждая из инструкций сама делает прыжок к следующему обработчику:
op_pushi: {
/* get the argument, push it onto stack */
uint16_t arg = NEXT_ARG();
PUSH(arg);
/* jump to the next instruction*/
goto *labels[NEXT_OP()];
}
Отсутствие switch «размазывает» точки ветвлений по телам инструкций, что в теории должно помочь предсказателю ветвлений при внеочередном выполнении инструкций. Мы как бы встроили switch прямо в инструкции и вручную сформировали таблицу переходов.
Вот и вся техника. Поросёнку она понравилась своей простотой. Посмотрим, что получается на практике:
> ./pigletvm runtimes test/sieve.bin 100 > /dev/null
PROFILE: switch code finished took 443ms
PROFILE: switch code (no range check) finished took 389ms
PROFILE: threaded code finished took 477ms
PROFILE: trace code finished took 364ms
Упс! Это самая медленная из всех наших техник! Что же случилось? Выполним те же тесты, выключив все оптимизации GCC:
> ./pigletvm runtimes test/sieve.bin 100 > /dev/null
PROFILE: switch code finished took 969ms
PROFILE: switch code (no range check) finished took 940ms
PROFILE: threaded code finished took 824ms
PROFILE: trace code finished took 1169ms
Здесь шитый код показывает себя лучше.
Тут играют роль три фактора:
- Оптимизирующий компилятор сам построит таблицу переходов не хуже нашей ручной таблички с метками.
- Современные компиляторы замечательно избавляются от лишних вызовов функций.
- Примерно начиная с поколения Haswell процессоров Intel предсказатели ветвлений научились точно прогнозировать переходы через единственную точку ветвлений.
По старой памяти эту технику ещё используют в коде, например, интерпретатора Python VM, но, честно говоря, в наши дни это уже архаизм.
Давайте, наконец, подведём итоги и оценим успехи, которых добилась наша свинья.
Не уверен, что это можно назвать полётом, но, давайте признаем, наш поросёнок прошёл большой путь от 550 миллисекунд на сто пробежек по «решету» до финальных 370 миллисекунд. Мы использовали разные техники: суперинструкции, избавление от проверки интервалов значений, сложную механику трасс и, наконец, даже шитый код. При этом мы, в общем-то, действовали в рамках вещей, реализованных во всех популярных компиляторах C. Ускорение в полтора раза, как мне кажется, это неплохой результат, и поросёнок заслужил лишнюю порцию отрубей в корыте.
Одно из неявных условий, которое мы со свиньёй себе поставили, — сохранение стековой архитектуры машины «ПоросёнокВМ». Переход к регистровой архитектуре, как правило, уменьшает количество необходимых для логики программ инструкций и, соответственно, может помочь избавиться от лишних выходов в диспетчер инструкций. Думаю, ещё 10—20% времени на этом можно было бы срезать.
Основное же наше условие — отсутствие динамической компиляции — тоже не закон природы. Накачать свинью стероидами в виде JIT-компиляции в наши дни очень даже несложно: в библиотеках вроде GNU Lightning или LibJIT вся грязная работа уже сделана. Но время на разработку и общий объём кода даже с использованием библиотек здорово увеличиваются.
Существуют, конечно, и другие приёмы, до которых у нашего поросёнка не дошли копытца. Но пределов совершенству нет, и наше свинское путешествие — вторая часть серии статей про интерпрепретаторы байт-кодов — всё же должно где-то закончиться. Если читателям придут в голову интересные способы разогнать свинью, мы с поросёнком будем рады их испробовать.
PS Отдельная благодарность моей сестре, Ренате Казановой, за эскизы иллюстраций, и нашему иллюстратору, Владимиру Шопотову (https://www.instagram.com/vovazomb/), за финальные рисунки.