【问题标题】:Why are loops always compiled into "do...while" style (tail jump)?为什么循环总是编译成“do...while”风格(尾跳)?
【发布时间】:2018-05-26 19:20:30
【问题描述】:

当试图理解程序集(启用编译器优化)时,我看到了这种行为:

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

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);

我看不到巨大的性能提升或代码可读性提升,那么为什么经常出现这种情况?这种循环样式是否有名称,例如“尾随条件检查”?

【问题讨论】:

  • 关于这个话题,我推荐阅读 Agner Fog 的 optimizing assembly 。特别是关于 loops 的第 12 节(第 89 页)。这个想法是消除循环内的无条件跳转。
  • 嗯,loop_start: 也可以在不执行 nops 后面的填充 jmp 的情况下对齐。虽然这不是关键卖点,但如果循环重复足够的时间 1-2 nops 以对齐未优化类型的代码,则不会造成明显的伤害。
  • @Ped7g:在现代 x86 上跳过一两条长 NOP 指令是不值得的。无论如何,现代 x86 CPU 上很少需要循环对齐。
  • 生成程序集的可读性与编译器无关。还有什么小问题只涉及 cmets,而不是代码生成。
  • 你看不到你所说的巨大的性能提升。嗯,你量过吗?

标签: performance loops assembly optimization micro-optimization


【解决方案1】:

相关:asm 循环基础:While, Do While, For loops in Assembly Language (emu8086)


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

有时这需要“循环旋转”(剥离第一次迭代的一部分,以便实际的循环体在底部有条件分支)。所以你做了一些第一次迭代,可能完全跳过循环,然后陷入循环。有时您还需要在循环之后编写一些代码来完成最后一次迭代。

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

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

do{}while() 是所有架构上 asm 中循环的规范/惯用结构,请习惯它。 如果有名称,请使用 IDK;我会说这样的循环有一个“do while 结构”。如果你想要名字,你可以将while() 结构称为“糟糕的未优化代码”或“由新手编写”。 :P 底部的循环分支是通用的,甚至不值得一提Loop Optimization。你总是这样做。

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

这个答案最终使用 x86 示例来处理所有内容,但其中大部分内容适用于所有架构。如果其他超标量/无序实现(如某些 ARM 或 POWER)也具有有限的分支指令吞吐量,无论它们是否被采用,我都不会感到惊讶。但是,当您只有底部的条件分支且没有无条件分支时,循环内的指令较少几乎是通用的。


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

顺便说一句,this paperwhile() 转换为 if(){ do{}while; } 称为“反转”,但循环反转通常意味着反转嵌套循环。 (例如,如果源以错误的顺序循环行主多维数组,聪明的编译器可以将for(i) for(j) a[j][i]++; 更改为for(j) for(i) a[j][i]++;,如果它可以证明它是正确的。)但我想你可以看看@987654356 @ 作为一个零或一的迭代循环。有趣的事实是,编译器开发人员教他们的编译器如何为(非常)特定情况反转循环(以允许自动矢量化)是why SPECint2006's libquantum benchmark is "broken"。大多数编译器在一般情况下不能反转循环,只有那些看起来几乎与 SPECint2006 中的一模一样的循环...


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

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


我看不到巨大的性能提升

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

此静态性能分析所需的背景知识:Agner Fog's microarch guide(阅读 Sandybridge 部分)。另请阅读他的优化组装指南,非常棒。 (不过,有时有些地方已经过时了。)另请参阅 标签 wiki 中的其他 x86 性能链接。另请参阅Can x86's MOV really be "free"? Why can't I reproduce this at all?,了解一些由性能计数器实验支持的静态分析,以及融合与非融合域微指令的一些解释。

您还可以使用 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 个融合域微指令 (with macro-fusion of the cmp/jae),因此它可以在每个时钟一次迭代中从前端发出到无序内核。但在未融合域中,有 4 个 ALU 微指令,而 Intel pre-Haswell 只有 3 个 ALU 端口。

