用字节码编译器和虚拟机对GoAWK进行优化(详细指南)

267 阅读16分钟

几年前,我写了GoAWK,一个用Go编写的AWK解释器,还有一篇文章描述了它是如何工作的,如何测试的,以及我如何让它变得更快。

GoAWK是一个有趣的副业,至少在一个大型的开源项目中使用,即Benthos流处理器。它甚至让我在Canonical找到了现在的工作。

GoAWK以前使用的是树形行走解释器:为了执行一个代码块,它递归地行走解析的语法树。这很简单,但不是特别快。我想改用字节码编译器和虚拟机解释器已经有一段时间了,我终于有了这个想法。

我早期的一个编程项目是Third,一个用于DOS的Forth编译器。大多数Forth编译器,包括Third,都是使用字节码形式的简单编译器--在Forth世界中称为线程代码。所以我想你可以说我对虚拟机感兴趣已经有25年了......这是否使我成为一个真正的极客,或者只是老了?

为什么虚拟机比树形行走快?

为什么编译成虚拟指令,然后用虚拟机执行它们比评估语法树("树形行走")更快,这并不明显。

这实际上是更多的前期工作:而不是仅仅将词法和语法树进行解析,我们现在也有一个编译步骤。也就是说,虚拟机编译器(包括GoAWK的编译器)通常是非常简单和非优化的,所以这个步骤是很快的。

执行得更快的一个原因是这样的。RAM--代表随机存取存储器--在现代处理器上实际上不是随机存取。内存块根据需要被加载到快速的CPU缓存中,所以当你要访问一个新的内存块时,需要的时间大约是缓存中的10倍。Peter Norvig的典型CPU上各种操作的时间表显示,从1级缓存中获取需要0.5纳秒,从2级缓存中获取需要14倍的时间,而从主内存中获取则需要14倍的时间。

考虑到这一点的编程被称为 "数据驱动的设计"。在观看Andrew Kelley的精彩演讲《应用面向数据的设计的实用指南》时,我想起了这有多大影响。安德鲁是Zig编程语言的创造者,他的演讲描述了他是如何通过应用数据驱动的设计技术大大加快了Zig编译器的速度。那次演讲推动了我对GoAWK的思考。但是,回到为什么虚拟机比树形行走快......

语法树是一堆指向其他节点的节点结构。它们分散在内存中,所以为了评估子节点,你必须遵循指针,在RAM中跳来跳去,可能会驱逐已经在缓存中的东西。

下面是表达式print $1+$2 的GoAWK语法树图,显示了每个节点名称上方的十六进制内存地址:

Syntax tree for 'print 1+2'

PrintStmtBinaryExpr 只有 48 字节,但左边的FieldExpr 离它有 8KB,然后它的NumExpr 离它有差不多 120KB。缓存块通常是64字节,所以每一个缓存块都可能需要从主内存加载一个额外的缓存块。对缓冲区不是很友好。

在虚拟机解释器中,指令是一个很好的操作码(指令号)的线性阵列,这些指令可能会被一次性加载到一个缓存块中。在RAM中的跳动要少得多。下面是同一程序的GoAWK虚拟机指令的样子(你可以用新的调试标志goawk -da 来查看这个 "汇编列表"):

$ echo 3 4 | goawk -da '{ print $1+$2 }'
        // { body }
0000    FieldInt 1
0002    FieldInt 2
0004    Add
0005    Print 1    // 1 is the number of values to print

7

这里显示了GoAWK编译器所做的(相对较少的)优化:当i 是一个整数常数时,它将$i 变成一条FieldInt i 指令,而不是两个指令序列Num i ,然后是Field 。这意味着大多数字段的查找只经过一次操作码解码循环,而不是两次。

虚拟机方法更快的另一个原因是有较少的函数调用,而函数调用是相对缓慢的。当评估一个语法树时,eval 函数递归调用eval ,再次评估子节点。在虚拟机中,这一切都被扁平化为一个单一的操作码数组,我们在上面循环操作--不需要为调度操作码而调用函数。

编译器和虚拟机细节

GoAWK的虚拟机使用32位操作码。最初我打算使用8位操作码("字节码 "中的 "字节 "来自于此),但32位操作码同样快,而且使用32位操作码可以避免对可变大小的跳转偏移量的需求:较大的AWK脚本可能需要超过-128到+127的跳转偏移量,而没有人会需要比32位给你的20亿更大的跳转偏移量。64位的操作码是不必要的大,而且它们的速度也稍慢。

这里是前10个操作码(总共有85个--你可以在internal/compiler/opcodes.go中看到完整列表):

// Opcode represents a single virtual machine instruction (or argument).
// The comments beside each opcode show any arguments that instruction
// consumes.
type Opcode int32

const (
    Nop Opcode = iota

    // Stack operations
    Num // numIndex
    Str // strIndex
    Dupe
    Drop
    Swap

    // Fetch a field, variable, or array item
    Field
    FieldInt    // index
    Global      // index
    Local       // index
    ...
)

正如你在上面的print $1+$2 汇编列表中所看到的,我使用了一个基于堆栈的虚拟机。这实现起来比较简单,因为编译器不需要搞清楚如何分配寄存器,它只是向堆栈推送和弹出。然而,基于堆栈的虚拟机可能会稍微慢一些--像Lua这样的非常快的虚拟机是基于寄存器的。

GoAWK的编译器相当简单,从语法树到指令的翻译相当直接。我使用了一些特殊的方法来访问不同范围的变量:例如,获取全局变量使用Global 指令,获取局部变量使用Local 。(正如你所看到的,我的指令命名方案非常有创意)。

下面是一个简单程序的汇编列表,该程序将数字从1到10相加:

$ goawk -da 'BEGIN { for (i=1; i<=10; i++) sum += i; print sum }'
        // BEGIN
0000    Num 1 (0)
0002    AssignGlobal i
0004    Global i
0006    Num 10 (1)
0008    JumpGreater 0x0018
000a    Global i
000c    AugAssignGlobal AugOpAdd sum
000f    IncrGlobal 1 i
0012    Global i
0014    Num 10 (1)
0016    JumpLessOrEqual 0x000a
0018    Global sum
001a    Print 1

55

这显示了我从Python中复制的一个整洁的小优化,它的解释器在Python 3.10中加入了这个优化(尽管我确信这不是一个新的想法)。要编译一个forwhile 循环,最简单的方法是在顶部做测试,然后在循环的底部使用无条件的Jump 。但这意味着你每个循环都要执行两条跳转指令:一条在顶部,一条在底部。

相反,我们将条件编译两次:一次是在循环前反转(JumpGreater),一次是在循环的底部(JumpLessOrEqual)。因为条件被重复,所以总体来说代码量略大,但是循环本身--这才是最重要的--却少了一条跳转指令。

我们几乎肯定可以进一步改进指令集,也许可以在提前知道操作类型的情况下为整数或字符串添加特殊指令。然而,这增加了复杂性,现在我打算保持简单。

GoAWK编译器做的另一个优化是针对赋值。AWK中的赋值是表达式,所以默认情况下,你会把它们的值推到堆栈上......只是在大多数情况下会立即丢弃它。而且你很少使用一个赋值表达式的值。

这里是一个优化的赋值表达式的程序集:

$ ./goawk -da 'BEGIN { x=42; print x }'
        // BEGIN
0000    Num 42 (0)
0002    AssignGlobal x
0004    Global x
0006    Print 1

42

这是在没有优化的情况下的样子:

0000    Num 42 (0)
0002    Dupe              # unnecessary
0003    AssignGlobal x
0005    Drop              # unnecessary
0006    Global x
0008    Print 1

下面是编译语句的代码,显示了用于这种优化的特殊情况。我还包括我们如何编译if 语句,以显示一些完全不同的东西。注意编译器是如何大量使用Go的类型切换的:

func (c *compiler) stmt(stmt ast.Stmt) {
    switch s := stmt.(type) {
    case *ast.ExprStmt:
        // Optimize assignment expressions to avoid extra Dupe and Drop
        switch expr := s.Expr.(type) {
        case *ast.AssignExpr:
            c.expr(expr.Right)
            c.assign(expr.Left)
            return

        case *ast.IncrExpr:
            ... // similar optimization for i++ and i--

        case *ast.AugAssignExpr:
            ... // similar optimization for i+=2 (for example)
        }

        // Non-optimized ExprStmt: push value and then drop it
        c.expr(s.Expr)
        c.add(Drop)

    ...

    case *ast.IfStmt:
        if len(s.Else) == 0 {
            jumpOp := c.condition(s.Cond, true)
            ifMark := c.jumpForward(jumpOp)
            c.stmts(s.Body)
            c.patchForward(ifMark)
        } else {
            jumpOp := c.condition(s.Cond, true)
            ifMark := c.jumpForward(jumpOp)
            c.stmts(s.Body)
            elseMark := c.jumpForward(Jump)
            c.patchForward(ifMark)
            c.stmts(s.Else)
            c.patchForward(elseMark)
        }

    ...
    }
}

虚拟机 execute函数是一个单一的for 循环,有一个大的switch 语句 - 每个操作码有一个case 。下面是一个片段,显示了指令的获取和处理几个操作码的代码:

func (p *interp) execute(code []compiler.Opcode) error {
    for ip := 0; ip < len(code); {
        op := code[ip]
        ip++

        switch op {
        case compiler.Num:
            index := code[ip]
            ip++
            p.push(num(p.nums[index]))

        case compiler.Str:
            index := code[ip]
            ip++
            p.push(str(p.strs[index]))

        case compiler.Dupe:
            v := p.peekTop()
            p.push(v)

        ...

        case compiler.FieldInt:
            index := code[ip]
            ip++
            v, err := p.getField(int(index))
            if err != nil {
                return err
            }
            p.push(v)

        ...
        }
    }
}

Go的switch语句

如上所示,虚拟机被实现为一个大的switch 语句,每个操作码有一个case (大约80个案例)。Go的switch 语句目前被实现为通过 "case空间 "的二进制搜索。你可以认为它编译成这样的东西--为了简洁起见,只完整地显示了树的几个分支:

if op < 40 {
    if op < 20 {
        if op < 10 {
            if op < 5 {
                if op < 2 {
                    if op < 1 {
                        // handle opcode 0
                    } else {
                        // handle opcode 1
                    }
                } else {
                    // cases for opcodes 2-4
                }
            } else {
                // cases for opcodes 5-9
            }
        } else {
            // cases for opcodes 10-19
        }
    } else {
        // cases for opcodes 20-39
    }
} else {
    if op < 60 {
        // cases for opcodes 40-59
    } else {
        // cases for opcodes 60-79
    }
}

正如你所看到的,你需要做O(log2 N)的比较和跳转来达到你所感兴趣的情况。对于80个操作码来说,每条指令都有6到7个分支需要解码。

随着指令数量的增长,分支的数量也在增长(不过幸好这种增长是对数的,不是线性的)。当我第一次为GoAWK编码一个概念验证的虚拟机时,只是实现了我在演示中所需要的7或8条指令,它带来了巨大的性能提升,几乎快了40%,因为switch ,只有少数情况。但是现在,我已经把所有的操作码都实现了,它 "只 "快了18%。

实际上,当我有大约100个操作码时,它比这个速度还慢。我删除了一些我认为会加快速度的专业,但由于操作码较少,这意味着少了一个二进制搜索分支,实际上平均快了12%

如果有一种方法可以获得恒定时间的指令调度,无论我们有多少条指令,这将是一件好事。为什么Go不能以跳转地址表的形式实现switch :在表中查找代码地址并直接跳转到该地址?事实证明,Go团队的Keith Randall正致力于此,所以我们可能会在Go 1.19中得到它。

我在GoAWK上试用了Keith的分支(目前只适用于int64 类型),它将一个简单的微测试的速度提高了10%。所以我绝对期待Go编译器能够学习 "跳表"。

我们可以自己做这种优化吗?一个函数数组呢?我试过了,把派发循环变成了下面这个样子:

func (p *interp) execute(code []compiler.Opcode) error {
    for ip := 0; ip < len(code); {
        op := code[ip]
        ip++

        n, err := vmFuncs[op](p, code, ip)
        if err != nil {
            return err
        }
        ip += n
    }
    return nil
}

// Type of function called for each instruction. Each function returns
// the number of arguments the instruction read from code[ip:].
type vmFunc func(p *interp, code []compiler.Opcode, ip int) (int, error)

var vmFuncs [compiler.EndOpcode]vmFunc

func init() {
    vmFuncs = [compiler.EndOpcode]vmFunc{
        compiler.Nop: vmNop,
        compiler.Num: vmNum,
        compiler.Str: vmStr,
        ...
    }
}

func vmNop(p *interp, code []compiler.Opcode, ip int) (int, error) {
    return 0, nil
}

func vmNum(p *interp, code []compiler.Opcode, ip int) (int, error) {
    index := code[ip]
    p.push(num(p.nums[index]))
    return 1, nil
}

func vmStr(p *interp, code []compiler.Opcode, ip int) (int, error) {
    index := code[ip]
    p.push(str(p.strs[index]))
    return 1, nil
}

这只让我在GoAWK的微观测试中增加了1-2%的速度(见结果和代码)。最后我决定还是坚持使用更简单的switch 代码,并寻找其他方法来提高速度。而当Go编译器支持switch 的跳转表时,我什么都不做就能获得10%的改进!

gcc 编译器有一个非标准的功能,叫做 "计算的goto",它允许你在每个操作码的代码末尾写上类似goto *dispatch_table[code[ip++]] 的东西,直接跳到下一个操作码的代码。Eli Bendersky已经写了一篇关于计算型goto的优秀文章,所以我在这里就不多说了。大多数用C语言编写的虚拟机都使用这种技术,包括CPython和其他许多虚拟机。不幸的是,Go没有计算过的goto,但同样的,当switch 被编译为跳转表时,这将使我们达到一半的效果。

如果你有兴趣阅读一些关于编译器如何优化switch ,请阅读Roger Sayle的论文"A Superoptimizer Analysis of Multiway Branch Code Generation [PDF]",该论文在2008年GCC开发者峰会上发表。

其他优化(和一个去优化)

除了从树形行走转换到虚拟机之外,我最近还增加了一些其他的优化。

我做的一个问题修复是改变GoAWK的字符串函数,如length()substr() ,以使用Unicode字符索引而不是字节索引。我知道这将使这些操作从字符串长度的O(1)变为O(N),但我认为这并不重要,因为 "N通常都很小"。

事实证明,这一假设并非如此。Volodymyr Gubarkov的gron.awk脚本从1秒内处理一个大的JSON文件变成了8分钟以上--意外地四次方。这是站不住脚的,所以我决定暂时恢复这个修复,并在将来找出一个O(1)的方法来解决这个问题。Gawk的长期维护者Arnold Robbins评论说,Gawk做了大量的工作来使字符串处理变得高效。

我希望将来能进一步优化GoAWK,并开设了一个总括问题来跟踪未来的性能工作。以下是其中的一些想法。

虚拟机的改进,这里有一些我正在考虑的事情,以加快虚拟机的速度:

  • 优化或减少堆栈操作。interp.push 方法特别慢,因为有append 的检查(而在正常的AWK代码中几乎不需要append )。如果你有关于如何提前确定最大堆栈大小的好主意,请告诉我。对于潜在的递归函数调用来说,这是否可能?
  • 有没有什么专门的操作码可以添加,比如一个Int 指令,推送其参数中的整数常数?增加Int ,就可以省去对interp.nums 片断的内存查询。
  • 据推测,JumpLess 和类似的操作码在字符串上不经常使用。用JumpLessNum 来代替它们,以避免对至少一个操作数进行类型检查,是否更好?(对于字符串,我们会使用一个较长的指令序列)。

当你连接两个以上的字符串时,由于多余的分配和复制,字符串连接也是不必要的昂贵。目前,像first_name " " last_name 这样的多串联表达式被编译成两条二进制Concat 指令:

Global first_name
Str " "
Concat
Global last_name
Concat

如果编译器能检测到这一点,并输出一条新的Concat numArgs 指令,会更有效率:

Global first_name
Str " "
Global last_name
Concat 3

这样就少了一条指令,但更重要的是,它可以避免分配一个临时字符串,却又不得不分配一个新的字符串,并将字节复制过来。在AWK中,串联两个以上的值是很常见的,你串联的值越多,这种优化就越好。

正则表达式将是很好的加速方式。GoAWK目前使用Go的regexp 包,但不幸的是它的速度相当慢。这使得使用正则表达式的AWK脚本的速度严重低于Gawk的一半,几乎只有Mawk的四分之一。

有两个方法可以改善这个问题:

  1. 编写我自己的正则表达式引擎(可能尝试将Mawk的引擎直接移植到Go上)。这可能是一个很大的工作,而且由于Go的边界检查和较少的编译器优化,可能仍然没有那么快。
  2. 提高 Go 的正则表达式引擎的速度。这将是迄今为止更好的方法,因为所有使用 Go 的regexp 包的人都会受益。这也可能是相当困难的。我可能会把这个问题留给比我更聪明的人--也许其中一些问题会随着时间的推移而得到解决。

虚拟机的结果

那么,虚拟机解释器速度有多快?微观测试--承认大部分不是你在AWK中写的那种脚本--总体上快了大约18%。这些是经过的时间,所以越小越好(你可以看到原始数据,或者用benchmark.shbenchstat.sh来测量,以显示这些延迟):

name                    old time/op  new time/op  delta
NativeFunc-8            10.7µs ± 0%  10.8µs ± 0%   +0.67%
BuiltinGsub-8           16.2µs ± 0%  16.2µs ± 0%   +0.36%
BuiltinGsubAmpersand-8  16.2µs ± 0%  16.2µs ± 0%   +0.29%
BuiltinSub-8            13.6µs ± 0%  13.6µs ± 0%     ~   
BuiltinSubAmpersand-8   13.5µs ± 0%  13.6µs ± 0%     ~   
SimplePattern-8          133ns ± 1%   134ns ± 0%     ~   
ConcatLarge-8           8.43ms ± 1%  8.35ms ± 2%     ~   
BuiltinSplitRegex-8     87.9µs ± 0%  87.7µs ± 0%   -0.21%
BuiltinSplitSpace-8     35.4µs ± 0%  35.1µs ± 0%   -0.70%
GetField-8               445ns ± 1%   435ns ± 2%   -2.42%
FuncCall-8              2.84µs ± 0%  2.76µs ± 2%   -2.65%
BuiltinSprintf-8        9.67µs ± 0%  9.23µs ± 0%   -4.58%
RecursiveFunc-8         15.7µs ± 0%  14.9µs ± 0%   -4.95%
ConcatSmall-8            735ns ± 0%   691ns ± 1%   -5.98%
BuiltinMatch-8          2.91µs ± 0%  2.71µs ± 1%   -7.02%
BuiltinIndex-8          1.23µs ± 1%  1.11µs ± 1%   -9.51%
RegexMatch-8            1.24µs ± 1%  1.11µs ± 4%  -10.07%
SetField-8               905ns ± 0%   810ns ± 0%  -10.45%
ForInLoop-8             2.04µs ± 2%  1.78µs ± 4%  -12.86%
ArrayOperations-8        657ns ± 0%   565ns ± 0%  -13.94%
BinaryOperators-8        493ns ± 0%   413ns ± 0%  -16.15%
BuiltinSubstr-8          975ns ± 0%   765ns ± 0%  -21.50%
Comparisons-8            417ns ± 0%   321ns ± 0%  -22.98%
SimpleBuiltins-8        1.00µs ± 0%  0.75µs ± 0%  -25.61%
CondExpr-8               203ns ± 0%   151ns ± 0%  -25.62%
BuiltinLength-8          607ns ± 0%   429ns ± 0%  -29.34%
IfStatement-8            219ns ± 0%   152ns ± 0%  -30.65%
AugAssign-8             1.50µs ± 0%  0.98µs ± 0%  -34.74%
LocalVars-8              479ns ± 0%   300ns ± 2%  -37.32%
Assign-8                 446ns ± 0%   261ns ± 0%  -41.55%
ForLoop-8               4.34µs ± 0%  2.50µs ± 0%  -42.39%
GlobalVars-8             468ns ± 0%   269ns ± 1%  -42.48%
IncrDecr-8               448ns ± 0%   148ns ± 0%  -66.87%
[Geo mean]              2.36µs       1.94µs       -17.90%

增量、减量和增量赋值都快得多,因为虚拟机有专门的操作码。变量访问也有了很大的改善,for 循环、if 语句、二进制运算符和其他许多基准也是如此。

我的更加 "真实 "的基准测试套件--其中大部分是我从原始AWK源中提取的--总体上快了13%。在这个表中,goawk 是新的虚拟机解释器,orig 是旧的树形行走解释器。有点不寻常的是,这里的数字是它比原来的awk 快的倍数,所以越大越好

测试测试原有的awkgawkmawk
tt.012.021.911.001.662.29
tt.021.591.601.001.772.20
tt.02a1.541.561.001.732.05
tt.031.321.271.003.851.83
tt.03a1.291.261.004.081.79
tt.040.970.801.001.262.74
tt.050.950.881.001.612.26
tt.061.391.351.002.531.97
tt.071.241.181.001.461.71
tt.081.971.981.001.132.70
tt.092.132.131.002.415.01
tt.100.340.341.001.453.40
tt.10a0.320.341.001.303.08
tt.112.202.131.001.153.68
tt.121.211.191.001.701.78
tt.133.142.621.003.115.92
tt.13a2.251.801.001.864.85
tt.141.171.091.000.641.56
tt.150.620.611.000.962.21
tt.161.471.211.001.272.12
tt.big1.621.411.001.833.82
tt.x12.251.621.001.343.44
tt.x21.761.031.001.152.68
地理平均数1.451.281.001.862.32

总结

我绝对喜欢我得到的性能改进。它们并不像我希望的那样多,但事实上GoAWK现在在许多与CPU有关的操作上比Gawk更快,这非常酷。它仍然总是比性能极好的Mawk慢。而对于AWK通常用于的东西--字符串处理和正则表达式--GoAWK仍有很大的改进空间。

说实话,我不完全确定这是否值得增加2500行代码(对于一个只有15000行代码的项目,包括测试)。如果我有一个工程经理来监督这件事,我会预料到反击("这对我们的真实世界的工作负载有帮助吗?")。然而,GoAWK过去和现在都是一个充满激情的项目--我在制作和分享这个项目时很开心,这对我来说已经足够了。

我已经合并了编译器和虚拟机,并在GoAWK v1.15.0中发布了它们。Go的API和goawk 命令应该是100%向后兼容的。它已经在我的解释器测试以及原始AWK的测试和Gawk的相关测试中得到了很好的检验,但如果你发现有什么不妥,请提交问题