【问题标题】:Fast strlen with bit operations带位操作的快速 strlen
【发布时间】:2015-12-01 19:59:37
【问题描述】:

我找到了这段代码

int strlen_my(const char *s)
{
    int len = 0;
    for(;;)
    {
        unsigned x = *(unsigned*)s;
        if((x & 0xFF) == 0) return len;
        if((x & 0xFF00) == 0) return len + 1;
        if((x & 0xFF0000) == 0) return len + 2;
        if((x & 0xFF000000) == 0) return len + 3;
        s += 4, len += 4;
    }
}

我很想知道它是如何工作的。 ¿ 谁能解释一下它是如何工作的?

【问题讨论】:

  • 它用未定义的行为换取了一个非常可疑的加速(它很可能甚至更慢)。并且不符合标准,因为它返回 int 而不是 size_t
  • @MillieSmith:这是最小的问题,因为大多数 64 位系统都是 I32LP64 (POSIX)。问题是访问不对齐,字节序(如您所说)。即使平台上允许未对齐的访问,它们也可能比对齐的访问慢得多。更不用说多重掩码和条件操作了。
  • if((x & 0xFF) == 0)--> 依赖字节序
  • 这可能来自here,它确实提到了与此代码的许多权衡,尽管它没有提到它是未定义的行为。链接代码的来源通常很有帮助。
  • 值得注意的是,glibc 使用了一个漂亮的 bithack,它可以一次测试四个或八个字节(取决于 long 的大小),使用单个条件来检查是否没有字节为 0。 (sourceware.org/git/?p=glibc.git;a=blob;f=string/…) 在特定架构上,它可以使用 SSE(或等效)或其他 bithacks 做得更好。道德:使用标准库。

标签: c bitwise-operators strlen


【解决方案1】:

按位与 1 将检索另一个操作数的位模式。意思是10101 & 11111 = 10101。如果按位与的结果是 0,那么我们知道我们知道另一个操作数是 0。当与 0xFF(一个)对单个字节进行 AND 运算时,结果为 0 将指示一个 NULL 字节。

代码本身检查 char 数组中的每个字节在四字节分区中。 注意:此代码不可移植;在另一台机器或编译器上,unsigned int 可能超过 4 个字节。最好使用 uint32_t 数据类型来确保 32 位无符号整数。

首先要注意的是,在little-endian机器上,组成字符数组的字节会以相反的顺序被读入无符号数据类型;也就是说,如果当前地址的四个字节是abcd对应的位模式,那么无符号变量将包含dcba对应的位模式。

第二个是 C 中的十六进制数字常量会产生一个 int 大小的数字,在位模式的小端具有指定的字节。意思是,0xFF 实际上是 0x000000FF 在使用 4 字节整数编译时。 0xFF000x0000FF00。以此类推。

所以程序基本上是在四个可能的位置寻找NULL字符。如果当前分区中没有 NULL 字符,则前进到下一个四字节槽。

以char数组abcdef为例。在 C 语言中,字符串常量的末尾总是有空终止符,因此该字符串的末尾有一个 0x00 字节。

它的工作原理如下:

将“abcd”读入无符号整数x:

x: 0x64636261 [ASCII representations for "dcba"]

检查每个字节是否有空终止符:

  0x64636261
& 0x000000FF
  0x00000061 != 0,

  0x64636261
& 0x0000FF00
  0x00006200 != 0,

并检查其他两个位置;这个 4 字节的分区中没有空终止符,所以前进到下一个分区。

将“ef”读入无符号整数x:

x: 0xBF006665 [ASCII representations for "fe"]

注意 0xBF 字节;这超过了字符串的长度,所以我们正在从运行时堆栈中读取垃圾。它可以是任何东西。在不允许未对齐访问的机器上,如果字符串后面的内存不是 1 字节对齐的,则会崩溃。如果字符串中只剩下一个字符,我们将读取两个额外的字节,因此与 char 数组相邻的内存对齐必须是 2 字节对齐。

检查每个字节是否有空终止符:

  0xBF006665
& 0x000000FF
  0x00000065 != 0,

  0xBF006665
& 0x0000FF00
  0x00006600 != 0,

  0xBF006665
& 0x00FF0000
  0x00000000 == 0 !!!

所以我们返回len + 2; len 是 4,因为我们将它增加了一次 4,所以我们返回 6,这确实是字符串的长度。

【讨论】:

  • 我接受这个答案,因为它帮助我理解了代码的工作原理
【解决方案2】:

代码通过尝试一次读取 4 个字节来“工作”,假设字符串像 int 的数组一样布局和可访问。代码读取第一个int,然后依次读取每个字节,测试它是否为空字符。理论上,使用int 的代码将运行得比4 个单独的char 操作更快。

但是有问题:

对齐是一个问题:例如*(unsigned*)s 可能出现段错误。

Endian 是if((x & 0xFF) == 0) 的问题,可能无法获取地址s 处的字节

s += 4 是个问题,因为sizeof(int) 可能与 4 不同。

数组类型可能超出int范围,最好使用size_t


试图纠正这些困难。

#include <stddef.h>
#include <stdio.h>

static inline aligned_as_int(const char *s) {
  max_align_t mat; // C11
  uintptr_t i = (uintptr_t) s;
  return i % sizeof mat == 0;
}

