我在一篇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;
}
Run Code Online (Sandbox Code Playgroud)
以下是在我的系统上运行此操作的结果:
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]
Run Code Online (Sandbox Code Playgroud)
这个代码计算的并不是非常重要:它只是长度为29的数组上的一个复杂的算术.它已经从我关心的更大吨的复杂算术中"简化"了.
因此,正如标题中所声称的那样,行为似乎是:如果我将此print语句重新放入,代码会变得更快.
我玩了一下:例如,打印一个常量字符串不能提高速度,但打印时钟时间确实如此.有一个非常明确的门槛:代码快或慢.
我考虑了一些奇怪的编译器优化可能会或不会启动的可能性,可能取决于代码是否有副作用.但是,如果是这样的话它非常微妙:当我查看反汇编的二进制文件时,它们看起来完全相同,只是有一个额外的打印语句,并且它们使用不同的可互换寄存器.我可能(必须)错过了重要的事情.
我完全无法解释地球可能造成什么.更糟糕的是,它确实影响了我的生活,因为我经常运行相关的代码,并且插入额外的打印语句并不是一个好的解决方案.
任何看似合理的理论都会受到欢迎.如果您可以解释如何解释任何问题,那么"您的计算机已损坏"的响应是可以接受的.
更新:对于问题越来越长的道歉,我已经缩小了这个例子
#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;
}
Run Code Online (Sandbox Code Playgroud)
我可以把它做得更小,但机器代码已经可以管理了.如果我将对应于第一个printf的callq指令的二进制的五个字节更改为0x90,则执行从快速变为慢速.
编译的代码非常繁重,函数调用__muldc3().我觉得必须要考虑Broadwell架构如何处理或不处理这些跳转:两个版本都运行相同数量的指令,因此它们的指令/周期不同(约为0.16 vs 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);
}
Run Code Online (Sandbox Code Playgroud)
很快,但是注释第1行或取消注释第2行会使它再次变慢.
必须相关的是,第一次运行库调用时,PLT中的"trampoline"被初始化为指向共享库.因此,也许某种程度上这个动态加载代码将处理器前端留在一个不好的地方,直到它被"救出".
作为记录,我终于弄清楚了这一点。
\n\n事实证明,这与 AVX-SSE 转换惩罚有关。去引用英特尔的这段论述:
\n\n\n\n\n使用 Intel\xc2\xae AVX 指令时,请务必了解将 256 位 Intel\xc2\xae AVX 指令与传统(非 VEX 编码)Intel\xc2\xae SSE 指令混合可能会导致性能下降。256 位 Intel\xc2\xae AVX 指令在 256 位 YMM 寄存器上运行,这些寄存器是现有 128 位 XMM 寄存器的 256 位扩展。128 位 Intel\xc2\xae AVX 指令对 YMM 寄存器的低 128 位进行操作,并将高 128 位清零。然而,传统的 Intel\xc2\xae SSE 指令在 XMM 寄存器上运行,并且不知道 YMM 寄存器的高 128 位。因此,当从 256 位 Intel\xc2\xae AVX 转换到旧版 Intel\xc2\xae SSE 时,硬件会保存 YMM 寄存器高 128 位的内容,然后在从 Intel\xc2\xae SSE 转换回来时恢复这些值。 xc2\xae SSE 到 Intel\xc2\xae AVX(256 位或 128 位)。保存和恢复操作都会导致每个操作花费数十个时钟周期的代价。
\n
上面我的主循环的编译版本包括遗留的 SSE 指令(movapd我认为还有朋友),而 libgcc_s 中的实现__muldc3使用了很多花哨的 AVX 指令(vmovapd等等vmulsd)。
这是速度变慢的最终原因。\n事实上,英特尔性能诊断显示,每次调用“__muldc3\”时,这种 AVX/SSE 切换几乎只发生一次(在上面发布的代码的最后一个版本中):
\n\n$ perf stat -e cpu/event=0xc1,umask=0x08/ -e cpu/event=0xc1,umask=0x10/ ./slow.elf\n\n Performance counter stats for \'./slow.elf\':\n 100,000,064 cpu/event=0xc1,umask=0x08/\n 100,000,118 cpu/event=0xc1,umask=0x10/\nRun Code Online (Sandbox Code Playgroud)\n\n(事件代码取自另一份 Intel 手册的表 19.5 )。
\n\n这就留下了一个问题:为什么当您第一次调用库函数时,减速会打开,而当您调用 或其他什么时,减速会再次printf关闭sprintf。线索再次出现在第一个文档中:
\n\n\n当无法删除转换时,通常可以通过显式将 YMM 寄存器的高 128 位清零来避免损失,在这种情况下,硬件不会保存这些值。
\n
我认为完整的故事如下。当您第一次调用库函数时,ld-linux-x86-64.so设置 PLT 的蹦床代码会使 MMY 寄存器的高位处于非零状态。当你调用sprintf其他东西时,它会将 MMY 寄存器的高位清零(无论是偶然还是设计,我不确定)。
将sprintf呼叫替换为asm("vzeroupper")(显式指示处理器将这些高位清零)具有相同的效果。
可以通过添加-mavx或-march=native,这就是系统其余部分的构建方式。我猜为什么默认情况下不会发生这种情况只是我的系统的一个谜。
我不太确定我们在这里学到了什么,但它就是这样。
\n