在位范围内查找第一个设置位的位置

Posted

技术标签:

【中文标题】在位范围内查找第一个设置位的位置【英文标题】:Finding the position of the first set bit within a range of bits 【发布时间】:2020-02-21 09:13:52 【问题描述】:

在我的代码中,我发现处理器大部分时间都花在了下面显示的函数上。循环的目的是找出满足循环内部条件的 val1 值。变量 Val1 和 a 的类型为 long long int(64 位)。而且,它们是在函数内部声明的局部非静态变量。

long long int findval(long long int x)


  long long int Val1,a=x;

  for (Val1 = 63; Val1 > 22; Val1--) 
  
        if (((a >> Val1) & 1) == 1) 
            break;
  

  return Val1;

还有其他简单/优化的方法来找出 Val1 值吗?

【问题讨论】:

可能有一个内在函数可以为您完成这项工作(找到最高设置位)。如果它 Val1 = __builtin_ctzll(a) 查找 64 位值的 最低 设置位。 您是要查找设置的最高有效位的位置,还是只想查看数据中是否设置了任何位?这是两种不同的算法。 好的,这段代码找到了最左边的位集,所以它是错误的。此外,使用有符号类型没有多大意义,因为如果你得到负数,MSB 将始终是符号位。 我们通常从最低有效位 = first 开始计数,而像 POSIX ffs 这样的函数名称(查找第一个集合 = 计算最低有效零)反映了这一点。您的标题与您在最后一条评论中所说的内容相反。您需要 GNU C __builtin_clzll,或非 GNU 编译器的等效项。不幸的是,C仍然没有费心提供一种可移植的方式来利用各种ISA中的硬件支持(Rust有前导/尾随零和popcnt,作为整数类型的基本操作......)或者你很幸运识别循环的编译器。 一个更有趣的问题可能是为什么您必须如此频繁地调用该函数。也许优化不在函数中,而是在利用您的(全局)算法中关于 x 的知识。 【参考方案1】:

出于某种原因,我认为这在某些时候被标记为 x86 和/或 x86-64。我的 GNU C 答案适用于任何 ISA,但我专注于 MSVC 的 x86 特定内在函数,以及它如何使用 GCC/clang 为 x86 编译。不幸的是,没有一种完全可移植的方式来有效地执行此操作,因此绝对值得做一些#ifdef 以利用硬件支持对您关心的目标进行此操作。


您似乎想要max(22, 63 - clz(x)),其中clz 是一些count-leading-zeros 函数。例如在 GNU C 中,__builtin_clzll()。 63-clz(x) 是 MSB 的位置,当 long long = int64_t 就像在 x86 上一样。

您的Val1 > 22 循环条件在Val1 = 22 处变为假,因此如果到那时还没有找到设置位,这就是非break 退出循环的方式。

__builtin_clzll 在其输入为零 (so it can compile to 63 - a bsr instruction on x86) 时具有未定义的行为。 我们可以通过在运行位扫描之前在输入中设置该位来处理这个 22 的下限。

#include <limits.h>
inline
int MSB_position_clamped (long long x)

    int maxpos = CHAR_BIT * sizeof(x) - 1;
    x |= 1LL << 22;              // avoid x==0 UB and make clz at least 22
    return maxpos - __builtin_clzll(x);

对于 MSVC,您需要 _BitScanReverse64(在 AMD 上较慢)或 63 - _mm_lzcnt_u64(需要 BMI1)。 _mm 内在版本适用于所有 x86-64 编译器。

(正如 Mike 指出的,移位计数只需为 int。更宽的移位计数没有帮助,尤其是在为 long long 需要 2 个寄存器的 32 位机器编译时)。

这对于 x86-64 编译效率很高,尤其是使用 clang (Godbolt)。我们还希望它能够有效地内联到这 2 条指令。

# clang 9.0 -O3 for x86-64 System V
MSB_position_clamped:
        or      rdi, 4194304
        bsr     rax, rdi
        ret

(x86 传统位扫描指令直接查找位位置,如您所愿。BMI1 lzcnt 在 AMD 上更快,但实际上计算前导零,因此您确实需要从类型宽度中减去它。即使在 GCC使用 BSR,它无法将 63 - clz 优化回只是 BSR;它翻转了两次。)


请注意,即使唯一的有效位较低,负 2 的补码整数也会设置其 MSB。你确定你想要一个签名类型吗?

如果是这样,您确定不想要 GNU C __builtin_clrsbll? (返回 x 中的前导冗余符号位的数量,即与它相同的最高有效位之后的位数)没有单一的 x86 指令,但我认为它可以通过对~x 进行位扫描并以某种方式组合。

