【问题标题】:How to optimize a C for loop?如何优化 C for 循环?
【发布时间】:2010-11-15 08:35:17
【问题描述】:

我的代码的瓶颈部分存在性能问题。基本上它是一个简单的嵌套循环。

分析问题表明程序花费大量时间来增加循环计数器 (++) 和测试终止 (i/j

观察程序集输出,我发现两个计数器都没有获取寄存器并且访问它们会花费很多周期。使用“register”关键字并不能说服编译器将它们实际放入寄存器中。有什么办法可以优化计数器的访问时间吗?

这是汇编输出。 C 源代码只是一个带有 i/j 计数器的简单嵌套循环。

  2738  0.2479  2459  0.1707   :    1e6c:   jne    1dd1 <process_hooks+0x121>
  1041  0.0942  1120  0.0778   :    1e72:   addl   $0x1,0xffffffd4(%ebp)
  2130  0.1928  2102  0.1459   :    1e76:   cmpl   $0x8,0xffffffd4(%ebp)
  2654  0.2403  2337  0.1622   :    1e7a:   jne    1da0 <process_hooks+0xf0>
  809   0.0732   814  0.0565   :    1e80:   jmp    1ce2 <process_hooks+0x32>

根据要求,这里也是 C 代码。编译器是 gcc 顺便说一句:

for (byte_index=0; byte_index < MASK_SIZE / NBBY; byte_index++)
{
    if (check_byte(mask,byte_index))
    {
        for (bit_index=0; bit_index < NBBY; bit_index++)
        {
            condition_index = byte_index*NBBY + bit_index;
            if (check_bit(condition_mask,condition_index))
            {
                .
                .
                .
            }
        }
    }
}

谢谢

【问题讨论】:

  • 您能否将代码也发布在 C 中,以便理智的编码人员也能理解问题所在?
  • 真的,代码可能包含一些内容,使得不为计数器分配寄存器是完全合理的。
  • 另外,您是否构建了具有全面优化的“发布”配置?
  • 为什么只有-O2?使用 -O3 -Wall -Wextra :)
  • 您是否在 gcc 选项中指定了处理器(-mtune=cpu-type -march=cpu-type)?也许您有一个带有大量寄存器的全新处理器,但 gcc 避免使用所有这些寄存器以使可执行文件在旧硬件上更具可移植性...

标签: c optimization compiler-construction


【解决方案1】:

这似乎是一个小问题,但不要使用以下形式: index++ ,使用 ++index;

基本原理是 index++ 需要在递增之前缓存当前的右值,而 ++index 返回新计算的右值,它应该已经被缓存,从而节省了引用。

当然,一个好的编译器会优化这个,所以这可能不是问题。

【讨论】:

    【解决方案2】:

    我会尝试以不同的方式看待问题。

    代码到底在做什么,也许在解释它的作用时,可以使用更有效的不同算法?

    例如,我经常看到迭代大型项目列表的代码,通过将列表拆分为两个链表,一个 "active" 项目和一个 "non -active" 项,然后具有在分配和释放项时将项从一个列表移动到另一个列表的代码。我认为这会给你最好的结果。

    【讨论】:

      【解决方案3】:

      如果内部 if() 中的 /* stuff */ 代码确实很少执行(并且它可能发生的次数有一个先验已知的界限,或者至少是一个合理的限制),更改为两遍解决方案很可能会提高性能。这将消除嵌套循环中的注册压力。以下是基于我之前的单索引答案:

      for (n = 0, condition_index = 0; condition_index < MASK_SIZE;)
      {
          if (check_byte(mask, condition_index / NBBY))
          {
              for (bound = condition_index + NBBY; condition_index < bound; condition_index++)
              {
                  if (check_bit(condition_mask, condition_index))
                  {
                      condition_true[n++] = condition_index;
                  }
              }
          }
          else
          {
              condition_index += NBBY;
          }
      }
      
      do {
          condition_index = condition_true[--n];
          /* Stuff */
      } while (n > 0);
      

      【讨论】:

        【解决方案4】:

        它没有被放入寄存器有两个可能的原因:

        变量需要保存在内存中

        如果您要获取变量的地址或将其声明为 volatile,则不会将其保存在寄存器中。您似乎没有这样做,但它可能发生在 ... 部分。

        gcc 在寄存器分配方面做得不好。

        这很有可能。 gcc 的分配器似乎很差(基于其开发人员的 cmets)。此外,寄存器分配变化无常,难以推理。您可能可以使用register allocator optimizations 对其进行调整以获得一些好处。如果您愿意,可以将它们设置为that function only

        gcc 4.4 有一个新的寄存器分配器,应该会更好,但也允许你选择分配算法。这将提供额外的调整。

        您也可以尝试使用hot attribute 告诉 gcc 更加努力。

        最后,您还可以使用 gcc 的 --param 标志进行调整。它们暴露了内部编译器设置,因此可能不应该轻易着手。

        【讨论】:

        • >> gcc 在寄存器分配方面做得不好。或者可能它在寄存器分配方面做得很好,并将寄存器用于其他事情/
        • 可能 - 很难说。但由于 gcc 的寄存器分配器很差,所以这不是我的第一个猜测。
        【解决方案5】:

        你应该改变你的设计,首先不应该存在内部循环 - 您应该避免使用位,将位检查转换为单字节检查。 我不能确切地告诉你如何,因为这取决于你所做的检查类型,但我假设涉及一个循环表外壳。

        编辑: 另一件要考虑的事情,如果你真的想让一部分代码更快,你可能会使用特殊的 CPU 指令,你的编译器可能会不知道何时使用它们。 例如,在 Intel 上,可以使用许多指令,直至 SSE4 甚至更多,这确实是您可以比编译器执行得更好的地方,因为它无法知道您想要在算法级别实现什么。 查看英特尔(R) 64 和 IA-32 架构软件开发人员手册了解详细信息。 同样在这个级别,您可能会受益于对管道的更好控制。

        如果你不想写汇编,有时会有指令的包装函数,在'C'中使用。

        关于检查某位是否开启: 如果某个位打开,不确定您想要做什么,但是(假设您的位是字节对齐的):

        假设您将在 X 上获得字节 0110 0110。 你会想做一些事情,也许打印一个像“Bits 1,2,5,6 are on”这样的按摩。 您可以创建 256 个函数,每个函数都会执行类似显示这种按摩的操作。 你怎么知道要激活哪一个? 函数号 shell 正是接收到的字节的值,所以你可以简单地使用 [] 运算符去那里。然而,它将是一个指向函数的表。 它应该看起来像这样:

        //define the functions
        void func0()
        {
           printf("No Bits are on.");
        }
        
        void func1()
        {
           printf("Bit 0 is on.");
        }
        .
        .
        .
        
        //create the table
        void  (*table[256])();
        table[0] = &func0;
        table[1] = &func1;
        .
        .
        .
        
        //the for loop
        void  (*pointer_to_func)();
        for...
        {
           X = getByte();
           pointer_to_func = table[X]; //table shell contain 256 function pointers.
           pointer_to_func(); //call the function
        }
        

        这应该调用X位置的函数并执行它,我假设位置X == 102(0110 0110的十进制)的函数将类似于:

        printf("位 1,2,5,6 开启");

        The Function Pointer Tutorials , 特别是this

        【讨论】:

        • 为什么会有帮助?它不会改变我将拥有的支票数量。此外,它会占用宝贵的堆栈空间。
        • 它应该减少 8 位检查,这是非常昂贵的位操作字节检查,以单个字节检查。这里的问题直接源于大多数现代 CPU 在位级别上不能很好地工作,因此您应该始终渴望至少在字节级别上工作,如果不是在更大的数据对象上。如果您描述“Bit-Check”,我很乐意提供进一步的帮助。 (你的 CPU 不支持直接位操作,对吧?因为如果支持,那么我的建议就没那么有用了。)
        • bit_check 只检查给定位是否打开。像 n & (1 1。无论哪种方式,我都必须循环相同的次数(在我的情况下为 8 * 8),正如我所说,这似乎是分析器输出的瓶颈
        【解决方案6】:

        您可以尝试将其重构为一个索引,看看是否会改变编译器的想法:

        for (condition_index = 0; condition_index < MASK_SIZE;)
        {
            if (check_byte(mask, condition_index / NBBY))
            {
                for (bound = condition_index + NBBY; condition_index < bound; condition_index++)
                {
                    if (check_bit(condition_mask, condition_index))
                    {
                        /* stuff */
                    }
                }
            }
            else
            {
                condition_index += NBBY;
            }
        }
        

        (希望 NBBY 是 2 的幂,因此除法将作为移位实现)

        【讨论】:

        • 试过了。计数器仍在使用基指针而不是寄存器,但它实际上使事情变得更好了。 (好 5%)。我不知道为什么,但不能与结果争论......所以谢谢!
        • 嗯,一定是/* stuff */里面的函数调用。如果这种情况不经常发生,您可以尝试将 check_bit() 包装为 __builtin_expect(check_bit(...), 0) 以向编译器表明它通常是错误的。
        【解决方案7】:

        如果不了解内部循环中的内容,则尝试优化循环是没有意义的。看起来代码是为 x86 32bit 生成的。如果循环中的计算需要多个寄存器,则编译器没有必要将循环计数器保存在寄存器中,因为无论如何它都必须将它们溢出到堆栈中。然后根据内部循环中使用的指令,寄存器分配可能会出现相当多的问题。移位仅使用 ECX 寄存器作为计数,乘法和除法对使用的寄存器有限制,字符串命令使用 ESI 和 EDI 作为寄存器,减少了编译器在其中保存值的机会。 正如其他人已经说过的那样,循环中间的调用也无济于事,因为无论如何都必须保存寄存器。

        【讨论】:

          【解决方案8】:

          假设分析信息是正确的,并且确实是增量操作导致了瓶颈,您可能会滥用一些乱序执行:

          for (byte_index = 0; byte_index < MASK_SIZE / NBBY; )
          {
              if (check_byte(mask,byte_index++))
              {
                  condition_index = byte_index*NBBY;
                  for (bit_index=0; bit_index < NBBY; )
                  {
                      if (check_bit(condition_mask,condition_index + bit_index++))
                      {
                          ...
                      }
                  }
              }
          }
          

          (由于显而易见的原因,上面的 sn-p 不起作用,但你应该明白了 :)

          此外,从 C sn-p 中的函数/宏名称来看,我假设您正在使用位掩码来做一些事情。帮助我更早提高性能的一件事是迭代一组掩码,而不是对输入进行动态计算,即类似

          for (byte_index = 0; byte_index < MASK_SIZE / NBBY; byte_index++)
          {
              if (check_byte(mask,byte_index))
              {
                  const char masks[] = { 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 };
                  for (mask_index=0; mask_index < sizeof(masks) / sizeof(masks[0]); mask_index++)
                  {
                      if (check_bit(masks[mask_index], byte_index))
                      {
                          ...
                      }
                  }
              }
          }
          

          ...编译器可能有更好的机会正确优化/展开。

          【讨论】:

          • 位移初始掩码比每次从数组中进行昂贵的查找要快得多。
          • 嗯,当然。但我不止一次看到“简单”代码为编译器提供了更好的优化基础,尤其是在谈论循环时——你不能在 C 代码中手动进行向量化。
          【解决方案9】:
              for (bit_index=0; bit_index < NBBY; bit_index++)
              {
                  condition_index = byte_index*NBBY + bit_index;
                  if (check_bit(condition_mask,condition_index))
                  {
                      .
                      .
                      .
                  }
              }
          

          同样容易;

              condition_index = byte_index * NBBY;
              for (bit_index=0; bit_index < NBBY; bit_index++, condition_index++)
              {
                  if (check_bit(condition_mask,condition_index))
                  {
                      .
                      .
                      .
                  }
              }
          

          我喜欢将计算保持在正确的范围内。您在外部循环中拥有此信息的所有信息,但选择将其置于内部循环中。新的循环稍微有点混乱,但这可以避免,现在你的编译器更有可能正确地做事。 (以前可能是这样,但如果不检查组件就无法确定。)

          说到正确的范围,没有理由在循环之外声明循环计数器。这种 C 风格已经过时多年了,虽然它可能并不是一个特别的性能劣势,但将事物限制在最小的逻辑范围会导致代码更清晰、更易于维护。

          对于 8 位,您可能会展开,但取决于您的硬件,它可能无法很好地工作。还有很多其他方法可以做到这一点,我可能错过了一些查看它的方法。在硬件中,我在循环中使用条件通常会对性能产生毒害,但我没有看到任何明显的方法来避免它。我当然会考虑在外部循环中迭代位而不是字节,以避免在常见情况下发生乘法。只是建议这个......我认为在这种情况下不会有明显的优势。

          【讨论】:

          • 您能解释一下这将如何帮助优化他的代码吗?据我所知,它实际上需要一个 extra 寄存器,除非编译器将它移回(这违背了目的)。
          • 假设编译器不能为你做(它应该这样做),第二个代码示例避免重新计算 byte_index*NBBY 每次循环迭代。
          • @Matt J:但已经确定这不是瓶颈。
          • 为什么多注册一个会是一件坏事?也许这不是瓶颈,但很难说编译器在做什么。将计算放在更合乎逻辑的范围内会使循环更容易展开。我只是在这里在黑暗中拍摄,抛出建议,并且可以根据其价值单独采取。
          • 他在问题中说了。引用:“观察程序集输出,我发现两个计数器都没有获取寄存器并且访问它们会花费很多周期。使用“寄存器”关键字并不能说服编译器将它们实际放入寄存器中。有什么可以是否可以优化计数器的访问时间?”
          【解决方案10】:

          如果编译器试图将计数器放入寄存器,则必须为循环内的每个函数调用保存和恢复寄存器(可能取决于定义这些函数的位置)。内联函数应该会大大加快速度(如果这确实是您的瓶颈)。

          【讨论】:

            【解决方案11】:

            我希望这 2 个函数是内联的(check_bit 和 check_byte),因为它们比任何寄存器变量可能使您的循环慢得多。

            如果编译器没有内联它们,请自己将它们内联到循环中。

            【讨论】:

            • 它们是内联的。它们是简单的按位运算,不使用局部变量。我确实在循环中调用了一个无法内联的函数,因为它太重了。我检查了开销,与循环计数器相比,它可以忽略不计。
            • 函数调用比任何循环计数器都要重得多。为什么?它必须将变量保存在堆栈上,(可能)中断管道,(可能)退出缓存,并再次恢复变量。与此相比,反问题只是小菜一碟。
            • 此外,尝试查看编译器是否将乘法转换为位移位。由于 NBBY 可能比这可能是 8、16 或 32。部门也是如此。
            • 最后但并非最不重要的......而不是不断计算'condition_index'。只需在每个循环中增加它。
            • 或者更好……因为 bit_check 可能只检查 1 位(顾名思义),所以每次迭代时,预先计算检查掩码并将其位移到左侧。所以:bitIndexMask = 1;并且对于每个循环都执行: bitIndexMask
            【解决方案12】:

            当在循环计数器中遇到性能瓶颈时,您应该考虑unrolling 循环。

            编辑:与往常一样,在优化时,请确保您进行基准测试并说服自己您获得了所需的结果。

            【讨论】:

            • 过度展开循环实际上对性能不利。因为它可能会导致整个循环(或其他位)不再适合缓存。
            • 如果编译器认为循环会提高性能,它通常会尝试展开循环。
            • 如果它可能导致循环不适合缓存,那么它可能对性能不利。 ,, 实际上很糟糕'' 言过其实。在优化之前进行测量。
            • C 编译器有时由于指针别名而无法像应有的那样积极展开。这意味着一些手动工作有时会有所作为。
            • OP 在上面的 cmets 中说他只使用 -O2,所以循环没有展开。使用 -O3 展开。
            【解决方案13】:

            您需要确保这是一个瓶颈,在现代处理器上,指令被分开并且部分指令被乱序执行,并且使用高速缓存和后备缓冲区,这完全有可能不会变慢。

            【讨论】:

              【解决方案14】:

              使用英特尔编译器时获得的最佳结果(速度方面)。

              你说得对,'register' 关键字只是作为编译器的提示(就像 inline 一样)。

              如果你真的认为这个循环是一个主要瓶颈,只需输入 raw assembly。我知道它很难便携,但话又说回来,通常这并不重要,如果它应该是便携的......它只在一个特定的地方。

              您甚至可以使用原始 C 代码#ifdef 整个位以保持可移植性

              【讨论】:

                【解决方案15】:

                This page 建议“寄存器关键字是一个有点过时的过程,因为很长一段时间以来,现代编译器中的优化器足够聪明,可以检测何时将变量存储在寄存器上将是有利的,并且会在优化期间这样做。因此,建议编译器在寄存器上存储一个变量,如果使用不当只会让事情变慢”。

                我猜这在很大程度上取决于您的编译器和优化级别。正如其他人所说,这可能是 -funroll-all-loops (gcc) 的良好候选者。

                【讨论】:

                • 我所知道的所有现代 C++ 编译器都会完全忽略 register。考虑到他们使用寄存器分配的技巧,这是完全可以理解的。
                【解决方案16】:

                您可以尝试展开循环。编译器可能会为您执行此操作,但如果没有,并且您真的需要性能,请自行执行。我假设您正在做类似调用 function(.., i, j, ..) 每次迭代的事情,所以只需将循环替换为:

                function(.., 0, 0, ..)
                ...
                function(.., 0, 7, ..)
                function(.., 1, 0, ..)
                ...
                function(.., 7, 7, ..)
                

                有了更多上下文(C 源代码),可能会有更多有用的事情要做。坦率地说,如果 2 个堆栈分配的计数器(许多现代处理器具有特殊的加速器硬件,可以几乎与寄存器一样快地访问堆栈的顶部位)在非玩具程序中引起明显的问题,我会感到震惊。

                【讨论】:

                • 在循环中调用一个函数是很糟糕的表现。如果可以,将该函数的内容放入循环中。它将节省大量时间(比我之前说的循环展开更多,由于缓存未命中可能对性能真的很糟糕)
                • true,内联和展开+内联也是可行的解决方案。根据我的经验,似乎编译器比展开更有可能为你做内联,所以在你的 C 代码中展开很有可能在编译的代码中产生展开+内联,假设函数足够小不会导致可怕缓存未命中数。
                猜你喜欢
                • 2018-12-23
                • 1970-01-01
                • 2011-06-15
                • 1970-01-01
                • 2022-11-03
                • 2011-07-04
                • 2011-08-30
                • 1970-01-01
                • 2014-09-28
                相关资源
                最近更新 更多