Unity游戏内存分布概览

Unity游戏内存分布概览

内存是游戏性能优化中一个非常重要的方面。尤其是移动设备,硬件设备受限,但又需要对该类机型的用户进行覆盖兼容的时候。游戏是在PC或者Mac下开发的,但是最终却是(大部分)在移动端(只考虑安卓iOS)运行的,如果在内存方面没有控制好,那么很容易会因为OOM的原因被移动端的OS杀掉进程。

但实际上,在不同的操作系统之下,内存的管理策略差距较大。针对各个平台都有比较专业的内存分析工具,但这些工具由于平台不一样,统计策略不一样,甚至是系统版本不一样都会导致统计偏差。比如,XCode的Memory Report页面和它自己在Instrument下统计的数据就不一样。

还有一些wrap的包装损耗,比如一张Texture,它的大部分Data内存会进入Native中,但仍然需要对外包装一些Class供逻辑层调用,这部分又会进入Mono堆中。又比如,System Framework的一部分Const数据会进入Clean内存,而另一部分会进入Dirty,又可能会被系统进一步压缩为Swap内存。

一、Unity视角下的内存

Unity视角下的内存分析,更多的是注重Unity本身所管理的内存(实际上不是它分配的它也管理不到),但一个Unity游戏实际上是要跑在平台上的(比如安卓或者iOS)。那么除了Unity自身分配的内存之外,还有一部分则来自于系统的共享库。再者,一个复杂的Unity游戏往往会引用很多第三方的插件,而这些插件所分配的本机内存也是Unity所无法顾及的。

1.1 Unity的内存起源
Unity实际上可以看作是一个使用C++开发的游戏引擎,它使用.net的脚本虚拟机。Unity从虚拟内存中给原生(C++)对象和虚拟机分配内存,同样,第三方插件的内存分配也都是在虚拟内存池中。

原生内存(Native Memory )是虚拟内存的一部分,它用来给所有需要的对象分配内存页面,包括Mono堆(Mono Heap)。

如上所说,Mono堆是出于虚拟机的需要而专门分配的本机内存的一部分。它包含了所有由C#分配的托管的内存类型,而这些内存的托管对象就是垃圾收集器(Garbage Collector),简称GC。

Unity内部有几个专门的分配器,它们负责管理虚拟内存的短期和长期分配需求。所有的Unity资产(Assets)都是存储在原生内存中的。但这些资产会被轻量的包装成Class,以供逻辑访问和调用。也就是说,如果我们用C#创建了一张Texture,那么它的大部分原始数据(RawData)存在于Native内存中,并且会有很小的一个Class对象进入到虚拟机中,也就是Mono堆中。

1.2 Reserved/Used
内存分页(iOS上大部分为16K/page)是内存管理的最小单位,当Unity需要申请内存的时候,都会以block的方式(若干个页)进行申请。如果某一页在若干次GC(iOS为8次)之后仍然为Empty,它就会被释放,实际的物理内存就会被还给物理内存。

但由于Unity是在虚拟内存中管理内存的,因此虚拟空间的地址并不会返还,所以它就变成“Reserved”的了。也就是说这些Reserved地址空间不能再被Unity重复分配了。

所以在一个32位的操作系统上,如果虚拟内存地址发生频繁分配和释放就会导致地址空间耗尽,从而被系统杀掉。

1.3 GC与内存碎片
Mono堆申请的物理内存是连续的,并且Mono堆在向操作系统申请扩展内存的时候,非常耗时。所以大部分情况下,Mono堆都会尽量保持自己已经申请到的物理内存,以防止之后要用。所以除了虚拟空间地址之外,Mono堆申请的内存也存在Reserved概念。

由于内存的分配单位是页,也就是说如果一个页只存储了一个int值,那么该页仍然会被表示为Used,它们的物理内存不会被释放。当然如果某个内存大于一页,就会申请多个连续页。

如果某个时刻,堆内存被GC了,那么这部分的物理地址就会被空出来。

当下一次需要申请堆内存的时候,Mono堆会首先检查当前堆内的空间内是否存在连续的空间能容纳这次内存申请,如果不够就会进行一次GC,也就是我们最讨厌那个GC操作。之后,如果还是找不到这样的block,Mono堆就会执行内存扩展操作,向操作系统要更多的内存。

