画面が klogg に約2倍負けていた原因は mmap だった — 改善記⑩:表示は mmap・通し読みは pread に分けて、50GB を開く時間を100.6秒→50.44秒

技術解説

6割しか出ていませんでした。

外付け USB SSD は dd で測ると 927MB/s 出ます。ところが同じ SSD から同じ 50GB を、mmap で 1MB ずつ読むと 551MB/s。半分強です。

なぜ遅いのか。そして、どちらの読み方が「正しい」のか。

先に結論: 同じファイルに読み方を2つ持たせました。表示は mmap のまま、索引作成と全文検索だけ pread(RandomAccess)に分離——これだけで 50GB を開く時間が 100.6秒→50.44秒(486→969MB/s)、検索が 88.9秒→53.50秒(550→914MB/s)になりました。比較相手の klogg(同じ 50GB を開く 52.55秒)に対して約2倍負けていたところが、ほぼ並びました。実効レートの確定値は dd 927/mmap+1MB 551/pread+1MB 889/pread+4MB 966/pread+16MB 968MB/s——mmap は pread の 57%、そして 4MB で頭打ちです。Mac(Apple M4・10コア)・メモリ 32GB・外付け USB SSD・OpenStreetMap XML・3巡の中央値。詳細は記事末尾。

この記事は「First Light」連載の第 10 回です。v1.6.6 には「First Light」というペットネームを付けました。画面もコマンドもファイルを 1 回読むだけで開いて探せるようになり、UwView としてひとつ完成した形になった版だからです。


問題: コマンドを速くしたら、画面が取り残された

連載の第 2 回で、コマンドライン側は 50GB の検索を 203秒から 50.82秒にしました。ところがその改善は、同じコードベースにある画面(GUI)側にはほとんど降りてきませんでした。

当時の 50GB の実測はこうです。

操作 UwView(当時) klogg 24.11 比
開く(行番号の索引ができるまで) 100.6秒 52.55秒 1.91倍 負け
検索(開いた後) 88.9秒 55.59秒 1.60倍 負け
通し(開く→検索) 189.5秒 108.14秒 1.75倍 負け

どちらも単項目で 1.5倍を超えているので、これは「差」です(判定の基準は第 9 回に書きました)。しかも同じ機械・同じファイルで、コマンドライン側は 50.82秒。同じ製品の中で、画面だけが 2倍の時間を使っていました。

最初は検索の実装を疑いました。画面側だけ余計な処理を挟んでいるのだろう、と。外れでした。

原因: 読み方を1つしか持っていなかった

分かったのは、遅さの原因が検索の仕方ではなく、読み方と読む回数だったことです。

読み方 — mmap は順次読みで帯域の6割

画面側はファイルを開くときに mmap(メモリマップ)を使います。ファイルをアドレス空間に貼り付けて、配列のように触れる仕組みです。好きな場所をすぐ覗けるので、スクロールする画面には理想的でした。

問題は、その mmap を索引作成にも全文検索にも使い回していたことです。どちらも先頭から末尾まで通しで読む処理です。

実効レートを測り直しました(2026-09-20・50GB・3巡の中央値)。

読み方 実効レート pread+4MB 比
dd(媒体の素の帯域の目安・1MB) 927MB/s —
mmap + 1MB ずつ 551MB/s 57%
pread(RandomAccess)+ 1MB ずつ 889MB/s 92%
pread + 4MB ずつ 966MB/s 100%
pread + 16MB ずつ 968MB/s 100%

mmap が落ちる理屈は、はっきりしています。触った瞬間にページフォルトが起き、カーネルがページを用意して、そこから自分のバッファへコピーが入ります。順次読みでは、この往復が全バイトに 1回ずつかかります。 pread は「このオフセットから 4MB くれ」と 1回頼むだけなので、往復の回数が桁で減ります。

面白いのは 4MB で止まることです。16MB にしても 968MB/s——2MB/s しか動きません。つまり 4MB の時点で律速が媒体側に移っています。ここから先は読み方をどう工夫しても伸びません。並列に読ませても 960MB/s 前後で止まりました(媒体が飽和している)。だから通し読みは単線のままにしています。スレッドを増やす改修をしなかったのは、増やしても伸びないと測ってから決めたからです。

数字が2つ、ぴったり符合した

ここで気づいたことが 1つあります。改善前の検索が 550MB/s だったことです。

mmap の上限が 551MB/s。検索の実測が 550MB/s。検索は、読み方の上限にぴったり張り付いていました。 判定の仕方を工夫する余地はここには無くて、読み方を変えるしか手が無かったということです。

