使用 AVX512,4 路字节交错内存中的 4x 16 字节向量

4-way bytewise interleave 4x 16-byte vectors from memory, with AVX512

一个avx512向量可以容纳64个int8值。 我想做如下事情:

  1. 从内存位置a加载16个连续值,假设它们是1
  2. 从内存位置b加载16个连续值,假设它们是2
  3. 从内存位置c加载16个连续值,假设它们是3
  4. 从内存位置d加载16个连续值,假设它们是4
  5. 生成具有以下模式的 avx512 向量:123412341234...1234.

注意:内存加载的16个值预计不会相同,如上例所示。

我知道如何通过先加载再随机播放的方式在功能上做到这一点。 但是,我想知道在注册使用和预期吞吐量方面最有效的方法是什么。

也许有一些奇怪的指令为此目的而优化。

谢谢!

既然你提到吞吐量是一个主要问题,那么将洗牌端口的 back-end 微指令最小化是个好主意,and/or 最小化总 front-end 微指令。 (see this re: perf analysis)。总体瓶颈将取决于周围的代码。

我认为最好的策略是将所有数据有效地放入一个向量的正确 128 位块(通道)中,然后用 vpshufb_mm512_shuffle_epi8)修复它。

正常的 128 位 lane-insert 加载 (vinserti128 ymm, ymm, mem, imm) 每条指令需要 2 微指令:加载和合并,但是 ALU 部分可以 运行 在 [=240= 上的任何端口上],p015,而不仅仅是端口 5 上的洗牌单元。(或者端口 1 上的矢量 ALU 单元因为运行中的 512 位微指令而关闭,只有 p05)。 https://uops.info/ and https://agner.org/optimize/.

不幸的是,vinserti128不是micro-fuse,所以两个微指令必须分别通过front-end1 .

但是,vbroadcasti32x4 ymm{K}, [mem] does micro-fuse (RETIRE_SLOTS: 1.0) 所以我们可以通过 1-fused-domain-uop 插入一个 merge-masked broadcast-load。 merge-masking 确实需要一个 ALU uop,显然能够在 p015* 上 运行。 (memory-source vinserti128 不能以这种方式解码为 1 uop,这很愚蠢,但这确实需要提前准备一个掩码寄存器。)

(*: uops.info detailed results strangely show none of the uops actually running on port 0, but a ZMM version does。如果测试显示 ymm 版本(运行中有 512 位 uops)实际上在 p5 上只有 运行s,那么我想做一个 broadcast-load 到 ZMM 寄存器 0x00f0 merge-mask.)

如果您可以提升 2 个 shuffle-control 向量的负载并设置掩码寄存器,我会建议这样的事情。 [a][c]可以是任何寻址方式,但是像[rdi + rcx]这样的索引寻址方式可能会打败广播的micro-fusion,变成un-laminate。 (或者 maybe not 如果它像 add eax, [rdi + rcx] 这样算作 2 操作数指令,因此可以在 Haswell/Skylake 上的 back-end 中保留 micro-fused。)

## ahead of the loop
   mov         eax,  0xf0                   ; broadcast loads will write high 4 dwords
   kmovb       k1, eax
   vpmovzxbd   zmm6, [vpermt2d_control]     ; "compress" controls with shuffle/bcast loads
   vbroadcasti32x4   zmm7, [vpshufb_control]

## Inside the loop, the actual load+interleave
   vmovdqu     xmm0, [a]                 ; 1 uop, p23
   vmovdqu     xmm1, [c]                 ; 1 uop, p23
   vbroadcasti32x4  ymm0{k1}, [b]        ; 1 uop micro-fused, p23 + p015
    ; ZMM0 = 00... 00...  BBBBBBBBBBBBBBBB  AAAAAAAAAAAAAAAA
   vbroadcasti32x4  ymm1{k1}, [d]        ; 1 uop micro-fused, p23 + p015

   vpermt2d    zmm0, zmm6, zmm1          ; 1 uop, p5.  ZMM6 = shuffle control
    ;ZMM0 = DDDDCCCCBBBBAAAA  DDDDCCCCBBBBAAAA ...
   vpshufb     zmm0, zmm0, zmm7          ; 1 uop, p5.  ZMM7 = shuffle control
    ;ZMM0 = DCBADCBADCBADCBA  DCBADCBADCBADCBA ...

