jit что это такое

Java HotSpot JIT компилятор — устройство, мониторинг и настройка (часть 1)

AOT и JIT компиляторы

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

Существуют компилируемые языки программирования, такие как C и C++. Программы, написанные на этих языках, распространяются в виде машинного кода. После того, как программа написана, специальный процесс — Ahead-of-Time (AOT) компилятор, обычно называемый просто компилятором, транслирует исходный код в машинный. Машинный код предназначен для выполнения на определенной модели процессора. Процессоры с общей архитектурой могут выполнять один и тот же код. Более поздние модели процессора как правило поддерживают инструкции предыдущих моделей, но не наоборот. Например, машинный код, использующий AVX инструкции процессоров Intel Sandy Bridge не может выполняться на более старых процессорах Intel. Существуют различные способы решения этой проблемы, например, вынесение критичных частей программы в библиотеку, имеющую версии под основные модели процессора. Но часто программы просто компилируются для относительно старых моделей процессоров и не используют преимущества новых наборов инструкций.

В противоположность компилируемым языкам программирования существуют интерпретируемые языки, такие как Perl и PHP. Один и тот же исходный код при таком подходе может быть запущен на любой платформе, для которой существует интерпретатор. Минусом этого подхода является то, что интерпретируемый код работает медленнее, чем машинный код, делающий тоже самое.

Язык Java предлагает другой подход, нечто среднее между компилируемыми и интерпретируемыми языками. Приложения на языке Java компилируются в промежуточный низкоуровневый код — байт-код (bytecode).

Название байт-код было выбрано потому, что для кодирования каждой операции используется ровно один байт. В Java 10 существует около 200 операций.

Байт-код затем исполняется JVM также как и программа на интерпретируемом языке. Но поскольку байт-код имеет строго определенный формат, JVM может компилировать его в машинный код прямо во время выполнения. Естественно, старые версии JVM не смогут сгенерировать машинный код, использующий новые наборы инструкций процессоров вышедших после них. С другой стороны, для того, чтобы ускорить Java-программу, ее даже не надо перекомпилировать. Достаточно запустить ее на более новой JVM.

HotSpot JIT компилятор

Единица скомпилированного кода называется nmethod (сокращение от native method).

Многоуровневая компиляция (tiered compilation)

На самом деле в HotSpot JVM существует не один, а два компилятора: C1 и C2. Другие их названия клиентский (client) и серверный (server). Исторически C1 использовался в GUI приложениях, а C2 в серверных. Отличаются компиляторы тем, как быстро они начинают компилировать код. C1 начинает компилировать код быстрее, в то время как C2 может генерировать более оптимизированный код.

Существует 5 уровней компиляции:

Последовательность Описание
0-3-4 Интерпретатор, уровень 3, уровень 4. Наиболее частый случай.
0-2-3-4 Случай, когда очередь уровня 4 (C2) переполнена. Код быстро компилируется на уровне 2. Как только профилирование этого кода завершится, он будет скомпилирован на уровне 3 и, наконец, на уровне 4.
0-2-4 Случай, когда очередь уровня 3 переполнена. Код может быть готов к компилированию на уровне 4 все еще ожидая своей очереди на уровне 3. Тогда он быстро компилируется на уровне 2 и затем на уровне 4.
0-3-1 Случай простых методов. Код сначала компилируется на уровне 3, где становится понятно, что метод очень простой и уровень 4 не сможет скомпилировать его оптимальней. Код компилируется на уровне 1.
0-4 Многоуровневая компиляция выключена.

Code cache

Машинный код, скомпилированный JIT компилятором, хранится в области памяти называемой code cache. В ней также хранится машинный код самой виртуальной машины, например, код интерпретатора. Размер этой области памяти ограничен, и когда она заполняется, компиляция прекращается. В этом случае часть «горячих» методов так и продолжит выполняться интерпретатором. В случае переполнения JVM выводит следующее сообщение:

Другой способ узнать о переполнении этой области памяти — включить логирование работы компилятора (как это сделать обсуждается ниже).
Code cache настраивается также как и другие области памяти в JVM. Первоначальный размер задаётся параметром -XX:InitialCodeCacheSize. Максимальный размер задается параметром -XX:ReservedCodeCacheSize. По умолчанию начальный размер равен 2496 KB. Максимальный размер равен 48 MB при выключенной многоуровневой компиляции и 240 MB при включенной.

Начиная с Java 9 code cache разделен на 3 сегмента (суммарный размер по-прежнему ограничен пределами, описанными выше):

Мониторинг работы компилятора

Включить логирование процесса компиляции можно флагом -XX:+PrintCompilation (по умолчанию он выключен). При установке этого флага JVM будет выводить в стандартный поток вывода (STDOUT) сообщение каждый раз после компиляции метода или цикла. Большинство сообщений имеют следующий формат: timestamp compilation_id attributes tiered_level method_name size deopt.

Поле timestamp — это время со старта JVM.

Поле compilation_id — это внутренний ID задачи. Обычно он последовательно увеличивается в каждом сообщении, но иногда порядок может нарушаться. Это может произойти в случае, если существует несколько потоков компиляции работающих параллельно.

