c++ 为什么在x86-64 GCC上__int128_t比long long快?

42fyovps  于 2024-01-09  发布在  其他
关注(0)|答案(2)|浏览(246)

这是我的测试代码:

  1. #include <chrono>
  2. #include <iostream>
  3. #include <cstdlib>
  4. using namespace std;
  5. using ll = long long;
  6. int main()
  7. {
  8. __int128_t a, b;
  9. ll x, y;
  10. a = rand() + 10000000;
  11. b = rand() % 50000;
  12. auto t0 = chrono::steady_clock::now();
  13. for (int i = 0; i < 100000000; i++)
  14. {
  15. a += b;
  16. a /= b;
  17. b *= a;
  18. b -= a;
  19. a %= b;
  20. }
  21. cout << chrono::duration_cast<chrono::milliseconds>(chrono::steady_clock::now() - t0).count() << ' '
  22. << (ll)a % 100000 << '\n';
  23. x = rand() + 10000000;
  24. y = rand() % 50000;
  25. t0 = chrono::steady_clock::now();
  26. for (int i = 0; i < 100000000; i++)
  27. {
  28. x += y;
  29. x /= y;
  30. y *= x;
  31. y -= x;
  32. x %= y;
  33. }
  34. cout << chrono::duration_cast<chrono::milliseconds>(chrono::steady_clock::now() - t0).count() << ' '
  35. << (ll)x % 100000 << '\n';
  36. return 0;
  37. }

字符串
测试结果如下:

  1. $ g++ main.cpp -o main -O2
  2. $ ./main
  3. 2432 1
  4. 2627 1


在x64 GNU/Linux上使用GCC 10.1.0,无论是使用-O2优化还是未优化,__int128_t总是比long long快一点。
intdouble都比long long快得多; long long已成为最慢的类型。
这是怎么发生的?

nszi6y05

nszi6y051#

性能差异来自GCC/Clang在此特定情况下的128位除法/模数**效率。
实际上,在我的系统以及godboltsizeof(long long) = 8sizeof(__int128_t) = 16上都是如此,因此前者的操作是由本机指令执行的,而后者不是(因为我们专注于64位平台)。加法,乘法和减法在__int128_t上较慢。但是,16字节类型(x86 GCC/Clang上的__divti3__modti3)的除法/取模内置函数比原生idiv指令(至少在Intel处理器上相当慢)快得惊人。
如果我们深入研究GCC/Clang内置函数的实现(此处仅用于__int128_t),我们可以看到__modti3使用条件语句(在调用__udivmodti4时)。英特尔处理器可以更快地执行代码,因为:

  • taken分支可以很好地预测在这种情况下,因为它们总是相同的(也因为循环执行了数百万次);
  • 除法/模数被拆分为更快的本地指令,这些指令大多可以 * 由多个CPU端口并行执行 *(并且受益于乱序执行)。div指令 * 仍然在大多数可能的路径中使用 *(特别是在这种情况下);
  • div/idiv指令的执行时间占总执行时间的大部分,因为它们的延迟非常高**。div/idiv指令不能并行执行,因为循环依赖性。但是,div的 * 延迟低于idiv *,使得前者更快。

请注意,两种实现的性能可能因架构而异(由于CPU端口的数量、分支预测能力和idiv指令的延迟/吞吐量)。实际上,例如,latency of a 64-bit idiv instruction在Skylake上需要41-95个周期,而在AMD Ryzen处理器上需要8-41个周期。div的延迟分别约为6-这意味着基准性能测试结果在Ryzen处理器上应该会有很大的不同(在128位的情况下,由于额外的指令/分支成本,可能会看到相反的效果)。

0ejtzxu1

0ejtzxu12#

