Что такое jit компилятор

Как работать с JIT

Что такое jit компилятор. Смотреть фото Что такое jit компилятор. Смотреть картинку Что такое jit компилятор. Картинка про Что такое jit компилятор. Фото Что такое jit компилятор

В некоторых внутренних системах для быстрого поиска по большому битовому массиву мы в Badoo используем JIT. Это очень интересная и не самая известная тема. И, чтобы исправить такую досадную ситуацию, я перевел полезную статью Элая Бендерски о том, что такое JIT и как его использовать.

Раньше я уже публиковал вводную статью по libjit для программистов, которые уже знакомы с JIT. Хотя бы немного. В том посте я совсем коротко описал JIT, а в этом сделаю полный обзор JIT и дополню его примерами, код в которых не требует никаких дополнительных библиотек.

Определение JIT

JIT – это акроним от “Just In Time” или, если переводить на русский, “на лету”. Это нам ни о чем не говорит и звучит так, будто к программированию не имеет никакого отношения. Мне кажется, это описание JIT больше всего похоже на правду:

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

Но откуда произошло это название? К счастью, Джон Айкок из университета Калгари написал очень интересную статью под названием “Краткая история JIT”, в которой рассматривает JIT-техники с исторической точки зрения. Судя по статье, первое упоминание кодогенерации и исполнения кода во время работы программы появилось в 1960 году в статье про LISP, написанной McCarthy. В более поздних работах (например, статья Томсона от 1968 года про регулярные выражения) этот подход совсем очевиден (регулярные выражения компилируются в машинный код и исполняются на лету).

Сам же термин JIT впервые появился в книгах по Java Джеймса Гослинга. Айкок говорит, что Гослинг перенял этот термин из области промышленного производства и начал его использовать в ранних 90-х. Если вам интересны подробности, то прочитайте статью Айкока. А теперь давайте посмотрим, как все описанное выше работает на практике.

JIT: сгенерируйте машинный код и запустите его

Мне кажется, что JIT проще понять, если сразу разделить его на две фазы:

Первая фаза – это 99% всей сложности JIT. Но в то же время это самая банальная часть процесса: это именно то, что делает обычный компилятор. Широко известные компиляторы, такие как gcc и clang/llvm, транслируют исходники из C/C++ в машинный код. Дальше машинный код обычно сохраняется в файл, но нет смысла не оставлять его в памяти (на самом деле и в gcc, и в clang/llvm есть готовые возможности для сохранения кода в памяти для использования его в JIT). Но в этой статье я хотел бы сфокусироваться на второй фазе.

Выполнение сгенерированного кода

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

Я надеюсь, всем понятно, что машинный код – это просто данные, набор байтов. Как вот это, например:

Для кого-то эти три байта – просто три байта, а для кого-то – бинарное представление валидного x86-64 кода:

Поместить этот машинный код в память очень легко. Но как сделать его исполняемым и, собственно, исполнить?

Посмотрим на код

Дальше в этой статье будут примеры кода для POSIX-совместимой UNIX операционной системы (а именно Linux). На других ОС (таких как Windows) код будет отличаться в деталях, но не в подходе. У всех современных ОС есть удобные API для того, чтобы сделать то же самое.

Без лишних предисловий посмотрим, как динамически создать функцию в памяти и выполнить ее. Эта функция специально сделана очень простой. В C она выглядит так:

Вот первая попытка (полный исходник вместе с Makefile доступен в репозитории):

Три основных этапа, которые выполняет этот код:

Прошу заметить, что третий этап возможен только тогда, когда кусок памяти с машинным кодом имеет права на исполнение. Без нужных прав вызов функции привел бы к ошибке ОС (скорее всего, ошибке сегментирования). Это произойдет, если, например, мы выделим m обычным вызовом malloc, который выделяет RW память, но не X.

Отвлечемся на минутку: heap, malloc и mmap

Так что называть память, полученную от mmap “памятью из кучи”, не ошибка, по моему мнению, и я собираюсь и дальше использовать это название.

Заботимся о безопасности

У кода выше есть серьезная уязвимость. Причина в блоке RWX-памяти, который он выделяет – рай для эксплоитов. Давайте будем чуть более ответственными. Вот немного измененный код:

Что насчет malloc?

Подводя итоги

Эта статья начиналась с общего обзора JIT, того, что мы вообще подразумеваем, когда говорим “JIT”, и закончилась примерами кода, который демонстрирует, как динамически выполнять кусок машинного кода из памяти. Техники, представленные в статье – это примерно то, как делается JIT в настоящих JIT-системах (LLVM или libjit). Остается всего лишь “простая” часть генерации машинного кода из какого-либо другого представления.

