为什么要快速运行glibc的复杂性太复杂?


286

我在这里浏览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)

为什么此版本运行很快?

是不是做了很多不必要的工作?


2
评论不作进一步讨论;此对话已转移至聊天
塞缪尔·柳

18
为了将来参考,GNU libc的官方源代码库位于< sourceware.org/git/?p=glibc.git >。< sourceware.org/git/?p=glibc.git;a=blob;f=string/… >确实显示了类似于上面的代码;但是,sysdeps将在大多数glibc支持的体系结构上使用目录中的手写汇编语言实现(最常用的没有替代的体系结构是MIPS)。
zwol

9
投票以主要基于意见的方式将其关闭;“ xxx中真的需要xxx吗?” 服从人们的意见。
SS安妮

2
@ JL2210:很好,已修复标题,以使标题看起来像是在想知道是否需要性能,这就是为什么我们需要这些优化才能获得性能,从而抓住问题的实质。
彼得·科德斯

9
@ JL2210 FWIW,原始标题为“为什么在C [sic!]中会变得如此复杂”,因此以“太宽泛”关闭,然后重新打开,然后以“主要基于意见”关闭。我试图解决此问题(与此同时,出现了“您打断了我的问题!”和“你们正在滥用您的编辑能力!”的交火),但是IMVHO,问题在于(并且仍然是)问题的基本前提,这是有问题的(“此代码对我来说太复杂了”,不适合进行问与答-IMO,这是补习的要求,而不是答案)。我不再用60英尺高的杆子

Answers:


233

不会需要你千万别写代码这样的-特别是如果你不是一个C编译器/标准库供应商。它是用于以strlen一些非常可疑的速度hack和假设(未经断言测试或在注释中提及)实现的代码:

  • unsigned long 是4或8个字节
  • 字节为8位
  • 一个指针可以被转换为unsigned long longuintptr_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或对代码进行矢量化处理;但是编译器不太可能优化复杂版本。


C11 7.24.6.3strlen功能描述为:

描述

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

退货

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

现在,如果by指向的字符串s位于字符数组中,长度足以容纳该字符串和终止NUL,则如果我们通过空终止符访问该字符串,则行为将是不确定的,例如

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

因此,真正的完全可移植/符合标准的C 唯一正确实现此目标的方法是它在您的问题中的编写方式,除了微不足道的转换之外-您可以通过展开循环等来假装更快,但是仍然需要这样做一次一个字节

(如评论者所指出的那样,当严格的可移植性成为沉重负担时,利用合理或已知安全的假设并不总是一件坏事。尤其是在属于特定C实现的一部分的代码中。但是您必须了解在知道如何/何时弯曲它们之前确定规则。)


链接的strlen实现首先单独检查字节,直到指针指向的自然4或8字节对齐边界为止unsigned long。C标准说,访问未正确对齐的指针具有未定义的行为,因此绝对必须这样做,以使下一个肮脏的技巧变得更加肮脏。(实际上,在x86以外的某些CPU架构上,未对齐的字或双字加载将出错。C 不是可移植的汇编语言,但是此代码以这种方式使用它)。这也是在对象保护在对齐块中工作的实现(例如4kiB虚拟内存页面)上实现读取错误而不会出错的风险的原因。

现在到了最脏的部分:代码违反了承诺,一次读取了4或8个8位字节(a long int),并使用了带无符号加法的位技巧来快速确定在这4或8位中是否有零字节字节-它使用特制的数字来导致进位更改由位掩码捕获的位。从本质上讲,这将找出掩码中4或8个字节中的任何一个是否为零,这比循环遍历每个字节要快得多。最后,最后有一个循环,找出哪个字节是第一个零(如果有的话),并返回结果。

最大的问题是,在sizeof (unsigned long) - 1偶尔的sizeof (unsigned long)情况下,它将读取超过字符串末尾的内容-仅当空字节位于最后访问的字节中时(即,在低位字节序中是最高有效,而在高位字节序中是最低有效)。 ,它不会超出范围访问数组!


