事情的起因来自于我突发奇想,想让 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 只好作罢。不过这个发现本身还是挺有意思的,所以还是记录了一下。