Введение
Техника оптимизации компилятора
В оптимизации компилятора распределение регистров — это процесс назначения локальных автоматических переменных и результатов выражений ограниченному числу регистров процессора. Распределение регистров может выполняться в пределах базового блока (локальное распределение регистров), для всей функции/процедуры (глобальное распределение регистров) или между функциями, связанными графом вызовов (межпроцедурное распределение регистров). При распределении регистров для каждой функции/процедуры соглашение о вызовах может потребовать вставки команд сохранения и восстановления регистров вокруг каждого места вызова.
Компоненты распределения реестра
Таким образом, распределение регистров заключается в выборе места хранения переменных во время выполнения, то есть внутри регистров или в памяти. Если переменная должна храниться в регистре, то распределителю необходимо определить, в каком (каких) регистрах она будет храниться. Кроме того, необходимо определить время, в течение которого переменная должна оставаться в одном и том же месте. Распределитель регистров, независимо от выбранной стратегии, может опираться на ряд основных операций для решения этих задач. Эти операции можно объединить в несколько категорий:
Move insertion This action consists of increasing the number of move instructions between registers, i. e. make a variable live in different registers during its lifetime, instead of one. This occurs in the split live range approach. Spilling This action consists of storing a variable into memory instead of registers. Assignment This action consists of assigning a register to a variable. Coalescing This action consists of limiting the number of moves between registers, thus limiting the total number of instructions. For instance, by identifying a variable live across different methods, and storing it into one register during its whole lifetime. Many register allocation approaches optimize for one or more specific categories of actions.
Вставка перемещений – увеличение количества инструкций перемещения между регистрами, то есть обеспечение существования переменной в разных регистрах в течение её времени жизни, а не в одном. Это происходит при подходе с разделением областей жизни.
Move insertion This action consists of increasing the number of move instructions between registers, i. e. make a variable live in different registers during its lifetime, instead of one. This occurs in the split live range approach. Spilling This action consists of storing a variable into memory instead of registers. Assignment This action consists of assigning a register to a variable. Coalescing This action consists of limiting the number of moves between registers, thus limiting the total number of instructions. For instance, by identifying a variable live across different methods, and storing it into one register during its whole lifetime. Many register allocation approaches optimize for one or more specific categories of actions.
Вытеснение – хранение переменной в памяти вместо регистра.
Move insertion This action consists of increasing the number of move instructions between registers, i. e. make a variable live in different registers during its lifetime, instead of one. This occurs in the split live range approach. Spilling This action consists of storing a variable into memory instead of registers. Assignment This action consists of assigning a register to a variable. Coalescing This action consists of limiting the number of moves between registers, thus limiting the total number of instructions. For instance, by identifying a variable live across different methods, and storing it into one register during its whole lifetime. Many register allocation approaches optimize for one or more specific categories of actions.
Присваивание – назначение регистра переменной.
Move insertion This action consists of increasing the number of move instructions between registers, i. e. make a variable live in different registers during its lifetime, instead of one. This occurs in the split live range approach. Spilling This action consists of storing a variable into memory instead of registers. Assignment This action consists of assigning a register to a variable. Coalescing This action consists of limiting the number of moves between registers, thus limiting the total number of instructions. For instance, by identifying a variable live across different methods, and storing it into one register during its whole lifetime. Many register allocation approaches optimize for one or more specific categories of actions.
Коалесценция – ограничение количества перемещений между регистрами, что уменьшает общее количество инструкций. Например, путём определения переменной, используемой в нескольких методах, и хранения её в одном регистре на протяжении всего времени жизни.
Move insertion This action consists of increasing the number of move instructions between registers, i. e. make a variable live in different registers during its lifetime, instead of one. This occurs in the split live range approach. Spilling This action consists of storing a variable into memory instead of registers. Assignment This action consists of assigning a register to a variable. Coalescing This action consists of limiting the number of moves between registers, thus limiting the total number of instructions. For instance, by identifying a variable live across different methods, and storing it into one register during its whole lifetime. Many register allocation approaches optimize for one or more specific categories of actions.
Многие подходы к распределению регистров оптимизируются для одной или нескольких конкретных категорий операций.
Move insertion This action consists of increasing the number of move instructions between registers, i. e. make a variable live in different registers during its lifetime, instead of one. This occurs in the split live range approach. Spilling This action consists of storing a variable into memory instead of registers. Assignment This action consists of assigning a register to a variable. Coalescing This action consists of limiting the number of moves between registers, thus limiting the total number of instructions. For instance, by identifying a variable live across different methods, and storing it into one register during its whole lifetime. Many register allocation approaches optimize for one or more specific categories of actions.
Общие проблемы, возникающие при распределении реестра
Распределение регистров порождает ряд проблем, которые можно решить (или избежать) с помощью различных подходов к распределению регистров. Три наиболее распространенные проблемы определяются следующим образом:
Алиасинг (перекрытие) В некоторых архитектурах присвоение значения одному регистру может повлиять на значение другого: это называется алиасингом. Например, архитектура x86 имеет четыре 32-битных регистра общего назначения, которые также могут использоваться как 16-битные или 8-битные регистры. В этом случае присвоение 32-битного значения регистру eax повлияет на значение регистра al. Предварительная раскраска (предопределение цветов) Эта проблема заключается в принудительном назначении определенных переменных конкретным регистрам. Например, в соглашениях о вызовах PowerPC параметры обычно передаются в регистрах R3–R10, а возвращаемое значение – в R3. NP-полная задача Chaitin и др. показали, что распределение регистров является NP-полной задачей. Они привели задачу раскраски графа к задаче распределения регистров, показав, что для произвольного графа можно построить программу, для которой распределение регистров (где регистры представляют узлы, а машинные регистры – доступные цвета) будет являться раскраской исходного графа. Поскольку раскраска графа является NP-трудной задачей, а распределение регистров находится в классе NP, это доказывает NP-полноту задачи.
Методы распределения реестров
Распределение регистров может выполняться над базовым блоком кода: такой подход называют "локальным", и впервые о нём упомянули Хорвиц и др. Поскольку базовые блоки не содержат переходов, процесс распределения считается быстрым, так как управление точками слияния графа потока управления при распределении регистров оказывается ресурсоемкой операцией. Однако считается, что этот подход не позволяет получить столь же оптимизированный код, как и "глобальный" подход, который оперирует над всей единицей компиляции (например, методом или процедурой).
Распределение графических цветов
Распределение цветов графа — основной подход к решению задачи распределения регистров. Впервые он был предложен Чейтином и др. В этом подходе узлы графа представляют живые диапазоны (переменные, временные переменные, виртуальные/символические регистры), которые являются кандидатами на выделение регистров. Рёбра соединяют живые диапазоны, которые конфликтуют, то есть живые диапазоны, которые одновременно активны как минимум в одной точке программы. Распределение регистров сводится к задаче раскраски графа, в которой узлам назначаются цвета (регистры) таким образом, чтобы два узла, соединённые ребром, не имели одинаковый цвет. С помощью анализа времени жизни можно построить граф интерференции. Граф интерференции, являющийся неориентированным графом, где узлами являются переменные программы, используется для моделирования того, какие переменные нельзя размещать в одном и том же регистре.
Недостатки и дальнейшие улучшения
У распределения цветов графа есть три основных недостатка. Во-первых, оно опирается на задачу раскраски графа, являющуюся NP-полной, для определения, какие переменные будут вытеснены. Поиск минимальной раскраски графа действительно является NP-полной задачей. Во-вторых, если не используется разделение областей жизни, вытесненные переменные выгружаются повсеместно: инструкции сохранения вставляются как можно раньше, то есть сразу после определения переменных; инструкции загрузки, соответственно, вставляются поздно, непосредственно перед использованием переменных. В-третьих, переменная, которая не выгружается, остается в одном и том же регистре на протяжении всего времени жизни. С другой стороны, одно имя регистра может встречаться в нескольких классах регистров, где класс представляет собой набор имен регистров, взаимозаменяемых в определенной роли. Затем, несколько имен регистров могут быть синонимами для одного физического регистра. Наконец, раскраска графа – это агрессивная техника распределения регистров, но она вычислительно затратна из-за использования интерференционного графа, размер которого в худшем случае может быть квадратичным по отношению к количеству областей жизни. Традиционная формулировка распределения регистров с помощью раскраски графа неявно предполагает наличие единого банка неперекрывающихся регистров общего назначения и не учитывает нерегулярные архитектурные особенности, такие как перекрывающиеся пары регистров, специальные регистры и множественные банки регистров. Одно из последующих улучшений подхода к раскраске графов в стиле Чейтина было предложено Бриггсом и др. и называется консервативным объединением. Это улучшение добавляет критерий для определения, когда два диапазона жизни могут быть объединены. В основном, помимо требований о неконфликтности, две переменные могут быть объединены только в том случае, если их объединение не приведет к дальнейшей выгрузке. Бриггс и др. представили второе улучшение в работах Чейтина, которое называется смещенной раскраской. Смещенная раскраска пытается назначить один и тот же цвет в раскраске графа диапазонам жизни, связанным операциями копирования.
Линейный сканирование
Линейное сканирование — это ещё один подход к глобальному распределению регистров. Он был впервые предложен Полетто и др. в 1999 году. В этом подходе код не преобразуется в граф. Вместо этого все переменные линейно сканируются для определения их области жизни, представленной в виде интервала. Как только области жизни всех переменных определены, интервалы просматриваются в хронологическом порядке. Хотя этот просмотр может помочь выявить переменные, чьи области жизни пересекаются, граф интерференции не строится, и переменные распределяются жадным способом. Мотивацией для этого подхода является скорость – не с точки зрения времени выполнения сгенерированного кода, а с точки зрения времени, затрачиваемого на генерацию кода. Как правило, стандартные алгоритмы раскраски графов генерируют качественный код, но имеют значительные накладные расходы, поскольку используемый алгоритм раскраски графов имеет квадратичную сложность. Благодаря этому, линейное сканирование в настоящее время используется в нескольких JIT-компиляторах, таких как клиентский компилятор Hotspot, V8, Jikes RVM и Android Runtime (ART). Серверный компилятор Hotspot использует раскраску графов для получения более качественного кода.
Недостатки и дальнейшие улучшения
Однако линейное сканирование имеет два основных недостатка. Во-первых, из-за своей жадности, оно не учитывает промежутки неиспользования, то есть "диапазоны, в которых значение переменной не требуется". Кроме того, переменная, вытесненная из регистра, останется вытесненной на протяжении всего времени жизни. Многие другие исследования развивали алгоритм линейного сканирования Полетто. Traub и др., например, предложили алгоритм, называемый second chance binpacking, направленный на генерацию кода более высокого качества. В этом подходе вытесненные переменные получают возможность быть сохраненными в регистре позднее, используя эвристику, отличную от используемой в стандартном алгоритме линейного сканирования. Вместо живых интервалов алгоритм опирается на живые диапазоны, что означает, что если необходимо вытеснить диапазон, нет необходимости вытеснять все остальные диапазоны, соответствующие этой переменной. Линейное распределение с помощью сканирования также было адаптировано для использования SSA-формы: свойства этого промежуточного представления упрощают алгоритм распределения и позволяют напрямую вычислять промежутки неиспользования времени жизни. Во-первых, время, затрачиваемое на анализ графа потока данных, направленный на построение интервалов времени жизни, сокращается, в частности, благодаря уникальности переменных. Это, в свою очередь, приводит к созданию более коротких живых интервалов, поскольку каждое новое присваивание соответствует новому живому интервалу. Чтобы избежать моделирования интервалов и промежутков неактивности, Роджерс предложил упрощение, называемое future active sets, которое успешно исключило интервалы для 80% инструкций.
Гибридное распределение
Некоторые другие подходы к распределению регистров не ограничиваются одним методом оптимизации использования регистров. Например, Кавазос и др. предложили решение, в котором можно использовать как линейное сканирование, так и алгоритмы раскраски графов. В этом подходе выбор между одним или другим решением определяется динамически: сначала алгоритм машинного обучения используется "оффлайн", то есть не во время выполнения, для построения эвристической функции, определяющей, какой алгоритм распределения следует использовать. Затем эвристическая функция используется во время выполнения; основываясь на поведении кода, распределитель может выбирать один из двух доступных алгоритмов. Распределение регистров по трассам – это недавний подход, разработанный Eisl et al. Эта техника выполняет распределение локально: она опирается на данные динамического профилирования для определения наиболее часто используемых ветвей в заданном графе потока управления. Затем она выводит набор "трасс" (то есть сегментов кода), в которых точка слияния игнорируется в пользу наиболее используемой ветви. Каждый трасс затем обрабатывается распределителем независимо. Этот подход можно считать гибридным, поскольку для разных трасс можно использовать различные алгоритмы распределения регистров.
Разделенное распределение
Разделенное распределение – это еще одна техника распределения регистров, сочетающая различные подходы, обычно рассматриваемые как противоположные. Например, гибридная техника распределения может считаться разделенной, поскольку первый, эвристический этап построения выполняется вне процесса компиляции (оффлайн), а использование эвристики – в процессе компиляции (онлайн). Аналогично, B. Diouf и др. предложили технику распределения, опирающуюся как на оффлайн-, так и на онлайн-поведение, то есть на статическую и динамическую компиляцию. На этапе оффлайн сначала формируется оптимальный набор регистров для вытеснения (spill set) с использованием целочисленного линейного программирования. Затем живые диапазоны аннотируются с помощью алгоритма compressAnnotation, который опирается на ранее определенный оптимальный набор регистров для вытеснения. Распределение регистров выполняется впоследствии на этапе онлайн, на основе данных, собранных на этапе оффлайн. В 2007 году Bouchez и др. также предложили разделить распределение регистров на различные этапы, выделив один этап для вытеснения, а другой – для раскраски и коалесценции.
Сравнение различных методов
Для оценки производительности различных методов распределения регистров использовалось несколько метрик. Распределение регистров обычно связано с компромиссом между качеством кода, то есть скоростью его выполнения, и затратами на анализ, то есть временем, затрачиваемым на анализ исходного кода для генерации кода с оптимизированным распределением регистров. С этой точки зрения, время выполнения сгенерированного кода и время, затраченное на анализ живучести, являются важными метриками для сравнения различных методов. После выбора соответствующих метрик код, к которому они будут применены, должен быть доступен и релевантен решаемой задаче, либо отражая поведение реальных приложений, либо будучи значимым для конкретной проблемы, которую алгоритм призван решить. В современных публикациях о распределении регистров особенно часто используется эталонный набор тестов Dacapo.