【问题标题】:CISC short instructions vs long instructionsCISC 短指令与长指令
【发布时间】:2019-03-11 16:25:35
【问题描述】:

我目前正在编写一个编译器,并且即将实现代码生成。目前的目标指令集是 x64。
现在x64是CISC,所以有很多复杂的指令。但我知道这些是由 CPU 在内部转换为 RISC 的,之后还有乱序执行。
因此,我的问题是:使用更短的指令(类似 RISC)对使用更少的复杂指令有性能影响吗?我的语言的测试程序并没有那么大,所以我认为将指令放入缓存中目前应该没有问题。

【问题讨论】:

    标签: performance x86-64 instruction-set


    【解决方案1】:

    不,主要使用简单的 x86 指令(例如避免使用 push 并使用 sub rsp, whatever 并使用 mov 存储 args)对 P5-pentium 是一种有用的优化,因为它没有知道如何在内部拆分紧凑但复杂的指令。它的 2 宽超标量流水线只能配对简单的指令。

    现代 x86 CPU(自 Intel P6 (pentium pro / PIII) 以来,包括所有 x86-64 CPU)确实将复杂的指令解码为可以独立调度的多个微指令。 (对于常见的复杂指令,如push / pop,他们有技巧将它们作为单个 uop 处理。在这种情况下,一个堆栈引擎将堆栈指针重命名在核心的无序部分之外,因此pushrsp-=8 部分不需要uop。)

    add eax, [rdi] 这样的内存源指令甚至可以通过将负载与 ALU uop 微融合来解码为 Intel CPU 上的单个 uop,仅在乱序调度程序中将它们分开以分派到执行单元。在管道的其余部分,它只使用 1 个条目(在前端和 ROB 中)。 (但请参阅Micro fusion and addressing modes 以了解 Sandybridge 对索引寻址模式的限制,在 Haswell 及更高版本上有所放松。)AMD CPU 只是自然地将内存操作数与 ALU 指令融合在一起,并且不习惯将它们解码为额外的 m-ops / uops所以它没有一个花哨的名字。

    指令长度与简单的不完全相关。例如idiv rcx 只有 3 个字节,但在 Skylake 上解码为 57 uop。 (避免 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 instruction vs ADD 1: Does it matter?


    现代主流 x86 CPU 具有解码微指令缓存(AMD 自 Ryzen,Intel 自 Sandybridge),主要避免平均指令长度 > 4 的旧 CPU 的前端瓶颈。

    在此之前(Core2 / Nehalem),为了避免前端瓶颈而进行的调优比平均使用短指令要复杂得多。请参阅 Agner Fog 的微架构指南,了解有关解码器可以在那些较旧的 Intel CPU 中处理的 uop 模式的详细信息,以及相对于 16 字节边界的代码对齐效果,以便在跳转后获取等等。

    AMD Bulldozer 系列在 L1i 缓存中标记指令边界,如果集群的两个内核都处于活动状态,则每个周期最多可以解码 2x 16 个字节,否则 Agner Fog 的微架构 PDF (https://agner.org/optimize/) 报告每个周期约 21 个字节 (与英特尔在不从 uop 缓存运行时解码器每个周期最多 16 个字节的情况相比)。 Bulldozer 较低的后端吞吐量可能意味着前端瓶颈发生的频率较低。但我真的不知道,我还没有为 Bulldozer 系列调整任何东西,因为它可以访问硬件来测试任何东西。


    一个例子:这个函数用-O3-Os-Oz的clang编译

    int sum(int*arr) {
        int sum = 0;
        for(int i=0;i<10240;i++) {
            sum+=arr[i];
        }
        return sum;
    }
    

    Godbolt compiler explorer 上的 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
    

    这很愚蠢;它展开了 8,但仍然只有 1 个累加器。因此,它在 1 个周期延迟 add 上成为瓶颈,而不是在英特尔自 SnB 和自 K8 以来的 AMD 上每个时钟吞吐量 2 个负载。 (而且每个时钟周期只读取 4 个字节,它可能不会对内存带宽造成太大的瓶颈。)

    使用普通 -O3 效果更好,不禁用矢量化,使用 2 个矢量累加器:

    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 + paddd 只有 2 uop,所以我们离前端瓶颈还很远。在每时钟 2 个 movdqu 加载的情况下,假设数据在 L1d 高速缓存或 L2 中是热的,则此循环每个时钟周期可以处理 32 个字节的输入,否则它将运行得更慢。这种超过最小值的展开将使乱序执行提前运行,并在 paddd 工作赶上之前看到循环退出条件,并且可能主要隐藏最后一次迭代的分支错误预测。

    使用超过 2 个累加器来隐藏延迟在 FP 代码中非常重要,因为大多数指令没有单周期延迟。 (这对于 AMD Bulldozer 系列上的此功能也很有用,其中paddd 有 2 个周期延迟。)

    由于大展开和大位移,编译器有时会生成大量指令,在寻址模式中需要disp32 位移而不是disp8。选择增加循环计数器或指针的点以使用 -128 的位移保持尽可能多的寻址模式.. +127 可能是一件好事。

    除非您正在调整 Nehalem / Core2 或其他没有 uop 缓存的 CPU,否则您可能不希望增加额外的循环开销(add rdi, 256 两次而不是 add rdi, 512 或其他东西)只是为了缩小代码大小。


    相比之下,clang -Os 仍然自动矢量化(除非您禁用它),在 Intel 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
    

    但是使用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 将避免inccmp 上的REX 前缀。已知范围固定为 32 位。可能它正在使用 RCX,因为它在寻址模式下使用之前将 int 提升为 64 位以避免 movsxd rcx,ecx 符号扩展为 64 位。 (因为有符号溢出是C中的UB。)但是这样做之后,它可以在注意到范围后再次优化它。

    循环是 3 微指令(假设从 Nehalem 开始在 Intel 上使用宏融合 cmp/jne,从 Bulldozer 开始使用 AMD),或者在 Sandybridge 上使用 4 微指令(使用索引寻址模式对 add 进行分层)。指针增量循环可能会稍微在某些 CPU 上效率更高,即使在 SnB/IvB 上也只需要循环内的 3 微指令。


    Clang 的 -Oz 输出实际上更大,显示出其代码生成策略的迹象。许多循环不能被证明至少运行 1 次,因此需要一个条件分支来跳过循环,而不是在零次运行的情况下陷入循环。或者他们需要跳转到底部附近的入口点。 (Why are loops always compiled into "do...while" style (tail jump)?)。

    看起来 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
    

    除了额外的 jmp 进入循环之外,一切都一样。

    在功能更多的函数中,您会在代码生成中看到更多差异。就像可能使用慢速 div 一样,即使是编译时常量,而不是乘法逆 (Why does GCC use multiplication by a strange number in implementing integer division?)。

    【讨论】:

      猜你喜欢
      • 2012-06-27
      • 1970-01-01
      • 2016-09-23
      • 1970-01-01
      • 2017-04-23
      • 1970-01-01
      • 1970-01-01
      • 2017-03-20
      • 2020-02-22
      相关资源
      最近更新 更多