计算两个缓冲区之间的差异似乎太慢

Scr*_*mer 8 optimization performance x86 assembly clang++

我的问题

我有 2 个相邻的大小相同的字节缓冲区(每个缓冲区大约 20 MB)。我只是想数一下它们之间的差异。

我的问题

该循环在具有 3600MT RAM 的 4.8GHz Intel I7 9700K 上运行需要多长时间?

我们如何计算最大理论速度?

我尝试过的

uint64_t compareFunction(const char *const __restrict buffer, const uint64_t commonSize)
{
    uint64_t diffFound = 0;

    for(uint64_t byte = 0; byte < commonSize; ++byte)
        diffFound += static_cast<uint64_t>(buffer[byte] != buffer[byte + commonSize]);

    return diffFound;
}
Run Code Online (Sandbox Code Playgroud)

在我的电脑(9700K 4.8Ghz RAM 3600 Windows 10 Clang 14.0.6 -O3 MinGW)上需要 11 毫秒,我觉得它太慢了,而且我错过了一些东西。

CPU 读取 40MB 的时间应该少于 2 毫秒(我的 RAM 带宽在 20 到 30GB/s 之间)

我不知道如何计算执行一次迭代所需的周期(特别是因为现在的 CPU 是超标量)。如果我假设每个操作有 1 个周期,并且如果我没有搞乱计数,那么每次迭代应该有 10 个操作 -> 2 亿个操作 -> 在 4.8 Ghz 下只有一个执行单元 -> 40ms。显然我在如何计算每个循环的周期数上是错误的。

有趣的事实:我在 Linux PopOS GCC 11.2 -O3 上尝试过,它的运行时间为 4.5 毫秒。为什么会有这样的差异?

以下是由 clang 生成的矢量化和标量的反汇编:

compareFunction(char const*, unsigned long): # @compareFunction(char const*, unsigned long)
        test    rsi, rsi
        je      .LBB0_1
        lea     r8, [rdi + rsi]
        neg     rsi
        xor     edx, edx
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        movzx   r9d, byte ptr [rdi + rdx]
        xor     ecx, ecx
        cmp     r9b, byte ptr [r8 + rdx]
        setne   cl
        add     rax, rcx
        add     rdx, 1
        mov     rcx, rsi
        add     rcx, rdx
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret
Run Code Online (Sandbox Code Playgroud)

铿锵14 O3:

.LCPI0_0:
        .quad   1                               # 0x1
        .quad   1                               # 0x1
compareFunction(char const*, unsigned long):                # @compareFunction(char const*, unsigned long)
        test    rsi, rsi
        je      .LBB0_1
        cmp     rsi, 4
        jae     .LBB0_4
        xor     r9d, r9d
        xor     eax, eax
        jmp     .LBB0_11
.LBB0_1:
        xor     eax, eax
        ret
.LBB0_4:
        mov     r9, rsi
        and     r9, -4
        lea     rax, [r9 - 4]
        mov     r8, rax
        shr     r8, 2
        add     r8, 1
        test    rax, rax
        je      .LBB0_5
        mov     rdx, r8
        and     rdx, -2
        lea     r10, [rdi + 6]
        lea     r11, [rdi + rsi]
        add     r11, 6
        pxor    xmm0, xmm0
        xor     eax, eax
        pcmpeqd xmm2, xmm2
        movdqa  xmm3, xmmword ptr [rip + .LCPI0_0] # xmm3 = [1,1]
        pxor    xmm1, xmm1
