【问题标题】:Performance optimisations of x86-64 assembly - Alignment and branch predictionx86-64 程序集的性能优化 - 对齐和分支预测
【发布时间】:2017-04-26 22:40:29
【问题描述】:

我目前正在使用带有 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 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

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

【问题讨论】:

  • SSE2 有分支提示前缀(2E3E)。
  • @KerrekSB 感谢您的评论。现代 CPU 是否仍在使用这些指令,或者它们只是被忽略了?我在英特尔的 x86-64 优化手册中找不到任何关于它们的信息...
  • 除 P4 之外的所有处理器都会忽略分支提示。
  • 就现代 x86 CPU 上的分支预测而言,请查看 section 3 of this manual
  • 我想知道这种级别的优化在整个字符串不存在于 L1 缓存中的更现实的设置中会有多大用处,这显然对您正在使用的基准测试有用。与内存获取成本相比,您担心的 20% 的性能差异可能完全是微不足道的。

标签: performance assembly x86-64 sse2 branch-prediction


【解决方案1】:

对齐优化

1。使用 .p2align <abs-expr> <abs-expr> <abs-expr> 代替 align

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

  • param1 - 对齐到什么边界。
  • param2 - 用什么(零或NOPs)填充填充。
  • param3 - 如果填充将超过指定的字节数,则不对齐。

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

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

3。使用多字节NOPs 填充到reduce the time spent executing 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,最多 10byte NOPs。来源 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。请参阅英特尔的优化手册了解更多信息。

【讨论】:

  • 感谢您的回答,尤其是对于多字节 NOP。我将在另一个答案中添加更多详细信息,因为它也可能对人们有所帮助。与此同时,我将赏金奖励给你,感谢你花时间写一个详细的答案,即使它不能回答所有问题:)
  • 谢谢。 :-) 期待您在研究中遇到的详细信息的回答。
  • 在 x86 中,FAR 跳转是跳转到不同的代码段,即它会更改 CS。这几乎只与 16 位相关。甚至无需提及优化普通用户空间代码。 Short (rel8) 和 Near (rel32) 跳跃都是预测和推测执行的。 如果您认为 Far 意味着 rel32 或其他什么,IDK。
  • @TheCodeArtist 回复:“~23 的迭代计数可能是紧外循环内的内循环的最坏情况”这不是因为分支预测器。这是因为内部循环将在大约 23 次迭代和the only stop condition for the LSD is a branch miss 开始耗尽 LSD
【解决方案2】:

为了扩展 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 将使用他们所谓的静态预测,它遵循简单的规则,如 Intel 手册中所述:

  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

所以,以防万一,请记住这一点。

【讨论】:

  • 虽然您和 TCA 的回答是很好的一般原则,但更深层次的问题是这些规则何时真正适用。一般来说,如果没有(大量)参考目标 CPU 的细节,就无法回答这个问题。虽然避免分支错误预测至关重要,但每次迭代都应正确预测此循环,但无论您以哪种方式跳转,都应退出。我认为对齐的真正问题在于指令解码和微操作循环缓冲区。您是否可能在较旧的处理器上进行测试?你能发布你的完整代码吗?我认为更多的探索可能会很有趣。
  • "所有分支目标都应该是 16 字节对齐的。"此编码规则似乎已在 2020 年 5 月的英特尔® 64 和 IA-32 架构优化参考手册中删除,也许更早。
  • 有人知道为什么吗?
  • @Olsonist:因为具有 uop 缓存的现代 CPU 关心 32 字节边界,但这太宽了,不值得填充。最好只考虑函数内的密度,通常包括循环的顶部。并且绝对分支实现“if”/“else”逻辑,每次调用该函数时只跳转一次。
  • 顺便说一句,在 16 字节边界上对齐代码和对齐分支目标是两件事。我记得英特尔曾经建议不要让指令重叠 16 字节边界。也许那是内存故障,但他们现在说“前端每个周期可以获取 16 个字节的指令”。 NB 不是 16 个 aligned 字节。因此英特尔明显放宽了他们的建议。至于 LCP,他们提到它们在 LSD 中不是问题,因为“没有 LCP 惩罚,因为已经通过了预解码阶段。”所以对于循环,它们不是问题。他们是优势吗?只有测试才能证明。
【解决方案3】:

要更好地了解对齐的重要性和重要性,请查看Agner Fog's the microarchitecture doc,尤其是。关于各种 CPU 设计的指令提取前端的部分。 Sandybridge 引入了 uop 缓存,这对吞吐量产生了巨大的影响,尤其是。在 SSE 代码中,指令长度通常太长,每个周期 16B 无法覆盖 4 条指令。

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

循环缓冲区也有帮助,但采用的分支会破坏每个循环的 4 微指令,尤其是在 Haswell 之前。例如在 SnB/IvB 上执行 3 个 uops 的循环,例如 abcabc,而不是 abcabcda。因此,5-uop 循环每 2 个周期进行一次迭代,而不是每 1.25 次迭代。这使得展开更有价值。 (Haswell 和后来似乎在 LSD 中展开了微小的循环,使 5-uop 循环变得不那么糟糕:Is performance reduced when executing loops whose uop count is not a multiple of processor width?

【讨论】:

  • 我现在遇到了这个问题。这比我想象的要复杂。我将不得不问一个关于它的问题。
【解决方案4】:

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

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

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

【讨论】:

    猜你喜欢
    • 2015-11-24
    • 1970-01-01
    • 2016-09-14
    • 2018-07-02
    • 2012-03-11
    • 2016-08-17
    • 2013-09-04
    • 2014-08-07
    相关资源
    最近更新 更多