最近我改进了GoAWK--我用Go编写的AWK解释器--的性能,从树形行走解释器切换到带有虚拟机解释器的字节码编译器。
在这样做的同时,我认为看看Go本身的性能在这些年里有多大的提高是很有趣的。
用Go编写的程序有很多方式变得更快:Go团队和外部贡献者改进了编译器,优化了运行时、垃圾回收器和标准库。下面是GoAWK在使用Go的每个发布版本(从1.2(我可以下载的最早的版本)到1.18(现在是测试版))进行编译时的性能比较。
我在两个AWK程序上运行了GoAWK,这两个程序代表了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脚本来运行它们并测量时间。以下是图表(如果你愿意,也可以用表格的形式)。


我猜他们在Go1.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这样一个积极开发的编译器来说,能够通过等待和让别人做所有艰苦的工作来获得性能的提高是非常酷的。)