为什么我的SSE程序集在发布版本中速度较慢



我一直在玩一些x64程序集和XMM寄存器来做一些浮点运算,我看到了一些让我困惑的性能。

作为一个自学练习,我编写了一些SSE汇编来近似"sin"函数(使用泰勒级数),并在循环中从一些基本C++中调用它,以与标准库版本进行比较。代码如下,我已经粘贴了一些典型运行的输出。(我不想在这里批评代码或方法,只是想了解性能数字)。

我不明白的是,在"Release"构建中,实际运行的程序集是完全相同的(我已经仔细检查了调试器),为什么总是慢40-50个周期。(取消对LFENCE指令的注释会给Debug和Release增加大约100个周期,因此delta保持不变)。作为一个额外的问题,为什么第一次迭代通常在数千中!!

我知道这些东西非常复杂,受到许多因素的微妙影响,但我脑海中突然出现的所有潜在原因都没有意义。

我已经检查了两次运行中的MSCSR标志,这在不同的构建中也是相同的(默认值为1f80h,它屏蔽了所有异常)。

知道是什么原因造成的吗?我可以做什么进一步的分析来更深入地了解这一点?

装配

_RDATA segment
pi  real4 3.141592654
rf3 real4 0.1666666667
rf5 real4 0.008333333333
rf7 real4 0.0001984126984
_RDATA ends

_TEXT segment
; float CalcSin(float rads, int* cycles)
CalcSin PROC
; "leaf" function - doesn't use the stack or any non-volatile registers
mov r8, rdx                ; Save the 'cycles' pointer into R8
rdtsc                      ; Get current CPU cyles in EDX:EAX
;    lfence                     ; Ensure timer is taken before executing the below
mov ecx, eax               ; Save the low 32 bits of the timer into ECX
movss xmm2, xmm0
mulss xmm2, xmm2           ; X^2
movss xmm3, xmm0
mulss xmm3, xmm2           ; x^3
movss xmm4, rf3            ; 1/3!
mulss xmm4, xmm3           ; x^3 / 3!
subss xmm0, xmm4           ; x - x^3 / 3!
mulss xmm3, xmm2           ; x^5
movss xmm4, rf5            ; 1/5!
mulss xmm4, xmm3           ; x^5 / 5!
addss xmm0, xmm4           ; x - x^3 / 3! + x^5 / 5!
mulss xmm3, xmm2           ; x^7
movss xmm4, rf7            ; 1/7!
mulss xmm4, xmm3           ; x^7 / 7!
subss xmm0, xmm4           ; x - x^3 / 3! + x^5 / 5! - x^7 / 7!
;    lfence                     ; Ensure above completes before taking the timer again
rdtsc                      ; Get the timer now
sub eax, ecx               ; Get the difference in cycles
mov dword ptr [r8], eax
ret
CalcSin ENDP
_TEXT ends
END

C++

#include <stdio.h>
#include <math.h>
#include <vector>
const float PI = 3.141592654f;
extern "C" float CalcSin(float rads, int* cycles);
void DoCalcs(float rads) {
int cycles;
float result = CalcSin(rads, &cycles);
printf("Sin(%.8f) = %.8f.  Took %d cyclesn", rads, result, cycles);
printf("C library = %.8fn", sin(rads));
}
int main(int argc, char* argv[]) {
std::vector<float> inputs{PI / 1000, PI / 2 - PI / 1000, PI / 4, 0.0001f, PI / 2};
for (auto val : inputs) {
DoCalcs(val);
}
return 0;
}

对于"调试"构建(我使用的是Visual Studio 2019),我通常会看到以下时间报告:

Sin(0.00314159) = 0.00314159.  Took 3816 cycles
C library = 0.00314159
Sin(1.56765473) = 0.99984086.  Took 18 cycles
C library = 0.99999507
Sin(0.78539819) = 0.70710647.  Took 18 cycles
C library = 0.70710680
Sin(0.00010000) = 0.00010000.  Took 18 cycles
C library = 0.00010000
Sin(1.57079637) = 0.99984306.  Took 18 cycles
C library = 1.00000000

与"Release"构建完全相同的代码,我通常会看到以下内容:

Sin(0.00314159) = 0.00314159.  Took 4426 cycles
C library = 0.00314159
Sin(1.56765473) = 0.99984086.  Took 70 cycles
C library = 0.99999507
Sin(0.78539819) = 0.70710647.  Took 62 cycles
C library = 0.70710680
Sin(0.00010000) = 0.00010000.  Took 64 cycles
C library = 0.00010000
Sin(1.57079637) = 0.99984306.  Took 62 cycles
C library = 1.00000000

===更新1======

我修改了代码,将常量加载为即时值,而不是像Peter提到的那样引用.rdata段,这消除了缓慢的第一次迭代,即用下面的2行替换了注释行:

;    movss xmm4, rf5            ; 1/5!
mov eax, 3C088889h         ; 1/5! float representation
movd xmm4, eax

预热CPU并没有帮助,但我确实注意到Release中的第一次迭代现在和调试一样快,其余的仍然很慢。由于printf直到第一次计算之后才被调用,我想知道这是否有影响。我更改了代码,只在运行时存储结果,并在完成后打印它们,现在Release也同样快速。即

更新的C++代码