Поле attributes — это набор из пяти символов, несущих дополнительную информацию о скомпилированном коде. Если какой-то из атрибутов не применим, вместо него выводится пробел. Существуют следующие атрибуты:

Атрибут «b» означает, что компиляция произошла не в фоне, и не должен встречаться в современных версиях JVM.

Атрибут «n» означает, что скомпилированный метод является оберткой нативного метода.
Поле tiered_level содержит номер уровня, на котором был скомпилирован код или может быть пустым, если многоуровневая компиляция выключена.

Поле method_name содержит название скомпилированного метода или название метода, содержащего скомпилированный цикл.

Поле size содержит размер скомпилированного байт-кода, не размер полученного машинного кода. Размер указан в байтах.

Поле deopt появляется не в каждом сообщении, оно содержит название проведенной деоптимизации и может содержать такие сообщения как «made not entrant» и «made zombie».
Иногда в логе могут появиться записи вида: timestamp compile_id COMPILE SKIPPED: reason. Они означают, что при компиляции метода что-то пошло не так. Есть случаи, когда это ожидаемо:

Параметр -compiler выводит сводную информацию о работе компилятора (5003 — это ID процесса):

Эта команда также выводит количество методов, компиляция которых завершилась ошибкой и название последнего такого метода.

Планы на вторую часть

В следующей части мы рассмотрим пороговые значения счетчиков при которых JVM запускает компиляцию и как можно их поменять. Мы также рассмотрим как JVM выбирает количество потоков компилятора, как можно его поменять и в каких случаях стоит это делать. И наконец, кратко рассмотрим некоторые из оптимизаций выполняемых JIT компилятором.

Источник

JIT для начинающих

Предпосылка

Большинство разработчиков слышали о компиляторах JIT и о том, как они могут заставить медленные интерпретируемые языки работать со скоростью, сравнимой с нативным кодом. Однако мало кто понимает, как работает JIT, и ещё меньше людей могут писать свои собственные компиляторы.

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

В этой статье мы посетим некоторые вершины JIT-острова и, возможно, даже реализуем компилятор самостоятельно!

С чего мы начнём

Зная некоторые основы, мы можем предположить, что каждый компилятор преобразует входные данные в каком-то формате (обычно, исходный код) в выходные данные в другом или таком же формате (как правило, машинный код). JIT-компиляторы не исключение.

Их делает особенными то, что они работают не до запуска кода (как, например, gcc, clang и другие), а «Just-In-Time» (то есть прямо перед выполнением скомпилированного кода).

Чтобы начать разработку собственного JIT-компилятора, нам нужно выбрать язык ввода для него. Учитывая Top Github Languages for 2013 ( статья написана в 2013 году, — прим. пер.), JavaScript кажется хорошим кандидатом для реализации его ограниченного подмножества с упрощенной семантикой. Более того, мы будем реализовывать JIT-компилятор в самом JavaScript. Вы можете назвать его META-META!

Наш компилятор будет принимать исходный код JavaScript в качестве входных данных и производить (и сразу же запускать) машинный код для очень популярной платформы X64. Хотя для людей работать с текстовым представлением довольно удобно, разработчики компилятора обычно стремятся создавать несколько промежуточных представлений (Intermediate Representations или сокращённо IR) до создания окончательного машинного кода.

Поскольку мы пишем упрощенный компилятор, для нас достаточно одного IR, и для этого я выбрал aбстрактное синтаксическое дерево (AST).

Например, этот код: obj.method(42) будет производить следующий AST (используя esprima.parse(«. «) ):

Машинный код

Подведем итог: у нас есть исходный код JavaScript ( сделано), его AST ( сделано), и мы хотим получить машинный код для него.

Если вы уже знакомы с ассемблером, вы можете пропустить эту главу, так как она содержит только базовые материалы по этой теме. Однако, если вы новичок в этом, чтение следующей главы может быть тяжелым, если не изучить сначала некоторые основы. Поэтому, пожалуйста, оставайтесь с нами, это не займет слишком много времени!

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

Обычно у процессоров достаточно регистров для хранения результатов промежуточных операций, но в некоторых ситуациях вы можете использовать оперативную память для хранения/загрузки данных (и работы с ним):

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

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

Генерация кода

Полная реализация JavaScript — довольно сложная задача, поэтому на данный момент мы реализуем только упрощенный арифметический движок (который должен быть таким же забавным, как добраться до полной реализации позже!).

Самый лучший и самый простой способ сделать это: обойти AST с помощью поиска в глубину, создавая машинный код для каждого узла. Вы могли бы задаться вопросом, как вы можете генерировать машинный код в таком ограниченном в прямой работе с памятью языке, как JavaScript. Вот где я собираюсь познакомить вас с jit.js.

Это модуль для node.js (фактически, дополнение на C++), способный генерировать и выполнять машинный код, используя сходный с ассемблером синтаксис:

Давайте напишем это

Мы будем поддерживать:

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