更重要的是,port5 压力是瓶颈:这个循环每 2 个周期只能执行一次迭代,因为 cmp/jae 和 jmp 都需要在 port5 上运行。其他窃取端口 5 的微指令可能会将实际吞吐量降低到低于此值。

为 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);

请立即注意,与其他所有内容无关,这是循环中的少一条指令。从简单的非流水线 8086 到 classic RISC(如早期的 MIPS),这种循环结构至少稍微好一点,尤其是对于长时间运行的循环(假设它们不会成为内存带宽的瓶颈)。

Core2 及更高版本应该以每个时钟一次迭代运行此操作,如果内存不是瓶颈(即假设 L1D 命中,或至少实际上是 L2;这只是 SSE2 每个时钟 16 字节)。

这只是 3 个融合域微指令,因此自 Core2 以来的任何时间都可以以每时钟一个以上的速度发布,或者如果问题组总是以采用的分支结束,则每个时钟只能发布一个。

但重要的是端口 5 压力大大降低:只有 cmp/jb 需要它。其他微指令可能会在某些时候被安排到端口 5 并从循环分支吞吐量中窃取周期,但这将是几个 % 而不是 2 倍。请参阅How are x86 uops scheduled, exactly?

大多数通常具有每 2 个周期 1 个分支吞吐量的 CPU 仍然可以以每时钟 1 个的速度执行微小循环。不过也有一些例外。 (我忘记了哪些 CPU 不能以每时钟 1 次的速度运行紧密循环;也许是 Bulldozer 系列?或者可能只是一些低功耗 CPU,如 VIA Nano。)Sandybridge 和 Core2 绝对可以每时钟一次运行紧密循环。他们甚至有循环缓冲区; Core2 在指令长度解码之后但在常规解码之前有一个循环缓冲区。 Nehalem 和后来在提供问题/重命名阶段的队列中回收 uops。 (在带有微码更新的 Skylake 上除外;由于部分寄存器合并错误,英特尔不得不禁用循环缓冲区。)

但是,xmm0 上有循环承载的依赖链:英特尔 CPU 有 1 个周期的延迟 paddd,所以我们也正面临这个瓶颈。 add esi, 16 也是 1 个周期延迟。在 Bulldozer 系列上,即使是整数向量操作也有 2c 的延迟,因此每次迭代都会在 2c 处成为循环的瓶颈。 (从 K8 开始的 AMD 和从 SnB 开始的 Intel 每个时钟可以运行两个负载,所以我们无论如何都需要展开以获得最大吞吐量。)对于浮点,您肯定想要使用多个累加器展开。 Why does mulss take only 3 cycles on Haswell, different from Agner's instruction tables? (Unrolling FP loops with multiple accumulators).


如果我使用索引寻址模式,例如paddd xmm0, [edi + eax],我可以在循环条件下使用sub eax, 16 / jnc。 SUB/JNC 可以在 Sandybridge 系列上进行宏融合,但索引负载 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 上不进行宏融合,因此即使在 64 位模式下,这也是 3 个融合域微指令,而不依赖于宏融合。 AMD K8/K10/Bulldozer-family/Ryzen 相同:没有融合循环条件,但带有内存操作数的 PADDD 是 1 m-op / uop。

在 SnB 上,paddd 从负载中解压,但添加/jl 宏熔断器,因此又是 3 个熔断域微指令。 (但在未融合域中,只有 2 个 ALU 微指令 + 1 个负载,因此可能更少的资源冲突降低了循环的吞吐量。)

在 HSW 及更高版本上,这是 2 个融合域 uop,因为索引负载可以与 PADDD 保持微融合,add/jl 宏融合。 (predicted-taken 分支运行在 6 端口,所以永远不会发生资源冲突。)

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


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

是的,这夸大了循环开销的影响。 但是 gcc 默认情况下即使在-O3 也不会展开(除非它决定完全 展开)。它仅使用配置文件引导的优化展开,以使其知道哪些循环是热的。 (-fprofile-use)。您可以启用-funroll-all-loops,但我只建议针对您知道有一个需要它的热循环的编译单元在每个文件的基础上执行此操作。或者甚至可以在每个功能的基础上使用__attribute__,如果有这样的优化选项的话。

