SoA vs AoS - データ指向設計とキャッシュ最適化
- Unity C# Job System + Burst Compiler 完全攻略 - 原理から実践パイプラインまで
- SoA vs AoS - データ指向設計とキャッシュ最適化
- Burst Compiler 内部動作の深掘り - LLVMパイプラインからアセンブリ読解まで
- データ指向設計(DOD)は「オブジェクトが何をするか」ではなく「データがどのように変換されるか」を中心に設計するパラダイムである
- SoAレイアウトはキャッシュ活用率を25%から100%に引き上げ、プリフェッチャ親和性+メモリ帯域幅削減で理論以上の性能を達成する
- すべてのデータをSoAに変える必要はない — TLBコスト、False Sharing、アクセスパターン分析で「どこにSoAを適用するか」を判断することが核心である
はじめに
前回のポストではUnity Job SystemとBurst Compilerの原理を扱った。Part 4でキャッシュ階層、AoS vs SoAの基本概念、メモリアラインメントとSIMDの関係を見てきたが — SoAが速いことは確認できた。
しかし、いくつかの疑問が残っている:
- なぜ速いのか?キャッシュライン単位の動作を数学的に分析できるのか?
- 既存のOOPコードをどのようにSoAに変換するのか?
- いつSoAを使うべきではないのか?
この記事ではこれらの疑問に答える。SoA/AoSは単純な配列配置技法ではなく、データ指向設計(Data-Oriented Design) というパラダイムの一部だ。パラダイムそのものを理解してこそ、正しい判断を下すことができる。
キャッシュ階層構造、キャッシュライン(64バイト)、False Sharing、NativeArray内部構造などの基礎概念はJob Systemポスト Part 4で扱ったため、ここでは繰り返さない。該当セクションを先に読むことを推奨する。
Part 1: データ指向設計(DOD)の哲学
OOPからDODへ:パラダイムシフト
ほとんどの開発者が最初に学ぶ設計パラダイムはオブジェクト指向プログラミング(OOP)だ。OOPの核心的な問いはこれだ:
「このオブジェクトは何をするのか?」
敵(Enemy)を設計するなら、自然とこう考える:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// OOP: 敵の「行動」を中心に設計
abstract class Enemy : MonoBehaviour
{
protected float health;
protected float speed;
protected Vector3 velocity;
public abstract void UpdateAI();
public virtual void TakeDamage(float amount) { health -= amount; }
public virtual void Move() { transform.position += velocity * Time.deltaTime; }
}
class Walker : Enemy
{
public override void UpdateAI() { /* 歩行AI */ }
}
class Runner : Enemy
{
public override void UpdateAI() { /* 高速追跡AI */ }
public override void Move() { /* より速く移動 */ }
}
この設計は直感的だ。「WalkerはEnemyであり、RunnerもEnemyである。」現実世界の分類体系をそのままコードに移している。
問題は性能だ。 5,000体の敵がいる場合:
1
2
3
4
5
6
7
8
9
メモリ配置 (OOP):
Walker#0 → ヒープアドレス 0x10000 [vtable|health|speed|vel|transform_ptr|...]
Runner#0 → ヒープアドレス 0x50000 [vtable|health|speed|vel|transform_ptr|...]
Walker#1 → ヒープアドレス 0x30000 [vtable|health|speed|vel|transform_ptr|...]
Runner#1 → ヒープアドレス 0x80000 [vtable|health|speed|vel|transform_ptr|...]
...
→ 5,000個のオブジェクトがヒープ全体に散在
→ 毎フレーム5,000回の仮想関数呼び出し (vtable間接参照)
→ アクセスのたびにキャッシュミスの可能性
データ指向設計(DOD)はまったく異なる問いから始まる:
「このシステムは毎フレームどのデータをどう変換するのか?」
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DOD: 「データ変換」を中心に設計
// データ: 連続配列
NativeArray<float3> positions; // 5,000個の位置 — 連続メモリ
NativeArray<float3> velocities; // 5,000個の速度 — 連続メモリ
NativeArray<float> speeds; // 5,000個の移動速度 — 連続メモリ
// 変換: Job struct
[BurstCompile]
struct MoveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Velocities;
[ReadOnly] public NativeArray<float> Speeds;
public NativeArray<float3> Positions;
public float DeltaTime;
public void Execute(int i)
{
Positions[i] += Velocities[i] * Speeds[i] * DeltaTime;
}
}
OOPでは「EnemyがMove()を呼び出す」と考えるが、DODでは「MoveJobがpositions配列をvelocities配列で変換する」と考える。
| 観点 | OOP | DOD |
|---|---|---|
| 設計単位 | オブジェクト (Enemy, Walker) | データ配列+変換関数 (Job) |
| メモリ | オブジェクトごとのヒープ割り当て、散在 | フィールドごとの連続配列 |
| 多態性 | 仮想関数 (vtable) | データ値による分岐 (byte type) |
| キャッシュ | ポインタチェイシング → ミス頻発 | シーケンシャルアクセス → プリフェッチャ最適 |
| 並列化 | 困難 (共有状態) | 自然 (配列分割) |
Mike Actonの3つの原則
2014年のCppConでInsomniac GamesのMike Actonが発表した“Data-Oriented Design and C++”は、DODを確立した重要な講演だ。彼が指摘した「3つの嘘(lies)」は以下の通りだ:
Lie 1:「ソフトウェアがプラットフォームだ」
ソフトウェアはプラットフォームではない。ハードウェアがプラットフォームだ。
プログラマは「C#の上で開発する」や「Unityの上で開発する」と考えがちだ。しかしコードが実際に動くのはCPU+キャッシュ+RAMだ。
1
2
3
4
5
6
開発者の認識: 実際:
C# コード CPUパイプライン
↓ ↓
Unity API レジスタ → L1 → L2 → L3 → RAM
↓ ↓
"うまく動くだろう" キャッシュミス 80ns × 5,000回 = 0.4ms
Unityのforeachで5,000個のMonoBehaviourのUpdate()を呼び出すと、C#レベルではきれいなコードだが、ハードウェアレベルでは5,000回のポインタチェイシングだ。
Lie 2:「コードがデータより重要だ」
コードの目的はデータを変換することだ。データの形がコードを決定する。
OOPではクラス階層構造をまず設計し、データをそこに当てはめる。DODでは逆だ:
- 入力データは何か? (positions, velocities)
- 出力データは何か? (新しいpositions)
- 変換はどんなパターンか? (1:1マッピング、並列可能)
- ならばコードは
IJobParallelForになる。
データのアクセスパターンが決まれば、コード構造は自動的についてくる。
Lie 3:「世界をモデリングしたコードが良いコードだ」
「WalkerはEnemyの一種だ」は現実の分類体系であり、データ変換の最適構造ではない。
WalkerとRunnerの移動ロジックが異なるのはspeed値が異なるだけだ。仮想関数と継承階層の全体が、一つのfloat値の違いを表現するために存在している。
1
2
3
4
5
6
7
// OOP: 継承で「種類」を表現
class Walker : Enemy { speed = 2f; }
class Runner : Enemy { speed = 5f; }
// DOD: データ値で「種類」を表現
NativeArray<float> speeds; // speeds[i] = 2f or 5f
// → 仮想関数呼び出し0回、キャッシュミス0回
ゲーム開発がDODに適している理由
すべてのソフトウェアがDODの恩恵を等しく受けるわけではない。ゲーム開発が特に適している理由は3つだ:
数千の同種エンティティ: 弾丸、敵、パーティクル — 同じ構造のデータが数千〜数万個。配列で表現するのに完璧な条件。
厳格なフレーム予算: 60fps = フレームあたり16.6ms。毎フレームすべてのエンティティを処理しなければならないので、ループの効率がフレーム予算そのもの。
予測可能な変換パターン: 「すべての敵の位置を速度に応じて更新する」「すべての弾丸の衝突を検査する」 — 入出力が明確なバッチ処理。
DODの歴史:ハードウェアが強制したパラダイム
DODは学界で生まれた理論ではなく、ゲームハードウェアの制約の中で生き残るために実戦で発展したパラダイムだ。
PS3 Cell Broadband Engine (2006)
DODがゲーム業界で本格的に注目されたきっかけはPlayStation 3のCellプロセッサだ。
1
2
3
4
5
6
Cellアーキテクチャ:
PPE (PowerPC) — 汎用コア1個
SPE (Synergistic Processing Element) × 6個 (ゲーム用)
└── 各SPEのLocal Store: 256 KB
└── メインRAMへの直接アクセス不可!
└── DMAでデータをLocal Storeに明示的に転送する必要あり
256KBのLocal Storeにゲームデータを詰め込むには、Working Setサイズを精密に管理する必要があった。OOPの巨大なオブジェクトを丸ごとDMAすれば256KBはすぐに埋まる。必要なフィールドだけを抜き出して連続配列(SoA)でDMAするのが唯一の解法だった。
Naughty DogのJason GregoryはPS3でThe Last of Usを開発しながらこの経験を体系化した。彼の著書“Game Engine Architecture” (Chapter 16)でデータ指向ランタイムシステム設計を詳しく扱っている。
Insomniac GamesとMike Acton (2004〜2014)
Insomniac Games(Ratchet & Clank, Resistanceシリーズ)のエンジンディレクターMike Actonは、PS2/PS3時代からDODを実戦に適用していた。
- 2004: GDCで”Pitfalls of Object Oriented Programming”をテーマにDOD事例を発表
- 2014: CppConで“Data-Oriented Design and C++”を発表 — DODをC++コミュニティ全体に広めた
- 2017: Unity Technologiesに合流し、DOTS(Data-Oriented Technology Stack)の開発を主導
Unity DOTS (2018〜)
Mike ActonがUnityに合流した後、Unityはゲームエンジンとして初めてDODを公式フレームワークとして提供した:
- Entity Component System (ECS): Archetypeベースのレイアウト自動化
- Job System: マルチコアバッチ処理
- Burst Compiler: LLVMベースのネイティブコード生成
これが本シリーズで扱っているNativeArray + IJobParallelFor + [BurstCompile]の組み合わせの背景だ。
1
2
3
4
5
6
7
8
9
DODタイムライン:
2004 Mike Acton、GDCでDOD事例を発表
2006 PS3 Cell — 256KB Local StoreがDODを強制
2007 Ulrich Drepper — "What Every Programmer Should Know About Memory"
2009 Noel Llopis — "Data-Oriented Design" アーティクル
2014 Mike Acton — CppCon "Data-Oriented Design and C++"
2017 Mike Acton → Unity合流
2018 Unity DOTSプレビュー
2023 Unity ECS 1.0正式リリース
DODは「最新トレンド」ではなく、20年間ゲームハードウェアとともに進化した実戦哲学だ。PS3の256KB制約はなくなったが、キャッシュ効率の重要性はCPUコア数とメモリレイテンシの格差が大きくなるほど、ますます高まっている。
Part 2: メモリレイアウト深層分析
前回のポストでキャッシュ階層とキャッシュラインの基本概念を扱った。ここではもう一段踏み込んで、定量的にメモリレイアウトの効率を分析する方法を扱う。
Stride:キャッシュ効率の核心指標
Stride(ストライド)とは、イテレーション時に連続する2つのアクセス間のバイト距離だ。
配列を走査する際、CPUはアクセスのたびにstrideバイトずつ先に進む。この値がキャッシュライン(64バイト)に対してどれほど大きいかがキャッシュ効率を決定する。
AoSのStride
1
2
3
4
5
6
7
8
9
10
11
struct Agent // 48 bytes
{
public float3 position; // 12B (offset 0)
public float3 velocity; // 12B (offset 12)
public float speed; // 4B (offset 24)
public float health; // 4B (offset 28)
public int state; // 4B (offset 32)
public byte type; // 1B (offset 36)
// padding: 11B → 合計48B (またはコンパイラによっては40B)
}
NativeArray<Agent> agents; // 5,000個
positionだけを走査するJobを考えてみよう:
1
2
3
4
5
6
7
8
9
10
11
メモリ配置 (AoS):
Stride = sizeof(Agent) = 48 bytes
agents[0]: [pos(12B)|vel(12B)|spd(4B)|hp(4B)|st(4B)|type(1B)|pad(11B)]
↓ 48B skip
agents[1]: [pos(12B)|vel(12B)|spd(4B)|hp(4B)|st(4B)|type(1B)|pad(11B)]
↓ 48B skip
agents[2]: [pos(12B)|vel(12B)|spd(4B)|hp(4B)|st(4B)|type(1B)|pad(11B)]
キャッシュライン (64B)にAgentが1.33個 → positionは1〜2個のみロード
→ 残り36B(vel, spd, hp, st, type, pad)はこのJobで使わないがキャッシュに載る
SoAのStride
1
2
3
4
NativeArray<float3> positions; // Stride = 12 bytes
NativeArray<float3> velocities;
NativeArray<float> speeds;
NativeArray<float> healths;
1
2
3
4
5
6
7
8
メモリ配置 (SoA):
Stride = sizeof(float3) = 12 bytes
positions: [pos0(12B)|pos1(12B)|pos2(12B)|pos3(12B)|pos4(12B)|pos5(12B)|...]
←────────── キャッシュライン (64B): position 5個ロード ──────────→
→ キャッシュラインの全バイトが実際に使用されるデータ
→ 不要なデータロード 0
キャッシュ活用率の公式
キャッシュラインにロードされたデータのうち、実際に使用する割合をキャッシュ活用率(Cache Utilization)と呼ぼう:
\[\text{Cache Utilization} = \frac{\text{Useful Bytes per Cache Line}}{\text{Cache Line Size}} = \frac{\left\lfloor \frac{64}{\text{Stride}} \right\rfloor \times \text{Element Size}}{64}\]| レイアウト | Stride | Element Size | キャッシュラインあたりのロード数 | 活用率 |
|---|---|---|---|---|
| AoS (Agent全体のうちpositionのみアクセス) | 48B | 12B | 1個 | 25% |
| SoA (positions配列) | 12B | 12B | 5個 | 93.75% |
| SoA (speeds配列, float) | 4B | 4B | 16個 | 100% |
| SoA (isAlive配列, byte) | 1B | 1B | 64個 | 100% |
AoSでpositionだけにアクセスするとキャッシュ帯域幅の75%を浪費する。SoAではほぼ100%を活用する。
次の図は、同じ64バイトのキャッシュラインにAoSとSoAがどのようにロードされるかを比較している:
block-beta
columns 8
block:header1:8
columns 8
h1["AoS: 64Bキャッシュラインへのエージェント構造体ロード (positionだけ必要なJob)"]
end
pos0["pos[0]\n12B\n✓ 使用"] vel0["vel[0]\n12B\n✗ 浪費"] spd0["spd[0]\n4B\n✗"] hp0["hp[0]\n4B\n✗"] st0["st[0]\n4B\n✗"] t0["t[0]\n1B\n✗"] pad0["pad\n11B\n✗"] pos1a["pos[1]\n一部\n✓"]
block:result1:8
columns 1
r1["→ 64B中12Bのみ使用 = 活用率 25%"]
end
space:8
block:header2:8
columns 8
h2["SoA: 64Bキャッシュラインへのpositions配列ロード"]
end
p0["pos[0]\n12B\n✓"] p1["pos[1]\n12B\n✓"] p2["pos[2]\n12B\n✓"] p3["pos[3]\n12B\n✓"] p4["pos[4]\n4B\n✓"] space2 space3 space4
block:result2:8
columns 1
r2["→ 64B中52B使用 = 活用率 93.75%"]
end
style pos0 fill:#4CAF50,color:#fff
style pos1a fill:#4CAF50,color:#fff
style vel0 fill:#f44336,color:#fff
style spd0 fill:#f44336,color:#fff
style hp0 fill:#f44336,color:#fff
style st0 fill:#f44336,color:#fff
style t0 fill:#f44336,color:#fff
style pad0 fill:#f44336,color:#fff
style p0 fill:#4CAF50,color:#fff
style p1 fill:#4CAF50,color:#fff
style p2 fill:#4CAF50,color:#fff
style p3 fill:#4CAF50,color:#fff
style p4 fill:#4CAF50,color:#fff
これが「同じ演算、同じBurstコンパイルなのにレイアウトが異なるだけで性能が数倍差が出る」根本原因だ。
Working Setサイズの計算
Working Setとは、一つのJobが全実行を通じてアクセスする総メモリサイズだ。
\[\text{Working Set} = \sum_{\text{array}} (\text{element count} \times \text{element size})\]Working Setがどのキャッシュ階層に収まるかによって性能が決まる:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 例: 5,000エージェントの距離計算Job
[BurstCompile]
struct DistanceJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Positions; // 5,000 × 12B = 60 KB
[ReadOnly] public NativeArray<byte> IsAlive; // 5,000 × 1B = 5 KB
[WriteOnly] public NativeArray<float> Distances; // 5,000 × 4B = 20 KB
[ReadOnly] public float3 TargetPos; // 12B
public void Execute(int i)
{
if (IsAlive[i] == 0) { Distances[i] = float.MaxValue; return; }
Distances[i] = math.distance(Positions[i], TargetPos);
}
}
// Working Set = 60 + 5 + 20 = 85 KB
| Working Setサイズ | ロード先キャッシュ | 期待性能 |
|---|---|---|
| < 32 KB | L1キャッシュ | 最高 (~1ns/アクセス) |
| 32 KB〜256 KB | L2キャッシュ | 良好 (~4ns/アクセス) |
| 256 KB〜8 MB | L3キャッシュ | 普通 (~12ns/アクセス) |
| > 8 MB | RAM | 低速 (~80ns/アクセス) |
次の図は、キャッシュ階層ごとの容量とAoS/SoA Working Setがどこに位置するかを示している:
graph TD
subgraph キャッシュ階層["CPUメモリ階層 (アクセス遅延 / 容量)"]
REG["レジスタ\n~0.3ns / ~1KB"]
L1["L1キャッシュ\n~1ns / 32KB"]
L2["L2キャッシュ\n~4ns / 256KB"]
L3["L3キャッシュ\n~12ns / 8MB"]
RAM["RAM\n~80ns / 16GB+"]
end
REG --> L1 --> L2 --> L3 --> RAM
SoA["SoA Working Set\n85KB ✓"]
AoS["AoS Working Set\n240KB ⚠"]
SoA -.->|"L2に余裕を持ってロード"| L2
AoS -.->|"L2境界付近 — スラッシング危険"| L2
style SoA fill:#4CAF50,color:#fff
style AoS fill:#FF9800,color:#fff
style L1 fill:#E3F2FD
style L2 fill:#BBDEFB
style L3 fill:#90CAF9
style RAM fill:#64B5F6,color:#fff
上記DistanceJobのWorking Setは85KB — L2キャッシュに完全にロードされる。もしAoS方式で同じデータを処理すると:
1
2
3
AoS Working Set = 5,000 × 48B(Agent struct) = 240 KB
→ L2境界 (256KB) 付近 — キャッシュスラッシング危険
→ アクセスするフィールドはposition + isAliveだけなのに、velocity/speed/health/state/typeも一緒にロード
SoAはWorking Set自体を縮小して、より小さなキャッシュ階層に収まるようにする。 これが単純な「キャッシュライン効率」以上の効果だ。
Working Set計算の実践ヒント
- 読み取り配列+書き込み配列をすべて合算する。
[ReadOnly]でも[WriteOnly]でもメモリにロードされるのは同じだ。 - スカラーパラメータ(float, intなど)はレジスタに収まるので無視してよい。
- IJobParallelForは配列全体をバッチ単位で処理するため、一度にアクティブなWorking Setは
batchCount × elementSize × arrayCountに近い。ただしプリフェッチャが先読みするため、配列全体のサイズで保守的に計算するのが安全だ。
ハードウェアプリフェッチャ:SoAが速い本当の理由
キャッシュ活用率は「浪費されるデータがどれほどか」を説明する。しかしSoAが速い理由の残り半分はハードウェアプリフェッチャ(Hardware Prefetcher)にある。
プリフェッチャの動作原理
現代のCPUには複数種類のプリフェッチャが内蔵されている。核心はStride Prefetcherだ:
1
2
3
4
5
6
7
Stride Prefetcherの動作:
1. CPUがアドレスAにアクセス
2. 次にアドレスA+Sにアクセス (S = stride)
3. さらに次にアドレスA+2Sにアクセス
4. プリフェッチャ: 「パターン検知!stride = S」
5. → A+3S, A+4S, A+5Sを先にL1/L2にロード
6. CPUがA+3Sに到達した時、すでにキャッシュにある → ミス0!
Intel CPUの場合、2〜3回のアクセスだけでstrideを検知し、以降のアクセスではキャッシュミス遅延を完全に隠蔽する。
次の図は、SoAシーケンシャルアクセスでプリフェッチャが動作するタイムラインだ:
gantt
title SoAシーケンシャルアクセス: Stride Prefetcherタイムライン
dateFormat X
axisFormat %s
section CPUアクセス
pos[0] アクセス (キャッシュミス) :crit, a0, 0, 80
pos[1] アクセス (キャッシュヒット) :done, a1, 80, 81
pos[2] アクセス (キャッシュミス) :crit, a2, 81, 161
pos[3] アクセス — stride検知! :done, a3, 161, 162
pos[4] アクセス (プリフェッチヒット) :done, a4, 162, 163
pos[5~4999] すべてヒット :done, a5, 163, 200
section Prefetcher
パターン学習中 :active, p0, 0, 161
stride=12B 検知完了 :milestone, p1, 161, 161
pos[4] 先読みロード :p2, 130, 161
pos[5,6,7...] 連続プリフェッチ :p3, 161, 200
Intel Optimization Manual Section 2.5.5.4: L2 Stride Prefetcherは最大2KBまでのstrideを検知する。L1 Data Prefetcherはキャッシュライン内のシーケンシャルアクセスを検知する。
SoA vs AoSでのプリフェッチャ効果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
SoA (stride = 12B, float3):
アクセス: pos[0] → pos[1] → pos[2] → ...
アドレス: 0x1000 → 0x100C → 0x1018 → ...
stride = 12B (一定) ✓
→ プリフェッチャが2回目のアクセスで即座に検知
→ 3回目のアクセスからキャッシュミスほぼ0
→ 5,000個走査中の実際のキャッシュミス: 最初の2〜3回のみ
AoS (stride = 48B, Agent全体のうちpositionのみ):
アクセス: agents[0].pos → agents[1].pos → agents[2].pos → ...
アドレス: 0x2000 → 0x2030 → 0x2060 → ...
stride = 48B (一定) ✓ — プリフェッチャの検知自体は可能!
しかし:
→ 48B stride = キャッシュライン(64B)をほぼ毎回超える
→ プリフェッチしたキャッシュラインのうち12Bしか使わない (残り36B浪費)
→ プリフェッチが「不要なデータ」でキャッシュを埋めて他の有用なデータを追い出す
核心的インサイト: プリフェッチャはAoSでもstrideを検知できる。しかしプリフェッチしたデータの活用率がSoAとAoSで大きく異なる。プリフェッチャが懸命に動いても、取得したデータを25%しか使わないならキャッシュ汚染(cache pollution)が発生する。
Srinath et al.の”Feedback Directed Prefetching” (HPCA 2007)では、プリフェッチャの精度(accuracy)とカバレッジ(coverage)を区別する。AoSの問題はカバレッジではなく精度(取得したデータのうち実際に使用する割合)が低いことだ。
プリフェッチャが失敗するケース
プリフェッチャは不規則なアクセスパターンには無力だ:
1
2
3
4
5
6
7
8
// プリフェッチャ失敗例: 間接インデキシング
NativeArray<int> sortedIndices; // [42, 7, 3891, 102, ...]
for (int i = 0; i < count; i++)
{
int idx = sortedIndices[i];
float3 pos = positions[idx]; // ← ランダムアクセス!stride不規則
// → プリフェッチャ無力化 → 毎アクセスでキャッシュミスの可能性
}
このような場合にはソフトウェアプリフェッチヒントを使用するか、インデックスをソートしてアクセスの局所性を高める方法がある。
メモリ帯域幅:キャッシュの先にあるボトルネック
キャッシュ効率とプリフェッチャを論じたが、もう一つの観点がある。メモリ帯域幅(bandwidth)だ。
現代のCPUで多くのバッチ処理ループはcompute-bound(演算ボトルネック)ではなくmemory-bound(メモリボトルネック)だ。つまり、演算自体は速いがデータを取得する速度がボトルネックになる。
Rooflineモデルで見るSoAの利点
Rooflineモデル(Williams et al., 2009)は、プログラムがcompute-boundかmemory-boundかを視覚的に判断するフレームワークだ。
\[\text{Operational Intensity} = \frac{\text{FLOP}}{\text{Bytes Transferred}}\]| 項目 | AoS | SoA |
|---|---|---|
| 演算 (distance計算) | 7 FLOP/エンティティ | 7 FLOP/エンティティ (同一) |
| 転送バイト | 48B/エンティティ (Agent全体) | 16B/エンティティ (pos 12B + dist 4B) |
| Operational Intensity | 7/48 = 0.146 | 7/16 = 0.438 |
| 状態 | 極度のmemory-bound | memory-bound緩和 |
1
2
3
4
5
6
7
DDR4-3200 帯域幅: ~51.2 GB/s (理論), 実際 ~25 GB/s
AoS: 5,000 × 48B = 240KB 転送 → 240KB / 25GB/s = 0.0096ms
SoA: 5,000 × 16B = 80KB 転送 → 80KB / 25GB/s = 0.0032ms
→ SoAはメモリ帯域幅消費が1/3
→ 同じ帯域幅で3倍多くのエンティティを処理可能
帯域幅の観点から見ると、AoSの「使わないフィールド」は単なるキャッシュの浪費ではなく、メモリバスの帯域幅を消費する実質的なコストだ。Working Setがキャッシュを超えると、このコストが直接的に性能を決定する。
ゲームで数万のエンティティを処理するループはほとんどがmemory-boundだ。Rooflineモデルを描いてみると、SoA変換は「同じハードウェアでoperational intensityを高めてmemory-bound領域から脱出すること」として理解できる。
Power-of-2 Strideの罠
strideがキャッシュラインサイズの正確な倍数のとき、注意が必要だ。
現代CPUのL1キャッシュはset-associative構造だ。キャッシュを複数の「set」に分け、各メモリアドレスは特定のsetにのみマッピングされる。
1
2
3
4
5
6
7
Stride = 64B (キャッシュラインサイズの1倍):
agents[0] → Set 0
agents[1] → Set 0 ← 同じset!
agents[2] → Set 0 ← また同じset!
...
→ 一つのsetにアクセスが集中 → 他のsetは空いているのにこのsetだけ溢れる
→ 「キャッシュスラッシング(thrashing)」発生
これはstrideが正確に64, 128, 256, 512など2のべき乗の場合に発生し得る。
緩和方法:
- structサイズが正確に64Bの倍数にならないようにパディングを追加または削除
- 実際に問題になるケースは稀だが、性能が理論値より低い場合に疑うべき項目
TLBミス:SoAの隠れたコスト
キャッシュ効率だけを見るとSoAが圧倒的に有利だが、TLB(Translation Lookaside Buffer)の観点ではSoAが不利になり得る。
TLBとは?
仮想メモリシステムにおいて、CPUは仮想アドレス → 物理アドレス変換をメモリアクセスのたびに行う。この変換をキャッシュするのがTLBだ。
1
2
3
仮想アドレスアクセス → TLB参照
→ Hit: 物理アドレスを即座に取得 (~1 cycle)
→ Miss: ページテーブルウォーク (~100 cycles, 最悪 ~1000 cycles)
L1 DTLBは一般的に64〜128エントリを持ち、各エントリが4KBページをカバーする。つまりTLBでカバー可能な範囲は128 × 4KB = 512KB程度だ。
SoAがTLBに不利な理由
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
AoS — 配列1個:
NativeArray<Agent> agents → 連続メモリページ
→ TLBエントリ1個で4KB(~85エージェント)をカバー
→ シーケンシャルアクセスなのでページもシーケンシャル → TLBミス最小
SoA — 配列8個:
positions[] → ページグループA
velocities[] → ページグループB
speeds[] → ページグループC
healths[] → ページグループD
isAlive[] → ページグループE
states[] → ページグループF
cooldowns[] → ページグループG
types[] → ページグループH
→ 一つのJobが4配列にアクセスすると → 4つのページグループに同時アクセス
→ TLBエントリ消費4倍
5,000エージェント、MoveJob(4配列):
1
2
3
4
5
6
7
8
9
10
11
各配列のメモリサイズ:
positions: 5,000 × 12B = 60KB → 15ページ
velocities: 5,000 × 12B = 60KB → 15ページ
speeds: 5,000 × 4B = 20KB → 5ページ
isAlive: 5,000 × 1B = 5KB → 2ページ
必要なTLBエントリ合計: 37個 (L1 DTLBの~29%)
→ 5,000個では問題なし
しかし配列が20個でエンティティが50,000個なら:
→ 数百のTLBエントリが必要 → TLBスラッシング危険
緩和方法
- Partial SoA: 常に一緒にアクセスされるフィールドをstructにまとめて配列数を減らす — これがPartial SoAのハードウェア的根拠
- Huge Pages (2MB): OSレベルで2MBページを使用すればTLBカバレッジが512倍に増加
- 配列数の管理: 一つのJobがアクセスする配列を5〜6個以下に維持
Ulrich Drepperの”What Every Programmer Should Know About Memory” Section 4でTLBミスの影響を詳しく分析している。特にFigure 4.5ではデータサイズがTLBカバレッジを超えた時に性能が急落するグラフを示しており、これがSoAで配列を過度に分離した場合に発生し得る現象だ。
C#構造体のメモリレイアウト
AoSのstrideを正確に計算するには、C#がstructをメモリにどう配置するかを知る必要がある。
StructLayoutとパディング
C#のstructはデフォルトでSequentialレイアウトを使用する。各フィールドは自身のサイズに合わせてアラインメント(alignment)される:
1
2
3
4
5
6
7
8
アラインメント規則:
byte → 1バイトアラインメント (任意のアドレスに配置可能)
short → 2バイトアラインメント (偶数アドレス)
int → 4バイトアラインメント (4の倍数アドレス)
float → 4バイトアラインメント
float3 → 4バイトアラインメント (float × 3なのでfloatのアラインメントに従う)
double → 8バイトアラインメント
float4 → 16バイトアラインメント (SIMD最適化のためにBurstが強制)
例1: パディングのない理想的な構造体
1
2
3
4
5
6
7
8
struct GoodLayout // 28 bytes (パディングなし)
{
public float3 position; // offset 0, 12B
public float speed; // offset 12, 4B
public float health; // offset 16, 4B
public int state; // offset 20, 4B
public int type; // offset 24, 4B
}
1
2
3
4
バイトマップ (4B単位):
[pos.x ][pos.y ][pos.z ][speed ]
[health][state ][type ]
合計 28B, パディング 0B
すべてのフィールドが4バイトアラインメントなのでパディングは発生しない。
例2: フィールド順序によるパディング発生
1
2
3
4
5
6
7
8
9
10
11
struct BadLayout // 32 bytes! (パディング 4B)
{
public float3 position; // offset 0, 12B
public byte isAlive; // offset 12, 1B
// ← 3B padding (次のfloatが4の倍数アドレスに来る必要があるため)
public float speed; // offset 16, 4B
public float health; // offset 20, 4B
public int state; // offset 24, 4B
public byte type; // offset 28, 1B
// ← 3B padding (struct全体のサイズが最大アラインメントの倍数である必要があるため)
}
1
2
3
4
バイトマップ:
[pos.x ][pos.y ][pos.z ][a|pad ] ← byte後に3Bパディング
[speed ][health][state ][t|pad ] ← byte後に3Bパディング
合計 32B, パディング 6B (18.75%浪費)
例3: フィールド再配置によるパディング除去
1
2
3
4
5
6
7
8
9
10
struct OptimizedLayout // 28 bytes (パディング 0B)
{
public float3 position; // offset 0, 12B
public float speed; // offset 12, 4B
public float health; // offset 16, 4B
public int state; // offset 20, 4B
public byte isAlive; // offset 24, 1B
public byte type; // offset 25, 1B
// ← 2B padding (structサイズを4の倍数に合わせる)
}
1
2
3
4
バイトマップ:
[pos.x ][pos.y ][pos.z ][speed ]
[health][state ][a|t|pp]
合計 28B, パディング 2B (7%浪費) — BadLayout比4B節約
規則: 大きなフィールドを前に、小さなフィールドを後ろに配置するとパディングが最小化される。
sizeof比較
1
2
3
4
// Unityエディタで確認
Debug.Log(UnsafeUtility.SizeOf<GoodLayout>()); // 28
Debug.Log(UnsafeUtility.SizeOf<BadLayout>()); // 32
Debug.Log(UnsafeUtility.SizeOf<OptimizedLayout>()); // 28
UnsafeUtility.SizeOf<T>()が実際のメモリサイズを返す。Marshal.SizeOf()はinteropマーシャリングサイズなので異なる場合がある。Job/Burstコンテキストでは常にUnsafeUtility.SizeOf<T>()を使用する。
Burstのレイアウト保証
Burstコンパイラはstructを常にSequentialレイアウトで処理する。CLRのStructLayout.Auto(フィールド再配置許可)とは異なり、Burstはフィールド順序をそのまま維持する。したがって:
- Burst Jobで使用するstructはフィールド順序がそのままメモリ順序
- パディングを減らすにはコードレベルでフィールドを直接整列する必要がある
- Burstはさらに16バイトアラインメントを活用してSIMD aligned load/storeを生成する
パディングがAoSのstrideを大きくする
1
2
3
パディングなしのstruct (28B) × 5,000 = 140 KB → L2にロード
パディングありのstruct (32B) × 5,000 = 160 KB → L2にロード (余裕減少)
パディング多のstruct (48B) × 5,000 = 240 KB → L2境界付近 (危険)
4バイトのパディング差が5,000個で20KBのメモリ浪費になる。これが一つのstructのサイズの問題ではなく、配列全体のキャッシュロード可否に影響を与える理由だ。
Part 3: AoS → SoA 変換方法論
ステップバイステップのプロセス
既存のAoSコードをSoAに変換する体系的な5ステップ:
Step 1: アクセスパターン分析
各システム(Job)がどのフィールドを読み書きするかを表に整理する。
1
2
3
4
5
6
7
8
9
10
11
// 例: 仮想のAoS Agent構造体
struct Agent
{
public float3 position; // Movement, Distance, Renderingで使用
public float3 velocity; // Movementで使用
public float speed; // Movementで使用
public float health; // Combatで使用
public byte isAlive; // すべてのJobで使用
public byte state; // AI, Combatで使用
public byte type; // Spawn時のみ使用
}
アクセスパターン表:
| Job | 読み取りフィールド | 書き込みフィールド |
|---|---|---|
| MoveJob | position, velocity, speed, isAlive | position |
| DistanceJob | position, isAlive | (別途distances配列) |
| AttackJob | distances, isAlive, state | state, attackCooldown |
| RenderJob | position, isAlive | (別途matrices配列) |
核心的観察: すべてのJobがAgentの7フィールドすべてを使用するわけではない。MoveJobは4個、DistanceJobは2個だけ必要だ。
このアクセスパターンをヒートマップで可視化すると、どのフィールドがHotでどのフィールドがColdかが一目でわかる:
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#fff'}}}%%
quadrantChart
title フィールド別アクセス頻度ヒートマップ
x-axis "Cold (イベント性)" --> "Hot (毎フレーム)"
y-axis "単一Jobのみ" --> "複数Jobで"
quadrant-1 "SoA必須 (Hot + 複数)"
quadrant-2 "SoA推奨 (Hot + 単一)"
quadrant-3 "AoS OK (Cold + 単一)"
quadrant-4 "分離検討 (Cold + 複数)"
position: [0.95, 0.9]
isAlive: [0.85, 0.95]
velocity: [0.9, 0.3]
speed: [0.8, 0.3]
state: [0.4, 0.5]
health: [0.3, 0.25]
type: [0.1, 0.1]
Step 2: データグループの識別
アクセスパターンが類似するフィールドをグループにまとめる:
1
2
3
4
Movementグループ: position, velocity, speed (MoveJobが毎フレームアクセス)
Stateグループ: isAlive, state (複数Jobで読み取り)
Combatグループ: health, attackCooldown (CombatJobのみアクセス)
Identityグループ: type (スポーン時のみアクセス、以降読み取り専用)
Step 3: NativeArrayの分離
グループを基に、各フィールドを個別のNativeArrayに分離する:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Movement
NativeArray<float3> positions;
NativeArray<float3> velocities;
NativeArray<float> speeds;
// State
NativeArray<byte> isAlive;
NativeArray<byte> states;
// Combat
NativeArray<float> healths;
NativeArray<float> attackCooldowns;
// Identity
NativeArray<byte> types;
Step 4: Jobで必要な配列のみを参照
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[BurstCompile]
struct MoveJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Velocities;
[ReadOnly] public NativeArray<float> Speeds;
[ReadOnly] public NativeArray<byte> IsAlive;
public NativeArray<float3> Positions;
public float DeltaTime;
public void Execute(int i)
{
if (IsAlive[i] == 0) return;
Positions[i] += Velocities[i] * Speeds[i] * DeltaTime;
}
}
// Working Set = 60KB + 20KB + 5KB + 60KB = 145 KB (L2に余裕を持ってロード)
AoS方式だった場合: 5,000 × 48B = 240KB (Agent全体をロードする必要があるため)。 SoA方式でのMoveJobのWorking Setは145KB — 40%削減。
Step 5: 管理クラスでライフサイクルを統合
分離されたNativeArrayの割り当てと解放を一つのクラスで管理する:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class AgentData : IDisposable
{
public int Capacity { get; }
public int ActiveCount { get; set; }
// Movement
public NativeArray<float3> Positions;
public NativeArray<float3> Velocities;
public NativeArray<float> Speeds;
// State
public NativeArray<byte> IsAlive;
public NativeArray<byte> States;
// Combat
public NativeArray<float> Healths;
public NativeArray<float> AttackCooldowns;
// Identity
public NativeArray<byte> Types;
public AgentData(int capacity)
{
Capacity = capacity;
Positions = new NativeArray<float3>(capacity, Allocator.Persistent);
Velocities = new NativeArray<float3>(capacity, Allocator.Persistent);
Speeds = new NativeArray<float>(capacity, Allocator.Persistent);
IsAlive = new NativeArray<byte>(capacity, Allocator.Persistent);
States = new NativeArray<byte>(capacity, Allocator.Persistent);
Healths = new NativeArray<float>(capacity, Allocator.Persistent);
AttackCooldowns = new NativeArray<float>(capacity, Allocator.Persistent);
Types = new NativeArray<byte>(capacity, Allocator.Persistent);
}
public void Dispose()
{
if (Positions.IsCreated) Positions.Dispose();
if (Velocities.IsCreated) Velocities.Dispose();
if (Speeds.IsCreated) Speeds.Dispose();
if (IsAlive.IsCreated) IsAlive.Dispose();
if (States.IsCreated) States.Dispose();
if (Healths.IsCreated) Healths.Dispose();
if (AttackCooldowns.IsCreated) AttackCooldowns.Dispose();
if (Types.IsCreated) Types.Dispose();
}
}
このパターンがDODのFlyweightパターンでもある。「振る舞いロジック(Job struct)」は一つだけ存在し、「インスタンスデータ(NativeArray)」だけがN個ある構造だ。
Before/After メモリ比較
5,000エージェント、MoveJob実行基準:
| 項目 | AoS | SoA |
|---|---|---|
| アクセスメモリ | 5,000 × 48B = 240 KB | pos(60) + vel(60) + spd(20) + alive(5) = 145 KB |
| キャッシュ活用率 (positionアクセス時) | 25% | 93.75% |
| キャッシュ階層 | L2境界 (スラッシング危険) | L2安定 |
| 不要なデータロード | health, state, type, padding | 0 |
Partial SoA:関連フィールドのグルーピング
SoAが「すべてのフィールドを個別配列に分離せよ」という意味ではない。常に一緒にアクセスされるフィールドは一つのstructにまとめてもよい。
1
2
3
4
5
6
7
// Bad: float3のx, y, zを個別配列に分離
NativeArray<float> positionsX; // 意味のない分離
NativeArray<float> positionsY; // float3のx,y,zは常に一緒にアクセス
NativeArray<float> positionsZ;
// Good: float3は一つの単位
NativeArray<float3> positions; // x,y,zが常に一緒に必要なのでこれが正しい
分離の基準は「アクセスパターン」だ:
positionのx, y, zは常に一緒に読まれるため →NativeArray<float3>維持healthとattackCooldownが同じJobでのみアクセスされるなら →NativeArray<float2>にまとめてもOK (float2.x = health, float2.y = cooldown)healthとpositionは異なるJobでアクセスされるため → 必ず分離
この判断は結局Step 1のアクセスパターン分析から導かれる。アクセスパターンが同じフィールド同士はまとめ、異なるフィールド同士は分離する。
実践パターン:生成/削除が頻繁なエンティティ管理
SoAで最も厄介な部分はエンティティの動的追加/削除だ。AoSではオブジェクトをnew/deleteすればよいが、SoAではすべての配列で同一のインデックスを管理する必要がある。
Free Listパターン
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class SoAEntityPool : IDisposable
{
// SoA配列
public NativeArray<float3> Positions;
public NativeArray<float3> Velocities;
public NativeArray<byte> IsAlive;
// 再利用可能なインデックススタック
NativeQueue<int> _freeIndices;
int _highWaterMark; // 一度でも使用された最大インデックス
public int Spawn(float3 pos, float3 vel)
{
int idx;
if (!_freeIndices.TryDequeue(out idx))
{
idx = _highWaterMark++;
}
Positions[idx] = pos;
Velocities[idx] = vel;
IsAlive[idx] = 1;
return idx;
}
public void Despawn(int idx)
{
IsAlive[idx] = 0;
_freeIndices.Enqueue(idx);
}
}
Free List vs Swap and Pop 比較:
| 項目 | Free List | Swap and Pop |
|---|---|---|
| インデックス安定性 | 維持される (外部参照安全) | 壊れる (リマップ必要) |
| 配列密度 | 穴が発生 (fragmentation) | 常に密集 |
| Job走査 | if (IsAlive[i]) 分岐が必要 | 分岐なしでActiveCountまで |
| Working Set | 死んだエンティティも含む | 生きているもののみ |
| 適した状況 | 外部からID参照が必要な場合 | 性能の極限最適化時 |
プロジェクト初期にはFree Listで始め、プロファイリングで分岐コストが問題になった時にSwap and Popに切り替えるのが現実的だ。
Part 4: Hot/Coldデータ分離パターン
Hot Data vs Cold Data
すべてのデータが毎フレームアクセスされるわけではない。アクセス頻度によってデータを分類できる:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌────────────────────────────────────────────────────┐
│ Hot Data (毎フレーム、並列Job) │
│ positions, velocities, isAlive │
│ → NativeArray + IJobParallelFor 必須 │
│ → キャッシュ効率が性能を直接決定 │
├────────────────────────────────────────────────────┤
│ Warm Data (毎フレームだが条件付き) │
│ speeds, distances, separationForces │
│ → NativeArray 推奨 │
│ → isAlive == 0ならスキップするため実際のアクセス < 配列サイズ │
├────────────────────────────────────────────────────┤
│ Cold Data (イベント/遷移時のみ) │
│ health, type, state, attackCooldown │
│ → NativeArrayも可能だが、managedでもOK │
│ → キャッシュ効率よりコードの可読性が重要 │
└────────────────────────────────────────────────────┘
アクセス頻度に基づく分離戦略
核心原則: HotデータのWorking Setを最小化せよ。
HotデータにColdデータを混ぜるとWorking Setが不必要に大きくなる:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// アンチパターン: HotとColdを同じstructに
struct Agent // 48B
{
// Hot (毎フレームアクセス)
public float3 position; // 12B
public float3 velocity; // 12B
// Cold (たまにアクセス)
public float health; // 4B
public float cooldown; // 4B
public int kills; // 4B
public byte faction; // 1B
// ... padding
}
// MoveJobがposition + velocityだけ必要なのに
// health, cooldown, kills, factionもキャッシュに載る
// Working Set: 5,000 × 48B = 240KB (Coldデータ込み)
1
2
3
4
5
6
// 正しい分離: Hot配列のみ別途
NativeArray<float3> positions; // Hot
NativeArray<float3> velocities; // Hot
// MoveJob Working Set: 5,000 × 24B = 120KB (Hotのみ!)
// → L2に余裕を持ってロード
Coldデータのオプション:
Coldデータは必ずしもNativeArrayである必要はない。アクセス頻度が低く少量であればmanaged配列やDictionaryも許容される:
1
2
3
4
// Cold: イベントベースでアクセス
NativeArray<float> healths; // Jobでアクセスが必要ならNativeArray
float[] attackCooldowns; // Jobが不要ならmanagedでもOK
Dictionary<int, string> agentNames; // デバッグ/UI専用ならmanagedが自然
ただし、JobでアクセスするColdデータは依然としてNativeArrayでなければならない (managed → Job不可)。
分離判断チェックリスト
データフィールド一つ一つにこの質問を適用する:
flowchart TD
A["このフィールドは毎フレームアクセスされるか?"] -->|Yes| B["並列Jobからアクセスするか?"]
A -->|No| C["イベント/遷移時のみアクセス"]
B -->|Yes| D["Hot: NativeArray + SoA分離"]
B -->|No| E["メインスレッドのみからアクセスか?"]
E -->|Yes| F["Warm: NativeArray推奨\n(managedも可)"]
E -->|No| D
C --> G["Jobからのアクセスが必要か?"]
G -->|Yes| H["Cold: NativeArray\n(別グループ)"]
G -->|No| I["Cold: managed OK\n(可読性優先)"]
核心は「HotデータのWorking SetにColdデータが混ざらないようにせよ」ということだ。これがSoAとHot/Cold分離の接点だ。
分岐予測とSoA:隠れた利点
Hot/Cold分離に関連して、あまり知られていない利点がもう一つある。分岐予測器(Branch Predictor)との相互作用だ。
1
2
3
4
5
6
// 多くのJobにこのパターンがある:
public void Execute(int i)
{
if (IsAlive[i] == 0) return; // ← 分岐
Positions[i] += Velocities[i] * Speeds[i] * DeltaTime;
}
現代CPUの分岐予測器は最近の分岐履歴(Branch History Buffer)に基づいて次の分岐を予測する。
1
2
3
4
5
6
7
8
9
SoA — IsAlive配列が連続:
[1,1,1,1,1,0,0,0,1,1,1,0,...] ← 同じキャッシュラインに64個!
→ 分岐予測器がパターンを素早く学習
→ 予測精度が高い → パイプラインストール最小化
AoS — isAliveが48B間隔:
Agent[0].isAlive ... (48B gap) ... Agent[1].isAlive ...
→ 分岐履歴バッファにより少ないサンプルが蓄積
→ パターン学習が遅い
この効果はisAliveのようなブーリアンフィールドだけでなく、stateベースの分岐でも同様に適用される。
より根本的な解決:分岐を除去せよ
分岐予測精度を高めることより分岐自体を除去する方がよい。2つの方法がある:
方法1: 乗算で分岐を代替 (Branchless)
1
2
3
4
5
6
7
8
public void Execute(int i)
{
// if (IsAlive[i] == 0) return; の代わりに:
float alive = IsAlive[i]; // 0.0f or 1.0f
Positions[i] += Velocities[i] * Speeds[i] * DeltaTime * alive;
// → deadエンティティは0を掛けて変化なし
// → 分岐0回、SIMDベクトル化にも有利
}
方法2: Swap and Pop — 配列圧縮
1
2
3
4
5
6
7
8
9
10
11
12
// エンティティが死んだら配列末尾の生存エンティティと交換
void Kill(int index)
{
ActiveCount--;
positions[index] = positions[ActiveCount];
velocities[index] = velocities[ActiveCount];
speeds[index] = speeds[ActiveCount];
// ... すべてのSoA配列に対してswap
}
// JobはActiveCountまでのみ走査 — isAliveチェック不要!
new MoveJob { ... }.Schedule(ActiveCount, 64);
Swap and Popは分岐除去+Working Set縮小を同時に達成する。5,000個中3,000個だけが生存していれば、Working Setが40%縮小する。ただし、インデックス安定性が必要な場合(外部からagentIdでアクセス)、別途インデックスリマップテーブルが必要だ。
Part 5: トレードオフと判断基準
AoSの方が良いケース
SoAが常に正解ではない。以下の状況ではAoSの方が適している:
1. 単一エンティティの全データアクセス
1
2
3
4
5
6
7
8
9
10
11
12
// UIで選択したユニットの情報を表示する場合
void ShowUnitInfo(int unitId)
{
// SoA: 8個の配列からそれぞれunitIdインデックスでアクセス → 8回のランダムアクセス
string info = $"HP: {healths[unitId]}, Speed: {speeds[unitId]}, " +
$"State: {states[unitId]}, Type: {types[unitId]}...";
// AoS: 1回のアクセスですべてのデータを取得 → キャッシュライン1〜2個
var unit = units[unitId];
string info = $"HP: {unit.health}, Speed: {unit.speed}, " +
$"State: {unit.state}, Type: {unit.type}...";
}
SoAはバッチ走査(batch iteration)に最適化されている。単一エンティティの全フィールドを一度に読む必要がある場合はAoSの方がキャッシュ効率が良い。
2. Cold Path (イベントベース、少量処理)
1
2
3
4
5
6
7
8
9
10
// ターン切り替え時に1回実行、ユニット20個のみ処理
void OnTurnEnd()
{
foreach (var unit in selectedSquad) // 最大20個
{
unit.health += unit.healRate;
unit.morale += CalculateMorale(unit);
unit.fatigue -= unit.restRate;
}
}
このコードは20回しか実行されない。キャッシュ効率が性能に与える影響は測定不可能なほど小さい。ここでSoAを適用するとコードの複雑度だけが上がる。
3. エンティティ数 < 100
エンティティが100個未満なら、AoS全体のWorking Setは:
1
100 × 48B = 4.8 KB → L1キャッシュ(32KB)に完全にロード
L1に収まればAoSでもSoAでも性能差はほぼない。キャッシュ最適化はWorking Setがキャッシュを溢れる時に意味がある。
4. プロトタイプ段階
リリースまで遠く、ゲームプレイを実験する段階なら可読性と修正の容易さが性能より重要だ。AoSで素早くプロトタイプし、プロファイリングでボトルネックが確認された後にSoAに切り替えても遅くはない。
ハイブリッド:AoSoAパターン
SoAとAoSの長所を組み合わせたAoSoA(Array of Structure of Arrays)パターンがある。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// AoSoA: 8個のエンティティを一つのSoAブロックにまとめる
struct AgentBlock8
{
// 8エージェントのposition.xを連続配置
public fixed float posX[8]; // 32B — SIMD(AVX2)で一度に処理
public fixed float posY[8]; // 32B
public fixed float posZ[8]; // 32B
public fixed float velX[8]; // 32B
public fixed float velY[8]; // 32B
public fixed float velZ[8]; // 32B
}
NativeArray<AgentBlock8> blocks; // 5,000 / 8 = 625個
// 長所: SIMD 8-wide演算に完璧にマッチ
// 短所: コードの複雑度が急激に増加
AoSoAはSIMD幅に正確に合わせた極限の最適化だ。GPU compute shaderやISPC(Intel SPMD)のような環境で主に使用される。
Unity Jobs + Burstではほとんどの場合、通常のSoAで十分だ。 Burstの自動ベクトル化がSoA配列をSIMDでよく処理するため、AoSoAの複雑さを受け入れる必要はほぼない。Burst Inspectorでベクトル化されていないことを確認してから検討しても遅くはない。
SoAとFalse Sharing:batchCountチューニング
SoAはキャッシュ効率を最大化するが、並列処理でFalse Sharingを悪化させる可能性がある。SoAのstrideが小さいためだ。
1
2
3
4
5
6
7
8
IJobParallelForでbatchCount=1に設定すると:
Thread 0 → positions[0] ─┐
Thread 1 → positions[1] ├─ 同じキャッシュライン (12B stride, 64Bラインに5個)
Thread 2 → positions[2] │
Thread 3 → positions[3] │
Thread 4 → positions[4] ─┘
→ 5スレッドが同じキャッシュラインに同時書き込みしようとする
→ キャッシュラインがコア間でピンポン → False Sharing!
解決: batchCountをキャッシュライン基準で設定
1
2
3
4
5
6
7
// stride = 12B (float3), キャッシュライン = 64B
// 64 / 12 ≈ 5.3 → 最低6個ずつまとめてキャッシュライン1個を独占する必要あり
// 実戦では64〜128にゆとりを持って設定
new MoveJob { ... }.Schedule(entityCount, 64); // ← batchCount = 64
// Thread 0: positions[0..63] → キャッシュライン~12個を独占
// Thread 1: positions[64..127] → 別のキャッシュライン
// → False Sharing除去
batchCountの経験則:
| stride | 最小batchCount | 推奨batchCount |
|---|---|---|
| 4B (float) | 16 | 64〜128 |
| 12B (float3) | 6 | 64〜128 |
| 16B (float4) | 4 | 64 |
AoSではstrideが大きいため(48B)、キャッシュラインあたりのエンティティが1〜2個でFalse Sharingはあまり発生しない。SoAはstrideが小さいためbatchCountチューニングが必須だ。これはSoAのトレードオフの一つだ。
GPUとの接続:「GPUはもともとSoAだ」
CPUでSoAを理解したなら、GPU最適化も同じ原理で拡張できる。
GPUのSIMT(Single Instruction, Multiple Threads)アーキテクチャで32スレッド(warp)が同時にメモリにアクセスする際、連続したアドレスにアクセスすれば一つのメモリトランザクションに統合される(Coalesced Access)。散在したアドレスにアクセスすれば32回の個別トランザクションが発生する。
1
2
3
4
5
6
7
8
9
10
11
12
13
GPU Compute Shaderで:
// AoS: StructuredBuffer<Agent> — Stride 48B
// Thread 0 → agents[0].position (アドレス 0)
// Thread 1 → agents[1].position (アドレス 48)
// Thread 2 → agents[2].position (アドレス 96)
// → 32スレッドが48B間隔でアクセス → メモリトランザクション多数発生
// SoA: StructuredBuffer<float3> positions — Stride 12B
// Thread 0 → positions[0] (アドレス 0)
// Thread 1 → positions[1] (アドレス 12)
// Thread 2 → positions[2] (アドレス 24)
// → 32スレッドが連続アクセス → 少数のトランザクションに統合 (Coalesced!)
| 概念 | CPU | GPU |
|---|---|---|
| メモリ効率の単位 | キャッシュライン (64B) | メモリトランザクション (32/128B) |
| シーケンシャルアクセス最適化 | プリフェッチャ | Coalesced Access |
| SoAの利点 | キャッシュ活用率 ↑ | トランザクション数 ↓ |
| 散在アクセスのペナルティ | キャッシュミス | Uncoalesced (帯域幅浪費) |
NVIDIAの”CUDA C++ Best Practices Guide” Section 9.2でCoalesced Accessを詳しく扱っている。CPUでSoAを理解していれば、GPU Compute Shader最適化は同じ思考法の自然な拡張だ。
Unity DOTS/ECSとの関係
UnityのEntity Component System(ECS)は内部的にSoAと類似した構造を自動的に実装する。
1
2
3
4
5
6
ECS Archetypeメモリレイアウト:
┌──────────── Chunk (16 KB) ──────────┐
│ [Position][Position][Position]... │ ← SoA: 同じコンポーネント同士が連続
│ [Velocity][Velocity][Velocity]... │
│ [Health] [Health] [Health] ... │
└─────────────────────────────────────┘
ECSを使用すればSoAレイアウトを手動で実装する必要はない — Archetypeシステムが自動的に処理する。
しかしECS導入はプロジェクト全体のアーキテクチャを変更する決定だ。Jobs + Burstだけでも NativeArray + IDisposableラッパークラスパターンを使用すれば、ECSと同等のキャッシュ効率を達成できる。チーム規模、学習曲線、既存コードベースを考慮して判断する。
判断フローチャート
flowchart TD
A["データレイアウトの決定"] --> B{"毎フレーム数千回\n実行されるコードか?"}
B -->|No| C["AoS維持\n(可読性優先)"]
B -->|Yes| D{"一つのJobが全フィールドの\n50%以上にアクセスするか?"}
D -->|Yes| E["Partial SoA\n(関連フィールド同士をまとめて分離)"]
D -->|No| F{"Working Setが\nL2キャッシュを超えるか?"}
F -->|Yes| G["SoA必須\n(フィールドごとにNativeArray分離)"]
F -->|No| H["SoA推奨\n(Working Set削減効果あり)"]
Part 6: ベンチマーク
ベンチマーク設計
理論を実測で検証しよう。3つの構成を比較する:
- Managed + AoS:
float3[]+ 通常forループ (baseline) - Burst + AoS:
NativeArray<AgentAoS>+IJobParallelFor+[BurstCompile] - Burst + SoA:
NativeArray<float3>分離 +IJobParallelFor+[BurstCompile]
タスク: 5,000エージェントの目標地点までの距離計算 (キャッシュ効果が明確に現れる単純な演算)。
ベンチマークコード
Unityプロジェクトにドロップインできる自己完結コード:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using Unity.Profiling;
using UnityEngine;
public class SoABenchmark : MonoBehaviour
{
[SerializeField] int entityCount = 5000;
[SerializeField] int warmupFrames = 60;
// AoS構造体
struct AgentAoS
{
public float3 position;
public float3 velocity;
public float speed;
public float health;
public int state;
public byte type;
}
// ── Managed + AoS ──
static readonly ProfilerMarker s_Managed = new("Bench.Managed.AoS");
void BenchManaged(AgentAoS[] agents, float[] dists, float3 target)
{
s_Managed.Begin();
for (int i = 0; i < agents.Length; i++)
{
float3 d = agents[i].position - target;
dists[i] = math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
}
s_Managed.End();
}
// ── Burst + AoS Job ──
static readonly ProfilerMarker s_BurstAoS = new("Bench.Burst.AoS");
[BurstCompile]
struct DistanceAoSJob : IJobParallelFor
{
[ReadOnly] public NativeArray<AgentAoS> Agents;
[WriteOnly] public NativeArray<float> Distances;
[ReadOnly] public float3 Target;
public void Execute(int i)
{
float3 d = Agents[i].position - Target;
Distances[i] = math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
}
}
// ── Burst + SoA Job ──
static readonly ProfilerMarker s_BurstSoA = new("Bench.Burst.SoA");
[BurstCompile]
struct DistanceSoAJob : IJobParallelFor
{
[ReadOnly] public NativeArray<float3> Positions;
[WriteOnly] public NativeArray<float> Distances;
[ReadOnly] public float3 Target;
public void Execute(int i)
{
float3 d = Positions[i] - Target;
Distances[i] = math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);
}
}
NativeArray<AgentAoS> _aosAgents;
NativeArray<float3> _soaPositions;
NativeArray<float> _distances;
AgentAoS[] _managedAgents;
float[] _managedDists;
float3 _target;
int _frame;
void Start()
{
_aosAgents = new NativeArray<AgentAoS>(entityCount, Allocator.Persistent);
_soaPositions = new NativeArray<float3>(entityCount, Allocator.Persistent);
_distances = new NativeArray<float>(entityCount, Allocator.Persistent);
_managedAgents = new AgentAoS[entityCount];
_managedDists = new float[entityCount];
_target = new float3(50, 0, 50);
var rng = new Unity.Mathematics.Random(42);
for (int i = 0; i < entityCount; i++)
{
var pos = rng.NextFloat3() * 100f;
_aosAgents[i] = new AgentAoS
{
position = pos, velocity = rng.NextFloat3(),
speed = rng.NextFloat(1f, 5f), health = 100f,
state = 1, type = (byte)(i % 4)
};
_soaPositions[i] = pos;
_managedAgents[i] = _aosAgents[i];
}
}
void Update()
{
if (++_frame < warmupFrames) return;
// 1. Managed + AoS
BenchManaged(_managedAgents, _managedDists, _target);
// 2. Burst + AoS
s_BurstAoS.Begin();
new DistanceAoSJob
{
Agents = _aosAgents, Distances = _distances, Target = _target
}.Schedule(entityCount, 64).Complete();
s_BurstAoS.End();
// 3. Burst + SoA
s_BurstSoA.Begin();
new DistanceSoAJob
{
Positions = _soaPositions, Distances = _distances, Target = _target
}.Schedule(entityCount, 64).Complete();
s_BurstSoA.End();
}
void OnDestroy()
{
if (_aosAgents.IsCreated) _aosAgents.Dispose();
if (_soaPositions.IsCreated) _soaPositions.Dispose();
if (_distances.IsCreated) _distances.Dispose();
}
}
期待結果
Unity ProfilerのTimeline ViewでBench.*マーカーを確認すると、以下のような結果が期待できる:
| 構成 | 1,000 | 5,000 | 10,000 |
|---|---|---|---|
| Managed + AoS | ~0.15ms | ~0.8ms | ~1.6ms |
| Burst + AoS | ~0.02ms | ~0.08ms | ~0.18ms |
| Burst + SoA | ~0.01ms | ~0.04ms | ~0.08ms |
実際の数値はCPU、キャッシュサイズ、他のタスクのキャッシュ干渉によって異なる。重要なのは相対的な比率だ。
核心的観察:
- Managed → Burst: Burstコンパイルだけで~10倍改善 (SIMD + ネイティブコード)
- Burst AoS → Burst SoA: レイアウト変更だけで~2倍の追加改善 (キャッシュ効率)
- 規模拡大時に格差拡大: 10,000個でAoS Working SetがL2を超えると性能低下が急激に
1
2
3
4
5
6
Burst + AoS Working Set:
10,000 × sizeof(AgentAoS) = 10,000 × 48B = 480 KB → L2超過!
→ L3アクセス開始 → 遅延時間3倍増
Burst + SoA Working Set (DistanceJob):
10,000 × 12B(pos) + 10,000 × 4B(dist) = 160 KB → L2内!
これが「Burstだけではキャッシュ問題を解決できない」という意味だ。 Burstは演算を最適化するが、メモリレイアウトは開発者が決定しなければならない。
Burst InspectorでSIMDを検証
Burst InspectorはJobが実際にどのようなネイティブコードにコンパイルされたかを表示する。
開き方: Unityメニュー → Jobs → Burst → Open Inspector
確認ポイント:
1
2
3
4
5
6
7
8
9
10
✅ 良い兆候 (SoAが正しくベクトル化):
movaps xmm0, [rdi + rcx*4] ; aligned SIMDロード (128-bit, 4 float)
subps xmm0, xmm1 ; packed subtract (4個同時)
mulps xmm0, xmm0 ; packed multiply
addps xmm0, xmm2 ; packed add
sqrtps xmm0, xmm0 ; packed sqrt (4個同時!)
❌ 悪い兆候 (AoSで発生し得る):
movss xmm0, [rdi + rcx] ; scalarロード (1 floatのみ)
vgatherdps ymm0, [rdi + ymm1] ; gather (散在データ収集 → 遅い)
movaps/addps/mulps: Packed(ベクトル)演算 → SoAが連続データをうまくベクトル化movss: Scalar(単一)演算 → ベクトル化失敗vgatherdps: Gather → データが散在しているためSIMDで集める必要あり (AoSの典型)
Burst Inspectorでホットループの命令を確認し、ベクトル化が正しく行われているか検証しよう。
キャッシュミス実測:時間ではなく回数を数えよ
上記ベンチマークは実行時間を測定する。しかし「SoAがキャッシュ効率に優れる」という主張を定量的に証明するには、キャッシュミス回数自体を数える必要がある。
プラットフォーム別測定ツール
| プラットフォーム | ツール | 核心カウンタ |
|---|---|---|
| Linux | perf stat | cache-misses, cache-references, L1-dcache-load-misses |
| macOS | Instruments → Counters | L1D_CACHE_MISS_LD, INST_RETIRED |
| Windows | Intel VTune | Memory Access Analysis → L1/L2/L3 Bound |
| クロスプラットフォーム | Cachegrind (Valgrind) | D1mr (L1 data read miss), DLmr (LL read miss) |
Linux perf の例
Unityビルドではなくstandalone C#ベンチマークでテストする場合:
1
2
3
4
5
# AoS実行 — キャッシュミス測定
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./bench_aos
# SoA実行 — キャッシュミス測定
perf stat -e cache-misses,cache-references,L1-dcache-load-misses ./bench_soa
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
期待結果 (5,000エージェント、DistanceJob基準):
AoS:
cache-references: ~150,000
cache-misses: ~45,000 (miss rate 30%)
L1-dcache-load-misses: ~80,000
SoA:
cache-references: ~40,000
cache-misses: ~2,000 (miss rate 5%)
L1-dcache-load-misses: ~5,000
→ SoAのキャッシュミスがAoS比で約1/20
→ 時間差(~2倍)よりキャッシュミス差(~20倍)の方がはるかに劇的
(CPUがミスをパイプラインとプリフェッチャで部分的に隠蔽するため)
なぜ時間差よりキャッシュミス差の方が大きいのか? 現代CPUはOut-of-Order実行とプリフェッチャでキャッシュミスの遅延を部分的に隠す。ミスが20倍減っても実行時間は2〜4倍速くなるのが一般的だ。しかしこれは「キャッシュミスがあまり重要でない」という意味ではなく、CPUがミスを隠すために膨大なリソースを消費しているという意味だ。
Unityでの間接測定
Unity Profilerで直接キャッシュミスカウンタを読むことはできないが、間接指標で推定できる:
- 実行時間に対する演算量の比率: 同一の数学演算(distance計算)なのに時間差が大きければ → メモリがボトルネック
- エンティティ数増加時の非線形性能低下: 1,000→5,000は5倍なのに時間が8倍遅くなれば → Working Setがキャッシュ境界を超えた
- Burst Inspector: gather命令(
vgatherdps)の存在有無でAoSの非効率を確認
まとめ
核心要約
| 概念 | 説明 | 適用基準 |
|---|---|---|
| DOD | 「データ変換」中心の設計 | 数千の同種エンティティ+フレーム予算 |
| Stride | 連続アクセス間のバイト距離 | stride ↓ = キャッシュ活用率 ↑ |
| Working Set | Jobがアクセスする総メモリ | L2以内 = 良好、超過 = SoA必須 |
| Prefetcher | シーケンシャルアクセスパターンを検知して先読みロード | SoAの均一なstrideが最適 |
| 帯域幅 | メモリバス転送量 | AoSは不要なフィールドも転送 → 浪費 |
| TLB | 仮想→物理アドレス変換キャッシュ | SoA配列の過度な分離でTLBミス危険 |
| SoA | フィールドごとの配列分離 | Hot path+部分フィールドアクセス |
| Partial SoA | 関連フィールドをまとめて分離 | 一緒にアクセスされるフィールド+TLB圧迫緩和 |
| Hot/Cold分離 | アクセス頻度別のデータ分類 | Hot Working Setの最小化 |
| Swap and Pop | 死んだエンティティを末尾と交換 | 分岐除去+Working Set縮小 |
| batchCount | Jobバッチサイズ | SoAはstrideが小さいためFalse Sharingに注意 |
| AoS維持 | 構造体配列のまま | Cold path、少量、全フィールドアクセス |
次回のポスト
この記事で扱ったメモリレイアウトの原則をもとに、次回はBurst Compiler内部動作の深堀り — LLVMパイプライン、Burst Inspectorの読み方、SIMD最適化パターン — を扱う予定だ。
References
講演 & 発表
- Mike Acton, “Data-Oriented Design and C++”, CppCon 2014 — DODを確立した核心的講演。YouTube
- Scott Meyers, “CPU Caches and Why You Care”, code::dive 2014 — キャッシュの基本原理をC++の観点から解説。YouTube
- Chandler Carruth, “Efficiency with Algorithms, Performance with Data Structures”, CppCon 2014 — データ構造の選択が性能に与える影響。YouTube
- Andreas Fredriksson, “SIMD at Insomniac Games”, GDC 2015 — SoA + SIMD実戦適用事例
論文 & ドキュメント
- Ulrich Drepper, “What Every Programmer Should Know About Memory”, 2007 — CPUキャッシュ、TLB、メモリ階層のバイブル。PDF
- Samuel Williams et al., “Roofline: An Insightful Visual Performance Model for Multicore Architectures”, Communications of the ACM, 2009 — compute-bound vs memory-boundの判断フレームワーク
- Srinath et al., “Feedback Directed Prefetching: Improving the Performance and Bandwidth-Efficiency of Hardware Prefetchers”, HPCA 2007 — ハードウェアプリフェッチャの動作原理と効率分析
- Intel, “64 and IA-32 Architectures Optimization Reference Manual” — Stride prefetcherスペック、cache associativity、TLB構造の公式文書
- NVIDIA, “CUDA C++ Best Practices Guide”, Section 9.2 — Coalesced Memory Access (GPUのSoA原理)
書籍
- Richard Fabian, “Data-Oriented Design”, 2018 — DOD専門書籍
- Jason Gregory, “Game Engine Architecture”, 3rd Edition, 2018, Chapter 16 — ゲームエンジンでのDOD実戦適用
- Hennessy & Patterson, “Computer Architecture: A Quantitative Approach”, 6th Edition, Chapter 2 — キャッシュ階層の定量的分析 (教科書レベルのリファレンス)
ブログ & 記事
- Noel Llopis, “Data-Oriented Design (Or Why You Might Be Shooting Yourself in The Foot With OOP)”, 2009 — DOD初期文献、Mike Acton講演の思想的先駆
Unity公式ドキュメント
- Unity Documentation, “Burst Compiler User Guide” — Burstの制約事項と最適化ガイド
- Unity Documentation, “C# Job System” — NativeContainer、Jobインターフェースリファレンス
