IL2CPP 优化:虚拟化

Unity 的脚本虚拟机团队一直在寻找让您的代码运行得更快的方法。这是三部曲中的第一篇,介绍 IL2CPP AOT 编译器进行的一些微优化,以及如何利用这些优化。虽然这些优化措施并不能让代码运行速度提高两三倍,但对于游戏的重要部分还是有帮助的,我们希望这些优化措施能让您了解代码的执行情况。
没有别的办法,虚拟方法调用总是比直接方法调用昂贵。我们一直在努力改进 libil2cpp 运行时库的性能,以减少虚拟方法调用的开销(下一篇文章将详细介绍),但它们仍然需要某种运行时查找。编译器不可能知道运行时会调用哪个方法,它能知道吗?
去虚拟化是一种常见的编译器优化策略,它将虚拟方法调用变为直接方法调用。当编译器在编译时能准确证明哪个实际方法将被调用时,编译器可能会采用这种策略。遗憾的是,这一事实往往难以证明,因为编译器并不总是能看到整个代码库。但如果有可能,它可以使虚拟方法调用的速度更快。
作为一名年轻的开发人员,我是通过一个颇为拙劣的动物示例了解虚拟方法的。您可能对这些代码并不陌生:
public abstract class Animal {
public abstract string Speak();
}
public class Cow : Animal {
public override string Speak() {
return "Moo";
}
}
public class Pig : Animal {
public override string Speak() {
return "Oink";
}
}然后在 Unity(5.3.5 版)中,我们可以使用这些类来制作一个小型农场:
public class Farm: MonoBehaviour {
void Start () {
Animal[] animals = new Animal[] {new Cow(), new Pig()};
foreach (var animal in animals)
Debug.LogFormat("Some animal says '{0}'", animal.Speak());
var cow = new Cow();
Debug.LogFormat("The cow says '{0}'", cow.Speak());
}
}在这里,对 Speak 的每次调用都是一次虚拟方法调用。让我们看看能否说服 IL2CPP 对这些方法调用进行虚拟化,以提高性能。
我喜欢 IL2CPP 的一个特点是,它能生成 C++ 代码,而不是汇编代码。当然,这段代码看起来并不像你手写的 C++ 代码,但它比汇编代码更容易理解。让我们看看 for each 循环主体的生成代码:
// Set up a local variable to point to the animal array
AnimalU5BU5D_t2837741914* L_5 = V_2;
int32_t L_6 = V_3;
int32_t L_7 = L_6;
// Get the current animal from the array
V_1 = ((L_5)->GetAt(static_cast<il2cpp_array_size_t>(L_7)));
Animal_t3277885659 * L_9 = V_1;
// Call the Speak method
String_t* L_10 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Animal::Speak() */, L_9);为了简化操作,我删除了一些生成的代码。看到那个丑陋的 Invoke 调用了吗?它会在 vtable 中查找合适的虚拟方法,然后调用它。这种 vtable 查找会比直接调用函数慢,但这是可以理解的。动物可以是牛、猪或其他衍生类型。
让我们看看 Debug.LogFormat 第二次调用的生成代码,它更像是一个直接方法调用:
// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;
// Call the Speak method
String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);
即使在这种情况下,我们仍然在调用虚拟方法!IL2CPP 在优化方面相当保守,在大多数情况下更倾向于确保正确性。由于没有进行足够的全程序分析来确定是否可以直接调用,它选择了更安全(也更慢)的虚拟方法调用。
假设我们知道农场里没有其他类型的牛,因此没有任何类型的牛会从 "牛 "衍生出来。如果我们向编译器明确说明这些知识,就能得到更好的结果。让我们把类的定义改成这样:
public sealed class Cow : Animal {
public override string Speak() {
return "Moo";
}
}sealed关键字告诉编译器,任何人都不能从 Cow 派生(sealed也可以直接用于 Speak 方法)。现在,IL2CPP 有信心直接调用方法了:
// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;
// Look ma, no virtual call!
String_t* L_17 = Cow_Speak_m1607867742(L_16, /*hidden argument*/NULL);这里调用 Speak 的速度不会太慢,因为我们对编译器的要求很明确,让它可以放心地进行优化。
这种优化不会让你的游戏快到令人难以置信的地步,但在代码中表达你对代码的任何假设是一种很好的做法,这既是为了将来阅读代码的人类读者,也是为了编译器。如果您使用 IL2CPP 进行编译,我建议您仔细阅读项目中生成的 C++ 代码,看看还能发现什么!
下一次,我们将讨论虚拟方法调用为何昂贵,以及如何让它们更快。
