返回 文章 学习 CMS 文章

OpenAI 如何为 Git 打造可扩展后端 Pando

OpenAI 自研 Pando 后端,在不改变 Git 使用方式的前提下,实现水平扩展的写入和最大化读取吞吐。

Git分布式系统版本控制OpenAI
成长分 / 100 71 综合收获、行动、留存与影响

OpenAI 如何为 Git 打造可扩展后端 Pando
为什么值得读了解 OpenAI 如何应对 Codex 带来的并发开发规模激增,以及传统 Git 服务模式的瓶颈。

深入理解 Pando 的架构选择:因果一致性、独立分支协调、packfile 复用,及其与 3PC 和 WAL 方案的对比。

关键洞察
  1. Pando 通过因果一致性放宽仓库级一致性,使不同分支的推送可以并行处理,避免全局协调瓶颈。
  2. Pando 复用 packfile 而非为每个客户端生成独立包,以少量冗余数据换取服务端计算的大幅降低,从而支撑高并发读取。
  3. 与 3PC 和 WAL 方案相比,Pando 避免了副本间多轮协调或共享清单的串行化,更适合高并发推送场景。
转成行动

深入阅读

正文与原文对照

原文保真覆盖:全文原文字符:35100

在过去一年里,我们 monorepo 的规模和对它的需求都以惊人的速度增长。很明显,我们需要改造源代码控制系统,使其能够扩展到 Codex 所实现的并发工作水平。

我们构建了 Pando,一个用于 Git 仓库的新后端,没有中断任何工程工作,也没有改变我们团队使用 Git 的方式。Pando 现在为 OpenAI monorepo 中的所有开发提供支持。Pando 旨在实现两个雄心勃勃的目标:第一,水平可扩展的写入,针对向多个分支的并发推送进行优化。第二,最大化读取吞吐量,以便客户端能够以足够快的速度下载数据,完全饱和其可用的网络带宽。

我们在保持与 Git 协议和客户端兼容的同时实现了这些目标,使我们的工程师和代理能够无缝地将 Pando 纳入他们现有的工作流程。在生产环境中,Pando 将中位推送延迟降低了近 5 倍。它现在每天传输近 1 PB 的 Git 数据。

在这里,我们将介绍构建新源代码控制系统的动机,以及为什么我们选择保持与 Git 的兼容性。我们将我们的系统与扩展 Git 的常见方法进行比较,描述为什么 Pando 具有独特的能力来满足 OpenAI 的可扩展性需求。我们还讨论了下一步计划,以及我们将如何继续为 Git 做出贡献以支持代理规模的工作。

为什么我们需要一个新的源代码控制系统

Codex 改变了 OpenAI 的开发规模和节奏,使工程师能够并行完成更多工作。这意味着更多的分支和推送,更多的合并,以及来自开发环境、研究任务和 CI 的更多获取和克隆。

这些需求会叠加。一个工程师现在有多个更改同时准备好进行 CI,每次推送都可能设置多个作业来获取相同的提交。

大约从去年年底开始,支持我们 monorepo 的系统难以跟上。我们遭遇了缓慢的获取和推送以及失败的合并,可靠性有时低于 90%。我们是达到了 Git 本身的极限,还是我们服务 monorepo 的方式的极限?Git 诞生于一个通过电子邮件交换内核补丁的社区。二十多年来,随着构建软件的方式发生变化,开发者们已经围绕 Git 调整了他们的工具。当我们开始在自己的 monorepo 中感到摩擦时,对我们来说重要的是我们能够继续在此基础上构建。

我们看到了一种方法,可以保持熟悉的工作流程,同时重建后端以并行处理对不同分支的推送,并同时向许多读者提供相同的历史记录。将 Git 作为我们源代码控制工作流程的基础意味着对客户端的改进可能对 OpenAI 之外也有用。我们希望将我们 Git 副本 中的这些改进贡献给上游,与更广泛的社区分享。

为大规模 Git 进行工程设计

