让Unity迭代器性能提升5倍

最近在研究Unity il2cpp的代码生成和编译优化,结合之前遇到过的一个优化案例,给大家讲讲在Unity中迭代器相关代码生成的底层原理,以及在写代码过程中需要注意的一些特殊情况。

案例

首先我们来看一个非常简单的案例,代码如下:

public class NewBehaviourScript : MonoBehaviour {
    private List<float> _objects = new List<float>();

    private float GetSumSlow(IEnumerable<float> arr) {
        float sum = 0;
        foreach (var value in arr) {
            sum += value;
        }
        return sum;
    }

    void Start() {
        for (int i = 0; i < 100000; i++) {
            _objects.Add(0.1f);
        }
    }

    void Update() {
        float sum = GetSumSlow(_objects);
    }
}

其中重点来看 GetSumSlow 这个函数的逻辑,就是使用迭代器遍历一个集合,计算元素之和,代码乍看起来也没有什么问题。但是当我们使用 VTune 去抓数据的时候,会发现正是这个很简单的函数出现了问题,在函数耗时TOP的前几名都能看到好几个和这个迭代器相关的函数,甚至在某些情况下还会因为较多的 Cache Miss 引起 Memory Bound 问题。

从代码逻辑来看,这个函数内部其实做一件非常简单而常见的事情,并且访问的内存也是连续的,理论来看并不应该会引起任何性能问题,但通过详细分析了Unity il2cpp代码生成逻辑以后,我们通过修改了其中一行代码,修改后如下:

private float GetSumFast(List<float> arr) {   // 只修改这一行
    float sum = 0;
    foreach (var value in arr) {
        sum += value;
    }
    return sum;
}

其中我们仅仅是将函数的参数类型从 IEnumerable 接口类型修改成了具体的实现类型:List,然后我们再使用 VTune 去对比数据,惊奇的发现该函数的CPU耗时大幅度的减少,性能居然大幅的提升了 5 倍左右,对比数据如下:

这个案例很小,但足够有趣,因此接下来我们就用这个案例作为引子,来简单聊聊 il2cpp 的代码生成和编译优化的一整套流程,并随着这个过程我们来解答这个案例的谜底。

il2cpp

对于 il2cpp 大家都比较熟悉,它相对的概念是早期的 mono 虚拟机,Unity游戏的开发使用是 C# 语言,在 il2cpp 模式下会将 C# 语言翻译成 C++ 在用户的机器上运行,其运行的效率和安全性相比于早期的 mono 是有大幅提升的,我们这里主要关注从你写的 C# 代码,是如何一步一步的转换成 C++ 代码,并最终运行在用户的机器上的。

首先Unity通过如下流程从 C# 转成 C++ 代码:

然后使用本机的 C++ 的编译器转成机器码(这里以 Android 平台为例 ):

在这整个流程中,其实比较特殊的只有两个环节:

UnityLinker, Unity在 C# 的 IL Linker 的基础上,实现的一个专门处理 Assembly 文件的 Linker(这里的 Linker 类似于 gcc/clang 等编译器相关的 linker,它主要负责在编译环节将相关的模块链接到一起),但是 Unity 为了实现 il2cpp ,还需要在这个 Linker 之上实现一个最重要的 C# 模块/代码剔除的功能,因为一般 C# 代码都会有很多运行时库和第三方库的依赖,而如果原封不动的将所有的这些DLL里面的C#代码都转成 C++,那么最后在编译 C++ 代码 的时候,就连 LLVM 都会因为其代码量之大需要编译非常久的时间,甚至还可能超过 linker 的上限而无法编译成功,因此 UnityLinker 需要去分析各个 Assembly 的依赖关系,将不需要的DLL剔除出去,甚至它还需要去分析其中的 C# 函数和函数之间的调用关系,将DLL中不需要的代码给完全剔除出去,使其只将需要的精简的 C# 代码在下一个阶段转换为 C++ 代码。

il2cpp, 它是Unity中用 C# 实现的一个二进制程序,它主要负责将 Assembly 文件中的 IL 指令,一条一条的翻译成 C++ 代码,这个翻译的过程比较机械和枯燥,但我们可以用一个比较简单的方式来理解它,那就是这个 il2cpp 编译器(或者叫翻译器)的主要逻辑和之前 mono 虚拟机的实现逻辑是差不多的流程,mono虚拟机内有一个很大很大的 switch 语句,它负责处理每一条 IL 指令 ,将它们在运行时立即执行,而 il2cpp 编译器的逻辑也是类似的,它只是在离线的状态下去模拟一个IL虚拟机,最主要的区别是它在处理每一条 IL 指令时不是立即执行,而是将该条指令翻译成 C++ 的代码(纯文本形式),并且为了支持这种1对1的机械性翻译,以及C#的动态语言的要求,它需要非常多的生成代码和一个非常强大的 il2cpp runtime 来作为这些指令翻译代码的支撑。

下面我们还是以上一节案例中提到的 GetSum 函数来作例子说明,例如有如下的 C# 代码:

public class NewBehaviourScript2 : MonoBehaviour {
    private List<float> _objects = new List<float>();

    private float GetSumSlow(IEnumerable<float> arr) {
        float sum = 0;
        foreach (var value in arr) {
        sum += value;
        }
        return sum;
    }
}

这里通过 il2cpp 会翻译成如下的 C++ 代码(因为它翻译的 C++ 代码不宜人去读,因此这里我人工精简了一下):

struct NewBehaviourScript2 : public MonoBehaviour {
   List_1* objects;
};


float NewBehaviourScript2_GetSumSlow(NewBehaviourScript2* this, RuntimeObject* arr, const RuntimeMethod* method) {
  float sum = 0.0f;

  // System.Collections.Generic.IEnumerable`1<System.Single>::GetEnumerator()
  RuntimeObject* enumerator = InterfaceFuncInvoker0<RuntimeObject*>::Invoke(0, IEnumerable_1_il2cpp_TypeInfo_var, arr);

  while (true) {
    // System.Collections.Generic.IEnumerator`1<System.Single>::get_Current()
    float cur = InterfaceFuncInvoker0<float>::Invoke(0, IEnumerable_1_il2cpp_TypeInfo_var, enumerator);

    sum += cur;

    // System.Boolean System.Collections.IEnumerator::MoveNext()
    bool has_next = InterfaceFuncInvoker0< bool >::Invoke(0, IEnumerable_1_il2cpp_TypeInfo_var, enumerator);
    if (!has_next) {
       break;
   }
  }
  return sum;
}

我们通过 C# 代码和生成的 C++ 代码对比,可以发现 il2cpp 的几个特点:

  • C# 里面的 class 的定义会被翻译成 C++ 里面的 struct。
  • C# class里面的成员变量都会转化成 C++ 结构体里面的成员。
  • C# class里面的成员方法都会转成独立的 C++ 函数,并且第一个参数为 this 指针。

这里我们看到它虽然叫自己 il2cpp ,但是其还是将 C# class 翻译成了C风格的面向对象的代码。另外我们也可以看出来 C# 里面的 foreach 有点类似语法糖的感觉,其内部实现还是通过调用 C#迭代器(IEnumerable + IEnumerator)来实现的。因为 il2cpp 并不是直接将 C# 源码 转成 C++ 源码,而是先用 mcs 编译器将 C# 源码编译成 IL 指令,然后再将 IL指令 转成为 C++ 源码的,因此我们这里直接对比 C# 和 C++ 代码中间就会出现一些跳跃,所以这里我们可以来观察一下 C# 源码编译后的 IL 指令和转换后的 C++源码,就更加一目了然了:

il2cpp 编译器在翻译的时候就是从下往下依次遍历这些 IL 指令,例如:

  • callvirt IL指令,表示调用一个虚函数,而对应翻译出来的 C++ 代码为 InterfaceFuncInvoker0::Invoke()
  • add IL指令,表示加法运行,对应翻译出来的 C++ 代码为 ilc2pp_codegen_add()

这里对照着看,就能看出来我们之前提过的:il2cpp 将 IL指令翻译成 C++ 代码是很机械的,它几乎就是1条1条的硬翻,而为了满足这种简单的翻译逻辑,il2cpp 需要提供很多生成的函数以及一个强大的运行时来作为支撑,例如这里简单的一个 add 指令,它无法将其直接翻译成加法的机器指令,而是翻译成了一个函数调用,再在调用的这个生成函数里面取做加法运算,也正是因为这种比较笨拙的方法,导致了 il2cpp 翻译出 C++ 代码,即使经过了 llvm 这种优秀的编译器优化后,依然存在一些冗余的不必要的消耗,虽然Unity在生成C++代码的时候已经引入了很多编译器代码优化的技巧来尽量使其翻译的代码更加高效,但毕竟和人直接写出来的C++代码在运行效率的质量上还是不能比的。

这里我们可以详细来分析一下它生成的C++代码,明明我们写的 C#代码 想做的事情很简单很朴素,就是遍历一个连续内存空间的集合,计算其元素的值之和。但是在经过 il2cpp 翻译成C++代码后,我们看了至少N个看似很多余的虚函数调用,在遍历整个集合的过程中,每次迭代都需要先查找2个虚函数的地址,先调用get_Current() 虚函数获取当前元素的值,然后调用 MoveNext() 虚函数迭代到下一个元素继续遍历。

我们也可以看看 il2cpp 生成的虚函数调用的代码实现:

template <typename R>
struct InterfaceFuncInvoker0
{
    typedef R (*Func)(void*, const RuntimeMethod*);

    static inline R Invoke (Il2CppMethodSlot slot, RuntimeClass* declaringInterface, RuntimeObject* obj)
    {
        const VirtualInvokeData& invokeData = il2cpp_codegen_get_interface_invoke_data(slot, obj, declaringInterface);
        return ((Func)invokeData.methodPtr)(obj, invokeData.method);
    }
};

它在调用虚函数之前,必须先调用另一个函数来找到这个虚函数的指针地址,而每次遍历的时候,这个重复的操作都需要进行,增加了很多不必要的消耗。

我们再来看 VTune 的数据:

我们可以明显从数据中看到,CPU耗时TOP前面几名的函数都是由于 il2cpp 生成的这些查找和调用虚函数的代码,因为它生成的 C# 的class 并不是 C++ 的class,编译器并没有生成 C++ 类的虚表,而是自己实现和管理了每个类的 C 风格的 vtable ,它在遍历集合时每一次迭代中都需要重复去查找这些虚函数的地址,这些函数时间的耗时甚至都超过了 for 循环里面业务逻辑的耗时,这些消耗并不是程序员写的 C# 业务代码带来的直接消耗,而是 il2cpp 生成代码带来的额外消耗。

优化

当我们分析了一整套 il2cpp 的代码生成逻辑,就明白了为什么一个简单的迭代器和for循环会带来这么多额外CPU耗时,我们无法改变 il2cpp 的代码生成逻辑,但在理解了它的内部逻辑后,我们依然可以在写C#代码的时候,带着这些对于 il2cpp 的内部实现的理解来避免一些不必要的消耗。

例如在这里案例中,我们已经知道了在使用迭代器中,最需要被优化掉的消耗,应该是那几个虚函数的调用,正常人在for循环中都不会写出这样低效的代码,如果我们需要优化这些的代码,有几个思路:

  • 不要在for循环里面调用函数,主要优化手段就是 inline
  • 如果非要调用函数,也尽量让它是一个直接的函数调用,而不是一个虚函数的间接调用(要先查询函数地址,再调用函数)。

这里我们已经知道问题的关键在于 IL 中的 callvirt 指令,我们需要把这个指令优化成一个 call 的指令,因此我们最直接的修改办法就是避免向上转型,将这个函数的参数类型从 IEnumerable 接口类型修改成了具体的实现类型:List,我们来看修改后的 IL 指令:

这里我们看到两个很重要的区别:

  • 首先在 IL指令 callvirt 指令,可以被我们优化成 call 的指令,从而避免了虚函数的调用。
  • 然后我们调用的函数是一个 inline 函数,Unity已经在帮我们做了优化,因此在for循环中又少了1次函数调用的消耗(当你的集合比较大,并且每帧又需要频繁去遍历的时候,在for循环中少1次函数调用也能明显提升这个函数的性能)。

下面我们把这两个函数同时编译进去,并用 VTune 进行对比:

这里可以明显看到几点区别:

  • 优化之前的函数在遍历的时候调用了很多其他函数,并且整体CPU耗时都基本浪费在调用的这些子函数上,函数自身耗时并不大。
  • 优化之后的函数在遍历的时候只调用了1个函数,并且整体CPU耗时明显要少了很多倍。

最后我们总结发现其实只花了相对很少的时间,对于Unity il2cpp底层的实现原理多了一点点的了解,我们就能精准的通过仅仅修改了一行代码,就让其函数性能提升了整整5倍之多,这种性价比是非常高的。

相关推荐
恬静的小魔龙32 分钟前
【SKFramework框架核心模块】3-1、事件链模块
3d·unity·编辑器·游戏引擎
神仙别闹37 分钟前
基于C语言实现的(控制台)校园导航系统
java·服务器·c语言
路在脚下@40 分钟前
Spring MVC
java·spring
J不A秃V头A1 小时前
IntelliJ IDEA 中,自动删除无用导包
java·intellij-idea
小_太_阳1 小时前
idea_卸载与安装
java·ide·intellij-idea
旧故新长1 小时前
版本控制和idea简体中文教程
java·开发语言·intellij-idea
疯一样的码农1 小时前
使用 Maven 构建一个简单的 Java 项目
java·开发语言·maven
小_太_阳1 小时前
idea_常用设置
java·ide·intellij-idea
等什么君!1 小时前
Servlet细节
java·servlet
G丶AEOM2 小时前
Redis与MySQL如何保证数据一致性
java·redis