UE 手游在 iOS 平台运行时内存占用太高?试试这样着手优化

作者:GWB-腾讯独立游戏孵化器
2020-08-25
3 4 0

原编者按

性能优化,对游戏开发来说是一个需要不断钻研的课题,性能越好,游戏才会运行的更加顺畅,玩家的体验感才会更好。腾讯游戏学院专家、游戏客户端开发 Leonn,将和大家分享 UE 手游在 iOS 平台上的内存分布和优化。(本文首发于腾讯游戏学院专家团月刊《EXP 手册》)

文 | Leonn
腾讯游戏学院专家 游戏客户端开发

对于在 iOS 平台上运行的 UE 程序,经常会出现内存占用较高,xcode 的内存统计和 UE 的统计有偏差的问题。本文将讨论下 iOS 上内存的管理机制,内存的组成,iOS 特有的一些资源管理特性,以及 UE 程序针对 iOS 常见的内存瓶颈和优化。

iOS 程序内存分配原理

1.1 iOS 系统内存分配原理

iOS 也是一个类 linux 的系统,所以基本的内存分配还是走的虚拟内存系统中 page mapping 和 swap out 那套,即虚拟内存访问缺页产生对物理内存的实际占用,以及对物理内存的唤入唤出(详细可以看前面的《Android 内存分布和优化》的文章)。iOS 上的 page size 为 16k,

Swap out 和 compressed memory

关于 swap out 机制,由于移动端内存的寿命问题,没有实现传统虚拟内存那样的 swap in/out 机制,只有一些 read-only 的数据(例如代码)在被 swap out 的时候,会被直接从物理内存移除,而不会 back up 到磁盘文件上,下次 swap in 就直接重新加载,也就是非 read only 的 page 只要被访问就永远不可能唤出物理内存。

此外 iOS 还有额外的 memory compress 机制,即将一些不常用的内存压缩存储在内存中。

所以 iOS 中有个特有的表示内存占用的说法 memory footprint 就是值得压缩前的总大小,而不是当前的实际物理内存大小。

当系统内存吃紧的时候,系统会通过 didrecievememorywarning 通知程序,让程序自愿的释放一些内存,这时候如果程序仍然不能有效降低内存,进程就会被杀掉。

VM Object

在 iOS 内核中,使用一个叫做 VM Object 的对象表示在虚拟内存空间的一块被映射的内存区域(region),一个 region 是由几个连续的 page 组成,所以一个 vm object region 的起始地址必定是某个虚拟空间上 page 的起始地址。VM Object 还记录了其他的一些信息,包括继承关系,读写权限,是否是 wired(能不能被 swap-out)。此外它还关联了一个 pager,用来做内存映射,这个 pager 是 default pager 或者 vnode pager 的一种, default pager 负责将虚地址 VA 跟物理地址 PA 做映射(即访问 va 缺页后将开辟物理内存),vnode pager 直接将文件映射到虚地址空间(这样不经过内存直接读写文件)。

VM_Copy

Vm object 的 pager 除了可以是 default pager 或者 vnode pager 之外,还可以直接映射另外一个 vm object,这时是为了做 copy-on-write 优化。这允许不同的 vm object 映射同一段 page 区域,直到其中一个 vm object 需要发生写入它才会 copy 出一个新的。在 iOS 系统下我们可以直接调用 vm_copy 代替 memcpy 来执行这种 copy-on-write 的 copy,只要你 copy 后面不写入,就一直没有实际的 copy 开销。Vm_copy 的唯一缺点是如果发生写入那么 copy 会存在延时,所以对于频繁的小内存的 copy 还是推荐直接 memcpy。

Vm_copy 和 memcpy


1.2 iOS 系统内存的分配方式

在《Android 内存分布和优化》中我们讲了使用 malloc 和 mmap 分配系统内存的原理和区别。这里我们详细讨论下他们在 iOS 上的特点。

vm_allocate

首先 iOS 上做 mmap 的函数是 vm_allocate,它同 Android 上的 mmap 用法相当,即分配一个虚存,做物理内存或者文件映射。

Malloc

直接从堆上分配内存,并且这些内存会立即从虚拟内存映射物理内存,并且不会初始化内存内容。在 iOS 上 malloc 的底层实现细节如下:

