我们将 conda 索引抓取带宽降低了 99%
2023 年 3 月发布的新 conda 23.3.1 版本包含一个
--experimental=jlap
标志或experimental: ["jlap"]
.condarc
设置,可以将 repodata.json 抓取带宽降低几个数量级。这就是我们开发 conda 新的增量 repodata 功能的方式。
Conda 是一个跨平台、语言无关的二进制包管理器,它包含一个约束求解器来选择兼容的软件包集合。在 conda 可以安装软件包之前,它会下载有关所有可用软件包的信息。这允许求解器对要安装哪些软件包做出全局决策。下载此元数据所花费的时间和带宽可能非常可观,但我们在 conda 23.3.1 中改进了这一点。通过在 .condarc
中启用 experimental: ["jlap"]
功能,conda 用户可以看到索引抓取带宽降低 99% 以上。
想法
传统上,conda 尝试在缓存过期时(通常距离上次远程请求 30 秒到 20 分钟之间)抓取每个频道的整个 repodata.json
或较小的 current_repodata.json
。如果频道没有更改,这是一个快速的 304 Not Modified
,但非常活跃的频道每小时会更改几次。任何更改(通常只添加了几个软件包)都需要用户重新下载整个索引;大多数 conda 用户都会熟悉此过程。我的经理说 Anaconda 的一位客户想要一种更好的方法来跟踪我们存储库中的更改,我对解决这个问题产生了兴趣。
我开始寻求基于计算连续版本 repodata.json
之间的补丁的解决方案,以便用户在缓存中拥有索引的初始完整副本后,仅下载更改。
初始原型
我选择了 RFC 6902 JSON Patch,这是一种用于 .json
的通用补丁格式。JSON Patch
显示了两个 .json
文件之间的逻辑差异,而不是以文本方式比较文件,从而通过丢弃格式来节省空间。
我编写了一个 Rust 实现的 .json
补丁集格式,该格式基于一个带有补丁数组的 .json
文件。
Rust 实现有助于简化格式,并表明它可以是语言独立的。在 Python 中,我们可能在没有考虑的情况下包含了可选或多类型的“字符串或 null”字段。在 Rust 中,“强制性、始终为单一类型”字段最容易指定。我惊讶地发现,这种更改也简化了 Python 代码。
实验表明,对于比较两个大型 repodata.json
,PyPy 比 Rust 更快;CPython 的 json.loads
和 json.dumps
的性能也出奇地好。停止了 Rust 实现的开发。
我开始编写基于补丁数组样式的正式规范。
规范
我为新的 基于 json lines 的 .jlap
格式 编写了一个 Conda 增强提案 (CEP)。早期的补丁数组格式具有与 repodata.json
相同的问题,但规模较小,因为客户端每次都必须下载每个补丁。相比之下,新的 .jlap
系统 将补丁附加到文件末尾。它旨在通过 HTTP Range 请求 从增长的文件中抓取新补丁。使用此系统,更新带宽与自上次以来发生的更改量成正比。
.jlap
格式允许客户端使用单个 HTTP Range 请求抓取最新的补丁,并且仅抓取最新的补丁。它由前导校验和、任意数量的补丁行(每行采用 JSON Lines 格式)和尾部校验和组成。
校验和的构造方式使得无需重新读取(或保留)文件开头即可重新验证尾部校验和(如果客户端记住中间校验和)。尾部校验和用于确保远程文件的其余部分未被更改。
当
repodata.json
更改时,服务器将截断metadata
行,附加新补丁、新的 metadata 行和新的尾部校验和。
我们需要补丁数据来测试此系统。我编写了一个 Web 服务 来创建该数据。该服务每五分钟检查一次 repodata.json
,比较当前版本和先前版本,更新补丁文件并将其与主存储库分开托管。
最初的演示使用了 repodata.json
代理, 从 repodata.fly.dev
获取补丁,同时将软件包请求转发到上游服务器。用户将 conda 指向代理服务器而不是默认频道。另一个原型在 conda 基于 requests
的 HTTP 后端中添加了类似的代理,但在现有缓存之上复制了一个额外的本地缓存。
.jlap
格式是通用于JSON
的。底层校验和/范围请求系统通用于任何增长的基于行的文件。如果您遇到类似的问题,请考虑采用它。
服务器端改进
我成为了 conda-index
维护者,将其从 conda-build index
重写为一个新的独立软件包。我们解决了更新大型存储库(如 conda-forge
和 defaults
)的速度问题。这种参与使我可以轻松控制服务器端数据,以便我们可以一起改进客户端和服务器。
团队变动
我成为了 conda 团队的全职成员,从 Anaconda 的打包团队调来。我慢慢开始了解 conda 的内部结构,以便能够生成可以与 conda 现有缓存集成的解决方案,而不是本地缓存代理。
zstandard 压缩的 repodata.json 实现,并行下载
11 月 6 日,一位社区成员 注意到,如果您的连接速度快于远程服务器的 gzip 压缩器,则使用即时服务器压缩抓取 repodata.json
比抓取未压缩的 repodata.json
慢。我们在 11 月 14 日将 服务器端 repodata.json.zst
支持 合并到 conda-index 中。
11 月的 conda 版本包括并行软件包下载和解压,这种速度改进与您到软件包服务器的延迟成正比。
发布 repodata.json.zst
我们在 12 月 15 日将 zstd 压缩的 repodata 发布到 conda-forge
和 defaults
。
重构缓存
1 月份的 23.1.0 conda 版本包括对其缓存的重构,这对于增量 repodata.json
支持非常重要。conda 没有将缓存元数据内联到修改后的 repodata.json
中,而是将其未修改的 repodata.json
存储在其缓存中,并将缓存元数据存储在单独的文件中。在许多情况下,我们避免了重新序列化 repodata.json
,并且可以保留其原始内容和格式。
Wolf Vollprecht 提交了一份 CEP 草案,以标准化 conda 和 mamba 之间的缓存格式。我们继续趋同于共享格式。
发布 repodata.jlap
增量 repodata
3 月份的 23.3.1 conda 版本发布了对 repodata.jlap
的支持,使用 --experimental=jlap
标志。此功能还包括对 repodata.json.zst
的支持,如果不可用,则回退到 repodata.json
。
当缓存为空时,conda 将尝试下载 repodata.json.zst
。与 Content-Encoding: gzip
相比,此文件下载和解压缩速度更快,并且略小。
当缓存被预热后,conda 将查找 repodata.jlap
。它将下载整个文件,应用任何相关的补丁(与 repodata.json
的内容哈希进行比较),并记住补丁文件的长度。
在后续抓取中,conda 将使用 HTTP Range 请求下载添加到 repodata.jlap
的任何新字节(如果有),并应用新补丁。
结论
这个 conda-forge/noarch 搜索的屏幕截图 显示,我们能够以 1464 字节下载频道的单个更新,否则这将是 10358799 字节的完整索引下载。补丁大小与自上次运行 conda 以来发生的更改量成正比。
当启用 --experimental=jlap
时,频繁的 conda 用户将看到更快的索引更新,尤其是在带宽受限时。