64位平台的效率:指针与32位数组索引

时间:2015-12-17 22:09:11

标签: c++ arrays performance pointers

在他的一个主题演讲中,Andrei Alexandrescu建议,在64位平台上,使用32位数组索引比使用原始指针更快:

第16页:http://www.slideshare.net/andreialexandrescu1/three-optimization-tips-for-c-15708507

在他的Facebook帐户上,他更精确,并说:"喜欢数组索引到指针(这个似乎每十年逆转一次)。"。

我已经尝试了许多方法来找到差异,但我还没有设法构建任何显示这种差异的程序。知道安德烈,我不会感到惊讶,差异不超过百分之几,但如果有人找到这样的例子,我会很高兴。

这是我做过的测试。我选择n = 5000,足够大以获得合适的时序并且足够小以使一切都适合L1缓存。我循环了几次,以便CPU频率上升。

#include <iostream>
#include <chrono>

int main(int argc, const char* argv[]) {
  const int n{5000};
  int* p{new int[n]};

  // Warm up the cache
  for (int i{0}; i < n; i++) {
    p[i] += 1;
  }

  for (int j{0}; j < 5; j++) {
    {
      auto start_pointer = std::chrono::high_resolution_clock::now();
      for (int* q{p}; q != p + n; ++q) {
        ++(*q);
      }
      auto end_pointer = std::chrono::high_resolution_clock::now();
      auto time_pointer = std::chrono::duration_cast<std::chrono::nanoseconds>(
                              end_pointer - start_pointer)
                              .count();
      std::cout << " Pointer: " << time_pointer << std::endl;
    }

    {
      auto start_pointer = std::chrono::high_resolution_clock::now();
      for (int* q{p}; q != p + n; ++q) {
        ++(*q);
      }
      auto end_pointer = std::chrono::high_resolution_clock::now();
      auto time_pointer = std::chrono::duration_cast<std::chrono::nanoseconds>(
                              end_pointer - start_pointer)
                              .count();
      std::cout << " Pointer: " << time_pointer << std::endl;
    }

    {
      auto start_index_32 = std::chrono::high_resolution_clock::now();
      for (int i{0}; i < n; i++) {
        p[i] += 1;
      }
      auto end_index_32 = std::chrono::high_resolution_clock::now();
      auto time_index_32 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_32 - start_index_32)
                               .count();
      std::cout << "Index 32: " << time_index_32 << std::endl;
    }

    {
      auto start_index_32 = std::chrono::high_resolution_clock::now();
      for (int i{0}; i < n; i++) {
        p[i] += 1;
      }
      auto end_index_32 = std::chrono::high_resolution_clock::now();
      auto time_index_32 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_32 - start_index_32)
                               .count();
      std::cout << "Index 32: " << time_index_32 << std::endl;
    }

    {
      const std::size_t n_64{n};
      auto start_index_64 = std::chrono::high_resolution_clock::now();
      for (std::size_t i{0}; i < n_64; i++) {
        p[i] += 1;
      }
      auto end_index_64 = std::chrono::high_resolution_clock::now();
      auto time_index_64 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_64 - start_index_64)
                               .count();
      std::cout << "Index 64: " << time_index_64 << std::endl;
    }

    {
      const std::size_t n_64{n};
      auto start_index_64 = std::chrono::high_resolution_clock::now();
      for (std::size_t i{0}; i < n_64; i++) {
        p[i] += 1;
      }
      auto end_index_64 = std::chrono::high_resolution_clock::now();
      auto time_index_64 = std::chrono::duration_cast<std::chrono::nanoseconds>(
                               end_index_64 - start_index_64)
                               .count();
      std::cout << "Index 64: " << time_index_64 << std::endl;
    }
    std::cout << std::endl;
  }

  delete[] p;

  return 0;
}

这是我在我的机器上获得的结果(核心i7)。我得到的只是&#34;噪音&#34;。

 Pointer: 883
 Pointer: 485
Index 32: 436
Index 32: 380
Index 64: 372
Index 64: 429

 Pointer: 330
 Pointer: 316
Index 32: 336
Index 32: 321
Index 64: 337
Index 64: 318

 Pointer: 311
 Pointer: 314
Index 32: 318
Index 32: 319
Index 64: 316
Index 64: 301

 Pointer: 306
 Pointer: 325
Index 32: 323
Index 32: 313
Index 64: 318
Index 64: 305

 Pointer: 311
 Pointer: 319