一方、索引作成は 486MB/s でした。551 より遅い。読み方の上限より下にいるので、こちらは読み方以外にもう 1つ足を引っ張るものがあります。それは改行の数え方でした(IndexOf に替えた話は次の第 11 回で書きます)。

同じ「遅い」でも、上限に張り付いているのか、上限より下にいるのかで、直す場所が変わります。

読む回数 — 開くときに1回、検索でもう1回

もう 1つは単純な話です。画面は開くときに索引のために 1回読み、検索でもう 1回読んでいました。通しで 189.5秒は、ほぼ 100.6 + 88.9 です。コマンドライン側は 1回しか読んでいません(第 2 回)。

手当て: 読み取り元を2つに分ける

やったことは、DocumentSession に通し読み専用の読み取り元(ScanSource)を足すことです。

  • 表示は mmap のまま。これは変えません。開いた直後に末尾へ飛べる、任意の位置をすぐ覗ける、追記されたら貼り直して伸ばせる(Tail)——この性質は mmap でしか安く手に入りません
  • 索引作成と全文検索だけ pread。先頭から末尾まで通しで読むと分かっている処理だけを、新しく足した SequentialFileByteSource(RandomAccess で 4MB ずつ)に向けます

commit は 83e1e4e(SequentialFileByteSource.cs)と 08fd81d(画面側をそこに繋ぐ)です。

言い方を変えると、「ファイルをどう読むか」を 1つに決めるのをやめました。 入力は同じ 1本のファイルで、用途が 2つある。ランダムアクセスは mmap、通し読みは pread。用途ごとに読み方を選べる形にしたのが、この回の設計上の変更点です。

読む回数のほうは、索引が未完成のまま検索が始まったら、走行中の索引作成を止めて 1回読みで両方やる形にしました(コマンドライン側と同じ判定コード)。読み切ったときだけ索引を採り、上限で打ち切ったときは採りません。

踏んだ罠: pread は同期で返る

一度ハングさせています。pread は同期で返るので、UI スレッドで回すと await しても一度も制御が戻りません。50GB なら 50秒、258GB なら 4分半の無応答です。Task.Run で背景へ逃がして直しました(64f80c7)。

再発防止に入れたテストは、秒数を見るものではありません。ヒットの通知が、呼び出し元と別のスレッドから来ることを確かめます。UI スレッドで回していれば必ず落ちるので、時間に依存しません。「遅い」をテストするのは難しいけれど、「別スレッドから来る」なら決定的に書けます。

数字: 50GB で、開くが半分になった

操作(50GB) 前 後 倍率 klogg 24.11
開く(索引ができるまで) 100.6秒(486MB/s) 50.44秒(969MB/s) 1.99倍 52.55秒
検索(開いた後) 88.9秒(550MB/s) 53.50秒(914MB/s) 1.66倍 55.59秒
索引作成中に検索(1回読み) ハング 50.6秒(966MB/s) — —
開く+あとから検索 116.4秒(59.98 + 56.38) 同構成なら同左 — 108.14秒

開く・検索ともに klogg とほぼ並びました。 1.04倍・1.04倍なので、これは「勝った」ではなく「同等」です(単項目 1.5倍未満)。約2倍負けていたところが同等になった、という言い方までが正確です。

そして「索引作成中に検索」の 50.6秒は 966MB/s——pread+4MB の上限 966MB/s と同じ値です。媒体の限界で走っています。

258GB のファイルに当てはめると、読み方の違いだけで 459秒 対 273秒の見積りになります(実測ではなく帯域からの換算です)。

正直に書いておくこと

  • 上の秒数はすべて Mac(Apple M4・10コア)・メモリ 32GB・外付け USB SSD・OpenStreetMap XML(51,254,526,392 バイト。記事では「50GB」と書いています)での測定例です。内蔵 NVMe や別の OS では数字が変わります。とくに媒体が速くなると mmap と pread の差はもっと開くはずですが、そこは測っていません
  • 改善後の索引作成が 969MB/s で、dd の 927MB/s を上回っています。順序が逆に見えますが、これは dd を 1MB ブロックで測ったからで、媒体の真の上限ではありません(pread+4MB の 966〜968MB/s のほうが上限に近い)。素読みより速い数字が出たら、まず測り方を疑うべきという第 9 回の話の実例です
  • 帯域の 4 値は 2026-09-20 に 3巡して取り直した中央値です。それ以前にソースコードのコメントに書いていた値(別日の 1巡)とは少しずれます。記事には取り直した値だけを載せています
  • klogg との比較は、開く操作から件数が確定するまでをストップウォッチ 1本で測った手計測です。人間の操作時間が入ります。自動計測ではありません
  • 私は比較の一方の開発者です。その分は割り引いてお読みください
  • ripgrep が mmap を使わない理由についても測ったことがありますが、それは別の記事にまとめてあります。本記事は「自分のアプリの読み方」の話に絞りました

