x86-64对齐和分支预测的性能优化



我目前正在使用带有SSE-2指令的x86-64程序集编写一些C99标准库字符串函数的高度优化版本,如strlen()memset()等。

到目前为止,我在性能方面取得了出色的成绩,但当我试图优化更多时,有时会出现奇怪的行为。

例如,添加甚至删除一些简单的指令,或者简单地重组一些与跳转一起使用的本地标签,都会完全降低整体性能。在代码方面绝对没有任何理由。

因此,我的猜测是,代码对齐和/或分支预测错误存在一些问题。

我知道,即使使用相同的架构(x86-64),不同的CPU也有不同的分支预测算法。

但是,在x86-64上开发高性能时,是否有一些关于代码对齐和分支预测的一般建议

特别是,关于对齐,我应该确保跳转指令使用的所有标签都在DWORD上对齐吗?

_func:
    ; ... Some code ...
    test rax, rax
    jz   .label
    ; ... Some code ...
    ret
    .label:
        ; ... Some code ...
        ret

在前面的代码中,我应该在.label:之前使用align指令吗,比如:

align 4
.label:

如果是,在使用SSE-2时,在DWORD上对齐是否足够?

关于分支预测,是否有一种"首选"的方法来组织跳转指令使用的标签,以帮助CPU,或者今天的CPU是否足够聪明,可以在运行时通过计算分支的执行次数来确定这一点?

编辑

好的,这里有一个具体的例子——这是strlen()与SSE-2:的开始

_strlen64_sse2:
    mov         rsi,    rdi
    and         rdi,    -16
    pxor        xmm0,   xmm0
    pcmpeqb     xmm0,   [ rdi ]
    pmovmskb    rdx,    xmm0
    ; ...

用一个1000个字符的字符串运行它10’000’000次大约需要0.48秒,这很好
但它不会检查NULL字符串输入。很明显,我将添加一个简单的检查:

_strlen64_sse2:
    test       rdi,    rdi
    jz          .null
    ; ...

同样的测试,现在运行0.59秒。但如果我在这次检查后对齐代码:

_strlen64_sse2:
    test       rdi,    rdi
    jz          .null
    align      8
    ; ...

原来的演出又回来了。我用8来对齐,因为4不会改变任何东西
有人能解释一下这一点,并就何时对齐或不对齐代码部分给出一些建议吗?

编辑2

当然,这并不像对齐每个分支目标那么简单。如果我这样做,表现通常会变得更糟,除非是像上面这样的特定情况。

对齐优化

1.使用.p2align <abs-expr> <abs-expr> <abs-expr>而不是align

使用其3参数授予细粒度控制

  • param1-与边界对齐
  • param2-填充内容(零或NOP s)
  • param3-如果填充超过指定的字节数,请不要对齐

2.将常用代码块的开头与缓存行大小边界对齐

  • 这增加了整个代码块位于单个缓存行中的可能性。一旦加载到一级缓存中,就可以完全运行,而无需访问RAM进行指令获取。这对于具有大量迭代的循环非常有益

3.使用多字节NOPs进行填充,以减少执行NOPs所花费的时间

  /* nop */
  static const char nop_1[] = { 0x90 };
  /* xchg %ax,%ax */
  static const char nop_2[] = { 0x66, 0x90 };
  /* nopl (%[re]ax) */
  static const char nop_3[] = { 0x0f, 0x1f, 0x00 };
  /* nopl 0(%[re]ax) */
  static const char nop_4[] = { 0x0f, 0x1f, 0x40, 0x00 };
  /* nopl 0(%[re]ax,%[re]ax,1) */
  static const char nop_5[] = { 0x0f, 0x1f, 0x44, 0x00, 0x00 };
  /* nopw 0(%[re]ax,%[re]ax,1) */
  static const char nop_6[] = { 0x66, 0x0f, 0x1f, 0x44, 0x00, 0x00 };
  /* nopl 0L(%[re]ax) */
  static const char nop_7[] = { 0x0f, 0x1f, 0x80, 0x00, 0x00, 0x00, 0x00 };
  /* nopl 0L(%[re]ax,%[re]ax,1) */
  static const char nop_8[] =
    { 0x0f, 0x1f, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00};
  /* nopw 0L(%[re]ax,%[re]ax,1) */
  static const char nop_9[] =
    { 0x66, 0x0f, 0x1f, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00 };
  /* nopw %cs:0L(%[re]ax,%[re]ax,1) */
  static const char nop_10[] =
    { 0x66, 0x2e, 0x0f, 0x1f, 0x84, 0x00, 0x00, 0x00, 0x00, 0x00 };

(对于x86,高达10字节NOP秒。来源binutils-2.23。)


分支预测优化

x86_64微体系结构/代之间有很多变化。然而,适用于所有这些机构的一套通用指南可总结如下参考:Agner Fog的x86微架构手册第3节。

1.取消滚动循环以避免迭代次数略高

  • 环路检测逻辑保证仅适用于<64次迭代。这是由于这样一个事实:如果分支指令向一个方向n-1次,然后向另一个方向1时,对于任何n到64,分支指令被识别为具有循环行为。

    这并不适用于Haswell及其后版本中的预测器,它们使用TAGE预测器,并且没有针对特定分支的专用环路检测逻辑。对于Skylake上没有其他分支的紧密外循环中的内循环来说,~23的迭代次数可能是最坏的情况:从内循环退出的次数大多预测错误,但跳闸次数太低,经常发生。展开可以通过缩短模式来提供帮助,但对于非常高的环路跳闸次数,末端的单个预测失误会在大量跳闸中摊销,并且需要不合理的展开量才能对此采取任何措施。