Index 32: 313
Index 32: 324
Index 64: 315
Index 64: 303

5 个答案:

答案 0 :(得分:40)

像这样的低级别建议(甚至来自Andrei Alexandrescu)的问题在于它忽略了编译器优化的事实。

现代编译器如此积极地(并且,通常,成功地)进行优化,以便尝试对它们进行二次猜测,从而真正成为一个杯子游戏。总的来说,编写清晰易读的代码将帮助您,您的同事和编译器分析代码。我真的相信这是最好的一般建议。

现代编译器使用的一个众所周知的优化是基于索引和基于指针的循环之间的转换。在您的基准测试的特定情况下,对于大多数优化设置,gcc会将基于指针和基于32位索引的循环编译为相同的汇编器输出。

在下文中,我使用++sentry替换了chrono内容,其中sentryvolatile,以减少代码大小。程序集输出对应于:

for (int* q{p}; q != p + n; ++q) ++(*q);
++sentry;
for (int i{0}; i < n; i++) p[i] += 1;

使用-O2进行编译,产生了以下内容:(%rdi%ebp仍然是从填充p)的循环中初始化的

        movq    %rdi, %rdx
        cmpq    %rcx, %rdi
        je      .L10
.L16:
        addl    $1, (%rdx)
        addq    $4, %rdx
        cmpq    %rcx, %rdx
        jne     .L16
.L10:
        movl    sentry(%rip), %eax
        movq    %rdi, %rdx
        addl    $1, %eax
        movl    %eax, sentry(%rip)
        testl   %ebp, %ebp
        jle     .L8
.L14:
        addl    $1, (%rdx)
        addq    $4, %rdx
        cmpq    %rdx, %rsi
        jne     .L14
.L8:

您可以看到.L16.L14的循环之间没有任何区别。

当然,不同的优化设置会产生不同的结果。使用-O3,使用SIMD指令和Duff的设备对循环进行矢量化,但同样几乎相同。 clang在-O2

进行此优化

这些都没有否定这一点,即编译器可能需要更加努力地证明正在写入的指针不能修改任意内存。

但是在这种情况下,就像在很多情况下一样,循环索引是一个局部变量,循环很简单,编译器可以完全分析它,从而允许强度降低,展开和向量化;控制变量是指针还是索引是不相关的。

一个更有趣的例子(可能)是两个数组的循环,其中基本元素的大小不同。鉴于以下两个功能:

void d2f_ptr(float* out, const double* in, int n) {
  for (auto lim = out + n; out < lim;) *out++ = *in++;
}
void d2f_idx(float out[], const double in[], int n) {
  for (int i = 0; i < n; ++i) out[i] = in[i];
}

gcc(v5.3.0,-O2)确实产生不同的循环,基于索引的循环缩短了一条指令:

d2f_ptr(float*, double const*, int):    d2f_idx(float*, double const*, int):
        movslq  %edx, %rdx                      xorl    %eax, %eax
        leaq    (%rdi,%rdx,4), %rax             testl   %edx, %edx
        cmpq    %rax, %rdi                      jle     .L16
        jnb     .L11
.L15:                                   .L20:
        addq    $4, %rdi                        pxor    %xmm0, %xmm0
        addq    $8, %rsi                        cvtsd2ss (%rsi,%rax,8), %xmm0
        pxor    %xmm0, %xmm0                    movss   %xmm0, (%rdi,%rax,4)
        cvtsd2ss -8(%rsi), %xmm0                addq    $1, %rax
        movss   %xmm0, -4(%rdi)
        cmpq    %rdi, %rax                      cmpl    %eax, %edx
        ja      .L15                            jg      .L20
.L11:                                   .L16:
        ret                                     ret

但是将doublefloat更改为其大小不再允许使用英特尔芯片的索引寻址模式的对象,并且编译器再次将基于索引的代码转换为基于指针的代码变体。

这里的代码基本上和以前一样,但是double被填充到48个字节:

struct Big { double val; char padding[40]; };
struct Small {
  float val;
  Small& operator=(const Big& other) {
    val = other.val;
    return *this;
  }
};

d2f_ptr(Small*, Big const*, int):       d2f_idx(Small*, Big const*, int):
        movslq  %edx, %rdx                      testl   %edx, %edx
        leaq    (%rdi,%rdx,4), %rax             jle     .L26
        cmpq    %rax, %rdi                      leal    -1(%rdx), %eax
        jnb     .L21                            leaq    4(%rdi,%rax,4), %rax
