【问题标题】:Convert 16 bits mask to 16 bytes mask将 16 位掩码转换为 16 字节掩码
【发布时间】:2021-07-15 23:31:40
【问题描述】:

有什么办法可以转换下面的代码:

int mask16 = 0b1010101010101010; // int or short, signed or unsigned, it does not matter

__uint128_t mask128 = ((__uint128_t)0x0100010001000100 << 64) | 0x0100010001000100;

所以更清楚的是:

int mask16 = 0b1010101010101010; 
__uint128_t mask128 = intrinsic_bits_to_bytes(mask16);

或直接应用掩码:

int mask16 = 0b1010101010101010; 
__uint128_t v = ((__uint128_t)0x2828282828282828 << 64) | 0x2828282828282828;
__uint128_t w = intrinsic_bits_to_bytes_mask(v, mask16); // w = ((__uint128_t)0x2928292829282928 << 64) | 0x2928292829282928;

【问题讨论】:

标签: c++ c bit-manipulation sse intrinsics


【解决方案1】:

位/字节顺序:除非另有说明,否则这些都跟在问题后面,将 uint16_t 的 LSB 放在 __uint128_t 的最低有效字节(little-endian x86 上的最低内存地址) )。例如,对于位图的 ASCII 转储,这是您想要的,但它与单个 16 位数字的 base-2 表示形式的位值打印顺序相反。

关于有效地将值(返回)到 RDX:RAX 整数寄存器的讨论与大多数正常用例无关,因为您只需从向量寄存器存储到内存中,无论是 0/1 字节整数或 ASCII '0'/'1' 数字(在 __m128i 中没有 0/1 整数,更不用说在 unsigned __int128 中了)。

目录:

  • SSE2 / SSSE3 版本:如果您想要向量中的结果,例如用于存储 char 数组。
    SSE2 NASM version,改组为 MSB 优先打印顺序并转换为 ASCII。)
  • BMI2 pdep:如果您要在标量寄存器中使用结果,则适用于带有 BMI2 的 Intel CPU 上的标量 unsigned __int128。 AMD 速度慢。
  • 带有乘法 bithack 的纯 C++:对于标量来说非常合理
  • AVX-512:AVX-512 使用标量位图将屏蔽作为一级操作。如果您将结果用作标量半数,则可能不如 BMI2 pdep,否则甚至比 SSSE3 更好。
  • AVX2 打印顺序(MSB 在最低地址) 32 位整数转储。
  • 另请参阅is there an inverse instruction to the movemask instruction in intel avx2?,了解元素大小和掩码宽度的其他变化。 (SSE2 和 multiply bithack 改编自该集合链接的答案。)

使用 SSE2(最好是 SSSE3)

查看@aqrit 的How to efficiently convert an 8-bit bitmap to array of 0/1 integers with x86 SIMD 答案

调整它以使用 16 位 -> 16 字节,我们需要一个 shuffle 将掩码的第一个字节复制到向量的前 8 个字节,并将第二个掩码字节复制到高 8 个向量字节。这可以通过一个 SSSE3 pshufbpunpcklbw same,same + punpcklwd same,same + punpckldq same,same 最终复制最多两个 64 位 qwords。

typedef unsigned __int128  u128;

u128 mask_to_u128_SSSE3(unsigned bitmap)
{
    const __m128i shuffle = _mm_setr_epi32(0,0, 0x01010101, 0x01010101);
    __m128i v = _mm_shuffle_epi8(_mm_cvtsi32_si128(bitmap), shuffle);  // SSSE3 pshufb

    const __m128i bitselect = _mm_setr_epi8(
        1, 1<<1, 1<<2, 1<<3, 1<<4, 1<<5, 1<<6, 1U<<7,
        1, 1<<1, 1<<2, 1<<3, 1<<4, 1<<5, 1<<6, 1U<<7 );
    v = _mm_and_si128(v, bitselect);
    v = _mm_min_epu8(v, _mm_set1_epi8(1));       // non-zero -> 1  :  0 -> 0
    // return v;   // if you want a SIMD vector result

    alignas(16) u128 tmp;
    _mm_store_si128((__m128i*)&tmp, v);
    return tmp;   // optimizes to movq / pextrq (with SSE4)
}

