性能:Mod和分配与条件和分配

时间:2018-10-02 02:24:30

标签: c assembly x86 micro-optimization

我在ISR中有一个计数器(由50us的外部IRQ触发)。计数器增加并环绕MAX_VAL(240)。

我有以下代码:

if(condition){
  counter++;
  counter %= MAX_VAL;
  doStuff(table[counter]);
}

我正在考虑另一种实施方式:

if(condition){
  //counter++;//probably I would increment before the comparison in production code
  if(++counter >= MAX_VAL){
    counter=0;
  }
  doStuff(table[counter]);
}

我知道人们建议不要尝试像这样进行优化,但这使我感到奇怪。在x86上,什么更快?什么样的MAX_VAL值可以证明第二种实现?

大约每50us调用一次,因此减少指令集并不是一个坏主意。如果将if(++ counter> = MAX_VAL)预测为false,则在大多数情况下会将其分配为0。为了我的目的,id更喜欢%=实现的一致性。

1 个答案:

答案 0 :(得分:4)

就像@RossRidge所说的那样,大部分开销将因现代x86(可能至少100个时钟周期,很多)的中断服务噪声而损失掉(如果这是一个设置了Meltdown + Spectre缓解功能的现代操作系统。


如果MAX_VAL是2的幂,则counter %= MAX_VAL是极好的,特别是如果counter是无符号的(在这种情况下,只是一个简单的and,或者在这种情况下, movzx字节到dword,在Intel CPU上可能具有零延迟。当然,它的吞吐成本仍然是:Can x86's MOV really be "free"? Why can't I reproduce this at all?

是否可以用无害或重复的内容来填充最后255-240个条目?


不过,只要MAX_VAL compile-time 常量,counter %= MAX_VAL就可以有效地编译为几个乘法,移位和加法。 (再次,对未签名的效率更高。)Why does GCC use multiplication by a strange number in implementing integer division?

但是检查环绕效果更好。与使用乘法逆运算相比,无分支检查(使用cmov)的等待时间要短于其余时间,并且吞吐量成本更低。

正如您所说,分支支票可以完全取消关键路径的支票,但有时会导致错误的预测。

// simple version that works exactly like your question
// further optimizations assume that counter isn't used by other code in the function,
// e.g. making it a pointer or incrementing it for the next iteration
void isr_countup(int condition) {
    static unsigned int counter = 0;

    if(condition){
      ++counter;
      counter = (counter>=MAX_VAL) ? 0 : counter;  // gcc uses cmov
      //if(counter >= MAX_VAL) counter = 0;        // gcc branches
      doStuff(table[counter]);
    }
}

我使用最近的gcc和clang编译了该on the Godbolt compiler explorer的许多版本。

(有关x86 asm短块的吞吐量和延迟的静态性能分析的更多信息,请参见What considerations go into predicting latency for operations on modern superscalar processors and how can I calculate them by hand?,以及x86 tag wiki中的其他链接,尤其是Agner Fog's guides中的链接)。

clang对两个版本都使用无分支cmov。我使用-fPIE进行了编译,以防您在内核中使用它。如果可以使用-fno-pie,则编译器可以保存LEA并使用mov edi, [table + 4*rcx],假设您所处的目标是位置相关代码中的静态地址适合32位符号扩展常数(例如,在Linux内核中为true,但是我不确定它们是使用-fPIE编译还是在加载内核时使用重定位进行内核ASLR。)

# clang7.0 -O3 -march=haswell -fPIE.
#  gcc's output is the same (with different registers), but uses `mov edx, 0` before the cmov for no reason, because it's also before a cmp that sets flags
isr_countup:                            # @isr_countup
    test    edi, edi
    je      .LBB1_1                  # if condition is false

    mov     eax, dword ptr [rip + isr_countup.counter]
    add     eax, 1                   # counter++
    xor     ecx, ecx
    cmp     eax, 239                 # set flags based on (counter , MAX_VAL-1)
    cmovbe  ecx, eax                 # ecx = (counter <= MAX_VAL-1) ? 0 : counter
    mov     dword ptr [rip + isr_countup.counter], ecx   # store the old counter
    lea     rax, [rip + table]
    mov     edi, dword ptr [rax + 4*rcx]        # index the table

    jmp     doStuff@PLT             # TAILCALL
.LBB1_1:
    ret

从旧计数器值的加载开始的8条指令块总计为8 oups(在AMD或Intel Broadwell及更高版本上,其中cmov仅为1 uop)。从counter准备到table[++counter % MAX_VAL]准备就绪的关键路径延迟是1(添加)+1(cmp)+1(cmov)+负载的负载使用延迟。即3个额外的周期。这就是1条mul指令的等待时间。或在cmov为2 oups的较旧的Intel上增加1个周期。

通过比较,使用gcc的该块的模数版本为14 oups,其中包括3 uop mul r32。等待时间至少是8个周期,我没有确切地数过。 (不过,对于吞吐量而言,情况只会稍差一点,除非更高的延迟会降低乱序执行与依赖计数器的内容执行重叠的能力。)


其他优化

  • 使用counter的旧值,并为下次准备一个值(使计算脱离关键路径。)

  • 使用指针而不是计数器。保存两个指令,但代价是使用8个字节而不是该变量的1或4的缓存占用空间。 (uint8_t counter可以很好地编译某些版本,仅使用movzx到64位即可。)

这是向上计数的,因此表格可以井井有条。它会在 加载后增加,从而使该逻辑脱离关键路径依赖关系链的无序执行。

void isr_pointer_inc_after(int condition) {
    static int *position = table;

    if(condition){
        int tmp = *position;
        position++;
        position = (position >= table + MAX_VAL) ? table : position;
        doStuff(tmp);
    }
}

这对gcc和clang都可以很好地编译,尤其是在使用-fPIE的情况下,因此编译器无论如何都需要在寄存器中使用表地址。

# gcc8.2 -O3 -march=haswell -fPIE
isr_pointer_inc_after(int):
    test    edi, edi
    je      .L29

    mov     rax, QWORD PTR isr_pointer_inc_after(int)::position[rip]
    lea     rdx, table[rip+960]        # table+MAX_VAL
    mov     edi, DWORD PTR [rax]       # 
    add     rax, 4
    cmp     rax, rdx
    lea     rdx, -960[rdx]             # table, calculated relative to table+MAX_VAL
    cmovnb  rax, rdx
    mov     QWORD PTR isr_pointer_inc_after(int)::position[rip], rax

    jmp     doStuff(int)@PLT
.L29:
    ret

再次为8微码(假设cmov为1微码)。它的延迟甚至比计数器版本的延迟还要低,因为在Sandybridge系列上,[rax]寻址模式(带有来自负载的RAX)的延迟比索引寻址模式低1个周期。没有位移,它永远不会遭受Is there a penalty when base+offset is in a different page than the base?

中所述的惩罚
  • 或(带有计数器)将 down 计数为零:如果编译器可以使用减量设置的标志来检测该值变为负数,则可以保存一条指令。或者,我们总是可以使用递减后的值,然后进行折回:因此,当counter为1时,我们将使用table[--counter]table[0]),但是存储{{1} }。再次,请取消检查关键路径。

    如果您想要一个分支版本,则希望它在进位标志上分支,因为counter=MAX_VAL / sub eax,1可以宏熔合为1微码,但是jc / { {1}}无法对Sandybridge系列进行宏熔断。 x86_64 - Assembly - loop conditions and out of order。但是使用无分支,就可以了。 sub eax,1(如果设置了标志标志则移动,即,如果最后结果为负)则与js(如果设置了进位标志运动)同等效率。

    要让gcc使用dec或sub的标志结果而不用做cmovs来将索引符号扩展到指针宽度是很棘手的。我想我可以使用cmovc计数器,但这很愚蠢;最好只使用一个指针。使用无符号计数器,gcc和clang都希望在减量之后再执行另一个cdqe或其他操作,即使已经从减量中设置好了标志也是如此。但是我们可以通过检查intptr_t来使gcc使用SF:

    cmp eax, 239

    仍为8微妙(应该是7微妙),但关键路径上没有额外的延迟。因此,所有额外的减量和包装指令都是多汁的指令级并行性,可以无序执行。