Goのインタフェースがパフォーマンスに及ぼす影響

Go Advent Calendar 2015 その3 11日目です。その3まであるなんてGo大人気ですね。

Gopherというのはいろいろな人がいてLLからGoへ、という方も多いかと思います。

LLではそもそも全てがオブジェクトだったりで話題になりませんが、よりマシンに近く変態的に速度を重視される方が多いC++では例えば仮想関数や実行時キャストのコストが議論になります。

Goにおいてこういう多態性はインタフェースで表現されます。結論からいうと、 Goのインタフェースにもそれなりのコストがあります。 なので極限までパフォーマンスを要求される場合には 例えばインターフェースを使わない というも選択肢に入ってくるのではないかと思います。

Go言語におけるインタフェースの内部表現

さて、Goはブートストラップ化(言語処理系をその言語自身で実装する)を進めており、1.5ではGoのコンパイラ、ランタイムはほぼほぼGoで実装されています。ということはインターフェースもGoで実装されているはずですね。

Goのインタフェースは2個のポインタを持った struct です。例えば、 reflect/value.go では以下のようなコードがあります。

reflect/value.go :

 // emptyInterface is the header for an interface{} value.
 type emptyInterface struct {
     typ  *rtype
     word unsafe.Pointer
 }

 // nonEmptyInterface is the header for a interface value with methods.
 type nonEmptyInterface struct {
     // see ../runtime/iface.go:/Itab
     itab *struct {
         ityp   *rtype // static interface type
         typ    *rtype // dynamic concrete type
         link   unsafe.Pointer
         bad    int32
         unused int32
         fun    [100000]unsafe.Pointer // method table
     }
     word unsafe.Pointer
 }

コメントに書かれているように実態は runtime/iface.go ですがこちらのほうが簡略化されていてわかりやすいでしょう。1個目のポインタは 「空のインタフェース( iterface{} )」(これを eface という)の場合、型情報、そうでない場合(これを iface という)の場合 itab と呼ばれるC++における仮想テーブルのようなものです。2個目に値が格納されます。2個目はどちらもポインタであることに注意してください。

インターフェース経由のメソッド呼び出し

ここで、インターフェースを使用するとどの程度パフォーマンスペナルティがあるのか、様々なケースで見てみます。

まずは、直接メソッドを呼び出すのと、インタフェース経由で呼び出す場合の違いを見てみましょう。

 type MyError struct{}
 func (e *MyError) Error() string { return "error" }

 func BenchmarkNoInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         err := &MyError{}
         err.Error()
     }
 }

 func BenchmarkInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         var err error = &MyError{}
         err.Error()
     }
 }

結果は以下のようになります。10倍程度遅くなります。単位が単位なので微々たる違いですが極限までパフォーマンスを追求したい場合は考慮しておいたほうがいいかもしれませんね。

 BenchmarkNoInterface    2000000000               0.73 ns/op
 BenchmarkInterface      200000000                9.56 ns/op

しかし実はこの中には *MyErrorerror インタフェースに変換するコストも含まれていて、そのコストを除くと5倍くらいの遅さです。次はそのインタフェースへの変換コストを詳しく見ていこうと思います。

インターフェースへの変換コスト(非ポインタ型)

Goにおけるインタフェースの変換時はC++におけるキャストよりも複雑なことが発生します。

Goでは「なんでも」を表すために interface{} を使いますね。 int などの組み込み型かつ非ポインタ型を interface{} として処理するとなにが起こるでしょうか?以下のようにフィボナッチを計算させてみます。

 func IntFibNoInterface(n int) int {
     if n < 2 {
         return n
     }
     return IntFibNoInterface(n-1) + IntFibNoInterface(n-2)
 }

 func IntFibInterface(o interface{}) interface{} {
     n := o.(int)
     if n < 2 {
         return o
     }
     return IntFibInterface(n-1).(int) + IntFibInterface(n-2).(int)
 }

 func BenchmarkNoInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         IntFibNoInterface(20)
     }
 }

 func BenchmarkInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         IntFibInterface(20)
     }
 }

結果は以下。40 - 50倍くらいおそくなりますね。これを見ると気軽にインタフェースを使いたくなくなるかもしれません。

 BenchmarkNoInterface       30000             57938 ns/op               0 B/op          0 allocs/op
 BenchmarkInterface           500           2448250 ns/op          525376 B/op      32836 allocs/op

はじめに書いたように、インタフェースは ポインタとして値を保持します 。 そのためコード上アロケートしていなくても、内部的に int をアロケートしそこに値をコピーせねばなりません。

どのような処理が実行されたか、プロファイルを見てみましょう。

 1.46s of 1.46s total (  100%)
 Showing top 20 nodes out of 35 (cum >= 0.01s)
       flat  flat%   sum%        cum   cum%
      0.37s 25.34% 25.34%      0.68s 46.58%  runtime.mallocgc
      0.26s 17.81% 43.15%      1.44s 98.63%  12.IntFibInterface
      0.20s 13.70% 56.85%      0.20s 13.70%  runtime.memmove
      0.16s 10.96% 67.81%      0.16s 10.96%  runtime.mSpan_Sweep.func1
      0.12s  8.22% 76.03%      0.96s 65.75%  runtime.convT2E
      0.11s  7.53% 83.56%      0.31s 21.23%  runtime.typedmemmove
      0.10s  6.85% 90.41%      0.26s 17.81%  runtime.heapBitsSweepSpan
      0.05s  3.42% 93.84%      0.22s 15.07%  runtime.assertE2T
      0.02s  1.37% 95.21%      0.70s 47.95%  runtime.newobject
      0.02s  1.37% 96.58%      0.02s  1.37%  runtime.prefetchnta
      0.01s  0.68% 97.26%      0.01s  0.68%  runtime.(*gcControllerState).findRunnableGCWorker
      0.01s  0.68% 97.95%      0.01s  0.68%  runtime.lock
      0.01s  0.68% 98.63%      0.01s  0.68%  runtime.mSpanList_InsertBack
      0.01s  0.68% 99.32%      0.01s  0.68%  runtime.xadd64
      0.01s  0.68%   100%      0.01s  0.68%  runtime.xchg
          0     0%   100%      1.44s 98.63%  12.BenchmarkInterface
          0     0%   100%      0.01s  0.68%  runtime.GC
          0     0%   100%      0.01s  0.68%  runtime.deductSweepCredit
          0     0%   100%      0.01s  0.68%  runtime.gc
          0     0%   100%      0.01s  0.68%  runtime.gc.func4

非ポインタ型のインターフェースへの変換コスト

では、ということで自分でアロケーションしてみましょう。 int ではなく *int を使ってみます。

 func IntFibNoInterface(n int) int {
     if n < 2 {
         return n
     }
     return IntFibNoInterface(n-1) + IntFibNoInterface(n-2)
 }

 func IntFibPointer(i *int) *int {
     n := *i
     if n < 2 {
         return i
     }
     a1 := new(int)
     *a1 = n - 1
     a2 := new(int)
     *a2 = n - 2
     v1 := IntFibPointer(a1)
     v2 := IntFibPointer(a2)
     ret := new(int)
     *ret = *v1 + *v2
     return ret
 }

 func IntFibInterface(o interface{}) interface{} {
     n := *(o.(*int))
     if n < 2 {
         return o
     }
     a1 := new(int)
     *a1 = n - 1
     a2 := new(int)
     *a2 = n - 2
     v1 := IntFibInterface(a1).(*int)
     v2 := IntFibInterface(a2).(*int)
     ret := new(int)
     *ret = *v1 + *v2
     return ret
 }

 func BenchmarkNoInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         IntFibNoInterface(20)
     }
 }

 func BenchmarkPointer(b *testing.B) {
     for i := 0; i < b.N; i++ {
         a := new(int)
         *a = 20
         IntFibPointer(a)
     }
 }

 func BenchmarkInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         a := new(int)
         *a = 20
         IntFibInterface(a)
     }
 }

結果は以下のようになります。20-30倍というところでしょうか。こちらのほうがコードだけ見ると遅そうですが、なんと int を使った場合より速いのです。そして、インタフェースへの変換コストは少ない、ということがわかります。

 BenchmarkNoInterface       30000             55558 ns/op               0 B/op          0 allocs/op
 BenchmarkPointer            1000           1297598 ns/op          525376 B/op      32836 allocs/op
 BenchmarkInterface          1000           1357172 ns/op          525376 B/op      32836 allocs/op
 1.54s of 1.54s total (  100%)
 Showing top 20 nodes out of 37 (cum >= 0.01s)
       flat  flat%   sum%        cum   cum%
      0.75s 48.70% 48.70%      1.24s 80.52%  runtime.mallocgc
      0.22s 14.29% 62.99%      0.22s 14.29%  runtime.mSpan_Sweep.func1
      0.16s 10.39% 73.38%      0.38s 24.68%  runtime.heapBitsSweepSpan
      0.14s  9.09% 82.47%      1.38s 89.61%  runtime.newobject
      0.11s  7.14% 89.61%      1.49s 96.75%  12.IntFibInterface
      0.08s  5.19% 94.81%      0.08s  5.19%  runtime.prefetchnta
      0.02s  1.30% 96.10%      0.02s  1.30%  runtime.heapBitsForObject
      0.01s  0.65% 96.75%      0.01s  0.65%  runtime.(*bucket).mp
      0.01s  0.65% 97.40%      0.01s  0.65%  runtime.atomicload64
      0.01s  0.65% 98.05%      0.41s 26.62%  runtime.mCentral_CacheSpan
      0.01s  0.65% 98.70%      0.03s  1.95%  runtime.scanblock
      0.01s  0.65% 99.35%      0.01s  0.65%  runtime.schedule
      0.01s  0.65%   100%      0.01s  0.65%  runtime.xchg
          0     0%   100%      1.49s 96.75%  12.BenchmarkInterface
          0     0%   100%      0.04s  2.60%  runtime.backgroundgc
          0     0%   100%      0.01s  0.65%  runtime.deductSweepCredit
          0     0%   100%      0.04s  2.60%  runtime.gc
          0     0%   100%      0.02s  1.30%  runtime.gc.func1
          0     0%   100%      0.01s  0.65%  runtime.gc.func3
          0     0%   100%      0.01s  0.65%  runtime.gc.func4

非ポインタ型とポインタ型の違いは?

プロファイルをみるとわかりますが、 runtime.convT2E , runtime.assertE2T が非ポインタ型の場合重荷になっています。ここまで「ポインタ型」「非ポインタ型」という曖昧な表現をしていましたが、正確には これは isdirectiface という関数の結果が true かどうかです。これによりインターフェス変換の処理が変わります。

 // Can this type be stored directly in an interface word?
 // Yes, if the representation is a single pointer.
 func isdirectiface(t *Type) bool {
     switch t.Etype {
     case TPTR32,
         TPTR64,
         TCHAN,
         TMAP,
         TFUNC,
         TUNSAFEPTR:
         return true

         // Array of 1 direct iface type can be direct.
     case TARRAY:
         return t.Bound == 1 && isdirectiface(t.Type)

         // Struct with 1 field of direct iface type can be direct.
     case TSTRUCT:
         return t.Type != nil && t.Type.Down == nil && isdirectiface(t.Type.Type)
     }

     return false
 }

これが false になるオブジェクトをインタフェースとして利用する際は注意です。

時にはインタフェースを使わない、という選択も

以上のことから、特に性能が必要な場合、インタフェースを使わず擬似共用体(Goに共用体はないので)のような実装をしたほうがよい場合が出てきます。フィボナッチの例を struct で書いてみましょう。

 const (
     TypeInt int = iota
     TypeBool
 )

 type Object struct {
     Type      int
     IntValue  int
     BoolValue bool
 }

 func IntFibNoInterface(n int) int {
     if n < 2 {
         return n
     }
     return IntFibNoInterface(n-1) + IntFibNoInterface(n-2)
 }

 func IntFibStruct(o Object) Object {
     if o.IntValue < 2 {
         return o
     }
     return Object{TypeInt,
         IntFibStruct(Object{TypeInt, o.IntValue - 1, false}).IntValue +
             IntFibStruct(Object{TypeInt, o.IntValue - 2, false}).IntValue,
         false}
 }

 func BenchmarkNoInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         IntFibNoInterface(20)
     }
 }

 func BenchmarkStruct(b *testing.B) {
     for i := 0; i < b.N; i++ {
         IntFibStruct(Object{TypeInt, 20, false})
     }
 }

速度ですが、インタフェースを使うより断然早く、2-3倍程度しか遅くなりません。当たり前ですが、暗黙的なメモリアロケーションも発生しません。

 BenchmarkNoInterface       30000             58491 ns/op               0 B/op          0 allocs/op
 BenchmarkStruct            10000            139924 ns/op               0 B/op          0 allocs/op

reflect を使えば・・・というのもありますが、結局 reflect.ValueOf の引数が interface{} なので素直に使うと interface{} の場合と同様の遅さです。

 func IntFibNoInterface(n int) int {
     if n < 2 {
         return n
     }
     return IntFibNoInterface(n-1) + IntFibNoInterface(n-2)
 }

 func IntFibReflect(r reflect.Value) reflect.Value {
     n := r.Int()
     if n < 2 {
         return r
     }
     return reflect.ValueOf(IntFibReflect(reflect.ValueOf(n-1)).Int() + IntFibReflect(reflect.ValueOf(n-2)).Int())
 }

 func BenchmarkNoInterface(b *testing.B) {
     for i := 0; i < b.N; i++ {
         IntFibNoInterface(20)
     }
 }

 func BenchmarkReflect(b *testing.B) {
     for i := 0; i < b.N; i++ {
         IntFibReflect(reflect.ValueOf(20))
     }
 }
 BenchmarkNoInterface       30000             56238 ns/op               0 B/op          0 allocs/op
 BenchmarkReflect            1000           2271026 ns/op          525376 B/op      32836 allocs/op

ちなみにGopherLuaでは

拙作のGo言語によるLua実装 GopherLua では一番おそい、非ポインタ型をインタフェースとして使う実装になっています。なのでGopherLuaはおそらく現在でもGo上で動くスクリプト言語としては最速の部類ですがさらに速くしようと思えばできたのかもなあと思っています(まぁこの例ほど言語実装は単純ではないし、擬似共用体にするにしてもサイズが大きくなるので、速くなるかどうかはやってみないとなんともいえないのですが。あとサイズがおおきくなるのでスタック型のAPIにするよりないでしょうね)。

これは使い易さや実装のし易さやメモリ効率、そして「Goらしさ」とのトレードオフだと思っています。私は、とにかく使い易いものがほしかったので一番使い易く、そして実装が楽なものを選びました。

とはいえ、特に数値型をインタフェースに変換する負荷を軽減するために、独自のアロケータを実装しています。これは一定数の float64 をまとめてアロケートすることでインタフェース変換コストを減らしています。かなり強引なのですが興味のある方は gopher-lua/alloc.go をご参照ください。

最後に

インタフェースはGoで中心的な役割を果たしていますが、時に大幅なパフォーマンス劣化を起こす可能性があります。 特に、非ポインタ型を interface{} などとして引数に渡す場合が要注意です。

Go Advent Calendar 2015 その3 12日目は Ladicle さんです。

comments powered by Disqus