GNU diff はなぜ 10GB で 150 秒かかるのか — ソースを読んで分かった「全部読んでから比べる」設計と、GNU grep が速いままの理由

技術解説

10GB(約 1 億行)のテキスト 2 本を比べると、GNU diff は 150.93 秒、私の作っている UwDiff の uvd は 21.23 秒でした。使ったメモリは 17.23GiB と 0.41GiB です。

一方で、同じ GNU の grep は、私の uvf(UwView の検索コマンド)と同じように、ストレージの読み出しの速さに張り付いて走ります。

同じ GNU の道具なのに、なぜ diff だけが大きなファイルで遅くなるのか。推測で書くのをやめて、GNU diff と GNU grep のソースを読みました。

先に結論: GNU diff は「2 本を全部メモリに読んでから、全行を何周もする」作り。GNU grep は「行に切らずに読み流す」作りで、uvf とそっくりだった

  • GNU diff: 旧と新を丸ごとメモリに読み終えてから、全行のハッシュ表を作り、全行をまとめて Myers のアルゴリズムにかけます。読み終わるまで比較が始まらず、行数に比例する大きな表を何周もします。
  • uvd: 2 本を8MB ずつ流し読みしながら比べます。同じ行が続く間はハッシュを見て進むだけで、違いが出た所の前後(窓)だけに表を作り、短い区間だけ Myers をかけます。
  • GNU grep: 読んだ塊を行に切らずに探し、当たってから行を切り出し、行番号も当たったときだけ数えます。uvf と同じ原理で、どちらも 1 バイトあたりの仕事が少なく、ストレージの読み出しの速さで決まります。

速さの差は、アルゴリズムの賢さよりも、「全部読んでから始める」か「読みながら進む」かという設計の違いから来ていました。


読んだもの

  • GNU diffutils: src/io.c(読み込みと行のハッシュ)・src/analyze.c(比較の流れ)・gnulib の lib/diffseq.h(Myers のアルゴリズムの本体)
  • GNU grep: src/grep.c(読み込みと行の扱い)・src/kwset.c(固定文字列の検索)・src/kwsearch.c・src/dfasearch.c(正規表現)
  • 版: GitHub の非公式ミラー(gnu-mirror-unofficial/diffutils・grep)の開発版。上に挙げたファイルの作りは、実測に使った GNU diff 3.12 と同じです。
  • 比べた自分のソース: UwDiff の TextDiffEngine・Myers・Hashing、UwView の RawGrep(uvf の検索)

GNU のソースは GPL です。この記事では作りを説明するだけで、コードは写していません。

まず実測: GNU diff は大きくなるほど差が開く

同じ MacBook Air(Apple M4・10 コア・メモリ 32GB)・外付け SSD・同じ試験データで、毎回キャッシュを消してから(sudo purge)1 回ずつ測りました。テキストを行の並びで比べる diff -u の形です。

大きさ × 2 本 uvd 1.0.0 GNU diff 3.12 比
1GB(1,053 万行) 2.37 秒・0.31GiB 8.24 秒・3.15GiB 3.5 倍
5GB(5,267 万行) 10.74 秒・0.41GiB 41.24 秒・13.78GiB 3.8 倍
10GB(1 億 534 万行) 21.23 秒・0.41GiB 150.93 秒・17.23GiB 7.1 倍

uvd は大きさにほぼ比例して伸び、メモリは 0.4GiB 前後で止まります。GNU diff はメモリが大きさとともに増え、5GB から 10GB で時間が 3.7 倍に跳ねます。

全部の表と測り方は UwDiff の実測のページ にあります。

GNU diff の中で起きていること

