web-dev-qa-db-ja.com

Pythonで文字列比較がこんなに速いのはなぜですか?

次のアルゴリズムの問​​題の例を解決しているときに、pythonで文字列比較がどのように機能するかを理解することに興味を持ちました。

2つの文字列が与えられると、最も長い共通の接頭辞の長さを返します

解決策1:charByChar

私の直感は、最適な解決策は両方の単語の先頭にある1つのカーソルから始め、接頭辞が一致しなくなるまで前方に反復することだと私に教えてくれました。何かのようなもの

_def charByChar(smaller, bigger):
  assert len(smaller) <= len(bigger)
  for p in range(len(smaller)):
    if smaller[p] != bigger[p]:
      return p
  return len(smaller)
_

コードを簡略化するために、この関数は、最初の文字列smallerの長さが常に2番目の文字列bigger以下であると想定しています。

解決策2:binarySearch

別の方法は、2つの文字列を2等分して2つの接頭辞部分文字列を作成することです。プレフィックスが等しい場合、共通のプレフィックスポイントは少なくともミッドポイントと同じ長さです。それ以外の場合、共通のプレフィックスポイントは、少なくとも中間点より大きくありません。その後、再帰的に接頭辞の長さを見つけることができます。

別名バイナリ検索。

_def binarySearch(smaller, bigger):
  assert len(smaller) <= len(bigger)
  lo = 0
  hi = len(smaller)

  # binary search for prefix
  while lo < hi:
    # +1 for even lengths
    mid = ((hi - lo + 1) // 2) + lo

    if smaller[:mid] == bigger[:mid]:
      # prefixes equal
      lo = mid
    else:
      # prefixes not equal
      hi = mid - 1

  return lo
_

最初は、binarySearchのようにプレフィックス文字だけでなく、文字列比較ですべての文字を数回比較するため、charByCharの方が遅いと思いました。

驚いたことに、binarySearchは、いくつかの予備的なベンチマークの結果、はるかに高速であることが判明しました。

図A

lcp_fixed_suffix

上記は、プレフィックス長が増加した場合のパフォーマンスへの影響を示しています。サフィックスの長さは50文字で一定のままです。

このグラフは2つのことを示しています。

  1. 予想通り、両方のアルゴリズムは、プレフィックス長が増加するにつれて直線的にパフォーマンスが低下します。
  2. charByCharのパフォーマンスは、はるかに速い速度で低下します。

なぜbinarySearchがこれほど優れているのですか?なぜならそれは

  1. binarySearchの文字列比較は、おそらく舞台裏でインタプリタ/ CPUによって最適化されています。
  2. charByCharは、アクセスされた文字ごとに実際に新しい文字列を作成し、これにより大きなオーバーヘッドが生じます。

これを検証するために、以下でそれぞれcmpおよびsliceというラベルが付いた文字列を比較およびスライスするパフォーマンスをベンチマークしました。

図B

cmp

このグラフは、2つの重要なことを示しています。

  1. 予想どおり、比較とスライスは長さとともに直線的に増加します。
  2. 比較とスライスのコストは、アルゴリズムのパフォーマンスに比べて、長さとともに非常にゆっくりと増加します(図A)。両方の数値は、10億文字の長さの文字列に達することに注意してください。したがって、1文字を10億回比較するコストは、10億文字を1回比較するよりもはるかに高くなります。しかし、これでも理由はわかりません...

Cpython

Cpythonインタープリターが文字列比較を最適化する方法を見つけるために、次の関数のバイトコードを生成しました。

_In [9]: def slice_cmp(a, b): return a[0] == b[0]

In [10]: dis.dis(slice_cmp)
            0 LOAD_FAST                0 (a)
            2 LOAD_CONST               1 (0)
            4 BINARY_SUBSCR
            6 LOAD_FAST                1 (b)
            8 LOAD_CONST               1 (0)
           10 BINARY_SUBSCR
           12 COMPARE_OP               2 (==)
           14 RETURN_VALUE
_

私はcpythonコードをざっと見て、次の twopieces のコードを見つけましたが、これが文字列比較が行われる場所かどうかはわかりません。

質問

  • Cpythonのどこで文字列比較が行われますか?
  • CPUの最適化はありますか?文字列比較を行う特別なx86命令はありますか? cpythonによって生成されたアセンブリ命令を確認するにはどうすればよいですか?私が最新のpython3、Intel Core i5、OS X 10.11.6を使用していると想定するかもしれません。
  • 長い文字列を比較する方が、各文字を比較するよりもはるかに速いのはなぜですか?

おまけの質問:charByCharのパフォーマンスが向上するのはいつですか?

プレフィックスが残りの文字列の長さに比べて十分に小さい場合、ある時点で、charByCharに部分文字列を作成するコストは、binarySearchの部分文字列を比較するコストよりも低くなります。

この関係を説明するために、ランタイム分析について詳しく調べました。

ランタイム分析

以下の方程式を簡略化するために、smallerbiggerが同じサイズであると想定します。これらを_s1_および_s2_と呼びます。

charByChar

_charByChar(s1, s2) = costOfOneChar * prefixLen
_

どこ

_costOfOneChar = cmp(1) + slice(s1Len, 1) + slice(s2Len, 1)
_

ここで、cmp(1)は、長さが1文字の2つの文字列を比較するコストです。

sliceは、charにアクセスするためのコストであり、charAt(i)と同等です。 Pythonには不変の文字列があり、charにアクセスすると実際には長さ1の新しい文字列が作成されます。slice(string_len, slice_len)は長さ_string_len_の文字列をスライスにスライスするコストですサイズ_slice_len_の。

そう

_charByChar(s1, s2) = O((cmp(1) + slice(s1Len, 1)) * prefixLen)
_

binarySearch

_binarySearch(s1, s2) = costOfHalfOfEachString * log_2(s1Len)
_

_log_2_は、長さが1の文字列に達するまで文字列を半分に分割する回数です。

_costOfHalfOfEachString = slice(s1Len, s1Len / 2) + slice(s2Len, s1Len / 2) + cmp(s1Len / 2)
_

したがって、binarySearchのビッグOは、

_binarySearch(s1, s2) = O((slice(s2Len, s1Len) + cmp(s1Len)) * log_2(s1Len))
_

のコストの以前の分析に基づいて

costOfHalfOfEachStringがおおよそcostOfComparingOneCharであると仮定すると、両方をxとして参照できます。

_charByChar(s1, s2) = O(x * prefixLen)
binarySearch(s1, s2) = O(x * log_2(s1Len))
_

それらを同一視した場合

_O(charByChar(s1, s2)) = O(binarySearch(s1, s2))
x * prefixLen = x * log_2(s1Len)
prefixLen = log_2(s1Len)
2 ** prefixLen = s1Len
_

したがって、O(charByChar(s1, s2)) > O(binarySearch(s1, s2)

_2 ** prefixLen = s1Len
_

したがって、上記の式を挿入して、図Aのテストを再生成しましたが、2つのアルゴリズムのパフォーマンスがほぼ等しいことを期待して、全長_2 ** prefixLen_の文字列を使用しています。

img

ただし、明らかにcharByCharの方がはるかに優れています。少し試行錯誤して、2つのアルゴリズムのパフォーマンスは_s1Len = 200 * prefixLen_

img

なぜ200倍の関係なのですか?

36
david_adler

TL:DR:スライス比較は、いくつかのPythonオーバーヘッド+高度に最適化されたmemcmp(UTF-8処理がない限り)です。)理想的には、スライス比較を使用します最初の不一致を128バイト以内か何かに見つけるために、charを一度にループします。

または、それがオプションであり、問​​題が重要な場合は、等しい/等しくないのではなく、最初の差異の位置を返すasm最適化memcmpの変更されたコピーを作成します。文字列全体の単一の_==_と同じ速さで実行されます。 Pythonには、ライブラリ内のネイティブC/asm関数を呼び出す方法があります。

CPUがこれを非常に高速に実行できるのは苛立たしい制限ですが、Pythonは(AFAIK)では、最適化された比較ループへのアクセスを提供します。/もっと少なく。


インタープリターのオーバーヘッドが、単純なPythonループ、CPythonを使用したループで実際の作業のコストを支配することは完全に正常です。最適化されたビルディングブロックからアルゴリズムを構築することは、それが意味する場合でも価値がありますより多くの総作業を実行します。これがNumPyが優れている理由ですが、要素ごとに行列をループするのはひどいです。速度の違いは、CPythonとコンパイルされたC(asm)ループの場合、20から100の係数のようなものかもしれません。一度に1バイトを比較するため(数値を構成しますが、おそらく1桁以内で適切です)。

Pythonループとリスト/スライス全体の操作の最大のミスマッチの1つは、メモリブロックを比較して比較することです。これは、高度に最適化されたソリューション(たとえば、ほとんどのlibc実装( OS Xを含む)手動でベクトル化された手動でコード化されたasm memcmpがあり、SIMDを使用して16または32バイトを並行して比較し、1バイトよりもはるかに高速に実行されます Cまたはアセンブリの-at-a-timeループ)したがって、16から32の別の係数があり(メモリ帯域幅がボトルネックでない場合)、Python =およびCループ。または、memcmpの最適化方法によっては、サイクルあたり6バイトまたは8バイトのみ「多分」になる可能性があります。

中規模のバッファーのL2またはL1dキャッシュでデータがホットになると、Haswell以降のCPUでmemcmpのサイクルあたり16または32バイトを期待するのが妥当です。 (i3/i5/i7ネーミングはNehalemで始まりました。i5だけでは、CPUについて多くを語るのに十分ではありません。)

どちらかまたは両方の比較でUTF-8を処理して、同等のクラスまたは同じ文字をエンコードするためのさまざまな方法を確認する必要があるかどうかはわかりません。最悪のケースは、Python char-at-a-timeループが潜在的にマルチバイト文字をチェックする必要があるが、スライス比較でmemcmpを使用できる場合です。


Pythonで効率的なバージョンを書く:

positionを除いて、問題はC標準ライブラリ関数memcmpとほとんど同じです。 -/ 0/+の結果ではなく、最初の違いのどちらの文字列が大きいかがわかります。検索ループは同じです。これは、結果を見つけた後の関数の動作の違いにすぎません。

バイナリ検索は、高速比較ビルディングブロックを使用する最良の方法ではありません。スライス比較の場合、O(n)ではなくO(1)のコストがかかりますが、定数係数ははるかに小さくなります。 スライスを使用して大きなチャンクを比較し、不一致が見つかるまでバッファの先頭を繰り返し再比較することはできません。その後、チャンクサイズが小さい最後のチャンクに戻ります。

_# I don't actually know Python; consider this pseudo-code
# or leave an edit if I got this wrong :P
chunksize = min(8192, len(smaller))
# possibly round chunksize down to the next lowest power of 2?
start = 0
while start+chunksize < len(smaller):
    if smaller[start:start+chunksize] == bigger[start:start+chunksize]:
        start += chunksize
    else:
        if chunksize <= 128:
            return char_at_a_time(smaller[start:start+chunksize],  bigger[start:start+chunksize])
        else:
            chunksize /= 8        # from the same start

# TODO: verify this logic for corner cases like string length not a power of 2
# and/or a difference only in the last character: make sure it does check to the end
_

CPUに32kiB L1dキャッシュがあり、2つの8kスライスの合計キャッシュフットプリントが16kであり、L1dの半分であるため、8192を選択しました。ループが不一致を検出すると、最後の8kiBを1kのチャンクで再スキャンし、これらの比較はL1dでまだホットなデータをループします。 (_==_が不一致を検出した場合、8k全体ではなく、おそらくその時点までのデータのみを処理したことに注意してください。しかし、HWプリフェッチはそれを少し超えます。)

8の係数は、大きなスライスを使用して迅速にローカライズすることと、同じデータを何度も通過する必要がないこととのバランスを保つ必要があります。これはもちろんチャンクサイズと共に調整可能なパラメーターです。 Pythonとasmの間の不一致が大きいほど、この係数はPythonループ反復を減らすために小さくなります。

うまくいけば、8kはPythonループ/スライスオーバーヘッドを隠すのに十分な大きさです。ハードウェアプリフェッチは、Python _memcmp呼び出しの間のオーバーヘッド中にも機能します。インタプリタから取得するため、粒度を大きくする必要はありません。ただし、実際に大きな文字列の場合、8kがメモリ帯域幅を飽和させない場合は、64kにすることもできます(L2キャッシュは256kiBです。

memcmpはどれほど正確ですか?

私はこれをIntel Core i5で実行していますが、ほとんどの最新のCPUで同じ結果が得られると思います。

Cでさえ memcmpがforループチェックよりもはるかに高速なのはなぜですか?memcmpは、一度に1バイトずつ比較するループより高速です。自動ベクトル化検索ループに優れています(またはまったく対応できません)。

ハードウェアSIMDサポートがなくても、最適化されたmemcmpは、16バイトまたは32バイトのSIMDがない単純なCPUでも、一度に4または8バイト(ワードサイズ/レジスタ幅)をチェックできます。

しかし、最近のほとんどのCPU、およびすべてのx86-64には、SIMD命令があります。 SSE2はx86-64のベースラインです 、32ビットモードの拡張機能として使用できます。

SSE2またはAVX2 memcmpは、pcmpeqb/pmovmskbを使用して、16バイトまたは32バイトを並行して比較できます。 (x86 asmまたはC組み込み関数でmemcmpを記述する方法については詳しく説明しません。Googleとは別に、またはこれらのasm命令をx86命令セットリファレンスで調べます。 http: //felixcloutier.com/x86/index.html 。asmおよびパフォーマンスリンクについては x86タグwiki も参照してください。例 SkylakeがBroadwell-Eよりもはるかに優れている理由シングルスレッドメモリのスループット? シングルコアメモリの帯域幅制限に関する情報があります。)

オープンソースのWebサイトで 2005年のAppleのx86-64 memcmpの古いバージョン (AT&T構文アセンブリ言語)を見つけました。それは間違いなく良いかもしれません。大きなバッファーの場合、1つのソースポインターを整列させ、もう1つのポインターにはmovdquのみを使用する必要があります。これにより、2 [movdquではなく、メモリオペランドを使用してpcmpeqbmovdqu 、文字列が相互にずれている場合でも。 _xorl $0xFFFF,%eax_/jnzは、_cmp/jcc_がFuseをマクロ化できるが、_xor / jcc_ができないCPUでは最適ではありません。

64バイトのキャッシュライン全体を一度にチェックするために展開すると、ループのオーバーヘッドも隠されます。 (これは大きなチャンクのアイデアと同じであり、ヒットを見つけるとそれをループバックします)。 GlibcのAVX2 -movbeバージョン は、これをvpandで実行して、メインの大容量バッファループで比較結果を結合し、最終的な結合はvptestになります。また、結果からフラグを設定します。 (コードサイズは小さいですが、vpand/vpmovmskb/cmp/jccよりもuopsは少なくありません。しかし、ブランチの誤予測を減らすために、不利な点はなく、おそらくレイテンシは低くなりますループ終了時のペナルティ)。 Glibcは動的リンク時に動的CPUディスパッチを行います。それをサポートするCPUでこのバージョンを選択します。

うまくいけば、最近のAppleのmemcmpの方が優れている。ただし、最新のLibcディレクトリにはソースがまったく表示されません。うまくいけば、実行時にHaswell以降のCPUのAVX2バージョンにディスパッチされます。

リンクしたバージョンのLLoopOverChunksループは、Haswellで〜2.5サイクルごとに1回の反復(各入力から16バイト)でのみ実行されます。 10個の融合ドメインuops。しかし、それでもナイーブCループの場合はサイクルあたり1バイトよりもはるかに高速であり、Pythonループの場合よりもはるかに悪いです。

GlibcのL(loop_4x_vec):ループは18の融合ドメインuopsであるため、L1dキャッシュでデータがホットな場合、クロックサイクルごとに(各入力から)32バイトよりわずかに少なく実行できます。そうしないと、L2帯域幅でボトルネックになります。別のループカウンターをデクリメントしてループ内で追加の命令を使用せず、ループの外側でエンドポインターを計算していなければ、17 uopsであった可能性があります。


Pythonインタプリタ自身のコードでの手順/ホットスポットの検索

コードが呼び出すC命令とCPU命令を見つけるために、どのようにドリルダウンできますか?

Linuxでは、_perf record python ..._、次に_perf report -Mintel_を実行して、CPUが最も多くの時間を費やした関数と、それらの関数のどの命令が最もホットであったかを確認できます。ここに投稿したような結果が得られます: float()がint()より速いのはなぜですか? 。 (perfには逆アセンブラが組み込まれているため、実行された実際の機械語命令を表示するには、アセンブリ言語として表示されます。)

各イベントのコールグラフをサンプリングするより微妙なビューについては、 linux perf:ホットスポットを解釈して見つける方法 を参照してください。

(実際にoptimizeプログラムを探しているときは、どの関数呼び出しが高価であるかを知りたいので、そもそもそれらを回避しようとすることができます。 "時間はホットスポットを見つけますが、特定のループがほとんどの反復を実行する原因となった呼び出し元が常にわかるとは限りません。そのパフォーマンスに関する質問についてのMike Dunlaveyの回答を参照してください。)

しかし、この特定のケースでは、大きな文字列に対してスライス比較バージョンを実行しているインタプリタをプロファイリングすると、ほとんどの時間を費やしていると思われるmemcmpループが見つかるはずです。 (または、char-at-a-timeバージョンの場合は、「ホット」なインタープリターコードを見つけます。)

次に、ループ内にあるasm命令を直接確認できます。関数名から、バイナリに記号があると仮定すると、ソースを見つけることができます。または、Pythonのバージョンがある場合は、プロファイル情報から直接ソースにアクセスできます(最適化を無効にしたデバッグビルドではなく、完全なシンボルのみを使用)。

21
Peter Cordes

これは、実装依存とハードウェア依存の両方です。あなたのターゲットマシンと特定のディストリビューションを知らなければ、私は確かに言うことができませんでした。ただし、基本的なハードウェアには、ほとんどの場合と同様に、メモリブロック命令があると強く思います。とりわけ、これは、並列でパイプライン方式で、任意の長い文字列(アドレッシング制限まで)を比較できます。たとえば、クロックサイクルごとに1つのスライスで8バイトのスライスを比較できます。これはlotバイトレベルのインデックスをいじるよりも高速です。

4
Prune