Unity的脚本虚拟机团队一直在寻找让代码运行得更快的方法。这是三篇介绍关于IL2CPP AOT编译器小优化文章的第一篇,另外这篇文章也会教大家如何进行优化。虽然这些优化并不会让你的代码运行速度提升两到三倍,但是它们也会对游戏起到非常重要的帮助,我们也希望它们能帮助您了解你的代码是如何运行的。


现代编译器非常擅长执行各种优化来提高代码运行时的性能。作为开发人员,我们通常可以向编译器显式的传达一些代码信息来帮助编译器提升性能。今天,我们将详细讨论IL2CPP的一个小优化,看看它如何改进现有代码的运行效率。


Devirtualization


众所周知,虚方法的调用通常比函数直接调用开销更大。我们对libil2cpp的运行时库中进行了一些性能优化,以降低虚函数的调用开销(在下一篇文章中会有更多的介绍),但是它们仍然需在运行时进行一些查找有一些开销。编译器无法知道在运行时那个函数会被调用,或者是否可以被调用?


Devirtualization 是一种常见的编译器优化策略,它将通过虚方法通过虚表的调用转换为直接调用。当编译器在编译时能够准确地知道运行时实际会调用哪种方法时,编译器就会使用这种策略优化。但不幸的是,这点往往很难做到,因为编译器通常无法了解整个代码库的代码。但是如果可以做到的话,它可以使虚拟方法的调用变的更快。


典型的例子


当我作为一个年轻开发者的时候,我通过一个相当常见的动物例子学习了虚方法的相关知识。下面这段代码您可能也很熟悉:

 

  1. public abstract class Animal {
  2.   public abstract string Speak();

  3. public class Cow : Animal {
  4.    public override string Speak() {
  5.        return "Moo";
  6.    }

  7. public class Pig : Animal {
  8.     public override string Speak() {
  9.         return "Oink";
  10.    }

复制代码

接下来在Unity(5.3.5版)中,我们可也以使用这些类来做一个小农场:

 

  1. public class Farm: MonoBehaviour {
  2.    void Start () {
  3.        Animal[] animals = new Animal[] {new Cow(), new Pig()};
  4.        foreach (var animal in animals)
  5.            Debug.LogFormat("Some animal says '{0}'",animal.Speak());

  6.        var cow = new Cow();
  7.        Debug.LogFormat("The cow says '{0}'", cow.Speak());
  8.    }

复制代码

这里的每次调用都是一个虚方法的调用。让我们看看能否让IL2CPP对这些方法调用做出优化直接调用来提高执行性能。


生成的C++代码


我非常喜欢IL2CPP的一个特性就是它时生成C++代码而不是汇编代码。当然,这段代码看起来不像一般手写的C++代码,但是还是比汇编更容易理解。让我们看看生成的foreach里的代码:

 

  1. // Set up a local variable to point to the animal array
  2. AnimalU5BU5D_t2837741914* L_5 = V_2;
  3. int32_t L_6 = V_3;
  4. int32_t L_7 = L_6;

  5. // Get the current animal from the array
  6. V_1 = ((L_5)->GetAt(static_cast<il2cpp_array_size_t>(L_7)));
  7. Animal_t3277885659 * L_9 = V_1;

  8. // Call the Speak method
  9. String_t* L_10 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Animal::Speak() */, L_9);

复制代码

我已经删除了一些其他的生成代码来做简化。二手手机号转让看到那个丑陋的Invoke调用了吗?它先在虚表中查找真正被调用的虚方法,然后才调用它。显而易见,虚表的查找会比直接调用函数慢很多。因为这种动物可以是一头牛或一头猪,也可以是某种其他类型的动物。


接下来让我们看看第二段代码生成的C++代码。第二段代码我们new了一个Cow,然后调用了LogFormat打印Cow的Speak函数的返回值,这看上去应该是直接调用函数了吧:

  1. // Create a new cow
  2. Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
  3. Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
  4. V_4 = L_14;
  5. Cow_t1312235562 * L_16 = V_4;

  6. // Call the Speak method
  7. String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);

复制代码

但即使在这种情况下,我们可以看到编译器仍然在通过虚表调用函数!IL2CPP在优化方面相当保守,在大多数情况下都更倾向于保证正确性。由于它没有对全程序进行分析来确定这是一个可以直接调用的函数,因为可能牛也有派生类,所以它选择了更安全(和更慢)的虚方法调用。


但是假如我们知道农场里没有其他种类的牛了,牛没有其他派生类了。那么我们就可以把这些信息显式传达给编译器,让编译器优化,我们就能得到一个更好的结果。让我们对Cow做一些修改:

 

  1. public sealed class Cow : Animal{
  2.    public override string Speak() {
  3.        return "Moo";
  4.    }

复制代码

sealed关键字可以告诉编译器,Cow不会有派生类了(sealed 也可以修饰Speak函数)。这样IL2CPP就能确信可以直接进行方法调用了:

 

  1. // Create a new cow
  2. Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
  3. Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
  4. V_4 = L_14;
  5. Cow_t1312235562 * L_16 = V_4;

  6. // Look ma, no virtual call!
  7. String_t* L_17 = Cow_Speak_m1607867742(L_16, /*hidden argument*/NULL);

复制代码

可以看到这次调用就是直接调用不会再慢了,因为我们已经明确的告诉编译器相关信息,可以让编译器进行优化了。


虽然这种优化可能不会让您的游戏运行速度有显著的提升,但是对于代码的阅读和编译器本身来说,这都是一个非常好的实践,清楚的表达您写的代码的意图。如果您使用IL2CPP进行编译,那么我强烈建议您阅读一下编译后生成的C++代码,或许会有意想不到的收获!