扩展源代码控制系统有两个基本维度:许多写入者推送到不同分支,以及许多读取者获取相同的历史记录。Pando 对两者都进行了优化。

首先,Pando 独立协调对每个分支的更新。对不相关分支的更新可以并行进行,获取某个分支的读取者会获得该修订所需的历史记录。权衡是,查看多个分支的读取者可能会在不同时间点看到它们。

其次,Pando 在多次获取之间重用包文件(Git 将多个对象压缩在一起的格式),而不是为每个客户端生成单独的包文件。客户端可能会下载一些它已经拥有的对象,但服务器可以以很少的计算量向许多客户端提供相同的字节。

这些选择让我们能够跨分支扩展写入,并从缓存中提供读取。为了了解它们如何协同工作,让我们从 Git 如何存储和传输历史记录开始。

Git 对象存储入门

Git 必须能够保持仓库的两个部分同步:对象 和 引用。

Git 仓库将文件快照以及连接它们的历史记录记录为对象图。对象可以是 blob、tree、commit 或 tag。Blob 保存单个文件的内容,tree 将名称与 blob 或其他 tree 关联以形成目录结构。Commit 指向一个根 tree 和零个或多个父 commit,形成仓库的历史记录。

对象通过其类型和内容的哈希来标识。对象也是不可变的:记录更改的文件会创建一个新的 blob,而未更改的文件和目录可以重用现有的对象。

分支和标签是引用,或“refs”,指向 commit。在分支上提交会创建一个新的 commit,并更新分支的 ref 指向它。

分支 refs/heads/main 指向 commit C2,其父提交是 C1。C1 指向根 tree T1,其中 parser.py 引用 blob B1。C2 指向根 tree T2,其中 parser.py 引用 blob B2。在两个根 tree 中,docs/ 都引用同一个 tree T3。其 README.md 条目指向 blob B3,usage.md 指向 blob B4。
Git 对象通过其 ID 相互引用,commit 指向 tree,tree 指向 tree 和 blob。分支 refs/heads/main 指向 commit C2,其父提交是 C1。C1 指向根 tree T1,其中 parser.py 引用 blob B1。C2 指向根 tree T2,其中 parser.py 引用 blob B2。在两个根 tree 中,docs/ 都引用同一个 tree T3。其 README.md 条目指向 blob B3,usage.md 指向 blob B4。

Git 将对象存储为“松散”或“打包”状态。松散对象存储在由对象 ID 标识的单独文件中。打包对象一起存储在包文件中,使得相似对象可以作为彼此的增量存储。

parser.py 的两个版本出现在 $GIT_DIR/objects 下。作为松散对象,blob A 包含 parse(),blob B 包含 parse() 和 parse_file()。包文件完整存储 A,并将 B 表示为复制所有 A 并追加 parse_file() 的指令。附带的 *.idx 文件通过对象 ID 在包文件中定位两个 blob。
Git 可以将 blob 存储为单独的文件,也可以将它们打包在一起,将一个 blob 编码为相对于另一个 blob 的增量。

在 Git 中,如果 Git 可以从某个 commit 出发,沿着其父提交、tree 和 blob 的链接找到某个对象,则该对象从该 commit 可达。在 fetch 过程中,客户端告诉服务器它想要哪些 commit 以及它已经拥有哪些 commit。服务器找出从所需 commit 可达的对象,排除它已知客户端已经拥有的对象,并将剩余部分以 Git 在磁盘上使用的相同格式作为包文件发送。完整克隆开始时没有任何对象,因此它需要被克隆历史中可达的所有对象。

push 通过要求服务器更新一个或多个 ref 来向相反方向传输对象。客户端利用服务器通告的 ref 集合来避免发送服务器已经拥有的对象。因此,发送的包可以依赖于现有对象:仅接收该包不足以重建新的历史。

