Наши партнеры

UnixForum





Библиотека сайта rus-linux.net

PyPy

Глава 19 из книги "Архитектура приложений с открытым исходным кодом", том 2.
Оригинал: PyPy
Автор: Benjamin Peterson
Перевод: А.Панин

19.5. JIT-компиляция в PyPy

Python, как и большинство динамических языков программирования, традиционно отдает предпочтение гибкости в обмен на снижение производительности. Архитектура PyPy, обладая особенной гибкостью и широким спектром абстракций, затрудняет реализацию возможности очень быстрой интерпретации. Мощные абстракции пространств объектов и мультиметодов в стандартном пространстве объектов не могут быть реализованы без последствий. В результате производительность не модифицированного интерпретатора PyPy будет в четыре раза ниже производительности интерпретатора CPython. Для того, чтобы не создавать репутацию медленного языка не только для нашей реализации, но и для языка Python в общем, в рамках PyPy был реализован динамический компилятор (just-in-time compiler, обычно обозначаемый с помощью аббревиатуры JIT). С помощью JIT-компилятора часто используемые пути исполнения кода преобразуются в ассемблерное представление в процессе исполнения программы.

JIT-компилятор из состава PyPy использует преимущества уникальной архитектуры процесса преобразования кода в PyPy, описанной в Разделе 19.4. На самом деле PyPy не использует Python-специфичный JIT-компилятор; вместо него используется JIT-генератор. Генерация JIT-кода реализована просто в виде еще одной дополнительной фазы преобразования кода. Интерпретатор, желающий провести генерацию JIT-кода, должен осуществить два вызова специальных функций, называемых указаниями jit (jit hints).

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

Как было сказано ранее, JIT-генератору требуется только два указания от интерпретатора для генерации JIT-кода: merge_point и can_enter_jit. Функция can_enter_jit указывает JIT-генератору на начало цикла в рамках интерпретатора. При использовании интерпретатора языка Python это конец байткода JUMP_ABSOLUTE. (JUMP_ABSOLUTE заставляет интерпретатор перейти к началу цикла уровня приложения). Функция merge_point сообщает JIT-генератору о том, где он может безопасно вернуть управление интерпретатору. Это начало управляющего байткода цикла в интерпретаторе Python.

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

В процессе работы приложения на уровне JIT-генератора поддерживается счетчик для каждого цикла, исполняемого в ходе работы программы. В момент, когда счетчик цикла преодолевает заданное в процессе конфигурации пороговое значение, осуществляется вызов JIT-генератора и начинается трассировка. Ключевым для процесса трассировки объектом является мета-интерпретатор (meta-interpreter). Мета-интерпретатор исполняет jit-коды, сформированные в процессе преобразования кода. Таким образом, происходит их интерпретация средствами основного интерпретатора, отсюда и название компонента. По мере трассировки цикла, он создает список выполняемых операций и записывает их в промежуточном представлении JIT (JIT intermediate representation - JIT IR), являющемся другим форматом записи операций. Этот список называется трассировкой цикла (trace of the loop). В моменты, когда мета-интерпретатор сталкивается с вызовом функции, преобразованной с использованием JIT-компиляции (функции, для которой существует jit-код), мета-интерпретатор входит в нее и записывает операции в оригинальную трассировку. Таким образом, процесс трассировки оказывает эффект, заключающийся в уменьшении глубины стека вызовов; единственным типом вызовов в рамках трассировки являются вызовы функций интерпретатора, которые выходят за пределы сферы действия jit-кода.

Мета-интерпретатор вынужден преобразовывать данные трассировки в свойства итерации цикла, трассировка которого производится. Например, в момент, когда мета-интерпретатор сталкивается с условным переходом в jit-коде, он, как и ожидается, должен выбрать один путь исполнения кода на основе состояния программы. При осуществлении выбора на основе информации, полученной во время исполнения программы, мета-интерпретатор записывает операцию в промежуточном представлении, называемую охранной операцией (guard). В случае условного перехода это будет операция guard_true или guard_false в отношении переменной условия. В большинстве арифметических операций также используются охранные операции, которые позволяют быть уверенным в том, что в ходе выполнения арифметической операции не произойдет переполнения. По существу охранные операции позволяют объявлять в коде предположения, которые делает мета-интерпретатор в ходе трассировки. В момент генерации ассемблерного кода охранные операции будут защищать ассемблерный код от выполнения в контексте, для работы в котором он не предназначен. Трассировка заканчивается в тот момент, когда мета-интерпретатор достигает той же операции can_enter_jit, с которой и началась трассировка. Теперь код промежуточного представления цикла может быть передан оптимизатору.

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

