AVX512BW:使用 bsf/tzcnt 处理 32 位代码中的 64 位掩码?

AVX512BW: handle 64-bit mask in 32-bit code with bsf / tzcnt?

这是我在 AVX512BW

中 'strlen' 函数的代码
vxorps          zmm0, zmm0, zmm0   ; ZMM0 = 0
vpcmpeqb        k0, zmm0, [ebx]    ; ebx is string and it's aligned at 64-byte boundary
kortestq        k0, k0             ; 0x00 found ?
jnz             .chk_0x00

现在'chk_0x00',在x86_64系统中,没有问题,我们可以这样处理:

chk_0x00:
kmovq   rbx, k0
tzcnt   rbx, rbx
add     rax, rbx

这里我们有一个 64 位寄存器,因此我们可以将掩码存储到其中,但我的问题是关于 x86 系统的,我们没有任何 64 位寄存器,因此我们必须使用 'memory' 保留( 8-byte) 并一一检查掩码的两个DWORD(其实我就是这样,我想知道有没有更好的方法)

chk_0x00:
kmovd   ebx, k0       ; move the first dword of the mask to the ebx
test    ebx, ebx      ; 0x00 found in the first dword ?
jz      .check_next_dword
bsf     ebx, ebx
add     eax, ebx
jmp     .done
.check_next_dword:
      add     eax, 32     ; 0x00 is not found in the first DWORD of the mask so we pass it by adding 32 to the length
      sub     esp, 8      ; reserve 8-byte from memory
      kmovq   [esp], k0   ; move the 8-byte MASK from k0 to our reserved memory
      mov     ebx, [esp+4] ; move the second DWORD of the mask to the ebx
      bsf     ebx, ebx
      add     eax, ebx
      add     esp, 8

在我的 x86 方式中,我使用 'kmovd' 将掩码的第一个 DWORD 移动到 ebx 中,但我不知道我必须为掩码的第二个 DWORD 做什么!!!所以我只是从内存中保留 8 字节并将掩码(8 字节)移入其中然后我将第二个双字移入 ebx 并再次检查它......有没有更好的解决方案? (我认为我的方式不够快) 使用 vxorpszmm 寄存器初始化为零也是正确的吗?

看起来 KSHIFTRQ 可以用作替代方法,将 k0 计数器的高 32 位右移到低 32 位,可以将其复制到常规用途寄存器.喜欢:

.check_next_dword:
      add     eax, 32     
      KSHIFTRQ k0, k0, 32  ;shift hi 32 bits to be low 32 bits
      kmovd   ebx, k0   
    ...

是的,vxorps zmm0, zmm0, zmm0 会将 zmm0 设置为零,根据 vxorps referense it's xor-ing without mask into 3-rd argument (you may check as well this 关于将 zmm 寄存器归零)

首先,如果您的程序在很大程度上依赖于 strlen 大缓冲区的性能,那么您可能做错了。使用像 std::string 这样的显式长度字符串(指针 + 长度),这样您就不必扫描数据来找到结尾。

不过,一些 API 使用隐式长度字符串,所以您不能总是避免它。对于短到中等缓冲区来说,快速通常很重要。允许过度读取其缓冲区的版本使启动更加方便。


如果可以的话,首先要避免使用 32 位模式;你确定手写 32 位 AVX512 asm 值得吗?

此外,您确定要使用 64 字节向量吗?在 Skylake-Xeon 上,这限制了最大涡轮增压(在最后一个 512 位 uop 之后很长一段时间)并且还关闭了矢量 ALU uops 的端口 1(至少在 512 位 uops 正在运行时)。但是,如果您已经在其余代码中使用 512 位向量,那么就使用它,尤其是在您有足够的对齐保证的情况下。但是使用 AVX512 然后根本不展开循环似乎很奇怪,除非您需要在小代码占用空间和良好的大案例处理之间取得平衡。

