CISC短指令与长指令

Ric*_*ard 1 performance x86-64 instruction-set

我目前正在编写编译器,即将实现代码生成.目前的目标指令集是x64.
现在x64是CISC,因此有许多复杂的指令.但我知道这些内部由CPU内部转换为RISC,之后也会出现无序执行.
因此,我的问题是:使用更短的指令(类似RISC)是否会比使用更少的复杂指令产生性能影响?我语言的测试程序并不是那么大,所以我认为在缓存中使用指令应该不是问题.

Pet*_*des 6

不,使用大多数简单的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前缀inccmp.已知该范围以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 次

最近记录:

7 年,2 月 前