(要获得 0 / 0xFF 而不是 0 / 1,请将 _mm_min_epu8 替换为 v= _mm_cmpeq_epi8(v, bitselect)如果您想要一个 ASCII 字符串 '0' / '1' 字符,请执行 cmpeq 和_mm_sub_epi8(_mm_set1_epi8('0'), v)。这样就避免了 set1(1) 向量常数。)

Godbolt 包括测试用例。 (对于这个版本和其他非 AVX-512 版本。)

# clang -O3 for Skylake
mask_to_u128_SSSE3(unsigned int):
        vmovd   xmm0, edi                                  # _mm_cvtsi32_si128
        vpshufb xmm0, xmm0, xmmword ptr [rip + .LCPI2_0] # xmm0 = xmm0[0,0,0,0,0,0,0,0,1,1,1,1,1,1,1,1]
        vpand   xmm0, xmm0, xmmword ptr [rip + .LCPI2_1]    # 1<<0, 1<<1, etc.
        vpminub xmm0, xmm0, xmmword ptr [rip + .LCPI2_2]    # set1_epi8(1)

  # done here if you return __m128i v or store the u128 to memory
        vmovq   rax, xmm0
        vpextrq rdx, xmm0, 1
        ret

BMI2 pdep:在 Intel 上好,在 AMD 上不好

BMI2 pdep 在拥有它的 Intel CPU 上速度很快(自 Haswell 起),但在 AMD 上却非常慢(超过 12 微指令,高延迟。)

typedef unsigned __int128  u128;
inline u128 assemble_halves(uint64_t lo, uint64_t hi) {
    return ((u128)hi << 64) | lo; }
// could replace this with __m128i using _mm_set_epi64x(hi, lo) to see how that compiles

#ifdef __BMI2__
#include <immintrin.h>
auto mask_to_u128_bmi2(unsigned bitmap) {
    // fast on Intel, slow on AMD
    uint64_t tobytes = 0x0101010101010101ULL;
    uint64_t lo = _pdep_u64(bitmap, tobytes);
    uint64_t hi = _pdep_u64(bitmap>>8, tobytes);
    return assemble_halves(lo, hi);
}

如果您希望结果在标量寄存器(不是一个向量)中很好,否则可能更喜欢 SSSE3 方式。

# clang -O3
mask_to_u128_bmi2(unsigned int):
        movabs  rcx, 72340172838076673    # 0x0101010101010101
        pdep    rax, rdi, rcx
        shr     edi, 8
        pdep    rdx, rdi, rcx
        ret
      # returns in RDX:RAX

带有魔法乘法位黑客的便携式 C++

在 x86-64 上还不错;自 Zen 以来的 AMD 拥有快速的 64 位乘法,而英特尔自 Nehalem 以来就有。一些低功耗的 CPU 还是比较慢imul r64, r64

此版本可能最适合__uint128_t 结果,至少对于没有 BMI2 的 Intel 和 AMD 的延迟而言,因为它避免了到 XMM 寄存器的往返。但是对于吞吐量,它是相当多的指令

请参阅@phuclv 在How to create a byte out of 8 bool values (and vice versa)? 上的回答,了解乘法和相反方向的解释。对 mask 的每个 8 位一半使用一次来自 unpack8bools 的算法。

//#include <endian.h>     // glibc / BSD
auto mask_to_u128_magic_mul(uint32_t bitmap) {
    //uint64_t MAGIC = htobe64(0x0102040810204080ULL); // For MSB-first printing order in a char array after memcpy.  0x8040201008040201ULL on little-endian.
    uint64_t MAGIC = 0x0102040810204080ULL;    // LSB -> LSB of the u128, regardless of memory order
    uint64_t MASK  = 0x0101010101010101ULL;
    uint64_t lo = ((MAGIC*(uint8_t)bitmap) ) >> 7;
    uint64_t hi = ((MAGIC*(bitmap>>8)) ) >> 7;

    return assemble_halves(lo & MASK, hi & MASK);
}

