0
0

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 の検索が 205秒→51秒。「速さの道具ではない」と書いた自作 CLI/GUIアプリ を ripgrep 並みにした10の改善(調査・原因・修正・結果)

0
Posted at

はじめに

.NET で巨大テキスト用の検索 CLI を作っています。5日前のリリースノートに、自分でこう書きました。

速さの道具ではありません。ripgrep があるならそちらを使ってください。

51.25GB の XML(OpenStreetMap 日本)で「東京」を探すと、ripgrep 56秒に対して 205秒。4倍近く負けていました。

そこから5日で、同じ検索が 50.82秒になりました。この記事は、その間に直したことを1件ずつ、調査 → 原因 → 修正 → 結果の順で並べた記録です。うまくいった話だけでなく、効かなかった最適化と、今も負けているところも書きます。

測定環境: Mac M4/メモリ 32GB/外付け USB SSD(素読み 約950MB/s)。特記のないかぎり sudo purge 後のコールド。
51.25GB(51,254,526,392 バイト・8.9億行)、語は「東京」(94,979件)。秒数は環境で変わるので、倍率と傾向を見てください。

目次

  1. 2回半読んでいた → 1回読みに(205秒 → 50.82秒)
  2. mmap の順次読みは帯域の57% → pread+4MB に
  3. 改行の数え方を SIMD にした → ディスクからは1秒も縮まなかった
  4. -i をデコードせずにやる → 例外は U+212A だけ(…と思ったら ripgrep とは1文字ずれていた)
  5. 正規表現を固定文字列と同じ速さに(必須リテラル抽出)
  6. CLI が探した結果を GUI に渡す(二度手間の解消)
  7. GUI を「開きながら探す」に(189.5秒 → 53.7秒)
  8. 3GB でだけ負ける → 固定費だった(JIT 設定は効く所と効かない所がある)
  9. gz を展開せずに探す → gzip -dc | rg を追い抜いた
  10. 小さい仕様の直し(打ち切りの非決定・-head・絞り込み)
  11. おまけ: 「効いた」と言ってよい差の基準

1. 2回半読んでいた → 1回読みに

調査

50GB の検索 205秒の内訳を測ると、こうでした。

時間
索引(行の先頭位置)を作る 約100秒
検索する 約82秒
当たった行の本文を読み直して出力 約20秒

原因

GUI 用の部品をそのまま CLI に流用していたので、ファイルを「全部読む」処理が2回と、部分的な読み直しが1回——合わせて2回半読んでいました。GUI では「開いてから探す」の順が自然ですが、CLI では索引を先に作る理由がありません。

修正

1回の通し読みの最中に、次の3つを同時にやる専用の走査を書きました。

  • 改行を数える(=出力する行番号がわかる)
  • 行が語に一致するか判定する
  • 一致した行は、手元のバッファにある本文をそのまま出す

判定規則は GUI の検索と同じものを使い、結果が GUI と1行も違わないことをテストで突き合わせています。

結果

205秒 → 50.82秒(962MB/s)。メモリは 57MB。ripgrep(54.94〜55.81秒)と同等になりました。


2. mmap の順次読みは帯域の57%

調査

1回読みにしても、読み方しだいで上限が変わります。同じ 50GB を、読み方だけ変えて3巡ずつ測りました(中央値)。

読み方 MB/s
dd(参考) 927
mmap + 1MB ずつ触る 551
pread(RandomAccess.Read)+ 1MB 889
pread + 4MB 966
pread + 16MB 968

この表だけは sudo が使えない状態で測ったため purge なしです。50GB はメモリ(32GB)の1.6倍あり、通し読みではキャッシュに残らないので実質コールドとして扱っています。

原因

mmap の順次読みは pread の 57% しか出ませんでした。ページフォールトごとに少しずつ読む形になり、デバイスに大きな要求がまとまって届きません。

修正

表示は mmap のまま(好きな位置を覗く・末尾追従で伸ばすのに向いている)、頭から終わりまで通して読む処理だけ pread に分けました。

