Skip to content
全部文章

我们如何在不删减任何检查的情况下加快本地 CI

编程智能体全天向 AnyRouter 推送代码,所以它们都在等 CI。我们把 50 次运行拆成排队、准备和运行三段时间,找到了慢的部分,并在同一个提交上把热启动类型检查从 162 秒降到 79 秒,主要是去掉了一个跑了两次的检查。本文介绍审计方法、改动、数据,以及一段可以交给你自己的智能体的提示词。


智能体全天推送,CI 就是等待

AnyRouter 的大部分改动由编程智能体编写和合并。智能体推送代码,等 CI,修复失败的部分,再推送一次。写代码变快了而 CI 没有变快时,大家等的就是 CI。

我们的 CI 不在 GitHub 上运行。为了省钱,我们关掉了 GitHub Actions 的 CI,所有检查都通过 pnpm local-ci 在一台 16 核、15 GB 内存的机器上运行。很多智能体共用这台机器。本系列第一篇介绍了这个循环。

本文的起点是 Owen Gretzinger 的文章 How I made our CI 20x faster。一句话概括,Owen 审计了 CI 时间花在哪里,然后逐个修复原因,例如运行器慢、lint 工具慢、缓存未命中和测试隔离。文中的一些想法适合我们,一些不适合。下文会逐一说明。

先审计:排队、准备和运行

在做任何改动之前,我们读了最近 50 份本地 CI 日志,时间是 10 月 23 日到 26 日。我们把每次运行拆成三段。

  • 排队。等待机器或锁的时间。平均值是 0 秒。
  • 准备。准备一份干净的仓库副本和依赖的时间。平均值是 52 秒。
  • 运行。检查本身的时间。平均值是 326 秒,中位数是 20 秒,最长的一次约 2,900 秒。

平均值和中位数之间的差距是最有用的数字。一半的运行在 20 秒内完成了检查。少数几次运行一个接一个地对约 20 个应用做类型检查,把平均值拉高了。所以问题不在每一次运行,而在长尾,而长尾就是类型检查。

我们以为慢的地方其实并不慢。Biome lint 约 1 秒。50 次运行中只有 1 次跑了完整的测试套件,因为本地 CI 只运行与改动文件相关的测试。等待机器不花时间。限制在内存,而不在核数。

日志还显示了一些较小的开销。

  • 图片构建步骤在 21 次运行中有 19 次没有命中缓存。每次运行都会删除未被 git 跟踪的文件以便从干净状态开始,这也删掉了约 1,200 张生成的社交卡片图片。所以每次都要重新绘制。
  • 锁文件一有改动,就会为 CI 创建一份新的工作副本,里面没有 TypeScript 构建状态。日志中 6 天内出现了 11 个锁文件键,所有类型检查程序冷启动一次约 5 分钟。
  • 测试的 setup 文件把 Testing Library 和 React DOM 加载进每个测试文件,包括不使用 DOM 的文件。这占了一小批 node 测试 38% 到 42% 的时间。

同一个检查跑了两次

接着我们找到了最大的问题,而它来自一次修复。11 月 1 日,我们修改了根目录的 pnpm typecheck,让它检查每个包和应用。在此之前,一些测试文件里的类型错误可能进入 main。这个改动是对的。但本地 CI 在根步骤之后,仍然分别对 API、server core、UI 和每个应用再做一次类型检查。结果每次运行有 29 个 TypeScript 程序,其中约 26 个被检查了两次,并且全部串行。一次热启动运行要 162 秒。

没有任何检查失败,所以没有任何东西提醒我们。重复的检查是绿色的,只是慢。现在,本地 CI 规划根类型检查时,规划的正是 pnpm typecheck 覆盖的那一组,并且每个程序只运行一次。它从每个包的 package.json 读取这一组,所以新应用会被自动纳入,不需要手写列表。

并行运行,但要给内存做预算

本系列第一篇说过,本地 CI 一次只跑一个检查,因为机器只有 15 GB 内存,承受不了并行构建。对固定数量的任务来说,这是对的。在这台机器上,同时启动三个冷启动的 TypeScript 程序,会在剩余约 1.5 GB 时被内存不足处理程序杀掉。

所以我们没有选一个固定的任务数。一个新脚本先启动最大的程序。只有当下一个程序的内存峰值放得进剩余内存时,它才会启动下一个程序。计算剩余内存时,先减去正在运行的程序还会增长的部分,再保留 2 GB。它从过去的运行中学习每个程序的峰值,没见过的程序按 2.8 GB 估算。

一个程序如果被杀掉而且没有类型错误,就会单独重跑一次。如果再次被杀,这次运行会报告为内存不足,而不是检查失败。看到类型检查失败的智能体会去找类型错误。看到内存不足的智能体知道应该等一等再重试。

热启动运行时,剩余内存始终不低于 2.5 GB。在冷启动并行运行中,没有程序被杀掉。

我们还改了什么

  • 15 个轻量守卫检查,例如 schema 漂移和应用间导入边界,现在每次 4 个并行。它们只读文件,所以并行运行是安全的。重量级检查和测试运行仍然各自持有锁。
  • 有一个守卫会解析每个 TypeScript 文件来查找 issue 引用。现在它会跳过不包含 # 加数字的文件。
  • 新的 CI 工作副本会带上最近使用过的副本的 TypeScript 构建状态和缓存。TypeScript 会重新检查任何改动过的文件,我们也确认了,注入的类型错误在复制来的状态上仍然会让检查失败。
  • 渲染好的社交卡片存放在清理步骤不会删除的缓存里,按路径和源内容做键。14 天未使用的卡片会被删除。
  • Testing Library 只在有 DOM 的测试中加载。
  • pre-push 钩子会对改动的文件运行 Biome,只报告错误。智能体在推送之前就能知道 lint 错误,而不是等 CI 跑完。

下面是数据,每一项都在同一个提交上测量改动前后。每行都注明了运行次数。

检查之前之后变化
类型检查,29 个程序,热启动(3 次的中位数)162 秒79 秒-51%
类型检查,29 个程序,冷启动(各 1 次)402 秒256 秒-36%
15 个守卫(3 次的中位数,机器繁忙)35 秒14 秒-60%
仅 issue 引用守卫(3 次的中位数)13.9 秒5.0 秒-64%
清理后的图片构建(各 2 次)100 秒60 秒-40%
20 个 node 测试文件(3 次的中位数)4.86 秒3.58 秒-26%
12 个 DOM 测试文件(各 2 次)16.5 到 17.6 秒16.7 秒无变化
pre-push Biome,13 个改动文件无0.1 秒新增检查

每个程序只运行一次但仍然串行时,热启动类型检查从 162 秒降到 111 秒。并行运行后降到 79 秒。所以大约 60% 的提升来自去掉重复,其余来自并行。

这些是单项检查的数据。我们还没有测量改动后整次本地 CI 运行的平均时间。接下来的 50 份日志会告诉我们。

空列表可能意味着全部

有一个陷阱值得单独说。本地 CI 只运行与改动文件相关的测试。脚本通常从 git diff 生成这个列表,例如 T=$(git diff --name-only main | grep test),然后运行 vitest run $T。

如果 diff 返回空,例如 git pull 失败、HEAD 没有移动,命令就变成了不带文件的 vitest run。对 Vitest 来说,这意味着运行整个测试套件。在我们的机器上,整个套件有触发内存不足处理程序的风险。我们曾在 600 秒后停掉过这样一次运行。

守卫只需要一行。运行之前先检查列表,写成 [ -n "$T" ] && vitest run $T。Owen 关于 Nx 也说过类似的话。跳过工作的规则需要自己的测试,因为一个失效的跳过要么看起来是绿色,要么会运行全部。

哪些想法适合我们

Owen 的文章列出了九项改动。下面是每一项在我们这里的情况。

想法对我们来说状态
更快的运行器本地 CI 排队 0 秒。但我们在 GitHub 上的发布工作流一直在等一个从未出现的自托管运行器,146 次运行全部被取消。我们把它们改到了 GitHub 的标准运行器。已完成
更快的 lint 工具我们已经在用 Biome,约 1 秒。没有可提升的空间
更快的类型检查器用 Go 编写的 TypeScript 编译器 tsgo。下一步
把测试分片到更大的机器我们只有一台机器,CI 不花钱。增加机器要花钱。不采用
不要为每个测试文件重新加载应用Vitest 的 isolate: false。我们的 DOM 测试没有变快,所以测试启动是要看的地方。下一步
修复准备阶段预先填充的工作副本,以及在清理后仍然保留的卡片缓存。已完成
并行运行检查按内存预算做类型检查,守卫每次 4 个。已完成
只运行改动涉及的部分之前已经这样做了。新增了空列表守卫。已完成
每次推送前先检查pre-push 钩子中只报错误的 Biome。已完成

Owen 的团队花钱买速度。Owen 在文中说 CI 账单从每月 $230 涨到了 $4,300,并认为值得。我们的约束不同。CI 运行在我们已有的机器上,所以我们先找被浪费的时间,再考虑能买到的时间。

给你的编程智能体的提示词

你可以把这段提示词交给自己的编程智能体。它要求做同样的审计,并遵守我们遵守的规则。

在仓库根目录下,粘贴到 Claude Code、Codex 或任何编程智能体中。
审计这个仓库的 CI 并让它更快。绝不删除或放宽任何检查。
1. 拉取最近 50 次 CI 运行。把每个任务的时间拆成排队、准备和运行。
2. 报告每一段的平均值、中位数和最大值。如果平均值远高于中位数,找出造成这一点的慢运行。
3. 列出最慢的 5 个任务,以及每个任务的时间花在哪里。
4. 检查常见原因:运行器排队慢、lint 工具慢、类型检查器慢、测试可用的核数太少、每个测试文件都重新加载应用、缓存未命中或被清理步骤删除、可以并行却在串行的工作、对改动无法触及的代码做检查,以及推送前从不运行的检查。
5. 查找跑了两次的同一个检查,以及被当成"全部运行"的空文件列表。
6. 在内存预算内并行运行。因内存被杀的任务报告为内存不足,而不是检查失败。
7. 每项改动都在同一个提交上测量前后,取 3 次的中位数。不要为了数字好看而跳过测试或降低阈值。
8. 不要包含要花钱的改动(付费运行器、更大的机器、更多分片)。把它们列给人来决定,写明节省的时间和成本。
9. 开一个 PR,附上前后对比表和复现结果的命令。

两分钟发第一笔请求

用自己的 key 免费开始,或充值按 token 计费。Go 每月 $4 额度 — $2/月,或捐一把 provider key 免费开通。

开始使用