Ahead of Time Compilation
Этот документ описывает проектирование и структуру системы компиляции заранее (AOT) в Julia. Эта система используется при создании системных образов и образов пакетов. Большая часть реализации, описанной здесь, находится в aotcompile.cpp
, staticdata.c
и processor.cpp
.
Introduction
Хотя Julia обычно компилирует код в режиме just-in-time (JIT), возможно скомпилировать код заранее и сохранить полученный код в файл. Это может быть полезно по ряду причин:
- Чтобы сократить время, необходимое для запуска процесса Julia.
- Чтобы сократить время, затрачиваемое на JIT-компилятор вместо выполнения кода (время до первого выполнения, TTFX).
- Чтобы уменьшить объем памяти, используемой компилятором JIT.
High-Level Overview
Следующие описания являются моментальным снимком текущих деталей реализации конвейера от начала до конца, который происходит внутри, когда пользователь компилирует новый AOT-модуль, например, когда он вводит using Foo
. Эти детали, вероятно, будут изменяться со временем, поскольку мы реализуем лучшие способы их обработки, поэтому текущие реализации могут не точно соответствовать описанному ниже потоку данных и функциям.
Compiling Code Images
Сначала необходимо определить методы, которые нужно скомпилировать в нативный код. Это можно сделать только путем фактического выполнения кода, который нужно скомпилировать, так как набор методов, которые нужно скомпилировать, зависит от типов аргументов, передаваемых методам, и вызовы методов с определенными комбинациями типов могут быть неизвестны до времени выполнения. В процессе этого отслеживаются точные методы, которые видит компилятор, для последующей компиляции, создавая трассировку компиляции.
В настоящее время, когда компилируются изображения, Julia выполняет генерацию трассировки в другом процессе, чем процесс, выполняющий AOT-компиляцию. Это может повлиять на попытки использования отладчика во время предварительной компиляции. Лучший способ отладки предварительной компиляции с помощью отладчика — использовать отладчик rr, записать все дерево процессов, использовать rr ps
для идентификации соответствующего неудачного процесса, а затем использовать rr replay -p PID
для воспроизведения только неудачного процесса.
Как только методы, которые нужно скомпилировать, были определены, они передаются в функцию jl_create_system_image
. Эта функция настраивает ряд структур данных, которые будут использоваться при сериализации нативного кода в файл, а затем вызывает jl_create_native
с массивом методов. jl_create_native
выполняет кодогенерацию для методов и создает один или несколько модулей LLVM. Затем jl_create_system_image
записывает некоторую полезную информацию о том, что кодогенерация произвела из модуля(ей).
Модуль(и) затем передаются в jl_dump_native
, вместе с информацией, записанной с помощью jl_create_system_image
. jl_dump_native
содержит код, необходимый для сериализации модуля(ов) в биткод, объектные или ассемблерные файлы в зависимости от параметров командной строки, переданных в Julia. Сериализованный код и информация затем записываются в файл в виде архива.
Последний шаг — это запуск системного компоновщика на объектных файлах в архиве, созданном с помощью jl_dump_native
. После завершения этого шага создается общая библиотека, содержащая скомпилированный код.
Loading Code Images
При загрузке образа кода общая библиотека, созданная компоновщиком, загружается в память. Затем данные системного образа загружаются из общей библиотеки. Эти данные содержат информацию о типах, методах и экземплярах кода, которые были скомпилированы в общую библиотеку. Эти данные используются для восстановления состояния среды выполнения до того, каким оно было, когда образ кода был скомпилирован.
Если изображение кода было скомпилировано с многоверсионностью, загрузчик выберет соответствующую версию каждой функции для использования на основе доступных на текущем компьютере функций процессора.
Для системных изображений, поскольку другой код не был загружен, состояние времени выполнения теперь такое же, как и было, когда изображение кода было скомпилировано. Для пакетных изображений окружение могло измениться по сравнению с моментом компиляции кода, поэтому каждый метод должен быть проверен по глобальной таблице методов, чтобы определить, является ли он все еще действительным кодом.
Compiling Methods
Tracing Compiled Methods
Julia имеет флаг командной строки для записи всех методов, которые компилируются JIT-компилятором, --trace-compile=filename
. Когда функция компилируется и этот флаг имеет имя файла, Julia выведет в этот файл оператор предварительной компиляции с методом и типами аргументов, с которыми она была вызвана. Это, таким образом, генерирует скрипт предварительной компиляции, который можно использовать позже в процессе AOT-компиляции. Пакет PrecompileTools имеет инструменты, которые могут облегчить использование этой функциональности для разработчиков пакетов.
jl_create_system_image
jl_create_system_image
сохраняет все специфические для Julia метаданные, необходимые для последующего восстановления состояния среды выполнения. Это включает в себя данные, такие как экземпляры кода, экземпляры методов, таблицы методов и информацию о типах. Эта функция также настраивает структуры данных, необходимые для сериализации нативного кода в файл. Наконец, она вызывает jl_create_native
, чтобы создать один или несколько модулей LLVM, содержащих нативный код для переданных ей методов. jl_create_native
отвечает за выполнение генерации кода для переданных ей методов.
jl_dump_native
jl_dump_native
отвечает за сериализацию модуля LLVM, содержащего нативный код, в файл. В дополнение к модулю, данные системного изображения, созданные с помощью jl_create_system_image
, компилируются как глобальная переменная. Выход этого метода представляет собой биткод, объектные файлы и/или архивы ассемблера, содержащие код и данные системного изображения.
jl_dump_native
обычно является одной из больших временных затрат при генерации нативного кода, при этом большая часть времени тратится на оптимизацию LLVM IR и генерацию машинного кода. Поэтому эта функция способна использовать многопоточность на этапах оптимизации и генерации машинного кода. Эта многопоточность параметризована размером модуля, но может быть явно переопределена путем установки переменной окружения JULIA_IMAGE_THREADS
. Максимальное количество потоков по умолчанию составляет половину от доступного количества потоков, но установка его на более низкое значение может снизить пиковое использование памяти во время компиляции.
jl_dump_native
также может производить нативный код, оптимизированный для нескольких архитектур, когда интегрирован с загрузчиком Julia. Это запускается установкой переменной окружения JULIA_CPU_TARGET
и регулируется проходом многоверсионности в конвейере оптимизации. Чтобы это работало с многопоточностью, перед тем как модуль будет разделен на подмодули, которые будут испускаться в своих собственных потоках, добавляется шаг аннотирования, и этот шаг аннотирования использует информацию, доступную на протяжении всего модуля, чтобы решить, какие функции клонировать для разных архитектур. После того как аннотирование произошло, отдельные потоки могут испускать код для разных архитектур параллельно, зная, что другой подмодуль гарантированно произведет необходимые функции, которые будут вызваны клонированной функцией.
Некоторые другие метаданные о том, как модуль был сериализован, также хранятся в архиве, такие как количество потоков, использованных для сериализации модуля, и количество функций, которые были скомпилированы.
Static Linking
Последний шаг в процессе компиляции AOT заключается в запуске компоновщика на объектных файлах в архиве, созданном с помощью jl_dump_native
. Это создает общую библиотеку, содержащую скомпилированный код. Эта общая библиотека затем может быть загружена Julia для восстановления состояния времени выполнения. При компиляции системного образа используется нативный компоновщик, используемый компилятором C, для создания окончательной общей библиотеки. Для образов пакетов используется компоновщик LLVM LLD, чтобы обеспечить более согласованный интерфейс компоновки.
Loading Code Images
Loading the Shared Library
Первый шаг в загрузке образа кода заключается в загрузке общей библиотеки, созданной компоновщиком. Это делается с помощью вызова jl_dlopen
с путем к общей библиотеке. Эта функция отвечает за загрузку общей библиотеки и разрешение всех символов в библиотеке.
Loading Native Code
Загрузчику сначала необходимо определить, является ли нативный код, который был скомпилирован, действительным для архитектуры, на которой работает загрузчик. Это необходимо, чтобы избежать выполнения инструкций, которые старые ЦП не распознают. Это делается путем проверки доступных функций ЦП на текущей машине по сравнению с функциями ЦП, для которых был скомпилирован код. Когда включено многоверсионное выполнение, загрузчик выберет соответствующую версию каждой функции для использования на основе доступных функций ЦП на текущей машине. Если ни один из наборов функций, которые были многоверсионными, не подходит, загрузчик выдаст ошибку.
Часть процесса многоверсионности создает ряд глобальных массивов всех функций в модуле. Когда этот процесс выполняется в многопоточном режиме, создается массив массивов, который загрузчик реорганизует в один большой массив со всеми функциями, которые были скомпилированы для этой архитектуры. Аналогичный процесс происходит для глобальных переменных в модуле.
Setting Up Julia State
Загрузчик затем использует глобальные переменные и функции, полученные из загрузки нативного кода, для настройки основных структур данных времени выполнения Julia в текущем процессе. Эта настройка включает добавление типов и методов в время выполнения Julia и делает кэшированный нативный код доступным для использования другими функциями Julia и интерпретатором. Для образов пакетов каждый метод должен быть проверен, так как состояние глобальной таблицы методов должно соответствовать состоянию, для которого был скомпилирован образ пакета. В частности, если в момент загрузки существует другой набор методов по сравнению с моментом компиляции образа пакета, метод должен быть аннулирован и перекомпилирован при первом использовании. Это необходимо для обеспечения того, чтобы семантика выполнения оставалась одинаковой независимо от того, был ли пакет предварительно скомпилирован или код был выполнен напрямую. Системные образы не нуждаются в выполнении этой проверки, так как глобальная таблица методов пуста в момент загрузки. Таким образом, системные образы имеют более быстрое время загрузки, чем образы пакетов.