对于启用优化的大型数组,内联汇编数组总和基准时间接近于零,即使使用结果

0xd*_*eef 4 c++ optimization performance gcc inline-assembly

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

  • 如果在编译期间未启用-O标志,则使用内联汇编的函数几乎比 C++ 版本快 4-5 倍。

    cpp time : 543070068 nanoseconds
    cpp time : 547990578 nanoseconds
    
    asm time : 185495494 nanoseconds
    asm time : 188597476 nanoseconds
    
    Run Code Online (Sandbox Code Playgroud)
  • 如果-O标志设置为-O1,它们会产生相同的性能。

    cpp time : 177510914 nanoseconds
    cpp time : 178084988 nanoseconds
    
    asm time : 179036546 nanoseconds
    asm time : 181641378 nanoseconds
    
    Run Code Online (Sandbox Code Playgroud)
  • 但是,如果我尝试将-O标志设置为-O2-O3,则使用内联汇编编写的函数会得到不寻常的2-3 位纳秒性能,该性能速度很快(至少对我来说,请耐心等待,因为我对汇编编程没有扎实的经验,所以我不知道它与用 C++ 编写的程序相比有多快或多慢。)

    cpp time : 177522894 nanoseconds
    cpp time : 183816275 nanoseconds
    
    asm time : 125 nanoseconds
    asm time : 75 nanoseconds
    
    Run Code Online (Sandbox Code Playgroud)

我的问题

  • 为什么在启用 -O2 或 -O3 后使用内联汇编编写的数组求和函数如此快?

  • 这是正常读数还是性能计时/测量有问题?

  • 或者我的内联汇编功能有问题?

  • 如果数组求和的内联汇编函数正确并且性能读取正确,为什么 C++ 编译器未能针对 C++ 版本优化简单的数组求和函数并使其与内联汇编版本一样快?

我还推测也许在编译过程中改进了内存对齐和缓存未命中以提高性能,但我对此的了解仍然非常有限。

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


[编辑]

因此,我删除了宏的使用并隔离运行两个版本,并尝试添加易失性关键字、“内存”破坏和输出的“+&r”约束,并且性能现在与cpp_sum相同。

不过,如果我删除volatile关键字和“内存”破坏它,我仍然可以获得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;
}
Run Code Online (Sandbox Code Playgroud)

Pet*_*des 8

编译器正在将内联汇编从重复循环中提升出来,从而将其提升到计时区域之外。

如果您的目标是性能,请访问 https://gcc.gnu.org/wiki/DontUseInlineAsm。首先花时间学习的有用的东西是 SIMD 内在函数(以及它们如何编译为 asm),例如使用单个 AVX2 指令_mm256_add_epi64添加 4x 。uint64_t请参阅https://stackoverflow.com/tags/sse/info(编译器可以对像这样的简单求和进行适当的自动矢量化,如果您使用较小的数组并在定时区域内放置重复循环,您可以看到好处以获得一些缓存命中。)

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

回复:在 进行基准测试-O0,是的,编译器在默认一致调试的情况下使汇编变慢-O0,并且根本不尝试优化。当它双手被绑在背后时,击败它并不是什么太大的挑战。


为什么你asm可以被提升到定时区域之外