小内存:小于几个 pagesize 的内存,malloc 会从一个 pool 上分配,这个 pool 本身是由 vm_allocate 分配的虚存,这些虚存可能都是已经存在物理内存映射的了,这个池分配的粒度都是按照 16 字节对齐,所以我们用 malloc 也尽量 16 字节对齐,否则就存在了浪费。这个小内存池的预分配的大小有多大要取决于系统策略。

大内存:对于大于几个 pagesize 的内存,Malloc 自动使用 vm_allocate,它只分配虚存,不立即映射物理内存,分配粒度为 1 个 page 大小(即 16k),因为不同的 vmobject 是由不同的独立的 page 组成,这时如果 malloc 的大小没有 16k 对齐,也会产生较多的内存浪费。在这种情况,使用 malloc 和直接使用 vm_allocate 是相当的。

Calloc

calloc 同 malloc 不同的是,它在分配内存后,在使用前会保证将内存初始化为 0。他比用 malloc+memset 要优化,因为 memset 发生时会立即产生缺页造成物理内存占用,然后初始化 0,而 calloc 则是延迟的,它不会马上产生物理内存占用,而是要等得到真正这块内存被使用之前。我们应该完全使用 calloc 代替 malloc+memset。

Malloc zone

iOS 上所有的内存分配都是来自于某个 malloc zone 的,每个 zone 有独立的内存池,默认的分配都是在 default malloc zone 上的。使用 malloc zone 有个好处是减少小内存池的浪费。我们知道内存池的浪费主要有两种来源,一种是对齐浪费,即为了匹配内存池的分配粒度,没有对齐的内存产生的浪费,如 malloc 一个 17 byte 的内存其实需要 malloc 32byte,另一种则来源于对页的空白浪费,例如在频繁的分配内存后,会开辟大量的新 page,这样在后面即使先后发生了一些释放,但是因为释放不集中在一个 page 上,也导致了很多 page 上只被少量的 block 占据,导致大量的空白部分的浪费。如果我们可以知道某些内存的生命周期是相同的,那么我们可以把它们在同样的一个 zone 上分配,这样我们在确定他们的生命周期全都到期后,可以对整个 zone 执行释放的操作,这样就杜绝了这两种浪费。在 iOS 下使用 malloc zone 的相关接口是:

  • malloc_create_zone 创建一个 zone
  • malloc_zone_malloc 再某个 zone 上分配
  • malloc_destroy_zone 释放整个 zone

UE 程序在 iOS 上的内存组成清单

了解了 iOS 上的基本内存分配原理后,我们来统计我们 iOS 上的 UE 程序的内存组成。在对 UE 程序进行内存分析和优化过程中,我们要做的的第一件事就是获取一个完整的关于你程序的内存组成清单。UE 的引擎内部提供了 LLM,memreport 等内存统计工具,但是这些只是 UE 能感知到的内存,我们需要能明确整个程序的内存被花到哪里了,以及为什么程序会因内存过高而产生问题。


2.1 iOS 内存组成统计口径

Memory footprint

Android 上我们一般使用 PSS,即程序(按分摊统计共享库)分配的实际物理内存大小来定义内存开销。iOS 有所不同,iOS 上通常使用 memory footprint(下面简写为 mem foot)这一个概念来定义内存开销,mem foot 同实际占用的物理内存之间有一定差别。Mem foot 在 iOS 上的定义是进程实际占用的物理内存+进程被压缩了的内存在压缩前的大小,即 mem foot = resident + swapped (这里的 swapped 不是指 swap out 的意思,是前文说到的 iOS 的内存压缩机制)。所以从定义上看,所谓 mem foot 是指你的进程所可能触碰到的所有物理内存大小(尽管部分已经被压缩),这就是脚印的意思。

在 xcode 的 allocater 中,我们可以计算 vm tracker 中 all 中的 resident+swapped 的大小来得到 mem foot 值。如果是在代码中,则可以通过 darwin 内核的接口 task_info 获取 TASK_VM_INFO 来获取其中的 phys_footprint 来获得,darwin 源码中关于 phys_footprint 的定义是

其中 internal 即除了显存外的 resident 内存,internal_compressed 即指除了显存外的 swapped 部分,iokit_mapped 一般就是(其实是不能使用 purgeable memory 的)显存,后面的 purgeable 是指使用 purgeable memory 中属性为 nontvolatile 的。关于 purgeable memory 后文再说。

