Sankalp 在 GPU Mode 的 qr_v2 竞赛里拿了第 12 名(183 人参赛),相对 baseline 提速 232 倍。他八月初把过程写成了《Auto-research with codex》。我看完的第一反应不是"232 倍很猛",而是这个数字背后的方法:他没有手写汇编,而是把"提出问题—验证—收敛"这个研究循环本身工程化了,然后让 Codex 在这个循环里跑。

这件事值得单独记一笔,因为它划清了 AI 辅助编程的边界:在你无法快速打分的问题上,模型只会给你看起来合理的代码;在你能几秒出分的问题上,模型能真的把性能推上去。

问题本身:compact Householder QR

竞赛要求做 QR 分解,但输出不是显式的 Q 和 R。它要的是 compact Householder 形式:只输出 Householder 向量和标量,让检查器自己重建 Q,并从结果矩阵的上三角读出 R。

这个约束很关键。显式构造 Q 意味着你要把一堆反射矩阵乘起来,得到一个稠密矩阵,再算 Q 乘以某个向量。compact 形式把这步省了——Q 永远不需要落地成矩阵,它可以一直保持为"一连串反射"的隐式表示,作用到向量上是逐次 Householder 变换。显存和 flop 都省一大截。懂算法的人一眼就知道该往哪走,不懂的人会老老实实构造 Q 然后被时间打爆。

矩阵尺寸集中在 512×512 的批量方阵,另有 1024、2048、4096 的大块。批量方阵意味着你可以把 batch 维度当成额外的并行度——这是 GPU 最擅长的地方,也是这个题目"可自动研究"的第一个原因。

为什么这类问题适合自动研究

自动研究要成立,需要三个条件同时满足:

目标可判定。QR 分解有精确的数学定义,检查器能算出残差和正交性,对错是二值的。没有这一点,模型给出的"优化"无从验证。

反馈快且连续。他搭的基准脚本几秒内就能给出耗时。评分是连续的,不是通过/失败——这让你能用梯度式的思路调参,而不是靠运气撞出一个能跑的版本。

搜索空间大但有结构。kernel 融合、block size、共享内存布局、流水深度,这些参数组合起来是天文数字,但每个组合都能立刻打分。搜索空间大 + 反馈快,正是自动研究吃得最好的地形。

反过来,如果你在做一个"架构设计"或"需求分析"任务,没有干净的度量,模型给的建议就只能靠人脑判断——那不是自动研究,是自动生成待审草稿。

Householder 的数学,以及串行的代价

Householder 反射的形式是 H = I - τvvᵀ,其中 v 是 Householder 向量,τ 是标量。它把一个向量映射到指定方向上,用来逐列消去下三角部分。

朴素做法是逐列的:对每一列,算出一个反射,作用到整块剩余矩阵上。每一列的反射都依赖前一列的消元结果,所以这是一个串行链。列数越多,串行段越长,GPU 就越多时间在干等。

Blocked Householder(WY 表示)就是冲着这个串行段去的。它一次处理一列 panel(比如 32 列),把这 32 次反射聚合成一个紧凑的矩阵表示,然后用两次 GEMM 把这个聚合反射作用到后面的 trailing matrix 上。串行的 panel 分解只占总工作量的一小部分,剩下 90% 以上的计算都是 GEMM——而 GEMM 在 GPU 上是神级问题,有成熟的高效实现。

他文章里"Make serial work small"那节讲的就是这件事:不要试图把串行部分做快,而是把它变小,让计算受限的部分吃掉绝大部分时间。这是一个反直觉但很实用的判断。很多人第一反应是去优化 panel 分解的 kernel,其实收益上限很低。

GPU 上的账:working set 与内存层级

要理解 232 倍从哪来,得算一笔内存账。

512×512 的单精度矩阵是 1MB。批量 32 个就是 32MB。RDNA3 的 L2 是 6MB(他文里提到这个数),单张 512×512 的矩阵塞得进 L2,批量 32 个就塞不下了。一旦超过 L2,每次访问都要走显存。

他给的估算很直观:PCIe 4.0 x16 的带宽不到 32GiB/s。如果要求最低 30 帧每秒(每帧 33.3ms),那么 GPU 在这段时间里最多能访问约 1075MiB 的数据——刚过 1GiB。这意味着凡是超过 1GiB 的 working set,性能就被带宽锁死,你再怎么优化计算都是白搭。

到 6MB 这个量级(L2 尺寸),CPU 内存的访问延迟升到每次约 2400 周期,而显存延迟反而在同一量级。这个数字说明什么?说明在超出缓存之后,CPU 内存和显存对 GPU 来说差别不大,真正的分水岭是"在不在缓存里"。所以优化的方向是把数据切块,让每一块在片上缓存里被反复利用,而不是指望把更多数据塞进带宽。

方法论:让模型"提问"而不是"盲写"

他文章里有一节标题是《Learning Enough to Ask Better Questions》,我读的时候停了一下。这才是关键。

让 Codex 直接写 kernel,它会给你一个能编译、能跑、但不快的版本——因为模型不知道这块硬件的带宽上限在哪,不知道你为什么需要 blocked 形式,也不知道 compact 输出意味着什么。但当你自己先把数学和内存账理清楚,把问题拆成"panel 阶段要小、trailing 更新要映射到 GEMM、Q 不能落地"这些具体约束,再让模型在约束内生成候选实现,结果完全不同。

他的流程大致是:先补课(Householder 推导、blocked 算法),把串行工作用 WY 压缩,然后让 Codex 帮着写具体的 kernel、调 block size、试不同的融合方案,每改一版就用基准脚本打分。模型在这里的角色是"不知疲倦的候选生成器",判断和方向由人给。

结果与它的边界

232 倍是相对 baseline 的——baseline 是朴素的逐列 Householder,慢是正常的。拿第 12 名说明还有 11 个方案更快,真正的竞争者在做更深的内存布局和指令级优化。

我不觉得"自动研究"能普适。它在有精确检查器、有连续评分、有快速反馈的问题上是真有用的;在需要判断"要做什么"的问题上,它帮不上忙,甚至会让你误以为进展很快。把它当放大器用,前提是你自己清楚方向。

我的做法

如果你手上有一个性能敏感的内核,想试试这个路子:

  • 先搭一个 30 秒内能出分的本地基准,固定输入、固定机器、多次取中位数。没有这个,后面全是玄学。
  • 把正确性检查器和性能计时分开。正确性只有 0/1,性能是连续分数。两者都要,但别混在一个脚本里。
  • 自己先把算法层的账算清楚——哪部分是串行的,working set 多大,缓存放不放得下。这些约束写进给模型的提示里。
  • 让模型生成 10 个候选而不是 1 个,全部跑分,保留最好的三个,再让它在最好的那个基础上变异。
  • 每轮改完都重新跑正确性检查。GPU 上的一个 off-by-one 可能只影响某些尺寸的矩阵,漏掉就要返工。

232 倍听起来夸张,拆开看是一连串小决定的复利:不构造 Q、blocked 化、panel 变小、trailing 用 GEMM、按缓存切块。每一步都有据可查,模型的贡献是让你更快走完这些步,不是替你决定往哪走。