.LBB0_7:                                # =>This Inner Loop Header: Depth=1
        movzx   ecx, word ptr [r10 + rax - 6]
        movd    xmm4, ecx
        movzx   ecx, word ptr [r10 + rax - 4]
        movd    xmm5, ecx
        movzx   ecx, word ptr [r11 + rax - 6]
        movd    xmm6, ecx
        pcmpeqb xmm6, xmm4
        movzx   ecx, word ptr [r11 + rax - 4]
        movd    xmm7, ecx
        pcmpeqb xmm7, xmm5
        pxor    xmm6, xmm2
        punpcklbw       xmm6, xmm6              # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
        pshuflw xmm4, xmm6, 212                 # xmm4 = xmm6[0,1,1,3,4,5,6,7]
        pshufd  xmm4, xmm4, 212                 # xmm4 = xmm4[0,1,1,3]
        pand    xmm4, xmm3
        paddq   xmm4, xmm0
        pxor    xmm7, xmm2
        punpcklbw       xmm7, xmm7              # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
        pshuflw xmm0, xmm7, 212                 # xmm0 = xmm7[0,1,1,3,4,5,6,7]
        pshufd  xmm5, xmm0, 212                 # xmm5 = xmm0[0,1,1,3]
        pand    xmm5, xmm3
        paddq   xmm5, xmm1
        movzx   ecx, word ptr [r10 + rax - 2]
        movd    xmm0, ecx
        movzx   ecx, word ptr [r10 + rax]
        movd    xmm1, ecx
        movzx   ecx, word ptr [r11 + rax - 2]
        movd    xmm6, ecx
        pcmpeqb xmm6, xmm0
        movzx   ecx, word ptr [r11 + rax]
        movd    xmm7, ecx
        pcmpeqb xmm7, xmm1
        pxor    xmm6, xmm2
        punpcklbw       xmm6, xmm6              # xmm6 = xmm6[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
        pshuflw xmm0, xmm6, 212                 # xmm0 = xmm6[0,1,1,3,4,5,6,7]
        pshufd  xmm0, xmm0, 212                 # xmm0 = xmm0[0,1,1,3]
        pand    xmm0, xmm3
        paddq   xmm0, xmm4
        pxor    xmm7, xmm2
        punpcklbw       xmm7, xmm7              # xmm7 = xmm7[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
        pshuflw xmm1, xmm7, 212                 # xmm1 = xmm7[0,1,1,3,4,5,6,7]
        pshufd  xmm1, xmm1, 212                 # xmm1 = xmm1[0,1,1,3]
        pand    xmm1, xmm3
        paddq   xmm1, xmm5
        add     rax, 8
        add     rdx, -2
        jne     .LBB0_7
        test    r8b, 1
        je      .LBB0_10
.LBB0_9:
        movzx   ecx, word ptr [rdi + rax]
        movd    xmm2, ecx
        movzx   ecx, word ptr [rdi + rax + 2]
        movd    xmm3, ecx
        add     rax, rsi
        movzx   ecx, word ptr [rdi + rax]
        movd    xmm4, ecx
        pcmpeqb xmm4, xmm2
        movzx   eax, word ptr [rdi + rax + 2]
        movd    xmm2, eax
        pcmpeqb xmm2, xmm3
        pcmpeqd xmm3, xmm3
        pxor    xmm4, xmm3
        punpcklbw       xmm4, xmm4              # xmm4 = xmm4[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
        pshuflw xmm4, xmm4, 212                 # xmm4 = xmm4[0,1,1,3,4,5,6,7]
        pshufd  xmm4, xmm4, 212                 # xmm4 = xmm4[0,1,1,3]
        movdqa  xmm5, xmmword ptr [rip + .LCPI0_0] # xmm5 = [1,1]
        pand    xmm4, xmm5
        paddq   xmm0, xmm4
        pxor    xmm2, xmm3
        punpcklbw       xmm2, xmm2              # xmm2 = xmm2[0,0,1,1,2,2,3,3,4,4,5,5,6,6,7,7]
        pshuflw xmm2, xmm2, 212                 # xmm2 = xmm2[0,1,1,3,4,5,6,7]
        pshufd  xmm2, xmm2, 212                 # xmm2 = xmm2[0,1,1,3]
        pand    xmm2, xmm5
        paddq   xmm1, xmm2
.LBB0_10:
        paddq   xmm0, xmm1
        pshufd  xmm1, xmm0, 238                 # xmm1 = xmm0[2,3,2,3]
        paddq   xmm1, xmm0
        movq    rax, xmm1
        cmp     r9, rsi
        je      .LBB0_13
.LBB0_11:
        lea     r8, [r9 + rsi]
        sub     rsi, r9
        add     r8, rdi
        add     rdi, r9
        xor     edx, edx
.LBB0_12:                               # =>This Inner Loop Header: Depth=1
        movzx   r9d, byte ptr [rdi + rdx]
        xor     ecx, ecx
        cmp     r9b, byte ptr [r8 + rdx]
        setne   cl
        add     rax, rcx
        add     rdx, 1
        cmp     rsi, rdx
        jne     .LBB0_12
.LBB0_13:
        ret
.LBB0_5:
        pxor    xmm0, xmm0
        xor     eax, eax
        pxor    xmm1, xmm1
        test    r8b, 1
        jne     .LBB0_9
        jmp     .LBB0_10
