【问题标题】:What are the differences between using array offsets vs pointer incrementation?使用数组偏移量与指针增量有什么区别?
【发布时间】:2009-11-02 19:15:12
【问题描述】:

给定 2 个函数,如果有任何区别,应该更快?假设输入数据很大

void iterate1(const char* pIn, int Size)
{
   for ( int offset = 0; offset < Size; ++offset )
   {
      doSomething( pIn[offset] );
   }
}

对比

void iterate2(const char* pIn, int Size)
{
   const char* pEnd = pIn+Size;
   while(pIn != pEnd)
   {
      doSomething( *pIn++ );
   }
}

这两种方法是否还有其他问题需要考虑?

【问题讨论】:

    标签: c++


    【解决方案1】:

    很有可能,您的编译器的优化器将为第一种情况创建一个loop induction variable 以将其转换为第二种情况。我希望优化后没有区别,所以我更喜欢第一种样式,因为我发现它更易于阅读。

    【讨论】:

    • 是的 - 指出精细实现是编译器细节/关注点。 (虽然我发现次要风格更具可读性:-)
    【解决方案2】:

    Boojum 是正确的 - IF 你的编译器有一个很好的优化器并且你已经启用了它。如果不是这种情况,或者您对数组的使用不是顺序的并且易于优化,那么使用数组偏移量可能会慢得多。

    这是一个例子。回到大约 1988 年,我们在 Mac II 上实现了一个带有简单电传打字界面的窗口。这包括 24 行 80 个字符。当您从代码中获得新行时,您向上滚动前 23 行并在底部显示新行。当电传打字机上有些东西时,它不是一直都是,它以 300 波特的速度输入,串行协议开销约为每秒 30 个字符。所以我们说的根本不是应该对 16 MHz 68020 征税的东西!

    但是写这篇文章的人是这样做的:

    char screen[24][80];
    

    并使用二维数组偏移量来滚动字符,如下所示:

    int i, j;
    for (i = 0; i < 23; i++)
      for (j = 0; j < 80; j++)
        screen[i][j] = screen[i+1][j];
    

    这样的六个窗口让机器瘫痪了!

    为什么?因为当时的编译器很笨,所以在机器语言中,内循环赋值的每个实例screen[i][j] = screen[i+1][j] 看起来有点像这样(Ax 和 Dx 是 CPU 寄存器);

    Fetch the base address of screen from memory into the A1 register
    Fetch i from stack memory into the D1 register
    Multiply D1 by a constant 80
    Fetch j from stack memory and add it to D1
    Add D1 to A1
    Fetch the base address of screen from memory into the A2 register
    Fetch i from stack memory into the D1 register
    Add 1 to D1
    Multiply D1 by a constant 80
    Fetch j from stack memory and add it to D1
    Add D1 to A2
    Fetch the value from the memory address pointed to by A2 into D1
    Store the value in D1 into the memory address pointed to by A1
    

    因此,我们为 23x80=1840 次内部循环迭代中的每一个讨论 13 条机器语言指令,总共 23920 条指令,包括 3680 条 CPU 密集型整数乘法。

    我们对 C 源代码进行了一些更改,所以它看起来像这样:

    int i, j;
    register char *a, *b;
    for (i = 0; i < 22; i++)
    {
      a = screen[i];
      b = screen[i+1];
      for (j = 0; j < 80; j++)
        *a++ = *b++;
    }
    

    还有两个机器语言乘法,但是它们在外循环中,所以只有 46 个整数乘法而不是 3680。而内循环 *a++ = *b++ 语句只包含两个机器语言操作。

    Fetch the value from the memory address pointed to by A2 into D1, and post-increment A2
    Store the value in D1 into the memory address pointed to by A1, and post-increment A1.
    

    鉴于有 1840 次内部循环迭代,总共有 3680 条 CPU 廉价指令 - 减少了 6.5 倍 - 并且没有整数乘法。在此之后,我们没有在六个电传打字机窗口中死去,而是无法拉起足够的速度来使机器陷入困境——我们首先用完了电传打字机数据源。还有一些方法可以进一步优化这一点。

    现在,现代编译器将为您进行这种优化 - IF 您要求他们这样做,并且 IF 您的代码的结构允许它.

    但仍然存在编译器无法为您执行此操作的情况 - 例如,如果您在数组中执行非顺序操作。

    所以我发现尽可能使用指针而不是数组引用对我很有帮助。性能肯定不会更差,而且通常会好得多。

    【讨论】:

    • 等待分析器告诉您循环是减速发生的位置不是更好吗?
    • @Steve Rowe:分析器并不总是可用的。 1987 年的 Mac 上还没有,现在 ARM 处理器上的 Linux 也没有什么好的。此外,熟练掌握指针确实是从 C/C++ 初学者到大师之路的第一步。如果你在面试的时候不会写基于指针的代码,硅谷的很多地方都不会雇佣你。如果您看不懂,您将在包括 Linux 和 Mac OS 内核、X 服务器和许多设备驱动程序在内的代码中无可救药地迷失方向。
    • @Steve Rowe:另外,我可以根据经验说:任何时候,如果循环嵌套两个或更深,最里面的代码可能至少会成为潜在的性能瓶颈。
    【解决方案3】:

    使用现代编译器,两者之间的性能应该没有任何差异,尤其是在这些简单易识别的示例中。此外,即使编译器不承认它们的等价性,即“按字面意思”翻译每个代码,在典型的现代硬件平台上仍然不应该有任何明显的性能差异。 (当然,可能有更专业的平台,差异可能很明显。)

    至于其他考虑...从概念上讲,当您使用索引访问实现算法时,您对底层数据结构施加了随机访问要求。当您使用指针(“迭代器”)访问时,您只需对底层数据结构施加顺序访问要求。随机访问是比顺序访问更强的要求。出于这个原因,一方面,我在我的代码中更喜欢尽可能坚持指针访问,并且仅在必要时使用索引访问。

    更一般地说,如果一个算法可以通过顺序访问有效地实现,那么最好这样做,而不涉及随机访问的不必要的更强要求。如果需要重构代码或更改算法,这可能在未来证明是有用的。

    【讨论】:

      【解决方案4】:

      它们几乎相同。这两种解决方案都涉及一个临时变量、系统上一个单词的增量(int 或 ptr),以及一个应该采用一条汇编指令的逻辑检查。

      我看到的唯一区别是数组查找

      arr[idx]

      可能需要指针算术,然后在取消引用时进行提取:

      *ptr

      只需要一次获取

      我的建议是,如果真的很重要,请同时实施,看看是否有任何节省。

      【讨论】:

        【解决方案5】:

        可以肯定的是,您必须在预期的目标环境中进行概要分析。

        也就是说,我的猜测是任何现代编译器都会将它们优化为非常相似(如果不相同)的代码。

        如果您没有优化器,第二个可能会更快,因为您不会在每次迭代时重新计算指针。但除非 Size 是一个非常大的数字(或者该例程被经常调用),否则差异对程序的整体执行速度无关紧要。

        【讨论】:

        • 英特尔有直接对指针进行偏移的指令,因此您不会显式地重新计算指针。我已经看到第一个循环运行得更快。
        【解决方案6】:

        以前的指针操作要快得多。现在速度快了一点,不过编译器可能会为你优化

        从历史上看,通过*p++ 迭代比p[i] 快得多;这是在语言中使用指针的部分动机。

        另外,p[i] 通常需要较慢的乘法运算或至少需要移位,因此在循环中将乘法替换为与指针相加的优化对于具有特定名称非常重要:强度降低时间>。下标也倾向于产生更大的代码。

        但是,有两件事发生了变化:一是编译器更加复杂,通常能够为您进行这种优化。

        另一个是操作和内存访问之间的相对差异增加了。当*p++ 被发明时,内存和cpu 操作时间是相似的。今天,一台随机台式机每秒可以进行 30 亿次整数运算,但随机 DRAM 读取次数只有大约 10 或 2000 万次。缓存访问速度更快,并且系统会在您单步执行数组时预取和流式处理顺序内存访问,但访问内存仍然需要大量成本,而且一点下标摆弄也没什么大不了的。

        【讨论】:

          【解决方案7】:

          几年前我问过这个确切的问题。面试中的某个人在选择数组符号时未能通过候选人,因为据说它显然更慢。那时我编译了两个版本并查看了反汇编。数组表示法中有一个额外的操作码。这是使用 Visual C++(.net?)。根据我所看到的,我得出的结论是没有明显差异。

          再次这样做,这是我发现的:

              iterate1(arr, 400); // array notation
          011C1027  mov         edi,dword ptr [__imp__printf (11C20A0h)] 
          011C102D  add         esp,0Ch 
          011C1030  xor         esi,esi 
          011C1032  movsx       ecx,byte ptr [esp+esi+8] <-- Loop starts here
          011C1037  push        ecx  
          011C1038  push        offset string "%c" (11C20F4h) 
          011C103D  call        edi  
          011C103F  inc         esi  
          011C1040  add         esp,8 
          011C1043  cmp         esi,190h 
          011C1049  jl          main+32h (11C1032h) 
          
              iterate2(arr, 400); // pointer offset notation
          011C104B  lea         esi,[esp+8] 
          011C104F  nop              
          011C1050  movsx       edx,byte ptr [esi] <-- Loop starts here
          011C1053  push        edx  
          011C1054  push        offset string "%c" (11C20F4h) 
          011C1059  call        edi  
          011C105B  inc         esi  
          011C105C  lea         eax,[esp+1A0h] 
          011C1063  add         esp,8 
          011C1066  cmp         esi,eax 
          011C1068  jne         main+50h (11C1050h) 
          

          【讨论】:

          • @John,这也是我的想法。如果我更喜欢一个,那将是更清晰的代码。做任何其他事情都是过早的优化。
          • @JohnMcG:我会让一个认为使用“滑动指针”技术的唯一原因是微优化的候选人失败。它不是。还有其他令人信服的理由更喜欢指针而不是索引。
          • @Doug,现在这可能是一个严重的问题。
          【解决方案8】:

          您为什么不尝试两者并计时呢?我的猜测是编译器将它们优化为基本相同的代码。请记住在比较时打开优化 (-O3)。

          【讨论】:

          • 如果性能对您来说真的很重要,请测试您正在考虑的代码。有太多变数不能依靠互联网上陌生人的建议。
          【解决方案9】:

          在“其他注意事项”一栏中,我想说方法一更清楚。不过这只是我的看法。

          【讨论】:

            【解决方案10】:

            你问错问题了。 Should a developer aim for readability or performance first?

            第一个版本是处理数组的惯用语,任何使用过数组的人都会清楚你的意图,而第二个版本严重依赖数组名称和指针之间的等价性,迫使阅读代码的人切换隐喻数次。

            提示 cmets 说第二个版本对于任何值得他的键盘的开发者来说都是非常清晰的。

            如果你编写了你的​​程序,并且它运行缓慢,并且你已经将这个循环识别为瓶颈,那么打开引擎盖看看是有意义的其中哪个更快。但是首先使用众所周知的惯用语言结构来搞清楚并运行一些东西。

            【讨论】:

            • 这些天来,我的经验法则是,如果人类更容易理解,那么编译器也更容易理解。
            【解决方案11】:

            抛开性能问题不谈,while 循环变体有潜在的可维护性问题让我感到震惊,因为程序员要添加一些新的花里胡哨,必须记住将数组增量放在正确的位置,而 for 循环变体将其安全地置于循环体之外。

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 2013-04-16
              • 2013-07-12
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              相关资源
              最近更新 更多