はじめに
Should I Use a Pointer instead of a Copy of my Struct?といった記事をいくつか最近目にしました。
func NewMyStructVar() myStruct
な関数と func NewMyStructPtr() *myStruct
な関数はどう使い分けたら良いか腹落ちさせるために実験してみました。
この記事では計測して得たプロファイル結果を踏まえて私の"考察"、"推測"を記載しているのでご注意ください。確認が不足してる点もあるので随時更新したいです。
TL;DR
どちらが結果的に速いかというのは一意に決められません。返された値・ポインタの寿命と使われ方に依ります。
端的にまとめると、寿命が短く頻繁に呼ぶような使い方の場合は値の方が良く、低頻度に呼ばれ他の処理に渡して長く使い回すような場合はポインタの方が良いと言えそうです。
環境
$ go version
go version go1.13.1 darwin/amd64
Main Contents
値を返す関数NewMyStructVar
とポインタを返す関数NewMyStructPtr
を定義
package main
const size = 10
type myStruct struct {
arr [size]int
}
func NewMyStructVar() myStruct {
var ms myStruct
for i := 0; i < 1; i++ {
}
return ms
}
func NewMyStructPtr() *myStruct {
var ms myStruct
for i := 0; i < 1; i++ {
}
return &ms
}
for i := 0; i < 1; i++ {}
というのが明らかに不要なのですがこれを付けている理由は"補足1"に記載しています。
size
というのは構造体myStruct
が持つ配列フィールドarr
のサイズでこれが大きいと構造体1インスタンス当たりのアロケーションされるメモリ領域は大きくなります。
Short lifeに構造体を利用するBenchmarkとLong lifeに利用するBenchmark
i. Short lifeに構造体を利用するBenchmark
ii. Long lifeに利用するBenchmark
といったものを用意します。
package main
import (
"testing"
)
func BenchmarkVar(b *testing.B) {
var sum int
for i := 0; i < b.N; i++ {
v := NewMyStructVar()
sum += v.arr[0]
}
}
func BenchmarkPointer(b *testing.B) {
var sum int
for i := 0; i < b.N; i++ {
v := NewMyStructPtr()
sum += v.arr[0]
}
}
package main
import (
"testing"
)
func BenchmarkVar(b *testing.B) {
var list []myStruct
for i := 0; i < b.N; i++ {
v := NewMyStructVar()
list = append(list, v)
}
}
func BenchmarkPointer(b *testing.B) {
var list []*myStruct
for i := 0; i < b.N; i++ {
v := NewMyStructPtr()
list = append(list, v)
}
}
2つのベンチマークとも値が返る関数とポインタが返る関数を比較しますが、i.short_lifeな方は引っ張ってきた構造体v
はfor文スコープ内でしか使われませんが、ii.long_lifeな方はグローバルなリストlist
にappendされていきます。
比較してみる
go test -bench . -benchmem
上記の実行で2つのベンチマークで2つの関数の様子を比較してみます。
上述size
の値を可変にして計測しました。10 ^ X
がsize
のことです。
i.short_life ベンチマーク
10 ^ 2
BenchmarkVar-4 27091660 37.1 ns/op 0 B/op 0 allocs/op
BenchmarkPointer-4 8812474 119 ns/op 896 B/op 1 allocs/op
10 ^ 3
BenchmarkVar-4 4567400 246 ns/op 0 B/op 0 allocs/op
BenchmarkPointer-4 1000000 1003 ns/op 8192 B/op 1 allocs/op
10 ^ 4
BenchmarkVar-4 300658 3541 ns/op 0 B/op 0 allocs/op
BenchmarkPointer-4 162746 7458 ns/op 81920 B/op 1 allocs/op
10 ^ 5
BenchmarkVar-4 30100 37592 ns/op 0 B/op 0 allocs/op
BenchmarkPointer-4 18902 63361 ns/op 802818 B/op 1 allocs/op
10 ^ 6
BenchmarkVar-4 1958 606954 ns/op 0 B/op 0 allocs/op
BenchmarkPointer-4 2265 756371 ns/op 8003591 B/op 1 allocs/op
10 ^ 7
BenchmarkVar-4 28 42280720 ns/op 160006202 B/op 2 allocs/op
BenchmarkPointer-4 499 3792485 ns/op 80003078 B/op 1 allocs/op
ii.long_life ベンチマーク
10 ^ 2
BenchmarkVar-4 673123 2153 ns/op 4437 B/op 0 allocs/op
BenchmarkPointer-4 4755847 373 ns/op 941 B/op 1 allocs/op
10 ^ 3
BenchmarkVar-4 65912 15195 ns/op 42729 B/op 0 allocs/op
BenchmarkPointer-4 1000000 1217 ns/op 8237 B/op 1 allocs/op
10 ^ 4
BenchmarkVar-4 9891 222891 ns/op 457133 B/op 0 allocs/op
BenchmarkPointer-4 98650 29229 ns/op 81967 B/op 1 allocs/op
10 ^ 5
BenchmarkVar-4 1032 1108600 ns/op 2579090 B/op 0 allocs/op
BenchmarkPointer-4 8014 306176 ns/op 802851 B/op 1 allocs/op
10 ^ 6
BenchmarkVar-4 97 14752371 ns/op 21031165 B/op 0 allocs/op
BenchmarkPointer-4 793 3462450 ns/op 8003604 B/op 1 allocs/op
10 ^ 7
BenchmarkVar-4 7 146299135 ns/op 331436630 B/op 2 allocs/op
BenchmarkPointer-4 100 38625495 ns/op 80003093 B/op 1 allocs/op
結果について
i.short_lifeのベンチマークでは10^6
のオーダーまでは値を返す関数の方が処理速度が速く利用されるメモリ量も小さいことがわかります。(10^7
のオーダーでの逆転については"補足2"で言及しています。)
一方でii.long_lifeのベンチマークではポインタを返す関数の方が良いことがわかります。
i.short_lifeベンチマークでNewMyStructPtr
の方が遅い理由
NewMyStructVar
では1度コピーされてきた値がすぐにスタック領域から消えるのに対して、NewMyStructPtr
ではヒープ領域に入った構造体ポインタのためのガベージコレクションが頻繁に発生していることが原因と考えられます。
NewMyStructPtr
のランタイムの負荷の様子をpprofで見てみます。
$ go test -bench BenchmarkPointer -cpuprofile cpu.prof
$ pprof -top var_vs_pointer.test cpu.prof
Dropped 27 nodes (cum <= 0.02s)
flat flat% sum% cum cum%
1.75s 45.81% 45.81% 1.75s 45.81% runtime.pthread_cond_signal
1.08s 28.27% 74.08% 1.08s 28.27% runtime.pthread_cond_wait
0.31s 8.12% 82.20% 0.31s 8.12% runtime.pthread_cond_timedwait_relative_np
0.11s 2.88% 85.08% 0.11s 2.88% runtime.procyield
0.08s 2.09% 87.17% 0.08s 2.09% runtime.memclrNoHeapPointers
0.08s 2.09% 89.27% 0.08s 2.09% runtime.usleep
0.07s 1.83% 91.10% 0.07s 1.83% runtime.nanotime
0.05s 1.31% 92.41% 0.05s 1.31% runtime.wbBufFlush1
(以下省略)
Graphvizをインストールするとプロファイルを可視化できるで可視化もしてみます。
$ pprof -top var_vs_pointer.test cpu.prof
生成されたSVGファイル画像の一部が以下になります。
CPU利用時間の約8割を占めているruntime.pthread_cond_signal
、runtime.pthread_cond_wait
といったメソッドはGoのランタイムのガベージコレクション(以降GC)が走る際の同期処理時のロック・アンロックを実施しているようです。
i.short_lifeでのベンチマークではfor i := 0; i < b.N; i++
のループの度にヒープ領域に確保される領域が実行中もGCの対象になりそのためにロック・アンロックが頻繁に発生しているので遅くなっていると推測できます。
ii.long_lifeベンチマークでNewMyStructVar
の方が遅い理由
上述と同様にgo test -bench BenchmarkVar -cpuprofile cpu.prof
でプロファイルをとってみます。
Dropped 13 nodes (cum <= 0.02s)
flat flat% sum% cum cum%
1.29s 41.75% 41.75% 1.29s 41.75% runtime.usleep
1.20s 38.83% 80.58% 1.20s 38.83% runtime.memmove
0.21s 6.80% 87.38% 0.21s 6.80% runtime.memclrNoHeapPointers
0.18s 5.83% 93.20% 0.18s 5.83% runtime.nanotime
0.11s 3.56% 96.76% 0.11s 3.56% runtime.madvise
(以下略)
runtime.memmove
に関しては明らかにappend(list, v)
によるスライスの拡張処理によるものだとわかります。runtime.usleep
は現在のGo(Go1.13時点)のGCの実装で採用されている"Concurrent Mark & Sweep"というアルゴリズムでのメモリ位置に対するマーキング処理によるもののようですが深く追えていません。
(Go Blog ではGo GC: Prioritizing low latency and simplicityの記事で言及されています。GCの実装について今後勉強していきたいです。)
また、ii.long_lifeでのNewMyStructPtr
での様子を見ると
Showing nodes accounting for 1.85s, 100% of 1.85s total
flat flat% sum% cum cum%
1.63s 88.11% 88.11% 1.63s 88.11% runtime.memclrNoHeapPointers
0.14s 7.57% 95.68% 0.14s 7.57% runtime.madvise
0.02s 1.08% 96.76% 0.02s 1.08% runtime.(*gcSweepBuf).push
0.02s 1.08% 97.84% 0.02s 1.08% runtime.(*mheap).setSpans
0.01s 0.54% 98.38% 1.65s 89.19% runtime.mallocgc
0.01s 0.54% 98.92% 0.01s 0.54% runtime.nanotime
0.01s 0.54% 99.46% 0.01s 0.54% runtime.unlock
0.01s 0.54% 100% 0.01s 0.54% runtime.usleep
(以下略)
このようにi.short_lifeの場合とは異なりruntime.pthread_cond_signal
などが無いことからGCによる処理が少なくメモリアロケーションの処理がメインの高負荷の原因となっていることがわかります。
補足
補足1 for i := 0; i < 1; i++ {}
を入れたのはコンパイル時のインライン展開がされないようにするため
この余計なfor文を省いてi.short_lifeベンチマークにて計測すると以下のようにNewMyStructPtr
ではメモリアロケーションが発生しなくなり差がなくなりました。
BenchmarkVar-4 1000000000 0.301 ns/op 0 B/op 0 allocs/op
BenchmarkPointer-4 1000000000 0.300 ns/op 0 B/op 0 allocs/op
なぜこのようになるか初めはわからずだったのですがどうやらコンパル時のインライン展開が関係あったようです。
無駄なfor文を除いて
$ go test -c -gcflags=-m
を実施してコンパル時の最適化の様子を見ると
./new.go:14:6: can inline NewMyStructPtr
./new.go:9:6: can inline NewMyStructVar
./bench_test.go:10:22: inlining call to NewMyStructVar
./bench_test.go:18:22: inlining call to NewMyStructPtr
このように関数がinlining
されてbench_test.go
の中に組み込まれていることがわかります。そのためi.short_lifeのケースでは構造体のポインタ変数はfor文スコープの中でスタック領域としてGCの力を借りずに寿命を終えるのでアロケーションがされなかったと推測できます。
補足2 i.short_lifeベンチマークのNewMyStructVar
でsizeが10^7
からアロケーションが発生したのはなぜか
sizeを10^6
から10^7
にしたタイミングで突然に変数がアロケーションがされるようになりました。これも上述と同様にコンパイル時のインライン展開の様子を見るとコンパイル時にてヒープ領域に入れる判断がされるようになっていることを確認しました。
$ go test -c -gcflags=-m
./new.go:10:6: moved to heap: ms # NewMyStructVar関数の返り値もヒープ領域へ案内されるようになる
なのでGoのコンパイラが構造体のサイズを考慮してヒープ領域に入れることを判断しているとわかります。
コンパル時のインライン展開・エスケープ解析については公式ではCompilerOptimizationsなどで言及されていますがエスケープするしないの直接な基準はわかりかねています。
参考
- https://medium.com/a-journey-with-go/go-should-i-use-a-pointer-instead-of-a-copy-of-my-struct-44b43b104963
- https://medium.com/@philpearl/bad-go-pointer-returns-340f2da8289
- https://deeeet.com/writing/2016/05/08/gogc-2016/
- https://blog.golang.org/go15gc
- https://github.com/golang/go/wiki/CompilerOptimizations