Run Code Online (Sandbox Code Playgroud)

Jér*_*ard 8

TLDRClang 代码如此缓慢的原因来自于糟糕的矢量化方法使端口 5 饱和(已知这通常是一个问题)。GCC 在这方面做得更好,但离高效还很远。人们可以使用 AVX-2 编写更快的基于块的代码,而不会使端口 5 饱和。


未矢量化的 Clang 代码分析

要了解发生了什么,最好从一个简单的示例开始。事实上,正如您所说,现代处理器是超标量的,因此理解这种架构上某些生成代码的速度并不容易。

Clang 使用优化标志生成的代码-O1是一个好的开始。这是您问题中提供的 GodBold 生成的热循环的代码:

(instructions)                                 (ports)

.LBB0_4:
        movzx   r9d, byte ptr [rdi + rdx]      p23
        xor     ecx, ecx                       p0156
        cmp     r9b, byte ptr [r8 + rdx]       p0156+p23
        setne   cl                             p06
        add     rax, rcx                       p0156
        add     rdx, 1                         p0156
        mov     rcx, rsi                       (optimized)
        add     rcx, rdx                       p0156
        jne     .LBB0_4                        p06
Run Code Online (Sandbox Code Playgroud)

像 Coffee Lake 9700K 这样的现代处理器由两大部分组成:前端获取/解码指令(并将它们分成微指令,又名uops),以及后端调度/执行它们。后端在许多端口上调度微指令,每个端口都可以执行一些特定的指令集(例如,仅内存加载,或仅算术指令)。对于每条指令,我都放置了可以执行它们的端口。p0156+p23意味着指令被分成两个微指令:第一个可以由端口 0 或 1 或 5 或 6 执行,第二个可以由端口 2 或 3 执行。请注意,前端可以以某种方式优化代码,以便不为循环中的基本指令产生任何微指令mov(感谢一种称为寄存器重命名的机制)。

对于每次循环迭代,处理器需要从内存中读取 2 个值。像 9700K 这样的 Coffee Lake 处理器每个周期可以加载两个值,因此循环至少需要 1 个周期/迭代(假设加载r9d并且r9b不会由于使用同一r964 位寄存器的不同部分而发生冲突)。该处理器有一个微指令缓存,并且循环有很多指令,因此解码部分应该不成问题。也就是说,有 9 个微指令要执行,而处理器每个周期只能执行其中 6 个微指令,因此循环不能少于 1.5 个周期/迭代。更准确地说,端口 0、1、5 和 6 处于压力之下,因此即使假设处理器完美负载平衡微指令,也需要 2 个周期/迭代。这是一个乐观的执行时间下限,因为处理器可能无法完美地调度指令,并且有很多事情可能会出错(比如我没有看到的偷偷摸摸的隐藏依赖项)。频率为4.8GHz,最终执行时间至少为8.3ms。通过 3 个周期/迭代可以达到 12.5 ms(请注意,由于微指令到端口的调度,2.5 个周期/迭代是可能的)。

可以使用展开来改进循环。事实上,仅仅执行循环而不是实际计算就需要大量指令。展开有助于提高有用指令的比例,从而更好地利用可用端口。尽管如此,2 个负载仍会阻止循环快于 1 个周期/迭代,即 4.2 ms。


矢量化 Clang 代码分析

Clang 生成的矢量化代码很复杂。人们可以尝试应用与之前的代码相同的分析,但这将是一项乏味的任务。

可以注意到,即使代码是矢量化的,负载也不是矢量化的。这是一个问题,因为每个周期只能完成 2 次加载。也就是说,加载是通过两个连续的 char 值对执行的,因此与之前生成的代码相比,加载速度并不算慢。