.L25:                                   .L29:
        addq    $48, %rsi                       pxor    %xmm0, %xmm0
        addq    $4, %rdi                        addq    $4, %rdi
        pxor    %xmm0, %xmm0                    cvtsd2ss (%rsi), %xmm0
        cvtsd2ss -48(%rsi), %xmm0               addq    $48, %rsi
        movss   %xmm0, -4(%rdi)                 movss   %xmm0, -4(%rdi)
        cmpq    %rdi, %rax                      cmpq    %rax, %rdi
        ja      .L25                            jne     .L29
.L21:                                   .L26:
        ret                                     ret

可能有必要补充一点,对于编译器来说,分析特定指针写入将修改哪个对象并不一定更困难。 [已编辑:Alexandrescu在这里引用了一句话,但它没有我想象的那么重要,所以我将其删除,留下这部分主要是一个稻草人。]

实际上,如果一个指针只被直接分配给一次,而所有其他修改都是通过递增和递减操作(包括+=-=),那么编译器完全有权使用假设指针始终指向同一对象。如果指针的某些附加修改超出了某些其他对象,那将是Undefined Behavior,编译器可以放弃这种可能性。在流程图中跟踪assign和inc / dec操作很容易,因此在指针可以用索引表达式替换的情况下,编译器很可能想出这个并因此知道其他对象不是通过指针写入随机变异。

答案 1 :(得分:11)

他的(Andrei Alexandrescu)推理似乎是基于以下事实:对于编译器使用寄存器通常更难以编译,因为指针可能指向全局数据。但我没有看到任何特定于32位数组索引的内容(对于我的阅读,幻灯片不是很清楚,如果他实际上指的是32位数组或数组索引32位系统)

From the horse's mouth:(是的,这是他的Facebook帐户的链接:)

  

最小化数组写入

     

为了加快速度,代码应该减少数组写入次数等等   通常,通过指针写。

     

在具有大寄存器文件和充足寄存器的现代机器上   重命名硬件,您可以假设大多数命名的个别变量   (数字,指针)最终坐在寄存器中。经营   寄存器速度很快,并且充当了硬件设置的优势。   即使数据依赖 - 指令级别的主要敌人   并行性 - 发挥作用,CPU有专门的硬件   管理各种依赖模式。使用寄存器操作(即   命名变量)打赌房子。做吧。

     

相反,数组操作(和一般间接访问)较少   整个编译器 - 处理器 - 缓存层次结构中的自然。除了   一些明显的模式,数组访问没有注册。也,   无论何时涉及指针,编译器都必须采用指针   可以指向全局数据,这意味着任何函数调用都可能发生变化   任意指向数据。对于数组操作,数组写入是   最糟糕的一包。鉴于所有带内存的流量都是在   缓存行粒度,将一个字写入内存本质上是一个   缓存行读取后跟缓存行写入。所以考虑到了   不管怎样,阵列读取都是不可避免的,这条建议   归结为“尽可能避免数组写入。

他似乎也建议,这是一般的建议,而不是始终更快地使用数组索引(来自同一篇文章):

  

为快速代码做一些好的,但鲜为人知的事情:

     

首选静态链接和位置相关代码(与PIC相反,与位置无关的代码)   首选64位代码和32位数据   首选数组索引到指针(这个似乎每十个反转一次   年)。
  喜欢常规的内存访问模式。最大限度地减少控制流量   避免数据依赖。

答案 2 :(得分:7)

我给Andrei Alexandrescu发了一封电子邮件,他很友善地回复。这是他的解释:

“为了使加速可见,您需要利用ALU在一个周期内运行两个32位操作或一个64位操作的能力。并非每个基准测试都会显示加速。”

我理解为“SIMD指令使用32位数据每周期处理的数据多于64位数据”。我还没有找到一个基准(它不包含任何整数数组),它会产生影响。但我认为这将很难。安德烈过去常常在Facebook工作,每一个百分比都值得。

答案 3 :(得分:3)

不完全是答案,但评论过于复杂:

您的测试是指针算法与数组索引的非常有限的测试;在简单的情况下,优化出现每个编译器都值得它的盐将为两者生成相同的程序集。如果没有使用索引变量,编译器可以完全自由地切换到程序集中的指针运算,并且如果它选择的话,它同样能够将指针运算切换回数组访问。

