Работа с LLVM

Данная глава не является заменой документации LLVM, а является всего лишь набором советов по работе с LLVM в Julia.

Обзор интерфейса между Julia и LLVM

По умолчанию динамическая компоновка в Julia выполняется с LLVM. Для статической компоновки выполняйте сборку с параметром USE_LLVM_SHLIB=0.

Код для понижения представления AST Julia до промежуточного представления (IR) или его непосредственной интерпретации находится в каталоге src/.

Файл Описание

aotcompile.cpp

Устаревший конвейер диспетчера проходов, вход в интерфейс C компилятора

builtins.c

Встроенные функции

ccall.cpp

Понижение ccall

cgutils.cpp

Понижение вспомогательных функций, в первую очередь для доступа к массивам и кортежам

codegen.cpp

Верхний уровень генерации кода, список проходов, понижение встроенных функций

debuginfo.cpp

Отслеживает отладочную информацию для JIT-кода

disasm.cpp

Обрабатывает машинный объектный файл и выполняет дизассемблирование JIT-кода

gf.c

Универсальные функции

intrinsics.cpp

Понижение внутренних функций

jitlayers.cpp

Код, относящийся к JIT, уровни и вспомогательные средства компиляции ORC

llvm-alloc-helpers.cpp

Escape-анализ, относящийся к Julia

llvm-alloc-opt.cpp

Пользовательский проход LLVM для понижения выделений в куче с переводом в стек

llvm-cpufeatures.cpp

Пользовательский проход LLVM для понижения функций на основе ЦП (например, haveFMA)

llvm-demote-float16.cpp

Пользовательский проход LLVM для понижения 16-разрядных операций с плавающей запятой до 32-разрядных операций с плавающей запятой

llvm-final-gc-lowering.cpp

Пользовательский проход LLVM для понижения вызовов сборщика мусора до окончательной формы

llvm-gc-invariant-verifier.cpp

Пользовательский проход LLVM для проверки инвариантов сборки мусора Julia

llvm-julia-licm.cpp

Пользовательский проход LLVM для поднятия или понижения внутренних функций Julia

llvm-late-gc-lowering.cpp

Пользовательский проход LLVM для «укоренения» значений, отслеживаемых сборщиком мусора

llvm-lower-handlers.cpp

Пользовательский проход LLVM для понижения блоков try-catch

llvm-muladd.cpp

Пользовательский проход LLVM для быстрого сопоставления FMA

llvm-multiversioning.cpp

Пользовательский проход LLVM для генерирования кода образа системы для нескольких архитектур

llvm-propagate-addrspaces.cpp

Пользовательский проход LLVM для канонизации адресных пространств

llvm-ptls.cpp

Пользовательский проход LLVM для понижения операций TLS

llvm-remove-addrspaces.cpp

Пользовательский проход LLVM для удаления адресных пространств Julia

llvm-remove-ni.cpp

Пользовательский проход LLVM для удаления нецелостных адресных пространств Julia

llvm-simdloop.cpp

Пользовательский проход LLVM для @simd

pipeline.cpp

Новый конвейер диспетчера проходов, анализ конвейера проходов

sys.c

Ввод-вывод и вспомогательные функции операционной системы

Некоторые файлы .cpp образуют группу, которая компилируется в один объект.

Разница между внутренней функцией и встроенной заключается в том, что встроенная функция является полноправной и может использоваться как любая другая функция Julia. Встроенная функция может работать только с распакованными данными, поэтому ее аргументы должны быть статически типизированными.

Анализ псевдонимов

В Julia в настоящее время применяется анализ псевдонимов на основе типов LLVM. Найти комментарии, в которых документируются отношения включения, можно по static MDNode* в файле src/codegen.cpp.

Параметр -O включает базовый анализ псевдонимов LLVM.

Сборка Julia с другой версией LLVM

Версия LLVM по умолчанию указывается в файле deps/llvm.version. Ее можно переопределить, создав в каталоге верхнего уровня файл с именем Make.user и добавив в него такую строку:

LLVM_VER = 13.0.0

Помимо номеров выпусков LLVM, вы также можете задать параметр DEPS_GIT = llvm в сочетании с USE_BINARYBUILDER_LLVM = 0 для сборки с применением последней версии разработки LLVM.

Кроме того, можно выполнять сборку с отладочной версией LLVM, указав в файле Make.user параметр LLVM_DEBUG = 1 или LLVM_DEBUG = Release. В первом случае сборка LLVM будет полностью неоптимизированной, а во втором — оптимизированной. В зависимости от потребностей второго варианта может быть достаточно, и он весьма быстрее. При использовании параметра LLVM_DEBUG = Release может быть желательно также задать LLVM_ASSERTIONS = 1, чтобы включить диагностику для разных проходов. По умолчанию этот параметр включен только при LLVM_DEBUG = 1.

