Найти в Дзене
Абстрактное IT

Интерпретатор, компилятор, JIT компилятор. Разбираемся в особеностях.

Оглавление

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

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

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

Термины Ahead-of-Time (AOT) и Just-in-Time (JIT) относятся к тому, когда происходит компиляция: "время", упоминаемое в этих терминах, - это "время выполнения", т. е. Компилятор JIT компилирует программу во время ее выполнения, компилятор AOT компилирует программу до ее выполнения. Обратите внимание, что для этого требуется, чтобы JIT-компилятор с языка X на язык Y каким-то образом работал вместе с интерпретатором для языка Y, иначе не было бы никакого способа запустить программу. (Так, например, JIT-компилятор, который компилирует JavaScript в машинный код x86, не имеет смысла без процессора x86; он компилирует программу во время ее работы, но без процессора x86 программа не будет работать.)

Обратите внимание, что это различие не имеет смысла для интерпретаторов: интерпретатор запускает программу. Идея интерпретатора AOT, который запускает программу до ее запуска, или интерпретатора JIT, который запускает программу во время ее запуска, бессмысленна.

Итак, мы имеем:

  • Компилятор AOT: компилируется перед запуском
  • JIT-компилятор: компилируется во время работы
  • Интерпретатор: запускает исполнение кода по строчно.

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

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

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

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

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

Объединение интерпретаторов и компиляторов

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

Объединение компилятора AOT от X до Y с интерпретатором для Y. Здесь обычно X-это язык более высокого уровня, оптимизированный для читаемости людьми, тогда как Y-это компактный язык (часто какой-то байт-код), оптимизированный для интерпретации машинами. Например, механизм выполнения CPython и Python имеет компилятор AOT, который компилирует исходный код Python в байт-код CPython, и интерпретатор, который интерпретирует байт-код CPython. Аналогично, (оригинальный) механизм выполнения YARV Ruby имеет компилятор AOT, который компилирует исходный код Ruby в байт-код YARV, и интерпретатор, который интерпретирует байт-код YARV. (Примечание: более поздние версии YARV также имеют JIT-компилятор.) Почему вы хотите это сделать? Ruby и Python являются очень высокоуровневыми и несколько сложными языками, поэтому мы сначала компилируем их на язык, который легче анализировать и легче интерпретировать, а затем интерпретируем этот язык.

Другой способ объединить интерпретатор и компилятор-это механизм выполнения в смешанном режиме. Здесь мы "смешиваем" два "режима" реализации одного и того же языка вместе, т. е. интерпретатор для X и JIT-компилятор от X до Y. (Таким образом, разница здесь заключается в том, что в приведенном выше случае у нас было несколько "этапов", когда компилятор компилировал программу, а затем передавал результат в интерпретатор, здесь у нас есть два работающих бок о бок на одном языке). Код, скомпилированный компилятором, имеет тенденцию работать быстрее, чем код, выполняемый интерпретатором, но на самом деле компиляция кода сначала требует времени (и особенно, если вы хотите сильно оптимизировать код, чтобы работать очень быстро, это занимает много времени). Таким образом, чтобы преодолеть это время, когда JIT-компилятор занят компиляцией кода, интерпретатор уже может запустить код, и как только JIT закончит компиляцию, мы можем переключить выполнение на скомпилированный код. Это означает, что мы получаем наилучшую производительность скомпилированного кода, но нам не нужно ждать завершения компиляции, и наше приложение начинает работать сразу (хотя и не так быстро, как могло бы быть).

На самом деле это просто простейшее возможное применение механизма выполнения смешанного режима. Более интересными возможностями являются, например, не начинать компиляцию сразу, а позволить интерпретатору немного поработать и собрать статистику, информацию о профилировании, информацию о типе, информацию о вероятности того, какие конкретные условные ветви берутся, какие методы вызываются чаще всего и т. д., а затем скормить эту динамическую информацию к компилятору, чтобы он мог генерировать более оптимизированный код. Это также способ реализации де-оптимизации, о которой я говорил выше: если окажется, что вы были слишком агрессивны в оптимизации, вы можете выбросить (часть) кода и вернуться к интерпретации. Это делает, например, HotSpot JVM. Он содержит как интерпретатор для байт-кода JVM, так и компилятор для байт-кода JVM. (На самом деле, он фактически содержит два компилятора!)

Также возможно и на самом деле часто комбинировать эти два подхода: две фазы: первая-компилятор AOT, который компилирует X в Y, а вторая фаза-движок смешанного режима, который интерпретирует Y и компилирует Y в Z. Например, механизм выполнения Rubinius Ruby работает следующим образом: он имеет компилятор AOT, который компилирует исходный код Ruby в байт-код Rubinius, и механизм смешанного режима, который сначала интерпретирует байт-код Rubinius и после сбора некоторой информации компилирует наиболее часто вызываемые методы в машинный код. То же самое относится и к текущим версиям YARV.

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

Так, например, работало второе поколение движка Google V8 ECMAScript. Это поколение V8 никогда не интерпретирует, оно всегда компилируется. Первый компилятор-это очень быстрый, очень тонкий компилятор, который запускается очень быстро. Однако код, который он производит, не очень быстр. Этот компилятор также вводит код профилирования в код, который он генерирует. Другой компилятор медленнее и использует больше памяти, но производит гораздо более быстрый код, и он может использовать информацию профилирования, собранную при запуске кода, скомпилированного первым компилятором. (Примечание: V8 значительно изменился за это время. Например, теперь у него также есть интерпретатор.)