3.3.3 内存优化
整体上对内存占用的目标是,内存占用尽可能少,这不仅是UE在运行时的一个内存占用的考量,当UE退出或被销毁或在后台运行时,需要尽量减少系统资源的使用,以降低系统负担。这里的挑战在于实际的内存预算是有限的,尤其是存在于App中的SDK。
3.3.3.1 内存分配体系
UE的整个内存分配体系如图3.19所示[7]。通过Operator New和Operator Delete能够执行大部分基本的内存分配操作,整个UObject体系使用自己的一套内存分配方法。这些内存分配方法最终实际使用的都是底层的某种内存分配器。移动端可用的分配器选择只有几种,并不是所有的内存分配器都可以被支持,对于PC平台都是可以的。
图3.19 内存分配体系简介
3.3.3.2 内存碎片化
内存碎片化指内存中存在一些不连续的小块空闲内存。操作系统底层是以页为单位进行操作的,通常是指固定大小的内存块。一页中有很多槽,只要有一个槽被占据,这一页就不能被释放掉,如图3.20所示。
Binned和Binned2是UE的两种内存分配器。
Binned分配器是一种基于固定大小的内存块的分配器,它将内存块分成不同的大小类别,并将它们存储在不同的“桶”中。当需要分配内存时,Binned分配器会从适当的桶中选择一个内存块,并将其返回给调用者。
Binned2分配器是Binned分配器的改进版本,它使用了更高效的算法来管理内存分配。与Binned分配器不同,Binned2分配器可以动态地调整内存块的大小,并且可以在多个线程之间共享内存池。Binned2分配器的多线程设计使得它的速度要优于Binned分配器。
图3.20 内存碎片化图示
这里想测试的点在于,哪种分配器在实际的业务场景下内存的使用率更高,从两个方面进行比较,UE运行时的状态和UE退出之后的状态。
表3.7和表3.8展示了不同状态下的内存分配结果。在UE运行的时候,两种分配器的内存使用效率都是很高的,基本没有低于90%的情况。但是在引擎退出的时候,不同内存分配器的使用率都有所降低,对比之后,Binned方式更加符合需求,这里希望引擎退出的时候内存占用尽量少,所以这里选择了第一代Binned分配器。上线测试Binned分配器发现有问题,进行了修复,最终选择了修改过后的Binned算法。由于Binned2分配器是多线程的,所以表格中有两组数据。
表3.7 UE退出状态的内存分配结果
表3.8 UE运行时状态的内存分配结果
从表3.7中可以看到,UE在退出的时候依然会占据80MB左右的内存,实际占用60MB左右的内存。由于在方案采用的引擎退出策略中,有大量的反射数据并没有被析构掉,UStruct、UClass,以及一些Plugin模块的内容仍然存在,所以这部分的内存开销是相对合理的。
3.3.3.3 有限的地址空间
一般情况下,实际程序中使用的地址空间会远远大于实际物理内存的使用量,如图3.21所示[8]。比如,把一个50MB左右的二进制代码文件加载进来,它是会占用使用的地址空间的。Code段中的这个二进制代码文件中的很多代码都没有被执行,所以在物理内存中实际是没有分配50MB这么多的。
图3.21 地址空间与物理地址空间
那为什么要讲地址空间的事情呢,原因是,在测试中(iOS的环境下)遇到了虚拟地址空间不足的问题。实际问题是,分配一张渲染目标的时候,由于内存不足创建失败了,导致程序崩溃。这个时候看实际的物理内存占用只有700MB,而测试的机器都是3GB内存的机器。
对于iOS的设备来说,一般达到物理内存的上限是设备内存的一半,对于3GB的机器来说就是1.5GB,但是实际的物理内存使用量为700MB,远没有达到上限。那么首先想到的原因是上文提到的内存碎片化的问题,是不是由于碎片化过于严重导致内存分配失败呢?并不是,在一些测试场景下,UE运行一会儿程序就崩溃了,所以这里推测不大可能是内存碎片问题导致的。于是又在iOS设备上做了更多的测试,发现在没有UE的情况下,也会出现一样的问题。
翻阅iOS内核代码(xnu):
从代码中可以看到,这里实际上是有限制的。iOS进程要有4GB的PAGE_ZERO和4GB的Shared Region的占用,导致实际可用的地址空间需要减去8GB,如表3.9所示。
表3.9 地址空间大小
但是上文也分析过,UE的内存分配器的效率是非常高的,如果3.375GB已经被占满了,实际物理内存的占用率也会很高,但实际情况并不是这样的。于是又做了一些测试,发现在QQ启动的时候,虚拟地址空间已经占用3GB多了,留给UE的只剩700多MB,这也就比较好地解释了为什么UE刚刚拉起,并且在进入游戏的时候,非常容易发生这种崩溃的问题,它并不是UE本身的问题。
在iOS 14以上的版本中可以通过下面这个属性增加虚拟地址空间的使用上限[9]:
在iOS 15以上的版本中可以通过下面这个属性增加物理内存的使用上限:
3.3.3.4 在Android中分离二进制代码文件
通过分析Linkmap可以发现,游戏侧业务的代码消耗了相当一部分的内存,无论是Code段还是Data段,因此想到了修改UBT分离主体和游戏侧业务逻辑的代码。比如在QQ秀的业务上,并不需要任何游戏业务的代码内容,那这部分代码是不需要被加载进来的。如图 3.22所示,流程上在UBT中分析UE的主体代码,编译成SO,再把游戏侧业务的代码也编译成一个SO,为了避免符号表导出冗余,先收集游戏侧业务代码需要导入的符号表的内容(输出Version Script),然后再重新进行链接操作,主体SO导出的符号表就只包含游戏侧业务代码用到的内容了,保证最终导出的两个SO相比最初的SO不会增加过多冗余。
图3.22 分离SO流程
在Android端做了这部分处理,好处是可以根据需求动态地加载/卸载这部分库文件,增加了灵活性,分离的结果如图 3.23所示。
图3.23 分离SO的结果
3.3.3.5 其他优化项
还可以从其他方面着手降低内存的使用,这些方式与游戏开发的优化手段相似,这里只做部分罗列,不再展开详述。
3.3.3.6 数据结果
表3.10展示了两个版本经过一系列优化迭代后的内存数据对比分析,可以看到,内存占用相比较早期版本有了大幅优化。
表3.10 内存优化前后的数据对比(iPhone 13 Pro Max)