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


93

我一直在尝试通过循环展开来优化一些对性能至关重要的代码(一种快速排序算法,在蒙特卡洛仿真中被称为百万次)。这是我要加快的内循环:

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

我尝试展开为以下内容:

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
}

这绝对没有区别,所以我将其改回了可读性更好的形式。我尝试循环展开时也有类似的经历。鉴于现代硬件上分支预测器的质量,何时展开循环仍然是有用的优化?


1
请问您为什么不使用标准库quicksort例程?
彼得·亚历山大

14
@Poita:因为我的我有一些我正在做的统计计算所需要的额外功能,并且针对我的用例进行了非常高的调整,因此比标准库要少一些,但是可以测量得更快。我正在使用D编程语言,该语言具有旧的糟糕的优化器,并且对于大量的随机浮点数,我仍然比GCC的C ++ STL排序高10-20%。
dsimcha'2

Answers:


122

如果可以打破依赖关系链,则展开循环很有意义。这使乱序或超标量CPU可以更好地安排事情,从而更快地运行。

一个简单的例子:

for (int i=0; i<n; i++)
{
  sum += data[i];
}

这里,参数的依赖关系链非常短。如果由于数据数组上的高速缓存未命中而导致停顿,则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;

可以运行得更快。如果在一次计算中遇到高速缓存未命中或其他停顿,那么仍然有三个不依赖于停顿的依赖链。故障CPU可以执行这些命令。


2
谢谢。我曾尝试在库中其他几个用于求和和填充的地方使用这种样式进行循环展开,并且在这些地方产生了奇迹。正如您所建议的,我几乎可以确定原因是它增加了指令级并行性。
dsimcha'3

2
不错的答案和启发性的例子。尽管我看不到此特定示例在缓存缺失上的停顿如何影响性能。我来向我自己解释这两段代码之间的性能差异(在我的机器上,第二段代码的速度快了2-3倍),并注意到第一段代码在浮点通道中禁用了任何种类的指令级并行性。第二个将允许超标量CPU同时执行最多四个浮点加法。
Toby Brull 2014年

2
请记住,以这种方式计算总和时,结果在数值上不会与原始循环相同。
巴拉巴斯

循环携带的依赖性是一个循环,即加法。OoO核心会很好。在这里展开可能会帮助浮点SIMD,但这与OoO无关。
Veedrac

2
@Nils:不是很多;主流的x86 OoO CPU仍然足够类似于Core2 / Nehalem / K10。在缓存未命中后追赶仍然很少,隐藏FP延迟仍然是主要优势。在2010年,每个时钟可以执行2次加载的CPU更为罕见(只是AMD,因为尚未发布SnB),因此对于整数代码,多个累加器的价值肯定不如现在(当然,标量代码应该自动矢量化) ,那么谁知道编译器是将多个累加器转换为矢量元素还是将多个矢量累加器...)
Peter Cordes

25

这些不会有什么区别,因为您进行的比较次数相同。这是一个更好的例子。代替:

for (int i=0; i<200; i++) {
  doStuff();
}

写:

for (int i=0; i<50; i++) {
  doStuff();
  doStuff();
  doStuff();
  doStuff();
}

即使这样,也几乎可以肯定不会有问题,但是您现在要进行50次比较,而不是200次(假设比较会更复杂)。

但是,手动循环展开通常是历史的产物。当重要的时候,好的编译器将为您做的事情越来越多。例如,大多数人不费心去写x <<= 1x += x代替x *= 2。您只需编写x *= 2,编译器就会为您优化它,以达到最佳效果。

基本上,对您的编译器进行二次猜测的需求越来越少。


1
@Mike如果困惑不解是一个好主意,当然可以关闭优化,但是值得阅读Poita_发布的链接。编译器正在痛苦地擅长于该业务。
dmckee ---前主持人小猫,2010年

16
@Mike“我完全有能力决定何时或何时不做那些事情”……我对此表示怀疑,除非你是超人。
男孩先生2010年

5
@约翰:我不知道你为什么这么说;人们似乎认为优化只是某种卑鄙的艺术,只有编译器和好的猜测者才知道该怎么做。一切都取决于指令和周期以及使用它们的原因。正如我在SO上已经解释过很多次一样,很容易看出这些花费的方式和原因。如果我的循环必须占用大量时间,并且与内容相比,它在循环开销中花费了太多的周期,那么我可以看到并展开它。代码提升也是如此。不需要天才。
Mike Dunlavey 2010年