另外,如果您的原始代码旨在完全移植到所有 ISO C 实现,我不确定是否可以保证符号位移动到较低位位置。我不希望它用于符号/幅度 C 实现的符号右移。 (ISO C 将有符号整数类型的右移是逻辑的还是算术的,由实现决定;理智/优质的实现选择算术。使用 2 的补码整数,您的代码可以以任何一种方式工作;您不在乎它是否在零中移动或符号位的副本。)


许多 CPU(不仅仅是 x86)都有 bit-scan instructions 可以在一条硬件指令中执行此操作,但 AFAIK 无法编写 可移植 C 来编译成这样一条指令。 ISO C 没有费心添加可以在存在此类指令时使用的标准函数。 所以唯一的好选择是编译器特定的扩展。(一些编译器确实可以识别 popcount 循环,但是您的循环停止在 22 而不是 0,它不太可能适合 CLZ 识别模式(如果有的话)编译器甚至会寻找它。)有些语言在这方面比 C 更好,尤其是 Rust 具有设计精良的整数原语,包括位扫描。

GNU C __builtin_clzll() 在具有硬件指令的 ISA 上编译为硬件指令,如果没有,则回退到调用库函数。 (IDK 回退的效率如何;它可能一次使用一个字节或半字节 LUT,而不是简单的移位。)

在 32 位 x86 上,__builtin_clzll 在低半部分和高半部分使用 bsr,并将结果与​​ cmov 或分支组合。 _BitScanReverse64_mm_lzcnt_u64 之类的纯内在函数在 32 位模式下不可用,因此如果您使用内在函数而不是 GNU C“可移植”内置函数,则必须自己执行此操作。

