为什么glibc的strlen需要如此复杂才能快速运行?

304

我在这里查看 strlen 代码我想知道代码中使用的优化是否真的有必要?例如,为什么像以下代码一样的东西不能同样地或更好地工作呢?

unsigned long strlen(char s[]) {
    unsigned long i;
    for (i = 0; s[i] != '\0'; i++)
        continue;
    return i;
}

简单的代码不是更好,编译器也更容易优化吗?

链接后面的 strlen 代码看起来像这样:

/* Copyright (C) 1991, 1993, 1997, 2000, 2003 Free Software Foundation, Inc.
   This file is part of the GNU C Library.
   Written by Torbjorn Granlund (tege@sics.se),
   with help from Dan Sahlin (dan@sics.se);
   commentary by Jim Blandy (jimb@ai.mit.edu).

   The GNU C Library is free software; you can redistribute it and/or
   modify it under the terms of the GNU Lesser General Public
   License as published by the Free Software Foundation; either
   version 2.1 of the License, or (at your option) any later version.

   The GNU C Library is distributed in the hope that it will be useful,
   but WITHOUT ANY WARRANTY; without even the implied warranty of
   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
   Lesser General Public License for more details.

   You should have received a copy of the GNU Lesser General Public
   License along with the GNU C Library; if not, write to the Free
   Software Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA
   02111-1307 USA.  */

#include <string.h>
#include <stdlib.h>

#undef strlen

/* Return the length of the null-terminated string STR.  Scan for
   the null terminator quickly by testing four bytes at a time.  */
size_t
strlen (str)
     const char *str;
{
  const char *char_ptr;
  const unsigned long int *longword_ptr;
  unsigned long int longword, magic_bits, himagic, lomagic;

  /* Handle the first few characters by reading one character at a time.
     Do this until CHAR_PTR is aligned on a longword boundary.  */
  for (char_ptr = str; ((unsigned long int) char_ptr
            & (sizeof (longword) - 1)) != 0;
       ++char_ptr)
    if (*char_ptr == '\0')
      return char_ptr - str;

  /* All these elucidatory comments refer to 4-byte longwords,
     but the theory applies equally well to 8-byte longwords.  */

  longword_ptr = (unsigned long int *) char_ptr;

  /* Bits 31, 24, 16, and 8 of this number are zero.  Call these bits
     the "holes."  Note that there is a hole just to the left of
     each byte, with an extra at the end:

     bits:  01111110 11111110 11111110 11111111
     bytes: AAAAAAAA BBBBBBBB CCCCCCCC DDDDDDDD

     The 1-bits make sure that carries propagate to the next 0-bit.
     The 0-bits provide holes for carries to fall into.  */
  magic_bits = 0x7efefeffL;
  himagic = 0x80808080L;
  lomagic = 0x01010101L;
  if (sizeof (longword) > 4)
    {
      /* 64-bit version of the magic.  */
      /* Do the shift in two steps to avoid a warning if long has 32 bits.  */
      magic_bits = ((0x7efefefeL << 16) << 16) | 0xfefefeffL;
      himagic = ((himagic << 16) << 16) | himagic;
      lomagic = ((lomagic << 16) << 16) | lomagic;
    }
  if (sizeof (longword) > 8)
    abort ();

  /* Instead of the traditional loop which tests each character,
     we will test a longword at a time.  The tricky part is testing
     if *any of the four* bytes in the longword in question are zero.  */
  for (;;)
    {
      /* We tentatively exit the loop if adding MAGIC_BITS to
     LONGWORD fails to change any of the hole bits of LONGWORD.

     1) Is this safe?  Will it catch all the zero bytes?
     Suppose there is a byte with all zeros.  Any carry bits
     propagating from its left will fall into the hole at its
     least significant bit and stop.  Since there will be no
     carry from its most significant bit, the LSB of the
     byte to the left will be unchanged, and the zero will be
     detected.

     2) Is this worthwhile?  Will it ignore everything except
     zero bytes?  Suppose every byte of LONGWORD has a bit set
     somewhere.  There will be a carry into bit 8.  If bit 8
     is set, this will carry into bit 16.  If bit 8 is clear,
     one of bits 9-15 must be set, so there will be a carry
     into bit 16.  Similarly, there will be a carry into bit
     24.  If one of bits 24-30 is set, there will be a carry
     into bit 31, so all of the hole bits will be changed.

     The one misfire occurs when bits 24-30 are clear and bit
     31 is set; in this case, the hole at bit 31 is not
     changed.  If we had access to the processor carry flag,
     we could close this loophole by putting the fourth hole
     at bit 32!

     So it ignores everything except 128's, when they're aligned
     properly.  */

      longword = *longword_ptr++;

      if (
#if 0
      /* Add MAGIC_BITS to LONGWORD.  */
      (((longword + magic_bits)

        /* Set those bits that were unchanged by the addition.  */
        ^ ~longword)

       /* Look at only the hole bits.  If any of the hole bits
          are unchanged, most likely one of the bytes was a
          zero.  */
       & ~magic_bits)
#else
      ((longword - lomagic) & himagic)
#endif
      != 0)
    {
      /* Which of the bytes was the zero?  If none of them were, it was
         a misfire; continue the search.  */

      const char *cp = (const char *) (longword_ptr - 1);

      if (cp[0] == 0)
        return cp - str;
      if (cp[1] == 0)
        return cp - str + 1;
      if (cp[2] == 0)
        return cp - str + 2;
      if (cp[3] == 0)
        return cp - str + 3;
      if (sizeof (longword) > 4)
        {
          if (cp[4] == 0)
        return cp - str + 4;
          if (cp[5] == 0)
        return cp - str + 5;
          if (cp[6] == 0)
        return cp - str + 6;
          if (cp[7] == 0)
        return cp - str + 7;
        }
    }
    }
}
libc_hidden_builtin_def (strlen)
为什么这个版本运行速度快? 它不是在做很多不必要的工作吗?


结果:
为什么这个版本运行速度快? 它不是在做很多不必要的工作吗?

3
评论不适合进行详细讨论;此对话已经转移到聊天室,请前往查看。 - Samuel Liew
23
GNU libc 的官方源代码存储库在 https://sourceware.org/git/?p=glibc.git。确实,https://sourceware.org/git/?p=glibc.git;a=blob;f=string/strlen.c;hb=HEAD 中显示的代码与上述代码类似;然而,在大多数 glibc 支持的架构上(没有替代品的最常用架构是 MIPS),将使用来自“sysdeps”目录的手写汇编语言实现来代替。 - zwol
11
投票将其关闭,因为它主要是基于观点的;“xxx是否真的在xxx中需要?”是根据人们的观点而定的。 - S.S. Anne
13
原文:@JL2210,这个问题的原始标题是“为什么在C语言中strlen函数如此复杂[sic!]”,因为太宽泛而被关闭,然后重新开放,然后因为“主要基于观点”而关闭。我试图修复它(同时卷入了“你破坏了我的问题!”和“你们滥用了编辑权限!”之间的争端),但我认为问题在于它的基本前提有问题(“这段代码对我来说太复杂了”并不适合问答——在我看来,这是一种请求辅导而不是回答的方式)。我再也不会碰它了 :)翻译:原文作者表示,这个问题最初的标题是“为什么在C语言中strlen函数如此复杂[sic!]”,由于问题太过笼统,被关闭,然后重新开放,之后又因为“主要基于观点”而关闭。作者尝试修复该问题,但在争议中发现问题的基本前提有误,即该问题所询问的内容“这段代码对我来说太复杂了”并不适合作为问答问题,而更适合于求助或者辅导。作者表示自己不会再去处理这个问题。 - user719662
2
@PeterA.Schneider:那个函数只需要使用gcc单独编译为高效的机器代码,而不是内联到其他任何内容中。除非在char对象上调用它(例如char []),否则它不是UB,因此没有链接时优化的危险。(这就是glibc不启用链接时优化的原因之一)。请参阅我的答案。 - Peter Cordes
显示剩余5条评论
9个回答

244

不需要,也永远不应该编写这样的代码 - 特别是如果你不是C编译器 /标准库供应商。这段代码用于实现strlen函数,并使用了一些非常可疑的速度优化和假设(这些假设未经断言测试或在注释中提到):

  • unsigned long是4或8字节
  • 字节是8位
  • 指针可以强制转换为unsigned long long而不是uintptr_t
  • 可以通过检查2或3个最低位是否为零来对齐指针
  • 可以将字符串作为unsigned long访问
  • 可以在没有任何负面效果的情况下读取数组末尾之后的部分内容。

更重要的是,一个好的编译器甚至可以将代码替换为以下写法:

size_t stupid_strlen(const char s[]) {
    size_t i;
    for (i=0; s[i] != '\0'; i++)
        ;
    return i;
}

注意,它必须是与size_t兼容的类型,并具有编译器内置的strlen的内联版本,或者将代码向量化; 但编译器不太可能能够优化复杂的版本。


strlen函数由C11 7.24.6.3描述:

描述

  1. strlen函数计算指向s的字符串的长度。

返回值

  1. strlen函数返回终止空字符之前的字符数。

现在,如果s指向的字符串恰好足够长,只能包含字符串和终止的NUL,则如果我们在终止符之后访问字符串,例如在中,行为将是未定义的

char *str = "hello world";  // or
char array[] = "hello world";

为了在完全可移植/符合标准的 C 中正确实现此功能,除了一些微小的变换外,您需要像您的问题中所写的那样进行操作 - 您可以通过展开循环等方式来假装更快,但仍然需要逐个字节进行处理。

(正如评论者所指出的那样,当严格的可移植性过于繁琐时,利用合理或已知安全的假设并不总是不好的事情。特别是在代码是某个特定 C 实现的一部分的情况下。 但在知道如何/何时可以弯曲规则之前,必须了解规则。)

链接的 strlen 实现首先检查每个字节,直到指针指向 unsigned long 的自然 4 或 8 字节对齐边界。C 标准规定访问不正确对齐的指针具有未定义行为,因此必须这样做才能使下一个不光彩的技巧更加不堪。 (在某些 CPU 架构上,与 x86 不同,错误的字或双字负载将故障。C 不是便携式汇编语言,但这段代码正在以这种方式使用它)。 它也是使在内存保护按对齐块(例如 4kiB 虚拟内存页)工作的实现中读取对象结尾后面的内容而不冒险故障的代码的先决条件。

