CISC 短指令与长指令

CISC short instructions vs long instructions

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

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

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

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

指令长度与 simplicitly 不完全相关。例如idiv rcx 只有 3 个字节,但在 Skylake 上解码为 57 微指令。 (避免 64 位除法,它比 32 位慢。)


代码越小越好,其他条件相同。当足以避免 REX 前缀时,首选 32 位操作数大小,并选择不需要 REX 前缀的寄存器(如 ecx 而不是 r8d)。但通常不会花费额外的指令来实现这一点。 (例如,使用 r8d 而不是 saving/restoring rbx,这样您就可以将 ebx 用作另一个临时寄存器)。

但是当所有其他因素相等时,大小通常是高性能的最后优先事项,排在最小化微指令和保持延迟依赖性之后短链(尤其是循环携带的依赖链)。


大多数程序将大部分时间花在小到足以放入 L1d 缓存的循环中,并且大部分时间花在其中的几个更小的循环中。

除非你能正确识别 "cold" 代码(很少执行),使用 3 字节 push 1 / [= 之类的东西优化大小而不是速度31=] 而不是 5 字节 mov eax, 1 绝对不是一个好的默认值。 clang/LLVM 将 push/pop 用于具有 -Oz 的常量(仅针对大小进行优化),而不是 -Os (针对大小和速度的平衡进行优化)。

使用 inc 而不是 add reg,1 可以节省一个字节(x86-64 中只有 1 个字节,而 32 位代码中只有 2 个字节)。使用寄存器目标,在大多数情况下,它在大多数 CPU 上都一样快。参见


现代主流 x86 CPU 具有解码 uop 缓存(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 缓存 运行ning 时解码器每个周期最多 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 上的源代码 + 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 上成为瓶颈,而不是在 Intel 自 SnB 和 AMD 自 K8 以来每个时钟吞吐量上有 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

这个版本的展开可能超出了它的需要;循环开销很小,movdqu + paddd 只有 2 微指令,所以我们离前端的瓶颈还很远。对于每个时钟 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 以来英特尔和 AMD 自 Bulldozer 以来宏融合 cmp/jne),或 Sandybridge 上的 4 微指令(使用索引寻址模式的 add unlamination。)指针增量循环在某些 CPU 上的效率可能稍微高一些,即使在 SnB/IvB.

上,循环内也只需要 3 微指令

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

看起来像 LLVM 的 -Oz code-gen 无条件地使用跳到底部的策略,而不检查条件是否在第一次迭代中始终为真。

 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?)。