Clang 这样做是因为只有两个 64 位值可以放入 128 位 SSE 寄存器和一个 64 位寄存器中,并且它需要这样做,因为diffFound是一个 64 位整数。8位到 64 位的转换是代码中最大的问题,因为它需要多个 SSE 指令来完成转换。此外,由于 Coffee Lake 上有 3 个 SSE 整数单元,并且每个单元一次只能计算两个 64 位整数,因此一次只能计算 4 个整数。最后,Clang 只在每个 SSE 寄存器中放入 2 个值(并使用其中的 4 个值,以便每次循环迭代计算 8 个项目),因此人们应该期望代码运行速度快两倍以上(特别是由于 SSE 和循环展开),但情况并非如此,因为 SSE 端口比 ALU 端口少,并且类型转换所需的指令更多。简而言之,矢量化显然是低效的,但在这种情况下 Clang 想要生成高效的代码并不那么容易。尽管如此,由于每个循环有 28 个 SSE 指令和 3 个 SSE 整数单元计算 8 个项目,人们应该预期代码的计算部分会花费大约28/3/8 ~= 1.2周期/项目,这与您可以观察到的相距甚远(这不是由于其他指令造成的,因为它们大多可以并行执行,因为它们大多可以在其他端口上调度)。

事实上,性能问题肯定来自于端口 5 的饱和。事实上,该端口是唯一可以对 SIMD 寄存器项进行改组的端口。因此,指令punpcklbwpshuflwpshufd甚至 只能movd在端口 5 上执行。这是 SIMD 代码的一个非常常见的问题。这是一个大问题,因为每个循环有 20 条指令,处理器甚至可能无法完美地使用它。这意味着代码应该至少需要 10.4 毫秒,这非常接近观察到的执行时间 (11 毫秒)。


矢量化 GCC 代码分析

与 Clang 相比,GCC 生成的代码实际上相当不错。首先,GCC 直接使用 SIMD 指令加载项目,这更加高效,因为每条指令(并通过迭代)计算 16 个项目:每次迭代只需要 2 个加载微指令,减少了端口 2 和 3 上的压力(1 个周期/迭代即,0.0625 周期/项目)。其次,GCC 仅使用 14punpckhwd条指令,而每次迭代计算 16 个项目,从而减少了端口 5 上的临界压力(为此为 0.875 个周期/项目)。第三,SIMD 寄存器几乎被完全使用,至少对于比较来说是这样,因为比较pcmpeqb指令一次比较 16 个项目(而不是使用 Clang 比较 2 个项目)。类似的其他指令paddq很便宜(例如,paddq可以在 3 个 SSE 端口上进行调度),并且它们不会对执行时间产生太大影响。最后,这个版本应该仍然受到端口 5 的限制,但它应该比 Clang 版本快得多。事实上,我们应该预期执行时间将达到 1 个周期/项目(因为端口调度肯定不完美,并且内存负载可能会引入一些停顿周期)。这意味着执行时间为 4.2 毫秒。这与观察到的结果很接近。


更快的实施

GCC 的实施并不完美。

首先,它不使用您的处理器支持的 AVX2,因为未提供-mavx2标志(或任何类似的标志,如-march=native)。事实上,GCC 与其他主流编译器一样,默认情况下只使用 SSE2,以便与以前的架构兼容:SSE2 在所有 x86-64 处理器上可以安全使用,但不能在其他指令集(如 SSE3、SSSE3、SSE4.1、SSE4.2)上使用。 AVX、AVX2。有了这样的标志,GCC 应该能够生成内存绑定代码。

此外,理论上编译器可以执行多级求和缩减。这个想法是使用大小为 1024 个项目(即 64x16 项目)的块在 8 位宽 SIMD 通道中累积比较结果。这是安全的,因为每个通道的值不能超过 64。为了避免溢出,累加值需要存储在更宽的 SIMD 通道中(例如 64 位通道)。采用这种策略,指令的开销punpckhwd减少了 64 倍。这是一个很大的改进,因为它消除了端口 5 的饱和。即使仅使用 SSE2,此策略也应该足以生成内存限制代码。这是一个未经测试的代码示例,要求该标志-fopenmp-simd有效。

(instructions)                                 (ports)

.LBB0_4:
        movzx   r9d, byte ptr [rdi + rdx]      p23
        xor     ecx, ecx                       p0156
        cmp     r9b, byte ptr [r8 + rdx]       p0156+p23
        setne   cl                             p06
        add     rax, rcx                       p0156
        add     rdx, 1                         p0156
        mov     rcx, rsi                       (optimized)
        add     rcx, rdx                       p0156
        jne     .LBB0_4                        p06
Run Code Online (Sandbox Code Playgroud)

GCC和Clang都会生成相当高效的代码(虽然对于缓存中的数据拟合来说不是最佳的),尤其是 Clang 例如,以下是 Clang 使用 AVX2 生成的代码:

