【Go】関数からポインタを返すのは値を返すより遅くなる？


# はじめに

[Should I Use a Pointer instead of a Copy of my Struct?](https://medium.com/a-journey-with-go/go-should-i-use-a-pointer-instead-of-a-copy-of-my-struct-44b43b104963)といった記事をいくつか最近目にしました。

`func NewMyStructVar() myStruct` な関数と `func NewMyStructPtr() *myStruct` な関数はどう使い分けたら良いか腹落ちさせるために実験してみました。

この記事では計測して得たプロファイル結果を踏まえて私の"考察"、"推測"を記載しているのでご注意ください。確認が不足してる点もあるので随時更新したいです。

# TL;DR

どちらが結果的に速いかというのは一意に決められません。返された値・ポインタの寿命と使われ方に依ります。
端的にまとめると、寿命が短く頻繁に呼ぶような使い方の場合は値の方が良く、低頻度に呼ばれ他の処理に渡して長く使い回すような場合はポインタの方が良いと言えそうです。

# 環境

```
$ go version
go version go1.13.1 darwin/amd64
```

# Main Contents

## 値を返す関数`NewMyStructVar`とポインタを返す関数`NewMyStructPtr`を定義


```new.go
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

といったものを用意します。

```go:i.short_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]
	}
}
```

```go:ii.long_lifeな利用Benchmark
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](https://github.com/google/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](http://www.graphviz.org/)をインストールするとプロファイルを可視化できるで可視化もしてみます。

```
$ pprof -top var_vs_pointer.test cpu.prof
```

生成されたSVGファイル画像の一部が以下になります。

<img width="1040" alt="スクリーンショット 2019-12-11 22.46.14.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91562/6e7a2941-5af9-1907-3a66-a80ee9292691.png">

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
(以下略)
```

<img width="739" alt="スクリーンショット 2019-12-11 23.06.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/91562/67b08bd4-2d43-177f-c968-bca08f56efec.png">

`runtime.memmove`に関しては明らかに`append(list, v)`によるスライスの拡張処理によるものだとわかります。`runtime.usleep`は現在のGo(Go1.13時点)のGCの実装で採用されている"Concurrent Mark & Sweep"というアルゴリズムでのメモリ位置に対するマーキング処理によるもののようですが深く追えていません。
(Go Blog では[Go GC: Prioritizing low latency and simplicity](https://blog.golang.org/go15gc)の記事で言及されています。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://github.com/golang/go/wiki/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