はじめに
Goはスタックとヒープの扱いをいい感じにやってくれる。
しかし、ヒープよりもスタックの方が高速なので、なるべくスタックの方を使いたい。
そこで、エスケープ解析を意識してヒープ割当を減らしてみる
エスケープ解析を出力
go build gcflags="-m -l" sample.go
go test gcflags="-m -l" -bench=.
-
-m
エスケープ解析を出力 -
-l
関数のインライン化をオフにする (今回はより簡単な例で説明したいのでオフにする。インライン化の影響でエスケープされないことがあるので)
実践
例1
最初に↓のベンチマークを実行してみます
package main
import "testing"
func Escape() *int {
v := 1000
return &v
}
func NoEscape() int {
v := 1000
return v
}
func BenchmarkEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = Escape()
}
}
func BenchmarkNoEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
_ = NoEscape()
}
}
結果↓
$ go test -bench=. -gcflags "-m -l"
# github.com/Kooooya/ground/escape_analysis
./01_test.go:7: &v escapes to heap
./01_test.go:6: moved to heap: v
.....
BenchmarkNoEscape-4 2000000000 1.57 ns/op
BenchmarkEscape-4 100000000 15.3 ns/op
エスケープしてる方が10倍ほど遅いですね。
./01_test.go:7: &v escapes to heap
./01_test.go:6: moved to heap: v
こちらが&vがヒープに割り当て(エスケープ)られたというログです。
ヒープに割り当てられなければ、BenchmarkEscapeが6行目のvの値を参照することができません。
例2
package main
import "testing"
func Escape() *int {
s := []int{1, 2, 3, 4, 5}
y := &s[0]
return y
}
func NoEscape() int {
s := []int{1, 2, 3, 4, 5}
y := s[0]
return y
}
func BenchmarkEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
Escape()
}
}
func BenchmarkNoEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
NoEscape()
}
}
# command-line-arguments
./02_test.go:7: &s[0] escapes to heap
./02_test.go:6: []int literal escapes to heap
./02_test.go:12: NoEscape []int literal does not escape
.....
BenchmarkEscape-4 50000000 27.9 ns/op
BenchmarkNoEscape-4 500000000 3.33 ns/op
約8倍ほどの差が出ています。
注目するべきは
./02_test.go:7: &s[0] escapes to heap
./02_test.go:6: []int literal escapes to heap
スライスがエスケープされています。
Goはスライスの要素の1つでもエスケープされてしまうと、 スライス全体がエスケープされます。
なので、戻り値とするスライスの要素を関数のローカル変数に入れて、そこのポインタを返却する用に変更すると、エスケープされる値が小さくなるので高速化されます。
package main
import "testing"
func LittleEscape() *int {
s := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3,......}
y := s[0]
return &y
}
func Escape() *int {
s := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 1, 2, 3,......}
return &s[0]
}
func BenchmarkLitteEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
LittleEscape()
}
}
func BenchmarkEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
Escape()
}
}
./02_test.go:8: &y escapes to heap
./02_test.go:7: moved to heap: y
./02_test.go:6: LittleEscape []int literal does not escape
./02_test.go:13: &s[0] escapes to heap
./02_test.go:12: []int literal escapes to heap
.....
BenchmarkLitteEscape-4 500000 3104 ns/op
BenchmarkEscape-4 100000 11782 ns/op
例3
mapにセットしたポインタは必ずエスケープされます。
package main
import "testing"
func Escape() {
s := map[string]*int{}
i := 1
s["a"] = &i
}
func NoEscape() {
s := map[string]int{}
i := 1
s["a"] = i
}
func BenchmarkEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
Escape()
}
}
func BenchmarkNoEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
NoEscape()
}
}
./04_test.go:8: &i escapes to heap
./04_test.go:7: moved to heap: i
./04_test.go:6: Escape map[string]*int literal does not escape
./04_test.go:12: NoEscape map[string]int literal does not escape
.....
BenchmarkEscape-4 10000000 203 ns/op
BenchmarkNoEscape-4 10000000 188 ns/op
例4
sliceも同様にセットしたポインタは必ずエスケープされます。
package main
import "testing"
func Escape() {
s := make([]*int, 1)
i := 1
s[0] = &i
}
func NoEscape() {
s := make([]int, 1)
i := 1
s[0] = i
}
func BenchmarkEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
Escape()
}
}
func BenchmarkNoEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
NoEscape()
}
}
.....
./05_test.go:8: &i escapes to heap
./05_test.go:7: moved to heap: i
./05_test.go:6: Escape make([]*int, 1) does not escape
./05_test.go:12: NoEscape make([]int, 1) does not escape
.....
BenchmarkEscape-4 100000000 21.0 ns/op
BenchmarkNoEscape-4 300000000 4.72 ns/op
こちらは結構違いますね。
例5
構造体のフィールドであってもエスケープされるものはされます
package main
import "testing"
type S struct {
M *int
}
func Escape(y int) (z S) {
z.M = &y
return z
}
func NoEscape(y *int) (z S) {
z.M = y
return z
}
func BenchmarkEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
Escape(i)
}
}
func BenchmarkNoEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
NoEscape(&i)
}
}
# command-line-arguments
./06_test.go:10: &y escapes to heap
./06_test.go:9: moved to heap: y
./06_test.go:14: leaking param: y to result z level=0
.....
BenchmarkEscape-4 100000000 17.4 ns/op
BenchmarkNoEscape-4 500000000 4.29 ns/op
↓の関数がなぜリークしないかというと
func NoEscape(y *int) (z S) {
z.M = y
return z
}
yは一個前のスタックフレームに乗っている値なので、ヒープ上になくても知っているからです。
例6
package main
import "testing"
type S struct {
M *int
}
func Escape(y *int, z *S) {
z.M = y
}
func NoEscape(y *int) (z S) {
z.M = y
return z
}
func BenchmarkEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
var s S
Escape(&i, &s)
}
}
func BenchmarkNoEscape(b *testing.B) {
for i := 0; i < b.N; i++ {
NoEscape(&i)
}
}
.....
./07_test.go:9: leaking param: y
./07_test.go:9: Escape z does not escape
./07_test.go:13: leaking param: y to result z level=0
./07_test.go:21: &i escapes to heap
./07_test.go:19: moved to heap: i
.....
BenchmarkEscape-4 200000000 8.18 ns/op
BenchmarkNoEscape-4 500000000 2.97 ns/op
例5で前のスタックフレームの変数なのでという説明をしましたが、
func Escape(y *int, z *S) {
z.M = y
}
Goのコンパイラは入力と出力の流れでしか解析できないので、こちらはエスケープされます
まとめ
- gcflags="-m -l"
- GCある言語でも、完全にメモリのことを把握しなくてよいわけではない
- スタック使ってても遅い場合がある。ベンチマークを取る