.LBB0_4:
        lea     r10, [rdx + 128]
        vmovdqu ymm2, ymmword ptr [r9 + rdx - 96]
        vmovdqu ymm3, ymmword ptr [r9 + rdx - 64]
        vmovdqu ymm4, ymmword ptr [r9 + rdx - 32]
        vpcmpeqb        ymm2, ymm2, ymmword ptr [rcx + rdx - 96]
        vpcmpeqb        ymm3, ymm3, ymmword ptr [rcx + rdx - 64]
        vpcmpeqb        ymm4, ymm4, ymmword ptr [rcx + rdx - 32]
        vmovdqu ymm5, ymmword ptr [r9 + rdx]
        vpaddb  ymm2, ymm4, ymm2
        vpcmpeqb        ymm4, ymm5, ymmword ptr [rcx + rdx]
        vpaddb  ymm3, ymm4, ymm3
        vpaddb  ymm2, ymm3, ymm2
        vpaddb  ymm2, ymm2, ymm0
        vextracti128    xmm3, ymm2, 1
        vpaddb  xmm2, xmm2, xmm3
        vpshufd xmm3, xmm2, 238
        vpaddb  xmm2, xmm2, xmm3
        vpsadbw xmm2, xmm2, xmm1
        vpextrb edx, xmm2, 0
        add     rax, rdx
        mov     rdx, r10
        cmp     r10, r8
        jb      .LBB0_4
Run Code Online (Sandbox Code Playgroud)

所有负载均为 256 位 SIMD。的数量vpcmpeqb是最佳的。数量vpaddb还是比较不错的。还有一些其他指令,但它们显然不应该成为瓶颈。该循环每次迭代对 128 个项目进行操作,我预计每次迭代对于缓存中已有的数据只需要不到十几个周期(否则它应该完全受内存限制)。这意味着 <0.1 个周期/项目,即远小于之前的实现。事实上,uiCA 工具显示大约 0.055 个周期/项目,即 81 GiB/s!人们可以使用 SIMD 内在函数手动编写更好的代码,但代价是可移植性、可维护性和可读性明显较差。

请注意,生成顺序内存限制并不总是意味着 RAM 吞吐量将饱和。事实上,在一个内核上,有时没有足够的并发性来隐藏内存操作的延迟,尽管它在您的处理器上应该没问题(就像在我的带有 2 个交错 3200 MHz DDR4 内存通道的 i5-9600KF 上一样)。

  • 而且它在循环绑定方面可能会做得更好,你尝试过“byteChunk &lt; commonSize-63”吗?如果大小是无符号的,则需要在第一次迭代之前进行单独检查,但可能有助于避免循环内“byteChunk+63”的“lea”。(编译器不能证明它不能对无符号进行换行。) (2认同)
  • 是的,clang -O3(不带 -march)https://godbolt.org/z/KTj7sG8rq。是的,最终代码中的吞吐量瓶颈是端口 5,但这是首先选择糟糕策略的结果,即在进行任何添加之前将比较结果扩大到 64 位。不管你怎么做,速度都会慢得令人无法接受。确实,clang 对该策略的实际实现比它需要的要糟糕得多,就像没有 SSE4.1 一样,它还可以执行 16 字节加载+比较并解压 lo + hi,而不仅仅是 low。这仍然与最佳状态“相去甚远”,但也没有那么糟糕。 (2认同)
  • 我正在谈论更智能的编译器可以做出的改变;我并不乐观地认为 clang 可以实际制作出更好的汇编。但 https://godbolt.org/z/4Woeq1sG9 显示了同一策略的微优化版本,在循环内减少为标量。SKL 的前端 uops 比 28 少了 21 个(包括保存 lea / mov 且不击败宏融合),uICA 预测 SKL 上的前端 uops 为 5.31c/iter,Tiger / Rocket Lake 上的前端 uops 为 4.81,预测略有端口 0/1调度不完善造成的瓶颈。与您当前的代码生成器相比,SKL 上为 6.97,TGL/RKL 上为 6.0,前端 uops 为 27 (2认同)
  • 每 4 个向量比较需要 4.8 个周期,非常接近每个时钟 2 个负载所施加的 4 个周期的限制。6 或 7 个周期更糟糕,但如果两个阵列在 L1d 中都不热(但在 L2 中很热),则可能仍能跟上 L2 缓存的速度。超线程仍然不太友好,但对于相当紧凑和可维护的便携式源来说可能是可接受的权衡。我一直想尝试有时查看 GCC 或 clang 代码,看看我是否可以教他们如何在展开时、在针对 Intel 上的通用进行调整时最小化索引寻址模式。他们的循环(对我来说)显然经常是次优的。 (2认同)

