Goby: Rubyライクな言語(3)Go言語の`defer`を減らしたら10%以上高速化した話など

こんにちは、hachi8833です。ゴールデンウィーク中日いかがお過ごしでしたしょうか。

Gobyを完全に理解してから書こうとするといつまでたっても書けないので、悩んでいることも含めて書くことにしました。

Go言語のdeferはかなり遅い

ごく最近、GobyのSlackで「ちょいとベンチマーク取ってみたら、改良できそうなところが目についた: Goのdeferはコストが高いので、これを減らすだけで高速化できそう」という書き込みがありました。

それを受けてst0012さんが早速VMのスタックからdeferを取り除きました。正直、私がVM部分のコードをまじまじと覗き込んだのはこれが初めてかもしれません。

...
-   *sync.RWMutex
+   sync.RWMutex
 }

 func (s *stack) set(index int, pointer *Pointer) {
    s.Lock()

-   defer s.Unlock()
-
    s.Data[index] = pointer
+
+   s.Unlock()
 }

 func (s *stack) push(v *Pointer) {
    s.Lock()
-   defer s.Unlock()

    if len(s.Data) <= s.thread.sp {
        s.Data = append(s.Data, v)
    } else {
        s.Data[s.thread.sp] = v
    }

    s.thread.sp++
+   s.Unlock()
 }
...

defer s.Unlock()を外してs.Unlock()に置き換えています。ついでに*sync.RWMutexのポインタもやめています。

ベンチマークの結果、基本的な演算が確かに速くなってます。Gobyの最適化は機能が揃ってからの予定ですが、こういうリファクタリングは歓迎ですね。

go test -run '^$' -bench '.' -benchmem -benchtime 2s ./... > .tmp_benchmarks
benchcmp current_benchmarks .tmp_benchmarks
benchmark                         old ns/op     new ns/op     delta
BenchmarkBasicMath/add-8          6500          5783          -11.03%
BenchmarkBasicMath/subtract-8     6461          5723          -11.42%
BenchmarkBasicMath/multiply-8     6484          5709          -11.95%
BenchmarkBasicMath/divide-8       6445          5768          -10.50%

benchmark                         old allocs     new allocs     delta
BenchmarkBasicMath/add-8          78             78             +0.00%
BenchmarkBasicMath/subtract-8     78             78             +0.00%
BenchmarkBasicMath/multiply-8     78             78             +0.00%
BenchmarkBasicMath/divide-8       78             78             +0.00%

benchmark                         old bytes     new bytes     delta
BenchmarkBasicMath/add-8          3792          3792          +0.00%
BenchmarkBasicMath/subtract-8     3792          3792          +0.00%
BenchmarkBasicMath/multiply-8     3792          3792          +0.00%
BenchmarkBasicMath/divide-8       3792          3792          +0.00%

と思ったら、さらに「deferをさらに減らしてもっと速くした」「スレッドをAPI化してVMから切り出してみようと思う」と追い打ちが来ました。何それ凄い。
まだ私は全然追いきれてませんが、「こういうのはスレッドあたりのミューテックスやdeferを1つだけにしておくのがいいんですよ」だそうです。

benchmarking 2fd6637efac2bf8c371f020ab1e95aa0f7606873
benchmarking 5cfab57be7564a02bf15dd8ac63b9e66685014d9
universal.x86_64-darwin17
benchmark                         old ns/op     new ns/op     delta
BenchmarkBasicMath/add-8          5950          5473          -8.02%
BenchmarkBasicMath/subtract-8     5884          5416          -7.95%
BenchmarkBasicMath/multiply-8     5967          5418          -9.20%
BenchmarkBasicMath/divide-8       5916          5502          -7.00%

benchmark                         old allocs     new allocs     delta
BenchmarkBasicMath/add-8          78             78             +0.00%
BenchmarkBasicMath/subtract-8     78             78             +0.00%
BenchmarkBasicMath/multiply-8     78             78             +0.00%
BenchmarkBasicMath/divide-8       78             78             +0.00%

benchmark                         old bytes     new bytes     delta
BenchmarkBasicMath/add-8          3792          3792          +0.00%
BenchmarkBasicMath/subtract-8     3792          3792          +0.00%
BenchmarkBasicMath/multiply-8     3792          3792          +0.00%
BenchmarkBasicMath/divide-8       3792          3792          +0.00%
Switched to branch 'thread-rework'

いずれにしろまだ作業中なので、今後が楽しみです。

最近のGoby

Ruby X Elixir Conf Taiwan 2018での発表

作者のst0012さんがRuby X Elixir Conf Taiwan 2018の2日目にGobyについて発表しました。なおst0012さんは台湾の方です。

スライドをWikiに設置

前回の記事に掲載したGobyの紹介スライドをWikiに配置しました。GitHubのMarkdownにはスライドや動画の埋め込みができないので、考えた挙句画像だけ貼り、そこからリンクでスライドに飛ばすようにしました。

「アンスコ数字形式1_000_000が欲しい」

#660で、1_000_000みたいなリテラル形式が欲しいというリクエストがありました。ついでに0b001001とか1.2e-3なども欲しいという意見もあり、「いずれは全部取り入れたいけど今じゃない」とst0012さんが締めました。

Ripperクラスを作ってみた