复制 Git 仓库意味着在多个服务器上同时保持对象和引用可用。在副本能够提供某个引用的新值之前,它必须能够提供从该值可达的对象。该服务还需要协调相互竞争的引用更新,即试图将同一分支从同一旧顶端移动到不同新 commit 的 push。对所有仓库写入施加单一顺序的系统可能会让对不相关分支的 push 相互等待。

现有的分布式 Git 服务模式

在设计 Pando 时,我们评估了使用 Git 扩展源代码管理的传统方法:一种使用三阶段提交(3PC,一种跨多个节点协调提交的协议)来协调仓库副本之间的 ref 更新,另一种通过 blob 存储中的共享预写日志(WAL,在应用更改之前写入的更改记录)来排序更新。

三阶段提交(3PC)

在我们建模的三阶段提交设计中,Git 服务在多个副本上保留仓库的完整副本。当任何服务器收到 push 时,传入的包文件会被复制并并行发送到所有副本。要发布该 push,必须有多数副本接受 ref 更新。管理此过程的服务器是协调者,它需要协调者与每个其他副本之间进行多轮通信。

为了接受引用更新,每个副本会锁定其 ref 副本,并检查它是否仍指向客户端期望的 commit。然后,协调者向接受的副本发送预提交消息。一旦多数副本确认预提交消息,协调者便指示它们执行更新。随后,任何最新的副本都可以从其本地仓库副本通告新的分支顶端。

保留完整副本使得在本地提供读取成为可能,并保证无论客户端选择哪个副本,都能获得所有引用的一致视图。因此,引用一次更新一个,并且每次 push 都需要多次往返以在副本之间协调 ref 更新。

等待法定人数会将副本磁盘性能和通信延迟置于 push 路径上。随着写入量增长,副本必须跟上传入的包以及读取流量。

预写日志(WAL)

在我们建模的基于 WAL 的设计中,Git 服务使用 blob 存储中的共享清单来对仓库中的更新进行排序。当服务器收到 push 时,它将传入的包文件上传到 blob 存储,并在其本地仓库副本中准备 ref 更新。要发布该 push,它会向存储在 blob 存储中的共享清单添加一个条目,记录 ref 更新并指向该包文件。然后,Git 服务器使用比较并交换操作更新清单。这会原子地替换清单,否则拒绝该更改。如果另一个写入者在此期间更新了它,服务器会读取中间条目,检查其 ref 更新是否仍然有效,并使用它已经上传的包文件重试。

写入者可以并行上传其包文件,但即使是对不相关分支的 push 也需要更新同一个清单。成功的比较并交换的往返时间限制了仓库发布新清单的频率。低延迟的 blob 存储使此步骤快速。批处理允许一次清单更新同时更新多个 ref,代价是排队更新并增加延迟。

blob 存储可以并行接收包文件,但对共享清单的更新仍然相互竞争。仅增加 blob 存储的容量并不能解决该瓶颈。每个副本在本地提供包之前,仍然需要从 blob 存储读取并索引包。如果传入写入超过该工作,副本就会落后,客户端获取新推送的 commit 的等待时间就会更长。

设计 Pando 架构

这就引出了 Pando。Pando 是一种用于服务 Git 仓库的新架构,目标是扩展到许多客户端同时推送。以下是它的工作原理:

重新思考约束

在开发 Pando 之前,我们有一种预感:我们可以在不损害 Git 的情况下满足我们的需求,但我们确实需要在大多数 Git 系统视为需求的一些方面做出妥协:

  • 仓库范围的一致性:提供所有 ref 的一致时间点快照需要跨仓库进行协调。在对所有 ref 更新一起排序的系统中,对不相关分支的 push 会在单一协调点上竞争。
  • 为每个客户端生成唯一的包文件:如果两个具有不同本地历史的客户端获取同一分支,服务器可能需要为每个客户端生成不同的包,以避免发送它们已经拥有的对象。创建这些响应会消耗 CPU 和磁盘 I/O,使其难以扩展到大量读取者。

相反,Pando 提供了一种宽松的一致性模型(因果一致性)来扩展写入,并复用包文件来扩展读取。

因果一致性(CC)

