gpt4 book ai didi

c++ - 添加打印语句可将代码速度提高一个数量级

转载 作者:可可西里 更新时间:2023-11-01 17:58:15 27 4
gpt4 key购买 nike

我在一段 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 的数组进行的大量复杂算术运算。它已从我关心的大量复杂算术运算中“简化”而来。

因此,行为似乎如标题中所述:如果我将此 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("%.15lf\n", blah.real());
}
printf("%.15lf\n", blah.real());

return 0;
}

我可以让它更小,但机器代码已经是可管理的了。如果我将对应于第一个 printf 的 callq 指令的二进制文件的 5 个字节 更改为 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 行会使它再次变慢。

必须相关的是,第一次运行库调用时,PLT 中的“蹦床”被初始化为指向共享库。因此,也许这种动态加载代码会以某种方式将处理器前端留在一个糟糕的地方,直到它被“拯救”。

最佳答案

郑重声明,我终于弄明白了。

事实证明,这与 AVX–SSE 转换惩罚有关。引用this exposition from Intel :

When using Intel® AVX instructions, it is important to know that mixing 256-bit Intel® AVX instructions with legacy (non VEX-encoded) Intel® SSE instructions may result in penalties that could impact performance. 256-bit Intel® AVX instructions operate on the 256-bit YMM registers which are 256-bit extensions of the existing 128-bit XMM registers. 128-bit Intel® AVX instructions operate on the lower 128 bits of the YMM registers and zero the upper 128 bits. However, legacy Intel® SSE instructions operate on the XMM registers and have no knowledge of the upper 128 bits of the YMM registers. Because of this, the hardware saves the contents of the upper 128 bits of the YMM registers when transitioning from 256-bit Intel® AVX to legacy Intel® SSE, and then restores these values when transitioning back from Intel® SSE to Intel® AVX (256-bit or 128-bit). The save and restore operations both cause a penalty that amounts to several tens of clock cycles for each operation.

我上面的主循环的编译版本包括遗留的 SSE 指令(movapd 和 friend ,我认为),而 libgcc_s 中的 __muldc3 的实现使用了很多奇特的AVX 指令(vmovapdvmulsd 等)。

这是放缓的最终原因。事实上,Intel 性能诊断表明,每次调用 `__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 of another Intel manual)。

这就留下了一个问题,为什么当你第一次调用一个库函数时减速会打开,而当你调用 printfsprintf 或其他任何东西时又会关闭.线索是in the first document again :

When it is not possible to remove the transitions, it is often possible to avoid the penalty by explicitly zeroing the upper 128-bits of the YMM registers, in which case the hardware does not save these values.

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

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

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

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

关于c++ - 添加打印语句可将代码速度提高一个数量级,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/42358211/

27 4 0
Copyright 2021 - 2024 cfsdn All Rights Reserved 蜀ICP备2022000587号
广告合作:1813099741@qq.com 6ren.com