现在就是糟糕的部分:代码违反了约定,一次读取 4 或 8 个 8 位字节(long int),并使用无符号加法的位技巧快速确定这些 4 或 8 个字节中是否有任何零字节 - 它使用一个特别制作的数字来引起进位位更改被位掩码捕获的位。 实质上,这将比逐个处理这些字节更快地确定掩码中的任何 4 或 8 个字节是否为零。最后,在循环结束时,找出第一个零字节(如果有)并返回结果。

最大的问题在于在 sizeof(unsigned long) - 1 次 sizeof(unsigned long) 的情况下,它将超出字符串结尾 - 只有当空字节在最后一个访问的字节中(即在小端中最重要,在大端中最不重要)时,才不会访问数组越界!

即使用于在 C 标准库中实现 strlen,该代码也是糟糕的代码。它具有多个实现定义和未定义方面,因此不应在任何地方使用代替系统提供的 strlen - 我将函数重命名为 the_strlen,并添加了以下 main:

int main(void) {
    char buf[12];
    printf("%zu\n", the_strlen(fgets(buf, 12, stdin)));
}

缓冲区大小被精心设计为能够恰好容纳hello world字符串和终止符。但是在我的64位处理器上,unsigned long是8个字节,因此访问后面的部分会超出该缓冲区。

如果现在使用-fsanitize=undefined-fsanitize=address编译并运行生成的程序,我会得到:

% ./a.out
hello world
=================================================================
==8355==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffffe63a3f8 at pc 0x55fbec46ab6c bp 0x7ffffe63a350 sp 0x7ffffe63a340
READ of size 8 at 0x7ffffe63a3f8 thread T0
    #0 0x55fbec46ab6b in the_strlen (.../a.out+0x1b6b)
    #1 0x55fbec46b139 in main (.../a.out+0x2139)
    #2 0x7f4f0848fb96 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x21b96)
    #3 0x55fbec46a949 in _start (.../a.out+0x1949)

Address 0x7ffffe63a3f8 is located in stack of thread T0 at offset 40 in frame
    #0 0x55fbec46b07c in main (.../a.out+0x207c)

  This frame has 1 object(s):
    [32, 44) 'buf' <== Memory access at offset 40 partially overflows this variable
HINT: this may be a false positive if your program uses some custom stack unwind mechanism or swapcontext
      (longjmp and C++ exceptions *are* supported)
SUMMARY: AddressSanitizer: stack-buffer-overflow (.../a.out+0x1b6b) in the_strlen
Shadow bytes around the buggy address:
  0x10007fcbf420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf440: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf450: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf460: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x10007fcbf470: 00 00 00 00 00 00 00 00 00 00 f1 f1 f1 f1 00[04]
  0x10007fcbf480: f2 f2 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf490: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4a0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4b0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x10007fcbf4c0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==8355==ABORTING

也就是说,发生了不好的事情。


137
关于“非常可疑的加速器和假设”这一点,是指在可移植代码中非常可疑。标准库是为特定编译器/硬件组合编写的,了解语言定义留下的未定义事物的实际行为。是的,大多数人不应该编写那样的代码,但在实现标准库的上下文中,非可移植并不是本质上不好的。 - Pete Becker
5
同意,不要自己写这样的代码,或者几乎不要写。过早的优化是万恶之源。(在这个情况下,可能确实有动机)。如果你最终需要对同一个非常长的字符串进行大量的strlen()调用,那么你的应用程序可能可以用不同的方式编写。例如,当字符串创建时,可以将字符串长度保存在变量中,而根本不需要调用strlen()。 - ghellquist
79
经常使用的库函数优化并不是“过早优化”。 - jamesqf
14
@Antti Haapala:您认为strlen为什么应该是O(1)?这里有几种实现方式,它们都是O(n),但具有不同的常数乘数。您可能认为这并不重要,但对于我们中的一些人来说,一个在微秒内完成工作的O(n)算法实现比需要数秒甚至毫秒的实现更好,因为它可能会在作业过程中被调用数十亿次。 - jamesqf
14
在标准库的背景下(虽然在这种情况下不是那么明显),编写非可移植代码可能是正常的,因为标准库的目的是提供一个标准接口来访问实现特定的内容。 - PlasmaHH
显示剩余16条评论

180

关于这个问题,评论中有很多(稍微或完全)错误的猜测。

你正在看的是glibc优化的C回退优化实现。(对于没有手写汇编实现的ISA)。或者是那段代码的旧版本,仍然存在于glibc源代码树中。https://code.woboq.org/userspace/glibc/string/strlen.c.html 是基于当前glibc git树的代码浏览器。显然,它仍然被一些主流的glibc目标平台使用,包括MIPS(感谢@zwol)。

在像x86和ARM这样的流行ISA上,glibc使用手写汇编

因此,改变这段代码的动机比你想象的要低。