如果您要使用memcpy__uint128_t 存储到内存中,您可能希望通过使用htole64(0x0102040810204080ULL);(来自GNU / BSD <endian.h>)或等效于始终映射输入的低位来控制主机字节序到输出的最低字节,即到charbool 数组的第一个元素。或htobe64 用于其他订单,例如用于打印。在常量而不是变量数据上使用该函数允许在编译时进行常量传播。

否则,如果你真的想要一个低位匹配 u16 输入的低位的 128 位整数,则乘数常数与主机字节序无关;没有对更广泛类型的字节访问。

clang 12.0 -O3 用于 x86-64:

mask_to_u128_magic_mul(unsigned int):
        movzx   eax, dil
        movabs  rdx, 72624976668147840   # 0x0102040810204080
        imul    rax, rdx
        shr     rax, 7
        shr     edi, 8
        imul    rdx, rdi
        shr     rdx, 7
        movabs  rcx, 72340172838076673   # 0x0101010101010101
        and     rax, rcx
        and     rdx, rcx
        ret

AVX-512

使用 AVX-512BW 很容易;您可以将掩码用于来自重复的 0x01 常量的零掩码负载。

__m128i bits_to_bytes_avx512bw(unsigned mask16) {
    return _mm_maskz_mov_epi8(mask16, _mm_set1_epi8(1));

//    alignas(16) unsigned __int128 tmp;
//    _mm_store_si128((__m128i*)&u128, v);  // should optimize into vmovq / vpextrq
//    return tmp;
}

或者避免使用内存常量(因为编译器可以做set1(-1)with just a vpcmpeqd xmm0,xmm0):做一个零掩码的绝对值-1。常量设置可以提升,与 set1(1) 相同。

__m128i bits_to_bytes_avx512bw_noconst(unsigned mask16) {
    __m128i ones = _mm_set1_epi8(-1);    // extra instruction *off* the critical path
    return _mm_maskz_abs_epi8(mask16, ones);
}

但请注意,如果做进一步的向量操作,maskz_mov 的结果可能能够优化到其他操作中。例如 vec += maskz_mov 可以优化为合并屏蔽添加。但如果没有,vmovdqu8 xmm{k}{z}, xmm 需要像 vpabsb xmm{k}{z}, xmm 这样的 ALU 端口,但 vpabsb 不能在 Skylake/Ice Lake 的端口 5 上运行。 (来自零寄存器的零掩码vpsubb 可以避免可能的吞吐量问题,但是您将设置2 个寄存器只是为了避免加载常量。在手写asm 中,您只需实现set1(1)如果您想避免常量的 4 字节广播加载,请自行使用 vpcmpeqd / vpabsb。)

Godbolt compiler explorer 与 gcc 和 clang -O3 -march=skylake-avx512。Clang 看穿了被屏蔽的 vpabsb 并像第一个版本一样编译它,带有一个内存常量。)

如果您可以使用向量 0 / -1 而不是 0 / 1,那就更好了:使用 return _mm_movm_epi8(mask16)。编译为 kmovd k0, edi / vpmovm2b xmm0, k0

如果您想要ASCII 字符向量,例如'0''1',您可以使用_mm_mask_blend_epi8(mask, ones, zeroes)。 (这应该比合并屏蔽添加到set1(1) 的向量中更有效,这需要额外的寄存器副本,也比set1('0')_mm_movm_epi8(mask16) 之间需要两条指令的sub 更好:一个转掩码成一个向量,和一个单独的 vpsubb。)


AVX2 位按 打印 顺序(MSB 在最低地址),字节按内存顺序,ASCII '0' / '1'

使用[] 定界符和\t 制表符这样的输出格式,来自this codereview Q&A

[01000000]      [01000010]      [00001111]      [00000000]

显然,如果您希望所有 16 位或 32 位 ASCII 数字都是连续的,那会更容易,并且不需要打乱输出以分别存储每个 8 字节块。在这里发帖的主要原因是它具有正确的打印顺序的 shuffle 和 mask 常量,并在结果证明这是问题真正想要的之后显示针对 ASCII 输出优化的版本。