TL:DR:__int128除法帮助函数在内部执行无符号div reg64(在值为正且上半部分为0的一些分支之后)。在Ice Lake之前的Intel CPU上,64-位div比GCC为有符号long long内联的有符号idiv reg64快。快得足以弥补帮助程序的所有额外开销函数,以及其他操作的扩展精度。
你可能不会在AMD CPU上看到这种效果:long long会像预期的那样更快,因为idiv r64在性能上与div r64足够相似。
即使在较旧的Intel CPU上,unsigned long long也比unsigned __int128快。例如,在我的3.9GHz的i7- 6700 k(Skylake)上(在perf stat下运行以确保测试期间的CPU频率):

  • 2097(i128)vs. 2332(i64)-您的原始测试(连续运行CPU频率预热)
  • 2075(u128)vs. 1900(u64)-无符号版本。u128除法与i128除法的分支稍少,但i64与u64的主要区别在于dividiv

此外,从这样一个非常具体的微基准测试中得出任何“一般”的结论是一个坏主意。不过,深入研究为什么扩展精度__int128类型在这个除法基准测试中能够更快,因为正数小到足以容纳32位整数。(有趣的事实:如果数字适合32位,clang与一些调优选项使代码使用32位除法,例如如果dividend|divisor的高半部分为无符号全零。这在旧的Intel上是值得的,但不应该在AMD上,或英特尔自冰湖。)
你的基准测试非常重视除法,每次迭代都要做两次(/%),尽管它比其他操作要昂贵得多,而且在大多数代码中使用得很少(例如,对整个数组求和,然后除以一次以获得平均值。
您的基准测试也没有配置级别的并行性:每一步都有一个对前一步的数据依赖性,这可以防止自动向量化或任何显示较窄类型的优点的东西,或者更宽CPU管道的好处,比如桤木Lake的每周期6 uops与Skylake的(或Core 2的)4 uops。最近的AMD中的类似宽度,如6 uops / clock in Zen 3 and 4,以及Zen 1和2中的5指令或6 uops的限制,以较少者为准。
(It也不小心避免预热效果,比如第一个定时区域在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。即128位加法与add/decode,乘法与一个mul低半部分的全乘,以及叉积的2x非扩展imul。是的,这些比int64_t的单指令等效慢。
但是Godbolt没有向你展示libgcc帮助函数的asm,它甚至在“编译到二进制”和反汇编模式下也没有反汇编它们(而不是通常的编译器asm文本输出),因为它动态链接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编译后,使用gdb手动单步执行对__divti3的调用。

  1. # Inputs: dividend = RSI:RDI, divisor = RCX:RDX
  2. # returns signed quotient RDX:RAX
  3. | >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.
  4. 0x7ffff7c4fd44 <__divti3+4> push r12
  5. 0x7ffff7c4fd46 <__divti3+6> mov r11,rdi
  6. 0x7ffff7c4fd49 <__divti3+9> mov rax,rdx 0x7ffff7c4fd4c <__divti3+12> xor edi,edi
  7. 0x7ffff7c4fd4e <__divti3+14> push rbx
  8. 0x7ffff7c4fd4f <__divti3+15> mov rdx,rcx
  9. 0x7ffff7c4fd52 <__divti3+18> test rsi,rsi # check sign bit of dividend (and jump over a negation)
  10. 0x7ffff7c4fd55 <__divti3+21> jns 0x7ffff7c4fd6e <__divti3+46>
  11. ... taken branch to
  12. | >0x7ffff7c4fd6e <__divti3+46> mov r10,rdx
  13. 0x7ffff7c4fd71 <__divti3+49> test rdx,rdx # check sign bit of divisor (and jump over a negation), note there was a mov rdx,rcx earlier
  14. 0x7ffff7c4fd74 <__divti3+52> jns 0x7ffff7c4fd86 <__divti3+70>
  15. ... taken branch to
  16. >0x7ffff7c4fd86 <__divti3+70> mov r9,rax
  17. 0x7ffff7c4fd89 <__divti3+73> mov r8,r11
  18. 0x7ffff7c4fd8c <__divti3+76> test r10,r10 # check high half of abs(divisor) for being non-zero
  19. 0x7ffff7c4fd8f <__divti3+79> jne 0x7ffff7c4fdb0 <__divti3+112> # falls through: small-number fast path
  20. 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)
  21. 0x7ffff7c4fd94 <__divti3+84> jbe 0x7ffff7c4fe00 <__divti3+192> # falls through: small-number fast path
  22. 0x7ffff7c4fd96 <__divti3+86> mov rdx,rsi
  23. 0x7ffff7c4fd99 <__divti3+89> mov rax,r11
  24. 0x7ffff7c4fd9c <__divti3+92> xor esi,esi
  25. >0x7ffff7c4fd9e <__divti3+94> div r9 #### Do the actual division ###
  26. 0x7ffff7c4fda1 <__divti3+97> mov rcx,rax
  27. 0x7ffff7c4fda4 <__divti3+100> jmp 0x7ffff7c4fdb9 <__divti3+121>
  28. ...taken branch to
  29. >0x7ffff7c4fdb9 <__divti3+121> mov rax,rcx
  30. 0x7ffff7c4fdbc <__divti3+124> mov rdx,rsi
  31. 0x7ffff7c4fdbf <__divti3+127> test rdi,rdi # check if the result should be negative
  32. 0x7ffff7c4fdc2 <__divti3+130> je 0x7ffff7c4fdce <__divti3+142>
  33. ... taken branch over a neg rax / adc rax,0 / neg rdx
  34. >0x7ffff7c4fdce <__divti3+142> pop rbx
  35. 0x7ffff7c4fdcf <__divti3+143> pop r12
  36. 0x7ffff7c4fdd1 <__divti3+145> ret
  37. ... return back to the loop body that called it

字符串
Intel CPUs (since IvyBridge) have zero-latency mov(除了Ice Lake),所以所有这些开销不会显著恶化关键路径延迟(这是您的瓶颈)。或者至少不足以弥补idivdiv之间的差异。
分支由分支预测和推测执行处理,只有当实际输入寄存器值相同时才检查预测。分支每次都以相同的方式进行,因此分支预测学习起来很简单。由于除法非常慢,因此有足够的时间让无序的exec赶上。
64-位操作数大小的整数除法在Intel CPU上非常慢,即使数字实际上很小,适合32位整数,并且用于有符号整数除法的额外微码甚至更昂贵。
例如,在我的Skylake(i7- 6700 k)上,https://uops.info/显示(table search result

*idiv r64是前端的56 uops,延迟从41到95周期(从除数到商,我认为这是相关的情况)。
*div r64是前端的33 uop,延迟从35到87周期。(对于相同的延迟路径)。

延迟最好的情况发生在小的股息或小的股息或什么,我永远不记得。
类似于GCC在软件中对128位除法进行64位除法的分支,我认为CPU微码在内部进行64位除法,操作范围更窄,可能是32位,只有10个uop用于有符号或无符号,延迟更低。(Ice Lake改进了除法器,因此64位除法并不比32位慢多少。)
这就是为什么你发现long longint慢得多的原因。在很多情况下,如果涉及内存带宽或SIMD,它的速度差不多,或者是一半。(每128位向量宽度只有2个元素,而不是4个)。

**AMD CPU更有效地处理64位操作数大小,性能仅取决于实际值,因此对于具有相同数字的div r32与div r64,性能大致相同。

顺便说一句,实际值往往是a=1814246614 / b=1814246613 = 1,然后是a=1 % b=1814246612(每次迭代b减1)。只有商=1的除法测试看起来很愚蠢。(第一次迭代可能不同,但我们在第二次及以后进入这种状态。)
除了除法之外,整数运算的性能在现代CPU上并不依赖于数据。(当然,除非有 * 编译时 * 常量允许发出不同的asm。就像除以一个常量,当在编译时计算一个乘法逆时,成本要低得多。)
回复:double:请参阅浮点除法与浮点乘法,了解除法与乘法。FP除法通常很难避免,而且它的性能在更多情况下都是相关的,所以它处理得更好。
相关:

展开查看全部

相关问题