启用优化的大型阵列的内联汇编阵列总和基准时间接近零,即使使用了结果

Inline assembly array sum benchmark near-zero time for large arrays with optimization enabled, even though result is used

我写了两个获取数组总和的函数,第一个是用C++写的,另一个是用内联汇编(x86-64)写的,我比较了这两个函数在我的设备上的性能.


我的问题

我还推测可能在编译期间改进了内存对齐和缓存未命中以提高性能,但我对这方面的了解仍然非常有限。

除了回答我的问题,如果你有什么要补充的,请随时补充,希望有人能解释一下,谢谢!


[编辑]

所以我删除了宏的使用并隔离了 运行 这两个版本,还尝试添加 volatile 关键字,一个“内存”破坏和“+&r " 输出约束和性能现在与 cpp_sum 相同。

虽然如果我删除 volatile 关键字和 "memory" 破坏它我仍然得到那些 2-3 位数纳秒性能。

代码:

#include <iostream>
#include <random>
#include <chrono>

uint64_t sum_cpp(const uint64_t *numbers, size_t length) {
    uint64_t sum = 0;
    for(size_t i=0; i<length; ++i) {
        sum += numbers[i];
    }
    return sum;
}

uint64_t sum_asm(const uint64_t *numbers, size_t length) {
    uint64_t sum = 0;
    asm volatile(
        "xorq %%rax, %%rax\n\t"
        "%=:\n\t"
        "addq (%[numbers], %%rax, 8), %[sum]\n\t"
        "incq %%rax\n\t"
        "cmpq %%rax, %[length]\n\t"
        "jne %=b"
        : [sum]"+&r"(sum)
        : [numbers]"r"(numbers), [length]"r"(length)
        : "%rax", "memory", "cc"
    );
    return sum;
}

int main() {
    std::mt19937_64 rand_engine(1);
    std::uniform_int_distribution<uint64_t> random_number(0,5000);

    size_t length = 99999999;
    uint64_t *arr = new uint64_t[length];
    for(size_t i=1; i<length; ++i) arr[i] = random_number(rand_engine);

    uint64_t cpp_total = 0, asm_total = 0;

    for(size_t i=0; i<5; ++i) {
        auto start = std::chrono::high_resolution_clock::now();
#ifndef _INLINE_ASM
        cpp_total += sum_cpp(arr, length);
#else
        asm_total += sum_asm(arr,length);
#endif
        auto end = std::chrono::high_resolution_clock::now();
        auto dur = std::chrono::duration_cast<std::chrono::nanoseconds>(end-start);
        std::cout << "time : " << dur.count() << " nanoseconds\n";
    }

#ifndef _INLINE_ASM
    std::cout << "cpp sum = " << cpp_total << "\n";
#else
    std::cout << "asm sum = " << asm_total << "\n";
#endif

    delete [] arr;
    return 0;
}

编译器将内联 asm 提升到您的重复循环之外,因此超出了您的计时区域。

如果您的目标是性能,https://gcc.gnu.org/wiki/DontUseInlineAsm. The useful thing to spend your time learning first is SIMD intrinsics (and how they ) like _mm256_add_epi64 to add 4x uint64_t with a single AVX2 instruction. See https://whosebug.com/tags/sse/info(编译器可以 auto-vectorize 像这样的简单求和,如果您使用较小的数组,您可以从中看到好处并在定时区域内放置一个重复循环以获得一些缓存命中。)

如果您想使用 asm 来测试各种 CPUs 上的实际速度,您可以在 stand-alone 静态可执行文件或从 C++ 调用的函数中执行此操作。 https://whosebug.com/tags/x86/info 有一些性能良好的链接。

回复:在 -O0 进行基准测试,是 the compiler makes slow asm,默认 -O0 一致调试,根本不尝试优化。双手被绑在背后,打败它也不是什么难事。


为什么你的asm可以吊出定时区域

没有asm volatile,你的asm语句是你告诉编译器的输入的纯函数,它是一个指针,一个长度,初始值为sum=0。它 包含 pointed-to 内存,因为您没有为此使用虚拟 "m" 输入。 ()

没有 "memory" 破坏,您的 asm 语句就不会按顺序排列。函数调用,因此 GCC 将 asm 语句提升到循环之外。 有关 "memory" 破坏的效果的更多详细信息,请参阅

查看 https://godbolt.org/z/KeEMfoMvo 上的编译器输出,看看它是如何内联到 main 中的。 -O2 及更高版本启用 -finline-functions,而 -O1 仅启用 -finline-functions-called-once 而这不是 staticinline 因此它必须发出 stand-alone 其他编译单元调用时的定义。

