0%

【译】UV是如何这么快的

【由 ChatGPT 辅助翻译】

原文链接

发布时间: 2025-12-26

uv 安装包的速度比 pip 快一个数量级。通常的解释是“它是用 Rust 写的”。这确实没错,但并不能解释太多。毕竟,也有大量用 Rust 编写的工具,并没有表现出显著的速度优势。真正有意思的问题在于:究竟是哪些设计决策带来了这种差异

Charlie Marsh 在 Jane Street 的一次演讲(Jane Street talk)以及一篇来自 Xebia 的工程深度解析(Xebia engineering deep-dive)已经很好地覆盖了技术细节。更值得关注的,是其中的设计选择:哪些标准让“快速路径”成为可能,uv 放弃了哪些 pip 所支持的特性,以及哪些优化其实完全不依赖 Rust

使 uv 成为可能的标准

pip 的缓慢并不是实现层面的失败。多年来,Python 的打包体系必须通过执行代码才能知道一个包依赖什么。

问题出在 setup.py 上。你无法在不运行 setup 脚本的情况下知道一个包的依赖;但你又无法在不先安装构建依赖的情况下运行 setup 脚本。PEP 518 在 2016 年明确指出了这一点:“你无法在不知道依赖的情况下执行 setup.py 文件,但目前又没有一种标准化的方式,可以在不执行 setup.py 文件的前提下,以自动化手段获知这些依赖。”

这种“先有鸡还是先有蛋”的问题,迫使 pip 只能采取一条低效且危险的路径:下载包、执行不受信任的代码、失败、安装缺失的构建工具、再重试。每一次安装,都可能演变成一连串子进程的启动和任意代码的执行。安装一个源码分发包,本质上就像是多了几步的 curl | bash

解决方案是分阶段出现的:

  • PEP 518(2016)引入了 pyproject.toml,为包提供了一个无需执行代码即可声明构建依赖的位置。TOML 格式借鉴自 Rust 的 Cargo,这也让“一个 Rust 工具回过头来修复 Python 打包体系”这件事看起来不那么像巧合。
  • PEP 517(2017)将构建前端与后端解耦,使 pip 不再需要理解 setuptools 的内部细节。
  • PEP 621(2020)标准化了 [project] 表,使依赖信息可以通过解析 TOML 获取,而不是运行 Python 代码。
  • PEP 658(2022)将包的元数据直接放入 Simple Repository API,使解析器甚至无需下载 wheel 就能获取依赖信息。

PEP 658 于 2023 年 5 月 在 PyPI 上正式上线;uv 则在 2024 年 2 月 发布。uv 之所以能够如此之快,是因为整个生态系统终于具备了支撑这种速度的基础设施。像 uv 这样的工具在 2020 年是不可能发布的——当时这些标准还不存在。

其他生态系统更早就解决了这个问题。Cargo 从一开始就拥有静态元数据;npm 的 package.json 是声明式的。Python 的打包标准,直到最近才终于在这一点上追平了它们。

uv 放弃了什么

速度来自于删减。你不需要走的每一条代码路径,都是你不必等待的时间。

uv 的兼容性文档本质上就是一份“它不做什么”的清单:

不支持 .egg
Egg 是早于 wheel 的二进制分发格式。pip 仍然要处理它们;uv 则干脆完全不支持。这个格式早在十多年前就已经过时了。

不支持 pip.conf
uv 完全忽略 pip 的配置文件:不解析、不读取环境变量、不从系统级或用户级位置继承配置。

默认不进行字节码编译。
pip 在安装时会将 .py 文件编译为 .pyc。uv 跳过了这一步,从而为每次安装节省时间。如果你需要,也可以显式开启。

强制使用虚拟环境。
pip 默认允许直接安装到系统 Python。uv 则反其道而行,在没有显式标志的情况下拒绝修改系统 Python。这消除了一整类权限检查和安全相关的代码。

更严格地执行规范。
pip 会接受一些在技术上违反打包规范的畸形包;uv 会直接拒绝。容忍度越低,回退逻辑就越少。

忽略 requires-python 的上界。
当一个包声明需要 python<4.0 时,uv 会忽略这个上界,只检查下界。这极大地减少了解析器的回溯,因为这些上界几乎总是错误的。包作者声明 python<4.0,通常只是因为还没在 Python 4 上测试过,而不是因为真的会出问题。这种约束是防御性的,而非预测性的。

默认“第一个索引即胜出”。
当配置了多个包索引时,pip 会逐一检查所有索引;uv 则在第一个找到该包的索引处立即停止。这既可以防止依赖混淆攻击,也避免了多余的网络请求。

上述每一项,都是 pip 必须执行而 uv 不需要执行的一条代码路径。

在这里,Rust 才真正发挥作用

有些优化确实必须依赖 Rust 才能实现:

零拷贝反序列化。
uv 使用 rkyv 对缓存数据进行反序列化,而无需复制数据。其数据格式本身就是内存中的表示形式。其他语言中也有类似 FlatBuffers 这样的库可以做到这一点,但 rkyv 与 Rust 的类型系统深度集成。1

线程级并行。
Python 的 GIL 迫使并行工作只能通过多进程来完成,这会带来进程间通信的开销以及数据复制。Rust 可以在原生线程之间并行执行,并共享内存而无需序列化边界。这在依赖解析阶段尤为重要,因为求解器需要探索大量版本组合。1

没有解释器启动成本。
pip 每次启动子进程,都要付出 Python 解释器启动的代价;而 uv 是一个单一的静态二进制文件,没有需要初始化的运行时。

紧凑的版本表示。
在可能的情况下,uv 会将版本号打包为 u64 整数,使比较和哈希操作非常高效。超过 90% 的版本号都能放入一个 u64 中。这是一种微优化,但在数百万次比较中会不断累积效果。

这些都是实实在在的优势。但与放弃遗留支持、并充分利用现代标准所带来的架构层收益相比,它们反而显得次要一些。

设计优先于语言

uv 之所以快,并不是因为它用什么语言写成,而是因为它选择了不做什么。PEP 518、517、621 和 658 所推动的标准化工作,使得高速的包管理成为可能;而放弃 egg、pip.conf 以及宽松(但复杂)的解析策略,则让这种速度真正落地。Rust 只是让它在此基础上又快了一点。

从技术上讲,pip 明天就可以实现并行下载、全局缓存,以及仅基于元数据的依赖解析。但它并没有这样做,主要原因在于:对过去十五年各种边缘情况的向后兼容性,始终优先于激进的重构。这也意味着,pip 注定会比那些在现代假设之上重新出发的工具更慢。

其他包管理器可以从中学到的经验是:使用静态元数据;在发现依赖时避免执行代码;在下载之前就一次性完成完整的依赖解析。Cargo 和 npm 多年来一直以这种方式运作。如果你的生态系统必须运行任意代码才能知道一个包需要什么,那你在设计层面上就已经输了。

扫码加入技术交流群🖱️
QR code