世界を動かす技術を、日本語で。

部分文字列検索のためのSIMDフレンドリーアルゴリズム (2018)

2025年6月14日原文(0x80.pl)

概要

  • 文字列検索アルゴリズムの従来手法の課題と現代CPUとのギャップ
  • SIMD命令を活用した高速な部分文字列検索手法の紹介
  • Karp-RabinアルゴリズムのSIMD最適化と具体的な実装例
  • 各種SIMD命令セット(SSE, AVX2, AVX512F, SWAR, ARM Neon等)への適用方法
  • 実装の工夫点とパフォーマンス向上のポイント

文字列検索アルゴリズムとSIMD最適化の概要

  • 一般的なプログラミング言語には、 部分文字列検索用API (例:Cのstrstr、C++のstd::string::find、Pythonのindex等)を標準搭載
  • これらAPIは 一度きりの検索用 に設計されている
  • 代表的なアルゴリズムは大きく2系統
    • 決定性有限オートマトン系 (Knuth-Morris-Pratt, Boyer-Moore等)
    • 単純比較系 (Karp-Rabin等)
  • 従来アルゴリズムは「 文字単位比較が安価、部分文字列比較が高価」という前提に基づく
  • しかし現代のCPU(特にSIMD命令対応)では、 複数バイト同時比較が非常に高速 であり、この前提が崩れている

SIMDを活用した部分文字列検索のアプローチ

  • SIMD命令を用いることで、 複数バイト(16, 32, 64バイト等)を同時比較 可能
  • Karp-Rabinアルゴリズムをベースに、 ハッシュ判定をSIMDベクトル判定に置換
  • 各SIMD命令セット(SSE, AVX2, AVX512F, SWAR, ARM Neon等)に最適化した実装が可能
  • 部分文字列長が既知の場合、memcmp呼び出しを省略し、数命令で比較が完了 する

アルゴリズム1:汎用SIMDアルゴリズムの流れ

  • 検索対象部分文字列(needle)の 先頭・末尾文字(または最適な2文字) を抽出

  • それぞれの文字をベクトルレジスタに複製(例:SSE/AVX2なら16/32バイト分)

  • 対象文字列(haystack)の該当範囲から 2つのチャンク(先頭・末尾位置) をロード

  • 各チャンクとneedle先頭・末尾文字を ベクトル比較 し、両方一致した位置をマスクで抽出

  • マスクで抽出された位置のみ 厳密な部分文字列比較 を実施

    • 例:「cat」を8バイトレジスタで検索する場合、先頭・末尾文字を複製し、比較結果マスクから候補位置を抽出
  • 先頭・末尾以外も、 needle内で最も遠い異なる文字 を末尾として選択することで、パターンによる無駄な比較を削減

各種SIMD命令セットへの実装例

  • SSE/AVX2 :_mm256_set1_epi8, _mm256_loadu_si256, _mm256_cmpeq_epi8等を活用し、32バイト単位で高速検索
  • SWAR :64ビット整数演算でxorとビットマスクを駆使し、8バイト単位で比較
  • AVX512F :32ビット単位の比較を工夫し、4バイトごとに部分文字列比較を並列化
  • ARM Neon :128ビットSIMDレジスタを使い、比較結果をメモリに一旦保存して順序を維持

実装上の工夫点

  • memcmpの呼び出し回数削減、部分一致時のみ厳密比較
  • needle長に応じた分岐や特殊化 により命令数・分岐数を最小化
  • キャッシュ効率分岐予測回避 にも配慮したループ設計
  • パターンが単一文字のみの場合 は専用ルーチンで最適化

まとめ

  • 現代CPUのSIMD命令を活用することで、 従来アルゴリズムより高速な文字列検索 が実現可能
  • needle長やパターン特性に応じて 複数の最適化手法を組み合わせることが鍵
  • 実装例はC++で提供されており、 SSE/AVX2/AVX512F/Neon/SWAR それぞれの特徴を活かした構造
  • SIMD命令による並列比較と、 無駄な比較回数削減 がパフォーマンス向上のポイント

Hackerたちの意見

Wasm/WASI libcのSIMD最適化を試みる中で、これを実装したよ。 https://github.com/ncruces/go-sqlite3/tree/main/sqlite3/libc 既知のハヤシの長さと大きなニードルに対して、クイックサーチを使うと役立つことがわかった。 https://igm.univ-mlv.fr/~lecroq/string/node19.html

ストリングの検索や分割のために、SMIDメソッドを使ったいろんなアルゴリズムも実装したよ。 https://github.com/naver/tamgu/blob/06aedf2f14895925d7b5a8e2... ここで紹介されているアルゴリズムとは別のものを使ったんだ。

あなたのアルゴリズムをざっくりまとめてもらえる? 高レベルな説明で。記事のアルゴリズム1は、最初と最後の文字をマッチさせてN個の可能な位置を同時にチェックする感じ。アルゴリズム2は、代わりに先頭の4文字をテストするんだ。

数年前にZigのジェネリックSIMDを使ってLZ77ウィンドウ検索の修正版を実装しようとしたことがあるよ: https://news.ycombinator.com/item?id=44273983

libcのstrstrがひどいのは知ってるけど、muslは速くて最先端だよね。 今は名前が必要なだけで、スマートシュートアウトに追加できる。 これが最高のSIMDアルゴリズムと比べてどうなるか気になるな。

おおまかなベンチマークに興味があれば、muslの「二方向」とこのアルゴリズムのバリエーションを比較したよ。 私のSIMD最適化されたlibcについては、別のコメントでリンクしてある。 そのファイルのこの行を見つけるベンチマークだよ: https://github.com/ncruces/go-sqlite3/blob/v0.26.1/sqlite3/l... muslに対する改善点はこの表にあるよ: https://github.com/ncruces/go-sqlite3/tree/v0.26.1/sqlite3/l... まあ、そこそこ改善されてると思うけど、特別なわけじゃないかな。 muslは既知の長さの文字列(memmem)には特に強いけど、私は不明な長さの文字列(strstr)でちょっとズルできるからね。 Wasm環境ではいくつかの未定義動作を回避できるんだ。 NUL終端の文字列の曲者が多くの良いアルゴリズムを台無しにするんだよね。

SIMDアルゴリズムがもっとあったらいいな。時間があれば、ちょっと試してみようかな。

missing (2018) ?

実際には2016年で、2018年にスペルの修正があったんだ。まだいくつかのエラーを見逃してるけどね。「二つのサブストリングを比較するのはexpansive」→ expensive とか、「memcmpのコストは単にridden off」→ written みたいな。stdlibに対するbuldozerのひどさは興味深いけど、SSE2があればWestmereともっと競争力があったんだね。

効率のための文字列サイズの閾値はどれくらい? SIMD(アライン、サイズ計算など)のセットアップコードは、通常「小さい」文字列のバイト指向の基本検索よりもコストが高いからね。 そう、ハードウェアアーキテクチャに大きく依存するんだ。

ここでは、逆のことが多いんだ。 これらのアルゴリズムは基本的にブートフォースで、セットアップがほとんどいらないから、大きくて周期的なニードルに対して二次的になることがある。 二次的な挙動を避けるストリング検索アルゴリズム(場合によってはサブリニアになることもある)は、ニードルの構造を探るために高いコストのセットアップが必要なんだ。

swarアルゴリズムは、1バイトアラインのデータを8バイトアラインにキャストするから、未定義動作(UB)が発生するんだよね。もしかしたら、アラインされてないロードからパフォーマンスの問題が出てるかも?

Hacker Newsで議論の続きを見る