Виртуальные объекты являются объектами, о которых известно, то, что они не покидают пространство трассировки, что подразумевает тот факт, что они не передаются в качестве аргументов при вызове внешних функций, не подвергающихся преобразованию в jit-код. Структуры и массивы постоянной длины также могут быть виртуальными объектами. Для виртуальных объектов не должна резервироваться память и их данные могут храниться непосредственно в регистрах и в стеке. (Это очень напоминает случай удаления статических операций резервирования памяти, описанный в разделе об оптимизациях преобразованного кода). Оптимизация виртуальных объектов позволяет удалить неэффективные операции косвенной адресации и резервирования памяти в рамках интерпретатора Python. Например, после преобразования в виртуальные объекты объектов Python для хранения в контейнерах целочисленных значений, эти значения могут быть извлечены из контейнеров и преобразованы в простые целочисленные значения длиной в машинное слово, после чего сохранены непосредственно в машинных регистрах.

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

После проведения оптимизации данные трассировки готовы для преобразования в ассемблерный код. Так как промежуточно представление кода JIT само по себе является достаточно низкоуровневым, генерация ассемблерного кода не является очень сложной задачей. Большинство операций из промежуточного представления соответствуют всего лишь нескольким ассемблерным операциям для архитектуры x86. Система резервирования регистров использует простой линейный алгоритм. На данный момент увеличение затрат времени для использования более оптимизированного алгоритма резервирования регистров в обмен на генерацию немного более качественного кода не оправдывает себя. Наиболее сложными аспектами генерации ассемблерного кода являются интеграция сборщика мусора и реализация механизма восстановления состояния охранных операций. Сборщик мусора должен отслеживать корневые элементы стека в рамках генерируемого jit-кода. Эта возможность реализуется путем специальной поддержки динамических карт корневых элементов стека в рамках сборщика мусора.

При неудачном выполнении охранной операции скомпилированный ассемблерный код более не считается корректным и управление должно быть передано интерпретатору байткода. Эта операция передачи управления является одной их наиболее сложных частей реализации механизма JIT-компиляции, так как данные состояния интерпретатора должны быть воссозданы на основе данных состояния регистров и стека в момент неудачного завершения охранной операции. Для каждой охранной операции в рамках ассемблерного кода создается компактное описание того, где находятся все значения для воссоздания данных состояния интерпретатора. В случае неудачного выполнения охранной операции начинается выполнение функции, которая декодирует это описание и передает данные для восстановления на более высокий уровень для осуществления операции восстановления данных состояния. Неудачно выполненная охранная операция может находиться по середине пути исполнения запутанного кода операции, поэтому интерпретатор не сможет просто начать исполнение следующего кода операции. Для решения этой проблемы PyPy использует интерпретатор "чреной дыры" (blackhole interpreter). Интерпретатор "черной дыры" выполняет операции в рамках jit-кода с позиции неудачного выполнения охранной операции до достижения следующей точки безопасной передачи управления. Он не записывает данные о любых операциях, которые выполняет. Процесс некорректного завершения охранной операции проиллюстрирован на Рисунке 19.3.

Передача управления интерпретатору при неудачном выполнении охранной операции
Рисунок 19.3: Передача управления интерпретатору при неудачном выполнении охранной операции

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

Насколько же хорошо зарекомендовали себя техники JIT-компиляции, применяемые в PyPy? В момент работы над этой главой среднее геометрическое для значений производительности реализаций PyPy и CPython указывает на пятикратное преимущество в быстродействии первой при использовании полного набора тестов производительности. При использовании JIT-компиляции код Python уровня приложений имеет возможность более быстрой работы, чем код уровня интерпретатора. Разработчики PyPy недавно столкнулись с замечательной задачей, заключающейся в необходимости реализации циклов уровня интерпретатора в рамках кода уровня приложения Python для достижения лучшей производительности.

Наиболее важным является то, что система JIT-компиляции не является специфичной для языка Python и это означает, что она может быть применена при разработке любого интерпретатора на основе фреймворка PyPy. Это не обязательно должен быть интерпретатор языка программирования. Например, JIT-компиляция используется для работы механизма регулярных выражений языка Python. NumPy является мощным модулем для работы с массивами языка Python, используемым при выполнении численных операций и научных исследований. В PyPy имеется экспериментальная повторная реализация модуля NumPy. Она использует мощные механизмы JIT-компиляции PyPy для ускорения операций с массивами. Несмотря на то, что реализация модуля NumPy все еще находится на раннем этапе своего развития, начальные замеры производительности выглядят многообещающе.


Продолжение статьи: Недостатки архитектуры