我目前正在使用带有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足够聪明,可以通过计算执行分支的次数来确定运行时的状态?

编辑

好的,这是一个具体示例-这是SSE-2的strlen()的开始:
_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)。
  • param3-如果填充超过指定的字节数,则不对齐。

  • 2.将常用代码块的开头与高速缓存行大小边界对齐。
  • 这增加了整个代码块位于单个高速缓存行中的机会。一旦加载到L1缓存中,便可以完全运行,而无需访问RAM进行指令提取。这对于具有大量迭代的循环非常有好处。

  • 3.使用多字节NOP填充到reduce the time spent executing NOP s

      /* 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.2.3。)

    分支预测优化

    x86_64微架构/世代之间有很多差异。但是,可以将适用于所有准则的一组通用准则总结如下。 引用:Section 3 of Agner Fog's x86 micro-architecture manual

    1.展开循环,以避免迭代计数过高。
  • 保证循环检测逻辑仅适用于 迭代的循环。这是由于以下事实:如果分支指令以一种方式 n-1 次运行,然后以另一种方式 1 时间运行,则对于任何 n 最高为64,都被认为具有循环行为。

    这实际上不适用于Haswell及以后的使用TAGE预测器的预测器,并且没有针对特定分支的专用环路检测逻辑。在Skylake上,对于没有其他分支的紧密外部循环中的内部循环而言,〜23的迭代次数可能是最坏的情况:从内部循环退出的次数最多,但跳闸次数很少,因此经常发生。展开可以通过缩短模式来提供帮助,但是对于非常高的循环行程计数,最后的单个错误预测会在很多行程中分期摊销,并且为此做任何事情都会花费不合理的展开费用。

  • 2.坚持近距离/短距离跳跃。
  • 无法预测远跳转,即流水线总是在远跳转至新代码段(CS:RIP)时停顿。无论如何,基本上没有理由使用跳远,所以这几乎没有关系。

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

    但是Silvermont(英特尔的低功耗CPU)在目标距离超过4GB时预测间接跳转方面有一些限制,因此避免在虚拟地址空间的低32位加载/映射可执行文件和共享库可以避免这种情况。 。例如在GNU / Linux上,通过设置环境变量 LD_PREFER_MAP_32BIT_EXEC 。有关更多信息,请参阅英特尔的优化手册。
  • 关于performance - x86-64组件的性能优化-对齐和分支预测,我们在Stack Overflow上找到一个类似的问题:https://stackoverflow.com/questions/18113995/

    10-11 22:37
    查看更多