Ric*_*ard 1 performance x86-64 instruction-set
我目前正在编写编译器,即将实现代码生成.目前的目标指令集是x64.
现在x64是CISC,因此有许多复杂的指令.但我知道这些内部由CPU内部转换为RISC,之后也会出现无序执行.
因此,我的问题是:使用更短的指令(类似RISC)是否会比使用更少的复杂指令产生性能影响?我语言的测试程序并不是那么大,所以我认为在缓存中使用指令应该不是问题.
不,使用大多数简单的x86指令(例如,避免push和使用sub rsp, whatever和存储args mov)是P5-pentium的有用优化,因为它不知道如何在内部拆分紧凑但复杂的指令.它的2宽超标量管道只能配对简单的指令.
现代x86 CPU(自Intel P6(pentium pro/PIII),包括所有x86-64 CPU)确实可以将复杂指令解码为可以独立调度的多个uop.(对于像push/ 这样的常见复杂指令pop,它们有把它们作为单个uop处理的技巧.在这种情况下,堆栈引擎将堆栈指针重命名为核心的无序部分之外,因此不需要uop对于rsp-=8部分push.)
内存源指令add eax, [rdi]甚至可以通过将负载与ALU uop微融合来解码到Intel CPU上的单个uop,只将它们分离在无序调度程序中以便分派到执行单元.在管道的其余部分,它只使用1个条目(在前端和ROB).(但请参阅微融合和寻址模式,了解Sandybridge的索引寻址模式的限制,在Haswell及其后的版本中有所放松.)AMD CPU只是自然地将内存操作数与ALU指令融合,并且不用于将它们解码为额外的m-ops/uops所以它没有花哨的名字.
指令长度并不是完全相关的.例如idiv rcx,只有3个字节,但在Skylake上解码为57微秒.(避免64位除法,它比32位慢.)
较小的代码更好,其他条件相同.当它足以避免REX前缀时,首选32位操作数大小,并选择不需要REX前缀的寄存器(比如ecx代替r8d).但通常不会花费额外的指示来实现这一点.(例如,使用r8d而不是保存/恢复,rbx因此您可以使用ebx另一个临时寄存器).
但是当其他所有不相等时,大小通常是高性能的最后优先级,在最小化uops并保持延迟延迟依赖链(特别是循环携带的依赖链)之后.
大多数程序将大部分时间花在小到足以适应L1d缓存的循环中,并且大部分时间都在其中的一些甚至更小的循环中.
除非你能正确识别"冷"代码(很少执行),否则用3字节push 1/ pop rax而不是5字节mov eax, 1来优化大小速度绝对不是一个好的默认值.clang/LLVM将推送/弹出常量-Oz(仅针对大小进行优化),但不是-Os(针对大小和速度的平衡进行优化).
使用inc而不是add reg,1保存一个字节(在x86-64中只有1,在32位代码中只有2).使用寄存器目标,在大多数情况下,它在大多数CPU上都一样快.请参阅INC指令与ADD 1:重要吗?
现代主流的x86 CPU具有解码uop缓存(自Ryzen以来的AMD,自Sandybridge以来的Intel),主要避免了平均指令长度> 4的旧CPU的前端瓶颈.
在此之前(Core2/Nehalem),调整以避免前端瓶颈更加复杂,平均只使用简短的指令.有关解码器可以在较旧的Intel CPU中处理的uop模式的详细信息,以及相对于跳转后获取的16字节边界的代码对齐效果等详细信息,请参阅Agner Fog的微型指南.
AMD Bulldozer系列标记L1i缓存中的指令边界,如果群集的两个核心都处于活动状态,则每个周期可以解码最多2x 16个字节,否则Agner Fog的微架PDF(https://agner.org/optimize/)报告~21每个周期的字节数(相对于英特尔,当没有从uop缓存运行时,解码器每个周期最多16个字节).Bulldozer的后端吞吐量较低可能意味着前端瓶颈的发生频率较低.但我真的不知道,我没有为Bulldozer家族调整任何东西,可以使用硬件来测试任何东西.
举个例子:这个函数编译铿锵用-O3,-Os和-Oz
int sum(int*arr) {
int sum = 0;
for(int i=0;i<10240;i++) {
sum+=arr[i];
}
return sum;
}
Run Code Online (Sandbox Code Playgroud)
Godbolt编译器资源管理器上的Source + asm输出,您可以在其中使用此代码和编译器选项.
我也使用过,-fno-vectorize因为我假设您不会尝试使用SSE2进行自动向量化,即使这是x86-64的基线.(虽然这会使这个循环加速4倍
# clang -O3 -fno-vectorize
sum: # @sum
xor eax, eax
mov ecx, 7
.LBB2_1: # =>This Inner Loop Header: Depth=1
add eax, dword ptr [rdi + 4*rcx - 28]
add eax, dword ptr [rdi + 4*rcx - 24]
add eax, dword ptr [rdi + 4*rcx - 20]
add eax, dword ptr [rdi + 4*rcx - 16]
add eax, dword ptr [rdi + 4*rcx - 12]
add eax, dword ptr [rdi + 4*rcx - 8]
add eax, dword ptr [rdi + 4*rcx - 4]
add eax, dword ptr [rdi + 4*rcx]
add rcx, 8
cmp rcx, 10247
jne .LBB2_1
ret
Run Code Online (Sandbox Code Playgroud)
这很傻; 它由8展开但仍然只有1个累加器.因此,add自从K8以来,自从SnB和AMD以来,英特尔的1个周期延迟而不是每时钟吞吐量2个负载的瓶颈.(并且每个时钟周期只读取4个字节,它可能不会对内存带宽产生很大的影响.)
使用2个向量累加器,使用正常的-O3,而不是禁用向量化,效果更好:
sum: # @sum
pxor xmm0, xmm0 # zero first vector register
mov eax, 36
pxor xmm1, xmm1 # 2nd vector
.LBB2_1: # =>This Inner Loop Header: Depth=1
movdqu xmm2, xmmword ptr [rdi + 4*rax - 144]
paddd xmm2, xmm0
movdqu xmm0, xmmword ptr [rdi + 4*rax - 128]
paddd xmm0, xmm1
movdqu xmm1, xmmword ptr [rdi + 4*rax - 112]
movdqu xmm3, xmmword ptr [rdi + 4*rax - 96]
movdqu xmm4, xmmword ptr [rdi + 4*rax - 80]
paddd xmm4, xmm1
paddd xmm4, xmm2
movdqu xmm2, xmmword ptr [rdi + 4*rax - 64]
paddd xmm2, xmm3
paddd xmm2, xmm0
movdqu xmm1, xmmword ptr [rdi + 4*rax - 48]
movdqu xmm3, xmmword ptr [rdi + 4*rax - 32]
movdqu xmm0, xmmword ptr [rdi + 4*rax - 16]
paddd xmm0, xmm1
paddd xmm0, xmm4
movdqu xmm1, xmmword ptr [rdi + 4*rax]
paddd xmm1, xmm3
paddd xmm1, xmm2
add rax, 40
cmp rax, 10276
jne .LBB2_1
paddd xmm1, xmm0 # add the two accumulators
# and horizontal sum the result
pshufd xmm0, xmm1, 78 # xmm0 = xmm1[2,3,0,1]
paddd xmm0, xmm1
pshufd xmm1, xmm0, 229 # xmm1 = xmm0[1,1,2,3]
paddd xmm1, xmm0
movd eax, xmm1 # extract the result into a scalar integer reg
ret
Run Code Online (Sandbox Code Playgroud)
这个版本的展开可能超出了它的需要; 循环开销很小,movdqu+ paddd只有2微秒,所以我们远远没有在前端的瓶颈.每个时钟movdqu负载2个,这个循环每个时钟周期可以处理32个字节的输入,假设数据在L1d高速缓存或L2中是热的,否则它将运行得更慢.这个超过最小的展开将允许无序执行提前运行并在paddd工作赶上之前看到循环退出条件,并且可能主要隐藏最后一次迭代的分支错误预测.
在FP代码中使用2个以上的累加器来隐藏延迟非常重要,因为大多数指令都没有单周期延迟.(对于paddd具有2个周期延迟的AMD Bulldozer系列中的这个功能也很有用.)
由于大的展开和大的位移,编译器有时会生成大量需要disp32位移而不是disp8寻址模式的指令.选择增加循环计数器或指针的点以使用-128 .. +127的位移保持尽可能多的寻址模式可能是一件好事.
除非你在没有uop缓存的情况下调整Nehalem/Core2或其他CPU,否则你可能不希望增加额外的循环开销(add rdi, 256两次而不是两次add rdi, 512)只是为了缩小代码大小.
相比之下,clang -Os仍然自动矢量化(除非你禁用它),内部循环在英特尔CPU上正好是4微秒长.
# clang -Os
.LBB2_1: # =>This Inner Loop Header: Depth=1
movdqu xmm1, xmmword ptr [rdi + 4*rax]
paddd xmm0, xmm1
add rax, 4
cmp rax, 10240
jne .LBB2_1
Run Code Online (Sandbox Code Playgroud)
但是,有了clang -Os -fno-vectorize,我们得到了简单明了的最小标量实现:
# clang -Os -fno-vectorize
sum: # @sum
xor ecx, ecx
xor eax, eax
.LBB2_1: # =>This Inner Loop Header: Depth=1
add eax, dword ptr [rdi + 4*rcx]
inc rcx
cmp rcx, 10240
jne .LBB2_1
ret
Run Code Online (Sandbox Code Playgroud)
错过优化:使用ecx会避免使用REX前缀inc和cmp.已知该范围以32位固定.可能它正在使用RCX,因为它提升int到64位以避免movsxd rcx,ecx在寻址模式下使用之前将符号扩展扩展到64位.(因为签名溢出在C中是UB.)但是在这样做之后,它可以在注意到范围后再次优化它.
循环是3 uops(假设自Nehalem和AMD自Bulldozer以来在英特尔上使用宏融合cmp/jne),或者在Sandybridge上使用4 uop(使用索引寻址模式添加unula).指针增量循环可能会稍微提高效率一些CPU,即使在SnB/IvB上也只需要3个uop.
Clang的-Oz输出实际上更大,显示出其代码策略的迹象.许多循环不能被证明至少运行一次,因此需要一个条件分支来跳过循环而不是在run-zero-times情况下陷入循环.或者他们需要跳到靠近底部的入口点.(为什么循环总是编译成"do ... while"样式(尾部跳转)?).
看起来LLVM的-Oz代码无条件地使用跳到底策略,而不检查条件在第一次迭代时是否可证明总是为真.
sum: # @sum
xor ecx, ecx
xor eax, eax
jmp .LBB2_1
.LBB2_3: # in Loop: Header=BB2_1 Depth=1
add eax, dword ptr [rdi + 4*rcx]
inc rcx
.LBB2_1: # =>This Inner Loop Header: Depth=1
cmp rcx, 10240
jne .LBB2_3
ret
Run Code Online (Sandbox Code Playgroud)
一切都是相同的,除了jmp进入循环的额外.
在功能更强大的功能中,您会看到代码更多的差异.就像使用慢速div甚至编译时常量,而不是乘法逆(为什么GCC在实现整数除法时使用乘以奇数?).
| 归档时间: |
|
| 查看次数: |
81 次 |
| 最近记录: |