// 通し読み専用の読み取り元(要点)
using var handle = File.OpenHandle(path, FileMode.Open, FileAccess.Read,
                                   FileShare.ReadWrite, FileOptions.SequentialScan);
var buf = new byte[4 << 20];            // 4MB。1MB より1割速く、16MB でも変わらない
long offset = 0;
int n;
while ((n = RandomAccess.Read(handle, buf, offset)) > 0)
{
    Scan(buf.AsSpan(0, n));             // 改行を数える・照合する・出力する
    offset += n;
}

並列に読んでも 960MB/s 止まり(=媒体が飽和)なので、読むのは1本のスレッドです。

結果

GUI の「開く」(索引作成)も同じ読み方にして、100.6秒 → 50.44秒(486 → 969MB/s)。§7 の土台になりました。


3. 改行の数え方を SIMD にした → ディスクからは1秒も縮まなかった

調査

索引作成は「改行を数える」処理です。1バイトずつ == '\n' で比べていたので、ここが遅いと思っていました。メモリ上のデータで3つの書き方を比べると、確かに差があります。

数え方(メモリ上) GB/s
1バイトずつ比較 1.74
Span<byte>.IndexOf((byte)'\n') で跳ぶ 3.27
ベクタで一括比較して popcount 17.9
// IndexOf 版(要点)
long count = 0;
var span = buffer.AsSpan(0, n);
int i;
while ((i = span.IndexOf((byte)'\n')) >= 0)
{
    count++;
    span = span[(i + 1)..];
}

原因(のつもりだったもの)

ところが 50GB をディスクから読むと、1バイト版も IndexOf 版も 968〜969MB/s で完全に同じでした(行数 892,239,125 も一致)。

媒体の 950MB/s に対して、1バイト版でも 1.74GB/s とすでに倍近い余裕がありました。つまり律速は最初から読み出しで、§2 の mmap → pread が効いた分がすべてです。「1バイトずつだと 1GB/s で頭打ちになる」と思い込んでいましたが、この機械では再現しませんでした。

修正

IndexOf 版は残しています。CPU 側の余裕が倍になるので、もっと速い媒体(NVMe 直結など)に載せたときに効く準備という位置づけです。

結果

0秒。 10倍速いコードに替えても、媒体が律速なら1秒も縮みません。先にディスクから測っていれば、書く順番を変えていたと思います。


4. -i をデコードせずにやる

調査

大文字小文字を無視する検索(-i)を素直に書くと、バイト列を UTF-16 の string にデコードしてから RegexOptions.IgnoreCase に渡すことになります。50GB 全部をデコードするのは重い。

ASCII の語なら、バイトのまま A-Za-z に畳んで比べるだけで済むはずです。ただし、これが正規表現の IgnoreCaseまったく同じ結果になる保証が要ります。非 ASCII の文字が ASCII 英字と「同じ」とみなされるケースがあれば、取りこぼすからです。

そこで、BMP 全域を総当たりしました。

using System.Text.RegularExpressions;

// BMP 全域(サロゲートを除く)の非 ASCII 文字が、
// IgnoreCase | CultureInvariant の正規表現で ASCII 英字と一致するかを調べる
for (char a = 'a'; a <= 'z'; a++)
{
    var re = new Regex("^" + a + "$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
    for (int cp = 0x80; cp <= 0xFFFF; cp++)
    {
        if (cp is >= 0xD800 and <= 0xDFFF) continue;
        if (re.IsMatch(((char)cp).ToString()))
            Console.WriteLine($"'{a}' <- U+{cp:X4}");
    }
}
'k' <- U+212A

1件だけ。U+212A(ケルビン記号 K)が k と一致します。(.NET 8 と .NET 10 で同じ結果。数秒で終わります)

比較のために StringComparison.OrdinalIgnoreCase で同じことをすると 0件です。OrdinalIgnoreCase と正規表現の IgnoreCase は同じではありません。

原因

-i を正規表現側の規則に合わせる以上、例外はケルビン記号だけ。逆に言えば、kK を含まない ASCII の語なら、ASCII の畳み込みだけで正規表現と1行も違わないことが保証できます。

修正

// この語なら、バイトのまま大小を畳んで探しても正規表現と同じ結果になる
static bool IsFoldable(string pattern) =>
    pattern.Length > 0 && Ascii.IsValid(pattern) && !pattern.AsSpan().ContainsAny('k', 'K');
  • kK を含まない ASCII の語 → バイトのまま畳んで照合。語の中でいちばん出現頻度の低い文字z q x …の順)を目印に IndexOfAny で跳び、前後だけ確かめる
  • k を含む語 → 正規表現へ回す(k 以外の連続部分を「候補行を絞る手がかり」にだけ使う)

