「gz は検索できません」と書いていた — 改善記⑧:OS標準の zlib と8バイトCRCで 50GB の gz を 35.65秒→17.20秒、gzip と ripgrep をパイプでつないだ 22.06秒に並んだ

技術解説

展開のほうが、速かった。

圧縮ファイルの検索を作っていて、いちばん意外だったのはここでした。遅いのは展開だと思い込んでいたのに、測ったら展開は間に合っていて、遅かったのは「壊れていないか確かめる工程」でした。

3GB の gz を探して 7.8 秒。そのうち 6 秒が、検索でも展開でもないところに消えていました。

先に結論: 圧縮したままの gz を、展開ファイルを作らずに検索できるようにしました。macOS では OS に必ずある zlib を直接呼び、CRC32 の照合を 1 バイトずつから 8 バイトずつ(ARM64 では専用命令)に変えた結果、50GB 相当の gz 検索が 35.65 秒 → 17.20 秒(2.0〜2.1 倍)。gzip -dc の出力を ripgrep にパイプでつないだ形は同じ機械で 22.06 秒なので、1.28 倍。これは「同等」で、追い抜いたとは書きません(単項目で 1.5 倍未満を差と呼ばないと決めているため)。Windows・Linux でも同じ向きの結果が出ています(それぞれの OS の中で 1.71 倍・2.00 倍)。Mac M4・メモリ 32GB・外付け USB SSD・OpenStreetMap XML での測定例。詳細は記事末尾。

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


問題: 圧縮したログは、いったん解凍しないと探せなかった

置いてあるログは、たいてい圧縮されています。日次でローテートして app.log.20260901.gz のように積んでいく運用は珍しくありません。50GB のログが 5GB になるなら、そうするのが自然です。

ところが、その状態では探せませんでした。v1.6.0 の UwView は、gz を受け取ると展開して平文を作り、それを開く作りでした。つまり探す前に、

  • 50GB ぶんの空き容量が要る
  • 展開が終わるまで何も始まらない
  • 探し終わったら平文を消す(消し忘れる)

という段取りが挟まります。いちばん困るのは 1 番目です。圧縮して置いてあるのは、たいてい容量が足りないからです。探すために容量を要求する道具は、いちばん探したい場面で使えません。

一方、コマンドラインの世界にはすでに答えがあります。

gzip -dc app.log.gz | rg 'ERROR'

展開の出力をパイプで検索に流す。ファイルは作りません。強い手です。これに並べないなら、gz の直接検索を作る意味はありません。

問いはこうです。展開しながら探すとき、時間はどこへ消えるのか。

原因: 律速は展開ではなく、CRC32 だった

まず素直に作りました。gz を先頭から展開しながら、出てきたバイト列をそのまま検索に渡す仕組みです。検索は前へ前へ進むので、直近の窓だけ手元に残せば済みます。展開と検索は別スレッドに分けて重ねました。

動きました。そして遅かった。3GB の gz 検索で 7.8 秒。内訳を測って、内訳のほうに驚きました。

工程 速度
展開(GZipStream) 約 2GB/s
CRC32 の照合 約 0.5GB/s

gz には、末尾に展開後データの CRC32(4 バイト)と長さが入っています。読み終えたら計算した値と突き合わせて、壊れていないか確かめる決まりです。この照合が、展開より 4 倍遅かった。

素朴な CRC32 は、1 バイト取り出して表を引いて畳むループです。1 バイトあたり数命令。単純だから速いつもりでいましたが、1 バイト単位という点で並列化の余地がなく、展開のほうが先に終わってしまう。3GB の検索 7.8 秒のうち 6 秒が、この「確かめる」工程でした。

探していたものが 1 バイトも見つからない場合でも、CRC の計算は全バイトに走ります。つまりこれは、検索の成否と無関係な固定の税です。

手当て(1): CRC32 を 8 バイトずつ、ARM では命令で

CRC32 の高速化は、よく知られた道が 2 つあります。

1 つは 8 バイト(あるいは 16 バイト)まとめて畳む表引きです。表を 8 本持って、8 バイトぶんの寄与を同時に引く。ループ回数が 8 分の 1 になります。

もう 1 つは CPU の CRC32 命令です。ここで 1 つ落とし穴がありました。x86 の SSE4.2 にある CRC32 命令は、gzip が使う多項式と違う多項式のものです(いわゆる CRC-32C)。使うと値が合いません。一方 ARM64 の CRC32 命令は gzip と同じ多項式なので、そのまま使えます。

