glibcのstrlenをすばやく実行するには、なぜ複雑にする必要があるのですか?


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
コメントは詳細な議論のためのものではありません。この会話はチャットに移動しました
サミュエルLiew

18
将来の参考のために、GNU libcの公式ソースリポジトリは< sourceware.org/git/?p=glibc.git >にあります。< sourceware.org/git/?p=glibc.git;a=blob;f=string/… >確かに上記と同様のコードが表示されます。ただし、sysdepsglibcでサポートされているほとんどのアーキテクチャー(代わりに使用されない最も一般的に使用されるアーキテクチャーはMIPSです)では、代わりにディレクトリーからの手書きのアセンブリ言語実装が使用されます。
zwol

9
これを主に意見に基づくものとして締めくくる投票。「xxxはxxxで本当に必要ですか?」人々の意見に主観的です。
SSアンヌ

2
JL2210 @:良い点、パフォーマンスが必要な場合、我々は必要なだけの理由、それは、不思議に思っているように聞こえるしないタイトルに疑問の精神をキャプチャするためにタイトルを固定し、これらの性能を得るために最適化を。
Peter Cordes

9
@ JL2210 FWIW、元のタイトルは「なぜCで複雑になるのか」というものでしたが、「広すぎる」として閉じられ、再度開かれ、その後「主に意見ベース」として閉じられました。私はこれを修正しようとしました(その間、「あなたは私の質問を破った!」と「あなたたちは編集力を乱用している!」というクロスファイアを起こしました)。これは問題がありました(「このコードは私には理解するには複雑すぎます」はQ&Aにはあまり適していません-IMOは個別指導の要求であり、回答ではありません)。私は60フィートのポールで再びそれに触れていません:)

回答:


233

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

  • unsigned long 4または8バイトのいずれか
  • バイトは8ビット
  • ポインタがにキャストすることができませんunsigned long longuintptr_t
  • 2つまたは3つの最下位ビットがゼロであることを確認するだけで、ポインターを整列できます。
  • 文字列にunsigned longs としてアクセスできます
  • 配列の最後を超えても悪影響はありません。

さらに、優れたコンパイラは、次のように記述されたコードを置き換えることもできます

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関数は、終端のnull文字の前にある文字数を返します。

ここで、が指す文字列sが、文字列と終端のNULを含むのに十分な長さの文字の配列内にある場合、たとえば次のようにnullターミネータを越えて文字列にアクセスすると、動作未定義になります。

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

したがって、完全に移植可能/標準に準拠したCでこれを正しく実装する唯一の方法は、簡単な変換を除いて、質問に書かれている方法です-ループを展開するなどして高速になりがちですが、それでも実行する必要があります一度に1バイト

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


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

汚い部分があります:コードは約束を破り、一度に4または8の8ビットバイトを読み取り(a long int)、ビットトリックを符号なしの追加で使用して、それらの4または8内にゼロバイトがあったどうかをすばやく理解しますバイト-特別に細工された数値を使用して、キャリービットがビットマスクでキャッチされるビットを変更するようにします。本質的に、これにより、マスク内の4バイトまたは8バイトのいずれかがゼロであるかどうかが、これらの各バイトをループするより高速であると考えられます。最後端のループを把握することがあるどのあれば、最初のゼロでバイト、および結果を返します。

最大の問題はsizeof (unsigned long) - 1sizeof (unsigned long)場合によっては文字列の終わりを超えて読み取られることです-nullバイトが最後にアクセスされたバイトにある場合のみ(つまり、リトルエンディアンが最も重要で、ビッグエンディアンが最も重要ではありません) 、それは範囲外の配列にアクセスしません


コード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()呼び出しを実行すると、アプリケーションの記述が異なる可能性があります。例としてmigtは、文字列の作成時にすでに文字列長を変数に保存しており、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)は、実際にserver / desktop / laptop / smartphoneで実行されているものではありません。単純なバイト単位のループよりも優れていますが、このビットハックでさえ、最新のCPUの効率的なasm(特に、AVX2 SIMDが2つの命令で32バイトをチェックできるため、クロックあたり32〜64バイトを許可するx86)に比べるとかなり悪いです。 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を使用します。しかし、カウント先行ゼロを使用して、ヒットしたレジスター内のバイトを検索し、ページクロスをチェックした後、AArch64の効率的な非境界整列アクセスを利用します。

また関連:最適化を有効にすると、なぜこのコードは6.5倍遅くなるのですか?strlen大きなバッファーと、gccがインライン化する方法を知るのに適した単純なasm実装でのx86 asmの高速と低速の詳細については、(一部のgccバージョンrep scasbは、非常に遅いインラインで、またはこのように一度に4バイトずつのビットハックです。したがって、GCCのインラインストレンレシピは、更新または無効にする必要があります。)

AsmにはCスタイルの「未定義の動作」はありません。メモリ内のバイトに好きな方法でアクセスしても安全であり、有効なバイトを含む整列ロードは失敗しません。メモリー保護は、ページの細分性を調整して行われます。それよりも狭い境界整列アクセスは、ページ境界を越えることができません。 x86とx64の同じページ内のバッファーの終わりを超えて読み取っても安全ですか? 同じ理由が、このCハックがコンパイラーにこの関数のスタンドアロンの非インライン実装用に作成させるマシンコードにも当てはまります。

コンパイラーが不明な非インライン関数を呼び出すコードを発行する場合、関数がポインターを持っている可能性のあるすべて/すべてのグローバル変数とメモリーを変更すると想定する必要があります。つまり、アドレスをエスケープしていないローカルを除くすべてが、呼び出し全体でメモリ内で同期している必要があります。これは、明らかにasmで記述された関数に適用されますが、ライブラリ関数にも適用されます。リンク時の最適化を有効にしない場合、個別の翻訳単位(ソースファイル)にも適用されます。


なぜこれがglibcの一部として安全であるそれ以外の場合は安全でないのか。

最も重要な要素は、これstrlenが他のものにインライン化できないことです。 それは安全ではありません。厳密なエイリアスのUBが含まれています(をchar介してデータを読み取るunsigned long*)。 char*それ以外のエイリアスは許可されていますが、その逆は当てはまりません

これは、事前にコンパイルされたライブラリ(glibc)のライブラリ関数です。 呼び出し側へのリンク時最適化とインライン化されません。 これは、スタンドアロンバージョンのの安全なマシンコードにコンパイルする必要があることを意味しますstrlen。ポータブルで安全なCである必要はありません。

GNU CライブラリはGCCでコンパイルするだけです。どうやらですサポートされていない、彼らはGNU拡張をサポートしていても、打ち鳴らすか、ICCでそれをコンパイルします。GCCは、Cソースファイルをマシンコードのオブジェクトファイルに変換する事前コンパイルです。インタプリタではないため、コンパイル時にインライン化しない限り、メモリ内のバイトはメモリ内のバイトです。つまり、異なるエイリアスのUBは、互いにインライン化されない異なる関数で異なるタイプのアクセスが発生する場合、危険ではありません。

strlenの動作はISO C標準で定義さていることに注意してください。その関数名は、具体的には実装の一部です。GCCのようなコンパイラーは-fno-builtin-strlen、を使用しない限り、名前を組み込み関数として扱うため、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ます。(これは、一部のDSP CHAR_BIT= 16などのorを備えたシステムでは安全に見えます32sizeof(long) = sizeof(char) = 1すべてのポインターが常に整列され、p & sizeof(long)-1常にゼロであるため、unaligned-prologueループは常に0の反復を実行します。)charが9である非ASCII文字セットがある場合または12ビット幅0x8080...は、間違ったパターンです。
  • (たぶん)unsigned longは4または8バイトです。または、実際にはunsigned long最大8の任意のサイズで機能し、それassert()をチェックするためにを使用します。

これら2つは可能なUBではなく、一部のC実装への移植性がないだけです。このコードは、それが機能するプラットフォームでのC実装の一部である(またはそうであった)ので、問題ありません。