可以说 memery footprint 是 iOS 上统计内存占用的金标准。

XCode Gauge

当我们使用 xcode 运行游戏,会看到一个实时的显示内存的仪表盘,如下图

这个叫做 xcode memory gauge,它统计的又是什么内存呢,其实它严格来说统计的不是 memory footprint,它统计的是 vmtracker 里面 dirty+swapped 的值,那么什么是 dirty 内存呢,dirty 是指实际占用的物理内存(resident)中那些一定不能被 swap out 出去的内存,前面提到 iOS swap out 机制时说,iOS 上只有那些可读的文件等才能被 swap out,这些能 swap out 的内存通常危害不大,在内存吃紧的时候可以部分被系统调度出物理内存,他们一般是各种文件映射,代码库,符号文件等,所以 dirty 才是程序动态分配的需要考虑的内存,xcode gause 统计的是真正用户能够决定的内存脚印大小。他要比 mem foot 小一些,小了那些代码和文件的内存占用。


2.2 iOS 程序主要内存构成

我们以 memory footprint 为统计标准来得到 iOS 的完整内存构成,最正确方便的方法是使用 xcode 的 allocations 工具,里面有个 vm tracker,vm tracker 就是用来跟踪程序的每个 vmobject 的,即每个虚拟内存区域的分配情况。在 vm tracker 里面做一个 snapshots,就可以得到当前内存分配的一个快照。

一个典型的 vm tracker 快照截图

其中 All 是指总的分类,all 下面是各种细类,右面的 resident dirty swapped 分别指实际物理内存,不能被 swap out 出去的物理内存,以及被压缩的内存的压缩前大小,最后面的 virtual size 是虚存大小。我们把 all 中的 resident+swapped 就是总 memory foot print。

下面是占据大头的几个细类:

  • IOKIt 和 IOSurface:通常就是指我们 GPU 需要访问的内存,即显存
  • Performance tool data:是实际运行没有的,Profile 工具本身内存。
  • Mappedfile:文件映射,用于读写的文件,一般不占用很多 dirty
  • __LINKEDIT 和 __TEXT:代码段部分,即代码段内存,只读,他们一般不占 dirty
  • __DATA:代码的数据段,包括可读写的全局变量等。

Malloc_NANO/TINY/SMALL/LARGE :这就是前面提到的 iOS 的 malloc 小内存池,nano/TINY 指的就是文章最前提到的 malloc zone。虽然默认的 malloc 是在 default zone 上分配的,但是系统还是会根据不同的大小再选择不同的 zone。对于 0-256B 的 malloc,系统会使用 nano zone,nano zone 比较特殊,它专门为小内存而优化,并且预先就 vm_allocate 了一块 512M 的虚拟内存空间做这个 nano zone 的 pool,这块空间处于堆底。对于更大一些的小内存分配,则会根据情况使用到 tiny small large 这三个 zone 的 pool。所以我们推荐大家在 iOS 上对于 256b 以内的内存分配直接走 malloc,而不是 UE 的 malloc,可能会得到更多的收益。

VM_ALLOCATE:这个就是通过 vm_allocate 方法申请虚存后缺页触发的内存占用,在 UE 里面一定是大头,因为 UE 自己的 Fmalloc 在 iOS 上就是走的 vm_allocate。又因为 UE 的 fmalloc 在做 vm_allocate 的时候传递的 tag 是 255,所以在 vmtracker 中,所有体现为 memory tag 255 的 vm 就是 UE 的 fmalloc。


2.3 UE 程序内存组成清单

从 vm tracker 出发,在配合我们在《Android 内存分布和优化》中提到的 UE 的自带的 LLM 机制,我们就可以构建 UE 程序在 iOS 平台上的内存完整清单了。它至少应该被分割成以下几个大部分:

这是我们对于任何一个 UE 程序,可以得到的在 iOS 上的详细的内存分布情况,这里面有几个问题需要注意:

实际的总 mem foot 和下面各自项加起来可能是存在一定偏差的。因为 LLm 中各个从 UE Fmalloc 出来的子项的总和其实也只是个 vm_allocate 的虚存大小,它实际上占用的物理内存脚印是要小一些的,另外 LLM 里面对 metal texture 和 buffer 的内存计算也是估计的,但是一般情况不会差别过大,我们只要了解这其中存在差值即可。


