ripgrep は {grep, ag, git grep, ucg, pt, sift} より速い
- これは Andrew Gallant 著 ripgrep is faster than {grep, ag, git grep, ucg, pt, sift} の翻訳です。英語版は UNLICENSE と MIT ライセンスのデュアルライセンスで公開されています。
- この翻訳は UNLICENSE の許諾に基づいて公開されます。
この記事では新しいコマンドライン検索ツール ripgrep を紹介する。ripgrep は The Silver Searcher (ack
クローン) の利便性と GNU grep の高い性能を併せ持つ。ripgrep は高速で、クロスプラットフォーム (Linux, Mac, Windows 用のバイナリが利用可能) で、Rust を使って書かれている。
ripgrep は Github で公開されている。
この記事では不可能なことを試みる: いくつかの有名なコード検索ツールを公平にベンチマークして比較する。具体的には、次の主張を実証する 25 個のベンチマークをこれから見ていく:
- 単一ファイルの検索と大量のファイルからなるディレクトリの検索の両方において、性能あるいは正確性が ripgrep より明らかに上回るツールは存在しない。
- ripgrep はきちんとした Unicode サポートを性能への大きなペナルティ無しに提供する唯一のツールである。
- 大量のファイルを同時に検索するツールでメモリマップを使うと一般に実行が遅くなる。速くはならない。
これまで二年半にわたって空き時間で Rust を使ったテキスト検索に取り組んできた者として、そして ripgrep と ripgrep で使われている正規表現エンジンの著者として、二つのコード検索ツールの性能に関する詳細な説明をここに記す。精査されないベンチマークなどあり得ない!
対象読者: Unicode とプログラミングを簡単に知っていて、コマンドラインを使った作業をしたことがある人。
検索結果のスクリーンショット
ripgrep の紹介
セールスポイント
他の検索ツールではなく ripgrep を使うべき理由は何か? さて...
- ripgrep は他のツールが持つ機能のほとんどを持ち、さらに他のツールより高速なので、多くの場面で他のツールを ripgrep に置き換えられる (ripgrep が本当に
grep
を置き換えられるかどうかについては FAQ を参照)。 - コード検索に特化した他のツールと同様、ripgrep はデフォルトで再帰的なディレクトリ検索を行い、そのとき
.gitignore
が指定するファイルを無視する。隠しファイルやバイナリファイルもデフォルトで無視される。また ripgrep は.gitignore
の完全なサポートを実装する。ripgrep と同じ機能を持つと主張する他のコード検索ツールには.gitignore
関連の機能にバグが多い。 - ripgrep では特定の種類のファイルだけに対する検索を行える。例えば
rg -tpy foo
は Python ファイルだけを検索し、rg -Tjs foo
は JavaScript ファイルを検索から除外する。独自のマッチング規則を持った新しいファイルの種類について ripgrep に教えることもできる。 - ripgrep は
grep
の機能を多く持つ。例えば検索結果の文脈 (前後の行) の表示、複数パターンの検索、マッチの色付け、完全な Unicode サポートが実装される。GNU grep とは異なり、ripgrep は Unicode サポートを有効化した場合でも高速に検索を行える (Unicode サポートは常に有効化される)。 - ripgrep は正規表現エンジンを PCRE2 に切り替える機能を持つ。PCRE2 を使うと例えばパターン中の先読み/後読みと後方参照が可能になる。この機能はデフォルトの正規表現エンジンではサポートされない。PCRE2 は
-P
フラグで有効化できる。 - ripgrep は UTF-8 以外でエンコードされたファイルの検索をサポートする。例えば UTF-16, latin-1, GBK, EUC-JP, Shift_JIS など (簡単な形で UTF-16 を自動検出する機能が存在する。他のテキストエンコーディングは
-E/--encoding
フラグで指定する必要がある)。 - ripgrep は一般的な圧縮フォーマット (gzip, xz, lzma, bzip2, lz4) で圧縮されたファイルの検索をサポートする。
-z/--search-zip
で有効化できる。 - ripgrep では入力の前処理 (preprocess) を行うフィルタを任意に設定できる。例えば PDF からのテキスト抽出、デフォルトではサポートされていない圧縮フォーマットの解凍、暗号化文書の複合化、自動的なエンコーディング検出などを前処理で行える。
言い換えれば、デフォルトで行われるフィルタリング、Unicode サポート、少ないバグ、速さを気に入るなら ripgrep を使えばいい。
セールスでないポイント
ripgrep を使うべきでない理由についても納得してもらった方がいいと思う。これは ripgrep を使うべき理由よりもずっと見えにくいことが多いためだ。
ありとあらゆる機能を ripgrep に追加するつもりは当初なかったものの、開発が進むにつれ ripgrep は他のファイル検索ツールが持つ機能のほとんどをサポートするようになった。例えば複数行のテキスト検索やオプトインの PCRE2 (先読み/後読みと後方参照のサポートを提供する正規表現エンジン) サポートが ripgrep には実装されている。
現時点において、ripgrep を使うべきでない主な理由はおそらく次のいずれかであると思われる:
- ポータブルでユビキタスなツールが必要である。ripgrep は Windows, Mac, Linux で動作するものの、ユビキタス (どんな場所でも利用可能) ではなく、POSIX といった標準規格に従っているわけでもない。この仕事には古き良き
grep
が一番だ。 - あなたが使っている ripgrep でないツールの機能 (あるいはバグ) が README に載っていない。
- ripgrep の性能が落ちるエッジケースがあり、他のツールだと性能が落ちない。 (ぜひバグを報告してほしい!)
- ripgrep をマシンにインストールできない。あるいは使いたいプラットフォームで ripgrep が利用できない。 (ぜひバグを報告してほしい!)
インストール方法
ripgrep のバイナリは rg
と呼ばれる。
Windows, Mac, Linux 用のバイナリは Github のリリースページから入手できる。Linux 用のバイナリは静的実行形式である。Windows 用のバイナリは MinGW (GNU) でビルドしたものと Microsoft Visual C++ (MSVC) でビルドしたものが用意される。可能な限り GNU ではなく MSVC でビルドしたものを使ってほしい。ただし MSVC でビルドしたバイナリを使うときは Microsoft VC++ 2015 redistributable が必要になる。
あなたが Homebrew ユーザーなら、ripgrep は次のコマンドでインストールできる:
$ brew install ripgrep
あなたが Arch Linux ユーザーなら、ripgrep はオフィシャルレポジトリからインストールできる:
$ pacman -Syu ripgrep
あなたが Rust プログラマーなら、ripgrep は cargo
でインストールできる:
$ cargo install ripgrep
ripgrep をソースからビルドしたい場合でも簡単に行える。ripgrep は Rust で書かれているので、コンパイルするには Rust のインストールが必要になる。ripgrep は Rust 1.9 (stable) 以降でコンパイルできる。ビルドするには次のコマンドを実行する:
$ git clone git://github.com/BurntSushi/ripgrep
$ cd ripgrep
$ cargo build --release
$ ./target/release/rg --version
0.1.2
Rust nightly コンパイラを使っているなら、オプショナルな SIMD アクセラレーションを次のコマンドで有効化できる。この記事のベンチマークでは SIMD アクセラレーションを有効にして測定している。
RUSTFLAGS="-C target-cpu=native" cargo build --release --features simd-accel
弾丸ツアー
ripgrep のコマンドラインでの使い方は同じような機能を持つツールと大きくは変わらないので、あなたは既に ripgrep の使い方を知っているはずだ。完全な説明は rg --help
から確認できるが、ここにも簡単に使い方を示しておく。
ripgrep は出力先が端末かどうかを自動的に判別し、もしそうなら出力に色を付けて行番号を付け足す。これは The Silver Searcher と同じ動作である。色付けは Windows でも動作する! 色は --color
フラグから細かく調整できる。
実行を始める前にもう一つ: 通常 ripgrep は入力が UTF-8 でエンコードされることを仮定する。ただし ripgrep は入力ファイルが UTF-16 であることを検出でき、その場合でも正しく検索が行える。他のエンコーディングを扱うときは -E/--encoding
フラグで明示的に指定しなければならない。
次のコマンドはカレントディレクトリを再帰的に検索する。そのとき隠しファイル、隠しディレクトリ、バイナリファイル、全ての .gitignore
が指定するファイルは無視される:
$ rg foobar
このコマンドは .rgignore
というファイルが指定するファイルも無視する (親ディレクトリにある .rgignore
も検出される)。.rgignore
ファイルは .gitignore
では不十分な場合に利用できる。どんな場合でも .rgignore
にあるパターンは .gitignore
にあるものより優先される。
-u
フラグで全ての ...ignore
ファイルを無視できる。加えて隠しファイルと隠しディレクトリを検索するには -uu
フラグを指定する。さらにバイナリファイルも検索するには -uuu
フラグを加える (「とにかく全て検索してくれ!」を意味する)。このため rg -uuu
は grep -a -r
とほぼ同じコマンドとなる:
$ rg -uu foobar # grep -r とほぼ同じ
$ rg -uuu foobar # grep -a -r とほぼ同じ
(Tip: ...ignore
ファイルが期待通りに動作しないときは、--flag
フラグを付けて検索を実行すると何が起きているかを確認できる。)
大文字と小文字を区別せずに検索するには -i
フラグ、検索を反転させるには -v
フラグ、検索結果それぞれの二行前を表示するには -C2
フラグを利用できる。
全てのマッチが単語境界に囲まれることを強制するには -w
フラグを使う。
検索と置換も行える。次のコマンドはファーストネームとラストネームを見つけて入れ替える:
$ rg '([A-Z][a-z]+)\s+([A-Z][a-z]+)' --replace '$2, $1'
名前付きグループもサポートされる:
$ rg '(?P<first>[A-Z][a-z]+)\s+(?P<last>[A-Z][a-z]+)' --replace '$last, $first'
完全な Unicode サポートに関するハードルを上げるために、Unicode における大文字に Unicode における小文字の列が続く文字列を検索するコマンドを示す (他の検索ツールで同じことをするときは幸運を祈る!):
$ rg '(\p{Lu}\p{Ll}+)\s+(\p{Lu}\p{Ll}+)' --replace '$2, $1'
次のコマンドは特定の glob にマッチするファイルだけを検索する:
$ rg foo -g 'README.*'
特定の glob にマッチするファイルを検索から除外することもできる:
$ rg foo -g '!*.min.js'
HTML ファイルと CSS ファイルだけを検索するコマンドを示す:
$ rg -thtml -tcss foobar
次のコマンドは JavaScript ファイル以外の全てのファイルを検索する:
$ rg -Tjs foobar
rg --type-list
を実行するとサポートされるタイプの一覧を確認できる。新しいタイプを追加するには --type-add
を使った上で検索パターンを続けて書く (rg
はタイプの設定を永続化しない):
$ rg --type-add 'foo:*.{foo,foobar}' -tfoo bar
ここでは foo
というタイプが .foo
あるいは .foobar
で終わる名前を持つ任意のファイルにマッチする。
正規表現の構文
サポートされる構文は Rust の regex ライブラリのドキュメントで説明されている。
grep
の構造
ベンチマークを見ていく前に、まずは grep
ライクな検索ツールの動作に関する高レベルな概観を、特に ripgrep に特別な焦点を当てながら説明しておいた方がいいと思う。この節の目標は考えている問題のコンテキストを多少でも理解し、ベンチマークの理解と解析を容易にすることだ。
背景
コマンドライン引数のパースは脇に置くとして、最初の「本当の」仕事は検索するファイルを決める処理である。grep
などのツールは賢いことを何も行わない: コマンドラインに渡されたファイルを検索するだけだ。例外は -r
フラグで、このフラグが指定されると grep
はカレントディレクトリに含まれる全てのファイルとディレクトリを再帰的に検索する。様々なコマンドラインフラグを使うと検索される (あるいは検索されない) ファイルを制御できる。
ack
はこの種のデフォルトの動作に対する考え方をくつがえした。ack
は検索するファイルをスマートに決定する。例えば ack
はデフォルトでカレントディレクトリを再帰的に検索し、そのときソースコントロールに関連するファイルおよびディレクトリ (.git
など) を無視する。この検索手法には疑いなく独自の利点と欠点がある。というのも確かにツールは「賢く」なるが、この言葉は「不透明」の言い換えでもあるからだ。つまり、本当に全てのファイルを検索したいときに、そうツールに伝えるための正しい呪文を唱える方法を見つけるのが難しい場合が多い。そうは言ってもデフォルトでスマートになるのは便利であり、特に「スマート」が「ソースコントロールの設定を使って検索すべきファイルを求める」を意味するときは非常に便利になる。シェルのエイリアスと grep
を使っては同じことを行えない。
本記事のベンチマークで使用する ripgrep 以外の全てのツールは grep
または ack
の流れをくんでいる。sift
は grep
の系統であり、ag
, ucg
, pt
は ack
の系統である。ripgrep は二つのハイブリッドと言える: grep
のように巨大なファイルを効率良く検索できつつも、ack
のような「スマートな」デフォルトの動作を持つようにしてある。最後に git grep
も特筆に値する。サポートされるオプションを見ると git grep
はプレーンの grep
と非常に似ているものの、デフォルトの検索モードを見ると ack
の系統に属する: git grep
はソースコントロールにチェックインされたファイルだけを検索する。
もちろん、両方の種類のツールに共通する要素も数多く存在する。しかし、少し目を凝らして注目するに値する大きな違いがいくつかある:
grep
ライクなツールは大きなファイルの検索に非常に優れる必要がある。そのため内部で使われる正規表現ライブラリの性能が最優先事項となる。ack
ライクなツールは再帰的なディレクトリ検索と.gitignore
などのファイルが定義する規則の適用を同時に素早く行う必要がある。ack
ライクなツールは多くの検索を並列に実行するように設計される。そのため内部で使われる正規表現ライブラリの生の性能が多少悪くてもそれほど問題にはならず、その場合でもgrep
などを使ったシングルスレッドの「全検索」より高速になる可能性がある。もしack
の「スマートな検索」がディレクトリツリーにある 2GB のアーティファクトをスキップするなら、性能の差はさらに大きくなる。- ripgrep は両方の世界の良い部分を取り入れるためにたくさんの努力をしている。ripgrep 内部の正規表現エンジンが非常に高速であるだけではなく、検索は並列化に行われ、さらに検索するファイルはスマートに選択される。
検索するファイルの収集
ack
ライクなツールでは、カレントディレクトリで検索すべきファイルがどれかを判断する処理が重要になる。これは非常に高速な再帰的ディレクトリイテレータが必要なことを意味する。このイテレータを使って素早くファイルパスをフィルタリングして、実際の検索を実行するワーカーのプールにファイルパスを送らなければならない。
ディレクトリの走査では慎重になる必要がある。というのも、再帰的ディレクトリイテレータの中には stat
の呼び出しを厳密に必要な回数よりも多く行うものがあり、そうすると性能が大きく落ちるからだ。そういったイテレータの実装は標準ライブラリのどこかに埋め込まれている場合が多いので、この種の問題を調べて解決するのは非常に難しくなる場合がある。例えば Python にはつい最近までこの問題が存在した。ただ ripgrep の使うディレクトリイテレータは最小限のシステムコールを行うので安心してほしい。
ファイルパスのフィルタリングではコマンドラインフラグ (grep
の --include
や --exclude
フラグ) に与えられた規則を解釈するだけではなく、.gitignore
といったファイルを読み込んでその規則を全てのファイルパスに適用する必要がある。全てのディレクトリで .gitignore
ファイルを探すだけでも観測できる程度のオーバーヘッドが生じる! これ以外にも .gitignore
に関連する性能上の重要なポイントとして、全ての規則と全てのファイルパスを個別にマッチさせてはいけない。Linux カーネルのソースツリーのような巨大なレポジトリは数百個の .gitignore
と全部で数千個の規則を持つ。
最後に、他のスレッドに仕事を割り当てるには何らかの同期が必要になる。解決法の一つはミューテックスで保護されたリングバッファを用意してキューのように振る舞わせることだが、これより高速になる可能性のあるロックフリーの解決法も存在する。Rust のエコシステムは素晴らしく、私は検索を行うスレッドのプールに仕事を振り分ける処理でロックフリーの Chase-Lev work-stealing queue を再利用できた。本記事のベンチマークに登場する他の全てのツールでは、何らかのミューテックスで保護されたキューを使って仕事を並列化している (現在の sift
と pt
は違うかもしれない。この二つは Go チャンネルを使っており、私はここ数年で Go チャンネルの実装がどう改善されたかを知らない)。
検索
検索は本記事で考える全てのツールの心臓部に位置する。この話題について語り尽くすまで二年半ほど時間をかけることもできる (「Rust を使ったテキスト検索に私がどれだけ時間をかけたか」にようこそ) が、ここでは要点に軽く触れるだけにする。
正規表現エンジン
最初に正規表現エンジンがある。どんな検索ツールも何らかの正規表現の構文をサポートする。正規表現の構文の例を示す:
foo|bar
はリテラルfoo
またはbar
にマッチする。[a-z]{2}_[a-z]+
は「小文字のアルファベット二文字、アンダースコア、一つ以上の小文字のアルファベット」の並びにマッチする。\bfoo\b
は単語境界に囲まれたリテラルfoo
にマッチする。例えばfoobar
に含まれるfoo
にはマッチしないが、I love foo
に含まれるfoo
にはマッチする。(\w+) \1
は「単語文字列、スペース、一つ目の単語文字列と同じ文字列」の並びにマッチする。このパターンに含まれる\1
は「後方参照 (back-reference)」と呼ばれる。このパターンはfoo foo
にはマッチするが、foo bar
にはマッチしない。
正規表現エンジンは利用可能な機能によって大まかに二つのカテゴリに分類される。上記の機能を全てサポートする正規表現エンジンはバックトラッキング (backtracking) と呼ばれるアプローチを使っており、通常はとても高速であるものの一部の入力に対しては非常に遅くなる。ここで「非常に遅い」は検索を完了するまでの時間が指数的になる可能性があることを意味する。例えば次の Python コードを実行してみてほしい:
>>> import re
>>> re.search('(a*)*c', 'a' * 30)
正規表現と検索対象文字列はどちらも非常に短いにもかかわらず、検索には非常に長い時間がかかるはずだ。これは Python 内部の正規表現エンジンがバックトラッキングを使っており、一部のクエリに答えるのに指数時間がかかるためである。
もう一つの種類の正規表現エンジンは有限オートマトンを利用する。この種類の正規表現エンジンはバックトラッキングを使うものより一般に機能が少なく、例えば後方参照をサポートしないことが多い。ただその代わり、有限オートマトンのアプローチでは全ての検索が、正規表現と入力がどんなものであれ検索対象文字列の長さに関する線形時間で終わることがたいてい保証される。
二種類のアプローチのいずれかが平均実行時間でもう一方より優れているわけではない。非常に高速な正規表現エンジンはどちらの種類にも存在する。これを納得してもらった上で、いくつかの検索ツールとそこで使われる正規表現エンジンを次にまとめる:
- GNU grep と
git grep
はそれぞれ有限オートマトンベースの自家製エンジンを使っている。 - ripgrep は Rust の正規表現ライブラリを使っており、これは有限オートマトンを利用する。
- The Silver Searcher と Universal Code Grep は PCRE を使っており、これはバックトラッキングを利用する。
- The Platinum Searcher と
sift
は Go の正規表現ライブラリを使っており、これは有限オートマトンを利用する。
Rust と Go の正規表現ライブラリはどちらも Google の RE2 を祖先に持つ。
最後に、PCRE を使うツール (The Silver Searcher と Universal Code Grep) はどちらもバックトラッキングのアプローチに特有な最悪ケースから逃れられていない。例を示す:
$ cat wat
c
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
c
$ ucg '(a*)*c' wat
terminate called after throwing an instance of 'FileScannerException'
what(): PCRE2 match error: match limit exceeded
Aborted (core dumped)
The Silver Searcher も同様の入力に対して検索に失敗する。最初の行をマッチとして報告するものの、三行目のマッチが無視される。本記事のベンチマークに登場する他のツールはこの入力を問題なく処理できる。
リテラル検索の最適化
どんな検索ツールも最終的には正規表現エンジンを利用するので、高速なものを選択するのは重要である。ただそうは言っても、最速の正規表現エンジンを使った検索でさえ単純なリテラル検索に比べれば長い時間がかかる。Boyer-Moore のアルゴリズムは特定の部分文字列を見つける古典的なアルゴリズムであり、今日でさえ、汎用の検索でこのアルゴリズムを上回るのは難しい。Boyer-Moore のアルゴリズムの特筆すべき特徴が、検索対象文字列を数文字まとめてスキップできることだ。そのときは検索を始める前に計算される小さなスキップテーブルが利用される。
モダンな CPU で Boyer-Moore を高速に実装する上で鍵となるのはスキップできる文字数では必ずしもなく、むしろマッチの候補をどれだけ高速に特定できるかである。例えば多くの Boyer-Moore の実装ではリテラルの最後のバイトから検索を行い、検索対象文字列に含まれるそのバイトが全てマッチの候補となる。Boyer-Moore がテーブルを参照してスキップを行うのはそれからなので、候補となる箇所を見つける最初の処理を高速に行う必要がある。幸い C 標準ライブラリには、まさにこの処理を行う memchr
といった特殊ルーチンが存在する。通常 memchr
の実装は SIMD 命令を使って反復ごとに 16 バイトを調べられるので、memchr
は非常に高速となる。私のシステムでは memchr
のスループットは秒速数 GB 程度である (私が行った実験では memchr
を使った Boyer-Moore アルゴリズムの実装が PCMPESTRI
を使った明示的な SIMD 実装と同程度に高速だった。ただ、この点はもう一度調べてみたいと思っている)。
検索ツールが様々なベンチマークで優秀な成績を納めるには、ツール自身あるいは内部の正規表現エンジンでリテラル検索の最適化を行わなければならない。例えば Rust の正規表現ライブラリは任意のパターンから接頭辞および接尾辞のリテラルを取り出すのに大きな手間をかけている。正規表現のパターンから接頭辞のリテラルを取り出す例を示す:
foo|bar
からはfoo
とbar
を検出する。(a|b)c
からはac
とbc
を検出する。[ab]foo[yz]
からはafooy
,afooz
,bfooy
,bfooz
を検出する。(foo)?bar
からはfoobat
とbar
を検出する。(foo)*bar
からはfoo
とbar
を検出する。(foo){3,6}
からはfoofoofoo
を検出する。
こういったパターンが正規表現の先頭に現れるとき、Rust の正規表現ライブラリはその事実を検出してマッチの候補を高速に検出するのに利用する (複数のリテラルが検出されるときでさえ同様の処理が行われる)。Rust のコアにある正規表現エンジンは高速であるものの、接頭辞のリテラルを最初に検索し、マッチを検証する必要が生じたときにだけコアの正規表現エンジンを起動する方が高速になる。
最良のケースは正規表現全体が単一のリテラルあるいはリテラルの和集合になるときで、このときはコアの正規表現エンジンを全く使わないで検索が行える!
検索ツールはさらに奥の手を持っている。すなわち、多くの検索ツールは行ごとの検索を行う (The Silver Searcher は興味深い例外で、デフォルトの動作が複数行検索となっている) ので、正規表現パターンの非接頭部 (つまり「内側」) にあるリテラルを取り出し、それを使ってマッチ候補の行を検索する。例えば正規表現 \w+foo\d+
からは foo
が取り出される。具体的には ripgrep が候補の行を見つけると、その行に対してだけ行頭と行末を検索し、その行全体をコアの正規表現エンジンに入力する。この工夫により ripgrep は正規表現エンジンを使うことなくファイルの大部分を高速にスキップできるようになる。私たちがベンチマークした検索ツールの大部分はこの最適化を行っておらず、性能を改善する余地がある (特にコアの正規表現エンジンがそれほど高速でないとき)。
リテラルが複数あるとき (例えば foo|bar
) を扱えるのも同様に重要である。GNU grep は複数のパターンを検索する Commentz-Walter というあまり知られていないアルゴリズムを使っている。短くまとめると、Commentz-Walter は Aho-Corasick と Boyer-Moore を組み合わせたアルゴリズムと言える: 逆オートマトンを持ったスキップテーブルである。一方で Rust の正規表現ライブラリはプレーンの Aho-Corasick または (SIMD が有効なときは) Teddy と呼ばれる特別な SIMD アルゴリズムを利用する。Teddy は Intel の正規表現ライブラリ Hyperscan の一部として Geoffrey Langdale によって開発された。この SIMD アルゴリズムは ripgrep が GNU grep を上回るために鍵となる最適化 (最低でもその一つ) であることがベンチマークで判明する。
こういった最適化の結果として素晴らしいのが、ripgrep からはリテラル検索の最適化をほとんどしないで済むことだ。大部分の仕事は Rust の正規表現ライブラリがやってくれるので、そのライブラリを使う側はパフォーマンスの最適化を全て自動的に得られる!
メカニズム
リピートアフターミー: 汝検索を行ごとに行うことなかれ。
検索ツールを実装するナイーブなアプローチは、ファイルを一行ごとに読み込んで各行に対して個別にパターンの検索を行うというものだ。しかしこのアプローチには問題がある。一番の問題は、一般的なケースでマッチが稀であることだ。つまり巨大なレポジトリを検索している場合でも大部分のファイルは全くマッチを生成しないので、大汗かいて各行をパースしても何の成果も得られない可能性が高い。
各行の始まりを見つける処理が追加の仕事であるだけではなく、大きなオーバーヘッドも存在する。リテラルを検索するのであれ正規表現を検索するのであれ、ファイルの全ての行の最初で検索を初めて行末で検索を終わらなければならない。検索をやり直すオーバーヘッドは破滅的である。
その代わり、検索ツールはどれもバイトが並んだ大きなバッファを一度にまとめて検索する方法を見つけている。ファイルをメモリにマップしたり、ファイル全体をメモリに読み込んだり、定常サイズのバッファを使ってファイルをインクリメンタルに検索したりと、方式の違いはあれどツールは様々な工夫を実装する。ただ問題がないわけではない。例えばファイル全体をメモリに読み込む方式あるいはメモリマップを使うツールは (Universal Code Grep のように) stdin
をサポートできなかったり、(The Silver Searcher のように) stdin
を使うと行ごとの検索に切り替わったりする。ripgrep, GNU grep, git grep
が採用するインクリメンタルな形式の検索は任意のファイルやストリームを問題なく扱える。
全てのツールがインクリメンタルな形式の検索を実装するわけではないのには理由がある: 実装が難しいのだ。完全な機能を持った検索ツールでは次の要素を全て考慮しなければならない:
- 行数をカウントする (リクエストされた場合)。
- ファイルからの読み込みが途中で終わったときは、途中で終わる最後の行を取っておいて次のデータが読み込まれてから検索を続ける。
- 一行がバッファより長いときは、バッファを大きくするか諦めるかを選択する。
- 検索関数はマッチを反転できる必要がある。
- 最悪なのがこれだ: 検索関数はマッチの文脈 (現在読んでいる行の前後の行など) を表示できる必要がある。例えばバッファの最初でマッチが生じたときを考えてほしい。どうすればバッファにない一つ前の行を表示できるだろうか? 想像できると思うが、要求された文脈の文だけバッファの間で行を取っておかなければならない。
コードの複雑性の観点から言うと安くない処理である。しかしいやはや、これは実装する価値がある。ベンチマーク結果を読み進めると、この方式がメモリマップを使うより高速であることが判明する!
出力
結果の出力は自明なステップに思えるかもしれない。しかし最低でも多少は気を使っておく必要がある。例えば検索を行うスレッドからマッチを見つけるたびに出力させても構わないものの、通常は同じファイルにおけるマッチをまとめて出力したいことが多いだろう。この処理を実装するナイーブなアプローチは、出力処理を直列化して同時に一つのスレッドだけから出力させるというものだ。ただこのアプローチにも問題がある。というのは、もし検索スレッドが出力処理のロックを取得してからファイルの検索を始める (そしてファイルの検索が終わってから解放する) と、全ての検索が直列化される。これだと並列化のアプローチが全て無駄になる。
そのため本記事のベンチマークに登場するコード検索ツールで検索を並列化するものはどれも、結果をメモリ上の中間バッファに書き込んでいる。こうすると全ての検索スレッドが完全に並列な形で検索を行えるようになる。結果の出力ではそれでも直列化が必要になるものの、その処理は中間バッファの中身を stdout
に書き出すだけだ。メモリ上のバッファを使うと聞いて頭の中で警報ベルが鳴った人もいるかもしれない: 2GB のファイルを検索していて、全ての行がマッチしたらどうする? メモリ使用量が大きくなってしまうのでは? 答えは「なるよ。もちろん!」である。鍵となる洞察は、一般的なケースではマッチする行が検索する行よりずっと少ないことだ。もちろん、メモリ使用量を抑える方法も存在する。例えば ripgrep は stdin
または単一のファイルを検索するとき結果を stdout
に直接書き出し、中間バッファは必要ないので使われない (ripgrep が並列実行しないよう命令されたときも中間バッファを省略できるはずだが、これはまだ実装していない)。言い換えれば、私たちが選べるのは空間、時間、正確性の中から二つだけだ。
結果の出力に関する細かい点はツールごとに異なる点に注意してほしい。具体的に言うと、The Silver Searcher と Universal Code Grep はマッチを構造化データ (match
構造体の配列など) としてメモリに書き込み、git grep
と ripgrep は実際の出力をメモリ上の動的に伸びる文字列バッファに書きこむ。いずれのアプローチも十分高速なようではあるものの、git grep
と ripgrep はインクリメンタル検索をサポートするためにこの方式を使わなけれならない事情がある。これに対して The Silver Searcher は必ずファイル全体をメモリマップし、Universal Code Grep は必ずファイル全体をメモリに読み込む。後者のアプローチだと実際に結果の出力を行うときにメモリ上のファイルの内容を参照できるが、git grep
と ripgrep はこれを行えない。
実験手法
概要
公平で意味のあるベンチマークを行うのは難しい。私は確実に何度も間違いを犯した。特に、制御できる変数があまりにも多いために可能な組み合わせを全て試すのは現実的でない。これは、私がここに示すベンチマークが要約されたものであることを意味する。そして私はベンチマークするツールの一つの作者でもあるから、ベンチマークにバイアスがあることも避けられない。しかし、たとえ私が作成したベンチマークスイートが公平性の観点から見て欠陥を持っていたとしても、読者にはそれぞれのベンチマークに対して私が加える解析を興味深く読んでもらえることを願っている。解析はどれも私の行ったことを多く語るよう大きくバイアスがかかっているが、これは私がそれを最も良く知っているためだ。ただ私はベンチマークを行った全てのツール (そして内部の正規表現エンジン) のソースコードを最低でも一部は確認している。
言葉を変えれば、私は解析の詳細が正しいことについては大きな自信を持っているものの、何らかの大局的な見地を見逃している可能性があるということだ。このため、まずはこのベンチマークを作る上で私が意識したポイントを説明しよう:
- エンドユーザーが解決しようとしている問題に焦点を当てる。例えば、ベンチマーク全体は二つに分けられる: 大量のファイルを持つディレクトリの検索と、単一の大きなファイルの検索である。前者はコードベースなどを検索するエンドユーザーに対応し、後者はログなどを検索するエンドユーザーに対応する。これから見るように、この二つのユースケースは驚くほど異なる性能特性を持つ。片方に優れるツールがもう一方に優れるとは限らない (ripgrep の強みは両方に優れることだ!)。
- エンドユーザーの問題を細かくベンチマークにする。例えばマッチは対象のコーパスと比べてごく少数しか存在しないことがほとんどなので、マッチをほんの少しだけ生成するベンチマークを優先する。もう一つの例: 私は経験に基づき、ほとんどの検索は単純なリテラルや非常に軽量な正規表現からなるだろうと仮説を立てた。そのためベンチマークはそういった種類のパターンを優先するようバイアスがかかっている。
- ほぼ全てのツールはデフォルトの動作が少しずつ異なり、その違いが性能に影響する場合がある。ツールの「そのままの」性能を測定することにも価値はある (そういったベンチマークを行った) ものの、それで終わっては満足いかない結果となる。私たちの目標が公平なベンチマークなら、最低でも各ツールにはエンドユーザーの視点から見てほぼ同じ仕事を行わせるべきだ。この考え方の良い例が行番号の報告処理である。一部のツールでは行数のカウントを無効化できないので、無効化できるツールと比較するときは行番号を報告するよう明示的に設定する必要がある。行数のカウントはコストが非常に高くなる場合があるので、これは重要となる! この考え方と関係がない違いの例はツールがメモリマップを使うか中間バッファを使うかである。これは実装の選択肢であって、ユーザーが目にする結果を変えるものではない。よってこの二つの実装の選択肢をベンチマークで比較するのは (解析がそのことに触れることを仮定すれば) 完全に公平である。
これで細かいことは片付いたので、本題に入ろう。何よりもまず、どのツールをベンチマークするのだろうか?
-
ripgrep (
rg
) (v0.1.2) ── あなたはこれについて十分なほど読んできた。 - GNU grep (v2.25) ── 古くて信用のおけるツール。
-
git grep (v2.7.4) ──
grep
に似ているが、git
に組み込まれている。git
レポジトリでのみ正しく動作する。 -
The Silver Searcher (
ag
) (コミットcda635
, PCRE 8.38 を利用) ──ack
に似ているが、C で書かれておりずっと速い。ripgrep と同様にユーザーの.gitignore
を読む。 -
Universal Code Grep (
ucg
) (487bfb
, JIT を有効にした PCRE 10.21 を利用) ── これもack
と似ているが、C++ で書かれており、ホワイトリストにあるファイルだけを検索する。.gitignore
の読み込みはサポートしない。 -
The Platinum Searcher (
pt
) (コミット509368
) ── Go で書かれ、.gitignore
の読み込みをサポートする。 -
sift
(コミット2d175c
) ── Go で書かれ、オプショナルなフラグで.gitignore
の読み込みをサポートする。ただし一般に全てのファイルを検索しようとする (この動作をするのは他にgrep
だけ)。
ここに無いツールとして言及しておくべきなのが ack
だ。ack
をベンチマークしなかったのは、執筆時点で ack
がこのリストにある他のツールより格段に遅かったためである。ただ現在ベータ中の ack 3 では一部の場面で検索時間が半分になるなど性能が改善されている。
ベンチマークランナー
ベンチマークランナーは Python プログラム (最低でも Python 3.5 が必要) であり、ベンチマークの実行だけではなくベンチマークで利用されるコーパスのダウンロードも行う。このスクリプトは ripgrep
のレポジトリの benchsuite/benchsuite
に置いてある。次のように実行する:
$ git clone git://github.com/BurntSushi/ripgrep
$ cd ripgrep/benchsuite
# 注意! 以下のコマンドは数 GB のデータをダウンロードして
# Linux カーネルをビルドするので、高速な回線があっても 15 分ほどの時間がかかる。
# Tip: --download subtitles-ru を指定すると一番小さいコーパスだけが
# ダウンロードされる。ただその場合はダウンロードしたコーパスに対する
# ベンチマークしか実行されない。
$ ./benchsuite --dir /path/to/data/dir --download all
# 実行可能なベンチマークを表示する。
$ ./benchsuite --dir /path/to/data/dir --list
# ベンチマークを実行する。
# ベンチマークの名前を省略すると全てのベンチマークが実行される。
# 完全なスイートの実行にはデフォルトの設定だと 30 分ほどかかり、
# --warmup-iter 3 --bench-iter 10 だと 120 分ほどかかる。
$ ./benchsuite --dir /path/to/data/dir '^subtitles_ru_literal$'
ベンチマークで使われるコード検索ツールの一部を持っていない場合は --allow-missing
を benchsuite
に渡すと実行を飛ばすようにできる。生データ (実行された全てのコマンドの測定結果) を保存するには --raw /path/to/raw.csv
を指定する。
誤解を招きかねないデータが得られる可能性を小さくするために、ベンチマークランナーはいくつかの基本的な対策を行っている:
- 全てのコマンドは三回実行して「温めて」から測定される。具体的に言うと、この処理は検索されるコーパスがオペレーティングシステムのページキャッシュに載ることを保証するために存在する。この「温め」を最初に行わないと、ベンチマークがディスク I/O を測定することになる。これは私たちの目的と合っていないばかりか、エンドユーザーのシナリオとしても考えにくい。普通は同じコーパスに対して何度も検索を行う可能性が高いはずだ (少なくとも、私はそうだ)。
- ベンチマークされる全てのコマンドは 10 回実行され、実行時間が毎回記録される。コマンドの最終的な「実行結果」は測定された実行時間の分布 (平均と標準偏差) である。もし私が統計学者なら 10 個のサンプルでは不十分であることを証明できるかもしれない。ただサンプルをもっと集めるのには時間がかかり、たいていの場合で実行時間の分散は非常に小さい。
それぞれのベンチマークの定義は各コマンドが同じような仕事をすることを保証する責任がある。例えば GNU grep で完全な Unicode サポートを有効にすると実行速度が大きく低下する場合があるので、注意して正しく設定する必要がある。一つのベンチマークで注目すべき変数が複数ある場合が多いので、私はプログラムを表す文字列に (ASCII)
や (whitelist)
といったラベルを付けるようにしている。このラベルについては後述する。
自分でコードをいじるときは、新しいベンチマークを自分で書くことを推奨する。ベンチマークは benchsuite
ファイルの前半部分にあり、コピペして改変するのは簡単なはずだ。新しいベンチマークを定義するだけで利用可能になる。ファイルの後半部分はベンチマークランナー本体なので、おそらく改変するべきではない。
環境
本記事で示されるベンチマーク結果を得るのに使われた実際の環境は Amazon EC2 の c3.2xlarge
インスタンスである。このインスタンスは Ubuntu 16.04 を実行し、Xeon E5-2680 2.8 GHz CPU と 16 GB のメモリ、80 GB の SSD を持つ (コーパスは SSD に保存される)。これはコーパス全体が収まるだけのメモリである。インスタンスはベンチマークを実行するためだけに起動され、他にプログラムは実行されない。
システムのセットアップおよび検索ツールのインストールとベンチマークの実行に利用したコマンドの完全なログはここから確認できる。またベンチマークランナーの出力 (ネタバレ注意!) と生の出力もキャプチャしてある。生の出力には実行時間、完全なコマンドライン引数、それぞれのコマンドを実行するときに設定された環境変数がベンチマークごとに記録されている。
コード検索ベンチマーク
これはベンチマークの前半部分であり、大量のコードを含むレポジトリから特定のパターンを検索するエンドユーザーに対応する。
このベンチマークで使われるコーパスは Linux カーネル (コミット d0acc7
) をチェックアウトしてビルドしたディレクトリである。Linux カーネルを実際にビルドするのは、ビルドによって検索に含めるべきでないファイルが大量に生まれるためだ。これは検索ツールが返す結果の適合度だけではなく性能にも影響する。
本節で実行結果が示されるベンチマークは全てレポジトリのルートで実行された。もし見たければ各コマンドの生の出力を確認できる。ベンチマークの名前は以降の節の名前に対応する。
ベンチマークが実行された EC2 インスタンスは VM を使っているので、メモリマップを利用する検索ツールが不利になる可能性があることには注意が必要である。そこで私は自分のローカルマシンでもベンチマークを実行した。このローカルマシンは 16 スレッド 3.2 GHz の Intel i7-6900K CPU と 64 GB のメモリ、SSD を持つ。このマシンだと ag
が高速になることに気が付くだろう (ただそれでも ripgrep よりは遅い)。rg
の結果が良く見えるように EC2 マシンの結果を使ったのではないかと思うかもしれないが、そうではないので安心してほしい。具体的に言うと、ローカルマシンで rg
は一つを除く全てのベンチマークで他のツールを上回った。これに対して EC2 インスタンス上で rg
はいくつかのベンチマークで他のツールに負けている。
前置きはこれくらいにして、ベンチマークを見ていこう。
linux_literal_default
説明: このベンチマークは各ツールのデフォルト設定で単純なリテラル検索を実行したときの実行時間を比較する。これはツールの「そのままの」設定を比較するベンチマークであり、不公平なのは意図的である。
パターン: PM_RESUME
結果:
rg 0.349 +/- 0.104 (lines: 16)
ag 1.589 +/- 0.009 (lines: 16)
ucg 0.218 +/- 0.007 (lines: 16)*+
pt 0.462 +/- 0.012 (lines: 16)
sift 0.352 +/- 0.018 (lines: 16)
git grep 0.342 +/- 0.005 (lines: 16)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間rg == ripgrep
,ag == The Silver Searcher
,ucg == Universal Code Grep
,pt == The Platinum Searcher
解析: まず各ツールが行う処理を説明する:
rg
は Linux カーネルのレポジトリが持つ.gitignore
ファイル (なんと 178 個もある) を解釈し、さらに隠しファイルとバイナリファイルをスキップする。また行数をカウントしない。ag
のデフォルト動作は基本的にrg
と同じで、行数をカウントする点だけが異なる。ucg
も行数をカウントするが、.gitignore
を読もうとしない。その代わりucg
は glob ルールからなる (非常に詳細な) ホワイトリストを使って検索するファイルを決定する。例えばrg
とag
はfs/jffs2/README.Locking
を検索するのに対して、ucg
は拡張子Locking
のファイルを無視するので検索しない (おそらく検索ツールはこのファイルを検索するべきである。ただこのベンチマークでは結果への影響はない)。pt
はag
と同じデフォルト動作を持つ。sift
は隠しファイルとバイナリファイルを含めた全てのファイルを検索する。grep -r
と等価になるはずである。行数はカウントしない。git grep
はrg
と同じ動作を持つはずであり、同様に行数もカウントしない。ただgit grep
には特別に有利な点がある: ディレクトリ階層を走査する必要がなく、git インデックスから直接検索を行える。
このベンチマークからはまず、性能という観点からは検索ツールのナイーブな比較が全く公平でないことが分かる。ただ結果の適合度について話をするなら、この比較は非常に重要な意味を持つ。sift
および grep -r
は全てのファイルから文字列を検索して返すので、このベンチマークで試した他のツールとかなり異なる哲学を持つ: 他のツールはユーザーが気にするであろうファイルだけを検索対象とする。例えば .git
フォルダの中身をユーザーはおそらく気にしない (sift
の哲学が間違っているわけではない。明らかに sift
はユーザーに自分で検索を設定させるようにしており、その方式には利点と欠点がある)。
性能に関して言うと、注意しておくべき重要な変数が二つある。この二つの変数はこれからのベンチマークで何度も登場する:
- 行数のカウントは非常にコストが高くなる可能性がある。例えばナイーブな解決法──全てのバイトを一つずつループして
\n
と比較する──は非常に遅い。Universal Code Grep は SIMD を使って行数をカウントしており、ripgrep は packed comparison を使って一度に 16 バイトを比較している。ただし Linux カーネルを検索するベンチマークでは各ファイルが非常に小さくマッチの個数はコーパスに比べてごく小さいので、行数のカウントにかかる時間はそれほど大きくない。このベンチマークでは全てのツールが検索をある程度並列化するのでなおさらである。単一ファイルのベンチマークではこの変数が大きな影響を持つことになる。 .gitignore
ファイルを解釈するとオーバーヘッドがある程度生じる。.gitignore
を解釈すると検索対象のファイルの個数は減るものの、そこに書かれたパターンを読み込んでコンパイルし、全てのパスに対してマッチを判定するのは単に全てのファイルを検索するより遅くなる可能性がある。このベンチマークでucg
が ripgrep よりずっと高速になっている理由はまさにこれである (以降のベンチマークではこの変数を制御する)。言い換えれば、.gitignore
の解釈は何よりもまずマッチの適合度を向上させるための機能であり、性能が改善したとしてもそれはボーナスでしかない。
.gitignore
をサポートすると全体の検索が遅くなる具体的な理由は次の通りである:
- 全ての下層ディレクトリで対応する
.gitignore
を検索しなければならない。The Silver Searcher や ripgrep のように追加の...ignore
ファイルをサポートするなら複数回検索が必要になる。Linux カーネルレポジトリには 4,640 個のディレクトリがあり、178 個が.gitignore
ファイルを持つ。 - それぞれの
.gitignore
ファイルをコンパイルしてファイルパスとのマッチが行える形式へ変換しなければならない。この処理を高速にする工夫は The Silver Searcher と ripgrep の両方に存在する。例えば/vmlinux
や*.o
のような単純なパターンは単純なリテラル比較あるいは候補パスの拡張子と直接比較することでマッチを判定できる。一方で*.c.[012]*.*
のような複雑なパターンでは完全な glob マッチャーを使う必要がある。The Silver Searcher はfnmatch
を使っているが、ripgrep は全ての glob を単一の正規表現に変換し、それに対してパスがマッチするかどうかを判定している。こういった処理には時間がかかる。 ag
と異なり、rg
は.gitignore
ファイルの完全な意味論をサポートしようとしている。これはファイルパスにマッチする無視パターンを全て見つけて最も最近に定義されたものを優先することを意味する。ag
は最初のマッチを見つけた段階で仕事を終えてしまう。- 実際にパスとのマッチを判定する処理には自明でないオーバーヘッドがあり、それは検索される全てのパスで生じる。上述のコンパイルフェーズが複雑なのは、まさにこの点を高速にするためである。正規表現の機構に頼るのはできる限り避けようとしているものの、完全に使わないことはできない。
これと比べると、ucg
が採用するホワイトリスト方式は高速に実装しやすい。考えるべき glob の集合が最初に分かるので、ファイルツリーの走査中に追加のチェックは必要にならない。さらに glob の多くは *.ext
の形をしているので、そういった glob をまとめておけばファイルパスの拡張子を見るだけで効率的にマッチが判定できる。
ホワイトリスト方式の欠点は明らかだ: ucg
に伝えていない拡張子のファイルに対しては検索結果が得られない可能性がある。ucg
に新しい拡張子を伝えることはいつでもできるものの、「知らないことを知らないこと (unknown unknown)」には気付けない (検索した方がいいことを知らないと何もできない)。
linux_literal
説明: このベンチマークが実行するクエリは linux_literal_default
と同じだが、ここでは公平な比較ができるよう設定を調整してある。具体的に言うと、ripgrep は二つのモードで実行される: .gitignore
を解釈するモード (ラベル (ingore)
に対応) と、ホワイトリストを使って .gitignore
を無視するモード (ラベル (whitelist)
に対応) である。一つ目のモードは rg
, pt
, sift
, git grep
と比較でき、二つ目のモードは ucg
と比較できる。また ag
と同様に検索でメモリマップを使うよう明示的に指示した rg
も実行している。sift
は .gitignore
を解釈し、バイナリファイル、隠しファイル、PDF ファイルを無視する設定で実行される。また全てのコマンドは行数をカウントする (ag
と ucg
が行数カウントの無効化をサポートしないため)。
パターン: PM_RESUME
結果:
rg (ignore) 0.334 +/- 0.053 (lines: 16)
rg (ignore) (mmap) 1.611 +/- 0.009 (lines: 16)
ag (ignore) (mmap) 1.588 +/- 0.011 (lines: 16)
pt (ignore) 0.456 +/- 0.025 (lines: 16)
sift (ignore) 0.630 +/- 0.004 (lines: 16)
git grep (ignore) 0.345 +/- 0.007 (lines: 16)
rg (whitelist) 0.228 +/- 0.042 (lines: 16)+
ucg (whitelist) 0.218 +/- 0.007 (lines: 16)*
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: このベンチマークからはたくさんのことを読み取れる。
まず、(ignore)
と (whitelist)
を比べると rg
の性能が大きく変化しているのが分かる。linux_literal_default
で行った解析を繰り返すことはしないが、rg
をホワイトリストモードに切り替えると実行速度は ucg
と同程度になる。
次に、ベンチマークしたツールの中だと ripgrep が最速だと前に言ったにもかかわらず、ucg
は rg (whitelist)
と同程度に高速であり、git grep (ignore)
は rg (ignore)
と同程度に高速である。ここから ucg
, git grep
, rg
は大きなレポジトリからプレーンなリテラルを検索するとき同じような速度となることが分かる。以降のベンチマークでは速度の差異が分かりやすく現れる。ただ、ucg
が速いのはなぜだろうか?
ucg
は検索する前にファイル全体をメモリに読み込む。これは後述するメモリマップの問題が存在しないことを意味する。コードレポジトリの検索ではこのアプローチに軍配が上がるものの、シングルファイルのベンチマークでは非常に大きな対価を払うことになる。ucg
は明示的な SIMD ベースの行数カウントアルゴリズムを利用する。ripgrep
も似たことを行ってはいるが、コンパイラの自動ベクトル化に頼っている。ucg
は PCRE2 の JIT を使っており、これが桁違いに速い。私が行った非常に粗いベンチマークでは、PCRE2 の JIT は Rust の正規表現エンジンに対抗できる数少ない正規表現エンジンの一つだった (ただしバックトラッキングをサポートする PCRE に特有な指数的振る舞いを生じさせない正規表現に対してのみである。Rust の正規表現エンジンは指数的振る舞いを持たない)。ucg
はディレクトリ走査を並列化するのに対して、ripgrep は並列化しない。ucg
は.gitignore
ファイルをサポートしないので有利なアプローチを選択できる。.gitignore
の状態を管理した上でディレクトリ走査をスケールする形で並列化する問題に対しては綺麗な解答をまだ見つけられていない。
git grep
についてはどうだろうか? git grep
の主な性能上の利点はディレクトリツリーを歩き回る必要がないことであり、これによって時間を大きく節約できる。また git grep
の正規表現エンジンも非常に高速で、GNU grep および Rust の正規表現エンジンや RE2 と同様の方式で (DFA を使って) 動作する。
sift
と pt
はどちらも ripgrep と同じような性能を示している。実は sift
と pt
は .gitignore
を解釈しながらも並列に行えるディレクトリ走査を実装しており、これが速度が出ている理由だと思われる。ただ以降のベンチマークで見るように、ここでの sift
と pt
の速度はミスリーディングである。具体的には、この二つのツールが速いのはパターンがリテラルであるために Go の正規表現エンジンを使っていないためだ (この点は後で詳しく議論する)。
最後に、The Silver Searcher はどうしたのだろうか? 本当に他のツールのどれよりもあれほど遅いのか? 鍵となるのは、The Silver Searcher がメモリマップを使うことで検索を遅くしている事実だ。メモリマップがあっても検索は速くならない (README にある主張とは正反対である)。
OK、少し時間を取って、この結果が意味することについて話そう。まず、それぞれの検索ツールが実際にどのように動作するかを考える必要がある。一般に言って、検索ツールがディスク上のファイルを検索する方法は二つある:
- ファイルをメモリマップして、ファイル全体が連続するメモリ領域に存在するかのような状態にしてから検索をまとめて行う。ファイルをメモリの連続領域にあるかのように見せる処理はオペレーティングシステムが裏で行ってくれる。二つ目のアプローチと比べると、このアプローチは非常に簡単に実装できる。
- ...あるいは中間バッファをアロケートして、固定サイズのバイトブロックをそこに読み込んで検索を行うという処理を繰り返すこともできる。バッファが行の途中で終わる可能性があるので、このアプローチは実装の難易度が恐ろしく高い。さらに一行がバッファより長くなる可能性も考慮しなければならない。最後に、マッチの前後の行 (「文脈」) を表示する機能を (
grep
や ripgrep と同様に) サポートしたい場合は、バッファの先頭でマッチが起こったときでも一つ前のバッファのデータが正しく出力されるように追加でデータの管理が必要になる。
ナイーブに考えると、明らかに一つ目のアプローチが速いように思える。二つ目のアプローチではデータのコピーや管理が必要だから、ずっと遅いに決まってるじゃないか! しかし実際には、この考え方は全く正しくない。一つ目のアプローチだとプログラマーはデータの管理をせずに済むかもしれないが、メモリマップを行うために Linux カーネル内部で起きるデータ管理処理は非常に多い (リンク先はかなり古いメーリングリストの投稿だが、現在でも同じことが言える)。
ripgrep を書き始めたとき、私はメモリマップのアプローチを使っていた。中間バッファを使う二つ目の道を試してみようと思うまでには長い時間がかかった (CPU プロファイルや strace
からはメモリマップに時間がかかっていることが分からなかったからだ) ものの、プロトタイプを実装するとすぐにメモリマップのアプローチより速いことが判明した。
ただそうは言っても、メモリマップに良い所が何もないわけではない。今考えている「数千個の小さなファイルを開いてスキャンし、すぐに閉じる」という処理に対してはたまたま相性が悪かっただけだ。別の処理、例えば「巨大なファイルを開いて、一度だけ検索する」といった処理ではメモリマップの方が優れる。これは後で見る単一ファイルのベンチマークで実際に確認できる。
この結論を支える重要なデータ点が rg (ignore)
と rg (ignore) (mmap)
である。具体的に言えば、この二つでは検索の戦略だけが異なるので、メモリマップが問題であることがほぼ決定的になる。
さらに言っておくと、メモリマップの性能は環境によって大きく変化するので rg (ignore)
と rg (ignore) (mmap)
はミスリーディングである可能性がある。特にベンチマークを実行する EC2 インスタンス c3.2xlarge
はおそらく仮想マシン上で実行されるので、メモリマップの性能に影響があると考えられる。これを検証するために、私は同じベンチマークを机の下にある私のマシン (16 スレッド 3.2 GHz の Intel i7-6900K CPU と 64 GB メモリ、そして SSD を持つ) で実行した。次に結果を示す:
rg (ignore) 0.156 +/- 0.006 (lines: 16)
rg (ignore) (mmap) 0.397 +/- 0.013 (lines: 16)
ag (ignore) (mmap) 0.444 +/- 0.016 (lines: 16)
pt (ignore) 0.159 +/- 0.008 (lines: 16)
sift (ignore) 0.344 +/- 0.002 (lines: 16)
git grep (ignore) 0.195 +/- 0.023 (lines: 16)
rg (whitelist) 0.108 +/- 0.005 (lines: 16)*+
ucg (whitelist) 0.165 +/- 0.005 (lines: 16)
ここでも rg (ignore)
は rg (ignore) (mmap)
より高速であり、メモリマップに関する先ほどの結論はこのデータからも支持される。ただ rg (ignore)
と rg (ignore) (mmap)
の違いは少し小さくなったようだ!
linux_literal_casei
説明: このベンチマークは基本的に linux_literal
と同様で、検索ツールに大文字と小文字を同一視する検索 (case insensitive search) を行うよう伝えている点だけが異なる。
パターン: PM_RESUME
(加えて -i
フラグを設定する)
結果:
rg (ignore) 0.345 +/- 0.073 (lines: 370)
rg (ignore) (mmap) 1.612 +/- 0.011 (lines: 370)
ag (ignore) (mmap) 1.609 +/- 0.015 (lines: 370)
pt (ignore) 17.204 +/- 0.126 (lines: 370)
sift (ignore) 0.805 +/- 0.005 (lines: 370)
git grep (ignore) 0.343 +/- 0.007 (lines: 370)
rg (whitelist) 0.222 +/- 0.021 (lines: 370)+
ucg (whitelist) 0.217 +/- 0.006 (lines: 370)*
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: 一つ前のベンチマークとの最も大きな違いは、pt
が次に遅いツールより一桁遅くなっていることだ。
pt
がこれほど遅くなったのはなぜだろうか? 特に、sift
と pt
はどちらも Go の regexp
パッケージで検索を行っているのに、片方だけが格段に遅くなりもう一方は少ししか遅くなっていないのはどうしてだろうか? 実は大文字と小文字を同一視する検索を示す -i
フラグを pt
が検出すると、Go の regexp
エンジンに i
フラグが設定される。そのため、例えば CLI から pt -i foo
と起動すると (?i)foo
という Go の正規表現が生成され、正規表現エンジンが大文字と小文字を同一視する検索を行う。
これに対して、-i
を検出した sift
は別の道を行く: sift
はパターンと検索するバイトの両方を小文字に変換してから検索を行う。一つ前の linux_literal
からの性能低下が sift
で小さい主な理由は検索対象の文字列全てに対するこのフィルタであると思われる (正確に言うとこの最適化は正しくないことも言及に値するだろう。バイトごとの変換では ASCII の大文字と小文字しか考慮されないからだ。Go の正規表現エンジンを利用する pt
は Unicode の大文字と小文字を完全にサポートする)。
ただそれでも疑問は残る。Go の正規表現エンジンはそれほど遅いのだろうか? 残念ながら、その通りである。Go の正規表現エンジンは全ての検索で線形の最悪実行時間を保証する (そのため一部の正規表現とコーパスに対しては PCRE2 より指数的に高速になる) ものの、実際の実装はあまり成熟していないようだ。実際、有限オートマトンベースの高速な正規表現エンジンで私が知っているものはどれも DFA エンジンの一種を実装する。例えば GNU grep, Google の RE2, Rust の正規表現ライブラリはどれも実装している。しかし Go の正規表現エンジンは実装していない (ただ DFA エンジンを実装する作業は進行中なので、将来 pt
は何もしなくてもこのベンチマークの結果が改善するかもしれない!)。
次の話題に移る前に説明したいことがもう一つある。rg
, ag
, git grep
, ucg
の性能は一つ前のベンチマークからほとんど変化がない。大文字と小文字を同一視する検索ではオーバーヘッドがいくらか生じるはずではないだろうか? この疑問への解答は込み入っており、それぞれのツールが内部に持つ正規表現エンジンに関して私が持っているより深い知識が必要になる。ただ幸いにも、Rust の正規表現エンジンについては私が答えられる。
ここで重要な洞察として、大文字と小文字を区別する PM_RESUME
の検索は PM_RESUME
の大文字と小文字を入れ替えて得られる全ての文字列の大文字と小文字を同一視する検索と等しい。例えば今考えている検索は PM_RESUME|pM_RESUME|Pm_RESUME|PM_rESUME|...
といった形の正規表現の検索に等しい。ただもちろん、大文字と小文字を入れ替えて得られる文字列は (たとえ今考えている短いリテラルであっても) 非常に数が多くなる。ここで鍵となる事実は入れ替えた文字列の接頭辞が簡単に取り出せる事実である。Rust の正規表現エンジンが入れ替えた文字列の接頭辞として取り出す文字列を次に示す (この情報は rg
に --debug
フラグを渡すと stderr
に表示される):
PM_RE
PM_Re
PM_rE
PM_re
Pm_RE
Pm_Re
Pm_rE
Pm_re
pM_RE
pM_Re
pM_rE
pM_re
pm_RE
pm_Re
pm_rE
pm_re
(この処理には Unicode サポートが組み込まれているので安心してほしい。例えば S
の大文字と小文字を同一視する検索では S
, s
, ſ
のリテラルが取り出される。)
大文字と小文字を入れ替えたリテラルが手に入ったら、何ができるだろうか? 古典的な解答 (Aho-Corasick など) は DFA にコンパイルして検索対象のテキストを高速にスキップするというものだ。テキストがリテラルのどれかにマッチすると、正規表現エンジンが起動してマッチの検証が行われる。この方法だと速度が劣る正規表現エンジンを検索対象のテキスト全体に対して実行せずに済む。
ただし、Rust の正規表現エンジンはこの処理に Aho-Corasick を使っていない。SIMD アクセラレーションが有効なとき (本記事のベンチマーク、および私が配布しているバイナリでは有効になっている)、Teddy と呼ばれる特別な多パターン検索アルゴリズムが使われる。Teddy は未発表であり、Intel の正規表現ライブラリ Hyperscan の一部として Geoffrey Langdale によって開発された。このアルゴリズムはリテラルがマッチする可能性のある位置を packed comparison により一度に 16 バイトずつ判定する。私は Hyperscan プロジェクトに含まれるこのアルゴリズムを採用し、Rust に移植した。README に詳しい解説を書いてあるので、気になる人は読んでほしい。
このベンチマークでは Teddy があっても特に得るものはないものの、以降のベンチマークでは大きな性能向上が得られる。
linux_word
説明: このベンチマークでは PM_RESUME
をもう一度使う。ただ今回は -w
フラグを各ツールに渡す。この -w
フラグを付けるとき、「単語」だけがマッチとして報告される。ここで「単語」とは単語境界で始まり単語境界で終わる文字の並びであり、単語境界とは検索対象のテキストで単語文字と非単語文字が隣接する箇所を言う。
パターン: PM_RESUME
(加えて -w
フラグを設定する)
結果:
rg (ignore) 0.362 +/- 0.080 (lines: 6)
ag (ignore) 1.603 +/- 0.009 (lines: 6)
pt (ignore) 14.417 +/- 0.144 (lines: 6)
sift (ignore) 7.840 +/- 0.123 (lines: 6)
git grep (ignore) 0.341 +/- 0.005 (lines: 6)
rg (whitelist) 0.220 +/- 0.026 (lines: 6)*+
ucg (whitelist) 0.221 +/- 0.007 (lines: 6)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: この結果は linux_literal
および linux_literal_casei
と大きくは変わらない。一番に記しておくべきは -w
フラグが性能に大きな影響を及ぼさないことだが、それ以外にも言及したいことがある。
rg
は Unicode 的に正しい単語境界を認識するのに対して、他のツールは全て ASCII の単語境界だけしか認識しない (git grep
ではシステムロケールを調整すると Unicode の単語境界を使うようにできる。このベンチマークでは ASCII の単語境界を利用する)。
一つ前のベンチマークと比べると、pt
と sift
だけが実行速度を大きく落としている。この理由は pt
が linux_literal_casei
で遅くなったのと同じで、Go
の正規表現ライブラリがボトルネックになる。pt
と sift
では最初にリテラル PM_RESUME
を検索して、そこで見つかったマッチが単語境界にあるかどうかを判定させることで性能を改善できるはずだ。こうしても Go の regexp
ライブラリは使われるかもしれないが、その頻度は低くて済む。
linux_unicode_word
説明: このベンチマークは Ah (アンペア時) に接頭辞を付けた文字列を検索する単純なクエリの速度を測定する。例えば mAh
(ミリアンペア時) や µAh
(マイクロアンペア時) がマッチする。これが興味深い理由は Unicode に対応するとき \w
が µ
にマッチしなければならず、ASCII だけに対応するときマッチしてはいけないことだ。ここでも .gitignore
を解釈するかどうかを制御する。
パターン: \wAh
結果:
rg (ignore) 0.355 +/- 0.073 (lines: 186)
rg (ignore) (ASCII) 0.329 +/- 0.060 (lines: 174)
ag (ignore) (ASCII) 1.774 +/- 0.011 (lines: 174)
pt (ignore) (ASCII) 14.180 +/- 0.180 (lines: 174)
sift (ignore) (ASCII) 11.087 +/- 0.108 (lines: 174)
git grep (ignore) 13.045 +/- 0.008 (lines: 186)
git grep (ignore) (ASCII) 2.991 +/- 0.004 (lines: 174)
rg (whitelist) 0.235 +/- 0.031 (lines: 180)
rg (whitelist) (ASCII) 0.225 +/- 0.023 (lines: 168)*+
ucg (ASCII) 0.229 +/- 0.007 (lines: 168)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: このベンチマークでは新しい変数が導入される: Unicode サポートを有効にするかどうかである。Unicode を解釈する検索は ASCII だけを解釈する検索よりいくつか多いマッチを報告する。
このベンチマークで考えているツールの中では rg
と git grep
だけが Unicode サポートの切り替えに対応する。Unicode サポートはパターン内でフラグを通して設定される (例えば \w
は Unicode をサポートし、(?-u)\w
はサポートしない)。また git grep
では Unicode サポートを環境変数 LC_ALL
を通して有効にできる (en_US.UTF-8
だと有効になり、C
だと無効になる)。より一般的に言うと git grep
の Unicode サポートはシステムロケールと一致するようになっているので、LC_ALL
を設定するのはハックの一種と言える。
しかし問題はさらに深い。Unicode サポートの切り替えに対応する rg
と git grep
だけであるだけではなく、そもそも Unicode をサポートするツールがこの二つしかない。ag
, pt
, sift
, ucg
では文字クラス \w
が必ず ASCII 文字だけから構成される (pt
と sift
では Go の regexp
ライブラリが Unicode を解釈する \w
に対応していない。ag
と ucg
では PCRE に Unicode をサポートするフラグが存在するものの、ツールがその機能に対応していない)。
このベンチマークで注目すべき重要な結果は、Unicode サポートを有効にしたとき git grep
では性能が大きく低下するのに対して、rg
では目立った性能低下が見られないことだ。もちろん rg (ignore)
と git grep (ignore)
は同じ個数のマッチを報告している。
一つ前のベンチマークと同様に、pt
と sift
はリテラル Ah
を最初に検索してから Go の regexp
ライブラリでマッチを検証することで高速化ができるはずだ。
ベンチマークの結果を見ると、重要な疑問が二つ思い浮かぶ:
git grep (ignore) (ASCII)
がrg (ignore) (ASCII)
より格段に遅いのはなぜか? また直接比較できるわけではないものの、git grep (ignore) (ASCII)
はucg (ASCII)
と比べても遅くなっている。rg (ignore)
(Unicode を解釈する検索) がrg (ignore) (ASCII)
(Unicode を解釈しない検索) と同程度に高速なのはなぜか?
私は一つ目の疑問に対するはっきりとした解答を持ち合わせていない。ただ少なくとも rg
について言えば、rg
はパターンから取り出した接尾辞のリテラル Ah
を使ってマッチの候補を見つけてから接頭辞 \w
を検索している。GNU grep は同様の洗練されたリテラル抽出処理を持つものの、git grep
はリテラルを見つける処理にそこまで力を入れないようだ (私は git grep
のソースコードを軽く読んだだけなので、間違っている可能性もある)。
ucg
に関しては、PCRE2 が rg
と似たリテラル最適化を行っているものと思われる。
二つ目の疑問には幸いずっと簡単に答えられる。鍵となるトリックは rg
自体ではなく rg
が使う正規表現ライブラリの内部にある。具体的に言うと、この正規表現エンジンはUTF-8 デコーディングを有限オートマトンに組み込む (このトリックは最初 Ken Thompson によって提案され、その後 Russ Cox によって詳しく説明された)。こうしたとき高速になるのは、追加のデコーディングステップが必要にならないためだ。UTF-8 でエンコードされたバイト列に正規表現を一バイトずつ直接マッチさせることができる。UTF-8 として不当なバイト列があっても問題は起こらない: 有限オートマトンはそのバイト列を認識しないだけで、マッチは起こらずそのまま処理が進む。
これに対して、git grep
(および GNU grep) は \w
といった Unicode を解釈する機能が有効なとき全く異なるコードでマッチが判定される。公平のため言っておくと、git grep
は UTF-8 以外のテキストエンコーディングを処理できるのに対して、現在の rg
は UTF-8 (および「ASCII 互換な」テキストエンコーディング) しか扱えない。
linux_re_literal_suffix
説明: このベンチマークではリテラルで終わる単純な正規表現パターンを使った検索の性能が測定される。ここでも .gitignore
を解釈するかどうかを制御する。
パターン: [A-Z]+_RESUME
結果:
rg (ignore) 0.318 +/- 0.034 (lines: 1652)
ag (ignore) 1.899 +/- 0.008 (lines: 1652)
pt (ignore) 13.713 +/- 0.241 (lines: 1652)
sift (ignore) 10.172 +/- 0.186 (lines: 1652)
git grep (ignore) 1.108 +/- 0.004 (lines: 1652)
rg (whitelist) 0.221 +/- 0.022 (lines: 1630)*+
ucg (whitelist) 0.301 +/- 0.001 (lines: 1630)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: このベンチマークから分かる新しいことは特にない。これまでのベンチマークで見たことがまた現れている。具体的には、rg
と ucg
はどちらも非常に高速であり、pt
と sift
では Go の regexp
ライブラリがボトルネックとなり、git grep
は linux_unicode_word
と同様に性能が低下している (git grep
の性能が落ちる理由も同じだろうと私は考えている)。最後に、ag
はメモリマップを使っているために性能が優れない。
rg
はパターンから取り出したリテラルの接尾辞 _RESUME
を最初に検索することで、テキスト全体に対する正規表現を避けている。ucg
もまず間違いなく PCRE2 を通して同じ最適化を行っており、パターンが多少複雑になってもこれらのツールで性能が落ちない理由はこれで説明できる。rg
が少しだけ ucg
より速いのは、内部の正規表現ライブラリによるリテラル検索方式の違いによると思われる。
linux_alternates
説明: このベンチマークでは四つのリテラルの和集合を検索する。最適化を難しくするために、四つのリテラルは異なる文字から始まるようにしてある。
パターン: ERR_SYS|PME_TURN_OFF|LINK_REQ_RST|CFG_BME_EVT
結果:
rg (ignore) 0.351 +/- 0.074 (lines: 68)
ag (ignore) 1.747 +/- 0.005 (lines: 68)
git grep (ignore) 0.501 +/- 0.003 (lines: 68)
rg (whitelist) 0.216 +/- 0.031 (lines: 68)+
ucg (whitelist) 0.214 +/- 0.008 (lines: 68)*
*
── 最高の平均実行時間+
── 最高のサンプル実行時間- このベンチマークと次のベンチマークでは便宜上
pt
とsift
を省略した。この二つのツールはこれまでのベンチマークと同様に次点のツールより一桁程度遅いためだ。パターンの複雑性が増しても遅さは少しも改善されない。
解析: パターンは単純なリテラルではなくなったが、ここでもまた rg
と ucg
が高い速度を達成している。今回のパターンではマッチの候補を高速に見つけるのに使えるリテラルは一つも存在しない。それでも優れた正規表現エンジンは検索を高速化する方法を見つけている。
rg
について言えば、四つのリテラルを検出した rg
は SIMD を利用する多文字列検索アルゴリズム Teddy を使った検索に切り替える (Teddy については linux_literal_casei
でも説明した)。実はこのパターンの検索では正規表現エンジンが全く使われない。和集合に含まれるリテラルのマッチがどれもパターン全体のマッチとなることに正規表現エンジンは気が付くので、検証ステップは飛ばされる。これによってリテラルの和集合の検索は非常に高速となる。
linux_alternates_casei
説明: このベンチマークは基本的に linux_alternates
と同じで、大文字と小文字と区別しない検索を指示する -i
フラグを設定した点だけが異なる。git grep
は高速に実行できるように ASCII モードで実行した。
パターン: ERR_SYS|PME_TURN_OFF|LINK_REQ_RST|CFG_BME_EVT
(加えて -i
フラグを設定する)
結果:
rg (ignore) 0.391 +/- 0.078 (lines: 160)
ag (ignore) 1.968 +/- 0.009 (lines: 160)
git grep (ignore) 2.018 +/- 0.006 (lines: 160)
rg (whitelist) 0.222 +/- 0.001 (lines: 160)*+
ucg (whitelist) 0.522 +/- 0.002 (lines: 160)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: 大文字と小文字を同一視するフラグを設定したときの結果を linux_alterates
の結果と比較すると、ツールごとに様子が大きく異なるのが分かる。例えば git grep
は四倍遅くなっており、ucg
さえ二倍遅くなっている。これに対して rg
の速度はほぼ変わらない!
rg
の秘密は linux_alternates
と同じく Teddy アルゴリズムである。トリックは大文字と小文字を同一視するリテラルの集合を Teddy アルゴリズムが利用できる通常のリテラルの (大きな) 集合に変換する方法にある。実は、この処理は linux_literal_casei
で説明したのと全く同じように進む: リテラルに含まれる各文字を大文字および小文字に入れ替えて得られる文字列全体の集合が計算され、それを使ってマッチの候補が検索される。この集合は非常に大きくなる可能性があるので、実際には各文字列の接頭辞に対して大文字と小文字を入れ替えた集合が使われる。今考えているパターンであれば、Rust の正規表現ライブラリは次の文字列集合を計算する (この情報は rg
に --debug
フラグを渡すと stderr
に表示される):
CFG_
CFg_
CfG_
Cfg_
ERR_
ERr_
ErR_
Err_
LIN
LIn
LiN
Lin
PME_
PMe_
PmE_
Pme_
cFG_
cFg_
cfG_
cfg_
eRR_
eRr_
erR_
err_
lIN
lIn
liN
lin
pME_
pMe_
pmE_
pme_
この文字列集合が Teddy アルゴリズムの入力となり、検索対象のテキストに含まれるマッチの候補が素早く特定される。マッチの候補が必ずマッチになるわけではないので、候補には検証が必要になる。完全な正規表現エンジンはここで初めて呼び出される。
linux_unicode_greek
説明: このベンチマークは Unicode で定義される符号位置のクラスを表す正規表現を使ったときの性能を測定する。Rust の正規表現エンジンと Go の正規表現エンジンはどちらもネイティブにこの機能をサポートし、他のツールはサポートしない。
パターン: \p{Greek}
(任意のギリシャ文字にマッチする)
rg 0.414 +/- 0.021 (lines: 23)*+
pt 12.745 +/- 0.166 (lines: 23)
sift 7.767 +/- 0.264 (lines: 23)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: このベンチマークは非常に単純である。rg
が使う Rust の正規表現エンジンは \p{Greek}
を決定的有限オートマトンに変換する。これに対して Go の正規表現エンジン (pt
と sift
が使うもの) も有限オートマトンを使うのだが、こちらでは非決定的なシミュレーションが使われる。二つのアプローチの決定的な違いは、前者ではオートマトンが任意の時点で一つの状態しか取らないのに対して、後者では常に複数の状態を管理する必要が生じることだ。
linux_unicode_greek_casei
設定: このベンチマークは基本的に linux_unicode_greek
と同じだが、大文字と小文字を同一視する検索を行うフラグを設定する。このクエリは少し風変わりではあるものの、rg
の優れた Unicode サポートを示すには都合がいい。
パターン: \p{Greek}
(加えて -i
フラグを設定する)
rg 0.425 +/- 0.027 (lines: 103)
pt 12.612 +/- 0.217 (lines: 23)
sift 0.002 +/- 0.000 (lines: 0)*+
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: sift
は rg
より優れているわけではない: sift
は検索リクエストを正しく認識できず、諦めてマッチを報告せずに終わっている。pt
は検索を行っているようだが、大文字と小文字の判定が Unicode に即していない。これに対して rg
はリクエストを正しく処理しており、それでいて高速である。
rg
において、大文字と小文字を含む Greek
カテゴリ全体を検索するこのパターンは高速に実行できる単一の有限オートマトンにコンパイルされる。
この検索に関して興味深いのが、実行すると µ
が含まれる結果が多く報告されることだ。この文字は大文字と小文字を区別する検索で報告される μ
と同じではない。予想が付いたかもしれないが、この二つの文字は同じ見た目を持つものの、Unicode 符号位置としては異なる:
µ
は符号位置U+000000B5
のMICRO SIGN
μ
は符号位置U+000003BC
のGREEK SMALL LETTER MU
後者の符号位置は \p{Greek}
グループに含まれるものの、前者の符号位置は含まれない (Linux カーネルでは前者を使うのが正しいようだ)。しかし Unicode simple case folding tables によると MICRO SIGN
は GREEK SMALL LETTER MU
にマップされる。そのため -i
が指定されるとき、それ自身は Greek
グループに入っていない MICRO SIGN
も rg
によって検出される。
linux_no_literal
説明: これは Linux カーネルに対する最後のベンチマークであり、linux_unicode_greek_casei
と同様少し風変わりなパターンを利用する。具体的には「一つ以上の空白文字で区切られた五文字の単語が五つ並ぶ文字列」を検索する。本記事の他のベンチマークで使うパターンとの重要な違いは、このパターンがリテラルを全く含まないことだ。\w
と \s
には Unicode 的な解釈と ASCII 的な解釈があるので、このベンチマークでは Unicode サポートの有無も制御する。
パターン: \w{5}\s+\w{5}\s+\w{5}\s+\w{5}\s+\w{5}
結果:
rg (ignore) 0.577 +/- 0.003 (lines: 490)
rg (ignore) (ASCII) 0.416 +/- 0.025 (lines: 490)
ag (ignore) (ASCII) 2.339 +/- 0.010 (lines: 766)
pt (ignore) (ASCII) 22.066 +/- 0.057 (lines: 490)
sift (ignore) (ASCII) 25.563 +/- 0.108 (lines: 490)
git grep (ignore) 26.382 +/- 0.044 (lines: 490)
git grep (ignore) (ASCII) 4.153 +/- 0.010 (lines: 490)
rg (whitelist) 0.503 +/- 0.011 (lines: 419)
rg (whitelist) (ASCII) 0.343 +/- 0.038 (lines: 419)*+
ucg (whitelist) (ASCII) 1.130 +/- 0.003 (lines: 416)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間ag
が他のツールより多いマッチを報告するのは、\s
が\n
にマッチする複数行検索を行うため。
解析: このパターンはリテラルを一つも含まないので、検索の性能は内部の正規表現エンジンだけに依存する。入力を賢くスキップすることはできず、全体を正規表現エンジンに通さなければならない。私の経験上リテラルを全く含まないパターンは非常に稀なので、このベンチマークは内部の正規表現エンジンの動作をテストする人工的な手立てとして存在する。
他のツールと比較すると、rg
は .gitignore
の解釈の有無、Unicode サポートの有無に関わらず非常に高い性能を示している。特に git grep
は Unicode をサポートするとき実行時間が 6 倍になっているのに対して、rg
は 1.3 倍程度しか遅くなっていない。興味深いことに、ucg
は Unicode サポートを持たないにもかかわらず、PCRE2 の JIT を使っても rg
より遅くなっている!
なぜ rg
はこれほど速いのだろうか? 具体的には何が 30 % の性能低下を引き起こしたのだろうか?
このベンチマークで rg
が高速な理由は、Unicode に注目した他のベンチマークで rg
が高速な理由と同じである: UTF-8 のデコード処理を決定的有限オートマトンに直接コンパイルするためだ。これは検索対象のテキストを最初 Unicode の符号位置にデコードする追加ステップが必要にならないことを意味する。生のバイトに対して直接マッチを判定できる。
大ざっぱに言うと、30% の性能低下はパターンとのマッチを判定するのに使う DFA のコンパイル処理で生じる。Unicode サポートが有効なときの DFA は ASCII だけを考えるときの DFA よりずっと大きい。だいたいのサイズの違いを実感してもらうために、具体的な数字を示しておく:
- ASCII だけに対応する DFA は約 250 個の異なる NFA 状態を持つ。
- Unicode に対応する DFA は約 77,000 個の異なる NFA 状態を持つ。
(この数字は Rust の正規表現ライブラリ内部のコンパイラから直接取り出されたものであり、必ずしも最小のオートマトンを表すわけではない。)
こういったパターンから作成される DFA が同じ数の状態を持つとは限らない。なぜなら、通常 DFA の状態は複数の NFA の状態に対応するからだ (Wikipedia の Powerset construction のページを読むことを勧める。Rust の正規表現エンジンでは異なる実装戦略が使われるものの、直感的な理解が得られるだろう)。
しかし、この説明は少し誤解を生じさせかねない点がある。Rust の正規表現エンジンは事前処理としてコンパイルフェーズを持つものの、そこには NFA を DFA に変換する処理が含まれない。実はこの処理を実際に行うと指数的な時間が必要になる可能性がある! その代わり、Rust の正規表現エンジンは検索対象のテキストを見ていきながらその場で (on the fly に) DFA を構築する。このとき ASCII のパターンでは NFA の状態が非常に少ないのでその構築にはほとんど時間がかからない。しかし Unicode のパターンでは NFA の状態が非常に多いので、新しい DFA 状態のコンパイルに長い時間がかかる (私は perf
が生成するプロファイルを調べることでこれを確認した)。さらに深く調べると、ここで実際に起こることはもっと入り組んでいるかもしれない。例えば、このベンチマークでは大部分が ASCII 文字からなる同じ文字列を Unicode のパターンおよび ASCII のパターンで検索するので、二つのパターンで DFA の状態の個数はほぼ同じになると思われる。つまり性能の低下は個々の DFA 状態のコンパイル時間が長くなったことによるものであるに違いない。そのため Unicode のパターンにおける DFA の状態は (ASCII のパターンにおける DFA の状態より) 多くの NFA の状態を含むはずである。検索対象のテキストの組成を検索するより前に知ることはできないので、これより効率的な検索方法は明らかでない (もちろん Unicode の \w
をもっと小さい集合に置き換えることならできる)。
これを改善するアイデアとして、異なる種類の DFA を用意する方法が考えられる。例えば最初は ASCII のみに対応した DFA を使ってマッチを行い、その途中で ASCII でないバイトを検出したら Unicode に対応する DFA に遷移するということだ。ただ性能の低下は非常に小さいので、これを実装することで生じる複雑さを正当化するのは難しい!
単一ファイルのベンチマーク
ベンチマークの後半部分ではギアを変えて、単一の巨大なファイルに対する検索ツールの性能を詳しく見ていく。それぞれのベンチマークは OpenSubtitles2016 データセットから取った英語とロシア語の二つのサンプルに対して実行される。もちろん英語のサンプルは大部分がアルファベット (ASCII 文字) から構成され、ロシア語のサンプルは大部分がキリル文字から構成される。ロシア語のサンプルを検索するときのパターンは英語のパターンから Google 翻訳を使って翻訳された (残念ながら私はロシア語が読めないのだが、検索を自分で試して結果を英語に翻訳することで似た文章が検索できていることを確認した)。英語のサンプルは約 1 GB で、ロシア語のサンプルは約 1.6 GB である。
このベンチマークでは内部の正規表現エンジンとリテラル最適化の性能がずっと大きな影響を持つ。私たちが制御しなければならない重要な変数として、行数カウントと Unicode サポートがある。基本的に各ツールには行数をカウントしないように要請するが、The Silver Searcher と Universal Code Grep は行数カウントの無効化をサポートしない。Unicode サポートは一部のベンチマークで制御が難しくなる: ripgrep は非 ASCII 文字列で検索するとき ASCII 文字についてだけ大文字と小文字を同一視する検索をサポートしないためだ。必ず Unicode の意味での大文字と小文字の区別が使われ、それを無効化する手立てが存在しない。後で見るように、少なくとも ripgrep に関して言えば、Unicode の意味での大文字と小文字の同一視をサポートしたとしても ASCII に設定した他のツールより速くなる。
Linux カーネルを使ったベンチマークと同様に、実行したコマンドと記録実行時間の生データは github から確認できる。
ripgrep はこのラウンドでも性能と正確性の両方で他のツールを大きく上回る。
subtitles_literal
説明: このベンチマークは単一のリテラル文字列の検索という最も単純なケースの性能を測定する。(lines)
とラベルの付いた結果は出力に行数を加える -n
フラグ (またはそれと同じ意味のフラグ) を付けたときの結果を表す。
英語のパターン: Sherlock Holmes
結果:
rg 0.268 +/- 0.000 (lines: 629)*+
rg (no mmap) 0.336 +/- 0.001 (lines: 629)
pt 3.433 +/- 0.002 (lines: 629)
sift 0.326 +/- 0.002 (lines: 629)
grep 0.516 +/- 0.001 (lines: 629)
rg (lines) 0.595 +/- 0.001 (lines: 629)
ag (lines) 2.730 +/- 0.003 (lines: 629)
ucg (lines) 0.745 +/- 0.001 (lines: 629)
pt (lines) 3.434 +/- 0.005 (lines: 629)
sift (lines) 0.756 +/- 0.002 (lines: 629)
grep (lines) 0.969 +/- 0.001 (lines: 629)
ロシア語のパターン: Шерлок Холмс
結果:
rg 0.325 +/- 0.001 (lines: 583)*+
rg (no mmap) 0.452 +/- 0.002 (lines: 583)
pt 12.917 +/- 0.009 (lines: 583)
sift 16.418 +/- 0.008 (lines: 583)
grep 0.780 +/- 0.001 (lines: 583)
rg (lines) 0.926 +/- 0.001 (lines: 583)
ag (lines) 4.481 +/- 0.003 (lines: 583)
ucg (lines) 1.889 +/- 0.004 (lines: 583)
pt (lines) 12.935 +/- 0.011 (lines: 583)
sift (lines) 17.177 +/- 0.010 (lines: 583)
grep (lines) 1.300 +/- 0.003 (lines: 583)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間pt
とsift
は非常に遅いので、以降のベンチマークでは省略する。
解析: 全ての検索ツールは内部の正規表現エンジンの一部あるいは検索ツール自身の一部としてリテラル最適化を行う。ag
はパターンを調べ、正規表現の特殊文字が使われていなければ Boyer-Moore の亜種を使って PCRE を使わずに検索を行う。GNU grep も同様の最適化を行うが、大きな改善が可能だと指摘されている。
もしこれが本当なら、rg
が GNU grep より倍近く高速なのはどうしてだろうか? さて、何よりもまず、sift
と ucg
も GNU grep より速いことに注目しよう。私は PCRE2 の JIT をよく理解していないので ucg
の速さについて詳しく説明できない。ただ rg
と sift
が GNU grep より速い理由ははっきりしている:
-
sift
は Go のregexp
ライブラリを利用し、これが少なくとも一つの小さなリテラル最適化を行う: 正規表現の任意のマッチが同じバイトから始まることが確認できるとき、正規表現エンジンはそのバイトを見つけるスキャンを行ってからマッチの判定を開始する。x86_64
システムでこのスキャンを行うコードをたどっていくと、ymm
レジスタ と AVX2 命令を使っており、一度の反復で 32 バイトのスキャンが可能なことが分かる。これに対して GNU grep はlibc
のmemchr
を使っており、これは AVX2 命令を使っていない (ただし、この C コードはxmm
レジスタと SIMD 命令に自動ベクトル化される。xmm
レジスタのサイズはymm
レジスタの半分である)。言い換えれば、sift
は Go で書かれているために CPU を効率的に利用できている。 -
rg
もlibc
のmemchr
を使っている。本ベンチマークで利用されたrg
バイナリはmusl
に静的リンクされており、musl はmemchr
の独自実装を持つ。musl の実装は GNU の libc より簡潔ではあるものの、ほぼ同じ処理を行うようである。だとすれば、なぜrg
は GNU grep より高速なのだろうか? 答えはmemchr
の中にはなく、どの Boyer-Moore を使うかにも関係がなく、Boyer-Moore が一度にスキップできる文字数にも関係がない。答えはmemchr
を使って検索するバイトにある。実はrg
はリテラルに含まれる最も出現頻度が低いバイトを推測し、それをmemchr
で検索する (標準的な Boyer-Moore の実装では常に最後のバイトがmemchr
に渡される)。このベンチマークの英語のパターンであれば、memchr
にs
を渡すよりS
またはH
を渡した方がずっと高速に検索が行えるだろう:S
やH
はs
より稀にしか出現しないためだ。このときrg
は SIMD で最適化された高速なループでバイトをスキップするのに長い時間を費やせる。rg
の推測が間違う可能性もあるものの、最低でも簡単な推測をして平均的なケースを速くする方が一般的な Boyer-Moore 実装をそのまま使うよりは確実に優れている。
リテラル検索の秘密がこれで明らかになった。次はロシア語のパターンで性能の差が生じる理由を詳しく調べよう。答えはここでもスキャンに使うバイトにある。sift
と pt
はどちらも Go のランタイムに含まれる AVX2 ルーチンを利用する。であればどうして、ロシア語で検索するときこの二つのツールの性能が他のツールより大きく落ちているのだろうか? この疑問の答えは検索したパターン Шерлок Холмс
を UTF-8 でエンコードしたときのバイト列を見ると明らかになる:
\xd0\xa8\xd0\xb5\xd1\x80\xd0\xbb\xd0\xbe\xd0\xba \xd0\xa5\xd0\xbe\xd0\xbb\xd0\xbc\xd1\x81
このバイト列から分かる重要な事実が二つある:
- パターン
Шерлок Холмс
に含まれる全ての文字は二つの UTF-8 符号単位でエンコードされる。二つの UTF-8 符号単位は二バイトに対応する。 - 全ての文字は
\xD0
または\xD1
で始まる。
検索対象のロシア語字幕の UTF-8 バイト列を見ると、全く同じパターンが並んでいるのが確認できる。このパターンが生じるのはファイルのほとんどがキリル文字で書かれており、キリル文字の大部分が Unicode で小さな範囲を占めるためだ。これは検索対象の文章に \xD0
と \xD1
が大量に出現することを意味する。
上述の説明を思い出せば、Go の正規表現エンジンはパターンの一つ目のバイトを使ってスキャンを行う。しかし、もし今の例のように一つ目のバイトが検索対象のテキストに頻繁に表れるようなことがあると、スキャンのオーバーヘッドが上昇して検索全体が遅くなってしまう。これは memchr
を使うとき避けられないトレードオフである。
予想できたかもしれないが、rg
は出現頻度の低いバイトを推測することでこの問題を回避する。rg
は事前に計算された 256 個のバイトそれぞれの頻度を記した表から引いた値を使う。例えば \xD0
や \xD1
といったバイトは頻度が高いとみなされ、\xA8
や \x81
といったバイトは頻度が低いとみなされる。そのため rg
は \xD0
と \xD1
を避け、これら以外のバイトを memchr
に渡す。
GNU grep がこのベンチマークで優れた性能を示すのはほぼ偶然と言える: Boyer-Moore が選択する最後のバイト \x81
がたまたま \xD0
や \xD1
より格段に優れるためだ。
ギアを変えて、メモリマップについても少し話しておくべきだろう。このベンチマークでは rg
が rg (no mmap)
を 25% ほど上回っている。この二つの唯一の違いは、前者がファイルをメモリ上にメモリマップするのに対して、後者はファイルを少しずつ中間バッファに読み込むという点だ。このベンチマークではファイルが一つだけでメモリマップのオーバーヘッドが非常に小さいためにこういった結果となる。これは前半の Linux カーネルを使ったベンチマークとは正反対の結果である。Linux カーネルの検索では検索対象の全てのファイルに対して新しくメモリマップを作成しなければならずオーバーヘッドが大きくなるので、中間バッファを使った方が高速だった。rg
は経験的なアプローチを取り、検索するファイルが数個だけならメモリマップを使って、そうでなければ中間バッファを使って検索を行う。
最後にもう一つ: (lines)
についてはこれといって言うべきことがないので、特に話さなかった。行数のカウントには追加で処理が必要であり、カウントしなくていいならその処理を飛ばせるというだけだ。行数カウントのために ucg
はかなり洗練された SIMD アルゴリズムを持っており、rg
も先述した memchr
の実装に似た packed comoparison を利用するアルゴリズムを持つ。
私の好きにできるなら、行数カウントを有効にしたベンチマークは削除してしまうだろう。どのツールも多少の処理を追加することで安定して行数カウントを行えているからだ。ただ ag
と ucg
では行数カウントを無効にできないので、公平な比較のため他のツールで行数カウントを有効にした計測も行わなければならない。
subtitles_literal_casei
説明: このベンチマークは基本的に subtitles_literal
と同様で、大文字と小文字を同一視する検索を行う点だけが異なる。(lines)
のラベルが付いたツールは行数を表示し、(ASCII)
のラベルが付いたツールは ASCII だけを認識する検索を行う。逆に (ASCII)
が付いていないツールでは Unicode を認識する検索が行われる。
英語のパターン: Sherlock Holmes
(加えて -i
をフラグを設定する)
rg 0.366 +/- 0.001 (lines: 642)*+
grep 4.084 +/- 0.005 (lines: 642)
grep (ASCII) 0.614 +/- 0.001 (lines: 642)
rg (lines) 0.696 +/- 0.002 (lines: 642)
ag (lines) (ASCII) 2.775 +/- 0.004 (lines: 642)
ucg (lines) (ASCII) 0.841 +/- 0.002 (lines: 642)
ロシア語のパターン: Шерлок Холмс
rg 1.131 +/- 0.001 (lines: 604)
grep 8.187 +/- 0.006 (lines: 604)
grep (ASCII) 0.785 +/- 0.001 (lines: 583)
rg (lines) 1.733 +/- 0.002 (lines: 604)
ag (lines) (ASCII) 0.729 +/- 0.001 (lines: 0)*+
ucg (lines) (ASCII) 1.896 +/- 0.005 (lines: 583)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間rg (ASCII)
が無いのは、ASCII だけを認識する大文字と小文字を同一視する検索をrg
が行えないため。
解析: rg
の Unicode サポートの素晴らしさが明らかになるので、このベンチマークは興味深い。rg
の結果は正確であるだけではなく、高速でもある。他のツールが ASCII だけを認識する検索を行うときと比べたとしても rg
の方が速い。
英語のパターンに対する結果を見よう。まず分かることとして、GNU grep では Unicode をサポートする大文字と小文字を区別しない検索が非常に高くついている。ここで GNU grep が直面している問題は、簡単で高速な Boyer-Moore による検索が行えないために他のリテラル検索アルゴリズムあるいは完全な正規表現エンジンにフォールバックすることだ。GNU grep は ASCII だけを認識する大文字と小文字を区別する検索ではずっと高速になるものの、rg
の Unicode を認識する大文字と小文字を区別しない検索はその速度を軽々と上回る。
このベンチマークで rg
が高速な理由は linux_literal_casei
と同様である: rg
は Sherlock Holmes
というパターンに含まれる各文字を Unicode の simple case folding rules に従って変化させて得られる文字列を考え、それぞれの短い接頭辞からなる集合を検索する。今の例であれば次の集合が検索される:
SHER
SHEr
SHeR
SHer
ShER
ShEr
SheR
Sher
sHER
sHEr
sHeR
sHer
shER
shEr
sheR
sher
ſHER
ſHEr
ſHeR
ſHer
ſhER
ſhEr
ſheR
ſher
(長い S (ſ
) が含まれていることに注目してほしい。これは Unicode 的に正しい。)
このリテラルの集合はその後 SIMD を使った複数パターン検索アルゴリズム Teddy に入力される。このアルゴリズムは未発表であり、Intel の正規表現ライブラリ Hyperscan の一部として Geoffrey Langdale によって開発された。このアルゴリズムはリテラルがマッチする可能性のある位置を packed comparison により一度に 16 バイトずつ判定する。私は Hyperscan プロジェクトに含まれるこのアルゴリズムを採用し、Rust に移植した。README に詳しい解説を書いたので、気になる人は読んでほしい。
ロシア語のパターンに対する結果でも同じことが言えるが、それ以外にもいくつか興味深いことがある。まず grep (ASCII)
は非常に高速であるものの、パターンが ASCII 文字列ではないので明らかに -i
は無視されている。よく見ると、測定結果は一つ前の subtitles_literal
ベンチマークとほぼ同一なことが分かる。もう一つ興味深いのは、ag
が 0
個のマッチを報告していることだ。これが全く理解できない動作というわけではない: 「Unicode をサポートしない状態で非 ASCII 文字列を大文字と小文字を区別せずに検索せよ」という要求は実現不可能だと ag
が判断したのかもしれない。おそらくは PCRE (例えば pcre_exec
) で起きたエラーがユーザーに表示されていないだけだと思うが、これは全くの当て推量だ。
subtitles_alternate
説明: このベンチマークではいくつかのリテラルからなる集合を検索する速度を計測する。それぞれのリテラルは異なる接頭バイトを持つ。行数カウントも有無も制御する。
英語のパターン: Sherlock Holmes|John Watson|Irene Adler|Inspector Lestrade|Professor Moriarty
rg 0.294 +/- 0.001 (lines: 848)*+
grep 2.955 +/- 0.003 (lines: 848)
rg (lines) 0.619 +/- 0.001 (lines: 848)
ag (lines) 3.757 +/- 0.001 (lines: 848)
ucg (lines) 1.479 +/- 0.002 (lines: 848)
grep (lines) 3.412 +/- 0.004 (lines: 848)
ロシア語のパターン: Шерлок Холмс|Джон Уотсон|Ирен Адлер|инспектор Лестрейд|профессор Мориарти
rg 1.300 +/- 0.002 (lines: 691)*+
grep 7.994 +/- 0.017 (lines: 691)
rg (lines) 1.902 +/- 0.002 (lines: 691)
ag (lines) 5.892 +/- 0.003 (lines: 691)
ucg (lines) 2.864 +/- 0.006 (lines: 691)
grep (lines) 8.511 +/- 0.005 (lines: 691)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: このベンチマークでは英語とロシア語の両方で rg
が非常に優れている。これは subtitles_literal_casei
の解析で説明した Teddy アルゴリズムによるところが大きい。特に英語のパターンでは rg
が GNU grep より一桁近く高速になっている。
結果からは行数を数える処理の性能に与えるコストもよく分かる。少なくとも rg
では、行数を数えると検索時間が倍程度になる。
ベンチマークの説明でも述べたように、検索するリテラルはそれぞれ異なる接頭バイトを持つことに注意してほしい。これは正規表現エンジンが共通の接頭バイトを検出し、それに対して memchr
を実行する最適化を避けるためにある。この最適化はもちろん重要で rg
も行うものの、それより少しだけ複雑なケースに対するベンチマークを行った方がずっと有用な情報が得られる。
subtitles_alternate_casei
説明: このベンチマークは基本的に subtitles_alternate
と同様だが、大文字と小文字を同一視する検索を行う点が唯一異なる。またここでは行数を数える処理の有無ではなく Unicode サポートの有無を制御する (全てのコマンドで行数を数えるものとする)。
英語のパターン: Sherlock Holmes|John Watson|Irene Adler|Inspector Lestrade|Professor Moriarty
(加えて -i
フラグを設定する)
rg 2.724 +/- 0.002 (lines: 862)*+
grep 5.125 +/- 0.006 (lines: 862)
ag (ASCII) 5.170 +/- 0.004 (lines: 862)
ucg (ASCII) 3.453 +/- 0.005 (lines: 862)
grep (ASCII) 4.537 +/- 0.025 (lines: 862)
ロシア語のパターン: Шерлок Холмс|Джон Уотсон|Ирен Адлер|инспектор Лестрейд|профессор Мориарти
rg 4.834 +/- 0.004 (lines: 735)
grep 8.729 +/- 0.004 (lines: 735)
ag (ASCII) 5.891 +/- 0.001 (lines: 691)
ucg (ASCII) 2.868 +/- 0.005 (lines: 691)*+
grep (ASCII) 8.572 +/- 0.009 (lines: 691)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: rg
の速度は他のツールを (Unicode をサポートしないものさえも) 大きく上回るものの、subtitles_alternate
と比べると一桁遅くなっている。このベンチマークが示す重要な事実は Teddy アルゴリズムの限界である。実はこのベンチマークで rg
は Teddy の性能が優れないだろうと判断し、Teddy を利用しない。
Teddy が高速にならないのはなぜだろうか? この疑問への答えは検索するパターンから生成されるリテラルの集合にある。rg
は Unicode の simple case folding rules に従ってマッチする可能性のあるリテラルを全て生成し、それらの接頭辞を取ることで扱える量まで検索すべきリテラルを減らす。この例では次の 48 個のリテラルを使ったリテラル検索が行われる:
INS
INs
INſ
IRE
IRe
InS
Ins
Inſ
IrE
Ire
JOH
JOh
JoH
Joh
PRO
PRo
PrO
Pro
SHE
SHe
ShE
She
iNS
iNs
iNſ
iRE
iRe
inS
ins
inſ
irE
ire
jOH
jOh
joH
joh
pRO
pRo
prO
pro
sHE
sHe
shE
she
ſHE
ſHe
ſhE
ſhe
これらのリテラルを全て Teddy に渡すと、Teddy は圧倒されてしまう。具体的な話をしよう。Teddy はマッチの候補を非常に高速に見つけることで動作する。そのため見つかるマッチ候補の個数とマッチの個数が同程度なら、Teddy はとても高速に動作する。しかし Teddy に入力するリテラルの個数が増えるとマッチでないマッチ候補を見つける可能性が高くなり、マッチの検証に費やされる時間が増えてしまう。
(Teddy の実装に関してさらに細かい特徴を言っておくと、リテラルの個数を増やすとたとえ生成されるマッチ候補の個数が変わらない場合でも検証のコストが増加する。先述したように、もし Teddy について詳しい説明が必要なら実装にある詳細なコメントを確認してほしい。Teddy に関してさらに細かいことを説明するにはブログ記事がもう一つ必要になるだろう!)
リテラルの個数が多いことを検出したときに rg
が取れる手段は次の二つだ:
- リテラルの集合をさらに小さくする。例えば今考えている例であれば、接頭辞の最後の文字を削除すれば集合はずっと小さくなる。しかし残念ながら、こうしても手に入るのは二文字のリテラルからなるそれなりに大きい集合であり、加えてリテラルが短いので偽陽性が増えてしまう。
- Aho-Corasick などの異なる多パターン検索アルゴリズムを使う。
以前に一つ目の選択肢を実装したことがあるのだが、どうしても「モグラ叩き」になってしまうようだった。何らかの一般的なケースを速くできても、他の一般的なケースが大きく遅くなってしまう。このような場合は平均的な性能に優れるアプローチが上手く行くことが多い。幸い Aho-Corasick はまさにこの特徴を持つ。
ただ Aho-Corasick を教科書通りに実装すれば済むわけではない。例えば多くの Aho-Corasick 実装は失敗遷移を後方ポインタに設定したトライを構築するが、これより優れた方法が存在する。失敗遷移を DFA にコンパイルし、そのとき遷移テーブルをメモリの連続領域に置くことができる。こうすると入力の各バイトが遷移テーブルを一度だけ見て次の状態を調べる処理に対応する。バイトを一つ読んだときにポインタをたどったり失敗遷移をいくつも行う必要はない。
もちろん、この遷移テーブルベースのアプローチはメモリを多く消費する。必要なメモリ量は number_of_literals * number_of_states
に比例し、number_of_states
は全リテラルのバイト数にほぼ等しい。長さ 3 のリテラル 48 個は Teddy では多すぎるのに対して、Aho-Corasick ならメモリを多く必要とする遷移テーブルベースのアプローチを使ったとしても軽々と扱える (参考: Aho-Corasick のこの実装は専門的な文献で Advanced Aho-Corasick と呼ばれることが多い)。
subtitles_surrounding_words
説明: このベンチマークは単語に囲まれた Holmes
というリテラル文字列を検索する。このパターンは接頭辞と接尾辞に関するリテラル最適化を起こさないように作ってある。
英語のパターン: \w+\s+Holmes\s+\w+
rg 0.605 +/- 0.000 (lines: 317)
grep 1.286 +/- 0.002 (lines: 317)
rg (ASCII) 0.602 +/- 0.000 (lines: 317)*+
ag (ASCII) 11.663 +/- 0.008 (lines: 323)
ucg (ASCII) 4.690 +/- 0.002 (lines: 317)
grep (ASCII) 1.276 +/- 0.002 (lines: 317)
ロシア語のパターン: \w+\s+Холмс\s+\w+
rg 0.957 +/- 0.001 (lines: 278)*+
grep 1.660 +/- 0.002 (lines: 278)
ag (ASCII) 2.411 +/- 0.001 (lines: 0)
ucg (ASCII) 2.980 +/- 0.002 (lines: 0)
grep (ASCII) 1.596 +/- 0.003 (lines: 0)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間
解析: このベンチマークで優れた成績を収めるには、いわゆる「内部リテラル最適化 (inner literal optimization)」が検索ツールに必要となる。この最適化が何を意味するかは想像がつくだろう: パターン内の任意の位置に出現するリテラル文字列を見つけ、それが全てのマッチに現れるかどうかを判定し、もしそうなら完全な正規表現ではなくそのリテラルを検索対象の文字列から高速に見つけ出す最適化が内部リテラル最適化と呼ばれる。
内部リテラル最適化が正しく動作する上で鍵となるのは多くの検索ツールが結果を行ごとに報告するという事実である。例えば今回のベンチマークで使った \w+\s+Holmes\s+\w+
というパターンを検索するなら、最初に Holmes
というリテラルが含まれる行を検索し、そこから行の始まりと終わりを検索してその行だけに対して完全なパターンの検索を行って構わない。取り出された内部リテラルの頻度が低いときは検索のほとんどを正規表現エンジンを使わずに行える。それからもちろん、内部リテラルがコーパスに全く現れないとき正規表現エンジンは一度も起動されない。
内部リテラル最適化を完全に行うには、パターンをパースして抽象構文木 (abstract syntax tree, AST) を作ってから内部リテラルを取り出す必要がある。これより単純なヒューリスティックでもおそらくは大きな速度向上が得られるものの、内部リテラル最適化をロバストに行うには実際にパーサーを使うしかないことは指摘に値する。ここでの問題は多くの正規表現エンジンでパターンのパースは公開されない実装詳細となっていることだ。このとき検索ツールは正規表現からリテラルを取り出すために独自にパーサーを書かなければないが、現代的な正規表現パーサーは簡単に書けるものではない! 幸い Rust では正規表現ライブラリが公開する追加ライブラリ regex-syntax
が完全なパーサーを提供する。rg
は regex-syntax
の手を借りることで比較的簡単に内部リテラル最適化を実装している。これに対して、GNU grep が同じ最適化を実装できるのは検索ツールと内部の正規表現エンジンが密に結合しているためだ。
内部リテラル最適化を検索ツールが行うのはどうしてだろうか? 内部の正規表現エンジンが行わないのはなぜ? この疑問について私は長いこと頭を悩ませているのだが、納得いく答えが見つかっていない。一番の問題は内部リテラルを見つけたときに完全な正規表現の検索をどこから始めるべきかが分からないことだ。汎用の正規表現エンジンではパターンが任意に長い文字列とマッチする可能性がある。例えば \w+\s+Holmes\s+\w+
が数ギガバイトのドキュメントの最後にしかマッチしないこともある。完全なマッチを見つける問題を解決する方法はいくつかある。例えば正規表現を三つの部分 \w+\s+
, Holmes
, \s+\w+
に分解し、リテラル Holmes
を見つけるたびにリテラルの先頭から \w+\s+
を後ろに向かって検索してマッチの先頭を特定し、さらにリテラルの末尾から \s+\w+
を前に向かって検索することでマッチの末尾を特定する方法が考えられる。この方法の大きな問題は最悪ケースで振る舞いが二次関数的になることだ (\w+\s+
あるいは \s+\w+
の検索で既に見た文字列をもう一度スキャンする必要が生じる可能性がある)。この問題を線形時間の保証付きで汎用的に解決することは可能だろうと私は思っているものの、優れた解決法は見つけられていない。
このベンチマークの結果から判断すると、rg
と GNU grep だけが内部リテラル最適化を行っている。ag
と ucg
はどちらもパターン内部のリテラルを取り出して検索を高速化することはなく、PCRE もそこまで賢いことを行わないようだ (もちろん Rust の正規表現ライブラリも行わない。この最適化は rg
の側で行われる)。
ロシア語のパターンに対する結果を見ると、Unicode をきちんとサポートするツールでのみクエリが正しく処理されていることが分かる。この理由は ucg
と ag
では \w
が ASCII 文字だけからなるために、ロシア語のサンプルに含まれるほぼ全ての単語文字 (キリル文字) とマッチしないためである。この二つ以外の rg
と GNU grep はどちらも高速なままであり、これは主に内部リテラル最適化による。
subtitles_no_literal
説明: このベンチマークでは意図的にリテラルを含まないパターンを使って検索を行う。エンドユーザーが検索するパターンはたいていの場合リテラルを含むので、このパターンは少し奇妙と言えるものの、内部の正規表現エンジンの一般的な性能を測定するには適している。
英語のパターン: \w{5}\s+\w{5}\s+\w{5}\s+\w{5}\s+\w{5}\s+\w{5}\s+\w{5}
rg 2.777 +/- 0.003 (lines: 13)
rg (ASCII) 2.541 +/- 0.005 (lines: 13)*+
ag (ASCII) 10.076 +/- 0.005 (lines: 48)
ucg (ASCII) 7.771 +/- 0.004 (lines: 13)
grep (ASCII) 4.411 +/- 0.004 (lines: 13)
ロシア語のパターン: \w{5}\s+\w{5}\s+\w{5}\s+\w{5}\s+\w{5}\s+\w{5}\s+\w{5}
rg 4.905 +/- 0.003 (lines: 41)
rg (ASCII) 3.973 +/- 0.002 (lines: 0)
ag (ASCII) 2.395 +/- 0.004 (lines: 0)*+
ucg (ASCII) 3.006 +/- 0.005 (lines: 0)
grep (ASCII) 2.483 +/- 0.005 (lines: 0)
*
── 最高の平均実行時間+
── 最高のサンプル実行時間- 英語のパターンで
ag
のマッチが多いのは複数行の検索を行っているため。つまりag
では\s
が\n
にマッチする。 - Unicode サポートを有効にした場合の
grep
が結果に含まれていないのは実行が非常に遅かったため。英語のパターンでは 90 秒以上、ロシア語のパターンでは 4 分以上かかった。両方の場合で GNU grep とrg
は同じ結果を報告した。
解析: ここでも rg
と同程度の成績を収めたツールは存在しない。英語のパターンに対する rg
と rg (ASCII)
の結果を比べると、rg
では Unicode がサポートされるにもかかわらず二つは非常に似た性能を示すのが分かる。
このベンチマークで rg
が GNU grep より高速になった具体的な理由はなんだろうか? どちらのツールも最終的には DFA を使ってパターンを検索するので、ほぼ同じ性能が得られるはずだ。この疑問に対するはっきりとした答えを私は持ち合わせていない。GNU grep と rg
はどちらも正規表現ライブラリが DFA のインナーループを展開し、状態を on the fly に計算する。ただ理由の推測はできる。
Rust の正規表現ライブラリでは状態の遷移でポインタのデリファレンスが一回分少なくなるように DFA が実装されている。具体的な処理は複雑だが、状態を表すのに単純なインクリメンタル ID ではなく遷移テーブルに対する添え字を使うことで実装される。この工夫があると遷移先の位置を単純な加算で計算でき、この計算はアドレッシングモードを使った単一の命令で行える (特に、この最適化があるとき状態の遷移を計算するのに乗算が必要にならない)。「ポインタのデリファレンスが一度減る」というのは大したことない最適化に思えるかもしれないが、今回のような大きなコーパスの全てのバイトで起こる処理では大きな効果がある。
そういった細かなことはロシア語のパターンではそれほど重要にならない。GNU grep では検索に分単位の時間がかかっているためだ。この結果は GNU grep が UTF-8 のデコード処理を DFA に組み込んでおらず、その場で一文字ごとにデコードするといったオーバーヘッドの大きい処理を行っていることを示唆する。ただ私は GNU grep の処理を深く理解したわけではないので、このコストモデルが間違っている可能性もある。それから公正を期すために言っておくと、GNU grep のロケールおよびエンコーディングのサポートは rg
のものを大きく上回る。ただ現在の世界では UTF-8 が広く普及しており、UTF-8 だけをサポートすれば十分なことも多い。さらに UTF-8 がこれだけ普及していることを考えれば、Unicode をサポートしたときでも実行を高速に保つことが重要である (GNU grep はこれができていない)。
残念ながら他のツールは Unicode をサポートしないので、ロシア語のパターンに対して意味のあるベンチマークが行えない。
ボーナスベンチマーク
この節では、これまでよりさらにおかしなベンチマークをいくつか見ていく。これらのベンチマークは公開されているスイートに含まれていない。結果を見れば分かるが、これらのベンチマークではツール間の性能差があまりにも大きくなって細かな解析が必要とならない場合も多い。さらに使われるパターンが一般的な使い方を代表するわけでもないので、性能の差はそれほど重要でない。ただ、そういった極端なパターンを rg
がどれだけ上手く扱えるかを見るのはなかなか面白い。
everything
説明: このベンチマークでは、各ツールが全ての行をマッチとして報告するのにかかる時間を比較する。コマンドは Linux カーネルレポジトリのルートで実行された。
パターン: .*
rg 1.081 (lines: 22065361)
ag 1.660 (lines: 55939)
git grep 3.448 (lines: 22066395)
sift 110.018 (lines: 22190112)
pt 0.245 (lines: 3027)
rg (whitelist) 0.987 (lines: 20936584)
ucg (whitelist) 5.558 (lines: 23163359)
解析: 全ての行が報告される検索をユーザーが行うことはおそらく決してないので、ある意味このベンチマークは馬鹿げている。ただマッチが膨大なときでも rg
が問題なくスケールすることを確認するのにはちょうどいい。
このベンチマークのような場合に優れた正規表現エンジンが行う工夫の一つとして、ユーザーがマッチの有無だけを気にしているときはマッチを一つでも見つけた時点で次の検索に移るというものがある。今の例であれば、検索ツールは各行の先頭でマッチを見つけ、検索を中断し、次の行の始まりまで進んでから検索を再開できる。rg
と Rust の正規表現ライブラリはどちらも .*
に対して特別に用意された最適化を持たない (しようと思えば実装はできる)。
興味深いことに、ar
と pt
はいずれも全ての行を報告していない。マッチ数に何らかの制限があるようだ。これが不合理な挙動というわけでは必ずしもない。結局 ar
や pt
は検索ツールなのだから、大量に見つかった結果を全て報告しても役に立たないだろうと判断されてもおかしくはない。
nothing
説明: これは everything
の逆であり、結果を一行も報告しないのが正しい挙動となる。このベンチマークも Linux カーネルレポジトリのルートで実行された。
パターン: .*
(加えて -v
フラグあるいは --invert-match
フラグを設定する)
rg 0.302 (lines: 0)
ag 数分
git grep 0.905 (lines: 0)
sift 12.804 (lines: 0)
pt -----
rg (whitelist) 0.251 (lines: 0)
ucg (whitelist) -----
解析: このベンチマークは一つ前の everything
よりさらに奇妙なこと (「全てをマッチとして見つけて、何も報告しないでくれ」) を要求しており、他のツールの欠点や省略されている処理が明らかになる。具体的に挙げると、ag
はマッチを逆転させると一気に実行が遅くなる。また pt
と ucg
はマッチの逆転をそもそもサポートしない。sift
は前回のベンチマークにおける汚名を挽回している (おそらくマッチの出力に関連するオーバーヘッドが大きいのだろう)。rg
と git grep
は問題なく処理を行えている。
context
説明 このベンチマークはマッチの文脈 (マッチ前後の行) を表示する機能の性能を計測する。具体的には、各ツールに全てのマッチの前後それぞれ二行を表示させる。英語の字幕コーパスを行数をカウントする状態で検索した。
パターン: Sherlock Holmes
(加えて --context 2
フラグを設定する)
rg 0.612 (lines: 3533)
ag 3.530 (lines: 3533)
grep 1.075 (lines: 3533)
sift 0.717 (lines: 3533)
pt 17.331 (lines: 2981)
ucg -----
解析: ここでも rg
は優れた性能を見せているが、sift
との差はかなり小さい。一般に文脈の計算は稀に (マッチごとに一度) しか起こらないのでコストは大きくならないはずである。ただ ag
と pt
は性能を大きく落としている。また pt
はバグがあるようだ (文脈を正しく実装するのは難しいので理解できることではある)。最後に、ucg
は文脈の機能をサポートしないためベンチマークしていない。
huge
説明: このベンチマークでは単純なリテラル検索を 9.3GB
のファイルに対して行う。実はこのファイルは英語の字幕コーパス全体である (ベンチマークスイートでは 1GB のサンプルを取っていた)。
パターン: Sherlock Holmes
rg 1.786 (lines: 5107)
grep 5.119 (lines: 5107)
sift 3.047 (lines: 5107)
pt 14.966 (lines: 5107)
rg (lines) 4.467 (lines: 5107)
ag (lines) 19.132 (lines: 5107)
grep (lines) 9.213 (lines: 5107)
sift (lines) 6.303 (lines: 5107)
pt (lines) 15.485 (lines: 5107)
ucg (lines) 4.843 (lines: 1543)
解析: 一見すると行数カウントが有効なとき ucg
の速度が rg
と同程度かのように見える。しかし ucg
は間違った結果を返している! ucg
では 2GB を超えるファイルの検索で問題が起きているのではないかと私は疑っている。
もう一つ興味深いのが、sift
が高速であるにもかかわらず pt
が行数をカウントしないときでさえ遅いことだ。sift
と pt
はどちらも Go の regexp
に含まれる正規表現エンジンを使っており、単純なリテラルなら高速に検索できるはずである。pt
が遅い理由は明らかでない。行数をカウントせよと伝えないときでもカウントした上で無視している可能性が仮説の一つとして考えられる。
まとめ
私はこのブログ記事の最初で、次の主張を裏付ける証拠を示すと書いた:
- 単一ファイルの検索と大量のファイルからなるディレクトリの検索の両方において、性能あるいは正確性が ripgrep より明らかに上回るツールは存在しない。
- ripgrep はきちんとした Unicode サポートを性能への大きなペナルティ無しに提供する唯一のツールである。
- 大量のファイルを同時に検索するツールでメモリマップを使うと一般に実行が遅くなる。速くはならない。
私は最初の主張を実証するためにまず有名なレポジトリ (Linux カーネル) に対してエンドユーザーが入力するであろう様々なパターンを使ったベンチマークを行った。全てのベンチマークで rg
がトップになったわけではないものの、明らかに rg
より優れると言えるツールは存在しなかった。例えば git grep
は単純なパターンで rg
より数ミリ秒だけ速くなることがあったものの、パターンが複雑になる (特に Unicode サポートを有効になる) と rg
が git grep
よりずっと高速になる (linux_unicode_word
のように速度が一桁違うこともある)。rg
が git grep
と同程度の、The Silver Searcher を上回る速度を達成できた理由は次の通りである:
stat
の呼び出しを最小限にした高速なディレクトリ走査を実装する。-
.gitignore
のフィルタを適用するときに、単一のパスに複数の glob をまとめてマッチングできるRegexSet
を利用する。 - Chase-Lev work-stealing queue を使って複数のスレッドに効率良く仕事を振り分ける。
- メモリマップを明示的に無効化する。
- 全体的に非常に高速な正規表現エンジンを使う。
続いて単一の大きなファイルに対する rg
と他のツールのベンチマーク結果も示した。こちらでは rg
が全てのベンチマークでトップであり、他のツールを大きく引き離すことも多かった。この結果には次の最適化が影響している:
memchar
に「レアな」バイトを渡すことでスキップを高速に行う。- 特別な SIMD アルゴリズム Teddy を使って高速に多パターン検索を行う。
- Teddy が使えないときは "advanced" な Aho-Corasick に切り替える。このアルゴリズムでは入力一バイトごとに遷移が最大でも一度しか起こらない。
- UTF-8 のデコード処理を有限オートマトンに組み込む。
二つ目の主張を実証するために、Unicode の simple case folding rules や Unicode に対応した文字クラス \w
といった機能を正しく実装できているかどうかを確かめるベンチマークを示した。Unicode を正しく扱えるツールは rg
, GNU grep, git grep
のみだった。また GNU grep と git grep
の性能は Unicode を完全にサポートすると非常に低くなるのに対して rg
の性能はほとんど落ちなかった。
三つ目の主張を実証するために、メモリマップの有無を制御した rg
のベンチマークをいくつか示した。具体的にはメモリマップを有効にした状態と無効にした状態で rg
の性能を計測し、(少なくとも Linux x86_64
では) たくさんの小さなファイルを並列に検索するときはメモリマップを使うと性能が落ちるのに対して、単一の大きなファイルを検索するときはメモリマップを使うと性能が向上することを見た。また VM の中ではメモリマップに追加でオーバーヘッドが生じることも分かった。
私の願いはこの記事を読んだ読者が rg
は非常に高速だと納得することだけではない。それより重要なこととして、それぞれのベンチマークに対して行った解析から読者が何かを学ぶことを願っている。文字列検索は情報科学で古くからある問題であるものの、真に最先端の文字列検索ツールを書くには膨大な手間が必要となる。