🚀

Goの構造体の作成&初期化のヘルパ関数について検証する

2024/02/25に公開

目的

Goの構造体を作成し初期化する関数(単純なファクトリ)を作っている場合、ポインタ型で返す例をよく見かけるが、ほぼ確実にヒープに確保してしまうのではないかと気になった。
そのため、検証しつつ、他の案を検討する。

今回の結論

ヒープに乗せたいのであれば、ポインタを返す方法でも別に問題はないと思うが、スタックに確保して使いたいのであれば、値を返す関数か、事前にメモリ確保した構造体のポインタを初期化関数で変更する方法が高速に動作するだろう。構造体のサイズが小さい間、特に64バイト内であれば値返しは速い。大きなサイズになってくると、ポインタを渡して初期化するほうが効果が高いため、どんな状況でも安定して使いやすい。考え方としては、事前に必要サイズが分かる用途の場合は検討の価値がある可能性がある。

前提説明

以下の構造体を使って具体例を示す。

type Point struct {
	x int
	y int
}

上記の構造体のメモリを確保し、初期化する関数やメソッドを考える。
どの手法がどんな場面で適切か検討したい。

値を返す

以下のような関数をつくるイメージ

func NewPoint(x, y int) Point {
	return Point{x: x, y: y}
}

想定する使い方

p := NewPoint(1,2)

戻り値のコピーが発生するあたりが特徴。

値を渡して値を返す

この方法は比較のために用意しただけで、実際に使うことはあまりないだろう。
コピー関数を作る場合は近い形状のものはつくるかもしれない。

func (p Point)Init(x, y int) Point {
	p.x = x
	p.y = y
	return p
}

想定する使い方

p := Point{}
p = p.Init(1, 2)

pをInitにわたすときもコピーが発生し、戻り値にもコピーが発生する。

ポインタを返す

たまに見かける方法で、疑問を感じたもの。
とはいえ、目的次第では気にする必要はないだろう。

func NewPoint(x, y int) *Point {
	return &Point{x: x, y: y}
}

想定する使い方

p := NewPoint(1,2)

戻り値がポインタになるだけ。
ただし、確保した関数のスコープから抜けてしまうため、単純に考えるとヒープに確保するか、親のスタックに載せられるか解析が必要になりそうであると感じた。

ポインタを渡す

となると、事前に確保してポインタで渡すことでコピーを避ける手段が思いつく。
関数でも実装できるが、メソッドでも実装できるだろう。

func InitPoint(p &Point, x, y int) {
	p.x = x
	p.y = y
}

func (p *Point)Init(x, y int) {
	p.x = x
	p.y = y
}

利用例

p = Point{}
InitPoint(&p, 1, 2)
p.Init(1, 2)   // メソッド版の場合

明示的にヒープに確保することもできる

p = new(Point)
InitPoint(&p, 1, 2)
p.Init(1, 2)   // メソッド版の場合

ヒープに確保するとポインタを返すバージョンと同じ速度だったことを一応報告しておくが、検証項目には今回いれていない。(いれればよかった)

検証内容

以下のコードをフィールドが60個まで増やして、go test -bench . -benchmemでデータを取った。

package main

import (
	"testing"
)



type Value1 struct {
     v1 int64
	
}

func NewValue1Entity() Value1 {
	return Value1{
	       v1: 1,

	}
}

func NewValue1Ref() *Value1 {
	return &Value1{
	       v1: 1,

	}
}

func InitValue1(v *Value1) {
	v.v1 = 1
	
}

func (v Value1)Init() Value1{
     v.v1 = 1
     
     return v
}

func (v *Value1)InitRef() {
     v.v1 = 1
     
}
	

func Benchmark_値返し_______________メンバ数1(b *testing.B) {
	var v Value1
	for i := 0; i <b.N; i++ {
		v = NewValue1Entity()
	}
	if v.v1 != 1 {
		b.Errorf("v1 is not 1")
	}
}

func Benchmark_値返しメソッド_______メンバ数1(b *testing.B) {
        var v Value1
	for i := 0; i <b.N; i++ {
		v = v.Init()
	}
	if v.v1 != 1 {
		b.Errorf("v1 is not 1")
	}
}

func Benchmark_ポインタ返し_________メンバ数1(b *testing.B) {
	var v *Value1
	for i := 0; i <b.N; i++ {
		v = NewValue1Ref()
	}
	if v.v1 != 1 {
		b.Errorf("v1 is not 1")
	}

}

func Benchmark_ポインタ渡し_________メンバ数1(b *testing.B) {
	var v Value1
	for i := 0; i <b.N; i++ {
	        v = Value1{}
		InitValue1(&v)
	}
	if v.v1 != 1 {
		b.Errorf("v1 is not 1")
	}
}

func Benchmark_ポインタ渡しメソッド_メンバ数1(b *testing.B) {
     	var v *Value1
	for i := 0; i <b.N; i++ {
	        v = new(Value1)
		v.InitRef()
	}
	if v.v1 != 1 {
		b.Errorf("v1 is not 1")
	}
}



type Value2 struct {
     v1 int64
v2 int64
	
}

func NewValue2Entity() Value2 {
	return Value2{
	       v1: 1,
v2: 2,

	}
}

func NewValue2Ref() *Value2 {
	return &Value2{
	       v1: 1,
v2: 2,

	}
}

func InitValue2(v *Value2) {
	v.v1 = 1
	v.v2 = 2
	
}

func (v Value2)Init() Value2{
     v.v1 = 1
     v.v2 = 2
     
     return v
}

func (v *Value2)InitRef() {
     v.v1 = 1
     v.v2 = 2
     
}
	

func Benchmark_値返し_______________メンバ数2(b *testing.B) {
	var v Value2
	for i := 0; i <b.N; i++ {
		v = NewValue2Entity()
	}
	if v.v2 != 2 {
		b.Errorf("v2 is not 2")
	}
}

func Benchmark_値返しメソッド_______メンバ数2(b *testing.B) {
        var v Value2
	for i := 0; i <b.N; i++ {
		v = v.Init()
	}
	if v.v2 != 2 {
		b.Errorf("v2 is not 2")
	}
}

func Benchmark_ポインタ返し_________メンバ数2(b *testing.B) {
	var v *Value2
	for i := 0; i <b.N; i++ {
		v = NewValue2Ref()
	}
	if v.v2 != 2 {
		b.Errorf("v2 is not 2")
	}

}

func Benchmark_ポインタ渡し_________メンバ数2(b *testing.B) {
	var v Value2
	for i := 0; i <b.N; i++ {
	        v = Value2{}
		InitValue2(&v)
	}
	if v.v2 != 2 {
		b.Errorf("v2 is not 2")
	}
}

func Benchmark_ポインタ渡しメソッド_メンバ数2(b *testing.B) {
     	var v Value2
	for i := 0; i <b.N; i++ {
	        v = Value2{}
		(&v).InitRef()
	}
	if v.v2 != 2 {
		b.Errorf("v2 is not 2")
	}
}

結果

Benchmark_値返し_______________メンバ数1-12                          	1000000000	         0.2924 ns/op	       0 B/op	       0 allocs/op
Benchmark_値返しメソッド_______メンバ数1-12                              	1000000000	         0.2913 ns/op	       0 B/op	       0 allocs/op
Benchmark_ポインタ返し_________メンバ数1-12                             	157535590	         7.714 ns/op	       8 B/op	       1 allocs/op
Benchmark_ポインタ渡し_________メンバ数1-12                             	1000000000	         0.2899 ns/op	       0 B/op	       0 allocs/op
Benchmark_ポインタ渡しメソッド_メンバ数1-12                                 	1000000000	         0.2893 ns/op	       0 B/op	       0 allocs/op
Benchmark_値返し_______________メンバ数2-12                          	1000000000	         0.2898 ns/op	       0 B/op	       0 allocs/op
Benchmark_値返しメソッド_______メンバ数2-12                              	1000000000	         0.2899 ns/op	       0 B/op	       0 allocs/op
Benchmark_ポインタ返し_________メンバ数2-12                             	100000000	        10.34 ns/op	      16 B/op	       1 allocs/op
Benchmark_ポインタ渡し_________メンバ数2-12                             	1000000000	         0.4341 ns/op	       0 B/op	       0 allocs/op
Benchmark_ポインタ渡しメソッド_メンバ数2-12                                 	1000000000	         0.4332 ns/op	       0 B/op	       0 allocs/op
Benchmark_値返し_______________メンバ数3-12                          	1000000000	         0.2893 ns/op	       0 B/op	       0 allocs/op
Benchmark_値返しメソッド_______メンバ数3-12                              	1000000000	         0.2897 ns/op	       0 B/op	       0 allocs/op
Benchmark_ポインタ返し_________メンバ数3-12                             	96089685	        12.25 ns/op	      24 B/op	       1 allocs/op
...

グラフにしたらこんな感じになった。

検証結果

  • heap確保量は参考にだしているだけだが、ポインタ返しのときにしか関係がない。サイズと比例しており、ポインタを使えば呼び出しコストには差がなさそうなことが想像がつく
  • 64バイトまではポインタを使わないほうが速かった。そのご徐々にポインタで渡すことで巻き返されている
    • CPUが違えば違う傾向がでる可能性があるのだろうか
  • 160バイトくらいでコピーが2倍発生する側で速度ダウンがおきている。コピーが少ない値返しは320バイトで同じことがおきていそう。
    • スタックサイズでなにか変化があるのかもしれない
  • 大半がメモリ確保の時間のようなので、引数や戻り値にポインタを使うべきかどうかは別の検証が必要そう。64バイトくらいまではガンガンつかってよさそうな気配がある
    • 初期化に関しては、メモリ確保のオーバーヘッドに比べると40フィールドまでは大差ないようにみえる
  • なぜかきれいにぎざぎざしている

リンク

Discussion