Написать пост

Основы Just In Time компиляции, используемой в динамических языках, на примере программы на C

Аватар Типичный программист

Обложка поста Основы Just In Time компиляции, используемой в динамических языках, на примере программы на C

Я был сильно вдохновлен, когда узнал о динамической компиляции (JIT — Just In Time) из различных виртуальных машин Ruby и JavaScript. Я мог бы рассказать вам все о том, как работает «компиляция на лету» и как она может дать прирост в производительности для вашего интерпретируемого языка. Это было бы здорово. Но проблема в том, что я никогда не мог понять и, тем более, предположить, как работает JIT-компиляция.

Как вообще возможно компилировать код во время его выполнения? Я спросил Бенджамина Петерсона о том, где он так много узнал о технологии JIT. В ответ он меня направил к проекту pypy (это исходники Python JIT). Но изучение проекта заняло бы довольно много времени, а я всего лишь хотел простой пример для понимания работы технологии.

К счастью, у меня есть отличная работа, где я могу встретиться лицом к лицу с теми людьми, которые занимаются производством JIT-компиляторов. Часть прошедшей недели я провел на конференции GDC (Game Developers Conference), где увидел демку игры, разработанную на движке Unreal Engine 3 и запущенную в браузере. Ребята сделали большую работу, создавая эту демку, и я обязательно напишу о ней более подробно чуть позже. Однако Люк Вагнер (Luke Wagner) — JavaScript-программист из Mozilla — оптимизировал asm.js, добавив OdinMonkeys в SpiderMonkeys.

Люк очень дружелюбный человек. Сначала я слушал его разговоры с Дейвом Херманом и Алоном Закаи, но потом задал ему интересующий меня вопрос. Люк очень доступно все мне объяснил. Скомпилировать простой объектный файл, использовать objdump для получения специфичной для конкретной платформы сборки, использовать системный вызов mmap для выделения памяти, которую вы можете прочитать И выполнить, скопировать инструкции в этот буфер, привести его к типу указателя на функцию и, в конце концов, вызвать его.

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

			// Compile me with: clang -c mul.c -o mul.o
int mul (int a, int b) {
    return a * b;
}
		

Кстати, я работаю на 64-битной OS X. Таким образом, моя сборка может отличаться от вашей. Очевидно, что JIT-компиляция абстрагируется от платформозависимости, но, как бы то ни было, все инструкции в этой статье работают как на x86, так и на x64. Дальше нужно будет воспользоваться утилитами по работе с бинарными файлами . У меня ее не было, поэтому я установил эту утилиту при помощи команды brew install binutils.

Если у вас уже есть необходимые утилиты (в том числе [g]objdump), переходим к следующему шагу: чтение машинного кода из объектного файла, представленный в шестнадцатеричном формате. При запуске gobjdump -j .text -d mul.o -M intel вы должны получить приблизительно такое (приблизительно, потому что у нас могут быть разные платформы):

			$ gobjdump -j .text -d mul.o -M intel

Disassembly of section .text:

0000000000000000 :
0: 55 push rbp
1: 48 89 e5 mov rbp,rsp
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
a: 8b 75 fc mov esi,DWORD PTR [rbp-0x4]
d: 0f af 75 f8 imul esi,DWORD PTR [rbp-0x8]
11: 89 f0 mov eax,esi
13: 5d pop rbp
14: c3 ret
		

Итак, эти инструкции различаются по размеру. Я не знаю, как это будет выглядеть на платформе x86, поэтому не могу подробно комментировать эти строки. Однако очевидно, что это пары шестнадцатеричных цифр. 162 == 28 означает, что каждая пара шестнадцатеричных цифр может быть представлена одним байтом (тип char). Таким образом, мы можем записать все эти числа в массив unsigned char [].

Справочник man подробно описывает все забавные флаги функции mmap(). Примечательно, что такой способ выделения памяти может делать ее исполняемой. Таким свойством не обладает выделение памяти при помощи функции alloc(). Наверняка разработчики JavaScript каким-то образом пользуются такой возможностью.

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

			#include <stdio.h> // printf
#include <string.h> // memcpy
#include <sys/mman.h> // mmap, munmap

int main () {
// Hexadecimal x86_64 machine code for: int mul (int a, int b) { return a * b; }
unsigned char code [] = {
    0x55, // push rbp
    0x48, 0x89, 0xe5, // mov rbp, rsp
    0x89, 0x7d, 0xfc, // mov DWORD PTR [rbp-0x4],edi
    0x89, 0x75, 0xf8, // mov DWORD PTR [rbp-0x8],esi
    0x8b, 0x75, 0xfc, // mov esi,DWORD PTR [rbp-04x]
    0x0f, 0xaf, 0x75, 0xf8, // imul esi,DWORD PTR [rbp-0x8]
    0x89, 0xf0, // mov eax,esi
    0x5d, // pop rbp
    0xc3 // ret
};

    // allocate executable memory via sys call
    void* mem = mmap(NULL, sizeof(code), PROT_WRITE | PROT_EXEC,
                     MAP_ANON | MAP_PRIVATE, -1, 0);

    // copy runtime code into allocated memory
    memcpy(mem, code, sizeof(code));

    // typecast allocated memory to a function pointer
    int (*func) () = mem;

    // call function pointer
    printf("%d * %d = %d
", 5, 11, func(5, 11));

    // Free up allocated memory
    munmap(mem, sizeof(code));
}
		

Вуаля! Получилось очень аккуратно, а главное — оно работает! Помощь Люка Вагнера, час работы и эта статья в частности — вот то, что понадобилось для реализации технологии JIT.

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

Следите за новыми постами
Следите за новыми постами по любимым темам
19К открытий19К показов