3
我相信这并不难,但是我仍然怀疑您能否像编译器一样快地完成它。无论如何,编译器为您执行此操作有什么问题?如果您不喜欢它,请关闭优化功能,然后像1990年一样浪费时间!
男孩先生2010年

2
循环展开带来的性能提升与您保存的比较无关。没事
bobbogo

14

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

找出编译器为您进行了多少优化是值得的。

我发现Felix von Leitner的演讲对这个主题很有启发性。我建议您阅读。简介:现代编译器非常聪明,因此手动优化几乎永远无效。


7
这是一本不错的书,但是我认为唯一值得一提的是他谈到保持数据结构简单的问题。它的其余部分是准确的,但在一个巨大的未申明的假设休息-什么正在执行要。在我做的调优中,我发现人们担心大量时间进入不必要的抽象代码时会担心寄存器和缓存丢失。
Mike Dunlavey 2010年

3
“手动优化几乎永远不会有效”→如果您完全不熟悉该任务,那么可能会如此。否则根本就不正确。
Veedrac

在2019年,我仍然完成了手动展开操作,相对于编译器的自动尝试,它获得了可观的收益。因此让编译器完成所有操作并不可靠。似乎并不经常这样展开。至少对于C#,我不能代表所有语言。
WDUK

2

据我了解,现代编译器已经在适当的地方展开了循环-例如gcc,如果传递了优化标志,则手册会说:

展开循环的迭代次数可以在编译时或在进入循环时确定。

因此,实际上,您的编译器很可能会为您完成一些琐碎的工作。因此,由您自己决定确保编译器可以轻松确定尽可能多的循环来确定需要进行多少次迭代。


只是在编译器通常不进行循环展开时,启发式方法太昂贵了。静态编译器可以在上面花费更多的时间,但是两种主要方式之间的区别很重要。
亚伯

2

循环展开(无论是手动展开还是编译器展开)通常会适得其反,特别是对于更新的x86 CPU(Core 2,Core i7)。底线:在计划将代码部署到的任何CPU上使用循环展开和不展开循环对您的代码进行基准测试。


为什么特别是在Recet x86 CPU上?
JohnTortugo

7
@JohnTortugo:现代的x86 CPU对小循环有一定的优化-请参见例如Core和Nehalem体系结构上的循环流检测器-展开一个循环,使其不再足够小以适合LSD缓存,这不利于优化。参见例如tomshardware.com/reviews/Intel-i7-nehalem-cpu,2041-3.html
Paul R

1

不知不觉尝试不是做到这一点的方法。
这种花费的时间占总时间的百分比很高吗?

循环展开的所有操作都是减少递增/递减,比较停止条件和跳跃的循环开销。如果您在循环中执行的操作比循环开销本身花费了更多的指令周期,那么您就不会看到很多改进。

这是如何获得最佳性能的示例。


1

循环展开在特定情况下可能会有所帮助。唯一的收获就是不跳过某些测试!

例如,它可以允许进行标量替换,有效地插入软件预取...您可能会惊讶地发现,通过积极地展开它,它有多么有用(即使在使用-O3的情况下,大多数循环也可以轻松地将速度提高10%)。

如前所述,它在很大程度上取决于循环,因此编译器和实验是必需的。很难制定规则(否则编译器对展开的启发式将是完美的)


0

循环展开完全取决于您的问题大小。这完全取决于您的算法是否能够将大小减少到较小的工作组中。您在上面所做的事情看起来并非如此。我不确定是否可以展开蒙特卡洛模拟。

我的循环展开的好方案是旋转图像。因为您可以轮换单独的工作组。为了使它起作用,您必须减少迭代次数。


我正在展开一种快速排序,该排序是从模拟的内部循环而不是模拟的主循环调用的。
dsimcha'2

0

如果循环中和循环中都有很多局部变量,则循环展开仍然有用。重用那些寄存器,而不是为循环索引保存一个。

在您的示例中,您使用少量的局部变量,而不会过度使用寄存器。

如果比较繁重(例如,非test指令),尤其是如果它依赖于外部函数,则比较(到循环末尾)也是一个主要缺点。

循环展开也有助于提高CPU对分支预测的了解,但是无论如何都会发生。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.