当前位置: 首页 > 知识库问答 >
问题:

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

杜焕
2023-03-14

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

到目前为止,我已经设法在性能方面取得了出色的成绩,但是当我试图进一步优化时,我有时会遇到奇怪的行为。

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

所以我的猜测是,代码对齐和/或分支存在一些问题,这些分支被误解了。

我知道,即使是相同的架构(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个字符的字符串运行它10000次,得到大约0.48秒,这很好
但它不检查空字符串输入。显然,我将添加一个简单的检查:

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

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

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

原来的表演又回来了。我使用8进行对齐,因为4不会改变任何东西。
任何人都可以解释这一点,并给出一些关于何时对齐或不对齐代码部分的建议?

编辑 2

当然不是对准每个分支目标那么简单。如果我这样做,表现通常会变得更差,除非一些具体的情况像上面。

共有3个答案

李鹏
2023-03-14

要更好地理解对齐为什么以及如何重要,请查看Agner Fog的微体系结构文档。关于各种CPU设计的取指令前端的部分。Sandybridge引入了uop缓存,这极大地提高了吞吐量,尤其是。在SSE代码中,指令长度通常太长,每周期16位,无法涵盖4条指令。

填充微指令高速缓存行的规则很复杂,但是新的32B指令块总是启动新的高速缓存行,IIRC。所以将热函数入口点与32B对齐是一个好主意。在其他情况下,这么多的填充物对密度的伤害可能大于帮助。(L1 I$仍然有64B高速缓存行,所以有些事情可能会损害L1 I$的密度,同时有助于uop高速缓存密度。)

循环缓冲区也有帮助,但是执行分支会破坏每个周期的4个微操作,尤其是在Haswell之前。例如,在SnB/IvB上,3个UOP的循环执行起来像< code>abc、< code>abc,而不是< code>abca、< code>bcda。所以一个5-uop循环是每2个周期迭代一次,而不是每1.25次。这使得展开更加有价值。(Haswell和后来的版本似乎在LSD中展开了微小的循环,使得5-uop循环不那么糟糕:当执行uop计数不是处理器宽度倍数的循环时,性能会降低吗?)

苏宏峻
2023-03-14

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

1-代码对齐

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

3.4.1.5 - 汇编/编译器编码规则 12.(M影响,H普遍性)
所有分支目标都应以 16 字节对齐。

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

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

作为旁注,.p2align 指令在 NASM 或 YASM 中不可用。
但它们确实支持与标准对齐指令的NOP以外的其他指令对齐:

align 16, xor rax, rax

2.分支预测

这是最重要的部分。< br >虽然每一代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

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

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

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

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

这位演讲者在他的回答中也提到了循环展开。< br >虽然这不是问题,因为我的循环已经展开,但我在这里提到它,因为它确实极其重要,并且带来了显著的性能提升。

作为读者的最后一点注意,虽然这看起来很明显,而且不是这里的问题,但不要在不必要的时候分支。

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

test   rax, rax
cmovz  rbx, rcx

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

羊舌胡非
2023-03-14

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

  • param1-对齐到什么边界
  • param2-填充what(零或<code>NOP<code>s)
  • param3-如果填充将超过指定的字节数,则不对齐
  • 这增加了整个代码块位于单个缓存行中的可能性。一旦加载到一级缓存中,就可以完全运行,无需访问RAM进行指令提取。这对于具有大量迭代的循环非常有益
  /* 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字节<code>NOP<code>s。源代码binutils-2.2.3。)

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

>

  • 循环检测逻辑保证仅适用于以下循环:

    这并不真正适用于Haswell和后来的预测器,这些预测器使用TAGE预测器,并且没有针对特定分支的专用循环检测逻辑。在Skylake上,对于没有其他分支的紧密外部环路内部的内循环,迭代计数约为23可能是最坏的情况:从内部循环的退出在大多数时候都是错误的,但行程计数非常低,以至于经常发生。展开可以通过缩短html" target="_blank">模式来提供帮助,但是对于非常高的循环行程计数,最后的单个错误预测会在很多行程中摊销,并且需要不合理的展开量才能对此做任何事情。

    > < li>

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

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

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

  •  类似资料:
    • 我正在编写一些音频代码,其中基本上所有内容都是一个小循环。据我所知,分支预测失败是一个足够大的性能问题,我很难保持代码分支的自由。但是只有这么远的时间才能带我,这让我想知道不同类型的分支。 在 c 中,固定目标的条件分支: 并且(如果我正确理解这个问题),无条件分支到变量目标: 是否存在性能差异?在我看来,如果这两种方法中的一种明显快于另一种,编译器只需将代码转换为匹配即可。 对于那些分支预测非常

    • 我正在读一本关于计算机体系结构的书,我在这一章讨论分支预测。有一个小练习,我很难把我的头缠绕在它周围。 考虑以下内部for循环 ------>内循环: b)1位分支预测缓冲器会改善性能吗(与a相比)?假设第一个预测是“未采取”,并且没有其他分支映射到该条目。 ----假设第一个预测是“不采取”,如果预测错误,则1位预测器反转该位。所以它将是NT/T/T。这是否使它具有与问题a)相同的性能?有1个未

    • 我的代码经常调用具有多个(不可预测的)分支的函数。当我分析时,我发现这是一个小瓶颈,大部分CPU时间用于条件JMP。 考虑以下两个函数,其中原始函数有多个显式分支。 这是一个新函数,我试图在其中删除导致瓶颈的分支。 然而,当我分析新代码时,性能只提高了大约20%,而且调用本身(对mem_funcs数组中的一个func)花费了很长时间。 第二个变量仅仅是一个更隐含的条件吗,因为CPU仍然无法预测将要

    • 如果语句更多地依赖于分支预测,而v表查找更多地依赖分支目标预测,那么

    • 分支目标预测(BTP)与分支预测(BP)不同。我知道BTP会找到分支将跳转到的位置,而BP只是决定可能采取哪个分支。 BTP依赖BP吗,如果BTP不使用BP来预测哪个分支被采用,它怎么可能知道分支的目标呢? 我不明白为什么会有这么大的差异?一旦分支被预测为被占用,找到目标并不像读取指令中的地址一样简单吗?

    • 我想模拟x86/x86_64上禁止未对齐内存访问的系统。是否有一些调试工具或特殊模式来执行此操作? 当使用为SPARC或其他类似CPU设计的软件(C/C)时,我想在几台x86/x86_64PC上运行许多(CPU密集型)测试。但是我对Sparc的访问是有限的。 正如我所知,Sparc总是检查内存读写的对齐是否正常(从任何地址读取一个字节,但仅当地址可被4整除时才允许读取一个4字节的字)。 可能是Va