【问题标题】:Extract 10bits words from bitstream从比特流中提取 10 位字
【发布时间】:2019-08-22 13:48:25
【问题描述】:

我需要从构建为ABACABACABAC... 的原始比特流中提取所有 10 位字

它已经适用于像这样的天真的 C 实现

for(uint8_t *ptr = in_packet; ptr < max; ptr += 5){
    const uint64_t val =
        (((uint64_t)(*(ptr + 4))) << 32) |
        (((uint64_t)(*(ptr + 3))) << 24) |
        (((uint64_t)(*(ptr + 2))) << 16) |
        (((uint64_t)(*(ptr + 1))) <<  8) |
        (((uint64_t)(*(ptr + 0))) <<  0) ;

    *a_ptr++ = (val >>  0);
    *b_ptr++ = (val >> 10);
    *a_ptr++ = (val >> 20);
    *c_ptr++ = (val >> 30);
}

但我的应用程序的性能不足,因此我想使用一些 AVX2 优化来改进这一点。

我访问了https://software.intel.com/sites/landingpage/IntrinsicsGuide/# 网站以查找任何可以提供帮助的功能,但似乎没有任何功能可用于 10 位字,只有 8 位或 16 位。这似乎是合乎逻辑的,因为 10 位不是处理器原生的,但它让我很难。

有没有办法使用AVX2来解决这个问题?

【问题讨论】:

  • 一般来说,点优化是不值得的。大多数情况下,应该关注正在使用的算法的变化。
  • 我知道,但我无法控制输入数据,而且我的 C 循环似乎已经非常优化,也许可以做更多,但我不知道如何做。如果可能的话,我希望将速度提高 2-3 倍……
  • 您是否也有 BMI2 和 AVX2?您可以为 Intel 和 AMD CPU 制作单独的版本吗? (pext 对此有好处,但在 AMD 上慢,在 Intel 上快)。尽管由于您在循环中执行此操作并分别存储每个字段,但您可能希望使用 SIMD shuffle 以最终得到一个 16 位的整个向量? (像素?)组件,而不是每 10 位单元一个存储。 a_ptr等有哪些类型?
  • @user3629249:嗯嗯。解压缩 10 位像素组件听起来像是您在大多数普通算法开始时真正想要做的事情。尽管正如您所说,如果您正在读取未压缩的像素,则应该首先使用它,否则您应该解压缩为合理的格式。
  • 标量代码不是缺少一些掩码吗?还是可以在结果的上部留下“垃圾位”?

标签: c optimization simd avx2 bit-packing


【解决方案1】:

您的标量循环没有高效地编译。编译器将其作为 5 个单独的字节加载来完成。您可以在 C++ 中使用 memcpy 表示未对齐的 8 字节负载:

#include <stdint.h>
#include <string.h>

// do an 8-byte load that spans the 5 bytes we want
// clang auto-vectorizes using an AVX2 gather for 4 qwords.  Looks pretty clunky but not terrible
void extract_10bit_fields_v2calar(const uint8_t *__restrict src, 
   uint16_t *__restrict a_ptr, uint16_t *__restrict b_ptr, uint16_t *__restrict c_ptr,
   const uint8_t *max)
{
    for(const uint8_t *ptr = src; ptr < max; ptr += 5){
        uint64_t val;
        memcpy(&val, ptr, sizeof(val));

        const unsigned mask = (1U<<10) - 1; // unused in original source!?!
        *a_ptr++ = (val >>  0) & mask;
        *b_ptr++ = (val >> 10) & mask;
        *a_ptr++ = (val >> 20) & mask;
        *c_ptr++ = (val >> 30) & mask;
    }
}

ICC 和 clang 自动矢量化您的 1 字节版本,但做得非常糟糕(大量插入/提取单个字节)。这是你的原件和这个函数on Godbolt(使用 gcc 和 clang -O3 -march=skylake

这 3 个编译器都没有真正接近我们可以手动执行的操作。


手动矢量化

我当前的 AVX2 版本的这个答案忘记了一个细节:只有 3 种字段 ABAC,而不是像 10 位 RGBA 像素的 ABCD。所以我有一个版本,它可以解压缩为 4 个单独的输出流(如果我为 ABAC 交错添加专用版本,我将保留它,因为打包的 RGBA 用例)。

现有版本可以使用vpunpcklwd 交错两个A 部分,而不是使用单独的vmovq 存储应该适用于您的情况。可能会有更高效的方法,IDK。

顺便说一句,我发现更容易记住和键入指令助记符,而不是固有名称。 Intel 的online intrinsics guide 可以通过指令助记符搜索。


对您的布局的观察:

每个字段跨越一个字节边界,而不是两个,因此可以在包含 4 个完整字段的 qword 中组合任意 4 对字节。

或者使用字节洗牌,创建 2 字节的单词,每个单词在某个偏移量处都有一个完整的字段。 (例如,对于AVX512BW vpsrlvw,或对于 AVX2 2x vpsrld + word-blend。)像 AVX512 vpermw 这样的 word shuffle 不够:一些单独的字节需要与开头重复一个领域和另一个领域的结束。即源位置并非所有 对齐 字,尤其是当您在向量的同一个 16 字节“通道”内有 2x 5 个字节时。

00-07|08-15|16-23|24-31|32-39     byte boundaries  (8-bit)
00...09|10..19|20...29|30..39     field boundaries (10-bit)

幸运的是 8 和 10 的 GCD 为 2,即 >= 10-8=2。 8*5 = 4*10 所以我们没有得到所有可能的起始位置,例如绝不是从 1 个字节的最后一位开始的字段,跨越另一个字节,并包括第 3 个字节的第一位。

可能的 AVX2 策略:未对齐的 32 字节加载,在低通道顶部留下 2x 5 字节,在高通道底部留下 2x 5 字节。 然后vpshufb in-用于设置 2 次 vpsrlvd 可变计数班次和混合的车道洗牌。

一个我还没有扩展的新想法的快速总结。

给定来自我们未对齐负载的xxx a0B0A0C0 a1B1A1C1 | a2B2A2C2 a3B3A3C3 输入,我们可以通过正确选择vpshufb 控件得到a0 A0 a1 A1 B0 B1 C0 C1 | a2 A2 a3 A3 B2 B3 C2 C3 的结果。
然后 vpermd 可以将所有这些 32 位组按正确的顺序排列,所有 A 元素位于高半部分(准备好将 vextracti128 放入内存),而 B 和 C 位于低半部分(为vmovq / vmovhps 商店准备)。

对相邻对使用不同的vpermd shuffle,以便我们可以vpblendd 将它们合并为128 位BC 存储。


旧版本,可能比未对齐加载 + vpshufb 更糟糕

使用 AVX2,一种选择是将包含的 64 位元素广播到向量中的所有位置,然后使用可变计数右移将位移到 dword 元素的底部。

您可能希望为每个组执行单独的 64 位广播加载(因此与前一个组部分重叠),而不是尝试分离__m256i 的连续位。 (广播负载很便宜,洗牌很贵。)

_mm256_srlvd_epi64 之后,再用 AND 隔离每个 qword 中的低 10 位。

对 4 个输入向量重复该操作 4 次,然后使用 _mm256_packus_epi32 进行通道内打包到 32 位和 16 位元素。


这是简单的版本。交织的优化是可能的,例如通过使用左移或右移来设置vpblendd,而不是像vpackusdwvshufps 这样的2 输入随机播放。 _mm256_blend_epi32 在现有 CPU 上非常高效,可在任何端口上运行。

这也允许将 AND 延迟到第一个打包步骤之后,因为我们不需要避免高垃圾造成的饱和。

设计说明:

shown as 32-bit chunks after variable-count shifts
[0 d0 0 c0 | 0 b0 0 a0]      # after an AND mask
[0 d1 0 c1 | 0 b1 0 a1]

[0 d1 0 c1 0 d0 0 c0 | 0 b1 0 a1 0 b0 0 a0]   # vpackusdw
shown as 16-bit elements but actually the same as what vshufps can do

---------

[X d0 X c0 | X b0 X a0]    even the top element is only garbage right shifted by 30, not quite zero
[X d1 X c1 | X b1 X a1]

[d1 c1 d0 c0 | b1 a1 b0 a0 ]   vshufps  (can't do d1 d0 c1 c0 unfortunately)

---------

[X  d0  X c0 |  X b0  X a0]   variable-count >>  qword
[d1 X  c1  X | b1  X a1  0]   variable-count <<  qword

[d1 d0 c1 c0 | b1 b0 a1 a0]   vpblendd

最后一个技巧扩展到vpblendw,允许我们使用交错混合来做所有事情,根本不需要随机播放指令,从而产生我们想要的连续输出,并且在__m256i 的qwords 中以正确的顺序输出。

x86 SIMD 变量计数移位只能对所有元素进行左移或右移,因此我们需要确保所有数据都在所需位置的左侧或右侧,而不是同一向量中的每个数据。我们可以使用立即计数移位来为此进行设置,但更好的是只调整我们加载的字节地址。对于第一个之后的加载,我们知道在我们想要的第一个位域之前加载一些字节是安全的(不接触未映射的页面)。

# as 16-bit elements
[X X X d0  X X X c0 | ...]    variable-count >> qword
[X X d1 X  X X c1 X | ...]    variable-count >> qword from an offset load that started with the 5 bytes we want all to the left of these positions

[X d2 X X  X c2 X X | ...]    variable-count << qword
[d3 X X X  c3 X X X | ...]    variable-count << qword

[X d2 X d0  X c2 X c0 | ...]   vpblendd
[d3 X d1 X  c3 X c1 X | ...]   vpblendd

[d3 d2 d1 d0   c3 c2 c1 c0 | ...] vpblendw  (Same behaviour in both high and low lane)

Then mask off the high garbage inside each 16-bit word

注意:这有 4 个单独的输出,例如 ABCD 或 RGBA->平面,而不是 ABAC

// potentially unaligned 64-bit broadcast-load, hopefully vpbroadcastq. (clang: yes, gcc: no)
// defeats gcc/clang folding it into an AVX512 broadcast memory source
// but vpsllvq's ymm/mem operand is the shift count, not data
static inline
__m256i bcast_load64(const uint8_t *p) {
    // hopefully safe with strict-aliasing since the deref is inside an intrinsic?
    __m256i bcast = _mm256_castpd_si256( _mm256_broadcast_sd( (const double*)p ) );
    return bcast;
}

// UNTESTED
// unpack 10-bit fields from 4x 40-bit chunks into 16-bit dst arrays
// overreads past the end of the last chunk by 1 byte
// for ABCD repeating, not ABAC, e.g. packed 10-bit RGBA
void extract_10bit_fields_4output(const uint8_t *__restrict src, 
   uint16_t *__restrict da, uint16_t *__restrict db, uint16_t *__restrict dc, uint16_t *__restrict dd,
   const uint8_t *max)
{
  // FIXME: cleanup loop for non-whole-vectors at the end    
  while( src<max ){
    __m256i bcast = bcast_load64(src);  // data we want is from bits [0 to 39], last starting at 30
    __m256i ext0 = _mm256_srlv_epi64(bcast, _mm256_set_epi64x(30, 20, 10, 0));  // place at bottome of each qword

    bcast = bcast_load64(src+5-2);        // data we want is from bits [16 to 55], last starting at 30+16 = 46
    __m256i ext1 = _mm256_srlv_epi64(bcast, _mm256_set_epi64x(30, 20, 10, 0));   // place it at bit 16 in each qword element

    bcast = bcast_load64(src+10);        // data we want is from bits [0 to 39]
    __m256i ext2 = _mm256_sllv_epi64(bcast, _mm256_set_epi64x(2, 12, 22, 32));   // place it at bit 32 in each qword element

    bcast = bcast_load64(src+15-2);        // data we want is from bits [16 to 55], last field starting at 46
    __m256i ext3 = _mm256_sllv_epi64(bcast, _mm256_set_epi64x(2, 12, 22, 32));   // place it at bit 48 in each qword element

    __m256i blend20 = _mm256_blend_epi32(ext0, ext2, 0b10101010);   // X d2 X d0  X c2 X c0 | X b2 ...
    __m256i blend31 = _mm256_blend_epi32(ext1, ext3, 0b10101010);   // d3 X d1 X  c3 X c1 X | b3 X ...

    __m256i blend3210 = _mm256_blend_epi16(blend20, blend31, 0b10101010);  // d3 d2 d1 d0   c3 c2 c1 c0 
    __m256i res = _mm256_and_si256(blend3210, _mm256_set1_epi16((1U<<10) - 1) );

    __m128i lo = _mm256_castsi256_si128(res);
    __m128i hi = _mm256_extracti128_si256(res, 1);
    _mm_storel_epi64((__m128i*)da, lo);     // movq store of the lowest 64 bits
    _mm_storeh_pi((__m64*)db, _mm_castsi128_ps(lo));       // movhps store of the high half of the low 128.  Efficient: no shuffle uop needed on Intel CPUs

    _mm_storel_epi64((__m128i*)dc, hi);
    _mm_storeh_pi((__m64*)dd, _mm_castsi128_ps(hi));       // clang pessmizes this to vpextrq :(
    da += 4;
    db += 4;
    dc += 4;
    dd += 4;
    src += 4*5;
  }
}

这个compiles (Godbolt) 到每 4 组 4 个字段的循环中大约有 21 个前端微指令(在 Skylake 上)。 (包括_mm256_castsi256_si128 的无用寄存器副本,而不是仅使用 ymm0 = xmm0 的低半部分)。这将在 Skylake 上非常好。不同端口的微指令平衡良好,SKL 上的 p0 或 p1 的可变计数移位为 1 微指令(与以前更昂贵相比)。瓶颈可能只是每个时钟 4 个融合域微指令的前端限制。

由于未对齐的加载有时会跨越 64 字节的缓存行边界,因此会发生缓存行拆分加载的重播。但这只是在后端,由于前端瓶颈,我们在端口 2 和 3 上有几个空闲周期(每组结果 4​​ 个加载和 4 个存储,索引存储因此不能使用端口 7 )。如果依赖的 ALU 微指令也必须得到重放,我们可能会开始看到后端瓶颈。

尽管有索引寻址模式,但不会出现分层,因为 Haswell 和更高版本可以保持索引存储微融合,并且广播负载无论如何都是单个纯 uop,而不是微融合 ALU+负载。

在 Skylake 上,如果内存带宽不是瓶颈,它可能会接近每 5 个时钟周期 4 个 40 位组。 (例如,具有良好的缓存阻塞。)一旦考虑到开销和缓存行拆分负载的成本会导致偶尔的停顿,可能每 40 位输入需要 1.5 个周期,即在 Skylake 上每 20 字节输入需要 6 个周期。

在其他 CPU(Haswell 和 Ryzen)上,可变计数移位将成为瓶颈,但您对此无能为力。我不认为有什么更好的。在 HSW 上是 3 微指令:p5 + 2p0。在 Ryzen 上,它只有 1 uop,但每 2 个时钟的吞吐量只有 1 个(对于 128 位版本),或者对于 256 位版本,每 4 个时钟的吞吐量只有 2 uop。

请注意,clang 将 _mm_storeh_pi 存储为 vpextrq [mem], xmm, 1:2 微指令,随机播放 + 存储。 (而不是 vmovhps :英特尔上的纯存储,没有 ALU)。 GCC 将其编译为书面形式。


我使用了_mm256_broadcast_sd,尽管我真的想要vpbroadcastq,只是因为有一个内部函数接受一个指针操作数而不是__m256i(因为对于AVX1,只有内存源版本存在. 但是对于 AVX2,所有广播指令的寄存器源版本都存在)。要使用_mm256_set1_epi64,我必须编写不违反严格别名(例如使用memcpy)的纯C 来执行未对齐的uint64_t 加载。不过,我认为在当前 CPU 上使用 FP 广播负载不会影响性能。

我希望 _mm256_broadcast_sd 允许它的源操作数在没有 C++ 严格别名未定义行为的情况下为任何东西命名,就像 _mm256_loadu_ps 一样。无论哪种方式,如果它不内联到存储到*src 的函数中,它甚至可以在实践中工作。所以也许 memcpy 未对齐的负载会更有意义!

过去我让编译器从_mm_cvtepu16_epi32( _mm_loadu_si64(ptr) ) 之类的代码发出pmovzxdw xmm0, [mem] 的结果很糟糕;你经常得到一个实际的movq load + reg-reg pmovzx。这就是为什么我没有尝试_mm256_broadcastq_epi64(__m128i)


老想法;如果我们已经需要一个字节洗牌,我们不妨使用纯字移位而不是 vpmultishift。

使用 AVX512VBMI(IceLake、CannonLake),您可能需要vpmultishiftqb。我们可以先将正确的字节放在正确的位置,然后再对整个组向量进行所有工作,而不是一次广播/移动一个组。

您仍然需要/想要具有一些 AVX512 但不具有 AVX512VBMI 的 CPU 版本(例如 Skylake-avx512)。可能vpermd + vpshufb 可以将我们需要的字节放入我们想要的 128 位通道中。

我认为我们无法在 qword 移位后仅使用双字粒度移位来允许合并屏蔽而不是双字混合。不过,我们也许可以合并屏蔽vpblendw,保存vpblendd

IceLake 有 1 个/时钟 vpermwvpermb,单 uop。 (它在另一个端口上有一个第二个洗牌单元,可以处理一些洗牌微指令)。所以我们可以加载一个包含 4 或 8 组 4 个元素的完整向量,并有效地将每个字节打乱到位。我认为每个具有vpermb 的 CPU 都具有单微指令。 (但这只是 Ice Lake 和限量发行的 Cannon Lake)。

vpermt2w(将 2 个向量中的 16 位元素组合成任意顺序)是每 2 个时钟吞吐量之一。 (InstLatx64 for IceLake-Y),所以不幸的是它不如单向量随机播放效率高。

不管怎样,你可以这样使用它:

  • 64 字节/512 位加载(包括最后从 8x 8 字节组而不是 8x 5 字节组的一些过度读取。可选地使用零掩码加载以使其在接近结束时安全阵列感谢故障抑制)
  • vpermb 将包含每个字段的 2 个字节放入所需的最终目标位置。
  • vpsrlvw + vpandq 将每个 10 位字段提取为 16 位字

这大约是 4 微秒,不包括商店。

您可能希望包含A 元素的高半部分用于连续的vextracti64x4,而包含B 和C 元素的低半部分用于vmovdquvextracti128 存储。

或为 2x vpblenddd 设置 256 位存储。 (使用 2 个不同的 vpermb 向量来创建 2 个不同的布局。)

您不应该需要vpermt2wvpermt2d 来组合相邻的向量来扩大商店。

如果没有 AVX512VBMI,vpermd + vpshufb 可能会将所有必要的字节放入每个 128 位块而不是 vpermb。其余的只需要 Skylake-X 拥有的 AVX512BW。

【讨论】:

  • vpermb 效率低的想法从何而来?据我所知,它与任何交叉车道洗牌一样有效:3L1T。如果是 2 微秒,我会感到震惊,这不是 3T1T 东西的模式。你会期望那些是 2L 或 4L 或 6L(即 2 {1,3}L ops 的可能总和)。
  • 你确定 Cascade Lake 有 VBMI 吗?这将是第一个具有 AVX-512 材料的 14 nm 芯片,超出了基本的 SKX 材料,这将使其与众不同。 This diagram 表明它没有:只有 VNNI 的东西被添加到 SKX 基础中。
  • @BeeOnRope:维基百科的表格显示了带有 AVX512VBMI 的 CSL。这可能是错误的。回复:vpermb 昨晚完成更新时我睡着了;你是对的,这显然是我忘记查看的延迟的单uop。我一直认为即使vpermt2w 也很有效,但在检查并发现它不是时,我对一切都过于悲观了。
  • 我认为维基百科的文章是错误的。是的vpermb 是高效的,而且很重要,因为车道交叉字节粒度移位是 1 输入置换的圣杯。 2-input 版本似乎只使用了 1-input permute 两次,再加上一个 blend,所以它们几乎不比自己做更好(我认为它们 稍微好一点,因为它们基本上构建了免费混合面膜,您只需为混合付费。
  • @BeeOnRope:已更新。过去几天这一直困扰着我;广播和每个 qword 只做 16 个有用的比特会降低信息密度,这超出了必要的程度。终于开始输入至少一个更好的方法的摘要,并且正确地洗牌一次,这样你就不需要再做一次了。你也不需要vpmultishiftbq,只需要vpsrlvw。一旦我意识到单个字段不能超过 2 个字节,我应该早点看到这一点。
猜你喜欢
  • 2017-03-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2013-09-10
  • 1970-01-01
相关资源
最近更新 更多