記事

Burst Compiler 内部動作の深掘り - LLVMパイプラインからアセンブリ読解まで

Burst Compiler 内部動作の深掘り - LLVMパイプラインからアセンブリ読解まで
前提知識 — 先にこちらをご確認ください
TL;DR — 要点まとめ
  • BurstはC# ILを4段階パイプライン(Discovery → Front End → Middle End → Back End)でLLVMネイティブコードに変換し、Job Systemの「100% alias-free」保証のおかげでC++より速いコードを生成できる
  • LLVMのSROA、LICM、Loop/SLPベクトル化パスが性能の核心であり、自動ベクトル化は「notoriously brittle」で条件分岐一つで32→1演算への退化が起こりうる
  • FloatMode、FloatPrecision、Hint.Assumeなどのコンパイラオプションとヒントを理解し、Burst Inspectorで実際のアセンブリを検証することが最適化の核心である
Visitors

序論

2018年、Aras PranckevičiusはToyPathTracerベンチマークで驚くべき結果を発表した。C# BurstがC++より速いケースが存在するということだ — PCでBurst 140 Mray/s vs C++ 136 Mray/s。

Aras Pranckevičius, “Pathtracer 16: Burst & SIMD Optimization”, 2018

C#がC++より速いなんて、直感的におかしい。JITコンパイル、GCオーバーヘッド、managedタイプ制約 — これらすべてがC#を遅くする要因ではないか?

Burstがこれを可能にする秘訣はLLVMバックエンド + Job Systemの構造的保証にある。以前のポストでBurstのコンパイルパイプライン概要、SIMD基礎、[BurstCompile]の基本的な使い方を扱った。このポストではその内側を掘り下げる:

  • LLVMが内部的にどのような最適化パスを適用するか
  • [BurstCompile]オプションがコード生成をどう変えるか
  • Burst Inspectorのアセンブリを実際に読む方法
  • 自動ベクトル化が成功/失敗する条件と解決法

Part 1: LLVM最適化パスの解剖

1.1 Burstの4段階コンパイルパイプライン

Job Systemポストで「C# → IL → LLVM IR → ネイティブコード」パイプラインの概要を扱った。Unity公式ドキュメントはこれを4段階にさらに細分する:

flowchart LR
    A["1. Method Discovery\nコンパイル対象探索"] --> B["2. Front End\nIL → Burst IR"]
    B --> C["3. Middle End\nBurst IR → LLVM IR\n+ 最適化パス"]
    C --> D["4. Back End\nLLVM IR → ネイティブDLL"]

Unity Burst Manual v1.8 — Compilation Pipeline

Stage 1: Method Discovery

[BurstCompile]が付いたJob structを探し、Execute()メソッドをコンパイル対象として登録する。この段階でジェネリックのインスタンス化も処理される。

Stage 2: Front End (IL → Burst IR)

C#コンパイラが生成したIL(Intermediate Language)をBurst内部の中間表現(Burst IR)に変換する。

この段階で除去されるもの:

  • GC連携コード(メモリバリア、カードテーブル更新)
  • vtableベースの仮想関数ディスパッチ
  • boxing/unboxing
  • 例外処理インフラ(try-catch)

この段階で追加されるもの:

  • noaliasメタデータ — NativeContainerパラメータが互いに重ならないことを保証
  • readonlyメタデータ — [ReadOnly]アトリビュートが付いた配列
  • 型安全性検証 — managedタイプ使用時にコンパイルエラー

このnoaliasアノテーションこそがBurstがC++より速いコードを生成できる核心的な理由だ。C++コンパイラはポインタエイリアシングの可能性を常に考慮しなければならないが、BurstはJob SystemのSafety Systemのおかげで「100% alias-free」を構造的に保証する。

5argon, “Unity at GDC: C# to Machine Code” — C++で__restrictキーワード一つで4倍の性能向上が観測された例があるが、Burstはこれを自動的に解決する。

Stage 3: Middle End(最適化)

Burst IRをLLVM IRに変換した後、LLVMの最適化パスパイプラインを適用する。これがこのポストの核心テーマだ。

Stage 4: Back End(コード生成)

最適化されたLLVM IRをターゲットプラットフォームのネイティブコードに変換する。Instruction Selection → Register Allocation → Code Emissionの順序で進行する。

参考:「カーネル理論」の現実

Burstの元々の設計哲学は「小さな性能クリティカルなカーネル関数のみをコンパイルし、残りはmanaged glue code」だった。しかしSebastian Schonerは2024年の分析でこの「カーネル理論」が実証的に反証されたことを示した:

  • 単純なOnCreateメソッドのディスアセンブリ:約16,000行のアセンブリ
  • ECB(EntityCommandBuffer)再生のBurstコンパイル:システムあたり約64,000行

Sebastian Schoner, “Burst and the Kernel Theory of Game Performance”, 2024.12

実際のプロジェクトではカーネルだけでなくECSフレームワーク自体の複雑さ(enableable components、query caching、error handling)までBurstコンパイル範囲に入ることで、コンパイル時間が急激に増加しうる。これに対する実践的な対応はPart 5で扱う。

1.2 主要LLVM最適化パス

BurstのMiddle Endで適用される核心的なLLVMパスを整理する。各パスがコードをどう変換するかC#疑似コードで見てみよう。

LLVM Passes Reference — https://llvm.org/docs/Passes.html

SROA (Scalar Replacement of Aggregates)