diff 旧 新 を実行すると、GNU diff は次の順で進みます。

  1. 2 本を丸ごと読む(io.c の slurp)。普通のファイルなら、ファイルの大きさぶんのメモリを確保して、最後まで読み切ります。
  2. 共通の先頭と末尾を削る(find_identical_ends)。メモリ上で、先頭から同じ部分と末尾から同じ部分を、何バイトかずつまとめて比べて外します。速い処理ですが、1 行目から違いがあれば何も削れません。
  3. 全行をハッシュして、両ファイル共通の表に入れる(find_and_hash_each_line)。1 バイトずつ計算するハッシュで、全行を 1 つのハッシュ表に入れ、同じ行には同じ番号(等価クラス)を振ります。行の先頭を指す配列も全行ぶん作ります。
  4. 相手に無い行を外す(analyze.c の discard_confusing_lines)。片方にしか無い行は必ず追加か削除になるので、本番の比較から外します。これも全行の配列をなめます。
  5. 残りの全行を、1 回の Myers の分割統治で比べる(diffseq.h の compareseq)。前と後ろの両方から対角線を伸ばして真ん中で出会う点を探し、再帰します。手間がかかりすぎるときは、最良ではない点で打ち切ります(--minimal を付けない場合)。作業用の配列も行数ぶん確保します。
  6. 変更の境界を見やすく動かし(shift_boundaries)、差分を組み立てて出力します。

1 から 6 まで、前の段が全部終わってから次の段に進む作りです。ストレージから読んでいる間は比べられず、比べている間は読みません。2〜6 はどれも、行数(10GB で約 1 億行 × 2 本)に比例する配列を相手にします。

この作りから言えること・言えないこと

ソースから言えるのは、次のことです。

  • 10GB × 2 本の読み出しだけで、この外付け SSD では 20 秒前後かかります。GNU diff はそれが終わるまで比較を始めないので、比較がどれだけ速くても、合計は「読む時間+比べる時間」になります。
  • 両ファイル全行ぶんのハッシュ表と配列を作るので、メモリは行数に比例して増えます。測ったメモリ(1GB で 3.15GiB、10GB で 17.23GiB)もその形です。
  • 全行のハッシュ表は、1 億行規模ではあちこちに飛んで書き込む表になります。

一方、段ごとに何秒かかっているかは測っていません(プロファイルは取っていません)。5GB から 10GB で時間が 3.7 倍に跳ねるのは、使うメモリが機械の 32GB に近づいていく所と重なりますが、原因をそこに決めつけはしません。

uvd の中で起きていること

uvd(UwDiff のテキストの比較)は、GNU diff と同じ Myers のアルゴリズムを使いますが、かけ方が違います。

  1. 2 本を、ファイルごとに別のスレッドで 8MB ずつ読み、行に切って、行ごとに 16 バイト単位で計算するハッシュを付けます。
  2. 比べるスレッドは、ハッシュが同じ行が続く間は、ただ進みます。比べ終わった行は捨てます。
  3. 違いが出たら、その前後に窓を広げ、窓の中で旧と新にそれぞれ 1 回だけ出てくる行を目印にして、次に「そろう所」を決めます。
  4. そろう所までの短い区間だけ、Myers のアルゴリズムで最小の差分を求めます。
  5. 片方が終わったら、残りはまとめて追加か削除にします。

読むスレッド 2 本と比べるスレッド 1 本が重なって動くので、合計はほぼ「読む時間+α」です。ハッシュ表は違いの周りの窓の中にしか作らないので、表の大きさは違いの量で決まり、ファイルの大きさでは決まりません。

GNU diff uvd
読み方 2 本を丸ごとメモリに読み終えてから始める 2 本を別スレッドで 8MB ずつ読みながら比べる
行の表 両ファイル全行ぶん 違いの周りの窓だけ
ハッシュ 1 バイトずつ 16 バイトずつ
Myers 全行を 1 回で 目印から目印までの短い区間ごと
メモリ(10GB × 2 本) 17.23GiB 0.41GiB

GNU diff の方が良い所もある

uvd は、窓の中の目印でそろう所を決めるので、差分がいつも最小になるとは限りません。行の並びが細かく入れ替わった試験データでは、uvd の差分は GNU diff より 0.5% 多くなりました(どちらも、旧に当てると新とバイト単位で一致する正しい差分です)。

ファイルがメモリに余裕で収まり、差分が大きく、しかも最小の差分が欲しいなら(diff --minimal)、GNU diff の作りが合っています。uvd は、最小性を少し手放す代わりに、読みながら比べる形を選びました。

GNU grep が速いままの理由: uvf とほぼ同じ作りだった

