Writing an interpreter (virtual machine) for a simple byte-code + JIT compilation

WILD

Administrator
Staff member
ADMIN
SELLER
SUPREME
MEMBER
Joined
Jan 21, 2025
Messages
219
Reaction score
633
Deposit
0$
Есть две статьи о русском языке, автор которых пишет виртуальную машину (интерпретатор) для выполнения простого байт-кода, а затем применяет различные оптимизации для ускорения работы этой виртуальной машины. Кроме того, есть компилятор простого C-подобного языка для этого байт-кода. После прочтения этой статьи и ознакомления с компилятором я подумал, что было бы интересно попробовать написать виртуальную машину для этого языка, которая могла бы применять JIT-компиляцию к этому байт-коду с помощью библиотеки libjit . В этой статье описывается опыт такой работы.

Я нашел в интернете несколько статей, описывающих использование этой библиотеки, но те, что я видел, описывали компиляцию конкретных программ с помощью libjit, тогда как меня интересовала компиляция произвольного байт-кода. Для тех, кто хочет узнать больше, есть официальное руководство , серия статей и ряд сравнительных обзоров (на русском языке).

Реализация выполнена на C++, потому что мы здесь не играем в игры. Весь мой код находится в моём репозитории . В ветке "main" находится только интерпретатор байт-кода PigletVM; в ветке "labels-with-fallbacks" реализована частичная JIT-компиляция (не поддерживающая инструкции JUMP), в ветке "full-jit" реализована полностью рабочая JIT-компиляция; в ветке "making-jit-code-faster" код, сгенерированный JIT, работает быстрее, а в ветках "universal-base-vm*" объединены реализации интерпретатора и JIT-компиляции путем реализации базового обобщенного исполнителя, который может использоваться для различных реализаций PigletVM (как интерпретатора, так и компиляции libjit).

Компиляция в байт-код​

Как я уже упоминал ранее, PigletC может компилировать простой язык, похожий на C, в байт-код PigletVM в текстовом формате. Во-первых, мне понадобится программа, которая будет преобразовывать байт-код из текстового формата в двоичный.

Некоторые подробности:

  • Для простоты я буду кодировать каждую инструкцию 4 байтами (типа int). Это не совсем оптимально, поскольку у нас всего 25 инструкций байт-кода, но это упростит задачу, так как я хочу, чтобы аргументы инструкций также были 4-байтовыми. Теоретически, уменьшение размера исполняемого файла должно ускорить выполнение, но в нашем случае небольших программ разница будет незначительной.
  • Учитывая это, двоичный файл программы будет представлять собой последовательность целых чисел, каждое из которых является кодом операции инструкции, аргументом инструкции или специальным значением (см. далее). Аргументы инструкций следуют только за теми инструкциями, которые имеют аргумент.
  • Инструкции перехода (JUMP, JUMP_IF_TRUE, JUMP_IF_FALSE) сопровождаются индексом инструкции, к которой эта инструкция переходит (т.е. индексом числа, кодирующего целевую инструкцию).
  • Таким образом, программа на ассемблере сначала проходит по байт-коду, генерируя инструкции, но не устанавливая адреса после инструкций перехода — они устанавливаются в конце, когда становится известен адрес каждой инструкции.
  • Для удобства я решил добавить два специальных числа на место каждой метки в бинарном файле: 0xcafe и 0xbabe. Таким образом, при анализе байт-кода будет понятно, куда могут вести инструкции перехода. Хотя это на самом деле не обязательно (и влияет на производительность), поскольку я мог бы сохранить адреса после всех инструкций перехода.
В остальном, в коде ассемблера нет ничего действительно интересного. Его можно посмотреть здесь .

Интерпретация байт-кода​

Теперь пришло время написать интерпретатор байт-кода, чтобы у нас было с чем сравнивать выполнение JIT-скомпилированного кода.

Состояние виртуальной машины определяется следующими элементами:

  • Стек. В стеке можно хранить 4-байтовые знаковые числа.
  • Память — это строка с 4-байтовыми знаковыми числами, доступ к которым осуществляется с помощью индекса.
  • Номер следующей инструкции для выполнения (указатель инструкций)
Изначально мы могли бы использовать контейнеры STL для стека и вектора для памяти соответственно (или вектор или двустороннюю очередь для обоих случаев) — тогда размеры стека и памяти не нужно было бы ограничивать какими-либо константами. Однако в итоге я использовал вместо этого обычные указатели, потому что контейнеры STL плохо работали бы с JIT-компиляцией (технически, мы можем вызывать произвольные функции из JIT-скомпилированного кода, но встраивание методов контейнеров STL было бы невозможно, что привело бы к значительному снижению производительности).

В целом, реализация интерпретатора довольно проста: это большой переключатель, который выполняет каждую инструкцию.

код

Переход к JIT-компиляции​

Поскольку я пишу на C++, я буду использовать версию libjit, предназначенную для этого языка, с классами типа "блэкджек" и "хукерс", а также наследованием.