即使 AVX512BW 可用,您最好只使用 AVX2 strlen,并展开一些循环。或 AVX512BW + VL 仍然与掩码规则进行比较,但使用 32 位掩码。 也许不是; Skylake-X只能运行 vpcmpeqb k0, ymm, ymm/mem端口5,不能micro-fuse内存操作数(注意retire_slots:uops.info results中的2.0 ; 即使使用简单的寻址模式,它也会解码为 2 个独立的微指令)。但是 AVX2 vpcmpeqb ymm, ymm, ymm/mem 是 p01 的 1 uop,并且可以微熔断。因此,如果 L1d 能够跟上,它可以在每个时钟周期加载+比较 2x ymm,仅使用 4/时钟前端带宽中的 2 个融合域微指令。 (不过再检查的话费用会比kortest高)

AVX512 整数比较将比较谓词作为立即数(不是像 SSE/AVX pcmpeq/pcmpgt 那样的操作码的一部分),所以这可能是阻止它从微熔断负载。但是不,vptestmb k1,zmm0,[ebx] can't micro-fuse either, otherwise you could use it or vptestnmb 使用全一向量来检查内存中的零。

(请注意,微融合仅适用于具有非索引寻址模式的 Intel Skylake CPU。类似于 vpcmpeqb ymm1, ymm0, [ebx],而不是 [ebx+eax]。参见 Micro fusion and addressing modes。所以在末尾使用指针递增和减去。)


如果要针对大字符串进行优化,可以一次检查两个缓存行。将指针对齐 128 个字节(即通常检查最多 128 个字节的边界)。 kortestq k0,k1 与 2 个单独的掩码寄存器进行比较后,无需额外费用即可正常工作。

您可能想看看 glibc 的 AVX2 strlen 作品:https://code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/strlen-avx2.S.html。它的主循环(短字符串启动后)使用 vpminub(无符号字节的最小值)将 4 个 YMM 向量(128 字节 = 2 个缓存行)合并为一个并检查其是否为零。跳出循环后,它会找出第一个零的实际位置。 (它仍然在寄存器中有向量,因为它使用单独的 vmovdqa 负载;重新加载它们会让主循环微熔断负载以更加 HT 友好,但在中断后需要重新加载。)

在 SKX 上,vpminub zmm 运行s 在端口 0 上,但可以微融合内存操作数,而 vpcmpeqb zmm 运行s 仅在 p5 上。如果数据在寄存器中,请使用 vptestmb k0, zmm0,zmm0 这样您就不需要清零的寄存器来进行比较。 结合这些可以用很少的 uops 完成大量检查,允许乱序执行 window 到 "see" 很远,可能有助于记忆级并行。 (跨 4k 页边界的数据预取并不完美。)

但这种优化可能只是使循环对超线程更友好,而没有大大提高其自身的吞吐量,并增加了当您跳出循环时要排序的数据量。特别是如果您使用内存源操作数,那么原始数据不在向量 regs 中。因此,如果您关心中等长度的字符串(数百或数千字节),而不仅仅是数兆字节的大字符串,则将内部循环限制为每次检查仅查看几个缓存行听起来很合理。


但是无论如何,在 32 位代码中,您可以简单地使用 32 字节向量 -> 32 位位图重新检查候选区域。 也许 vextracti64x4 将 ZMM 的高半部分抓取到 AVX2 的 YMM vpcmpeqb / vpmovmskb -> 整数寄存器

但它很小,因此您需要完全展开和优化,这正是您要问的。

问题的实际答案:

kshift + kmov 是将 k 寄存器的高半部分放入 32 位 GP 寄存器的明显方法。 Store/reload 是额外的延迟(比如存储转发可能有 5 或 6 个周期)但避免了端口 5 ALU 微指令。或者更糟,比如 <= 10 个周期。 uops.info's dep chain to test that 使存储地址依赖于负载,作为将 store/reload 耦合到循环携带的 dep 链中的一种方式,所以 IDK 如果地址提前准备好会有所不同。