当客户端推送到一个分支时,Pando 将传入的包文件作为不可变 blob 存储在 blob 存储中。每次分支移动时,Pando 在元数据存储中记录一个引用版本,包含新的末端提交、提供该提交的包以及该包依赖的任何更早的引用版本。blob 存储和元数据存储都是水平可扩展的。

从 Pando 获取提交或引用需要向客户端重放一组引用版本(包文件),重建创建所获取引用版本的客户端的状态。这在整个仓库范围内提供了一种较弱的一致性形式,同时为单个引用的更新提供可串行化。客户端仅在同时推送到同一引用时发生冲突。读取多个分支的客户端不能假设这些读取描述了整个仓库的单一时间点。

左侧,三个连续的 trunk 包分别包含提交 A、B 和 C;D 和 E;以及 F。右侧,trunk 和 topic 在 C 处分叉。Trunk 前进到 G,而 topic 经过 D、E 和 F 前进。最新的 topic 包包含 D、E 和 F。虚线轮廓标记 D 和 E 也存在于较早的 topic 包中。蓝色箭头标记新包对 trunk 在 G 处的依赖。F 的 Git 父提交是 E。
推送到 trunk 会添加一个增量包,包含一个或多个提交。每次推送到侧分支都会再次包含该 topic 的较早提交,因此拥有所公布 trunk 版本的客户端可以通过一个包获取该 topic。

这是一种因果一致性形式。为了获取引用,读取者观察产生该引用的写入者的历史。在推送期间,Pando 控制客户端和服务器就哪些共享历史达成一致,确保客户端生成更易于共享的包文件。

因此,Pando 在 Git 操作期间不提供所有引用的时间点状态,也不提供克隆或获取中引用更新的排序。具体来说,假设分支 foo 和 bar 在全量克隆的协商期间都被更新。在协商期间,一个客户端可能观察到 foo 在版本 1 和 bar 在版本 2,而另一个客户端观察到 foo 在版本 2 和 bar 在版本 1。在实践中,用户和代理不关心引用更新的全局顺序,而是依赖于对单个引用的更新顺序。

方法比较

在这个三种一致性模型的模拟中,我们配置 1,000 个客户端各自选择一个不同的分支,然后重复推送唯一提交,测量总墙钟时间和推送至获取延迟。后者是工作负载的重要指标,因为它衡量用户和代理协作或使用依赖版本控制的其他系统的往返时间。该模拟对从商用硬件到高级虚拟机和 blob 存储系统的硬件进行建模。在此模拟中,我们放宽了仓库范围的一致性,以支持三阶段提交。

三阶段提交

就绪

模拟时间

0 秒

平均推送至获取

—

写入者完成

0 / 1,000

0 / 10,000 次推送已确认 · 0 次获取

选择一个方块以检查该写入者。

对象存储 WAL

就绪

模拟时间

0 秒

平均推送至获取

—

写入者完成

0 / 1,000

0 / 10,000 次推送已确认 · 0 次获取

选择一个方块以检查该写入者。

Pando

就绪

模拟时间

0 秒

平均推送至获取

—

写入者完成

0 / 1,000

0 / 10,000 次推送已确认 · 0 次获取

选择一个方块以检查该写入者。

写入者通过已验证获取的进度

  • 三阶段提交

  • 对象存储 WAL

  • Pando

  • 等待中

  • 推送中

  • 所有推送已确认

0 秒后

合成教学模型,非生产基准测试。平均推送至获取涵盖 topic 推送,从首次尝试到重试,直到独立读取者验证该提交。它包括等待读取者槽位,不包括构建和测试。启用合并时,写入者完成包括所需的获取和 trunk 合并。

模拟 Git 服务器实现
跟踪每个写入者通过重复推送,并单独测量独立读取者何时可以检出每个提交。

运行此模拟,将 Pando 与另外两种方法进行比较。从商用硬件到高级硬件、客户端数量以及更多设置中选择基础设施,以查看它们的比较。

