让Unity迭代器性能提升5倍

news2024/11/20 0:48:29

最近在研究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倍之多,这种性价比是非常高的。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/1418811.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

3338 蓝桥杯 wyz的数组IV 简单

3338 蓝桥杯 wyz的数组IV 简单 //C风格解法1&#xff0c;通过率50% #include<bits/stdc.h>int main(){std::ios::sync_with_stdio(false);std::cin.tie(nullptr);std::cout.tie(nullptr);int n; std::cin >> n;int ans 0;std::vector<int>a(n);for(auto &am…

Docker本地部署APITable结合内网穿透实现公网访问

文章目录 前言1. 部署APITable2. cpolar的安装和注册3. 配置APITable公网访问地址4. 固定APITable公网地址 前言 vika维格表作为新一代数据生产力平台&#xff0c;是一款面向 API 的智能多维表格。它将复杂的可视化数据库、电子表格、实时在线协同、低代码开发技术四合为一&am…

【C语言】socket编程接收问题

一、recv()函数接收到的返回值为0表示对端已经关闭 在TCP套接字编程中&#xff0c;通过recv()函数接收到的返回值为0通常表示对端已经关闭了套接字的发送部分。这是因为TCP是一个基于连接的协议&#xff0c;其中有定义明确的连接建立和终止流程&#xff1b;当对端调用close()或…

C语言指针进阶(1)(超详细)

前言&#xff1a; 指针其实就是地址&#xff0c;而凡是存储在内存中的值都会有属于自己的地址&#xff0c;指针指向地址&#xff0c;这样我们就能通过指针间接操作变量。我们在指针初阶中介绍了指针的基本概念&#xff1a;如指针大小、野指针问题、指针间的关系运算等&#xff…

Hugging Face创始人分享:企业如何在ChatGPT浪潮下实现战略布局

Hugging Face创始人兼首席执行官 Clem Delangue在IBM一年一度的 THINK大会中研讨了当前人工智能发展趋势&#xff0c;特别是ChatGPT模型以及其对行业的影响。他的演讲还涉及到一个关键的议题&#xff0c;在ChatGPT这样的通用模型出现后&#xff0c;企业如何在人工智能领域找到自…

自定义vue通用左侧菜单组件(未完善版本)

使用到的技术: vue3、pinia、view-ui-plus 实现的功能: 传入一个菜单数组数据,自动生成一个左侧菜单栏。菜单栏可以添加、删除、展开、重命名,拖动插入位置等。 效果预览: 代码: c-menu-wrap.vue <template><div class="main-container">&l…

【嵌入式学习】C++QT-Day3-C++基础

笔记 见我的博客&#xff1a;https://lingjun.life/wiki/EmbeddedNote/19Cpp 作业 设计一个Per类&#xff0c;类中包含私有成员:姓名、年龄、指针成员身高、体重&#xff0c;再设计一个Stu类&#xff0c;类中包含私有成员:成绩、Per类对象p1&#xff0c;设计这两个类的构造函…

当包容结构体遇见灵活的内存管理

&#x1f308;个人主页&#xff1a;小田爱学编程 &#x1f525; 系列专栏&#xff1a;c语言从基础到进阶 &#x1f3c6;&#x1f3c6;关注博主&#xff0c;随时获取更多关于c语言的优质内容&#xff01;&#x1f3c6;&#x1f3c6; &#x1f600;欢迎来到小田代码世界~ &#x…

课时7:shell基础_shell简介

1.3.1 shell简介 学习目标 这一节&#xff0c;我们从 运维、shell语言、小结 三个方面来学习。 运维 简介 运维是什么&#xff1f;所谓的运维&#xff0c;其实就是公司的内部项目当中的一个技术岗位而已&#xff0c;它主要做的是项目的维护性工作。它所涉及的内容范围非常…

方法、数组

方法 是语句的集合&#xff0c;在一起执行一个功能 它是解决一类问题的步骤的有序集合 包含于类或对象中 在程序中创建&#xff0c;在其他地方被引用 设计方法的原则&#xff1a;方法的本意是功能块&#xff0c;就是实现某一个功能的语句块的集合。设计时&#xff0c;最好保持…

基于go mod模式创建项目最佳实践

希望能带给你成功的喜悦&#xff01; 除go get、vendor这两种方式外&#xff0c;Go版本在1.11之后推出了go module模式来管理依赖&#xff0c;使用go mod时下载的依赖文件在$GOPATH/pkg/mod/下。本文以两种办法介绍如何创建go mod项目。 目录 goland开启玩法 本地手动创建项目…

###C语言程序设计-----C语言学习(6)#

前言&#xff1a;感谢老铁的浏览&#xff0c;希望老铁可以一键三连加个关注&#xff0c;您的支持和鼓励是我前进的动力&#xff0c;后续会分享更多学习编程的内容。 一. 主干知识的学习 1. while语句 除了for语句以外&#xff0c;while语句也用于实现循环&#xff0c;而且它…

shell脚本自动化备份网络设备配置教程

由于局域网内存在多台网络设备&#xff0c;如防火墙、路由器、交换机等&#xff0c;数量众多&#xff0c;且品牌不同&#xff0c;手工备份配置需要相当长的时间&#xff0c;现需要实现自动化导出备份配置。 经查询&#xff0c;该局域网内存在华为及阿尔卡特两种品牌&#xff0…

Android Studio 下载安装配置使用入门【2024年最新】

前言&#xff1a; Android Studio 是谷歌官方提供的主要集成开发环境&#xff08;IDE&#xff09;&#xff0c;专为 Android 平台应用开发而设计。它基于 JetBrains 的 IntelliJ IDEA 软件&#xff0c;并在此基础上增加了大量针对 Android 开发的定制功能。Android Studio 通过…

CMake 完整入门教程(一)

1 前言 每一次学习新东西都是很有乐趣的&#xff0c;虽然刚开始会花费时间用来学习&#xff0c;但是实践证明&#xff0c;虽然学习新东西可能会花费一些时间&#xff0c;但是它们带来的好处会远远超过这些花费的时间。学习新东西是值得的&#xff0c;也是很有乐趣的。 网络上…

C语言二维数组的使用案列,来自C语言程序设计第五版本

感谢关注我的123个小伙伴&#xff0c;我会给大家带来更多的知识&#xff0c;但是C语言的学习&#xff0c;我准备今天学了这个月暂时停止更新了&#xff0c;下个月一号砸门又见面&#xff0c;休息两天弄其它的事情&#xff0c;也好几天没有看论文了。 二维数组的创建比较简单&am…

基于Python 爬虫的房地产数据可视化分析与实现

摘要&#xff1a; 过去&#xff0c;不管是翻阅书籍&#xff0c;还是通过手机&#xff0c;电脑等从互联网上手动点击搜索信息&#xff0c;视野受限&#xff0c;信息面太过于狭窄&#xff0c;且数据量大而杂乱&#xff0c;爆炸式信息的更新速度是快速且不定时的。要想手动获取到海…

算法沉淀——前缀和(leetcode真题剖析)

算法沉淀——前缀和 01.一维前缀和02.二维前缀和03.寻找数组的中心下标04.除自身以外数组的乘积05.和为 K 的子数组06.和可被 K 整除的子数组07.连续数组08.矩阵区域和 前缀和算法是一种用于高效计算数组或序列中某个范围内元素之和的技巧。它通过预先计算数组的前缀和&#xf…

Spring5深入浅出篇:Spring中的FactoryBean对象

Spring5深入浅出篇:Spring中的FactoryBean对象 Spring工厂创建简单对象 之前我们通过Spring配置文件创建的都是简单对象,那么什么是简单对象呢?简单对象就是通过new 构造方法 创建的对象,比如:UserService,User,Person等.那么我们就需要知道什么是复杂对象 什么是复杂对象 复…

防御保护笔记02

防火墙 防火墙的主要职责在于&#xff1a;控制和防护 ---- 安全策略 --- 防火墙可以根据安全策略来抓取流量 防火墙分类 按物理特性划分 软件防火墙 硬件防火墙 按性能划分 百兆级防火墙 吞吐量&#xff1a;指对网络、设备、端口、虚电路或其他设施&#xff0c;单位时间内成…