即使用于strlen在C标准库中实现的代码也是错误的代码。它具有几个实现定义和未定义的方面,并且不应在任何地方使用而不是系统提供的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

即坏事发生了。


120
回复:“非常可疑的速度技巧和假设”-即在可移植代码中非常可疑。标准库是为特定的编译器/硬件组合编写的,具有语言定义保留为未定义的事物的实际行为的知识。是的,大多数人不应该那样写代码,但是在实现标准库的情况下,不可移植并不是天生的坏事。
皮特·贝克尔,

4
同意,不要自己写这样的东西。或几乎永远不会。过早的优化是万恶之源。(在这种情况下,实际上可能是出于动机)。如果最终在同一长字符串上执行了许多strlen()调用,则您的应用程序可能会以不同的方式编写。作为示例,您可以在创建字符串时就已经将stringlength保存到变量中了,根本不需要调用strlen()。
ghellquist,

65
@ghellquist:优化常用的库调用几乎不是“过早的优化”。
jamesqf

7
@Antti Haapala:究竟您为什么认为strlen应该是O(1)?我们这里有几个实现,所有实现都是O(n),但是具有不同的常数乘数。您可能并不认为这很重要,但是对于我们中的某些人来说,一种O(n)算法的实现要以微秒为单位的效果要好于花费数秒甚至是毫秒的算法,因为在算法中它可能被称为数十亿次。工作过程。
jamesqf

8
@PeteBecker:不仅如此,在标准库的上下文中(虽然在本例中不是很多),编写非便携式代码也可以成为一种规范,因为标准库的目的是为实现特定内容提供标准接口。
PlasmaHH

148

关于此内容的一些详细信息/背景,在评论中有很多(轻微或完全)错误的猜测。

您正在查看glibc的优化的C后备优化的实现。(对于没有手写asm实现的ISA)。或该代码的旧版本,仍在glibc源代码树中。 https://code.woboq.org/userspace/glibc/string/strlen.c.html是基于当前glibc git树的代码浏览器。显然,包括MIPS在内的一些主流glibc目标仍在使用它。(感谢@zwol)。

在x86和ARM等流行的ISA上,glibc使用手写的asm

因此,对此代码进行任何更改的动机都低于您的想象。

此bithack代码(https://graphics.stanford.edu/~seander/bithacks.html#ZeroInWord)并不是服务器/台式机/笔记本电脑/智能手机上实际运行的代码。它比幼稚的一次字节循环更好,但是与现代CPU的高效asm(尤其是x86,其中AVX2 SIMD允许通过几个指令检查32个字节,每个时钟允许32到64个字节)相比即使是这个bithack也非常糟糕。如果数据在具有2 /时钟矢量负载和ALU吞吐量的现代CPU上的L1d高速缓存中很热,则在主循环中循环(例如,对于启动开销不占主导的中型字符串)。

glibc使用动态链接技巧将其解析strlen为适合您CPU的最佳版本,因此即使在x86内,也有SSE2版本(16字节向量,x86-64的基线)和AVX2版本(32字节向量)。

x86在向量寄存器和通用寄存器之间具有有效的数据传输,这使其独特(?)适用于使用SIMD来加快隐式长度字符串上的函数的速度,其中循环控制取决于数据。 pcmpeqb/ pmovmskb使得可以一次测试16个单独的字节。

glibc具有一个类似于使用AdvSIMD的AArch64版本,以及一个用于vector- > GP寄存器使管线停滞的AArch64 CPU版本,因此它确实使用了bithack。但是一旦命中,就使用count-leading-zeros来查找内部寄存器字节,并在检查页面交叉之后利用AArch64的有效未对齐访问。

还相关:为什么启用优化后,此代码的速度是6.5倍慢?有关在x86 asm中strlen使用大型缓冲区和简单的asm实现的快与慢的更多详细信息,这可能对gcc知道如何内联非常有用。(某些gcc版本不明智地内联了rep scasb,这非常慢,或者像这样每次一次4字节的bithack。因此,GCC内联形式的食谱需要更新或禁用。)

Asm没有C风格的“不确定行为”;您可以随意访问内存中的字节,这是安全的,而且包含任何有效字节的对齐负载也不会出错。内存保护以对齐页面的粒度进行;对齐的访问范围比不能跨越页面边界的范围窄。 在x86和x64的同一页面中读取缓冲区的末尾是否安全? 同样的道理也适用于此C hack使编译器创建的用于此功能的独立非内联实现的机器代码。

当编译器发出代码来调用未知的非内联函数时,它必须假定该函数会修改任何/所有全局变量以及它可能具有指向的任何内存。也就是说,除了本地电话外,所有未进行地址转义的内容都必须在整个呼叫过程中在内存中同步。显然,这适用于用asm编写的函数,也适用于库函数。如果您不启用链接时间优化,则它甚至适用于单独的翻译单元(源文件)。


为什么这作为glibc的一部分是安全的并非如此。

最重要的因素是,它strlen不能内联到其他任何内容。 这样做不安全;它包含严格混叠的UBchar通过读取数据unsigned long*)。 char*允许为别的东西加上别名,但是反过来不是真的

