web-dev-qa-db-ja.com

glibcのstrlenをすばやく実行するために、なぜそれほど複雑にする必要があるのですか?

私はstrlenコードを探していました here そして、コードで使用される最適化が本当に必要かどうか疑問に思っていましたか?たとえば、次のようなものが同等に優れているのではないのでしょうか?

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 ([email protected]),
   with help from Dan Sahlin ([email protected]);
   commentary by Jim Blandy ([email protected]).

   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)

このバージョンがすぐに実行されるのはなぜですか?

それは多くの不必要な仕事をしていませんか?

286
user11954200

あなたは必要ない必要があり、あなたは決してそのようなコードを書かない-特に、Cコンパイラ/標準ライブラリベンダーでない場合。 strlenを実装するために使用されるコードで、非常に疑わしい速度のハックと仮定(アサーションでテストされていないか、コメントで言及されていないもの)があります。

  • _unsigned long_は4または8バイトです
  • バイトは8ビットです
  • ポインターは_unsigned long long_ではなく_uintptr_t_にキャストできます
  • 2つまたは3つの最下位ビットがゼロであることを確認するだけで、ポインターを整列できます。
  • _unsigned long_ sとして文字列にアクセスできます
  • 悪影響を与えることなく配列の終わりを超えて読み取ることができます。

さらに、優れたコンパイラーは、

_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. で次のように記述されます。

説明

  1. strlen関数は、sが指す文字列の長さを計算します。

戻り値

  1. strlen関数は、終端のヌル文字に先行する文字数を返します。

これで、sが指す文字列が、文字列と終端のNULを含むのに十分な長さの文字配列にあった場合、behaviournullターミネータを超えて文字列にアクセスした場合、undefinedになります

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

したがって、完全に移植可能な/標準に準拠したCで正しくを実装するonly方法が実際の方法ですあなたのquestionに書かれていますが、些細な変換を除きます-ループなどを展開することで速くなるふりをすることができますが、それでも実行する必要があります一度に1バイト

(コメンターが指摘したように、厳密な移植性が重すぎる場合、合理的または既知の安全な仮定を活用することは必ずしも悪いことではありません。特にpart of1つの特定のC実装。ただし、どのように/いつ曲げることができるかを知る前に、ルールを理解する必要があります。


リンクされたstrlen実装は、ポインターが_unsigned long_の自然な4または8バイトのアライメント境界を指すまで、最初にバイトを個別にチェックします。 C規格では、適切に位置合わせされていないポインターへのアクセスはundefined behaviourであるため、次のダーティートリックが均一になるように絶対に行う必要があると述べています。より汚れた。 (実際には、x86以外の一部のCPUアーキテクチャでは、誤ったWordまたはダブルワードのロードが失敗します。Cはnotポータブルアセンブリ言語ですが、このコードはそのように使用しています)。また、整列されたブロック(4kiB仮想メモリページなど)でメモリ保護が機能する実装で障害が発生するリスクなしに、オブジェクトの終わりを超えて読み取ることを可能にします。

コードはbreaks約束を破り、一度に4または8個の8ビットバイトを読み取り(_long int_)、少しのトリックを使用しますそれらの4バイトまたは8バイト内にanyゼロバイトがあるかどうかを迅速に把握するための符号なし加算ビットマスクによって。本質的には、マスク内の4バイトまたは8バイトのいずれかが、これらの各バイトをループするよりも速く高速であるかどうかを判断します。最後に、最後にループがあり、whichバイトが最初のゼロ(ある場合)であり、結果を返します。

最大の問題は、sizeof (unsigned long) - 1sizeof (unsigned long)の回数を超えた場合、文字列の終わりを超えて読み取ることです-nullバイトがlastにある場合のみアクセスされたバイト(つまり、リトルエンディアンで最上位、ビッグエンディアンで最下位)、それはnot範囲外の配列にアクセスします!


C標準ライブラリでstrlenを実装するために使用されていても、コードはbadコードです。いくつかの実装定義および未定義の側面があり、システム提供のstrlenの代わりにanywhereを使用しないでくださいここで関数の名前を_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
_

すなわち、悪いことが起こった。

232
Antti Haapala

リンクしたファイルのコメントで説明されています。

 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では、効率について詳細に推論することができます。

このコードのように、一度に1バイト以上をテストするよりも、nullを探す個々の文字を反復処理する方が効率的ではありません。

追加の複雑さは、テスト中の文字列を一度に1バイト以上(コメントに記載されているロングワード境界に沿って)テストを開始するために正しい場所に配置することを保証する必要があることと、コードが使用される場合、データ型のサイズについては違反されません。

most(ただし、すべてではない)現代のソフトウェア開発では、効率の詳細にこの注意を払う必要はないか、余分なコードの複雑さに見合う価値はありません。

このような効率に注意を払うのが理にかなっているのは、リンクした例のような標準ライブラリです。


Wordの境界について詳しく知りたい場合は、 この質問この優れたウィキペディアのページ をご覧ください。

61
Timothy Jones

ここでのすばらしい答えに加えて、質問にリンクされているコードは、GNUによるstrlenの実装用であることを指摘したいと思います。

strlenのOpenBSD実装 は、質問で提案されたコードに非常に似ています。実装の複雑さは作成者が決定します。

...
#include <string.h>

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

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

DEF_STRONG(strlen);

[〜#〜] edit [〜#〜]:上記でリンクしたOpenBSDコードは、ISAのないフォールバック実装のようです独自のasm実装。アーキテクチャに応じて、strlenの異なる実装があります。 AMD64 strlen のコードは、たとえばasmです。 PeterCordesのコメント/ - answer 非フォールバックGNU実装もasmです。

39
Peschke

要するに、これは標準ライブラリがどのコンパイラでコンパイルされているかを知ることで実行できるパフォーマンスの最適化です。標準ライブラリを作成していて特定のコンパイラに依存できる場合を除き、このようなコードを書くべきではありません。具体的には、アラインメント数のバイトを同時に処理します。32ビットプラットフォームでは4、64ビットプラットフォームでは8です。これは、ナイーブバイトの反復よりも4〜8倍高速になることを意味します。

これがどのように機能するかを説明するために、次の画像を検討してください。ここでは32ビットプラットフォームを想定しています(4バイトのアライメント)。

「Hello、world!」の文字「H」としましょう。 strlenの引数として文字列が提供されました。 CPUは、メモリ(理想的にはaddress % sizeof(size_t) == 0)に整列させることを好むため、整列前のバイトは低速メソッドを使用してバイト単位で処理されます。

次に、整列サイズの各チャンクに対して、(longbits - 0x01010101) & 0x80808080 != 0を計算することにより、整数内のバイトのいずれかがゼロであるかどうかをチェックします。この計算では、少なくとも1バイトが0x80よりも大きい場合に誤検出がありますが、多くの場合、動作するはずです。そうでない場合(黄色の領域にあるように)、長さは配置サイズによって増加します。

整数内のバイトのいずれかがゼロ(または0x81)であることが判明した場合、文字列はバイトごとにチェックされ、ゼロの位置が決定されます。

これにより、境界外アクセスが可能になりますが、アライメント内にあるため、大丈夫ではない可能性が高く、メモリマッピングユニットには通常、バイトレベルの精度がありません。

34
Konrad Borowski

コードを正しく、保守可能で、高速にする必要があります。これらの要因の重要性は異なります。

「正しい」ことは絶対に不可欠です。

「維持可能」は、コードをどれだけ維持するかによって異なります。strlenは40年以上にわたって標準Cライブラリ関数です。変わらないでしょう。したがって、保守性は非常に重要ではありません-この機能にとって。

「高速」:多くのアプリケーションでは、strcpy、strlenなどはかなりの実行時間を使用します。コンパイラを改善することにより、strlenのこの複雑ではあるがそれほど複雑ではない実装と同じ全体的な速度の向上を達成するには、勇敢な努力が必要です。

高速であることにはもう1つの利点があります。プログラマーが「strlen」の呼び出しが文字列内のバイト数を測定できる最速の方法であるとわかったとき、物事を速くするために独自のコードを書くことはもうありません。

そのため、これから記述するほとんどのコードよりも、速度がはるかに重要であり、保守性はそれほど重要ではありません。

なぜそんなに複雑なのでしょうか? 1,000バイトの文字列があるとします。単純な実装では1,000バイトを調べます。現在の実装では、64ビットワードを一度に検査する可能性があります。つまり、64ビットワードまたは8バイトワードは125です。一度に32バイトを調べるベクトル命令を使用することもありますが、これはさらに複雑で高速です。ベクトル命令を使用すると、コードが少し複雑になりますが、非常に簡単です。64ビットWordの8バイトの1つがゼロかどうかを確認するには、巧妙なトリックが必要です。したがって、中程度から長い文字列の場合、このコードは約4倍高速になると予想されます。 strlenと同じくらい重要な関数の場合、より複雑な関数を作成する価値があります。

PS。コードはあまり移植性がありません。しかし、それは実装の一部である標準Cライブラリの一部です-移植可能である必要はありません。

PPS。誰かが、文字列の終わりを超えてバイトにアクセスすることについてデバッグツールが文句を言う例を投稿しました。以下を保証する実装を設計することができます:pがバイトへの有効なポインターである場合、C標準に従って未定義の動作となる同じ位置合わせブロック内のバイトへのアクセスは、指定されていない値を返します。

PPPS。 Intelは、strstr()関数(文字列内の部分文字列を見つける)のビルディングブロックを形成する後のプロセッサに命令を追加しました。それらの説明は気が遠くなるでしょうが、その特定の機能をおそらく100倍速くすることができます。 (基本的に、 "Hello、world!"を含む配列aと16バイト "HelloHelloHelloH"で始まり、より多くのバイトを含む配列bが与えられた場合、文字列aはインデックス15で始まるよりも前にbで発生しないことがわかります) 。

32
gnasher729

簡単に説明すると、一度に大量のデータをフェッチできるアーキテクチャでは、バイト単位で文字列をチェックすると潜在的に時間がかかります。

Null終了のチェッ​​クを32ビットまたは64ビットベースで実行できる場合、コンパイラが実行する必要のあるチェックの量が削減されます。特定のシステムを念頭に置いて、リンクされたコードが実行しようとするものです。それらは、アドレッシング、アライメント、キャッシュの使用、非標準のコンパイラ設定などについて仮定します。

あなたの例のようにバイトごとに読むことは、8ビットCPU上での賢明なアプローチでしょう。または標準Cで書かれた移植可能なライブラリを書くときです。

Cの標準ライブラリを見て、高速/良いコードの書き方をアドバイスするのは良い考えではありません。あなたが初心者の場合、そのようなコードを読むことは教育的よりも有害である可能性が高いでしょう。

24
Lundin