为什么我的 SSE 程序集在发布版本中变慢?

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。

Pet*_*des 5

您在参考周期中计时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] 不过很好:作为负载,它零扩展到完整寄存器。