展开循环(C)

时间:2016-04-13 04:01:07

标签: c loops performance-testing unroll

我理解展开循环的概念但是,有人可以向我解释如何展开一个简单的循环吗?

如果您向我展示一个循环,然后是该循环的展开版本并解释正在发生的事情,那将会很棒。

3 个答案:

答案 0 :(得分:4)

我认为澄清循环展开最有效的时候很重要:依赖链。依赖关系链是一系列操作,其中每个计算取决于先前的计算。例如,以下循环具有依赖关系链。

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。它展开8次,这意味着每8次迭代而不是每次迭代都需要进行一次计数器加法和跳转。

答案 1 :(得分:3)

展开循环的过程利用了计算机科学中的一个基本概念:时空权衡,其中增加使用的空间通常会导致算法的时间缩短。

假设我们有一个简单的循环,

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

因此,执行〜(4 * 1000)= ~4000条指令的时空权衡为5行。

所以,让我们试着稍微展开循环。

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 * 100)= ~1400条指令执行,时空权衡是14行汇编。

我们可以完全展开,像这样:

foo();
foo();
// ...
// 996 foo()'s
// ...
foo();
foo();

汇编为1000个调用指令。

这为1000条指令提供了1000行装配的时空权衡。

正如您所看到的,总的趋势是,为了减少CPU执行的指令量,我们必须增加所需的空间。

完全展开循环效率不高,因为所需空间变得非常大。部分展开带来了巨大的好处,大大减少了回报,你展开循环的次数就越多。

虽然理解循环展开是个好主意,但请记住编译器很聪明并且会为你完成。

答案 2 :(得分:1)

滚动(常规):

token='my_token'
url='https://api.github.com/search/repositories?q=python'
rslt=requests.get(url,headers={'Authorization':auth})

展开后:

#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 ...
}

展开可能会以更大的程序规模为代价提高性能。性能提升可能是由于分支处罚,缓存未命中和执行指令的减少。一些缺点是显而易见的,比如代码量的增加和可读性的降低,有些则不那么明显。