事情的起因来自于我突发奇想,想让 codex 看看 go 最近加入的 simd 能不能给 TiDB 加速。codex 一下就定位到了一个潜在的优化点:bytes.IndexAny,正好这也落在 IMPORT INTO 的路径上,就让他实验了一下,结果在某些场景下(>32字节的输入)确实得到了不小的提升。不过可惜的是对于小输入却起到了反作用。

当然我也不甘心,毕竟我相信 AI 大人的神力,所以让他想办法优化 go 标准库在这种小输入下的性能,全程我只负责发送“继续”。您猜怎么着,AI 前前后后探索了很久之后,还真成了!

起点:第一次优化

一开始我还以为他是加上了 SIMD 的优化,结果看了 PR 才发现优化的是汇编代码生成,不得不说这引起了我的一泡兴趣。

AI 的发现是:在不改变执行流程的情况下,即使只是改变代码布局,也能显著影响耗时。以 bytes.IndexAny 为例,测试方法如下:

  • 固定输入为1 KiB、16 个分隔符,且搜索未命中,也就是需要扫描整个字符串。
  • 测试时使用 randlayout 用来随机生成不同的代码布局,也就是 go test -c -ldflags=-randlayout=xx。
    • 加上这个参数后,输出的二进制里 IndexAny 的位置是不确定的,例如下面的两种不同布局。
layout1
[main] [IndexAny] [func1] [func2]

layout2
[func2] [func1] [main] [IndexAny]

在我用的服务器上,测试出的性能浮动能达到1.5倍。而 AI 优化后,执行性能非常稳定。

为什么布局会影响性能?

这部分是结合 AI 和搜索引擎的结果,不一定代表本次测试结果的实际根因。

先说结论,一段代码的性能,不止受其计算量的影响,也和指令执行速度有关。CPU 收到指令前还需要经过译码,对于重复执行的热代码,如果能缓存这部分结果,就可以减少开销:

  • AMD Zen3/4 就有 Op Cache,用于缓存已译码的操作,大约能存储 6000 多条命令。
  • Intel 的 JCC 微码说明也提到,在部分处理器上,某些跨越或恰好在 32 字节边界的跳转指令,无法进行缓存,只能通过标准流程进行译码。

对于循环这种热代码来说,如果跳转指令的位置不合适,就可能影响到执行效率。

如何调整布局?

知道原理之后,那么具体是怎么调整的呢?AI 的改动点在 src/cmd/internal/obj/x86/asm6.go 的 span6,负责调整指令位置并生成机器码。由于第一次看这部分代码,看的我云里雾里,靠 AI 解释了半天才明白。

为了简化优化算法,需要对循环代码进行筛选,条件如下:

  • 长度不能超过 31 字节。
  • 只能包含一个回条和一个前向跳出分支。
  • 循环内没有调用,嵌套循环,简化优化实现。
  • 循环的前一条指令必须是无条件跳转(JMP)或者返回(RET)。

例如下面就是一段符合条件的汇编代码:

    JMP check
loop:
    INC index
check:
    CMP index, length
    JGE notFound
    JE loop
found:
    ...
notFound:
    ...

可以发现,loop 前的第一条指令是 JMP check,因此在这两条指令之间插入的任意指令都不会执行,因此可以靠插入 NOP 来调整 loop 的起始位置。

当然这也不代表能插入任意数量的指令,填充指令可能会影响其他跳转指令的间距,导致其编码长度的改变(跳转指令的长度和跳转距离有关),这又会进一步影响其他指令,造成连锁反应。为了避免对后续函数的影响,AI 加入了另一个约束:

roundUp32(优化后大小) == roundUp32(原始大小)

如果优化后不满足约束,则放弃优化。

优化后的代码在两个 Intel 平台上跑了大量测试,没有发现性能回退,看上去有理有据的,我还以为又可以给上游提个小 PR 贡献一下了。不过 AI 推荐我在 AMD 上也测一下,我就给了他 aws 账号去测了一轮,结果就给我泼了盆冷水。

反例:AMD 上的实验

在 AMD EPYC 9R14 上,几乎所有的测试项都没有问题,除了 regexp.BenchmarkOnePassShortA 从 247.05 ns 变为 267.55 ns,慢了大概 8%。

让 AI 大人再次分析后,发现问题是在 regexp.doOnePass 开头有一个初始化循环可以被优化:

for i := range m.matchcap {
    m.matchcap[i] = -1
}
原版:
[入口代码][初始化循环][热代码约525字节][末尾空隙]

补丁:
[入口代码][NOP][初始化循环][热代码约525字节][空隙]

但是这个优化却影响到了函数内部后续的热循环,导致前端完全停供周期却从每次操作约 19 增至 93,反而成了负优化。

这段循环里有很多个基本块,所以很难确定究竟是哪部分受影响了,但是通过控制变量能确认根因就是这个小小的位移。

结语

之后我也让 AI 大人尝试了一些其他优化方向,比如枚举不同的起始位置,但是想要做到在所有场景都能有提升,需要考虑太多因素,属于是牵一发而动全身,我也没什么学过编译器相关知识,这个 PR 只好作罢。不过这个发现本身还是挺有意思的,所以还是记录了一下。