持ち帰れる一般論: 「速い読み方」は用途で決まる

(1)mmap は速い/遅いではなく、向き不向きです。 ランダムアクセスなら圧勝、順次読みなら pread の 57%。同じ API が用途によって 2倍近く違います。「mmap は速い」という一般論は、どちらの用途の話かを言わないと意味を持ちません。

(2)1つの入力に、複数の読み方を持たせてよい。 抽象化の教科書は「ファイルを読む」を 1つのインターフェイスに寄せろと言います。それは正しいのですが、通しで読むと分かっている経路だけを別の実装に向けるのは、抽象化の敗北ではなく設計です。今回は表示(mmap)と通し読み(pread)の 2本で足りました。

(3)上限に張り付いているかどうかを、先に見る。 検索は 550MB/s で、読み方の上限 551MB/s に張り付いていました。張り付いている処理は、その工程をどう最適化しても動きません(読み方を替えるしかない)。逆に索引作成は 486MB/s で上限より下だったので、そちらには読み方以外の原因がありました。「遅い」を見たら、まず上限を測って、張り付いているかを判定する。 この 1手で、直す場所が決まります。

(4)ブロックサイズは、伸びなくなるところまで測る。 1MB → 4MB は 889 → 966MB/s で効きますが、4MB → 16MB は 966 → 968MB/s です。測らずに「大きいほど速い」でメモリを積むと、無駄に常駐が増えるだけでした。

使っている道具

UwView は巨大テキスト・巨大ログのビューアです(無料版 UwView/有料版 UwView Pro)。

コマンドラインから同じことをする

この回の変更は画面側のものですが、読み方の違いは手元でも確かめられます。

# 媒体の素の帯域の目安(ブロックサイズを変えて何度か)
dd if=huge.log of=/dev/null bs=1m count=20000
dd if=huge.log of=/dev/null bs=4m count=5000

# 1回読みで検索する(コマンドライン側は最初からこの形)
/usr/bin/time -p uvf huge.log 'ERROR' > /dev/null

# 探した結果をそのまま画面へ渡す(2回読みにしない。第6回)
uvf huge.log 'ERROR' -open

# 件数だけ先に知る
uvf huge.log 'ERROR' -count

終了コードは grep と同じで、0=あり・1=なし・2=エラーまたは結果が不完全です。

正直に書くと、1問目は uvp が ripgrep より少し遅くなります——.uwvz(uvp 用の圧縮形式+索引)を先に作るぶんで、50GB なら 59.0秒 対 54.9〜55.8秒=6〜7%(v1.6.6.1)。効いてくるのは 2問目からで、同じ 50GB の検索が 6.56秒、ripgrep に対して 8.4倍速くなります。3GB のような小さいファイルでは同等です。無料の uvf は、どのサイズでも ripgrep と同等です(実測記事。Mac(Apple M4・10コア)・メモリ 32GB・外付け USB SSD・OpenStreetMap XML での測定例で、環境により異なります)。

リンク

  • 第 2 回: 2回半読んでいたコマンドを1回にした話: https://uvp.y42u.net/blog/uwview-fl02-one-pass-rawgrep/
  • 第 6 回: 探した結果を画面に渡して2回読みを1回に: https://uvp.y42u.net/blog/uwview-fl06-cli-to-gui-handoff/
  • 第 7 回: 3GB でだけ負けた理由は固定費だった: https://uvp.y42u.net/blog/uwview-fl07-3gb-fixed-cost/
  • 第 9 回: どこからを「差」と呼ぶかの基準: https://uvp.y42u.net/blog/uwview-fl09-difference-threshold/
  • ripgrep が 50GB で mmap を使わない理由: https://uvp.y42u.net/blog/uvp-rg-no-mmap-50gb/
  • klogg と開く・探す・通しで比べた話: https://uvp.y42u.net/blog/uvp-klogg-open-lose-flow-win/
  • ソースコード(GitHub): https://github.com/amru195704/UwView

そして、ストレージを専有している巨大ログを圧縮して保管し、さらに高速に検索したいなら UwView Pro をどうぞ。永続索引・圧縮キャッシュ検索・約 1/9 保管で、開き直しも検索も一段速くなります(全 OS 対応・買い切り/月額プランあり)。


開発者より: アプリ・Kindle 本・公開プロジェクトの一覧は GitHub: amru195704 にまとめています。


タイトルとURLをコピーしました