如果你想在循环后 ,你可以使用 xmm/ymm/zmm16 和 17 或其他东西,在这种情况下你需要 vmovdqu32 xmm20, [a],这需要更多 code-size 比 VEX-encoded vmovdqu.

随机播放常量:

default rel           ; you always want this for NASM
section .rodata
align 16
vpermt2d_control: db 0,4,16,20, 1,5,17,21, ...   ; vpmovzxbd load this
vpshufb_control:  db 0,4,8,12,  1,5,9,13, ...    ; 128-bit bcast load this
; The top 2x 128-bit parts of each ZMM is zero
; I think this is right; edits welcome with full constants (_mm512_set... syntax is fine)

如果我们用 vpermd 然后用 vpshufb 洗牌一个 ZMM(插入 3 次后,见下文),我认为这将是相同的常量扩展 2 种不同的方式(将字节扩展为双字,或重复 4 次),做同样洗牌到 ZMM 中的 16 个双字,然后在每个通道中洗牌到 16 个字节。所以你会在 .rodata.

中保存 space

(您可以按任何顺序加载:如果有理由预期其中 2 个源将首先准备好(存储转发,或更有可能命中缓存,或首先准备好加载地址),您可以将它们用作vmovdqu 负载的来源。或将它们配对,以便合并 uop 可以执行并更快地在 RS 调度程序中腾出空间。我以这种方式将它们配对以使随机播放控制常量更多 human-friendly。)

如果这个不是在一个循环中(所以你不能提升常量设置)不值得花2微秒要设置 k1,只需使用 vinserti128 ymm0, ymm0, [b], 1ymm1, [d] 也一样。 (每个 2 微指令,不是 micro-fused,p23 + p015)。此外,vpshufb 控制向量可以是一个 64 字节的内存源操作数。如果您想避免加载任何常量,则使用 vpuncklbw / hbw 和插入 () 的不同策略可能值得考虑,但这会造成更多混乱。或者可能 vpmovzxbd 负载 + shift/merge?

性能分析

  • 总 front-end 成本:6 微指令。 (SKX 上为 1.5 个时钟周期)。使用 vinserti128

    从 8 微指令/2 个周期下降
  • 总 back-end 成本:每个结果最少 2 个周期

    • p23 加载 4 次
    • 2 p5 洗牌
    • 2 p05 合并(插入),希望安排到 p0。 (当任何 512 位微指令运行时,端口 1 的向量执行单元将关闭。它仍然可以 运行 诸如 imullea 和 simple-integer 之类的东西。)

(任何缓存未命中都将导致合并 uops 在数据确实到达时必须重播。)

运行 只是 这个back-to-back 将在端口 2/3(负载)和 0、5(矢量)的 back-end 吞吐量上出现瓶颈铝)。有一些空间可以通过 front-end 压缩更多的 uops,比如将它存储在某个地方 and/or 一些循环开销 运行s 在其他端口上。或者 less-than-perfect front-end 吞吐量。矢量 ALU 工作将导致 p0 / p5 瓶颈。

使用内在函数,clang 的随机播放优化器可能会将屏蔽的广播变成 vinserti128,但希望不会。 GCC 可能不会发现这种去优化。您没有说您使用的是什么语言,也没有提到寄存器,所以我只会在答案中使用 asm。很容易翻译成 C 内在函数,也许是 C# SIMD 的东西,或者你实际使用的任何其他语言。 (Hand-written asm 在生产代码中通常不是必需的或不值得的,特别是如果你想移植到其他编译器。)


也可以做一个 vmovdquvinserti128 ymm 和 2 个 vinserti32x4 zmm。 (或等效的 1-uop merge-masking 广播负载)。但这会使合并的 ILP 更差,我们仍然需要 vpermd + vpshufb 因为vpermb 需要 AVXM512VBMI(Ice Lake,而不是 Skylake-X)。