もう1つ、バイトのまま照合してよいのは UTF-8/ASCII/Latin-1 だけです。Shift-JIS では 83 5C なので、\ を探すと の後半バイトに当たってしまいます。UTF-16 は1文字が2バイトなので、そもそもバイトの一致が文字の一致になりません。これらはデコードする経路に回します。

結果

-i の 50GB が 51.58秒(ripgrep -i は 56.64秒)。

書いている途中で分かったこと — ripgrep とは1文字ずれていた

ここまでは「ripgrep の -i もケルビン記号を畳むので、三者(GUI・CLI・ripgrep)が一致する」と考えていました。この記事のために、同じ総当たりを ripgrep 14.1.0 にもかけてみました。

# BMP の非 ASCII 全文字を1行ずつ書いたファイルを作り、a〜z を -i で探す
for a in {a..z}; do rg -i "	$a\$" bmp.txt; done
U+212A	K    ← 'k'
U+017F	ſ    ← 's'

ripgrep は U+017F(ロング s ſ)も s と一致させます。.NET の正規表現は一致させません。 Rust の regex は Unicode の単純ケースフォールディングを使うので ſs が入り、.NET の IgnoreCase の同値表には入っていない、という違いです。

なので正確には、GUI と CLI は1行も違わない。ripgrep とは「s を含む語で、本文に ſ がある行」だけ食い違う、です。ſ は古い欧文の組版にしか出てこないので、ログや XML で踏むことはまず無いと思いますが、「三者一致」とは書けませんでした。


5. 正規表現を固定文字列と同じ速さに

調査

同じ 50GB(索引あり)で、固定文字列 7.15秒に対して -E13.68秒。倍近く遅い。

原因

当たりようのない行まで全部 string にデコードしてから正規表現にかけていました。

修正

パターンから「この正規表現に当たる行は、必ずこの文字列のどれかを含む」という集合を、保守的に取り出します。

  • ? * {0,} が付いた部分は必須ではない
  • + は1回分だけ必須
  • (a|b) は集合 {a, b}
  • 取り出せなければ、従来の経路(全行デコード)にそのまま回す

取れた文字列で SIMD の IndexOf をかけて候補行を絞り、候補行だけデコードして正規表現にかけます。「取りこぼさない」ことを最優先にしているので、迷ったら取り出しません。

結果

-E 13.68秒 → 7.75秒(固定文字列 7.15秒と並んだ)。必須リテラルが取れない式([0-9]{3}-[0-9]{4} など)は従来どおりで、遅くはなっていません。


6. CLI が探した結果を GUI に渡す

調査

検索コマンド ファイル 語 -open で「探して、結果を GUI で開く」ができるようにしていましたが、50GB なら 50秒 × 2 かかっていました。

原因

CLI から GUI へ渡していたのは検索語だけでした。GUI は受け取った語で、もう一度同じ検索をしていました。

修正

  1. 当たった行の行頭バイト位置を一時ファイル1つで渡す。読んだら消す。前後でファイル長を突き合わせ、違えば捨てて GUI が普通に検索する(失敗しても止まらない)
  2. さらに索引の目印も一緒に渡す。CLI はどのみち全部読んで改行を数えているので、N 行ごとの行頭位置を記録しても、値段はほとんど変わらない。GUI は索引を組み立てるだけで済み、結果一覧は最初から行番号つきになる

結果

検索だけ 3.27/10.40/51.70秒 に対して、GUI に渡すまでが 3.40/10.42/50.76秒(3GB/10GB/50GB)。渡す分の上乗せは測定の揺れの範囲に収まりました。


