繁体   English   中英

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

[英]Why is __int128_t faster than long long on x86-64 GCC?

这是我的测试代码:

#include <chrono>
#include <iostream>
#include <cstdlib>
using namespace std;

using ll = long long;

int main()
{
    __int128_t a, b;
    ll x, y;

    a = rand() + 10000000;
    b = rand() % 50000;
    auto t0 = chrono::steady_clock::now();
    for (int i = 0; i < 100000000; i++)
    {
        a += b;
        a /= b;
        b *= a;
        b -= a;
        a %= b;
    }
    cout << chrono::duration_cast<chrono::milliseconds>(chrono::steady_clock::now() - t0).count() << ' '
         << (ll)a % 100000 << '\n';

    x = rand() + 10000000;
    y = rand() % 50000;
    t0 = chrono::steady_clock::now();
    for (int i = 0; i < 100000000; i++)
    {
        x += y;
        x /= y;
        y *= x;
        y -= x;
        x %= y;
    }
    cout << chrono::duration_cast<chrono::milliseconds>(chrono::steady_clock::now() - t0).count() << ' '
         << (ll)x % 100000 << '\n';

    return 0;
}

这是测试结果:

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

在 x64 GNU/Linux 上使用 GCC 10.1.0,无论是使用 -O2 优化还是未优化, __int128_t总是比long long快一点。

intdouble都比long long快得多; long long已成为最慢的类型。

这是怎么发生的?

在这种特定情况下,性能差异来自 GCC/Clang的 128 位除法/模数的效率

事实上,在我的系统和godbolt上, sizeof(long long) = 8sizeof(__int128_t) = 16 因此,前者的操作由本机指令执行,而后者不是(因为我们专注于 64 位平台)。 __int128_t的加法、乘法和减法速度较慢。 但是,用于 16 字节类型的除法/模数的内置函数(x86 GCC/Clang 上的__divti3__modti3 )比本机idiv指令快得惊人(这相当慢,至少在英特尔处理器上)。

如果我们更深入地研究 GCC/Clang 内置函数的实现(此处仅用于__int128_t ),我们可以看到__modti3使用条件(在调用__udivmodti4时)。 英特尔处理器可以更快地执行代码,因为:

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

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

TL:DR: __int128除法辅助函数在内部最终会执行一个无符号的div reg64 (在对值为正且上半部分为0进行一些分支之后)。 在 Intel CPU 上,64 位div比 GCC 内联为 signed long long的签名idiv reg64更快。 速度快到足以弥补帮助程序 function 的所有额外开销,以及其他操作的扩展精度。

您可能不会在 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 的主要区别在于dividiv的唯一区别。

此外,从像这样的非常具体的微基准中得出任何一般性结论都是一个坏主意。 有趣的是,深入研究为什么扩展精度__int128类型在这个除法基准测试中能够更快,正数小到足以适应 32 位 integer。


您的基准非常重视除法,每次迭代( /% )执行两次,即使它其他操作昂贵得多,并且在大多数代码中使用的频率要低得多。 (例如,对整个数组求和,然后除以一次以获得平均值。)

您的基准测试也没有指令级并行性:每一步都对上一步有数据依赖性。 这可以防止自动矢量化或任何会显示更窄类型的一些优点的东西。

(在 CPU 达到最大 turbo 之前,避免第一个定时区域变慢等预热效果也是不小心的。性能评估的惯用方式?但这比你的定时区域的几秒钟发生得快得多,所以这就是这里没有问题。)

128-bit integer division (especially signed) is too complicated for GCC to want to inline, so gcc emits a call to a helper function, __divti3 or __modti3 . (TI = tetra-integer,GCC 的内部名称 integer 是int大小的 4 倍。)这些函数记录在GCC-internals 手册中。

您可以在 Godbolt compiler-explorer上看到编译器生成的 asm。 即带有add/adc 的128 位加法,与低半部分的1 mul全乘的乘法,以及叉积的2x 非加宽imul 是的,这些比int64_t的单指令等价物慢。

但是 Godbolt 并没有向您展示 libgcc 辅助函数的 asm。 即使在“编译为二进制”和反汇编模式下(而不是通常的编译器 asm 文本输出),它也不会反汇编它们,因为它动态链接 libgcc_s 而不是libgcc.a

扩展精度有符号除法是通过在必要时取反并对 64 位块进行无符号除法来完成的,然后在必要时修复结果的符号。

输入都小且为正,不需要实际的否定(只需测试和分支)。 对于小数也有快速路径(高半除数 = 0,商将适合 64 位),这里就是这种情况。 最终结果是通过__divti3的执行路径如下所示:

这是在我的 Arch GNU/Linux 系统上使用g++ -g -O3 int128-bench.cpp -o int128-bench.O3编译后,使用 gdb 手动单__divti3用 __divti3,使用 gcc -2。

# 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

英特尔 CPU(自 IvyBridge 以来)具有零延迟mov ,因此所有这些开销都不会显着恶化关键路径延迟(这是您的瓶颈)。 或者至少不足以弥补idivdiv之间的差异。

分支由分支预测和推测执行处理,仅在实际输入寄存器值相同时才检查预测。 每次分支都以相同的方式进行,因此分支预测的学习是微不足道的。 由于除法太慢了,乱序的 exec 有足够的时间赶上。

64 位操作数大小的 integer 除法在 Intel CPU 上非常慢,即使数字实际上很小并且适合 32 位 integer,而签名 Z157DB7DF530023572E815 的额外微码甚至更昂贵。

例如在我的 Skylake (i7-6700k) 上, https://uops.info/显示( 表搜索结果

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

延迟最好的情况发生在小商或小红利之类的东西上,我永远不记得是哪一个。

类似于 GCC 在软件中针对 64 位进行 128 位除法的分支,我认为 CPU 微码在内部进行 64 位除法,就更窄的操作而言,可能是 32 位,对于有符号的只有 10 微秒或无符号,延迟低得多。 (Ice Lake 改进了除法器,因此 64 位除法并不比 32 位慢多少。)

这就是为什么在这个基准测试中你发现long longint慢得多。 在很多情况下,如果涉及 memory 带宽或 SIMD,则速度大致相同或一半。 (每 128 位向量宽度只有 2 个元素,而不是 4 个)。

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

顺便说一句,实际值往往类似于a=1814246614 / b=1814246613 = 1,然后a=1 % b=1814246612 (每次迭代b减少 1)。 只有用 quotient=1 测试除法似乎很愚蠢。 (第一次迭代可能会有所不同,但我们会在第二次及以后进入这个 state。)

除除法以外的 integer 操作的性能不依赖于现代 CPU 的数据。 (当然,除非有编译时常量允许发出不同的 asm。就像在编译时计算乘法逆时,除以常量一样便宜得多。)

re: double : 请参阅浮点除法与浮点乘法以了解除法与乘法。 FP除法通常更难避免,而且它的性能在更多情况下相关,因此处理得更好。


有关的:

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM