OpenAI

2026年11月2日

工程

OpenAI 如何扩展 Git

构建适应智能体工作规模的源码控制系统,同时持续投入 Git 生态。

作者:技术团队成员 Aaron Friel、Taylor Blau 和 Ted Nyman

正在加载…

过去一年,我们单体代码仓库的规模和承载需求都以惊人的速度增长。显然,我们需要改造源码控制系统,才能支持 Codex 带来的并发工作规模。

我们构建了全新的 Git 代码仓库后端 Pando,期间没有中断任何工程工作,也没有改变团队使用 Git 的方式。如今,OpenAI 单体代码仓库中的所有开发工作都由 Pando 支撑。Pando 的设计旨在实现两个雄心勃勃的目标:第一,让写入能力能够水平扩展,并针对向大量分支并发推送进行优化。第二,最大化读取吞吐量,让客户端的数据下载速度足以用满可用网络带宽。

我们在保持与 Git 协议和客户端兼容的同时实现了这些目标,让工程师和智能体都能在现有工作流中无缝采用 Pando。在生产环境中,Pando 将推送延迟的中位数降至原来的约五分之一。如今,它每天分发近 1 PB 的 Git 数据。

本文将介绍我们构建新源码控制系统的动机,以及为什么选择保留与 Git 的兼容性。我们将把这一系统与常见的 Git 扩展方案进行比较,说明 Pando 为何能够以独特的方式满足 OpenAI 的可扩展性需求。我们还将讨论未来方向,以及如何继续为 Git 做出贡献,以支持智能体带来的工作规模。

为什么需要新的源码控制系统

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

这些需求还会相互叠加。现在,一名工程师可以同时准备好多个变更进入 CI,而每次推送都可能触发多个任务去获取同一个提交。

大约从去年年底开始,支撑单体代码仓库的系统逐渐难以跟上需求。获取和推送变得缓慢,合并也会失败,可靠性一度跌至 90% 以下。我们触及的究竟是 Git 本身的极限,还是单体代码仓库服务方式的极限?Git 起源于一个通过电子邮件交换内核补丁的社区。二十多年来,随着软件构建方式不断变化,开发者也一直围绕 Git 调整自己的工具。当我们自己的单体代码仓库开始遇到阻力时,能否在这些积累之上继续发展,对我们尤为重要。

我们看到了这样一条路径:保留熟悉的工作流,同时重建后端,使其能够并行处理不同分支的推送,并同时向大量读取端提供相同的历史。以 Git 作为源码控制工作流的基础,也意味着对客户端的改进可以惠及 OpenAI 之外的用户。我们希望将自己维护的 Git 版本⁠(在新窗口中打开)中的这些改进贡献给上游,与更广泛的社区共享。

面向大规模 Git 的工程设计

扩展源码控制系统主要涉及两个维度:大量写入端向不同分支推送,以及大量读取端获取相同历史。Pando 对两者都进行了优化。

首先,Pando 独立协调每个分支的更新。互不相关的分支可以并行更新,而读取端获取某个分支时,会获得该版本所需的历史。代价是,读取端查看多个分支时,看到的可能是它们在不同时间点的状态。

其次,Pando 会在不同获取操作之间复用包文件(Git 用于将多个对象一起压缩的格式),而不是为每个客户端单独生成包文件。客户端可能会下载一些已有的对象,但服务器只需极少的计算,就能向大量客户端提供同一份数据。

这些设计让我们能够跨分支扩展写入能力,并通过缓存提供读取服务。要理解这些设计如何协同运作,先来看看 Git 如何存储和传输历史。

Git 对象存储入门

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

Git 代码仓库以对象图的形式记录文件快照,以及连接这些快照的历史。对象可以是 blob、树、提交或标签。blob 保存单个文件的内容,树则将名称与 blob 或其他树关联起来,构成目录结构。提交指向一棵根树以及零个或多个父提交,由此构成代码仓库的历史。

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

分支和标签都是指向提交的引用,也称为“refs”。在分支上提交时,会创建一个新提交,并将该分支的引用更新为指向这个提交。

分支 refs/heads/main 指向提交 C2,C2 的父提交是 C1。C1 指向根树 T1,其中 parser.py 指向 blob B1。C2 指向根树 T2,其中 parser.py 指向 blob B2。在两棵根树中,docs/ 都指向同一棵树 T3。T3 中的 README.md 条目指向 blob B3,usage.md 指向 blob B4。

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

Git 以“松散”或“打包”状态存储对象。松散对象存放在各自独立的文件中,由对象 ID 标识。打包对象一起存储在包文件中,从而可以将相似对象存储为彼此之间的增量差异。

$GIT_DIR/objects 下显示了 parser.py 的两个版本。作为松散对象,blob A 包含 parse(),blob B 同时包含 parse() 和 parse_file()。包文件完整存储 A,并将 B 表示为一组指令:复制 A 的全部内容,再追加 parse_file()。配套的 *.idx 文件通过对象 ID 定位包文件中的两个 blob。

Git 可以将 blob 存储为独立文件,也可以将它们打包在一起,把一个 blob 编码为相对于另一个 blob 的增量差异。

在 Git 中,如果从某个提交出发,沿着父提交、树和 blob 之间的链接可以找到一个对象,那么这个对象就是从该提交可达的。获取过程中,客户端会告诉服务器自己需要哪些提交,以及已经拥有哪些提交。服务器找到所需提交可达的对象,排除已知客户端拥有的对象,再将其余对象以包文件形式发送出去,格式与 Git 在磁盘上使用的格式相同。完整克隆开始时没有任何对象,因此需要获取被克隆历史中所有可达的对象。

推送则沿相反方向传输对象,并请求服务器更新一个或多个引用。客户端利用服务器通告的一组引用,避免发送服务器已有的对象。因此,发送的包可能依赖已有对象:仅接收这个包,并不足以重建新的历史。

复制 Git 代码仓库,意味着要让对象和引用在多台服务器上都保持可用。副本在能够对外提供某个引用的新值之前,必须先能提供从该值可达的对象。服务还需要协调相互竞争的引用更新,也就是那些试图将同一分支从同一个旧分支头更新到不同新提交的推送。如果系统对代码仓库的所有写入都强制采用统一顺序,那么向互不相关分支的推送也可能需要相互等待。

现有分布式 Git 服务模式

设计 Pando 时,我们评估了扩展 Git 源码控制能力的传统方案:一种使用三阶段提交(3PC,一种跨多个节点协调事务提交的协议)来协调代码仓库副本之间的引用更新;另一种通过 blob 存储中的共享预写日志(WAL,在变更生效前先写入的变更记录)对更新进行排序。

三阶段提交(3PC)

在我们建模的三阶段提交设计中,Git 服务在多个副本节点上保留代码仓库的完整副本。当任意服务器收到推送时,传入的包文件会被复制,并并行发送给所有副本。要使推送生效,必须有多数副本接受该引用更新。负责这一过程的服务器是协调者,它需要与其他各个副本进行多轮通信。

为接受引用更新,每个副本都会锁定自己的引用副本,并检查它是否仍指向客户端预期的提交。随后,协调者向接受更新的副本发送预提交消息。一旦多数副本确认了预提交消息,协调者就会通知它们执行更新。此后,任何已更新的副本都可以根据本地代码仓库副本通告新的分支头。

保留完整副本可以让读取在本地完成,并确保客户端无论选择哪个副本,都能获得所有引用的一致视图。相应地,引用只能逐个更新,而且每次推送都需要多轮往返通信,才能协调各副本之间的引用更新。

由于需要等待法定多数副本响应,副本磁盘性能和通信延迟也就成了推送路径上的制约因素。随着写入量增加,各副本既要跟上传入包的速度,也要应对读取流量。

预写日志 (WAL)

在我们建模的 WAL 设计中,Git 服务使用 blob 存储中的共享清单,对整个代码仓库的更新进行排序。服务器收到推送后,会将传入的包文件上传到 blob 存储,并在本地代码仓库副本中准备引用更新。为使推送生效,它会在 blob 存储中的共享清单里添加一个条目,记录引用更新并指向对应的包文件。随后,Git 服务器使用比较并交换操作更新清单。该操作要么以原子方式替换清单,要么拒绝此次变更。如果期间有其他写入端更新了清单,服务器就会读取其间新增的条目,检查自身的引用更新是否仍然有效,并使用已上传的包文件重试。

写入端可以并行上传包文件,但即使推送到互不相关的分支,也需要更新同一份清单。一次成功的比较并交换操作所需的往返时间,限制了代码仓库发布新清单的频率。低延迟的 blob 存储可以加快这一步。批处理允许一次清单更新同时更新多个引用,代价是更新需要排队,延迟随之增加。

blob 存储可以并行接收包文件,但对共享清单的更新仍然会相互争用。仅增加 blob 存储的容量,无法消除这一瓶颈。每个副本仍需先从 blob 存储读取包并构建索引,才能在本地提供这些包。如果新写入的速度超过这项工作的处理速度,副本就会落后,客户端也需要等待更久才能获取刚推送的提交。

Pando 的架构设计

这就引出了 Pando。Pando 是一种为 Git 代码仓库提供服务的全新架构,目标是扩展到支持大量客户端同时推送。它的工作原理如下:

重新审视约束条件

在开发 Pando 之前,我们就隐约觉得,无需放弃 Git 也能满足需求。但对于大多数 Git 系统视为必需的一些要求,我们确实需要做出取舍:

  • 代码仓库全局一致性:要提供所有引用在同一时间点的一致快照,就需要在整个代码仓库范围内进行协调。在对所有引用更新统一排序的系统中,向互不相关的分支推送也会争用同一个协调点。

  • 为每个客户端生成专属包文件:如果两个本地历史不同的客户端获取同一个分支,服务器可能需要分别生成不同的包,以免发送客户端已有的对象。生成这些响应会消耗 CPU 和磁盘 I/O 资源,因此难以扩展到大量读取端。

Pando 则采用了更宽松的一致性模型(因果一致性)来扩展写入能力,并通过复用包文件来扩展读取能力。

因果一致性 (CC)

当客户端向分支推送时,Pando 会将传入的包文件作为不可变的 blob 保存到 blob 存储中。每次分支指向发生变化时,Pando 都会在元数据存储中记录一个引用版本,其中包含新的分支头、提供该分支头的包,以及该包所依赖的所有先前引用版本。blob 存储和元数据存储均可水平扩展。

从 Pando 获取提交或引用时,需要向客户端重放一组引用版本(包文件),以重建创建所获取引用版本的客户端当时的状态。这为整个代码仓库提供了较弱的一致性,同时保证了单个引用更新的可串行化。客户端只有在同时向同一个引用推送时才会发生冲突。客户端读取多个分支时,不能假定这些读取结果反映的是代码仓库在同一时间点的状态。

左侧,三个连续的主干包分别包含提交 A、B、C;D、E;以及 F。右侧,主干与主题分支在 C 处分叉。主干推进到 G,而主题分支依次推进到 D、E 和 F。最新的主题分支包包含 D、E 和 F。虚线轮廓标明 D 和 E 也存在于此前的主题分支包中。蓝色箭头标明新包对主干 G 版本的依赖。F 的 Git 父提交是 E。

向主干推送会新增一个增量包,其中包含一次或多次提交。每次向侧分支推送时,都会再次包含该主题分支此前的提交,因此,拥有已通告主干版本的客户端只需获取一个包,就能获取该主题分支。

这是一种因果一致性。为了获取某个引用,读取端会看到生成该引用的写入端所经历的历史。推送过程中,Pando 会控制客户端与服务器协商确认的共同历史,确保客户端生成的包文件更易于共享。

因此,Pando 在 Git 操作期间不提供所有引用在同一时间点的状态,也不保证一次克隆或获取操作内各引用更新的顺序。具体来说,假设在一次完整克隆的协商过程中,分支 foo 和 bar 都发生了更新。在协商过程中,一个客户端可能看到 foo 为版本 1、bar 为版本 2,而另一个客户端看到 foo 为版本 2、bar 为版本 1。实际使用中,用户和智能体并不依赖引用更新的全局顺序,而是依赖单个引用的更新顺序。

方案对比

在这项针对三种一致性模型的模拟中,我们配置了 1,000 个客户端,让它们各自选择一个不同的分支,然后反复推送各自独有的提交,并测量实际总耗时和从推送到获取的延迟。后者是衡量工作负载的重要指标,因为它反映了用户和智能体协作,或使用其他依赖版本控制的系统时所需的往返时间。模拟涵盖了从普通配置到高端配置的虚拟机和 blob 存储系统。在这项模拟中,我们放宽了三阶段提交对代码仓库全局一致性的要求。

模拟 Git 服务器实现

跟踪每个写入端反复推送的过程,并单独测量独立读取端何时能够检出每次提交。

三阶段提交

就绪
模拟时间
0 s
推送到抓取的平均耗时
—
已完成的写入端
0 / 1,000

每个方块代表一个写入端。所有选定的架构运行相同的工作负载。随着推送得到确认,方块颜色逐渐变浅;写入端完成后,方块变为蓝色。图表显示已完成的写入端数量随模拟时间的变化;独立读取端之后仍可继续抓取。 使用方向键选择写入端,按 Home 或 End 键跳转,按 Escape 键清除选择。

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

选择一个方块以查看对应的写入端。

对象存储预写日志(WAL)

就绪
模拟时间
0 s
推送到抓取的平均耗时
—
已完成的写入端
0 / 1,000

每个方块代表一个写入端。所有选定的架构运行相同的工作负载。随着推送得到确认,方块颜色逐渐变浅;写入端完成后,方块变为蓝色。图表显示已完成的写入端数量随模拟时间的变化;独立读取端之后仍可继续抓取。 使用方向键选择写入端,按 Home 或 End 键跳转,按 Escape 键清除选择。

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

选择一个方块以查看对应的写入端。

Pando

就绪
模拟时间
0 s
推送到抓取的平均耗时
—
已完成的写入端
0 / 1,000

每个方块代表一个写入端。所有选定的架构运行相同的工作负载。随着推送得到确认,方块颜色逐渐变浅;写入端完成后,方块变为蓝色。图表显示已完成的写入端数量随模拟时间的变化;独立读取端之后仍可继续抓取。 使用方向键选择写入端,按 Home 或 End 键跳转,按 Escape 键清除选择。

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

选择一个方块以查看对应的写入端。

写入端进度(直至抓取验证完成)

  • 三阶段提交
  • 对象存储预写日志(WAL)
  • Pando
05001,00000 s
  • 等待中
  • 推送中
  • 所有推送均已确认
0 s较晚

这些是用于教学的模拟模型,并非生产环境基准测试。推送到抓取的平均耗时针对主题分支推送计算,从首次尝试开始,涵盖重试过程,直至独立读取端验证该提交。该耗时包含等待读取端空闲名额的时间,不含构建和测试时间。启用合并后,写入端需完成必要的抓取和主干合并才算完成。

实验设置

正在准备模拟…

结果

播放对比演示,查看记录的结果。

这些是用于教学的模拟模型,并非生产环境基准测试。推送到抓取的平均耗时针对主题分支推送计算,从首次尝试开始,涵盖重试过程,直至独立读取端验证该提交。该耗时包含等待读取端空闲名额的时间,不含构建和测试时间。启用合并后,写入端需完成必要的抓取和主干合并才算完成。

运行这项模拟,比较 Pando 与另外两种方案。选择从普通到高端硬件的基础设施配置、客户端数量及其他设置,查看它们的对比结果。

模拟中的三阶段提交系统在负载下因磁盘达到饱和而出现显著更高的写入延迟;与此同时,预写日志系统也因同样的原因出现显著更高的读取延迟,因为服务器难以及时读取日志。无论是在我们的单体代码仓库实测中,还是在这项模拟中,Pando 都能支持数千乃至更多客户端并发推送,而不影响负载下的性能。

处理推送时,Pando 一边将传入的包文件流式写入 blob 存储,一边进行验证,无需将其解包到本地代码仓库。当客户端开始获取时,Pando 会选出读取端所需的已存储包,再由读取端下载。任何服务器都不需要将包文件应用到本地代码仓库,也不需要执行开销高昂的解包和打包操作。由于 Pando 服务器主要执行协商和包文件选择,因此可以在普通硬件上运行。

这使得 Pando 能够持续扩展,直到达到客户端、客户端与 Pando 之间的网络或 blob 存储服务的容量上限。只要容量充足,在独立分支上工作的客户端就都能以各自连接允许的最高速度传输包。要以这样的速率服务更多客户端,我们可以增加 Pando 服务器,并同步扩展元数据存储和 blob 存储。

在 Pando 中推送和抓取分支

Pando 将写入端的打包文件存入 Blob 存储,并在元数据服务中记录引用版本。抓取时,Pando 选出读取端所需的包,再由 Express 提供这些包。

流式传输推送数据

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

智能体在本地编辑文件并创建提交,然后运行 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 只通告一个指定的主干分支作为基础。这样,即使代码仓库的分支不断增加,通告大小也保持不变,但这同样需要取舍。当工程师多次向同一分支推送,或在一个功能分支之上叠加另一个功能分支时,客户端必须重新发送主干之外的所有内容。

这会让后续的分支推送变大,其中可能包含冗余对象,并消耗额外的客户端带宽。在我们的单体代码仓库中,以这种方式生成的包文件大小中位数低于 20 KiB,第 99 百分位数低于 3 MiB。对我们而言,这是合理的取舍,因为已经拥有通告主干历史的读取端,只需这一个包文件就能获取该分支。

选定要向客户端通告的一组引用后,Pando 会接受推送并更新元数据存储。Pando 将元数据更新分为同步和异步两部分。同步元数据更新确保客户端能够立即获取刚刚更新的提交。这一步会写入一个新的引用版本,并将引用指针映射更新为指向该版本,同时存储新的分支头、协商包文件时使用的依赖关系,以及指向已推送包文件的链接。

与此同时,调度合并压缩等异步工作也会记录在同一个事务中。对于规模很大的推送,只有部分提交会被同步写入。这样,无论推送有多大,对元数据存储的读写次数都可以保持为常数。

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

达到线速的获取与克隆性能

客户端执行获取操作时,会向服务器提供两组提交 ID,在协议中分别称为 have 和 want。传统 Git 服务器会计算所需提交 (want) 可达对象与已有提交 (have) 可达对象之间的差集,将其压缩后发送给客户端。虽然开源 Git 服务器在本地维护了多种索引来加速这一操作,但生成这些包文件的计算成本可能很高,而且通常难以有效缓存。

Pando 则利用推送时记录的提交映射,查找客户端的 have 和 want,将其解析为一组包含这些提交的引用版本。每个引用版本都关联到 Pando 此前接收过的一个包文件。服务器据此确定客户端所需的内容,选出能够覆盖客户端所需对象集合的包文件。

在 Pando 中规划抓取

Pando 比较客户端已有和所需的内容,然后选出所需的已存储打包文件,包括所有缺失的分支依赖。

已拥有抓取缺失的分支依赖

抓取 27 个主干包。

选择一个分支,将其主干依赖纳入其中。

Pando 将 Git 协议中的 have 和 want 映射为一系列引用版本。需要某个侧分支,也就意味着需要获取主干历史,直至该分支所依赖的主干版本。

Git 协议只允许在克隆或获取操作的 HTTP 响应中发送一个包文件。不过,Git 协议中一个未获充分重视的功能 packfile-uri,允许服务器返回链接,以便单独下载其他包。Pando 完全采用这种方式,响应正文中仅传输一个空包文件。为支持这一功能,OpenAI 向上游贡献了多项针对 packfile-uri 的改进。

OpenAI 的系统运行在多个地理区域,因此我们在每个区域都维护了带有本地缓存的 Pando 集群。包分发服务 Express 为 blob 存储提供缓存,另一个独立服务则为元数据存储提供缓存。借助这些缓存,Pando 可以完全利用该集群的缓存状态,响应针对提交 ID 的请求,或对引用的特殊读取请求。我们使用一致性哈希,将包文件请求路由到 Express 服务器,缓存命中率超过 99%。

这套缓存系统提供了极高的吞吐量,能够用满客户端与 Pando 之间的网络带宽。我们已经验证,借助这些包文件 URI 和本地缓存,即使只使用云服务商的标准虚拟机,克隆和获取操作也能用满 10 Gbps 网络链路。

合并压缩与首字节时间

对大多数 Git 服务器而言,随着代码仓库不断增长,重新打包和垃圾回收是维持性能的必要操作。这两个步骤都能回收空间,但也都需要大量计算和 I/O 资源。

对于 OpenAI 单体代码仓库这样更新频繁的代码仓库,为上次获取之后发生的每一次推送都发送一个独立的包文件 URI,是不可行的。否则,客户端每天都需要下载数万个包文件,才能让本地主干跟上最新进度。

为降低这项开销,Pando 会在后台合并包,采用类似日志结构合并树中合并压缩的设计。每次推送都会生成一个 Pando 所称的“L0”包文件,这是 Pando 能够传输的最小数据单元。随着时间推移,后台工作进程会将这些包文件归集为覆盖一系列引用版本的覆盖集合,其规模从 L0 开始按几何级数增长。目前,我们的单体代码仓库使用的包文件覆盖集合最高达到 L4。

这些覆盖集合有两个作用。当覆盖集合积累到足够多的内容后,合并压缩工作进程会领取它,并生成一个包文件。通过重新组织包文件中的对象,可以提高压缩率。同时,这些覆盖集合还能充当引用版本上的跳表,改善获取协商的性能。通过同时扫描各层索引,Pando 能够在对数时间内选出要发送给客户端的一组包。无论是否已经重新打包,这些包文件覆盖集合都能让获取规划更高效,降低 Pando 对吞吐量和延迟的要求。

Pando 中的压缩

