Это цикл статей об оптимизирующих компиляторах вообще и LLVM в частности. Смотри все статьи данного цикла:
Цикловые инварианты
Сегодня мы поговорим о нескольких разных классах оптимизаций, которые позволяют компилятору увеличивать быстродействие наших программ. Для тех, кто здесь впервые, стоит сначала прочитать статьи об SSA-форме, доминировании, циклах и (опционально) неопределённом поведении, чтобы понимать, о чём идёт речь.
Что такое цикловой инвариант
В предыдущей статье была рассмотрена ситуация, когда мы несколько раз вычисляли одно и то же подвыражение, и могли сэкономить, переиспользовав первый результат. Тогда две разные операции в исходной программе вычисляли один и тот же результат. Но бывают ситуации, когда в коде программы операция одна, но её результаты -- избыточны, поскольку вычисляются снова и снова. Как вы догадались, речь идёт об операциях в циклах. Рассмотрим следующий пример:
int foo(int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
sum += i + n * 2;
}
return sum;
}
Как несложно заметить, на каждой итерации мы вычисляем n * 2
, чтобы потом добавить это выражение к сумме. Поскольку значение n
на протяжении функции не меняется, выражение n * 2
также не будет меняться и зависеть от номера итерации цикла. Вполне очевидно, что его можно бы было посчитать однажды, произведя простейшую оптимизацию:
int foo(int n) {
int sum = 0;
int tmp = n * 2;
for (int i = 0; i < n; i++) {
sum += i + tmp;
}
return sum;
}
Тогда при исполнении программы мы сэкономим n - 1
умножение, что выгодно при n > 1
. Итак, начнём с определений.
Цикловым инвариантом называется выражение, значение которого не зависит от номера итерации цикла.
Очевидно, что значения всех выражений, вычисленных до входа в цикл, являются инвариантами, а некоторые выражения внутри цикла -- могут быть инвариантами, если все их операнды -- инварианты. В нашем примере значение n
вычислено до входа в цикл, и поэтому является инвариантом, а n * 2
-- зависит только от инвариантов и является инвариантом даже несмотря на то, что изначально вычислялось внутри цикла.
Теперь рассмотрим другой пример:
int foo(int n) {
int sum = 0;
for (int i = 0; i < n; i++) {
if ((n / 8) > 100) {
sum += i + n * 2;
} else {
sum -= i;
}
}
return sum;
}
Как мы видим, выражение (n / 8) > 100
является инвариантом, поэтому ветвление по нему называется инвариантным ветвлением. Его особенность в том, что, войдя в цикл и однажды пройдя по одной из веток, мы на каждой итерации будем идти в ту же ветку.
В дальнейших разделах я расскажу, как современные компиляторы умеют оптимизировать такой код.
Вынос инвариантных выражений из цикла
Давайте рассмотрим наш первый пример и сразу перепишем его в SSA-форме:
define i32 @foo(i32 %n) {
entry:
br label %header
header: ; preds = %backedge, %entry
%sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
%i = phi i32 [ 0, %entry ], [ %i.next, %backedge ]
%loop.cond = icmp slt i32 %i, %n
br i1 %loop.cond, label %backedge, label %done
backedge: ; preds = %header
%n2 = mul nsw i32 %n, 2
%i.plus.n2 = add nsw i32 %i, %n2
%sum.next = add nsw i32 %sum, %i.plus.n2
%i.next = add nsw i32 %i, 1
br label %header
done: ; preds = %header
ret i32 %sum
}
Выражение в SSA-форме является цикловым инвариантом, если выполнено одно из двух условий:
Оно строго доминирует над заголовком цикла (т.е. вычислено до входа в цикл);
Все операнды данного выражения являются цикловыми инвариантами.
В примере выше видно, что выражения %n
и 2
доминируют над заголовком цикла (т.е. вычислены ранее), а выражение %n2 = mul nsw i32 %n, 2
соответствует второму случаю (все операнды -- инварианты)
Понятно, что если цикл делает хотя бы две итерации, то вычислять инвариантные выражения за его пределами -- скорее всего выгодно. Естественным образом встаёт вопрос -- если не в цикле, то где?
В зависимости от обстоятельств, может быть выгодно вычислять инвариантные выражения как до входа в цикл (например, в предзаголовочном блоке), так и после выхода из цикла (например, в выходном блоке). В зависимости от того, что мы выбираем, слегка меняется и алгоритм оптимизации, которая занимается выносом. Обычно разделяют подъём (hoisting) и спуск (sinking) инвариантных выражений по дереву доминирования. В LLVM обеими преобразованиями целенаправленно занимается оптимизационный пасс LICM (Loop Invariant Code Motion, Движение инвариантного циклового кода), однако в разных формах подобные преобразования делают и другие оптимизации. Рассмотрим, как это делается.
В обоих случаях алгоритм можно описать следующей схемой:
Обходим блоки цикла в определённом порядке
Обходим инструкции внутри блока в определённом порядке
Всякий раз, когда мы видим инструкцию, которая является цикловым инвариантом, перемещаем её в целевой блок и продолжаем
Порядок, в котором обходятся блоки и инструкции, определяются соображениями оптимальности. Желательно, чтобы алгоритм сошёлся за один проход, после чего в цикле не должно остаться инвариантных выражений. В случае подъёма инструкций (т.е. выноса из тела цикла в предзаголовок) мы хотим обеспечить, чтобы на момент обработки очередной инструкции все её инвариантные операнды уже были вынесены из цикла, чтобы проверка данной инструкции была дешёвой, а её вынос -- законным (напомним, что все операнды инструкции обязаны над ней доминировать). С другой стороны, в случае спуска мы хотим, чтобы все инвариантные пользователи данной инструкции к моменту её обработки уже были перемещены вниз, поскольку мы обязаны поддерживать свойство SSA-формы, согласно которому операнд доминирует своих пользователей. Рассмотрим это на примере:
define i32 @example_hoist_sink(i32 %a, i32 %b, i32 %c) {
entry:
br label %header
header: ; preds = %backedge, %entry
%iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
%sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
%cond = icmp slt i32 %iv, 12
%x = mul i32 %a, %b
br i1 %cond, label %if.true, label %backedge
if.true: ; preds = %header
%y = mul i32 %x, %c
%z = mul i32 %y, %c
%sum.incremented = add i32 %sum, %x
%if.true.cond = icmp eq i32 %x, 1000
br i1 %if.true.cond, label %backedge, label %side.exit
backedge: ; preds = %if.true, %header
%sum.next = phi i32 [ %sum, %header ], [ %sum.incremented, %if.true ]
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret i32 %sum
side.exit: ; preds = %if.true
ret i32 %z
}
Здесь значения %a
, %b
и %с
доминируют над заголовком цикла (и потому являются инвариантами), а значения %x
, %y
, %z
и %if.true.cond
вычислены внутри цикла, но зависят только от инвариантных аргументов. Давайте сначала рассмотрим, как выглядит подъём инвариантов в этом случае. Алгоритм подъёма обходит блоки в порядке RPOT (то есть в топологическом порядке) и инструкции в блоке сверху вниз. Такой обход обеспечивает требуемое свойство, чтобы инвариантные аргументы были обработаны до своих пользователей. В данном случае, алгоритм сработает так:
RPOT для блоков данного цикла -
header
,if.true
,backedge
. Они будут обрабатываться в этом порядке, инструкции в блоках обрабатываются сверху вниз.При обработке блока
header
выяснится, что все аргументы инструкции%x
-- инварианты, поэтому она будет перемещена в блок%entry
.При обработке блока
if.true
сначала будет обработана инструкция %y и перемещена вверх, поскольку к этому моменту её операнды%x
и%с
уже оба инварианты, а потом -- инструкция%z
. Потом будет вынесено также условие%if.true.cond
.
В результате мы получим следующий код:
define i32 @example_hoist_sink(i32 %a, i32 %b, i32 %c) {
entry:
%x = mul i32 %a, %b
%y = mul i32 %x, %c
%z = mul i32 %y, %c
%if.true.cond = icmp eq i32 %x, 1000
br label %header
header: ; preds = %backedge, %entry
%iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
%sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
%cond = icmp slt i32 %iv, 12
br i1 %cond, label %if.true, label %backedge
if.true: ; preds = %header
%sum.incremented = add i32 %sum, %x
br i1 %if.true.cond, label %backedge, label %side.exit
backedge: ; preds = %if.true, %header
%sum.next = phi i32 [ %sum, %header ], [ %sum.incremented, %if.true ]
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret i32 %sum
side.exit: ; preds = %if.true
ret i32 %z
}
Довольны ли мы такой оптимизацией? С одной стороны, мы существенно снизили объём вычислений во время исполнения цикла. С другой -- могло статься, что мы никогда не ходили в блок if.true
, и таким образом, все вынесенные значения нам на самом деле не пригождались никогда. В этом случае выгоднее было бы просто переместить %x
в блок %if.true
, убрав его из заголовка, и мы бы получили больший выигрыш. Как же быть?
На самом деле, вынос %x
в предзаголовок -- это всё равно выгоднее, чем считать его на каждой итерации в заголовке, даже если это и не оптимальное решение. Что касается %y
и %z
, то они нужны только в выходном блоке %side.exit
, поэтому к ним разумнее было бы применить не подъём, а спуск. Оставлю вопрос о том, какой обход блоков и инструкций нужно использовать при спуске, для самостоятельного решения читателем. В оптимальном решении %y
и %z
будут спущены в выходной блок. Что касается инструкций %x
и %if.true.cond
, то их выгодно либо поднять в предзаголовок, либо опустить в блок %if.true
, в зависимости от того, какой из блоков горячее. Узнать это можно, если есть профилировочная информация или какие-то дополнительные знания относительно условия цикла. В данном случае можно статически доказать, что блок if.true
выполнится целых 12 раз, и поэтому выгодно поднять их в заголовок. Итак, оптимальное решение для данной задачи выглядит следующим образом:
define i32 @example_hoist_sink(i32 %a, i32 %b, i32 %c) {
entry:
%x = mul i32 %a, %b
%if.true.cond = icmp eq i32 %x, 1000
br label %header
header: ; preds = %backedge, %entry
%iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
%sum = phi i32 [ 0, %entry ], [ %sum.next, %backedge ]
%cond = icmp slt i32 %iv, 12
br i1 %cond, label %if.true, label %backedge
if.true: ; preds = %header
%sum.incremented = add i32 %sum, %x
br i1 %if.true.cond, label %backedge, label %side.exit
backedge: ; preds = %if.true, %header
%sum.next = phi i32 [ %sum, %header ], [ %sum.incremented, %if.true ]
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret i32 %sum
side.exit: ; preds = %if.true
%y = mul i32 %x, %c
%z = mul i32 %y, %c
ret i32 %z
}
Инвариантные загрузки из памяти
Стоит сделать отдельную оговорку про случай, когда мы имеем дело с памятью. Работа с ней не в полной мере отражается SSA-формой, и в LLVM IR моделируется с помощью свойств чтения и записи памяти. В настоящее время есть попытка исправить ситуацию при помощи Memory SSA, но это всё равно надстройка над IR, а не его часть.
Рассмотрим простой пример:
define void @example_hoist_load(ptr dereferenceable(32) %p1, ptr dereferenceable(32) %p2) {
entry:
br label %header
header: ; preds = %backedge, %entry
%iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
%cond = call i1 @cond()
br i1 %cond, label %if.true, label %if.false
if.true: ; preds = %header
store i32 %iv, ptr %p1, align 4
br label %backedge
if.false: ; preds = %header
%loaded.value = load i32, ptr %p2, align 4
br label %backedge
backedge: ; preds = %if.false, %if.true
%phi = phi i32 [ %iv, %if.true ], [ %loaded.value, %if.false ]
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret void
}
declare i1 @cond() #0
attributes #0 = { memory(read) }
Здесь в зависимости от условия %cond мы либо пишем память по указателю %p1
, либо читаем по указателю %p2
. Можем ли мы вынести загрузку из %p2
в предзаголовок?
На самом деле, ответ на этот вопрос -- сложный, и зависит от множества факторов. Такой вынос возможен, если всякий раз, когда мы читаем значение из этой памяти, оно одно и то же. Как минимум это возможно, если мы докажем один из фактов:
Все записи в цикле не делаются в эту память (т.е. области памяти, куда указывают
%p1
и%p2
, не пересекаются)Функция
cond()
обладает свойством монотонности: она может вернуть сначалаfalse
, потомtrue
, но не наоборот. Если это так, то все чтения%p2
будут сделаны до записей в%p1
, поэтому вынести чтение можно.
Также нельзя забывать, что для того, чтобы вынести чтение из-под условия, необходимо доказать, что данный указатель будет доступен для чтения в том месте, куда вы его выносите. Чтение из неинициализированного указателя может приводить к неопределённому поведению, и о его последствиях у меня есть отдельная статья.
Всё это -- довольно продвинутые области оптимизации и выходит далеко за рамками нашего сегодняшнего рассказа. Пока просто запомним, что с памятью всё не так просто, и для того, чтобы вынести чтение или запись в память, инвариантности указателя недостаточно. Нужно ещё знать что-то об инвариантности данных по этому указателю.
Ветвления по инвариантным условиям
Итак, с выносом инструкций всё более или менее ясно. Но можно ли выносить из цикла сложный код с передачей потока управления, если все переходы в нём -- инвариантные? На самом деле, есть несколько способов это сделать, и я попробую их перечислить, хотя и не обещаю, что покрою здесь все нюансы. В LLVM этим явно занимаются тот же LICM и Loop Unswitching, и подозреваю, что какие-то элементы подобного толка иногда могут выполнять и другие трансформации.
Давайте рассмотрим самый простой пример:
define i32 @example_hoist_cfg_simple(i32 %n, i32 %m) {
entry:
%cond = icmp eq i32 %n, %m
br label %header
header: ; preds = %backedge, %entry
%iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
br i1 %cond, label %backedge, label %side.exit
backedge: ; preds = %header
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret i32 -1
side.exit: ; preds = %header
ret i32 %iv
}
Здесь в цикле присутствует ветвление по инвариантному условию, причём никакой важный код до этого ветвления не исполняется. На самом деле, мы либо на первой итерации цикла сразу выйдем в %side.exit
, если %cond == false
, либо же не попадём в этот блок никогда. В данном случае можно поднять ветвление так же, как мы ранее поднимали выражения, в предзаголовок. Давайте механически проделаем эту операцию:
define i32 @example_hoist_cfg_simple(i32 %n, i32 %m) {
entry:
%cond = icmp eq i32 %n, %m
br i1 %cond, label %preheader, label %side.exit
preheader:
br label %header
header: ; preds = %backedge, %entry
%iv = phi i32 [ 0, %preheader ], [ %iv.next, %backedge ]
br label %backedge
backedge: ; preds = %if.true, %header
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret i32 -1
side.exit:
ret i32 %iv ; ТАК НЕЛЬЗЯ!
}
Итак, мы превратили условный переход в цикле в безусловный и подняли ветвление вверх. В этом коде есть проблема: если раньше мы могли использовать значение %iv
в блоке %side.exit
, потому что между ними существовало отношение доминирования, то теперь так делать нельзя. При переходе entry -> side.exit
мы никогда не вычисляли значение %iv
! Поэтому перед тем, как выполнять такое преобразование, нужно убедиться, что все значения, используемые в блоке %side.exit
, будут там доступны после оптимизации. Например, здесь можно использовать знание о том, что мы могли попасть в блок %side.exit только на нулевой итерации, и заменить ret i32 %iv
на ret i32 0
. Также, чтобы два раза не вставать, мы можем избавиться и от безусловного перехода, склеив блоки header
и backedge
в один. Итоговый оптимизированный код выглядит так:
define i32 @example_hoist_cfg_simple(i32 %n, i32 %m) {
%cond = icmp eq i32 %n, %m
br i1 %cond, label %preheader, label %side.exit
preheader: ; preds = %entry
br label %header
header: ; preds = %header, %preheader
%iv = phi i32 [ 0, %preheader ], [ %iv.next, %header ]
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %header
ret i32 -1
side.exit: ; preds = %entry
ret i32 0
}
Давайте слегка усложним пример. Теперь перед ветвлением, которое мы хотим вынести, существует инструкция со сторонним эффектом:
define i32 @example_hoist_cfg_side_effects(i32 %n, i32 %m) {
entry:
%cond = icmp eq i32 %n, %m
br label %header
header: ; preds = %backedge, %entry
%iv = phi i32 [ 0, %entry ], [ %iv.next, %backedge ]
call void @side_effect_1()
call void @side_effect_2()
call void @side_effect_3()
br i1 %cond, label %backedge, label %side.exit
backedge: ; preds = %header
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret i32 -1
side.exit: ; preds = %header
ret i32 %iv
}
declare void @side_effect_1()
declare void @side_effect_2()
declare void @side_effect_3()
Теперь надо учитывать, что код выше ветвления мог записать какую-нибудь внешнюю память, и в случае, если функция вернёт 0
, внешний код вправе ожидать, что это будет сделано. Поэтому нельзя допустить, чтобы после оптимизации ветвление оказалось выше данного стороннего эффекта. Поэтому оптимизированный код в этом случае обязан будет продублировать инструкции, лежащие выше ветвления:
define i32 @example_hoist_cfg_side_effects(i32 %n, i32 %m) {
entry:
%cond = icmp eq i32 %n, %m
br i1 %cond, label %preheader, label %header.copy
preheader: ; preds = %entry
br label %header
header: ; preds = %backedge, %preheader
%iv = phi i32 [ 0, %preheader ], [ %iv.next, %backedge ]
call void @side_effect_1()
call void @side_effect_2()
call void @side_effect_3()
br label %backedge
backedge: ; preds = %header
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %backedge
ret i32 -1
side.exit: ; preds = %header.copy
ret i32 %iv.copy
header.copy: ; preds = %entry
%iv.copy = phi i32 [ 0, %entry ]
call void @side_effect_1()
call void @side_effect_2()
call void @side_effect_3()
br label %side.exit
}
declare void @side_effect_1()
declare void @side_effect_2()
declare void @side_effect_3()
Или, после упрощений и склеиваний безусловных переходов:
define i32 @example_hoist_cfg_side_effects(i32 %n, i32 %m) {
entry:
%cond = icmp eq i32 %n, %m
br i1 %cond, label %header, label %header.copy
header: ; preds = %header, %entry
%iv = phi i32 [ %iv.next, %header ], [ 0, %entry ]
call void @side_effect_1()
call void @side_effect_2()
call void @side_effect_3()
%iv.next = add i32 %iv, 1
%loop.cond = icmp slt i32 %iv.next, 1000
br i1 %loop.cond, label %header, label %done
done: ; preds = %header
ret i32 -1
header.copy: ; preds = %entry
call void @side_effect_1()
call void @side_effect_2()
call void @side_effect_3()
ret i32 0
}
declare void @side_effect_1()
declare void @side_effect_2()
declare void @side_effect_3()
Но иногда бывает так, что ветвление по инвариантному условию не выходит из цикла, или объём кода, который придётся раскопировать для его удаления, очень велик. В этом случае делается полная версия данной трансформации, которую проще всего описать схематично. Изначальный код:
cond = ...
for (...) {
// A
if (cond) {
// B
} else {
// C
}
// D
}
превращается в
cond = ...
if (cond) {
for (...) {
// A
if (true) {
// B
} else {
// C
}
// D
}
} else {
for (...) {
// A
if (false) {
// B
} else {
// C
}
// D
}
}
В этом случае объём и сложность фрагментов кода A
, B
, C
, D
не имеют особого значения. Трансформация выполняется чисто механически, дальнейшие трансформации избавятся от ветвлений по константным условиям и соответствующим образом преобразуют все финоды. Единственным недостатоком здесь является удвоение размера кода, что негативно сказывается на времени компиляции и объёме сгенерированного кода. Поэтому прибегать к этой технике (она называется non-trivial unswitching) стоит только в крайнем случае и ради больших выигрышей в производительности.
На самом деле, как вы могли заметить, все простые случаи вынесения инвариантного CFG -- это всего лишь частные случаи последнего, самого тяжеловесного преобразования. Тем не менее, на практике они тоже важны, так как их выполнение гораздо дешевле с точки зрения времени компиляции и не приводит к чрезмерному раздуванию объёма кода.
Заключение
В этой статье мы рассмотрели, как можно сокращать избыточность вычислений, вынося инвариантные выражения и ветвления из циклов. За рамками рассмотрения остались некоторые тонкости реализации, в частности, связанные с неопределённым поведением (они ранее освещались в соответствующей статье), а также сложности взаимодействия таких оптимизаций с последующей кодогенерацией. Темп выхода статей слегка замедлился, но я всё-таки надеюсь со временем обозреть все интересные высокоуровневые оптимизации, с которыми доводилось иметь дело в бытность компиляторным инженером.