Pet*_*des 7

是的,如果您的数据在缓存中不热,即使 SSE2 也应该跟上内存带宽。如果 L1d 缓存中的数据很热,或者缓存外部级别可以提供的任何带宽,则每个周期 32 个比较结果(来自两个 32 字节加载)的比较和求和是完全可能的。

\n

如果没有,编译器就做得很糟糕。不幸的是,对于像这样减少到更广泛的变量的问题来说,这很常见;编译器不知道用于求和字节的良好向量化策略,尤其是必须为 0/-1 的比较结果字节。它们可能会扩展到 64 位pmovsxbq(如果 SSE4.1 指令不可用,情况会更糟)。

\n

所以即使-O3 -march=native没有多大帮助; 这是一个很大的优化失误;希望 GCC 和 clang 将在某个时候学习如何矢量化这种循环,总结比较结果可能会出现在足够的代码库中,值得识别该模式。

\n

有效的方法是使用psadbw水平求和成qwords。但只有在内循环之后才会进行一些迭代vsum -= cmp(p, q),减去 0 或 -1 来增加计数器或不增加计数器。8 位元素可以进行 255 次迭代,而没有溢出风险。通过展开多个向量累加器,每个向量都有 32 字节,因此您不必经常跳出内部循环。

\n

请参阅如何使用 SIMD 计算手动矢量化 AVX2 代码的字符出现次数。 (一个答案有一个指向 SSE2 版本的 Godbolt 链接。)对比较结果求和是同样的问题,但是您加载两个向量来馈送 pcmpeqb,而不是在循环外广播一个字节以查找单个字节的出现字符。

\n

那里的答案有基准报告,在 i7-6700 Skylake 上,AVX2 为 28 GB/s,SSE2 为 23 GB/s(仅 3.4GHz,也许他们禁用了 Turbo 或只是报告额定速度。未提及 DRAM 速度。 )

\n

我希望 2 个输入数据流能够实现与 1 个输入流大致相同的持续带宽。

\n

如果您对适合 L2 缓存的较小数组进行重复传递的基准测试,那么优化会更有趣,那么 ALU 指令的效率就很重要。(该问题的答案中的策略非常好,并且针对这种情况进行了很好的调整。)

\n

快速计算两个数组之间的相等字节数是一个较旧的问答,使用更糟糕的策略,而不是使用psadbw将字节求和为 64 位。(但不像 GCC/clang 那么糟糕,当它扩展到 32 位时仍然会出现问题。)

\n
\n

多线程/核心对现代桌面几乎没有帮助,尤其是在像您这样的高核心时钟下。内存延迟足够低,每个内核都有足够的缓冲区来保持足够的请求,几乎可以使双通道 DRAM 控制器饱和。

\n

在大型 Xeon 上,情况会非常不同。您需要大多数核心来实现峰值聚合带宽,即使只是 memcpy 或 memset,因此 ALU 工作为零,只需加载/存储。较高的延迟意味着单个核心的可用内存带宽比台式机少得多(即使是绝对意义上的,更不用说占 6 个通道而不是 2 个通道的百分比)。另请参阅适用于 memcpy 的增强型 REP MOVSB为什么 Skylake 在单线程内存吞吐量方面比 Broadwell-E 好得多?

\n
\n

可编译为不太糟糕的 asm 的可移植源代码,从 J\xc3\xa9r\xc3\xb4me\'s 进行微优化:假设 L1d 缓存命中,每 4x 32 字节向量 5.5 个周期,从 7 或 8 个下降。

\n

仍然不好(因为它减少到每 128 个字节,或者 192 个字节,如果你想尝试的话),但是 \n@J\xc3\xa9r\xc3\xb4me Richard 想出了一个聪明的方法来给 clang 一些可以向量化的东西具有良好策略的短路,使用 a uint8_t sum,将其用作足够短而不会溢出的内部循环。

\n

但是 clang 仍然在这个循环中做了一些愚蠢的事情,正如我们在他的回答中看到的那样。我修改了循环控制以使用指针增量,这减少了一点循环开销,只需一个指针添加和比较/jcc,而不是 LEA/MOV。我不知道为什么 clang 使用整数索引效率低下。

\n