这个 bithack 代码(https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord)并不是实际在您的服务器/台式机/笔记本电脑/智能手机上运行的。它比朴素的逐字节循环要好,但与现代 CPU 的高效汇编相比,即使是这个 bithack 在性能上也相当糟糕(尤其是对于 x86 架构的处理器,AVX2 SIMD 允许使用几条指令检查32字节,如果数据在现代 CPU 的 L1d 缓存中是热的,则每个时钟周期内的主循环可以达到32到64字节,有2个时钟周期的向量加载和 ALU 吞吐量。即针对中等大小的字符串,启动开销不会占主导地位。)

glibc使用动态链接技巧来将strlen解析为针对您的CPU最优化的版本,因此即使在x86中,也有一个SSE2版本(16字节向量,适用于x86-64)和一个AVX2版本(32字节向量)。

x86在向量寄存器和通用寄存器之间具有高效的数据传输,这使得它在使用SIMD加速隐式长度字符串函数且循环控制取决于数据时(唯一?)表现出色。 pcmpeqb / pmovmskb 可以同时测试16个单独的字节。

glibc有一个像使用AdvSIMD一样的AArch64版本,以及一个针对AArch64 CPU的版本,其中向量-> GP寄存器会导致流水线停顿,因此它实际使用这个bithack。但是,在找到命中之后,它使用前导零计数来查找寄存器内字节,并利用AArch64的高效非对齐访问在检查页面跨越后。

也相关:为什么启用GCC优化后,此代码在使用strlen时速度慢了6.5倍?">为什么启用GCC优化后,此代码在使用strlen时速度慢了6.5倍?有关x86汇编中strlen的快与慢的一些详细信息,以及一个对于gcc来说可能适合内联的简单汇编实现。 (某些gcc版本不明智地内联了rep scasb,这是非常慢的,或者使用每次4字节的位操作技巧。因此,GCC的内联-strlen需要更新或禁用。)

汇编语言没有C风格的"未定义行为";您可以安全地按任意方式访问内存中的字节,并且包含任何有效字节的对齐加载不会出错。内存保护以对齐页面为粒度进行;窄于该粒度的对齐访问不会跨越页面边界。 在x86和x64上,在同一页内读取缓冲区末尾之后是否安全?">相同的推理适用于这个C hack为该函数的独立非内联实现所创建的机器代码。

当编译器发出调用一个未知的非内联函数的代码时,它必须假设该函数修改任何/所有全局变量和任何可能具有指针的内存。也就是说,除了那些没有让地址逃逸的局部变量外,在调用过程中,所有的变量都必须在内存中保持同步。这适用于使用汇编语言编写的函数,显然也适用于库函数。如果不启用连接时优化,甚至适用于独立的翻译单元(源文件)。


为什么这在 glibc 中是安全的,但在其他情况下却不是。

最重要的因素是这个 strlen 不能内联到其他任何地方。 对于它来说是不安全的;它包含有严格别名 UB(通过 unsigned long* 读取 char 数据)。char* 允许别名其他任何东西 但反过来则不成立

这是一个预编译库函数(glibc)。它不会被链接时优化内联到调用者中。 这意味着它只需编译为一个独立版本的安全机器码即可。它不需要是可移植/安全的 C 代码。

GNU C库只需要与GCC一起编译。显然,使用clang或ICC编译它是不支持的,尽管它们支持GNU扩展。GCC是一个提前编译器,将C源文件转换为机器代码的目标文件。它不是解释器,因此除非在编译时内联,否则内存中的字节只是内存中的字节。即严格别名未定义行为在不内联到彼此的不同函数中访问具有不同类型的情况下并不危险。

请记住,strlen的行为是由ISO C标准定义的。该函数名称特定地是实现的一部分。像GCC这样的编译器甚至将该名称视为内置函数,除非您使用-fno-builtin-strlen,因此strlen("foo")可以是一个编译时常量3。库中的定义仅在gcc决定实际发出对它的调用而不是内联自己的实现或其他内容时使用。

当UB在编译时对编译器不可见时,你会得到合理的机器代码。机器代码必须适用于无UB情况,即使你想要,汇编也无法检测调用者用于将数据放入指向内存的类型。
Glibc被编译为一个独立的静态或动态库,无法与链接时优化进行内联。glibc的构建脚本不会创建包含机器代码和gcc GIMPLE内部表示的“fat”静态库,以便在内联到程序中时进行链接时优化。(即libc.a不会参与-flto链接时优化到主程序中)。以这种方式构建glibc可能在实际使用这个.c的目标上是不安全的。
事实上,正如@zwol所评论的那样,在构建glibc本身时不能使用LTO,因为如果在glibc源文件之间进行内联是可能的,那么像这样的“脆弱”代码可能会出错。(例如,有一些内部使用strlen的地方,比如作为printf实现的一部分)

这个strlen做了一些假设:

  • CHAR_BIT是8的倍数。 在所有GNU系统上都为真。 POSIX 2001甚至保证CHAR_BIT == 8。(对于CHAR_BIT= 1632等某些DSP系统,这看起来是安全的;如果sizeof(long) = sizeof(char) = 1,则未对齐的前导循环永远不会运行0次迭代,因为每个指针始终对齐且p & sizeof(long)-1始终为零。)但是,如果你使用的是非ASCII字符集,其中字符宽度为9或12位,则0x8080...是错误的模式。
  • (可能)unsigned long是4或8字节。 或者也许它实际上适用于任何大小的unsigned long,最多为8,并且它使用assert()来进行检查。

这两个不是可能的未定义行为(UB),它们只是对某些 C 实现的非可移植性问题。这段代码(或者曾经是)C 实现的一部分,在支持的平台上运行良好,所以没问题。

下一个假设是潜在的 C UB:

  • 包含任何有效字节的对齐加载不能导致故障,只要你忽略实际想要的对象之外的字节就安全。(对于每个 GNU 系统上的汇编语言是正确的,并且对于所有正常的 CPU 也是正确的,因为内存保护以对齐页面为粒度发生。在 x86 和 x64 上同一页缓冲区末尾读取是否安全? 在 C 中当 UB 在编译时不可见时是安全的。没有内联,这在这里是成立的。编译器无法证明在第一个 0 之后读取是 UB 的;例如它可以是一个包含 {1,2,0,3} 的 C char[] 数组)

最后一点是使得在这里安全地读取超出 C 对象末尾的原因。即使在与当前编译器内联时,这也基本上是安全的,因为我认为它们目前不会将其视为执行路径不可达。但无论如何,如果您允许内联,严格别名已经成为一个阻碍。

然后,您可能会遇到类似于 Linux 内核旧不安全的 memcpy CPP 宏 的问题,它使用指针转换为 unsigned longgcc, strict-aliasing, and horror stories)。(现代 Linux 使用 -fno-strict-aliasing 进行编译,而不是仔细处理 may_alias 属性。)

这个 strlen 追溯到您通常可以在其中获得类似功能的时代;即使在没有 "仅在不内联时" 的警告的情况下,在 GCC3 之前,它通常是安全的。


只有在跨越 call/ret 边界时才能看到的未定义行为对我们没有伤害。 (例如,在一个 char buf[] 上调用这个函数,而不是在一个 unsigned long[] 数组上进行类型转换为 const char*)。一旦机器代码确定下来,就只需处理内存中的字节。非内联函数调用必须假设被调用者读取任何/所有内存。


安全地编写,避免严格别名 UB

GCC 类型属性 may_alias 可以将类型视为与 char* 相同的任意别名处理方式。 (建议来自 @KonradBorowsk)。 GCC 头文件目前在 x86 SIMD 向量类型(例如 __m128i)中使用它,因此您可以始终安全地执行 _mm_loadu_si128( (__m128i*)foo )。(有关此操作的详细信息,请参阅 在硬件 SIMD 向量指针和相应类型之间进行 reinterpret_cast 的行为是否是未定义行为?)。

strlen(const char *char_ptr)
{
  typedef unsigned long __attribute__((may_alias)) aliasing_ulong;

  // handle unaligned startup somehow, e.g. check for page crossing then check an unaligned word
  // else check single bytes until an alignment boundary.
  aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;

  for (;;) {
     // alignment still required, but can safely alias anything including a char[]
     unsigned long ulong = *longword_ptr++;

     ...
  }
}

你可以使用aligned(1)来表示一个类型,其中alignof(T) = 1
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;。如果你不仅仅是在第一个对齐边界之前逐个字符地执行,这对于strlen的非对齐启动部分可能很有用。(主循环需要对齐,这样如果终止符正好在未映射页面之前,就不会出错。) 在ISO中表达别名加载的一种可移植方式是使用memcpy,现代编译器知道如何将其内联为单个加载指令。例如:
   unsigned long longword;
   memcpy(&longword, char_ptr, sizeof(longword));
   char_ptr += sizeof(longword);

这也适用于未对齐的加载,因为memcpy按照每次访问一个char的方式工作。但是在实践中,现代编译器对memcpy非常了解。
危险在于,如果GCC不能确定char_ptr是否是字对齐的,在一些可能不支持汇编中的未对齐加载的平台上,它不会内联它。例如,MIPS的MIPS64r6之前版本或较旧的ARM。如果你得到一个实际的函数调用来加载一个字(并将其留在其他内存中),那将是一场灾难。GCC有时可以看到代码如何对齐指针。或者在达到ulong边界的char-at-a-time循环之后,您可以使用
p = __builtin_assume_aligned(p, sizeof(unsigned long)); 这并不能避免读超过对象可能引起的未定义行为,但在当前的GCC中,这在实践中并不危险。

为什么手动优化的C源代码是必要的:当前的编译器还不够好

当您需要每一点性能的时候,手动优化的汇编代码可能会更好,尤其是对于广泛使用的标准库函数,例如memcpystrlen。在这种情况下,使用x86内嵌函数来利用SSE2并不容易。

但在这里,我们只讨论了一个简单版本的C代码,没有使用任何特定于ISA的特性。

(我认为我们可以认定strlen被广泛使用,使其尽可能快速运行是很重要的。所以问题是我们是否可以从更简单的源代码中获得高效的机器代码。不,我们不能。)

