【问题标题】:How come my array index is faster than pointer为什么我的数组索引比指针快
【发布时间】:2011-12-30 00:49:58
【问题描述】:

为什么数组索引比指针快? 指针不应该比数组索引快吗?

** 我用 time.h clock_t 测试了两个函数,每个循环 200 万次。

Pointer time : 0.018995

Index time : 0.017864

void myPointer(int a[], int size)
{
     int *p;
     for(p = a; p < &a[size]; p++)
     {
         *p = 0;
     }
}


void myIndex(int a[], int size)
{
     int i;
     for(i = 0; i < size; i++)
     {
         a[i] = 0;
     }
}

【问题讨论】:

  • 我希望指针版本能做到int * end = a + size; for(p=a; a&lt;end; p++)
  • 这些时间看起来非常接近,很容易受到操作系统多线程等的影响。尝试将它们中的每一个运行 2 亿次甚至 10 亿次以减少系统错误。
  • 轻度重新标记,直接取自“计算机体系结构和设计”(Patterson/henrssy)。您的答案(以下除外)在示例下方的“详细说明”中:..
  • C 很有趣,但程序集才是最重要的。在下面的 VC 2008 示例中,编译器使用 REP STOSB 渲染循环,有效地使用汇编指令(在寄存器中带有参数)来覆盖内存。也就是说,编译器并没有按照你的要求做,所以问这两个实现有什么不同是没有意义的。
  • 您应该使用特定于平台的功能来更准确地测量时间。例如在 Windows 上 QueryPerformanceCounter.

标签: c arrays pointers indexing


【解决方案1】:

不,指针永远不会比数组索引快。如果其中一个代码比另一个更快,主要是因为某些地址计算可能不同。该问题还应提供编译器和优化标志的信息,因为它会严重影响性能。

上下文中的数组索引(数组边界未知)与指针操作完全相同。从编译器的角度来看,它只是指针运算的不同表达。下面是一个在 Visual Studio 2010 中优化的 x86 代码示例,该代码具有完全优化无内联

     3: void myPointer(int a[], int size)
     4: {
013E1800  push        edi  
013E1801  mov         edi,ecx  
     5:      int *p;
     6:      for(p = a; p < &a[size]; p++)
013E1803  lea         ecx,[edi+eax*4]  
013E1806  cmp         edi,ecx  
013E1808  jae         myPointer+15h (13E1815h)  
013E180A  sub         ecx,edi  
013E180C  dec         ecx  
013E180D  shr         ecx,2  
013E1810  inc         ecx  
013E1811  xor         eax,eax  
013E1813  rep stos    dword ptr es:[edi]  
013E1815  pop         edi  
     7:      {
     8:          *p = 0;
     9:      }
    10: }
013E1816  ret 

    13: void myIndex(int a[], int size)
    14: {
    15:      int i;
    16:      for(i = 0; i < size; i++)
013E17F0  test        ecx,ecx  
013E17F2  jle         myIndex+0Ch (13E17FCh)  
013E17F4  push        edi  
013E17F5  xor         eax,eax  
013E17F7  mov         edi,edx  
013E17F9  rep stos    dword ptr es:[edi]  
013E17FB  pop         edi  
    17:      {
    18:          a[i] = 0;
    19:      }
    20: }
013E17FC  ret 

乍一看,myIndex 看起来更快,因为指令的数量更少,但是,两段代码本质上是相同的。两者最终都使用rep stos,这是x86 的重复(循环)指令。唯一的区别在于循环边界的计算。 myIndex 中的 for 循环按原样具有行程计数 size(即,不需要计算)。但是,myPointer 需要一些计算才能获得for 循环的行程计数。这是唯一的区别。重要的循环操作是一样的。因此,差异可以忽略不计。

总而言之,myPointermyIndex 在优化代码中的性能应该是相同的。


仅供参考,如果数组的边界在编译时已知,例如,int A[constant_expression],那么对该数组的访问可能比指针快得多。这主要是因为数组访问不受pointer analysis 问题的影响。编译器可以完美地计算固定大小数组上的计算和访问的依赖信息,因此它可以进行高级优化,包括自动并行化。

但是,如果计算是基于指针的,编译器必须执行指针分析以进一步优化,这在 C/C++ 中非常有限。它通常以指针分析的保守结果告终,并带来一些优化机会。

【讨论】:

  • 如果不使用 for 循环,而是使用索引数组(相对于指针数组),索引是否会更快?像这样的东西:arr[indices[i]]; vs pointers_to_arr[i];
【解决方案2】:

可能是 for 循环中的比较导致了差异。每次迭代都会测试终止条件,并且您的“指针”示例具有稍微复杂的终止条件(取 &a[size] 的地址)。由于 &a[size] 不会改变,您可以尝试将其设置为变量以避免在循环的每次迭代中重新计算它。