而这些空出来,却又不能被重复利用的内存就会成为内存碎片。它们既不能被利用,又不会被销毁。

比如上图,Mono Reserved/Used的关系:
Reserved size:256KB + 256KB + 128KB = 640KB
Used:88 562B

1.4 Profiler Simple视图
使用Unity的Profiler进行内存分析的时候,在Simple模式下,可以看到类似如下截图:

这里展示的是Unity自己所管理的虚拟内存。

这就很明显了,第一行展示的是Used内存,第二行展示的Reserved。

  • Total:
  • Unity:所有Unity申请和管理的内存减掉<Profiler>、<FMOD>和<Video>。也就是说包含Mono Heap。
  • Mono:托管堆内存。
  • GfxDriver:
    GPU显存开销,主要由Texture、Vertex buffer以及index buffer组成。
    但不包括Render Targets。
    (也不包含其他平台的驱动层)
  • FMOD:
    由FMOD申请的内存。
  • Video:
    视频文件播放所需的内存。
  • Profiler:分析器自身开销。

这里的Total Reserved也还不是游戏虚拟内存的精确值,因为:

  • 它不包括游戏的二进制可执行文件,已加载的libraries和frameworks的大小。
  • GfxDriver值不包括渲染目标和由驱动层分配的各种缓冲区。
  • 分析器只看到由Unity代码完成的分配,看不到第三方native插件和操作系统的分配。

1.5 Profiler Detailed 视图
Detailed视图样例如下:

它展示了虚拟内存的详细分配情况。

  • Assets — 当前从scenes、Resources和Asset Bundles加载的总资源。
  • Built-in Resources — Unity Editor资源或者Unity default资源。
  • Not Saved — 被标记为DontSave的GameObjects。
  • Scene Memory — GameObject和它附属的Components。
  • Other — 其他不在上面几条分类中的。

大部分时候,内存中的热点问题都可以在Assets中找到。比如通过引用次数找出纹理和资源的泄漏(一般泄漏的资源没有引用次数)。

这里我要关注一下Other目录下的Objects项。

实际上这里值是由一些BUG导致的。这一项表示各种从Object继承的对象,包括纹理,Mesh等等。它们在某个时刻和实际上的对象断开了链接,可以忽略。

  • System.ExecutableAndDlls:这是Unity的猜测值。
    它尝试通过汇总文件大小来猜测已加载的二进制代码消耗的内存。
  • ShaderLab:这些是与编译着色器有关的分配。
    着色器本身具有自己的object root,并在Shaders下列出。

1.6 Unity视角的局限
Unity的内存分析远不止自带的Profiler这一项。我们常用的还有:

  • MemoryProfiler

  • MemoryProfiler Extension

但它们都有一个同样的问题,就是依赖Unity自身提供的Profiler API。换句话说,尽管各个工具在数据展示和操作方式上有不同,但它们测量的结果没什么不同。

也就是说Unity视角下的工具都只看到由Unity代码完成的分配,看不到第三方native插件和操作系统的分配。

但一个完整的Unity项目最终是要跑在平台上的,那么它就会和平台的内存分析工具统计的结果有较大的误差。另外也很难确切掌握Unity项目真正的内存分布和开销。

这一篇,我们介绍Unity游戏在iOS平台下的内存情况。

二、XCode视角下的内存

当Unity自身的工具无法满足内存分布的全景统计时,我们转头看向拥有最好调试能力的XCode工具。一般我们需要将Unity项目导出成XCode工程,然后使用XCode以及它的Instrument进行Profiler。

2.1 iOS视角下的内存管理
还是从起源讲起。iOS视角下的内存和Unity视角下的已经完全不一样了。无论是概念,管理或者类别上。

作为操作系统,iOS关注的层面不会再像Unity那么细致(实际上它也做不到),它更多的是关心操作系统层级的内存管理,以及对沙盒APP的各种底层的内存操作记录,但这也只能从堆栈上反映。实际上,它记录的是APP跟操作系统申请内存的记录。

