使用英特尔编译器时,Windows和Linux之间的性能差异:查看汇编代码

65

我在Windows和Linux(x86-64)上运行同一个程序。它是使用相同的编译器(Intel Parallel Studio XE 2017)和相同的选项编译的,但Windows版本比Linux版本快3倍。罪魁祸首是对std::erf的调用,该函数在Intel数学库中解析了两个情况(默认情况下,在Windows上动态链接,在Linux上静态链接,但在Linux上使用动态链接会得到相同的性能)。

这里有一个简单的程序来复现这个问题。

#include <cmath>
#include <cstdio>

int main() {
  int n = 100000000;
  float sum = 1.0f;

  for (int k = 0; k < n; k++) {
    sum += std::erf(sum);
  }

  std::printf("%7.2f\n", sum);
}
当我使用vTune对这个程序进行剖析时,我发现Windows版本和Linux版本的汇编有些不同。以下是Windows上的调用站点(循环)。

当我使用vTune进行程序分析时,我发现Windows版本和Linux版本的汇编有些不同。以下是Windows上的调用站点(循环)。

Block 3:
"vmovaps xmm0, xmm6"
call 0x1400023e0 <erff>
Block 4:
inc ebx
"vaddss xmm6, xmm6, xmm0"
"cmp ebx, 0x5f5e100"
jl 0x14000103f <Block 3>

erf函数在Windows上的起始部分

Block 1:
push rbp
"sub rsp, 0x40"
"lea rbp, ptr [rsp+0x20]"
"lea rcx, ptr [rip-0xa6c81]"
"movd edx, xmm0"
"movups xmmword ptr [rbp+0x10], xmm6"
"movss dword ptr [rbp+0x30], xmm0"
"mov eax, edx"
"and edx, 0x7fffffff"
"and eax, 0x80000000"
"add eax, 0x3f800000"
"mov dword ptr [rbp], eax"
"movss xmm6, dword ptr [rbp]"
"cmp edx, 0x7f800000"
...

在Linux上,代码有些不同。调用站点是:

Block 3
"vmovaps %xmm1, %xmm0"
"vmovssl  %xmm1, (%rsp)"
callq  0x400bc0 <erff>
Block 4
inc %r12d
"vmovssl  (%rsp), %xmm1"
"vaddss %xmm0, %xmm1, %xmm1"   <-------- hotspot here
"cmp $0x5f5e100, %r12d"
jl 0x400b6b <Block 3>

而被调用函数(erf)的开始部分是:

"movd %xmm0, %edx"
"movssl  %xmm0, -0x10(%rsp)"   <-------- hotspot here
"mov %edx, %eax"
"and $0x7fffffff, %edx"
"and $0x80000000, %eax"
"add $0x3f800000, %eax"
"movl  %eax, -0x18(%rsp)"
"movssl  -0x18(%rsp), %xmm0"
"cmp $0x7f800000, %edx"
jnl 0x400dac <Block 8>
...

我已经展示了在Linux上时间消耗的2个点。

有没有人理解汇编语言,能解释一下这两段代码之间的区别,以及为什么Linux版本要慢3倍?


硬件是一样的吗? - Leon
2
是的,硬件相同。我已经在核心i7 Haswell上为Windows和Linux测试了这个案例,在Xeon Broadwell上也为Windows和Linux测试了它。结果相同。 在核心i7上,我还在macOS上进行了测试,速度与Windows版本相同。 - InsideLoop
6
Linux可以在虚拟机中运行吗? - Leon
1
结果在数值上完全相同吗?英特尔的实现可能更准确。当然,确定这一点并不是易事。 - MSalters
1
Linux版本将xmm1保存并在块3和块4中恢复到/从RAM,但Windows版本将xmm6保存(我假设稍后会恢复,但上面没有显示)到/从RAM。 - rcgldr
显示剩余3条评论
2个回答