Gobyでいずれlinter的なことを標準で行うのであれば、RubyのRipperみたいな標準パーサーがいずれ必要になるだろうと思って、見よう見まねでよちよち作ってみました(#658)。Goby自身のlexerやparserに委譲するだけなのでそんなに大変ではないかなと思ったのですが、型変換が意外にめんどかった…

func builtInRipperClassMethods() []*BuiltinMethodObject {
    return []*BuiltinMethodObject{
...
            Name: "instruction",
            Fn: func(receiver Object, sourceLine int) builtinMethodBody {
                return func(t *thread, args []Object, blockFrame *normalCallFrame) Object {
                    if len(args) != 1 {
                        return t.vm.initErrorObject(errors.ArgumentError, sourceLine, "Expect 1 argument. got=%d", len(args))
                    }

                    arg := args[0]
                    switch arg.(type) {
                    case *StringObject:
                    default:
                        return t.vm.initErrorObject(errors.TypeError, sourceLine, errors.WrongArgumentTypeFormat, classes.StringClass, arg.Class().Name)
                    }

                    i, err := compiler.CompileToInstructions(arg.toString(), NormalMode)
                    if err != nil {
                        return t.vm.initErrorObject(errors.TypeError, sourceLine, errors.InternalError, classes.StringClass, errors.InvalidGobyCode)
                    }

                    return t.vm.convertToTuple(i)
                }
            },
...

func (vm *VM) convertToTuple(instSet []*bytecode.InstructionSet) *ArrayObject {
    ary := []Object{}
    for _, insts := range instSet {
        hInsts := make(map[string]Object)
        hInsts["name"] = vm.initStringObject(insts.Name())
        hInsts["type"] = vm.initStringObject(insts.Type())
        if insts.ArgTypes() != nil {
            hInsts["arg_types"] = vm.getArgNameType(insts.ArgTypes())
        }
        ary = append(ary, vm.initHashObject(hInsts))

        aInst := []Object{}
        for _, ins := range insts.Instructions {
            hInst := make(map[string]Object)
            hInst["action"] = vm.initStringObject(ins.Action)
            hInst["line"] = vm.initIntegerObject(ins.Line())
            hInst["source_line"] = vm.initIntegerObject(ins.SourceLine())
            anchor, _ := ins.AnchorLine()
            hInst["anchor"] = vm.initIntegerObject(anchor)

            aParams := []Object{}
            for _, param := range ins.Params {
                aParams = append(aParams, vm.initStringObject(param))
            }
            hInst["params"] = vm.initArrayObject(aParams)

            if ins.ArgSet != nil {
                hInsts["arg_set"] = vm.getArgNameType(ins.ArgSet)
            }

            aInst = append(aInst, vm.initHashObject(hInst))
        }

        hInsts["instructions"] = vm.initArrayObject(aInst)
        ary = append(ary, vm.initHashObject(hInsts))
    }
    return vm.initArrayObject(ary)
}

func (vm *VM) getArgNameType(argSet *bytecode.ArgSet) *HashObject {
    h := make(map[string]Object)

    aName := []Object{}
    for _, argname := range argSet.Names() {
        aName = append(aName, vm.initStringObject(argname))
    }
    h["names"] = vm.initArrayObject(aName)

    aType := []Object{}
    for _, argtype := range argSet.Types() {
        aType = append(aType, vm.initIntegerObject(argtype))
    }

    h["types"] = vm.initArrayObject(aType)
    return vm.initHashObject(h)
}

instructionメソッドの部分を抜粋してみました。配列の中にハッシュや別の配列が入る形になっています。Gobyの配列はGoのスライスを、ハッシュはmapを使っていますが、使い分けが少々ややこしいです。
プルリクが通るかどうかはわかりませんが、正味2日ほどで作れたのはIDE(JetBrainsのGoland)という強い味方のおかげです。感謝です。私がRubyのようにC言語で書くとしたら何年かかるかわかったものではありません。適当ですが、mattnさんなら3時間もあれば作っちゃうんじゃないかしら。

とはいうもののまだ改良の余地はあって、何とGo言語にはソースの(定数の値ではなく)定数名を直接取得する手段がないらしいので、一部を以下のような直書きでしのいでいます。コンパイラ言語だししょうがないか。

func convertLex(t token.Type) string {
    var s string
    switch t {
    case token.Asterisk:
        s = "asterisk"
    case token.And:
        s = "and"
    case token.Assign:
        s = "assign"
...
    default:
        s = strings.ToLower(string(t))
    }

    return "on_" + s
}

いろいろ調べたところ、GoのStringerというライブラリを使えば、型指定されている定数を元に定数名を取得する関数を生成できるようなのですが、試してみると定数値が1, 2, 3のような連続した数値(iotaとかいうのを使うと簡単にできる)でないとうまくいかない感じなので諦めました。

参考: go generateでコードを自動生成する

さらに調べたところ、go generateコマンドをコードのコメントに書いておけば任意のUnixコマンドをトリガできるらしいので、それを使ってGobyのtoken.goからこのコードを自動生成するスクリプトを書くのがよさそうです。そのうちやります。

ただしgo buildでは自動生成がトリガされないので、手動なりmakeなりシェルスクリプトなりでコンパイル前にgo generateを実行しておく必要があるようです。確かにセキュリティを考えればそんなことしない方がいいでしょうね(go getとかgo installで知らないコマンドが実行されたらコワイ)。

その後st0012さんから、「#662でローダブルなライブラリをVMから切り出す作業が進められていて、Ripperもそれと同じように配置すべきなので、そっちが終わったら再開しようか」とコメントがありました。(`Д´)ゞラジャー!!

関連記事

Goby: Rubyライクな言語(2)Goby言語の全貌を一発で理解できる解説スライドを公開しました!

Goby: Rubyライクな言語(1)Gobyを動かしてみる

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

BPSアドベントカレンダー

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