【问题标题】:Compact storage of shuffle vectors: unpacking 4 bytes to shuffle uint32_t elements with a byte-shuffleshuffle 向量的紧凑存储:解包 4 个字节以使用 byte-shuffle 对 uint32_t 元素进行混洗
【发布时间】:2021-11-20 21:17:14
【问题描述】:

我有一个跨架构代码,它按索引查找随机播放,用于在向量中移动 uint32_t 元素。每次 shuffle 都需要一个完整的向量常量,但只有 4 个字节的非冗余信息。 (或者实际上是 4x 2 位信息,但解压成本会更高。)

在 SSSE3-SSE4.2 上我使用 _mm_shuffle_epi8 而在 arm 上它是 table 内在函数。


但是,现在我存储整个 shuffle 掩码,也称为控制向量,因此例如对于 int 的身份,我将存储: 0x0f0e0d0c0b0a09080706050403020100

我只想存储0x03020100,每个唯一的随机播放控制元素存储在一个字节/uint8_t中。

有没有一种有效的方式来从一个到另一个? convert + multiply 好像有点重。

【问题讨论】:

  • 如果只是一个控制向量的查找表呢?然后,您可以轻松地将排列编码为压缩的 8 位,并将其用作 256 个控制向量(大多数未使用)的数组的索引。或者,如果您愿意,可以更紧凑地对其进行编码 - 只有 24 种可能的排列。
  • @NateEldredge:最初的问题并不意味着它是一个排列,例如永远不会得到一个元素的 2 个副本并删除另一个元素。在尝试改写以介绍问题前面的内容时,我添加了“四处走动”一词。无论如何,256 x 16 B = 4 KiB。这仍然是一个相当大的足迹,只是 L1d$ 的一小部分。根据用例,它可能是值得的(例如在循环中),但对于偶尔的调用,紧凑表更有可能在缓存中命中,并且可能值得加载两个固定向量并运行额外的 pshufb 的额外成本+paddb。
  • @NateEldredge:我猜你已经想到了,当你需要基于多个_mm_movemask_ps 结果的串联或其他东西来查找多个LUT 条目时,你需要相同的随机播放;是的,这是有道理的。
  • 它们是排列,只有 9 个。目前,我正是这样做的 9 个完整字节元素的 LUT。但是:1:不能为不同类型重用同一张表,2:为了更小的表而缩小表会很好。

标签: c sse intrinsics neon


【解决方案1】:

存储打包的 LUT,每个字节都包含起始字节编号,因此您无需放大它们。
将每个控制索引广播到对应元素的字节中(1个固定shuffle),然后添加一个常量set1_epi32(0x03020100)来偏移它们。

  __m128i v = _mm_cvtsi32_si128(shuffle_lut[i]);

  v = _mm_shuffle_epi8(v, _mm_set1_epi32(0x03030303, 0x02020202, 0x01010101, 0x00000000));  // broadcast each byte into a dword
  v = _mm_add_epi8(v, _mm_set1_epi32(0x03020100));   // offset the byte indices

 // v is your shuffle-control vector, usable with another pshufb
 // as if you'd just unpacked lut[i]>>2 to dwords for vpermilps

身份洗牌存储为0x0c080400。 0x0c + 0x03 = 0x0f 在顶部元素的顶部字节中。

我猜你在 C 中的 LUT 实际上是作为 uint32_t shuffle_lut 完成的,在这种情况下,你不必担心执行严格混叠安全的双字加载。对此的内在支持是冒险的,但_mm_cvtsi32_si128movd 易于使用。它需要一个值(不是地址),因此在 C 语言中,内存访问发生在纯 C 中。编译器仍然可以将负载折叠到 movd 的内存操作数中。


顺便说一句,我假设您说的是 SSE4.2,因为 AVX1 有 _mm_permutevar_ps (vpermilps),所以 _mm_cvtepu8_epi32 (pmovzxbd) 可以解压 4 字节负载而无需进一步修改。使用双字索引,而不是字节索引,因此您将身份洗牌存储为0x03020100

不幸的是,让编译器从内部代码发出内存源 vpmovzxbd xmm0, [rdi] 指令对于除了 clang 之外的编译器来说是一件痛苦的事情。他们经常无法将 movdmovq 加载内在函数折叠到内存源操作数中,但如果您不想超过缓冲区的末尾,则必须使用它而不是完整的 __m128i 加载。调试构建。几年前的实际编译结果见Loading 8 chars from memory into an __m256 variable as packed single precision floats


AVX2 或 BMI2+AVX 打包成一个字节

每个 shuffle 索引实际上只有 2 位信息,因此可以将四个索引打包成 1 个字节 (uint8_t)。

解压方式是BMI2整数pdep。即_pdep_u32(lut[i], 0x03030303。然后vmovd/vpmovzxbd/vpermilps。也许pdep 甚至可以用乘数常数代替,因为vpermilps 只关心每个 dword 的低 2 位。

但是pext 在 Zen3 之前的 AMD 上非常慢。即使在 Intel 上,首先加载到整数也会有很大的延迟。

另一个选项是使用 AVX2 可变移位将适当的 2 位带到每个 dword 元素的底部。从字节的广播加载开始。或者在大多数情况下更有效(缓存行拆分除外),CPU 可以在加载端口中“免费”执行的双字广播,不需要单独的 ALU shuffle uop。 (https://uops.info/)

为此避免严格混叠 UB 是很痛苦的,例如_mm_set1_epi32( *(uint32_t*) &lut[i] ) 不安全。但是有一个内部函数需要一个指针,_mm_broadcast_ss

  // make sure LUT[] doesn't end right at the end of a page
  // so we can broadcast-load 4 bytes starting at any byte offset in it.
  // i.e. pad it by 3 bytes if needed.
  __m128i v = _mm_castps_si128( _mm_broadcast_ss( (const float*)&LUT[i] ));

  // alternative:  __m128i v = _mm_set1_epi8( LUT[i] );  // vpbroadcastb is an extra shuffle uop, but narrower load

  v = _mm_srlv_epi32(v, _mm_set_epi32(6, 4, 2, 0));

  // ready for _mm_permutevar_ps
 // low 2 bits of each 32-bit element of v are correct

不需要_mm_and_si128vpermilps 不关心控制向量元素中的高垃圾。

请注意,AVX2 vpermd 没有 XMM 版本,因此即使有可用的 AVX2,vpermilps 仍然是使用 32 位粒度的可变控制随机播放的最佳选择。

(除非您想将整个算法扩展到 __m256i 中的 8 个元素,否则使用车道交叉 vpermd aka _mm256_permutexvar_epi32。但是您需要 8 x 3 位的随机控制数据 = 3字节不是 1。然后可能仍然有太多可能为其制作 LUT。)

也相关:

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 2018-01-12
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2012-04-03
    相关资源
    最近更新 更多