当前的GCC和clang无法自动向量化循环,其中迭代次数在第一次迭代之前是未知的(例如,在运行第一次迭代之前,必须能够检查循环是否至少执行16次)。例如,根据目前的编译器,可以进行自动向量化的memcpy(使用显式长度缓冲区),但不适用于strcpy或strlen(使用隐式长度字符串)。

这包括搜索循环,或者任何具有数据相关 if()break 和计数器的其他循环。

ICC(Intel 的 x86 编译器)可以自动向量化某些搜索循环,但对于像 OpenBSD 的 libc 使用的简单/天真的 C strlen,它仍然只生成原始的逐字节汇编代码。 (Godbolt)。 (来源:@Peske's answer)。

一个经过手动优化的libc `strlen` 对于当前的编译器性能是必要的。每次只处理1个字节(在宽超标量CPU上可能每个周期展开2个字节)是非常低效的,因为主内存可以每个周期处理大约8个字节,而L1d缓存可以每个周期传送16到64个字节。(自Haswell和Ryzen以来,现代主流x86 CPU每个周期可以进行2次32字节的加载。不包括AVX512,因为使用512位向量会降低时钟速度;这也是为什么glibc可能不急于添加AVX512版本的原因。虽然使用256位向量,AVX512VL + BW掩码比较成掩码并且`ktest`或`kortest`可以通过减少每次迭代的微操作数使`strlen`更加适合超线程,从而提高其性能。)
我在这里包括了非x86,那就是“16个字节”。例如,大多数AArch64 CPU至少可以做到这一点,我认为有些甚至可以做得更多。而且有些CPU的执行吞吐量足够让`strlen`跟上这种加载带宽。
当然,处理大字符串的程序通常应该跟踪长度,以避免经常重新查找隐式长度的C字符串的长度。但是,手写实现仍然可以提高短到中等长度字符串的性能,我相信一些程序最终会在中等长度的字符串上使用strlen函数。

13
注:(1) 目前无法使用除GCC之外的编译器编译glibc本身。(2) 目前无法启用链接时优化编译glibc本身,因为正是这些情况,如果允许内联发生,则编译器将看到未定义行为。 (3)CHAR_BIT == 8是POSIX要求(截至2001版;请参见此处)。(4) strlen的C回退实现用于某些支持的CPU,我相信最常见的一个是MIPS。 - zwol
1
@SebastianRedl:你可以通过char*读写任何对象,但是仍然不允许通过long*读写char 对象(例如char[]的一部分)。严格别名规则和'char *'指针 - Peter Cordes
1
@Davislor:哦,对了。更新为9或12的真实硬件的可信示例。 - Peter Cordes
2
这个分析似乎是提出一个补丁的好基础,使代码在当前禁用优化的情况下更加健壮,除了给出一个令人赞叹的答案。 - Deduplicator
1
关于“虽然使用256位向量,但AVX512VL + BW...”即将在glibc 2.34中推出。将会有strlen-evex.S。值得注意的是,“AVX512 + BW”版本专门使用evex编码(因此没有vzeroupper,因此HLE安全),而不是可能理想的evex + avx2。因此,一些evex版本(例如memchr)需要支付一些代价。 - Noah
显示剩余13条评论

66

在您链接的文件中的注释中已经进行了解释:

 27 /* Return the length of the null-terminated string STR.  Scan for
 28    the null terminator quickly by testing four bytes at a time.  */

而且:

 73   /* Instead of the traditional loop which tests each character,
 74      we will test a longword at a time.  The tricky part is testing
 75      if *any of the four* bytes in the longword in question are zero.  */

在 C 语言中,可以详细地考虑效率。

逐个字符迭代查找空值比一次测试多个字节要不那么高效,就像这段代码所做的那样。

额外的复杂性来自于需要确保被测试的字符串对齐到正确的位置以开始一次测试多个字节(如注释中描述的那样),以及需要确保数据类型大小的假设在使用代码时不会被违反。

在大多数(但不是全部)现代软件开发中,像这样关注效率细节并非必要的,或者不值得额外的代码复杂性成本。

一个值得关注效率的地方是在标准库中,就像你链接的例子一样。


如果您想了解更多有关字边界的信息,请参见这个问题这个优秀的维基百科页面


我认为上面的这个答案是更清晰、更详细的讨论。


40

除了这里提供的很好的答案,我想指出问题中链接的代码是GNU的strlen实现。

OpenBSD的strlen实现与问题中提出的代码非常相似。一个实现的复杂度由作者决定。

...
#include <string.h>

size_t
strlen(const char *str)
{
    const char *s;

    for (s = str; *s; ++s)
        ;
    return (s - str);
}

DEF_STRONG(strlen);
编辑:我上面提供的OpenBSD代码似乎是针对没有自己的汇编实现的ISA的后备实现。根据不同的体系结构,strlen有不同的实现。例如,amd64 strlen的代码是汇编语言。与PeterCordes'的评论/答案类似,指出非后备GNU实现也是汇编语言。

6
这段话表达了OpenBSD和GNU工具在优化不同价值方面的差异,这是一个很好的例子。 - Jason
14
这是glibc的可移植回退实现。所有主要的指令集架构(ISA)在glibc中都有手写的汇编实现,当使用SIMD加速(例如在x86上)时也会被调用。请参考https://code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/strlen-avx2.S.html和https://code.woboq.org/userspace/glibc/sysdeps/aarch64/multiarch/strlen_asimd.S.html。 - Peter Cordes
4
即使是 OpenBSD 版本也存在原版不存在的缺陷!如果 s - str 的结果无法用 ptrdiff_t 表示,则其行为是未定义的。 - Antti Haapala -- Слава Україні
2
在GNU C中,最大对象大小为“PTRDIFF_MAX”。但至少在Linux上仍然可以mmap超过该大小的内存(例如,在x86-64内核下的32位进程中,我可以映射约2.7GB的连续空间,然后开始出现失败)。关于OpenBSD,内核可能会使达到那个return成为不可能,除非发生段错误或在该大小范围内停止。但是,您会认为避免理论上的C UB的防御性编码应该是OpenBSD想要做的事情。即使strlen无法内联且真正的编译器只会将其编译为减法。 - Peter Cordes
显示剩余5条评论

37
简而言之,这是一种性能优化,标准库可以通过了解编译器的信息来实现 - 除非您正在编写标准库并且可以依赖特定的编译器,否则不应编写此类代码。具体来说,它会同时处理对齐字节数 - 在32位平台上为4,在64位平台上为8。这意味着它比朴素的字节迭代快4到8倍。
要解释这是如何工作的,请考虑以下图像。假设这里是32位平台(4字节对齐)。

假设"Hello, world!"字符串的字母"H"被提供为strlen的参数。因为CPU喜欢在内存中对齐东西(理想情况下,address % sizeof(size_t) == 0),对齐之前的字节会使用缓慢的方法逐字节处理。
然后,对于每个对齐大小的块,通过计算(longbits - 0x01010101) & 0x80808080 != 0来检查整数内的任何字节是否为零。当至少有一个字节高于0x80时,此计算会产生误报,但往往应该可行。如果不是这种情况(如黄色区域),则长度增加对齐大小。
如果整数内的任何字节都为零(或0x81),则按字节检查字符串以确定零的位置。
这可能会造成越界访问,但由于它在对齐范围内,所以很可能是好的,内存映射单元通常没有字节级精度。

这个实现是 glibc 的一部分。GNU 系统使用页面粒度进行内存保护。因此,是的,包括任何有效字节的对齐加载是安全的。 - Peter Cordes
size_t 没有保证对齐。 - S.S. Anne

35
您希望代码正确、可维护且快速。这些因素的重要性不同:
"正确" 绝对是必须的。
"可维护" 取决于您将要维护代码的程度:strlen 已经成为标准的 C 库函数超过 40 年了。它不会改变。因此,对于这个函数来说,可维护性相当不重要。
"快速":在许多应用程序中,strcpy、strlen 等函数使用了大量的执行时间。为了实现与这种复杂但不太复杂的 strlen 实现相同的整体速度提升,通过改进编译器所需要的工作量将是巨大的。
快速有另一个优点:当程序员发现调用 "strlen" 是他们可以测量字符串长度的最快方法时,他们就不再被诱惑去编写自己的代码以加快速度。
因此对于 strlen 来说,速度比大多数您将撰写的代码更重要,而可维护性则不那么重要。为什么要这样复杂呢?假设你有一个包含1,000个字节的字符串,简单的实现方式将检查1,000个字节。当前的实现方式通常会一次性检查64位的字,即125个64位或8字节的字。甚至可能使用矢量指令一次性检查32个字节,这样就更加复杂,同时也更快。使用矢量指令会导致代码稍微复杂一些,但是很直接,检查64位字中的八个字节是否为零需要一些巧妙的技巧。因此,对于中等到长字符串,可以期望该代码的速度约快四倍。对于像strlen这样重要的函数来说,编写一个更复杂的函数是值得的。
PS. 该代码不太可移植。但它是标准C库的一部分,是实现的一部分-它不需要可移植性。
PPS. 有人发了一个例子,在其中一个调试工具中抱怨访问字符串结尾后面的字节时出错。可以设计一种实现方式来保证以下内容:如果p是有效的字节指针,则任何对同一对齐块中的字节的访问都将返回未指定的值,这在C标准中被视为未定义行为。

PPPS. Intel在其后期处理器中添加了指令,用于构建strstr()函数(查找字符串中的子字符串)。他们的描述让人难以置信,但它们可以使这个特定的函数快大约100倍。(基本上,给定一个包含“Hello, world!”的数组a和一个以16字节“HelloHelloHelloH”开头并包含更多字节的数组b,它会确定字符串a没有在索引15之前出现在b中)。


如果我发现自己正在进行大量的基于字符串的处理,并且存在瓶颈,那么我可能会实现自己的 Pascal Strings 版本,而不是改进 strlen 函数。 - Baldrickk
1
没有人要求你改进strlen函数。但是让它足够好可以避免像人们实现自己的字符串这样的无意义操作。 - gnasher729
1
strlen()有时被过度使用 - Peter Mortensen

26
简而言之:逐字节检查字符串在可以一次获取较大数据的架构上可能会很慢。
如果能够在32位或64位基础上检查空终止符,将减少编译器需要执行的检查量。这就是链接代码试图做的事情,具有特定的系统考虑因素。它们对寻址、对齐、缓存使用、非标准编译器设置等等作出了假设。
按照您的示例逐字节读取是在8位CPU上或编写使用标准C的可移植库时的明智选择。
查看C标准库以获得编写快速/良好代码的建议并不是一个好主意,因为它将是不可移植的,并依赖于非标准假设或定义不清晰的行为。如果你是个初学者,阅读这样的代码可能会比有益的教育更加有害。

1
当然,优化器很有可能展开或自动向量化这个循环,并且预取器可以轻松检测到这种访问模式。这些技巧在现代处理器上是否真的重要需要进行测试。如果有胜利可图,那么最好使用向量指令。 - russbishop
7
@russbishop:你可能会希望如此,但实际情况并非如此。GCC和clang无法自动向量化循环,其中迭代计数在第一次迭代之前是未知的。这包括搜索循环或任何具有数据依赖if()break的循环。ICC可以自动向量化这样的循环,但我不确定它对一个简单的strlen处理得如何。而且,SSE2的pcmpeqb / pmovmskb对于strlen来说非常有效,一次可测试16个字节。 https://code.woboq.org/userspace/glibc/sysdeps/x86_64/strlen.S.html 是glibc的SSE2版本。另请参见此问答 - Peter Cordes
哎呀,这真是不幸。我通常非常反对UB,但正如你所指出的,C字符串需要技术上的UB缓冲区末尾读取才能允许矢量化。我认为ARM64也适用于同样的情况,因为它需要对齐。 - russbishop

1
为什么像下面这样的东西不能同样好或者更好地工作呢?
// OP's code - what is needed to portably function correctly?
unsigned long strlen(char s[]) {
    unsigned long i;
    for (i = 0; s[i] != '\0'; i++)
        continue;
    return i;
}

OP的代码存在功能性错误。

不过修正起来很容易。


在编写可移植代码时,需要注意首先确保函数正确,然后再考虑性能优化。

即使是非常简单的代码,看起来似乎正确,但也可能存在功能上的缺陷。

类型

字符串长度位于size_t范围内,该范围可能与unsigned long不同。函数签名存在问题,因为它与size_t (*f)() = strlen不匹配。在ULONG_MAX<SIZE_MAX且字符串长度巨大的罕见平台上存在问题。

const

s应该是const char *

非二进制补码

(这个问题今天只影响极少数处理器,所以实际上只是一种学究式的关注。非二进制补码可能会在下一个C(C23?)中被规定掉。)

s[i] != '\0' 可能会在 char有符号 而不是二进制补码时触发 -0。它不应该这样。在这种情况下,str...() 函数的行为就像访问 unsigned char 一样。

对于本子条款中的所有函数,每个字符都应被解释为类型 unsigned char(因此,每个可能的对象表示都是有效的并且具有不同的值)。


修复OP简单代码的这些方面。
size_t strlen(const char *s) {
    size_t i;
    for (i = 0; ((const unsigned char *)s)[i] != '\0'; i++)
        continue;
    return i;
}

现在有了更好、更便携的strlen()候选项,可以将其与“复杂”的替代方案进行比较。

@JimBalter C库中的“对于本子句中的所有函数,每个字符都应被解释为具有unsigned char类型(因此每个可能的对象表示都是有效的且具有不同的值)。”适用于_string_函数。字符串处理不应将-0解释为_null character_,而应通过unsigned char *访问该字节。 - chux - Reinstate Monica
@JimBalter的说法“字符串是一系列字符(有符号或无符号,取决于实现)”是规范错误引用。C库中的定义是“字符串是由第一个空字符终止并包括在内的连续字符序列。” C库将_characters_视为某些_unsigned_类型,即使char是有符号的。例如:<ctype.h>“其值可表示为unsigned char”,以及<stdio.h>:“int参数被转换为unsigned char,并写入结果字符。” - chux - Reinstate Monica

-7

其他答案没有提到的一件重要的事情是,自由软件基金会非常谨慎地确保专有代码不会进入GNU项目。在GNU编码标准中,在引用专有程序下,有一个关于组织实现方式以避免与现有专有代码混淆的警告:

无论如何都不要在你的GNU工作中参考Unix源代码!(或任何其他专有程序。)

如果你对Unix程序的内部有模糊的记忆,这并不意味着你不能写一个它的模仿,但请尝试按照不同的方式来组织模仿的内部结构,因为这可能会使Unix版本的细节与你的结果不相关和不相似。

例如,Unix实用程序通常被优化以最小化内存使用;如果你追求速度而不是内存使用,那么你的程序将会非常不同。

(强调是我的。)


5
这怎么回答问题了呢? - S.S. Anne
1
OP中的问题是“这个更简单的代码不会更好吗?”,而这个问题并不总是基于技术优劣来决定。对于像GNU这样的项目,避免法律陷阱是代码“更好地工作”的重要组成部分,“显然”的strlen()实现可能与现有代码相似或相同。像glibc实现这样“疯狂”的东西是无法追溯的。考虑到在Google/Oracle之争中针对rangeCheck的11行代码有多少法律纠纷,我认为FSF的担忧是合理的。 - Jack Kelly

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接