为什么循环总是编译成 "do...while" 样式(尾部跳转)?

Why are loops always compiled into "do...while" style (tail jump)?

在尝试理解汇编(启用编译器优化)时,我看到了这种行为:

像这样的一个非常基本的循环

outside_loop;
while (condition) {
     statements;
}

经常被编译成(伪代码)

    ; outside_loop
    jmp loop_condition    ; unconditional
loop_start:
    loop_statements
loop_condition:
    condition_check
    jmp_if_true loop_start
    ; outside_loop

但是,如果没有开启优化,它会编译成正常可理解的代码:

loop_condition:
    condition_check
    jmp_if_false loop_end
    loop_statements
    jmp loop_condition  ; unconditional
loop_end:

根据我的理解,编译后的代码更像这样:

goto condition;
do {
    statements;
    condition:
}
while (condition_check);

我看不到巨大的性能提升或代码可读性提升,那么为什么经常出现这种情况?这种循环样式是否有名称,例如 "trailing condition check"?

相关:asm循环基础:


循环内的指令/微指令更少 = 更好。在循环外构建代码来实现这一点通常是个好主意。

有时这需要 "loop rotation"(剥离第一次迭代的一部分,以便实际的循环体在底部有条件分支)。所以你做了一些第一次迭代,可能会完全跳过循环,然后进入循环。有时您还需要在循环后的一些代码来完成最后一次迭代。

如果最后一次迭代是特殊情况,例如您需要跳过的商店。这使您可以将 while(1) {... ; if(x)break; ...; } 循环实现为 do-while,或将 multiple-condition 循环的条件之一放在底部。

其中一些优化与软件流水线相关或启用软件流水线,例如为下一次迭代加载一些东西。 (x86 上的 OoO exec 使得 SW 流水线现在不是很重要,但它仍然对像许多 ARM 这样的 in-order 内核有用。并且使用多个累加器展开对于在缩减循环中隐藏 loop-carried FP 延迟仍然非常有价值像点积或数组的总和。)

do{}while() 是所有体系结构上 asm 中循环的规范/惯用结构,习惯它。 IDK 如果有它的名称;我会说这样的循环有一个"do while structure"。如果需要名称,可以调用 while() 结构 "crappy unoptimized code" 或 "written by a novice"。底部的 :P Loop-branch 是通用的,甚至不值得一提 Loop Optimization。你总是这样做。

这种模式被广泛使用,以至于在对 branch-predictor 缓存中没有条目的分支使用静态分支预测的 CPU 上,预测未知的前向条件分支 not-taken,预测未知的后向分支采取(因为它们可能是循环分支)。请参阅 Matt Godbolt 博客上的 Static branch prediction on newer Intel processors 和 Agner Fog 在其微架构 PDF 开头的 branch-prediction 章节。

这个答案最终对所有内容都使用了 x86 示例,但其中大部分适用于所有架构。如果其他超标量/out-of-order 实现(如某些 ARM 或 POWER)也具有有限的 branch-instruction 吞吐量,无论是否采用,我都不会感到惊讶。但是当你在底部只有一个条件分支而没有无条件分支时,循环内的指令更少几乎是通用的。


如果循环可能需要 运行 零次 ,编译器通常会在循环外放置一个 test-and-branch 来跳过它,而不是跳转到底部的循环条件。 (即,如果编译器无法证明循环条件在第一次迭代时始终为真)。

