Edited at
Go3Day 17

plugin機構を使って簡単なJITもどき(動的コンパイル)を実現して遊ぶ

More than 1 year has passed since last update.


はじめに

このエントリーはAdvent Calendar Go3 17日目の記事です。

今年は何か書いてみようと登録してみたものの、特にネタもなく、急遽ひねり出したネタでいくます。


きっかけ

Go1.8からLinuxのみですが、buildmode=pluginでコンパイルしたバイナリを動的に読み込んで使えるという機能が実装されました。

動的に読み込んで使えるのであれば動的にコンパイルして読み込んで使えばJITもどきとして使えるんじゃないのということで遊んでみました。


コンパイル環境

Go1.8+


実行環境

Go1.8+

Linux

*) Go1.10からMacでのpluginサポートが復活する様なのですが、残念ながら現時点ではクラッシュしてまともに動きませんでした。


plugin機構とは

pluginに関しては去年のAdvent Calendar Go2の13日目の記事があるのでそちらをご一読下さい。

ものすごくざっくりまとめると、go build -buildmode=pluginとしてパッケージをビルドするとGo仕様のShared Libraryが生成され、CGOの時の様なCの型を経由せずに動的にライブラリをロードして使えるという機能です。


実装方針

動的にビルドしたいソースコード(またはAST)を一時ディレクトリに書き出して、それを実行環境のGoを使ってpluginとしてビルドします。

ビルドが成功したらそのpluginを読み込んで、関数として使うだけ!簡単ですね。

以下がソースコードをビルドして読み込むサンプルコードです。

ビルドする関数はF0という名前で限定してしまっています。


jit.go

func (jit *JITCompiler) BuildSrc(src string) (interface{}, error) {

if err := makeTempDir(jit.buildDir); err != nil {
return nil, err
}

p, err := saveToFile(jit.buildDir, src)
if err != nil {
return nil, err
}

name := filepath.Base(p)
cmd := exec.Command("go", "build", "-buildmode=plugin", name)
cmd.Env = os.Environ()
cmd.Dir = jit.buildDir
cmd.Stderr = os.Stderr
cmd.Stdout = os.Stdout
if err := cmd.Run(); err != nil {
return nil, err
}

so, err := plugin.Open(strings.TrimSuffix(p, filepath.Ext(p)) + ".so")
if err != nil {
return nil, err
}

return so.Lookup("F0")
}



実験

こんなもので使い物になるか微妙ですね。というか使い物にならない気がします。

ということで、どの程度のものなのか試してみたくなるのがエンジニアの心情です。

そこで、普通に実装されたBrainf*ckインタプリタとJIT(動的コンパイル)あり実装したもので実行速度を比較してみました。


Brainf*ck 通常版

Golangの通常版は先人が既に書いたコードがありましたので、Go 言語で BrainFuck を実装してみたのコードを使わせて頂きました。


Brainf*ck JIT版(動的コンパイル版)


gojitbf.go

package main

import (
"bytes"
"fmt"
"io/ioutil"
"os"

"github.com/yanolab/gopjit"
)

func main() {
if len(os.Args) < 2 {
fmt.Println("Usage: " + os.Args[0] + " FILE")
return
}
buf, err := ioutil.ReadFile(os.Args[1])
if err != nil {
panic(err)
}

var b bytes.Buffer
for i := range buf {
switch buf[i] {
case '>':
fmt.Fprintf(&b, "p++\n")
case '<':
fmt.Fprintf(&b, "p--\n")
case '+':
fmt.Fprintf(&b, "b[p]++\n")
case '-':
fmt.Fprintf(&b, "b[p]--\n")
case ',':
case '.':
fmt.Fprintf(&b, "os.Stdout.Write(b[p:p+1])\n")
case '[':
fmt.Fprintf(&b, "for b[p] != 0 {\n")
case ']':
fmt.Fprintf(&b, "}\n")
}
}

src := fmt.Sprintf(`package main
import "os"
func F0() {
b := make([]byte, 30000)
p := 0
%s
}`
, b.String())

jit := gopjit.NewJIT()
sym, err := jit.BuildSrc(src)
if err != nil {
panic(err)
}

(sym.(func()))()
}


こちらはBrainf*ckのコードをそのままGoに変換して、コンパイルするものです。


比較結果

まずはHello Worldを表示するプログラム。前者がインタプリタ、後者がJIT(動的コンパイル)版です。

gopjit/example/bf$ time ./gobf hello.bf

Hello World!

real 0m0.001s
user 0m0.000s
sys 0m0.000s
gopjit/example/bf$ time ./gojitbf hello.bf
Hello World!

real 0m1.747s
user 0m1.740s
sys 0m0.112s

圧倒的にインタプリタの勝利ですね。JITもどきの方はコンパイルに時間がかかって勝負になっていません。

続いてそれなりに処理負荷があるハノイの塔を解くプログラムです。

(Brainf*ckのプログラムはbrainf*ckでハノイの塔のものを利用させて頂きました。)

gopjit/example/bf$ time ./gobf hanoi.bf > /dev/null

real 0m9.145s
user 0m8.320s
sys 0m0.824s

gopjit/example/bf$ time ./gojitbf hanoi.bf > /dev/null

real 0m4.729s
user 0m4.020s
sys 0m0.832s

JITの方が2倍近く早くなりました。

続いてもっと処理が重いものとして、マンデルブロ集合を描画するプログラムを実行してみました。

(世の中にはクレイジー(褒め言葉)な人がいるものですね。。。)

gopjit/example/bf$ time ./gobf mandelbrot.bf > /dev/null

real 0m39.090s
user 0m39.092s
sys 0m0.008s
gopjit/example/bf$ time ./gojitbf mandelbrot.bf > /dev/null

real 0m2.912s
user 0m2.952s
sys 0m0.132s

JITありの方が13倍近く結果となりました。


まとめ

Goコンパイラーにすべての最適化等をぶん投げる投げやり実装においてもわりと良い結果が得られてしまいました。

ひとえにGoコンパイラーが優秀であると言えると思います。

動的にコンパイルしている内容をGOSSAFUNCでダンプしてみるとどの様に最適化されているかがわかって面白いのですが、それはまた別の機会に。

今回のソースコード一式はgithub上に上げておきました。

func (jit *JITCompiler) Build(n *ast.Node) (interface{}, error)という関数も用意してありますので、Goの抽象構文木(AST)を手入力してHello, Worldを作る #golangastutil.Applyで抽象構文木を置き換える #golangを参考にASTいじり回して動的に実行して遊んでみるのも楽しいかも知れません。


おわりに

公式のドキュメントにもあるのですが、


The plugin support is currently incomplete, only supports Linux, and has known bugs. Please report any issues.


「pluginはまだ不完全でLinuxのみのサポートだしバグもあるよっ」ということなので利用には注意が必要です。