Unity的项目作为一个APP运行在iOS平台上,那么它只会被iOS系统当做一个是普通的APP进行记录和分析。而事实上,Unity相比于原生的APP而言,还有天然的劣势。相比于iOS原生APP和控件而言,Unity申请内存的用途它是完全不知道的。或者说操作系统根本就不会关注APP申请完的内存怎么用。

这就好比家长(iOS)给孩子(Unity)零用钱,家长会记录你前天跟我要了100块说要买试卷(堆栈记录),昨天又要了50块交班费,今天又要了100块去和同学吃饭。当孩子要的数量太多,超过了家长的忍受限度之后,就会被终结(你这个月的零花钱没有了!)。

而孩子的视角则不一样,我前天要了100块钱,20块钱买了语文,20块钱买了数学,20块钱买了英语,来回坐车花了8块,还剩32,算了,不还回去了,万一过几天物理或者化学还要买呢?

2.2 iOS使用的内存类型
和Unity只关注虚拟内存不一样,OS需要关注物理内存Physical Memory(RAM)。尤其是移动平台,更是要将有限的内存使用到极致才可以。所以不少PC平台使用的内存策略移动平台并不能用,比如交换空间(iOS只能对Clean类型的内存做Paging )。

接下来就列举一下iOS系统所使用的内存形态:

Physical Memory:iOS设备上的物理芯片内存。也就是我们常说的机器内存。移动设备上,物理内存的实际用量要扣除操作系统自身的占用量。iOS内存崩溃阈值 这篇文章里记录了iOS各种设备上APP所能使用的物理内存量。

Virtual Memory(VM):虚拟内存,也是OS给每一个APP分配的虚拟地址空间,这和Unity的虚拟空间地址比较类似。它由内存管理单元MMU( memory management unit),进行管理,并和实际的物理内存进行映射。前面也说过了,内存是按页分配的,早期的iOS处理器使用的是4K/页(也个别代是用64K的),A9之后统一使用16K/页。虚拟内存一般由代码段、动态库、GPU驱动,Malloc堆和一些其他的部分所组成。

GPU Driver Memory:iOS系统使用所谓的统一架构,即GPU和CPU共享某一部分内存,比如纹理和网格数据,这些是由驱动器进行分配的。另外还有一些是GPU的驱动层以及GPU独享的内存类型(Video memory )。

Malloc Heap:APP实际申请内存的地方。Unity的内存申请行为都会发生在这里,通malloc和calloc函数进行内存申请。苹果没有公开可使用的最大虚拟堆的地址,理论上来说它只受指针大小的限制(32位或者64位),但实际的经验来看,远低于理论值,并且没有规律。实际经验总结如下:

和Unity自身的虚拟地址一样,应用程序最好不要频繁的申请和释放内存。

Resident Memory:驻留内存,这是游戏或者App实际所占用的物理内存。一般来说,当应用向系统申请内存的时候,虚拟内存是直接增长的。但如果申请完的内存并没有向里面写入数据,它并不会产生实际的物理内存分配。所以虚拟内存是>=驻留内存的。

Clean Memory:Clean内存是驻留内存的一部分。但这部分内存的类型是只读的。常见的Clean内存包括System frameworks的常量部分、应用程序的二进制文件、内存映射文件等。由于是只读的特性,因此它可以在应用程序内存不足的时候被Page Out。

Dirty Memory:与Clean相对的就是Dirty内存。这部分是指无法被OS换页操作的。

Swapped Compressed Memory:Swapped Compressed实际上属于Dirty内存。当应用内存不足的时候,OS会将脏内存中使用频次较少的内存进行压缩存放。等需要使用的时候再重新解压出来。这些算法和策略目前是没有被公开出来的,但从经验来看,OS会比较积极的来做压缩以减少脏内存的总量。注意,这里的压缩交换并不是传统操作系统上的磁盘空间交换。

上图展示的就是压缩的过程。压缩和解压都是由CPU损耗的。

2.3 FootPrint
Footprint是苹果推荐的内存度量及优化的指标。当Memory Footprint的值达到Limit line时,就会触发内存警告,并进一步导致OOM。

Footprint主要是由Dirty和Compressed组成。或者说Resident是由Footprint和Clean组成的。Footprint没有统一标准,它会因为设备、操作系统、甚至是当前运行环境不同而不同。

