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

piah890a  于 2022-11-24  发布在  其他
关注(0)|答案(3)|浏览(108)

我的问题
我有两个相邻的大小相同的缓冲区(每个大约20 MB)。我只想计算它们之间的差异。
我的问题
在4.8GHz英特尔I7 9700 K和3600 MT RAM上运行此循环需要多长时间?
我们如何计算最大理论速度?

我所尝试的#

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

它需要11毫秒在我的个人电脑(9700 K 4. 8 Ghz RAM 3600 Windows 10叮当14. 0. 6-O3 MinGW),我觉得它太慢了,我错过了一些东西。
CPU读取40 MB应该需要不到2 ms的时间(我的RAM带宽在20到30 GB/s之间)

我不知道如何计算执行一次迭代所需的周期(特别是因为现在的CPU是超标量的)。如果我假设每次操作1个周期,并且如果我没有搞砸我的计数,那么在4.8 Ghz时,每次迭代应该是10个操作-〉2亿个操作-〉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

锵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
bmvo0sr5

bmvo0sr51#

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

像Coffee Lake 9700 K这样的现代处理器由两大部分组成:前端提取/解码指令(并将它们拆分成微指令,也称为. uops),以及后端调度/执行它们。后端调度许多 * 端口 * 上的uops,每个端口都可以执行一些 * 特定的指令集 *(例如,仅内存加载,或仅算术指令)。对于每条指令,我放置了可以执行它们的端口。p0156+p23意味着指令被拆分为两个uop:第一个可由端口0或1或5或6执行,第二个可由端口2或3执行。请注意,前端可以通过某种方式 * 优化代码 *,从而不会为循环中的mov等基本指令产生任何微操作(这要归功于一种称为 * 寄存器重命名 * 的机制)。
对于每个循环迭代,处理器需要从内存中读取2个值。像9700 K这样的Coffee Lake处理器每个周期可以加载两个值,因此循环至少需要1个周期/迭代(假设r9dr9b中的加载不因使用同一r9 64位寄存器的不同部分而冲突)。这个处理器有一个uops缓存,循环有很多指令,所以解码部分应该不是问题。也就是说,有9个uops要执行,处理器每个周期只能执行其中的6个,所以循环不能少于1.5个周期/迭代。更准确地说,端口0、1这是一个乐观的下限执行时间,因为处理器可能不会完美地调度指令,并且有许多事情可能出错(就像一个我没有看到的鬼鬼祟祟的隐藏依赖)。频率为4. 8 GHz,最终执行时间至少为8.3ms。在3个周期/迭代的情况下,它可以达到12.5ms(注意,由于微操作到端口的调度,2.5个周期/迭代是可能的)。
可以使用展开改进循环。实际上,仅执行循环而非实际计算就需要大量指令。展开有助于提高有用指令的比率,从而更好地利用可用端口。尽管如此,2次加载仍会阻止循环快于1个周期/迭代,即4.2 ms。

矢量化Clang代码分析

Clang生成的矢量化代码很复杂,你可以尝试应用与前面代码相同的分析,但这将是一项乏味的任务。
可以注意到,即使代码被矢量化,加载也没有被矢量化。这是一个问题,因为每个周期只能执行2次加载。也就是说,加载是通过两个相邻的字符值对来执行的,因此与之前生成的代码相比,加载并不那么慢。
Clang这样做是因为只有两个64位值可以放入一个128位SSE寄存器和一个64位寄存器,而且它需要这样做,因为diffFound是一个64位整数。8位到64位的转换是代码中最大的问题,因为它需要几个SSE指令来完成转换。此外,由于Coffee Lake上有3个SSE整数单元并且每个单元一次只能计算两个64位整数,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 ms,这与观察到的执行时间(11 ms)非常接近。

分析矢量化GCC代码

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

加快实施

GCC实现并不完美。
首先,它不使用处理器支持的AVX 2,因为没有提供-mavx2标志(或任何类似的标志,如-march=native)。实际上,GCC和其他主流编译器一样,为了与以前的架构兼容,默认情况下只使用SSE 2:SSE 2在所有x86-64处理器上使用都是安全的,但SSE 3、SSSE 3、SSE4.1、SSE4.2、AVX、AVX 2等其他指令集则不安全。有了这样的标志,GCC应该能够生成内存受限代码。
此外,编译器在理论上可以执行多级总和缩减。其思想是使用大小为1024项的块在8位宽SIMD通道中累积比较结果(即64 x16个项目)。这是安全的,因为每个通道的值不能超过64。为了避免溢出,累加值需要存储在更宽的SIMD通道中(例如64位)。使用此策略,punpckhwd指令的开销是原来的1/64。这是一个很大的改进,因为它消除了端口5的饱和。即使只使用SSE 2,这种策略也足以生成内存受限代码。下面是一个 * 未经测试 * 代码的示例,该代码需要标志-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。

.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个项目,我希望对于该高速缓存中已经存在的数据,每次迭代只需要不到12个循环(否则它应该是完全受存储器限制的)。这意味着〈0.1周期/项,也就是说,远小于之前的实现。实际上,uiCA工具指示大约0.055周期/项,即81 GiB/s!可以使用SIMD内部函数手动编写更好的代码,但代价是明显较差的可移植性、维护性和可读性。
请注意,生成顺序内存限制并不总是意味着RAM吞吐量将饱和。事实上,在一个内核上,有时会出现not enough concurrency to hide the latency of memory operations,但在您的处理器上应该没有问题(就像在我的i5- 9600 KF上,带有2个交错的3200 MHz DDR4内存通道)。

nkoocmlb

nkoocmlb2#

是的,如果您的数据在高速缓存中不是热数据,那么即使是SSE2也应该能跟上内存带宽的速度。如果数据在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代码,请参见How to count character occurrences using SIMD。**(其中一个答案包含指向SSE2版本的Godbolt链接。)对比较结果求和也是同样的问题,但您要加载两个矢量以提供pcmpeqb,而不是在循环外广播一个字节来查找单个字符的出现。

有一个答案是,在i7 - 6700 Skylake上,AVX2的基准报告为28 GB/s,SSE2为23 GB/s(只有3.4GHz,可能他们禁用了Turbo,或者只是报告了额定速度。DRAM速度未提及)。
我希望2个输入数据流能达到与1个相同的持续带宽。
如果您在适合二级缓存的较小数组上对重复传递进行基准测试,那么优化更有趣,ALU指令的效率很重要。(该问题答案中的策略非常好,并且针对这种情况进行了很好的调整。)

  • Fast counting the number of equal bytes between two arrays * 是一个使用更差策略的老问答,没有使用psadbw将字节相加到64位。(但没有GCC/clang那么差,当它扩展到32位时仍然是hsumming。)
    • 多线程/内核**在现代台式机上几乎没有帮助,尤其是在像您这样的高内核时钟下。内存延迟足够低,每个内核都有足够的缓冲区来保持足够的请求,几乎可以使双通道DRAM控制器饱和。

在一个大的至强,这将是非常不同的;您需要大部分内核来实现峰值聚合带宽,即使只对memcpy或memset也是如此,因此没有ALU工作,只有加载/存储。更高的延迟意味着单核的可用内存带宽比台式机少得多(即使是绝对意义上的,更不用说6通道而不是2通道的百分比)。另请参阅 * Enhanced REP MOVSB for memcpy * 和 * Why is Skylake so much better than Broadwell-E for single-threaded memory throughput? *

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

仍然不太好(因为它每128个字节减少一个标量,如果你想尝试的话,可以减少到192个),但是@Jérôme Richard想出了一个聪明的方法,给clang一些它可以用一个好的策略向量化一个短的东西,用一个uint8_t sum,用它作为一个足够短的内部循环,而不会溢出。
但是clang仍然用这个循环做了一些愚蠢的事情,正如我们在他的回答中所看到的。我修改了循环控制,使用了一个指针增量,这减少了一点循环开销,只有一个指针add和compare/jcc,而不是LEA/MOV。我不知道为什么clang使用整数索引效率很低。
它还避免了vpcmpeqb内存源操作数letting them stay micro-fused on Intel CPUs的索引寻址模式(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;
}
    • 神箭**叮当作响 *
# 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字节。我们不能使用256,或者任何高于UINT8_MAX(255)的值,并且必须坚持使用32的倍数。或者使用64作为好的度量。
修正常数set1_epi8(4)有一个额外的vpaddb,它将四个0/-1的和转换为来自C !=运算符的四个1/0结果的和。

我不认为有任何方法可以摆脱它或将它从循环中删除,同时仍然累积到uint8_t中,这是clang以这种方式进行矢量化所必需的。(非截断)字节总数,这很讽刺,因为这正是它在用于全零寄存器时的实际作用。如果你执行sum += ptr[off + commonSize] == ptr[off] ? -1 : 0这样的操作,你可以让它直接使用vpcmpeqb的结果,用3次加法将4个向量相加为1,最后经过一些归约步骤,将其输入到vpsadbw中,得到matches * 0xFF的和,对于每个128字节的块,取uint8_t,或者作为int8_t,这是-1 * matches的和,所以0..-128,它不会溢出一个有符号的字节。这很有趣。但是在64位计数器中添加零扩展可能会破坏信息,并且在外部循环中进行符号扩展将花费另一条指令。这将是一条标量movsx指令,而不是vpaddb,但这对Skylake来说并不重要,可能只有在使用AVX-512和512位向量时才重要(clang和GCC都做得不好,不使用掩码加法)。我们能在循环后执行128*n_chunks - count以从匹配之和恢复差异吗?不,我不这么认为。
uiCA static analysis预测天空湖(例如您的CPU)将以**5.51周期/iter的速度运行主循环(4个矢量)**如果数据在一级缓存中是热数据,则为5.05(Ice Lake/Rocket Lake)。(我不得不手动调整asm以模拟-mbranches-within-32B-boundaries的填充效果,对于uiCA的默认假设,即循环顶部相对于32字节对齐边界的位置。我本可以在uiCA中更改该设置。:/)
在实施此次优策略时,唯一遗漏的微优化是它使用了vpextrb(因为它不能证明不需要截断到uint8_t?),而不是vmovdvmovq。因此,前端需要额外的uop,后端的端口5需要额外的uop。(链接中的comment + uncomment),Skylake上为5.25c/iter,Ice Lake上为4.81,非常接近2负载/时钟瓶颈。
(每个iter执行6个向量,192个字节,预计SKL上每个iter为7个周期,或每个向量为1.166个周期,低于5.5/iter = 1.375个周期。或ICL/RKL上大约为6.5个周期= 1.08 c/vec,会遇到后端ALU端口瓶颈。)
这对于我们能够从可移植C++源代码中生成clang代码来说并不坏,而对于高效的手动矢量化来说,每4个32字节的矢量需要4个周期的比较。这很可能会跟上内存或缓存带宽,即使是二级缓存也是如此,所以它非常可用,而且对于L1d中的热数据也不会慢多少。多执行几个uop确实会影响无序执行,并且使用共享物理核的另一个逻辑核可以使用的更多执行资源(超线程)。

    • 遗憾的是,gcc/clang * 没有 * 充分利用AVX-512实现此目的。**如果您使用的是512位矢量(或256位矢量上的AVX-512特征),您可以比较掩码寄存器,然后执行vpaddb zmm0{k1}, zmm0, zmm1合并掩码之类的操作,有条件地递增矢量,其中zmm1 = set1_epi8( 1 )。(或者将-1常量与sub组合使用。)如果处理得当,每个向量的指令和微操作计数应与AVX2大致相同,但gcc/clang使用的数量大约是AVX2的两倍,因此,唯一的节省是在减少到标量,这似乎是代价,让任何东西在所有可用的。

这个版本还避免了清理循环的展开,只是使用每个iter 4个字节的策略进行矢量化,这个策略非常适合清理size%128字节。它同时使用vpxor进行翻转,并使用vpand将0xff转换为0x01,这非常愚蠢。当它可以使用vpandn在一条指令中完成这两件事时,这将使清除循环减少到8个微操作,仅仅是Haswell/Skylake上流水线宽度的两倍,因此它将更有效地从循环缓冲区发出,除了Skylake在微码更新中禁用了它。这对Haswell会有一点帮助

vcirk6k6

vcirk6k63#

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

    • -马奇=土人为胜。
  • 代码的标量版本是CPU瓶颈,而不是RAM瓶颈
  • 使用uica.uops.info估计每个环路的周期数

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

详细数据

在花了一个下午的时间对这些建议进行修改之后,我发现了以下几点:

  • O 1约10 ms,标量代码

-O3启用SSE 2,速度与O 1一样慢,可能是汇编代码不正确

-O3 -march=韦斯特米尔也支持SSE 2,但速度更快(7毫秒)
-O3 -march=native启用AVX -〉2.5ms,我们可能受到RAM带宽限制(接近理论速度)
标量10毫秒现在是有意义的,因为根据这个了不起的工具uica.uops.info,它需要

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

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

  • O 1 11毫秒
  • O2标量静止,但9 ms
  • O3 SSE 4.5毫秒
  • O3 -进行曲=韦斯特米尔7 ms,类似金属撞击声
  • O3 -进行曲=本机3.4ms,略慢于铿锵声

相关问题