使用How to perform the inverse of _mm256_movemask_epi8 (VPMOVMSKB)?,基本上是256位版本的SSSE3代码。

#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <immintrin.h>
#include <string.h>

// https://stackoverflow.com/questions/21622212/how-to-perform-the-inverse-of-mm256-movemask-epi8-vpmovmskb
void binary_dump_4B_avx2(const void *input)
{
    char buf[CHAR_BIT*4 + 2*4 + 3 + 1 + 1];  // bits, 4x [], 3x \t, \n, 0
    buf[0] = '[';
    for (int i=9 ; i<sizeof(buf) - 8; i+=11){ // GCC strangely doesn't unroll this loop
        memcpy(&buf[i], "]\t[", 4);       // 4-byte store as a single; we overlap the 0 later
    }
    __m256i  v = _mm256_castps_si256(_mm256_broadcast_ss(input));         // aliasing-safe load; use _mm256_set1_epi32 if you know you have an int
    const __m256i shuffle = _mm256_setr_epi64x(0x0000000000000000,        // low byte first, bytes in little-endian memory order
      0x0101010101010101, 0x0202020202020202, 0x0303030303030303);
    v =  _mm256_shuffle_epi8(v, shuffle);

//    __m256i bit_mask = _mm256_set1_epi64x(0x8040201008040201);    // low bits to low bytes
    __m256i bit_mask = _mm256_set1_epi64x(0x0102040810204080);      // MSB to lowest byte; printing order

    v = _mm256_and_si256(v, bit_mask);               // x & mask == mask
//    v = _mm256_cmpeq_epi8(v, _mm256_setzero_si256());       // -1  /  0  bytes
//    v = _mm256_add_epi8(v, _mm256_set1_epi8('1'));          // '0' / '1' bytes

    v = _mm256_cmpeq_epi8(v, bit_mask);              // 0 / -1  bytes
    v = _mm256_sub_epi8(_mm256_set1_epi8('0'), v);   // '0' / '1' bytes
    __m128i lo = _mm256_castsi256_si128(v);
    _mm_storeu_si64(buf+1, lo);
    _mm_storeh_pi((__m64*)&buf[1+8+3], _mm_castsi128_ps(lo));

    // TODO?: shuffle first and last bytes into the high lane initially to allow 16-byte vextracti128 stores, with later stores overlapping to replace garbage.
    __m128i hi = _mm256_extracti128_si256(v, 1);
    _mm_storeu_si64(buf+1+11*2, hi);
    _mm_storeh_pi((__m64*)&buf[1+11*3], _mm_castsi128_ps(hi));
//    buf[32 + 2*4 + 3] = '\n';
//    buf[32 + 2*4 + 3 + 1] = '\0';
//    fputs
    memcpy(&buf[32 + 2*4 + 2], "]", 2);  // including '\0'
    puts(buf);                           // appends a newline
     // appending our own newline and using fputs or fwrite is probably more efficient.
}

void binary_dump(const void *input, size_t bytecount) {
}
 // not shown: portable version, see Godbolt, or my or @chux's answer on the codereview question


int main(void)
{
    int t = 1000000;
    binary_dump_4B_avx2(&t);
    binary_dump(&t, sizeof(t));
    t++;
    binary_dump_4B_avx2(&t);
    binary_dump(&t, sizeof(t));
}

Runnable Godbolt demogcc -O3 -march=haswell

请注意,GCC10.3 和更早版本是哑的,并且复制 AND/CMPEQ 向量常量,一次作为字节,一次作为 qwords。 (在这种情况下,与零比较会更好,或者使用带有反转掩码的 OR 并与全一比较)。 GCC11.1 使用.set .LC1,.LC2 修复了该问题,但仍将其加载两次,作为内存操作数,而不是将一次加载到寄存器中。 Clang 没有这些问题。

