我认为澄清循环展开何时最有效很重要:使用依赖链。依赖链是一系列操作,其中每个计算都依赖于先前的计算。例如,下面的循环有一个依赖链。
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,因此您应该展开 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
我们还假设处理器每个周期可以执行 5 条这样的指令。在这种情况下,每次迭代有 7 条指令,但每个周期只能完成 5 条指令。然后可以使用循环展开来分摊标量加法到计数器i 和条件跳转的成本。例如,如果您完全展开循环,则不需要这些指令。
为了摊销循环计数器的成本并跳转 -funroll-loops 与 GCC 配合得很好。它展开 8 次,这意味着计数器的加法和跳转必须每 8 次迭代而不是每次迭代进行一次。