42
在这两种情况下,根据Windows和GNU/Linux各自的调用约定,参数和结果通过寄存器传递。
在GNU/Linux变体中,xmm1 用于累加总和。由于它是一个被调用方保存的寄存器(也称作callee-saved),所以每次调用时它都会被存储(并恢复)在调用者的堆栈帧中。
在Windows变体中,xmm6 用于累加总和。这个寄存器在Windows调用约定中被调用方保存(但在GNU/Linux约定中不是)。
因此,总的来说,GNU/Linux版本在调用者中保存/恢复xmm1(在被调用方中保存/恢复xmm0),而Windows版本仅保存/恢复xmm6(在被调用方中)。

寄存器是否为被调用者保存,这一点在Windows上总是遵循而在Linux上从未遵循? - InsideLoop
2
编译器始终尊重ABI(Application Binary Interface),只是不同的ABI以不同方式定义调用者保存寄存器和被调用者保存寄存器的集合。 - chill
10
只有在编译器无法看到定义时,ABI 才需要遵守外部调用。否则(当编译器可以看到被调用函数的定义时),它可以执行任何不改变定义明确的代码结果的转换,包括内联或使用自定义调用约定。 - R.. GitHub STOP HELPING ICE
@R.,确实,在“非导出”函数以及所有调用点都已知的情况下。 - chill
6
@chill:并非所有调用站点都必须知道,编译器可以(gcc就会这样做)在函数既可在外部访问(不是所有调用站点已知),又可以从本地以一种有利于不同调用约定(或过程间常量传播等)的方式使用时,生成多个版本的函数。 - R.. GitHub STOP HELPING ICE
显示剩余2条评论