如果没有asm volatile,您的asm语句就是您告诉编译器的输入的纯函数,这些输入是指针、长度和初始值sum=0。它不包括指向的内存,因为您没有"m"为此使用虚拟输入。(我如何表明可以使用内联 ASM 参数*指向*的内存?

如果没有"memory"破坏,你的 asm 语句就不会被排序。函数调用,因此 GCC 将 asm 语句提升到循环之外。有关破坏效果的更多详细信息, 请参阅Google 的“DoNotOptimize()”函数如何强制语句排序"memory"

查看https://godbolt.org/z/KeEMfoMvo上的编译器输出,看看它如何内联到main. -O2和更高的启用-finline-functions,而-O1仅启用-finline-functions-called-once,而这不是staticinline因此它必须在来自其他编译单元的调用的情况下发出独立的定义。

75ns 只是std::chrono一个几乎空的定时区域周围函数的定时开销。 它实际上正在运行,只是不在定时区域内。如果您单步执行整个程序的 asm,或者例如在 asm 语句上设置断点,您可以看到这一点。在对可执行文件进行 asm 级调试时,您可以通过放置像mov $0xdeadbeef, %eaxbefore一样的时髦指令来帮助自己找到它xor %eax,%eax,您可以在调试器的反汇编输出中搜索该指令(例如 GDB 的layout asm或;请参阅https://layout reg底部的 asm 调试提示)/stackoverflow.com/tags/x86/info)。是的,您确实经常想看看编译器在调试内联汇编时做了什么,它如何填充您的约束,因为踩到它的脚趾是一种非常现实的可能性。

请注意,没有的"memory"破坏者 asm volatile仍会让 GCC在语句的两次调用之间执行公共子表达式消除 (CSE)asm。就像您在定时区域内放置一个重复循环来测试足够小的数组以适应某种级别的缓存的性能一样。

健全性检查您的基准

这是正常阅读吗

你竟然要问这个,这太疯狂了。 9999999975ns 内的 8 字节整数的内存带宽99999999 * 8 B / 75 ns= 10666666 GB/s,而快速双通道 DDR4 可能会达到 32 GB/s。(或者缓存带宽,如果它那么大,但事实并非如此,所以你的代码在内存上遇到瓶颈)。

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

即使使用 AVX-512,uint64_t每个核心也增加了 2/时钟 8 倍,但编译器不会重写您的内联汇编;与使用普通 C++ 或内在函数相比,这会违背其目的。

这很明显只是std::chrono来自接近空的定时区域的定时开销。


Asm 代码审查:正确性

如上所述,如何指示可以使用内联 ASM 参数*指向*的内存?

您还缺少一个&早期的 clobber 声明"+&r"(sum),理论上它会让它选择与输入之一相同的寄存器作为总和。但由于sum也是一个输入,因此只有当numberslength也是一个输入时,它才能做到这一点0可以这样做。

这是一个难以抉择的问题,是在 asm 内对输出进行异或清零更好"=&r",还是使用"+&r"清零并将其留给编译器更好。对于循环计数器来说,这是有意义的,因为编译器根本不需要知道这一点。但是,通过手动为其选择 RAX(使用 clobber),您将阻止编译器选择sum在 RAX 中生成代码,就像它希望非内联函数一样。虚拟[idx] "=&r" (dummy)输出操作数将使编译器为您选择适当宽度的寄存器,例如intptr_t


Asm 代码审查:性能

正如 David Wohlferd 所说:xor %eax, %eax将 RAX 归零。隐式零扩展保存 REX 前缀。(机器代码中的代码大小为 1 个字节。机器代码通常越小越好。)

如果你不打算做任何比 GCC 本身没有-ftree-vectorize或有-mgeneral-regs-only或更聪明的事情,那么手写汇编似乎不值得-mno-sse2(即使它是 x86-64 的基线,内核代码通常需要避免 SIMD 寄存器) 。但我想它可以作为内联汇编约束如何工作的学习练习,以及测量的起点。并获得一个基准测试,以便您可以测试更好的循环。

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

但是您的循环最多可以在每个时钟周期运行 1x 8 字节负载,因为循环分支可以运行 1/时钟,并且add mem, %[sum]具有 1 个周期的循环携带依赖关系sum.

这可能会最大化 DRAM 带宽(在硬件预取器的帮助下),例如 8 B/周期 * 4GHz = 32GB/s,现代台式机/笔记本电脑 Intel CPU 可以为单核(但不能是大型 Xeon)进行管理。但是,有了足够快的 DRAM 和/或相对较慢的 CPU,即使 DRAM 也可以避免成为瓶颈。但与 L3 或 L2 缓存带宽相比,DRAM 带宽的目标相当低。

因此,即使您想继续使用不带movdqu/的标量代码paddq(或者更好地获得内存源的对齐边界paddq,如果您想花费一些代码大小来优化此循环),您仍然可以使用两个寄存器累加器sum展开你在最后添加。这公开了一些指令级并行性,允许每个时钟周期加载两个内存源。


您还可以避免cmp,这可以减少循环开销。更少的微指令可以让乱序执行者看得更远。

获取指向数组末尾的指针和从-length上到零的索引。喜欢(arr+len)[idx]for(idx=-len ; idx != 0 ; idx++). 对于某些硬件预取器来说,在某些 CPU 上向后循环数组的情况会更糟,因此通常不建议将其用于通常受内存限制的循环。

另请参阅微融合和寻址模式- 索引寻址模式只能在 Intel Haswell 及更高版本的后端保持微融合,并且仅适用于像addRMW 这样的指令及其目标寄存器。

因此,最好的选择是一个循环,其中一个指针增量和 2 到 4 个使用它的添加指令,以及cmp/jne底部的 a 。


归档时间:

查看次数:

243 次

最近记录:

3 年,3 月 前