Вот результирующий код с комментариями, описывающими, что в нём происходит:

Спасибо, что дочитали до этого момента! В следующий раз я расскажу о куче и операциях с плавающей точкой!

Источник

Что такое JIT компиляция в Java?

Каждый язык программирования использует компилятор для преобразования кода языка высокого уровня в двоичный код машинного уровня, поскольку система понимает только двоичный код. В зависимости от типа языка программирования он отличается, в Java также есть свой компилятор.

Понятие JIT компилятора

JIT Compiler в Java – это одна из составных частей Java Runtime Environment. Он в основном отвечает за оптимизацию производительности приложений во время выполнения. В общем, главный девиз компилятора – повышение производительности приложения для конечного пользователя и разработчика приложения.

Читайте также:  к чему снится горящий дом во сне для мужчины

Нюансы

Принцип работы

JIT или динамический компилятор ускоряет производительность приложений во время выполнения. Поскольку Java программа состоит из классов и объектов. По сути, представляет собой байт-код, который не зависит от платформы и выполняется JVM в различных архитектурах.

На диаграмме ниже показано, как происходит фактическая компиляция в среде выполнения Java.

Когда вы кодируете Java-программу, JRE использует компилятор javac для компиляции исходного кода высокого уровня в байт-код. После этого JVM загружает байт-код во время выполнения и преобразует его в двоичный код машинного уровня для дальнейшего выполнения с использованием Interpreter.

Интерпретация байтового кода Java снижает производительность по сравнению с нативным приложением. Именно здесь JIT-компилятор помогает повысить производительность, компилируя байт-код в машинный код «точно в срок» для запуска.

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

Аспекты безопасности

Компиляция байтового кода в машинный код JIT-компилятором осуществляется непосредственно в памяти. т.е. компилятор передает машинный код непосредственно в память и выполняет его. В этом случае он не сохраняет машинный код на диске перед вызовом файла класса и его выполнением.

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

Плюсы и минусы JIT в Java

Источник

JIT-компилятор как учебный проект в Академическом Университете

Около шестнадцати лет назад вышла первая версия Hotspot – реализация JVM, впоследствии ставшая стандартной виртуальной машиной, поставляемой в комплекте JRE от Sun.

Основным отличием этой реализации стал JIT-компилятор, благодаря которому заявления про медленную Джаву во-многих случаях стали совсем несостоятельными.
Сейчас почти все интерпретируемые платформы, такие как CLR, Python, Ruby, Perl, и даже замечательный язык программирования R, обзавелись своими реализациями JIT-трансляторов.

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

Преимущества JIT-компиляции

Чтобы говорить о преимуществах сначала разберемся, с тем, что это такое. Начнем издалека, а именно с определения компилируемого языка.

Компилируемый язык программирования — язык программирования, исходный код которого преобразуется компилятором в машинный код конкретной архитектуры, например x86/ARM. Этот машинный код представляет собой последовательность команд, которая совсем понятна вашему процессору, и может быть исполнена им без посредника.

