0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

単一 50GB のファイルで ripgrep に勝つには索引が要る — 1.2問目で逆転、正規表現だけ4倍止まりだった理由と直し方

0
Posted at

キャッシュに載った 3GB(1億行の XML)を rg で検索すると 0.34 秒。3.03GB ÷ 0.34 秒 = 約 9 GB/s で、1 スレッドの memchr がメモリ上で出す速度です(rg は 1 つのファイルを複数スレッドに分割しません)。50GB は 55.7 秒、10GB は 11.2 秒。どちらも 約 0.92 GB/s で、外付け USB SSD の逐次読み出し速度です。rg は、キャッシュに載っていればメモリの速度で、載っていなければディスクの速度で、単一ファイルでも上限まで使い切ります

作者の Andrew Gallant が ripgrep is faster than {grep, ag, git grep, ucg, pt, sift} で挙げている理由は次の 4 つです。

  • 行単位で処理しない("Thou Shalt Not Search Line By Line")。大きなバッファをそのまま検索し、ヒットしたときだけ行の境界を求める。ヒットは稀なので、行を切り出す仕事のほとんどは無駄になるから
  • リテラル最適化。正規表現から必ず含まれる固定文字列を取り出し、その中の最も出現頻度が低いバイトmemchr(SIMD で 16 バイトずつ)で探す。複数リテラルは Teddy(Hyperscan 由来の SIMD アルゴリズム)
  • 遅延 DFA。UTF-8 をデコードせず、バイト列のまま状態機械で当てる
  • 固定サイズのバッファに読み込んで検索、または mmap。どちらを使うかは rg が状況で選ぶ

並列ディレクトリ走査は複数ファイルの再帰検索でだけ効く仕組みで、単一ファイルには関係ありません。

索引が勝てるのは、ディスクの速度で頭打ちになっているところです。50GB を 55 秒で読む相手に対して、.uwvz(索引つき圧縮キャッシュ)は 5.74GB——元の 1/9——を読んで復号します。読むバイト数が 1/9 なら、同じディスクで 7 秒。これがこの記事の 7 倍の正体で、CPU で勝っているわけではありません。キャッシュに載る 3GB では、rg は 9 GB/s でメモリを舐め、uvp は 252MB を読んで復号する CPU 時間が乗るので(0.68 秒)、勝てません。

私は UwView という巨大テキストビューアを作っていて、その有料版に uvp という CLI を付けました。ファイルを一度開くと .uwvz を作り、2回目からはそれを読みます。この記事は、その uvp を ripgrep 15.2.0 と同じファイル・同じ質問で突き合わせたときに出た、3つの数字の話です。

  1. 累積時間は 1.2 問目で逆転する(10GB・50GB)
  2. 固定文字列の検索は rg の 6〜7 倍速いのに、正規表現だけ 4 倍止まりだった
  3. 正規表現から必須リテラルを取り出して先に絞るようにしたら、固定文字列と同じ 7 倍になった

出力は 3GB・10GB・50GB、81 項目を行単位で rg / sed と比較し、全件一致です。環境は Mac M4 / 32GB、外付け USB SSD。ファイルは OpenStreetMap 日本の XML(3.03 / 10.26 / 51.25 GB)。

1. 1.2 問目で逆転する

索引を作る側は、初回に索引を作る時間を払います。.uwvz の作成は 10GB 11.8 秒、50GB 59.6 秒——どちらも 0.87 GB/s で、ディスクを 1 回読む時間そのものです。この分を 1 問目に含めて、rg と累積で比べます。

1問目 rg 1問目 uvp(索引作成込み) 2問目 rg 2問目 uvp
10GB 11.33 s 13.56 s 11.04 s 1.74 s
50GB 56.86 s 66.78 s 55.63 s 7.21 s

1 問目は rg が 15〜20% 速い。2 問目から rg は同じ時間を払い続け、uvp は 1.7 秒・7.2 秒で返す。累積が並ぶ点は

$$
n = 1 + \frac{u_1 - r_1}{r_2 - u_2}
$$

で、10GB は 1.24 問目、50GB は 1.20 問目です。「同じファイルにもう 1 回質問するか」が分岐点で、2 問目の途中で逆転します。5 問なら 50GB で rg 279 秒 vs uvp 96 秒、10 問なら 558 秒 vs 132 秒。

3GB は逆転しません。キャッシュに載るので rg は 0.34 秒、uvp は .uwvz を読んで復号する 0.68 秒。何問聞いても rg が速い。索引が元を取るのは、キャッシュに収まらないサイズだけです。

2. 正規表現だけ 4 倍止まりだった

2 問目以降(.uwvz 再利用)の操作別の比率です。値は rg ÷ uvp で、1 より大きいほど uvp が速い。

操作 10GB 50GB
固定文字列 5.98 7.78
大小無視 -i 6.08 7.84
2語絞り込み(rg A | rg B 相当) 5.96 7.55
集計(rg -o | sort | uniq -c 相当) 5.14 6.65
-out にテキスト保存 5.49 7.36
正規表現 -E(修正前) 4.39 4.06