它避免了vpcmpeqb内存源操作数的索引寻址模式,让它们在英特尔 CPU 上保持微融合。(Clang 似乎根本不知道这很重要!将操作数反转到!=源代码中就足以使其使用索引寻址模式而vpcmpeqb不是vmovdqu纯加载。)

\n
// micro-optimized version of J\xc3\xa9r\xc3\xb4me\'s function, clang compiles this better\n// instead of 2 arrays, it compares first and 2nd half of one array, which lets it index one relative to the other with an offset if we hand-hold clang into doing that.\n\nuint64_t compareFunction_sink_fixup(const char *const __restrict buffer, const size_t commonSize)\n{\n    uint64_t byteChunk = 0;\n    uint64_t diffFound = 0;\n\n    const char *endp = buffer + commonSize;\n    const char *__restrict ptr = buffer;\n\n    if(commonSize >= 127) {\n        // A signed type for commonSize wouldn\'t avoid UB in pointer subtraction creating a pointer before the object\n        // in practice it would be fine except maybe when inlining into a function where the compiler could see a compile-time-constant array size.\n        for(; ptr < endp-127 ; ptr += 128)\n        {\n            uint8_t tmpDiffFound = 0;\n            #pragma omp simd reduction(+:tmpDiffFound)\n            for(int off = 0 ; off < 128; ++off)\n                tmpDiffFound += ptr[off + commonSize] != ptr[off];\n                // without AVX-512, we get -1 for ==, 0 for not-equal.  So clang adds set1_epi(4) to each bucket that holds the sum of four 0 / -1 elements\n            diffFound += tmpDiffFound;\n        }\n    }\n\n    // clang still auto-vectorizes, but knows the max trip count is only 127\n    // so doesn\'t unroll, just 4 bytes per iter.\n    for(int byte = 0 ; byte < commonSize % 128 ; ++byte)\n        diffFound += ptr[byte] != ptr[byte + commonSize];\n\n    return diffFound;\n}\n
Run Code Online (Sandbox Code Playgroud)\n

Godbolt与 clang15-O3 -fopenmp-simd -mavx2 -march=skylake -mbranches-within-32B-boundaries

\n
# The main loop, from clang 15 for x86-64 Skylake\n.LBB0_4:                                # =>This Inner Loop Header: Depth=1\n        vmovdqu ymm2, ymmword ptr [rdi + rsi]\n        vmovdqu ymm3, ymmword ptr [rdi + rsi + 32]     # Indexed addressing modes are fine here\n        vmovdqu ymm4, ymmword ptr [rdi + rsi + 64]\n        vmovdqu ymm5, ymmword ptr [rdi + rsi + 96]\n        vpcmpeqb        ymm2, ymm2, ymmword ptr [rdi]      # non-indexed allow micro-fusion without un-lamination\n        vpcmpeqb        ymm3, ymm3, ymmword ptr [rdi + 32]\n        vpcmpeqb        ymm4, ymm4, ymmword ptr [rdi + 64]\n        vpaddb  ymm2, ymm4, ymm2\n        vpcmpeqb        ymm4, ymm5, ymmword ptr [rdi + 96]\n        vpaddb  ymm3, ymm4, ymm3\n        vpaddb  ymm2, ymm2, ymm3\n\n        vpaddb  ymm2, ymm2, ymm0       # add a vector of set1_epi8(4) to turn sums of 0 / -1 into sums of 1 / 0\n        vextracti128    xmm3, ymm2, 1\n        vpaddb  xmm2, xmm2, xmm3\n        vpshufd xmm3, xmm2, 238                 # xmm3 = xmm2[2,3,2,3]\n        vpaddb  xmm2, xmm2, xmm3              # reduced to 8 bytes\n        vpsadbw xmm2, xmm2, xmm1              # hsum to one qword\n        vpextrb edx, xmm2, 0                  # extract and zero-extend\n        add     rax, rdx                      # accumulate the chunk sum\n\n        sub     rdi, -128                # pointer increment (with a sign_extended_imm8 instead of +imm32)\n        cmp     rdi, rcx\n        jb      .LBB0_4                # }while(p < endp)\n
Run Code Online (Sandbox Code Playgroud)\n

这可以使用192而不是128进一步分摊循环开销,但代价是需要执行以下操作%192(不是 2 的幂),并使清理循环最坏情况为 191 字节。我们不能达到 256,或者任何高于 UINT8_MAX (255) 的值,并且必须坚持 32 的倍数。或者最好是 64。

