Разматывание петель (C)
Однако я понимаю концепцию развертывания циклов. Может ли кто-нибудь объяснить мне, как развернуть простой цикл?
Было бы здорово, если бы вы показали мне цикл, а затем развернутую версию этого цикла с объяснениями того, что происходит.
3 ответа
Я думаю, что важно уточнить, когда развертывание цикла наиболее эффективно: с цепочками зависимостей. Цепочка зависимостей - это последовательность операций, в которой каждый расчет зависит от предыдущего расчета. Например, следующий цикл имеет цепочку зависимостей.
for(i=0; i<n; i++) sum += a[i];
Большинство современных процессоров могут выполнять несколько операций не по порядку за цикл. Это увеличивает пропускную способность команды. Однако неупорядоченные операции не могут делать это в цепочке зависимостей. В цикле выше каждый расчет связан с задержкой операции сложения.
В приведенном выше цикле мы можем развернуть его в две цепочки зависимостей, как это
sum1 = 0, sum2 = 0;
for(i=0; i<n/2; i++) sum1 += a[2*i], sum2 += a[2*i+1];
for(i=(n/2)*2; i<n; i++) sum += a[i]; // clean up for n odd
sum += sum1 + sum2;
Теперь процессор вне очереди может работать в любой цепочке независимо и одновременно в зависимости от процессора.
В общем случае вы должны развернуть на величину, равную задержке операции, умноженной на количество тех операций, которые можно выполнить за такт. Например, с процессором x86_64 он может выполнять как минимум одно добавление SSE за такт, а добавление SSE имеет задержку 3, поэтому вы должны развернуть три раза. С процессором Haswell он может выполнять две операции FMA за такт, и каждая операция FMA имеет задержку 5, поэтому вам потребуется развернуть 10 раз, чтобы получить максимальную пропускную способность.
Что касается компиляторов, GCC не развертывает цепочки зависимостей (даже при -funroll-loops
). Вы должны развернуть себя с GCC. С Clang он разворачивается четыре раза, что в целом неплохо (в некоторых случаях на Haswell и Broadwell вам нужно было бы развернуться 10 раз, а с Skylake - 8 раз).
Еще одна причина для развертывания - когда число операций в цикле превышает количество инструкций, которые можно выполнить за такт. Например, в следующем цикле
for(i=0; i<n; i++) b[i] += 3.14159*a[i];
нет цепочки зависимостей, поэтому нет проблем с выполнением не по порядку. Но давайте рассмотрим набор инструкций, который требует следующих операций на одну итерацию.
2 SIMD load
1 SIMD store
1 SIMD multiply
1 SIMD addition
1 scalar addition for the loop counter
1 conditional jump
Давайте также предположим, что процессор может выполнить пять из этих инструкций за цикл. В этом случае существует семь инструкций на итерацию, но только пять могут быть выполнены за цикл. Развертывание цикла может использоваться для амортизации скалярного сложения со счетчиком. i
и условный прыжок. Например, если вы полностью развернули цикл, эти инструкции не понадобятся.
Для амортизации стоимости счетчика цикла и прыжка -funroll-loops
отлично работает с GCC. Он разворачивается восемь раз, что означает добавление счетчика, и переход должен выполняться один раз каждые восемь итераций вместо каждой итерации.
Процесс развертывания циклов использует важную концепцию в информатике: компромисс между пространством и временем, когда увеличение используемого пространства часто может привести к сокращению времени работы алгоритма.
Допустим, у нас есть простой цикл,
const int n = 1000;
for (int i = 0; i < n; ++i) {
foo();
}
Это скомпилировано в сборку, которая выглядит примерно так:
mov eax, 0
loop:
call foo
inc eax
cmp eax, 1000
jne loop
Таким образом, компромисс между пространством и временем составляет 5 строк сборки для ~(4 * 1000) = ~4000 выполненных инструкций.
Итак, давайте попробуем немного развернуть цикл.
for (int i = 0; i < n; i += 10) {
foo();
foo();
foo();
foo();
foo();
foo();
foo();
foo();
foo();
foo();
}
И его сборка:
mov eax, 0
loop:
call foo
call foo
call foo
call foo
call foo
call foo
call foo
call foo
call foo
call foo
add eax, 10
cmp eax, 1000
jne loop
Пространственно-временной компромисс составляет 14 строк сборки для ~(14 * 100) = ~1400 выполненных инструкций.
Мы можем сделать полное развертывание, как это:
foo();
foo();
// ...
// 996 foo()'s
// ...
foo();
foo();
Который собирается в сборке как 1000 инструкций вызова.
Это дает пространственно-временной компромисс из 1000 строк сборки для 1000 инструкций.
Как видите, общая тенденция заключается в том, что для уменьшения количества инструкций, выполняемых ЦП, мы должны увеличить требуемое пространство.
Неэффективно полностью развернуть цикл, так как требуемое пространство становится чрезвычайно большим. Частичное развертывание дает огромные преимущества с очень уменьшающимся возвратом, чем больше вы разворачиваете цикл.
Хотя это хорошая идея, чтобы понять развертывание цикла, имейте в виду, что компилятор умный и сделает это за вас.
Прокат (обычный):
#define N 44
int main() {
int A[N], B[N];
int i;
// fill A with stuff ...
for(i = 0; i < N; i++) {
B[i] = A[i] * (100 % i);
}
// do stuff with B ...
}
раскатали:
#define N 44
int main() {
int A[N], B[N];
int i;
// fill A with stuff ...
for(i = 0; i < N; i += 4) {
B[i] = A[i] * (100 % i);
B[i+1] = A[i+1] * (100 % i+1);
B[i+2] = A[i+2] * (100 % i+2);
B[i+3] = A[i+3] * (100 % i+3);
}
// do stuff with B ...
}
Развертывание может потенциально повысить производительность за счет увеличения размера программы. Повышение производительности может быть связано с уменьшением штрафов за переходы, отсутствием кэша и выполнением инструкций. Некоторые недостатки очевидны, такие как увеличение объема кода и снижение читабельности, а некоторые не так очевидны.