LLVM содержит в себе полноценный компилятор, так что он может транслировать C и C++-код (через LLVM IR) в машинный код на лету и исполнять его. Libjit работает на гораздо более низком уровне: он может служить бэкендом для компилятора. Моя вводная статья по libjit демонстрирует, как генерировать и выполнять нетривиальный код с помощью этой библиотеки. Но JIT – это гораздо более общий концепт. Создавать код на лету можно для структур данных, регулярных выражений и даже для доступа к C из виртуальных машин различных языков. Я покопался в архивах своего блога и нашел упоминание о JIT в статье восьмилетней давности. Она о Perl-коде, который генерирует другой Perl-код на лету (из XML-файла с описанием), но идея та же самая.

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

Источник

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

Источник

Процесс выполнения управляемого кода

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

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

При компиляции исходный код преобразуется в MSIL и создаются необходимые метаданные.

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

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

Выбор компилятора

Чтобы воспользоваться преимуществами, предоставляемыми средой CLR, необходимо применить один или несколько языковых компиляторов, ориентированных на среду выполнения, например компилятор Visual Basic, C#, Visual C++, F# или один из многочисленных компиляторов от независимых разработчиков, например компилятор Eiffel, Perl или COBOL.

Поскольку среда выполнения является многоязычной, она поддерживает широкий набор разнообразных типов данных и языковых средств. Доступные средства среды выполнения определяются используемым языковым компилятором, и разработчики создают код с использованием этих средств. Используемый в коде синтаксис определяется компилятором, а не средой выполнения. Если компонент должен быть полностью доступен для компонентов, написанных на других языках, то экспортируемые этим компонентом типы должны предоставлять исключительно языковые функции, включенные в состав спецификации CLS. Атрибут CLSCompliantAttribute позволяет гарантировать, что код является CLS-совместимым. Дополнительные сведения см. в разделе Независимость от языка и независимые от языка компоненты.

Компиляция в MSIL

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

Когда компилятор создает код MSIL, одновременно создаются метаданные. Метаданные содержат описание типов в коде, включая определение каждого типа, сигнатуры каждого члена типа, члены, на которые есть ссылки в коде, а также другие сведения, используемые средой выполнения во время выполнения. MSIL и метаданные содержатся в переносимом исполняемом (PE) файле, который основывается на форматах Microsoft PE и COFF, ранее использовавшихся для исполняемого контента, но при этом расширяет их возможности. Этот формат файлов, позволяющий размещать код MSIL или машинный код, а также метаданные, позволяет операционной системе распознавать образы среды CLR. Наличие в файле метаданных наряду с MSIL позволяет коду описывать себя. Это устраняет потребность в библиотеках типов и языке IDL. Среда выполнения находит и извлекает метаданные из файла по мере необходимости при выполнении.

Компиляция инструкций MSIL в машинный код

Компиляция с помощью JIT-компилятора

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

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

Создание кода во время установки с помощью NGen.exe

Тот факт, что JIT-компилятор преобразует MSIL-код сборки в машинный код при вызове отдельных методов, определенных в этой сборке, отрицательно сказывается на производительности во время выполнения. В большинстве случаев снижение производительности приемлемо. Что более важно, код, созданный JIT-компилятором, будет привязан к процессу, вызвавшему компиляцию. Его нельзя сделать общим для нескольких процессов. Чтобы созданный код можно было использовать в нескольких вызовах приложения или в нескольких процессах, которые совместно используют набор сборок, среда CLR предоставляет режим предварительной компиляции. В таком режиме компиляции для преобразования сборок MSIL в машинный код в стиле JIT-компилятора используется генератор образов в машинном коде (Ngen.exe). Однако, работа Ngen.exe отличается от JIT-компилятора в трех аспектах.

Ngen.exe выполняет преобразование из MSIL-кода в машинный код перед выполнением приложения, а не во время его выполнения.

При этом сборка компилируется целиком, а не по одному методу за раз.

Она сохраняет созданный код в кэше образа машинного кода в виде файла на диске.

Проверка кода

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

Среда выполнения основывается на истинности следующих утверждений для поддающегося проверке типобезопасного кода:

ссылка на тип строго совместима с адресуемым типом;

для объекта вызываются только правильно определенные операции;

удостоверения являются подлинными.

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

Выполнение кода

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

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

В Microsoft Windows Vista загрузчик операционной системы выполняет поиск управляемых модулей, анализируя бит в заголовке COFF. Установленный бит обозначает управляемый модуль. При обнаружении управляемых модулей загружается библиотека Mscoree.dll, а подпрограммы _CorValidateImage и _CorImageUnloading уведомляют загрузчик о загрузке и выгрузке образов управляемых модулей. Подпрограмма _CorValidateImage выполняет следующие действия:

Проверяет, является ли код допустимым управляемым кодом.

Заменяет точку входа в образе на точку входа в среде выполнения.

В 64-разрядных системах Windows _CorValidateImage изменяет образ, находящийся в памяти, путем преобразования его из формата PE32 в формат PE32+.

Источник

Введение в компиляторы, интерпретаторы и JIT’ы

Но прежде чем говорить о том, как это всё работает, давайте разберём один простой пример. Представим, что у нас есть новый язык программирования (придумайте любое название). Язык довольно прост:

set a 1
set b 2
add a b c
print c

Теперь давайте напишем программу, которая считывает каждое «выражение», находит оператор и операнды, а затем что-то с ними делает, в зависимости от конкретного оператора. Это довольно просто реализовать на PHP, как вы можете видеть на примере листинга 1.

Это очень простая программа, и вам не придётся писать своё следующее веб-приложение на вашем новом языке. Но данный пример помогает понять, как легко можно создать новый язык и получить программу, которая способна считывать и выполнять этот язык. В нашем случае она построчно считывает исходный файл и выполняет код в зависимости от текущего оператора. Для запуска приложения нам не нужно преобразовывать его в ассемблер или двоичный код, оно и так прекрасно работает. Этот метод выполнения программ называется интерпретированием. Например, таким образом часто выполняются программы на Basic: каждое выражение считывается и сразу же выполняется в высокоуровневом режиме.

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

Но, несмотря на неторопливость, у интерпретирования есть преимущества: мы можем сразу запускать программу после каждого внесённого изменения. Для внимательных: когда я что-то меняю в PHP-скрипте, я сразу могу его выполнить и увидеть изменения; означает ли это, что PHP — интерпретируемый язык? На данный момент будем считать, что да. PHP-скрипт интерпретируется подобно нашему гипотетическому простому языку. Но в следующих разделах мы ещё к этому вернёмся!

Транскомпилирование

Как можно заставить нашу программу «работать быстро»? Это можно сделать разными способами. Один из них, разработанный в Facebook, называется HipHop (я имею в виду «старую» систему HipHop, а не используемую сегодня HHVM). HipHop преобразовывал один язык (PHP) в другой (С++). Результат преобразования можно было с помощью компилятора С++ превратить в двоичный код. Его компьютер способен понять и выполнить без дополнительной нагрузки в виде интерпретатора. В результате экономится ОГРОМНОЕ количество вычислительных ресурсов и приложение работает гораздо быстрее.

Этот метод называется source-to-source компилированием, или транскомпилированием, или даже транспилированием (transpiling). На самом деле происходит не компилирование в двоичный код, а преобразование в то, что может быть скомпилировано в машинный код существующими компиляторами.

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

Транскомпилирование также используется для того, чтобы сделать «жёсткие» языки более простыми и динамичными. Например, браузеры не понимают код, написанный на LESS, SASS и SCSS. Но зато его можно транспилировать в CSS, который браузеры понимают. Поддерживать CSS проще, но приходится дополнительно транскомпилировать.

Компилирование

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

К сожалению, написание компилятора — одна из труднейших задач в информатике. Например, при компилировании в двоичный код нужно учитывать, на каком компьютере он будет выполняться: на 32-битной Linux, или 64-битной Windows, или вообще на OS X. Зато интерпретируемый скрипт может легко выполняться где угодно. Как и в PHP, нам не нужно переживать о том, где выполняется наш скрипт. Хотя может встречаться и код, предназначенный для конкретной ОС, что сделает невозможным выполнение скрипта на других системах, но это не вина интерпретатора.

Но даже если мы избавимся от стадии транскомпилирования, нам никуда не деться от компилирования. Например, большие программы, написанные на С (компилируемый язык), могут компилироваться чуть ли не час. Представьте, что вы написали приложение на PHP и вам нужно ждать ещё десять минут, прежде чем увидеть, работают ли внесённые изменения.

Используя всё лучшее

Если интерпретирование подразумевает медленное выполнение, а компилирование сложно в реализации и требует больше времени при разработке, то как работают языки вроде PHP, Python или Ruby? Они довольно быстрые!

