Escape Analysis and Stack Allocation on Go lang 1.2 レシーバ編

最近、Go言語にいろんな意味ではまっているので、調べたメモpart2。
前回のやつは色々と中途半端で間違っていたので、書き直し+go 1.2開発版 (go version devel +f1545db4a9c4)で実験。
最終版ではないので、今後もぼちぼち修正+更新するかもしれません。

Introduction

Go言語の特徴として、スタック領域ヒープ領域を区別しないという特徴がある。
そのため、C言語初心者がやるような、こんなプログラムを書いてもよい。

package main
import "fmt"

func main() {
	var a1 *int = call1()
	fmt.Printf("value = %d", *a1)
}

func call1() *int {
	var b1 int = 10
	return &b1
}

とはいえこれは、プログラマが明示的には区別しないというだけで、実際にはコンパイラがコードを解析し割り当て先を決めている。 ref: http://golang.jp/go_faq#stack_or_heap
一般に、変数はスタックに割り当てたほうが高速に動作する。確保も解放も速く、キャッシュに乗りやすい(たぶん)し、GCによって回収する必要もないからだ。
では、どのような変数がスタックに割り当てられるのか?
素直に考えると、↑のコードの`b1`はヒープに割り当てられるように見える。ポインタa1によってmain関数からb1を参照するためには、b1がcall1関数のスタック内にあるとダメだからだ。
が、実際はもうちょっと複雑である。Goのコンパイラはわりとかしこいので、インライン化エスケープ解析をしてくれる。
↑のコードのコンパイル中の動作を-mオプションで出力すると、こうなる。

go build -o /dev/null -gcflags -m main.go 2>&1| tee main
# command-line-arguments
./main.go:9: can inline call1
./main.go:5: inlining call to call1
./main.go:5: call1 &b1 does not escape
./main.go:6: main ... argument does not escape
./main.go:10: moved to heap: b1
./main.go:11: &b1 escapes to heap

main関数内ではcall1が展開され(インライン化)、b1がmain関数のスタックから外に出ないことが確認できた(エスケープ解析)結果、b1はスタックに割り当てられている。
一方、call1関数そのものは、b1がcall1関数のスタックから外に出ていることが解析されたため、b1をヒープに割り当てている。

という長い前振りは置いておいて、どういう条件ならスタックに割り当てられるのか?をいくつか実験してみたのでその結果。 コードはこのへん https://github.com/t-yuki/goalloctest

Analysis1

Source: analysis1.gopkg1/pkg1.go -> Result

Analysis1では、int型の引数と返り値をもつ、レシーバーつきの別パッケージの関数Max1/Max2/Max3/Max4を、呼び出し方を変えながらmainから呼び出している。
pkg1のMax1/Max2/Max3/Max4/Max5は以下の通り。

Method Description
Max1 公開パッケージ関数Maxを呼ぶ
Max2 非公開パッケージ関数maxを呼ぶ
Max3 非公開レシーバつき関数max3を呼ぶ
Max4 数値を比較し、大きいほうを返す
Max5 公開レシーバつき関数Max4を呼ぶ

Maxは非公開パッケージ関数maxを呼ぶ関数で、max/max3はMax4と同様に、数値を比較し、大きいほうを返すだけの関数である。

Resultから抽出した、HEAP/STACKの割り当てパターンを以下に示す。

Declaration Max1 Max2 Max3 Max4 Max5
var c pkg1.MaxerImpl HEAP HEAP STACK STACK STACK
d := new(pkg1.MaxerImpl) HEAP HEAP STACK STACK STACK
e := pkg1.NewMaxerImpl() HEAP HEAP STACK STACK STACK
f := pkg1.NewMaxer() HEAP HEAP HEAP HEAP HEAP

interfaceとして扱ったfは常にHEAPが使用された。
c-eについては、パッケージ関数を呼び出しているMax1/Max2がHEAPに割り当てられた一方で、Max3/Max4/Max5はSTACK割り当てになった。

mainについての出力をみると、pkg1のMax4, NewMaxerImpl, NewMaxerをインライン化していることがわかる。
他の関数を呼び出しているMax1/Max2/Max3/Max5はインライン化されていない。
どうやら、他の関数を呼び出している場合はインライン化しないようだ。(インライン化の深さやサイズに制限がある?)
pkg1についての出力をみると、すでにpkg1のコンパイル段階でインライン化しているようにみえるので、実質的にはどれも大差ないはずなので、いささか奇妙な振る舞いに感じる。
このあたりはコンパイラの出来次第だろうか。

pkg1についての出力をみると、Max4, NewMaxerImpl, NewMaxer関数は`can inline XXX`されている、ここでインライン化できると判定された関数のみが、他のパッケージから使用したときにインライン化されるのだろう。
加えて、Max3, Max5は`XXX does not escape`と、レシーバがエスケープするかしないかも含まれている。

これらのことから、Max1,2がHEAP割り当てでMax3,4,5がSTACK割り当てとなったのは、

1. pkg1のコンパイル時にレシーバがエスケープしないと判定できた(Max3,5)
2. インライン化できると判定されたので、mainのコンテキストで処理された(Max4)
3. パッケージ関数を呼び出しているので、エスケープすると判定された

ためと推測される。
3.の振る舞いはかなり奇妙に感じる。引数がわたっているならともかく、なぜまったく関与していないレシーバがエスケープすると判定されているのか?
いや、pkg1の出力を見る限りでは、Max1,2,4はレシーバがエスケープすると判定されたわけではないので、エスケープしないと判定されていないのは、ただのバグかパターン漏れなのかもしれない。
また、Go言語のメモリ割り当ての特性を考えると、もっとアグレッシブにインライン化できると判定してくれてもいい気はする。
このあたりも、コンパイラの出来次第だろうか。
結果、要約としては、

1. newで確保しようが、変数宣言で確保しようが関係ない
2. エスケープしないと判定されていない関数を呼び出すと、インライン化できない限りHEAPに割り当てられる
3. エスケープしないと判定された関数を呼び出すと、インライン化できなくてもSTACKに割り当てられる
4. interfaceとして扱うとからなずHEAPに割り当てられる

となる。