所以这与编译器生成的代码高度相关。 (但clang 确实默认将小循环展开 4 或小循环展开 2,并且非常重要的是,使用多个累加器来隐藏延迟。)


迭代次数非常少的好处:

考虑一下当循环体应该运行一次或两次时会发生什么:除了do{}while 之外,还有更多的跳跃。

  • 对于do{}while,执行是一条直线,没有采用的分支,底部有一个未采用的分支。这太棒了。

  • 对于可能运行循环零次的if() { do{}while; },它是两个未采用的分支。那还是很好的。 (对于前端来说,如果两者都被正确预测,则未采用的成本略低于采用的成本)。

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

  • 对于while(){} 结构,这是一个未采用的循环出口,一个在底部采用jmp,然后在顶部采用一个循环退出分支。

随着更多的迭代,每个循环结构会多做一个分支。 while(){} 每次迭代还会多做一个未采用的分支,因此它很快就会变得明显更糟。

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


跳到底部对于非小循环也有一个缺点,即如果循环的底部没有运行一段时间,L1I 缓存中可能会很冷。代码提取/预取擅长将代码直线带到前端,但如果预测没有足够早地预测分支,则可能会出现代码未命中的跳转到底部。此外,并行解码可能已经(或可能已经)解码了循环的顶部,同时将 jmp 解码到底部。

有条件地跳过do{}while 循环可以避免所有这些:只有在您跳过的代码根本不应该运行的情况下,您才会向前跳转到尚未运行的代码。它通常可以很好地预测,因为很多代码实际上从未在循环中执行 0 次。 (即它可能是do{}while,编译器只是无法证明它。)

跳到底部也意味着核心不能在真正的循环体上开始工作,直到前端追逐两个被占用的分支。

在循环条件复杂的情况下,这样写最容易,对性能的影响很小,但编译器往往会避免。


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

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

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

do {
    if () break;

    blah blah;
} while(condition);

或者只有靠近底部的两个条件。理想情况下,您可以使用相同的实际指令测试多个逻辑条件(例如,5 &lt; x &amp;&amp; x &lt; 25 使用 sub eax, 5 / cmp eax, 20 / ja .outside_range,用于范围检查的无符号比较技巧,或将其与 ORcheck for alphabetic characters of either case in 4 instructions 结合使用)但有时你不能,只需要使用if()break 风格的循环退出分支以及正常的向后分支。


延伸阅读:

题外话:

【讨论】:

  • 如果有人发现这个版本的答案太“密集”或充满旁注,the first version of the answer 有直接回答问题的核心内容(仍然有示例+静态分析)。它比当前版本更快。
  • 直到 gcc 默认不展开循环。不过,我似乎确实在某些情况下展开了,例如嵌套循环和矢量化。这有点太糟糕了,因为特别是使用矢量化,你最终会得到一个巨大的序幕和一个巨大的尾声,然后是一个小的未展开的循环体。所以代码量很大,但都是为了最多执行一次的部分。
  • @BeeOnRope: gcc 真的 需要了解何时可以使用未对齐(可能重叠)的第一个向量而不是标量介绍。特别是对于更宽的向量,它可以是全标量,直到相当大的计数。 IDK 如果已经打开了一个错过优化的错误。
  • 或者至少在进入和结束循环中失败,而不是完全展开的东西,通常会遇到 100 条指令。诚然,这是一个空间/时间的折衷——但 gcc 已经通过不展开循环有效地在该频谱上占据了一席之地,因此同时生成巨大的输入和/或输出是非常不一致的。
  • 这是我在堆栈交换中遇到的最长的答案......
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2022-12-12
  • 2016-05-26
  • 1970-01-01
  • 2016-07-14
  • 1970-01-01
  • 1970-01-01
  • 2021-04-14
相关资源
最近更新 更多