【问题标题】:does unrolling loops in x86-64 actually make code faster?x86-64 中的展开循环实际上会使代码更快吗?
【发布时间】:2014-01-03 04:53:58
【问题描述】:

我假设每个人都知道“展开循环的含义”。以防万一我稍后会给出一个具体的例子。我要问的问题是...... 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 字节)。

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

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

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

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

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

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

【问题讨论】:

  • 这取决于循环及其展开方式。我知道从 XFree86 代码中删除 Duff 的设备会使 Xorg 更快。
  • 展开因子通常小于 16,尤其是在无法预先计算迭代次数的情况下。
  • 虽然这不是一般情况,但我展示的具体情况几乎总是会在 16 次循环迭代中的第 1 次或第 2 次出现分支。这对决策有何影响?通过“展开因子”,您的意思是不应将超过 16 个元素展开到一个序列中,并且应该在循环中执行任何进一步的操作?
  • 你看过agner.org/optimize/microarchitecture.pdf吗?以及agner.org/optimize 的其他书籍?
  • 为什么您认为多次调用相同的代码(并因此将其缓存)是不现实的?大多数现实世界的工作负载都应该以这种方式运行,至少在它们的性能关键阶段是这样。

标签: performance loops caching assembly loop-unrolling


【解决方案1】:

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

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

【讨论】:

  • 感谢您的提示。我读了你提到的英特尔手册。我还查看了agner雾手册中loop指令的指令时序,发现时序与其他条件分支指令完全相同。由于它还在循环计数器上执行减法,这似乎使loop 比任何替代方法都快。这就是为什么我不理解您的评论,或者 agner fog 文档中关于优化汇编语言的类似评论!但是loop 在没有推土机和打桩机的每个 CPU 上都非常慢。哎呀——在所有英特尔 CPU 上也很慢!!!
【解决方案2】:

这很复杂。

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

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

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

【讨论】:

  • 看起来我将实施三种不同的方法来为自己找出答案。无聊,但似乎有必要。
  • 除了超级中心、超级关键的循环本身就值得优化到最后一个循环......例程/函数如何影响软件应用程序的整体吞吐量才是最重要的。显然,人们倾向于忽略这样一个事实,即用展开的循环填充 L1 和 L2 缓存会弹出其他可能很快执行的例程。例如,它可能会在例程/函数返回给调用者之后弹出刚刚执行的代码部分。所以在“各种应用”的“典型用途”的背景下测试功能的想法显然是明智的。
【解决方案3】:

这种级别的优化高度依赖于微架构,主题过于宽泛,无法提供全面的答案。 GMP 库的 mpn/x86_64 目录包含 README 和用于不同 u-arch 的带注释的程序集。

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

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

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

【讨论】:

  • 我将查看“Knuth 算法 D”,看看它提供了什么。当然,总体思路很吸引人,这就是为什么我基于该想法实现了乘法函数(通过累积 x64 128-bit = 64-bit * 64-bit 指令的中间结果。但是,我仍然想知道它是否可以击败 newton-raphson。可能我会实现所有三种技术并比较它们。昨晚写了第一个(虽然我仍然需要测试有效答案)。现在我将写 newton-raphson,并尝试找到“Knuth algorithm D”的描述。希望在互联网?也许?
  • 那些 Agner Fog 文档太棒了 - 大约 2 年前发现的。
  • @honestann - 我相信你会找到其他references。这确实是值得的,因为商调整“概念”扩展到其他算法 - 巴雷特缩减、2×1 或 3×2 分词案例等。我不确定牛顿的收支平衡 - Raphson - 对于固定的 1024 位长度,它可能有点高。
猜你喜欢
  • 1970-01-01
  • 2014-03-06
  • 2020-04-30
  • 1970-01-01
  • 2017-10-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2018-09-21
相关资源
最近更新 更多