Это потому, что они используют и интерпретирование, и компилирование. Давайте посмотрим, как это получается.

Что, если бы мы могли преобразовывать наш выдуманный язык не напрямую в двоичный код, а в нечто, очень на него похожее (это называется «байт-код»)? И если бы этот байт-код был так близок к тому, как работает компьютер, что его интерпретирование выполнялось бы очень быстро (например, миллионы байт-кодов в секунду)? Это сделало бы наше приложение почти таким же быстрым, как и компилируемое, при этом сохранились бы все преимущества интерпретируемых языков. Самое главное, нам не пришлось бы компилировать скрипты при каждом изменении.

Выглядит очень заманчиво. По сути, подобным образом работают многие языки — PHP, Ruby, Python и даже Java. Вместо считывания и поочерёдного интерпретирования строк исходного кода, в этих языках используется другой подход:

Ещё одна оптимизация: после генерирования байт-кода мы можем использовать его при всех последующих запросах. Так что можно закешировать и его (главное, убедитесь, что при изменении исходного файла байт-код будет перекомпилироваться). Именно это делают кеши кода операций (opcode caches), вроде расширения OPCache в PHP: кешируют скомпилированные скрипты, чтобы их можно было быстро выполнить при последующих запросах без избыточных загрузок и компилирования в байт-код.

Наконец, последний шаг к высокой скорости — выполнение байт-кода нашим PHP-интерпретатором. В следующей части мы сравним это с обычными интерпретаторами. Во избежание путаницы: подобный интерпретатор байт-кода часто называется «виртуальной машиной», потому что в определённой степени он копирует работу машины (компьютера). Не надо путать это с виртуальными машинами, запускаемыми на компьютерах, вроде VirtualBox или VMware. Речь идёт о таких вещах, как JVM (Java Virtual Machine) в мире Java и HHVM (HipHop Virtual Machine) в мире PHP. Свои виртуальные машины есть у Python и Ruby. В некотором роде все они являются высокоспециализированными и производительными интерпретаторами байт-кода.

Каждая ВМ выполняет собственный байт-код, генерируемый конкретным языком, и они несовместимы между собой. Вы не можете выполнять байт-код PHP на ВМ Python, и наоборот. Однако теоретически возможно создать программу, компилирующую PHP-скрипты в байт-код, который будет понятен ВМ Python. Так что в теории вы можете запускать PHP-скрипты в Python (серьёзный вызов!).

Байт-код

Как выглядит и работает байт-код? Рассмотрим два примера. Возьмём PHP-код:

Посмотреть его байт-код можно с помощью 3v4l.org или установив расширение VLD. Получим следующее:

Что такое jit компилятор. Смотреть фото Что такое jit компилятор. Смотреть картинку Что такое jit компилятор. Картинка про Что такое jit компилятор. Фото Что такое jit компилятор

Теперь возьмём аналогичный пример на Python:

Python может напрямую сгенерировать коды операций ©python:

Что такое jit компилятор. Смотреть фото Что такое jit компилятор. Смотреть картинку Что такое jit компилятор. Картинка про Что такое jit компилятор. Фото Что такое jit компилятор

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

Иными словами, виртуалки взяли всё лучшее от двух миров. Хотя нам по-прежнему нужно компилировать из исходного кода в байт-код, этот процесс становится быстрым и прозрачным. А после получения байт-кода виртуальная машина быстро и эффективно интерпретирует его без излишних накладных расходов. И в результате мы имеем высокопроизводительное приложение.

От исходного кода к байт-коду

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

Рассмотрим следующие PHP-выражения:

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

Лексинг

Токенизация (лексинг) заключается в преобразовании исходного PHP-кода — без понимания его значения — в длинный список токенов. Это сложный процесс, но в PHP вы можете довольно легко сделать нечто подобное. Представленный в листинге 2 код выдаёт следующий результат:

Строковое значение преобразуется в токены:

Парсеры и токенизаторы полезны и в других сферах. Например, они используются для парсинга SQL-выражений в базах данных, и на PHP также написано немало парсеров и токенизаторов. У объектно-реляционного маппера Doctrine есть свой парсер для DQL-выражений, а также «транскомпилятор» для преобразования DQL в SQL. Многие движки шаблонов, в том числе Twig, используют собственные токенизаторы и парсеры для «компилирования» файлов шаблонов обратно в PHP-скрипты. По сути, эти движки тоже транскомпиляторы!

Абстрактное синтаксическое дерево