【讨论】:

  • 谢谢!它确实使指针更快,但仍然比数组索引慢一点。
  • 编译优化器可能会临时创建一个值为a + (size * (sizeof *a))的寄存器。
【解决方案3】:

数组取消引用p[i]*(p + i)。编译器使用在 1 或 2 个周期内执行数学运算 + 取消引用的指令(例如 x86 LEA 指令)来优化速度。

使用指针循环,它将访问和偏移量分成单独的部分,编译器无法对其进行优化。

【讨论】:

  • 谢谢!所以在这种情况下,指针总是更慢?因为编译器无法优化它。
  • 我正在查看 gcc -O3 的汇编器输出,以了解两者之间的唯一区别是 dereference-with-index 与 plain-old-dereference 的区别。
  • 我会这样说:编译器优化比索引解引用和普通解引用产生更大的速度差异。
  • 我正在用 O0、O1、O2、O3 的示例对 100 万个元素的数组进行计时循环,重复 100 万次。我需要几个小时才能得到一些数字。
  • 尝试在指针一中进行以下修改:引入一个临时变量int *p_end = a + size,然后与p_end进行比较以隔离指针与索引。我运行 1000 次 long[1000000] 的时间如下: gcc -O0 P: 4420000 I: 5460000; gcc -O1 P:2260000 I:2250000; gcc -O2 P:2300000 I:2290000; gcc -O3 P: 2280000 I: 2290000。很明显,最后没有真正的区别。您的开销在 &amp;a[size] 计算中。
【解决方案4】:

我建议每个循环运行 2 亿次,然后每个循环运行 10 次,并以最快的速度测量。这将排除操作系统调度等的影响。

然后我建议你反汇编每个循环的代码。