这是提前编译库(glibc)的库函数。 它不会与链接时间优化内联到调用方中。 这意味着它仅需编译为独立版本的安全机器代码strlen。它不一定是便携式的/安全的C。

GNU C库仅需与GCC一起编译。显然,即使它们支持GNU扩展,也不支持使用clang或ICC对其进行编译。GCC是一种提前编译器,可以将C源文件转换为机器代码的目标文件。不是解释器,因此除非在编译时内联,否则内存中的字节只是内存中的字节。也就是说,当具有不同类型的访问发生在彼此不内联的不同功能中时,严格别名的UB并不危险。

请记住,strlen的行为是 ISO C标准定义。该功能名称专门是实现的一部分。除非您使用-fno-builtin-strlen,否则像GCC这样的编译器甚至会将名称视为内置函数,因此strlen("foo")可以是编译时常量3。库中的定义在gcc决定实际向其发出调用而不是内联其自己的配方等时才使用。

如果UB 在编译时对编译器不可见,您将获得健全的机器代码。本机代码必须工作,为无UB的情况下,即使你想要到,有没有办法为ASM检测类型调用者用什么来把数据放到指向的内存。

Glibc被编译为一个独立的静态或动态库,该库无法内联链接时间优化。glibc的构建脚本不会创建包含机器代码+ gcc GIMPLE内部表示形式的“胖”静态库,以便在插入程序时进行链接时优化。(即,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每个指针始终对齐且p & sizeof(long)-1始终为零,则unaligned-prologue循环将始终运行0次迭代。)但是,如果您有一个非ASCII字符集,其中chars为9或12位宽0x8080...是错误的模式。
  • (也许) unsigned long是4或8个字节。也许它实际上可以在unsigned long最大为8的任何大小上工作,并使用assert()进行检查。

那两个都是不可能的UB,它们只是某些C实现的不可移植性。这段代码是(或曾经)在其可以运行的平台上的C实现的一部分,所以很好。

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

  • 包含任何有效字节的对齐负载不会出错,并且只要您忽略实际需要的对象之外的字节即可,它是安全的。(在每个GNU系统和所有普通CPU上都正确存在asm,因为内存保护是使用对齐页面粒度进行的。 通过x86和x64上同一页面内缓冲区的末尾读取是否安全?在UB时在C中安全吗?在编译时不可见。在没有内联的情况下就是这种情况。编译器无法证明从第一个读取的内容0是UB;例如,它可能是char[]包含{1,2,0,3}以下内容的C 数组)

