PaPoo
cover
technews
Author
technews
世界の技術ニュースをリアルタイムでキャッチし、日本語でわかりやすく発信。AI・半導体・スタートアップから規制動向まで、グローバルテックシーンの「今」をお届けします。

uv が wheel cache で「同じファイルを一度だけ持つ」ようにした話

Rust で書かれた Python パッケージマネージャー uv に、キャッシュの中身をファイル単位で重複排除する変更が入った。見た目は地味だが、配布物の置き場をかなり節約できるうえ、インストール時の体感速度を大きく壊していないのが面白い。しかも対象は、単に「同じ wheel を別の場所から落としたとき」ではない。wheel の中に入っている個々のファイルまで踏み込んでいる。キャッシュの設計を少しだけ賢くする、というより、かなり本気で整理し直した内容だ。

wheel そのものではなく、中のファイルまでまとめた変更

元記事は、astral-sh/uv の pull request #21327 について説明している。uv ではすでに content-addressed caching、つまり内容でキャッシュを識別する仕組みを wheel 単位では使っていた。たとえば同じ wheel を別の配布元から2回ダウンロードしても、キャッシュ上は同じものとして扱える。ただし、それはあくまで wheel 全体の話で、中に入っているファイル同士、あるいは別 wheel にまたがる同じ内容のファイルまでは重複排除されていなかった。

この PR ではそこを一段深くして、各ファイルを BLAKE3 ハッシュで識別し、files-v0 というバケットに置くようにした。元の archive 側、つまり archive-v0 にはそのファイルを hardlink でつなぐ。hardlink は実体をコピーするのではなく、同じファイル本体を複数の場所から参照する仕組みなので、インストール手順は変わらない。利用者から見ると、これまでと同じように wheel が展開されているように見えるが、裏では同じ内容のファイルが一つにまとまっている。キャッシュの掃除も、それぞれの hardlink 数が 1 になったらファイルオブジェクトを消すようにしている。

作者は以前の提案で、どれだけ節約できるかを細かく測っている。たとえば「実行ファイルと native libraries だけ」を重複排除しても 275.7 MiB 省けたし、10 MiB 以上の payload ファイルなら 235.0 MiB、1 MiB 以上なら 279.7 MiB、100 KiB 以上なら 353.4 MiB だった。さらに 10 KiB 以上で 475.7 MiB、1 KiB 以上で 537.4 MiB、すべての payload ファイルを対象にすると 545.2 MiB まで達する。本人のローカル環境では、これでキャッシュ全体の約10%を削れる計算だった。

その代わり、cold install、つまりキャッシュが冷えた状態でのインストールは少し遅くなる。元記事では、全体として 4% 未満の slowdown に収まっており、warm install、すでにキャッシュがある状態では影響がないとされている。別のコメントでは、AnyIO 4.9.0、SymPy 1.14.0、NumPy 2.2.6、PyTorch 2.7.1+cpu を使ったベンチマーク結果も示されていて、cold ではおおむね +3% 台、warm ではほぼ横ばいだった。PyTorch の cold だけは 95% 区間の上限が +5.18% まで届くが、それ以外は 5% を切っている。テスト面でも、対象の統合テストや stress iteration、Formatting、Clippy が通っている。

10% のキャッシュ削減と 4% 未満の遅さ、どちらを取るか

この変更でいちばん印象的なのは、削減量と遅延のバランスの取り方だと思う。キャッシュは通常、速さのために多少の無駄を飲み込むものだが、uv はそこをかなり攻めている。545.2 MiB、キャッシュの約10%という数字は、個人のノートPCでも無視しにくい。wheel を何度も入れ替える人ほど、この差はじわじわ効くはずだ。しかも warm install に影響がないなら、普段の開発体験を壊さずに、ディスクだけを痩せさせていることになる。

ただ、私はこの種の最適化は「スペースを節約できた」だけでは評価しきれないと思う。キャッシュは速くするためにあるので、冷たい状態で 4% 近い遅さが入るなら、設計として本当に釣り合っているかを見たくなる。元記事では、その遅延が十分小さいと判断しているが、これは uv の用途を考えると筋が通っている。Python の依存関係管理では、毎回ネットワークから拾うよりも、同じ wheel や同じファイルを何度も使い回す局面のほうが多い。だから warm 側が無傷で、cold 側の悪化も一桁パーセントに収まるなら、実務上は十分に飲める判断なのだろう。

wheel 単位の共有から、ファイル単位の共有へ進んだ意味

もう一つ面白いのは、「同じ wheel を再利用する」から「同じファイルを再利用する」へ話が進んだことだ。wheel は Python の世界では便利な単位だが、実際には中身の多くが他の wheel と共通だったりする。たとえば pure Python の一部、あるいは似たようなメタデータや補助ファイルは、配布物が違っても中身がほとんど同じということがある。そこを wheel 全体でしか見ないと、まだかなりの重複が残る。今回の PR は、その穴を埋めにいった格好だ。

この方向性は、最近のパッケージ管理ツールが「ダウンロードを速くする」だけでは足りないと考え始めている流れともつながると思う。大きい配布物を何度も落とすより、保存のしかたを賢くして全体コストを下げるほうが、長く使うほど効いてくるからだ。特に uv は高速さを売りにしているので、ディスク節約のためにあまりに大きな再計算を入れるのは本末転倒になりやすい。その意味で、hardlink を使い、インストール経路をほぼ変えず、削除時の扱いも hardlink count に任せる実装はかなり現実的だと感じる。

この変更が刺さるのは、ローカルで依存関係を回している人

この PR は、巨大なクラウド基盤をいじる話というより、手元の開発環境を静かに改善するタイプの変更だ。だからこそ効く場面がはっきりしている。複数のプロジェクトで同じ系統の wheel を何度も入れ直す人、CI のキャッシュを詰め込みがちな人、uv を日常の依存関係管理に使っていて、ディスク容量が地味に気になる人には、かなり実用的だろう。逆に、毎回使い捨ての環境しか作らないなら恩恵は薄いかもしれない。

私は、こういう最適化がOSSの成熟をよく表していると思う。最初は速く落とせれば十分で、次に wheel 単位の再利用を覚え、最後に中身のファイルまで気にし始める。性能改善というとベンチマークの秒数ばかりが注目されがちだが、実際には「どこまで保存コストを削れるか」も同じくらい重要だ。uv の今回の変更は、見えにくいところで効率を積み上げる方向に振れていて、その判断はかなり堅い。少なくとも、ただの微調整ではなく、キャッシュ設計を一段深くした仕事として見るべきだと思う。


参考: Deduplicate all files in the wheel cache by charliermarsh · Pull Request #21327 · astral-sh/uv

同じ著者の記事