uv 的缓存里,一个 numpy 的 wheel 和一个 pytorch 的 wheel,经常装着好几个字节完全相同的文件——同一份可执行文件、同一份动态库,被拆开存了两遍。Astral 给 uv 提的 PR #21327,把去重粒度从"整个 wheel"下沉到"每一个文件":本地实测直接省出 545.2 MiB,约等于缓存总量的一成,代价是冷安装慢了不到 4%,热安装几乎没感觉。
这套机制现在还锁在 --preview-features content-addressed-cache 后面,PR 本身也仍在评审,没有合并进 main。真正的悬念不在那几个百分点的耗时,是这东西能不能在各种文件系统和权限模型下都站得住。
改了什么,测出了什么
uv 原来的缓存只做到 wheel 级去重:同一个 wheel 从不同源下载两次,能共享一份缓存。wheel 内部、wheel 之间具体的文件,从没被拆开比对过。
这次改法是:每个文件解压后按 BLAKE3 哈希存进一个叫 files-v0 的对象桶,再用硬链接把它挂回原来该在的位置(archive-v0)。安装流程本身不变,用户感觉不到区别,只是磁盘上少存了很多重复文件。缓存清理时,一个文件对象的硬链接数掉到 1,就说明没人再引用它,直接删除。
| 指标 | 数值 |
|---|---|
| 节省的缓存空间 | 545.2 MiB |
| 占原缓存总量比例 | 约 10% |
| 冷安装多花的时间 | 3%—4% 左右 |
| 热安装变化 | 基本无感 |
这套逻辑不新鲜。Git 用哈希对象去重 blob,Docker 用分层复用镜像——uv 现在把同一套思路搬到了 wheel 缓存的文件粒度上,注意这是按文件去重,不是按数据块或字节切片去重。
代价卡在哪
作者试过更"聪明"的做法:先把所有文件存成非可执行,安装时再补权限标记。听起来能少一步硬链接,结果更慢——硬链接会共享文件的 mode,想装可执行文件就得额外拷贝一份,反而亏了。他又试过提前拉取 wheel 的中央目录,好在解压前就知道哪些文件该带可执行位,结果多发了 HTTP 请求,也更慢。最后留下的方案是:先解压再判断,再决定硬链接还是拷贝。
早期实现在规模级 wheel 上吃了不小的性能亏,打磨几轮才压到现在的水平:
这组测试跑在 Linux/ext4 的固定环境里,装的是离线本地 wheel,不联网、不装依赖、不编译字节码,还得手动打开 preview 开关才生效。这是最干净的实验室场景,不是日常联网安装、跨盘部署的情况。
审阅者已经在追问 hardlink_count 能不能覆盖 reflink(写时复制文件系统,比如 Btrfs、ZFS、APFS)。macOS 上批量读取硬链接计数的支持,也还是另一个未合并的 PR。跨平台的稳定性目前是空白。
另一个容易被误读的数字:后续小优化 #21340,把哈希用的缓冲区在整包内复用,PyTorch 那种大 wheel 的缓冲区分配次数从 11,120 次砍到 1 次,测出冷安装能再快 7%—10%。但这组对比是拿"文件级去重版本"和"文件级去重 + 缓冲复用版本"互相比,不是对 main 的最终结论,别当成叠加收益直接外推。
谁该关心,接下来看什么
这笔交易本身没什么可挑的:花几个百分点的冷启动时间,换一成的缓存空间,对天天拉大依赖的人是稳赚。收益和 wheel 体量、依赖重叠度成正比——越是大而重的科学计算依赖栈,越划算;装几个小包的普通用户,感觉不到差别。
两类读者该做的事不太一样:
| 读者身份 | 该做什么 |
|---|---|
| CI 团队 / 大依赖开发者 | 可以在非关键流水线上先开 --preview-features content-addressed-cache 试跑,测本机文件系统下的真实增益,别直接刷进生产配置 |
| 包管理器 / 构建缓存 / 存储层工程师 | 重点盯 hardlink_count 在 reflink 文件系统上的行为,以及 macOS 批量读取硬链接计数那个还没合并的 PR——这两个开关决定这套方案能不能推广 |
接下来该看的不是 Linux/ext4 那 4% 的耗时,是 #21327 能不能走出 preview、合并进 main,以及 macOS、Windows、各种文件系统组合下的兼容性 PR 有没有跟上。冷启动的账已经算清了,跨平台那笔账,还没交卷。
硬链接的账已经算清,跨平台的账才刚开卷。
