Компиляция перед выполнением
Страница в процессе перевода. |
В этом документе описываются проект и структура системы компиляции перед выполнением (ahead-of-time, AOT) в Julia. Эта система используется при создании образов системы и образов пакетов. Большая часть описанного здесь процесса реализации находится в файлах aotcompile.cpp
, staticdata.c
и processor.cpp
.
Введение
Хотя в Julia обычно выполняется JIT-компиляция, код можно компилировать заранее перед выполнением и сохранять результат в файл. Это может быть полезно по ряду причин:
-
Для сокращения времени, необходимого для запуска процесса Julia.
-
Для сокращения времени, проводимого в JIT-компиляторе вместо выполнения кода (время до первого выполнения, TTFX).
-
Для уменьшения объема памяти, используемой JIT-компилятором.
Общий обзор
Далее приводятся сведения о текущей реализации полного процесса, который выполняется внутренним образом, когда пользователь компилирует новый модуль AOT, например когда он вводит using Foo
. Скорее всего, эта информация будет меняться со временем по мере внедрения более эффективных способов обработки, поэтому текущие реализации могут не совсем соответствовать потоку данных и функциям, описанным ниже.
Компиляция образов кода
Во-первых, необходимо определить методы, которые должны быть скомпилированы в машинный код. Это можно сделать только при реальном выполнении компилируемого кода, поскольку набор методов, которые необходимо скомпилировать, зависит от типов аргументов, передаваемых методам, а вызовы методов с определенными сочетаниями типов могут быть неизвестны до времени выполнения. Во время этого процесса точные методы, которые видит компилятор, отслеживаются для последующей компиляции, создавая трассировку компиляции.
В настоящее время при компиляции образов Julia запускает генерацию трассировки в другом процессе, а не в процессе, выполняющем AOT-компиляцию. Это может повлиять на попытку использовать отладчик во время предварительной компиляции. Для отладки предварительной компиляции с помощью отладчика лучше всего использовать rr-отладчик, записать все дерево процессов, использовать |
После определения методы, подлежащие компиляции, передаются функции jl_create_system_image
. Эта функция задает ряд структур данных, которые будут использоваться при сериализации машинного кода в файл, а затем вызывает jl_create_native
с массивом методов. jl_create_native
выполняет генерацию кода (codegen) для методов, создает один или несколько модулей LLVM. jl_create_system_image
затем записывает полезную информацию о том, что было создано при генерации кода из модулей.
После этого модули передаются функции jl_dump_native
вместе с информацией, записанной функцией jl_create_system_image
. jl_dump_native
содержит код, необходимый для сериализации модулей в файлы битового кода, объекта или сборки в зависимости от параметров командной строки, переданных Julia. Затем сериализованный код и информация записываются в файл в виде архива.
Последний шаг заключается в запуске системного компоновщика для файлов объектов в архиве, созданном с помощью jl_dump_native
. По завершении этого шага создается общая библиотека, содержащая скомпилированный код.
Загрузка образов кода
При загрузке образа кода общая библиотека, созданная компоновщиком, загружается в память. Затем из общей библиотеки загружаются данные образа системы. Эти данные содержат информацию о типах, методах и экземплярах кода, которые были скомпилированы в общую библиотеку. Данные используются для восстановления состояния среды выполнения до того, каким оно было на момент компиляции образа кода.
Если образ кода был скомпилирован с включенным множественным управлением версиями, загрузчик выберет подходящую версию каждой функции, основываясь на возможностях процессора, доступных на текущем компьютере.
Для образов системы: поскольку никакой другой код не был загружен, состояние среды выполнения сейчас такое же, каким оно было на момент компиляции образа кода. Для образов пакетов состояние среды может измениться по сравнению с тем, каким оно было на момент компиляции кода, поэтому каждый метод следует проверить на корректность по глобальной таблице методов.
Компиляция методов
Трассировка скомпилированных методов
В Julia есть флаг командной строки для записи всех методов, которые компилируются JIT-компилятором, — --trace-compile=filename
. Когда функция компилируется и этот флаг содержит имя файла, Julia выведет в этот файл отчет о предварительной компиляции с указанием метода и типов аргументов, с которыми она была вызвана. Таким образом, создается скрипт предварительной компиляции, который может быть использован позже в процессе AOT-компиляции. В пакете PrecompileTools содержатся инструменты, позволяющие упростить это процесс для разработчиков.
jl_create_system_image
jl_create_system_image
сохраняет все характерные для Julia метаданные, необходимые для последующего восстановления состояния среды выполнения. Сюда входят такие данные, как экземпляры кода, экземпляры методов, таблицы методов и информация о типах. Эта функция также задает структуры данных, необходимые для сериализации машинного кода в файл. Наконец, она вызывает jl_create_native
для создания одного или нескольких модулей LLVM, содержащих машинный код для переданных методов. Функция jl_create_native
отвечает за генерацию кода (codegen) для переданных ей методов.
jl_dump_native
jl_dump_native
отвечает за сериализацию модуля LLVM, содержащего машинный код, в файл. Помимо модуля, данные образа системы, созданные jl_create_system_image
, компилируются как глобальная переменная. Выходные данные этого метода представляют собой архивы битового кода, объектов и (или) сборок, содержащие код и данные образа системы.
jl_dump_native
обычно является одной из самых затратных по времени при генерации машинного кода, причем большая часть времени уходит на оптимизацию IR LLVM и создание машинного кода. Таким образом, эта функция способна выполнять многопоточную оптимизацию и генерацию машинного кода. Эта многопоточность зависит от размера модуля, но может быть явно переопределена заданием переменной среды JULIA_IMAGE_THREADS
. Максимальное число потоков по умолчанию равно половине числа доступных потоков, но если установить меньшее значение, то можно уменьшить пиковое потребление памяти во время компиляции.
jl_dump_native
также может создавать машинный код, оптимизированный для различных архитектур, при интеграции с загрузчиком Julia. Для запуска необходимо задать переменную среды JULIA_CPU_TARGET
и опосредованно выполнить проход с множественным управлением версиями в конвейере оптимизации. Для работы с многопоточностью перед разбиением модуля на подмодули, которые создаются в собственных потоках, добавляется шаг аннотирования, который использует доступную во всем модуле информацию, чтобы решить, какие функции следует клонировать для разных архитектур. После аннотирования отдельные потоки могут параллельно генерировать код для разных архитектур, учитывая, что другой подмодуль гарантированно создаст необходимые функции, которые будут вызваны клонированной функцией.
В архиве также хранятся некоторые другие метаданные о том, как был сериализован модуль, например количество потоков, использованных для сериализации модуля, и количество функций, которые были скомпилированы.
Статическая компоновка
Последний шаг в процессе AOT-компиляции заключается в запуске компоновщика для файлов объектов в архиве, созданном с помощью jl_dump_native
. В результате создается общая библиотека, содержащая скомпилированный код. Затем эта общая библиотека может быть загружена в Julia для восстановления состояния среды выполнения. При компиляции образа системы собственный компоновщик, применяемый компилятором C, создает итоговую общую библиотеку. Для образов пакетов LLVM-компоновщик LLD предоставляет более согласованный интерфейс компоновки.
Загрузка образов кода
Загрузка общей библиотеки
Первым шагом при загрузке образа кода является загрузка общей библиотеки, созданной компоновщиком. Для этого нужно вызвать функцию jl_dlopen
в пути к общей библиотеке. Эта функция загружает общую библиотеку и разрешает все символы в ней.
Загрузка машинного кода
Сначала загрузчику нужно определить, подходит ли скомпилированный машинный код для архитектуры выполнения загрузчика. Это необходимо, чтобы избежать выполнения инструкций, которые не распознаются старыми процессорами. Для этого нужно сверить возможности ЦП, доступные на текущем компьютере, с возможностями ЦП, для которых был скомпилирован код. Если включено множественное управление версиями, загрузчик выберет подходящую версию каждой функции, основываясь на возможностях процессора, доступных на текущем компьютере. Если ни один из наборов функций не попал под множественное управление версиями, загрузчик выдаст ошибку.
В процессе прохода множественного управления версиями создается несколько глобальных массивов всех функций модуля. Если процесс является многопоточным, создается массив массивов, который загрузчик переупорядочивает в один большой массив со всеми функциями, скомпилированными для данной архитектуры. Аналогичный процесс происходит и с глобальными переменными модуля.
Настойка состояния Julia
Затем загрузчик использует глобальные переменные и функции, полученные в результате загрузки машинного кода, для настройки основных структур данных среды выполнения Julia в текущем процессе. В этом процессе происходит добавление типов и методов в среду выполнения Julia, и кэшированный машинный код становится доступным для использования другими функциями и интерпретатором Julia. Для образов пакетов: каждый метод должен быть проверен, так как состояние глобальной таблицы методов должно соответствовать состоянию, для которого был скомпилирован образ пакета. В частности, если во время загрузки и компиляции образа пакета существуют разные наборы методов, то при первом использовании метод должен быть аннулирован и перекомпилирован. Это необходимо для того, чтобы семантика выполнения оставалась неизменной независимо от того, был ли пакет предварительно скомпилирован или код был выполнен напрямую. Образам системы не нужно выполнять эту проверку, поскольку во время загрузки глобальная таблица методов пуста. Таким образом, образы системы загружаются быстрее образов пакетов.