模拟 Git 服务器实现
跟踪每个写入者通过重复推送,并单独测量独立读取者何时可以检出每个提交。

建模的三阶段提交系统在负载下显示出显著更高的写入延迟,因为其磁盘饱和;同时,预写日志系统出于相同原因显示出显著更高的读取延迟,因为服务器难以跟上从日志读取。在我们 monorepo 上的真实世界测试和此模拟中,Pando 支持数千个客户端能够并发推送,而不会在负载下影响性能。

处理推送时,Pando 在将传入包文件流式传输到 blob 存储的同时进行验证,而不将其解包到本地仓库。当客户端开始获取时,Pando 选择读取者所需的已存储包,读取者下载它们。没有服务器需要将包文件应用到本地仓库或执行昂贵的解包和打包操作。由于这些服务器主要执行协商和包文件选择,Pando 服务器可以在商用硬件上运行。

这使得 Pando 能够扩展到客户端、客户端与 Pando 之间的网络以及 blob 存储服务的极限。有足够容量时,在独立分支上工作的客户端可以各自以自身连接允许的速度传输包。要以该速率服务更多客户端,我们可以添加 Pando 服务器并随之扩展元数据存储和 blob 存储。

流式推送

  • 包文件字节
  • 元数据
  • 请求 / 确认
  • 包文件 URI

在 Pando 中推送和获取分支
Pando 将写入者的包文件存储在 blob 存储中,并在元数据服务中记录引用版本。获取时,Pando 选择读取者所需的包,并由 Express 提供它们。

代理在本地编辑文件并创建提交,然后运行 git push,这会将这些更改打包成包文件以更新分支。Pando 接收该包并将其流式传输到 blob 存储。通过单次解码验证传入的推送后,Pando 使用多键事务在元数据存储中记录引用版本,并原子地更新引用指针。这使 Pando 能够提供“读己之写”一致性。整个写操作需要 O(1)O(1) 次元数据读取和写入。

当另一个客户端获取该分支时,Pando 使用客户端已有的历史选择一组已存储的包。此操作需要 O(log⁡Δ)O(\log \Delta) 次元数据读取,其中 Δ\Delta 是客户端已有内容与客户端所需内容之间引用版本的最短距离。服务器返回包文件 URI,客户端通过一个名为 Express 的服务下载它们,该服务将包文件存储在各区域缓存中。

此设计依赖于 Pando 架构的三个核心服务:推送、获取和压缩。

扩展推送

当客户端推送时,传统 Git 服务器就像一位亮出所有牌的扑克玩家,向客户端展示其拥有的每个分支。然后客户端收集提交、树和 blob 来创建一个包文件,该包文件排除了它知道服务器已有的对象。

公布更多引用可以使推送更小。它也可能使包更难复用,因为另一个客户端可能缺少写入者省略的对象。

Pando 采取了不同的方法。它不展示全部底牌,而是只向客户端公布一组选定的分支。这样做有两个原因:

  1. 为每个引用提供一致视图的成本很高。在拥有数百万个分支的情况下,仅引用公布就可能超过 100 MiB。提供此视图需要每台服务器要么维护当前映射(需要大量协调开销),要么执行随引用数量扩展的数据库查询。
  2. 通过只向客户端提供引用的一个子集,客户端将生成一个仅依赖于服务器明确允许客户端排除的内容的包。跟踪依赖关系使 Pando 能够复用客户端在一次推送中生成的包文件,将其作为后续获取的响应提供。

随着 Git 服务器公布更多引用,推送的客户端有更多来源可以排除对象,从而能够生成更小的包。然而,复用这些包文件需要服务器能够证明获取的客户端拥有每个依赖项。否则,包文件可能会省略客户端需要的对象。

另一方面,如果 Git 服务器完全不公布任何引用,客户端将生成一个包含仓库完整历史的包文件。该包文件可供任何其他客户端使用,但代价是使获取任何分支实际上都变成完整克隆。

