繁体   English   中英

将两位数转换为低内存表示的最快方法

[英]fastest way to convert two-bit number to low-memory representation

我有一个56位数字,可能有两个设置位,例如00000000 00000000 00000000 00000000 00000000 00000000 00000011 换句话说,两个位分布在56位之间,因此我们有bin(56,2)=1540可能的排列。

我现在寻找这样一个56位数字的无损映射到11位数字,可以携带2048,因此也是1540.知道结构,这个11位数字足以存储我的低密度值(的)56位数。

我希望最大化性能(如果可能,此功能应该每秒运行数百万甚至数十亿次)。 到目前为止,我只想出了一些循环:

int inputNumber = 24; // 11000
int bitMask = 1;
int bit1 = 0, bit2 = 0;    
for(int n = 0; n < 54; ++n, bitMask *= 2)
{
    if((inputNumber & bitMask) != 0)
    {
        if(bit1 != 0)
            bit1 = n;
        else
        {
            bit2 = n;
            break;
        }
    }
}

使用这两个位,我可以很容易地生成一些1540最大数。

但是没有比使用这样的循环更快的版本吗?

大多数ISA都具有硬件支持,用于查找设置位的位扫描指令。 对于任何您关心此运行速度快的架构,请使用它而不是天真的循环或bithack。 https://graphics.stanford.edu/~seander/bithacks.html#IntegerLogObvious有一些比没有好的技巧,但这些技巧仍然比单个高效的asm指令更糟糕。

但是ISO C ++不能轻易地暴露clz / ctz操作; 它只能通过intrinsics / builtins用于各种实现。 (并且x86 intrinsincs具有全零输入的怪癖,对应于asm指令行为)。

