LoginSignup
27
19

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-12

はじめに

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を定義

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

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

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]
    }
}
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 ^ Xsizeのことです。

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ファイル画像の一部が以下になります。

スクリーンショット 2019-12-11 22.46.14.png

CPU利用時間の約8割を占めているruntime.pthread_cond_signalruntime.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
(以下略)

スクリーンショット 2019-12-11 23.06.32.png

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などで言及されていますがエスケープするしないの直接な基準はわかりかねています。

参考

27
19
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
27
19