最后一点是什么使得在这里可以安全地读取C对象的末尾。即使在与当前编译器内联时,这也是非常安全的,因为我认为他们当前不认为暗示执行路径不可用。但是无论如何,如果您让此内联,则严格的别名已经成为了一个热门话题。

然后,您将遇到问题,例如Linux内核的旧的不安全的memcpy CPP宏,该使用了指向的指针广播unsigned longgcc,严格混叠和恐怖故事)。

strlen可以追溯到您可以摆脱一般的东西的时代 ; 以前在GCC3之前没有“仅当不内联时”需要注意的地方,因此它非常安全。


仅在跨越呼叫/重拨边界时才可见的UB不会伤害我们。(例如,在调用此char buf[]阵列上,而不是unsigned long[]浇注到一个const char*)。机器代码一成不变后,就只处理内存中的字节。非内联函数调用必须假定被调用方读取任何/所有内存。


安全地编写此文件,而无需严格混淆UB

GCC类型属性may_alias给出了一个类型相同的别名,任何的待遇char*。(由@KonradBorowsk建议)。目前,GCC标头将其用于x86 SIMD矢量类型,__m128i因此您可以始终放心使用_mm_loadu_si128( (__m128i*)foo )。(有关此功能的含义和含义的更多详细信息,请参见在硬件向量指针和相应类型之间的“ reinterpret_cast”操作是否是未定义的行为?

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

  aliasing_ulong *longword_ptr = (aliasing_ulong *)char_ptr;
  for (;;) {
     unsigned long ulong = *longword_ptr++;  // can safely alias anything
     ...
  }
}

您也可以使用aligned(1)来表示类型alignof(T) = 1
typedef unsigned long __attribute__((may_alias, aligned(1))) unaligned_aliasing_ulong;

用ISO表示别名加载的一种可移植方式是使用memcpy,现代编译器确实知道如何内联为单个加载指令。例如

   unsigned long longword;
   memcpy(&longword, char_ptr, sizeof(longword));
   char_ptr += sizeof(longword);

这也适用于未对齐的载荷,因为memcpy可以按char时间访问。但实际上,现代编译器了解memcpy非常。

这里的危险是,如果GCC不知道的肯定char_ptr是字对齐的,它不会内联它可能不支持未对齐载荷ASM一些平台。例如MIPS64r6之前的MIPS或更旧的ARM。如果有一个实际的函数调用memcpy只是为了加载一个单词(并将其保留在其他内存中),那将是一场灾难。GCC有时可以看到代码何时对齐指针。或者,在一次字符循环达到一个超长边界之后,您可以使用
p = __builtin_assume_aligned(p, sizeof(unsigned long));

这不能避免读取可能的对象的UB,但是使用当前的GCC在实践中并不危险。


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

当您想要广泛使用的标准库函数的所有性能下降时,手动优化的asm甚至会更好。特别是对像memcpy,也strlen。在这种情况下,使用带有x86内在函数的C来利用SSE2并不容易。

但是在这里,我们只是在谈论没有ISA特定功能的朴素vs. bithack C版本。

(我认为我们可以将其作为一种strlen广泛使用的前提,使其尽可能快地运行非常重要。因此问题就变成了我们是否可以从更简单的来源获得高效的机器代码。不,我们不能。)

当前的GCC和clang无法自动向量化循环,在第一次迭代之前未知迭代次数。(例如,必须运行第一次迭代之前检查循环是否将至少运行16次迭代。)例如,在给定当前电流的情况下,可以进行自动向量化memcpy(显式长度缓冲区),而不能进行strcpy或strlen(隐式长度字符串)编译器。

这包括搜索循环,或任何其他与数据相关的循环if()break以及计数器。

ICC(Intel的x86编译器)可以自动向量化某些搜索循环,但仍只能strlen像OpenBSD的libc一样,为简单的/天真C生成天真字节一次的asm 。(Godbolt)。(来自@Peske的答案)。