小型打包文件逐层归并为更大的覆盖包,从而提高压缩率并加快抓取速度。

压缩中已发布的覆盖包

合并压缩会在后台合并多个引用版本的包文件,让客户端用更少的文件完成更高效的获取。

与许多 Git 服务器不同,Pando 会保留每一层的包文件,不会将其丢弃或作为垃圾回收。根据客户端在获取请求中提供的 have 和 want,每一层的包都会持续用于向客户端提供服务。对于通过 API 使用 Pando 的客户端,从包文件中读取任意对象时,从 L0 读取通常比从 L4 读取所需的数据更少,因为该对象被压缩的可能性更低。

Pando 的合并压缩机制既能大幅减少向客户端传输的总数据量,又能让包规划开销随客户端落后程度的增加而呈次线性增长。这对大型且快速变化的代码仓库尤其重要,而我们预计这类代码仓库将成为软件开发的新常态。

构建 Pando

我们使用 Codex 构建 Pando,从项目启动到投入生产,仅用了 10 周。Pando 的核心由一名工程师设计,由多个智能体团队实现,再由少数几位工程师部署。Pando 于今年 4 月开始承接生产流量,是 OpenAI 所使用的更大规模源码控制管理工具体系的一部分。

在验证 Pando 的设计时,我们采用了多种方法。我们践行了构建更安全软件基础⁠的原则。作为一个从零开始的项目,我们选择使用 Rust 实现 Pando,不引入遗留依赖。我们编写了一套一致性测试,让智能体能够对照标准 Git 行为测试自己的变更。我们还开发了确定性模拟测试执行框架,用于检查系统的不变量和属性,从而发现并缓解协商与包规划中隐蔽的竞态条件和回归问题。

许多模型都为这个系统做出了贡献;每当新模型推出,我们都会用它们来改善 Pando 的性能和可靠性。借助 GPT‑6 Astra,智能体编写了 Lean 和 TLA+ 形式化描述,建立了一套“Pando 理论”。我们用它来发现并修复实现中的缺陷,以及证明系统属性。多个会话持续运行了数百小时,不断发现并修复 Pando 的边界情况问题。我们还让智能体全天候运行,使用具备 Daybreak 能力的模型对 Git 和 Pando 开展红队测试。

部署 Pando

最初部署时,Pando 作为现有源码控制系统的读穿透、写直达代理运行。这让 Pando 与原有系统保持同步,使我们能够在不停机的情况下,安全地将流量迁移到 Pando。

作为缓存设计的一部分,Pando 还可以在代理转发前对请求进行去重和合并。这样,在将各个系统逐一迁移到 Pando 的过程中,我们也能保持现有工具正常运行。随着更多工作负载迁移到 Pando,它们的推送不断充实 Pando 的缓存,也提高了原有后端的可靠性。

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

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 版本⁠(在新窗口中打开)中,客户端可以并行下载彼此独立的包,并为它们构建索引。我们引入了“显式 have”的概念,让服务器可以分别通告分支的当前头提交,以及推送客户端可以从包中省略的历史。在 Pando 上,这让客户端能够使用 --force-with-lease 检查功能分支是否未发生变化,同时仅以主干为基础构建包。随后,任何拥有该主干历史的客户端都能使用同一个包获取该分支。

其中一些改进已经进入上游 Git,另有许多改进已发布在我们的公开代码仓库中。我们将继续与 Git 社区合作,让这些改进在 Pando 之外也能发挥价值。你可以通过我们的公开补丁跟踪器⁠(在新窗口中打开)关注这项工作。

未来方向

通过重新审视关于 Git 代码仓库扩展的传统观念,Pando 让我们能够在不改变工程师和智能体现有 Git 工作流的前提下,扩展单体代码仓库。在我们的单体代码仓库中,Pando 提高了并行度和合并可靠性,并缩短了发布代码变更所需的时间。除了单体代码仓库,我们也在探索 Pando 在 OpenAI 内部的其他用途,包括需要大量智能体在共享 Git 代码仓库中协作的工作负载。

随着工程师和智能体开发出更多软件,OpenAI 之外的更多团队也将遇到我们在源码控制方面碰到的瓶颈。我们正在探索如何将支撑 Pando 的技术分享给用户。

Pando 建立在 Git 代码仓库用户和运维人员二十年来积累的经验之上。我们将继续开发 Pando,同时与 Git 开源社区合作,分享能够惠及更广泛 Git 用户的改进。

作者

OpenAI