そこで、ARM64 では命令を使い、それ以外では 8 バイト表引きにしました。「CPU に命令があるなら速い」ではなく、同じ名前の CRC32 でも中身が違うところが、この手当てのいちばん実務的な教訓でした。

手当て(2): 展開そのものを OS に任せる

CRC が律速でなくなると、こんどは展開が見えてきます。

.NET の GZipStream は素直な実装ですが、OS にはすでに最適化された zlib が入っています。macOS なら /usr/lib/libz.dylib です。これを直接呼ぶようにしました。

  • 1.6〜1.8 倍速い(50GB 相当の展開だけで 30.2 秒 → 18.5 秒)
  • 参考に gzip -dc 単体は同条件で 26.3 秒
  • OS に必ずあるので、同梱するものはゼロ
  • 呼び出しに失敗した場合や他の OS では GZipStream に落ちる(機能は失われない)

同梱物が増えないのが大きい点です。速くするために配布物を太らせると、その分だけ別の問題(更新・署名・ライセンス)が生まれます。OS にあるものを呼ぶのは、増やさずに速くする数少ない手です。

手当て(3): パイプを渡らない

最後に、gzip -dc | rg に対して構造的に有利な点が 1 つだけあります。プロセスをまたがないことです。

パイプは、展開側が書いてカーネルのバッファに入り、検索側が読む、という受け渡しをします。安くはありますが無料ではありません。同じプロセスの中で展開したバイト列をそのまま照合に渡せば、この受け渡しが丸ごと消えます。

ここで強調したいのは、この差は小さいということです。パイプを消したから勝ったのではありません。効いた順は、CRC32(6 秒)、zlib 直呼び(数秒)、パイプ(わずか)です。

数字: 2.0〜2.1 倍。パイプ版とは「並んだ」

gz 検索(展開ファイルを作らない)・Mac M4・メモリ 32GB・外付け USB SSD・OpenStreetMap XML の gz

元ファイル相当 改善前 改善後 倍率
3GB 2.18 秒 1.16 秒 1.88 倍
10GB 7.27 秒 3.66 秒 1.99 倍
50GB 35.65 秒 17.20 秒 2.07 倍

3 サイズで倍率が 1.88/1.99/2.07 と揃っています。 これが「たまたま速く出た」ではないと言える根拠です。もし測定のばらつきなら、サイズごとに倍率が散ります。同じ比で並ぶのは、全サイズに同じ割合でかかっていた工程(CRC と展開)を削ったときの形です。

比較相手はこうなりました。

50GB 相当の gz を探す 秒
gzip -dc の出力を ripgrep にパイプ 22.06 秒
UwView(直接・展開ファイルなし) 17.20 秒
比 1.28 倍

1.28 倍は「同等」です。 自分たちで「単項目は 1.5 倍未満を差と呼ばない」と決めているので、ここを「追い抜いた」「速い」とは書きません。書けるのは「並んだ」までです。

そのうえで、意味はあります。パイプ版と同じ速さで、展開ファイルを作らずに済む。容量が要らず、後片付けも要らず、途中で止めても残骸が出ない。同じ秒数なら、副作用の少ないほうが使いやすい——という言い方が、この結果に対して誠実な表現だと思っています。

Windows と Linux でも同じ向きの結果が出ました。ただし秒数を OS またぎで比べることはしません。それぞれの OS の中での比だけを書きます。

  • Windows(内蔵 SSD・管理者権限+キャッシュ破棄あり): gzip -dc | rg 120.86 秒に対して 70.52 秒=1.71 倍
  • Linux VM(2 コア・8GB・drop_caches 有効): 80.69 秒に対して 40.32 秒=2.00 倍

面白いのは、Windows・Linux では GZipStream のままだという点です(OS 標準 zlib の直呼びは macOS だけ)。それでも 1.7〜2.0 倍になるのは、Windows の gzip コマンド自体が遅い(5.74GB の展開に 2 分。Mac は 22 秒、Linux は 81 秒)という事情も混ざっています。「速くなった」の中身が OS ごとに違うわけで、ここを一括して語ると嘘になります。

.uwvz との関係

UwView Pro には .uwvz という圧縮形式+索引のサイドカーがあり、そちらは「圧縮したまま探す」をもっと徹底した形です(詳しい記事)。今回の話は違って、世の中にすでにある .gz をそのまま探すためのものです。手元にあるログを圧縮し直さずに扱えることのほうが、実務では効く場面が多いと考えました。

