この記事は MicroAd Advent Calendar 2022 の12日目の記事です。
「Goのポインタは8バイトだから、ちょっとした構造体を値渡しでコピーするよりポインタで渡した方が早くなる」
長らくそう思い込んでいたのですが、以下の記事でポインタ渡しには意外なデメリットが多いことを知り、誤解だと気づきました。
この記事では自分なりにポインタのデメリットをまとめつつ、ポインタ渡しで本当に良いのかを確認すべきパターンを紹介しようと思います。
ポインタが実は高価な理由
- ポインタが指す値にアクセスする際にnilかどうかのチェックが必ず入る
- ポインタがnilの場合、Goはpanic()をおこす必要があるため
- ポインタは動的メモリアロケーションの原因になりがち
- ポインタが指す値はヒープ領域に置かれがち(絶対ではないけど一般的に多い)
- ヒープ領域は確保にまとまったメモリの検索、解放にGCが必要になるので負荷が高い
- ポインタは参照の局所性が低くなりやすいため、CPUキャッシュが効きづらい
- 多くの場合、値のコピーはポインタを使用するオーバーヘッドよりもはるかに安価
- GoはDuff’s devicesという手法を用いてメモリコピーなどのよくある処理について非常に効率的なアセンブラコードを生成する
- x86アーキテクチャだと64バイト以下のオブジェクトであれば値のコピーとポインタのコピーはほぼ同じ
ポインタ渡しアンチパターン
以下、僕が個人的に気をつけるようになったポインタ渡しでパフォーマンスが下がりがちなケースです。
小さいオブジェクトのポインタ渡し
値渡しでも十分なサイズのオブジェクトにポインタ渡しを使ってしまっている例です。
type Pair struct { A, B int }
func Sum(p *Pair) int {
return t.A + t.B
}
実際に値渡しとポインタ渡しの比較をベンチマークテストで行ってみます。
type Pair struct{ A, B int }
func SumVal(p Pair) int {
return p.A + p.B
}
func SumPtr(p *Pair) int {
return p.A + p.B
}
func BenchmarkSumVal(b *testing.B) {
p := Pair{1, 2}
for i := 0; i < b.N; i++ {
SumVal(p)
}
}
func BenchmarkSumPtr(b *testing.B) {
p := &Pair{1, 2}
for i := 0; i < b.N; i++ {
SumPtr(p)
}
}
goos: linux
goarch: amd64
cpu: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz
BenchmarkSumVal
BenchmarkSumVal-32 1000000000 0.3102 ns/op 0 B/op 0 allocs/op
BenchmarkSumPtr
BenchmarkSumPtr-32 1000000000 0.3103 ns/op 0 B/op 0 allocs/op
上記の例では値渡しでもポインタ渡しでもパフォーマンスに大きな違いがないことが確認できました。
関数に副作用がないことを示すためにも、小さな構造体は値渡しで渡すのが無難かと思います。
ポインタを返す関数
主にコンストラクタで使ってしまいがちな例です。(以前の僕は全部のコンストラクタでポインタを返してました。。。)
関数内部で生成した構造体をポインタ渡しで関数外部に渡してしまうと、コンパイラが変数の寿命を判断できなくなり、動的メモリアロケーションが起きてしまいます。
また、参照の局所性も下がるので以下のようなコードは注意が必要です。
type Pair struct { A, B int }
func New(a, b int) *Pair {
return &Pair{a, b}
}
実際に値渡しとポインタ渡しの比較をベンチマークテストをで行ってみます。
type Pair struct { A, B int }
func NewVal(a, b int) Pair {
return Pair{a, b}
}
func NewPtr(a, b int) *Pair {
return &Pair{a, b}
}
// 変数の寿命をコンパイラから隠すために生成した構造体はグローバル変数に渡す
var val Pair
var ptr *Pair
func BenchmarkNewVal(b *testing.B) {
for i := 0; i < b.N; i++ {
val = NewVal(i, i+1)
}
}
func BenchmarkNewPtr(b *testing.B) {
for i := 0; i < b.N; i++ {
ptr = NewPtr(i, i+1)
}
}
goos: linux
goarch: amd64
cpu: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz
BenchmarkNewVal
BenchmarkNewVal-32 1000000000 0.6122 ns/op 0 B/op 0 allocs/op
BenchmarkNewPtr
BenchmarkNewPtr-32 27600812 43.30 ns/op 16 B/op 1 allocs/op
ポインタ渡しでは動的メモリアロケーションが起き、実行速度が大幅に遅くなっていることが確認できました。
この結果は、コンストラクタで生成されたオブジェクトをグローバル変数に渡さなければ起きません。
しかし、実用上コンストラクタで生成したオブジェクトを長く保持することは多く、上記のようなパフォーマンス低下は頻繁に起こります。
おわりに
いろいろ書きましたが、ホットパスな処理以外では値渡しでもポインタ渡しでも処理全体へのパフォーマンスにほぼ変化はありません。時期尚早な最適化は開発スピードを下げてしまいます。ベンチマークを取ってみてボトルネックになっている処理が見つかった場合に、不必要なポインタ渡しがないかを探すことをおすすめします。