添加打印语句可将代码加速一个数量级

时间:2017-02-21 03:38:56

标签: c++ performance

我在一篇C / C ++代码中遇到了一段非常离奇的表演行为,如标题所示,我不知道如何解释。

这是一个尽可能接近我发现的最小工作示例[编辑:见下面的更短的一个]:

#include <stdio.h>
#include <stdlib.h>
#include <complex>

using namespace std;

const int pp = 29;
typedef complex<double> cdbl;

int main() {
  cdbl ff[pp], gg[pp];
  for(int ii = 0; ii < pp; ii++) {
    ff[ii] = gg[ii] = 1.0;
  }

  for(int it = 0; it < 1000; it++) {
    cdbl dual[pp];

    for(int ii = 0; ii < pp; ii++) {
      dual[ii] = 0.0;
    }

    for(int h1 = 0; h1 < pp; h1 ++) {
      for(int h2 = 0; h2 < pp; h2 ++) {
        cdbl avg_right = 0.0;
        for(int xx = 0; xx < pp; xx ++) {
          int c00 = xx, c01 = (xx + h1) % pp, c10 = (xx + h2) % pp, 
              c11 = (xx + h1 + h2) % pp;
          avg_right += ff[c00] * conj(ff[c01]) * conj(ff[c10]) * gg[c11];
        }
        avg_right /= static_cast<cdbl>(pp);

        for(int xx = 0; xx < pp; xx ++) {
          int c01 = (xx + h1) % pp, c10 = (xx + h2) % pp, 
              c11 = (xx + h1 + h2) % pp;
          dual[xx] += conj(ff[c01]) * conj(ff[c10]) * ff[c11] * conj(avg_right);
        }
      }
    }
    for(int ii = 0; ii < pp; ii++) {
      dual[ii] = conj(dual[ii]) / static_cast<double>(pp*pp);
    }

    for(int ii = 0; ii < pp; ii++) {
      gg[ii] = dual[ii];
    }

#ifdef I_WANT_THIS_TO_RUN_REALLY_FAST
    printf("%.15lf\n", gg[0].real());
#else // I_WANT_THIS_TO_RUN_REALLY_SLOWLY
#endif

  }
  printf("%.15lf\n", gg[0].real());

  return 0;
}

以下是在我的系统上运行此操作的结果:

me@mine $ g++ -o test.elf test.cc -Wall -Wextra -O2
me@mine $ time ./test.elf > /dev/null
    real    0m7.329s
    user    0m7.328s
    sys     0m0.000s
me@mine $ g++ -o test.elf test.cc -Wall -Wextra -O2 -DI_WANT_THIS_TO_RUN_REALLY_FAST
me@mine $ time ./test.elf > /dev/null
    real    0m0.492s
    user    0m0.490s
    sys     0m0.001s
me@mine $ g++ --version
g++ (Gentoo 4.9.4 p1.0, pie-0.6.4) 4.9.4 [snip]

这个代码计算的并不是非常重要:它只是对长度为29的数组进行了一系列复杂的算术运算。它已经简化了&#34;&#34;简化&#34;来自我关心的更大吨的复杂算术。

所以,行为似乎就像标题中所声称的那样:如果我把这个print语句放回去,代码会变得更快。

我玩了一下:例如,打印一个常量字符串并不能提高速度,但打印时钟时间确实如此。这是一个非常明确的门槛:代码快或慢。

我认为一些奇怪的编译器优化可能会或不会启动,可能取决于代码是否有副作用。但是,如果是这样的话它非常微妙:当我查看反汇编的二进制文件时,它们看起来完全相同,除了一个有额外的打印语句并且它们使用不同的可互换寄存器。我可能(必须?)错过了重要的事情。

我完全无法解释地球可能造成什么。更糟糕的是,它确实影响了我的生活,因为我经常运行相关的代码,并且插入额外的打印语句并不是一个好的解决方案。

任何合理的理论都会受到欢迎。按照&#34;您的计算机损坏的方式回应&#34;如果你可以解释它可以解释什么,那是可以接受的。

更新:对于问题越来越长的道歉,我已将示例缩小为

#include <stdio.h>
#include <stdlib.h>
#include <complex>

using namespace std;

const int pp = 29;
typedef complex<double> cdbl;

int main() {
  cdbl ff[pp];
  cdbl blah = 0.0;
  for(int ii = 0; ii < pp; ii++) {
    ff[ii] = 1.0;
  }

  for(int it = 0; it < 1000; it++) {
    cdbl xx = 0.0;

    for(int kk = 0; kk < 100; kk++) {
      for(int ii = 0; ii < pp; ii++) {
        for(int jj = 0; jj < pp; jj++) {
          xx += conj(ff[ii]) * conj(ff[jj]) * ff[ii];
        }
      }
    }
    blah += xx;

    printf("%.15lf\n", blah.real());
  }
  printf("%.15lf\n", blah.real());

  return 0;
}