После токенизации и парсинга нашего языка мы можем генерировать байт-код. Вплоть до PHP 5.6 он генерировался во время парсинга. Но привычнее было бы добавить в процесс отдельную стадию: пусть парсер генерирует не байт-код, а так называемое абстрактное синтаксическое дерево (Abstract Syntax Tree, AST). Это древовидная структура, в которой абстрактно представлена вся программа. AST не только упрощает генерирование байт-кода, но и позволяет нам вносить изменения в дерево, прежде чем оно будет преобразовано. Дерево всегда генерируется особым образом. Узел дерева, представляющий собой выражение if, обязательно имеет под собой три элемента:

В результате мы можем «переписать» программу до того, как она будет преобразована в байт-код. Иногда это используется для оптимизации кода. Если мы обнаружим, что разработчик раз за разом перевычислял переменную внутри цикла, и мы знаем, что переменная всегда имеет одно и то же значение, то оптимизатор может переписать AST так, чтобы создать временную переменную, которую не нужно каждый раз вычислять заново. Дерево можно использовать для небольшой реорганизации кода, чтобы он работал быстрее: удалить ненужные переменные и т. п. Это не всегда возможно, но когда у нас есть дерево всей программы, то такие проверки и оптимизации выполнять куда легче. Внутри AST можно посмотреть, объявляются ли переменные до их использования или используется ли присваивание в условном блоке ( if ($a = 1) <> ). И при обнаружении потенциально ошибочных структур выдать предупреждение. С помощью дерева можно даже анализировать код с точки зрения информационной безопасности и предупреждать пользователей во время выполнения скрипта.

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

В PHP 7.0 появился новый движок парсинга (Zend 3.0), который тоже генерирует AST во время парсинга. Поскольку он достаточно свежий, с его помощью можно сделать не так много. Но сам факт его наличия означает, что мы можем ожидать появления в ближайшем будущем самых разных возможностей. Функция token_get_all() уже принимает новую, недокументированную константу TOKEN_PARSE, которая в будущем может использоваться для возвращения не только токенов, но и отпарсенного AST. Сторонние расширения вроде php-ast позволяют просматривать и редактировать дерево прямо в PHP. Полная переработка движка Zend и реализации AST откроет PHP для самых разных новых задач.

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

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

Мы получаем скорость компилируемого кода и наслаждаемся преимуществами кода интерпретируемого. Подобные системы могут работать быстрее обычного интерпретируемого байт-кода, иногда гораздо быстрее. Речь идёт о JIT-компиляторах (just-in-time, точно в срок). Название подходит как нельзя лучше. Система обнаруживает, какие части байт-кода могут быть хорошими кандидатами на компилирование в двоичный код, и делает это в тот момент, когда нужно выполнять эти самые части. То есть — точно в срок. Программа может стартовать немедленно, не нужно ждать завершения компилирования. В двоичный код преобразуются только самые эффективные части кода, так что процесс компилирования автоматизируется и ускоряется.

Хотя не все JIT-компиляторы работают таким образом. Некоторые компилируют все методы на лету; другие пытаются только определить, какие функции нужно скомпилировать на ранней стадии; третьи будут компилировать функции, если они вызываются два и больше раза. Но все JIT’ы используют один принцип: компилировать маленькие куски кода, когда они действительно нужны.

Ещё одно преимущество JIT’ов по сравнению с обычным компилированием заключается в том, что они способны лучше прогнозировать и оптимизировать на основании текущего состояния приложения. JIT’ы могут динамически анализировать код во время runtime и делать предположения, на которые неспособны обычные компиляторы. Ведь во время компиляции у нас нет информации о текущем состоянии программы, а JIT’ы компилируют на стадии выполнения.

Если вам доводилось работать с HHVM, то вы уже использовали JIT-компилятор: PHP-код (и надмножественный язык Hack) преобразуется в байт-код, запускаемый на виртуальной машине HHVM. Машина обнаруживает блоки, которые могут быть безопасно преобразованы в двоичный код; если это ещё не было сделано, она это делает и запускает их. По окончании запуска ВМ переходит к следующим байт-кодам, которые могут быть преобразованы в двоичный код.

PHP 7 не выполняется на JIT-компиляторе, но зато его новая система превосходит все предыдущие релизы. Сейчас во всех его компонентах проводятся эксперименты со статическим анализом, динамической оптимизацией, и даже есть простые JIT-системы. Так что не исключено, что однажды даже PHP 7 окажется позади!

Источник

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *