Open8

[Go]構造体フィールドの順番はパフォーマンスに影響するか?

さき(H.Saki)さき(H.Saki)

背景

https://twitter.com/tenntenn/status/1486884689071644674
https://twitter.com/tenntenn/status/1486908430225121282

  • Goの構造体は、後ろのフィールドにアクセスするのと最初のフィールドにアクセスするのではコストが違う
  • なぜなら1番目のフィールドのアドレス=構造体そのもののアドレスであるため、フィールドアクセス時にメモリオフセットの計算がいらないから

という話をどこかで聞いたことがあったのです。
どこかにそういうブログ記事があったのかそれも忘れてしまったのですが(情報求む)...

これをきちんと「推測するな、計測しろ」というポリシーに則って検証してみようと思いました。

さき(H.Saki)さき(H.Saki)

検証環境

Goのバージョン

  • go version go1.17.2 darwin/amd64

用意した構造体

int型のフィールドを100個持つ構造体を用意しました。

type MyStruct struct {
	Field1   int
	Field2   int
	Field3   int
	// (以下略)
	Field100 int
}

検証内容

  • 1番目に定義されたField1を100回インクリメント
  • 100番目に定義されたField100を100回インクリメント

この2つの動作に有意なパフォーマンス差が生じるのかを検証します。

func IncField1() {
	stc := MyStruct{}
	for i := 0; i < 100; i++ {
		stc.Field1++
	}
}

func IncField100() {
	stc := MyStruct{}
	for i := 0; i < 100; i++ {
		stc.Field100++
	}
}
さき(H.Saki)さき(H.Saki)

ベンチマークをとる

テストコード

以下のベンチマークテストを用意して実行しました。

func BenchmarkAccessField1(b *testing.B) {
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		IncField1()
	}
}

func BenchmarkAccessField100(b *testing.B) {
	b.ResetTimer()
	for i := 0; i < b.N; i++ {
		IncField100()
	}
}

結果

数回実行しただけで、平均取ったり分散取ったりとかきちんとしてない(いずれやりたいけど)ので考察には議論の余地ありだと思いますが……これ有意差ありますかね?ないと思っちゃうのですが……

$ go test -bench .
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i5-8279U CPU @ 2.40GHz
BenchmarkAccessField1-8          8629333               132.2 ns/op
BenchmarkAccessField100-8        8820518               132.2 ns/op

BenchmarkAccessField1-8          8634548               132.3 ns/op
BenchmarkAccessField100-8        8708960               133.4 ns/op

BenchmarkAccessField1-8          8765124               132.1 ns/op
BenchmarkAccessField100-8        8688177               132.2 ns/op

BenchmarkAccessField1-8          8606901               132.8 ns/op
BenchmarkAccessField100-8        8746033               133.2 ns/op

BenchmarkAccessField1-8          8318942               138.6 ns/op
BenchmarkAccessField100-8        8542576               137.2 ns/op

それとも検証状況が悪かったりするのでしょうか。
(例)

  • フィールド100個じゃ足りない
  • フィールドintだと大してパフォに影響でない
  • インクリメント100回じゃ大して差が出ない
NoboNoboNoboNobo

アセンブリコードをみるとなにかわかるやも。(オフセットの有無で命令コードのコストが変わらない?)もしくは別のCPUアーキテクチャ環境だと変化が現れたり。

SpiegelSpiegel

多分ですけど全部 int 型だと有意差が出にくいんじゃないですかね。

mattnmattn

tenntenn さんが示された CL は今の Go コンパイラでは差が出ないと思います。(2019年当時の Go でないと差がでないはず)
以前の Go ではフィールドのオフセット位置を計算する処理が入っていて、対象のフィールドが先頭にある(フィールドが遠いかどうかは関係なく単に先頭)ことでそのコードがインライン化されていたというだけの話になります。今はどちらも同じ INCQ が呼ばれており、コストは同じでした。
この CL が大きく影響したのは当時のコンパイラであるという条件の他に、特に sync.Once の特性上「初回のみ代入されそれ以降は更新されない」というフィールド done がホットパスにある事でコードがインライン化されるという、ちょっと特殊な話でした。

なのでこの件は、僕が書いた fieldalignment の記事とは関係ない話になります。fieldalignment は単純に struct のサイズが小さくなるので、コピーのコストに影響します。(ですので記事内では処理速度には言及していなかったと思います)

さき(H.Saki)さき(H.Saki)

皆さまコメントありがとうございます!

特にmattnさん、詳細な仕様まで書いていただけて勉強になりました。
フィールドの位置でパフォーマンスが変わるというのはもう昔の話なのですね。