「速さの道具ではありません」と書いた日の内訳 — 50GB で 205 秒かかった CLI を、読む回数で分解する

技術解説

50GB の検索に、205 秒。

「速さの道具ではありません。ripgrep があるならそちらを」——v1.6.0 のリリース文に、自分でそう書きました。ripgrep の約 4 倍。けれど、その 205 秒が何でできているのかは、まだ測っていませんでした。

測ってみると、遅さの正体はアルゴリズムではありませんでした。同じファイルを、何度も読んでいたのです。

先に結論: 50GB の検索に 205 秒かかっていた CLI(uvf)は、ripgrep(56 秒)の約 4 倍遅い代物でした。その正体を測ると、遅いアルゴリズムではなく、同じファイルを「索引を作る 100 秒 → 検索する 82 秒 → ヒット行を読み直す 20 秒」と 2 回半も読んでいただけでした。読む回数を 1 回に畳むと 50.82 秒(962MB/s)。それでも 1 問目は今なお ripgrep に 6〜7% 負けます(索引を持つ代価)。いずれも特定環境での測定例です。詳細は記事末尾。

この記事は「First Light」連載の第 1 回です。UwView という巨大ファイルビューアを、v1.6.0 から v1.6.6 まで「読む回数」だけを頼りに速くしていった記録を、1 本 1 テーマで書きます。数字はすべて同じ機械の実測で、勝っている数字も負けている数字もそのまま出します。


問題: 自分で「約 4 倍遅い」と書いていた

UwView は巨大テキスト・巨大ログのビューアです。v1.6.0 でコマンド(有料版 uvp/無料版 uvf)を付けたとき、リリース文にこう書きました。「これは速さの道具ではありません。ripgrep があるならそちらを使ってください」。

そう書くだけの理由はありました。51.25GB の 1 本のファイル(OpenStreetMap 日本の XML)を「東京」で検索させると、uvf205 秒(v1.6.2 以前は 203 秒)。同じ機械・同じファイルで ripgrep は 56 秒です。約 4 倍。正直に「負けています」と書くしかありませんでした。

ただ、「負けている」で止めると、次にどこを直せばいいのか分かりません。205 秒が何でできているのかを、まず分解しました。

原因: 205 秒は「2 回半読み」だった

処理を段に分けて時間を測ると、こうなっていました。

やっていること 時間 読んだ量
① 索引を作る ファイルを頭から終わりまで読み、改行の位置を数える 100 秒 全体を 1 回
② 検索する もう一度ファイルを頭から読み、語に一致する行を探す 82 秒 全体を 1 回
③ 出力する 一致した行の本文を、また読み直して並べる 20 秒 ヒット行だけ(約半分ぶん)
合計 約 205 秒 2 回半

遅さの正体は、アルゴリズムの賢さでも、言語(C#)の速さでもありませんでした。同じ 51.25GB を、索引で 1 回・検索で 1 回・出力でもう半分、合わせて 2 回半読んでいた——ただそれだけです。

ripgrep が速いのは、魔法だからではありません。読みながら探して、そのまま出す。ファイルを 1 回しか読まないからです。こちらは 3 つの仕事(数える・探す・書き出す)を、それぞれ別のパスに分けて実装していたので、そのぶん読む回数が増えていました。「速いコードを書く」以前に、「同じものを何度も読まない」という当たり前が抜けていたわけです。

ディスクの素読み速度には上限があります。この外付け USB SSD は素読みで約 950MB/s。51.25GB を 1 回読むだけでも約 54 秒はかかります。2 回半読めば、それだけで約 135 秒。205 秒の大半は、この「読み直し」でできていたという計算に合います。

手当て: 読む回数を 1 回に畳む(次回の主題)

直し方は「3 つのパスを 1 つにまとめる」——読んでいる最中に、改行を数え、語に一致するか確かめ、当たった行はその場で出力に回す。1 回読み切ったら、もう答えは手元にある、という形です。この 1 パス設計そのものは、次の第 2 回で詳しく書きます。

10 個の改善をまとめて 1 本にした版は、Qiita に置いてあります(50GB の検索が 205 秒→51 秒)。この連載では、そこで束ねた話を 1 テーマずつほどいていきます。

数字: 203 → 50.82 秒(1 回読み・962MB/s)

3 つのパスを 1 つに畳んだ結果、uvf の 50GB 検索は 203 秒 → 50.82 秒になりました。読み出しの速さに直すと 962MB/s——ディスクの素読み上限にほぼ届いています。作業メモリは 57MB(ファイルサイズに関係なく一定)。

やったことは「速いアルゴリズムに置き換えた」ではありません。読む回数を 2 回半から 1 回に減らしただけです。速さの正体が「読む回数」だったので、そこを削るのがいちばん効きました。

この 1 回読みは、後にコマンドだけでなく画面(GUI)にも広げ、v1.6.6 で「開く・探すがディスクの素読み速度に届く」ところまで来ました。

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

そして正直に書くと、1 問目は今なお ripgrep に 6〜7% 負けます。索引(.uwvz)を残すぶんの代価で、これは消せません(詳しくは末尾の CLI の節と最終回で)。

持ち帰れる一般論: 「遅い」には 2 種類ある

自分のツールが遅かったとき、原因は大きく 2 つに分かれます。ひとつはアルゴリズムが遅い(計算のしかたが悪い)。もうひとつは同じデータを何度も読んでいる(I/O の回数が多い)。前者はコードの書き換えが要りますが、後者は「読む回数を数える」だけで見つかり、たいてい後者のほうが効きます。

巨大ファイルでは、ディスクの素読み速度が天井になります。天井が 950MB/s なら、50GB を 1 回読むのに約 54 秒。「何秒かかったか」を、まず「何回読んだか × 1 回ぶんの時間」に分解する。それだけで、直すべき場所が「コード」なのか「読み方」なのかが見えます。私の場合、答えは「読み方」でした。

使っている道具

この記事の題材はコマンドそのものなので、末尾にも実際の書き方を置いておきます。

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

v1.6.0 で uvp コマンドが付きました(無料版には uvf)。GUI と同じ .uwvz を使うので、CLI で作った索引はそのまま GUI でも効きます。

# 1回読みで、語があるかだけ確かめる(無料版)
uvf japan-latest.osm '東京'

# 大文字小文字を無視して探す
uvf app.log 'error' -i

# 前後の文脈ごと見る
uvf huge.log 'FATAL' -C 5

# CLI で探した結果を、そのまま GUI に渡して開く
uvf access.log ' 503 ' -open

終了コードは grep と同じ 0=あり・1=なしで、if uvf access.log ' 503 '; then がそのまま書けます。

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

リンク

  • Qiita: 50GB の検索が 205 秒→51 秒(10 の改善をまとめた版): https://qiita.com/amru195704/items/3cb6451815624aeced73
  • uvp コマンドを付けました(ripgrep との実測比較): https://uvp.y42u.net/blog/uvp-cli-release-vs-ripgrep/
  • 3 サイズでのベンチマーク: https://uvp.y42u.net/blog/uwview-pro-benchmark-3sizes/
  • ソースコード(GitHub): https://github.com/amru195704/UwView

同じファイルを何度も開いて検索するなら、索引と圧縮を保存する UwView Pro が効いてきます。2 問目以降は行番号付きで瞬時に開き直せ、約 1/9 に圧縮したまま検索できます(全 OS 対応・買い切り/月額プランあり)。


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


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