はじめに
最近、Python のパッケージマネージャとして uv を使う例を見かけるようになりました。
Qiita・Zennの記事でも、Python の環境構築で pip ではなく uv で書かれているものが目立ちます。
ただ、自分の場合は今のところ pip で特に困っていません。
pip install -r requirements.txt で必要なものは入りますし、Docker のレイヤーキャッシュも効いてくれます。
「uv が速いらしい」というのは耳にしますが、それだけのために乗り換えるべきかと言われると、いまいち腰が重いままでした。
そこで今回、自分が普段の ML 開発で使っている Dockerfile をベースに、pip 版と uv 版を実際に作って速度を比較してみることにしました。
uv とは
uv は Astral 社が開発している Python のパッケージマネージャです。Rust で実装されており、pip、pip-tools、venv、pyenv あたりを 1 つにまとめて置き換える存在として位置づけられています。
「pip より速い」とよく言われますが、実際に置き換えてみると速度以外にも嬉しい点がいくつかありました。
uv を使って嬉しいこと
1. インストールが速い
uv は依存解決とダウンロードを並列で行います。Rust 実装によるリゾルバ自体の速さに加え、複数パッケージを同時にダウンロードするため、pip install よりかなり短時間でセットアップが終わります。
ML 系の依存は torch のように数百 MB クラスの wheel が含まれることも多く、並列ダウンロードの効果が出やすい領域です。
2. lock ファイルが標準で付いてくる
pip 単体には lock の仕組みがありません。requirements.txt を pip freeze で書き出す運用もありますが、これは現在の環境に入っているものを丸ごと書き出すだけで、本来の「再現可能な依存定義」とは少し違います。再現性のある運用にしたい場合は pip-tools のような別ツールを組み合わせる必要があります。
uv は pyproject.toml で書いた直接依存に対して、間接依存まで含めた解決結果を uv.lock として自動で残してくれます。uv sync --frozen でその lock を厳密に再現するインストールができるので、「3 か月後の自分が同じ環境を作り直せる」ことが標準で保証されます。
3. Python 本体も uv が管理してくれる
pip 版の Dockerfile では apt install python3 で Python を入れていましたが、uv 版ではこの行が消えています。これは uv が自分で Python ランタイムを取得してくれるからです。
pyproject.toml に requires-python = "==3.12.*" と書いておけば、uv sync のタイミングで対応する Python を自動で取ってきてくれます。OS の標準 Python に依存しないので、Ubuntu のバージョンが変わっても、別マシンに移っても、同じ Python で動かせます。
4. pyproject.toml に統一できる
これまで requirements.txt、requirements-dev.txt、setup.py、setup.cfg などに分かれていた依存定義を、pyproject.toml 1 ファイルに集約できます。新しめの Python プロジェクトの標準的な方向性とも合致しています。
補足: 依存解決アルゴリズムについて
調べてみたところ、pip と uv では依存解決の内部実装も違っているようです。
- pip: バックトラッキング型のリゾルバ。バージョン要求の衝突を見つけた時点で別の組み合わせに切り替えながら探索する。
- uv: PubGrub という SAT solver 系のアルゴリズムを採用。すべての制約を同時に満たすバージョンの組み合わせを探し、衝突した場合は 何と何がなぜ衝突しているか を明示的に返す。
pip でも依存衝突自体はエラーとして検知されますが、uv の方が原因の特定が分かりやすい傾向があるとのことです。今回の検証ではここまでの確認はしていませんが、複雑な依存ツリーを持つ ML プロジェクトでは差を感じやすい部分かもしれません。
pip環境
普段、私は WSL2 上の Ubuntu で ML 開発を行っており、開発環境は Devcontainer で管理しています。
それを元に、最低限のライブラリのみをの環境を作りました。
ベースイメージは nvidia/cuda:12.6.2-cudnn-devel-ubuntu24.04、Python は Ubuntu 標準の 3.12 を apt で入れ、venv を切って pip で依存をインストールする、という素直な構成です。
Dockerfile
FROM nvidia/cuda:12.6.2-cudnn-devel-ubuntu24.04
WORKDIR /workspace
# Ubuntu 24.04 標準リポジトリから Python 3.12 を導入
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv python3-dev \
&& rm -rf /var/lib/apt/lists/*
# 仮想環境を作成し、PATHを通す
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:${PATH}"
# 依存パッケージを一括インストール
COPY requirements.txt /tmp/requirements.txt
RUN pip install --upgrade pip \
&& pip install -r /tmp/requirements.txt
CMD ["bash"]
requirements.txt には、ML 開発で使う一通りのパッケージを並べています。
--extra-index-url https://download.pytorch.org/whl/cu126
torch==2.7.1+cu126
numpy
pandas
matplotlib
scikit-learn
jupyterlab
seaborn
tqdm
ipywidgets
ipykernel
uv環境
同じ依存パッケージを uv で導入する様に、Dockerfile を修正しました。
Dockerfile
FROM nvidia/cuda:12.6.2-cudnn-devel-ubuntu24.04
WORKDIR /workspace
# uv 本体のインストールに必要な最小ツールのみ導入(Python 本体は uv が管理する)
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl \
&& curl -LsSf https://astral.sh/uv/install.sh | sh \
&& rm -rf /var/lib/apt/lists/*
ENV PATH="/root/.local/bin:${PATH}"
# pyproject.toml と uv.lock から依存を再現
COPY pyproject.toml uv.lock /workspace/
RUN uv sync --frozen --no-dev
ENV PATH="/workspace/.venv/bin:${PATH}"
CMD ["bash"]
pip 版と並べると、いくつか違いがあります。
-
python3の apt インストールが消えている (Python 本体は uv が管理するため) -
python3 -m venvの代わりにuv syncが venv を作る -
requirements.txtの代わりにpyproject.tomlとuv.lockを使う
curl -LsSf https://astral.sh/uv/install.sh | sh は uv 公式が案内している標準のインストール方法で、Rust 製の uv バイナリを 1 つだけ ~/.local/bin に配置します。
Python に依存していないので、Python 本体が入っていない状態のイメージにそのまま入れられるのが特徴です。
pyproject.toml
依存定義は requirements.txt ではなく pyproject.toml に書きます。
[project]
name = "uv-bench"
version = "0.1.0"
description = "uv vs pip benchmark - uv side"
requires-python = "==3.12.*"
dependencies = [
"torch==2.7.1+cu126",
"numpy",
"pandas",
"matplotlib",
"scikit-learn",
"jupyterlab",
"seaborn",
"tqdm",
"ipywidgets",
"ipykernel",
]
[tool.uv.sources]
torch = { index = "pytorch-cu126" }
[[tool.uv.index]]
name = "pytorch-cu126"
url = "https://download.pytorch.org/whl/cu126"
explicit = true
PyTorch の CUDA 版 wheel は PyPI ではなく PyTorch 独自の index から配信されています。
pip 版で --extra-index-url https://download.pytorch.org/whl/cu126 を指定していたのと同じ目的で、uv では [[tool.uv.index]] で対象 index を宣言し、[tool.uv.sources] で「torch だけはこの index から取る」と明示します。
uv.lock
uv.lock は手元で uv lock を実行すると自動生成されるファイルです。
私はローカルに uv を入れたくなかったので、Docker 経由で生成しました。
docker run --rm -v "$PWD":/workspace -w /workspace \
ghcr.io/astral-sh/uv:python3.12-bookworm-slim \
uv lock
ghcr.io/astral-sh/uv:python3.12-bookworm-slim は uv 公式が提供している軽量イメージで、Python 3.12 と uv 本体が同梱されています。
これでホストには何も追加せずに uv.lock を生成できます。
実行すると次のような出力が返ってきました。
Using CPython 3.12.12 interpreter at: /usr/local/bin/python3 Resolved 134 packages in 1.28s
134 パッケージの依存解決が 1.28 秒で完了しています。
生成された uv.lock の中身は、たとえば torch の項を抜き出すとこんな具合です。
[[package]]
name = "torch"
version = "2.7.1+cu126"
source = { registry = "https://download.pytorch.org/whl/cu126" }
dependencies = [
{ name = "filelock" },
{ name = "fsspec" },
{ name = "jinja2" },
{ name = "networkx" },
{ name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" },
...
]
wheels = [
{ url = "https://download-r2.pytorch.org/whl/cu126/torch-2.7.1%2Bcu126-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:63bce0590bc540fc16139e2be0177847585182b8c5e68d7f9213789d1d96c978", ... },
]
pyproject.toml で直接指定したのは 10 個のパッケージだけですが、生成された uv.lock には間接依存も含めて 134 パッケージが、バージョン・ハッシュ・wheel の URL・対応プラットフォームまで含めて記録されていました。
Dockerfile 側では uv sync --frozen で「uv.lock を厳密に再現する形でインストールする」ように指示しています。
--frozen を付けることで、ビルド時に lock の再計算が走らず、誰のマシンで何度ビルドしても同じバージョンが入ることが保証されます。
計測方法
検証に使用したファイルの構成は次の通りです。
/
├── pip/
│ ├── Dockerfile
│ └── requirements.txt
├── uv/
│ ├── Dockerfile
│ ├── pyproject.toml
│ └── uv.lock
└── bench.sh
docker build --no-cache でフルビルドした際の経過時間を time コマンドで計測しています。レイヤーキャッシュは完全に無効化しているので、毎回ゼロからのビルドです。
計測は bench.sh というスクリプトにまとめて実行しました。
#!/bin/bash
set -e
run_build() {
local target=$1
local tag="bench-${target}"
echo "Building ${target} (no-cache)"
time docker build --no-cache --progress=plain \
-f "${target}/Dockerfile" \
-t "${tag}" \
"./${target}"
docker images "${tag}" --format "{{.Repository}}:{{.Tag}} {{.Size}}"
}
case "$1" in
pip|uv) run_build "$1" ;;
*) echo "Usage: $0 {pip|uv}"; exit 1 ;;
esac
今回は pip 版と uv 版をそれぞれ 1 回ずつビルドした結果を比較します。
./bench.sh pip 2>&1 | tee bench-pip.log
./bench.sh uv 2>&1 | tee bench-uv.log
結果
| pip | uv | |
|---|---|---|
| ビルド総時間 | 8 分 37 秒 | 4 分 23 秒 |
依存インストール部分 (pip install / uv sync) |
150.9 秒 | 62.4 秒 |
| 最終イメージサイズ | 24.7 GB | 22.1 GB |
ビルド総時間で 約 2 倍 の差が出ました。依存インストール部分だけを取り出すと 約 2.4 倍 の差です。
環境によっては差が変わる可能性はありますが、少なくとも今回の条件では一貫して uv の方が高速でした。
イメージサイズについて
最終イメージは pip が 24.7 GB、uv が 22.1 GB で、uv 側が約 2.6 GB 小さい 結果になりました。
中身を見ると、両方とも大半は torch とその CUDA 系依存(nvidia-cudnn-cu12 だけで 571 MB など)で占められています。差分の出どころは、おそらく pip 側ではキャッシュや中間ファイルがレイヤーに残っている影響と考えられます。
uv 側は不要な中間ファイルをほとんど残さない設計になっており、apt で導入するパッケージも ca-certificates と curl だけで済んでいる分、純粋にイメージが軽くなっています。
おわりに
pip でも特に困っていなかったのですが、自分の開発環境で実際に試してみると、想像していた以上に便利そうだなと思いました。
- ビルド総時間: pip 8 分 37 秒 → uv 4 分 23 秒(約 2 倍速)
- 依存インストール部分: 150.9 秒 → 62.4 秒(約 2.4 倍速)
- 最終イメージサイズ: 24.7 GB → 22.1 GB(2.6 GB 削減)
速度の差ももちろん大きいですが、個人的に印象に残ったのは lock ファイルが標準で付いてくること と Python 本体を uv が管理してくれること の 2 点です。
pyproject.toml 一つで Python のバージョンも含めて環境が定義できて、uv.lock で完全な再現性が保証されるというのは便利です。
Dockerfile からも apt install python3 の行が消えて、純粋に「uv 本体だけ入れて uv sync を呼ぶ」シンプルな構成になります。
今後の個人開発でも uv を使ってみようかなと思います。
pip は依然として Python 標準ツールであり、CI/CD や既存プロジェクトとの互換性という観点では採用しやすいという側面もありますが、既存の Dockerfile を置き換えるだけでも結構な恩恵がありそうなので、似たような構成で ML 開発をしている方は、一度試してみる価値はあるんじゃないでしょうか。
おまけ
今回使用したスクリプトです