我写了两个函数来获取数组的和,第一个用C++编写,另一个用内联汇编(x86-64)编写,我在设备上比较了这两个函数的性能。
-
如果-O标志在编译期间未启用,则具有内联汇编的函数几乎比C++版本快4-5倍。
cpp time : 543070068 nanoseconds cpp time : 547990578 nanoseconds asm time : 185495494 nanoseconds asm time : 188597476 nanoseconds
-
如果-O标志设置为-O1,它们将产生相同的性能。
cpp time : 177510914 nanoseconds cpp time : 178084988 nanoseconds asm time : 179036546 nanoseconds asm time : 181641378 nanoseconds
-
但是,如果我试图将-O标志设置为-O2或/O3,那么使用内联汇编编写的函数的性能将达到不寻常的2-3位纳秒,这非常快(至少对我来说,请耐心等待,因为我在汇编编程方面没有扎实的经验,所以我不知道它与用C++编写的程序相比有多快或有多慢。)
cpp time : 177522894 nanoseconds cpp time : 183816275 nanoseconds asm time : 125 nanoseconds asm time : 75 nanoseconds
我的问题
-
为什么启用-O2或-O3后,用内联汇编编写的这个数组求和函数如此之快?
-
这是正常读数还是性能的计时/测量有问题?
-
或者我的内联汇编函数可能有问题?
-
如果数组求和的内联汇编函数是正确的,性能读数也是正确的,为什么C++编译器未能为C++版本优化一个简单的数组求和函数,并使其与内联汇编版本一样快?
我还推测,在编译过程中,内存对齐和缓存未命中可能会得到改善,以提高性能,但我对这方面的了解仍然非常有限。
除了回答我的问题,如果你有什么要补充的,请随时补充,我希望有人能解释,谢谢!
[EDIT]
因此,我删除了使用宏和隔离运行两个版本,并尝试添加volatile关键字,一个"存储器";clobber和"+&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, %%raxnt"
"%=:nt"
"addq (%[numbers], %%rax, 8), %[sum]nt"
"incq %%raxnt"
"cmpq %%rax, %[length]nt"
"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() << " nanosecondsn";
}
#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。首先花时间学习SIMD内部函数(以及它们如何编译到asm)是有用的,比如_mm256_add_epi64
,用一条AVX2指令添加4xuint64_t
。看见https://stackoverflow.com/tags/sse/info(对于这样的简单求和,编译器可以很好地自动向量化,如果你使用一个较小的数组,并在定时区域内放置一个重复循环,以获得一些缓存命中,你可以看到它的好处。)
如果你想在各种CPU上测试asm的实际速度,你可以在一个独立的静态可执行文件或从C++调用的函数中进行测试。https://stackoverflow.com/tags/x86/info有一些不错的性能链接。
关于:在-O0
进行基准测试,是的,编译器使用默认的-O0
进行缓慢的asm,即一致调试,并且根本不尝试优化。当它双手被绑在背后时,打败它并不是什么挑战。
为什么你的asm
可以被吊出定时区域
如果不是asm volatile
,您的asm
语句是您告诉编译器有关的输入的纯函数,这些输入是指针、长度和sum=0
的初始值。它不包含指向内存,因为您没有使用伪"m"
输入。(如何指示可以使用内联ASM参数指向的内存*?)
如果没有"memory"
clobber,您的asm语句将不会按wrt.函数调用的顺序排列,因此GCC会将asm语句从循环中提升出来查看谷歌如何';s的"DoNotOptimize()"函数强制执行语句排序,以获取有关"memory"
clobber效果的更多详细信息。
查看上的编译器输出https://godbolt.org/z/KeEMfoMvo并查看它是如何内联到CCD_ 12中的。-O2
及更高版本启用-finline-functions
,而-O1
仅启用-finline-functions-called-once
,而这不是static
或inline
,因此在其他编译单元调用时,它必须发出独立定义。
75ns只是std::chrono
函数在几乎为空的定时区域附近的定时开销它实际上正在运行,只是不在定时区域内。如果您单步执行整个程序的asm,或者例如在asm语句上设置断点,就可以看到这一点。当对可执行文件进行asm级调试时,您可以通过在xor %eax,%eax
之前放一条像mov $0xdeadbeef, %eax
这样的时髦指令来帮助自己找到它,这是您可以在调试器的反汇编输出中搜索的内容(如GDB的layout asm
或layout reg
;请参阅https://stackoverflow.com/tags/x86/info)。是的,确实经常想看看编译器在调试内联asm时做了什么,它是如何填充约束的,因为踩到它的脚趾是一种非常真实的可能性。
请注意,如果在asm
语句的两次调用之间没有函数调用,那么不带asm volatile
的"memory"
clobber仍然会让GCC在这两次调用中执行Common Subexpression Elimination(CSE)。就像在一个定时区域中放入一个重复循环,以测试一个小到可以放入某个级别缓存的阵列的性能一样。
Sanity检查您的基准
这是正常读取吗
你甚至不得不这么问,这太疯狂了。75ns中的99999999
8字节整数将是99999999 * 8 B / 75 ns
=10666666 GB/s的内存带宽,而快速双通道DDR4可能达到32 GB/s。(或者缓存带宽,如果它那么大,但不是,所以你的代码在内存上会遇到瓶颈)。
或者,4GHz的CPU必须以每个时钟周期99999999 / (75*4)
=333333.33条add
指令的速度运行,但在现代CPU上,流水线只有4到6个uops宽,循环分支的分支吞吐量最多为1。(https://uops.info/和https://agner.org/optimize/)
即使使用AVX-512,每个内核也需要2/clock 8xuint64_t
的添加,但编译器不会重写内联asm;与使用普通C++或内部函数相比,这将违背其目的。
很明显,这只是来自一个几乎为空的定时区域的std::chrono
定时开销。
Asm代码审查:正确性
如上所述,如何指示可以使用内联ASM参数指向的内存*?
在"+&r"(sum)
中,您还缺少一个&
早期的clobber声明,理论上它可以为sum选择与其中一个输入相同的寄存器。但由于sum
也是一个输入,只有当numbers
或length
也是0
时,它才能做到这一点。
对于"=&r"
输出,是在asm中对零进行异或更好,还是使用"+&r"
并将零留给编译器更好,这有点悬而未决。对于循环计数器来说,这是有意义的,因为编译器根本不需要知道这一点。但是,通过手动为其选择RAX(使用clobber),可以防止编译器选择让代码在RAX中生成sum
,就像它想要的非内联函数一样。伪[idx] "=&r" (dummy)
输出操作数将使编译器为您选择一个适当宽度的寄存器,例如intptr_t
。
Asm代码审查:性能
正如David Wohlferd所说:xor %eax, %eax
至零RAX。隐式零扩展保存REX前缀。(机器代码中的代码大小为1字节。机器代码越小越好。)
如果你不打算在没有-ftree-vectorize
、-mgeneral-regs-only
或-mno-sse2
的情况下单独做任何比GCC更聪明的事情,那么手工编写asm似乎就不值得了(尽管这是x86-64的基线,但内核代码通常需要避免SIMD寄存器)。但我想这是一个学习内联asm约束如何工作的练习,也是衡量的起点。为了让基准测试发挥作用,您可以测试更好的循环。
典型的x86-64 CPU每个时钟周期可以进行2次加载(自Sandybridge以来的Intel,自K8以来的AMD),或者在Alder Lake上进行3/clock加载。在具有AVX/AVX2的现代CPU上,每个负载可以是32字节宽(或具有AVX-512的64字节),L1d命中率的最佳情况。或者更像是最近的英特尔上只有L2命中的1/clock,这是一个合理的缓存阻塞目标。
但是,您的循环最多可以在每个时钟周期运行1x 8字节负载,因为循环分支可以运行1/clock,而add mem, %[sum]
通过sum
有一个1周期的循环携带依赖项。
这可能会使DRAM带宽最大化(在硬件预取器的帮助下),例如8 B/周期*4GHz=32GB/s,现代台式机/笔记本电脑Intel CPU可以为单核(但不是大Xeons)管理这些带宽。但有了足够快的DRAM和/或相对较慢的CPU,即使是DRAM也可以避免成为瓶颈。但是,与L3或L2高速缓存带宽相比,DRAM带宽的目标是相当低的。
因此,即使您想继续使用没有movdqu
/paddq
的标量代码(或者更好地为内存源paddq
找到对齐边界,如果您想花费一些代码大小来优化这个循环),您仍然可以为sum
展开两个寄存器累加器,并在最后添加它们。这暴露了一些指令级并行性,允许每个时钟周期加载两个内存源。
您还可以避免cmp
,这可以减少环路开销。更少的uop让无序的exec看得更远。
获取一个指向数组末尾的指针,并从-length
向上指向零进行索引。类似于(arr+len)[idx]
和for(idx=-len ; idx != 0 ; idx++)
。对于一些HW预取器来说,在某些CPU上向后循环阵列的情况稍差,因此通常不建议用于经常绑定内存的循环。
另请参阅微融合和寻址模式-索引寻址模式只能在Intel Haswell及更高版本的后端保持微融合,并且只能用于RMW其目标寄存器的add
等指令。
因此,你最好的选择是一个循环,其中有一个指针增量和2到4个使用它的加法指令,底部有一个cmp/jne
。