2.4 显存大小的统计

Llm 统计的 metal tex 和 metal buffer 是 UE 统计的 gpu 访问的资源量,它同实际值是有偏差的,比如 UE 未考虑到使用 purgeable memory,memryless 等资源的内存减少,此外显存上还有除了 tex 和 buffer 之外的其他资源。所以如果想确定真实的 gpu 资源使用还是要看 IOKIT 的值,只是我们可以用 metal tex 和 buffer 估计下大致的比例。另外在最新版本 xcode 的截帧工具中我们也可以看到一个细节的 tex 和 buffer 的显存,如下:、


它可以显示详细的 tex 和 buffer 使用情况,但是内存值是明显偏大的,因为这里显示的是虚存值,不是物理内存,所以也只能参考。

UE 程序在 iOS 上的主要瓶颈和优化

我们从上面的清单上找到一些内存的大头。在一个大型 3D 项目中,内存较大的块一般集中在在代码段部分,GPU 访问内存,Uobject,和 Fmalloc 内存池浪费上。本章节也着重讲这几块的针对 iOS 的常用优化方法。很多平台通用的优化方法在文章《Android 内存分布和优化》中已经说到了,这里就不重复,主要将针对 iOS 平台的优化手段。


3.1 GPU 访问内存

也可以称为显存,显存的主要组成部分包括 buffer, texture 和 shader。显存的资源维护在 iOS 上就有一些特有的优化手段。

Purgeable memory

iOS 上的显存资源 MTLResource(mtlbuffer,mtltexture)使用的都是 purgeable memory。所谓 purgeable memory 是指这种内存有三种 purgeable state,分别为 volatile,none-volatile 和 empty。

Volatile:该内存资源是暂时不被使用的,系统将在内存吃紧的时候回收掉它,使用这种类型资源前要查询该资源是否已经无效了(变成 empty 状态)。

Non_volatile:该内存资源一直有用,不能被回收。

Empty:该内存资源明确不用了,需要立即释放。

重要的一点是 volatile 和 empty 状态的资源不计入程序自己的 mem footprint,它算系统的 cache 内存。

通过 purgeable state iOS 系统等于为我们提供了一层 pool 或 cache 机制,我们应该尽量利用它。事实上理想情况我们应该把大部分程序用到的可反复创建的显存资源用 purgeable state 来管理。就像一个缓存池一样,我们不用这个资源就把他标记为 volatile 的,我们想用就从池拿出来,判断它是否为 empty,被释放了就重新创建否则直接用。iOS 也开辟了大片的内存为这个 purgeable 的资源池,除非我们需要考虑重新创建的成本,否则你的显存资源都应该是在不用后做成 volatile 的。

在 UE 程序中,我们基本会用到 texure streaming pool 去做 texure 的 streaming,用 mesh streaming pool 去做 mesh 的 streaming,还有各种 rt pool 等等,事实上这些 pool 里所有的资源都应该走 volatile 的机制。这对显存总量的节省是巨大的,并且更加科学,iOS 系统会自动在内存压力下帮我释放 cache。

基于 purgeable state 的资源池管理方式

Memoryless Resource

除了 purgeable state 之外,metal 的 resource 还可以指定它的 storage mode。Storage mode 用于指定 mtl 资源的被 cpu 和 gpu 访问的途径和存储优化。对于用于做 rt 的 texture,有一个特殊的存储模式叫做 memoryless。

我们知道对于 tbdr 的设备,我们在创建一个 rt 之后,rt 的真正访问是在 gpu 的 cache 上的,除非我们显示的需要读取它才需要把整个资源从 gpu cache resolve 到 memory 上的,所以在很多情况,我们是根本不需要存在一个 memory 上的那一份 rt 的。例如你的只用作深度测试的深度图。这样的资源在 iOS 上可以声明为 memoryless 的 storage mode,这样整个 mtltexture 对象在创建后其实并不会产生一个 memory 上的内存占用,只会在 gpu 的 cache 上产生临时的对象,并且用后也不会 resolve 回内存,相当于我们节省了整个 rt 的内存开销。

在 iOS 上对于所有的不需要 resolve 的 rt(或 storeaction 设置为 don’t care)都应该设置为 memoryless。注意如果声明了 meoryless 但是实际又去读取了它则会产生 crash。

Memoryless 的资源同样不计入 mem foot。

Metal resource heap

iOS 中 xture 和 buffer 等资源通常可以直接从 mtldevice 上创建。但是能带来更多内存优化的方法是从 mtlheap 上创建。

Metal Resouce Heap 是一个抽象的用于创建 GPU 资源的 heap,它其实是维护了一个内存池。我们可以从 1 个 MTLHeap 上 subllocate 多个 texure 或者 buffer,这样做有很多好处:

首先它减轻了资源创建的时间开销,因为 heap 的后面是一个可复用的内存池。

然后因为 mtlheap 的内存可能被系统动态的压缩不常用的区域,所以基于 mtlheap 可以减少内存占用。

一个 mtlheap 上的 subllocate 资源拥有相同的 storage mode 和 cpu cache mode。另外 mtlheap 需要整体设置 purgeable state,而不能每个资源单独设置。所以实际使用中我们也要有很多的 mtlheap 组成的池,那些 storage mode 相同,purgeable state 相同的资源从一个 heap 上分配,另外 metal 文档提到单个 heap 也不能过大,因为对 heap 的压缩将影响其性能。

另外 mtlheap 上分配的资源支持 alias 机制,下面一段会讲。

Resource Alias

从 mtlheap 上创建的资源支持 alias 机制,alias 也是 iOS 上对 gpu 资源的一个独有优化机制,它是指一个被标记为 alias 的资源 A 可以被 mtlheap 重用,只要重用的资源 B 同 A 有相同的资源格式,只是内容不一致,并且逻辑上要保证 A 和 B 不会被 GPU 同时使用。

一个典型的使用场合是后处理链,在这里面要涉及很多后处理阶段,每个后处理阶段用到不同的 rt,但是这些 rt 不会被同时使用,我们就可以把这 rt 做成 alias 的,然后在后面阶段不断被重用,但是分配的内存一直都是那一个。这个过程要注意使用 fence 或 event 来保证共享这块内存的 rt 不会被同时使用到,这里我们的做法应该是从 mtlheap 创建一个 rt,然后执行第一步后处理,然后插入一个 fence,调用它的 makealiasable,然后再创建第二个 rt,执行第二步后处理…依次下去。

iOS 通过 alias 为我们在保持逻辑层有多个资源的同时,做到了一个底层的内存共享。

关于 shader

Shader 也会占用较多的显存。除了常规的减少 shader 变体之外,我们还应该利用 metal 的 shaderlibrary 的预编译,预先将 metal shader 编译成 native 的 mtllibrary,运行时从 library 中加载 shader function,而不是动态从源码编译 shader 。

首先从 mtllibrary 加载要更快,另外 mtllibrary 本身不占用物理内存,只占用虚存,只会在我们用到哪些 shader 的时候才产生内存占用,且由于 native code 本身体积也很小,占用内存少。而动态编译 shader 会将源码载入内存进行便于,源码体积大本身就会产生更大的内存脚印。


3.2 UE 的 fmalloc

对于 fmalloc 的分配,除了常规的减少内存分配次数,尽量对齐内存,减少 traray 的 resize,用 inline allocater 等栈模拟等方法之外,在 iOS 上还有一些额外可以尝试的操作。

UE 使用一个自带的内存池(Fmalloc)去进行内存的分配释放管理,预先分配整段内存,避免 malloc 产生磁盘碎片,这个过程会产生前面提到的所有内存池都会有的对齐浪费和页空白浪费。这部分浪费的内存显示在了 LLm 的 malloc unused 项目里。

其实对于 iOS 来说,iOS 底层已经实现了类似的 malloc 小内存池,所以在项目里可以实验一下在 iOS 上不采用 UE 的 fmalloc 而直接用 malloc 交给 iOS 的内存池管理,对比下内存用量的区别,对于不同的项目这个哪个更好不好说,但是可以试一下。即使最终发现使用 UE 的 fmalloc 还是更优的话,还是可以试一下对于 256B 以内的小内存直接使用 iOS 的 malloc 对比测试一下,因为 iOS 的 nano malloc 对于小内存还做了额外的优化。

