由于您提到吞吐量是一个主要问题,因此尽量减少 shuffle 端口的后端 uops 和/或尽量减少前端 uops 总量。 (see this re: perf analysis)。总体瓶颈将取决于周围的代码。
我认为最好的策略是将所有数据有效地放入一个向量的正确 128 位块(通道)中,然后使用 vpshufb (_mm512_shuffle_epi8) 修复它。
正常的 128 位通道插入加载 (vinserti128 ymm, ymm, mem, imm) 每条指令需要 2 个微指令:加载和合并,但 ALU 部分可以在 Skylake-X 上的任何端口上运行,p015,而不仅仅是端口 5 上的随机播放单元. (或者端口 1 上的向量 ALU 单元由于 512 位 uop 在飞行中而关闭,只是 p05)。 https://uops.info/ 和 https://agner.org/optimize/。
不幸的是,vinserti128没有微熔断,所以两个微指令必须分别通过前端1。
但是,vbroadcasti32x4 ymm{K}, [mem]does micro-fuse (RETIRE_SLOTS: 1.0) 所以我们可以通过合并掩码广播加载进行 1-fused-domain-uop 插入。合并屏蔽确实需要 ALU uop,显然能够在 p015* 上运行。 (内存源vinserti128 不能以这种方式解码为 1 uop,这非常愚蠢,但这确实需要提前准备好掩码寄存器。)
(*: uops.info detailed results 奇怪地显示没有实际在端口 0 上运行的微指令,but a ZMM version does。如果测试显示 ymm 版本(正在运行的 512 位微指令)实际上只在 p5 上运行,那么我猜测使用 0x00f0 合并掩码将广播加载到 ZMM 寄存器中。)
如果您可以提升 2 个随机控制向量的负载并设置掩码寄存器,我建议您这样做。 [a] 和[c] 可以是任何寻址模式,但像[rdi + rcx] 这样的索引寻址模式可能会破坏广播的微融合,使其不分层。 (或者 maybe not 如果它算作像 add eax, [rdi + rcx] 这样的 2 操作数指令,因此可以在 Haswell/Skylake 的后端保持微融合。)
## 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 ...
如果你想在循环之后 avoid vzeroupper,你可以使用 xmm/ymm/zmm16 和 17 或其他东西,在这种情况下你会想要 vmovdqu32 xmm20, [a],它比 VEX 编码的 @ 占用更多的代码大小987654346@.
随机播放常量:
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 个 dword,然后每个通道中的 16 个字节。所以你会在 .rodata 中节省空间。
(您可以按任何顺序加载:如果有理由期望其中 2 个源将首先准备好(存储转发,或者更有可能缓存命中,或者首先加载地址准备好),您可以将它们用作源vmovdqu 加载。或者将它们配对,以便合并 uop 可以更快地执行并在 RS aka 调度程序中腾出空间。我以这种方式配对它们以使随机播放控制常量更加人性化。)
如果这个不是在一个循环中(所以你不能提升常量设置)不值得花费 2 微秒来设置 k1,只是使用vinserti128 ymm0, ymm0, [b], 1 和ymm1, [d] 相同。 (每个 2 uop,非微融合,p23 + p015)。此外,vpshufb 控制向量可以是 64 字节的内存源操作数。如果您想避免加载任何常量,则可能值得考虑使用 vpuncklbw / hbw 和插入 (@EOF's comment) 的不同策略,但这会更加混乱。或者可能vpmovzxbd 加载+移位/合并?
性能分析
(任何缓存未命中都将导致合并微指令在数据到达时必须重播。)
运行只是这种背靠背将成为端口 2/3(负载)和 0、5(向量 ALU)的后端吞吐量的瓶颈。有一些空间可以通过前端挤压更多的微指令,例如将其存储在某个地方和/或在其他端口上运行的一些循环开销。或者对于不太完美的前端吞吐量。矢量 ALU 工作将导致 p0 / p5 瓶颈。
使用内在函数,clang 的 shuffle 优化器可能会将屏蔽的广播转换为 vinserti128,但希望不会。 GCC 可能不会发现这种去优化。您没有说您使用的是什么语言,并没有提到寄存器,所以我将在答案中使用 asm。很容易翻译成 C 内在函数,可能是 C# SIMD 的东西,或者你实际使用的任何其他语言。 (在生产代码中通常不需要或不值得使用手写 asm,尤其是如果您希望可移植到其他编译器。)
也可以做一个vmovdqu、vinserti128 ymm 和2x vinserti32x4 zmm。 (或等效的 1-uop 合并屏蔽广播负载)。但这会使 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]),或者一些变化。
将vpermt2b 与并行插入设置一起使用会更糟:Ice Lake vpermt2b 花费 3 微秒 (p05 + 2p5)。
两个 shuffle 常量可以在内存中压缩到每个 16 字节:用 vpmovzxbd 加载 vpermt2d 向量以将字节扩展为 dwords,用 VBROADCASTI64X2 zmm1, m128 加载 vpshufb 控件以重复通道内 shuffle矢量 4 次。可能值得将两个常量放入同一个缓存行,即使这需要在循环外进行加载+洗牌。
如果你用 C 内部函数来实现它,只需使用 _mm512_set_epi8/32;编译器通常会通过不断传播来挫败你变得聪明的尝试。 Clang 和 gcc 有时足够聪明,可以为您压缩常量,但通常只是广播加载,而不是 vpmovzx。
脚注 1: Agner Fog 的指令表表明 VINSERTI32x4 z,z,m,i 可以微熔断(1 个前端 uop),但 uops.info 的 mechanical testing results 不同意:RETIRE_SLOTS: 2.0 匹配 UOPS_EXECUTED.THREAD : 2.0。可能是 Agner 表中的一个错字;具有立即数的内存源指令不会微熔断是正常的。
(也可能它在解码器和 uop 缓存中进行微融合,但不在后端;我认为 Agner 的微融合测试是基于 uop 缓存,而不是问题/rename 瓶颈或性能计数器。RETIRE_SLOTS 计算无序后端中的融合域 uops,在问题/重命名之前/期间可能未分层之后。)
但无论如何,VINSERTI32x4 绝对无助于解决问题/重命名瓶颈,这在紧密循环中更常见。而且我怀疑它是否真的在解码器/uop-cache 中进行了微熔断。不幸的是,Agner 的表格确实有错别字。
替代策略:vpermt2d 记忆中(没有优势)
在我想出使用广播负载作为 1-uop 插入之前,这具有更少的前端 uop,但代价是更多的 shuffle,以及为 4 个源中的 2 个从内存中进行更广泛的负载。我不认为这有什么好处。
vpermt2d ymm, ymm, [mem] 可以在 Skylake 上为前端微融合为 1 个负载+随机播放 uop。 (uops.info result:注意 RETIRE_SLOTS:1.0 与 UOPS_EXECUTED.THREAD:2.0)
这需要从四个 128 位内存操作数中的两个执行 256 位加载。如果它在没有 128 位加载的情况下越过缓存线边界,那将会更慢。 (如果进入未映射的页面,可能会出错)。它还需要更多的洗牌控制向量。但是可以节省前端 uops 与 vinserti128,但不能与合并掩码 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 ...
- 前端成本:6 微指令
- 后端成本:端口 5 为 4 uops,p2/3 为 4 uops
可能对组合对和最终的 ZMM vpermt2d 或 q 使用相同的 shuffle 控制。也许用vpermt2q 组合对和vpermt2d 最后?我还没有真正考虑过这一点,您是否可以选择 ZMM 洗牌向量,以便低 YMM 可以用于组合具有不同元素大小的一对向量。应该不会吧。
不幸的是vpblendd ymm, ymm, [mem], imm8 没有微熔断。
如果您碰巧知道[a..d] 中的任何一个是如何相对于缓存线边界对齐的,那么您可以在执行 256 位加载时避免缓存线拆分,其中包括您想要的数据作为低或高 128位,适当地选择您的vpermt2d shuffle 控件。
混合数据顺序的替代策略,除非您有 AVX512VBMI
适用于 AVX512VBMI vpermb(冰湖)而不是 AVX512BW vpshufb
5 个融合域微指令,1 个向量常量,3 个掩码
通过使用不同的掩码广播将每个 16 字节源块的 4 个 dword 分配到单独的通道中来避免 vpermt2d,这样每个字节都会在某个地方结束,并且结果的每个 16 字节通道都有来自所有 4 个的数据向量。 (使用vpermb,不需要跨车道分布;如上所述,您可以使用0xf0 之类的掩码进行全车道掩蔽)
每个通道都有来自 a、b、c 和 d 的 4 个字节的数据,没有重复,因为每个掩码在每个半字节中都有不同的设置位。
# 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 字节的 shuffle 掩码,您可以在每个通道中进行 shuffle,生成 DCBA...
这可能没用(没有vpermb),但我开始写这个想法,然后才意识到屏蔽广播不可能将[a]的前4个字节放入与[b] 的前 4 个字节相同的通道,以此类推。
掩码设置实际上可以优化为更小的代码和更少的前端微指令,但在 k2 和 k3 实际准备好使用之前会有更高的延迟。使用 ak reg 作为需要 16 个掩码位的 SIMD 指令的掩码会忽略掩码 reg 中的较高位,因此我们可以将掩码数据变为 1 并将其右移几次以在低 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 个掩码,0xf0 和 0xf000,使用带有 vbroadcasti32x4 ymm{k1}, [b] 和 vbroadcasti64x2 zmm{k1}, [c] 的 0xf0 掩码。