Engine & platform

IL2CPPの内部:生成されたコードのツアー

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
May 13, 2015|15 分
IL2CPPの内部:生成されたコードのツアー
このページは機械翻訳されています。正確性のため、また情報源として原語バージョンを表示するには

これはIL2CPP内部シリーズの2番目のブログ記事です。この記事では、il2cpp.exeによって生成されたC++コードを調査する。その過程で、マネージド型がネイティブ・コードでどのように表現されるか、.NET仮想マシンをサポートするために使用されるランタイム・チェックを見て、ループがどのように生成されるかなどを確認する!

Unityの後のバージョンで確実に変更される、バージョン特有のコードに入ります。それでも、コンセプトは変わらない。

プロジェクト例

この例では、Unityの最新バージョンである5.0.1p1を使用します。このシリーズの最初の投稿と同じように、空のプロジェクトから始めて、スクリプト・ファイルを1つ追加する。今回の内容は以下の通り:

using UnityEngine;

public class HelloWorld : MonoBehaviour {
private class Important {
public static int ClassIdentifier = 42;
public int InstanceIdentifier;
}

void Start () {
Debug.Log("Hello, IL2CPP!");

Debug.LogFormat("Static field: {0}", Important.ClassIdentifier);

var importantData = new [] {
new Important { InstanceIdentifier = 0 },
new Important { InstanceIdentifier = 1 } };

Debug.LogFormat("First value: {0}", importantData[0].InstanceIdentifier);
Debug.LogFormat("Second value: {0}", importantData[1].InstanceIdentifier);
try {
throw new InvalidOperationException("Don't panic");
}
catch (InvalidOperationException e) {
Debug.Log(e.Message);
}

for (var i = 0; i < 3; ++i) {
Debug.LogFormat("Loop iteration: {0}", i);
}
}
}

私はこのプロジェクトをWebGL用に構築し、Windows上でUnityエディターを実行する。ビルド設定で「開発プレイヤー」オプションを選択したので、生成されるC++コードには比較的きれいな名前が入る。また、WebGL Player SettingsのEnable ExceptionsオプションをFullに設定しました。

生成されたコードの概要

WebGLのビルドが完了すると、生成されたC++コードがプロジェクト・ディレクトリのTempStagingAreaDatail2cppOutputディレクトリで利用できるようになる。エディタを閉じると、このディレクトリは削除されます。しかしエディターが開いている限り、このディレクトリは変更されないので、検査することができる。

il2cpp.exeユーティリティは、この小さなプロジェクトでも多数のファイルを生成した。4625個のヘッダーファイルと89個のC++ソースコードファイルが見える。このコードをすべて把握するために、私はExuberant CTagsと連動するテキストエディタを使いたい。CTagsは通常、このコードに対してタグファイルを素早く生成する。

最初に、生成されたC++ファイルの多くが単純なスクリプト・コードからではなく、mscorlib.dllのような標準ライブラリにあるコードの変換版であることがわかる。このシリーズの最初の投稿で述べたように、IL2CPPスクリプト・バックエンドは、Monoスクリプト・バックエンドと同じ標準ライブラリ・コードを使用している。il2cpp.exeが実行されるたびに、mscorlib.dllやその他の標準ライブラリ・アセンブリのコードを変換していることに注意してください。このコードは変更されないので、不要に思えるかもしれない。

しかし、IL2CPPスクリプトのバックエンドは、常にバイトコードストリッピングを使用して、実行ファイルのサイズを小さくする。そのため、スクリプトのコードを少し変更するだけでも、状況に応じて標準ライブラリのコードのさまざまな部分が使われたり使われなかったりする。そのため、毎回mscorlib.dllアセンブリを変換する必要がある。インクリメンタルビルドを行うためのより良い方法を研究しているが、まだ良い解決策はない。

マネージド・コードが生成されたC++コードにどのようにマッピングされるか

