添加print语句可以将代码的速度提高一个数量级

Adding a print statement speeds up code by an order of magnitude

本文关键字:速度 高一个 数量级 代码 print 语句 添加      更新时间:2023-10-16

正如标题中所建议的那样,我在一段C/C++代码中遇到了一个极其奇怪的性能行为,我不知道如何解释。

这是一个as-close-as-I've-found-to-minimal的工作示例[EDIT:请参阅下面的简短示例]:

#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("%.15lfn", gg[0].real());
#else // I_WANT_THIS_TO_RUN_REALLY_SLOWLY
#endif
}
printf("%.15lfn", 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的数组上的一堆复杂运算。它是从我关心的大量复杂算术中"简化"而来的。

因此,正如标题中所说,行为似乎是:如果我把这个print语句放回原处,代码会变得更快。

我玩过一些:例如,打印一个常量字符串不会加速,但打印时钟时间会加速。有一个非常明确的阈值:代码要么快要么慢。

我考虑了一些奇怪的编译器优化是否起作用的可能性,这可能取决于代码是否有副作用。但是,如果是这样的话,那就很微妙了:当我查看被分解的二进制文件时,它们似乎是相同的,只是其中有一个额外的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("%.15lfn", blah.real());
}
printf("%.15lfn", blah.real());
return 0;
}

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

编译后的代码非常重,函数调用__muldc3()。我觉得这一定与Broadwell体系结构如何很好地处理这些跳跃有关:两个版本都运行相同数量的指令,所以指令/周期不同(大约0.16和大约2.8)

此外,编译-静态使事情再次变得快速。


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

它似乎调用了任何库函数—包括我编造的一些愚蠢的、无所事事的故事;对于第一次,将执行置于慢速状态。随后对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, "%cn", 'c');
//fflush(stdout); // 2
for(int it = 0; it < 100000000; it++) {
foo += foo * foo;
}
return (foo.real() > 10.0);
}

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

第一次运行库调用时,PLT中的"蹦床"被初始化为指向共享库,这一点必须是相关的。因此,也许这种动态加载代码会让处理器前端处于一个糟糕的位置,直到它被"拯救"。

为了记录,我终于弄清楚了这一点。

事实证明,这与AVX–SSE过渡处罚。引用英特尔的这篇文章:

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

我上面的主循环的编译版本包括遗留的SSE指令(我想是movapd和朋友们),而在libgcc_s中实现__muldc3使用了很多花哨的AVX指令(vmovapdvmulsd等)

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

$ 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/

(事件代码取自另一本英特尔手册的表19.5)。

这就留下了这样一个问题:为什么当您第一次调用库函数时减速会打开,而当您调用printfsprintf或其他函数时减速又会关闭。线索再次出现在第一份文件中:

当无法删除转换时,通常可以通过明确地将YMM寄存器的高128位归零来避免惩罚,在这种情况下,硬件不会保存这些值。

因此,我认为整个故事如下。当您第一次调用库函数时,ld-linux-x86-64.so中设置PLT的蹦床代码会使MMY寄存器的高位处于非零状态。当您调用sprintf等时,它会将MMY寄存器的高位清零(无论是偶然还是有意,我都不确定)。

asm("vzeroupper")替换sprintf调用—其明确地指示处理器将这些高位置零—具有相同的效果。

可以通过将-mavx-march=native添加到编译标志中来消除这种影响,这就是系统其余部分的构建方式。为什么默认情况下不会发生这种情况,我想这只是我的系统的一个谜。

我不太确定我们在这里学到了什么,但它就在那里。

相关文章: