AI 辅助的 PR,署的还是你的名字

Read in English

有一个 shader,原来编译要占 11.5 GiB 内存,现在只要 0.66 GiB,编译产物逐字节相同。修复是加 12 行、删 2 行,改在 SPIRV-Tools 里,也就是 Khronos Group 给 Vulkan 所用 shader 格式做的那套工具。PR 开出十二天后被合并。

在这个 PR 存在之前,我一共打了四句话。其余的事是一组 AI agent 做的:找到 bug、写修复、写测试、做测量,然后用我的名字开了 PR。从我发第一条消息算起,前后 23 分钟。

PR 里写了这项工作由 OpenAI Codex 辅助。这篇文章是那句话的展开版:agent 做了什么,哪里做错了,以及留给我的是什么。

我为什么会在那里

索尼一直在让 PlayStation 离光盘越来越远。今年夏天它还宣布,用户买过的 550 多部电影和剧集会从他们的 PlayStation 库里下架。这让我对一类项目产生了兴趣:让人把自己拥有的 PS5 游戏继续跑在 PC 上。

AnyPS5 就是其中一个。它的 README 说,这是一个把 PS5 可执行文件自动移植到 Linux 和 Windows 的工具,不做模拟:把可执行文件重新链接成宿主系统的原生格式,再提供自己实现的系统库。这件事里有一步是把游戏的 shader 重新编译成 SPIR-V,它用 SPIRV-Tools 来做优化。

我的工作是可观测性和数据基础设施。shader 编译器我从来没碰过。

四句话

时间都是 UTC,2026 年 10 月 12 日。

  • 03:30 我问 Codex,AnyPS5 是不是开源的。
  • 03:31 我让它找三到五个我能贡献的 issue。
  • 03:36 它告诉我,有意思的那几个需要我手边没有的硬件。我问:不能用 Docker 测吗?
  • 03:38 我让它放手去提 PR,用 Docker 或者任何它能用的办法测试。

它筛出来的 issue 里有一个内存问题:打开 shader 优化之后,《Demon’s Souls》启动的头 26 秒里内存涨到了约 16.6 GiB。

之后它就自己干了。一个主 agent 起了三个子 agent:一个在 Docker 里搭 Linux 构建环境,一个去查优化器,还有一个先去看了另一个 issue,后来被改用作评审。

  • 03:44 一个约 2 MB 的合成 shader 把优化器内存推到了约 4.7 GB。agent 的第一个猜测是输入被校验了两遍,但这解释不了问题:去掉重复校验之后,峰值没有变。
  • 03:47 它把内存增长定位到 SPIRV-Tools 里的一个 pass:冗余消除。
  • 03:50 修复通过了上游全部 3,794 个优化器测试,外加评审 agent 写的一个新回归测试。
  • 03:53 PR 开出来了。

哪里错了

冗余消除会沿着每个函数的 dominator 树走一遍,把结果已经可用的计算删掉。为了知道哪些结果可用,每个 block 都带着一张表,从 value number 映射到持有它的 ID。一个 block 从父节点的表开始,再加上自己的条目。

负责遍历的循环是这样的:

for (size_t next_node = 0; next_node < todo.size(); next_node++) {
  modified |= EliminateRedundanciesInBB(todo[next_node].node->bb_, vnTable,
                                        &todo[next_node].value_to_id_map);
  for (DominatorTreeNode* child : todo[next_node].node->children_) {
    todo.push_back({child, todo[next_node].value_to_id_map});
  }
}

todo 是一个只增不减的工作列表。每个子节点入队时都带着父节点那张表的完整拷贝,而 block 处理完之后什么都不会被移除。每一张表都活到函数返回为止。

如果是一条很长的 block 链,每个 block 又各加一个新值,累积起来就很可观:

block 0   表里 1 项     留着
block 1   表里 2 项     留着
block 2   表里 3 项     留着
...
block n   表里 n 项     留着

处理一个只有 n 个值的函数,同时持有的条目大约是 n²/2。

修复

const auto& children = todo[next_node].node->children_;
for (size_t child_index = 0; child_index < children.size(); ++child_index) {
  if (child_index + 1 == children.size()) {
    todo.push_back({children[child_index],
                    std::move(todo[next_node].value_to_id_map)});
  } else {
    todo.push_back(
        {children[child_index], todo[next_node].value_to_id_map});
  }
}
todo[next_node].value_to_id_map.clear();

最后一个子节点用 move 接手父节点的表。前面的子节点仍然拿拷贝,因为每棵兄弟子树必须保有自己的状态。然后清空父节点那一项。遍历顺序不变。在一条链上,同时存活的表从 n 张变成了一张。

在一条合成的 block 链上,这个 pass 的内存峰值
  • 修复前
  • 修复后
4,096 个 block
修复前
402 MiB
修复后
17 MiB
8,192 个 block
修复前
1,572 MiB
修复后
30 MiB

峰值常驻内存,三次独立进程运行的中位数。macOS arm64,AppleClang 21,Release 构建。来源:PR 描述。

block 数 修复前峰值 修复后峰值 修复前耗时 修复后耗时
4,096 402.47 MiB 16.92 MiB 0.17 s 0.02 s
8,192 1,572.44 MiB 29.88 MiB 0.62 s 0.06 s

输入翻倍,旧代码的内存涨到约 3.9 倍,新代码约 1.8 倍。

agent 拿出来的证据

这个修复,我没法靠读代码来判断对不对。我能判断的是随它一起交上来的证据。这也是我会向任何一个 agent、任何一个人要的东西。

  • 一个针对“这个修复可能怎么错”的回归测试。 move 一张表的风险是:某个 block 丢了本该继承的值,或者两个兄弟节点共用了状态。测试构造了一个父 block、两个分支和一个汇合 block,两种情况都检查。
  • 一次变异,证明这个测试会失败。 评审 agent 把修复改成把表 move 给第一个子节点,而不是最后一个。测试失败了。
  • 输出完全一致。 新旧两个构建产出的模块逐字节相同,并且都通过校验器。这个改动本来就只该影响内存。
  • 同一个补丁放进真实的使用方里。 打在 AnyPS5 锁定的那个 SPIRV-Tools 版本上,Ubuntu 24.04 的 Docker、GCC 13.3,一个合成 shader 走完 AnyPS5 完整的优化封装,从 437.95 MiB 降到 41.70 MiB。
  • 一句直白的话,说明什么没有被证明。 PR 里写着:“The reported game workload has not been reproduced, so its resolution remains unconfirmed.”

agent 错在哪里

它说测不了。 它最初的回答是,那几个好 issue 需要我没有配好的硬件和平台。这话说过头了,我问了一句它就承认了:项目自己的 CI 用的是跑在 CPU 上的 Vulkan 渲染器,所以 Docker 完全可以。

它的第一个猜测是错的。 它在“输入被重复校验”上花了几分钟。它之所以放弃这个猜测,是因为它去测了,而数字没有动。

它没跑项目的格式化工具。 维护者在评审里只提了一个要求:“Please try to fix the formatting.”

它签不了贡献者协议。 这件事需要一个人。

留给我的

五件事,没有一件是写代码。

  1. 决定把它指向哪里。 项目是我选的,理由也是我的。
  2. 不接受“做不了”。 就是关于 Docker 的那一问。
  3. 要真实的证据。 第二天我要求用一个真实游戏的 shader 再测一遍,不要合成的。agent 找到了一个公开发布的自制游戏 MedievalTennis,测了它内嵌的 shader:最高的编译峰值从 695.80 MiB 降到 94.84 MiB,所有编译成功的输出逐字节一致。原始记录在这个 gist 里。
  4. 让修复不用等就能到用户手上。 我问能不能让 AnyPS5 直接带上这个补丁。这成了第二个 PR,一天后合并。
  5. 弄懂我签了什么。 PR 已经开出去了,我才坐下来让 agent 给我讲清楚:这个 pass 是干什么的,那张表是做什么用的,旧代码为什么会把每份拷贝都留着。凡是用我的名字发出去的东西,我都应该能为它辩护。等到维护者来看的时候,我已经能了。

另一边的人和 agent

那份 bug 报告的第一行是:“Posted by an AI agent on behalf of Tyler.”

一位我之前没有合作过的贡献者,在 Windows 11 上回放了一个从《Astro Bot》里抓下来的 shader。内存峰值从 11.516 GiB 降到 0.663 GiB,输出一致。这就是本文开头的那个数字。他的留言里说,他的测试也是 Codex 辅助的。

下游补丁合并的第二天,最初的报告人回来了。在保存下来的《Demon’s Souls》shader 模块上,最大的那个在打补丁之前会在超过 16,270 MiB 时耗尽内存,打了之后在 2,796 MiB 跑完。他没有重新跑游戏本身,并且说明了这一点。

所以是一个 agent 报了 bug,几个 agent 修了它,又有 agent 在三个人的机器上测了这个修复。环节里的人,是我们三个给 agent 指方向的,和批准并合并它的维护者。

评审

一位 SPIRV-Tools 的维护者在第二天批准了 PR,并要求修格式。CI 比评审吵得多:两个 macOS 任务挂在下载构建工具上,两个下游冒烟测试失败,失败项和未改动的基线分支一样。agent 把这些整理出来,并注明这不是受控对照。一位维护者回复 “You can ignore the smoketests”,重启了另外两个任务,PR 当天合并。

给要提 AI 辅助 PR 的人的三句话

  1. 你对代码越不熟,欠的证据就越多。 agent 让我有可能去改一个编译器 pass,但它没有让我变成一个维护者应该直接相信的人。
  2. 写下你的证据到哪里为止。 “没有复现”“不是受控对照”打出来不花什么力气,却是评审者最先看的地方。
  3. 说明你用了 AI。 维护者反正会猜。

这个修复只有十二行,而且不是我写的。但 PR 上署的还是我的名字。这一部分,没法委托出去。

来源