【问题标题】:Is it more efficient to branch or multiply?分支还是乘法更有效?
【发布时间】:2023-04-04 00:09:01
【问题描述】:

我正在尝试优化一个小型、高度使用的函数,它使用 unsigned short int 中的高位来指示要加在一起的数组的值。起初我使用的是如下所示的明显方法。请注意,循环展开没有明确显示,因为它应该由编译器完成。

int total = 0;
for(unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++){
    if (i & mask){
        total += value[j];
    }
}

但是,后来我认为最好删除分支以帮助 CPU 流水线化并提出以下建议。

int total = 0;
for(unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++){
    total += ((i & mask) != 0) * value[j];
}

请注意,由于 (i & mask) 不会产生布尔答案,因此与 0 的比较会强制结果为 1 或 0。虽然第二种方法消除了这部分代码中的 if 语句,但除了方程的其余部分之外,第二个解决方案需要在每次迭代中运行 0 或 1 的乘法。

哪个代码运行得更快?

【问题讨论】:

  • 它们都应该编译成相同的东西,给定一个理智的编译器。我会选择更具可读性的第一个选项。您的平台是否支持预测执行?它在这里会很好用,只有 1 条指令需要断言(加法),所以在这种情况下你不需要真正的分支。
  • 注意事项:您可以将((i &amp; mask) != 0) 替换为!!(i &amp; mask)。 “!!”是一种滥用!运算符通过应用两次来创建“转换为布尔”运算符。这不应该改变生成的程序集,但它是一个常见的习惯用法,对我来说更具可读性。
  • 提醒 ((i & mask) != 0) 可能不可移植.... false 为 0,true 不为 0....

标签: c++ c optimization if-statement branch


【解决方案1】:

哪个代码运行得更快?

测试一下就知道了。

此外,请查看编译器发出的代码的汇编语言版本,因为您可能会在其中看到令您惊讶的东西,并暗示进一步的优化(例如,在您使用时使用 short可能需要更多使用机器自然整数大小的指令)。