次の仮定は、潜在的なC UBです。

  • 有効なバイトを含む整列されたロードはエラーならず、実際に必要なオブジェクトの外部のバイトを無視する限り安全です。(すべてのGNUシステム、およびすべての通常のCPUで実際にasmを使用します。メモリ保護は境界整列ページ単位 で行われるためです。x86とx64の同じページ内のバッファーの終わりを超えて読み取っても安全ですか? UBの場合、Cでは安全です。コンパイル時に表示されません。インライン化しないと、これが当てはまります。コンパイラは、最初を超えた読み取り0がUBであることを証明できません。たとえば、C char[]配列である可能性があります{1,2,0,3}

その最後のポイントは、ここでCオブジェクトの終わりを超えて読むことが安全になる理由です。現在のコンパイラーでインライン化する場合でも、これはかなり安全です。実行パスに到達できないことを意味するものは現在扱っていないためです。しかし、とにかく、これをインライン化した場合、厳密なエイリアスは既にショートッパーです。

次に、Linuxカーネルの古い安全でないmemcpy CPPマクロunsigned longgcc、strict-aliasing、およびhorror stories)へのポインターキャストを使用する問題が発生します。

これstrlenは、一般的にそのようなものを回避できる時代にさかのぼります。以前は、GCC3の前に「インライン化しない場合のみ」の警告がなければ、かなり安全でした。


呼び出し/ retの境界を越えて見たときにのみ表示されるUBは私たちを傷つけることはできません。(たとえば、これをへchar buf[]unsigned long[]キャストの配列ではなくで呼び出しますconst char*)。マシンコードが固まったら、メモリ内のバイトを処理するだけです。非インライン関数呼び出しでは、呼び出し先が任意の/すべてのメモリを読み取ると想定する必要があります。


UBを厳密にエイリアシングせずにこれを安全に書く

GCCのtype属性は、may_aliasタイプAと同じエイリアス何も治療を提供します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-at-時間アクセス。しかし実際には、最近のコンパイラーはmemcpy非常によく理解しています。

ここでの危険は、GCCがそれがワード境界で整列されていることを確実に知らchar_ptrない場合、asmで境界整列されていないロードをサポートしない可能性がある一部のプラットフォームではインライン化しないことです。たとえば、MIPS64r6より前のMIPS、または古いARM。memcpy単語をロードするためだけに(そしてそれを他のメモリに残すために)への実際の関数呼び出しを取得した場合、それは惨事になります。GCCは、コードがいつポインターを調整するかを確認できます。または、あなたが使用できるulong境界に達するchar-at-a-timeのループの後
p = __builtin_assume_aligned(p, sizeof(unsigned long));

これはread-past-the-object可能なUBを回避しませんが、現在のGCCでは実際には危険ではありません。


手動で最適化されたCソースが必要な理由:現在のコンパイラーは十分ではありません

手で最適化されたasmは、広く使用されている標準ライブラリー関数のパフォーマンスをすべて低下させたい場合にさらに優れています。特にのようなものmemcpyだけでなくstrlen。この場合、Cをx86組み込みで使用してSSE2を利用するのはそれほど簡単ではありません。

ただし、ここでは、ISA固有の機能を持たない単純なバージョンとbithack Cのバージョンについて話しています。

strlen十分に広く使用されていて、可能な限り高速に実行することが重要であることを前提として考えることができます。したがって、問題は、より単純なソースから効率的なマシンコードを取得できるかどうかです。いいえ、できません。)

現在のGCCとclangは、最初の反復より前に反復回数がわからないループを自動ベクトル化できません。(たとえば、ループが最初の反復を実行する前に少なくとも16回反復を実行するかどうかを確認できる必要があります。)たとえば、現在の与えられたstrcpyまたはstrlen(暗黙の長さの文字列)ではなく、memcpyの自動ベクトル化は可能です(暗黙の長さのバッファー)。コンパイラ。

これには、検索ループ、またはデータ依存if()breakとカウンターを備えたその他のループが含まれます。

ICC(Intelのx86コンパイラ)は、一部の検索ループを自動ベクトル化できますが、strlenOpenBSDのlibcが使用するような単純/単純なCに対しては、単純なバイト単位の単純なasmしか作成できません。(Godbolt)。(@Peskeの回答から)。