用 256 位向量重做比较也可以作为 kmov 的替代方法,如 AVX2 vpcmpeqb ymm1, ymm0, [ebx+32] / vpmovmskb eax, ymm1。这是任何端口的 2 个融合域 uops,并且对 k0 没有数据依赖性,因此无序执行可以 运行 它与 kmov 并行。 kmov eax, k0vpcmpeqb 都需要端口 0,所以它实际上可能不是很好。 (假设端口 1 上的向量 ALU 由于最近 运行ning 512-bit uops 仍然关闭。)

kmov eax, k0 has 3 cycle latency on SKX. kshiftrq 在不同的端口上有 4 个周期的延迟。因此 kmov + kshift + kmov 可以在 kmov 和 kshift 开始执行(当 k0 准备就绪时,或者在分支错误预测离开环形)。循环分支通常会在离开循环时做出错误预测(绝对是对于大循环行程计数,但可能不适用于类似长度的字符串的重复使用)。为避免数据依赖而进行优化可能没有帮助,例如进行单独的 256 位比较。

不知道无分支清理是否是最好的选择。如果第一个非零字节在低半部分,避免数据依赖于提取高半部分是非常好的。但前提是它预测得好!

;; UNTESTED
; input pointer in ecx, e.g. MS Windows fastcall
strlen_simple_aligned64_avx512_32bit:
   vpxor     xmm0, xmm0, xmm0       ; ZMM0 = _mm512_setzero_si512()
   lea       eax, [ecx+64]          ; do this now to shorten the loop-exit critical path
