本系列文章是阅读《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 条指令 |
名词解释:
- uop: 即微指令(Micro-operation),每条汇编指令会被转译成一个或多个微指令,这些微指令是 CPU 中指令执行部件执行的对象。
- 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 并使用一次的数据