曲线图显示公布的引用如何影响包文件大小,从无引用时的完整历史,到公布主干,再到公布所有引用时仅包含新对象。
公布更多引用会导致更小的包文件。大部分大小缩减可以仅通过公布一个引用来实现。

默认情况下,当客户端推送新的功能分支时,Pando 只公布一个指定的主干分支作为基础。这使公布的大小在仓库增加分支时保持不变,但存在权衡。当工程师要么多次推送到单个分支,要么将一个功能分支堆叠在另一个之上时,客户端必须重新发送主干之外的所有内容。

这导致连续的分支推送更大,包含可能冗余的对象,并消耗额外的客户端带宽。在我们的 monorepo 中,以这种方式生成的包文件中位数低于 20 KiB,第 99 百分位数低于 3 MiB。对我们来说,这是一个合理的权衡,因为已经拥有所公布主干历史的读取者可以用那一个包文件获取该分支。

在选择要向客户端公布的引用集后,Pando 接受推送并更新其元数据存储。Pando 将元数据更新分为同步和异步部分。同步元数据更新确保客户端可以立即获取新更新的提交。这会写入一个新的引用版本,并更新引用指针映射到该新版本,存储该新顶端、用于协商包文件的依赖项,以及指向所推送包文件的链接。

同时,异步工作在同一事务中记录,例如调度压缩。对于非常大的推送,只有一部分提交被同步写入。这使我们能够仅要求对元数据存储进行恒定数量的读取和写入,无论推送的大小如何。

重要的是,Pando 服务器可以独立接受对不相关引用的推送。唯一争用的键是引用指针,我们使用类似原子比较并交换的操作来保护它。我们可以通过添加 Pando 服务器、元数据存储扩展单元或 blob 存储来扩展写入吞吐量。

线速获取和克隆性能

当客户端执行获取时,它向服务器提供两组提交 ID,在协议中称为 have 和 want。在传统 Git 服务器中,服务器计算 haves 可达对象与 wants 可达对象之间的集合差,压缩它们,并将结果发送给客户端。虽然开源 Git 服务器在本地有多种索引来加速此操作,但这些包文件的计算成本可能很高,并且通常难以缓存。

相反,Pando 使用推送时记录的提交映射来查找客户端的 have 和 want,将它们解析为包含这些提交的一组引用版本。每个引用版本都链接到 Pando 之前收到的包文件。服务器使用此信息来解决客户端需要什么,选择覆盖客户端所需对象集的包文件。

已持有获取缺失分支依赖

获取 27 个主干包。

选择一个分支以包含其主干依赖项。

在 Pando 中规划获取
Pando 会比较客户端已有的内容和它想要的内容,然后选择所需的已存储 packfile——包括任何缺失的分支依赖项。

Pando 将 Git 协议中的 haves 和 wants 映射为一系列 ref 版本。想要一个侧分支意味着想要它在主干上的依赖项。

在 Pando 中规划获取
Pando 会比较客户端已有的内容和它想要的内容,然后选择所需的已存储 packfile——包括任何缺失的分支依赖项。

Git 协议在克隆或获取时只允许在 HTTP 响应中发送单个 packfile。然而,Git 协议中一个未被充分重视的特性 packfile-uri 允许服务器返回链接以单独下载额外的 pack。Pando 完全使用这一特性,并在内联传输一个空的 packfile。为支持这一特性,OpenAI 向上游 packfile-uri 贡献了多项改进。

OpenAI 的系统在多个地理区域运行,因此我们在每个区域维护带有本地缓存的 Pando 集群。一个 pack 交付服务 Express 缓存我们的 blob 存储,另一个独立服务缓存元数据存储。这些缓存使 Pando 能够完全从该集群的缓存状态中处理对提交 ID 或对 ref 的特殊读取的请求。我们使用一致性哈希将对 packfile 的请求路由到 Express 服务器,缓存命中率超过 99%。

