Разработка и реализация JIT
|
Страница в процессе перевода. |
В этом документе приводятся сведения о разработке и реализации JIT Julia после завершения генерации кода и создания неоптимизированного представления IR LLVM. JIT отвечает за оптимизацию и компиляцию этого IR в машинный код, а также за его компоновку в текущий процесс и предоставление доступа к коду для выполнения.
Введение
JIT осуществляет управление ресурсами компиляции, поиск ранее скомпилированного кода и компиляцию нового кода. В основном он построен на технологии компиляции по запросу (ORCv2) в LLVM, которая реализует поддержку ряда полезных функций, таких как параллельная компиляция, отложенная компиляция и возможность компиляции кода в отдельном процессе. Хотя LLVM предоставляет базовый JIT-компилятор в виде LLJIT, для создания собственного JIT-компилятора в Julia напрямую используются многие API ORCv2.
Обзор
При генерации кода создается модуль LLVM, содержащий IR для одной или нескольких функций Julia, из исходного IR SSA Julia, полученного в результате вывода типов (помечен как translate (преобразование) на диаграмме компилятора выше). Также производится сопоставление экземпляра кода с именем функции LLVM. Однако, несмотря на то, что компилятор на основе Julia применил некоторые оптимизации к IR Julia, представление IR LLVM, полученное при генерации кода, все еще содержит много возможностей для оптимизации. Таким образом, первым шагом JIT является запуск независимого от целевого объекта конвейера оптимизации[1] в модуле LLVM. Затем JIT запускает зависящий от цели конвейер оптимизации для оптимизации и генерации кода и выводит файл объекта. Наконец, JIT компонует полученный файл объекта в текущий процесс и делает код доступным для выполнения. Все эти действия контролируются кодом в файле src/jitlayers.cpp.
В настоящее время в конвейер оптимизации-компиляции-компоновки одновременно может попасть только один поток. Это связано с ограничениями, налагаемыми одним из наших компоновщиков (RuntimeDyld). Однако JIT разработан для поддержки одновременной оптимизации и компиляции, и ожидается, что в будущем, когда RuntimeDyld будет полностью заменен на всех платформах, ограничение компоновщика будет снято.
Конвейер оптимизации
Конвейер оптимизации основан на новом диспетчере проходов LLVM, но адаптирован под нужды Julia. Конвейер определен в src/pipeline.cpp и в целом проходит несколько этапов, которые подробно описаны ниже.
-
Упрощение на ранних этапах
-
Эти проходы в основном используются для упрощения IR и канонизации шаблонов, чтобы последующие проходы могли легче идентифицировать эти шаблоны. Кроме того, различные внутренние вызовы, такие как подсказки для предсказания ветвей и аннотации, понижаются до других метаданных или других функций IR. Ключевую роль здесь играют
SimplifyCFG(упрощение графика порядка управления),DCE(исключение нерабочего кода) иSROA(скалярная замена агрегатов).
-
-
Оптимизация на ранних этапах
-
Эти проходы обычно дешевы и в основном направлены на уменьшение количества инструкций в IR и распространение знаний в другие инструкции. Например,
EarlyCSEиспользуется для устранения общих подвыражений, аInstCombineиInstSimplifyвыполняют ряд небольших локальных оптимизаций для удешевления операций.
-
-
Оптимизация циклов
-
Эти проходы канонизируют и упрощают циклы. Циклы часто являются «горячим» кодом, что делает оптимизацию циклов чрезвычайно важной для производительности. Ключевую роль здесь играют
LoopRotate,LICMиLoopFullUnroll. В результате проходаIRCEтакже происходит устранение части проверки границ, которое подтверждает, что определенные границы никогда не превышаются.
-
-
Скалярная оптимизация
-
Конвейер скалярной оптимизации содержит ряд более дорогих, но более мощных проходов, таких как
GVN(нумерация глобальных значений),SCCP(распространение разреженных условных констант) и еще один цикл устранения проверки границ. Эти проходы стоят дорого, но зачастую они позволяют удалить большое количество кода и сделать векторизацию гораздо более успешной и эффективной. Несколько других проходов упрощений и оптимизаций чередуются с более дорогими, чтобы сократить объем работы, которую им приходится выполнять.
-
-
Векторизация
-
Автоматическая векторизация — это чрезвычайно мощное преобразование для кода, требующего интенсивной загрузки ЦП. Если коротко, векторизация позволяет выполнять одну инструкцию с множеством данных (SIMD), например выполнять 8 операций сложения одновременно. Однако доказать, что код одновременно поддерживает векторизацию и выгоден для векторизации, довольно сложно, и это в значительной степени зависит от предварительных проходов оптимизации для перевода IR в состояние, в котором векторизация будет оправдана.
-
-
Понижение внутренних функций
-
Julia вставляет ряд пользовательских собственных внутренних функций для таких целей, как распределение объектов, сборка мусора и обработка исключений. Изначально эти внутренние функции были размещены для того, чтобы сделать возможности оптимизации более очевидными, но теперь они понижены в IR LLVM, чтобы IR можно было выдавать в виде машинного кода.
-
-
Очистка
-
Эти проходы являются оптимизациями последнего шанса и выполняют небольшие оптимизации, такие как распространение объединенного умножения-сложения и упрощение деления-нахождения остатка. Кроме того, для целевых объектов, не поддерживающих числа с плавающей запятой половинной точности, инструкции половинной точности будут понижены до инструкций одинарной точности, и будут добавлены проходы для поддержки санитайзеров.
-
Зависящая от целевого объекта оптимизация и генерация кода
LLVM обеспечивает зависящую от целевого объекта оптимизацию и генерацию машинного кода в одном конвейере, расположенном в TargetMachine для данной платформы. В число этих проходов входят выбор инструкций, планирование инструкций, выделение реестров и создание машинного кода. Хороший обзор этого процесса приводится в документации по LLVM, а более подробные сведения о конвейере и проходах можно найти в исходном коде LLVM.
Компоновка
В настоящее время Julia находится на этапе перехода со старого компоновщика RuntimeDyld на более новый JITLink. JITLink содержит ряд возможностей, которых нет у RuntimeDyld, например параллельную и повторно входимую компоновку, но сейчас у него нет достаточной поддержки интеграции профилирования и пока он не поддерживает все платформы, которые поддерживает RuntimeDyld. Ожидается, что со временем JITLink полностью заменит RuntimeDyld. Более подробные сведения о JITLink можно найти в документации по LLVM.
Выполнение
Код, скомпонованный в текущий процесс, становится доступным для выполнения. Генерирующий экземпляр кода узнает об этом в результате соответствующего обновления полей invoke, specsigflags и specptr. Экземпляры кода поддерживают обновление полей invoke, specsigflags и specptr при условии возможности вызова каждого их сочетания, существующего в любой момент времени. В этом случае JIT может обновлять эти поля, не аннулируя существующие экземпляры кода, поддерживая потенциально возможный в будущем параллельный JIT. В частности, могут быть действительны следующие состояния:
-
invokeимеет значение NULL,specsigflagsимеет значение 0b00,specptrимеет значение NULL-
Это начальное состояние экземпляра кода, которое указывает на то, что экземпляр кода еще не скомпилирован.
-
-
invokeимеет ненулевое значение,specsigflagsимеет значение 0b00,specptrимеет значение NULL-
Это указывает на то, что экземпляр кода не был скомпилирован с какой-либо специализацией и что его следует вызывать напрямую. Обратите внимание, что в данном случае
invokeне считывает ни полеspecsigflags, ни полеspecptr, поэтому их можно изменить, не аннулируя указательinvoke.
-
-
invokeимеет ненулевое значение,specsigflagsимеет значение 0b10,specptrимеет ненулевое значение-
Это указывает на то, что экземпляр кода был скомпилирован, но специализированная сигнатура функции признана ненужной для генерации кода.
-
-
invokeимеет ненулевое значение,specsigflagsимеет значение 0b11,specptrимеет ненулевое значение-
Это указывает на то, что экземпляр кода был скомпилирован, и специализированная сигнатура функции признана необходимой для генерации кода. Поле
specptrсодержит указатель на специализированную сигнатуру функции. Указательinvokeможет считывать оба поляspecsigflagsиspecptr.
-
Кроме того, в процессе обновления возникает несколько различных переходных состояний. Чтобы учесть эти возможные ситуации, при работе с этими полями экземпляра кода следует использовать следующие шаблоны записи и чтения.
-
При записи в поля
invoke,specsigflagsиspecptr: 1. Выполните атомарную операцию сравнения-обмена для поля specptr с предположением, что старое значение было равно NULL. Эта операция сравнения-обмена должна иметь, по крайней мере, упорядочение захвата-освобождения, чтобы гарантировать упорядочение оставшихся операций с памятью при записи. 2. Если полеspecptrимело ненулевое значение, прекратите операцию записи и дождитесь записи бита 0b10 в полеspecsigflags. 3. Запишите новый младший бит для поляspecsigflagsв качестве его конечного значения. Это может быть ослабленная операция записи. 4. Запишите новый указатель дляinvokeв качестве его конечного значения. Для синхронизации с операциями чтенияinvokeу него должно быть, по крайней мере, упорядочение освобождения памяти. 5. Задайте второму битуspecsigflagsзначение 1. Для синхронизации с операциями чтенияspecsigflagsтребуется, по крайней мере, упорядочение освобождения памяти. Этот шаг завершает операцию записи и сообщает всем остальным потокам о том, что все поля были заданы. -
При чтении всех полей
invoke,specsigflagsиspecptr:-
Считайте поле
invoke, по крайней мере, с упорядочением захвата памяти. Этот операция будет обозначаться какinitial_invoke. -
Если
initial_invokeимеет значение, экземпляр кода еще является выполняемым.invokeимеет значение NULL,specsigflagsможет рассматриваться как имеющее значение 0b00,specptrможет рассматриваться как имеющее значение NULL. -
Считайте поле
specptr, по крайней мере, с упорядочением захвата памяти. -
Если
specptrимеет значение NULL, то указательinitial_invokeне должен полагаться наspecptrдля правильного выполнения. Поэтомуinvokeимеет ненулевое значение,specsigflagsможет рассматриваться как имеющее значение 0b00,specptrможет рассматриваться как имеющее значение NULL. -
Если
specptrимеет ненулевое значение, тоinitial_invokeможет не быть конечным полемinvoke, использующимspecptr. Это может произойти, еслиspecptrуже записано, аinvoke— еще нет. Поэтому перебирайте второй бит поляspecsigflagsдо тех пор, пока не будет задано значение 1, по крайней мере, с упорядочением памяти захвата. -
Повторно считайте поле
invoke, по крайней мере, с упорядочением захвата памяти. Этот операция будет обозначаться какfinal_invoke. -
Считайте поле
specsigflagsс любым упорядочением памяти. -
invokeявляетсяfinal_invoke,specsigflags— это значение, считанное на шаге 7,specptr— это значение, считанное на шаге 3.
-
-
При обновлении
specptrдо другого, но аналогичного указателя функции:-
Выполните освобождение памяти нового указателя функции в
specptr. Гонки здесь должны неопасны, так как старый указатель функции должен быть все еще допустимым, так же как и любой новый. Указатель, записанный вspecptr, всегда должен быть доступен для вызова, независимо от того, будет ли он впоследствии перезаписан или нет.
-
Несмотря на сложность, эти шаги записи, чтения и обновления гарантируют, что JIT может обновлять экземпляры кода без аннулирования существующих экземпляров и что JIT может обновлять экземпляры кода без аннулирования существующих указателей invoke. Это позволяет JIT в будущем повторно оптимизировать функции на более высоких уровнях оптимизации, а также поддерживать параллельную компиляцию функций.