正直に書いておくこと

  • 既知の制限を 1 つ残しています。 gz の中に 64MiB を超える 1 行があり、それが検索に一致した場合、「ファイルが壊れている」扱いになります。これは直しません——上限を外すと、普通のファイルの検索が遅くなるからです。制限として README に書く形を選びました
  • 展開が律速なので、cold(キャッシュを捨てた直後)と hot(メモリに載った状態)でほとんど差が出ません。平文の検索は cold と hot で大きく変わりますが、gz は「読む」より「展開する」のほうが重いので、キャッシュの効き方が違います
  • 上の 3 サイズの gz は gzip -6 で作ったものです。圧縮レベルが違えば展開の重さも変わります
  • ripgrep には -z という圧縮ファイルを扱うオプションもあります。ここで比べた gzip -dc | rg は、実務でよく使われる書き方として選んだものです
  • Linux では OS 標準 zlib の直呼びを入れれば Mac と同様にもう一段速くなる余地がありますが、入れていません。gzip -dc | rg に並ぶ目的は達成できていて、切り替えの仕組みを増やすほどの理由がないと判断しました

持ち帰れる一般論: 「重いはずの工程」を疑う前に、内訳を出す

この回の教訓は 1 行です。自分が重いと思っている工程は、たぶん重くない。

圧縮ファイルの処理を速くしようとすると、ほとんどの人は展開に目を向けます。名前が派手で、CPU を使いそうで、実際に重いからです。今回もそう思って始めました。ところが内訳を出したら、展開は 2GB/s で走っていて、律速は「確かめる」ほうの 0.5GB/s でした。

同じ形は他にもよく出ます。

  • 暗号化より、そのための鍵導出やハッシュのほうが重い
  • 圧縮より、チェックサムのほうが重い
  • パース(構文解析)より、入力を文字列に変換する前処理のほうが重い

共通点は、軽そうに見える工程が、1 バイト単位の単純なループになっていることです。1 バイトずつのループは、命令数が少なくても、まとめて処理できないぶん帯域が出ません。逆に「重そうな工程」は、重いからこそ最適化が積んであって、まとめて処理する形になっています。

だから順序はこうです。速くする前に、工程ごとの MB/s を出す。 全体の秒数だけ見ていると、いちばん最適化されている場所を、もう一段最適化しようとして時間を使います。内訳が出れば、「表引き 1 バイトずつ」のようなまだ誰も手を入れていない場所が見つかります。今回はそこが 6 秒でした。

もう 1 つ。同じ名前の仕様が同じとは限りません。 CRC32 という名前の CPU 命令があるのに使えなかったのは、多項式が違ったからです。ハッシュ、文字コード、日時形式、正規表現の方言——名前が一致していても中身が違う場所は、性能改善のときに事故になります。値が合うことを確かめてから速い道に乗り換えるのが、順番として正しいやり方でした。

使っている道具

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

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

# 2章: gz を展開せずにそのまま探す(無料版)
uvf app.log.gz 'ERROR'

# 4章: 正規表現・大小無視も gz のまま使える
uvf app.log.gz -E 'timeout|refused'
uvf app.log.gz 'error' -i

# 6章: 結果を gz で書き出す(圧縮したまま渡す)
uvf app.log.gz 'FATAL' -out fatal.txt.gz

# 6章: 前後の文脈ごと見る
uvf app.log.gz 'FATAL' -C 5

# 6章: 探した結果をそのまま画面で開く
uvf app.log.gz 'FATAL' -open

終了コードは grep と同じで、0=あり・1=なし・2=エラーまたは結果が不完全。if uvf app.log.gz 'FATAL'; then がそのまま書けます。

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

リンク

  • 第 1 回: 「速さの道具ではありません」と書いた日の内訳: https://uvp.y42u.net/blog/uwview-fl01-v160-confession/
  • 第 2 回: 2 回半読んでいた CLI を 1 回にする: https://uvp.y42u.net/blog/uwview-fl02-one-pass-rawgrep/
  • 第 4 回: 正規表現を固定文字列と同じ速さに: https://uvp.y42u.net/blog/uwview-fl04-regex-required-literal/
  • 圧縮したまま探す .uwvz の話: https://uvp.y42u.net/blog/uwvz-compressed-archive-search-extract/
  • ログの圧縮形式を 7 つ測って選んだ話: https://uvp.y42u.net/blog/log-compression-format-choice/
  • ソースコード(GitHub): https://github.com/amru195704/UwView

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


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


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