让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倍之多,这种性价比是非常高的。

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

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

相关文章

Linux实验记录:使用Apache服务部署静态网站

前言&#xff1a; 本文是一篇关于Linux系统初学者的实验记录。 参考书籍&#xff1a;《Linux就该这么学》 实验环境&#xff1a; VmwareWorkStation 17——虚拟机软件 RedHatEnterpriseLinux[RHEL]8——红帽操作系统 目录 前言&#xff1a; 备注&#xff1a; 正文&…

【C语言进阶篇】assert宏 使用详解

文章目录 一、assert简介 二、assert使用方法和规则 2.1 头文件 2.2 原型 2.3 功能 2.4 示例 2.5 assert的打开与关闭 三、注意事项 3.1 运行效率问题 3.2 assert只适用于调试版本 3.3 资源释放与清理 3.4 过度依赖 四、总结 个人主页&#xff1a; 倔强的石头的…

氢气传感器报警值:守护实验室安全的隐形卫士

随着科技的发展&#xff0c;我们的生活变得越来越便捷&#xff0c;但是与此同时&#xff0c;安全问题也日益凸显。其中&#xff0c;氢气作为一种清洁能源&#xff0c;被广泛应用于各个领域&#xff0c;但是如果不加以控制&#xff0c;氢气泄漏也可能带来严重的安全隐患。因此&a…

柔性电流探头方向判断有哪些方法?干货分享!

柔性电流探头方向判断的方法干货分享&#xff01;从理论到实践&#xff0c;助您成为专业人士&#xff01;干货收藏&#xff0c;快看起来吧&#xff01;      柔性电流探头方向判断一直是电力行业测试中的关键问题之一&#xff0c;确切地判断电流方向对于测试电力系统的稳定…

Docker部署xxl-job调度器并结合SpringBoot测试

文章目录 一、Docker部署1. 创建数据库2. 启动容器3. 访问4. 新建执行器 二、SpringBoot整合1. 模块注册到执行器2. 创建配置类3. 启动测试 三、任务发布-普通任务1. 编写任务代码2. 创建任务3. 启动任务 四、任务发布-分片任务1. 编写任务代码2. 启动多个实例3. 创建任务4. 启…

CTF特训(二):青少年CTF-MISC部分WP

FLAG&#xff1a;当觉得自己很菜的时候&#xff0c;就静下心来学习 专研方向:MISC&#xff0c;CTF 每日emo&#xff1a;听一千遍反方向的钟&#xff0c;我们能回到过去吗&#xff1f; CTF特训(二)&#xff1a;青少年CTF-MISC部分WP&#xff1a; 文章目录 CTF特训(二)&#xff1…

IDEA开发使用 thymeleaf 模板$表达式报红波浪线解决方案

系列文章目录 文章目录 系列文章目录后端存值前端取值thymeleaf 后端存值 RequestMapping("/testModelAndView")//使用ModelAndView时返回的方法类型必须是ModelAndViewpublic ModelAndView testModelAndView() {//创建ModelAndView对象ModelAndView mav new Model…

QT播放gstreamer命令(三)---使用QMediaPlayer

前文&#xff1a; 因为之前听说过&#xff0c;QMediaPlayer已经集成了gstreamer&#xff0c;但是并没有什么接口来例子来说明&#xff0c;根本看不出来有任何gstreamer的形式&#xff0c;于是在QT5助手里面搜了一下&#xff0c;发现确实有gstreamer的痕迹&#xff0c;但是例子写…

谷歌浏览器网站打不开,显示叹号

问题&#xff1a; 您与此网站之间建立的连接不安全请勿在此网站上输入任何敏感信息&#xff08;例如密码或信用卡信息&#xff09;&#xff0c;因为攻击者可能会盗取这些信息。 了解详情 解决方式&#xff1a; 网上有很多原因&#xff0c;亲测为DNS问题&#xff0c;设置&…

Qt之窗口位置

Qt提供了很多关于获取窗体位置及显示区域大小的函数&#xff0c;如x&#xff08;&#xff09;&#xff0c;y()和pos()&#xff0c;rect()&#xff0c;size()&#xff0c;geometry()等&#xff0c;统称为"位置相关函数"或"位置函数"。几种主要位置函数及其之…

第0章 Linux 基础入门

第0章 Linux 基础入门 RHCSA Red Hat Certified System Administrator 红帽认证系统管理员。 什么是计算机 计算机的组成&#xff1a; 控制器 运算器 存储器 输出设备 输入设备 计算机只能识别0和1&#xff0c;也就是二进制数。 为什么要学习Linux Linux 因其高效率…

Python||五城P.M.2.5数据分析与可视化_使用华夫图分析各个城市的情况(上)

目录 五城P.M.2.5数据分析与可视化——北京市、上海市、广州市、沈阳市、成都市&#xff0c;使用华夫图分析各个城市的情况 1.北京市的空气质量 2.广州市的空气质量 【上海市和成都市空气质量情况详见下期】 五城P.M.2.5数据分析与可视化——北京市、上海市、广州市、沈阳市、成…

重发布

一&#xff1a;作用 在两种路由协议之间&#xff0c;或者一个协议的不同进程之间&#xff0c;借助ASBR &#xff08;同时工作在两种协议或 者协 议的不同进程中&#xff09;学习到两个网络的路由信息&#xff0c;并且通过重发布进行路由共享&#xff0c;最终实现全网可 达。…

大模型实践笔记(1)——GLM-6B实践

目录 在Ubuntu上的配置Git Large File Storage 安装Git LFS&#xff1a; 设置Git LFS&#xff1a; 使用Git LFS&#xff1a; 安装GLM-6B 环境依赖 ChatGLM2-6B介绍 配置GLM 下载代码 构建环境 安装依赖 本地部署 网页UI 很多模型在hugging face上面&#xff0c;…

知识库系统为什么优秀企业都必备?这篇文章告诉你

在今天的商业世界中&#xff0c;知识就是力量。企业里的每一个小小的灵感、想法、经验&#xff0c;都可能是推动业务增长的源泉。那么&#xff0c;如何系统地管理和使用这些宝贵的知识资源呢&#xff1f;答案就是——知识库系统。 那么&#xff0c;什么是知识库系统呢&#xf…

六、Nacos源码系列:Nacos健康检查

目录 一、简介 二、健康检查流程 2.1、健康检查 2.2、客户端释放连接事件 2.3、客户端断开连接事件 2.4、小结 2.5、总结图 三、服务剔除 一、简介 Nacos作为注册中心不止提供了服务注册和服务发现的功能&#xff0c;还提供了服务可用性检测的功能&#xff0c;在Nacos…

【面试深度解析】快手后端一面:G1、IOC、AOP、并发、JVM生产问题定位、可重复读、ThreadLocal

欢迎关注公众号&#xff08;通过文章导读关注&#xff1a;【11来了】&#xff09;&#xff0c;及时收到 AI 前沿项目工具及新技术的推送&#xff01; 在我后台回复 「资料」 可领取编程高频电子书&#xff01; 在我后台回复「面试」可领取硬核面试笔记&#xff01; 文章导读地址…

代码随想录算法训练营第38天 | 动态规划理论基础 509.斐波那契数 70.爬楼梯 746.使用最小花费爬楼梯

动态规划理论基础 动态规划适用于解决有重叠子问题的问题。所以动态规划中的每一个状态一定是由上一个状态推导来的&#xff0c;这一点区分于贪心&#xff0c;因为贪心每一步总是取局部最优。 解题步骤&#xff1a; 确定dp数组的含义确定递推表达式dp数组如何初始化确定遍历顺…

图像处理之《可逆重缩放网络及其扩展》论文精读

一、文章摘要 图像重缩放是一种常用的双向操作&#xff0c;它首先将高分辨率图像缩小以适应各种显示器或存储和带宽友好&#xff0c;然后将相应的低分辨率图像放大以恢复原始分辨率或放大图像中的细节。然而&#xff0c;非单射下采样映射丢弃了高频内容&#xff0c;导致逆恢复…

LVGL部件6

一.圆弧部件 1.知识概览 2.函数接口 1.lv_obj_clear_flag 在 LVGL&#xff08;LittlevGL&#xff09;中&#xff0c;lv_obj_clear_flag 函数用于清除对象的特定标志位。该函数的原型如下&#xff1a; void lv_obj_clear_flag(lv_obj_t * obj, lv_obj_flag_t flag);obj 是指…