hon*_*ann 5 performance assembly caching loops loop-unrolling
我想每个人都知道“展开循环意味着什么”。以防万一,我稍后会给出一个具体的例子。我要问的问题是……在 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
Run Code Online (Sandbox Code Playgroud)
这是循环展开的样子:
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)
Run Code Online (Sandbox Code Playgroud)
我应该指出,上面的例子具有代表性,但很可能不是本次讨论的最佳选择。原因是条件分支数量较多。虽然我相信“未采用的分支”与其他指令类似,但该假设在某些情况下可能不正确(这可能很模糊)。因此,如果这方面相关,我们可以假设这两个条件分支只是简单的指令,例如本次讨论中的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 仅在 4 纳秒内就加载了下一个缓存行?或者,对于可以并行执行的指令,我们是否可以合理地期望 CPU 仅在 2 纳秒内加载下一个缓存行?
根据我对动态 RAM 的了解,这似乎……很紧张。我知道当 CPU 访问连续地址时,它可以使 RAS(高地址位)锁存,并通过 CAS 更快地输出连续的 64 位或 128 位内存块。但是,我们真的可以期望 CPU 在 2 纳秒或 4 纳秒内读取 64 字节吗?即 4 或 8 个从 DRAM 读取周期,具体取决于 CPU 每次读取操作读取的是 64 位(8 字节)还是 128 位(16 字节)。
我的具体代码可能会进一步解决这个问题。根据该算法的性质,我的代码需要首先比较分子和分母的最高有效部分,然后向下处理每次访问的较低地址。这是否会使自动预取不太可能起作用?
我见过很多人测试循环与展开循环。但我见过的每一个实例都存在致命的设计缺陷。它不断地一遍又一遍地调用相同的例程......通常是数百万次......以便获得足够大的计时器值来理解。可是等等!与大多数应用程序一样,代码可能只是偶尔调用我的 1024 位除法函数。这与我看到的这些测试完全不同,这些测试本质上确保指令保留在 L1 指令缓存中,并且访问的数据保留在 L1 数据缓存中。
当然,如果您确保代码和数据已经在 L1 缓存中,则展开循环会更快!呃!
这些都不是代表性的测试——甚至差得远!
这些测试确实测量了“最佳情况性能”,但根本无法代表正常的程序执行。但为了决定如何最好地编写 64 位汇编语言代码(或编译器的代码发射器部分),我们需要更加现实地考虑我们的前提。或者至少我是这么认为的,这就是我问这个问题的原因。
有人彻底、现实地解决过这些问题吗?