以前、このレポジトリがバズっていた。
bytebufが小さなサイズのバッファ用にあらかじめ用意しているbootstrapというバイト列が、エスケープ解析の際に必ずヒープ上に確保されてしまうため、buffer構造体も本来はスタック上に確保できるはずであるが、ヒープ上にエスケープされてしまうという問題があった。
つまり、64byte以下の小さなバッファであっても必ずヒープ上にアロケーションが走ってしまい、パフォーマンスが落ちていた。
この件は既にパッチが当たっており、正しくエスケープがされるよう修正されているのだが、これをきっかけに「なんでヒープに確保することが重たいんだっけ」「どういうときにエスケープされるんだっけ」などのメモリ周りを調べたメモ。
なんでヒープ確保が重たいの
スタックの確保はとてもシンプルで、関数呼び出し時にスタックを伸ばし、関数を抜けた時点でスタックを縮める。
比較して、ヒープ上へ確保する場合は、確保先の探索、GCなど様々な処理が走り、この差がパフォーマンスに効いてくる。
他の関数で参照される、参照を取得する必要があるといった場合を除き、極力エスケープしないようなコードが高速に動作する。
エスケープされる条件
基本的には、
- 関数内のみで参照される値は、スタック上に確保される
- そうでないものは、ヒープ上へ確保される
のだが、コンパイラが途中で解析を止め、エスケープする条件が複数ある。
この条件は日々変更が入ったり、今回のような特定の条件下で異なる動作をする場合があるため、網羅することはできないが(本来プログラマが意識するべきではないが)、2015年の時点で以下のリンクなどにまとまっている。
Go Escape Analysis Flaws - Google ドキュメント
Goの公式ドキュメントを読んでも「すべての条件を説明するのは複雑すぎるので、コンパイルフラグで実際にエスケープされるか確認して」と書いてある。 以下のコマンドで確認することが出来る。
$ go build -gcflags '-m' ./main.go
CompilerOptimizations · golang/go Wiki · GitHub
関数のインライン化
直接関係はないが、エスケープ解析に効いてくる最適化の一つに、関数のインライン化の解析がある。
Goのコンパイラは、関数が以下の条件に当てはまるとき、関数を直接、呼び出し元に展開する最適化を行っている。(これも上のコマンドで解析結果を確認できる)
- コードが80ノード以下(Go1.4以前は40ノード)
- 関数呼び出し、ループ、ラベル、クロージャ、panic、recover、select、switchなどの複雑な構文を含まない
CompilerOptimizations · golang/go Wiki · GitHub
このインライン化がどうメモリ周りに効いてくるかというと、インライン化が無い場合、関数の引数に値を渡した時点でその値はヒープ上に確保されてしまう。 しかし、インライン化された関数であれば、関数内での値の使用となるため、スタック上の確保で済む。
別の記事で、この最適化を意識したコードの例を考えてみたいと思う。
参考
CompilerOptimizations · golang/go Wiki · GitHub
Allocation efficiency in high-performance Go services · Segment Blog