IL2CPP优化(中):虚方法调用

发表于2016-08-19
评论0 4.6k浏览
  上一篇:IL2CPP优化(上):去虚拟化
  上一篇我们谈到了虚方法的调用通常比直接调用来得慢,也提到了如何通知IL2CPP将给定的虚方法调用转换(去虚拟化)为更快的直接调用。但有时候万一 “必须”执行虚方法调用呢?至少尽可能让它更快吧。
  虚方法调用是必须在运行时决定的调用。编译器在编译代码时并不知道哪个方法即将被调用,所以它为每个类都构建了方法数组(称为虚函数表,或称vtable)。当有人调用这些方法其中之一的时候,运行时会查找出vtable中正确的方法并且调用它。但是,当这些方法不管用,或者是vtable中没有相应的虚方法以供调用的时候呢?

当虚方法不好用的时候
  看看下面这个比较极端的例子,这里使用的对象会在运行时创建一个“类型”:
1
2
3
4
5
6
7
8
9
10
11
class BaseClass {
    public virtual string SayHello() {
        return "Hello from base!";
    }
}
  
class GenericDerivedClass : BaseClass {
    public override string SayHello() {
        return "Hello from derived!";
    }
}
  给定这些类型以后,我们可以在Unity(版本5.3.5)中尝试以下代码:
1
2
3
4
5
6
7
8
9
10
public class VirtualInvokeExample : MonoBehaviour {
    void Start () {
        Debug.Log(MakeRuntimeBaseClass().SayHello());
    }
  
    private BaseClass MakeRuntimeBaseClass() {
        var derivedType = typeof(GenericDerivedClass<>).MakeGenericType(typeof(int));
        return (BaseClass)FormatterServices.GetUninitializedObject(derivedType);
    }
}
  MakeRuntimeBaseClass的细节不是太重要。真正重要的是它创建的对象都有一个类型(GenericDerivedClass),这是在运行时创建的。
  这个有点古怪的代码在Just-in-time (JIT,即时编译)编译器里运行的时候没有任何问题,它会在运行时进行编译。如果在Unity编辑器中运行,输出结果如下:
  Hello from derived!
  UnityEngine.Debug:Log(Object)
  VirtualInvokeExample:Start() (at Assets/VirtualInvokeExample.cs:7)
  但如果使用Ahead-of-time (AOT,预先编译)编译器结果就完全不同了,如果我们在iOS上用IL2CPP运行同样的代码,将出现如下报错信息:
  ExecutionEngineException: Attempting to call method   'GenericDerivedClass`1[[System.Int32, mscorlib, Version=2.0.5.0, Culture=, PublicKeyToken=7cec85d7bea7798e]]::SayHello' for which no ahead of time (AOT) code was generated.
  at VirtualInvokeExample.Start () [0x00000] in :0
  在运行时创建类型(GenericDerivedType)正是导致SayHello虚方法调用产生问题的原因。既然IL2CPP是一个AOT编译器,而且并没有GenericDerivedType类型的源代码,IL2CPP就将无法生成SayHello方法的实现。

当调用的方法不存在时
  要了解这里发生了什么,我们可以在Xcode里创建一个异常断点。这个断点会在il2cpp::vm::Runtime::GetVirtualInvokeData函数中被触发,其中libil2cpp 运行时会尝试解析虚方法调用。函数代码如下:
1
2
3
4
5
6
static inline void GetVirtualInvokeData(Il2CppMethodSlot slot,
                     void* obj, VirtualInvokeData* invokeData) {
    *invokeData = ((Il2CppObject*)obj)->klass->vtable[slot];
    if (!invokeData->methodPtr)
        RaiseExecutionEngineException(invokeData->method);
}
  第一行执行了上面所说的vtable内查找。第二行检查虚方法是否存在,如果方法不存在则抛出托管异常。

让这段代码更快
  这仅有三行的代码,我们真的能让它更快吗?事实证明,完全可以!vtable查找是必要的,所以必须保持原样。但是检查部分呢?大多数情况下都是可以找到的,所以为什么要在这不常使用的代码分支中平添额外的消耗呢?
  下面试试“通常”写调用的方法!如果不是由AOT编译器生成的方法,则用抛出托管异常的方法来替换它。在Unity5.5(目前处于封闭测试阶段的alpha版本)中,GetVirtualInvokeData代码如下:
1
2
3
4
static inline void GetVirtualInvokeData(Il2CppMethodSlot slot,
                     void* obj, VirtualInvokeData* invokeData) {
    *invokeData = ((Il2CppObject*)obj)->klass->vtable[slot];
}
  IL2CPP现在会为项目中每个虚函数使用的不同“函数签名”都生成一个存根方法。如果vtable内没有真正的方法,它将获取匹配其函数签名的正确的存根方法。这种情况下,所调用的虚方法是:
1
2
3
4
5
6
static  Il2CppObject * UnresolvedVirtualCall_2 (Il2CppObject * __this,
                                             const MethodInfo* method)
{
    il2cpp_codegen_raise_execution_engine_exception(method);
    il2cpp_codegen_no_return();
}
  因此代码还是一样,在AOT编译器无法为虚方法调用生成代码时抛出一个适当的托管异常。最重要的是,这种行为在正常情况下“不会消耗”任何资源。

这样会有多快呢?
  现在的关键是:这样的小优化效果真的很明显么?是的。我们的分析表明整体执行时间有了3%到4%的改善。改善效果取决于虚方法调用的次数以及进程架构。如果处理器带有更深层的指令流水线,更好的分支预测处理并且检查成本不高,那么取消检查并不会带来太明显的性能提升。而那些无法很好的处理分支预测的处理器则可以获得更高的性能提升。
  实际上这是一个常见的虚拟机优化技术,所以我们很高兴能将它应用于IL2CPP。它沿用了古老的关于性能的口头禅:“不执行代码比执行某些代码更好。” 下一篇我们将讨论另一种小优化,其中如果我们可以证明有些代码并不重要,IL2CPP就可以完全避免执行这些代码。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引