如何将 conda 的索引获取带宽降低 99%
2023 年 3 月发布的全新 conda 23.3.1 版本包含一个 `--experimental=jlap` 标记,或者 `experimental: ["jlap"]` `.condarc` 设置,可以将 repdata.json 获取带宽降低几个数量级。这是我们开发 conda 新的增量 repodata 功能的方式。
Conda 是一个跨平台、与语言无关的二进制软件包管理器,它包含一个约束求解器来选择兼容的软件包集。在 conda 可以安装软件包之前,它会下载有关所有可用软件包的信息。这使得求解器能够对要安装的软件包做出全局决策。用于下载此元数据的时长和带宽可能相当可观,但在 conda 23.3.1 中我们对这一点进行了改进。通过在 `.condarc` 中启用 `experimental: ["jlap"]` 功能,conda 用户可以看到索引获取带宽降低了 99% 以上。
构想
传统上,conda 尝试在每次缓存过期时获取每个通道的完整 `repodata.json` 或更小的 `current_repodata.json`,通常在上次远程请求后的 30 秒到 20 分钟之间。如果通道没有更改,这将是一个快速的 `304 Not Modified`,但非常活跃的通道会每小时更改几次。任何更改,通常只添加几个软件包,都需要用户重新下载整个索引;大多数 conda 用户都熟悉此过程。我的经理说,Anaconda 的一个客户想要一种更好的方法来跟踪我们存储库中的更改,我对解决这个问题产生了兴趣。
我开始寻求基于计算连续 `repodata.json` 版本之间的补丁的解决方案,以便让用户只下载更改,前提是他们已在缓存中拥有索引的初始完整副本。
初始原型
我选择了 RFC 6902 JSON 补丁,这是一种针对 `.json` 的通用补丁格式。`JSON 补丁` 显示了两个 `.json` 文件之间的逻辑差异,而不是逐字比较文件,通过丢弃格式来节省空间。
我编写了 基于单个 `.json` 文件,其中包含补丁数组的 `.json` 补丁集格式的 Rust 实现。
Rust 实现有助于简化格式,并表明它可以与语言无关。在 Python 中,我们可能会包含可选的或多类型 “字符串或空” 字段,而没有考虑它。在 Rust 中,“强制,始终为单个类型” 字段是最容易指定的。令我惊讶的是,这种更改简化了 Python 代码。
实验表明,PyPy 比 Rust 更快地比较两个大型 `repodata.json`;CPython 的 `json.loads` 和 `json.dumps` 的性能也非常出色。停止了 Rust 实现的开发。
我开始基于补丁数组风格编写正式规范。
规范
我为基于 json 行的 `.jlap` 格式 编写了 Conda 增强提案 (CEP)。早期的补丁数组格式与 `repodata.json` 具有相同的问题,但规模很小,因为客户端每次都必须下载所有补丁。相比之下,新的 `.jlap` 系统 将补丁附加到文件的末尾。它旨在使用 HTTP 范围请求 从不断增长的文件中获取新补丁。使用此系统,更新带宽与自上次更新以来发生的更改量成正比。
`.jlap` 格式允许客户端使用单个 HTTP 范围请求获取最新的补丁,并且仅获取最新的补丁。它包含一个前导校验和、任意数量的补丁行(每行一个,以 JSON 行 格式),以及一个尾部校验和。
校验和以这样一种方式构建,即如果客户端记住中间校验和,则无需重新读取(或保留)文件的开头即可重新验证尾部校验和。尾部校验和用于确保远程文件的其余部分没有更改。
当 `repodata.json` 更改时,服务器将截断 `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 月的 conda 23.1.0 版本包含对其缓存的重构,这对增量 `repodata.json` 支持至关重要。conda 不是将缓存元数据内联到修改后的 `repodata.json` 中,而是将其缓存中存储未修改的 `repodata.json`,并将缓存元数据存储在单独的文件中。我们在许多情况下避免了重新序列化 `repodata.json`,并且可以保留其原始内容和格式。
Wolf Vollprecht 提交了一个草案 CEP 来标准化 conda 和 mamba 之间的缓存格式。我们继续朝着共享格式收敛。
发布 `repodata.jlap` 增量 repodata
3 月的 conda 23.3.1 版本发布了 `--experimental=jlap` 标记下的 `repodata.jlap` 支持。此功能还包括对 `repodata.json.zst` 的支持,如果不可用则回退到 `repodata.json`。
当缓存为空时,conda 将尝试下载 `repodata.json.zst`。与 `Content-Encoding: gzip` 相比,此文件下载和解压缩速度要快得多,并且体积略小。
当缓存已启动时,conda 将查找 `repodata.jlap`。它将下载整个文件,应用任何相关的补丁(与 `repodata.json` 的内容哈希进行比较),并记住补丁文件的长度。
在随后的获取中,conda 将使用 HTTP 范围请求下载(如果有)添加到 `repodata.jlap` 的新字节,并应用新的补丁。
结论
此 conda-forge/noarch 搜索的屏幕截图 显示,我们能够以 1464 字节下载对该通道的单个更新,而原本需要下载完整的索引,大小为 10358799 字节。补丁大小与自上次运行 conda 以来发生的更改量成正比。
启用 `--experimental=jlap` 后,频繁使用 conda 的用户将看到更快的索引更新,尤其是在带宽有限的情况下。