这套缓存系统提供了极高的吞吐量,能够饱和客户端与 Pando 之间的网络上限。使用这些 packfile URI 和本地缓存,我们已验证,仅使用标准云提供商虚拟机,克隆和获取操作即可饱和 10 Gbps 网络链路。

压缩与首字节时间

对于大多数 Git 服务器,重新打包和垃圾回收是维持不断增长的仓库性能所必需的。这两个步骤都能回收空间,但都是计算和 I/O 密集型的。

在像 OpenAI 的 monorepo 这样快速变化的仓库中,为自上次获取以来发生的每次推送都发送单独的 packfile URI 是不可行的。每天,客户端需要下载数万个 packfile 才能赶上其主干。

相反,Pando 在后台合并 pack 以减少这种开销,采用类似于日志结构合并树中压缩的设计。每次推送都会产生 Pando 所称的“L0”packfile,代表 Pando 可以传输的最小数据单元。随着时间的推移,后台工作进程将这些 packfile 收集成跨越一系列 ref 版本的 cover,从 L0 开始按几何级数扩展。如今,我们的 monorepo 使用多达 L4 的 packfile cover。

这些 cover 有双重用途。当一个 cover 足够满时,压缩工作进程会认领它并生成单个 packfile。这通过重新组织 packfile 内部的对象来提高压缩率。但它也通过充当 ref 版本上的跳表来提高获取协商性能。通过同时扫描每一层的索引,Pando 可以在对数时间内选择一组要发送给客户端的 pack。这些 packfile cover,无论是否重新打包,都能为获取规划提供更高效的解决方案,降低 Pando 的吞吐量和延迟要求。

L2

L1

L0

压缩进行中已发布的 cover

Pando 中的压缩
小的 packfile 被分组为跨层级的更大 cover,从而提高压缩率并加快获取速度。

压缩在后台合并来自许多 ref 版本的 packfile,以更少的文件实现更高效的获取。

Pando 中的压缩
小的 packfile 被分组为跨层级的更大 cover,从而提高压缩率并加快获取速度。

与许多 Git 服务器不同,packfile 保留在每一层,不会被丢弃或垃圾回收。每一层的 pack 都用于持续服务客户端,具体取决于它们在获取中请求的 have 和 want。而对于将 Pando 用作 API 的客户端,从 packfile 中读取任意对象时,从 L0 读取的数据往往比从 L4 读取的少,因为它被压缩的可能性较小。

Pando 中的压缩显著减少了发送给客户端的总传输大小,并使 pack 规划能够以亚线性的方式随客户端落后程度扩展。这对于大型且快速变化的仓库尤为重要,我们预计这将成为软件开发的新常态。

构建 Pando

我们用 Codex 构建 Pando,从启动到生产仅用了 10 周。Pando 的核心由一名工程师设计,由多个智能体团队实现,并由少数几名工程师部署。Pando 于今年 4 月开始服务生产流量,是 OpenAI 使用的更大源代码管理工具系统的一部分。

在验证 Pando 的设计时,我们采用了多种方法。我们应用了构建更安全的软件基础⁠的原则。作为一个全新项目,我们选择用 Rust 实现 Pando,且不依赖遗留依赖项。我们编写了一套一致性测试套件,以便智能体可以针对标准 Git 行为测试它们的更改。我们还开发了确定性模拟测试框架,使我们能够检查系统的不变量和属性,从而发现并缓解协商和 pack 规划中微妙的竞态条件和回归。

许多模型为这个系统做出了贡献;随着新模型的推出,我们使用它们来提高 Pando 的性能和可靠性。使用 GPT‑6 Astra,智能体编写了 Lean 和 TLA+ 来形式化一个“Pando 理论”,我们用它来发现并修复实现中的错误,并证明系统的属性。多个连续会话运行了数百小时,寻找并修复 Pando 中的边缘情况。我们还使用具备 Daybreak 能力的模型全天候运行智能体,对 Git 和 Pando 进行红队测试。

部署 Pando

我们最初将 Pando 部署为现有源代码管理系统的读写穿透代理。这使我们能够保持 Pando 与先前系统同步,从而可以安全地将流量迁移到 Pando 而无需停机。