Принцип работы libjit заключается в том, что сначала генерируется промежуточное представление (IR) кода вашей функции, а затем libjit преобразует это представление в машинный код. IR генерируется путем вызова функций libjit.

Для создания функции, компилируемой JIT-компилятором, необходимо определить класс, наследуемый от jit_function, определить конструктор и переопределить методы build и create_signature. В действительности, программа на PigletC содержит только одну функцию, и байт-код не содержит инструкций для вызова функций и возврата из них (а поскольку инструкции JUMP могут переходить только к адресам, известным во время компиляции, мы не можем иметь функции без дополнительной поддержки со стороны байт-кода). Тем не менее, мы можем определить универсальную функцию create_signature:

jit_type_t create_signature() override {
auto ret_type = (is_void ? jit_type_void : jit_type_int);
jit_type_t params[num_args];
for (int i = 0; i < num_args; i++) {
params = jit_type_int;
}
return jit_type_create_signature(jit_abi_cdecl, ret_type, params, num_args, 1);
}


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

Теперь нам осталось только создать функцию на основе байт-кода. Здесь я потратил некоторое время на размышления о том, что делать со стеком. Когда интерпретатор выполняет код, стеком является массив/вектор/std::stack, определенный в самой виртуальной машине. Что касается JIT-компиляции, я подумал, что было бы эффективно использовать реальный стек (тот, на который ссылается %rspregister на x86). Однако я не нашел в libjit методов, которые бы это позволяли: во-первых, библиотека предназначена для кроссплатформенной работы, и стек может отличаться на разных архитектурах, а во-вторых, libjit будет использовать сам стек и не предоставит прямого доступа к нему.

Здесь важно четко понимать, какой код выполняется при создании промежуточного представления (IR), а какой — во время выполнения JIT-скомпилированной функции. Например, может возникнуть необходимость создать массив значений типа jit_type_int в функции build для стека. Но индексы в стеке неизвестны во время компиляции, поэтому это невозможно.

Учитывая это, мы передадим указатели на память, стек и размер стека в конструктор JIT-компилируемой функции и будем использовать их так же, как и в интерпретаторе. Побочным эффектом этого станет возможность переключения между выполнением функции в JIT-режиме и в режиме интерпретации — и если JIT-компиляция не поддерживает некоторые инструкции, она вернется к интерпретатору, обнаружив такую инструкцию, который выполнит неподдерживаемые инструкции. На данный момент JIT-компиляция не поддерживает инструкции JUMP.

Другой вариант — вызвать функцию malloc из JIT-компилятора и использовать выделенную память для стека.

Подготовка к компиляции функций будет выглядеть следующим образом:

// Создание указателя на int* и запись внешнего указателя на память в него
автоматическая память = (jit_type_create_pointer(jit_type_int, 1));
memory = this->new_constant(memory_outer);
// делаем то же самое для стека
auto stack = new_value(jit_type_create_pointer(jit_type_int, 1));
stack = this->new_constant(stack_outer);
// Создание указателя на размер стека
jit_value stack_size_ptr = new_value(jit_type_create_pointer(jit_type_int, 1));
stack_size_ptr = this->new_constant(stack_size_ptr_outer);
// Удобная обертка над функцией для добавления элементов
// в стек
auto push = [this, &stack, &stack_size_ptr] (jit_value&& value) {
push_on_stack(stack, stack_size_ptr, std::move(value));
};
// То же самое для извлечения элемента из стека
auto pop = [this, &stack, &stack_size_ptr] () {
return pop_from_stack(stack, stack_size_ptr);
};
int& ip = *ip_ptr;


А где-то внизу:

void push_on_stack(jit_value& stack, jit_value& stack_size_ptr, jit_value&& arg) {
insn_store_elem(stack, insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int), arg);
insn_store_elem(stack_size_ptr, new_constant(0),
insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int) + new_constant(1));
}

jit_value pop_from_stack(jit_value& stack, jit_value& stack_size_ptr) {
insn_store_elem(stack_size_ptr, new_constant(0),
insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int) - new_constant(1));
return insn_load_elem(stack, insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int), jit_type_int);
}

jit_value peek_stack(jit_value& stack, jit_value& stack_size_ptr) {
return insn_load_elem(stack, insn_load_elem(stack_size_ptr, new_constant(0), jit_type_int) - new_constant(1),
jit_type_int);
}


Все переменные и значения, которые может использовать JIT-скомпилированная функция, должны быть объявлены как переменные типа jit_value.

Для удобства нам нужно написать функции, которые будут «помещать значения в стек» и «извлекать значения из стека». Они взяты в кавычки, потому что в реальности они этого делать не будут — они будут добавлять соответствующие действия в промежуточное представление функции.

Теперь обработка инструкций байт-кода относительно проста. Вот несколько примеров:

case OP_STORE: {
auto val = pop();
auto index = pop();
insn_store_elem(memory, index, val);
перерыв;
}
case OP_LOAD: {
auto index = pop();
push(insn_load_elem(memory, index, jit_type_int));
перерыв;
}
case OP_PRINT: {
auto tmp = pop().raw(); // .raw возвращает структуру C из обертки C++
this->insn_call_native("print_int", (void*)(&print_int), signature_helper(jit_type_void, jit_type_int, end_params),
&tmp, 1, 0);
перерыв;
}


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