strlen現在のコンパイラでのパフォーマンスには、手動で最適化されたlibc が必要です。メインメモリが1サイクルあたり約8バイトを維持でき、L1dキャッシュが1サイクルあたり16〜64を配信できる場合、一度に1バイトずつ(ワイドスーパースカラーCPUでサイクルあたり2バイトを展開すると)は悲惨です。(HaswellとRyzen以降の最新のメインストリームx86 CPUでは、サイクルあたり32バイトのロードが2つ。512ビットのベクトルを使用するためだけにクロック速度を低下させるAVX512はカウントされません。そのため、glibcはAVX512バージョンを急いでいません。 。256ビットのベクトルを持つものの、AVX512VL + BWは、マスクに比較して、マスクしktestたりkortest作ることができstrlen、そののuop /繰り返しを減らすことによって、より多くのハイパースレッディングが優しいです。)

ここではx86以外を含めていますが、これは「16バイト」です。たとえば、ほとんどのAArch64 CPUは少なくともそれを実行できます。またstrlen、その負荷帯域幅に追いつくのに十分な実行スループットを備えているものもあります。

もちろん、大きな文字列を処理するプログラムは、通常、長さを追跡して、暗黙の長さのC文字列の長さを頻繁に見つける必要がないようにする必要があります。しかし、短い長さから中程度の長さのパフォーマンスは、手書きの実装から依然として恩恵を受け、一部のプログラムは中程度の長さの文字列でstrlenを使用することになると私は確信しています。


12
注意事項:(1)現在、glibc自体をGCC以外のコンパイラでコンパイルすることはできません。(2)リンク時の最適化を有効にしてglibc自体をコンパイルすることは、インライン化が許可されている場合にコンパイラーがUBを参照するまさにこのようなケースのため、現在は不可能です。(3)CHAR_BIT == 8はPOSIX要件です(-2001 revの時点で、こちらを参照してください)。(4)のCフォールバック実装はstrlen、サポートされている一部のCPUで使用されています。最も一般的なものはMIPSだと思います。
zwol

1
興味深いことに、厳密なエイリアスのUBは、__attribute__((__may_alias__))属性を使用して修正できます(これは移植できませんが、glibcでは問題ありません)。
Konrad Borowski、

1
@SebastianRedl:を介して任意のオブジェクトを読み書きできますが、を介しchar*char オブジェクト(例:の一部char[])を読み書きすることは UB long*です。 厳密なエイリアシングルールと 'char *'ポインター
Peter Cordes '28

1
CとC ++の標準では、これは少なくともCHAR_BIT8(C11の付録vのqv)でなければならない、と述べているので、少なくとも7ビットcharは言語弁護士が心配する必要のあるものではありません。これは、「UTF-8文字列リテラルの場合、配列要素はtype charであり、UTF-8でエンコードされたマルチバイト文字シーケンスの文字で初期化される」という要件によって動機付けられました。
Davislor

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

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

さらに複雑になるのは、テスト対象の文字列を適切な場所に配置して、一度に複数のバイトを(コメントに記載されているように、ロングワード境界に沿って)テストを開始する必要があることと、前提条件を確実にする必要があることです。コードを使用しても、データ型のサイズに違反することはありません。

最も(すべてではない)、現代のソフトウェア開発、効率の細部へのこの注意が必要な、または余分なコードの複雑さのコスト価値がないではありません。

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


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


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ツールで最適化されているさまざまな値の非常に優れた説明になります。
Jason

11
それはglibcのポータブルなフォールバック実装です。すべての主要なISAはglibcで手書きのasm実装を備えており、SIMDを使用して(x86などで)役立つ場合があります。code.woboq.org/userspace/glibc/sysdeps/x86_64/multiarch/…およびcode.woboq.org/userspace/glibc/sysdeps/aarch64/multiarch/…を
Peter Cordes

4
OpenBSDバージョンでさえ、オリジナルが回避する欠陥があります!s - str結果がで表現できない場合、の動作は未定義ですptrdiff_t
Antti Haapala

