IL2CPPの内部:メソッド呼び出し

これはIL2CPP内部シリーズの4番目のブログ記事です。この投稿では、il2cpp.exeがどのようにしてマネージド・コード内のメソッド呼び出し用のC++コードを生成するかを見ていく。具体的には、6種類のメソッド・コールを調査する:
- インスタンスメソッドとスタティックメソッドの直接呼び出し
- コンパイル時のデリゲートによる呼び出し
- 仮想メソッド経由の呼び出し
- インターフェース・メソッド経由の呼び出し
- ランタイム・デリゲート経由の呼び出し
- 反射による通話
それぞれの場合において、生成されたC++コードが何をしているのか、そして特に、それらの命令にどれだけのコストがかかるのかに注目する。
このシリーズのすべての投稿と同様に、変更される可能性があり、実際にUnityの新しいバージョンで変更される可能性があるコードを調査します。しかし、コンセプトは変わらないはずだ。この連載で取り上げたことはすべて、実装の詳細として受け止めてほしい。私たちは、可能な限りこのような詳細を明らかにし、議論したいと思っています。
設定
Unityのバージョンは5.0.1p4です。エディターをWindows上で動かし、WebGLプラットフォーム用にビルドする。開発プレーヤー」オプションを有効にし、「例外を有効にする」オプションを「フル」に設定してビルドしています。
異なるタイプのメソッド・コールを見ることができるように、前回の投稿を修正した1つのスクリプト・ファイルでビルドしてみよう。スクリプトはインターフェースとクラスの定義から始まる:
interface Interface {
int MethodOnInterface(string question);
}
class Important : Interface {
public int Method(string question) { return 42; }
public int MethodOnInterface(string question) { return 42; }
public static int StaticMethod(string question) { return 42; }
}
そして、定数フィールドとデリゲート型があり、どちらも後のコードで使われる:
private const string question = "What is the answer to the ultimate question of life, the universe, and everything?";
private delegate int ImportantMethodDelegate(string question);
最後に、私たちが興味を持っているメソッドを紹介しよう(これに加えて、必須メソッドであるStartメソッドも紹介する):
private void CallDirectly() {
var important = ImportantFactory();
important.Method(question);
}
private void CallStaticMethodDirectly() {
Important.StaticMethod(question);
}
private void CallViaDelegate() {
var important = ImportantFactory();
ImportantMethodDelegate indirect = important.Method;
indirect(question);
}
private void CallViaRuntimeDelegate() {
var important = ImportantFactory();
var runtimeDelegate = Delegate.CreateDelegate(typeof (ImportantMethodDelegate), important, "Method");
runtimeDelegate.DynamicInvoke(question);
}
private void CallViaInterface() {
Interface importantViaInterface = new Important();
importantViaInterface.MethodOnInterface(question);
}
private void CallViaReflection() {
var important = ImportantFactory();
var methodInfo = typeof(Important).GetMethod("Method");
methodInfo.Invoke(important, new object[] {question});
}
private static Important ImportantFactory() {
var important = new Important();
return important;
}
void Start () {}
それでは、さっそく始めよう。生成されたC++コードは、プロジェクトのTempStagingAreaDatail2cppOutputディレクトリに配置されることを思い出してください(エディタが開いている限り)。また、生成されたコードにCタグを生成し、ナビゲートすることもお忘れなく。
メソッドを直接呼び出す
メソッドを呼び出す最も簡単な(そして後述するように最も速い)方法は、メソッドを直接呼び出すことである。以下はCallDirectlyメソッドの生成コードである:
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
Important_t1 * L_1 = V_0;
NullCheck(L_1);
Important_Method_m1(L_1, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_Method_m1_MethodInfo);
最後の行が実際のメソッド呼び出しである。C++のコードで定義されたfree関数を呼び出すだけで、特別なことは何もしていないことに注意してほしい。生成されるコードに関する以前の投稿で、il2cpp.exeはすべてのメソッドをC++のフリー関数として生成することを思い出してほしい。IL2CPPスクリプトのバックエンドは、生成されるコードにC++のメンバ関数や仮想関数を使用しない。ということは、スタティック・メソッドのディレクトリを呼び出すのも同じようなものだ。以下はCallStaticMethodDirectlyメソッドから生成されたコードである:
Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);
オブジェクトのインスタンスを生成して初期化する必要がないので、静的メソッドを呼び出す方がオーバーヘッドが少ないと言える。しかし、メソッドの呼び出し自体はまったく同じで、C++のフリー関数の呼び出しである。ここでの唯一の違いは、第1引数の値が常にNULLで渡されることである。
スタティック・メソッドとインスタンス・メソッドの呼び出しの違いは非常に小さいので、この記事の残りの部分ではインスタンス・メソッドだけに焦点を当てますが、この情報はスタティック・メソッドにも当てはまります。
コンパイル時のデリゲートによるメソッドの呼び出し
もう少しエキゾチックなメソッド呼び出し、例えばデリゲートを介した間接的な呼び出しはどうなるのか?つまり、コンパイル時にどのメソッドがどのオブジェクト・インスタンスに対して呼び出されるかがわかるということだ。このタイプの呼び出しのコードはCallViaDelegateメソッドにある。生成されたコードではこのようになっている:
// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
Important_t1 * L_1 = V_0;
// Create the delegate.
IntPtr_t L_2 = { &Important_Method_m1_MethodInfo };
ImportantMethodDelegate_t4 * L_3 = (ImportantMethodDelegate_t4 *)il2cpp_codegen_object_new (InitializedTypeInfo(&ImportantMethodDelegate_t4_il2cpp_TypeInfo));
ImportantMethodDelegate__ctor_m4(L_3, L_1, L_2, /*hidden argument*/&ImportantMethodDelegate__ctor_m4_MethodInfo);
V_1 = L_3;
ImportantMethodDelegate_t4 * L_4 = V_1;
// Call the method
NullCheck(L_4);
VirtFuncInvoker1< int32_t, String_t* >::Invoke(&ImportantMethodDelegate_Invoke_m5_MethodInfo, L_4, (String_t*) &_stringLiteral1);
生成されたコードの異なる部分を示すために、いくつかのコメントを追加した。
ここで呼ばれる実際のメソッドは、生成されたコードの一部ではないことに注意してほしい。メソッド VirtFuncInvoker1<int32_t, String_t*>::Invoke は GeneratedVirtualInvokers.h ファイルにあります。このファイルはil2cpp.exeによって生成されるが、ILコードから来ているわけではない。その代わりに、il2cpp.exe は、値を返す仮想関数 (VirtFuncInvokerN) と返さない仮想関数 (VirtActionInvokerN) の使用法に基づいてこのファイルを作成します。
ここでのInvokeメソッドは次のようになる:
template <typename R, typename T1>
struct VirtFuncInvoker1
{
typedef R (*Func)(void*, T1, MethodInfo*);
static inline R Invoke (MethodInfo* method, void* obj, T1 p1)
{
VirtualInvokeData data = il2cpp::vm::Runtime::GetVirtualInvokeData (method, obj);
return ((Func)data.methodInfo->method)(data.target, p1, data.methodInfo);
}
};
libil2cpp GetVirtualInvokeDataの呼び出しは、マネージコードに基づいて生成されたvtable構造体の仮想メソッドを検索し、そのメソッドを呼び出します。
なぜC++11を使わないのか可変テンプレートを 使用して、これらの VirtFuncInvokerN メソッドを実装しないのですか?これはまるで、変種変型のテンプレートを求めているような状況であり、実際にそうなのだ。しかし、il2cpp.exeによって生成されたC++コードは、まだバリアディック・テンプレートなどC++ 11のすべての機能をサポートしていないC++コンパイラで動作しなければならない。少なくともこのケースでは、C++11コンパイラ用に生成されたコードをフォークすることは、複雑さを増すだけの価値があるとは考えられなかった。
しかし、なぜこれが仮想メソッド呼び出しなのか?C#のコードでインスタンスメソッドを呼び出しているのではないのか?C#のデリゲートを介してインスタンス・メソッドを呼び出していることを思い出してほしい。上の生成されたコードをもう一度見てほしい。実際に呼び出すメソッドは、MethodInfo*(メソッドのメタデータ)引数で渡される:ImportantMethodDelegate_Invoke_m5_MethodInfo.生成されたコードで「ImportantMethodDelegate_Invoke_m5」というメソッドを探すと、実際にはImportantMethodDelegate型のマネージドInvokeメソッドへの呼び出しであることがわかる。これは仮想メソッドなので、仮想呼び出しが必要だ。C#コードのMethodという名前のメソッドを実際に呼び出すのは、このImportantMethodDelegate_Invoke_m5関数である。
うわぁ、確かに口がいっぱいになったよ。C#のコードに単純な変更を加えるだけで、C++のフリー関数を1回呼び出すだけだったのが、複数の関数を呼び出すようになり、さらにテーブル・ルックアップも行うようになった。デリゲートを介してメソッドを呼び出すと、同じメソッドを直接呼び出すよりもかなりコストがかかる。
デリゲート・メソッドの呼び出しを見ていく過程で、仮想メソッド経由の呼び出しがどのように機能するかも見てきたことに注意してほしい。
インターフェースを介したメソッドの呼び出し
C#では、インターフェースを介してメソッドを呼び出すことも可能だ。この呼び出しはil2cpp.exeによって仮想メソッド呼び出しと同様に実装される:
Important_t1 * L_0 = (Important_t1 *)il2cpp_codegen_object_new (InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo));
Important__ctor_m0(L_0, /*hidden argument*/&Important__ctor_m0_MethodInfo);
V_0 = L_0;
Object_t * L_1 = V_0;
NullCheck(L_1);
InterfaceFuncInvoker1< int32_t, String_t* >::Invoke(&Interface_MethodOnInterface_m22_MethodInfo, L_1, (String_t*) &_stringLiteral1);
ここでの実際のメソッド呼び出しは、GeneratedInterfaceInvokers.hファイルにあるInterfaceFuncInvoker1::Invoke関数を介して行われる。VirtFuncInvoker1クラスと同様に、InterfaceFuncInvoker1クラスもlibil2cppのil2cpp::vm::Runtime::GetInterfaceInvokeData関数を使ってvtableを検索します。
なぜlibil2cppでは、インターフェイスメソッド呼び出しは、仮想メソッド呼び出しとは異なるAPIを使用する必要があるのですか?InterfaceFuncInvoker1::Invokeの呼び出しは、呼び出すメソッドとその引数だけでなく、そのメソッドを呼び出すインターフェース(この場合はL_1)も渡していることに注意してください。各タイプのvtableは、インターフェイスメソッドが特定のオフセットに書き込まれるように格納される。したがって、il2cpp.exeは、どのメソッドを呼び出すかを決定するために、インターフェースを提供する必要がある。
要するに、IL2CPPでは、仮想メソッドの呼び出しとインターフェイス経由のメソッドの呼び出しは、実質的に同じオーバーヘッドを持つということだ。
ランタイム・デリゲートによるメソッドの呼び出し
デリゲートを使うもう一つの方法は、実行時にDelegate.CreateDelegateメソッドでデリゲートを作成することである。このアプローチはコンパイル時のデリゲートに似ているが、実行時にいくつかの方法で変更できる点が異なる。私たちはその柔軟性の代償として、追加のファンクション・コールを支払う。以下は生成されたコードである:
// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
// Create the delegate.
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo));
Type_t * L_1 = Type_GetTypeFromHandle_m19(NULL /*static, unused*/, LoadTypeToken(&ImportantMethodDelegate_t4_0_0_0), /*hidden argument*/&Type_GetTypeFromHandle_m19_MethodInfo);
Important_t1 * L_2 = V_0;
Delegate_t12 * L_3 = Delegate_CreateDelegate_m20(NULL /*static, unused*/, L_1, L_2, (String_t*) &_stringLiteral2, /*hidden argument*/&Delegate_CreateDelegate_m20_MethodInfo);
V_1 = L_3;
Delegate_t12 * L_4 = V_1;
// Call the method
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1));
NullCheck(L_5);
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0);
ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1);
*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1;
NullCheck(L_4);
Delegate_DynamicInvoke_m21(L_4, L_5, /*hidden argument*/&Delegate_DynamicInvoke_m21_MethodInfo);
このデリゲートは、作成と初期化にかなりのコードを必要とする。しかし、メソッド呼び出し自体にもさらにオーバーヘッドがある。まず、メソッドの引数を格納する配列を作成し、DelegateインスタンスのDynamicInvokeメソッドを呼び出します。生成されたコードでそのメソッドをたどってみると、コンパイル時のデリゲートと同じように、VirtFuncInvoker1::Invoke関数を呼び出していることがわかる。そのため、このデリゲートでは、コンパイル時のデリゲートに比べて関数呼び出しが1回多くなり、さらにvtableのルックアップも1回ではなく2回必要になる。
リフレクションによるメソッドの呼び出し
メソッドを呼び出す最もコストのかかる方法は、驚くことではないが、リフレクションである。CallViaReflectionメソッドの生成コードを見てみましょう:
// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
// Get the method metadata from the type via reflection.
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo));
Type_t * L_1 = Type_GetTypeFromHandle_m19(NULL /*static, unused*/, LoadTypeToken(&Important_t1_0_0_0), /*hidden argument*/&Type_GetTypeFromHandle_m19_MethodInfo);
NullCheck(L_1);
MethodInfo_t * L_2 = (MethodInfo_t *)VirtFuncInvoker1< MethodInfo_t *, String_t* >::Invoke(&Type_GetMethod_m23_MethodInfo, L_1, (String_t*) &_stringLiteral2);
V_1 = L_2;
MethodInfo_t * L_3 = V_1;
// Call the method.
Important_t1 * L_4 = V_0;
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1));
NullCheck(L_5);
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0);
ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1);
*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1;
NullCheck(L_3);
VirtFuncInvoker2< Object_t *, Object_t *, ObjectU5BU5D_t9* >::Invoke(&MethodBase_Invoke_m24_MethodInfo, L_3, L_4, L_5);
ランタイム・デリゲートの場合と同様に、メソッドの引数用の配列を作るのに時間をかける必要がある。次に、MethodBase::Invoke(MethodBase_Invoke_m24関数)を仮想メソッド呼び出しする。この関数は、最終的に実際のメソッド呼び出しにたどり着く前に、別の仮想関数を呼び出す!
結論
これは実際のプロファイリングや測定に代わるものではないが、生成されたC++コードがさまざまなタイプのメソッド呼び出しにどのように使用されるかを見ることで、任意のメソッド呼び出しのオーバーヘッドについてある程度の洞察を得ることができる。具体的には、ランタイム・デリゲートとリフレクションによる呼び出しは、可能な限り避けたいのは明らかだ。いつものことだが、パフォーマンス向上に関する最善のアドバイスは、プロファイリングツールを使って早い段階で頻繁に測定することだ。
私たちは常にil2cpp.exeによって生成されるコードを最適化する方法を探しているので、これらのメソッド呼び出しはUnityの後のバージョンでは違って見える可能性があります。
次回は、メソッドの実装についてさらに深く掘り下げ、生成されるコードと実行ファイルのサイズを最小化するために、ジェネリック・メソッドの実装をどのように共有するかを見ていこう。