手动优化的libc strlen对于当前编译器的性能是必需的。当主存储器每个周期可以跟上大约8个字节,而一级缓存可以每个周期提供16到64个字节时,一次只占用1个字节(在宽的超标量CPU上每个周期可以展开2个字节)是可悲的。(自Haswell和Ryzen以来,现代主流x86 CPU上每个周期2个32字节负载。不算AVX512会仅使用512位向量就降低时钟速度;这就是为什么glibc可能并不急于添加AVX512版本。尽管具有256位向量,但被屏蔽的AVX512VL + BW比较成一个掩码,ktestkortest可以使strlen通过减少其uops /迭代更多的超线程友好。)

我在这里包括非x86,即“ 16字节”。例如,我认为大多数AArch64 CPU至少可以做到这一点,当然还有更多。有些具有足够的执行吞吐量strlen以跟上该负载带宽。

当然,使用大型字符串的程序通常应跟踪长度,以避免不得不重做频繁发现隐式长度C字符串的长度。但是中短长度的性能仍然可以从手写实现中受益,而且我敢肯定,某些​​程序的确会在中长字符串上使用strlen。


12
一些注意事项:(1)当前无法使用GCC以外的任何编译器来编译glibc本身。(2)目前由于启用了链接时间优化,目前无法编译glibc本身,因为正是这种情况,如果允许进行内联,则编译器将看到UB。(3)CHAR_BIT == 8是POSIX要求(从-2001版本开始;请参见此处)。(4)的C后备实现strlen用于某些受支持的CPU,我相信最常见的是MIPS。
zwol

1
有趣的是,可以通过使用__attribute__((__may_alias__))属性来固定严格混叠的UB (这是不可移植的,但是对于glibc来说应该没问题)。
康拉德·波罗夫斯基

1
@SebastianRedl:您可以通过读写任何对象char*,但是通过读写char 对象(例如的一部分char[])仍然是UB long*严格的别名规则和'char *'指针
Peter Cordes

1
C和C ++标准都在说,CHAR_BIT必须至少为8(QV附件E C11的),所以至少7位的char是不是一个语言律师需要操心。这是由以下要求引起的:“对于UTF-8字符串文字,数组元素具有type char,并使用多字节字符序列的字符进行初始化(如UTF-8编码)。”
戴维斯洛

2
似乎这种分析是提出补丁的良好基础,该补丁除了可以做出令人敬畏的答案外,还可以使代码在当前禁用的优化面前更加健壮。
Deduplicator

61

在您链接的文件的注释中对此进行了解释:

 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语言中,可以详细说明效率。

像在此代码中那样,一次遍历单个字符寻找空位的效率要比一次测试多个字节的效率低。

额外的复杂性来自需要确保被测字符串在正确的位置对齐以一次开始测试一个以上的字节(沿着长字边界,如注释中所述),以及需要确保假设使用代码时,不会违反有关数据类型大小的信息。

大多数(但不是全部)现代软件开发中,不必要关注效率细节,或者不值得付出额外代码复杂性的代价。

像这样链接效率的地方确实值得关注,这是在标准库中,例如您链接的示例。


如果您想了解有关单词边界的更多信息,请参阅此问题以及本出色的维基百科页面


39

除了这里的好答案之外,我想指出的是,问题中链接的代码是针对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代码似乎是没有asm实现的ISA的后备实现。strlen根据架构有不同的实现。例如,amd64strlen的代码是asm。与PeterCordes的注释/ 答案相似,后者指出非后备GNU实现也为asm。


5
这很好地说明了OpenBSD与GNU工具中优化的不同值。
杰森

11
这是glibc的可移植后备实现。所有主要的ISA在glibc中都有手写的asm实现,并在帮助时使用SIMD(例如,在x86上)。见code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/...code.woboq.org/userspace/glibc/sysdeps/aarch64/multiarch/...
彼得·科德斯