7. GUI を「開きながら探す」に

調査

GUI で「開いて、探して、結果一覧が出るまで」を測ると 189.5秒(開く 100.6秒+探す 88.9秒)。比較した klogg は 108.14秒でした。

原因

2つ重なっていました。

  • 開くときに索引を作るため1回全部読み、索引ができてから検索でもう1回全部読む
  • その通し読みが mmap(§2 の 57%)

修正

  • 通し読みを pread に(§2)
  • 索引がまだできていなければ、検索のついでに索引も作る。走っている索引作成は止めて、検索の走査に相乗りさせる。ファイルを最後まで読み切ったときだけ、その索引を採用する
  • pread は同期で返るので、UI スレッドで回すと固まる → Task.Run で背景へ
  • 検索結果は Dispatcher.Post で後から画面に反映されるので、「今の検索の結果か」を確認してから反映し、積んだ Post が全部片付いてから「完了」にする(件数が少なく出る・クリア後に古い結果が戻る、というバグがこれで消えた)

結果

189.5秒 → 53.7秒(910MB/s)。開くだけ(50.44秒)との差は 3.3秒で、検索の尻尾だけが残る形になりました。3GB・10GB はストップウォッチでは測れない速さになったので、秒数は出していません(3GB では検索語を打っている間に開き終わります)。


8. 3GB でだけ負ける → 固定費だった

調査

索引を持つ側(Pro の検索コマンド)の 3GB・2回目(hot)が、ripgrep 0.32秒に対して 1.04秒。大きいファイルでは勝つのに、小さいファイルでだけ負ける。

原因

毎回かかる固定費が、索引の恩恵より大きくなっていました。2つあります。

  1. Tiered JIT: .NET は既定で、最初は最適化の弱いコードで走らせ、途中で最適化版に差し替えます。ループの多い関数も最初は弱いコードで走るので、短い検索ほど「最適化前のコード」で終わってしまう割合が大きい
  2. 外部コマンドの起動: ライセンス確認で外部コマンドを起動していて、その初回起動が約0.2秒

修正

<!-- .csproj:ループを持つ関数は最初から最適化してコンパイルする -->
<TieredCompilationQuickJitForLoops>false</TieredCompilationQuickJitForLoops>

外部コマンドの起動はやめて、プロセス内で済ませるようにしました。

結果

  • Pro の 3GB hot、7種の検索の合計が 11.46秒 → 7.95秒(1.44倍)-v は 3.59 → 1.95秒。対照に測った ripgrep は 7.81 → 7.81秒で動かず
  • 外部コマンドの分は、事前に見積もった 0.2秒/回と実測が一致。3GB hot の比は 1/1.48 → 1.17倍

ただし、同じ設定は無料版の検索コマンドには効きませんでした。 設定が届いていないのではと疑って、配布物の runtimeconfig を確認したところ、ちゃんと届いていました。それでも 3GB hot 7種の合計は 8.33秒(設定あり)対 8.23秒(既定)で、差 1.2%=効果なし

理由は§3と同じで、こちらはディスクの読み出しが律速だからです。JIT の差が出るのは、CPU が律速の処理(索引を引く・展開する)だけでした。「JIT 設定で速くなる」は、何が律速かで答えが変わります。


9. gz を展開せずに探す

調査

.gz のログは検索できず、GUI で「展開して開く」しかありませんでした。展開しながら探すようにしたところ、今度は 3GB 相当の gz で 7.8秒、うち約6秒が CRC32 でした。

原因

gzip の末尾にある CRC32 を、1バイトずつ表を引いて計算していました。これが約0.5GB/s で、展開(約2GB/s)より遅い。律速が展開ではなく検査の方になっていました。

修正

  • CRC32: ARM64 では専用命令(System.Runtime.Intrinsics.Arm.Crc32)を使い、それ以外は8バイトずつの表引き(slicing-by-8)に