\n

还有一个额外vpaddb的修正常量set1_epi8(4),它将四个 0 / -1 的和转换为来自 C 的四个 1 / 0 结果的和!=

\n

我认为没有任何方法可以摆脱它或将其从循环中剔除,同时仍然累积到 a 中uint8_t,这对于 clang 以这种方式矢量化是必要的。它不知道如何使用vpsadbw来扩大(非截断)字节总和,这很讽刺,因为这就是它在针对全零寄存器使用时实际所做的事情。如果你做了类似的事情,sum += ptr[off + commonSize] == ptr[off] ? -1 : 0你可以让它vpcmpeqb直接使用结果,将 4 个向量通过 3 个加法求和为 1,并最终vpsadbw在一些归约步骤之后将其输入。matches * 0xFF因此,每个 128 字节块的总和被截断为 uint8_t。或者作为,它是 、 so int8_t的总和,它不会溢出有符号字节。这很有趣。但是,向 64 位计数器添加零扩展可能会破坏信息,并且外循环内的符号扩展将花费另一条指令。这将是一个标量指令而不是,但这对于 Skylake 来说并不重要,可能只有在使用带有 512 位向量的 AVX-512 时(clang 和 GCC 都做得很糟糕,不使用屏蔽添加)。我们可以在循环之后恢复匹配总和的差异吗?不,我不这么认为。-1 * matches0..-128movsxvpaddb128*n_chunks - count

\n
\n

uiCA 静态分析预测,如果 L1d 缓存中的数据很热,Skylake(例如您的 CPU)将以5.51 个周期/iter(4 个向量)运行主循环,或者在 Ice Lake/Rocket Lake 上为 5.05。-mbranches-within-32B-boundaries(对于 uiCA 循环顶部相对于 32 字节对齐边界的默认假设,我必须手动调整 asm 来模拟填充效果反而。 :/)

\n

在实现这种次优策略时唯一错过的微观优化是它使用vpextrb(因为它不能证明不需要截断到uint8_t?)而不是vmovdor vmovq。因此,前端和后端的端口 5 都会花费额外的 uop。经过优化(在链接中注释+取消注释),S​​kylake 上为 5.25c/iter,或 Ice Lake 上为 4.81,非常接近 2 负载/时钟瓶颈。

\n

(每个迭代器执行 6 个向量,192 字节,预测 SKL 上每个迭代器有 7 个周期,或每个向量 1.166,低于每个向量 5.5 / iter = 1.375。或者 ICL/RKL = 1.08 c/vec 上大约 6.5,命中后端ALU 端口瓶颈。)

\n

这对于我们能够诱导 clang 从可移植 C++ 源生成的东西来说还不错,而不是每 4 个 32 字节向量比较 4 个周期,以实现高效的手动向量化。这很可能会跟上 L2 缓存的内存或缓存带宽,因此它非常有用,而且对于 L1d 中的热数据来说,速度也不会慢很多。多获取一些 uops 确实会损害乱序执行,并消耗更多共享物理核心的另一个逻辑核心可以使用的执行资源。(超线程)。

\n

不幸的是,gcc/clang 没有充分利用 AVX-512 来实现这一点。 如果您使用 512 位向量(或 256 位向量上的 AVX-512 功能),您将与掩码寄存器进行比较,然后执行vpaddb zmm0{k1}, zmm0, zmm1合并掩码之类的操作来有条件地递增向量,其中 zmm1 = set1_epi8( 1 )。(或者-1带有 . 的常量sub。)如果操作正确,每个向量的指令和 uop 计数应该与 AVX2 大致相同,但 gcc/clang 使用的数量大约是 AVX2 的两倍,因此唯一的节省是减少到标量,这似乎是价格为了让任何东西都可用。

\n

这个版本还避免了展开清理循环,只是使用其愚蠢的每迭代 4 字节策略进行矢量化,这对于size%128字节清理来说是正确的。它同时使用vpxor翻转和vpand将 0xff 转换为 0x01,这是非常愚蠢的,而它本来可以vpandn在一条指令中完成这两件事。这将使清理循环降至 8 uops,只是 Haswell / Skylake 上管道宽度的两倍,因此它可以更有效地从循环缓冲区发出问题,除非 Skylake 在微代码更新中禁用了它。对 Haswell 会有一点帮助

\n