字节现有的测试工具GamePref在测试iOS的时候,抓取的就是Footprint内存。详细数据可以参考GamePerf 使用说明文档 。

2.4 Xcode memory gauge
这是XCode调试时最简单的界面。切换至Debug页签下就能看到。

绿色表示内存良好,黄色是危险区域,如果不及时处理马上就会被OS杀掉。

仪表的最大内存指的是设备的物理内存,但实际上,它没有在正式的场合说明指针内存指的是什么内存。但从测试的结果上来说,它总是比用VM Tracker工具测出的Dirty Memory + Swapped Memory要大10-15MB。

但实际上,这个数值并不是OS杀掉APP的唯一判断标准。一般在杀掉一个APP的时候会经过以下的步骤:

  • 尝试移除Clean内存页。
  • 如果某个APP占用了太多的Dirty内存,OS发送一个内存警告过去,让它释放资源。
  • 几次警告之后,如果Dirty内存仍然占用很高就会被Kill掉。

由于iOS杀APP的策略也是不透明的,所以如果要防止APP被系统终结,唯一的办法就是尽量降低Dirty内存。

2.5 VM Tracker
VM Tracker是XCode Instruments工具组里的一个。它提供比较详细的虚拟内存的分布情况,也是为一个提供Dirty Memory信息的工具。但遗憾的是它没有展示内存分配的目的和时间。

一张典型的VM Tracker的Profiler快照如下:

表头分别是:
Type — 内存类型
Resident Size — Resident Memory内存
Dirty Size — Dirty Memory内存
Swapped Size — Compressed Swapped Memory内存
Virtual Size — Virtual Memory内存
Res. % — Resident Memory和Virtual Memory的占比

接下来就是一些实际的类型在各个层面的具体数值了。就不一一介绍类型了,挑几个重点解释一下。

*All*— 所有的分配
*Dirty*— Dirty Memory
IOKit — graphics driver memory,比如:render targets, textures, meshes, compiled shaders等
VM_ALLOCATE — 主要是Mono Heap。如果此项值过大,可以用Unity Profiler来查看具体分配
MALLOC_* — 主要是Unity native或者是third-party plugins分配的内存
__TEXT — 只读的可执行代码段和静态数据
__DATA — 可写的可执行code/data
__LINKEDIT — 各种链接库的实际元数据,比如:符号、string、重分配的表格等

可以通过比对虚拟内存和脏内存等各个组的信息,可以做一些内存根源的分析。比如上面这个快照,如果分析可以得出什么结论呢?

2.5.1 Regions Map
Regions Map是VM Tracker的另外一个视角,主要提供了对内存分页的展示和虚拟地址空间的结构。

比如上图就可以看出,Mono Heap块的内存并不是连续的。

2.6 Allocations
Allocations工具显示应用程序地址空间中的所有分配,这些分配是由所有线程(包括Unity本机代码,垃圾收集器,IL2CPP虚拟机,第三方插件等)在所有线程上完成的。

相比于VM Tracker而言,Allocations的优势就在于它能查看任何时间段内的内存分配情况。通过堆栈还可以看到是由哪段代码完成的分配。

但它仍然是有缺陷的,它只能查看分配情况,而无法查看驻留情况。

在CallTrees的视角下我们可以看到堆栈情况。

比如上面这个截图就可以看出,代码创建了一个Pooler类的实例,该实例克隆了一些Prefab,从而导致了配内存。

在Summary下则可以看到虚拟机内存分配的详细列表。

当选中一个点开之后,能看到更详细的分配情况。比如下面这个内存分配就是Json的解析所导致的。

2.7 Memory Debugger
除了工具分析之外,Xcode还提供了Memory Debugger功能。它需要在工程设置里开Malloc Stack。

之后点击Debug页签下的这个标识,就能抓取内存帧进行分析了。

这里可以查看每个字节的分配情况。但它又太过于细节了。

2.8 vmmap
通过对Memory快照进行导出操作,我们还可以使用命令行工具对内存进行更细致的分析。

导出的memgraph文件通过vmmap的命令行可以物理内存的实际分配情况。