4
甚至OpenBSD版本也有原始版本可以避免的缺陷!s - str如果结果无法用表示,则的行为不确定ptrdiff_t
安蒂·哈帕拉

1
@AnttiHaapala:在GNU C中,最大对象大小为PTRDIFF_MAX。但是mmap至少仍然有可能比Linux上的内存更多(例如,在x86-64内核下的32位进程中,在开始出现故障之前,我可以连续映射大约2.7GB)。有关OpenBSD的IDK;内核可能无法在return没有段错误或大小限制的情况下达到目标。但是,是的,您认为避免理论C UB的防御性编码将是OpenBSD想要做的事情。即使strlen不能内联,真正的编译器也只能将其编译为减法。
彼得·科德斯

2
完全是@PeterCordes。在OpenBSD中也一样,例如i386程序集:cvsweb.openbsd.org/cgi-bin/cvsweb/src/lib/libc/arch/i386/string/…–
dchest

34

简而言之,这是标准库可以通过了解编译器使用的编译器来实现的性能优化-除非您正在编写标准库并且可能依赖于特定的编译器,否则您不应编写这样的代码。具体来说,它同时处理对齐字节数-在32位平台上为4,在64位平台上为8。这意味着它可以比纯字节迭代快4或8倍。

为了解释它是如何工作的,请考虑以下图像。此处假定为32位平台(4字节对齐)。

假设“世界你好!”的字母“ H” 字符串作为的参数提供strlen。因为CPU喜欢在内存中对齐(最好是address % sizeof(size_t) == 0),所以对齐之前的字节将使用慢速方法逐字节处理。

然后,对于每个对齐大小的块,通过计算(longbits - 0x01010101) & 0x80808080 != 0来检查整数内的任何字节是否为零。当至少一个字节的字节数大于时,此计算将产生误报0x80,但它经常不应该工作。如果不是这种情况(因为它在黄色区域中),则通过对齐大小来增加长度。

如果整数中的任何字节结果为零(或0x81),则将逐字节检查字符串以确定零位。

这可以进行越界访问,但是由于它在对齐范围内,因此很有可能不是很好,内存映射单元通常不具有字节级精度。


此实现是glibc的一部分。GNU系统使用页面粒度进行内存保护。所以是的,包含任何有效字节的对齐负载是安全的。
彼得·科德斯

size_t不保证对齐。
SS安妮

32

您希望代码正确,可维护且快速。这些因素具有不同的重要性:

“正确”是绝对必要的。

“可维护”取决于您要维护代码的数量:strlen已经成为40多年来的标准C库函数。它不会改变。因此,对于此功能,可维护性并不重要。

“快速”:在许多应用程序中,strcpy,strlen等会占用大量执行时间。要通过改进编译器来实现与这种复杂但不是很复杂的strlen实现相同的整体速度增益,将需要付出巨大的努力。

快速是另一个优点:当程序员发现调用“ strlen”是最快的方法,他们可以测量字符串中的字节数时,他们不再试图编写自己的代码来使事情变得更快。

因此,相对于您将要编写的大多数代码而言,对于Strlen而言,速度更为重要,而可维护性则不那么重要。

为什么必须这么复杂?假设您有一个1,000字节的字符串。简单的实现将检查1,000个字节。当前的实现可能会一次检查64位字,这意味着125个64位或8字节字。它甚至可能使用向量指令一次检查32个字节,这将变得更加复杂甚至更快。使用向量指令会导致代码更加复杂,但非常简单,检查64位字中的八个字节之一是否为零需要一些巧妙的技巧。因此,对于中长字符串,此代码的速度预计将提高四倍左右。对于像strlen这样重要的函数,值得编写一个更复杂的函数。

PS。该代码不是很可移植。但这是标准C库的一部分,该库也是实现的一部分-它不必具有可移植性。

PPS。有人发布了一个示例,其中调试工具抱怨访问字符串末尾的字节。可以设计一种实现,以保证以下各项:如果p是指向字节的有效指针,则对同一对齐块中字节的任何访问(根据C标准,这将是未定义的行为)将返回未指定的值。