固定文字列・大小無視・絞り込み・集計・保存はどれも 50GB で 6.6〜7.8 倍。正規表現だけ 4 倍でした。uvp の実時間で言うと、固定文字列 7.15 秒に対して正規表現 13.68 秒——ほぼ 2 倍かかっています。一方 rg は固定 55.66 秒、正規表現 55.70 秒で、正規表現にしても速度が落ちません

落ちない理由は上に書いた rg の設計です。正規表現から「当たる行が必ず含む文字列」を取り出し、まず memchr / SIMD でそれを探し、見つかった行にだけ正規表現エンジンをかける。ほとんどの行はバイト列のまま通り過ぎます。

uvp の固定文字列の経路は同じ考えで、エンコード済みのバイト列を Span<byte>.IndexOf(SIMD)で探し、ヒットした位置から行頭へ戻るだけです。ところが正規表現の経路は、全行を UTF-16 にデコードしてから Regex.IsMatch を当てていました。50GB を全部デコードする。当たりようのない行まで、です。差の正体はデコードでした。

3. 必須リテラルで先に絞る

直し方は rg と同じです。正規表現をパースして必須リテラル——「この正規表現に当たる行は、必ずこのうちどれか 1 つを含む」と言える文字列の集合——を取り出し、固定文字列と同じ SIMD 経路で探し、候補行だけデコードして正規表現で確定します。

/// 正規表現の必須リテラルで候補行を探す係(使えないときは null=全行を見る)。
/// 大小無視・UTF-8 以外・リテラルが取れない式では使わない。
public static LiteralFinder? CreatePrefilter(SearchOptions options, Encoding encoding)
{
    if (!UseLiteralPrefilter || !options.UseRegex || options.IgnoreCase || !LiteralFinder.Supports(encoding))
        return null;
    return RegexLiterals.Extract(options.Pattern, ignoreCase: false) is { } literals
        ? new LiteralFinder(literals, encoding)
        : null;
}

肝は取り出しを保守的にすることです。取り損ねても遅くなるだけですが、必須でない文字列を「必須」と言ってしまうとヒットが消えます。迷ったら取らない側に倒す。ルールはこうです。

  • 大小無視(-i(?i))は扱わない → null
  • 文字クラス・.\d など・後方参照・先読み/後読みは、リテラルの切れ目
  • ? * {0,…} の付いた要素は必須でない。+ {1,…} は 1 回分だけ必須
  • 選択肢がすべてリテラルのグループ (bus_stop|traffic_signals) は、候補の集合として前後の文字とつなぐ
  • 全体が a|b のときは、枝ごとの必須リテラルの和集合。1 つでも取れない枝があれば null
  • 候補が 16 個を超える、または 2 文字未満のリテラルしか無いときは null(1 文字はほぼ全行に当たる)

v="(bus_stop|traffic_signals)" からは v="bus_stop"v="traffic_signals" の 2 つが取れます。[0-9]{3}-[0-9]{4}" からは何も取れないので、従来どおり全行を見ます。

結果(50GB・.uwvz 再利用):

50GB rg uvp 修正前 uvp 修正後 rg/uvp
固定文字列 55.66 s 7.15 s 7.78
正規表現 v="(bus_stop|traffic_signals)" 55.70 s 13.68 s 7.75 s 7.19
リテラルが取れない [0-9]{3}-[0-9]{4}" 67.60 s 12.88 s 5.25

正規表現が固定文字列に並びました(7.75 秒 vs 7.15 秒)。10GB でも 4.39 倍 → 5.59 倍。リテラルが取れないパターンは従来の経路のままですが、rg 側もこの式では 67.6 秒に落ちるので、5.25 倍です。修正後の出力も全パターンで rg と一致しています。

残っている負け

  • -head 10:rg は 10 件出た時点で止まる(50GB でも 0.16 秒)。uvp は全件走査してから先頭を返す(6.7 秒)。先頭だけ見たいなら rg です。uvp では -limit N で走査を打ち切れ、50GB で -limit 1000 は 0.52 秒。
  • 3GB 以下:キャッシュに載るサイズでは rg が 1.2〜2.9 倍速い。

まとめ

  • rg が速いのは並列化ではなく、行単位で処理しない・最も稀なバイトを memchr で探す・UTF-8 をデコードしない、という作りによる。単一ファイルでもメモリ速度(約 9 GB/s)かディスク速度(約 0.9 GB/s)の上限まで使う。
  • 索引が勝てるのはディスクの速度で頭打ちになるサイズだけ。読むバイト数を 1/9 にして、1 問目にその分を払い、1.2 問目で逆転する(10GB・50GB)。3GB 以下は rg。
  • 正規表現が固定文字列の 2 倍かかっていたのは、当たらない行までデコードしていたから。rg が正規表現で遅くならないのは、必須リテラルで先に絞るから。
  • 同じことを保守的に実装したら、50GB の正規表現が 13.68 秒 → 7.75 秒、固定文字列と同じ 7.2 倍になった。取り損ねは遅くなるだけ、取りすぎはヒットが消える——この非対称が設計の全部です。

計測の生ログ(81 項目・全文):uvp と rg・sed の突き合わせ 81項目 — スクリプトの動作ログ。ソース:amru195704/UwViewUwView.Core/RegexLiterals.csSearchService.cs

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?