【讨论】:

    【解决方案2】:

    两者都可以更快。对于某些处理器,实际输入数据可能会改变答案。您将需要使用真实数据来分析这两种方法。以下是一些可能会影响 x86 硬件实际性能的因素。

    我们暂时假设您使用的是最新型号的 Pentium 4。该处理器在 CPU 中内置了两级分支预测器。如果分支预测器可以正确猜测分支方向,我怀疑第一个将是最快的。如果标志几乎都是相同的值,或者它们大部分时间以非常简单的模式交替出现,则最有可能发生这种情况。如果标志是真正随机的,那么分支预测器将有一半是错误的。对于我们假设的 32 级 Pentium 4,这将影响性能。对于 Pentium 3 芯片、Core 2 芯片、Core i7 和大多数 AMD 芯片,流水线更短,因此错误分支预测的成本要低得多。

    如果您的值向量明显大于处理器的缓存,那么这两种方法都会受到内存带宽的限制。它们都将具有基本相同的性能特征。如果值向量很适合缓存,请注意如何进行任何分析,以免其中一个测试循环因填充缓存而受到惩罚,而另一个则从中受益。

    【讨论】:

      【解决方案3】:

      你可以在没有乘法的情况下让它无分支。看起来对于每个位集,您都使用该位位置作为数组的索引。

      首先,您可以轻松地提取位设置:

      unsigned short set_mask= i & -i;
      i&= i - 1;
      

      然后,您可以通过计算(set_mask - 1)中设置的位来获得位索引。这有一个恒定的时间公式。

      某些平台还具有获取位集的位索引的内在特性,这可能更快。 x86 有bsr,PPC 有cntlz

      所以答案是无分支无乘法版本可能是最快的:)

      【讨论】:

      • 非常有趣,但我想知道“恒定时间公式”是否值得,您能否提供有关此公式的更多详细信息?
      • 谢谢,这是一个非常优雅的解决方案。
      • 这个解决方案实现了: unsigned int total = 0 while (i){ total += value[countBits((i & -i) - 1)];我 &= (i - 1); }
      • GCC 有 __builtin_popcount(x),它返回 x 中设置的位数。
      • 不要计算mask中的位;甚至创建mask 也是一个错误。看我的回答。
      【解决方案4】:

      这次修订怎么样?

      int total = 0;
      for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++){
          total += (mask & 0x0001) * value[j];
      }
      

      我已将mask 复制为i 的副本,限制为16 位无符号范围,但代码检查是否设置了掩码的最后一位,将数组值乘以该位。这应该更快,因为每次迭代的操作更少,并且只需要主循环分支和条件。此外,如果 i 开始时很小,则循环可以提前退出。


      这说明了为什么测量很重要。我正在使用过时的 Sun SPARC。如图所示,我编写了一个测试程序,问题中的两个竞争者作为测试 0 和测试 1,我自己的答案作为测试 2。然后运行计时测试。 'sum' 被打印为健全性检查 - 以确保所有算法都给出相同的答案。

      64 位未优化:

      gcc -m64 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib/sparcv9 -ljl -lposix4
      
      Test 0: (sum = 1744366)  7.973411 us
      Test 1: (sum = 1744366) 10.269095 us
      Test 2: (sum = 1744366)  7.475852 us
      

      不错:我的比原版稍快,而加强版的速度较慢。

      64 位优化:

      gcc -O4 -m64 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib/sparcv9 -ljl -lposix4
      
      Test 0: (sum = 1744366)  1.101703 us
      Test 1: (sum = 1744366)  1.915972 us
      Test 2: (sum = 1744366)  2.575318 us
      

      该死 - 我的版本现在明显是最慢的。优化器不错!

      32 位优化:

      gcc -O4 -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib -ljl -lposix4
      
      Test 0: (sum = 1744366)  0.839278 us
      Test 1: (sum = 1744366)  1.905009 us
      Test 2: (sum = 1744366)  2.448998 us
      

      32 位未优化:

      gcc -std=c99 -I$HOME/inc -o x x.c -L$HOME/lib -ljl -lposix4
      
      Test 0: (sum = 1744366)  7.493672 us
      Test 1: (sum = 1744366)  9.610240 us
      Test 2: (sum = 1744366)  6.838929 us
      

      在(32 位)Cygwin 和不太老的笔记本电脑(32 位,优化)上使用相同的代码

      Test 0: (sum = 1744366)  0.557000 us
      Test 1: (sum = 1744366)  0.553000 us
      Test 2: (sum = 1744366)  0.403000 us
      

      现在我的代码最快的。这就是你测量的原因!它还说明了为什么以运行基准为生的人会心烦意乱。

      测试工具(如果你想要 timer.htimer.c 代码,请大声喊叫):

      #include <stdio.h>
      #include "timer.h"
      
      static volatile int value[] =
      {
          12, 36, 79, 21, 31, 93, 24, 15,
          56, 63, 20, 47, 62, 88,  9, 36,
      };
      
      static int test_1(int i)
      {
          int total = 0;
          for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
          {
              if (i & mask)
                  total += value[j];
          }
          return(total);
      }
      
      static int test_2(int i)
      {
          int total = 0;
          for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
          {
              total += ((i & mask) != 0) * value[j];
          }
          return(total);
      }
      
      static int test_3(int i)
      {
          int total = 0;
          for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++)
          {
              total += (mask & 0x0001) * value[j];
          }
          return(total);
      }
      
      typedef int(*func_pointer)(int);
      
      static func_pointer test[] = { test_1, test_2, test_3 };
      
      #define DIM(x)(sizeof(x)/sizeof(*(x)))
      
      int main()
      {
          int i, j, k;
          char buffer[32];
          for (i = 0; i < DIM(test); i++)
          {
              Clock t;
              long sum = 0;
              clk_init(&t);
              clk_start(&t);
              for (j = 0; j < 0xFFFF; j += 13)
              {
                  int rv;
      
                  for (k = 0; k < 1000; k++)
                      rv = (*test[i])(j);
                  sum += rv;
              }
              clk_stop(&t);
              printf("Test %d: (sum = %ld) %9s us\n", i, sum,
                     clk_elapsed_us(&t, buffer, sizeof(buffer)));
          }
      }
      

      我没有花时间弄清楚为什么我的代码在优化后变慢了。

      【讨论】:

      • 我尝试了一个 test_4(),它是 test_3(),但总计 += -(mask & 1) & value[j]。在 MacBook 上,4 比 -O4 下的 3 稍慢,未优化时稍快。反汇编一目了然,显示了一个实际的乘法和一个实际的和,所以让我感到惊讶:MUL 比 NEG 和 AND 快!酷。
      • 顺便说一句,我会在内部循环中使用 j
      • 呃,time.h中的clock(),也就是。
      • 因为我不能一个人呆着,所以我也尝试了我的 4-bits-at-a-time 建议,它在 test_3() 的 53% 的时间内运行。跨度>
      【解决方案5】:

      完全取决于编译器、机器指令集,可能还取决于月相。

      因此没有具体的正确答案。如果您真的想知道,请检查编译器的程序集输出。

      从简单的角度来看,我会说第二个比较慢,因为它涉及第一个加上乘法的所有计算。但是编译器可能足够聪明,可以优化掉它。

      所以正确答案是:视情况而定。

      【讨论】:

      • +1。此外,展开循环几乎肯定会比搞乱分支与乘法更能提高性能。
      • 除了我通过滚动一个循环来提高性能(那个函数占用了 80% 的运行时间,所以我迫切需要优化)。旧的传统优化智慧早就该彻底改革了。
      【解决方案6】:

      虽然第二个示例没有显式分支,但可能有一个隐式分支将比较结果转换为布尔值。通过打开编译器的汇编列表输出并查看它,您可能会有所了解。

      当然,唯一可以确定的方法是双向把握时间。

      【讨论】:

      • 是的,我认为你是对的,有一个隐含的分支。感谢您指出这一点。
      • 这取决于架构 - 在 x86 上,int-to-bool 可以通过两条指令“cmp”和“setne”无分支地完成。
      【解决方案7】:

      确定陈述的真实性的唯一真正方法是测试。考虑到这一点,我会同意以前的帖子说试试看!

      在大多数现代处理器上,分支是一个代价高昂的过程,尤其是很少采用的分支。这是因为必须刷新流水线,导致 CPU 实际上无法尝试同时执行一条或多条指令——仅仅是因为它不知道下一条指令来自何处。由于有几个分支,可能的控制流变得复杂,CPU 需要同时尝试所有可能性,因此它必须执行分支,然后在此之后立即开始执行许多指令。

      【讨论】:

        【解决方案8】:

        为什么不这样做(假设 i 是 32 位)

          for (i2 = i; i2; i2 = i3) {
            i3 = i2 & (i2-1);
            last_bit = i2-i3;
            a = last_bit & 0xffff;
            b = (last_bit << 16);
            j = place[a] + big_place[b];
            total += value[j];
          }
        

        place 是一个大小为 2^15+1 的表格,使得 place[0] = 0, place[1] = 1, place[2] = 2, place[4] = 3, place[8] = 4...place[15] = 16(其余值不要没关系)。和 big_place 几乎相同: big_place[0] = 0,big_place[1] = 17.... big_place[15] = 32.

        【讨论】:

          【解决方案9】:

          要超快,您可以避免循环、移位和乘法 - 使用 switch。

          switch (i) {
              case 0: break;
              case 1: total = value[0]; break;
              case 2: total = value[1]; break;
              case 3: total = value[1] + value[0]; break;
              case 4: total = value[2]; break;
              case 5: total = value[2] + value[0]; break;
              ...
          }
          

          要键入的内容很多,但我想它在运行时会快得多。您无法击败查找表的性能!

          我宁愿编写一个小的 Perl 脚本来为我生成此代码 - 只是为了避免输入错误。

          如果您认为这有点极端,您可以使用较小的表 - 4 位,并进行多次查找,每次都移动掩码。性能会受到一点影响,但代码会小很多。

          【讨论】:

          • 直到 switch 语句对于代码缓存行来说太大,并且性能受到影响。
          • 在这种情况下,您可以使用较小的查找表(如我所述)并多次查找。
          • 而且代码可能更快,但附近的代码更慢,因为这个版本占用更多的缓存。 :-)
          【解决方案10】:

          答案肯定是:在目标硬件上试试看。请务必遵循过去几周在 SO 上发布的大量微基准/秒表基准问题的建议。

          链接到一个基准测试问题:Is stopwatch benchmarking acceptable?

          就个人而言,我会选择 if,除非有真正令人信服的理由使用“混淆”替代方案。

          【讨论】:

            【解决方案11】:

            试试

            total += (-((i & mask) != 0)) & value[j];
            

            代替

            total += ((i & mask) != 0) * value[j];
            

            这避免了乘法。是否会有分支取决于编译器是否足够聪明,可以为 -(foo != 0) 找到无分支代码。 (这是可能的,但我会有点惊讶。)

            (当然,这取决于二进制补码表示;C 标准对此是不可知的。)

            你可能会像这样帮助编译器,假设 32 位整数并且有符号的 >> 传播符号位:

            total += (((int)((i & mask) << (31 - j))) >> 31) & value[j];
            

            也就是说,在上述实现下,将可能设置的位向左移动到最高有效位置,转换为有符号整数,然后一直向右移动到最低有效位置,产生全 0 或全 1 -定义的假设。 (我还没有测试过。)

            另一种可能性:一次考虑(比如说)4 位的块。有16种不同的加法序列;您可以为它们中的每一个分派到展开的代码,而在每个代码块中根本不需要测试。这里的希望是一次间接跳转的成本少于 4 个测试和分支。

            更新:使用 Jonathan Leffler 的脚手架,4-bits-at-a-time 方法在我的 MacBook 上是最快的。否定和结果与乘法大致相同。我想知道处理器是否将 0 和 1 之类的特殊情况相乘得更快(或者,如果它对于大多数位清除或大多数位设置的被乘数来说通常更快,则不是这种特殊情况)。

            我没有编写可接受的答案,因为它在这个特定的基准测试中不太可能是最快的(它应该从仅枚举集合位中获得大部分好处,在稀疏集合上表现最好,但完全有一半的位是在此基准中设置)。以下是我对 Leffler 代码的更改,以防其他人有奇怪的动机花时间在这上面:

            #include <stdio.h>
            #include <time.h>
            
            static int value[] =
            {
                12, 36, 79, 21, 31, 93, 24, 15,
                56, 63, 20, 47, 62, 88,  9, 36,
            };
            
            static int test_1(int i)
            {
                int total = 0;
                for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
                {
                    if (i & mask)
                        total += value[j];
                }
                return(total);
            }
            
            static int test_2(int i)
            {
                int total = 0;
                for (unsigned short mask = 0x0001, j = 0; mask != 0; mask <<= 1, j++)
                {
                    total += ((i & mask) != 0) * value[j];
                }
                return(total);
            }
            
            static int test_3(int i)
            {
                int total = 0;
                for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++)
                {
                    total += (mask & 0x0001) * value[j];
                }
                return(total);
            }
            
            static int test_4(int i)
            {
                int total = 0;
                for (unsigned mask = i & 0xFFFF, j = 0; mask != 0; mask >>= 1, j++)
                {
                    total += -(mask & 0x0001) & value[j];
                }
                return(total);
            }
            
            static int test_5(int i)
            {
                int total = 0;
                const int *p = value;
                for (unsigned mask = i & 0xFFFF; mask != 0; mask >>= 4, p += 4)
                {
                    switch (mask & 0xF)
                    {
                    case 0x0: break;
                    case 0x1: total += p[0]; break;
                    case 0x2: total += p[1]; break;
                    case 0x3: total += p[1] + p[0]; break;
                    case 0x4: total += p[2]; break;
                    case 0x5: total += p[2] + p[0]; break;
                    case 0x6: total += p[2] + p[1]; break;
                    case 0x7: total += p[2] + p[1] + p[0]; break;
                    case 0x8: total += p[3]; break;
                    case 0x9: total += p[3] + p[0]; break;
                    case 0xA: total += p[3] + p[1]; break;
                    case 0xB: total += p[3] + p[1] + p[0]; break;
                    case 0xC: total += p[3] + p[2]; break;
                    case 0xD: total += p[3] + p[2] + p[0]; break;
                    case 0xE: total += p[3] + p[2] + p[1]; break;
                    case 0xF: total += p[3] + p[2] + p[1] + p[0]; break;
                    }
                }
                return(total);
            }
            
            typedef int(*func_pointer)(int);
            
            static func_pointer test[] = { test_1, test_2, test_3, test_4, test_5 };
            
            #define DIM(x)(sizeof(x)/sizeof(*(x)))
            
            int main()
            {
                int i, j, k;
                for (i = 0; i < DIM(test); i++)
                {
                    long sum = 0;
                    clock_t start = clock();
                    for (j = 0; j <= 0xFFFF; j += 13)
                    {
                        int rv;
            
                        for (k = 0; k < 1000; k++)
                            rv = (*test[i])(j);
                        sum += rv;
                    }
                    clock_t stop = clock();
                    printf("(sum = %ld) Test %d: %8.6f s\n", sum, i + 1, 
                           (stop - start) / (1.0 * CLOCKS_PER_SEC));
                }
            }
            

            结果(gcc -O4 -std=c99 branchmult2.c):

            (sum = 1744366) Test 1: 0.225497 s
            (sum = 1744366) Test 2: 0.221127 s
            (sum = 1744366) Test 3: 0.126301 s
            (sum = 1744366) Test 4: 0.124750 s
            (sum = 1744366) Test 5: 0.064877 s
            

            编辑 2:我认为没有 volatile 限定符的测试会更真实。

            【讨论】:

              【解决方案12】:

              明显的解决方案:

              int total = 0;
              for(unsigned j = 0; j < 16; j++){
                  total += -(i>>j & 1) & value[j];
              }
              

              【讨论】:

                猜你喜欢
                • 2018-03-20
                • 2015-11-16
                • 2012-10-10
                • 2013-07-02
                • 2012-08-19
                • 1970-01-01
                • 2021-11-25
                • 2011-07-03
                • 1970-01-01
                相关资源
                最近更新 更多