goで書いたコードがヒープ割り当てになるかを確認する方法

はじめに

Allocation Efficiency in High-Performance Go Services · Segment Blog という記事を読みました。素晴らしいのでぜひ一読をお勧めします。

この記事は自分の理解と実際に試してみた結果のメモです。

一番のポイントは go build -gcflags '-m' のようにオプションを指定してビルドすればコードのどの箇所でヒープ割り当てが発生したかを確認できるということです。

pprofgo test -benchmem でもヒープ割り当ての発生回数は確認できますが、上の方法ではコードのどこ(何行目の何カラム目)でヒープ割り当てが発生したかとなぜ発生したかの理由を確認できます。

元記事の内容メモ

冒頭にあげた記事を読んで私が理解した内容のメモです。 元記事の全ての内容を書いているわけでないので、元記事もぜひご覧ください。

一方、元記事にないけど読んで私が思った内容も追記していて、間違ったことを書いている可能性もあります。その場合はtwitterなどでご指摘いただけるとありがたいです。

動作確認した環境

動作確認した環境はUbuntu16.04でgoのバージョンは以下の通りです。

$ go version
go version go1.10rc1 linux/amd64

実際に試してみた

例1

1
2
3
4
5
6
7
8
package main

import "fmt"

func main() {
        x := 42
        fmt.Println(x)
}

-gcflags '-m' つきでビルドしてみた例。 7行目の x はスタック割り当てかと思いきやヒープ割り当てになります。

$ go build -gcflags '-m' main.go
# command-line-arguments
./main.go:7:13: x escapes to heap
./main.go:7:13: main ... argument does not escape

-gcflags '-m -m' つきでビルドするとより詳細な出力が出ます。

$ go build -gcflags '-m -m' main.go
# command-line-arguments
./main.go:5:6: cannot inline main: non-leaf function
./main.go:7:13: x escapes to heap
./main.go:7:13:         from ... argument (arg to ...) at ./main.go:7:13
./main.go:7:13:         from *(... argument) (indirection) at ./main.go:7:13
./main.go:7:13:         from ... argument (passed to call[argument content escapes]) at ./main.go:7:13
./main.go:7:13: main ... argument does not escape

xfmt.Println という関数の引数に渡されて、その引数がエスケープするので、 x もエスケープするということがわかります。

他の例も試しましたが、この記事では省略します。気になる方は元記事をご覧ください。

ちょっと注意 ^^^^^^^^^^^^

ちなみに -gcflags の指定を変えずに2回実行すると何も出力されませんでした。 コンパイルされたバイナリファイル (この場合は ./main) を消してから再度実行すれば出力されました。 ファイルを消さずに touch main.go してビルドしても出力されませんでした。

ファイルを消さずに go build-a オプションを指定するという手でも出来ましたが、コンパイル時間が長かったのでファイルを消すほうが良さそうです。

なお、 main.go を書き換えてから再度ビルドしたときはエスケープ分析の結果が出力されました。 普通はコード変更せずに2度ビルドしたりはせず、変更してからビルドするでしょうから、普段は意識する必要はなさそうです。

おわりに

元記事の最後にあったまとめを訳しておきます。

  1. 時期尚早な最適化はしないこと! 最適化するときは計測したデータに基づいて行うこと。
  2. スタック割り当ては安い(軽い処理)がヒープへの割り当ては高くつく(重い処理)。
  3. エスケープ分析のルールを理解することでより効率的なコードを書くことができる。
  4. ポインターがあるとほとんどの場合はスタック割り当てにできずヒープ割り当てになる。
  5. パフォーマンスクリティカルなコードのセクションではメモリ割り当てを制御できるAPIを提供することを検討する。
  6. ホットパス(繰り返し実行される処理)ではインターフェース型の使用は控えめにする(多用しない)。

補足すると 4. は上記の func (t Time) AppendFormat(b []byte, layout string) []byte のようにAPIの利用者が予め必要なメモリ割り当てをすることを可能にするようなAPIという意味です。 func (t Time) Format(layout string) string のほうが手軽に使えますが、戻り値がヒープ割り当てになってしまいます。パフォーマンスが重要な局面では AppendFormat のほうが制御する余地があるわけです。

あと元記事では出てませんでしたが、一時的なオブジェクトを繰り返し利用する場合は sync.Pool もパフォーマンス改善に役立ちます。 顕著な例が valyala/fasthttp: Fast HTTP package for Go. Tuned for high performance. Zero memory allocations in hot paths. Up to 10x faster than net/http でHTTPリクエストやレスポンスなどのオブジェクトを sync.Pool で管理し、リクエスト処理が終わったら回収して次のリクエスト処理で再利用することで高速化を実現しています。

ただ、 sync.Pool ではオブジェクトを使い終わった時点で func (p *Pool) Put(x interface{}) を明示的に呼ぶ必要があるのが面倒なところです。使い終わったことを伝えないとプールに回収できないので当然なのですが、メモリ管理をガベージコレクタに任せて気にしなくてよくなるという理想からは遠のくのがちょっと残念です。つまり自動ではなく手動管理なんですよね。 とはいえパフォーマンスクリティカルな箇所では速くなるほうが嬉しいのでトレードオフではあります。

ということで pprofgo test -benchmem に加えて go build -gcflags '-m' も活用していきたいですね。