Optimizing GoAWK with a bytecode compiler and virtual machine

Ben Hoyt

バイトコードコンパイラと仮想マシンによるGoAWKの最適化

原文は Ben Hoyt により に公開されました。 このブログを購読する

要約:最近、ツリーウォーキング型インタプリタからバイトコードコンパイラと仮想マシン型インタプリタへ切り替えることでGoAWKを高速化しました。なぜ速くなるのか、そして新しいインタプリタがどのように動作するのかを解説します。

追記:GoAWKは現在、CSVファイルのネイティブサポートを含んでいます。

数年前、私はGoで書かれたAWKインタプリタであるGoAWKを開発し、その仕組みやテスト方法、高速化の手法について説明した記事も書きました。

GoAWKは楽しいサイドプロジェクトでしたが、ストリームプロセッサであるBenthosをはじめ、少なくとも一つの比較的大規模なオープンソースプロジェクトで使われています。このプロジェクトのおかげで、今のCanonicalでの仕事を得ることもできました。

以前のGoAWKはツリーウォーキング型インタプリタを使っていました。コードブロックを実行する際に、パースされた構文木を再帰的にたどる方式です。非常にシンプルですが、特に高速というわけではありません。以前からバイトコードコンパイラと仮想マシン型インタプリタに切り替えたいと思っていましたが、ようやく実現できました。

私の初期のプログラミングプロジェクトの一つは、DOS用のForthコンパイラであるThirdでした。Thirdを含むほとんどのForthコンパイラは、Forthの世界ではスレッド化コードと呼ばれる一種のバイトコードを使うシンプルなコンパイラです。つまり、25年間仮想マシンに興味を持ち続けてきたと言えるかもしれません……これで私は真のギークなのでしょうか、それとも単に年を取っただけでしょうか?

なぜ仮想マシンはツリーウォーキングより速いのか

仮想命令にコンパイルしてから仮想マシンで実行する方式が、構文木を評価する方式(「ツリーウォーキング」)より速い理由は、一見しただけでは明らかではありません。

実際には事前の作業はむしろ増えます。単に字句解析と構文解析で構文木を作るだけでなく、さらにコンパイルのステップが加わるからです。とはいえ、仮想マシン向けのコンパイラ(GoAWKのものも含めて)は通常非常にシンプルで最適化も行わないため、このステップは高速です。

実行が速くなる理由の一つは次のとおりです。RAM(Random Access Memoryの略)は、現代のプロセッサにおいて実際にはランダムアクセスではありません。メモリブロックは必要に応じて高速なCPUキャッシュに読み込まれるため、新しいブロックにアクセスしなければならない場合、キャッシュに載っている場合と比べて約10倍もの時間がかかります。Peter Norvigによる一般的なCPUにおける各種操作の所要時間の表によれば、レベル1キャッシュからの取得は約0.5ナノ秒、レベル2キャッシュからはその14倍、メインメモリからはさらに14倍の時間がかかることが示されています。

こうした点を意識したプログラミングは「データ指向設計」と呼ばれます。このことがどれほど大きな影響を与えるかを改めて思い知らされたのは、Andrew Kelley氏による素晴らしい講演、A Practical Guide to Applying Data-Oriented Designを見たときでした。Andrew氏はZigプログラミング言語の作者で、この講演ではデータ指向設計のテクニックを適用してZigコンパイラを大幅に高速化した方法が語られています。この講演が、GoAWKについて同じことを考えるきっかけになりました。話を戻して、なぜ仮想マシンがツリーウォーキングより速いのかというと……

構文木は、互いにポインタで参照し合うノード構造体の集まりです。メモリ上ではそれらが散在しているため、子ノードを評価するにはポインタをたどってRAM内をあちこちにジャンプしなければならず、すでにキャッシュに載っているデータを追い出してしまう可能性もあります。

以下は、式print $1+$2に対するGoAWKの構文木の図で、各ノード名の上に16進数でメモリアドレスが示されています。

'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が整数定数の場合、Num iに続けてFieldという2命令のシーケンスではなく、単一のFieldInt i命令に変換します。これにより、ほとんどのフィールド参照ではオプコードのデコードループを2回ではなく1回通るだけで済むようになります。

仮想マシン方式が速いもう一つの理由は、関数呼び出しが少なくなることです。関数呼び出しは比較的遅い処理です。構文木を評価する場合、eval関数は子ノードを評価するために再帰的にevalを呼び出します。仮想マシンでは、それらすべてがループで処理する単一のオプコード配列に平坦化されるため、オプコードのディスパッチに際して関数呼び出しは不要になります。

コンパイラと仮想マシンの詳細

GoAWKの仮想マシンは32ビットのオプコードを使用しています。当初は8ビットのオプコード(「バイトコード」の「バイト」の由来)を使うつもりでしたが、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のインタプリタではPython 3.10で追加されました(もっとも、新しいアイデアではないでしょう)。forwhileループをコンパイルする際、最もシンプルなのは先頭でテストを行い、ループの末尾で無条件のJumpを使う方法です。しかし、それではループごとに2つのジャンプ命令を実行することになります。先頭で1つ、末尾で1つです。

その代わりに、条件を2回コンパイルします。ループの前に反転させたものを1回(JumpGreater)、ループの末尾で1回(JumpLessOrEqual)です。条件が繰り返されるため全体のコード量はわずかに増えますが、重要なのはループ本体であり、そこではジャンプ命令が1つ少なくなります。

命令セットは、ほぼ確実にさらに改善できるでしょう。例えば、操作の型が事前にわかっている場合に整数や文字列用の特別な命令を追加するといったことです。ただし、それは複雑さを増すことになるので、今のところはシンプルさを保つつもりです。

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文から成り、オプコードごとに1つの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文

上で示したように、仮想マシンはオプコードごとに1つのcaseを持つ大きなswitch文として実装されています(約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
    }
}

ご覧のとおり、目的のcaseにたどり着くまでにO(log2 N)回の比較とジャンプが必要です。80個のオプコードの場合、命令1つをデコードするのに6〜7回の分岐が必要になります。

命令数が増えるにつれて、分岐数も増えていきます(幸い、その増加は線形ではなく対数的なものですが)。最初にGoAWK向けの概念実証として仮想マシンを作り、デモに必要な7〜8個の命令だけを実装したときは、switchのcaseが少なかったため、約40%もの大きな性能向上が得られました。しかし今ではすべてのオプコードを実装したことで、高速化は「わずか」18%にとどまっています。

オプコードが100個ほどあったときは、実際にはそれよりも遅くなっていました。高速化につながると思っていたいくつかの特化を削除したところ、オプコードが減ったことで二分探索の分岐が1つ減り、結果的に平均で12%高速になりました

命令がいくつあっても定数時間で命令をディスパッチできる方法があれば素晴らしいでしょう。なぜGoはswitchをジャンプアドレスのテーブルとして実装し、テーブルからコードのアドレスを引いて直接ジャンプするようにできないのでしょうか?実は、GoチームのKeith Randall氏がまさにそれに取り組んでいるところで、Go 1.19で実現するかもしれません。

Keith氏のブランチ(現時点ではint64型でのみ動作します)をGoAWKで試してみたところ、シンプルなマイクロベンチマークの速度が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コンパイラには「computed goto」と呼ばれる非標準の機能があり、各オプコードのコードの末尾でgoto *dispatch_table[code[ip++]]のように書くことで、次のオプコードのコードへ直接ジャンプできます。Eli Bendersky氏がcomputed gotoに関する優れた記事を書いているので、ここではこれ以上詳しく触れません。CPythonをはじめ、Cで書かれたほとんどの仮想マシンはこの手法を使っています。残念ながらGoにはcomputed gotoがありませんが、繰り返しになりますが、switchがジャンプテーブルにコンパイルされるようになれば、半分はそこに近づけます。

コンパイラがswitchをどのように最適化できるかについて、もう少しアカデミックな読み物に興味がある方は、2008年のGCC Developers’ Summitで発表されたRoger Sayle氏による論文“A Superoptimizer Analysis of Multiway Branch Code Generation [PDF]”を読んでみてください。

その他の最適化(そして一つの逆最適化)

ツリーウォーキングから仮想マシンへの変換以外にも、最近いくつかの最適化を追加しました。

  • コマンドにパイプされる出力のバッファリング。これによりprintのリダイレクトが10倍高速になりました。もともとstdoutへのバッファリングを追加したとき、すべてのprint出力で同様の高速化が得られましたが、リダイレクトされる方については見落としていました。
  • 数値文字列を必要な場合にのみ数値に変換するようにしました。以前は積極的に変換していましたが、今では数値としての値が必要な場合にのみ変換します。これにより$iフィールドを文字列として扱う実際のスクリプトでは堅実な改善が得られましたが、比較処理は遅くなりました。より現実に近い単語カウント用スクリプトでは40%の高速化が得られました。
  • Go 1.13で私によるstrings.TrimSpace最適化が取り込まれたため、GoAWKが少なくともGo 1.13を要求するようになった今、独自版のTrimSpace削除できました。

私が行った問題のある修正の一つは、length()substr()といったGoAWKの文字列関数を、バイトインデックスではなくUnicode文字のインデックスを使うように変更したことでした。これによりこれらの操作が文字列長に対してO(1)からO(N)になることはわかっていましたが、「Nは通常小さい」ので大した問題にはならないだろうと考えていました。

しかし、その想定は外れました。Volodymyr Gubarkov氏のgron.awkスクリプトでは、大きなJSONファイルの処理が1秒から8分以上に膨れ上がってしまったのです。いわゆる意図せぬ二次計算量です。これは到底許容できなかったため、一旦その修正を元に戻し、将来的にO(1)で対応する方法を考えることにしました。Gawkの長年のメンテナであるArnold Robbins氏も、Gawkが文字列処理を効率化するために多大な工夫をしていることについてコメントしています。

今後さらにGoAWKを最適化していきたいと考えており、今後の性能改善の作業を追跡するための包括的なissueを作成しました。以下にいくつかのアイデアを示します。

仮想マシンの改善。仮想マシンを高速化するために検討していることがいくつかあります。

  • スタック操作の最適化または削減。interp.pushメソッドはappendのチェックのために特に遅くなっています(そして通常のAWKコードではappendが必要になることはほとんどありません)。最大スタックサイズを事前に判断する良いアイデアがあれば教えてください。再帰的な関数呼び出しが起こりうる場合でも、それは可能なのでしょうか?
  • 追加できる特化したオプコードはあるでしょうか?例えば、引数の整数定数をプッシュするInt命令などです。Intを追加すればinterp.numsスライスへのメモリ参照を節約できます。
  • おそらくJumpLessなどのオプコードが文字列に対して使われることはあまりありません。少なくとも一方のオペランドの型チェックを避けるために、それらをJumpLessNumに置き換えた方が良いでしょうか?(文字列の場合はより長い命令シーケンスを使うことになります。)

文字列の連結も、2つ以上の文字列を連結する際の余分な確保とコピーのために不必要にコストがかかっています。現在、first_name " " last_nameのような複数連結の式は、2つの二項Concat命令にコンパイルされます。

Global first_name
Str " "
Concat
Global last_name
Concat

コンパイラがこれを検出して、例えば次のような新しいConcat numArgs命令を出力する方が効率的でしょう。

Global first_name
Str " "
Global last_name
Concat 3

これは命令が1つ少なくなるだけでなく、さらに重要なことに、一時的な文字列を確保してから新たに確保し直してバイトをコピーする手間を避けられます。AWKでは2つ以上の値を連結することは非常によくあり、連結する値が増えるほどこの最適化の効果は高まります。

正規表現も高速化できれば素晴らしいでしょう。GoAWKは現在Goのregexpパッケージを使っていますが、残念ながらかなり遅いのが現状です。そのため、正規表現を多用するAWKスクリプトでは、Gawkの約半分、Mawkの約4分の1の速度になってしまいます。

これを改善する方法は2つあります。

  1. 独自の正規表現エンジンを書く(おそらくMawkのものを直接Goに移植する)。これはおそらく多大な作業を要し、Goの境界チェックやコンパイラ最適化が少ないことから、それでもそれほど速くならないかもしれません。
  2. Goの正規表現エンジンの速度を改善する。これはGoのregexpパッケージを使うすべての人が恩恵を受けるため、はるかに良い方法です。ただし、これもおそらくかなり困難でしょう。私より賢い方々に任せることにします。いつの日かこれらのissueのいくつかが修正されるかもしれません。

仮想マシンの結果

では、仮想マシンインタプリタは実際どれくらい速くなったのでしょうか?マイクロベンチマーク(認めざるを得ませんが、AWKで書くようなスクリプトとはほとんど言えません)では、全体で約18%高速になりました。ここでは経過時間を示しているので、数値が小さいほど良い結果です(元の結果を見ることも、benchmark.shに続けてbenchstat.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と比べて何倍速いかを示しているため、数値が大きいほど良い結果となります。

テストgoawkorigawkgawkmawk
tt.01 (print)2.021.911.001.662.29
tt.02 (print NR NF)1.591.601.001.772.20
tt.02a (print length)1.541.561.001.732.05
tt.03 (sum length)1.321.271.003.851.83
tt.03a (sum field)1.291.261.004.081.79
tt.04 (printf fields)0.970.801.001.262.74
tt.05 (concat fields)0.950.881.001.612.26
tt.06 (count lengths)1.391.351.002.531.97
tt.07 (even fields)1.241.181.001.461.71
tt.08 (even lengths)1.971.981.001.132.70
tt.09 (regex starts with)2.132.131.002.415.01
tt.10 (regex ends with)0.340.341.001.453.40
tt.10a (regex ends with var)0.320.341.001.303.08
tt.11 (substr)2.202.131.001.153.68
tt.12 (update fields)1.211.191.001.701.78
tt.13 (array ops)3.142.621.003.115.92
tt.13a (array printf)2.251.801.001.864.85
tt.14 (function call)1.171.091.000.641.56
tt.15 (format lines)0.620.611.000.962.21
tt.16 (count words)1.471.211.001.272.12
tt.big (complex program)1.621.411.001.833.82
tt.x1 (mandelbrot)2.251.621.001.343.44
tt.x2 (sum loop)1.761.031.001.152.68
幾何平均1.451.281.001.862.32

まとめ

得られた性能向上には確かに満足しています。期待していたほどではありませんでしたが、多くのCPUバウンドな処理でGoAWKがGawkよりも速くなったという事実はなかなかクールです。とはいえ、性能を極限まで追求したMawkと比べると依然として常に遅いですし、AWKが通常使われる用途、すなわち文字列処理や正規表現に関しては、GoAWKにはまだまだ改善の余地があります。

正直なところ、追加の2500行のコードに見合う価値があったかどうかは、まだ確信が持てません(テストを含めても15,000行程度のプロジェクトでの話です)。もしこれを監督するエンジニアリングマネージャーがいたら、「それは実際のワークロードの役に立つのですか?」といった反発があっただろうと思います。しかし、GoAWKは今も昔も情熱を注いだプロジェクトです。作るのも共有するのも楽しかったので、それだけで十分です。

コンパイラと仮想マシンをマージし、GoAWK v1.15.0としてリリースしました。GoのAPIとgoawkコマンドは100%後方互換のはずです。私自身のインタプリタテストに加え、オリジナルのAWKやGawkの関連テストでも十分に検証していますが、もし不具合を見つけた場合はissueを立ててください。

この記事を楽しんでいただけた、あるいは何か学びがあったなら幸いです。ご感想やアイデアがあれば、ぜひ気軽にご連絡ください。

この記事は「muse-spark-1.2-contributor」を使用して翻訳されました。

コメント