1
@AnttiHaapala:GNU Cでは、オブジェクトの最大サイズはPTRDIFF_MAXです。ただしmmap、少なくともLinuxのメモリよりも多くのメモリを使用することは可能です(たとえば、x86-64カーネルの32ビットプロセスでは、障害が発生し始める前に約2.7GB連続してマップできます)。OpenBSDに関するIDK。カーネルは、returnsegfaultingしたり、サイズ内で停止したりせずに、その到達を不可能にする可能性があります。しかし、はい、理論的なC UBを回避する防御的コーディングは、OpenBSDがやりたいことだと思います。にもかかわらずすることはstrlen、インラインと現実のコンパイラは、単に減算にそれをコンパイルしますすることはできません。
Peter Cordes

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バイトアライメント)。

「Hello、world!」の「H」の文字だとしましょう。文字列がの引数として指定されましたstrlen。CPUはメモリ(理想的にはaddress % sizeof(size_t) == 0)で整列させることを好むので、整列の前のバイトは遅い方法を使用してバイトごとに処理されます。

次に、アライメントサイズのチャンクごと(longbits - 0x01010101) & 0x80808080 != 0に、整数のバイトがゼロかどうかを計算してチェックします。この計算は、バイトの少なくとも1つがより大きい場合に誤検知が発生しますが0x80、機能するはずです。そうでない場合(黄色の領域の場合)、長さは配置サイズによって増加します。

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

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


この実装はglibcの一部です。GNUシステムは、ページ単位でメモリ保護を行います。したがって、はい、有効なバイトを含む整列ロードは安全です。
Peter Cordes

size_tアラインメントは保証されていません。
SSアン

32

あなたはコードが正しく、保守可能で、高速であることを望んでいます。これらの要素にはさまざまな重要性があります。

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

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

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

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

したがって、strlenの場合、これまでに作成するほとんどのコードよりも速度がはるかに重要であり、保守性はそれほど重要ではありません。

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

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

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

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


または...文字列ベースの処理を大量に実行していてボトルネックがあることがわかった場合、strlenを改善する代わりに、独自のバージョンのPascal文字列を実装する予定です...
Baldrickk

1
ストレンを改善するように頼む人はいません。しかし、十分に良いものにすることで、人々が独自の文字列を実装するようなナンセンスを回避できます。
gnasher729


24

簡単に言うと、文字列をバイト単位でチェックすると、一度に大量のデータをフェッチできるアーキテクチャでは遅くなる可能性があります。

null終了のチェッ​​クを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バージョンです。こちらのQ&Aもご覧ください。
Peter Cordes

Oof、それは残念です。私はたいていアンチUBですが、あなたが指摘するように、C文字列は技術的にはUBのバッファの終わりを読み取ってベクトル化さえも可能にする必要があります。アライメントが必要なため、ARM64にも同じことが当てはまると思います。
russbishop

-6

他の回答で言及されていない重要なことの1つは、FSFがプロプライエタリコードがGNUプロジェクトに組み込まないようにすることについて非常に慎重であることです。でコーディング標準GNUの下で独自のプログラムを参照して、それは既存のプロプライエタリコードと混同することができないような方法で実装を整理に関する警告があります:

GNUでの作業中または作業中にUnixのソースコードを参照しないでください。(または他の独自のプログラムに。)

Unixプログラムの内部の漠然とした記憶がある場合、これは絶対にそれを模倣できないことを意味するわけではありませんが、異なる方法で内部的に模倣を整理するようにしてください。結果とは無関係で異なるUnixバージョン。

たとえば、Unixユーティリティは一般的にメモリ使用量を最小限に抑えるように最適化されています。代わり速度を上げると、プログラムは大きく異なります。

(エンファシス鉱山。)


5
これはどのように質問に答えますか?
SSアンヌ

1
OPでの質問は、「この単純なコードはうまく機能しないのではないか」ということでした。これは、技術的なメリットが常に決定されるとは限らない質問です。GNUのようなプロジェクトの場合、法的な落とし穴を回避することは、コードが「うまく機能する」ための重要な部分であり、の「明白な」実装はstrlen()、既存のコードと類似または同一である可能性があります。glibcの実装のように「クレイジー」なものは、そのように追跡することはできません。法的な論争がどれほどあったかを考えると、rangeCheck11行のコードです!—グーグルとオラクルの戦いでは、FSFの懸念は適切な位置にあったと思います。
ジャックケリー
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.