マネージドコードの各型に対して、il2cpp.exeはその型のC++定義のヘッダーファイルと、その型のメソッド宣言のヘッダーファイルを生成します。例えば、変換されたUnityEngine.Vector3型の中身を見てみましょう。この型のヘッダーファイルの名前はUnityEngine_UnityEngine_Vector3.hです。名前は、UnityEngine.dllというアセンブリの名前に続いて、名前空間と型の名前に基づいて作成されます。このコードは次のようになります。

// UnityEngine.Vector3
struct Vector3_t78
{
// System.Single UnityEngine.Vector3::x
float ___x_1;
// System.Single UnityEngine.Vector3::y
float ___y_2;
// System.Single UnityEngine.Vector3::z
float ___z_3;
};

il2cpp.exeユーティリティは、3つのインスタンス・フィールドをそれぞれ変換し、コンフリクトや予約語を避けるために名前を少し整理した。先頭のアンダースコアを使うことで、C++で予約されている名前を使うことになるが、今のところC++標準ライブラリのコードと衝突することはない。

UnityEngine_UnityEngine_Vector3MethodDeclarations.hファイルには、Vector3のすべてのメソッドのメソッド宣言が含まれています。例えば、Vector3はObject.ToStringメソッドをオーバーライドする:

// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR

このネイティブ宣言が表すマネージド・メソッドを示すコメントに注意。特にToStringのような一般的な名前のメソッドでは、このフォーマットで管理メソッドの名前を検索すると便利だ。

il2cpp.exeによって変換されたすべてのメソッドについて、いくつか興味深いことがある:

- これらはC++のメンバ関数ではない。すべてのメソッドはフリー関数で、第一引数は "this "ポインターである。マネージドコードの静的関数では、IL2CPPは常にこの最初の引数にNULLを渡す。常に "this "ポインタを第一引数としてメソッドを宣言することで、il2cpp.exeのメソッド生成コードを簡略化し、生成されたコードで他のメソッド(デリゲートなど)を経由してメソッドを呼び出すことをより簡単にします。

- この引数には、メソッドに関するメタデータが含まれ、仮想メソッドの呼び出しなどに使用される。Monoスクリプトのバックエンドは、プラットフォーム固有のトランポリンを使ってこのメタデータを渡す。IL2CPPでは、可搬性を高めるためにトランポリンを使わないことにした。

- il2cpp.exeがC++コンパイラに嘘をつき、全てのメソッドが同じ型であるかのように扱えるように、全てのメソッドはextern "C "と宣言されています。

- 型には接尾辞"_t "が付く。メソッド名には接尾辞"_m "が付く。名前の衝突は、それぞれの名前に固有の番号を付加することで解決される。これらの数値は、ユーザースクリプトのコードが変更されると変更されるため、ビルドごとに依存することはできない。

最初の2点は、どのメソッドも少なくとも2つのパラメータ、"this "ポインタとMethodInfoポインタを持つことを意味している。これらの余分なパラメータは不必要なオーバーヘッドを引き起こすか?オーバーヘッドが増えるのは明らかだが、余分な引数がパフォーマンス上の問題を引き起こすことを示唆するものは、今のところ見当たらない。そのように見えるかもしれないが、プロファイリングによれば、パフォーマンスの差は測定不可能である。

Ctagsを使って、このToStringメソッドの定義に飛ぶことができる。Bulk_UnityEngine_0.cppファイルにあります。このメソッド定義のコードは、Vector3::ToString()メソッドのC#コードとあまり似ていない。しかし、ILSpyのようなツールを使ってVector3::ToString()メソッドのコードを反映させると、生成されたC++コードはILコードと非常によく似ていることがわかる。

なぜil2cpp.exeは、メソッド宣言と同じように、各タイプのメソッド定義用に別々のC++ファイルを生成しないのですか?このBulk_UnityEngine_0.cppファイルはかなり大きく、実際には20,481行ある!私たちが使っていたC++コンパイラーは、大量のソースファイルを扱うのに苦労していた。4,000個の.cppファイルをコンパイルするのは、同じソースコードを80個の.cppファイルにコンパイルするよりもはるかに時間がかかる。そこで、il2cpp.exeは型のメソッド定義をグループにまとめ、グループごとに1つのC++ファイルを生成する。

メソッド宣言のヘッダー・ファイルに戻り、ファイルの先頭近くにある次の行に注目してほしい:

#include "codegen/il2cpp-codegen.h"

il2cpp-codegen.hファイルには、生成されたコードがlibil2cppランタイムサービスにアクセスするためのインタフェースが含まれています。ランタイムが生成されたコードに使われるいくつかの方法については後で説明する。

メソッド・プロローグ

Vector3::ToString()メソッドの定義を見てみよう。具体的には、il2cpp.exeがすべてのメソッドで発する共通のプロローグがある。

StackTraceSentry _stackTraceSentry(&Vector3_ToString_m2315_MethodInfo);
static bool Vector3_ToString_m2315_init;
if (!Vector3_ToString_m2315_init)
{
ObjectU5BU5D_t4_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&ObjectU5BU5D_t4_0_0_0);
Vector3_ToString_m2315_init = true;
}

このプロローグの最初の行は、StackTraceSentry 型のローカル変数を作成します。この変数は、IL2CPPがEnvironment.StackTraceのようなコールで報告できるように、管理されたコールスタックを追跡するために使用される。このエントリーのコード生成は実際にはオプションで、この場合はil2cpp.exeに渡された--enable-stacktraceオプションによって有効になります(WebGL Player SettingsのEnable ExceptionsオプションをFullに設定しているため)。小さな関数の場合、この変数のオーバーヘッドがパフォーマンスに悪影響を及ぼすことがわかった。そのため、プラットフォーム固有のスタック・トレース情報を使用できるiOSやその他のプラットフォームでは、この行を生成コードに出力することはない。WebGLでは、プラットフォーム固有のスタック・トレース・サポートがないため、マネージド・コードの例外が正しく動作するようにする必要があります。

プロローグの2番目の部分は、メソッド本体で使用される配列またはジェネリック型の型メタデータの初期化を遅延させる。つまり、ObjectU5BU5D_t4という名前は、System.Object[]という型の名前である。プロローグのこの部分は一度しか実行されず、型が他の場所で既に初期化されていれば何もしないことが多いので、この生成コードによるパフォーマンスへの悪影響は見られない。

このコードはスレッドセーフなのか?2つのスレッドが同時にVector3::ToString()を呼び出したら?実際、このコードは問題ない。libil2cppランタイムで型の初期化に使われているコードはすべて、複数のスレッドから呼び出しても安全だからだ。il2cpp_codegen_class_from_type関数が複数回呼び出される可能性はある(可能性すらある)が、実際の処理は1つのスレッドで1回だけ行われる。この初期化が完了するまで、メソッドの実行は続行されない。したがって、このメソッドのプロローグはスレッドセーフである。

ランタイム・チェック

メソッドの次の部分では、オブジェクト配列を作成し、Vector3のxフィールドの値をローカルに格納し、そのローカルをボックス化してインデックスゼロの配列に追加する。以下は生成されたC++コードである(いくつかの注釈付き):

// Create a new single-dimension, zero-based object array
ObjectU5BU5D_t4* L_0 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 3));
// Store the Vector3::x field in a local
float L_1 = (__this->___x_1);
float L_2 = L_1;
// Box the float instance, since it is a value type.
Object_t * L_3 = Box(InitializedTypeInfo(&Single_t264_il2cpp_TypeInfo), &L_2);
// Here are three important runtime checks
NullCheck(L_0);
IL2CPP_ARRAY_BOUNDS_CHECK(L_0, 0);
ArrayElementTypeCheck (L_0, L_3);
// Store the boxed value in the array at index 0
*((Object_t **)(Object_t **)SZArrayLdElema(L_0, 0)) = (Object_t *)L_3;

3つのランタイム・チェックはILコードには存在せず、il2cpp.exeによって注入される。

- NullCheckコードは、配列の値がNullの場合、NullReferenceExceptionをスローします。

- IL2CPP_ARRAY_BOUNDS_CHECK コードは、配列のインデックスが正しくない場合に IndexOutOfRangeException をスローする。

