5

我假设每个人都知道“展开循环的含义”。以防万一我稍后会给出一个具体的例子。我要问的问题是...... x86-64 汇编语言中的展开循环真的可以让代码更快吗?我将解释为什么我开始质疑这个概念。

对于那些不熟悉“展开循环”一词的人,这里是我现在正在编写的代码中的一个循环示例:

    movq   $15, %rcx                  # rcx = loop iterations
s1024_divide_compare_loop:
    movq   (%r14, %rcx, 8), %rax      # rax = numerator
    subq   (%r15, %rcx, 8), %rax      # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)
    subq   $1, %rcx                   # rcx = rcx - 1 : one less loop interation
    jns    s1024_divide_compare_loop  # check next lower 64-bit portion of n & d

这是该循环展开的样子:

    movq   120(%r14), %rax            # rax = numerator
    subq   120(%r15), %rax            # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

    movq   112(%r14), %rax            # rax = numerator
    subq   112(%r15), %rax            # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

    movq   104(%r14), %rax            # rax = numerator
    subq   104(%r15), %rax            # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

    movq   96(%r14), %rax             # rax = numerator
    subq   96(%r15), %rax             # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)
 #
 # insert 11 more copies of the above 4 lines (with different offsets) here
 #
    movq   0(%r14), %rax              # rax = numerator
    subq   0(%r15), %rax              # flags = numerator - denominator
    js     s1024_divide_done          # divide done: (numerator < denominator)
    jnz    s1024_upshift_done         # do the divide: (numerator > denominator)

我应该注意到,上面的例子是有代表性的,但可能不是这个讨论的最佳选择。原因是大量的条件分支。虽然我相信“未采用分支”与其他指令类似,但在某些情况下该假设可能不正确(这可能是模糊的)。因此,如果这方面是相关的,我们可以假设这两个条件分支只是简单的指令,如movqaddq用于此讨论(尽管如果不同,则分别处理这两种情况是可取的)。

哦,最后两个条件:

#1:这个问题只适用于在单线程上运行的代码。

#2:这个问题仅适用于快速的现代 ~4GHz CPU(FX-8350 等)。

好的,现在让我质疑展开循环是否真的明智的想法。

这些新处理器的运行频率为 4GHz,有时每个周期可以执行两到三条指令。在我上面的代码中,前 2 条指令不能并行执行,可能最后 3 条指令也不能。但是带有可以并行执行的指令的循环只会使我的问题更加相关。

因此,每条指令可以在 0.25 纳秒内执行(或者对于并行执行的指令来说更短)。这意味着执行 4 条指令需要 1 纳秒。每组 4 条指令大致消耗 16 字节,我假设它是高速缓存行的 1/4。因此,4 组 4 行应该需要 4ns 来执行,此时它已经退出缓存行并需要另一个。

这就是问题变得更加复杂的地方。

因此,在 16 条指令和整个展开循环的 1/4 之后,CPU 需要执行更多指令。如果 CPU 正在运行代码的循环版本,它将再次执行完全相同的指令,这些指令肯定仍然在 L1 缓存中,因此继续执行全口径 CPU 速度。

但是,我们是否可以合理地期望 CPU 仅在 4ns 内加载下一个缓存行?或者在可以并行执行的指令的情况下,我们是否可以合理地期望 CPU 仅在 2ns 内加载下一个缓存行?

根据我对动态 RAM 的了解,这似乎……很紧。我知道当 CPU 访问连续地址时,它可以使 RAS(高地址位)保持锁定状态,并使用 CAS 更快地输出连续的 64 位或 128 位内存块。但是,我们真的可以期望 CPU 在 2ns 或 4ns 内读取 64 字节吗?即 4 或 8 个从 DRAM 读取周期,具体取决于 CPU 每次读取操作是读取 64 位(8 字节)还是 128 位(16 字节)。

我的具体代码可能会进一步解决这个问题。根据该算法的性质,我的代码需要首先比较分子和分母的最重要部分,然后在每次访问时向下处理较低的地址。这是否会使自动预取不太可能起作用?

我见过很多人测试循环与展开循环。但是我看到的每一个实例都有一个致命的设计缺陷。它一遍又一遍地调用相同的例程......通常是数百万次......为了获得足够大的计时器值来理解。可是等等!像大多数应用程序一样,代码可能只是偶尔调用我的 1024 位除法函数。这与我看到的这些测试完全不同,它们本质上确保指令都保留在 L1 指令高速缓存中,并且访问的数据保留在 L1 数据高速缓存中。

当然,如果您确保代码和数据已经在 L1 缓存中,展开循环会更快!呸!

这些不是具有代表性的测试——甚至没有接近!

这些测试确实测量了“最佳情况下的性能”,但根本无法代表正常的程序执行。但是要决定如何最好地编写 64 位汇编语言代码(或编译器的代码发射器部分),我们需要在我们的前提下更加现实。或者至少我是这么认为的,这就是我问这个问题的原因。

有没有人以彻底和现实的方式解决过这些问题?

4

3 回答 3

3

英特尔为有兴趣为其处理器调整代码的人提供了优化手册,其中包含一些循环展开的处理。我不希望有一个简单的好答案,但它可能会有所帮助。

顺便说一句,loop应该避免该指令。多年来,它一直比等效指令慢。

于 2014-01-03T06:06:58.147 回答
1

这种优化水平高度依赖于微架构,而且主题过于广泛,无法提供全面的答案。GMP库的目录mpn/x86_64有 README 和不同 u-arch 的带注释的程序集。

所以是的 - GMP 的贡献者已经彻底处理了这些问题。在某些情况下,展开确实提供了加速,尽管它在现代 x86-64 上并不那么有效。适合解码指令/微指令缓存的循环、循环对齐、分支预测、避免部分停顿等也很重要。Agner Fog 的优化手册是另一个极好的资源。

最后,使用汇编编写的按位移位/减法[非]恢复除法永远不会与用 C 编写的每字多精度除法实现竞争。GMP 不使用它是有原因的。经典的“Knuth 算法 D”需要一些努力(笔和纸)才能理解 - 特别是商估计/商调整条件。否则,我担心你在这里的努力可能会白费。

使用固定的操作数大小,您可以将规范化的除数和工作余数存储在堆栈上。该算法实际上由乘法指令的成本支配,因为除法指令仅用于估计步骤。《应用密码学手册》第 14 章是实现细节的一个很好的参考。

于 2014-01-03T10:56:21.407 回答
1

情况很复杂。

要回答你的最后一个问题,是和不是。有很多关于优化的研究,并进行了深入的分析。但是,由于您提到的某些原因,几乎不可能证明一种优化实际上会对实际性能产生影响。对一个优化所做的每一项更改都会影响其他优化,并且工作负载可能会产生不同的影响。

但是可以很容易地自己测试它(嗯......你很容易开始遇到问题和相互矛盾的结果,我想)。GCC允许您打开和关闭特定的优化,因此找到您要测试的程序的源代码并试一试。

我猜?它将归结为缓存。如果(平均而言)缓存性能得到改善,那么循环展开是值得的。

于 2014-01-03T06:08:19.927 回答