Vm_copy

前面提到了 iOS 上使用 vm_copy 来明确的使用 copy on write 优化,所以我们应该在代码里大量的对于大块内存的 memcpy 换成 vm_copy,除非你发现这里的 copy 时间是个瓶颈。这种优化对于图形程序的收益是很大的,一个典型的场景,我们从文件加载模型数据,然后将其 copy 到申请的一个 mtlbuffer 上,在 copy 之后我们几乎不会对这个内存做更改,如果使用 vm_copy,这个 copy 的操作就剩下了,也减少了内存脚印。


3.3 其他

代码段内存

另外对于 iOS 程序来说,代码段本身也有可能是个大头,这部分可以被 swap out,所以当内存真正吃紧的时候危害相对没这么大,但还是可以想办法减小。包括减少代码体积,减少模板的使用,strip 掉调试符号,将一些 iOS 上不会用到的 UE 的 plugin 去掉等。

对 iOS 系统 lowmemorywarning 的响应

iOS 系统会根据不同的机型制定该机型内存告警的级别,如 xcode memory gauge 上面显示的一样,到达红色区域(如 iphonexr 到达 2.1G)就会触发内存告警,你的程序不能无视内存告警,如果在内存告警到达时不能有效的尽快减轻内存负担,系统将会很快结束该进程以回收内存。我们需要在收到 didreceivememorywarning 的时候额外释放大量任何可以释放但不会导致程序异常的内存,例如你的各种 cache,这也可以大大减少系统异常退出的几率。

iOS 内存问题排查常用工具

UE 自带的 memoryreport

这是最简单方便的估计 UE 主要资源的方法,里面集成了一些指令,可以看到常用资源如贴图,uobject 等的详细清单,内存,但是这里面的内存值都是估计的,只供参考。

UE 自带的 obj list 指令

用于列出任意 uobject 类型的实例清单,对于内存泄露和因 uobject 产生的内存优化很重要。

UE 自带的 obj refs 指令

用于列出任何 uobject 实例的引用链条,可以在我们通过 obj list 找到泄露的对象后继续追查它未被 GC 的原因。

UE 自带的 LLM 工具

在《Android 内存分析和优化》中详细讲了 UE 这个工具的实现,它可以将 UE 范畴内分配的内存按 tag 列出,对显存资源也能做出较准确的估计。

Xcode 的 allocation

这个就是平台层面的工具,但是也是最全面的,它获取整个 iOS 程序的内存情况,进行内存分布分析,泄露查找。这里面可以按照各种 tag 列出整个虚存空间的分配情况,还可以看到如下图整个程序的虚存空间每个地址上的分配情况,tag,大小,映射的物理内存大小,类似于 Android 平台上的 pmap。

还可以插入 generation,来定位在某个时间段之内增长的内存。

注意这个工具里面的几个分类表述,all heap 是指从 malloc 途径的分配,anonymous VM 是指所有不带 tag 的 vm_allocate,而 UE 的 fmalloc 因为带了 255 的 tag,所以不在 anonymous VM 分类下,而是在 all vm region 下面的 memory tag 255 下。

此外 Xcode 中的 leaker,graph capture 中的 gpu memory,以及 memorygraph 都是比较有用的排查 iOS 内存问题和做优化的工具。Memory graph 里面就列出了所有程序范畴内的 vmobject 分布及之间的关系。

总的来说,相比较与其他平台,iOS 是一个从操作系统层面就极度追求优化的一个平台,提供了大量平台特有的内存优化手段,这使得同样的程序可以在 iOS 上比其他平台都有少的多的内存占用,使及时对于大量只有 2G 内存的 iOS 设备仍然能够良好的体验游戏,而我们不能无视这些手段,需要利用好 iOS 提供给我们的武器去优化程序的内存使用。

近期点赞的会员

 分享这篇文章

GWB-腾讯独立游戏孵化器 

腾讯游戏学堂 · 游戏扶持业务致力于为创意游戏团队提供研发指导、技术支持、资金对接和发行推广等全方位服务,帮助团队打磨游戏品质,打造精品游戏! 

您可能还会对这些文章感兴趣

参与此文章的讨论

暂无关于此文章的评论。

您需要登录或者注册后才能发表评论

登录/注册