我目前正在对编译器进行编程,并且即将实现代码生成。现在的目标指令集是x64。
现在x64是CISC,因此有许多复杂的指令。但是我知道它们是由CPU在内部转换为RISC的,在那之后还会发生乱序执行。
因此,我的问题是:与使用较少的复杂指令相比,使用较短的指令(类似RISC)是否会对性能产生影响?用于我的语言的测试程序并不算大,所以我认为将指令放入缓存中应该不是问题。
答案 0 :(得分:6)
不,对于P5-pentium,使用大多数简单的x86指令(例如,避免使用push
和使用sub rsp, whatever
并用mov
存储args)是对P5-pentium的有用的优化,因为它 didn 't 知道如何在内部拆分紧凑但复杂的指令。它的2宽超标量流水线只能配对简单的指令。
现代x86 CPU(自Intel P6(pentium pro / PIII起,包括所有x86-64 CPU))确实将复杂的指令解码为多个可以独立调度的指令。 (对于push
/ pop
等常见的复杂指令,它们具有将它们作为单个uop进行处理的技巧。在这种情况下,堆栈引擎会以无序的方式重命名堆栈指针核心的一部分,因此rsp-=8
的{{1}}部分不需要uop。)
像push
这样的内存源指令甚至可以通过将负载与ALU uop微融合,从而在Intel CPU上解码为单个uop,仅在乱序调度程序中将它们分开,以分派给执行单元。在管道的其余部分中,它仅使用1个条目(在前端和ROB中)。 (但请参阅Micro fusion and addressing modes,以了解使用索引寻址模式在Sandybridge上的局限性,在Haswell及更高版本上有所放松。)AMD CPU自然而然地将内存操作数与ALU指令融合在一起,并且没有用于将它们解码为额外的m-ops / uops,所以它没有花哨的名称。
指令长度不能简单地与之完全相关。例如add eax, [rdi]
只有3个字节,但在Skylake上解码为57 oups。 (避免64位除法,它比32位要慢。)
代码越小越好,其他所有条件都相同。如果可以避免REX前缀,则最好使用32位操作数大小,并选择不需要REX前缀的寄存器(例如idiv rcx
而不是ecx
)。但是通常不要花费额外的指令来实现这一目标。 (例如,使用r8d
而不是保存/恢复r8d
,以便可以将rbx
用作另一个暂存器)。
但是,当所有其他条件都不相等时,大小通常是实现高性能的最后优先级,这是在最大程度地降低uops并保持延迟依赖关系链短(尤其是循环依赖)之后链)。
大多数程序将大部分时间都花在足够小的循环中以适合L1d缓存,而大部分时间则花在其中的几个甚至更小的循环中。
除非您可以正确识别“冷”代码(很少执行),否则使用3字节ebx
/ push 1
(而不是5字节)来优化速度大小字节pop rax
绝对不是一个好的默认值。 clang / LLVM将使用mov eax, 1
(仅针对大小进行优化)而不是-Oz
(针对大小与速度的平衡进行优化)推送/弹出常量。
使用-Os
代替inc
可以节省一个字节(x86-64中只有1个字节,而32位代码中只有2个字节)。有了寄存器目标,在大多数情况下,在大多数CPU上,速度都一样快。参见INC instruction vs ADD 1: Does it matter?
现代主流x86 CPU具有解码uop缓存(自Ryzen起为AMD,自Sandybridge起为Intel),大多数情况下避免了平均指令长度> 4的较旧CPU的前端瓶颈。
在此之前(Core2 / Nehalem),为避免前端瓶颈而进行的调整要比平均使用简短指令复杂得多。有关解码器可以在那些较旧的Intel CPU中处理的uop模式的详细信息,请参阅Agner Fog的微体系结构指南,以及有关跳转后获取的相对于16字节边界的代码对齐效果的详细信息。
AMD Bulldozer系列标记了L1i缓存中的指令边界,并且如果集群的两个核心都处于活动状态,则每个周期最多可以解码2x 16字节,否则Agner Fog的microarch PDF(https://agner.org/optimize/)报告每个〜21字节周期(而不是从uop缓存中运行时,解码器的英特尔每个解码器最多16个字节)。推土机较低的后端吞吐量可能意味着前端瓶颈的发生频率降低。但是我真的不知道,我还没有为推土机家族调整任何可以访问硬件以测试任何东西的东西。
示例:此函数使用带有add reg,1
,-O3
和-Os
的clang编译
-Oz
Godbolt compiler explorer上的Source + asm输出,您可以在其中使用此代码和编译器选项。
我还使用了int sum(int*arr) {
int sum = 0;
for(int i=0;i<10240;i++) {
sum+=arr[i];
}
return sum;
}
,因为我假设您不会尝试使用SSE2自动矢量化,即使这是x86-64的基准。 (尽管这会使该循环加快4倍
-fno-vectorize
这真是愚蠢;它展开了8个,但仍然只有1个累加器。因此,自从SnB和AMD自K8起,它就瓶颈了1个周期延迟# 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
,而不是Intel的每时钟吞吐量2个负载。 (而且每个时钟周期仅读取4个字节,它可能不会对内存带宽造成很大的瓶颈。)
使用2个向量累加器,在普通-O3情况下效果更好,而不禁用向量化:
add
此版本的展开可能超出了所需的范围;循环开销很小,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
+ movdqu
仅为2 oups,因此我们离前端瓶颈还很远。假设每2个时钟paddd
加载一次,则该循环可以在每个时钟周期处理32个字节的输入,前提是数据在L1d缓存或L2d中很热,否则运行速度会变慢。这种多于最小的展开将使乱序的执行提前进行,并在movdqu
工作完成之前查看循环退出条件,并且可能大部分隐藏了上次迭代时分支的错误预测。
使用2个以上的累加器隐藏延迟在FP代码中非常重要,因为FP代码中的大多数指令都没有单周期延迟。 (对于paddd
具有2个周期延迟的AMD Bulldozer系列,此功能也很有用。)
在展开时,位移大时,编译器有时会生成很多指令,这些指令在寻址模式下需要paddd
位移而不是disp32
。选择偏移量来增加循环计数器或指针的位置以使用-128 .. +127的位移来保持尽可能多的寻址模式可能是一件好事。
除非您要针对Nehalem / Core2或其他没有uop缓存的CPU进行调整,否则您可能不想添加额外的循环开销(两次disp8
,而不是add rdi, 256
之类的)缩小代码大小。
与之相比,add rdi, 512
仍会自动矢量化(除非您将其禁用),其内部循环在Intel CPU上的长度恰好为4ups。
clang -Os
但是使用# 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
,我们得到了简单明显的最小标量实现:
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
将避免在ecx
和inc
上使用REX前缀。该范围已知可固定为32位。可能是因为它使用RCX,因为它已将cmp
提升为64位,以避免在寻址模式下使用之前将int
的符号扩展为64位。 (因为带符号的溢出是C中的UB。)但是这样做之后,它可以在注意到范围之后再次对其进行优化。
该循环为3 uops(假设自Nehalem以来在Intel上为宏融合cmp / jne,自Bulldozer以来为AMD上为AMD),或Sandybridge上为4 uops(以索引寻址模式对add进行分层)。在某些CPU上效率更高,即使在SnB / IvB上,循环内部也只需要3 uops。
Clang的movsxd rcx,ecx
输出实际上更大,显示出其代码生成策略的迹象。无法证明许多循环至少要运行1次,因此需要一个条件分支来跳过循环,而不是在“零运行时间”情况下陷入循环之中。或者他们需要跳到底部附近的入口点。 (Why are loops always compiled into "do...while" style (tail jump)?)。
看起来像LLVM的-Oz
代码源无条件地使用了从下到上的策略,而无需检查条件在第一次迭代中是否总是成立。
-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
以外,其他都一样。
在执行更多功能的函数中,您会看到代码生成方面的更多差异。就像甚至对于编译时常量也可以使用慢速jmp
一样,而不是乘法逆(Why does GCC use multiplication by a strange number in implementing integer division?)。