75ns只是std::chrono函数围绕一个nearly-empty定时区域的定时开销。实际上是运行ning,只是不是在定时区域内。如果您 single-step 整个程序的 asm,或者例如在 asm 语句上设置断点,您可以看到这一点。在对可执行文件进行 asm-level 调试时,您可以通过在 xor %eax,%eax 之前放置一个像 mov [=29=]xdeadbeef, %eax 这样的时髦指令来帮助自己找到它,您可以在调试器的反汇编输出中搜索这些内容(例如 GDB 的 layout asmlayout reg;参见 https://whosebug.com/tags/x86/info 底部的 asm 调试提示)。是的,你 do 经常想看看编译器在调试内联 asm 时做了什么,它是如何填充你的约束的,因为踩到它的脚趾是一种非常真实的可能性。

请注意 "memory" 破坏 没有 asm volatile 仍然会让 GCC 在 asm 的两次调用之间执行 Common Subexpression Elimination (CSE)语句,如果中间没有函数调用。就像你在一个定时区域内放置一个重复循环来测试一个足够小以适应某种级别缓存的数组的性能。

Sanity-checking 你的基准

Is this a normal reading

你甚至不得不问这个,真是太疯狂了。 99999999 75ns 中的 8 字节整数将是 99999999 * 8 B / 75 ns = 10666666 GB/s 的内存带宽,而快速 dual-channel DDR4 可能达到 32 GB/s。 (或者缓存带宽,如果它那么大,但它不是,所以你的代码在内存上存在瓶颈)。

或者 4GHz CPU 必须 运行 在 99999999 / (75*4) = 333333.33 add 每个时钟周期的指令,但流水线只有 4 到 6 微指令宽在现代 CPUs 上,循环分支的 taken-branch 吞吐量最多为 1。 (https://uops.info/ and https://agner.org/optimize/)

即使使用 AVX-512,每个内核也是 2/clock 8x uint64_t 添加,但编译器不会重写您的内联 asm;与使用纯 C++ 或内在函数相比,这将违背其目的。

这显然只是 std::chrono 来自 near-empty 定时区域的定时开销。


Asm code-review:正确性

如上所述,

您还遗漏了 "+&r"(sum) 中的 & 早期破坏声明,这在理论上会让它选择与其中一个输入相同的寄存器求和。但由于 sum 也是一个输入,它只能在 numberslength 也是 0.

的情况下执行此操作

这有点像 toss-up 是在 asm 内部 xor-zero 以获得 "=&r" 输出更好,还是使用 "+&r" 并将归零留给编译器。对于您的循环计数器,这是有道理的,因为编译器根本不需要知道它。但是通过为它手动选择 RAX(使用 clobber),你阻止了编译器选择让你的代码在 RAX 中生成 sum,就像它想要一个 non-inline 函数一样。虚拟 [idx] "=&r" (dummy) 输出操作数将使编译器为您选择一个适当宽度的寄存器,例如intptr_t.


Asm 代码审查:性能

正如 David Wohlferd 所说:xor %eax, %eax 将 RAX 归零。隐式 zero-extension 保存一个 REX 前缀。 (机器码中 code-size 的 1 个字节。通常 machine-code 越小越好。)

似乎不​​值得 hand-writing asm 如果你不打算做任何比没有 -ftree-vectorize-mgeneral-regs-only-mno-sse2(即使它是 x8 的基线-64,内核代码一般需要避免使用 SIMD 寄存器)。但我想它可以作为内联 asm 约束如何工作的学习练习,以及测量的起点。并获得基准测试,以便您可以测试更好的循环。

典型的 x86-64 CPUs 每个时钟周期可以执行 2 次加载(Intel 自 Sandybridge 以来,AMD 自 K8 以来)或 Alder Lake 上的 3/时钟。在具有 AVX/AVX2 的现代 CPUs 上,每次加载可以是 32 字节宽(或 AVX-512 为 64 字节)L1d 命中的最佳情况。或者更像是 1/clock,在最近的 Intel 上只有 L2 命中,这是一个合理的 cache-blocking 目标。

但是你的循环最多可以 运行 每个时钟周期加载 1x 8 字节,因为循环分支可以 运行 1/时钟,并且 add mem, %[sum] 有 1 个周期 loop-carried 通过 sum.

的依赖

这可能会最大化 DRAM 带宽(在硬件预取器的帮助下),例如8 B / 周期 * 4GHz = 32GB/s,现代 desktop/laptop 英特尔 CPU 可以管理单个内核(但不是大至强)。但是有了足够快的 DRAM and/or 相对于它较慢的 CPU,即使是 DRAM 也可以避免成为瓶颈。但是与 L3 或 L2 缓存带宽相比,针对 DRAM 带宽的目标是相当低的标准。

所以即使你想继续使用没有 movdqu / paddq 的标量代码(或者最好达到 memory-source paddq 的对齐边界,如果你想花一些 code-size 来优化这个循环),你仍然可以用两个寄存器累加器来展开你在最后添加的 sum 。这暴露了一些 instruction-level 并行性,允许每个时钟周期两个 memory-source 负载。


你也可以避免cmp,这样可以减少循环开销。更少的 uops 让 out-of-order exec 看得更远。

获取指向数组末尾的指针和从 -length 到零的索引。喜欢 (arr+len)[idx]for(idx=-len ; idx != 0 ; idx++)。对于某些 HW 预取器,通过数组向后循环在某些 CPU 上有点差,因此通常不建议用于通常受内存限制的循环。

另见 Micro fusion and addressing modes - 索引寻址模式只能在 Intel Haswell 及更高版本的 back-end 中保留 micro-fused,并且仅适用于像 add 这样的 RMW 指令他们的目标寄存器。

所以你最好的选择是一个循环,其中有一个指针增量和 2 到 4 个使用它的添加指令,并且在底部有一个 cmp/jne