TL:DR: __int128 除法辅助函数在内部最终会执行无符号的div reg64(在一些值为正数且上半部分为0 的分支之后)。 64 位 div 在 Intel CPU 上比 GCC 内联用于签名 long long 的签名 idiv reg64 更快。速度快到足以弥补辅助函数的所有额外开销,以及其他操作的扩展精度。
您可能不会在 AMD CPU 上看到这种效果:long long 会像预期的那样更快,因为idiv r64 的性能与那里的div r64 足够相似。
即使在 Intel CPU 上,unsigned long long 也比 unsigned __int128 快,例如在我的 i7-6700k (Skylake) 上,频率为 3.9GHz(在 perf stat 下运行以确保测试期间的 CPU 频率):
- 2097 (i128) 与 2332 (i64) - 您的原始测试(背靠背运行 CPU 频率预热)
- 2075 (u128) 与 1900 (u64) - 未签名版本。 u128 分区与 i128 的分支略少,但 i64 与 u64 的主要区别在于
div 与 idiv。
此外,像这样从非常具体的微基准得出任何一般结论都是一个坏主意。有趣的是,深入研究为什么扩展精度 __int128 类型在这个除法基准测试中能够更快,因为正数小到足以容纳 32 位整数。
您的基准高度偏重于除法,每次迭代执行两次(/ 和 %),即使它远比其他操作昂贵并且在大多数代码中使用的频率要低得多。 (例如,对整个数组求和,然后除以一次以获得平均值。)
您的基准测试也没有指令级并行性:每一步都对上一步有数据依赖性。这可以防止自动矢量化或任何会显示更窄类型的一些优点的东西。
(在 CPU 达到最大 turbo 之前,避免像第一个定时区域变慢这样的热身效果也是不小心的。Idiomatic way of performance evaluation?。但这比你的定时区域的几秒钟发生得快得多,所以这就是这里没有问题。)
128 位整数除法(尤其是带符号的)对于 GCC 来说太复杂而无法内联,因此 gcc 会发出对辅助函数 __divti3 或 __modti3 的调用。 (TI = tetra-integer,GCC 的内部名称,表示大小为 int 的 4 倍的整数。)这些函数记录在 GCC-internals manual 中。
您可以在 the Godbolt compiler-explorer 上查看编译器生成的 asm。即带有 add/adc 的 128 位加法,与低半部分的一个 mul 的全乘相乘,以及叉积的 2 倍非扩展 imul。是的,它们比 int64_t 的单指令等价物要慢。
但 Godbolt 并没有向您展示 libgcc 辅助函数的 asm。即使在“编译成二进制”和反汇编模式(而不是通常的编译器 asm 文本输出)下,它也不会反汇编它们,因为它动态链接 libgcc_s 而不是 libgcc.a。
扩展精度有符号除法是通过在必要时取反并对 64 位块进行无符号除法来完成的,然后在必要时修正结果的符号。
输入小而正,不需要实际的否定(只是测试和分支)。 对于小数也有快速路径(高半除数 = 0,商将适合 64 位),这里就是这种情况。 最终结果是通过 @ 的执行路径987654355@ 看起来像这样:
这是在使用 gcc-libs 10.1.0-2 在我的 Arch GNU/Linux 系统上使用 g++ -g -O3 int128-bench.cpp -o int128-bench.O3 编译之后,使用 gdb 手动单步执行对 __divti3 的调用。
# Inputs: dividend = RSI:RDI, divisor = RCX:RDX
# returns signed quotient RDX:RAX
| >0x7ffff7c4fd40 <__divti3> endbr64 # in case caller was using CFE (control-flow enforcement), apparently this instruction has to pollute all library functions now. I assume it's cheap at least in the no-CFE case.
│ 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 # check sign bit of dividend (and jump over a negation)
│ 0x7ffff7c4fd55 <__divti3+21> jns 0x7ffff7c4fd6e <__divti3+46>
... taken branch to
| >0x7ffff7c4fd6e <__divti3+46> mov r10,rdx
│ 0x7ffff7c4fd71 <__divti3+49> test rdx,rdx # check sign bit of divisor (and jump over a negation), note there was a mov rdx,rcx earlier
│ 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 # check high half of abs(divisor) for being non-zero
│ 0x7ffff7c4fd8f <__divti3+79> jne 0x7ffff7c4fdb0 <__divti3+112> # falls through: small-number fast path
│ 0x7ffff7c4fd91 <__divti3+81> cmp rax,rsi # check that quotient will fit in 64 bits so 128b/64b single div won't fault: jump if (divisor <= high half of dividend)
│ 0x7ffff7c4fd94 <__divti3+84> jbe 0x7ffff7c4fe00 <__divti3+192> # falls through: small-number fast path
│ 0x7ffff7c4fd96 <__divti3+86> mov rdx,rsi
│ 0x7ffff7c4fd99 <__divti3+89> mov rax,r11
│ 0x7ffff7c4fd9c <__divti3+92> xor esi,esi
│ >0x7ffff7c4fd9e <__divti3+94> div r9 #### Do the actual division ###
│ 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 # check if the result should be negative
│ 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
Intel CPUs (since IvyBridge) have zero-latency mov,因此所有这些开销都不会显着恶化关键路径延迟(这是您的瓶颈)。或者至少不足以弥补idiv 和div 之间的差异。
分支由分支预测和推测执行处理,仅在实际输入寄存器值相同时才检查预测。每次分支都以相同的方式进行,因此分支预测的学习是微不足道的。由于除法太慢了,乱序的 exec 有足够的时间赶上。
64 位操作数大小的整数除法在 Intel CPU 上非常慢,即使数字实际上很小并且适合 32 位整数,而且用于有符号整数除法的额外微码更加昂贵。
例如在我的 Skylake (i7-6700k) 上,https://uops.info/ 显示 (table search result)
-
idiv r64 是前端的 56 微秒,延迟从 41 到 95 个周期(从除数到商,我认为这是相关案例)。
-
div r64 是前端 33 微秒,延迟从 35 到 87 个周期。(对于相同的延迟路径)。
延迟最好的情况发生在小商或小红利之类的东西上,我永远记不起来是哪一个。
类似于 GCC 在软件中对 64 位进行 128 位除法的分支,我认为 CPU 微码在内部进行 64 位除法,就更窄的操作而言,可能只有 10 的 32 位有符号或无符号的微指令,延迟要低得多。 (Ice Lake 改进了除法器,因此 64 位除法并不比 32 位慢多少。)
这就是为什么您发现long long 在this 基准测试中比int 慢得多。在很多情况下,如果涉及内存带宽或 SIMD,它的速度大致相同,或者速度减半。 (每 128 位向量宽度只有 2 个元素,而不是 4 个)。
AMD CPU 更有效地处理 64 位操作数大小,其性能仅取决于实际值,因此对于具有相同数字的 div r32 与 div r64 大致相同。
顺便说一句,实际值往往类似于a=1814246614 / b=1814246613 = 1,然后是a=1 % b=1814246612(b 每次迭代都会减少 1)。 只用 quotient=1 测试除法似乎很愚蠢。(第一次迭代可能不同,但我们会在第二次及以后进入这种状态。)
除除以外的整数运算的性能不依赖于现代 CPU。 (当然,除非有 compile-time 常量允许发出不同的 asm。就像在编译时计算乘法逆时,除以常量一样便宜得多。)
回复:double:请参阅Floating point division vs floating point multiplication 了解除法与乘法。 FP除法通常更难避免,而且它的性能在更多情况下相关,因此处理得更好。
相关: