Go 从 1.2 到 1.18 的性能表现
最近,我通过将GoAWK——我用 Go 编写的 AWK 解释器——从树遍历解释器切换为带虚拟机的字节码编译器,大幅提升了它的性能。
在做这件事的过程中,我想到看看这些年来 Go 本身的性能到底提升了多少,应该会很有意思。
Go 程序变快的途径有很多:Go 团队和外部贡献者改进了编译器,并优化了运行时、垃圾回收器和标准库。下面是使用从 Go 1.2(我能下载到的最早版本)到 1.18(目前仍处于 Beta 阶段)的各个已发布版本编译 GoAWK 时的性能对比。
我通过在 GoAWK 上运行两个 AWK 程序来做测试,它们代表了 AWK 的两种极端用途:I/O 与字符串处理,以及数值计算。
首先是 countwords,这是一个统计输入中各单词出现频率并打印单词及其次数的字符串处理任务。这是 AWK 脚本的典型应用。输入是《钦定版圣经》的 10 倍拼接版本(我之前在做性能对比时也用过)。代码如下:
{
for (i=1; i<=NF; i++)
counts[tolower($i)]++
}
END {
for (k in counts)
print k, counts[k]
}第二个程序是 sumloop,它是一个紧凑循环,会反复将循环计数器累加到一个变量上。这其实并不是 AWK 的典型用法:
BEGIN {
for (i=0; i<10000000; i++)
sum += i+i+i+i+i
}第一张图表中的计时数字是我在 x86-64 Linux 笔记本上测得的耗时(单位为秒,取三次运行中的最佳成绩)。我还附上了每个 Go 版本下 GoAWK 二进制文件大小的图表。
我用一个Python 脚本来批量运行并测量耗时。图表如下(如果更喜欢表格,也可以查看表格):


看来 Go 1.3 时就摘掉了不少唾手可得的果实!发布说明提到,运行时、垃圾回收器以及栈的处理方式都发生了重大变化。之后,countwords 的性能一直稳步提升到 Go 1.7,sumloop 则持续提升到 1.9。再往后直到今天的 1.18,就只有非常平缓的改进了。
如果不做太深入的研究,我猜 countwords 的提升(至少在 1.3 之后)主要得益于标准库的改进,而“CPU 密集型”的 sumloop 的提升则来自编译器的优化。
最近的一项改进是在 1.17 版本中,改为通过寄存器而非栈来传递函数参数和返回值。这对 GoAWK 旧的树遍历解释器来说是一项显著的提升,在一项微基准测试中我看到了 38% 的速度提升,所有微基准测试平均提速 17%。
有意思的是,对于 GoAWK 新的虚拟机实现,通过寄存器传递参数的改动并没有带来明显的提升。这是因为虚拟机使用一个大的 switch 语句来分发不同的操作码,函数调用却很少。相比之下,树遍历解释器中每个表达式都需要对 eval 进行(递归)函数调用,因此获得了很大的性能收益。
展开查看旧版树遍历解释器的基准测试结果(表格)。整体走势大体相同,不过可以看到在 1.17 处由于改用寄存器传参而出现的明显下降。

我很期待有人能改进 Go 的 regexp 包的性能,它目前相当慢。正则表达式在 AWK 脚本中被大量使用,因此这对在实际脚本中使用 GoAWK 会带来很大影响。也许哪天我会自己尝试一下。
总的来说,countwords 现在的速度大约是用 Go 1.2 编译时的 5 倍,sumloop 则快了 14 倍!(不过我首次发布 GoAWK 时 Go 已经到了 1.11,所以它并没有享受到早期那些巨大的性能提升。)
对于 Go 这样仍在积极开发的编译器来说,只需等待、让别人完成所有艰苦的工作就能获得性能提升,真是件很酷的事。:-)
随机一篇博客
评论
登录后参与讨论