size_t strlen_my(const char *s) {
  size_t len = 0;
  // align
  while (!aligned_as_int(s)) {
    if (*s == 0) return len;
    s++;
    len++;
  }
  for (;;) {
    unsigned x = *(unsigned*) s;
    #if UINT_MAX >> CHAR_BIT == UCHAR_MAX
      if(!(x & 0xFF) || !(x & 0xFF00)) break;
      s += 2, len += 2;
    #elif UINT_MAX >> CHAR_BIT*3 == UCHAR_MAX
      if (!(x & 0xFF) || !(x & 0xFF00) || !(x & 0xFF0000) || !(x & 0xFF000000)) break;
      s += 4, len += 4;
    #elif UINT_MAX >> CHAR_BIT*7 == UCHAR_MAX
      if (   !(x & 0xFF) || !(x & 0xFF00)
          || !(x & 0xFF0000) || !(x & 0xFF000000)
          || !(x & 0xFF00000000) || !(x & 0xFF0000000000)
          || !(x & 0xFF000000000000) || !(x & 0xFF00000000000000)) break;
      s += 8, len += 8;
    #else
      #error TBD code
    #endif
  }
  while (*s++) {
    len++;
  }
  return len;
}

【讨论】:

  • aligned_as_intmax_align_t mat;的用途是什么,我也想知道aligned_as_int
  • @Kevin 各种平台都有对齐要求,例如,有些要求所有int 变量地址都是4 的倍数。在C11 之前,无法确定此要求的可移植性。对于 C11,max_align_t 是一种对较大类型有营养要求的类型。所以代码应该逐字节运行,直到s 位于int 对齐的地址上。然后可以开始更高的速度int。如果所有这些努力都值得,那仍然是一个悬而未决的问题。分析此解决方案与 strlen() 将回答这个问题 - 仍然取决于平台/编译器。
  • 即从不是四的倍数的地址移动四字节可能会导致对齐错误,但这取决于机器,对吗?
  • @Kevin 是的,根据给出的示例。另一个例子:一台机器可能有 8 字节对齐要求,但 int 为 4 字节。另一个:一台机器可能有 1 字节对齐要求(即:没有特殊要求),但在 4 字节对齐 int 下工作最快。这就是为什么这篇文章使用了一个组合函数aligned_as_int(),因为对齐要求和最佳性能的细节本身就是一个子问题。
【解决方案3】:

它用未定义的行为(未对齐的访问,75% 的访问超出数组末尾的概率)换取了一个非常可疑的加速(它很可能甚至更慢)。并且不符合标准,因为它返回int 而不是size_t。即使平台上允许非对齐访问,它们也可能比对齐访问慢得多。

它也不适用于大端系统,或者如果unsigned 不是 32 位。更不用说多重掩码和条件操作了。

也就是说:

它通过加载 unsigned 一次测试 4 个 8 位字节(甚至不能保证超过 16 位)。一旦任何字节包含'\0'-终止符,它就会返回当前长度的总和加上该字节的位置。否则,它将当前长度增加并行测试的字节数 (4) 并获取下一个 unsigned

我的建议:优化的坏例子加上太多的不确定性/陷阱。它可能不会更快 - 只需根据标准版本对其进行分析:

size_t strlen(restrict const char *s)
{
    size_t l = 0;
    while ( *s++ )
        l++;
    return l;
}

可能有一种方法可以使用特殊的向量指令,但除非你能证明这是一个关键函数,否则你应该把它留给编译器——有些可能会更好地展开/加速此类循环。

【讨论】:

  • +1 注意到这段代码有多糟糕。 1 此外,大多数编译器会将 std strlen 优化为特定于机器的 ASM,使用 SSE 和其他扩展会更快
  • @TomerW:谢谢。补充:这是最后一段的暗示。但是你不应该忘记,大多数 CPU 没有这样的扩展,或者在这里几乎没有用处。 (嵌入式 MCU 是迄今为止最多的 CPU,ARM Cortex-M 和类似的(ColdFire、嵌入式 PPC)已经是最大的)。
  • @Kevin:: 我不明白你的意思。
  • @kevin:如果你对我的回答投了反对票,你至少应该这么好心地留下评论为什么。您留下的评论现在被无缘无故删除,也没有为我的澄清请求提供答案。这很不友好——至少可以这么说。
【解决方案4】:

所有建议都比简单的 strlen() 慢。

原因是它们并没有减少比较的次数,只有一个处理对齐。

在网上查看来自 Torbjorn Granlund (tege@sics.se) 和 Dan Sahlin (dan@sics.se) 的 strlen() 提案。如果您在 64 位平台上,这确实有助于加快速度。

【讨论】:

    【解决方案5】:

    它检测是否在 little-endian 机器上的特定字节处设置了任何位。因为我们只检查一个字节(因为所有的半字节,0 或 0xF,都加倍)并且它恰好是最后一个字节位置(因为机器是 little-endian,因此数字的字节模式是相反的) 我们可以立即知道哪个字节包含 NUL。

    【讨论】:

      【解决方案6】:

      循环在每次迭代中占用 char 数组的 4 个字节。四个 if 语句用于确定字符串是否结束,使用带有 AND 运算符的位掩码来读取所选子字符串的第 i 个元素的状态。

      【讨论】:

        猜你喜欢
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 1970-01-01
        • 2023-03-07
        • 1970-01-01
        • 2017-05-25
        • 1970-01-01
        • 2016-01-23
        相关资源
        最近更新 更多