本系列文章是阅读《Performance Speed Limits》[1]一文的笔记,感兴趣的同学可以在读完本文后细读一下原文,相信会对相关知识有更深刻的理解。
更多精彩文章,请关注微信公众号:码工笔记
给你一段程序,如何评估它最快能运行多快呢?哪些因素决定了它运行速度的上限呢?本系列文章试图回答这个问题。
前序文章如下:
三、内存读操作(Load)次数
| CPU | Load 次数上限(每个时钟周期) |
|---|---|
| Intel Skylake, Ice lake | 2 loads |
| AMD Zen, Zen 2 | 2 loads |
| AMD Zen 3 | 3 loads |
| Apple M1 | 3 loads |
上表中的每个时钟 2 到 3 次的吞吐量上限,只有在所有的 Load 都命中 L1 Cache 才能达到。
如果没有命中 L1 cache,或者即使数据在 L1 cache 中,但多个读操作在 AMD 或老的 Intel CPU 上满足了 bank conflict 的条件,实际上也不能达到读操作吞吐量的上限值。
Bank conflict:对于设计为 bank 结构的 cache 来说,L1 cache 中的数据存放于多个 bank 中。同一个时钟周期内,对同一 bank 内的数据只能进行一次 load 操作,否则就会发生 bank conflict。而对于不同 bank 中的的数据,则可同时读取。
对于宽度为 4 的指令流水线来说,即使其中有 2 条是 load 指令,CPU 仍可全速运行。从吞吐量的角度来说,命中 cache 的访存操作并不比 ALU 算术指令慢多少。
这个 load 的吞吐量上限不太容易遇到,它要求所有的 load 操作都需要相互独立(无依赖),否则,瓶颈将在于等待依赖 load 完成,而不是 load 吞吐量上限。
以下是一个例子,
do {
sum1 += data[offsets[i - 1]];
sum2 += data[offsets[i - 2]];
i -= 2;
} while (i);
编译成汇编以后成为:
.top: ; total fused uops
mov r8d,DWORD PTR [rsi+rdx*4-0x4] ; 1
add ecx,DWORD PTR [rdi+r8*4] ; 2
mov r8d,DWORD PTR [rsi+rdx*4-0x8] ; 3
add eax,DWORD PTR [rdi+r8*4] ; 4
sub rdx,0x2 ; (fuses w/ jne)
jne .top ; 5
例子中一共只有 5 条 fused 微指令,指令流水线宽度为 4 的情况下,只需要 1.25 个时钟周期就能执行完吗?不行 - 实际上这段代码需要 2 个时钟周期,因为其中有 4 条 Load 指令,而每个时钟周期 Load 次数上限值为 2。
注意:那些 SIMD 指令,如 vpgatherdd 一次可以读取 8 个数据,其 Load 次数应该算作 8 而不是 1.
Cache 跨行
Cache 跨行读取 指的是所读数据超过 2 个字节,且这数据内存跨了 64 byte 的边界(即数据的前一部分属于一个 64 byte 的行,而后一部分属于另一个 64 byte 行)。
如果 Load 操作是自然对齐(naturally aligned),则不会出现 Cache 跨行的情况。如果 Load 操作对应的数据是随机对齐的,则 Cache 跨行的几率取决于 Load 的数据长度:要 Load N 个字节的数据,则其发生跨行的几率为 (N-1)/64。所以,对于 32-bit 随机对齐的 Load 来说,其跨行几率为 5%,256-bit AVX Load 则为 48%, AVX-512 则高达 98%。
在 AMD Zen 1 上,如果一次 Load 跨了 32-byte 的边界,则对于 Load 次数的限制来说,这次 Load 会算作两次 Load。Zen1 上 32-byte(AVX/AVX2)的 Load 也算作 2 次 Load,因为此 CPU 上的向量部件宽度为 128-bit,所以要 Load 32-byte 的数据实际也需要 2 次才能完成。未对齐的 32-byte Load 将被算作 3 次,因为这种情况下有且仅有一个16-byte 将会跨越 32-byte 边界。 AMD Zen 2 与 Intel 类似,只会在跨越 64-byte 边界时才会需要多余的一次 Load。
性能优化
注意,此项限制限的是 Load 的次数,跟 Load 的字节数无关。所以,在有些情况下,你可以将几条相邻数据的 Load 合成一次 Load 操作。
对于上面的例程,可以把 offset[i-1] 与 offset[i-1] 这两条相邻数据的 Load 合并成一次 Load:
do {
uint64_t twooffsets;
std::memcpy(&twooffsets, offsets + i - 2, sizeof(uint64_t));
sum1 += data[twooffsets >> 32];
sum2 += data[twooffsets & 0xFFFFFFFF];
i -= 2;
} while (i);
编译成汇编后成为:
.top: ; total fused uops
mov rcx,QWORD PTR [rsi+rdx*4-0x8] ; 1
mov r9,rcx ; 2
mov ecx,ecx ; 3
shr r9,0x20 ; 4
add eax,DWORD PTR [rdi+rcx*4] ; 5
add r8d,DWORD PTR [rdi+r9*4] ; 6
sub rdx,0x2 ; (fuses w/ jne)
jne .top ; 7
现在我们有 7 条 fused 微指令(对比之前的 5 条),但只需要 1.81 个时钟周期即可运行完成,提速 10%。理论上的最快应该是 7/4 = 1.75 个时钟。没达到理论的最快值可能是因为代码中的跳转语句和 shr 指令在竞争 p6(将循环展开可能能优化这一点)。 如果用 Clang 5.0 编译的话结果会更优一些,比上面的少了一条微指令:
.top:
mov r8,QWORD PTR [rsi+rdx*4-0x8]
mov r9d,r8d
shr r8,0x20
add ecx,DWORD PTR [rdi+r8*4]
add eax,DWORD PTR [rdi+r9*4]
add rdx,0xfffffffffffffffe
jne .top
这段代码一次循环需要 1.67 个时钟周期,与 4 次 Load 的版本相比,优化了 20%。