IL2CPP 内部:方法调用

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jun 3, 2015|11 Min
IL2CPP 内部:方法调用
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

这是IL2CPP Internals系列的第四篇博文。在本篇文章中,我们将了解 il2cpp.exe 如何为托管代码中的方法调用生成 C++ 代码。具体来说,我们将研究六种不同类型的方法调用:

- 直接调用实例方法和静态方法

- 通过编译时委托进行调用

- 通过虚拟方法调用

- 通过接口方法调用

- 通过运行时委托进行调用

- 通过反射呼叫

在每种情况下,我们将重点关注生成的 C++ 代码在做什么,特别是这些指令的成本是多少。

与本系列的所有文章一样,我们将探讨的代码可能会发生变化,事实上,在更新的 Unity 版本中也很可能会发生变化。不过,概念应保持不变。请将本系列中讨论的所有内容视为实施细节。不过,我们喜欢在可能的情况下揭露和讨论这样的细节!

设置

我将使用 Unity 5.0.1p4 版本。我将在 Windows 上运行编辑器,并为 WebGL 平台构建。我在构建时启用了 "开发播放器 "选项,并将 "启用异常 "选项设置为 "完全 "值。

我将使用从上一篇文章修改而来的单个脚本文件进行构建,这样我们就能看到不同类型的方法调用。脚本以接口和类的定义开始:

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);

最后,这些就是我们有兴趣探索的方法(加上必选的 "开始 "方法,这里没有内容):

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++ 代码将位于项目中的 Temp\StagingArea\Data\il2cppOutput 目录中(只要编辑器仍处于打开状态)。别忘了在生成的代码上生成Ctags,以帮助导航。

直接调用方法

调用方法的最简单(也是最快的)方法是直接调用。以下是 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++ 自由函数的调用。唯一不同的是,第一个参数的传递值始终为 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) 的使用情况创建该文件,其中N是方法的参数数。

这里的调用方法是这样的

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++ 编译器配合使用,这些编译器还不支持 C++ 11 的所有功能,包括可变模板。至少在这种情况下,我们认为为 C++11 编译器分叉生成的代码不值得增加复杂性。

但为什么这是一个虚拟方法调用呢?我们不是在 C# 代码中调用实例方法吗?请注意,我们是通过 C# 委托来调用实例方法的。再看看上面生成的代码。我们要调用的实际方法是通过 MethodInfo*(方法元数据)参数传递进来的:ImportantMethodDelegate_Invoke_m5_MethodInfo.如果我们在生成的代码中搜索名为 "ImportantMethodDelegate_Invoke_m5 "的方法,就会发现实际上调用的是 ImportantMethodDelegate 类型的受管 Invoke 方法。这是一个虚拟方法,因此我们需要进行虚拟调用。正是这个 ImportantMethodDelegate_Invoke_m5 函数将实际调用 C# 代码中名为 Method 的方法。

哇,真是大饱口福。通过对 C# 代码进行看似简单的修改,我们已经从对 C++ 自由函数的单次调用变成了对多个函数的调用,外加一个表格查询。通过委托调用方法比直接调用同一方法的成本要高得多。

请注意,在研究委托方法调用的过程中,我们也看到了通过虚拟方法调用的工作原理。

通过接口调用方法

在 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);

请注意,这里的实际方法调用是通过 InterfaceFuncInvoker1::Invoke 函数完成的,该函数位于 GeneratedInterfaceInvokers.h 文件中。与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);

创建和初始化该委托需要大量代码。但方法调用本身的开销也更大。首先,我们需要创建一个数组来保存方法参数,然后在委托实例上调用 DynamicInvoke 方法。如果我们在生成的代码中跟踪该方法,就会发现它调用了 VirtFuncInvoker1::Invoke 函数,就像编译时委托所做的那样。因此,与编译时委托相比,该委托需要多调用一个函数,还需要在 vtable 中进行两次查找,而不是一次。

通过反射调用方法

毫不奇怪,调用方法的最昂贵方式是通过反射。让我们看看 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 的后续版本中很可能会有所不同。

下一次,我们将深入研究方法的实现,看看我们如何共享通用方法的实现,以尽量减少生成的代码和可执行文件的大小。