TL:DR:在编译支持lzcnt
的64位机器时,使用uint64_t
移位以实现与uint32_t
的高效配合。对于没有lzcnt
(只有基线bsr
的x86),n==0
情况仍然很特殊。
对于
uint64_t
版本,难点在于最高位的65种不同可能位置,包括不存在的情况(当所有位都为零时
lzcnt
产生64)。但是,在x86上使用64位操作数大小的单个移位只能产生64个不同的值之一(假设输入为常量),因为x86移位像
foo >> (c&63)
这样掩码计数。
使用移位需要特殊处理一个前导位位置,通常是n==0
的情况。正如Harold的回答所示,BMI2 bzhi
避免了这种情况,允许位数从0到64。
对于32位操作数大小的移位操作,它们会掩码c&31。但是为了生成
uint32_t
的掩码,我们可以在x86-64上高效地使用64位移位。(或者对于
uint16_t
和
uint8_t
使用32位移位。有趣的事实是:x86汇编使用8或16位操作数大小进行移位仍然会掩码其计数模32,因此它们可以移出所有位而无需使用更宽的操作数大小。但32位操作数大小是高效的,不需要处理部分寄存器写入。)这种策略甚至比对于小于寄存器宽度的类型使用更有效。
#ifdef __LZCNT__
#include <immintrin.h>
uint32_t flip_32_on_64(uint32_t n)
{
uint64_t mask32 = 0xffffffff;
uint32_t mask = mask32 >> _lzcnt_u32(n);
return n ^ mask;
}
#endif
这同样适用于
uint8_t
和
uint16_t
(使用相同掩码的完全相同代码,在零扩展后对它们使用32位lzcnt)。但是不适用于
uint64_t
(您可以使用
unsigned __int128
移位,但
shrd
模 64 地掩盖其移位计数,因此编译器仍然需要一些条件行为来模拟它。所以最好手动执行cmov或者
sbb same,same
以生成一个在寄存器中作为掩码进行移位的
0
或
-1
。)
Godbolt使用gcc和clang。请注意,将
_lzcnt_u32
替换为
__builtin_clz
是不安全的;clang11及更高版本假定即使将其编译为
lzcnt
指令,也无法产生32,会将移位操作数大小优化为32,这将作为
mask32 >> clz(n) & 31
。
# clang 14 -O3 -march=haswell (or znver1 or bdver4 or other BMI2 CPUs)
flip_32_on_64:
lzcnt eax, edi # skylake fixed the output false-dependency for lzcnt/tzcnt, but not popcnt. Clang doesn't care, it's reckless about false deps except inside a loop in a single function.
mov ecx, 4294967295
shrx rax, rcx, rax
xor eax, edi
ret
如果没有BMI2,例如使用-march=bdver1
或barcelona
(也称为k10),我们得到与shr rax, cl
相同的代码生成。这些CPU仍然具有lzcnt
,否则这将无法编译。
(我想知道英特尔Skylake Pentium / Celeron是否将lzcnt
作为lzcnt
或bsf
运行。它们缺少BMI1 / BMI2,但lzcnt
具有自己的功能标志。看起来最近的Tremont之类的低功耗uarches缺少lzcnt
,但根据 InstLatx64 for a Pentium Silver N6005 Jasper Lake-D,Tremont core 显示。我没有手动查找最近的Pentium / Celeron的原始CPUID转储中的特征位,但Instlat提供了这些信息,如果有人想要检查。)
无论如何,
bzhi
还需要 BMI2,因此如果你对除了
uint64_t
以外的任何大小进行比较,这就是比较。这个
shrx
版本可以在循环中保留其
-1
常量。因此,在内联后,如果编译器有多余的寄存器,
mov reg,-1
可以被提升到循环外。最好的
bzhi
策略不需要掩码常量,因此它无法获得任何优势。对于 64 位机器上的 64 位整数,
_bzhi_u64(~x, 64 - _lzcnt_u64(x))
是 5 个 uops,但其延迟关键路径长度与此相同。(lzcnt/sub/bzhi)。
没有LZCNT指令,一个选项可能是始终进行翻转以获取设置FLAGS的方式,然后使用
-1 << bsr(n)
将其中一些异或回原始状态。这可以减少关键路径延迟。不知道C编译器是否能够发出此命令。特别是如果您想利用真实CPU保持BSR目标不变(如果源为零),但只有AMD记录了这一事实。(英特尔表示这是“未定义”的结果。)
(待办事项:完成这个手写的asm想法。)
其他针对
uint64_t
情况的C语言想法:并行使用
cmov
或
cmp/sbb
(生成
0
或
-1
),以及
lzcnt
以缩短关键路径延迟。请参阅我正在尝试的Godbolt链接。
ARM/AArch64会对其移位计数进行饱和,不像x86标量掩码。如果能够安全地利用这一点(没有C移位计数UB),那将是很好的,可以实现与此相当的效果。
x86 SIMD移位也会饱和它们的计数器,Paul R利用了这一点,使用vlzcnt
和变量移位来回答AVX-512问题。(但是,只有在有多个元素需要处理时才有用;如果只有一个标量移位,则不值得将数据复制到XMM寄存器中再返回。)
注1:带有__builtin_clz
或...ll
的clang代码生成
使用
__builtin_clzll(n)
将会让clang使用64位操作数大小进行移位,因为32到63的值变得可能。但是你不能在没有
lzcnt
的CPU上使用它来编译。一个编译器在没有lzcnt可用时使用的63-
bsr
将不会产生我们需要的
64
。除非你在
bsr
之前做了
n<<=1;
/
n|=1;
或者其他一些调整结果的操作,但这比
cmov
慢。
如果您正在使用64位
lzcnt
,则希望
uint64_t mask = -1ULL
,因为在零扩展为
uint64_t
后会有32个额外的前导零。幸运的是,在所有ISA上实现全1相对较便宜,因此请使用它,而不是
0xffffffff00000000ULL
。
uint8_t
只有256个值,因此您可以使用查找表。 - kaylumx ^ (ones >> lzcnt(x))
的简单解决方案不起作用(如果x = 0
则失败),因此其余部分也很有趣。 - harold