隙あらば寝る

うぇぶのかいしゃではたらくえんじにあがかいています

go で変数のメモリ割り当て状況を確認する

tl;dr

  • 気になったら testing.B で測定
  • -gcflags=-m を使って最適化状態を確認
  • heap を使っているつもりでもコンパイラが stack にのせてくれるケースがある

ベンチマーク

パフォーマンスについて調べるためにベンチマークを作成していた所、想定と異なる挙動があったので深追いしてみた。

heap と stack

func BenchmarkAllocationSmallSlice(b *testing.B) {
    for i := 0; i < b.N; i++ {
        data := make([]int, 1)
        somefunc(data)
    }
}

いきなりだが、このコードを go test -bench . -benchmem` すると以下の結果になる。

BenchmarkAllocationSmallSlice-4                  100000000              13.8 ns/op        0 B/op        0 allocs/op

0 allocs/op? int slice を make してるのになぜ?という疑問からスタート。

調べていると allocs/op` は heap に対する割り当てのみを対象としているらしい。

つまり stack に割り当てられたということになるので、これを確認する。

変数が heap/stack どちらに割り当てられたか確認するには、go コマンドに -gcflags=-m を渡すことで確認できる。

今回はテストコード(bench)なので以下のように実行。

go test -bench . -gcflags=-m -benchmem
...
./a_test.go:16: BenchmarkAllocationSmallSlice make([]int, 1) does not escape

このように、対象の行について make([]int, 1) does not escape という出力を得られた。

仮に heap に割り当てられた場合は escapes to heap と出力される。

調べ方が把握できたので、heap/stack の違いをパターン毎に確認していく。

大きな slice

stack は高速だが上限があり、一定以上のサイズになると割当できないはず。

試しに大きめの slice を作成してどうなるか確認してみる。

func BenchmarkAllocationLargeSlice(b *testing.B) {
    for i := 0; i < b.N; i++ {
        data := make([]int, 10000)
        somefunc(data)
    }
}
./a_test.go:23: make([]int, 10000) escapes to heap
BenchmarkAllocationLargeSlice-4                     50000      23631 ns/op    81920 B/op               1 allocs/op

予想通り escapes to heap1 allocs/op が得られた。

多くのメモリが必要になるケースでは heap が用いられる。

関数内で確保したメモリ

go は関数内で return するメモリは自動的に heap に割り当てられる。(よく C 言語と比較されるアレ)

次は slice 確保だけ行う関数を使ってみる。

func getSmallSlice() []int {
    return make([]int, 1)
}

func BenchmarkAllocationSmallSliceByFunction(b *testing.B) {
    for i := 0; i < b.N; i++ {
        data := getSmallSlice()
        somefunc(data)
    }
}
./a_test.go:34: inlining call to getSmallSlice
./a_test.go:34: BenchmarkAllocationSmallSliceByFunction make([]int, 1) does not escape
BenchmarkAllocationSmallSliceByFunction-4        100000000              13.3 ns/op        0 B/op        0 allocs/op

予想に反して allocation は 0。

最適化の情報をよく見ると、関数のインライン化が行われたということがわかる。

インライン化された結果、該当行に make 命令が存在することになり、stack 割当で大丈夫ということになったらしい。

heap に割り当たって遅くなるだろうと予想していたが、コンパイラが遅くならないよう工夫してくれたという結果になった。

inline なしで関数内で確保したメモリ

目的は動作を理解することなので、heap に割り当てられるパターンを見ておきたい。

動的に関数を作れば inline 化は行われないようだったので、以下のベンチを走らせてみる。

func BenchmarkAllocationSmallSliceByAnonFunction(b *testing.B) {
    for i := 0; i < b.N; i++ {
        data := func() []int {
            return make([]int, 1)
        }()
        somefunc(data)
    }
}
./a_test.go:42: make([]int, 1) escapes to heap
BenchmarkAllocationSmallSliceByAnonFunction-4    50000000         37.4 ns/op        8 B/op             1 allocs/op

期待どおり 1 alloc。ということで一通りの確認ができた。

まとめ

予想に反する挙動があったが、結果的には go コンパイラが頭良くて助けてくれていたということだった。

先にも書いたが testing.B で測定して、-gcflags=-m で最適化状況をちゃんと確認できるというツールの整備がありがたい。

最適化をすべて把握するのは難しいだろうが、気になったパターンぐらいは調べて認識しておきたい。

確認で使ったソース