- ArrayElementTypeCheckコードは、配列に追加される要素の型が正しくない場合、ArrayTypeMismatchExceptionをスローする。

これら3つの実行時チェックは、すべて.NET仮想マシンが提供する保証である。Monoスクリプティング・バックエンドは、コードを注入するのではなく、プラットフォーム固有のシグナリング・メカニズムを使用して、これらの同じランタイム・チェックを処理する。IL2CPPでは、よりプラットフォームに依存せず、プラットフォーム固有のシグナル伝達メカニズムがないWebGLのようなプラットフォームをサポートしたかったので、il2cpp.exeはこれらのチェックを注入する。

これらのランタイム・チェックはパフォーマンスの問題を引き起こすのか?ほとんどの場合、パフォーマンスへの悪影響は見られず、.NET仮想マシンが必要とする利点と安全性を提供している。しかし、いくつかの具体的なケースでは、これらのチェックが、特にタイトなループにおいてパフォーマンスの低下につながることが確認されている。現在、il2cpp.exeがC++コードを生成する際に、これらの実行時チェックを取り除くために、マネージドコードに注釈を付けられるようにする方法を研究している。期待していてほしい。

静的フィールド

インスタンス・フィールドが(Vector3型で)どのように見えるかを見てきたところで、静的フィールドがどのように変換されてアクセスされるかを見てみよう。私のビルドのBulk_Assembly-CSharp_0.cppファイルにあるHelloWorld_Start_m3メソッドの定義を見つけてください。そこからImportant_t1型(AssemblyU2DCSharp_HelloWorld_Important.hファイル内)にジャンプする:

struct Important_t1  : public Object_t
{
// System.Int32 HelloWorld/Important::InstanceIdentifier
int32_t ___InstanceIdentifier_1;
};
struct Important_t1_StaticFields
{
// System.Int32 HelloWorld/Important::ClassIdentifier
int32_t ___ClassIdentifier_0;
};

il2cpp.exeは、この型のスタティック・フィールドを保持するために別のC++構造体を生成している。したがって、実行時には、Important_t1_StaticFields型のインスタンスが1つ作成され、Important_t1型のインスタンスはすべて、その静的フィールド型のインスタンスを共有することになる。生成されたコードでは、スタティック・フィールドはこのようにアクセスされる:

int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);

Important_t1の型メタデータは、Important_t1_StaticFields型の単一インスタンスへのポインタを保持し、そのインスタンスは静的フィールドの値を取得するために使用される。

例外

マネージ例外はil2cpp.exeによってC++例外に変換される。我々は、プラットフォーム固有のソリューションを避けるために、この道を選んだ。il2cpp.exeが管理例外を発生させるコードを発行する必要がある場合、 il2cpp_codegen_raise_exception関数を呼び出します。

HelloWorld_Start_m3メソッドの中で、管理された例外をスローしキャッチするコードは以下のようになる:

try
{ // begin try (depth: 1)
InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new (InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));
InvalidOperationException__ctor_m8(L_17, (String_t*) &_stringLiteral5, /*hidden argument*/&InvalidOperationException__ctor_m8_MethodInfo);
il2cpp_codegen_raise_exception(L_17);
// IL_0092: leave IL_00a8
goto IL_00a8;
} // end try (depth: 1)
catch(Il2CppExceptionWrapper& e)
{
__exception_local = (Exception_t8 *)e.ex;
if(il2cpp_codegen_class_is_assignable_from (&InvalidOperationException_t7_il2cpp_TypeInfo, e.ex->object.klass))
goto IL_0097;
throw e;
}
IL_0097:
{ // begin catch(System.InvalidOperationException)
V_1 = ((InvalidOperationException_t7 *)__exception_local);
NullCheck(V_1);
String_t* L_18 = (String_t*)VirtFuncInvoker0< String_t* >::Invoke(&Exception_get_Message_m9_MethodInfo, V_1);
Debug_Log_m6(NULL /*static, unused*/, L_18, /*hidden argument*/&Debug_Log_m6_MethodInfo);
// IL_00a3: leave IL_00a8
goto IL_00a8;
} // end catch (depth: 1)

すべての管理例外は C++ の Il2CppExceptionWrapper 型でラップされます。生成されたコードがその型の例外をキャッチすると、(Exception_t8型を持つ)管理例外のC++表現をアンパックする。この場合、InvalidOperationExceptionだけを探しているので、そのタイプの例外が見つからなければ、C++の例外のコピーが再度スローされる。正しい型が見つかった場合、コードはキャッチ・ハンドラの実装にジャンプし、例外メッセージを書き出す。

後藤

このコードから興味深い点が浮かび上がってくる。あのラベルとgoto文は何をしているんだ?構造化プログラミングでは、これらの構成要素は必要ない!しかし、ILにはループやif/then文のような構造化プログラミングの概念はない。下位レベルなので、il2cpp.exeは下位レベルの概念に従ってコードを生成する。

例えば、HelloWorld_Start_m3メソッドのforループを見てみよう:

IL_00a8:
{
V_2 = 0;
goto IL_00cc;
}
IL_00af:
{
ObjectU5BU5D_t4* L_19 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 1));
int32_t L_20 = V_2;
Object_t * L_21 =
Box(InitializedTypeInfo(&Int32_t5_il2cpp_TypeInfo), &L_20);
NullCheck(L_19);
IL2CPP_ARRAY_BOUNDS_CHECK(L_19, 0);
ArrayElementTypeCheck (L_19, L_21);
*((Object_t **)(Object_t **)SZArrayLdElema(L_19, 0)) = (Object_t *)L_21;
Debug_LogFormat_m7(NULL /*static, unused*/, (String_t*) &_stringLiteral6, L_19, /*hidden argument*/&Debug_LogFormat_m7_MethodInfo);
V_2 = ((int32_t)(V_2+1));
}
IL_00cc:
{
if ((((int32_t)V_2) < ((int32_t)3)))
{
goto IL_00af;
}
}

ここでV_2変数はループのインデックスである。0から始まり、この行のループの一番下でインクリメントされる:

V_2 = ((int32_t)(V_2+1));

ループの終了条件はここでチェックされる:

if ((((int32_t)V_2) < ((int32_t)3)))

V_2が3より小さい限り、goto文はIL_00afラベル(ループ本体の先頭)にジャンプする。il2cpp.exeは現在、中間的な抽象構文ツリー表現を使わずに、ILから直接C++コードを生成していることが推測できるかもしれない。これを想像した人は正解だ。また、上記のランタイム・チェックのセクションで、生成されたコードの一部が以下のようになっていることにお気づきだろうか:

float L_1 = (__this->___x_1);
float L_2 = L_1;

明らかに、L_2変数はここでは必要ない。ほとんどのC++コンパイラーは、この追加代入を最適化して取り除くことができるが、私たちはこの代入をまったく出さないようにしたい。私たちは現在、ASTを使用してILコードをよりよく理解し、特にローカル変数やforループを含むケースについて、よりよいC++コードを生成する可能性を研究している。

結論

IL2CPPスクリプティング・バックエンドが生成するC++コードの表面を、非常に単純なプロジェクトについて調べただけである。もしまだそうしていないのであれば、あなたのプロジェクトで生成されたコードを調べてみてほしい。私たちはIL2CPPスクリプトバックエンドのビルドと実行時のパフォーマンスを改善するために常に取り組んでいるためです。

ILコードをC++に変換することで、移植性とパフォーマンスのバランスが取れたコードを得ることができた。C++コンパイラがさまざまなプラットフォーム向けに提供する高品質のマシン・コードの利点を得ながら、マネージド・コードの開発者に優しい機能の多くを利用することができる。

今後の投稿では、メソッド呼び出し、メソッド実装の共有、ネイティブ・ライブラリーを呼び出すためのラッパーなど、生成されるコードをさらに掘り下げていく。しかし次回は、Xcodeを使ってiOS 64ビット・ビルド用に生成されたコードの一部をデバッグしてみよう。