構造体や配列を個別のスカラー値に分解してレジスタに直接配置する。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Before SROA:
float3 pos = Positions[i];
float3 vel = Velocities[i];
float3 newPos = pos + vel * dt;  // float3はstruct — メモリに割り当て?
Positions[i] = newPos;

// After SROA:
// float3のx, y, zがそれぞれレジスタに分離
float px = Positions_x[i], py = Positions_y[i], pz = Positions_z[i];
float vx = Velocities_x[i], vy = Velocities_y[i], vz = Velocities_z[i];
Positions_x[i] = px + vx * dt;
Positions_y[i] = py + vy * dt;
Positions_z[i] = pz + vz * dt;

このパスがfloat3quaternionのようなUnity.Mathematicsタイプの性能に決定的だ。SROAがなければstructを毎回メモリに読み書きするオーバーヘッドが発生する。

Inlining(関数インライニング)

関数呼び出しを呼び出し地点に本体で置き換える。

1
2
3
4
5
6
7
// Before Inlining:
float dist = math.distance(pos, target);
// ↓ math.distance()の本体が挿入される

// After Inlining:
float3 d = pos - target;
float dist = math.sqrt(d.x * d.x + d.y * d.y + d.z * d.z);

[MethodImpl(MethodImplOptions.AggressiveInlining)]を付けるとインライニングの閾値を下げてより積極的にインライニングする。Unity.Mathematicsmath.*関数はほとんどこのアトリビュートが付いているため、Burstコンパイル時の呼び出しオーバーヘッドは0だ。

LICM (Loop-Invariant Code Motion)

ループ内で毎回同じ結果を出す計算をループの外に移動する。

1
2
3
4
5
6
7
8
9
10
11
12
13
// Before LICM:
for (int i = 0; i < count; i++)
{
    float invDt = 1f / DeltaTime;         // ← 毎回同じ!
    Velocities[i] = Positions[i] * invDt;
}

// After LICM:
float invDt = 1f / DeltaTime;             // ← ループの外に移動
for (int i = 0; i < count; i++)
{
    Velocities[i] = Positions[i] * invDt;
}

開発者が見落としやすい最適化だが、LLVMはこれを自動的に処理する。ただし、副作用のある関数呼び出しは移動しない。

Constant Folding + Propagation

コンパイル時に計算可能な定数を事前に計算し、その結果を使用箇所に伝播する。

1
2
3
4
5
6
// Before:
float twoPi = 2f * math.PI;
float angle = twoPi * 0.25f;

// After:
float angle = 1.5707963f;  // コンパイル時に計算完了

GVN (Global Value Numbering)

同一の式の重複計算を除去する。

1
2
3
4
5
6
7
8
9
// Before:
float distA = math.sqrt(dx * dx + dz * dz);
// ... 他のコード ...
float distB = math.sqrt(dx * dx + dz * dz);  // 同一の計算!

// After:
float dist = math.sqrt(dx * dx + dz * dz);
float distA = dist;
float distB = dist;  // 重複除去

Loop Unrolling

ループ本体を複数回複製して分岐オーバーヘッドを減らし、ベクトル化の機会を拡大する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Before:
for (int i = 0; i < 8; i++)
    result[i] = data[i] * 2f;

// After (4倍アンロール):
result[0] = data[0] * 2f;
result[1] = data[1] * 2f;
result[2] = data[2] * 2f;
result[3] = data[3] * 2f;
result[4] = data[4] * 2f;  // 続く...
result[5] = data[5] * 2f;
result[6] = data[6] * 2f;
result[7] = data[7] * 2f;
// → 分岐オーバーヘッド除去 + SIMDベクトル化機会拡大

パス適用順序

これらのパスは単独ではなくパイプラインとして順番に適用される。あるパスの結果が次のパスの入力になる:

flowchart TD
    A["SROA\n(struct → レジスタ)"] --> B["Inlining\n(関数呼び出し → 本体)"]
    B --> C["Constant Folding\n(定数の事前計算)"]
    C --> D["GVN + LICM\n(重複/不変コード除去)"]
    D --> E["Loop Unrolling\n(ループ展開)"]
    E --> F["ベクトル化パス\n(SLP + Loop Vectorizer)"]
    F --> G["Instruction Selection\n(x86/ARM命令選択)"]

1.3 ベクトル化パス:Loop Vectorizer vs SLP Vectorizer

LLVMには2つの独立したベクトル化器がある。

LLVM Vectorizers — https://llvm.org/docs/Vectorizers.html

Loop Vectorizer

スカラーループをベクトルループ + スカラー余り(remainder)に変換する。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Before (スカラーループ):
for (int i = 0; i < 1000; i++)
    distances[i] = math.distance(positions[i], target);

// After (ベクトル化、概念):
for (int i = 0; i < 1000; i += 4)  // 4個ずつ処理
{
    // SSE: 4個のfloatを同時計算
    __m128 dx = _mm_sub_ps(load4(pos_x + i), broadcast(target.x));
    __m128 dz = _mm_sub_ps(load4(pos_z + i), broadcast(target.z));
    __m128 distSq = _mm_add_ps(_mm_mul_ps(dx, dx), _mm_mul_ps(dz, dz));
    _mm_store_ps(distances + i, _mm_sqrt_ps(distSq));
}
// 余り (1000 % 4 = 0なら不要)

Loop Vectorizerはコストモデルを使用してベクトル化ファクター(一度に何個処理するか)とアンロールファクターを決定する。コストが利益より大きければベクトル化を放棄する。

エピローグベクトル化:ループ回数がベクトル幅で割り切れない場合、余りを処理するエピローグもより小さいベクトル幅でベクトル化できる。例:メインループAVX2(8-wide)+ エピローグSSE(4-wide)。

SLP Vectorizer (Superword-Level Parallelism)

ループなしでも並列可能な独立したスカラー演算をベクトル演算にまとめる。

1
2
3
4
5
6
7
8
// Before (独立したスカラー計算):
float ax = bx + cx;
float ay = by + cy;
float az = bz + cz;
float aw = bw + cw;

// After (SLPが4つの独立した加算を1つのSIMDに):
__m128 a = _mm_add_ps(b_xyzw, c_xyzw);

SLP Vectorizerはコードをボトムアップで分析して、同じ種類の演算が独立して並んでいるパターンを見つけてベクトル化する。これがfloat3float4演算が自動的にSIMDに変換されるメカニズムだ。


Part 2: [BurstCompile]オプション完全ガイド

Job Systemポスト[BurstCompile]の基本的な使い方と制約事項を扱った。ここではオプションパラメータがコード生成をどう変えるかを深く掘り下げる。

FloatPrecision

浮動小数点数学関数の精度許容範囲を設定する。

レベル許容ULP適用関数性能影響
Standard(デフォルト)≤ 3.5 ULPsin, cos, exp, log, powなど基準線
High≤ 1.0 ULP同一-5~10%
Medium≤ 範囲内同一やや速い
Low≤ 350.0 ULP同一最速

Unity Burst Manual v1.8 — FloatPrecision

Lowの350 ULPはかなりの誤差だ。sin(x)の結果が実際の値と最大350 ULP差が出ることがある。ゲームロジックでは十分かもしれないが、物理シミュレーションや金融計算では危険だ。

Lowが速い理由:rsqrt(逆平方根近似)rcp(逆数近似)のようなハードウェア専用命令の使用を許可するためだ。これらの命令は精度を犠牲にする代わりに非常に速い(Part 6で詳細比較)。

FloatMode

浮動小数点演算の再配置ルールを設定する。これがベクトル化に直接的な影響を与える。

モード再配置FMA許可NaN/Infリダクションベクトル化決定論
Default制限的プラットフォーム依存尊重不可なし
Strict不可不可尊重不可プラットフォーム内
Fast許可許可無視可能なし
Deterministic制限的プラットフォーム依存尊重不可クロスプラットフォーム

核心FloatMode.FastはLLVMの-fassociative-mathに相当する。これが浮動小数点リダクションベクトル化の鍵だ。

1
2
3
4
// リダクション例:
float sum = 0;
for (int i = 0; i < count; i++)
    sum += values[i];  // sum = ((sum + v[0]) + v[1]) + v[2] + ...

IEEE 754浮動小数点加算は非結合的だ。(a + b) + c ≠ a + (b + c)がありうる。したがってDefault/Strictでは加算順序を変えられず、SIMD 4-wide並列加算(順序変更必須)が不可能だ。

Fastモードはこの制約を解除して再配置を許可する → リダクションループがベクトル化される。

LLVM Vectorizers — “By default, the vectorizer will only vectorize reductions for integer types. For floating-point reductions, -fassociative-math (or -ffast-math) is needed.”

FloatMode.DeterministicとIEEE 754

クロスプラットフォームの再現性が重要なネットコードではDeterministicが必要だ。しかしこれは性能コストを伴う。

IEEE 754標準自体がクロスプラットフォームの再現性を保証しない。標準は「同じ演算、同じデータ、同じ丸めモード」で結果が同一であることのみ保証し:

  • アンダーフロー処理方法が2つ許可されている(実装の選択)
  • sin、cosのような超越関数は標準から完全に除外
  • decimal↔binary変換も完全には仕様化されていない

FloatMode.Deterministicはこれらの違いを抑制するために追加演算を挿入するため、性能低下が発生する。64ビットプラットフォームでのみサポートされる。

参考として、Box2D物理エンジン(2024)はクロスプラットフォーム決定論を達成するために-ffp-contract=off(FMA無効化)+ fast-math禁止 + 独自のatan2f実装を採用した。Apple M2とAMD Ryzenで同一の結果を確認し、驚くべきことに性能低下はなかった。これは決定論と性能が必ずしもトレードオフではない可能性があることを示す事例だ。

Erin Catto, “Determinism”, Box2D Blog, 2024.08

注意:オプションが効果がない場合がある

Jackson Dunstanの2019年テストでFloatMode/FloatPrecision設定が同一のアセンブリを生成した事例が報告された。オプションを変えても実際のコード生成が変わらない場合がある。

Jackson Dunstan, “FloatPrecision and FloatMode”, 2019

必ずBurst Inspectorで実際のアセンブリを確認せよ。 オプションだけ変えて検証しなければ、効果のない最適化に時間を浪費する可能性がある。

OptimizeFor

モードアンローリングコードサイズ適した状況
Default普通普通ほとんど
Performance積極的ホットループが明確な場合
Size最小I-cache圧力が高い場合、モバイル
Balanced中間中間折衷案

Performanceはループをより多くアンロールしインライニング閾値を高める。ホットループのスループットは増加するが、コードが大きくなり命令キャッシュ(I-cache)ミスが増加しうる。

Sizeは逆に最小限のアンロールのみ行う。コードが小さくなりI-cacheに有利だが、ループあたりのスループットは低い。モバイル(I-cacheが小さいARM)で有利な場合がある。

その他のオプション

オプション説明用途
CompileSynchronouslyエディタで非同期の代わりに同期コンパイルデバッグ:Burstコードが即座に有効であることを保証
DisableSafetyChecksbounds checkなど安全性検査を除去リリースビルド(Part 6で詳細)
Debug変数名保持、最適化無効化ネイティブデバッガ接続時

プラットフォーム別分岐:コンパイル時評価

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using static Unity.Burst.Intrinsics.X86;
using static Unity.Burst.Intrinsics.Arm.Neon;

[BurstCompile]
struct PlatformAwareJob : IJobParallelFor
{
    public void Execute(int i)
    {
        if (IsAvx2Supported)
        {
            // AVX2専用コード — x86でのみコンパイルされる
        }
        else if (IsNeonSupported)
        {
            // ARM NEON専用コード — ARMでのみコンパイルされる
        }
        else
        {
            // フォールバック
        }
    }
}

IsAvx2SupportedIsNeonSupportedなどはコンパイル時に評価されて、該当プラットフォームでサポートされない分岐はdead codeとして除去される。ランタイムオーバーヘッドなしにプラットフォーム別最適化を記述できる。

オプション組み合わせガイド

状況FloatModeFloatPrecisionOptimizeFor備考
一般ゲームロジックDefaultStandardDefault安全なデフォルト値
ホットループ最適化FastLowPerformanceリダクションベクトル化 + 積極的アンロール
決定論的ネットコードDeterministicStandardDefaultクロスプラットフォーム再現性
モバイル最適化FastLowSizeI-cacheの利点
デバッグDefaultStandardDefault+ Debug = true
精密物理StrictHighDefaultIEEE 754準拠

Part 3: Burst Inspector 実践ウォークスルー

SoAポストでBurst Inspectorを開いてxxxps(ベクトル)vs xxxss(スカラー)命令を区別する方法を味わった。ここでは実際のアセンブリを一行ずつ読むレベルまで進む。

3.1 x86アセンブリ読解最小ガイド

Burst Inspector出力を読むための最小限の知識だ。完全なx86理解ではなく、Burstが生成するコードを解釈できるレベルを目標とする。

レジスタ

レジスタサイズ用途
xmm0~xmm15128ビットSSE SIMD (float × 4 または double × 2)
ymm0~ymm15256ビットAVX2 SIMD (float × 8 または double × 4)
rdi, rsi, rcx, rdx64ビットポインタ、インデックス、カウンタ
rax64ビット戻り値、汎用
rsp, rbp64ビットスタックポインタ(通常無視可能)

アドレッシングモード

1
2
3
4
5
6
7
[rdi + rcx*4]
 ↑       ↑  ↑
 ベース   インデックス  スケール

意味: rdiポインタ + (rcx × 4) バイトオフセット
例: rdi = NativeArrayの開始アドレス, rcx = ループインデックス, 4 = sizeof(float)
→ float配列のrcx番目の要素

接尾辞ルール

接尾辞意味処理単位
psPacked Singlefloat × 4 (SSE) または × 8 (AVX)
pdPacked Doubledouble × 2 または × 4
ssScalar Singlefloat × 1
sdScalar Doubledouble × 1

Unity Learn DOTS Best Practices — 「xxxps命令(addps, mulpsなど)はベクトル化されたSIMDで、xxxss命令(addss, mulssなど)はスカラーだ。目標はできるだけ多くのスカラー命令を除去することだ。」

コア命令リファレンス

Burst Inspectorで頻出する命令と実際のコスト

命令意味レイテンシスループット
movapsAligned Packed Single ロード/ストア3-50.5
addpsPacked加算 (float×4)3-40.5
subpsPacked減算3-40.5
mulpsPacked乗算3-50.5
divpsPacked除算11-144-5
sqrtpsPacked平方根12-184-6
rsqrtpsPacked逆平方根(近似)41
rcppsPacked逆数(近似)41
vfmadd231psFused Multiply-Add (a*b+c)4-50.5
cmppsPacked比較3-40.5-1
vblendvps条件付きブレンド(select)21
movssScalar Singleロード3-50.5
addssScalar加算 (float×1)3-40.5

レイテンシ/スループットはSkylake基準のサイクル数。Agner Fog, “Instruction Tables” (2025.12) — ベンダー公式値ではなく独自測定に基づくデータ。

sqrtps(12-18サイクル)vs rsqrtps(4サイクル)の3〜4倍の差に注目せよ。FloatPrecision.Lowrsqrtpsの使用を許可することの性能への影響がこの数値から直接的に見て取れる。

3.2 Burst Inspector UI

開き方:Unityメニュー → JobsBurstOpen Inspector

Burst Inspectorは4つのビューを提供する:

ビュー内容用途
.NET ILC#コンパイラが生成した中間言語Burstが受け取る入力の確認
Unoptimized LLVM IR最適化前のLLVM中間表現パス適用前の状態確認
Optimized LLVM IR最適化後のLLVM中間表現どの最適化が適用されたか確認
Final Assemblyターゲットプラットフォームのネイティブアセンブリ実際の性能判断の基準

ターゲットドロップダウン:同じJobのアセンブリをSSE2、SSE4.2、AVX2など異なるターゲットでコンパイルした結果を比較できる。

3.3 実践ウォークスルー:DistanceJob

簡単な距離計算Jobのアセンブリを追跡してみよう。

1
2
3
4
5
6
7
8
9
10
11
12
13
[BurstCompile(FloatMode = FloatMode.Fast, OptimizeFor = OptimizeFor.Performance)]
struct DistanceJob : 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);
    }
}

