- PVSM.RU - https://www.pvsm.ru -
Около шестнадцати лет назад вышла первая версия Hotspot – реализация JVM, впоследствии ставшая стандартной виртуальной машиной, поставляемой в комплекте JRE от Sun.
Основным отличием этой реализации стал JIT-компилятор, благодаря которому заявления про медленную Джаву во-многих случаях стали совсем несостоятельными.
Сейчас почти все интерпретируемые платформы, такие как CLR, Python, Ruby, Perl, и даже замечательный язык программирования R, обзавелись своими реализациями JIT-трансляторов.
В рамках этой статьи я не планирую проливать свет на малоизвестные детали реализации промышленных JIT-компиляторов, скорее это будет совсем поверхностное ознакомление с азами и рассказ про учебный проект по соответствующей тематике.
Таким образом вам может быть интересно под катом, если:
Чтобы говорить о преимуществах сначала разберемся, с тем, что это такое. Начнем издалека, а именно с определения компилируемого языка.
Компилируемый язык программирования — язык программирования, исходный код которого преобразуется компилятором в машинный код конкретной архитектуры, например x86/ARM. Этот машинный код представляет собой последовательность команд, которая совсем понятна вашему процессору, и может быть исполнена им без посредника.
Интерпретируемые языки программирования отличаются тем, что исходники не преобразовываются в машинный код для непосредственного выполнения на ЦПУ, а исполняются с помощью специальной программы-интерпретатора.
Как промежуточный вариант, многие языки (Java, C#, Python) транслируются в машинно-независимый байт-код [1],
который все еще не может быть исполнен напрямую на ЦПУ, и чтобы его исполнить все еще необходим интерпретатор.
Почему интерпретация медленней? Ответ на этот вопрос, как ни странно не всем кажется очевидным, хотя он действительно очень простой: интерпретируя каждую команду по отдельности, мы затрачиваем дополнительные ресурсы на перевод семантики этой команды на язык процессора. Кроме того современные процессоры оптимизированы для выполнения последовательных команд (см. Конвейер [2]), а интерпретатор чаще всего представляет собой огромный switch с кучей опций, загоняющий в тупик предсказатель переходов [3].
Вполне естественным решением всех этих проблем было бы лишь однажды перевести байт-код на язык процессора и передать его ЦПУ для исполнения. Так чаще всего и поступают, называя этот процесс Just-in-time-компиляцией (JIT). Кроме того, именно в течение этой фазы чаще всего проводятся различные оптимизации генерируемого кода.
Теперь перейдем к практике.
Курс “Виртуализация и виртуальные машины” в нашем университете читает Николай Иготти, принимавший участие в разработке самых разных классов ВМ: Hotspot, VirtualBox, NativeClient, и не понаслышке знающий детали их реализации. Благодаря чудесам современных технологий, чтобы узнать про это все подробней необязательно даже быть студентом Академического Университета, так как курс опубликован на лекториуме [4]. Хотя нужно отметить, что это конечно немного не то, в силу интерактивности курса и работе с аудиторией на лекциях.
В рамках курса для получения допуска к экзамену студентам было предложено написать свою реализацию простой стековой виртуальной машины-интерпретатора и транслятор в байт-код из C-подобного языка. На самом деле задание несколько проще, чем может показаться на первый взгляд – вместе с заданием выдается много готового кода на C++:
Кодовое название у всего этого задания – mathvm, видимо в силу того, что основная цель языка программирования, как это часто бывает, что-нибудь посчитать.
Типичная программа на языке mathvm:
function int fib(int x) {
if (x <= 1) {
return x;
}
int r = fib(x - 1);
return r + fib(x - 2);
}
print(fib(35), 'n');
Основные возможности языка:
function void printfIDS(string format, int x1, double x2, string x3) native ‘printf’;
printfIDS(‘%d %f %sn’, 1, 2, ‘abc’);
Мне кажется именно последний пункт делает задание намного веселей:
Внимательный читатель мог заметить, что в программе из последнего пункта зачем-то вычисляется количество отрисовываемых кадров в секунду (FPS). Этот показатель позволяет оценить производительность интерпретатора и позволяет таким образом привнести соревновательный дух в казавшийся кому-то скучным учебный процесс.
Студент, интерпретатор которого будет самым быстрым на этом и еще паре подобных бенчмарков, автоматически сдает экзамен, а кроме того получает любовь и уважение коллег. Надо отметить, что мы не первый поток, с которым играли в эту игру, и обычно для победы нужно было реализовать JIT-компилятор.
Казалось бы, что существенно сложного в том, чтобы просто перевести семантику несложных байт-код инструкций в соответствующие элементы набора инструкций x86?
Для решения первых двух проблем вполне подходит Asmjit [8] – библиотека с открытым исходным кодом, развиваемая по большей части одним человеком в течение последних нескольких лет, и используемая в других проектах автора, а также в JIT для скриптов на сервере GTA San Andreas MP [9].
Как несложно понять из названия, её основная миссия – создание удобного API для генерации машинного кода Just-in-time. Eё публичный интерфейс можно разделить на две части:
В своей реализации я использовал именно Assembler, подкупивший меня своей прозрачностью и более тонкой управляемостью.
Самый простой работающий пример использования Asmjit:
#include "asmjit/asmjit.h"
#include <iostream>
int main(int argc, char** argv) {
using namespace std;
using namespace asmjit;
using namespace asmjit::x86;
JitRuntime runtime; // RAII object
X86Assembler assembler(&runtime);
assembler.add(rdi, rsi); // add second argument to first
assembler.mov(rax, rdi); // mov result to return register
assembler.ret();
void * address = assembler.make();
cout << reinterpret_cast<int64_t (*) (int64_t, int64_t)>(address)(2, 2) << endl; // 4
return 0;
}
Я думаю у людей, знакомых с ассемблером, он не должен вызвать много вопросов. Единственное, о чем следует упомянуть – объект runtime, отвечающий за время жизни выделенной памяти. Она будет освобождена после вызова его деструктора (RAII [10]).
Еще примеры использования можно подсмотреть в каталоге с тестами библиотеки или у меня в репозитории.
Как ни странно отладка сгенерированного кода не отличается особыми извращениями.
Во-первых, если что-то пошло не так, можно вдумчиво посмотреть логи Asmjit. В логах можно увидеть полученный ассемблерный код и немножко комментариев, откуда он здесь появился.
В случае, если даже это вам не помогло, то в большинстве современных дебаггеров (gdb/VS) есть возможность работы в режиме отладки с дизассемблированием. То есть вы можете поставить брейкпойнт на место вызова сгенерированной функции, и Step Into переведет вас на экран с ассемблерным кодом, где можно почти как обычно пошагово отлаживать.
Важно
После трех дней активного программирования и отладки появился первый совершенно бесхитростный вариант JIT-транслятора, в котором все значения стека и переменных каждый раз бездумно сохранялись в память.
Даже такое решение дало прирост производительности примерно в шесть раз, с 6 FPS, которые получались с интерпретатором до 36 FPS.
Вообще в начале семестра, когда выяснились правила игры, у меня были наполеоновские планы: сделать все совсем по-взрослому – с переводом байткода в SSA [11] и умным алгоритмом регистровой аллокации.
Но в связи с острой нехваткой времени и банальным малодушием все закончилось несколько прозаичней.
На всякий случай напомню, что один из наиболее критичных пунктов в плане производительности программы – эффективность использования имеющихся регистров ЦПУ.
Это связано с тем, что основная вычислительная деятельность может производиться только на них, а кроме того чтение/запись даже в память, находящуюся в L1-кэше, работает до двух раз дольше, чем аналогичные операции над регистрами.
Я воспользовался не самым сложным, но зато довольно действенным эвристическим решением: будем хранить в регистрах первые элементы стека виртуальной машины, 7 элементов для слотов общего назначения (строки/целые числа) и 14 для слотов для чисел с плавающей точкой.
Это решение кажется наиболее оправданным, так как наиболее горячими переменными в рамках работы функции действительно является именно низ стека, участвующий во всех вычислениях.
Кроме того, если использовать те же самые регистры, по которым раскладываются аргументы при вызове функций, то это в некоторых случаях позволяет сэкономить время в местах вызова.
В результате реализации этих идей, я получил ускорение на 9 FPS, таким образом достигнув 45 FPS, что не могло меня не радовать.
Одним из простых классических подходов при генерации являются так называемые Peephole-оптимизации [12], идея которых заключается в поиске и замене определенных последовательностей инструкций на другие, более производительные.
Например из-за недостатка выразительности байт-кода mathvm, операторы сравнения вроде (x0 <= x1) приходится описывать как-то так:
LOADIVAR_0
LOADIVAR_1
IF_CMPLE L1
ILOAD_0 // загрузка константы 0 на вершину стека
JA L2 // безусловный переход
L1:
ILOAD_1
L2:
...
Знатоки Instructions set’а x86, скажут, что такой код можно записать гораздо короче и без единого условного прыжка:
mov rdi, %(var0)
mov rsi, %(var1)
cmp rdi, rsi
setle al
movzx rax, al
Подобные конструкции встречаются и в других случаях: оператор отрицания, цикл for. Детектирование и исправление этих паттернов привело к ускорению на 4 FPS до 49 пунктов.
Для обеспечания работы с замыканиями в прологе и эпилоге каждой функции я сохранял в специально выделенную область памяти вне стека адрес, где расположены переменные последнего вызова.
Кроме того нужно сохранять и восстанавливать предыдущий base pointer. На всю эту радость уходило до семи лишних инструкций на каждый вызов, в том числе активно работающих с памятью.
На деле же большинство функций не содержит внутри себя замыканий, и этих потерь можно избежать.
Поэтому я провел дополнительный анализ всего полученного байт-кода на предмет наличия замыканий и соответствующие избыточные инструкции были удалены, в результате чего мультик стал рисоваться еще на 3 FPS быстрее.
Заключительный пункт истории про микрооптимизации – inlining или по-русски ”встраивание”. Метод, с помощью которого промышленные компиляторы борются с расходами на вызов маленьких функций: код вызываемой функции просто дублируется в месте вызова, без пролога/эпилога и прочих “лишних” инструкций. Правда надо отметить, что в настоящей жизни к решению о встраивании компиляторы подходят очень осторожно – размер получаемого машинного кода все-таки тоже является мерилом качества их работы.
Однако в моем случае, так как оценивается именно время работы, я решил попробовать встраивать все, что встраивается (нерекурсивные вызовы).
И вот здесь как ни странно оказалось легче работать на уровне трансляции AST в байт-код.
Полученный прирост на 2 FPS был уже не таким значительным, как в предыдущих случаях, но суммарные 53-54 FPS (почти девятикратное ускорение) не могут не радовать. Ускорение таких же порядков наблюдалось и на других доступных бенчмарках.
Что интересно, даже в моем простом JIT-трансляторе для получения пиковой производительности требовалось несколько итераций разогрева, хотя я совсем не запускал интерпретатор и сразу компилировал весь код.
Это иллюстрирует высказывания авторитетных людей про то, что warmup в бенчмарках нужен не только для того, чтобы все, что нужно, скомпилировалось, а еще и для устаканивания профиля программы.
В результате моя реализация стала самой быстрой, однако связано это не столько с тем, что я самый хитрый, сколько с тем, что никто из соперников не сделал даже самую простую реализацию JIT’а.
Таким образом отсутствие хоть какой-то спортивной интриги немного омрачило победу.
Исходники моей реализации можно посмотреть в репозитории [13], в каталоге impl.
Заранее извиняюсь за качество кода, но это все-таки игрушечный проект, и поддерживать его дальше я не планирую.
Спасибо за внимание!
Автор: bintree
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/virtualizatsiya/83244
Ссылки в тексте:
[1] байт-код: https://ru.wikipedia.org/wiki/%D0%91%D0%B0%D0%B9%D1%82-%D0%BA%D0%BE%D0%B4
[2] Конвейер: https://ru.wikipedia.org/wiki/%D0%92%D1%8B%D1%87%D0%B8%D1%81%D0%BB%D0%B8%D1%82%D0%B5%D0%BB%D1%8C%D0%BD%D1%8B%D0%B9_%D0%BA%D0%BE%D0%BD%D0%B2%D0%B5%D0%B9%D0%B5%D1%80
[3] предсказатель переходов: https://ru.wikipedia.org/wiki/%D0%9F%D1%80%D0%B5%D0%B4%D1%81%D0%BA%D0%B0%D0%B7%D0%B0%D1%82%D0%B5%D0%BB%D1%8C_%D0%BF%D0%B5%D1%80%D0%B5%D1%85%D0%BE%D0%B4%D0%BE%D0%B2
[4] опубликован на лекториуме: https://www.lektorium.tv/course/22769
[5] dlsym: http://pubs.opengroup.org/onlinepubs/009695399/functions/dlsym.html
[6] System V ABI: http://www.x86-64.org/documentation/abi.pdf
[7] такое: http://pastebin.com/aR4aVRAx
[8] Asmjit: https://github.com/kobalicek/asmjit
[9] JIT для скриптов на сервере GTA San Andreas MP: https://github.com/Zeex/samp-plugin-jit
[10] RAII: https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D0%BB%D1%83%D1%87%D0%B5%D0%BD%D0%B8%D0%B5_%D1%80%D0%B5%D1%81%D1%83%D1%80%D1%81%D0%B0_%D0%B5%D1%81%D1%82%D1%8C_%D0%B8%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F
[11] SSA: https://ru.wikipedia.org/wiki/SSA
[12] Peephole-оптимизации: http://en.wikipedia.org/wiki/Peephole_optimization
[13] репозитории: https://github.com/bintree/mathvm-impl
[14] Источник: http://habrahabr.ru/post/250841/
Нажмите здесь для печати.