32 位代码不如 64 位代码好,但它仍然是非循环的。 (而且你的循环变得非常低效;GCC 不会“考虑”在低 32 位之前在单独的循环中尝试高 32 位,所以它必须 shrd / sar 然后 cmov 基于移位计数高于 32 的位测试。(Godbolt)。Clang 仍然完全展开,并且确实利用了仅测试相关数字的一半。


由于您标记了此 SIMD,x86 AVX512CD 实际上在一个向量寄存器中的 2、4 或 8x int64_t 元素上有一条 lzcnt 指令:vplzcntq。内在是__m512i _mm512_lzcnt_epi64(__m512i a);

所有支持任何 AVX512 的真实 CPU 都有 AVX512CD。

在 Skylake-X 和 Ice Lake 上,它解码为具有 4 个周期延迟和 0.5 个时钟吞吐量的单个 uop。 (https://uops.info/)。 (看起来它与 FMA/mul/add FP 指令在相同的端口上运行,可能使用相同的硬件来规范浮点尾数,该操作也需要找到 MSB。)

因此,希望 GCC 和 clang 可以在您使用 -march=skylake-avx512 或在此类机器上使用 -march=native 编译时自动矢量化使用 __builtin_clzll 的代码。

【讨论】:

@EricPostpischil:谢谢,已修复。我有一个 21 嵌入在数量惊人的地方。愚蠢地包括函数名;花了一段时间来修复>. 【参考方案2】:

首先要记住,仅仅因为你发现处理器大部分时间都花在那个function sn-p上,并不意味着处理器有问题sn-p。也许您应该尝试找出为什么您的代码如此频繁地调用该 sn-p。

其次,既然您来这里寻求帮助,您不妨向我们展示您所拥有的一切,而不是您认为应该足以让我们找出问题所在的一部分。最重要的是,您确实应该向我们展示您的变量是如何声明的,以及它们声明的确切位置。它们是功能本地的吗?他们是static 吗?会不会是你声明了volatile?没有什么是无关紧要的,一切都很重要。

无论如何,如果我们假设 sn-p 可以优化,那么我会说:

你的Val1应该long long int,因为它的值只在23到63之间。所以,它应该是@987654326 @ 反而。

(如果出于某种原因 Val1 必须 计算为 long long int,然后尝试将其转换为另一个类型为 int 的变量之前循环,并在循环中使用该变量。)

如果您尝试这样做,那么编译器可能会发现您正在尝试做的是在位范围内找到第一个非零位,并用单个机器指令替换整个循环。

【讨论】:

Val1 的类型并不重要,因为移位的右操作数不参与整数提升。无论声明的类型如何,编译器都应该能够将其优化为 8 位类型。 @Lundin 是的,但是众所周知,编译器有一些怪癖(如果你愿意,也可以是错误),其中简单地使用错误的数据类型可能会阻止优化启动并将一段代码替换为内在的。 是的,尽管在 gcc x86 上,OP 的代码会导致 mov eax, 63,因此尽管很长很长,但它仍在 32 位上工作。 clang 也一样,只是它展开了整个循环。 @Lundin:这并不意味着什么。 mov eax,63 是将一个小常数放入 64 位寄存器的方法。知道x86-64 implicit zero-extension when writing a 32-bit register 的编译器基本上与知道在它不是编译时常量的情况下他们可以实际优化为更窄的类型是分开的。在 64 位 ISA 上我不会担心它,最坏的情况是 x86-64 上的操作数大小有一些额外的 REX 前缀,但在 32 位上可能会更糟。 @PeterCordes OP 最初在帖子中包含了一个 x64 标签,但它不再存在。如果 OP 实际上使用的是 32 位架构,那么是的,很明显为什么不必要地使用 64 位数量会降低性能。【参考方案3】:

警告:我写错了答案(右边第一位),对不起。无论如何,这些方法可以很容易地适应 MSb。


您可以通过查找表来简化流程。您预先计算从02^k-1 的所有数字的最右边位的索引。您将一次以k 位的切片处理您的数字,并从右到左尝试切片,直到切片不为零。

一个有趣的选择是将 long long 映射到一个 8 字节的数组;这些字节对应于 256 个条目的查找表。这样,您就可以从直接按字节寻址中受益。

shorts 也可以进行处理,但代价是 LUT 包含 65536 (64K) 个条目。最佳值可能介于两者之间。有缓存效果。


另一种有用的方法是二分法:屏蔽 32 个高位(或加载低位 int)并测试为零。然后用非零部分屏蔽掉 16 个高位,依此类推。最后,使用 LUT 技巧。只需 3 个步骤,您就可以从 64 个减少到 8 个。

如果位索引的分布是均匀的,这是合适的。如果它偏向于小值,那么顺序搜索无论如何都会更好。

【讨论】:

还有一个选择是逐字节迭代,然后进行半字节查找。比 256/64kib 替代方案稍慢,但只需要 16 字节查找。 @Lundin:当然值得比较具有不同块大小的版本。我不怕 8 位 LUT。 我正在使用低级嵌入式,因此半字节 LUT 是我最常用的一种,它是速度和内存使用之间的良好折衷。虽然现在,我认为在大多数 MCU 上 256 字节闪存都不是什么大问题。 @Lundin:OP 标记了这个[simd],所以我认为我们可以假设它不是一个 tiny 嵌入式机器。理论上,您可以将 x86 SSSE3 pshufb 用作 16x nibble-LUT 并行查找,但最好使用一条标量 bsr 指令来处理 16 个半字节 = 8 个字节!【参考方案4】:

如果您可以使用 GCC intrisic,那么您可以尝试类似的方法

请注意,这里假设 x 不为 0,因为当 x 为 0 时,__builtin_clzll() 的结果未定义

#include <limits.h>

long long int findval(long long int x)

    // Get size of long long in bits
    size_t llsize = sizeof(long long) * CHAR_BIT;

    // Subtract count of leading zeros from size of long long
    return llsize - __builtin_clzll(x);

【讨论】:

请注意,如果x == 0 __builtin_clz et al 的结果是未定义的。 @PaulR:有用的 hack:__builtin_clzll(x | 1) 所以输入总是至少有最后一位(按 clz 顺序)设置。或者更好的是,设置第 22 位,这样您就可以免费获得 OP 想要的max(clz(x), 22)。即使您使用 x86 _mm_lzcnt_u64(输入 = 0 时返回 64)也可能值得这样做 是的,非常好 - 可能比我想象的 __builtin_clz 之前对 x == 0 的显式测试更有效。 当在范围内找到一个位时,这将返回比问题中的原始代码多一个。当在该范围内未找到任何位时,原始代码返回 22,但此代码返回较低的值(除非该位正好位于位置 21)。 刚刚注意到这有一个错误:bsr = 63 - clz,而不是 64 - clz。 (记住这一点的诀窍是注意您需要从最大位置中减去,而不是从类型宽度中减去。)请参阅我的回答,了解可以编译(对于 x86-64)为 OR / BSR 的工作版本;你做了一个方便的复制/粘贴起点:)(@PaulR:是的,如果将那个特殊情况与x=1 情况合并是好的,它非常好,不能误判。尤其是在这里它实际上是 以后通过钳位保存指令。这种方式只有 2 条单指令(在 Intel 上),4 个周期的延迟。)

以上是关于在位范围内查找第一个设置位的位置的主要内容,如果未能解决你的问题,请参考以下文章

VBA-在命名范围内查找第一个单元格的位置以引用电子表格的标题行

c #interop excel range查找第一个和最后一个填充值

Hot10034. 在排序数组中查找元素的第一个和最后一个位置

在第一个元素在特定范围内的元组中查找元组列表中的最小值

STL二分算法

STL二分算法