Burst InspectorでこのJobをSSE4.2ターゲットで見ると、ホットループ部分はおおよそこのような形だ:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
; ベクトル化されたループ本体 (4個のfloat3を同時に処理)
.LBB0_4:                          ; ← ループ開始ラベル
    movaps  xmm2, [rdi + rcx*4]  ; Positions[i].xyz 4個分ロード
    subps   xmm2, xmm0           ; d = pos - target (4個同時)
    mulps   xmm2, xmm2           ; d*d (4個同時)
    ; ... haddpsでx²+y²+z²合算 ...
    sqrtps  xmm2, xmm2           ; sqrt (4個同時)
    movaps  [rsi + rcx*4], xmm2  ; Distances[i] = result (4個同時)
    add     rcx, 4                ; i += 4
    cmp     rcx, rdx              ; i < count?
    jb      .LBB0_4              ; → ループ反復

; スカラー余り (countが4で割り切れない場合)
.LBB0_6:
    movss   xmm2, [rdi + rcx*4]  ; Positions[i] 1個だけロード
    subss   xmm2, xmm0           ; スカラー減算
    ; ...
    sqrtss  xmm2, xmm2           ; スカラーsqrt
    movss   [rsi + rcx*4], xmm2  ; 1個だけストア

読み方のポイント:

  1. .LBB0_4がベクトル化されたメインループxxxps命令が主体
  2. .LBB0_6スカラー余りxxxss命令
  3. add rcx, 4で一度に4個ずつ処理
  4. movaps(aligned)が使用されている → NativeArrayの16バイトアライメントが活用されている

3.4 プラットフォーム別コード生成比較

同じJobを異なるターゲットでコンパイルすると:

特性x86 SSE4.2x86 AVX2ARM NEON
SIMDレジスタ幅128ビット (xmm)256ビット (ymm)128ビット (v/q)
float同時処理4個8個4個
加算命令addpsvaddpsfadd
ループあたり処理4個8個4個
FMA別々 (mulps + addps)vfmadd231ps 1個fmla 1個

AVX2ターゲットではymmレジスタを使用して一度に8個のfloatを処理するため、理論的にSSE4.2比2倍のスループットだ。

ARM Neon + Burst — https://learn.arm.com/learning-paths/mobile-graphics-and-gaming/using-neon-intrinsics-to-optimize-unity-on-android/


Part 4: 自動ベクトル化 — 成功と失敗

Unity公式ドキュメントはこう警告する:

“Loop vectorization is notoriously brittle.” — Unity Burst Manual v1.8, Optimization Guidelines

4.1 ベクトル化が成功する条件

  1. 単純なループ:単一forループ、複雑な制御フローなし
  2. 順次アクセスdata[i], data[i+1] — 連続的なメモリアクセス
  3. データ独立Execute(i)の結果がExecute(j)に影響しない
  4. SoAレイアウト:同じ型のデータが連続配置(前のポストで扱った)
  5. インライン可能な関数math.*関数は[AggressiveInlining]でインラインされる

Loop.ExpectVectorized()で検証

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#define UNITY_BURST_EXPERIMENTAL_LOOP_INTRINSICS
using static Unity.Burst.CompilerServices.Loop;

[BurstCompile]
struct VerifiedJob : IJobParallelFor
{
    [ReadOnly] public NativeArray<float> A;
    [WriteOnly] public NativeArray<float> B;

    public void Execute(int i)
    {
        // このループがベクトル化されなければコンパイルエラー
        ExpectVectorized();
        B[i] = A[i] * 2f;
    }
}

このイントリンジックはコンパイル時にベクトル化の可否を検証する。条件分岐一つ追加してベクトル化が壊れた場合を自動的にキャッチする。公式ドキュメントの実測:分岐一つで32個の整数演算 → 1個に退化

4.2 ベクトル化が失敗するパターン

パターン1:浮動小数点リダクション

1
2
3
4
// ❌ FloatMode.Defaultではベクトル化不可
float sum = 0;
for (int i = 0; i < count; i++)
    sum += values[i];  // loop-carried dependency + FP非結合性

原因:IEEE 754浮動小数点加算は非結合的 → 順序変更で結果が変わりうる → コンパイラがベクトル化を拒否。

解決[BurstCompile(FloatMode = FloatMode.Fast)]で再配置を許可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ FloatMode.Fastでベクトル化
[BurstCompile(FloatMode = FloatMode.Fast)]
struct SumJob : IJob
{
    [ReadOnly] public NativeArray<float> Values;
    public NativeReference<float> Sum;

    public void Execute()
    {
        float sum = 0;
        for (int i = 0; i < Values.Length; i++)
            sum += Values[i];
        Sum.Value = sum;
    }
}

パターン2:ループ内の条件分岐

1
2
3
4
5
6
7
8
// ❌ 分岐がベクトル化を妨害しうる
for (int i = 0; i < count; i++)
{
    if (IsAlive[i] == 1)
        Distances[i] = math.distance(Positions[i], target);
    else
        Distances[i] = float.MaxValue;
}

解決math.selectで無分岐化。

1
2
3
4
5
6
// ✅ math.select → SIMD vblendvps (無分岐)
for (int i = 0; i < count; i++)
{
    float dist = math.distance(Positions[i], target);
    Distances[i] = math.select(float.MaxValue, dist, IsAlive[i] == 1);
}

アセンブリレベルで:

1
2
3
4
5
6
7
; if/else版:分岐命令使用
cmpb    [rbx + rcx], 1
jne     .LBB0_skip        ; ← 分岐:予測失敗時パイプラインフラッシュ

; math.select版:無分岐ブレンド
cmpps   xmm3, xmm4, 0    ; 比較 → マスク生成
vblendvps xmm2, xmm5, xmm2, xmm3  ; ← 無分岐:マスクで選択

LLVMのIf-Conversionパスが簡単な条件文を自動的にpredicationに変換することもあるが、保証はされない。math.selectで明示的に記述する方が安全だ。

パターン3:非インライン関数呼び出し

1
2
3
4
5
6
7
// ❌ CustomDistanceがインラインされなければベクトル化不可
for (int i = 0; i < count; i++)
    Distances[i] = CustomDistance(Positions[i], target);

// ✅ 解決:AggressiveInlining
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static float CustomDistance(float3 a, float3 b) { ... }

パターン4:エイリアシング

2つのNativeArrayが同じメモリを指す可能性があれば、コンパイラは保守的にベクトル化を放棄する。

1
2
3
4
5
6
// JobのNativeArrayパラメータは自動的にnoalias → ベクトル化可能
// しかし関数にNativeArrayを渡すとnoaliasが消えることがある

// ✅ [NoAlias]の明示で解決
static void Process([NoAlias] NativeArray<float> input,
                    [NoAlias] NativeArray<float> output) { ... }

5argon (GDC 2018) — 「C++で__restrict一つで4倍の性能向上が観測された例があるが、BurstはJob SystemのSafety Systemのおかげでこれを自動的に解決する。」

これがJob内部のExecute()メソッドが一般関数よりベクトル化されやすい理由だ。JobのNativeContainerフィールドは構造的にalias-freeが保証される。

4.3 Unity.Mathematics → SIMDマッピング

math.*関数がSIMD命令にどう変換されるか主要なマッピングを整理する。

C# (Unity.Mathematics)x86 SSE/AVX同時処理備考
a + b (float3)addps4 floatSLPベクトル化
a * b (float3)mulps4 floatSLPベクトル化
math.sqrt(x)sqrtps4 float12-18サイクル
math.rsqrt(x)rsqrtps4 float4サイクル(近似)
math.select(a, b, c)vblendvps4 float無分岐
math.dot(a, b)mulps + haddps4 → 1水平演算(高コスト)
math.mad(a, b, c)vfmadd231ps4 floatFMA:1命令でa*b+c
math.normalizesafe(v)mulps + rsqrtps + mulps4 floatLowでrsqrt使用

math.* vs Mathf.*の違い:Burstはmath.*関数をイントリンジックとして認識し直接SIMD命令に変換する。Mathf.*はmanaged呼び出しとして扱われ、インライン/ベクトル化されない場合がある。

水平演算のコスト

math.dotmath.csumのような水平演算(horizontal operation)はSIMDでは比較的高コストだ。一つのSIMDレジスタ内の複数の値を合算する必要があるためだ。

1
2
3
4
5
; math.dot(a, b) の実際のアセンブリ (SSE):
mulps   xmm0, xmm1        ; a.x*b.x, a.y*b.y, a.z*b.z, a.w*b.w  (1サイクル)
haddps  xmm0, xmm0        ; (xy+zw), (xy+zw), ...                (3サイクル)
haddps  xmm0, xmm0        ; (xy+zw+xy+zw), ...                   (3サイクル)
; → 合計7サイクル:水平リダクションのため単純なmulps+addpsより遅い

haddpsは水平加算(horizontal add)でレイテンシが高い。可能であれば水平演算をループの外に押し出すか、SoAレイアウトで垂直演算(vertical operation)に変換するのが有利だ。

4.4 Frustum Cullingベンチマーク

Unity Learn DOTS Best Practicesが提供するFrustum Culling 4種類の実装のベンチマークは、ベクトル化戦略の実践的な比較を示す。

バージョン戦略核心特徴
v1Loop + early break6つの平面をループで検査、失敗時break
v2Unrolled, no branch6つの平面検査を展開して分岐除去
v3Plane packet SIMD平面を4つずつまとめてSIMD処理
v4Vertical SIMD(4球体同時)4つの球体を同時に検査(最速

Unity Learn, “Getting the Most Out of Burst”

v4が最速の理由:データ方向を垂直に転換して4つの球体を1つのSIMDレジスタにパッキングしたため。v1比で数学演算数が33%減少

このベンチマークの教訓:「アルゴリズムの数学演算数を数えることが性能の良い予測変数だ。」


Part 5: コンパイラヒントとアトリビュート

Hint.Likely / Hint.Unlikely

1
2
3
4
5
6
7
8
9
10
11
12
13
using Unity.Burst.CompilerServices;

public void Execute(int i)
{
    if (Hint.Unlikely(IsAlive[i] == 0))
    {
        // この分岐はほとんど実行されない → cold path
        Distances[i] = float.MaxValue;
        return;
    }
    // hot path:ほとんどここに来る
    Distances[i] = math.distance(Positions[i], target);
}

CPUの分岐予測器はほとんどの分岐を正しく予測するが、誤予測(misprediction)時にパイプラインフラッシュが発生する。そのコストはアーキテクチャによって異なる:

アーキテクチャ誤予測ペナルティ
Intel Skylake~16.5サイクル
Intel Golden Cove (Alder Lake)~17サイクル
AMD Zen 1~19サイクル
Apple M1~8サイクル

Agner Fog, “The Microarchitecture of Intel, AMD, and VIA CPUs” (2025); Cloudflare, “Branch predictor: How many ‘if’s are too many?”

Hint.Likely/Hint.UnlikelyはLLVMに分岐の予想経路を伝える。これに基づいて:

  • コードレイアウト:likely経路はfall-through(連続配置)、unlikely経路はjumpで処理 → I-cache効率向上
  • Loop Vectorizer決定:likely経路がベクトル化対象

Hint.Assume

1
2
3
4
5
6
7
8
public void Execute(int i)
{
    Hint.Assume(i >= 0 && i < Positions.Length);

    // これでコンパイラはiが範囲内であることを「知って」いるので
    // bounds checkを生成しない
    Distances[i] = math.distance(Positions[i], target);
}

Hint.Assumeは条件が常に真であるとコンパイラに保証する。偽であれば未定義動作(UB)なので危険だが、bounds check除去など強力な最適化を有効にする。

[AssumeRange]

1
2
3
4
5
6
[return: AssumeRange(0u, 12u)]
static uint GetMonthIndex() { /* ... */ }

// コンパイラが戻り値の範囲を知っているので:
// - 除算を乗算+シフトに置き換え可能(定数範囲内なので)
// - switch/if分岐のdead branch除去可能

Constant.IsConstantExpression()

1
2
3
4
5
6
7
8
9
10
11
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static float FastPow(float x, float exponent)
{
    if (Constant.IsConstantExpression(exponent))
    {
        // exponentがコンパイル時定数であれば特殊経路
        if (exponent == 2f) return x * x;       // math.powの代わりに乗算1回
        if (exponent == 0.5f) return math.sqrt(x);  // math.powの代わりにsqrt
    }
    return math.pow(x, exponent);
}

IsConstantExpressionは引数がコンパイル時に定数として評価可能か検査する。インライニング後に定数が伝播すれば条件が真になり最適経路が選択され、残りはdead codeとして除去される。

[BurstDiscard]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[BurstCompile]
struct MyJob : IJob
{
    public void Execute()
    {
        DoWork();
        LogDebug("Work done");  // Burstでは完全に除去される
    }

    [BurstDiscard]
    static void LogDebug(string msg)
    {
        Debug.Log(msg);  // managedコード — Burst不可
    }
}

[BurstDiscard]が付いたメソッドはBurstコンパイル時に本体が完全に除去される。managed専用デバッグコードをBurst Jobに入れる唯一の方法だ。

[SkipLocalsInit]

1
2
3
4
5
6
7
8
9
10
11
[BurstCompile, SkipLocalsInit]
struct MyJob : IJobParallelFor
{
    public void Execute(int i)
    {
        // ローカル変数が0で初期化されない
        // → 大型スタック割り当て時の初期化コスト節約
        float4x4 matrix;  // 64バイト — 0初期化スキップ
        // ...
    }
}

C#はデフォルトですべてのローカル変数を0で初期化する。[SkipLocalsInit]はこの初期化をスキップする。大型structを多用するホットループで微小な性能利点を提供する。

関数ポインタ:「コンパイルバリア」

一般的に関数ポインタはインライニングを防止して性能を低下させる。しかしSebastian Schonerはこれを逆に活用する戦略を提示した:

  • 中央ECSコンポーネントのコードがすべてのシステムにインラインされると → システムあたり数万行のアセンブリ
  • 関数ポインタで「コンパイルバリア」を作ると → インライン遮断 → コンパイル時間25%短縮(8分 → 6分)
  • ランタイム性能は低下するが、開発サイクルでは有効なトレードオフ

Sebastian Schoner, “Burst and the Kernel Theory”, 2024.12

Unity公式ベンチマークではJobはbatched function pointer比1.26倍速い。この差が許容可能な場合、コンパイル時間短縮のために関数ポインタを戦略的に使用できる。


Part 6: よくある落とし穴と最適化パターン

Safety Checksとnoaliasの関係

Safety Checksが有効だとnoalias最適化が無効になる。

Safety Systemはランタイムに NativeArrayアクセスを検証するための追加コードを挿入する。この過程でエイリアシング情報が汚染され、LLVMが積極的なベクトル化を実行できなくなる。

1
2
3
4
5
// エディタ(Safety Checks ON):noalias最適化無効 → 遅い
// ビルド(Safety Checks OFF):noalias最適化有効 → 速い

// ビルド時に明示的にオフ:
[BurstCompile(DisableSafetyChecks = true)]

リリースビルドではSafety Checksを必ず無効にせよ。 エディタでのプロファイリング結果がビルドと異なりうる主要な原因の一つだ。

除算 → 逆数乗算

除算(divps)は乗算(mulps)より20〜30倍遅い(レイテンシ 11-14 vs 0.5サイクル)。

1
2
3
4
5
6
7
8
9
// ❌ 遅い:除算使用
for (int i = 0; i < count; i++)
    Results[i] = Values[i] / constant;

// ✅ 速い:逆数乗算(Burstは定数除算を自動変換)
// しかし変数除算は手動で:
float rcp = math.rcp(divisor);  // 1回の逆数計算
for (int i = 0; i < count; i++)
    Results[i] = Values[i] * rcp;  // 乗算で置き換え

定数で割る場合はBurstが自動的に逆数乗算に変換するが、変数で割る場合は手動でmath.rcpを使用する必要がある。

sqrt vs rsqrt

演算命令レイテンシ精度
math.sqrt(x)sqrtps12-18サイクルIEEE 754完全精度
math.rsqrt(x)rsqrtps4サイクル約12ビット(~3.5 ULP)

Agner Fog, “Instruction Tables” (2025) — Skylake基準

rsqrt逆平方根の近似値を4サイクルで返す。FloatPrecision.Lowを設定するとBurstがmath.sqrtrsqrt + Newton-Raphson補正で自動置換できる。

1
2
3
4
// 手動でrsqrt + Newton-Raphson 1回補正(精度向上):
float rsq = math.rsqrt(x);
rsq = rsq * (1.5f - 0.5f * x * rsq * rsq);  // Newton-Raphson
float result = x * rsq;  // sqrt(x) ≈ x * rsqrt(x)

正規化(normalize)が頻繁なゲームコードではrsqrtpsの3-4倍の速度利点が累積して有意な差を生む。

分岐 vs 無分岐:いつどちらが良いか

math.select(無分岐)が常にif/else(分岐)より速いわけではない

状況速い方理由
分岐予測率 ~50%(ランダムデータ)無分岐2回に1回パイプラインフラッシュ → 分岐コスト高い
分岐予測率 ~95%(ほぼ片方)分岐予測がほぼ当たるので無分岐の「常に両方計算」コストが大きい
分岐内の計算が軽い無分岐vblendvps 1個 vs jne + 追加命令
分岐内の計算が重い(sqrtなど)分岐early exitで高コスト計算をスキップ

ベンチマークによると、CMOV(無分岐条件付き移動)と分岐の交差点は予測精度約75%だ。75%以上予測が当たれば分岐が有利で、それ以下なら無分岐が有利だ。

Algorithmica, “Branchless Programming” — CMOV vs conditional branch crossover at ~75% prediction accuracy

1
2
3
4
5
6
7
// isAliveが95% true → 分岐が有利
if (IsAlive[i] == 0) { Distances[i] = float.MaxValue; return; }
// 5%だけスキップするので分岐予測がほぼ当たる + early returnで残り計算省略

// IsAliveが50/50 → math.selectが有利
Distances[i] = math.select(float.MaxValue, dist, IsAlive[i] == 1);
// 無分岐なので予測失敗なし + 両方の計算が軽い

[NoAlias]とエイリアシング

Job structのNativeArrayフィールドは自動的にnoalias処理されるが、別の関数にNativeArrayを渡すと noalias情報が消える。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 関数パラメータでnoalias情報損失
static void BadProcess(NativeArray<float> input, NativeArray<float> output)
{
    // コンパイラ:inputとoutputが同じメモリを指すかもしれないので保守的に処理
    for (int i = 0; i < input.Length; i++)
        output[i] = input[i] * 2f;
}

// ✅ [NoAlias]で明示
static void GoodProcess([NoAlias] NativeArray<float> input,
                        [NoAlias] NativeArray<float> output)
{
    // コンパイラ:inputとoutputは絶対重ならない → 積極的ベクトル化可能
    for (int i = 0; i < input.Length; i++)
        output[i] = input[i] * 2f;
}

Job vs Function Pointer性能

Unity公式ベンチマークでの3つのBurstコード実行方式の性能比較:

方式相対速度理由
Non-batched Function Pointer1.00x(基準)呼び出しオーバーヘッド + 制限された最適化
Batched Function Pointer1.53xバッチングで呼び出しオーバーヘッド減少
Job1.93x完全なエイリアシング情報 + 最も広い最適化機会

Unity Burst Manual — Function Pointers vs Jobs

可能な限り常にJobを使用せよ。 Jobは構造的にコンパイラに最も多くの最適化情報を提供する。


まとめ

核心要約

概念核心参照
4段階パイプラインDiscovery → FrontEnd → MiddleEnd → BackEndUnity公式ドキュメント
SROAstruct → レジスタ分解(float3の核心)LLVM Passes
Loop/SLPベクトル化ループ = Loop Vectorizer、直線コード = SLPLLVM Vectorizers
FloatMode.Fastリダクションベクトル化の鍵(-fassociative-math公式ドキュメント + LLVM
noaliasJob = 自動alias-free → C++より速いコード生成可能GDC 2018
Safety Checks OFFリリースでnoalias最適化有効化公式ドキュメント
sqrtps vs rsqrtps12-18 vs 4サイクル(3-4倍の差)Agner Fog
Loop.ExpectVectorizedコンパイル時ベクトル化検証公式ドキュメント
分岐 vs 無分岐予測率ベースで判断Intelマニュアル

次のポスト

このシリーズで次に扱うテーマはNativeContainer深掘り — NativeList、NativeHashMap、NativeQueueなどJob Systemが提供するすべてのコンテナの内部実装と性能特性を分析する予定だ。


References

  • Unity Burst Manual v1.8docs.unity3d.com
  • LLVM Passes Referencellvm.org/docs/Passes.html
  • LLVM Vectorizersllvm.org/docs/Vectorizers.html
  • Agner Fog, “Optimizing Software in C++” / “Instruction Tables” (2025) — agner.org/optimize
  • Intel, “64 and IA-32 Architectures Optimization Reference Manual” v050 (2024)
  • Aras Pranckevičius, “Pathtracer 16: Burst & SIMD Optimization” (2018) — aras-p.info
  • Sebastian Schoner, “Burst and the Kernel Theory of Game Performance” (2024) — blog.s-schoener.com
  • 5argon, “Unity at GDC: C# to Machine Code” — medium.com/@5argon
  • Jackson Dunstan, “FloatPrecision and FloatMode” (2019) — jacksondunstan.com
  • Unity Learn, “Getting the Most Out of Burst” — DOTS Best Practices
  • Mike Acton, “Data-Oriented Design and C++” (CppCon 2014) — YouTube
  • ARM, “Using Neon Intrinsics to Optimize Unity on Android” — learn.arm.com
この記事は著者の CC BY 4.0 ライセンスの下で提供されています。