我可以让它更小但是机器代码已经可以管理了。如果我将对应于第一个printf的callq指令的二进制的五个字节更改为0x90,则执行从快到慢。

编译的代码非常繁重,函数调用__muldc3()。我觉得必须要考虑Broadwell架构如何处理或不能很好地处理这些跳转:两个版本都运行相同数量的指令,因此它在指令/周期中有所不同(约0.16对比约2.8)

另外,编译-static会让事情再次变快。

进一步无耻的更新:我意识到我是唯一可以玩这个的人,所以这里有更多的观察:

似乎调用任何库函数 - 包括我编写的一些愚蠢的函数 - 对于第一次,将执行置于慢速状态。随后对printf,fprintf或sprintf的调用以某种方式清除状态并且执行再次快速。所以,重要的是第一次调用__muldc3()时我们进入慢速状态,下一个{,f,s} printf重置所有内容。

一旦调用了库函数,并且状态已经重置,该函数就会自由,您可以在不改变状态的情况下随意使用它。

所以,例如:

#include <stdio.h>
#include <stdlib.h>
#include <complex>

using namespace std;

int main() {
  complex<double> foo = 0.0;
  foo += foo * foo; // 1
  char str[10];
  sprintf(str, "%c\n", 'c');
  //fflush(stdout); // 2

  for(int it = 0; it < 100000000; it++) {
    foo += foo * foo;
  }

  return (foo.real() > 10.0);
}

很快,但是注释第1行或取消注释第2行会使它再次变慢。

第一次运行图书馆通话时,必须有相关的&#34;蹦床&#34;在PLT中初始化为指向共享库。因此,也许某种程度上这种动态加载代码会将处理器前端留在一个不好的地方,直到它被救出&#34;。

1 个答案:

答案 0 :(得分:2)

为了记录,我终于弄明白了。

事实证明这与AVX-SSE过渡处罚有关。引用this exposition from Intel

  

使用英特尔®AVX指令时,重要的是要知道将256位英特尔®AVX指令与传统(非VEX编码)英特尔®SSE指令混合可能会导致可能影响性能的处罚。 256位英特尔®AVX指令在256位YMM寄存器上运行,这些寄存器是现有128位XMM寄存器的256位扩展。 128位英特尔®AVX指令在YMM寄存器的低128位操作,高128位为零。但是,传统的英特尔®SSE指令在XMM寄存器上运行,并且不知道YMM寄存器的高128位。因此,当从256位英特尔®AVX转换为传统英特尔®SSE时,硬件会保存YMM寄存器的高128位内容,然后在从英特尔®SSE转换回英特尔®AVX时恢复这些值( 256位或128位)。保存和恢复操作都会导致每次操作损失数十个时钟周期。

上面我的主循环的编译版本包括传统的SSE指令(我认为是movapd和朋友),而libgcc_s中__muldc3的实现使用了许多花哨的AVX指令({{1 },vmovapd等。

这是经济放缓的最终原因。 实际上,英特尔性能诊断显示,每次调用“__muldc3”(在上面发布的代码的最后一个版本中)每次都会发生这种AVX / SSE切换几乎一次:

vmulsd

(事件代码取自表19.5 of another Intel manual)。

这就留下了为什么第一次调用库函数时减速开启的问题,以及当你调用$ perf stat -e cpu/event=0xc1,umask=0x08/ -e cpu/event=0xc1,umask=0x10/ ./slow.elf Performance counter stats for './slow.elf': 100,000,064 cpu/event=0xc1,umask=0x08/ 100,000,118 cpu/event=0xc1,umask=0x10/ printf或其他什么时再次关闭的问题。线索是in the first document again

  

当无法移除转换时,通常可以通过显式清零YMM寄存器的高128位来避免损失,在这种情况下硬件不会保存这些值。

我认为完整的故事如下。当您第一次调用库函数时,设置PLT的sprintf中的trampoline代码会使MMY寄存器的高位保持非零状态。当你调用ld-linux-x86-64.so以及其他东西时,它会将MMY寄存器的高位(无论是偶然还是设计)归零,我不确定。

sprintf替换sprintf调用 - 它指示处理器明确地将这些高位置零 - 具有相同的效果。

通过向编译标志添加asm("vzeroupper")-mavx可以消除这种影响,这就是系统其余部分的构建方式。为什么默认情况下不会发生这种情况只是我系统的一个谜团。

我不太确定我们在这里学到了什么,但确实如此。