【问题标题】:When, if ever, is loop unrolling still useful?什么时候,如果有的话,循环展开仍然有用吗?
【发布时间】:2011-01-21 21:29:44
【问题描述】:

我一直在尝试通过展开循环来优化一些对性能至关重要的代码(一种在蒙特卡罗模拟中被调用数百万次的快速排序算法)。这是我试图加速的内部循环:

// 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
}

这完全没有区别,所以我把它改回更易读的形式。其他时候我也有过类似的经历,我尝试过循环展开。考虑到现代硬件上分支预测器的质量,循环展开何时(如果有的话)仍然是一种有用的优化?

【问题讨论】:

  • 请问您为什么不使用标准库快速排序例程?
  • @Poita:因为我有一些额外的功能,我需要进行统计计算,并且针对我的用例进行了高度调整,因此不太通用,但比标准库快得多。我使用的是 D 编程语言,它有一个旧的糟糕的优化器,对于大量的随机浮点数,我仍然比 GCC 的 C++ STL 排序高 10-20%。

标签: performance language-agnostic optimization micro-optimization


【解决方案1】:

这些不会有任何区别,因为您正在进行相同数量的比较。这是一个更好的例子。而不是:

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

写:

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

即使那样,它也几乎肯定没关系,但您现在进行 50 次比较而不是 200 次(想象一下比较更复杂)。

手动 循环展开通常在很大程度上是历史的产物。这是一个好的编译器在重要的时候会为你做的越来越多的事情之一。例如,大多数人不会费心写x &lt;&lt;= 1x += x 而不是x *= 2。你只要写x *= 2,编译器就会为你优化到最好的。

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

【讨论】:

  • @Mike 当然如果在困惑时关闭优化是个好主意,但值得阅读 Poita_ 发布的链接。编译器正在痛苦地擅长该业务。
  • @Mike “我完全有能力决定何时或何时不做这些事情”......我对此表示怀疑,除非你是超人。
  • @John:我不知道你为什么这么说;人们似乎认为优化是某种黑魔法,只有编译器和好的猜测者才知道该怎么做。这一切都归结为指令和周期以及使用它们的原因。正如我在 SO 上多次解释的那样,很容易判断这些费用是如何以及为什么要花费的。如果我有一个必须使用大量时间的循环,并且与内容相比,它在循环开销中花费了太多循环,我可以看到并展开它。代码提升也是如此。不需要天才。
  • 我确信这并不难,但我仍然怀疑你是否能像编译器那样快。无论如何,编译器为您做这件事有什么问题?如果您不喜欢它,请关闭优化,然后像 1990 年一样浪费时间!
  • 循环展开带来的性能提升与您保存的比较无关。什么都没有。
【解决方案2】:

循环展开完全取决于您的问题大小。这完全取决于您的算法能够将大小减少到更小的工作组中。你上面所做的看起来不像那样。我不确定是否可以展开蒙特卡罗模拟。

循环展开的好方案是旋转图像。因为您可以轮换不同的工作组。要使其工作,您必须减少迭代次数。

【讨论】:

  • 我正在展开一个快速排序,该排序是从模拟的内部循环调用的,而不是模拟的主循环。
【解决方案3】:

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

了解您的编译器为您做了多少优化是值得的。

我发现Felix von Leitner's presentation 在这个主题上很有启发性。我建议你阅读它。总结:现代编译器非常聪明,因此手动优化几乎从不有效。

【讨论】:

  • 读得很好,但我认为唯一正确的部分是他谈到保持数据结构简单的地方。其余部分是准确的,但基于一个巨大的未说明的假设 - 正在执行的内容必须。在我进行的调优中,我发现当大量时间投入到不必要的大量抽象代码中时,人们会担心寄存器和缓存未命中。
  • “手部优化几乎永远不会有效” → 如果您完全不熟悉这项任务,也许是这样。否则根本不正确。
  • 在 2019 年,我仍然进行了手动展开,与编译器的自动尝试相比获得了可观的收益。所以让编译器完成这一切并不那么可靠。它似乎并不经常展开。至少对于 c# 我不能代表所有语言。
【解决方案4】:

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

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

如果比较繁重(即非test 指令),比较(到循环结束)也是一个主要缺点,特别是如果它依赖于外部函数。

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

【讨论】:

    【解决方案5】:

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

    展开循环,其数量 迭代可以确定在 编译时间或进入 循环。

    因此,在实践中,您的编译器很可能会为您处理琐碎的案例。因此,您需要确保尽可能多的循环便于编译器确定需要多少次迭代。

    【讨论】:

    • 及时编译器通常不做循环展开,启发式方法太昂贵了。静态编译器可以花更多的时间在上面,但是两种主要方式之间的区别很重要。
    【解决方案6】:

    如果你可以打破依赖链,循环展开是有意义的。这为无序或超标量 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-3 倍),并指出第一段代码禁用了浮点通道中的任何指令级并行性。第二个将允许超标量 CPU 最多同时执行四个浮点加法。
    • 请记住,以这种方式计算总和时,结果在数值上与原始循环不同。
    • 循环携带的依赖是一个循环,加法。一个 OoO 核心就可以了。此处展开可能有助于浮点 SIMD,但这与 OoO 无关。
    • @Nils:不是很多;主流 x86 OoO CPU 仍然与 Core2/Nehalem/K10 足够相似。在缓存未命中后赶上仍然很小,隐藏 FP 延迟仍然是主要好处。在 2010 年,每个时钟可以执行 2 次负载的 CPU 甚至更少(只是 AMD,因为 SnB 尚未发布),因此多个累加器对于整数代码的价值肯定比现在低(当然这是应该自动矢量化的标量代码,所以谁知道编译器是将多个累加器变成向量元素还是变成多个 vector 累加器...)
    【解决方案7】:

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

    【讨论】:

    • 为什么特别是在接收 x86 CPU 上?
    • @JohnTortugo:现代 x86 CPU 对小循环进行了某些优化 - 参见例如Core 和 Nehalem 架构上的循环流检测器 - 展开循环以使其不再小到无法放入 LSD 缓存中,从而破坏了这种优化。参见例如tomshardware.com/reviews/Intel-i7-nehalem-cpu,2041-3.html
    【解决方案8】:

    在不知情的情况下尝试是不行的。
    这种排序占总时间的百分比高吗?

    所有循环展开所做的都是减少递增/递减、比较停止条件和跳转的循环开销。如果您在循环中执行的操作比循环开销本身需要更多的指令周期,那么您将不会看到太多的百分比改进。

    Here's an example of how to get maximum performance.

    【讨论】:

      【解决方案9】:

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

      例如,它可以允许标量替换、软件预取的有效插入...您会惊讶地发现它实际上是多么有用(即使使用 -O3,您也可以轻松地在大多数循环上获得 10% 的加速)通过积极展开。

      正如之前所说,它在很大程度上取决于循环,编译器和实验是必要的。很难制定规则(或者展开的编译器启发式将是完美的)

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 2020-03-18
        • 2010-10-08
        • 1970-01-01
        • 2012-06-27
        • 2016-09-30
        相关资源
        最近更新 更多