程序运行性能极限探究(三):内存读操作(Load)的次数

496 阅读5分钟

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

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

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

前序文章如下:

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

程序运行性能极限探究(二):执行部件个数限制

三、内存读操作(Load)次数

CPULoad 次数上限(每个时钟周期)
Intel Skylake, Ice lake2 loads
AMD Zen, Zen 22 loads
AMD Zen 33 loads
Apple M13 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%。

参考资料

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