PPPS。英特尔已向其后来的处理器中添加了指令,这些指令构成了strstr()函数的构建块(在字符串中找到子字符串)。他们的描述令人难以置信,但是它们可以使特定功能快100倍。(基本上,给定一个包含“ Hello,world!”的数组a和一个以16个字节“ HelloHelloHelloH”开头并包含更多字节的数组b,它可以确定字符串a不会早于索引15开始出现在b中) 。


或者...如果我发现我正在做很多基于字符串的处理并且有瓶颈,我可能会实现我自己的Pascal Strings版本,而不是改善strlen ...
Baldrickk

1
没有人要求改善生气。但是,使其足够好可以避免像人们实现自己的字符串那样胡说八道。
gnasher729


24

简要地说:在每次可以获取大量数据的体系结构上,逐字节检查字符串可能会很慢。

如果对空终止的检查可以在32位或64位的基础上进行,则它将减少编译器必须执行的检查量。这就是链接代码在考虑特定系统的情况下尝试执行的操作。他们对寻址,对齐,缓存使用,非标准编译器设置等进行了假设。

在您的示例中逐字节读取将是在8位CPU上或在编写以标准C语言编写的可移植lib时的明智方法。

在C标准库中寻找如何编写快速/良好代码的建议不是一个好主意,因为它将是不可移植的,并且依赖于非标准的假设或定义不明确的行为。如果您是初学者,则阅读此类代码可能比教育更具危害性。


1
当然,优化器极有可能展开或自动向量化此循环,并且预取器可以轻松地检测到此访问模式。这些技巧在现代处理器上是否真正重要,需要进行测试。如果要取胜,可能是使用矢量指令。
russbishop

6
@ russbishop:您希望如此,但没有。GCC和clang完全无法进行自动矢量化循环,因为在第一次迭代之前未知迭代次数。这包括搜索循环或任何其他与数据相关的循环if()break。ICC可以对这些循环进行自动向量化,但是IDK对于幼稚的strlen表现如何。是的,SSE2 pcmpeqb/ pmovmskb非常对的strlen好,同时测试16个字节。 code.woboq.org/userspace/glibc/sysdeps/x86_64/strlen.S.html是glibc的SSE2版本。另请参阅此问答
彼得·科德斯

哎呀,那是不幸的。我通常非常反对UB,但是正如您所指出的那样,C字符串需要从技术上读取UB缓冲区末尾以甚至允许向量化。我认为这同样适用于ARM64,因为它需要对齐。
russbishop

-6

其他答案未提及的重要一件事是,FSF对于确保专有代码不会将其纳入GNU项目非常谨慎。在“ 参考专有程序”下的GNU编码标准中,有一条警告,要求以某种方式将您的实现与现有的专有代码相混淆:

在任何情况下或在使用GNU的过程中,请勿使用Unix源代码!(或任何其他专有程序。)

如果您对Unix程序的内部有一个模糊的回忆,这并不完全意味着您不能编写它的模仿物,但是请尝试按照不同的方式在内部组织模仿物,因为这可能会使Unix版本与您的结果无关且无关。

例如,通常对Unix实用程序进行了优化以最大程度地减少内存使用。如果您追求速度,则您的程序将大为不同。

(强调我的。)


5
这如何回答这个问题?
SS安妮

1
OP中的问题是“这种简单的代码是否会更好地工作?”,而这个问题并非总是根据技术优点来决定。对于像GNU这样的项目,避免法律陷阱是代码“更好地工作”的重要组成部分,并且“显而易见的”实现strlen()很可能会与现有代码相似或相同。像glibc的实现那样“疯狂”的东西无法追溯到那样。考虑到在这rangeCheck11行代码中有多少法律争执!—在Google / Oracle的战斗中,我想说FSF的担忧是有根据的。
杰克·凯利
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.