我能想出的最好的例子来自几年前(从编译器到编译器,从架构到架构等,可能并不一致)。我正在玩(为了学习目的)两个版本的代码基本上等于数组复制操作:

for (unsigned i = 0; i < copycnt; ++i) {
    x[i] = y[i];
}

VS

while (copycnt--) {
    *x++ = *y++;
}

我猜测有一些复杂的因素(或者编译器优化已经改变了,因为我上次在高优化时测试了这样的东西,它编译成同一个程序集),但即使编译器可以简单地转换第一个大小写到第二个(理论上保存寄存器,避免偏移加载和存储指令,支持直接加载和存储指令,使用testl表示0而不是cmpl两个值,对于小添加一个减量指令的成本),编译器选择编译大致近似的代码:

const ptrdiff_t diff = y - x;
decltype(*x) *const end = x + copycnt;
while (x < end) {
    *x = *(x + diff);
    ++x;
}

这可能优于&#34;正常&#34;如果计算循环中所需寄存器的数量,循环中的指令数量(假设在固定寄存器偏移处的加载是在x86机器上的组合指令,而不是add然后直接加载),则代码的版本等等,并且编译器肯定这么认为,因为它选择了这个版本而不是简单的指针算法(任何编译器都可以在这里做)。

但是当我编译简单的指针算术代码时,编译器无法找出关系(可能是由于这个简化版本中没有的某些复杂因素;我既不知道x,{{1或者y再次被使用,因此它不是保留原始值的问题),并且或多或少地编译为我给它的两个指针,独立递增。

我的理论是,索引的使用给了我正在做的事情的编译器上下文;它不是两个不相关的指针,它是两个&#34;数组&#34;通过公共索引访问,它知道如何改进该模式的编译代码。指针算术是&#34;做我说的&#34;没有给出&#34;我想做什么&#34;并且编译器无法弄清楚它,因此它没有对它进行优化。

无论如何,显然只是轶事,但我认为这个例子代表了更复杂的可能性;数组索引为编译器提供了有关&#34;更高逻辑的更多信息。您正在做什么,指针算术说明要做什么,但不知道为什么要做,所以编译器有更难的优化时间,这可以解释建议。希望它有所帮助。

答案 4 :(得分:-1)

此类优化仅适用于金属级别,您应该忽略它们。我会更专注于其他实际上将 noise 引入测试的事情。

<强> [问题]

  1. 指针赋值使用++(*q)完成,而对于整数类型,它更快地执行(*q)++,但无论如何,您应该与int32 / int64测试保持一致并执行*q+=1
  2. 指针测试每次在循环中计算结束指针,你应该在int * ep{p+n}执行一次并检查它。
  3. 在整数测试中,您不应使用<,而应使用!=进行终止评估。
  4. 仅运行五次将无法提供足够的原理图。
  5. 您必须设置cpu affinity
  6. 您应该制作更多循环并仅考虑最后一个
  7. 您应该有一个系统,其中编译器已编译为金属
  8. 你不应该“按照你的方式预热缓存”
  9. 您应该随机输入您的输入。
  10. 我更改了您的代码,您可以从here检索它。

    您应该编译:

    g++ -O3 -march=native --std=c++11 -o intvsptr

    并使用

    启动

    taskset 0x00000001 ./intvsptr

    然后你应该得到一致的结果。

      

    指针:4396指针:4397指数32:4395指数32:4394指数64:   4394指数64:4395

         

    指针:4395指针:4397指数32:4397指数32:4395指数64:   4393指数64:4396

         

    指针:4395指针:4397指数32:4396指数32:4394指数64:   4394指数64:4396

         

    指针:4396指针:4397指数32:4397指数32:4395指数64:   4394指数64:4395

         

    指针:4395指针:7698指数32:4471指数32:4422指数64:   4425指数64:4407

         

    指针:4399指针:4416指数32:4394指数32:4393指数64:   4399指数64:4412

    此测试的精确度应该是最后的数字,但通常应该进行广泛的统计分析。

    我已经粘贴了一次执行的最后几次运行,但是已经完成了多次,我认为只要这个微基准标记允许指针算法更快或者在最坏的情况下,这是安全的em>慢一点。

    无论如何,你可以忽略这种类型的提示,这些提示可能在很久以前就已经很重要了,但目前的编译器却没有。