[英]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
而不是传统的bsr
) https://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的bsr
和bsr
(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
直接给你低位的位索引。现在是反转版本的高位。)假设rbit
与add
/ 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
因为bsr
和bsf
较慢(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.