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

в 10:14, , рубрики: jit, Блог компании Mail.Ru Group, интерпретаторы, Компиляторы, Программирование, разработка, Разработка веб-сайтов, срыв покровов

С рождением PHP 7 не прекращаются споры о деревьях абстрактного синтаксиса, just-in-time компиляторах, статическом анализе и т. д. Но что означают все эти термины? Это какие-то волшебные свойства, делающие PHP гораздо производительнее? И если да, то как это всё работает? В этой статье мы рассмотрим основы работы языков программирования и разъясним для себя процесс, который должен выполняться до того, как компьютер запустит, например, ваш PHP-скрипт.

Интерпретируя код

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

  • каждая строка представляет собой выражение,
  • каждое выражение состоит из команды (оператора)
  • и любого количества значений (операндов), которыми оперирует команда.

Пример:

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

Это простой язык, так что мы можем без опаски предположить, что этот код всего лишь выводит на экран 3. Оператор set берёт переменную и присваивает ей число (совсем как $a=1 в PHP). Оператор add берёт две переменные для добавления и сохраняет результат в третьей. Оператор print выводит её на экран.

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

Листинг 1

01. <?php
02.
03. $lines = file($argv[1]);
04.
05. $linenr = 0;
06. foreach ($lines as $line) {
07. 		$linenr++;
08. 		$operands = explode(" ", trim($line));
09. 		$command = array_shift($operands);
10.
11. 		switch ($command) {
12. 			case 'set' :
13. 				$vars[$operands[0]] = $operands[1];
14. 				break;
15. 			case 'add' :
16. 				$vars[$operands[2]] = $vars[$operands[0]] + $vars[$operands[1]];
17. 				break;
18. 			case 'print' :
19. 				print $vars[$operands[0]] . "n";
20. 				break;
21. 			default :
22. 				throw new Exception(sprintf("Unknown command in line %sn", $linenr));
23. 		}
24. }

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

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

  • Какой оператор нужно выполнить?
  • Это правильный оператор?
  • Есть ли у него нужное количество операндов?

А ведь нам нельзя забывать и о других задачах. Например, оператор set может присваивать переменным только числовые значения или строковые тоже? Или даже значения других переменных? Чтобы правильно обработать каждое выражение, нужно ответить на все эти вопросы. Что произойдёт, если написать set 1 4? Короче, таким образом практически невозможно создавать быстро работающие приложения.

Но, несмотря на неторопливость, у интерпретирования есть преимущества: мы можем сразу запускать программу после каждого внесённого изменения. Для внимательных: когда я что-то меняю в 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. Вместо считывания и поочерёдного интерпретирования строк исходного кода, в этих языках используется другой подход:

  • Шаг 1. Считать скрипт (PHP) целиком в память.
  • Шаг 2. Целиком преобразовать/компилировать скрипт в байт-код.
  • Шаг 3. Выполнить байт-код посредством интерпретатора (PHP).

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

Процесс можно легко оптимизировать: предположим, что мы запустили веб-сервер и каждый запрос выполняет скрипт index.php. Зачем каждый раз грузить его в память? Лучше закешировать файл, чтобы можно было быстро преобразовывать его при каждом запросе.

Ещё одна оптимизация: после генерирования байт-кода мы можем использовать его при всех последующих запросах. Так что можно закешировать и его (главное, убедитесь, что при изменении исходного файла байт-код будет перекомпилироваться). Именно это делают кеши кода операций (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-код:

$a = 3;
echo "hello world";
print $a + 1;

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

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

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

def foobar():
a = 1
print "hello world",
print a + 4

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

dis.dis(func):

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

У нас есть два простых скрипта и их байт-коды. Обратите внимание, что байт-коды похожи на язык, который мы «создали» в начале статьи: каждая строка представляет собой оператор с любым количеством операндов. В байт-коде PHP к переменным добавляется префикс !, поэтому !0 означает переменную 0. Байт-коду не важно, что вы используете переменную $a: в ходе компилирования имена переменных теряют значение и преобразуются в числа. Это облегчает и ускоряет их обработку виртуальной машиной. Большинство необходимых «проверок» выполняются на стадии компилирования, что также снимает нагрузку с виртуальной машины и увеличивает скорость её работы.

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

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

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

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

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

$a = 1;

$a=1;

$a
=
1;

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

Лексинг

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

T_OPEN_TAG <?php
T_VARIABLE $a
T_WHITESPACE
=
T_WHITESPACE
T_LNUMBER 3
;
T_WHITESPACE
T_ECHO echo
T_WHITESPACE
T_CONSTANT_ENCAPSED_STRING "hello world"
;

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

  • <?php преобразован в токен T_OPEN_TAG,
  • $a преобразован в токен T_VARIABLE, который содержит значение $a.

Токенизатор знает об этом, когда при чтении кода обнаруживает знак $ с буквой a, после которых может следовать любое количество букв и цифр. Числа токенизируются в виде T_LNUMBER и могут быть одно- и более разрядными. Токенизация позволяет представить исходный код в более структурированном виде, не заставляя делать это самого программиста. Но, как уже упоминалось, токенизатор не понимает значение токенов. Он идеально токенизирует и $a = 1, и 1 = $a. А в следующей части мы научимся парсить — задавать значение потоку токенов.

Парсинг

При парсинге токенов мы должны следовать некоторым «правилам», составляющим наш язык. Например, может быть правило: первый обнаруженный токен в программе должен быть T_OPEN_TAG (соответствует <?php).

Ещё одно возможное правило: присваивание может состоять из любого T_VARIABLE, после которого идёт символ =, а затем T_LNUMBER, T_VARIABLE или T_CONSTANT_ENCAPSED_STRING. Иными словами, мы разрешаем $a = 1, или $a = $b, или $a = 'foobar', но не 1 = $a. Если парсер обнаруживает серию токенов, не удовлетворяющих какому-то из правил, автоматически будет выдана ошибка синтаксиса. В общем, парсинг — это процесс, определяющий язык и позволяющий нам создавать синтаксические правила.

Посмотреть список правил, используемых в PHP, можно по адресу. Если ваш PHP-скрипт удовлетворяет синтаксическим правилам, то проводятся дополнительные проверки, чтобы подтвердить, что синтаксис не только правильный, но и осмысленный: определение public abstract final final private class foo() {} может быть корректным, но не имеет смысла с точки зрения PHP. Токенизация и парсинг — хитрые процессы, и зачастую для их выполнения берут сторонние приложения. Нередко используются инструменты вроде flex и bison (в PHP тоже). Их можно рассматривать и в качестве транскомпиляторов: они преобразуют ваши правила в С-код, который будет автоматически компилироваться, когда вы компилируете PHP.

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

Деревья абстрактного синтаксиса

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

  • первый содержит условие (вроде $a == true);
  • второй содержит выражения, которые должны быть выполнены, если соблюдается условие true;
  • третий содержит выражения, которые должны быть выполнены, если соблюдается условие false (выражение else).

Даже если else отсутствует, элемента три, просто третий будет пустым.

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

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

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

JIT

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

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

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

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

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

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

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

Автор: Mail.Ru Group

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js