为什么ICC会以这种方式展开此循环并使用LEA进行算术

Why does ICC unroll this loop in this way and use lea for arithmetic?

本文关键字:LEA 循环 ICC 方式展 为什么      更新时间:2023-10-16

查看ICC 17生成的代码,用于迭代:: unordered_map<>(使用https://godbolt.org)使我感到非常困惑。

我将示例提炼为:

long count(void** x)
{
  long i = 0;
  while (*x)
  {
    ++i;
    x = (void**)*x;
  }
  return i;
}

用ICC 17与-O3标志一起编译,从而导致以下拆卸:

count(void**):
        xor       eax, eax                                      #6.10
        mov       rcx, QWORD PTR [rdi]                          #7.11
        test      rcx, rcx                                      #7.11
        je        ..B1.6        # Prob 1%                       #7.11
        mov       rdx, rax                                      #7.3
..B1.3:                         # Preds ..B1.4 ..B1.2
        inc       rdx                                           #7.3
        mov       rcx, QWORD PTR [rcx]                          #7.11
        lea       rsi, QWORD PTR [rdx+rdx]                      #9.7
        lea       rax, QWORD PTR [-1+rdx*2]                     #9.7
        test      rcx, rcx                                      #7.11
        je        ..B1.6        # Prob 18%                      #7.11
        mov       rcx, QWORD PTR [rcx]                          #7.11
        mov       rax, rsi                                      #9.7
        test      rcx, rcx                                      #7.11
        jne       ..B1.3        # Prob 82%                      #7.11
..B1.6:                         # Preds ..B1.3 ..B1.4 ..B1.1
        ret                                                     #12.10

与明显的实现(GCC和Clang使用,即使在-O3)相比,它似乎对一些事情有所不同:

  1. 它可以展开循环,在循环后循环之前,有两个降低 - 但是,中间有一个有条件的跳跃。
  2. 它使用lea作为某些算术
  3. 它可以保留每个两个迭代的计数器(INC RDX),并立即计算每次迭代的相应计数器(进入RAX和RSI)

做所有这一切的潜在好处是什么?我认为这可能与计划有关?

仅用于比较,这是GCC 6.2生成的代码:

count(void**):
        mov     rdx, QWORD PTR [rdi]
        xor     eax, eax
        test    rdx, rdx
        je      .L4
.L3:
        mov     rdx, QWORD PTR [rdx]
        add     rax, 1
        test    rdx, rdx
        jne     .L3
        rep ret
.L4:
        rep ret

这不是一个很好的例子,因为循环在指针降低延迟上琐碎的瓶颈,而不是uop吞吐量或任何其他类型的环路。但是,有些情况下,UOPS较少的情况可能会帮助您可能会发现更远的情况。,或者我们可以谈论循环结构的优化并假装它们重要,例如对于做其他事情的循环。


即使在循环跳闸计数不可计算的情况下,展开也可能有用。(例如,在这样的搜索循环中,它在找到哨兵时停止)。一个未验证的条件分支与一个分支不同,因为它对前端没有任何负面影响(当它正确预测时)。

基本上ICC只是在展开此循环的情况下做得不好。它使用LEA和MOV处理i的方式是BrainDead,因为它使用的UOPS多于两个inc rax说明。(尽管它确实使关键路径变短,但在IVB上,后来具有零延迟的mov r64, r64,因此运行这些UOPS可以取得成功)。

当然,由于这种特殊的循环瓶颈在指针播种的延迟上,您将充其量获得一个每4个时钟的长链吞吐量(Skylake上的L1负载延迟,用于整数寄存器)或大多数其他英特尔微体系结构上的每5个时钟。(我没有仔细检查这些潜伏期;不要相信这些特定的数字,但是它们是正确的)。

IDK如果ICC分析了循环依赖链以决定如何优化。如果是这样,它可能根本没有展开,如果知道它确实试图展开时做得很差。

对于短链,止境执行可能能够在循环后开始运行某些内容,如果 loop-exit分支正确预测了。在这种情况下,优化循环是有用的。

展开还会在问题上抛出更多的分支机构条目。,而不是一个带有长图案的环路 - exiT分支(例如,在15次服用后不知所措),您有两个分支。在同一例子中,一个从未被拍摄过的例子,一个需要7次,然后第八次未能。


这是一个手写的通过两个实现的移动外观,看起来像

在一个出口点之一的循环外路路径中修复i,因此您可以在循环内廉价处理。

count(void**):
    xor       eax, eax              # counter
    mov       rcx, QWORD PTR [rdi]  # *x
    test      rcx, rcx
    je        ..B1.6
.p2align 4   # mostly to make it more likely that the previous test/je doesn't decode in the same block at the following test/je, so it doesn't interfere with macro-fusion on pre-HSW
.loop:
    mov       rcx, QWORD PTR [rcx]
    test      rcx, rcx
    jz        .plus1
    mov       rcx, QWORD PTR [rcx]
    add       rax, 2
    test      rcx, rcx
    jnz       .loop
..B1.6:
    ret
.plus1:           # exit path for odd counts
    inc       rax
    ret

如果两个测试/JCC配对宏观融合,则可以使Loop Body 5融合域UOPS。Haswell可以在单个解码组中进行两个融合,但是早期的CPU不能。

GCC的实现仅为3个UOPS,这比CPU的问题宽度小。请参阅此Q& a关于从循环缓冲区发出的小循环。没有CPU实际上每个时钟都能执行/退休一个以上的分支,因此不容易测试CPU的发行循环如何少于4个UOPS,但显然Haswell可以以每1.25个周期的一个1循环发出5-UOP循环。较早的CPU可能只以每2个周期的一个一个循环发出。

  1. 没有明确的答案,因为它是专有编译器,因为它是这样做的。只有英特尔知道为什么。也就是说,英特尔编译器通常在循环优化方面更具侵略性。这并不意味着更好。我已经看到,与Clang/GCC相比,英特尔的侵略性内在表现的情况下。在这种情况下,我不得不明确禁止在某些呼叫站点上内部。同样,有时候有必要通过英特尔C 的布拉格斯(Pragmas)进行展开以获得更好的性能。

  2. lea是一项特别有用的指令。它允许一次班次,两次添加和一个移动,仅在一项指令中全部移动。这比进行这四个操作分开要快得多。但是,这并不总是有所作为。而且,如果lea仅用于添加或移动,则可能会更好。因此,您在7.11中看到它使用移动,而在接下来的两行中,lea用于进行加法和移动,加法,移动以及移动

  3. 我看不到这里有可选的好处