程序运行性能极限探究(一):CPU 流水线宽度

1,874 阅读4分钟

本系列文章是阅读《Performance Speed Limits》[1]一文的笔记,感兴趣的同学可以在读完本文后细读一下原文,相信会对相关知识有更深刻的理解。

更多精彩文章,请关注微信公众号:码工笔记

给你一段程序,如何评估它最快能运行多快呢?哪些因素决定了它运行速度的上限呢?本系列文章试图回答这个问题。

一、CPU 流水线宽度(Pipeline Width

CPU 型号流水线宽度(每个时钟周期执行的指令数)
Intel Skylake最多 4 条 fused 微指令(uop)
Intell Ice Lake最多 5 条 fused 微指令
AMD Zen, Zen2由最多 5 条指令生成的最多 6 个 MOP
AMD Zen 3由最多 6 条指令生成的最多 6 个 MOP
Apple M1最多 8 条指令

名词解释:

  1. uop: 即微指令(Micro-operation),每条汇编指令会被转译成一个或多个微指令,这些微指令是 CPU 中指令执行部件执行的对象。
  2. fused 和 unfused 微指令:对于源操作数或目的操作数是涉及访存的指令来说,如:add eax, [rsp] 表示将 rsp 指向的内存数据与 eax 寄存器中的数据相加。指令执行部件在执行此指令时,会生成两条微指令:load 和add,这就叫做 unfused 指令。但是,在执行前,这些微指令是作为一条指令存在的,从 CPU 流水线的角度来说,它只算作一条指令。
    • AMD 的 MOP(macro operations)与 Intel 的 fused 微指令概念类似

CPU 在每个时钟周期内只能执行一定数量的指令。早期的 CPU 每个时钟只能执行一条指令。现代的支持超标量流水线的 CPU 可以执行 >1 条指令,但也是有其上限的。其所受限的原因在不同的 CPU 中可能来自其内不同的部件,如某些 CPU 可能是受指令译码器的限制,而另外一些 CPU 则受寄存器重命名或 retirement 的限制。

现代 Intel CPU 的流水线宽度上限为 4 个 fused 指令,如果一段代码包含的指令数超过了这个数值,则无法在一个时钟周期内完成。

参考如下例子,此 c 方法的功能是将一个 32 位整数数组中每个数的高 16 位和低 16 位分别求和:

uint32_t top = 0, bottom = 0;
for (size_t i = 0; i < len; i += 2) {
    uint32_t elem;

    elem = data[i];
    top    += elem >> 16;
    bottom += elem & 0xFFFF;

    elem = data[i + 1];
    top    += elem >> 16;
    bottom += elem & 0xFFFF;
}

编译成汇编语言后变成如下形式:

top:
    mov    r8d,DWORD [rdi+rcx*4]          ; 1
    mov    edx,DWORD [rdi+rcx*4+0x4]      ; 2
    add    rcx,0x2                        ; 3
    mov    r11d,r8d                       ; 4
    movzx  r8d,r8w                        ; 5
    mov    r9d,edx                        ; 6
    shr    r11d,0x10                      ; 7
    movzx  edx,dx                         ; 8
    shr    r9d,0x10                       ; 9
    add    edx,r8d                        ; 10
    add    r9d,r11d                       ; 11
    add    eax,edx                        ; 12
    add    r10d,r9d                       ; 13
    cmp    rcx,rsi                        ; (fuses w/ jb)
    jb     top                            ; 14

上面的例子中一共有 14 条 uop(最后两条会 macro-fuse 成为一条微指令)。

macro-fuse: 一些算术运算指令与紧跟其后的跳转指令(如:dec eax; jnz label),可以被融合成一个 uop。

如果使用 Intel CPU,则 14/4 = 3.5 个时钟就能完成一次循环(即,每个数据花费 1.75 个时钟)。经实验测试,每次循环花费 3.51 个时钟,即每个时钟周期执行 3.99 个 fused 微指令,也即达到了流水线的运行上限。

对于较复杂的代码来说,手动计算微指令数量就比较困难了,可以使用 Intel 提供的 performance counter: uops_issued.any(fused 微指令个数)。

:这个例子使用 performance counter 实际测量得到的值 14.03。其中多出来的 0.03 主要是由于在 benchmark 中这段代码是由一个外层循环(1024次)重复调用的。每次当上面的加法循环结束,要开始下一次外层循环时,CPU 会出现一次 branch misprediction,这时 CPU 会预取和预执行了一部分预判错误的代码。可以使用uopps_retired.retire_slots(只计 uops 中真正 reitred 的个数,而不包括预测错误的分支上的指令),其值为 14.01,也即 2/3 的多余操作来自分支错判,剩下的 0.01 则是外层循环的开销。

性能优化方法:

在流水线宽度的限制下,要想提高程序运行性能,就需要减少 fused 微指令数量:

  • 减少指令数量
    • 循环展开
    • 选择更高效的指令
    • 删除无用指令
  • 向量化
    • 用一条指令做尽量多的事
  • Micro-fusion
    • 局限于 x86,尽量寻找可以将 load 和 ALU 运算指令 fuse 的机会,这样 fuse 之后的指令只占流水线宽度的一个位置。这种优化通常只局限于那种只需要 load 并使用一次的数据

参考资料

  1. travisdowns.github.io/blog/2019/0…