Передача параметров в LLVM

Передавать параметры в LLVM можно посредством переменной среды JULIA_LLVM_ARGS. Вот пример параметров с использованием синтаксиса bash:

  • export JULIA_LLVM_ARGS=-print-after-all выводит IR после каждого прохода;

  • export JULIA_LLVM_ARGS=-debug-only=loop-vectorize выводит диагностику DEBUG(...) LLVM для векторизатора циклов. При получении предупреждений о неизвестном аргументе командной строки выполните сборку LLVM повторно с параметром LLVM_ASSERTIONS = 1.

Изолированная отладка преобразований LLVM

Иногда может быть полезно выполнять отладку преобразований LLVM отдельно от остальной системы Julia, например, потому, что воспроизведение проблемы в julia заняло бы слишком много времени, или потому, что требуется воспользоваться инструментарием LLVM (например, bugpoint). Чтобы получить неоптимизированное представление IR для всего образа системы, передайте параметр --output-unopt-bc unopt.bc в процесс сборки образа системы. В результате неоптимизированное представление IR будет выведено в файл unopt.bc. Затем этот файл можно передавать в инструменты LLVM обычным образом. Библиотека libjulia может выступать в роли плагина проходов LLVM и загружаться в инструменты LLVM, чтобы относящиеся к Julia проходы были доступны в соответствующей среде. Кроме того, она предоставляет метапроход -julia, который выполняет весь конвейер проходов Julia применительно к IR. Например, чтобы создать образ системы с помощью старого диспетчера проходов, можно сделать следующее.

opt -enable-new-pm=0 -load libjulia-codegen.so -julia -o opt.bc unopt.bc
llc -o sys.o opt.bc
cc -shared -o sys.so sys.o

Чтобы создать образ системы с помощью нового диспетчера проходов, можно сделать следующее.

opt -load-pass-plugin=libjulia-codegen.so --passes='julia' -o opt.bc unopt.bc
llc -o sys.o opt.bc
cc -shared -o sys.so sys.o

Этот образ системы затем может загружаться julia обычным образом.

Кроме того, следующим образом можно вывести дамп IR-модуля LLVM только для одной функции Julia:

fun, T = +, Tuple{Int,Int} # Подставьте здесь интересующую вас функцию
optimize = false
open("plus.ll", "w") do file
    println(file, InteractiveUtils._dump_function(fun, T, false, false, false, true, :att, optimize, :default))
end

Эти файлы могут обрабатываться точно так же, как приведенное выше неоптимизированное представление IR образа системы.

Улучшение оптимизаций LLVM для Julia

Для улучшения генерации кода LLVM обычно требуется либо сделать понижение кода Julia более совместимым с проходами LLVM, либо оптимизировать проход.

Если вы собираетесь оптимизировать проход, обязательно ознакомьтесь с политикой LLVM для разработчиков. Наилучшей стратегией будет создать пример кода в такой форме, которая позволяет использовать инструмент LLVM opt, чтобы изучить его и интересующий вас проход изолированно.

  1. Создайте пример нужного кода Julia.

  2. Используйте параметр JULIA_LLVM_ARGS=-print-after-all для получения дампа IR.

  3. Выберите IR в месте непосредственно перед выполнением интересующего вас прохода.

  4. Удалите отладочные метаданные и исправьте метаданные TBAA вручную.

Последнее потребует усилий. Мы были бы благодарны, если бы вы смогли предложить более удобный способ.

Соглашение о вызовах jlcall

В Julia есть общее соглашение о вызовах для неоптимизированного кода, которое выглядит примерно так:

jl_value_t *any_unoptimized_call(jl_value_t *, jl_value_t **, int);

Здесь первый аргумент — это упакованный объект функции, второй — это размещенный в стеке массив аргументов, а третий — количество аргументов. Теперь мы могли бы выполнить понижение напрямую и вызвать функцию alloca для массива аргументов. Однако это нарушило бы принципы использования SSA в месте вызова и существенно усложнило бы оптимизации (включая размещение корней сборки мусора). Вместо этого мы вызовем ее следующим образом:

call %jl_value_t *@julia.call(jl_value_t *(*)(...) @any_unoptimized_call, %jl_value_t *%arg1, %jl_value_t *%arg2)

Это позволяет соблюдать принципы использования SSA при любых операциях оптимизатора. Посредством размещения корней сборки мусора этот вызов в дальнейшем будет понижен до исходного ABI C.

Размещение корней сборки мусора

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

if some_condition()
    #= Возможно, здесь используются какие-либо переменные =#
    error("An error occurred")
end

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

Однако, чтобы позднее размещение корней сборки мусора было возможно, мы должны иметь возможность определить следующее: а) указатели, отслеживаемые сборщиком мусора; б) все случаи использования таких указателей. Таким образом, цель размещения корней сборки мусора проста:

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

Представление

Таким образом, главная сложность заключается в выборе представления IR, которое позволяет определять указатели, отслеживаемые сборщиком мусора, и случаи их использования даже после прогона программы через оптимизатор. Для этого наш подход предполагает использование трех функций LLVM:

  • пользовательские адресные пространства;

  • пакеты операндов;

  • нецелочисленные указатели.

Пользовательские адресные пространства позволяют нам пометить целым числом каждое место, которое должно сохраняться в процессе оптимизаций. Компилятор не может добавлять приведения между адресными пространствами, которых не было в исходной программе, и никогда не должен изменять адресное пространство указателя при операции загрузки, сохранения и т. д. Это позволяет аннотировать указатели, отслеживаемые сборщиком мусора, так, чтобы на это не мог влиять оптимизатор. Обратите внимание, что реализовать то же самое с помощью метаданных невозможно. Предполагается, что любые метаданные можно удалить без изменения смысла программы. Однако невозможность определить указатель, отслеживаемый сборщиком мусора, коренным образом меняет поведение программы — она может завершаться сбоем или возвращать неверные результаты. В настоящее время мы используем три разных адресных пространства (их номера определены в файле src/codegen_shared.cpp):

  • Указатели, отслеживаемые сборщиком мусора (в настоящее время 10): это указатели на упакованные значения, которые можно поместить в кадр сборки мусора. Они приблизительно похожи на указатель jl_value_t* в C. Примечание: в этом адресном пространстве не должно быть указателей, которые невозможно сохранить в слоте сборщика мусора.

  • Производные указатели (в настоящее время 11): это указатели, производные от какого либо указателя, отслеживаемого сборщиком мусора. Использование таких указателей влечет за собой использование исходного указателя. Однако сами они не обязательно должны быть известны сборщику мусора. Проход размещения корней сборки мусора обязательно ДОЛЖЕН находить указатель, отслеживаемый сборщиком мусора, от которого данный указатель является производным, и использовать его для создания корня.

  • Корневые указатели вызываемой стороны (в настоящее время 12): это вспомогательное адресное пространство для выражения понятия корневого значения вызываемой стороны. Все значения этого адресного пространства ДОЛЖНЫ иметь возможность сохранения в корне сборки мусора (хотя данное условие может стать менее строгим в будущем), но, в отличие от других указателей, не обязательно должны быть корневыми при передаче в вызов (однако по-прежнему должны быть корневыми, если они активны в другой безопасной точке между определением и вызовом).

  • Указатели, загружаемые из отслеживаемого объекта (в настоящее время 13): используются массивами, которые сами содержат указатель на управляемые данные. Эта область данных принадлежит массиву, но сама по себе не является объектом, отслеживаемым сборщиком мусора. Компилятор гарантирует, что пока этот указатель активен, будет оставаться активен и объект, из которого он был загружен.

Инварианты

Проход размещения корней сборки мусора использует несколько инвариантов, которые должны соблюдаться интерфейсной частью и сохраняются оптимизатором.

Во-первых, разрешены только следующие приведения адресных пространств:

  • 0->{Tracked,Derived,CalleeRooted} (отслеживаемые, производные, корневые вызываемой стороны): неотслеживаемый указатель может быть вырожден в любой другой. Однако обратите внимание, что у оптимизатора есть право не делать такое значение корневым. Наличие значения в адресном пространстве 0 в любой части программы небезопасно, если это значение требует корня сборки мусора (или является производным от такого значения).

  • Tracked (отслеживаемые)->Derived (производные): Это стандартный путь вырождения для внутренних значений. Проход размещения ищет такие значения для определения базового указателя для любого случая использования.

  • Tracked (отслеживаемые)->CalleeRooted (корневые вызываемой стороны): адресное пространство CalleeRooted просто указывает, что корень сборки мусора не требуется. Однако обратите внимание, что вырождение Derived (отслеживаемые)->CalleeRooted (корневые вызываемой стороны) запрещено, так как указатели в общем случае должны иметь возможность сохранения в слоте сборки мусора даже в этом адресном пространстве.

Теперь давайте рассмотрим, что относится к случаям использования:

  • операции загрузки значений, которые находятся в одном из адресных пространств;

  • операции сохранения значений, находящихся в одном из адресных пространств, в определенном месте;

  • операции сохранения в указателе в одном из адресных пространств;

  • вызовы, для которых операндом является значение в одном из адресных пространств;

  • вызовы в ABI jlcall, для которых массив аргументов содержит значение;

  • инструкции return.

