【问题标题】:Why is __int128_t faster than long long on x86-64 GCC?为什么 __int128_t 在 x86-64 GCC 上比 long long 快?
【发布时间】:2020-11-11 17:08:11
【问题描述】:

这是我的测试代码:

#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 已成为最慢的类型。

这是怎么发生的?

【问题讨论】:

  • 我认为这与long long 无关。如果你将xy 定义为__int128_t 你也会得到这样的区别godbolt.org/z/1e1YeE
  • 乱序执行会在多大程度上影响这里的结果?乍一看,这两个测试看起来完全相互独立,在这种情况下,处理器不是可以随意乱序执行它们吗?要求测试我对这个主题的潜在天真理解。
  • @Rich OOO 不会并行执行两个循环,可能是因为循环代码中的依赖关系,OOO 在这里效率不会很高。
  • @Rich:硬件 OoO exec 只能在短距离上工作,其中“短”是 Skylake 上最多约 224 条指令(ROB 大小:blog.stuffedcow.net/2013/05/measuring-rob-capacity)。这是沿着执行路径测量的,每次通过循环的行程都会运行循环体。见my answer here。理论上,融合这两个循环仅适用于像 Transmeta Crusoe 这样在内部进行动态重新编译的非常规 CPU,而不适用于按执行顺序查看指令的当前 CPU。
  • 但是,是的,这个糟糕的基准测试并没有做任何预热,所以唯一能将它从 CPU 频率和其他完全抛弃它的预热效果中拯救出来的是它运行一个 lot 的迭代,所以这只是杯水车薪。 Idiomatic way of performance evaluation?。此外,它通过与其他操作一样多地执行分区性能来非常重视分区性能。对于大多数用例来说非常不切实际。

标签: c++ performance x86-64 cpu-architecture integer-division


【解决方案1】:

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

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

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

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

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

【讨论】:

  • 那么为什么在这种情况下long long 执行得更快呢? godbolt.org/z/GznvoT
  • @AlexLop。也许你读错了? long long 在这种情况下执行速度较慢。
  • @xxhxx 看看我的链接。我换了顺序。第一个循环适用于 long long 类型,其时间为 ~300 与 ~800
  • 但显然 AMD 可以很好地处理宽寄存器中的小数字,如果数字相同,div r64 的速度与 div r32 大致相同。
  • 您的回答仅解释了为什么辅助函数中的额外指令不会使其变慢。他们没有解释为什么它更快。如果您单步浏览__modti3__divti3,您会看到它们运行div r8div r9。实际的答案是它是 div 而不是 idiv,对于 Intel CPU 上的 64 位操作数大小,它比 idiv 快一些。这些辅助函数不会手动进行除法,它们使用div r64 构建块构建扩展精度除法。小的非负数是最简单的转换,只减少到一个除法,但不是零。
【解决方案2】:

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 的主要区别在于 dividiv

此外,像这样从非常具体的微基准得出任何一般结论都是一个坏主意。有趣的是,深入研究为什么扩展精度 __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,因此所有这些开销都不会显着恶化关键路径延迟(这是您的瓶颈)。或者至少不足以弥补idivdiv 之间的差异。

分支由分支预测和推测执行处理,仅在实际输入寄存器值相同时才检查预测。每次分支都以相同的方式进行,因此分支预测的学习是微不足道的。由于除法太慢了,乱序的 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 longthis 基准测试中比int 慢得多。在很多情况下,如果涉及内存带宽或 SIMD,它的速度大致相同,或者速度减半。 (每 128 位向量宽度只有 2 个元素,而不是 4 个)。

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

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

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

回复:double:请参阅Floating point division vs floating point multiplication 了解除法与乘法。 FP除法通常更难避免,而且它的性能在更多情况下相关,因此处理得更好。


相关:

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 2011-11-08
    • 2016-11-25
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    相关资源
    最近更新 更多