.loop:
   vpcmpeqb  k0, zmm0, [ecx]     ; can't micro-fuse anyway, could use an indexed load I guess
   add       ecx, 64
   kortestq  k0, k0 
   jnz   .loop                   ; loop = 5 uops total :(
    ;;; ecx - 64 is the 64-byte block that contains a zero byte

; to branch: `kortestd k0,k0` to only look at the low 32 bits, or kmovd / test/jnz to be optimistic that it's in the low half

   kmovd     edx, k0              ; low bitmap
   kshiftrq  k0, k0, 32
    sub       ecx, eax            ; ecx = end_base+64 - (start+64) = end_base
   kmovd     eax, k0              ; high bitmap

   tzcnt     eax, eax             ; high half offset
   bsf       edx, edx             ; low half offset, sets ZF if low==0
   lea       eax, [ecx + eax + 32]  ; high half length = base + (32+high_offset)
       ;; 3-component LEA has 3 cycle latency
       ;; with more registers we could have just an add on the critical path here
   lea       ecx, [ecx + edx]       ; ecx = low half length not touching flags

    ; flags still set from BSF(low)
   cmovnz    eax, ecx             ; return low half if its bitmap was non-zero
   vzeroupper                 ; or use ZMM16 to maybe avoid needing this?
   ret

注意 bsf 根据其 输入 设置标志,而 tzcnt 根据结果设置标志。它是一个 uop,在 Intel 上有 3 个周期的延迟,与 tzcnt 相同。 AMD 速度较慢 bsf 但不支持任何当前 CPU 上的 AVX512。 我在这里假设 Skylake-avx512 / Cascade Lake 作为要优化的 uarch。(和 Ice Lake)。 KNL / KNM 速度较慢 bsf 但 Xeon Phi 没有 AVX512BW。

使用更多指令可以缩短关键路径,例如与 tzcnt / bsf 并行创建 base+32,这样我们就可以避免在它和 cmov 之间出现 3 分量 LEA。我想我必须 push/pop 像 EBX 或 EDI 这样的调用保留寄存器来保留所有临时文件。

简单 lea 运行s on p15 on Skylake, complex lea (3 component) 运行s on p1.所以它不与任何 kmovkshift 竞争,并且在飞行端口 1 中的 512 位 uops 已为 SIMD 关闭。但是 tzcnt/bsf 运行s 在端口 1 上,所以那里存在竞争。尽管如此,由于 LEA 依赖于 tzcnt 的输出,资源冲突可能不是问题。 Ice Lake 将 LEA 单元放在每个端口上,可以在一个周期内处理 3 分量 LEA (InstLatx64)。

如果您使用 kortest k0, k1 和 2 个单独的掩码,您可能想使用 kortest k0,k0 来确定第一个掩码中是否有零,然后才用 32 位 GP 整数寄存器选择 k0 或 k1。


bsf 当其输入全为零时,其目的地保持不变。 此 属性 由 AMD 而非 Intel 记录。英特尔 CPU 确实实现了它。您可能想利用它,特别是如果您包含一个单元测试以确保它在您 运行 正在使用的 CPU 上工作。

但也许不是,因为它将依赖链耦合在一起,使得低半部分的bsf依赖于tzcnt+[=64= 】 上半场。不过,它看起来确实节省了 uops。 不过,根据用例的不同,延迟可能不是很重要。如果您只是计算一个绑定到其他循环的循环,则不需要立即进行,以后会有工作这与 strlen 结果无关。 OTOH 如果你要再次遍历字符串,你通常可以动态地做 strlen 。

(我也从指针增量更改为索引寻址,以一种多节省 1 uop 的方式,因为它无论如何都不会微熔断。它确实在 add 之前引入了额外的地址延迟首次加载。)

;; untested, uses BSF's zero-input behaviour instead of CMOV
;; BAD FOR LATENCY
strlen_aligned64_throughput:
   vpxor     xmm0, xmm0, xmm0       ; ZMM0 = _mm512_setzero_si512()
   mov       edx, -64
.loop:
   add       edx, 64
   vpcmpeqb  k0, zmm0, [ecx+edx]     ; can't micro-fuse anyway on SKX, might as well use an indexed
   kortestq  k0, k0 
   jnz   .loop                   ; loop = 5 uops total :(
    ;;; edx is the lowest index of the 64-byte block

   kshiftrq  k1, k0, 32
   kmovd     eax, k1              ; high bitmap
   tzcnt     eax, eax              ; could also be bsf, it's just as fast on Skylake
   add       eax, 32              ; high index = tzcnt(high) + 32

   kmovd     ecx, k0              ; low bitmap
   bsf       eax, ecx             ; index = low if non-zero, else high+32

   add       eax, edx             ; pos = base + offset
   vzeroupper
   ret

注意使用 kshift 到一个单独的寄存器中,这样我们就可以先得到高半部分(按程序顺序),避免需要 save/restore 任何额外的寄存器。只有 3 个架构寄存器(没有 saving/restoring 更多),我们可以让寄存器重命名 + OoO exec 处理事情。

关键路径延迟不是很大。从k0准备好,kmovd可以取出低半位图,但是bsf eax, ecx不能开始,直到eax准备好。这取决于 kshift (4) -> kmov (3) -> tzcnt (3),加上 (1) = 11 个周期,然后 bsf 是另外 3 个周期。

如果我们并行执行 bsf 操作,最好的情况是我们可以将 tzcnt(hi) + add 送入 CMOV(1 个额外周期),其中有 2 个整数输入来自两个BSF 链接并标记来自低半部分的输入。 (所以关键路径只来自高半部分,低半部分不涉及kshift,可以更快准备好)。

在之前的版本中,我在上半部 dep 链上使用了一个 3 组件 lea,这也不是很好。


相关:AVX512CD有SIMD vplzcntq

但是你不能将它用于 tzcnt,因为我们没有有效的位反转。

此外,您需要将 64 位掩码返回到向量元素中,然后 vmovd 到整数 reg。

有将位掩码分解为向量掩码的说明(如 VPMOVM2B, but there's also VPBROADCASTMW2D xmm1, k1 只是将掩码复制到向量元素。不幸的是,它仅适用于字节或字掩码宽度(不适用于 AVX512BW)。所以这不解决问题。在 64 位模式下,显然你可以 kmovq 到一个整数 reg 和 vmovq 到一个向量,但是你只需要使用标量 lzcnttzcnt