繁体   English   中英

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

[英]Counting differences between 2 buffers seems too slow

我的问题

我有 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;
}

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

在 CPU 上读取 40MB 应该不到 2ms(我的 RAM 带宽在 20 到 30GB/s 之间)

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

有趣的事实:我尝试了 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

Clang14 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

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

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

因此,即使-O3 -march=native也无济于事; 这是一个很大的优化失误; 希望 GCC 和 clang 会在某个时候学习如何向量化这种循环,总结比较结果可能会出现在足够多的代码库中,值得识别这种模式。

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

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

在 i7-6700 Skylake(仅 3.4GHz,可能他们禁用了 Turbo 或只是报告额定速度)上,AVX2 的基准测试报告为 28 GB/s,SSE2 的报告为 23 GB/s。DRAM 速度未提及。 )

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

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

快速计算两个 arrays 之间的相等字节数是使用较差策略的较旧的问答,而不是使用psadbw将字节求和为 64 位。 (但不像 GCC/clang 那样糟糕,在扩展到 32 位时仍然会发出嗡嗡声。)


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

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


编译成不太糟糕的 asm 的便携式源代码,从 Jérôme 的微优化:每 4x 32 字节向量 5.5 个周期,假设 L1d 缓存命中,从 7 或 8 个下降。

仍然不好(因为它每 128 个字节减少到标量,或者如果你想尝试的话减少到 192 个),但是@Jérôme Richard 想出了一个聪明的方法来给 clang 一些它可以用一个好的策略向量化一个短的东西,用uint8_t sum ,使用它作为一个足够短的内部循环,不会溢出。

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

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

// micro-optimized version of Jérôme's function, clang compiles this better
// 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.

uint64_t compareFunction_sink_fixup(const char *const __restrict buffer, const size_t commonSize)
{
    uint64_t byteChunk = 0;
    uint64_t diffFound = 0;

    const char *endp = buffer + commonSize;
    const char *__restrict ptr = buffer;

    if(commonSize >= 127) {
        // A signed type for commonSize wouldn't avoid UB in pointer subtraction creating a pointer before the object
        // in practice it would be fine except maybe when inlining into a function where the compiler could see a compile-time-constant array size.
        for(; ptr < endp-127 ; ptr += 128)
        {
            uint8_t tmpDiffFound = 0;
            #pragma omp simd reduction(+:tmpDiffFound)
            for(int off = 0 ; off < 128; ++off)
                tmpDiffFound += ptr[off + commonSize] != ptr[off];
                // 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
            diffFound += tmpDiffFound;
        }
    }

    // clang still auto-vectorizes, but knows the max trip count is only 127
    // so doesn't unroll, just 4 bytes per iter.
    for(int byte = 0 ; byte < commonSize % 128 ; ++byte)
        diffFound += ptr[byte] != ptr[byte + commonSize];

    return diffFound;
}

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

# The main loop, from clang 15 for x86-64 Skylake
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        vmovdqu ymm2, ymmword ptr [rdi + rsi]
        vmovdqu ymm3, ymmword ptr [rdi + rsi + 32]     # Indexed addressing modes are fine here
        vmovdqu ymm4, ymmword ptr [rdi + rsi + 64]
        vmovdqu ymm5, ymmword ptr [rdi + rsi + 96]
        vpcmpeqb        ymm2, ymm2, ymmword ptr [rdi]      # non-indexed allow micro-fusion without un-lamination
        vpcmpeqb        ymm3, ymm3, ymmword ptr [rdi + 32]
        vpcmpeqb        ymm4, ymm4, ymmword ptr [rdi + 64]
        vpaddb  ymm2, ymm4, ymm2
        vpcmpeqb        ymm4, ymm5, ymmword ptr [rdi + 96]
        vpaddb  ymm3, ymm4, ymm3
        vpaddb  ymm2, ymm2, ymm3

        vpaddb  ymm2, ymm2, ymm0       # add a vector of set1_epi8(4) to turn sums of 0 / -1 into sums of 1 / 0
        vextracti128    xmm3, ymm2, 1
        vpaddb  xmm2, xmm2, xmm3
        vpshufd xmm3, xmm2, 238                 # xmm3 = xmm2[2,3,2,3]
        vpaddb  xmm2, xmm2, xmm3              # reduced to 8 bytes
        vpsadbw xmm2, xmm2, xmm1              # hsum to one qword
        vpextrb edx, xmm2, 0                  # extract and zero-extend
        add     rax, rdx                      # accumulate the chunk sum

        sub     rdi, -128                # pointer increment (with a sign_extended_imm8 instead of +imm32)
        cmp     rdi, rcx
        jb      .LBB0_4                # }while(p < endp)

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

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

我不认为有任何方法可以摆脱它或将其从循环中取出,同时仍然累积到uint8_t中,这是 clang 以这种方式矢量化所必需的。 它不知道如何使用vpsadbw来扩大(非截断)字节总和,这具有讽刺意味,因为这正是它在针对全零寄存器使用时实际执行的操作。 如果你做类似sum += ptr[off + commonSize] == ptr[off]? -1: 0 sum += ptr[off + commonSize] == ptr[off]? -1: 0你可以让它直接使用vpcmpeqb结果,将 4 个向量加到一个有 3 个加法,并最终在一些减少步骤后将其提供给vpsadbw 因此,对于每个 128 字节的块,您会得到一个matches * 0xFF截断为 uint8_t 的总和。 或者作为int8_t ,这是-1 * matches的总和,所以0..-128不会溢出有符号字节。 这很有趣。 但是将零扩展添加到 64 位计数器中可能会破坏信息,并且在外部循环内进行符号扩展会花费另一条指令。 这将是一个标量movsx指令而不是vpaddb ,但这对 Skylake 来说并不重要,可能只有在将 AVX-512 与 512 位向量一起使用时(clang 和 GCC 都表现不佳,不使用掩码添加)。 我们可以做128*n_chunks - count以恢复匹配总和的差异吗? 不,我不这么认为。


uiCA static 分析预测,如果 L1d 缓存中的数据很热,Skylake(例如您的 CPU)将以5.51 周期/迭代器(4 个向量)运行主循环,或者在 Ice Lake / Rocket Lake 上以 5.05 运行。 (我不得不手动调整 asm 以模拟填充效果-mbranches-within-32B-boundaries会产生,因为 uiCA 的默认假设是循环顶部相对于 32 字节 alignment 边界的位置。我可以只而是在 uiCA 中更改了该设置。:/)

实施此次优策略时唯一遗漏的微优化是它使用vpextrb (因为它不能证明不需要截断到uint8_t ?)而不是vmovdvmovq 因此,前端和后端的端口 5 需要额外的 uop。 经过优化(在链接中注释 + 取消注释),Skylake 上的 5.25c / iter,或 Ice Lake 上的 4.81,非常接近 2 负载/时钟瓶颈。

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

这对于我们能够从便携式 C++ 源中诱导 clang 生成的东西来说还不错,相对于每 4 个 32 字节的向量进行 4 个周期比较,每个向量进行高效的手动向量化。 这很可能会跟上 memory 或甚至来自 L2 缓存的缓存带宽,因此它非常有用,并且在 L1d 中热数据时不会慢很多。 多采用几个 uops 确实会损害乱序执行,并占用更多共享物理核心的另一个逻辑核心可以使用的执行资源。 (超线程)。

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

这个版本也避免了清理循环的展开,只是用它的 dumb 4 bytes per iter 策略进行矢量化,这对于size%128字节的清理来说是正确的。 它同时使用vpxor翻转和vpand将 0xff 转换为 0x01 是非常愚蠢的,而它本可以使用vpandn在一条指令中完成这两项操作。 这将使清理循环下降到 8 微指令,只是 Haswell / Skylake 管道宽度的两倍,因此它会从循环缓冲区更有效地发出,除了 Skylake 在微代码更新中禁用它。 这会对哈斯韦尔有所帮助

TLDRClang 代码之所以如此缓慢,是因为矢量化方法不佳导致端口 5 饱和(已知这通常是一个问题)。 GCC 在这里做得更好,但离效率还差得很远。 可以使用不使端口 5 饱和的 AVX-2 编写更快的基于块的代码。


未向量化的Clang代码分析

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

-O1使用 -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

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

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

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


矢量化Clang代码分析

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

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

Clang 这样做是因为只有两个 64 位值可以放入一个 128 位 SSE 寄存器和一个 64 位寄存器中,它需要这样做,因为diffFound是一个 64 位 integer。8位到 64 位的转换是代码中最大的问题,因为它需要几个 SSE 指令来进行转换。 此外,一次只能计算 4 个整数,因为 Coffee Lake 上有 3 个 SSE integer 单元,每个单元一次只能计算两个 64 位整数。 最后,Clang 只在每个 SSE 寄存器中放入 2 个值(并使用其中的 4 个,以便在每次循环迭代中计算 8 个项目)因此应该期望代码运行速度快两倍以上(特别是由于 SSE 和循环展开),但由于 SSE 端口少于 ALU 端口以及类型转换所需的更多指令,因此情况并非如此。 简而言之,向量化显然是低效的,但是在这种情况下 Clang 生成高效代码并不是那么容易。 尽管如此,使用 28 个 SSE 指令和 3 个 SSE integer 单元每个循环计算 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 仅使用 14 punpckhwd指令,每次迭代计算 16 项,减少了端口 5 的临界压力(0.875 周期/项)。 第三,SIMD 寄存器几乎被完全使用,至少在比较方面是这样,因为pcmpeqb比较指令一次比较 16 个项目(与 Clang 的 2 个项目相反)。 其他指令如paddq很便宜(例如, paddq可以调度在 3 个 SSE 端口上)并且它们不会对执行时间产生太大影响。 最后,这个版本应该还是受端口5限制,但是应该比Clang版本快很多。 实际上,应该期望执行时间达到 1 个周期/项(因为端口调度肯定不是完美的,memory 负载可能会引入一些停滞周期)。 这意味着 4.2 毫秒的执行时间。 这与观察到的结果很接近。


更快的实施

GCC 实现并不完美。

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

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

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

    if(commonSize >= 127)
    {
        for(; byteChunk < commonSize-127; byteChunk += 128)
        {
            uint8_t tmpDiffFound = 0;
            #pragma omp simd reduction(+:tmpDiffFound)
            for(uint64_t byte = byteChunk; byte < byteChunk + 128; ++byte)
                tmpDiffFound += buffer[byte] != buffer[byte + commonSize];
            diffFound += tmpDiffFound;
        }
    }

    for(uint64_t byte = byteChunk; byte < commonSize; ++byte)
        diffFound += buffer[byte] != buffer[byte + commonSize];

    return diffFound;
}

GCCClang都生成了相当高效的代码(虽然对于缓存中的数据拟合不是最优的),尤其是 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

所有加载都是 256 位 SIMD 的。 vpcmpeqb的数量是最优的。 vpaddb的数量比较好。 其他指令很少,但它们显然不应成为瓶颈。 该循环每次迭代对 128 个项目进行操作,我希望对于缓存中已有的数据,每次迭代花费的周期少于十几个周期(否则它应该完全受内存限制)。 这意味着<0.1 cycle/item,也就是远低于之前的实现。 事实上,uiCA 工具指示大约 0.055 周期/项,即 81 GiB/s,可以使用 SIMD 内在函数手动编写更好的代码,但代价是可移植性明显变差。 维护性和可读性。

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

如果我错了,请纠正我,但答案似乎是

  • -march=native 获胜。
  • 代码的标量版本是 CPU 瓶颈而不是 RAM 瓶颈
  • 使用 uica.uops.info 来估计每个循环的周期数

我将尝试编写自己的 AVX 代码进行比较。

细节

经过一个下午修改这些建议后,这是我在 clang 中发现的内容:

-O1 大约 10 毫秒,标量代码
-O3 启用 SSE2 并且与 O1 一样慢,可能是糟糕的汇编代码
-O3 -march=westmere 也启用 SSE2 但速度更快 (7ms)
-O3 -march=native 启用 AVX -> 2.5ms,我们可能会限制 RAM 带宽(接近理论速度)

标量 10ms 现在有意义,因为根据那个很棒的工具 uica.uops.info 它需要

  • 每个循环 2.35 个周期
  • 整个比较的 4700 万个周期(2000 万次迭代)
  • 处理器的时钟频率为 4.8GHz,这意味着它应该需要大约 9.8 毫秒并且接近测量值。

g++ 似乎在没有添加标志时生成更好的默认代码

  • O1 11毫秒
  • O2 标量仍然只有 9 毫秒
  • O3 SSE 4.5ms
  • O3 -march=westmere 7ms 像 clang
  • O3 -march=native 3.4ms,比clang稍慢

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM