はじめに
Goコンパイラは構造体の各フィールドを、そのフィールド型のアライメント要件に従ってメモリ上に配置する。
- CやRustとは異なり、コンパイラがフィールドの自動並び替えを行わない。
- 宣言順序がそのままメモリレイアウトに反映される。
Go言語には上記のような前提があります。
今回、「フィールド宣言順序の違い」がパフォーマンスにどの程度の影響を与えるのか気になったので調査を行ってみました。
本記事ではそのレポート結果を記事にしましたので、ぜひご覧いただければ。
1. 目的と背景
背景
冒頭にも記載しましたが...。
Goコンパイラは構造体の各フィールドを、そのフィールド型のアライメント要件に従ってメモリ上に配置します。
コンパイル時に自動並び替え(reordering)は行われません。
そのため、宣言順序がそのままメモリレイアウトに反映されます。
64-bitアーキテクチャにおけるアライメント規則
型によってアラインメントの規則があるようです。
| 型 | サイズ | アライメント |
|---|---|---|
bool / int8 / byte
|
1 B | 1 |
int16 |
2 B | 2 |
int32 / float32
|
4 B | 4 |
int64 / float64 / pointer
|
8 B | 8 |
string |
16 B | 8 |
slice |
24 B | 8 |
フィールド間にはアライメントを満たすためのパディング(未使用バイト)が挿入され、構造体全体のサイズは最大アライメントの倍数に切り上げられます。
このあたりは以下の記事が参考になります。
目的
今回の目的を定義しますね。
フィールド宣言順序の違いが、構造体サイズ・ヒープアロケーションコスト・ベンチマーク性能(ns/op)に与える影響を定量的に測定する。
2. 「仮説」と「期待値」
仮説
では早速検証しようと思いますが。その前に自身の仮説をお聞きいただきたく。
本検証では、以下の2つのサンプルで検証しようと思います。
Bad Pattern: アライメントを無視した順序(小→大→小の交互配置)
type Unpadded struct {
a bool // 1 B + 7 B padding
b int64 // 8 B
c bool // 1 B + 3 B padding
d int32 // 4 B
} // Total: 24 B
Good Pattern — サイズ降順に配置(パディング最小化)
type Padded struct {
b int64 // 8 B
d int32 // 4 B
a bool // 1 B
c bool // 1 B + 2 B padding (構造体末尾アライメント)
} // Total: 16 B
期待値
| Metric | Bad | Good | Diff |
|---|---|---|---|
| サイズ | 24 B | 16 B | -33% |
| Allocs/op (N個生成) | 同等 | 同等 | - |
| ns/op (N個生成) | 高い | 低い | - |
※ サイズ差はunsafe.Sizeofで静的に確認可能ですので、それをもとに期待値を計算しております。
3. 検証
検証環境
- Go: 1.25.7
- OS/Arch: darwin/arm64
- CPU: Apple M2
方法
-
静的サイズ検証:
unsafe.Sizeofとunsafe.Offsetofを用いて、実際のメモリレイアウトを出力 -
ベンチマーク:
testing.Bを使用し、以下の2点を測定
-
BenchmarkAlloc: 100万件の要素を持つスライスをmakeする速度(アロケーション性能) -
BenchmarkTraverse: 100万件の構造体スライスを全走査して値を集計する速度(キャッシュライン効率)
検証コード
1. サンプル
以下、2つの構造体を検証の対象とします。
2. 静的サイズ検証
以下テストコードでの検証を行います。
※ 24 B, 16 B がそれぞれログ出力されれば、仮説立証ですね。
3. アロケーション性能調査
以下、該当の構造体を対象にmakeでの検証を行います。
4. キャッシュ効率調査
4. 結果
4.1 静的サイズ検証結果
=== Unpadded ===
a bool offset=0 size=1 (+7B padding)
b int64 offset=8 size=8
c bool offset=16 size=1 (+3B padding)
d int32 offset=20 size=4
total=24 B
=== Padded ===
b int64 offset=0 size=8
d int32 offset=8 size=4
a bool offset=12 size=1
c bool offset=13 size=1 (+2B tail padding)
total=16 B
仮説通り、定義順序を変えるだけで 24B → 16B (33.3%削減) となりました。
4.2 ベンチマーク結果
go test -bench . -benchmem -count 5の実行結果です。
goos: darwin
goarch: arm64
pkg: go-lab/experiments/struct-padding
cpu: Apple M2
BenchmarkAllocUnpadded-8 3421 350996 ns/op 25165846 B/op 1 allocs/op
BenchmarkAllocUnpadded-8 3373 353656 ns/op 25165841 B/op 1 allocs/op
BenchmarkAllocUnpadded-8 3466 353864 ns/op 25165840 B/op 1 allocs/op
BenchmarkAllocUnpadded-8 3434 359303 ns/op 25165840 B/op 1 allocs/op
BenchmarkAllocUnpadded-8 3483 348602 ns/op 25165841 B/op 1 allocs/op
BenchmarkAllocPadded-8 4838 257856 ns/op 16777232 B/op 1 allocs/op
BenchmarkAllocPadded-8 4764 258257 ns/op 16777232 B/op 1 allocs/op
BenchmarkAllocPadded-8 4456 258710 ns/op 16777231 B/op 1 allocs/op
BenchmarkAllocPadded-8 4802 256633 ns/op 16777231 B/op 1 allocs/op
BenchmarkAllocPadded-8 4714 260774 ns/op 16777232 B/op 1 allocs/op
BenchmarkTraverseUnpadded-8 1621 721990 ns/op 0 B/op 0 allocs/op
BenchmarkTraverseUnpadded-8 1662 731117 ns/op 0 B/op 0 allocs/op
BenchmarkTraverseUnpadded-8 1688 726764 ns/op 0 B/op 0 allocs/op
BenchmarkTraverseUnpadded-8 1653 715831 ns/op 0 B/op 0 allocs/op
BenchmarkTraverseUnpadded-8 1585 724513 ns/op 0 B/op 0 allocs/op
BenchmarkTraversePadded-8 1868 636454 ns/op 0 B/op 0 allocs/op
BenchmarkTraversePadded-8 1812 646448 ns/op 0 B/op 0 allocs/op
BenchmarkTraversePadded-8 1920 632921 ns/op 0 B/op 0 allocs/op
BenchmarkTraversePadded-8 1932 639603 ns/op 0 B/op 0 allocs/op
BenchmarkTraversePadded-8 1802 636722 ns/op 0 B/op 0 allocs/op
PASS
ok go-lab/experiments/struct-padding 24.309s
分析
結果を表にまとめますね。
| Metric | Unpadded (Bad) | Padded (Good) | Diff |
|---|---|---|---|
unsafe.Sizeof |
24 B | 16 B | -33.3% |
| Alloc B/op (1M) | 25,165,841 B | 16,777,232 B | -33.3% |
| Alloc ns/op (1M) | ~353,284 ns | ~258,446 ns | -26.8% |
| Traverse ns/op (1M) | ~724,043 ns | ~638,430 ns | -11.8% |
| Allocs/op | 1 | 1 | 0% |
-
サイズ差 (-33.3%):
仮説通り、unsafe.Sizeofで Unpadded=24B / Padded=16B を確認できました。
フィールド間パディングが計 8B 削減されました。 -
アロケーションコスト (-26.8%):
1M要素スライスのmakeで、総バイト数が 33% 減少したことに比例してアロケーション時間も短縮。
メモリアロケータの作業量がバイト数に依存することを示しています。 -
走査スループット (-11.8%):
同一キャッシュライン (64B) に収容できる要素数が Unpadded=2.67個 vs Padded=4個 となり、キャッシュミス率の低下が走査速度に反映されました。
仮説の予測範囲(-10〜20%)内ですね。 -
Allocs/op は同等:
make([]T, N)は型サイズに関係なく 1 alloc。
構造体サイズはアロケーション回数ではなくバイト量にのみ影響しています。
5. 結論
仮説が立証できました!!
具体的には、
Go言語において構造体のフィールド定義順序を最適化(サイズ降順など)することで、以下のメリットがあることを実測値として確認できました。
- 構造体サイズが減る (今回は33%削減)
- 大量生成時の処理速度が向上 (今回は約27%高速化)
- スライス走査などの計算処理も高速化する (今回はキャッシュ効率向上により約12%高速化)
また、冒頭の前提に記載した、「Goコンパイラがフィールド自動並び替えを行わない」ことを本検証で確認できたとともに、手動最適化の余地を生むことを実測データで確認できました。
まとめ
実際の現場では、構造体フィールドの極端な順序変更は可読性と引き換えになってしまうこともあるかと。
今回はあくまで影響がどの程度あるのかな、と計測してみた結果の共有までです。
全ての構造体をサイズ順に並べるべきとは限りませんが、大量のデータを扱う構造体においては最適化の余地があるとわかってよかったなと。
時間があれば、
より複雑な構造体(string, slice, pointer 混在)等でのパディング影響測定や、構造体の入れ子が本テーマにおいてどんな影響を与えるか、など試してみたいなとも思います。
ちなみにですが、順序変更については以下のようなツールがあります。
参考