顺便说一句,this paper calls transforming while() to if(){ do{}while; } an "inversion", but loop inversion usually means inverting a nested loop. (e.g. if the source loops over a row-major multi-dimensional array in the wrong order, a clever compiler could change for(i) for(j) a[j][i]++; into for(j) for(i) a[j][i]++; if it can prove it's correct.) But I guess you can look at the if() as a zero-or-one iteration loop. Fun fact, compiler devs teaching their compilers how to invert a loop (to allow auto-vectorization) for a (very) specific case is why SPECint2006's libquantum benchmark is "broken"。在一般情况下,大多数编译器无法反转循环,只是那些看起来几乎与 SPECint2006 中的循环完全相同的循环...


当您知道调用者不允许传递 size=0 或其他任何内容时,您可以通过在 C 中编写 do{}while() 循环来帮助编译器制作更紧凑的 asm(循环外的指令更少)保证循环 运行s 至少一次。

(对于有符号循环边界,实际上为 0 或负数。有符号循环计数器与无符号循环计数器是一个棘手的优化问题,尤其是当您选择比指针更窄的类型时;检查编译器的 asm 输出以确保它不是 sign-extending 如果你将它用作数组索引,则循环内的一个窄循环计数器很长时间。但请注意,signed 实际上是有帮助的,因为编译器可以假设 i++ <= bound 最终会变为 false,because signed overflow is UB 但 unsigned 不是。所以对于 unsigned,while(i++ <= bound) 是无限的,如果 bound = UINT_MAX。) size_t 通常是遍历数组的不错选择,但是,如果您想避免循环开销中的 x86-64 REX 前缀(为了节省代码大小)但要说服编译器不要浪费任何指令零或 sign-extending,这可能很棘手。


I can't see a huge performance boost

这是一个示例,其中优化将在 Haswell 之前的 Intel CPU 上提供 2 倍的加速,因为 P6 和 SnB/IvB 只能 运行 端口 5 上的分支,包括 not-taken 条件分支.

此静态性能分析所需的背景知识:Agner Fog's microarch guide (read the Sandybridge section). Also read his Optimizing Assembly guide, it's excellent. (Occasionally outdated in places, though.) See also other x86 performance links in the tag wiki. See also 由性能计数器实验支持的一些静态分析,以及融合与未融合域 uops 的一些解释。

您也可以使用 Intel 的 IACA software (Intel Architecture Code Analyzer) 对这些循环进行静态分析。

; sum(int []) using SSE2 PADDD (dword elements)
; edi = pointer,  esi = end_pointer.
; scalar cleanup / unaligned handling / horizontal sum of XMM0 not shown.

; NASM syntax
ALIGN 16          ; not required for max performance for tiny loops on most CPUs
.looptop:                 ; while (edi<end_pointer) {
    cmp     edi, esi    ; 32-bit code so this can macro-fuse on Core2
    jae    .done            ; 1 uop, port5 only  (macro-fused with cmp)
    paddd   xmm0, [edi]     ; 1 micro-fused uop, p1/p5 + a load port
    add     edi, 16         ; 1 uop, p015
    jmp    .looptop         ; 1 uop, p5 only

                            ; Sandybridge/Ivybridge ports each uop can use
.done:                    ; }

这是总共 4 个 fused-domain 微指令 (),因此它可以在每个时钟迭代一次时从 front-end 发出到 out-of-order 内核。但是在未融合域中有 4 个 ALU 微指令,而英特尔 pre-Haswell 只有 3 个 ALU 端口。

更重要的是,port5压力是瓶颈:这个循环每2个周期只能执行一次迭代因为cmp/jae和jmp都需要运行 在端口 5 上。其他 uops 窃取 port5 可能会降低实用性ut 略低于那个。

为 asm 惯用地编写循环,我们得到:

ALIGN 16
.looptop:                 ; do {
    paddd   xmm0, [edi]     ; 1 micro-fused uop, p1/p5 + a load port
    add     edi, 16         ; 1 uop, p015

    cmp     edi, esi        ; 1 uop, port5 only  (macro-fused with cmp)
    jb    .looptop        ; } while(edi < end_pointer);

立即注意,与其他一切无关,这是循环中少了一条指令。这种循环结构至少在从简单的 non-pipelined 8086 到 classic RISC(如早期的 MIPS)的所有方面都稍微好一些,特别是对于 long-running 循环(假设它们没有内存带宽瓶颈)。

Core2 和更高版本应该 运行 每个时钟迭代一次,如果内存不是 while(){}-结构化循环,速度是其两倍瓶颈(即假设 L1D 命中,或者实际上至少是 L2;这只是 SSE2 每个时钟 16 字节)。

这只有 3 fused-domain 微指令,因此自 Core2 以来,任何时钟都可以比每个时钟发出一个更好,或者如果问题组总是以采用的分支结束,则每个时钟只发出一个。

但重要的是端口 5 压力大大降低:只有 cmp/jb 需要它。其他 uops 可能会在某些时候被安排到 port5 并从 loop-branch 吞吐量中窃取周期,但这将是几个 % 而不是 2 倍。参见

大多数通常具有每 2 个周期一个 taken-branch 吞吐量的 CPU 仍然可以以每个时钟 1 个的速度执行微型循环。不过也有一些例外。 (我忘记了哪些 CPU 不能 运行 每个时钟 1 个紧密循环;也许 Bulldozer-family?或者可能只是像 VIA Nano 这样的 low-power CPU。)Sandybridge 和 Core2 绝对可以 运行 每个时钟一个紧密循环。他们甚至有循环缓冲区; Core2 在 instruction-length 解码之后但在常规解码之前有一个循环缓冲区。 Nehalem 和后来在供给 issue/rename 阶段的队列中回收微指令。 (除了带有微代码更新的 Skylake;由于 partial-register 合并错误,英特尔不得不禁用循环缓冲区。)

但是,在 xmm0 上存在 loop-carried 依赖链 :Intel CPU 有 1 个周期的延迟 paddd,所以我们'我们也正面临着这个瓶颈。 add esi, 16 也是 1 个周期的延迟。在 Bulldozer-family 上,即使是整数向量操作也有 2c 延迟,因此这会在每次迭代 2c 时成为循环瓶颈。 (AMD 因为 K8 和英特尔因为 SnB 可以 运行 每个时钟两次加载,所以我们无论如何都需要展开以获得最大吞吐量。)对于浮点,你 肯定 想要展开多个蓄能器。 .


如果我使用索引寻址模式,如 paddd xmm0, [edi + eax],我可以在循环条件下使用 sub eax, 16 / jnc。 SUB/JNC 可以在 Sandybridge-family 上 macro-fuse,但索引负载 would un-laminate on SnB/IvB(但在 Haswell 和更高版本上保持融合,除非您使用 AVX 形式)。

    ; index relative to the end of the array, with an index counting up towards zero
    add   rdi, rsi          ; edi = end_pointer
    xor   eax, eax
    sub   eax, esi          ; eax = -length, so [rdi+rax] = first element

 .looptop:                  ; do {
    paddd   xmm0, [rdi + rax]
    add     eax, 16
    jl    .looptop          ; } while(idx+=16 < 0);  // or JNC still works

(展开一些以隐藏指针增量的开销而不是使用索引寻址模式通常更好,尤其是对于存储,部分原因是索引存储不能在 Haswell+ 上使用 port7 存储 AGU。)

在 Core2/Nehalem add/jl 上不 macro-fuse,所以即使在 64 位模式下也是 3 fused-domain 微指令,而不依赖于 macro-fusion .同样适用于 AMD K8/K10/Bulldozer-family/Ryzen:没有融合循环条件,但 PADDD 与内存操作数是 1 m-op / uop.

在 SnB 上,paddd un-laminates 来自负载,但是 add/jl macro-fuse,所以又是 3 fused-domain 微指令。 (但在未融合的域中,只有 2 个 ALU 微指令 + 1 个负载,因此减少循环吞吐量的资源冲突可能更少。)

在 HSW 及更高版本上,这是 2 fused-domain 微指令,因为索引负载可以保持 micro-fused 与 PADDD 和 add/jl macro-fuses。 (Predicted-taken 在端口 6 上分支 运行,因此永远不会发生资源冲突。)

当然,循环最多只能运行 每个时钟最多 1 次迭代,因为即使对于微小循环也有分支吞吐量限制。如果您在循环内还有其他事情要做,这个索引技巧可能会有用。


但是所有这些循环都没有展开

是的,这夸大了循环开销的影响。 但是 gcc 即使在 -O3 默认情况下也不会展开(除非它决定 完全 展开)。它只展开 profile-guided 优化,让它知道哪些循环是热的。 (-fprofile-use)。您可以启用 -funroll-all-loops,但我只建议在 per-file 的基础上为您知道有一个需要它的热循环的编译单元这样做。或者甚至在 per-function 的基础上加上 __attribute__,如果有这样的优化选项的话。

所以这与 compiler-generated 代码高度相关。 (但是 clang 默认展开小循环 4 个,或小循环 2 个,极其重要的是,使用多个累加器来隐藏延迟。)


迭代次数非常少的好处:

考虑一下当循环体应该 运行 一次或两次时会发生什么:除了 do{}while.

之外还有更多的跳跃
  • 对于do{}while,执行是一个straight-line,没有被采纳的分支,底部有一个not-taken分支。这太棒了。

  • 对于可能 运行 循环零次的 if() { do{}while; },它有两个 not-taken 分支那还是很不错的。 (Not-taken 对于 front-end 比两者都被正确预测时的成本略低)。

  • 对于jmp-to-the-bottomjmp; do{}while(),是一个取无条件分支,一个取循环条件,然后循环分支为not-taken。这有点笨拙,但现代分支预测器非常好......

  • 对于while(){}结构,这是一个not-taken循环出口,一个在底部取jmp,然后一个取loop-exit分支在顶部。

随着迭代次数的增加,每个循环结构都会多做一个分支。 while(){} 每次迭代还多做一个 not-taken 分支,所以它很快变得明显更糟。

后两个循环结构对于小行程计数有更多的跳跃。


跳到底部对于 non-tiny 循环也有一个缺点,即如果有一段时间没有 运行,循环底部在 L1I 缓存中可能是冷的。代码获取/预取擅长以直线将代码带到 front-end,但如果预测没有足够早地预测分支,您可能会错过跳转到底部的代码。此外,并行解码可能已经(或可能已经)解码了循环的顶部,同时将 jmp 解码到底部。

有条件地跳过 do{}while 循环避免了所有这些:您只跳转到尚未 运行 的代码,如果您跳过的代码不应该 运行 完全没有。它通常预测得很好,因为很多代码实际上从未在循环中执行 0 次。 (即它可能是 do{}while,编译器只是没能证明它。)

跳到底部也意味着内核在 front-end 追逐两个采用的分支之前无法开始处理真正的循环体。

有些循环条件复杂的情况,这样写最简单,性能影响也小,但编译器往往会避免。


具有多个退出条件的循环:

考虑一个 memchr 循环,或一个 strchr 循环:它们必须在缓冲区的末尾(基于计数)或 implicit-length 字符串的末尾停止(0 字节)。但如果他们在结束前找到匹配项,他们也必须 break 退出循环。

所以你会经常看到这样的结构

do {
    if () break;

    blah blah;
} while(condition);

或者底部附近只有两个条件。理想情况下,您可以使用相同的实际指令测试多个逻辑条件(例如 5 < x && x < 25 使用 sub eax, 5 / cmp eax, 20 / ja .outside_range,用于范围检查的无符号比较技巧,或将其与OR) 但有时你不能,只需要使用 if()break 样式 loop-exit 分支以及正常的向后分支。


延伸阅读:

有点跑题了:

  • 内存带宽几乎总是很重要,但大多数现代 x86 CPU 上的单核不能使 DRAM 饱和这一点并不广为人知,而且 .

  • What Every Programmer Should Know About Memory?(我的回答对 Ulrich Drepper 的 well-known 优秀文章中发生的变化和仍然相关的内容进行了评论。)