記事

CLR · Mono · IL2CPP · NativeAOT — ランタイムの分岐を比較する

CLR · Mono · IL2CPP · NativeAOT — ランタイムの分岐を比較する
前提知識 — 先にこちらをご確認ください
TL;DR — 要点まとめ
  • .NETランタイムは大きくJIT系列(CLR・CoreCLR・Mono)とAOT系列(IL2CPP・NativeAOT・Mono Full AOT)に分かれます
  • AOT系列は起動時間・配布サイズの面で有利ですが、Reflection.Emit・動的ジェネリックインスタンス化・Expression.Compileなどの機能が使えなくなります
  • ゲーム開発者がIL2CPPで出会う制約は、ランタイム自体の設計上の選択から来るものであり、Unity固有の問題ではありません
Visitors

Hits

序論: 同じIL、異なる運命

前の2篇では、.NETスタックの第4層 (Runtime) が複数の実装に分かれているという事実を確認しました。今回はその実装を一つずつ解剖して比較します。

現時点で実務的に意味のある5つのランタイムは次のとおりです。

ランタイム所属登場状態
CLR.NET Framework2002凍結 (4.8.1)
CoreCLR.NET 5+2016 (Core 1.0)アクティブ
MonoXamarin·Unity2004アクティブ (Unity フォーク)
IL2CPPUnity2014アクティブ
NativeAOT.NET 7+2022アクティブ

同じC#コードを書いても、どのランタイム上で動作するかによってパフォーマンス・メモリ・利用可能なAPI・配布サイズが大きく変わります。この篇の目的は、その違いを実用的な選択基準として整理することです。

5つのランタイムは複雑に見えますが、一つの軸さえ掴めばほぼすべてが整理されます。その軸がJIT vs AOTです。


Part 1. たった一つの軸 — JIT vs AOT

1篇で、ILをネイティブコードに翻訳するタイミングが2種類あると述べました。この翻訳タイミングが5つのランタイムを2つのグループに分ける決定的な違いです。

.NETランタイム5種 — JIT / AOT 分類 IL (中間バイトコード) JIT 系列 実行時に翻訳 AOT 系列 ビルド時に翻訳 CLR .NET Framework CoreCLR .NET 5+ Mono Unity デフォルト IL2CPP Unity iOS/WebGL NativeAOT .NET 7+ Mono Full AOT Xamarin iOS

JIT 系列 — CLR · CoreCLR · Mono

JIT (Just-In-Time) は、アプリが実行されているそのマシン上で、その瞬間に ILをネイティブコードに翻訳します。この方式の長所と短所は以下のとおりです。

長所

  • ハードウェア情報を実際に実行されているマシンから取得して最適化が可能
  • 実行時の統計 (Tiered Compilation、PGO) を活用した後続の再最適化が可能
  • Reflection.EmitExpression.Compile のような実行時コード生成APIが動作する

短所

  • 実行初期にJITコストを支払う (Cold Start が遅い)
  • 実行するマシンにランタイムのインストールが必要
  • JIT自体がメモリ・CPUを消費する

AOT 系列 — IL2CPP · NativeAOT · Mono Full AOT

AOT (Ahead-Of-Time) は、アプリを配布する前、開発者のビルドマシン上でILをネイティブコードに翻訳しておきます。

長所

  • Cold Start が極めて速い — 翻訳コストがすでに支払われている
  • JITが許可されていないプラットフォーム (iOS、コンソール、WebAssembly) での唯一の選択肢
  • 配布時にランタイムのインストールが不要 (NativeAOTの場合は単一バイナリ)

短所

  • 実行時に新しいコードを生成できないReflection.Emit が使えない
  • 動的ジェネリックインスタンス化の制限 → 実行時に新たな List<MyRuntimeType> を作れない
  • ビルド時間の増加 — すべてのILを事前に翻訳する
  • すべてのジェネリックインスタンスを事前生成 → 配布バイナリのサイズが増加

この表一枚が、以降のすべての比較の基盤となります。


Part 2. 各ランタイムの紹介

CLR — .NET Framework のランタイム

  • リリース: 2002年
  • プラットフォーム: Windows 専用
  • コンパイル: JIT
  • 状態: 凍結。.NET Framework 4.8.1 (2022) が最後のリリース
  • 特記事項: WPF・WinForms・WCF のような Windows 専用の上位フレームワークと強く結びついている

新規開発でCLRを選ぶ理由はありません。レガシーのメンテナンス用途としてのみ意味があります。

CoreCLR — 現代 .NET のメインランタイム

  • リリース: 2016年 (.NET Core 1.0)、2020年から .NET 5+ に統合
  • プラットフォーム: Windows・Linux・macOS・FreeBSD
  • コンパイル: Tiered JIT (Tier 0 高速初期翻訳 → Tier 1 最適化再翻訳)
  • 特記事項: PGO (Profile-Guided Optimization) 対応、実行統計でホットコードをより積極的に最適化

CoreCLR は JITの短所 (初期コスト) をTiered Compilationで緩和したランタイムです。起動時は高速なTier 0翻訳のみを行い、頻繁に呼ばれるホットコードだけを後でTier 1で再コンパイルします。(Microsoft Learn — CLR overview)

サーバー・Web・デスクトップ・WASMまで、.NETのデフォルトかつ最も活発に進化しているランタイムです。

Mono — クロスプラットフォームの原点

  • リリース: 2004年
  • プラットフォーム: Windows・Linux・macOS・iOS・Android・WebAssembly
  • コンパイル: JITがデフォルト、Full AOT モードも可能 (iOSのようにJITが禁止された環境向け)
  • 特記事項: 小さいフットプリント。モバイル・組み込み・ゲームエンジンに適している

Monoは2篇で見たように、外部のオープンソースから始まり Microsoft 公式実装となったランタイムです。2024年にMicrosoftがWineHQに所有権を移譲し、本家はメンテナンスモードに入りましたが、Unityは独自フォークを運営しています。

Unityで Scripting Backend: Mono を選択すると、このランタイムがエディターとデスクトップビルドに使用されます。

IL2CPP — Unity が作ったAOTパイプライン

  • リリース: 2014年
  • プラットフォーム: iOS・WebGL・コンソール (PS5・Xbox・Switch)・Android・Windows・macOS
  • コンパイル: AOT 専用。ILをC++コードに変換した後、プラットフォーム別のC++ツールチェーン (Xcode・Emscripten・コンソール SDK) でネイティブバイナリを生成
  • 特記事項: Reflection.Emit 禁止、ジェネリックインスタンス化の制限、ビルド時間の増加

IL2CPPの存在理由を一言で要約すると次のとおりです。「iOS・WebGL・コンソールがJITを許可しないため、Mono Full AOTでは解決できないパフォーマンス・制約の問題をUnityが独自のAOTパイプラインで解決しようとしたためです。」 (Unity Manual — IL2CPP overview) 内部の動作原理はUnityが直接公開した “An introduction to IL2CPP internals” の連載で確認できます。

NativeAOT — Microsoft のサーバー・クラウド AOT

  • リリース: 2022年 (.NET 7、コンソールアプリ・ライブラリ対応 — .NET Blog — “Announcing .NET 7” (2022.11.08))
  • 2023年 (.NET 8、ASP.NET Core 対応拡大)
  • プラットフォーム: Windows・Linux・macOS・iOS (実験的)・Android (実験的)
  • コンパイル: AOT 専用。ILをネイティブコードに直接コンパイル (C++経由なし)
  • 特記事項: 単一ネイティブバイナリでの配布、ランタイムのインストール不要、起動時間が極めて速い

NativeAOTのターゲットはコンテナ・サーバーレス・CLIツールです。ゲーム開発者がIL2CPPを使う理由 (プラットフォームがJITを禁止) とは異なる動機です。NativeAOTが実験段階から正式リリースに昇格した経緯は “Announcing .NET 7 Preview 3” で詳しく記述されています。(Microsoft Learn — Native AOT deployment)


Part 3. ランタイム比較マトリクス

同じ軸で5つのランタイムを一覧で比較します。

CLRCoreCLRMonoIL2CPPNativeAOT
コンパイル方式JITTiered JITJIT (+Full AOT オプション)AOT onlyAOT only
クロスプラットフォームWindowsWin/Lin/Mac広範囲Unity 対応全プラットフォームWin/Lin/Mac
Cold Start遅い中間 (Tier 0 速い)中間速い最も速い
実行中の再最適化なしあり (PGO)限定的なしなし
Reflection.EmitOOOXX
Expression.CompileOOOインタープリタモードインタープリタモード
動的ジェネリックインスタンス化OOO制限あり制限あり
ランタイムのインストールが必要OO (または Self-contained)OX (エンジン内蔵)X
配布サイズ小 (ランタイム別途)大 (エンジン含む)
ビルド時間速い速い速い非常に遅い遅い
主な用途レガシー Windowsサーバー・Web・デスクトップUnity エディター・デスクトップUnity モバイル・コンソールサーバーレス・CLI

この表から読み取るべき3つのこと

① AOT の2つのランタイム (IL2CPP、NativeAOT) が同じ制約を共有しています。 Reflection.EmitExpression.Compile・動的ジェネリック — この3項目がいずれもJITに依存する機能だからです。AOT環境では、根本的に実行時に新しいILを生成するエンジンがありません。

② Cold Start は AOT が圧倒的に有利です。 iOSでJITが禁止されているのはセキュリティ上の理由 (メモリの W^X 原則) ですが、AOTの高速な起動はサーバーレス・CLIツールでも決定的な優位点です。dotnet run するたびに数百ミリ秒のJITコストを支払う必要がなくなります。

③ CoreCLR の Tiered JIT は折衷案です。 JITコストを完全になくすことはできませんが、Tier 0 で高速に翻訳 → 頻繁に呼ばれるコードだけ Tier 1 で最適化するという方式で「最悪を避け、最善を追求」します。これがサーバー・Webで CoreCLR が今もデフォルトである理由です。


Part 4. IL2CPP の実際のパイプライン

IL2CPPの「ILをC++に変換してからネイティブにコンパイルする」という説明が抽象的に聞こえることがあります。実際のビルドパイプラインを図示すると以下のようになります。

IL2CPP ビルドパイプライン — IL からネイティブバイナリまで C# ソース .cs Roslyn C# → IL IL .NET Assemblies il2cpp.exe IL → C++ プラットフォーム ツールチェーン Xcode / Emscripten コンソール SDK 1. 作成 2. IL コンパイル 3. IL 中間物 4. C++ 変換 (Unity) 5. ネイティブビルド

なぜ中間にC++を挟んだのか

ILからネイティブコードに直接変換するコンパイラも理論上は可能です (NativeAOTはそうしています)。ところがUnityは IL → C++ → ネイティブ の2段階を選びました。この選択の根拠はUnityが公開した “IL2CPP Internals: A tour of generated code” ブログで、実際に生成されたC++の例とともに説明されています。要約すると以下のとおりです。

① プラットフォーム別C++ツールチェーンの再利用 iOSはXcode LLVM、WebGLはEmscripten、コンソールは各メーカーのSDK、AndroidはNDK — プラットフォームごとにすでに最高水準で最適化されたC++コンパイラが存在します。ILをC++に変換さえしておけば、残りの最適化はプラットフォームのツールチェーンが担当します。同等の水準を達成するには、Unityはプラットフォームごとに別々のバックエンドを開発・維持しなければなりませんでした。

② プラットフォーム固有機能へのアクセス C++の中間物は、各プラットフォームのネイティブライブラリ・SDKと自然に連携できます。直接AOTコンパイラを作成していたら、このような統合はずっと複雑になっていたでしょう。

③ デバッグのしやすさ IL2CPPビルドでランタイムクラッシュが発生した場合、生成されたC++コードを読むことができます。これは純粋なバイナリ出力よりずっと追跡しやすいです。


Part 5. AOT 環境の5つの制約

Microsoft 公式ドキュメントが明示しているNativeAOTの主な制約です。IL2CPPもほぼ同じ制約を持っています。(Microsoft Learn — Native AOT limitations)

Reflection.Emit 禁止

現象: System.Reflection.Emit で実行時に動的にメソッド・型を作成するコードが実行されません。

原因: AOT環境には実行時にILを受け取ってネイティブに翻訳するJITがありません。EmitはILを作成するAPIですが、受け取って翻訳するエンジンがないため動作できません。

影響: 多くのシリアライズライブラリ (旧 Newtonsoft.Json の一部パス)、高速プロキシ生成 (Castle DynamicProxy)、DIコンテナの動的コンストラクタインジェクションなどが壊れたり遅くなったりします。

代替手段: Source Generator。コンパイル時に必要なコードを生成しておけば、実行時のEmitが不要になります。System.Text.Json はこの方向に転換しており、AOTフレンドリーです。

Expression.Compile はインタープリタモードへ

現象: LINQクエリや Expression<Func<T>>.Compile()インタープリタモードで実行されます。コンパイルされたネイティブコードほど速くありません。

原因: Expressionのコンパイルは実行時にILを生成してJITする方式であるため、AOT環境では不可能です。

影響: ORM (EF Coreの一部パス)、繰り返し呼ばれるLINQ-to-Expressionコードのパフォーマンスが低下する可能性があります。

代替手段: 頻繁に実行されるExpressionは事前にデリゲートに変換しておく。またはSource Generatorベースの代替ライブラリを検討する。

③ 動的ジェネリックインスタンス化の制限

現象: 実行時に Type.MakeGenericType(typeof(List<>), runtimeType) のような方法でコードに存在しなかったジェネリックの組み合わせを作ると、失敗またはエラーが発生します。

原因: AOTコンパイラはビルド時点ですべてのジェネリックインスタンスを事前生成します。ビルド時点に存在しなかった組み合わせはネイティブコードもありません。

影響: 実行時の型に基づく Dictionary<string, object> の構成を Dictionary<string, RuntimeType> に最適化する一般的なパターンが壊れます。

代替手段: ジェネリックの組み合わせをビルド時点で明示的に一度使用する (_ = new List<MyType>() のような「ヒント」) か、非ジェネリックバージョンで回避する。

④ リフレクションとトリマーの相互作用

現象: Type.GetMethod("SomeMethod") のような文字列ベースのリフレクションが予期せず失敗する — トリマーが当該メソッドを使用されていないと判断して削除したため。

原因: AOT配布にはトリミング (Trimming) が必須です。使用されていないコードをビルド結果から削除してバイナリサイズを小さくしますが、文字列ベースの参照は静的解析ができません。

影響: 多くの旧来のライブラリがAOTビルドで実行時エラーになります。

代替手段: DynamicDependency 属性でトリマーにヒントを与える、またはSource Generatorでリフレクションを除去する。

⑤ 配布バイナリサイズの増大

現象: AOTビルドはすべてのジェネリックインスタンス・ランタイムライブラリ・依存関係を単一バイナリに含めるため、framework-dependent JITビルドよりファイルサイズが大きくなります。

原因: 「Self-contained」がデフォルトであるため。ランタイムのインストールがない代わりに、アプリ内に持ち込みます。

影響: モバイルアプリのインストールサイズ、コンテナイメージのサイズ、配布時間の増加。

代替手段: 積極的なトリミング・PublishTrimmed=true・不要な機能フラグのオフ。


Part 6. ランタイム意思決定ガイド

プロジェクトの種類ごとにどのランタイムを選ぶべきかを、簡単なツリーで整理します。

サーバー・Web APIを作るCoreCLR (.NET 8+)。高負荷・低遅延・高速な配布が求められる場合はNativeAOT を検討。ただし必ずAOT制約を確認すること。

CLIツール・サーバーレス関数を作るNativeAOT。Cold Startが決定的で、依存関係が多くない場合はAOT制約を受け入れられます。

Unityでゲームを作る → エディター・デスクトップビルドはMono。iOS・WebGL・コンソールビルドはIL2CPP (強制)。デスクトップビルドもIL2CPPでパフォーマンス改善が可能です。

Windowsデスクトップアプリを新規開発するCoreCLR + WPF/WinForms on .NET 8+。CLR (.NET Framework) は避ける。

レガシー .NET Framework システムを維持するCLR。ただし新機能の開発は .NET 8+ への段階的な移行計画が必要。

モバイルアプリを作る (非Unity) → 2024年のXamarinサポート終了以降は.NET MAUIが公式の選択肢。内部的にはMono + NativeAOTの混合。


まとめ

今回の篇のポイントを4行で整理します。

  1. .NETランタイムはJIT系列とAOT系列に分かれており、この軸一つがパフォーマンス特性・制約・配布サイズの大部分を決定します。
  2. AOT系列の制約はプラットフォームの制約ではなく設計上の選択です。Reflection.Emit・動的ジェネリック・Expression.Compile が使えなくなるのは実行時にJITがないためであり、IL2CPP・NativeAOT双方に共通しています。
  3. IL2CPPがIL → C++ → ネイティブの2段階を経る理由は、プラットフォーム別のC++ツールチェーンの高度な最適化を再利用するためです。
  4. ゲームプログラマーがUnityで出会う制約 (Reflection.Emit、ジェネリックの落とし穴、トリマーの問題) はランタイム設計の必然的な帰結であり、Source Generatorのようなコンパイル時メタプログラミングで回避するのが現代的な解法です。

Foundation シリーズの締めくくり

3篇にわたって .NETの地図 (1篇) → 歴史 (2篇) → ランタイムの分岐 (3篇) を巡りました。この3篇は、今後続くすべてのC#シリーズの共通の座標系となります。

次のシリーズは非同期シリーズ (6篇)です。今回扱ったJIT・AOTの文脈が、UniTask がなぜ Task よりUnityに適しているのか、async/await がIL2CPPでどのように変形されるのか、Reflection.Emit を避けたSource Generatorがなぜ重要なのかに自然につながっていきます。


参考資料

一次ソース · 公式発表および技術分析

リファレンスドキュメント

この記事は著者の CC BY 4.0 ライセンスの下で提供されています。