Bil*_*rst 1 cpu performance assembly sse microbenchmark
我一直在玩一些 x64 程序集和 XMM 寄存器来做一些浮点数学,我看到一些让我感到困惑的性能。
作为自学练习,我编写了一些 SSE 程序集来近似“sin”函数(使用泰勒级数),并在循环中从一些基本的 C++ 调用它以与标准库版本进行比较。代码如下,之后我粘贴了一些典型运行的输出。(我不是在寻找对代码或方法的批评,只是想了解性能数字)。
我不明白的是为什么使用“发布”构建,其中实际运行的程序集是相同的(我已经通过调试器进行了仔细检查),始终慢了大约 40 - 50 个周期。(取消对 LFENCE 指令的注释会为 Debug 和 Release 增加大约 100 个周期,因此增量保持不变)。作为一个额外的问题,为什么第一次迭代通常是数千次!!
我知道这些东西非常复杂,并且受到许多因素的微妙影响,但是作为潜在原因出现在我脑海中的一切都是没有意义的。
我已经在两次运行中检查了 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
Run Code Online (Sandbox Code Playgroud)
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 cycles\n", rads, result, cycles);
printf("C library = %.8f\n", 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;
}
Run Code Online (Sandbox Code Playgroud)
通过“调试”构建(我使用的是 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
Run Code Online (Sandbox Code Playgroud)
与“发布”版本完全相同的代码,我通常会看到以下内容:
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
Run Code Online (Sandbox Code Playgroud)
====更新1====
我更改了代码以将常量加载为立即数,而不是像 Peter 提到的那样引用 .rdata 段,这摆脱了缓慢的第一次迭代,即用以下两行替换了注释掉的行:
; movss xmm4, rf5 ; 1/5!
mov eax, 3C088889h ; 1/5! float representation
movd xmm4, eax
Run Code Online (Sandbox Code Playgroud)
预热 CPU 没有帮助,但我确实注意到 Release 中的第一次迭代现在和调试一样快,其余的仍然很慢。由于printf直到第一次计算后才调用,我想知道这是否有影响。我将代码更改为仅在运行时存储结果,并在完成后打印它们,现在 Release 也一样快。IE
更新的 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 cycles\n", rads, result, cycles);
// printf("C library = %.8f\n", 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 cycles\n", input, *value_iter++, *cycle_iter++);
printf("C library = %.8f\n", sin(input));
}
return 0;
}
Run Code Online (Sandbox Code Playgroud)
现在,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
Run Code Online (Sandbox Code Playgroud)
====更新2====
为了排除 CPU 加速下降,我进入并调整了一些 bios 设置(禁用 Turbo、设置一致的核心电压等),现在可以通过主板的“AI Suite”华硕应用程序查看 CPU是一致的3600MHz。(我在 Windows 10 x64 上运行 Intel Core 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
Run Code Online (Sandbox Code Playgroud)
因此,调用 CRT Release DLL 绝对是导致速度变慢的原因。我仍然不明白为什么,特别是因为 VS 中的调试版本也通过 DLL 使用 CRT。
您在参考周期中计时rdtsc,而不是核心时钟周期。在核心时钟周期内,这两次的速度可能相同,但 CPU 以不同的频率运行。
在您的函数被调用之前,调试版本可能会让 CPU 有时间提升到 max turbo(每个参考周期有更多的核心周期)。 因为调用代码编译为较慢的 asm。尤其是对于 MSVC,调试构建添加了额外的东西,例如毒化堆栈帧以捕获未初始化变量的使用。还有增量链接的开销。
这些都不会减慢您的手写函数本身的速度,这只是您在微基准测试中忽略手动执行的“热身”。
请参阅如何从 C++ 获取 x86_64 中的 CPU 周期数?有关 RDTSC 的更多详细信息。
对于现代 x86 CPU 来说,空闲 CPU 时钟和 max-turbo(或更高的时钟)之间的系数约为 3 是非常合理的。 我的 i7-6700k 空闲频率为 0.8GHz,额定频率为 4.0GHz,最大单核睿频为 4.2。但是许多笔记本电脑的 CPU 的非涡轮增压最大值要低得多(并且可能最初只会上升到非涡轮增压,而不是立即达到最大涡轮增压,这取决于 energy_performance_preference 硬件调节器,或者尤其是旧 CPU 上的软件调节器。)
作为一个额外的问题,为什么第一次迭代通常是数千次!!
rf3从数据存储器加载可能是 dTLB 未命中和缓存未命中。您可以尝试从 C 加载它们(通过声明extern volatile float rf3)来为该常量块准备 TLB + 缓存,假设它们都在同一缓存行中。
也可能是 I-cache 未命中rdtsc,但第一次加载可能在 I-cache 行结束之前,因此这些可能会并行发生。(将asm 函数rdtsc 放入内部意味着我们可能不会等待定时区域内的 iTLB 未命中或 i-cache 未命中甚至获取函数的第一个字节)。
代码审查:
不要movss在 XMM 寄存器之间使用,除非您想将低 4 个字节混合到目标的旧值中。使用movaps xmm2, xmm0复制整个寄存器; 它更有效率。
movaps可以通过寄存器重命名处理,无需任何后端执行单元,而movss仅在 Intel CPU 的一个执行单元上运行,端口 5。 https://agner.org/optimize/。此外,movaps避免了对寄存器旧值的错误依赖,因为它会覆盖完整的 reg,从而允许乱序 exec 正常工作。
movss xmm, [mem] 不过很好:作为负载,它零扩展到完整寄存器。
| 归档时间: |
|
| 查看次数: |
147 次 |
| 最近记录: |