【讨论】:

    【解决方案5】:

    糟糕,在我的 64 位系统上结果完全不同。我有这个

     int i;
    
     for(i = 0; i < size; i++)
     {
         *(a+i) = 0;
     }
    

    大约是 100 倍!比这个慢

     int i;
     int * p = a;
    
     for(i = 0; i < size; i++)
     {
         *(p++) = 0;
     }
    

    使用-O3 编译时。这向我暗示,对于 64 位 cpu,以某种方式移动到下一个地址比从某个偏移量计算目标地址要容易得多。但我不确定。

    编辑:
    这确实与 64 位体系结构有关,因为具有相同编译标志的相同代码在 32 位系统上没有表现出任何真正的性能差异。

    【讨论】:

    • 可能在第二种情况下,编译器已经识别了模式并使用 SSE/MMX 指令并行化了循环。此类指令在 64 位架构上始终可用,而在 32 位架构上,编译器无法假定它们的可用性(除非您在编译时提供一些明确的提示)。
    • 如果相差100倍,你的代码显然是错误的。最慢的部分是内存访问,无论循环做什么都是一样的。 (你做了一个,然后可能是另一个?)我会与 std::fill 进行比较,看看哪个是“奇数”
    【解决方案6】:

    编译器优化是模式匹配。

    当您的编译器进行优化时,它会查找已知的代码模式,然后根据某些规则转换代码。您的两个代码 sn-ps 似乎触发了不同的转换,因此产生的代码略有不同。

    这就是我们在优化方面始终坚持实际测量结果性能的原因之一:除非您对其进行测试,否则您永远无法确定编译器会将您的代码变成什么。


    如果您真的很好奇,请尝试使用gcc -S -Os 编译您的代码,这会产生最易读且经过优化的汇编代码。在你的两个函数上,我得到了以下汇编程序:

    pointer code:
    .L2:
        cmpq    %rax, %rdi
        jnb .L5
        movl    $0, (%rdi)
        addq    $4, %rdi
        jmp .L2
    .L5:
    
    index code:
    .L7:
        cmpl    %eax, %esi
        jle .L9
        movl    $0, (%rdi,%rax,4)
        incq    %rax
        jmp .L7
    .L9:
    

    差异很小,但确实可能引发性能差异,最重要的是,使用 addqincq 之间的差异可能很大。

    【讨论】:

      【解决方案7】:

      时间如此接近,如果你反复这样做,你可能看不出有太大的不同。两个代码段都编译为 exact 相同的程序集。根据定义,没有区别。

      【讨论】:

        【解决方案8】:

        看起来索引解决方案可以在for循环中通过比较节省一些指令。

        【讨论】:

        • 这是一个调试版本吗?带有优化的发布版本的输出有很大的不同。
        • 是的,我刚刚意识到它忘记获取发布文件...@minjang 已经发布了相同的程序集,所以我将删除调试版本
        【解决方案9】:

        通过数组索引或指针访问数据是完全等价的。和我一起完成下面的程序...

        有一个循环持续 100 次,但是当我们看到反汇编代码时,我们访问的数据与通过数组索引访问的指令可比性最小

        但这并不意味着通过指针访问数据很快实际上它取决于编译器执行的指令。指针和数组索引都使用地址数组从偏移量访问值并通过它递增并且指针具有地址.

        int a[100];
        fun1(a,100);
        fun2(&a[0],5);
        }
        void fun1(int a[],int n)
        {
        int i;
        for(i=0;i<=99;i++)
        {
        a[i]=0;
        printf("%d\n",a[i]);
        }
        }
        void fun2(int *p,int n)
        {
        int i;
        for(i=0;i<=99;i++)
        {
        *p=0;
        printf("%d\n",*(p+i));
        }
        }
        
        
        disass fun1
        Dump of assembler code for function fun1:
           0x0804841a <+0>: push   %ebp
           0x0804841b <+1>: mov    %esp,%ebp
           0x0804841d <+3>: sub    $0x28,%esp`enter code here`
           0x08048420 <+6>: movl   $0x0,-0xc(%ebp)
           0x08048427 <+13>:    jmp    0x8048458 <fun1+62>
           0x08048429 <+15>:    mov    -0xc(%ebp),%eax
           0x0804842c <+18>:    shl    $0x2,%eax
           0x0804842f <+21>:    add    0x8(%ebp),%eax
           0x08048432 <+24>:    movl   $0x0,(%eax)
           0x08048438 <+30>:    mov    -0xc(%ebp),%eax
           0x0804843b <+33>:    shl    $0x2,%eax
           0x0804843e <+36>:    add    0x8(%ebp),%eax
           0x08048441 <+39>:    mov    (%eax),%edx
           0x08048443 <+41>:    mov    $0x8048570,%eax
           0x08048448 <+46>:    mov    %edx,0x4(%esp)
           0x0804844c <+50>:    mov    %eax,(%esp)
           0x0804844f <+53>:    call   0x8048300 <printf@plt>
           0x08048454 <+58>:    addl   $0x1,-0xc(%ebp)
           0x08048458 <+62>:    cmpl   $0x63,-0xc(%ebp)
           0x0804845c <+66>:    jle    0x8048429 <fun1+15>
           0x0804845e <+68>:    leave  
           0x0804845f <+69>:    ret    
        End of assembler dump.
        (gdb) disass fun2
        Dump of assembler code for function fun2:
           0x08048460 <+0>: push   %ebp
           0x08048461 <+1>: mov    %esp,%ebp
           0x08048463 <+3>: sub    $0x28,%esp
           0x08048466 <+6>: movl   $0x0,-0xc(%ebp)
           0x0804846d <+13>:    jmp    0x8048498 <fun2+56>
           0x0804846f <+15>:    mov    0x8(%ebp),%eax
           0x08048472 <+18>:    movl   $0x0,(%eax)
           0x08048478 <+24>:    mov    -0xc(%ebp),%eax
           0x0804847b <+27>:    shl    $0x2,%eax
           0x0804847e <+30>:    add    0x8(%ebp),%eax
           0x08048481 <+33>:    mov    (%eax),%edx
           0x08048483 <+35>:    mov    $0x8048570,%eax
           0x08048488 <+40>:    mov    %edx,0x4(%esp)
           0x0804848c <+44>:    mov    %eax,(%esp)
           0x0804848f <+47>:    call   0x8048300 <printf@plt>
           0x08048494 <+52>:    addl   $0x1,-0xc(%ebp)
           0x08048498 <+56>:    cmpl   $0x63,-0xc(%ebp)
           0x0804849c <+60>:    jle    0x804846f <fun2+15>
           0x0804849e <+62>:    leave  
           0x0804849f <+63>:    ret    
        End of assembler dump.
        (gdb) 
        

        【讨论】:

        • 你的例子有缺陷。编译器可以看到循环的大小,并理解它在做什么,这就是为什么两个示例是相同的。如果你的循环足够小,编译器甚至可能完全删除循环(谷歌循环展开)。要测试速度,您需要隐藏大小(就像原始问题一样)
        【解决方案10】:

        这是一件非常困难的事情,因为编译器非常擅长优化这些事情。仍然最好为编译器提供尽可能多的信息,这就是为什么在这种情况下我建议使用 std::fill,并让编译器选择。

        但是……如果你想了解细节

        a) CPU 通常会免费提供指针+值,例如:mov r1, r2(r3)。
        b) 这意味着索引操作只需要:mul r3,r1,size
        这只是每个循环额外的一个循环。
        c) CPU 通常会提供停顿/延迟槽,这意味着您通常可以隐藏单周期操作。

        总而言之,即使您的循环非常大,与即使是几次缓存未命中的成本相比,访问成本也微不足道。最好在关心循环成本之前优化您的结构。例如,尝试packing your structures 首先减少内存占用

        【讨论】:

          猜你喜欢
          • 1970-01-01
          • 1970-01-01
          • 2023-03-08
          • 1970-01-01
          • 1970-01-01
          • 2011-12-08
          • 1970-01-01
          • 1970-01-01
          • 2013-05-17
          相关资源
          最近更新 更多