Мы явным образом разрешаем операции загрузки и сохранения и простые вызовы в адресных пространствах Tracked и Derived. Элементы массивов аргументов jlcall всегда должны находиться в адресном пространстве Tracked (согласно ABI они должны быть действительными указателями jl_value_t*). То же самое верно для инструкций return (однако обратите внимание, что возвращаемые аргументы в виде структур могут находиться в любом адресном пространстве). Единственный допустимый вариант использования указателя в адресном пространстве CalleeRooted — его передача в вызов (который должен иметь операнд соответствующего типа).

Кроме того, запрещено нахождение getelementptr в адресном пространстве Tracked. Причина в том, что если операция не является холостой, указатель в итоге будет невозможно сохранить в слоте сборки мусора и поэтому он не сможет находиться в этом адресном пространстве. Если такой указатель требуется, его сначала нужно привести к адресному пространству Derived.

Наконец, в этих адресных пространствах запрещены инструкции inttoptr и ptrtoint. Наличие таких инструкций означало бы, что некоторые значения i64 на самом деле отслеживаются сборщиком мусора. А это создавало бы проблему, так как нарушало бы требование возможности определения указателей, имеющих отношение к сборке мусора. Данный инвариант обеспечивается функцией «нецелочисленных указателей» LLVM, которая появилась в LLVM 5.0. Она запрещает оптимизатору производить оптимизации, которые привели бы к появлению таких операций. Обратите внимание: мы по-прежнему можем вводить статические константы во время JIT с помощью inttoptr в адресном пространстве 0, а затем приводить их к соответствующему адресному пространству.

Поддержка ccall

Важным аспектом, который пока не обсуждался, является обработка ccall. Особенностью ccall является то, что место и область использования не совпадают. Рассмотрим следующий пример:

A = randn(1024)
ccall(:foo, Cvoid, (Ptr{Float64},), A)

При понижении добавляет преобразование массива в указатель, в результате чего удаляется ссылка на значение массива. Однако, безусловно, необходимо сделать так, чтобы массив оставался активен, пока выполняется ccall. Чтобы понять, как это достигается, сначала вспомним, как понижается приведенный выше код:

return $(Expr(:foreigncall, :(:foo), Cvoid, svec(Ptr{Float64}), 0, :(:ccall), Expr(:foreigncall, :(:jl_array_ptr), Ptr{Float64}, svec(Any), 0, :(:ccall), :(A)), :(A)))

Последний элемент :(A) — это дополнительный список аргументов, который добавляется во время понижения и сообщает генератору кода, какие значения на уровне Julia должны оставаться активными на протяжении выполнения этого ccall. Затем мы берем эту информацию и представляем ее в виде «пакета операндов» на уровне IR. Пакет операндов — это по сути фиктивный случай использования, привязываемый к месту вызова. На уровне IR это выглядит так:

call void inttoptr (i64 ... to void (double*)*)(double* %5) [ "jl_roots"(%jl_value_t addrspace(10)* %A) ]

Во время прохода размещения корней сборки мусора пакет операндов jl_roots обрабатывается как обычный операнд. Однако на последнем шаге, после добавления корней сборки мусора, пакет операндов удаляется, чтобы не вносить путаницу в выбор инструкций.

Поддержка pointer_from_objref

Особенностью pointer_from_objref является то, что пользователь должен явно контролировать корни сборки мусора. Согласно приведенным выше инвариантам эта функция недопустима, так как она выполняет приведение из адресного пространства 10 в 0. Однако в некоторых ситуациях она может быть полезна, поэтому мы предоставляем специальную внутреннюю функцию:

declared %jl_value_t *julia.pointer_from_objref(%jl_value_t addrspace(10)*)

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

Поддержание значений в активном состоянии в отсутствие случаев использования

В некоторых случаях объект должен оставаться активным, даже если компилятору неизвестны случаи его использования. Это может быть справедливо в случае с низкоуровневым кодом, который напрямую оперирует с представлением объекта в памяти, или с кодом, который должен взаимодействовать с кодом C. Для этого мы предоставляем следующие внутренние функции на уровне LLVM:

token @llvm.julia.gc_preserve_begin(...)
void @llvm.julia.gc_preserve_end(token)

(Элемент llvm. необходим для использования типа token.) Эти внутренние функции имеют следующий смысл: в любой безопасной точке, которой управляет вызов gc_preserve_begin, но не управляет соответствующий вызов gc_preserve_end (то есть вызов, аргументом которого является токен, возвращаемый вызов gc_preserve_begin), значения, передаваемые как аргументы в этот вызов gc_preserve_begin, будут оставаться активными. Имейте в виду, что gc_preserve_begin по-прежнему считается обычным случаем использования этих значений, поэтому стандартная семантика времени существования будет обеспечивать активность значений до входа в область сохранения.