有趣的事实:clang -march=icelake-client 设法将其第二部分转换为 '0''1' 向量之间的 AVX-512 掩码混合,但不仅仅是 kmov 它使用广播负载,@ 987654410@ 字节洗牌,然后用位掩码测试到掩码。

【讨论】:

  • 想知道这与使用 byte to bool array 之类的东西相比如何。即godbolt
  • @Noah:OP 显然 想要 __int128 而不是 __m128i,因此如果我们希望返回整数 regs 中的结果,则延迟会更好。 godbolt.org/z/qf9G4o59q。 (但更多说明:请注意,您错过了在 lo 乘法之前隔离 mask 的低字节,并且忘记了对乘法结果进行屏蔽和移位。)
  • @Noah:更新了已解决的示例,因为我已经在 Godbolt 上对它们进行了测试。
  • @AntoninGAVREL:整个操作的成本并不比movq/pextrq(或movq+punpckhqdq/movq)多多少。而且,如果您最终想要 '0''1' 数字的 base-2 ASCII 字符串,则从 from set1('0') 中减去 pcmpeqb 结果是有效的,并且无需使用向量常量之一在 SSSE3 版本中。
  • @AntoninGAVREL,不,_mm_cmpeq_epi 产生 0 / -1 结果。不是屏蔽或以其他方式将其转换为 0 / 1 并添加,而是根据比较结果有条件地递增的常用技巧是 _mm_sub_epi8(x, _mm_cmpeq_epi8(y,z)) 我还在我的答案编辑的新段落中提到了这一点。
【解决方案2】:

如果你可以使用AVX512,你可以在一条指令中完成,没有循环:

#include <immintrin.h>

__m128i intrinsic_bits_to_bytes(uint16_t mask16) {
    const __m128i zeroes = _mm_setzero_si128();
    const __m128i ones = _mm_set1_epi8(1);;
    return _mm_mask_blend_epi8(mask16, ones, zeroes);
}

为了使用 gcc 构建,我使用:

g++ -std=c++11 -march=native -O3 src.cpp -pthread

这可以构建,但如果您的处理器不支持 AVX512,它将在运行时抛出 illegal instruction 时间。

【讨论】:

  • 嗨弗拉德,这很有趣!你能添加编译说明吗?使用 gcc 我正在寻找一种没有循环的方法
  • @AntoninGAVREL - 请查看我编辑的答案。
  • 不要使用static const __m128i,它编译的asm (godbolt.org/z/WYYjecnEE) 比普通的const __m128i 更糟糕。让编译器处理它,就像处理字符串文字和 FP 常量一样。请参阅godbolt.org/z/ofTvxfnT9(也使用零掩码_mm_maskz_mov_epi8 加载 1 或零,而不是混合。)
  • 发布了我的改进版本和非 AVX-512 版本的答案。
  • @VladFeinstein:我建议至少编辑您的答案以删除常量上的积极有害的static。之后就很好了,clang 可能仍会将其优化为您真正想要的,甚至使用set1('0') 或其他东西不断传播到添加中。
【解决方案3】:

对于掩码中的每一位,你想将位置n的一个位移动到位置n的字节的低位,即位位置8 * n。你可以通过循环来做到这一点:

__uint128_t intrinsic_bits_to_bytes(uint16_t mask)
{
    int i;
    __uint128_t result = 0;

    for (i=0; i<16; i++) {
        result |= (__uint128_t )((mask >> i) & 1) << (8 * i);
    }
    return result;
}

【讨论】:

  • 嗨 dbush,我已经用 while (--i &gt;= 0) result |= (__uint128_t )((mask &gt;&gt; i) &amp; 1) &lt;&lt; (i &lt;&lt; 3); 创建了这个函数,我从 16 开始,但可能会得到与你的答案相同的程序集输出。我将等待 Vlad 更新或其他答案,然后再选择是否无法避免循环,仍然支持,非常感谢!
猜你喜欢
  • 2017-03-16
  • 1970-01-01
  • 2010-12-02
  • 2020-12-22
  • 1970-01-01
  • 2022-10-18
  • 1970-01-01
  • 1970-01-01
  • 2012-09-07
相关资源
最近更新 更多