x86 の SSE4.2 にも crc32 命令がありますが、gzip には使えません。 SSE4.2 の命令は CRC32C(Castagnoli) 用で、gzip の CRC32(IEEE 802.3)とは多項式が違います。ARM64 には両方の命令があり、Crc32.ComputeCrc32 は gzip と同じ多項式です。

  • 展開と CRC を別スレッドで先回りさせ、照合と重ねる
  • macOS では OS 標準の zlib(/usr/lib/libz.dylib)を直接呼ぶ(.NET の GZipStream より 1.6〜1.8倍速かった。OS に必ずあるので同梱物は増えない)

結果

gz の検索(3GB/10GB/50GB 相当)が 2.18 → 1.16秒/7.27 → 3.66秒/35.65 → 17.20秒(2.0〜2.1倍)。そして gzip -dc | rg(=zgrep 相当)を追い抜きました。

gzip -dc | rg との比(hot) 倍率
macOS 1.02〜1.28倍
Windows 1.3〜1.7倍
Linux 1.6〜2.0倍

ここで大事なのは、速さの主因が「zlib を直接呼んだこと」ではない点です。zlib の直呼びは macOS だけで、しかも macOS がいちばん差が小さい(macOS の gzip 自体が速いため)。Windows と Linux は .NET 同梱の展開のままで、それでも 1.3〜2.0倍勝っています。効いているのはプロセス間のパイプを無くして、展開と照合を同じプロセスの中で重ねたことです。実装の話と、速さの理由を混ぜないように書いておきます。


10. 小さい仕様の直し

問題 原因 修正 結果
同じコマンドで件数が揺れる(356,940→356,356→357,803) 既定の「100万件で打ち切り」が、並列に探す分割(シャード)どうしの競走で、どこで止まるかが毎回違った 既定を無制限に。上限を指定して当たったら exit 2 で知らせる 件数が揺れなくなった
-head 10 が全件探す 全部探してから先頭 N 件を切っていた 1スレッドで先頭から読み、N 件で止める 50GB 7.01秒 → 0.32秒
絞り込み(2語目以降)が毎回フルスキャン 1段目で読んだ本文を、集計では再利用していたのに絞り込みでは使っていなかった 絞り込みにも再利用 50GB 3段 29.46秒 → 8.10秒

-head は今も rg | head(0.20秒)に負けています。 残りの 0.24秒はほぼ .NET ランタイムの起動で、ここは Native AOT などを入れないかぎり縮みません。


11. おまけ: 「効いた」と言ってよい差の基準

5日間で100回以上測って、見間違いも何度かありました。ディスクの空きが少ないときだけ出力の多い検索が2倍遅く出る、空きメモリしだいで同じ hot が 11秒にも3秒にもなる、素読みより速い値が出たらキャッシュを疑う——など。

そこで、次の基準に届かない差は**「変わった」と言わない**ことにしました。

  • 単一項目では 2倍未満は見ない
  • 7項目以上の合計でも 1.3倍未満は見ない
  • それ未満の差は、独立した裏付け(事前の予測と一致する/サイズをまたいで倍率がそろう/対照群が動かない)があるときだけ採用する

振り返ると、実際にあった変化はすべてこの基準を満たしていました(gz の 2.0〜2.1倍が3サイズでそろった、JIT の 1.44倍で ripgrep は不動、外部コマンドの 0.2秒が予測と一致)。逆に、基準を満たさない「速くなった気がする」は、全部あとで消えました。


まとめ

v1.6.0
50GB 検索(CLI) 205秒 50.82秒(ripgrep 54.94〜55.81秒と同等)
50GB 開く(GUI) 100.6秒 50.44秒
50GB 開いて探して一覧まで(GUI) 189.5秒 53.7秒
50GB -E 13.68秒 7.75秒
50GB gz 相当の検索 できない 17.20秒gzip -dc | rg 22.06秒)

効いたのは、ほとんどが**「読む回数を減らす」「読み方を変える」**でした。CPU 側を10倍速くしても、ディスクが律速なら0秒——§3 と §8 のこの2つが、いちばん勉強になった結果です。


この記事で扱ったのは、巨大テキストビューア UwView の検索コマンド(uvf、無料)です。計測の条件と全データは 実測まとめ に載せています。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?