2.坚持近距离/短距离跳跃

  • 不预测远跳,即管道总是在到新代码段的远跳时暂停(CS:RIP)。基本上从来没有理由使用跳远,所以这基本上是不相关的。

    在大多数CPU上,通常预测具有任意64位绝对地址的间接跳转。

    但是,当目标距离超过4GB时,Silvermont(英特尔的低功耗CPU)在预测间接跳跃方面有一些局限性,因此通过在低32位的虚拟地址空间中加载/映射可执行文件和共享库来避免这种情况可能是一种胜利。例如在GNU/Linux上通过设置环境变量CCD_ 11。有关更多信息,请参阅英特尔的优化手册。

为了扩展TheCodeArtist的答案,他提出了一些好的观点,这里有一些额外的东西和细节,因为我实际上能够解决这个问题。

1-代码对齐

英特尔建议在16字节边界上对齐代码和分支目标:

3.4.1.5-程序集/编译器编码规则12。(M影响,H一般性)
所有分支目标都应该是16字节对齐的。

虽然这通常是一个很好的建议,但应该小心
盲目地对所有内容进行16字节对齐可能会导致性能损失,因此在应用之前,应在每个分支目标上测试

正如TheCodeArtist所指出的,使用多字节NOP可能会有所帮助,因为简单地使用标准的单字节NOP可能不会带来代码对齐的预期性能增益。

附带说明一下,.p2align指令在NASM或YASM中不可用
但它们确实支持与标准align指令的NOP之外的其他指令对齐:

align 16, xor rax, rax

2。分支预测

这是最重要的部分
虽然每一代x86-64 CPU都有不同的分支预测算法,这是正确的,但通常可以应用一些简单的规则来帮助CPU预测可能会采用哪个分支。

CPU试图在BTB(分支目标缓冲区)中保留分支历史
但是,当BTB中没有分支信息时,CPU将使用他们所称的静态预测,这遵循英特尔手册中提到的简单规则:

  1. 预测不执行的前向条件分支
  2. 预测要执行的向后条件分支

以下是第一种情况的示例:

test rax, rax
jz   .label
; Fallthrough - Most likely
.label:
    ; Forward branch - Most unlikely

.label下的指令是不太可能的条件,因为.label是在实际分支之后声明的。

对于第二种情况:

.label:
    ; Backward branch - Most likely
test rax, rax
jz   .label
; Fallthrough - Most unlikely

这里,.label下的指令是可能的条件,因为.label在实际分支之前被声明为

因此,每个条件分支都应该始终遵循这个简单的模式
当然,这也适用于循环。

正如我之前提到的,这是最重要的部分

在添加简单的测试时,我遇到了不可预测的性能增益或损失,这些测试在逻辑上应该可以提高整体性能
盲目地遵守这些规则解决了问题
如果不是,则出于优化目的添加分支可能会产生相反的结果。

TheCodeArtist在他的回答中也提到了循环展开
虽然这不是问题所在,因为我的循环已经展开,但我在这里提到它,因为它确实非常重要,并带来了显著的性能提升。

作为读者的最后一点,虽然这似乎很明显,也不是问题所在,但在不必要的时候不要分支。

从Pentium Pro开始,x86处理器具有条件移动指令,这可能有助于消除分支并抑制预测失误的风险:

test   rax, rax
cmovz  rbx, rcx

所以以防万一,记住这是件好事。

要更好地了解对齐的原因和方式,请查看Agner Fog的微体系结构文档,特别是关于各种CPU设计的指令获取前端的部分。Sandybridge引入了uop缓存,这与吞吐量有很大不同,尤其是在SSE代码中,指令长度通常太长,每个周期16B无法覆盖4条指令。

填充uop缓存行的规则很复杂,但一个由32B指令组成的新块总是启动一个新的缓存行IIRC。因此,将热函数入口点与32B对齐是个好主意。在其他情况下,那么多的填充可能会对I$density造成伤害,而不是帮助。(不过,L1 I$仍然有64B的缓存线,所以有些事情可能会在帮助uop缓存密度的同时损害L1 I$的密度。)

循环缓冲区也有帮助,但采用的分支会破坏每个循环的4个uop,尤其是在Haswell之前。例如,3个uop的循环在SnB/IvB上像abcabc而不是abcabcda那样执行。因此,5-uop循环每2个循环进行一次迭代,而不是每1.25次迭代。这使得展开更有价值。(Haswell和后来似乎在LSD中展开了微小的循环,使5-uop循环的糟糕程度大大降低:当执行uop计数不是处理器宽度的倍数的循环时,性能会降低吗?)

"分支目标应该是16字节对齐的规则"不是绝对的。该规则的原因是,在16字节对齐的情况下,一个周期可以读取16字节的指令,然后在下一个周期中再读取16字节。如果您的目标位于偏移量16n+2,那么处理器仍然可以在一个周期内读取14字节的指令(缓存行的剩余部分),这通常就足够了。然而,在偏移量16n+15处开始循环是个坏主意,因为一次只能读取一个指令字节。更有用的是将整个循环保持在尽可能少的缓存行中。

在一些处理器上,分支预测具有奇怪的行为,即8或4字节内的所有分支都使用相同的分支预测器。移动分支,使每个条件分支使用其自己的分支预测器。

这两者的共同点是,插入一些代码可以改变行为,使其更快或更慢。

最新更新