【问题标题】:When is loop unwinding effective?循环展开何时有效?
【发布时间】:2010-09-16 11:34:41
【问题描述】:

Loop unwinding 是帮助编译器优化性能的常用方法。我想知道性能增益是否以及在多大程度上受到循环主体的影响:

  1. 语句数
  2. 函数调用次数
  3. 使用复杂的数据类型、虚拟方法等
  4. 动态(解除)内存分配

您使用什么规则(经验法则?)来决定是否展开性能关键循环?在这些情况下,您还考虑了哪些其他优化?

【问题讨论】:

    标签: c++ performance algorithm optimization


    【解决方案1】:

    一般来说,手动展开循环是不值得的。编译器更了解目标架构的工作原理,如果有好处,就会展开循环。

    有些代码路径在展开时对 Pentium-M 类型 CPU 有利,但对 Core2 没有好处。如果我手动展开,编译器将无法再做出决定,我最终可能会得到不是最优的代码。例如。与我试图实现的完全相反。

    在某些情况下,我会手动展开性能关键循环,但只有在我知道编译器(在手动展开后)能够使用架构特定功能(例如 SSE 或 MMX 指令)时,我才会这样做。然后,只有这样我才会这样做。

    顺便说一句 - 现代 CPU 在执行可预测的分支方面非常有效。这正是循环。如今,循环开销是如此之小,以至于几乎没有什么不同。然而,由于代码大小的增加可能会产生内存延迟效应。

    【讨论】:

      【解决方案2】:

      这是一个优化问题,因此只有一个经验法则:测试性能,并在您的测试表明您需要时尝试循环展开优化。首先考虑破坏性较小的优化。

      【讨论】:

        【解决方案3】:

        根据我的经验,循环展开以及它所花费的工作在以下情况下是有效的:

        • 循环中只有几条语句。
        • 语句只涉及少量不同的变量,没有函数调用
        • 您的操作在已分配的内存上运行(例如就地图像转换)

        部分展开通常会减少 80% 的收益。因此,不是循环遍历 N x M 图像的所有像素(NM 次迭代),其中 N 始终可以被 8 整除,而是在每个包含 8 个像素的块上循环 (NM/8) 次。如果您正在执行一些使用某些相邻像素的操作,这尤其有效。

        我在手动优化 MMX 或 SSE 指令(一次 8 或 16 个像素)中的逐像素操作方面取得了非常好的结果,但我也花了几天时间手动优化某些东西,结果发现优化的版本是编译器的运行速度快了十倍。

        顺便说一句,对于循环展开的最(美丽|显着)示例,请查看Duffs device

        【讨论】:

        • 这也是一个合适的词是 :-)
        • 虽然聪明,但我认为 Duff 的设备是糟糕的代码构造。相对于 switch 语句,他的结构并没有真正的速度优势。两个连续的循环,一个展开,另一个不处理舍入,这更加不言自明,并且不依赖于 C 的奇怪语法可能性
        • @TallJeff:没错,但也违反了 DRY 原则
        【解决方案4】:

        需要考虑的重要一点:在工作场所的生产代码中,代码的未来可读性远远超过循环展开的好处。硬件便宜,程序员的时间不便宜。如果这是解决已证明的性能问题的唯一方法(例如在低功率设备中),我只会担心循环展开。

        其他想法:编译器的特性差异很大,并且在某些情况下,如 Java,由 HotspotJVM 即时确定,因此我反对在任何情况下展开循环。

        【讨论】:

          【解决方案5】:

          这些优化高度依赖于执行代码的 cpu,应该由编译器完成,但如果您正在编写这样的编译器,您可能需要查看英特尔文档 Intel(R) 64 and IA-32 Architectures Optimization Reference Manual 第 3.4 节。 1.7:

          • 展开小循环,直到分支和归纳变量的开销(通常)占循环执行时间的 10% 以下。

          • 避免过度展开循环;这可能会破坏跟踪缓存或指令缓存。

          • 展开经常执行且具有可预测迭代次数的循环,以将迭代次数减少到 16 次或更少。除非它增加代码大小以致工作集不再适合跟踪或指令缓存,否则请执行此操作。如果循环体包含多个条件分支,则展开,使迭代次数为 16/(# 个条件分支)。

          您也可以免费订购纸质版here

          【讨论】:

            【解决方案6】:

            手动展开循环在较新的处理器上可能效率不高,但它们在 GPU 和轻型架构(如 ARM)上仍然有用,因为它们在预测方面不如当前一代 CPU 处理器,而且因为测试和跳转实际上会浪费这些处理器上的周期.

            也就是说,它应该只在非常紧凑的循环和块中完成,因为展开后代码会显着膨胀,这会破坏小型设备上的缓存,最终会遇到一个非常糟糕的问题.

            但请注意,展开循环应该是优化时的最后手段。它会在一定程度上扭曲您的代码,使其无法维护,并且阅读它的人可能会在以后抢购并威胁您和您的家人。知道这一点,让它值得:)

            使用宏可以极大地帮助使代码更具可读性,并且可以使展开故意。

            例子:

            for(int i=0; i<256; i++)
            {
                a+=(ptr + i) << 8;
                a-=(ptr + i - k) << 8;
                // And possibly some more
            }
            

            可以展开到:

            #define UNROLL (i) \
                a+=(ptr[i]) << 8; \
                a-=(ptr[i-k]) << 8;
            
            
            for(int i=0; i<32; i++)
            {
                UNROLL(i);
                UNROLL(i+1);
                UNROLL(i+2);
                UNROLL(i+3);
                UNROLL(i+4);
                UNROLL(i+5);
                UNROLL(i+6);
                UNROLL(i+7);
            }
            

            在一个不相关但仍然有些相关的注释中,如果您真的想在指令计数方面获胜,请确保所有常量在您的代码中统一为尽可能少的立即数,这样您就不会得到以下结果组装:

            // Bad
            MOV r1, 4
            //  ...
            ADD r2, r2, 1
            //  ...
            ADD r2, r2, 4
            

            代替:

            // Better
            ADD r2, r2, 8
            

            通常,严肃的编译器会保护您免受此类事情的影响,但并非所有人都会这样做。保留那些 '#define'、'enum' 和 'static const' 方便,并非所有编译器都会优化本地 'const' 变量。

            【讨论】:

              【解决方案7】:

              基本上,展开是有用的循环结构的成本是循环体的重要部分。大多数循环(以及几乎所有可以展开的循环)的结构包括(a)递增一个整数,(b)将其与另一个整数进行比较,以及(c)跳跃——其中两个是最快的CPU 指令。因此,几乎在任何循环中,身体都会超过结构的重量,从而产生微不足道的增益。如果你的身体中甚至有一个函数调用,身体将比结构慢一个数量级——你永远不会注意到这一点。

              几乎唯一可以真正从展开中受益的东西是 memcpy(),其中循环体只是将一个字节从一个位置移动到另一个位置 --- 这就是为什么许多 C 和 C++ 编译器自动内联和展开过去十年的 memcpy。

              【讨论】:

                【解决方案8】:

                手动循环展开通常只对最琐碎的循环有用。

                作为参考,g++ 中的 C++ 标准库在整个源代码中展开了两个循环,它们实现了带有和不带有谓词的“查找”函数,如下所示:

                while(first != last && !(*first == val))
                  ++first;
                

                我查看了这些和其他循环,并决定只为这种微不足道的循环是值得做的。

                当然,最好的答案是只展开那些您的分析器显示这样做有用的循环!

                【讨论】:

                  【解决方案9】:

                  如果您已尽其所能,并且这是您剩余的热点,并且循环内几乎没有任何内容,那么展开是有意义的。这些是很多“如果”。为了验证这是否是您的最后选择,try this

                  【讨论】:

                    【解决方案10】:

                    根据我的经验,循环展开可以在我的 intel i7 cpu 上不使用 SEE 的情况下带来 20% 到 50% 的性能。

                    对于单一次操作的简单循环,循环中有一次条件跳转和一次增量的开销。每一个跳转和增量执行多个操作可能是有效的。有效循环展开的示例如下代码:

                    在下面没有展开的代码中,有一个比较 + 一个 jumb + 每个 sum 操作一个增量的开销。此外,所有操作都必须等待先前操作的结果。

                    template<class TData,class TSum>
                    inline TSum SumV(const TData* pVec, int nCount)
                    {
                       const TData* pEndOfVec = pVec + nCount;
                       TSum   nAccum = 0;
                    
                       while(pVec < pEndOfVec)
                       {
                           nAccum += (TSum)(*pVec++);
                       }
                       return nAccum;
                    }
                    

                    在展开的代码中,每四次求和运算有一个比较 + 一个 jumb + 一个增量的开销。而且还有很多操作不需要等待前一次操作的结果,可以更好的被编译器优化。

                    template<class TData,class TSum>
                    inline TSum SumV(const TData* pVec, int nCount)
                    {
                      const TData* pEndOfVec = pVec + nCount;
                      TSum   nAccum = 0;
                    
                      int nCount4 = nCount - nCount % 4;
                      const TData* pEndOfVec4 = pVec + nCount4;
                      while (pVec < pEndOfVec4)
                      {
                          TSum val1 = (TSum)(pVec[0]);
                          TSum val2 = (TSum)(pVec[1]);
                          TSum val3 = (TSum)(pVec[2]);
                          TSum val4 = (TSum)(pVec[3]);
                          nAccum += val1 + val2 + val3 + val4;
                          pVec += 4;
                      }      
                    
                      while(pVec < pEndOfVec)
                      {
                          nAccum += (TSum)(*pVec++);
                      }
                      return nAccum;
                    }
                    

                    【讨论】:

                      猜你喜欢
                      • 1970-01-01
                      • 2016-10-27
                      • 1970-01-01
                      • 2011-05-16
                      • 1970-01-01
                      • 2016-08-03
                      • 1970-01-01
                      • 1970-01-01
                      • 1970-01-01
                      相关资源
                      最近更新 更多