对于某些ISA,它是一个计数前导零,为您提供31 - highbit_index 对于其他人来说,它是CTZ计数尾随零操作,为您提供低位的索引。 x86都有。 (并且它的高位查找器实际上直接找到高位索引,而不是前导零计数,除非你使用BMI1 lzcnt而不是传统的bsrhttps://en.wikipedia.org/wiki/Find_first_set有一个表不同的ISA有什么。

GCC可以移植提供__builtin_clz__builtin_ctz ; 在没有硬件支持的ISA上,它们编译为对辅助函数的调用。 请参阅在C中以整数查找最高设置位(msb)的最快/最有效方法是什么? __builtin_clz的实施

(对于64位整数,您需要long long版本:如__builtin_ctzll GCC手册 。)

如果我们只有一个CLZ,使用high=63-CLZ(n)low= 63-CLZ((-n) & n)隔离低位 请注意,x86的bsr指令实际上产生63-CLZ() ,即位索引而不是前导零计数。 因此63-__builtin_clzll(n)可以编译为x86上的单个指令; IIRC gcc确实注意到这一点。 如果GCC使用额外的xor-zeroing来避免不方便的错误依赖,则可以使用2条指令。

如果我们只有CTZ,则执行low = CTZ(n)high = CTZ(n & (n - 1))以清除最低设置位。 (保留高位,假设数字恰好有2个设置位)。

如果我们两者都有,则low = CTZ(n)high = 63-CLZ(n) 我不确定GCC在非x86 ISA上做了什么,它们本身不是可用的。 即使面向没有它的硬件,GCC内置也始终可用。 但内部实现不能使用上述技巧,因为它不知道总有2位设置。

(我写出了完整的公式;这个答案的早期版本有CLZ和CTZ在这一部分逆转。我发现这很容易发生在我身上,特别是当我还要跟踪x86的bsrbsr (bitscan反向和前进)时并记住那些分别是领先和尾随。)

因此,如果您只使用CTZ和CLZ,最终可能会对其中一个进行慢速仿真。 或与ARM快速仿真rbit比特反向,用于clz ,这是100%的罚款。

AVX512CD具有64位整数的SIMD VPLZCNTQ ,因此您可以将2,4或8x 64位整数与最近的Intel CPU并行编码。 对于SSSE3或AVX2,您可以使用pshufb _mm_shuffle_epi8 byte-shuffle作为4位LUT并与_mm_max_epu8组合来构建SIMD _mm_max_epu8 最近有关于此的问答,但我找不到。 (它可能仅适用于16位整数;更广泛需要更多工作。)

有了这个,一旦考虑到打包结果的吞吐量成本,Skylake-X或Cascade Lake CPU可能每2或3个时钟周期压缩8x 64位整数。 SIMD对于将12位或11位结果打包成连续的比特流非常有用,例如使用可变移位指令,如果这是您想要对结果进行的操作。 在~3或4GHz时钟速度下,单个线程可能会使每个时钟超过100亿。 但只有输入来自连续的内存。 根据您想要对结果执行的操作,可能需要花费更多周期才能将其打包到16位整数。 例如,打包成比特流。 但SIMD应该适用于具有可变移位指令的SIMD,这些指令可以在混洗后将每个寄存器中的11或12位排列到正确的位置与OR一起排列。


在编码效率和编码性能之间存在折衷。 对于两个6位索引(位位置)使用12位非常简单,无论是压缩还是解压缩,至少在具有位扫描指令的硬件上都是如此。

或者代替比特索引,一个或两个可能导致零计数 ,因此解码将是(1ULL << 63) >> a 1ULL>>63是一个固定常数,你可以实际右移,或者编译器可以将它转换为1ULL << (63-a)的左移,IIRC在程序集中优化为1 << (-a)像x86这样的ISA,其中移位指令屏蔽移位计数(仅查看低6位)。

此外,2x 12位是一个整数字节,但如果你打包它们,11位只能为每8个输出提供一个整数字节。 因此索引一个比特打包的数组更简单。

0仍然是一种特殊情况:可能通过使用全1比特索引来处理(即索引=比特63,其在低56比特之外)。 在解码/解压缩时,设置2位位置(1ULL<<a) | (1ULL<<b) (1ULL<<a) | (1ULL<<b)然后&掩码清除高位。 或偏置您的位索引并将右移解码1。

如果我们不必处理零,那么现代x86 CPU如果不需要做任何其他事情,则每秒可以执行1或20亿次编码。 例如,Skylake对于位扫描指令每时钟吞吐量为1,并且应该能够以每2个时钟1个数字编码,这只是瓶颈。 (或者SIMD可能更好)。 只需4个标量指令,我们就可以获得低和高指数(64位tzcnt + bsr ),移位6位和OR。 1或者在AMD上,避免使用bsr / bsf并手动执行63- lzcnt

input == 0分支或无分支检查将最终结果设置为任何硬编码常量(如63 , 63 )应该很便宜。

像AArch64这样的其他ISA的压缩也很便宜。 它有clz但不是ctz 可能你最好的选择是使用rbit的内在位来反转一个数字(因此位反转数字的clz直接给你低位的位索引。现在是反转版本的高位。)假设rbitadd / sub一样快,这比使用多个指令清除低位更便宜。

如果你真的想要11位,那么你需要避免2x 6位的冗余能够使索引大于另一个。 就像可能有6位a和5位b ,并且a<=b意味着像b+=32那样特殊的东西。 我没有完全想到这一点。 您需要能够在寄存器的顶部或底部附近编码2个相邻的位,或者如果我们考虑在边界处包裹,如56位旋转,则2个设置位可以相距28位。


Melpomene建议隔离低和高设置位可能是有用的一部分,但仅适用于只有一个可用位扫描方向的目标编码,而不是两者。 即便如此,您实际上也不会使用这两种表达方式。 前导零计数不需要您低位隔离 ,您只需要将其清零以获得高位。


脚注1:x86上的解码也很便宜: x |= (1<<a)是1条指令: bts 但是许多编译器错过了优化并且没有注意到这一点,而是实际上转移了1 bts reg, reg是自PPro以来英特尔的1 uop / 1周期延迟,有时是AMD的2 uop。 (只有内存目标版本很慢。) https://agner.org/optimize/


AMD CPU上的最佳编码性能需要BMI1 tzcnt / lzcnt因为bsrbsf较慢(6 uops而不是1 https://agner.org/optimize/ )。 在Ryzen上, lzcnt是1 lzcnt ,1c延迟,每时钟吞吐量4。 但是tzcnt是2 tzcnt

使用BMI1,编译器可以使用blsr清除寄存器的最低设置位(并复制它)。 即,现代x86具有dst = (SRC-1) bitwiseAND ( SRC );的指令dst = (SRC-1) bitwiseAND ( SRC ); 这是英特尔的单一uop,AMD上的2 uop。

但随着lzcnt比更有效tzcnt上AMD Ryzen,可能是AMD最好的ASM不使用它。

或者类似这样的事情(假设恰好是2位,显然我们可以做到)。

这个asm是你希望你的编译器发出的。不要实际使用inline asm!

Ryzen_encode_scalar:    ; input in RDI, output in EAX
   lzcnt    rcx, rdi       ; 63-high bit index
   tzcnt    rdx, rdi       ; low bit

   mov      eax, 63
   sub      eax, ecx

   shl      edx, 6
   or       eax, edx       ; (low_bit << 6) | high_bit

   ret                     ; goes away with inlining.

如果高位需要63-CLZ 移位低位索引可以平衡关键路径的长度,从而实现更好的指令级并行性

吞吐量:总计7个uop,没有执行单元瓶颈。 因此,每个时钟流水线宽度为5微秒,优于每2个时钟1个。

Skylake_encode_scalar:    ; input in RDI, output in EAX
   tzcnt    rax, rdi       ; low bit.  No false dependency on Skylake.  GCC will probably xor-zero RAX because there is on Broadwell and earlier.
   bsr      rdi, rdi       ; high bit index.  same,same reg avoids false dep
   shl      eax, 6
   or       eax, edx

   ret                     ; goes away with inlining.

从输入到输出,这有5个周期延迟:比特扫描指令在Intel上为3个周期,在AMD上为1个周期。 SHL + OR每增加1个循环。

对于吞吐量,我们每个周期(执行端口1)只有一个位扫描瓶颈,因此我们可以每2个周期进行一次编码,剩余4 uop的前端带宽用于加载,存储和循环开销(或其他内容) ),假设我们有多个独立的编码要做。

(但对于多个独立编码情况,如果存在vplzcntq的廉价仿真并且数据来自内存,则SIMD对于AMD和Intel可能仍然会更好。)

标量解码可以是这样的:

decode:    ;; input in EDI, output in RAX
    xor   eax, eax           ; RAX=0
    bts   rax, rdi           ; RAX |= 1ULL << (high_bit_idx & 63)

    shr   edi, 6             ; extract low_bit_idx
    bts   rax, rdi           ; RAX |= 1ULL << low_bit_idx

    ret

这有3个班次(包括bts ),Skylake只能在port0或port6上运行。 因此在英特尔上,前端只需要4 uop(因此,每个时钟需要1个作为其他功能的一部分)。 但是,如果这样做,它会在每1.5个时钟周期1个解码时产生移位吞吐量的瓶颈。

在一个4GHz的CPU上,这是每秒26.66亿个解码,所以是的,我们做得非常好,击中你的目标:)

或Ryzen, bts reg,reg是2 uops,吞吐量为0.5c,但shr可以在任何端口上运行。 因此它不会从bts窃取吞吐量,整个事情是6 uops(相对于Ryzen的管道在最窄点处是5宽)。 因此,每1.2个时钟周期进行1次编码,这只是前端成本的瓶颈。


在BMI2可用的情况下,从寄存器中的1开始并使用shlx rax, rbx, rdi可以用shlx rax, rbx, rdi替换xor- shlx rax, rbx, rdi +第一个BTS,假设寄存器中的1可以在循环中重用。

(此优化完全取决于您的编译器要查找;无标记移位只是更有效的复制和移位方式,可通过-march=haswell-march=znver1或其他具有BMI2的目标获得。)

无论哪种方式,您只需要写入retval = 1ULL << (packed & 63)来解码第一位。 但是,如果你想知道哪些编译器在这里制作了很好的代码,那么这就是你要找的东西。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM