什么时候,如果循环展开仍然有用?

dsi*_*cha 88 language-agnostic optimization performance micro-optimization

我一直试图通过循环展开来优化一些极其性能关键的代码(一种快速排序算法,在蒙特卡罗模拟中被称为数百万次).这是我试图加速的内循环:

// Search for elements to swap.
while(myArray[++index1] < pivot) {}
while(pivot < myArray[--index2]) {}
Run Code Online (Sandbox Code Playgroud)

我尝试展开类似的东西:

while(true) {
    if(myArray[++index1] < pivot) break;
    if(myArray[++index1] < pivot) break;
    // More unrolling
}


while(true) {
    if(pivot < myArray[--index2]) break;
    if(pivot < myArray[--index2]) break;
    // More unrolling
}
Run Code Online (Sandbox Code Playgroud)

这完全没有区别所以我把它改成了更易读的形式.我曾经尝试过循环展开,但我有类似的经历.鉴于现代硬件上的分支预测器的质量,何时(如果有的话)循环展开仍然是一个有用的优化?

Nil*_*nck 115

如果你可以打破依赖链,循环展开是有意义的.这使得无序或超标量CPU可以更好地安排事情并因此运行得更快.

一个简单的例子:

for (int i=0; i<n; i++)
{
  sum += data[i];
}
Run Code Online (Sandbox Code Playgroud)

这里参数的依赖链非常短.如果因为数据阵列上有缓存未命中而导致停顿,那么cpu除了等待之外什么也做不了.

另一方面这段代码:

for (int i=0; i<n; i+=4)
{
  sum1 += data[i+0];
  sum2 += data[i+1];
  sum3 += data[i+2];
  sum4 += data[i+3];
}
sum = sum1 + sum2 + sum3 + sum4;
Run Code Online (Sandbox Code Playgroud)

可以跑得更快.如果在一次计算中遇到缓存未命中或其他停顿,则仍有三个其他依赖链不依赖于停顿.乱序CPU可以执行这些.

  • 谢谢.我已经尝试在图书馆的其他几个地方以这种方式展开循环,我正在计算总和和东西,在这些地方它可以创造奇迹.我几乎可以肯定,正如你所建议的那样,它增加了指令级并行性. (2认同)
  • 很好的答案和有益的例子.虽然我没有看到缓存未命中的停顿如何影响此特定示例*的性能*.我来向自己解释两段代码之间的性能差异(在我的机器上,第二段代码快2-3倍),注意第一段禁用浮点通道中的任何类型的指令级并行.第二个允许超标量CPU同时执行多达四个浮点加法. (2认同)
  • 请记住,在以这种方式计算总和时,结果在数值上不会与原始循环相同. (2认同)
  • @Nils:不是很多; 主流的x86 OoO CPU仍然与Core2/Nehalem/K10相似.在高速缓存未命中之后追赶仍然很小,隐藏FP延迟仍然是主要的好处.在2010年,每个时钟可以执行2次加载的CPU甚至更少(只是AMD,因为SnB尚未发布),因此多个累加器对整数代码的价值肯定比现在少(当然这是应该自动向量化的标量代码) ,那么谁知道编译器是否会将多个累加器转换为向量元素或多个*向量*累加器......) (2认同)

cle*_*tus 22

那些没有任何区别,因为你正在进行相同数量的比较.这是一个更好的例子.代替:

for (int i=0; i<200; i++) {
  doStuff();
}
Run Code Online (Sandbox Code Playgroud)

写:

for (int i=0; i<50; i++) {
  doStuff();
  doStuff();
  doStuff();
  doStuff();
}
Run Code Online (Sandbox Code Playgroud)

即便如此,它几乎肯定无关紧要,但你现在正在做50次比较而不是200次(想象一下比较更复杂).

然而,手动循环展开通常是历史的工件.这是一个很好的编译器会在重要的时候为你做的事情中的另一个.例如,大多数人都懒得写x <<= 1x += x代替x *= 2.你只需编写x *= 2,编译器就会为你优化它.

基本上,对猜测编译器的猜测越来越少.

  • @Mike"我完全有能力决定何时或何时不做那些事情"......我对此表示怀疑,除非你是超人. (15认同)
  • @John:我不知道你为什么这么说; 人们似乎认为优化是某种黑色艺术只有编译器和优秀的猜测者知道如何做.这一切都归结为指令和周期以及它们花费的原因.正如我在SO上多次解释的那样,很容易说出这些是如何以及为何被花费的原因.如果我有一个必须占用大量时间的循环,并且它在循环开销中花费了太多周期,与内容相比,我可以看到并展开它.代码吊装也是如此.它不需要天才. (5认同)
  • 我确信它并不难,但我仍然怀疑你能像编译器一样快地完成它.无论如何编译器为你做的是什么问题?如果你不喜欢它,只需关闭优化并消耗你的时间就像1990年一样! (3认同)
  • 循环展开导致的性能提升与您正在保存的比较无关.什么都没有. (2认同)

Pet*_*der 14

无论现代硬件上的分支预测如何,大多数编译器都会为您循环展开.

值得了解一下编译器为您做了多少优化.

我发现Felix von Leitner的演讲在这个问题非常有启发性.我建议你阅读它.简介:现代编译器非常聪明,因此手动优化几乎从未有效.

  • 这是一个很好的阅读,但我认为唯一的部分是关于保持数据结构简单的地方.其余部分是准确的,但依赖于一个巨大的未说明的假设 - 正在执行的*具有*.在我做的调整中,当大量时间进入不必要的抽象代码时,我发现人们担心寄存器和缓存未命中. (7认同)
  • "手优化几乎永远不会有效"→如果你对这个任务完全陌生,也许是真的.否则就不是这样. (3认同)