但是,如果您也有 AVX512VBMI,vpermb 在 Ice Lake 上只有 1 uop,因此 3x 插入 + vpermb 将是吞吐量的理想选择。 使用 merge-broadcats 进行插入需要 2 个单独的合并掩码,0xf0(与 ymm 32x4 和 zmm 64x2 一起使用)和 0xf000(与 zmm 32x4 一起使用,加载 [d]最后), 或者一些变化。

在 parallel-insert 设置中使用 vpermt2b 会更糟:Ice Lake vpermt2b 花费 3 微指令 (p05 + 2p5)。


两个shuffle常量可以在内存中压缩到每个16字节:用vpmovzxbd加载vpermt2d向量以将字节扩展为双字,用[=加载vpshufb控件56=] 重复 in-lane 洗牌向量 4 次。将两个常量放入同一个缓存行可能是值得的,即使这会在循环外花费 load+shuffle。

如果您使用 C 内在函数实现它,只需使用 _mm512_set_epi8/32;编译器通常会通过 constant-propagation 打败你的聪明企图。 Clang 和 gcc 有时足够聪明,可以为您压缩常量,但通常只是 broadcast-loading,而不是 vpmovzx。


脚注1: Agner Fog的指令tables表明VINSERTI32x4 z,z,m,i可以micro-fuse(1 front-end uop),但 uops.info 的 mechanical testing results 不同意:RETIRE_SLOTS:2.0 匹配 UOPS_EXECUTED.THREAD:2.0。可能是 Agner 的 table 中的错字; memory-source 带有立即数的指令不 micro-fuse.

是正常的

(另外 可能 它 micro-fuse 在解码器和 uop 缓存中但不在 back-end 中;Agner 对 micro-fusion 的测试我认为是基于 uop 缓存,而不是 issue/rename 瓶颈或性能计数器。RETIRE_SLOTS 在 out-of-order back-end 中计数 fused-domain uops,可能 un-lamination before/during issue/rename.)

但无论如何,VINSERTI32x4 绝对无助于 issue/rename 瓶颈,这在紧密循环中更常见。我怀疑它实际上 micro-fuse 甚至在 decoders/uop-cache 中。不幸的是,Agner 的 table 确实有拼写错误。


备选策略:vpermt2d凭记忆(无优势)

在我想出使用 broadcast-load 作为 1-uop 插入之前,它有更少的 front-end uops,但代价是更多的洗牌,以及从内存中加载 2 of 4个来源。我认为这没有任何优势。

vpermt2d ymm, ymm, [mem] 可以 micro-fuse 在 Skylake 上为 front-end 加载 1 个 load+shuffle uop。 (uops.info result:注意 RETIRE_SLOTS:1.0 与 UOPS_EXECUTED.THREAD:2.0)

这需要从四个 128 位内存操作数中的两个进行 256 位加载。如果 128 位加载不会跨越 cache-line 边界,那会更慢。 (如果进入未映射的页面,可能会出错)。它还需要更多的洗牌控制向量。但是可以节省 front-end uops vs. vinserti128,但不能节省 merge-masked vbroadcasti32x4

;; Worse, don't use
; setup: ymm6, zmm7: vpermt2d/q shuffle controls: zmm8: vpshufb control
    vmovdqu   xmm0, [a]                   ; 1 uop p23
    vmovdqu   xmm1, [b]                   ; 1 uop p23
    vpermt2d  ymm0, ymm6, [c]             ; 1 uop micro-fused, p23 + p5.  256-bit load
    vpermt2d  ymm1, ymm6, [d]             ; 1 uop micro-fused, p23 + p5

   vpermt2q    zmm0, zmm7, zmm1           ; 1 uop, p5
    ;ZMM0 = DDDDCCCCBBBBAAAA  DDDDCCCCBBBBAAAA ...
   vpshufb     zmm0, zmm0, zmm8           ; 1 uop, p5
    ;ZMM0 = DCBADCBADCBADCBA  DCBADCBADCBADCBA ...
  • front-end 成本:6 微指令
  • back-end 成本:端口 54 微指令,p2/3
  • 4 微指令