extern "C" float CalcSin(float rads, int* cycles);
std::vector<float> values;
std::vector<int> rdtsc;
void DoCalcs(float rads) {
int cycles;
float result = CalcSin(rads, &cycles);
values.push_back(result);
rdtsc.push_back(cycles);
// printf("Sin(%.8f) = %.8f.  Took %d cyclesn", rads, result, cycles);
// printf("C library = %.8fn", sin(rads));
}
int main(int argc, char* argv[]) {
std::vector<float> inputs{PI / 1000, PI / 2 - PI / 1000, PI / 4, 0.0001f, PI / 2};
for (auto val : inputs) {
DoCalcs(val);
}
auto cycle_iter = rdtsc.begin();
auto value_iter = values.begin();
for (auto& input : inputs) {
printf("Sin(%.8f) = %.8f.  Took %d cyclesn", input, *value_iter++, *cycle_iter++);
printf("C library = %.8fn", sin(input));
}
return 0;
}

现在,Release与调试几乎完全相同,即每次调用大约有18-24个周期。

我不确定printf调用在Release构建中的作用,也不确定它与Release设置的链接/优化方式,但奇怪的是,它对相同和不同的程序集调用产生了负面影响。

Sin(0.00314159) = 0.00314159.  Took 18 cycles
C library = 0.00314159
Sin(1.56765473) = 0.99984086.  Took 18 cycles
C library = 0.99999507
Sin(0.78539819) = 0.70710647.  Took 24 cycles
C library = 0.70710680
Sin(0.00010000) = 0.00010000.  Took 20 cycles
C library = 0.00010000
Sin(1.57079637) = 0.99984306.  Took 24 cycles
C library = 1.00000000

===更新2====

为了排除CPU下降的可能性,我进去调整了一些bios设置(禁用Turbo、设置一致的核心电压等),现在可以通过主板的"AI套件"华硕应用程序看到CPU是一致的3600MHz。(我在Windows 10 x64上运行英特尔酷睿i9-9900k@3.6GHz)。

设置后。。。仍然没有变化。

接下来我想到的是,使用"printf",我在每个循环之间都有一个对C运行时库的调用,这是调试和发布版本之间的不同DLL。为了删除我从命令行而不是VS开始构建的任何其他变体。使用最大速度优化和发布的CRT DLL(分别为/O2和/MD)进行编译,我仍然看到同样的速度减慢。切换到调试CRT DLL,我看到了一些改进。如果我在CRT中切换静态链接,那么无论我是使用调试或发布版本,还是使用优化编译,我都会定期看到每次调用的24个周期,即

ml64 /c ..x64simd.asm
cl.exe /Od /MT /Feapp.exe ..main.cpp x64simd.obj
>app.exe
Sin(0.00314159) = 0.00314159.  Took 24 cycles
Sin(1.56765473) = 0.99984086.  Took 24 cycles
Sin(0.78539819) = 0.70710647.  Took 24 cycles
Sin(0.00010000) = 0.00010000.  Took 24 cycles
Sin(1.57079637) = 0.99984306.  Took 24 cycles

因此,它肯定是调用CRT Release DLL导致速度减慢的原因。我仍然很困惑为什么,尤其是VS中的Debug构建也通过DLL使用CRT。

您使用的是rdtsc的参考周期,而不是核心时钟周期。在核心时钟周期中,这两次的速度可能相同,但CPU的运行频率不同。

在调用函数之前,调试构建可能会让CPU有时间提升到最大turbo(每个引用周期有更多的核心周期)因为调用代码编译为较慢的asm。尤其是对于MSVC,调试构建添加了额外的东西,比如破坏堆栈帧以捕捉未初始化变量的使用。以及增量链接的开销。

所有这些都不会减慢手写函数本身的速度,它只是你忽略了在微基准测试中手动进行的"预热"。

请参阅如何从C++获取x86_64中的CPU周期计数?有关RDTSC的更多详细信息。

对于现代x86 CPU来说,空闲CPU时钟和最大turbo(或更高的时钟)之间的3倍是非常合理的My i7-6700k在0.8GHz下空闲,额定频率为4.0GHz,最大单核涡轮为4.2。但许多笔记本电脑CPU的非turbo最大值要低得多(最初可能只会上升到非turbo,而不是立即上升到最大turbo,这取决于energy_performance_preference硬件调速器,或者尤其是旧CPU上的软件调速器。)

作为一个额外的问题,为什么第一次迭代通常是数千次!!

可能是从数据内存加载rf3的dTLB未命中和缓存未命中。您可以尝试从C加载这些常量(通过声明extern volatile float rf3),为该常量块的TLB+缓存进行初始化,假设它们都在同一个缓存行中。

也可能是rdtsc之后的I缓存未命中,但第一次加载可能在I缓存行结束之前,因此这些可能并行发生。(将rdtsc放在您的asm函数中意味着我们可能不会在定时区域内等待iTLB未命中或i-cache未命中,甚至不会获取函数的第一个字节)。


代码审查:

不要在XMM寄存器之间使用movss,除非希望将低4个字节混合到目标的旧值中。使用movaps xmm2, xmm0复制整个寄存器;它的效率要高得多。

movaps可以通过寄存器重命名来处理,而不需要任何后端执行单元,而movss仅在Intel CPU的一个执行单元(端口5)上运行。https://agner.org/optimize/.此外,movaps避免了对寄存器旧值的错误依赖,因为它覆盖了完整的reg,允许无序的exec正常工作。

不过,movss xmm, [mem]很好:作为一个加载,它的零扩展到完整的寄存器中。