GNU grep のソースを読むと、作りは uvd ではなく、uvf(UwView の検索)とそっくりでした。

GNU grep(grep.c) uvf(RawGrep)
読み方 読んだ塊を行に切らず、最後の改行だけ探して、その手前までをひと塊で探す 4MB ずつ読み、最後の改行の手前までをひと塊で探す
行をまたいだ残り 次の塊の先頭へ繰り越す 次の塊の先頭へ繰り越す
固定の文字列 Boyer-Moore 法(kwset.c)。進みが小さいときは memchr 系に切り替え .NET の IndexOf(SIMD)で手がかりへ飛ぶ
正規表現 正規表現から「必ず含まれる文字列」を取り出して候補へ飛び、そこで DFA を当てる(dfasearch.c) 正規表現から手がかりの文字列を取り出して候補へ飛び、候補の行だけ正規表現を当てる
行の切り出し 当たってから前後の改行を探す 当たってから前後の改行を探す
行番号(-n) 当たったときだけ、前回の位置からの改行を数える 手がかりの位置までの改行を、SIMD でまとめて数える

どちらも、「読んだ塊を行に分けない」「当たるまで改行を見ない」「行番号は後から数える」という同じ考え方です。1 バイトあたりの仕事が少ないので、CPU に余裕が残り、速さはストレージの読み出しで決まります。

実際、258GB(45 億行)のファイルでは、GNU grep は 273.66 秒でした。ファイルを 1 回読み切るだけの wc -l が 268.49 秒なので、その +1.9% です(258GB の記事)。uvf も、50GB の検索を 50.82 秒・962MB/s で走ります(uvf を 1 回読みにした記事)。別の日・別のファイルの測定なので秒数どうしは比べませんが、どちらも約 0.96GB/s、つまり読み出しの速さそのものに届いています。

同じ GNU で、なぜ diff だけがこうなのか

ソースを読んで分かったのは、解いている問題の形の違いです。

  • grep は 1 本を前から 1 回読めば済む問題です。前の行を覚えておく必要が無いので、最初から「読み流す」作りが自然で、それがそのまま今の SSD の速さにも追いつきます。
  • diff は 2 本の「最小の差分」を求める問題です。Myers のアルゴリズム(1986 年の論文)は、2 本の列のどこでも自由に見られることを前提にしています。ファイルがメモリに収まるのが当たり前だった時代には、全部読んで全行に番号を振る作りが理にかなっていました。GNU diff のその後の工夫(共通の先頭と末尾を削る、相手に無い行を外す、手間がかかりすぎたら打ち切る)も、メモリの中の配列を速く処理する方向で、読みながら比べる形にはなっていません。

uvd は「diff で比べるのは、たいていよく似た 2 本」という前提を置き、最小性を少し手放す代わりに、grep と同じ「読み流す」形で diff を組み直しました。1GB では 3.5 倍だった差が 10GB で 7.1 倍に開くのは、この作りの違いが、ファイルが大きくなるほど効いてくるからです。

uvd に取り入れられそうなこと

逆に、GNU diff から学べることもありました。今の uvd には入っていないので、今後の検討の種として書いておきます。

  • 先頭から同じ間は、行に切らずにバイト列のまま進む: GNU diff の「共通の先頭を削る」に当たる道です。uvd は同じ行が続く間も行に切ってハッシュを付けているので、差分が後ろにしか無いファイルでは省ける仕事があります(ただし、読み出しで速さが決まっている間は、あまり効かないかもしれません)。
  • 相手に無い行を、Myers に渡す前に外す: 違いの窓が大きいときに、Myers にかける区間を小さくできる可能性があります。

試すには

# Mac(Homebrew)
brew install --cask amru195704/uwview/uwdiff
# Windows(Scoop)
scoop bucket add uwview https://github.com/amru195704/scoop-uwview
scoop install uwdiff
# Linux(Snap)
sudo snap install uwdiff
uvd 旧.log 新.log -o 差分.diff   # diff -u と同じ形の差分を、読みながら作る

Scoop が入っていない場合の入れ方や、手で入れる方法はダウンロードのページにあります。


次に確認する


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

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