TL:DR: __int128 division helper functions internally end up doing an unsigned div reg64 (after some branching on the values being positive and the upper halves being 0). 64-bit div is faster on Intel CPUs than the signed idiv reg64 that GCC inlines for signed long long. Faster by enough to make up for all the extra overhead of the helper function, and extended-precision for the other operations.
You probably wouldn't see this effect on AMD CPUs: long long would be faster as expected because idiv r64 is similar enough in perf to div r64 there.
And unsigned long long is faster than unsigned __int128 even on Intel CPUs, for example on my i7-6700k (Skylake) at 3.9GHz (run under perf stat to be sure of CPU frequency during the test):
- 2097 (i128) vs. 2332 (i64) - your original test (run back-to-back for CPU freq warm-up)
- 2075 (u128) vs. 1900 (u64) - unsigned versions. Slightly less branching in u128 division vs. i128, but major difference for i64 vs. u64 where the only difference is div vs. idiv.
Also, drawing any general conclusions from a very specific micro-benchmark like this would be a bad idea. It is interesting to dig into why exactly the extended-precision __int128 type manages to be faster in this division benchmark with positive numbers small enough to fit in a 32-bit integer, though.
你的基准测试过于偏向除法运算,每次迭代都执行两次除法运算(
/
和
%
),即使它比其他操作昂贵得多,在大多数代码中使用频率也要低得多。(例如:对整个数组求和,然后再除以数组长度得到平均值。)
你的基准测试也没有指令级并行处理:每个步骤都依赖于前一个步骤的数据。这会阻止自动矢量化或显示较窄类型的一些优点。
(还要注意避免预热效应,例如第一个计时区域直到CPU达到最大睿频才变慢。
性能评估的惯用方法是什么? 但这比你计时区域的几秒钟发生得快得多,所以这里不是问题。)
128位整数除法(尤其是有符号的)太复杂了,GCC不想内联,所以GCC会调用一个帮助函数
__divti3
或
__modti3
。(TI = tetra-integer,GCC对4倍于
int
大小的整数的内部名称。)这些函数在
GCC-internals manual中有文档说明。
你可以在
the Godbolt compiler-explorer上看到编译器生成的汇编代码。即128位加法使用add/adc,乘法使用一个
mul
完整乘以低半部分,和两个非扩展的
imul
交叉乘积。是的,这些比
int64_t
的单指令等效性要慢。
但是Godbolt不会显示libgcc辅助函数的汇编代码。它甚至不在“编译为二进制文件”和反汇编模式下(而不是通常的编译器汇编文本输出)中对其进行反汇编,因为它动态链接了libgcc_s而不是
libgcc.a
。
扩展精度有符号除法通过必要时取反并对64位块进行无符号除法来完成,然后如有必要修正结果的符号。
如果两个输入都很小且为正,则不需要实际取反(只需测试和分支)。
还有快速路径可用于小数值(高半部分除数= 0,并且商将适合64位),这在这种情况下是成立的。最终执行
__divti3
的路径如下:
这是我在Arch GNU/Linux系统上使用gcc-libs 10.1.0-2编译,并使用“g++ -g -O3 int128-bench.cpp -o int128-bench.O3”命令手动单步调用到
__divti3
的结果。
| >0x7ffff7c4fd40 <__divti3> endbr64
│ 0x7ffff7c4fd44 <__divti3+4> push r12
│ 0x7ffff7c4fd46 <__divti3+6> mov r11,rdi
│ 0x7ffff7c4fd49 <__divti3+9> mov rax,rdx │ 0x7ffff7c4fd4c <__divti3+12> xor edi,edi
│ 0x7ffff7c4fd4e <__divti3+14> push rbx
│ 0x7ffff7c4fd4f <__divti3+15> mov rdx,rcx
│ 0x7ffff7c4fd52 <__divti3+18> test rsi,rsi
│ 0x7ffff7c4fd55 <__divti3+21> jns 0x7ffff7c4fd6e <__divti3+46>
... taken branch to
| >0x7ffff7c4fd6e <__divti3+46> mov r10,rdx
│ 0x7ffff7c4fd71 <__divti3+49> test rdx,rdx
│ 0x7ffff7c4fd74 <__divti3+52> jns 0x7ffff7c4fd86 <__divti3+70>
... taken branch to
│ >0x7ffff7c4fd86 <__divti3+70> mov r9,rax
│ 0x7ffff7c4fd89 <__divti3+73> mov r8,r11
│ 0x7ffff7c4fd8c <__divti3+76> test r10,r10
│ 0x7ffff7c4fd8f <__divti3+79> jne 0x7ffff7c4fdb0 <__divti3+112>
│ 0x7ffff7c4fd91 <__divti3+81> cmp rax,rsi
│ 0x7ffff7c4fd94 <__divti3+84> jbe 0x7ffff7c4fe00 <__divti3+192>
│ 0x7ffff7c4fd96 <__divti3+86> mov rdx,rsi
│ 0x7ffff7c4fd99 <__divti3+89> mov rax,r11
│ 0x7ffff7c4fd9c <__divti3+92> xor esi,esi
│ >0x7ffff7c4fd9e <__divti3+94> div r9
│ 0x7ffff7c4fda1 <__divti3+97> mov rcx,rax
│ 0x7ffff7c4fda4 <__divti3+100> jmp 0x7ffff7c4fdb9 <__divti3+121>
...taken branch to
│ >0x7ffff7c4fdb9 <__divti3+121> mov rax,rcx
│ 0x7ffff7c4fdbc <__divti3+124> mov rdx,rsi
│ 0x7ffff7c4fdbf <__divti3+127> test rdi,rdi
│ 0x7ffff7c4fdc2 <__divti3+130> je 0x7ffff7c4fdce <__divti3+142>
... taken branch over a neg rax / adc rax,0 / neg rdx
│ >0x7ffff7c4fdce <__divti3+142> pop rbx
│ 0x7ffff7c4fdcf <__divti3+143> pop r12
│ 0x7ffff7c4fdd1 <__divti3+145> ret
... return back to the loop body that called it
自IvyBridge开始,Intel CPU具有零延迟的mov指令,因此所有开销不会显著恶化关键路径延迟(即瓶颈)。或者至少不足以弥补idiv和div之间的差异。
分支由分支预测和推测执行处理,在实际输入寄存器值相同时才检查预测。分支每次都走同一条路,因此分支预测很容易学习。由于除法非常缓慢,乱序执行有足够的时间赶上。
在Intel CPU上,64位操作数大小的整数除法非常缓慢,即使数字实际上很小并且适合32位整数,带符号整数除法的额外微码也更加昂贵。
例如,在我的Skylake(i7-6700k)上,
https://uops.info/ 显示(
表搜索结果)
idiv r64
的前端为56个uop,延迟为41到95个周期(从除数到商,这是我认为相关的情况)。
div r64
的前端为33个uop,延迟为35到87个周期。(对于同一延迟路径)。
最佳延迟情况发生在小商或小被除数等情况下,我总是记不住哪个。
类似于GCC在128位除法方面在64位方面进行的分支,我认为CPU微码在狭窄操作方面进行了64位除法,可能是仅有10个uop的32位除法(带符号或无符号),延迟要低得多。 (Ice Lake改进了分频器,因此64位除法与32位除法的速度差别不大。)
这就是为什么您发现
此基准测试中long long比int慢得多的原因。在许多情况下,它大致相同,如果涉及内存带宽或SIMD,则速度为一半。 (每个128位矢量宽度只有2个元素,而不是4个)。
AMD CPU更有效地处理64位操作数大小,性能仅取决于实际值,因此对于具有相同数字的div r32和div r64大致相同。
顺便提一下,实际值往往是类似于
a=1814246614 / b=1814246613
= 1,然后
a=1 % b=1814246612
(每次迭代
b
减1)。只测试商为1的除法似乎非常愚蠢。(第一次迭代可能不同,但我们在第二次及以后进入这种状态。)
现代CPU上整数运算的性能除了除法之外并不依赖数据。(当然,如果存在编译时常量允许发出不同的汇编代码,则除以常量时使用计算时求得的乘法逆元要便宜得多。)
关于
double
:请参阅
浮点数除法与浮点数乘法以进行除法和乘法的比较。FP除法通常更难避免,并且其性能在更多情况下都很重要,因此它被处理得更好。
相关: