观《Unite ShangHai 2019 高川先生 Unity内存》演讲笔记

4 篇文章 0 订阅
1 篇文章 0 订阅

在这里插入图片描述
无意中发现了干货满满的一期演讲视频,废话不多说,开始正题:
视频链接

第一节: 在这里插入图片描述

既然要讲Unity的内存详解,那么就先要从什么是内存讲起。高老师从以下三个方面剖析了内存是什么:

  • 物理内存
  • 虚拟内存
  • 内存寻址范围(一笔带过,这里就不记了)
  • 安卓内存管理

物理内存(Physical Memory):

节选了一下百度百科给出的解释:

物理内存(Physical memory)是相对于虚拟内存而言的。物理内存指通过物理内存条而获得的内存空间。内存主要作用是在计算机运行时为操作系统和各种程序提供临时储存。在应用中,自然是顾名思义,物理上,真实存在的插在主板内存槽上的内存条的容量的大小。

而讲到物理内存,就要提到CPU对于物理内存的访问速度,相对于CPU的运算速度,是一个非常缓慢的过程。而硬件生产商为了优化这一现象,就给CPU主板上加了大面积的板载的Cache(缓存),如图:
在这里插入图片描述
可以看到图中一大块的面积都用来放这个Shared L3 Cache去了。说到这里,也需要稍微讲解一下CPU读取内存的简单工作机制才方便理解这个Cache是做什么的。

简单来讲,CPU每次去内存找东西都会先分级去找缓存(缓存分了L1,L2,L3),每一级没有找到就会有一个cache miss然后继续往下一级的缓存去找(顺序:L1 -> L2 -> L3,往往缓存的大小也是按照这个顺序递增,即 L1 < L2 < L3)。一直到所有缓存都没有,CPU才会去主内存去找。

可以想象,每一次内存查找都这么麻烦的话,那么每一次的cache miss就会带来大量的性能损耗。目前Unity针对这一点给出的优化方案就是想办法减少cache miss,而这个办法就是ECS & DOTS。其根本就是将程序所使用的数据从不连续变成一个连续的排列状态(这里就不展开说了)。

另外有一点特别需要注意的就是,移动设备和台式设备的内存架构是存在差异的

  • 没有独立显卡,都是板载显卡
  • 没有独立显存,和数据内存用的是同一块内存
  • CPU板上面积更小,缓存级数更少,大小更小(例,一台主流台式机的CPU的三级CPU约 8 - 16 MB;而主流偏高端的移动端CPU的三级缓存(比如高通845),是2MB)

虚拟内存:

百度百科:

虚拟内存是计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。目前,大多数操作系统都使用了虚拟内存,如Windows家族的“虚拟内存”;Linux的“交换空间”等。

说到虚拟内存,其实主要就是聚焦在内存交换这一操作上。将相对闲置的内存空间交换到硬盘上(这里就涉及到IO操作),将内存释放出来给更活跃的正在运行的数据。

对于虚拟内存,高老师提到了以下几点:

  • 移动设备不进行内存交换,原因就是交换的所要求的IO操作代价太大以及内存卡的可擦写次数也是有差异的
  • iOS会进行内存压缩:iOS会对不活跃的数据进行一定的压缩以节约空间
  • 安卓不会对内存进行压缩

安卓内存管理

- 内存基本单位: Page (默认是4KB/Page)
- 内存管理工具: Low Memory Killer(LMK)
- 内存指标: 各种SS
安卓的应用分为以下的类别:
  1. Native: 系统内核
  2. System: 系统级应用和服务
  3. Persistent: 用户级的应用和服务(例:电话,蓝牙,WiFi等)
  4. Foreground: 前台应用;当前正在使用的Activity
  5. Perceptible: 辅助应用和服务
  6. Service: 驻后台的线程服务(例:云服务,垃圾回收等)
  7. Home: 桌面
  8. Previous: 上一个使用的应用
  9. Cached: 后台

当系统内存不够的时候,LMK就会按照上面的列表从下往上开始杀掉应用程序释放内存。LMK果真是名副其实的Killer。而LMK没杀掉一层级的应用,都会有一些特定的表现,如:

  • 当Cache层被杀掉,所有后台应用的内存都被释放掉。当你再次通过后台切换到这些应用的时候,你会发现这个应用重启了
  • Previous被杀掉与Cached层表现一样
  • Home层被杀掉会导致主页表现异常,例如壁纸不见了,应用图标正在一个一个重新被加载出来等
  • Foreground被杀掉就是常说的当前应用闪退了,崩溃了之类的表现
  • 而当LMK杀到System层,手机就重启了。杀手自己也被杀掉了
常用内存指标:
  • Resident Set Size (RSS):应用程序自己被分配到的内存和其所调取的公共服务所占用的内存之和
  • Proportional Set Size (PSS):应用程序自己的内存 + (每一个所调取公告服务/该公共服务调取程序数),即把公共服务所占用的内存平摊到每一个调取它的APP上面
  • Unique Set Size (USS):只有应用程序被分配到的内存

第二节:Unity 内存管理

在这里插入图片描述
参考文档

首先说一下Editor & Runtime 的不同。举个例子就是在Runtime的时候,我们的Assets如果不被Load,是不会被加载进内存。但是Editro里面的东西会在打开Unity的时候就全都被加载进内存。这样的好处是我们在开发过程中省去来等待加载的过程,开发起来更流畅和连贯。弊端则是如果遇到了超大型的项目,光是打开这个项目可能就要花去3天到一周的时间。

在这里插入图片描述
在这里插入图片描述
用户分配的Native内存有哪些呢?比如你导入了一个由使用C++编写的插件,Unity就无法分析到已经编译过的C++代码是如何去分配和使用内存;另外一种情况就是Lua。因为Lua是自己内部进行管理的,所以Unity无法检测到Lua对内存的使用情况。

Native Memory

在这里插入图片描述

  • Unity重载了C++里面每一个的分配内存的操作符,如alloc, new等,给这些操作符新增了一个额外的参数,memory label。这个memory label 就是我们在Profiler里面Memory Snapshot里面那些栏目的名字。Unity在底层就会根据这个memory label在分配内存的时候把这些一块块分配出去的内存放到对应的内存类型池(即,Allocator)里面,然后由这些池自己去做池子内存的跟踪。

  • Allocator 是由一个操作符NewAsRoot生成。当我们加载一个新的资源时,就会调取NewAsRoot生成一个Memory Island,然后以这个资源为Root去管理所有需要的子内存分配。例:当我们加载一个Shader时,会以这个Shader为Root生成一个Memory Island,然后这个Shader所需要的其他数据,如sub_shader,path, parameter等,就会作为这个Memory Island的一个成员,也就是这个Root底下的一个成员,去依次分配。最后,当Unity去统级一个Runtime的内存数据时,Unity统计的就是Root而不会去细究到底下的成员分别占用了多少。

  • Unity Native Memory因为是在C++层面,所以是会及时返还给os。

下图便是一个容易导致Native 内存上升的原由
在这里插入图片描述

  • Scene:由于Unity是一个C++引擎,它所有的实体都会反应到C++上,而不会反应到托管堆里面。当我们构建一个GameObject的时候,在Unity的底层会构建一个或多个Object来存储这一个GameObject的信息,比如它身上的各种Component的信息。所以当我们在一个Scene里面有过多的GameObject的时候,Native内存就会显著的上升。这也是最常见的导致Unity Native内存大量上升的原因。

  • Audio - DSP buffer: 当一个声音需要播放的时候,它需要向CPU发送指令。但是如果声音数据非常小,就会非常频繁的向CPU发送指令。会造成过多的IO消耗。因此在向CPU发送指令之前,这些声音数据都会缓冲在DSP Buffer里面,只有这个缓冲区域满了,才会向CPU发送指令。这个缓冲区也是很多人感知到安卓有声音延迟的原因,他们把DSP Buffer设置的太大了。

  • Audio - Force to mono: 简单来讲就是把大部分不需要双声道的音频给强制转换成单声道以节约内存空间。因为大部分音频其实两个声道的数据是一模一样的。

  • Audio - Format:音频的格式。主要就是不同的平台对不同的音频格式有支持。iOS对mp3格式有硬件支持等。详见Unity Manual。

  • Audio - Compression Format: 压缩格式,详见Unity Manual。

  • Code Size: iL2CPP底层编译展开代码导致cpp文件过大。一会影响内存占用,二会影响编译速度。最常见的源头是模板泛型的滥用。

在这里插入图片描述

  • AssetBundle - TypeTree:版本类型兼容数据结构。因为Unity不同版本可能有的类型会有所改变,而TypeTree则会在序列化时记录下来,反序列时保证能够找到对应版本不同的数据应该反序列化成哪种类型,从而尽可能实现了版本兼容的特性。Unity中提供一个开关的接口可以关掉TypeTree,所以当你确认游戏版本唯一时,可以关掉它以减少内存,缩小包体,build变快。

  • AssetBundle - Lz4 vs Lzma: Lz4 为目前主流压缩方式,优点是速度快Lzma10倍,而且是trunk base所以可以一次一次解压,即可以复用内存,减少内存峰值;缺点是包体大小会比Lzma大30%。Lzma是stream base,只能一次解压,速度慢,内存占用大,已经不推荐使用。

  • Size & Count: 包体大小和包体数量的均衡。因为AssetBundle是分包头(包头即包内数据共用的一些索引数据)和包内数据。如果包体过多,包内数据过少,可能导致包头比包体数据还要大。则会导致得不偿失。每个项目应该按需决定自己包体大小和包的数量。

  • Resource: 和AssetBundle包有一个包头去储存索引数据,Resource会在被打进包的时候做一个红黑树来帮助Resource去检索它所需要的资源的位置的。这个红黑树会在游戏刚刚加载就存进内存中,并且不可卸载。因此会造成一个持续的内存压力。当我们Resource文件夹过大时,这个红黑树也会跟着增大,带来更多的内存压力并且拖慢游戏的启动时间。建议:使用AssetBundle代替Resource。

  • Texture - upload buffer: 同Audio的DSP Buffer,缓存池。满了才向GPU推送一次。大小也可以在Unity中设置

  • Texture - r/w:Texture内存优化经典项目。当Unity检测到你把这个选项勾选上,会把这份Texture在显存和缓存中各备份一份以方便后续的修改。如果你的Texture加载之后再也不会有修改的操作,请把这个选项去掉,节省大量内存空间。

  • Mip Maps: 一些UI等资源,不需要的也请把这个选项去掉。

Managed Memory:

在这里插入图片描述

VM内存池(Mono 虚拟机内存池):
  • VM内存池在满足一定条件的时候,是会内存返回给OS的。这个条件是:当一个Block连续6此GC被访问到,这个block就会被返还给OS(然而,这种情况基本上看不到 😛)。

在这里插入图片描述
Unity 使用的GC回收算法是Boehm,他有以下两个特点,从而导致了内存碎片化的问题:

  • Non-Generational 非分代式:分代式指的是对内存按照block的大小,访问的频率来进行分区的操作。而非分代式则没有这些操作,全部内存block混杂在一起,优点是快。

  • Non-Compacting 非压缩式:压缩式指的是内存被回收的时候,回根据大小重新排布这些block。而Unity是非压缩式的,即不会重新排布内存。

  • 下一代GC: Incremental GC
    当前的GC回收需要主线程停下来,遍历一遍所有的Memory Island来决定那些资源可以被回收,会造成主线程卡顿。而新一代的Incremental GC把这个遍历的过程分开好几帧来完成,从而减少了CPU峰值。

Managed Memory Best Practices:

在这里插入图片描述

  • 只有显式的调用Destroy才能真正把一个东西摧毁,光是把引用设成null并不行
  • Class vs Struct:简单来说Struct因为不是引用类型,内存的占用比Class要优
  • Pool in Pool: 这里我的理解就是Pooling,将高频重用的东西放到池子里重用,而不是经常性的创建和摧毁
  • 闭包和匿名函数和协程:在底层iL把C#编译出来的代码里面,所有的闭包和匿名函数都会被new成一个Class。所以当这些匿名函数和闭包包括协程没有被释放,这些函数里面的变量(即是是local字段)也会一直holding在内存里面不会被释放。所以官方对于协程的使用建议即是使用的时候创建,不用的时候释放掉。不要把协程当线程来用。
  • Configurations:缩小配置表大小。进来把大配置表拆分开。
  • Singleton:慎用单例因为它会一直存在内存里。

Q & A:

  1. SetActive在项目里面占用了调用了很大的GC,请问SetActive里面到底为什么这么损耗性能?

SetActive实际上在背后会做很多设置,尤其是当我们在用UI的时候,他还会进行一个子UI递归的初始化操作。所以一般建议如果你项目的UI调用SetActive时有很大的消耗,可以只把UI移除屏幕外而不需要开关一次。

  1. 异步 vs 协程的使用哪个更好:

这两个其实是不冲突的两个东西。异步更多的可以应用在IO操作的时候,异步的进行可以让你的主线程不用停下来等待IO的返回;协程其实更多的是一个轮循的过程,每一个都会循环分时的被调用,而不是因为某一个自己需要等待而让别的协程先进行。这里面是一个主动等待和被等待的区别。当然他们很多时候也可以互相替代给开发者一种两者选一的感觉,其实只是看你当前需要完成的工作更看重什么。

  1. 好的我承认这道题我连他在问什么都不是很清楚,感觉这个观众想问很多导致说了一大堆没有重点。

高先生的回答总结就是Unity的mono源码是开源在Github上的,如果有需要是可以自己修改编译。然后因为Unity已经停止了对mono的支持,所以他这边不清楚以后会不会对mono有更新。


啊,写完了。以后常回来复习看看。真是干货满满的一期视频,希望下次有机会亲临Unite现场!

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Kayn_Liu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值