Здесь можно отметить, что код создания промежуточного представления libjit довольно похож на код интерпретатора. Кажется, мы могли бы заставить интерпретатор и JIT-компиляцию использовать один и тот же код для большинства инструкций — и мы сделаем это позже. Это позволило бы нам избежать дублирования работы при добавлении новых инструкций.

Теперь виртуальная машина проверит, является ли следующая инструкция переходом (JUMP). Если да, она будет выполнена. В противном случае, к следующим инструкциям будет применена JIT-компиляция до тех пор, пока не будет достигнут следующий переход. Скомпилированный JIT-код будет сохранен в кэше и использован повторно.

Естественно, это работает очень медленно — компилируемые части относительно невелики, и интерпретатор вызывается довольно часто. Пока это всего лишь демонстрация того, что мы можем сделать с libjit.

Теперь нам нужно добавить поддержку инструкций JUMP. В libjet, помимо переменных, мы можем создавать метки и выполнять с ними две операции: задавать место размещения метки в коде и определять инструкции JUMP для этой метки.

Теперь создадим словарь, содержащий сопоставление адресов инструкций с метками. При встрече с 0xcafe мы присваиваем метку именно этому адресу. При встрече с инструкцией JUMP мы указываем, что должен быть переход к метке, полученной из словаря по соответствующему адресу инструкции. Код:

case 0xcafe: {
// пропускаем 0xbabe
ip++;
// Теперь ip указывает на инструкцию после метки
if (labels.count(ip) == 0) {
labels[ip] = jit_label_undefined;
}
insn_label(labels[ip]);
перерыв;
}
case OP_JUMP: {
auto arg = instructions[ip];
ip++;
if (labels.count(arg) == 0) {
labels[arg] = jit_label_undefined;
}
insn_branch_if(new_constant(true), labels[arg]);
перерыв;
}
case OP_JUMP_IF_TRUE: {
auto arg = instructions[ip];
ip++;
if (labels.count(arg) == 0) {
labels[arg] = jit_label_undefined;
}
insn_branch_if(pop(), labels[arg]);
перерыв;
}
case OP_JUMP_IF_FALSE: {
auto arg = instructions[ip];
ip++;
if (labels.count(arg) == 0) {
labels[arg] = jit_label_undefined;
}
insn_branch_if_not(pop(), labels[arg]);
перерыв;
}


На этом этапе мы можем запустить виртуальную машину для выполнения простого кода, ожидая, что он будет работать намного быстрее с JIT-компиляцией. Однако наша JIT-компиляция лишь замедлила выполнение кода — 1,2 секунды с интерпретатором против 1,4 секунды с JIT-компиляцией!

В чём проблема? Проблема в том, что мы генерируем довольно сложный код (потому что я ожидал, что оптимизатор справится со всем), в то время как оптимизатор libjit относительно слаб и не может его упростить.

Давайте попробуем оптимизировать сгенерированный код.

Реализация инструкций байт-кода относительно проста, в то время как операции со стеком выглядят несколько многословными, поскольку мы не только увеличиваем или уменьшаем переменную с размером стека, но и считываем её по некоторому адресу, а затем записываем обратно, и libjit, вероятно, не может поместить размер стека в регистр. Давайте упростим это, сделав размер стека локальной переменной. Мы всё ещё сможем сохранить возможность совместной работы интерпретатора и JIT-компилятора, если сохраним размер стека до того, как JIT-компилятор вернётся.

jit_value stack_size = new_value(jit_type_int);
stack_size = new_constant(0);
auto push = [this, &stack, &stack_size](jit_value&& value) {
insn_store_elem(stack, stack_size, value);
stack_size = stack_size + new_constant(1);
};
auto pop = [this, &stack, &stack_size]() {
stack_size = stack_size - new_constant(1);
return insn_load_elem(stack, stack_size, jit_type_int);
};
auto peek = [this, &stack, &stack_size]() {
return insn_load_elem(stack, stack_size - new_constant(1), jit_type_int);
};


На этот раз улучшение производительности очевидно и значительно: 1,2 секунды против 0,4 секунды.

С другой стороны, можно было бы ожидать более значительного улучшения. Я подозреваю, что слабые оптимизации libjit в сочетании с тем фактом, что PigletVM — это стековая машина, а не регистровая, не позволяют коду работать действительно быстро — инструкции байт-кода постоянно используют стек, что намного медленнее, чем использование регистров. Эту проблему можно частично решить, заменив некоторые последовательности инструкций более сложными инструкциями (как это сделали авторы оригинальных статей о PigletVM, представив PUSHI, STOREI, LOADADDI и т. д.).

При измерении производительности я тестировал байт-код, полученный в результате компиляции следующей программы на языке PigletC:

int res;
int i;

void main() {
i = 1000000000;
пока (i > 0) {
i = i - 1;
}
print(i);
 
Top Bottom