我目前正在编写一些C99标准库字符串函数的高度优化版本,如strlen()、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 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秒的时间,这是很好的。
但是它不检查空字符串输入。因此,显然,我将添加一个简单的检查:
_strlen64_sse2:
test rdi, rdi
jz .null
; ...同样的测试,它现在在0.59秒内运行。但是,如果在检查后对齐代码:
_strlen64_sse2:
test rdi, rdi
jz .null
align 8
; ...原来的表演又回来了。我使用8来对齐,因为4不会改变任何东西。
有人能解释一下这一点吗,并就何时对齐或不对齐代码部分给出一些建议吗?
编辑2
当然,这并不像调整每个分支目标那么简单。如果我这样做,表演通常会变得更糟,除非像上面这样的一些特殊情况。
发布于 2013-08-17 10:41:28
为了扩展代码艺术家的答案,他提出了一些好的观点,这里有一些额外的内容和细节,因为我实际上能够解决这个问题。
1 -代码对齐
英特尔建议在16字节边界上调整代码和分支目标。
3.4.1.5 -汇编/编译器编码规则12 (M影响,H一般性) 所有分支目标应该是16字节对齐的。
虽然这通常是一个很好的建议,但应该小心地执行。
盲目地对齐所有的16字节可能会导致性能下降,因此在应用之前,应该在每个分支目标上测试这一点。
正如TheCodeArtist所指出的,使用多字节NOPs可能会有所帮助,因为简单地使用标准的单字节NOPs可能不会带来预期的代码对齐性能增益。
作为一个侧面,.p2align指令在NASM或YASM中是不可用的。
但它们确实支持与标准align指令中的NOPs以外的其他指令对齐:
align 16, xor rax, rax2 .分支预测
事实证明这是最重要的部分。
虽然每一代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在回答中还提到了循环展开。
虽然这不是问题,因为我的循环已经展开,但我在这里提到它,因为它确实是极其重要的,并带来了显著的性能提升。
作为读者的最后一个注意,虽然这看起来很明显,也不是这里的问题,但是当没有必要的时候,不要分支。
从奔腾Pro开始,x86处理器有条件移动指令,这可能有助于消除分支和抑制错误预测的风险:
test rax, rax
cmovz rbx, rcx所以以防万一,记住这是件好事。
发布于 2015-07-27 00:18:36
为了更好地理解对齐的原因和方式,请查看阿格纳·福格的微结构医生,特别是。有关各种CPU设计的指令获取前端的部分.Sandybridge引入了uop缓存,这与吞吐量(特别是吞吐量)有很大的不同。在SSE代码中,指令长度通常太长,每周期16 B,无法覆盖4条指令。
填充uop缓存行的规则很复杂,但是一个新的32B指令块总是启动一个新的缓存行IIRC。因此,将热函数入口点与32B对齐是个好主意。在其他情况下,这么多的填充物对I$ density的伤害可能比帮助更大。(不过,L1 I$仍然有64B个缓存行,所以有些东西可能会在帮助uop缓存密度的同时损害L1 I$ density。)
循环缓冲区也有帮助,但是分支破坏了每个周期的4个uop,特别是在Haswell之前。例如,在SnB/IvB上执行一个由3个uop组成的循环,如abc、abc、而不是abca、bcda。因此,5-uop循环每2个周期进行一次迭代,而不是1.25次迭代.这使得展开更有价值。(Haswell和后来似乎在LSD中展开了小循环,使5-uop循环没有那么糟糕:当执行uop计数不是处理器宽度倍数的循环时,性能会下降吗?)
发布于 2015-07-26 23:28:02
“分支目标应该是16字节对齐规则”不是绝对的。该规则的原因是,使用16字节对齐,16字节指令可以在一个周期中读取,然后在下一个周期中读取另16个字节。如果您的目标是偏移量16n + 2,那么处理器仍然可以在一个周期内读取14个字节的指令(缓存行的其余部分),这通常是足够好的。然而,在偏移量16n + 15处启动循环是个坏主意,因为一次只能读取一个指令字节。更有用的方法是将整个循环保持在尽可能少的缓存行中。
在某些处理器上,分支预测具有8或4个字节内所有分支都使用相同分支预测器的奇怪行为。移动分支,以便每个条件分支使用自己的分支预测器。
两者的共同点是,插入一些代码可以改变行为,使其更快或更慢。
https://stackoverflow.com/questions/18113995
复制相似问题