作为其缓存设计的一部分,Pando 还可以在代理之前对请求进行去重和合并。这使我们能够在逐个将系统迁移到 Pando 的过程中,保持现有工具的健康运行。随着我们将更多工作负载迁移到 Pando,它们的推送填充了 Pando 的缓存,并提高了我们先前后端的可靠性。

为了在使用 Pando 时优化性能,我们开始将 Git 的公开发行版分发到开发者机器上以及 ChatGPT 桌面应用内。为了将客户端迁移到 Pando,我们使用了一个围绕 Git CLI 的内部封装,分阶段迁移客户端。我们还创建了许多其他工具,用于处理变更请求、代码审查以及用于自动化的 API。我们迁移了数百个依赖旧版 API、webhook 和仓库下载的集成,并与 Git 流量一同迁移。

Codex 本身也帮助开发者和团队完成了迁移。我们创建了技能,用于准备现有的检出,并引导智能体使用新系统。因此,开发者只需让 Codex 迁移他们的开发环境和服务即可。

切换到 Pando 提高了合并可靠性,并将中位推送的延迟降低了近 80%。

Pando 仅用少量 CPU 核心,每天就能交付 1 PB 的 Git 数据。Pando 曾在单个小时内提供超过 57 TB 的 Git 包,其中超过 99% 的字节来自本地磁盘缓存。

复用包让许多客户端能够以极少的服务节点计算量下载相同的仓库历史。此示例展示的是单个仓库,但 Pando 的设计支持更多仓库。随着需求增长,我们可以独立地扩展数据交付和 Git 协商。

回馈 Git 项目

在 Git 协议和客户端之上构建 Pando,帮助我们发现了 Git 自身性能方面的错误和改进机会。

例如,Pando 直接向客户端提供可复用的包,因此下载并索引这些包成为使系统快速可靠的核心部分。这项工作暴露了并发下载、传输中断后的清理以及恢复已完成的下载中的边缘情况。我们修复了这些路径,在接收现有包用于获取时增加了验证,并将这项工作回馈到上游。

我们还需要了解时间和带宽的去向,因此我们为 packfile-uri 下载添加了跟踪,并为包生成添加了字节计数,从而更容易判断一次缓慢的获取是把时间花在生成包上还是传输包上。

在 OpenAI 的 Git 构建⁠ 中,客户端可以并行下载并索引独立的包。我们添加了“显式 haves”的概念,使服务器可以将分支的当前顶端与推送客户端可能未包含在其包中的历史分开通告。在 Pando 上,这使客户端能够使用 --force-with-lease 检查功能分支是否未移动,并仅基于主干构建其包。任何拥有该主干历史的人随后都可以使用同一个包获取该分支。

其中一些改进已经进入上游 Git,还有许多其他改进在我们的公共仓库中。我们正在继续与 Git 社区合作,使这些改进在 Pando 之外也能发挥作用。你可以在我们的 公共补丁跟踪器⁠ 中关注这项工作。

下一步

通过重新思考关于如何扩展 Git 仓库的传统观念,Pando 使我们能够扩展我们的 monorepo,而无需改变我们的工程师和智能体已经在使用的 Git 工作流。在我们的 monorepo 中,Pando 释放了更大程度的并行性,提高了合并可靠性,并缩短了发布代码变更所需的时间。在我们的 monorepo 之外,我们还在探索 Pando 在 OpenAI 内部的其他用途,包括大量智能体需要在共享 Git 仓库上协作的工作负载。

随着工程师和智能体生产更多软件,OpenAI 之外的更多团队将遇到我们在源代码管理中所遇到的限制。我们目前正在探索与用户分享使 Pando 成为可能的技术的方法。

Pando 建立在 Git 仓库用户和运营者二十年的经验之上。我们将继续开发 Pando,同时与开源 Git 社区合作,分享任何能更广泛地惠及 Git 用户的改进。