3
使用Visual Studio 2015,Win 7 64位模式,在erf()函数的一些路径中找到了以下代码(未显示所有路径)。每条路径涉及读取内存的最多8个常数(其他路径可能更多),因此单次存储/加载以保存寄存器似乎不太可能导致Linux和Windows之间的3倍速度差异。对于保存/恢复,这个例子保存和恢复xmm6和xmm7。关于时间,原帖中的程序在Intel 3770K上大约需要0.86秒(3.5Ghz CPU)(VS2015 / Win 7 64位)。更新-我后来确定在程序进行10^8次循环(每次循环约3纳秒)时,保存和恢复xmm寄存器的开销约为0.03秒。
000007FEEE25CF90  mov         rax,rsp  
000007FEEE25CF93  movss       dword ptr [rax+8],xmm0  
000007FEEE25CF98  sub         rsp,48h  
000007FEEE25CF9C  movaps      xmmword ptr [rax-18h],xmm6  
000007FEEE25CFA0  lea         rcx,[rax+8]  
000007FEEE25CFA4  movaps      xmmword ptr [rax-28h],xmm7  
000007FEEE25CFA8  movaps      xmm6,xmm0  
000007FEEE25CFAB  call        000007FEEE266370  
000007FEEE25CFB0  movsx       ecx,ax  
000007FEEE25CFB3  test        ecx,ecx  
000007FEEE25CFB5  je          000007FEEE25D0AF  
000007FEEE25CFBB  sub         ecx,1  
000007FEEE25CFBE  je          000007FEEE25D08F  
000007FEEE25CFC4  cmp         ecx,1  
000007FEEE25CFC7  je          000007FEEE25D0AF  
000007FEEE25CFCD  xorps       xmm7,xmm7  
000007FEEE25CFD0  movaps      xmm2,xmm6  
000007FEEE25CFD3  comiss      xmm7,xmm6  
000007FEEE25CFD6  jbe         000007FEEE25CFDF  
000007FEEE25CFD8  xorps       xmm2,xmmword ptr [7FEEE2991E0h]  
000007FEEE25CFDF  movss       xmm0,dword ptr [7FEEE298E50h]  
000007FEEE25CFE7  comiss      xmm0,xmm2  
000007FEEE25CFEA  jbe         000007FEEE25D053  
000007FEEE25CFEC  movaps      xmm2,xmm6  
000007FEEE25CFEF  mulss       xmm2,xmm6  
000007FEEE25CFF3  movaps      xmm0,xmm2  
000007FEEE25CFF6  movaps      xmm1,xmm2  
000007FEEE25CFF9  mulss       xmm0,dword ptr [7FEEE298B34h]  
000007FEEE25D001  mulss       xmm1,dword ptr [7FEEE298B5Ch]  
000007FEEE25D009  addss       xmm0,dword ptr [7FEEE298B8Ch]  
000007FEEE25D011  addss       xmm1,dword ptr [7FEEE298B9Ch]  
000007FEEE25D019  mulss       xmm0,xmm2  
000007FEEE25D01D  mulss       xmm1,xmm2  
000007FEEE25D021  addss       xmm0,dword ptr [7FEEE298BB8h]  
000007FEEE25D029  addss       xmm1,dword ptr [7FEEE298C88h]  
000007FEEE25D031  mulss       xmm0,xmm2  
000007FEEE25D035  mulss       xmm1,xmm2  
000007FEEE25D039  addss       xmm0,dword ptr [7FEEE298DC8h]  
000007FEEE25D041  addss       xmm1,dword ptr [7FEEE298D8Ch]  
000007FEEE25D049  divss       xmm0,xmm1  
000007FEEE25D04D  mulss       xmm0,xmm6  
000007FEEE25D051  jmp         000007FEEE25D0B2  
000007FEEE25D053  movss       xmm1,dword ptr [7FEEE299028h]  
000007FEEE25D05B  comiss      xmm1,xmm2  
000007FEEE25D05E  jbe         000007FEEE25D076  
000007FEEE25D060  movaps      xmm0,xmm2  
000007FEEE25D063  call        000007FEEE25CF04  
000007FEEE25D068  movss       xmm1,dword ptr [7FEEE298D8Ch]  
000007FEEE25D070  subss       xmm1,xmm0  
000007FEEE25D074  jmp         000007FEEE25D07E  
000007FEEE25D076  movss       xmm1,dword ptr [7FEEE298D8Ch]  
000007FEEE25D07E  comiss      xmm7,xmm6  
000007FEEE25D081  jbe         000007FEEE25D08A  
000007FEEE25D083  xorps       xmm1,xmmword ptr [7FEEE2991E0h]  
000007FEEE25D08A  movaps      xmm0,xmm1  
000007FEEE25D08D  jmp         000007FEEE25D0B2  
000007FEEE25D08F  mov         eax,8000h  
000007FEEE25D094  test        word ptr [rsp+52h],ax  
000007FEEE25D099  je          000007FEEE25D0A5  
000007FEEE25D09B  movss       xmm0,dword ptr [7FEEE2990DCh]  
000007FEEE25D0A3  jmp         000007FEEE25D0B2  
000007FEEE25D0A5  movss       xmm0,dword ptr [7FEEE298D8Ch]  
000007FEEE25D0AD  jmp         000007FEEE25D0B2  
000007FEEE25D0AF  movaps      xmm0,xmm6  
000007FEEE25D0B2  movaps      xmm6,xmmword ptr [rsp+30h]  
000007FEEE25D0B7  movaps      xmm7,xmmword ptr [rsp+20h]  
000007FEEE25D0BC  add         rsp,48h  
000007FEEE25D0C0  ret  

每个路径涉及读取内存中多达8个常量(对于其他路径可能更多),这在现代CPU上的吞吐量只需要4个周期(Intel SnB系列或AMD k8及更高版本),至于延迟:乱序执行可以与任何操作重叠,因为地址提前已知。也就是说,在寄存器输入到指令准备好的时候,它们可以完成并准备好,因此它们不一定会延长依赖链。我更担心的是mulss/addss链! - Peter Cordes
你说得对,看起来很奇怪。从C语言来看,OP的测试函数应该只会受到erf()延迟的瓶颈影响,再加上3个时钟周期的浮点加法(在SKL上为4个),以及可选的额外5或6个时钟周期的XMM溢出/重载。我没有仔细阅读汇编代码。也许存储/重载会使其他部分效率降低。 - Peter Cordes
2
@PeterCordes - 跟进一下,我用一个汇编程序替换了erf函数,其中一个程序只是返回,另一个程序则存储/加载xmm0并返回。在10^8个循环中,xmm0的存储/加载开销为0.03秒,即每对存储/加载指令的开销为3纳秒。将这个0.03秒的存储/加载开销与使用erf()的0.86秒总时间(同样是10^8个循环)进行比较。 - rcgldr

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接