我假设每个人都知道“展开循环的含义”。以防万一我稍后会给出一个具体的例子。我要问的问题是...... 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)
我应该注意到,上面的例子是有代表性的,但可能不是这个讨论的最佳选择。原因是大量的条件分支。虽然我相信“未采用分支”与其他指令类似,但在某些情况下该假设可能不正确(这可能是模糊的)。因此,如果这方面是相关的,我们可以假设这两个条件分支只是简单的指令,如movq
或addq
用于此讨论(尽管如果不同,则分别处理这两种情况是可取的)。
哦,最后两个条件:
#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 位汇编语言代码(或编译器的代码发射器部分),我们需要在我们的前提下更加现实。或者至少我是这么认为的,这就是我问这个问题的原因。
有没有人以彻底和现实的方式解决过这些问题?