はじめに
.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件)。秒数は環境で変わるので、倍率と傾向を見てください。
目次
- 2回半読んでいた → 1回読みに(205秒 → 50.82秒)
- mmap の順次読みは帯域の57% → pread+4MB に
- 改行の数え方を SIMD にした → ディスクからは1秒も縮まなかった
-
-iをデコードせずにやる → 例外は U+212A だけ(…と思ったら ripgrep とは1文字ずれていた) - 正規表現を固定文字列と同じ速さに(必須リテラル抽出)
- CLI が探した結果を GUI に渡す(二度手間の解消)
- GUI を「開きながら探す」に(189.5秒 → 53.7秒)
- 3GB でだけ負ける → 固定費だった(JIT 設定は効く所と効かない所がある)
- gz を展開せずに探す →
gzip -dc | rgを追い抜いた - 小さい仕様の直し(打ち切りの非決定・
-head・絞り込み) - おまけ: 「効いた」と言ってよい差の基準
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-Z を a-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 を正規表現側の規則に合わせる以上、例外はケルビン記号だけ。逆に言えば、k/K を含まない ASCII の語なら、ASCII の畳み込みだけで正規表現と1行も違わないことが保証できます。
修正
// この語なら、バイトのまま大小を畳んで探しても正規表現と同じ結果になる
static bool IsFoldable(string pattern) =>
pattern.Length > 0 && Ascii.IsValid(pattern) && !pattern.AsSpan().ContainsAny('k', 'K');
-
k/Kを含まない ASCII の語 → バイトのまま畳んで照合。語の中でいちばん出現頻度の低い文字(zqx…の順)を目印に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秒に対して -E が 13.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つで渡す。読んだら消す。前後でファイル長を突き合わせ、違えば捨てて GUI が普通に検索する(失敗しても止まらない)
- さらに索引の目印も一緒に渡す。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つあります。
- Tiered JIT: .NET は既定で、最初は最適化の弱いコードで走らせ、途中で最適化版に差し替えます。ループの多い関数も最初は弱いコードで走るので、短い検索ほど「最適化前のコード」で終わってしまう割合が大きい
- 外部コマンドの起動: ライセンス確認で外部コマンドを起動していて、その初回起動が約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、無料)です。計測の条件と全データは 実測まとめ に載せています。