为什么ICC以这种方式展开这个循环并使用lea进行算术运算?

时间:2016-12-10 22:45:35

标签: c++ assembly x86-64 icc

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

我将这个例子简化为:

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

使用-O3标志对ICC 17进行编译会导致以下反汇编:

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. 它为while循环的每个两个迭代保留一个计数器(inc rdx),并立即为每次迭代计算相应的计数器(进入rax和rsi)
  4. 做这一切有什么潜在的好处?我想它可能与调度有关?

    仅供比较,这是由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
    

2 个答案:

答案 0 :(得分:6)

这不是一个很好的例子,因为循环在指针追逐延迟方面存在微不足道的瓶颈,而不是uop吞吐量或任何其他类型的循环开销。但是,可能存在这样的情况:较少的uops可以帮助无序的CPU看到更远的地方。 或者我们可以谈谈对循环结构的优化并假装它们很重要,例如对于一个做其他事情的循环。

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

基本上ICC只是做了一个糟糕的工作展开这个循环。它使用LEA和MOV来处理i的方式相当大脑,因为它使用了比两个{{1}更多的uops说明。 (虽然它确实使关键路径更短,但在IvB及更高版本上具有零延迟inc rax,因此无序执行可以在运行那些uop时提前完成。)

当然,由于这个特定的循环会阻碍指针追逐的延迟,因此您最多可以获得每4个时钟一个的长链吞吐量(Skylake上的L1负载使用延迟,对于整数寄存器),或者大多数其他英特尔微体系结构中每5个时钟一个。 (我没有仔细检查这些延迟;不要相信那些特定的数字,但它们是正确的。)

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

对于短链,无序执行可能能够在循环后运行某些东西,如果循环出口分支正确预测。在这种情况下,优化循环很有用。

展开还会在问题上引发更多分支预测器条目。而不是一个带有长模式的循环退出分支(例如,在15次之后未采取),您有两个分支。对于同一个例子,一个从未采取的,一个需要7次然后不采取第8次。

以下是手写的二次展开实现

在其中一个退出点的循环退出路径中修复mov r64, r64,这样您就可以在循环中廉价地处理它。

i

如果TEST / JCC对都是宏熔丝,这使得循环体5融合域uops。 Haswell可以在一个解码组中进行两次融合,但早期的CPU不能。

gcc的实现只有3个uop,小于CPU的问题宽度。有关从循环缓冲区发出的小循环,请参阅this Q&A。没有CPU实际上每个时钟可以执行/退出一个以上的分支,因此不太可能测试CPU如何发出小于4微秒的循环,但显然Haswell可以每1.25个循环发出一个5-uop循环。早期的CPU可能每2个周期只发一次。

答案 1 :(得分:1)

  1. 因为它是一个专有的编译器,所以它没有明确的答案。只有英特尔知道原因。也就是说,英特尔编译器在循环优化方面往往更积极。这并不意味着它更好。我已经看到英特尔的攻击性内联导致性能比clang / gcc更差的情况。在这种情况下,我必须明确禁止在某些呼叫站点内联。同样,有时需要禁止在英特尔C ++中通过编译指示展开以获得更好的性能。

  2. lea是一个特别有用的指令。它允许一个移位,两个加法,一个移动只需一条指令。它比分离这四个操作要快得多。但是,它并不总是有所作为。如果lea仅用于添加或移动,则可能会或可能不会更好。所以你在7.11中看到它使用了一个移动,而在接下来的两行中lea被用来做加法加移动,加法,移位和移动

  3. 我认为这里没有可选的好处