当然vmmap工具还有更多的命令行可以支持查看更多的细节和内容,感兴趣可以查看嘉栋大佬写的《写给Unity开发者的iOS内存调试指南》的后半部分。

如果你想分析内存泄漏的情况,也可以使用leaks App.memgraph命令。比如下面这个循环引用:

2.9 XCode视角的局限
XCode视角一样有它自己的局限性。Unity关注或者管理的重点是虚拟内存。而XCode从OS的视角关注的更多的物理内存。

Unity是无法统计系统库和三方插件,XCode可以统计,但是很难区分。因为对于OS而言,这些内存都是APP跟我要的,都是Malloc Heap申请,所以都统归在一个类别中。如果真的要进行库区分,那么我们需要人工的将所有的函数分配收集起来,然后人工分类出哪些是Unity的,哪些是APlugin的,哪些是Lua分配的,等等。

再一个是XCode的分析工具确实很多,但这些工具统计各自统计的维度相加还是有彼此不相同的问题。也就是说即使是XCode自己的工具,它也没有把标准完全统一起来。

最后,XCode的工具链所统计的内容过于底层和细节,我们可以使用它很快速的定位到内存异常,但却很难将所有的内存进行快捷分类。

三、Android视角下的内存

iOS操作系统是基于Unix的,Android操作系统是基于Linux的,而Linux又是基于Unix的,所以安卓系统在内核上和iOS非常相似。

所以,安卓的内存管理策略和iOS也非常相似。但不同的是iOS系统是封闭的,并且每一代产品的硬件是已知的,甚至是可以枚举的。而安卓系统由于开源,硬件五花八门,非常难以控制,但开源的系统也让它有更多的可能性。

3.1 安卓视角下的内存管理
虽然内存策略相似,但在名词和实际管理过程中还是略有差异。比如安卓就将内存分为三个类型:

RAM:也就是我们常说的内存,但其大小通常有限。高端设备通常具有较大的RAM容量。
zRAM:是用于交换空间的RAM分区。当内存不足的时候,OS会将RAM中一部分数据进行压缩,然后存至zRAM。设备制造商可以设置zRAM大小上限。
Storage:通常说的存储器。平时的APP,照片,缓存文件啥的都在这里。

但与iOS的Footprint不同的是,安卓的内存是另外一种叫法。
VSS - Virtual Set Size虚拟耗用内存(包含共享库占用的内存)
RSS - Resident Set Size实际使用物理内存(包含共享库占用的内存)
PSS - Proportional Set Size实际使用的物理内存(比例分配共享库占用的内存)
USS - Unique Set Size进程独自占用的物理内存(不包含共享库占用的内存)

一般来说内存占用大小有如下规律:VSS >= RSS >= PSS >= USS。

目前Unity的游戏在安卓上的指标默认都在使用PSS。这是什么意思呢?

比如我们有一段内存页如下:

其中有一个位置共享服务,Google Play和某个游戏应用都在使用。这就很难界定到底是哪个APP用的更多,如果我们把位置共享服务的所有内存都分别加给两个应用,那么计算视角就是RSS。这确实是比较准确的物理内存使用,但这样一来位置共享服务就计算了两次,三个应用那就是三次。显然是不对的。

于是,干脆一点,大家平分这个共享服务内存。那么这个计算视角就是PSS,虽然不完全合理,但是是目前最平衡的方案了。

3.2 LMK (Low Memory Killer)
iOS中,Footprint到达临界值就会被OS杀掉了,安卓也是一样。不过相比于iOS来说,安卓的LMK进程更加的透明。

LMK使用一个名为oom_adj_score的“内存不足”分来确定正在运行的进程的优先级,并以此决定要终止的进程。最高分的进程最先被终止。后台应用最先被终止,系统进程最后被终止。下表列出了从高到低的LMK评分类别。评分最高的类别,即第一行中的项目将最先被终止。

以下是上表中各种类别的说明:
后台应用:之前运行过且当前不处于活动状态的应用。LMK将首先从具有最高oom_adj_score的应用开始终止后台应用。
上一个应用:最近用过的后台应用。上一个应用比后台应用具有更高的优先级(得分更低),因为相比某个后台应用,用户更有可能切换到上一个应用。
主屏幕应用:启动器应用。终止该应用会使壁纸消失。
服务:由应用启动的服务,可能包括同步或上传到云端。
可觉察的应用:用户可通过某种方式察觉到的非前台应用,例如运行一个显示小界面的搜索进程或听音乐。
前台应用:当前正在使用的应用。终止前台应用看起来就像是应用崩溃了,可能会向用户提示设备出了问题。
持久性(服务):这些是设备的核心服务,例如电话和WLAN。
系统:系统进程。这些进程被终止后,手机可能会重新启动。
原生:系统使用的极低级别的进程(例如:kswapd)。

设备制造商可以更改LMK的行为。

3.3 Android Profiler
由于安卓平台构建Unity包的便捷性,以及安卓本身的Studio对内存和性能分析工具的不足,导致很多时候我们都选择在安卓连接Unity Profiler进行内存调试。

但实际上,安卓现在也有不少工具是可以剖析性能了。比如:
https://developer.android.com/studio/profile

由于大多数情况下还是在使用XCode进行分析,所以这部分目前没有实践。未来会找时间深入调研工具的用法和技巧。

另外,谷歌大会上,安卓的开发人员推荐使用最新的性能分析工具Perfetto 。
https://perfetto.dev/docs/quickstart/android-tracing

就目前而言,安卓的工具和XCode一样,无法鉴别应用程序中的内存分配是由Unity完成的还是由Plugin完成的。所以他们给的建议也是隔离测量。

四、 预期的选择方向

鉴于以上的调研结果,思考了两个解决方式。

4.1 逐个击破
从Unity的视角出发。既然Unity无法统计三方插件的消耗,那我们就用“差异法”来逐个击破每个用到的三方插件。

比如我们基于一个mini工程,首先测量出该mini工程当前各内存指标值。然后接入三方插件,使用用例再次测试相同指标。得出的差异值就约等于该插件的内存消耗。

当我们将所有的插件的指标都按照该方式测量之后,再加上Unity自身的Reserved内存就可以看做是当前的内存分布情况。

该方法自然有其弊端:

差异值会受到不同设备当前环境的共享库影响,可能造成Plugin在在不同机器差异较大。

方法并非白盒,无法确定实际会受到哪些客观条件或者内部逻辑的影响。

由于移动平台的脏内存策略,实际上只能测量出虚拟内存的差异值。详情看如下测试结论:不同内存分配方式对实际内存的影响。

很难做到控制变量。所以需要针对插件写覆盖面足够全的测试用例。

4.2 海底捞月
另外一招就是海底捞月。从最底层的Malloc出发,写Hook函数监听内存申请,最后汇总分析。比如这篇文章:《mallochook内存分配回调(glibc-3-内存)》

这实际上和移动平台的内存工具模式相同。只不过用自定义的方式我们可以更加定制化工具的显示规则。虽然方案可行,但它存在和移动平台工具相同的问题,统计出的堆栈应该如何进行归类。怎么判定哪个函数归属于引擎,哪个归属于三方插件,以及系统共享库或者Framework呢?

补充:
对安卓内存的补充:
《Unity如何统计安卓PSS内存?》
《[教程汇总+持续更新]Unity从入门到入坟——收藏这一篇就够了。》

5. 参考
[1] Fixing Performance Problems - 2019.3 - Unity Learn
[2] Understanding iOS Memory
[3] 写给Unity开发者的iOS内存调试指南
[4] Finding iOS memory | Rambling Llamas
[5] Unity - Manual: Understanding the managed heap
[6] Memory Management in Unity - Unity Learn
[7] iOS Memory Deep Dive - WWDC 2018 - Videos - Apple Developer
[8] Delivering Optimized Metal Apps and Games - WWDC 2019 - Videos - Apple Developer
[9] Gain insights into your Metal app with Xcode 12 - WWDC 2020 - Videos - Apple Developer
[10] 安卓进程间的内存分配
[11] Memory allocation among processes
[12] Understanding Android memory usage
[13] Android memory and games

这是侑虎科技第1100篇文章,感谢作者放牛的星星供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)

作者主页:https://www.zhihu.com/people/niuxingxing

再次感谢放牛的星星的分享,如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。(QQ群:793972859)