Интерпретируемые языки программирования отличаются тем, что исходники не преобразовываются в машинный код для непосредственного выполнения на ЦПУ, а исполняются с помощью специальной программы-интерпретатора.
Как промежуточный вариант, многие языки (Java, C#, Python) транслируются в машинно-независимый байт-код,
который все еще не может быть исполнен напрямую на ЦПУ, и чтобы его исполнить все еще необходим интерпретатор.

Почему интерпретация медленней? Ответ на этот вопрос, как ни странно не всем кажется очевидным, хотя он действительно очень простой: интерпретируя каждую команду по отдельности, мы затрачиваем дополнительные ресурсы на перевод семантики этой команды на язык процессора. Кроме того современные процессоры оптимизированы для выполнения последовательных команд (см. Конвейер), а интерпретатор чаще всего представляет собой огромный switch с кучей опций, загоняющий в тупик предсказатель переходов.

Вполне естественным решением всех этих проблем было бы лишь однажды перевести байт-код на язык процессора и передать его ЦПУ для исполнения. Так чаще всего и поступают, называя этот процесс Just-in-time-компиляцией (JIT). Кроме того, именно в течение этой фазы чаще всего проводятся различные оптимизации генерируемого кода.

Теперь перейдем к практике.

Постановка задачи

Курс “Виртуализация и виртуальные машины” в нашем университете читает Николай Иготти, принимавший участие в разработке самых разных классов ВМ: Hotspot, VirtualBox, NativeClient, и не понаслышке знающий детали их реализации. Благодаря чудесам современных технологий, чтобы узнать про это все подробней необязательно даже быть студентом Академического Университета, так как курс опубликован на лекториуме. Хотя нужно отметить, что это конечно немного не то, в силу интерактивности курса и работе с аудиторией на лекциях.

Типичная программа на языке mathvm:

Сложности

Казалось бы, что существенно сложного в том, чтобы просто перевести семантику несложных байт-код инструкций в соответствующие элементы набора инструкций x86?

Asmjit

Самый простой работающий пример использования Asmjit:

Я думаю у людей, знакомых с ассемблером, он не должен вызвать много вопросов. Единственное, о чем следует упомянуть – объект runtime, отвечающий за время жизни выделенной памяти. Она будет освобождена после вызова его деструктора (RAII).
Еще примеры использования можно подсмотреть в каталоге с тестами библиотеки или у меня в репозитории.

Отладка

Оптимизация

После трех дней активного программирования и отладки появился первый совершенно бесхитростный вариант JIT-транслятора, в котором все значения стека и переменных каждый раз бездумно сохранялись в память.
Даже такое решение дало прирост производительности примерно в шесть раз, с 6 FPS, которые получались с интерпретатором до 36 FPS.

Вообще в начале семестра, когда выяснились правила игры, у меня были наполеоновские планы: сделать все совсем по-взрослому – с переводом байткода в SSA и умным алгоритмом регистровой аллокации.
Но в связи с острой нехваткой времени и банальным малодушием все закончилось несколько прозаичней.

Распределение регистров

На всякий случай напомню, что один из наиболее критичных пунктов в плане производительности программы – эффективность использования имеющихся регистров ЦПУ.
Это связано с тем, что основная вычислительная деятельность может производиться только на них, а кроме того чтение/запись даже в память, находящуюся в L1-кэше, работает до двух раз дольше, чем аналогичные операции над регистрами.
Я воспользовался не самым сложным, но зато довольно действенным эвристическим решением: будем хранить в регистрах первые элементы стека виртуальной машины, 7 элементов для слотов общего назначения (строки/целые числа) и 14 для слотов для чисел с плавающей точкой.
Это решение кажется наиболее оправданным, так как наиболее горячими переменными в рамках работы функции действительно является именно низ стека, участвующий во всех вычислениях.
Кроме того, если использовать те же самые регистры, по которым раскладываются аргументы при вызове функций, то это в некоторых случаях позволяет сэкономить время в местах вызова.
В результате реализации этих идей, я получил ускорение на 9 FPS, таким образом достигнув 45 FPS, что не могло меня не радовать.

Peephole-оптимизации

Одним из простых классических подходов при генерации являются так называемые Peephole-оптимизации, идея которых заключается в поиске и замене определенных последовательностей инструкций на другие, более производительные.

Например из-за недостатка выразительности байт-кода mathvm, операторы сравнения вроде (x0

Источник

[Перевод] Как работает Graal — JIT-компилятор JVM на Java

Введение

Одной из причин по которой я стал исследователем языков программирования является то, что, в большом сообществе людей связанных с компьютерными технологиями, почти все используют языки программирования, и многие интересуются тем как они работают. Когда я впервые столкнулся с программированием, будучи ребенком, и познакомился с языком программирования, первым, о чем я хотел узнать, было то как это работает, и самым первым, что мне хотелось сделать, было создание собственного языка.

В этом выступлении я покажу некоторые механизмы работы используемого всеми вами языка — Java. Особенностью является то, что я буду использовать проект под названием Graal, который реализует концепцию Java на Java.

Graal является только одной из составляющих в работе Java — это just-in-time компилятор. Это та часть JVM, которая преобразует байткод Java в машинный код в ходе работы программы, и является одним из факторов обеспечивающих высокую производительность платформы. Также это, как мне кажется, то, что большинство людей считают одной из наиболее сложных и туманных частей JVM, которая находится вне рамок их понимания. Изменить это мнение является целью данного выступления.

Если вы знаете, что такое JVM; в целом понимаете, что означают термины байткод и машинный код; и способны читать код написанный на Java, то, я надеюсь, этого будет достаточно, чтобы понять излагаемый материал.

Я начну с обсуждения того почему мы можем хотеть новый JIT-компилятор для JVM написанный на Java, а после покажу, что в этом нет чего-то сверх особенного, как вы могли бы думать, разбив задачу на сборку компилятора, использование, и демонстрацию того, что его код является таким же как и в любом другом приложении.

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

Я буду использовать скриншоты кода в Eclipse, вместо их запуска в ходе презентации, чтобы избежать неминуемых проблем live-кодинга.

Что такое JIT-компилятор?

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

Когда вы запускаете команду javac или compile-on-save в IDE, ваша программа на Java компилируется из Java-кода в байткод JVM, который является бинарным представлением программы. Он более компактен и прост, чем исходный Java-код. Однако, обычный процессор вашего ноутбука или сервера не может просто так выполнить байткод JVM.

Для работы вашей программы JVM интерпретирует этот байткод. Интерпретаторы, обычно, значительно медленнее, чем машинный код запускаемый на процессоре. По этой причине JVM, во время работы программы, может запустить еще один компилятор, который преобразует ваш байткод в машинный код, выполнить который процессор уже в состоянии.

Зачем писать JIT-компилятор на Java?

На сегодняшний день реализация JVM под названием OpenJDK включает два основных JIT-компилятора. Клиентский компилятор, известный как C1, спроектирован для более быстрой работы, но, при этом, выдает менее оптимизированный код. Серверный компилятор, известный как opto или C2, требует несколько больше времени на работу, но выдает более оптимизированный код.

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

На сегодня они могут быть совмещены, чтобы код сперва компилировался C1, и после, если он продолжает интенсивно выполняться и имеет смысл затратить дополнительное время, — C2. Это называется ступенчатой компиляцией (tiered compilation).

Давайте остановимся на C2 — серверном компиляторе, который выполняет больше оптимизаций.

Мы можем склонировать OpenJDK с зеркала на GitHub, или просто открыть дерево проекта на сайте.

Код C2 находится в openjdk/hotspot/src/share/vm/opto.

Прежде всего стоит отметить, что C2 написан на C++. Конечно, в этом нет чего-то плохого, но есть определенные недостатки. С++ — небезопасный язык. Это означает, что ошибки в C++ могут привести к краху VM. Возможно, что причиной тому возраст кода, но код C2 на C++ стало очень трудно поддерживать и развивать.

Читайте также:  К чему снится что твои дети утонули

Одна из ключевых фигур, стоящих за компилятором C2, Cliff Click сказал, что никогда бы больше не стал писать VM опять на C++, и мы слышали как JVM-команда Twitter высказывала мнение о том, что C2 пришел в застойное состояние и требует замены по причине трудности дальнейшей разработки.

Итак, возвращаясь к вопросу, что такого есть в Java, что может помочь решить эти проблемы? Тоже самое, что дает написание программы на Java вместо C++. Это, вероятно, безопасность (исключения вместо крахов, отсутствие реальной утечки памяти или висячих указателей), хорошие вспомогательные средства (отладчики, профилировщики, и инструменты вроде VisualVM), хорошая поддержка IDE и т.д.

Настройка Graal

Первое, что нам понадобится, — это Java 9. Используемый Graal интерфейс под названием JVMCI был добавлен в Java в рамках JEP 243 Java-Level JVM Compiler Interface и первой версией, его включающей, является Java 9. Я использую 9+181. В случае каких-то особенных требований имеются порты (backports) для Java 8.

Теперь нам надо склонировать сам Graal. Я использую дистрибутив под названием GraalVM версии 0.28.2.

Для работы с кодом Graal я буду использовать Eclipse IDE. Я использую Eclipse 4.7.1. mx может сгенерировать для нас файлы Eclipse-проекта.

Чтобы открыть каталог graal как рабочую область (workspace) нужно выполнить File, Import…, General, Existing projects и опять выбрать каталог graal. Если вы запустили Eclipse не на Java 9, то, возможно, также, потребуется прикрепить и исходники JDK.

Хорошо. Теперь, когда все готово, давайте посмотрим как это работает. Мы будем использовать этот очень простой код.

Я не буду подробно это пояснять, а скажу лишь, что это вывод лога, который показывает, что метод workload был скомпилирован.

Теперь, в качестве JIT-компилятора нашей Java 9 JVM, мы используем только что скомпилированный Graal. Для этого необходимо добавить еще несколько флагов.

Как и в предыдущем примере мы видим, что был скомпилирован один метод. Но, в этот раз, для компиляции мы использовали только что собранный Graal. Пока просто поверьте мне на слово.

Интерфейс компилятора JVM

Вам не кажется, что мы сделали что-то достаточно необычное? У нас есть установленная JVM, и мы заменили JIT-компилятор на только что скомпилированный новый не меняя что-либо в самой JVM. Эту возможность обеспечивает новый интерфейс JVM под названием JVMCI, — JVM compiler interface, — то, что как я говорил выше, было JEP 243 и вошло в Java 9.

Идея аналогична некоторым другим существующим технологиям JVM.

Возможно вы когда-нибудь уже сталкивались с дополнительной обработкой исходного кода в javac с использованием API Java для обработки аннотаций (Java annotation processing API). Этот механизм дает возможность выявления аннотаций и модели исходного кода, в которой они используются, и создания новых файлов на их основе.

Также, вы, возможно, использовали дополнительную обработку байткода в JVM с помощью Java-агентов (Java agents). Этот механизм позволяет модифицировать байткод Java перехватывая его при загрузке.

Идея JVMCI схожа. Он позволяет подключить собственный Java JIT-компилятор, написанный на Java.

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

С этого момента я приступаю к развеиванию мнения, которое могло у вас быть, что JIT-компилятор — это очень сложно.

Что JIT-компилятор принимает на вход? Он принимает байткод метода, который надо скомпилировать. А байткод, как подсказывает название, это просто массив байт.

Что JIT-компилятор выдает в качестве результата? Он выдает машинный код метода. Машинный код это тоже просто массив байт.

В итоге, интерфейс, который надо реализовать при написании нового JIT-компилятора, для его встраивания в JVM, будет выглядеть примерно так.

Также, интерфейс не требует возврата скомпилированного кода. Вместо этого, для установки (install) машинного кода в JVM, используется еще одно API.

Давайте переключимся в Eclipse IDE с Graal и посмотрим на некоторые реальные интерфейсы и классы. Как говорилось ранее, они будут несколько сложнее, но не намного.

Сейчас я хочу показать, что мы можем вносить в Graal изменения, и сразу использовать их в Java 9. Я добавлю новое сообщения лога, которое будет выводиться при компиляции метода с использованием Graal. Добавим его в реализованный метод интерфейса, который вызывается JVMCI.

Пока отключим существующее в HotSpot логирование компиляции. Теперь мы можем видеть наше сообщение из измененной версии компилятора.

Граф Graal

По сути, компилятор занимается обработкой вашей программы. Для этого программу необходимо представить в виде какой-то структуры данных. Одним из вариантов является байткод и подобные ему списки инструкций, но они не очень выразительны.

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

Иногда это называют графом зависимостей программы (program dependency graph).

Синие ребра на этом графе показывают направление потока данных от чтения локальных переменных к суммированию.

Также, мы можем использовать ребра для отражения порядка выполнения программы. Если, вместо чтения локальных переменных, мы вызываем методы, то нам нужно запомнить порядок вызова, и мы не можем переставлять их местами (не зная о коде внутри). Для этого есть дополнительные ребра которые и задают этот порядок. Они показаны красным цветом.

Итак, граф Graal, на самом деле, это два графа совмещенных в одном. Узлы одинаковые, но одни ребра показывают направление потока данных, а другие — порядок передачи управления между ними.

Простой поток данных можно увидеть написав несложное выражение.

Можно видеть как параметры 0 ( P(0 ) и 1 ( P(1) ) поступают на вход операции сложения, которая, вместе с константой 2 ( C(2) ) поступает на вход операции деления. После данное значение возвращается.

Для того чтобы посмотреть на более сложный поток данных и управления введем цикл.

В этом случае у нас есть узлы начала и окончания цикла, чтения элементов массива, и чтения длины массива. Как и ранее, синие линии показывают направление потока данных, а красные — поток управления.

Теперь вы можете видеть почему эту структуру данных иногда называют морем узлов (sea of nodes) или солянкой узлов (soup of nodes).

Хочу сказать, что C2 использует очень схожую структуру данных, и, в действительности, именно C2 популяризировал идею компилятора моря узлов, так что это не нововведение Graal.

Я не буду показывать процесс построения этого графа до следующей части выступления, но когда Graal получает программу в таком формате, оптимизация и компиляция выполняется при помощи модификации данной структуры данных. И это одна из причин почему написание JIT-компилятора на Java имеет смысл. Java — объектно-ориентированный язык, а граф — это набор объектов, соединенных ребрами в виде ссылок.

От байткода к машинному коду

Давайте посмотрим как эти идеи выглядят на практике, и проследим некоторые этапы процесса компиляции.

Получение байткода

Компиляция начинается с байткода. Вернемся к нашему небольшому примеру с суммированием.

Выведем принимаемый на входе байткод непосредственно перед началом компиляции.

Как видно, входными данными для компилятора является байткод.

Парсер байткода и построитель графа

Построитель, воспринимая этот массив байт как байткод JVM, преобразует его в граф Graal. Это является, своего рода, абстрактной интерпретацией — построитель интерпретирует байткод Java, но, вместо передачи значений, манипулирует свободными концами ребер и постепенно соединяет их друг с другом.

Давайте воспользуемся преимуществом того, что Graal написан на Java, и посмотрим как это работает используя инструменты навигации Eclipse. Мы знаем, что в нашем примере есть узел сложения, поэтому давайте найдем где он создается.

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

Таким образом строится граф Graal.

Получение машинного кода

Повторюсь, тут мы работаем на очень высоком уровне абстракции. У нас есть класс, с помощью которого мы выдаем инструкции машинного кода не вдаваясь в детали того как это работает.

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

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

Можно видеть, что результатом являются байты, которые добавляются в стандартный ByteBuffer — просто создание массива байт.

Выходной машинный код

Давайте посмотрим на выходной машинный код также как ранее мы делали с входным байткодом — добавим распечатку байт в месте его установки.

Также я воспользуюсь инструментом который дизассемблирует машинный код при его установке. Это стандартное средство HotSpot. Я покажу как его собрать. Оно находится в репозитории OpenJDK, но, по-умолчанию, не включено в поставку JVM, поэтому нам надо собрать его самим.

Теперь мы можем запустить наш пример и увидеть вывод инструкций для нашего сложения.

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

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

Итак, что мы узнали? Graal просто принимает массив байт байткода; мы можем увидеть как из него создается граф узлов; мы можем увидеть как узлы выдают инструкции; и как они кодируются. Мы видели, что можем внести изменения в сам Graal.

Оптимизация

И так, мы посмотрели как строится граф, и как узлы графа преобразуются в машинный код. Теперь давайте поговорим о том как Graal оптимизирует граф, делая его более эффективным.

Фаза оптимизации — это просто метод у которого есть возможность выполнить модификацию графа. Фазы создаются с помощью реализации интерфейса.

Каноникализация (canonicalisation)

Каноникализация означает переупорядочивание узлов в единообразное представление. У этой техники есть и другие задачи, но для целей данного выступления я скажу, что в действительности это означает свёртывание констант (constant folding) и урощение узлов.

Читайте также:  Литий ионный или литий полимерный аккумулятор что лучше для смартфона

Это действительно хороший пример того насколько Graal прост для понимания. Практически, данная логика проста настолько насколько это возможно.

Global value numbering

Global value numbering (GVN) — это техника удаления многократно повторяющегося избыточного кода. В примере ниже a + b может быть вычислено единожды, а результат — переиспользован.

Graal может сравнивать узлы на равенство. Это просто — они равны если у них одинаковые входные значения. В фазе GVN выполняется поиск одинаковых узлов и их замена единственной копией. Эффективность этой операции достигается за счет использования hash map в виде, своего рода, кэша узлов.

Заметьте проверку на то, что узел нефиксированный — это означает, что он не обладает побочным эффектом, который может проявиться в какой-то момент времени. Если бы, вместо этого, вызывался метод, то терм стал бы фиксированным и неизбыточным, а их слияние в один — невозможным.

Укрупнение блокировок (lock coarsening)

Давайте рассмотрим более сложный пример. Иногда программисты пишут код который два раза подряд синхронизируется на одном и том же мониторе. Возможно, что они так не писали, но это стало результатом других оптимизаций, таких как встраивание (inlining).

Если развернуть конструкции, то мы увидим, что, фактически, происходит.

Мы можем оптимизировать этот код захватывая монитор только один раз вместо его освобождения и повторного захвата на следующем же шаге. Это и есть укрупнение блокировок.

Не затронутые практические аспекты

Рассматривая работу Graal на высоком уровне, конечно же, я упустил множество важных практических деталей, которые обеспечивают его хорошую работу и создание эффективного машинного кода. Фактически, я, также, пропустил некоторые базовые вещи необходимые для его работы в принципе.

Я не рассказал о некоторых частях Graal по той причине, что они, концептуально, не так просты для демонстрации как приведенный выше код, но я покажу где, при желании, вы сможете их найти.

Назначение регистров

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

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

Задача выбора регистров для каждого ребра называется назначением регистров (register allocation). Graal использует, схожий с другими JIT-компиляторами, алгоритм назначения регистров — алгоритм линейной развёртки (linear scan algorithm).

Диспетчеризация

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

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

Эта проблема называется диспетчеризацией графа (graph scheduling). Диспетчер требуется для определения порядка обработки узлов. Он определяет последовательность вызова кода учитывая требование, что все значения должны быть вычислены на момент их использования. Можно создать диспетчер который будет просто работать, но есть возможность улучшить производительность кода, например, не вычисляя значение до момента его фактического использования.

Можно сделать еще хитрее применив знание об имеющихся ресурсах процессора и давать ему работу таким образом, чтобы они использовались наиболее эффективно.

В каких случаях использовать Graal?

В начале выступления в вводном слайде я говорил, что, на данный момент, Graal — это исследовательский проект, а не находящийся на поддержке продукт Oracle. Каким может быть практическое применение исследований, осуществляемых в рамках Graal?

Компилятор нижнего уровня (final-tier compiler)

C помощью JVMCI Graal может использоваться как компилятор нижнего уровня в HotSpot — то, что я и демонстрировал выше. По мере появления новых (и отсутствующих в HotSpot) оптимизаций в Graal он может стать компилятором, используемым для повышения производительности.

Огромная польза от JVMCI заключается в том, что он дает возможность подгружать Graal отдельно от JVM. Вы можете развертывать (deploy) какую-то версию JVM, и отдельно подключать новые версии Graal. Как и в случае с Java-агентами, при использовании Graal, обновление компилятора не требует пересборки самой JVM.

Проект OpenJDK по названием Metropolis имеет своей целью реализацию большей части JVM на языке Java. Graal представляет собой один из шагов в этом направлении.

Пользовательские оптимизации

Graal можно расширять дополнительными оптимизациями. Так же как Graal подключается к JVM, есть возможность подключения к Graal новых фаз компиляции. Если у вас есть желание применить определенную оптимизацию к вашему приложению, в Graal вы можете написать для этого новую фазу. Или, если у вас есть какой-то особенный набор кодов машинных инструкций, который вы хотите использовать, вы можете просто написать новый метод вместо использования низкоуровневого кода и его последующего вызова с помощью JNI.

Charles Nutter уже предложил проделать это для JRuby и продемонстрировал рост производительности от добавления новой фазы Graal смягчающей идентификацию объектов для упакованных чисел Ruby. Думаю, что скоро он выступит с этим на какой-нибудь конференции.

AOT (ahead-of-time) компиляция

Graal — это просто библиотека Java. JVMCI предоставляет интерфейс, используемый Graal, для осуществления низкоуровневых действий, таких как установка машинного кода, но большая часть Graal достаточно изолирована от всего этого. Это означает, что вы можете использовать Graal и для других приложений, а не только как JIT-компилятор.

На самом деле между JIT- и AOT-компилятором не такая уж и большая разница, и Graal можно использовать в обоих случаях. В действительности существует два проекта реализующих AOT с помощью Graal.

Java 9 включает инструмент предварительной компиляции классов в машинный код для сокращения времени требуемого для JIT-компиляции, особенно на фазе запуска приложения. Для работы этого кода все еще нужна JVM, только вместо запуска компилятора по требованию используется предварительно скомпилированный код.

AOT Java 9 использует несколько устаревшую версию Graal, которая включена только в сборки для Linux. Именно по этой причине я не стал использовать её для демо и, вместо этого, продемонстрировал сборку более свежей версии и необходимые для использования аргументы командной строки.

Второй проект более амбициозен. SubstrateVM — это AOT-компилятор, который компилирует Java-приложение в независимый от JVM машинный код. Фактически, на выходе вы имеете статически-связанный (statically linked) исполняемый модуль. В этом случае JVM не требуется, а исполняемый файл может иметь размер всего несколько мегабайт. Для выполнения такой компиляции SubstrateVM использует Graal. В некоторых конфигурациях для компиляции кода во время выполнения (just-in-time) SubstrateVM, также, может скомпилировать Graal в себя. Таким образом Graal AOT-компилирует себя самого.

Truffle

Еще один проект использующий Graal в качестве библиотеки имеет название Truffle. Truffle — это фреймворк для создания интерпретаторов языков программирования поверх JVM.

Большинство языков, работающих на JVM, выдают байткод, который потом JIT-компилируется как обычно (но, как я говорил выше, по той причине, что JIT-компилятор JVM представляет собой черный ящик, довольно трудно контролировать что произойдет с этим байткодом). Truffle использует другой подход — вы пишете простой интерпретатор для вашего языка, следуя определенным правилам, и Truffle, автоматически, комбинирует программу и интерпретатор для получения оптимизированного машинного кода используя технику известную как частичное вычисление (partial evaluation).

Частичное вычисление имеет в своей основе интересную теоретическую часть, но с практической точки зрения мы можем говорить об этом как о включении кода (inlining) и сворачивании констант (constant folding) программы вместе с используемыми ею данными. Graal имеет функционал включения кода и сворачивания констант, поэтому Truffle может использовать его в качестве частичного вычислителя.

Именно так я и познакомился с Graal — через Truffle. Я работаю над реализацией языка программирования Ruby, которая называется TruffleRuby и использует фреймворк Truffle и, также, Graal. TruffleRuby — это самая быстрая реализация Ruby, обычно в 10 раз быстрее других, которая, при этом, реализует практически все возможности языка и стандартную библиотеку.

Выводы

Главная идея, которую я хотел донести этим выступлением, заключается в том, что с JIT-компилятором Java можно работать также как и с любым другим кодом. JIT-компиляция включает множество сложностей, в основном наследуя их от лежащей в основе архитектуры и, также, из-за желания выдачи как можно более оптимизированного кода за возможно кратчайшее время. Но, все равно, это верхнеуровневая задача. Интерфейс к JIT-компилятору представляет собой не больше, чем конвертер byte[] байткода JVM в byte[] машинного кода.

Эта задача, которая хорошо подходит для реализации на Java. Сама компиляция не является задачей требующей низкоуровневого и небезопасного языка программирования, такого как C++.

Java-код Graal не является какой-то магией. Не буду притворяться, что он всегда прост, но заинтересованный новичок будет в состоянии прочесть и понять большую его часть.

Очень советую вам самим поэкспериментировать с этим пользуясь данными подсказками. Если вы начнете с изучения приведенных выше классов, то не потеряетесь в коде впервые открыв Eclipse и увидев длинный список пакетов. От этих стартовых точек вы можете двигаться к реализациям методов (definitions), местам их вызова и т.д. постепенно исследуя кодовую базу.

Если у вас уже есть опыт контроля и настройки JIT с использованием инструментов для существующих JIT-компиляторов JVM, таких как JITWatch, то вы заметите, что чтение кода поможет лучше понять почему Graal компилирует ваш код именно так, а не иначе. И, если вы поймете, что что-то работает не так как вы того ожидаете, то сможете внести в Graal изменения и просто перезапустить JVM. Для этого вам даже не потребуется покидать вашу IDE, как я показал на примере с hello-world.

Мы работаем над такими потрясающими исследовательскими проектами как SubstrateVM и Truffle, которые используют Graal, и действительно меняют картину того, что будет возможно в Java в будущем. Все это возможно благодаря тому, что весь Graal написан на обычной Java. Если бы, для написания нашего компилятора, мы использовали что-то вроде LLVM, как предлагают некоторые компании, то, во многих случаях, переиспользование кода было бы затруднено.

И, наконец, на данный момент есть возможность использовать Graal не внося изменений в саму JVM. Т.к. JVMCI является частью Java 9, Graal может быть подключен также как и, уже существующие, процессоры аннотаций или Java-агенты.

Graal — это большой проект над которым работает много людей. Как уже говорилось выше, я не работаю непосредственно над Graal. Я просто им пользуюсь, и вы тоже можете это делать!

Источник

Обзорно-познавательный сайт