可以使用相同的洗牌控制来组合对和最终的 ZMM vpermt2d 或 q。也许用 vpermt2q 组合对,最后用 vpermt2d?我还没有真正想清楚,是否可以选择一个 ZMM 洗牌向量,这样低 YMM 就可以用于组合一对具有不同元素大小的向量。应该不是。

不幸的是 vpblendd ymm, ymm, [mem], imm8 没有 micro-fuse。

如果您碰巧知道 [a..d] 中的任何一个是如何相对于 cache-line 边界对齐的,您可以在执行包含数据的 256 位加载时避免 cache-line 拆分你想要低或高 128 位,适当地选择你的 vpermt2d 随机播放控制。


混合数据顺序的替代策略,除非你有 AVX512VBMI

将使用 AVX512VBMI vpermb(Ice Lake)而不是 AVX512BW vpshufb
5 fused-domain 微指令,1 个向量常量,3 个掩码

通过使用不同的 masked-broadcasts 将每个 16 字节源块的 4 个双字分布到单独的通道中来避免 vpermt2d,这样每个字节都会在某个地方结束,并且结果的每个 16 字节通道都有来自所有 4 个向量的数据。 (使用 vpermb,不需要跨车道分​​布;如上所述,您可以使用 0xf0 等掩码进行 whole-lane 掩码)

每个通道都有来自 a、b、c 和 d 的 4 个字节的数据,没有重复,因为每个掩码在每个半字节中都有不同的 set-bit。

# before the loop: setup
  ;mov      eax, 0x8421      ; A_mask.  Implicit, later merges leave these elements
  mov       eax, 0x4218      ; B_mask
  kmovw     k1, eax
  mov       eax, 0x2184      ; C_mask
  kmovw     k2, eax
  mov       eax, 0x1842      ; D_mask
  kmovw     k3, eax
  vbroadcasti32x4  zmm7, [inlane_shuffle]    ; for vpshufb


## Inside the loop, the actual load+interleave
  vbroadcasti32x4  zmm0, [a]
      ; ZMM0 = AAAA AAAA AAAA AAAA   (each A is a 4-byte chunk)
  vbroadcasti32x4  zmm0{k1}, [b]          ; b_mask = 0x4218
      ; ZMM0 = A3B2A1A0  AAB1A    AAAB0    B3A2A1A0
  vbroadcasti32x4  zmm0{k2}, [c]          ; c_mask = 0x2184
      ; ZMM0 = A3B2C1A0  AAB1C0   C3AAB0   B3C2A1A0
  vbroadcasti32x4  zmm0{k3}, [d]          ; d_mask = 0x1842
      ; ZMM0 = A3B2C1D0  D3A2B1C0 C3D2A1B0 B3C2D1A0

  vpshufb  zmm0, zmm0, zmm7    ; not lane-crossing >.<

使用 64 字节洗牌掩码,您可以在每个通道中进行洗牌,从而产生 DCBA...在每个通道中,但使用来自 non-corresponding 个源位置的数据。

这可能没用(没有vpermb),但我开始写这个想法,然后意识到 masked-broadcasts 不可能得到[a] 的前 4 个字节与 [b] 的前 4 个字节进入同一通道,依此类推。

掩码设置实际上可以优化为更小的代码和更少的 front-end 微指令,但在 k2 和 k3 实际准备好使用之前会有更高的延迟。使用 k regs 需要 16 个掩码位的 SIMD 指令的掩码会忽略掩码寄存器中的高位,因此我们可以将掩码数据合并为一个并将其右移几次以生成我们想要的低 16 位掩码。

mov       eax, 0x42184218
                          ; 0x8421  A_mask
kmovd     k1, eax         ; 0x4218 in low 16 bits
kshiftrd  k2, k1, 12      ; 0x2184 in low 16 bits   ; 4 cycle latency, port  5 only.
kshiftrd  k3, k1, 8       ; 0x1842 in low 16

但是同样,如果你有 vpermb 那么你只需要 2 个掩码,0xf00xf000,使用 0xf0 掩码和 vbroadcasti32x4 ymm{k1}, [b]vbroadcasti64x2 zmm{k1}, [c].