我目前正在使用带有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上对齐?
关于分支预测,是否有一种«preffered»方式来组织跳转指令使用的标签,以帮助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
当然,它并不像对齐每个分支目标那么简单。如果我这样做,表演通常会变得更糟,除非上面有一些特殊情况。
答案 0 :(得分:24)
.p2align <abs-expr> <abs-expr> <abs-expr>
代替align
。使用3个参数
授予细粒度控制NOP
s)填充填充。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,最高 10byte NOP
。来源binutils-2.2.3。)
<子> x86_64微架构/代之间有很多变化。但是,适用于所有这些指南的一套通用指南可归纳如下。 参考:Section 3 of Agner Fog's x86 micro-architecture manual。 子>
保证循环检测逻辑仅适用于&lt;的循环。 64次次迭代。这是因为分支指令被识别为具有循环行为,如果它以单向 n-1 的方式然后以另一种方式 1 时间,对于任何 n 高达64。
这并不适用于Haswell及其后的预测变量,它们使用TAGE预测器,并且没有特定分支的专用循环检测逻辑。在Skylake上,迭代计数〜23可能是在紧密外环内没有其他分支的内环的最坏情况:内环的退出大多数时间错误预测,但是行程计数太低而经常发生。展开可以通过缩短模式来提供帮助,但是对于非常高的循环行程计数,最终的单个误预测会在很多次行程中摊销,并且需要进行不合理的展开才能对其进行任何操作。
没有预测到远程跳转,即管道总是在远程跳转到新代码段(CS:RIP)时停止。无论如何,基本上没有理由使用远距离跳跃,所以这大部分都不相关。
通常在大多数CPU上预测具有任意64位绝对地址的间接跳转。
但是当目标超过4GB时,Silvermont(英特尔的低功耗CPU)在预测间接跳转方面存在一些限制,因此通过在低32位虚拟地址空间中加载/映射可执行文件和共享库可以避免这种情况。在那里取胜。例如在GNU / Linux上通过设置环境变量LD_PREFER_MAP_32BIT_EXEC
。有关更多信息,请参阅英特尔的优化手册。
答案 1 :(得分:21)
要扩展 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将使用他们所谓的静态预测,这符合简单规则,如英特尔手册中所述:
以下是第一种情况的示例:
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
所以为了以防万一,请记住这件好事。
答案 2 :(得分:4)
为了更好地了解对齐的原因和方式,请查看Agner Fog's the microarchitecture doc,尤其是关于各种CPU设计的指令前端的部分。 Sandybridge引入了uop缓存,这与吞吐量有很大的不同,尤其是吞吐量。在SSE代码中,指令长度通常太长,每个周期16B,以覆盖4条指令。
填充uop缓存行的规则很复杂,但新的32B指令块总是会启动一个新的缓存行IIRC。因此,将热门功能入口点与32B对齐是一个好主意。在其他情况下,那么多的填充可能会伤害我的密度而不是帮助。 (L1 I $仍然有64B缓存行,所以有些东西可能会损害L1 I $密度,同时有助于提升缓存密度。)
循环缓冲区也有帮助,但是采用分支会破坏每个循环的4个uop。例如执行3个uop的循环,如abc
,abc
,而不是abca
,bcda
。因此,5-uop循环每2个循环进行一次迭代,而不是每1.25个循环。这使得展开更有价值。
答案 3 :(得分:3)
&#34;分支目标应该是16字节对齐的规则&#34;不是绝对的。规则的原因是,对于16字节对齐,可以在一个周期中读取16个字节的指令,然后在下一个周期中再读取16个字节。如果您的目标位于偏移16n + 2,则处理器仍然可以在一个周期内读取14个字节的指令(高速缓存行的其余部分),这通常足够好。然而,在偏移16n + 15处开始循环是个坏主意,因为一次只能读取一个指令字节。更有用的是将整个循环保持在尽可能少的高速缓存行中。
在某些处理器上,分支预测具有奇怪的行为,即8或4字节内的所有分支使用相同的分支预测器。移动分支,以便每个条件分支使用自己的分支预测器。
这两者的共同之处在于插入一些代码可以改变行为并使其更快或更慢。