本讨论假定您的编译器不支持该操作,或者它不能产生足够好的汇编。请注意,现在这两种情况都不太可能,因此我建议您仅在编译器上使用 __builtin_clz 用于 gcc 或等效项。
请注意,确定哪个是“最佳”clz 算法只能由您自己完成。现代处理器是复杂的野兽,这些算法的性能在很大程度上取决于你运行它的平台、你扔给它的数据以及使用它的代码。唯一确定的方法是测量,测量和测量更多。如果您无法区分,那么您可能没有关注您的瓶颈,您的时间将更好地花在其他地方。
现在无聊的免责声明已经结束,让我们看看Hacker's Delight 对这个问题有什么看法。一项快速调查表明,所有算法都依赖于对某些描述的二分搜索。这是一个简单的例子:
int n = 32;
unsigned y;
y = x >>16; if (y != 0) { n = n -16; x = y; }
y = x >> 8; if (y != 0) { n = n - 8; x = y; }
y = x >> 4; if (y != 0) { n = n - 4; x = y; }
y = x >> 2; if (y != 0) { n = n - 2; x = y; }
y = x >> 1; if (y != 0) return n - 2;
return n - x;
请注意,这适用于 32 个整数,如果需要,它也可以转换为迭代版本。不幸的是,该解决方案没有大量的指令级并行性,并且有相当多的分支,这并不能构成一个非常好的旋转算法。请注意,上面的代码存在一个无分支版本,但它更冗长,所以我不会在这里重现。
所以让我们通过使用 pop 指令(计算位数)来改进解决方案:
x = x | (x >> 1);
x = x | (x >> 2);
x = x | (x >> 4);
x = x | (x >> 8);
x = x | (x >>16);
return pop(~x);
那么这是如何工作的呢?关键是末尾的pop(~x) 指令,它计算x 中零的数量。为了使零的计数有意义,我们首先需要去掉所有不领先的 0。我们通过使用二进制算法正确传播 1 来做到这一点。虽然我们仍然没有太多的指令级并行性,但我们确实摆脱了所有分支,并且它使用的周期比之前的解决方案更少。好多了。
那么那个弹出指令怎么样,这不是作弊吗?大多数架构都有一个 1 周期的弹出指令,可以通过编译器内置指令(例如 gcc 的 __builtin_pop)访问。否则存在基于表的解决方案,但在权衡缓存访问周期时必须小心,即使表完全保存在 L1 缓存中。
最后,像通常的黑客一样,我们开始在陌生的领域徘徊。让我们用浮点数计算一些前导零:
union {
unsigned asInt[2];
double asDouble;
};
asDouble = (double)k + 0.5;
return 1054 - (asInt[LE] >> 20);
首先,一点警告:不要使用此算法。就标准而言,这会触发未定义的行为。这是为了有趣的因素而复制的,而不是任何实际用途。使用后果自负。
现在免责声明已经不存在了,它是如何工作的?它首先将 int 转换为 double 并继续提取 double 的指数分量。整洁的东西。如果在 little-endian 机器上执行 LE 常量应该是 1,在 big-endian 机器上执行应该是 0。
这应该让您简要了解解决此问题的各种位旋转算法。请注意,这本书有几种变体,它们会做出各种权衡,但我会让你自己发现这些。