写给Unity开发者的iOS内存调试指南
0x00 前言
工作的过程中,常常会发现有小伙伴对Unity的Profiler提供的内存数据与某些原生平台Profiler工具,例如iOS系统和Xcode,所提供的内存数据有差异而感到好奇。而且大家对如何解读原生平台工具的数据更加感兴趣,同样例如iOS系统和Xcode。最近正好看了一个来自Unite Copenhagen题为 Developing and optimizing a procedural game | The Elder Scrolls Blades - Unite Copenhagen 的演讲,其中就涉及到了一些关于iOS内存的话题(虽然并不是很详细)。正好也结合工作中的一些经验,写一篇文章来讨论一下一个Unity开发者如何处理和iOS内存有关的问题。主要内容包括解析iOS系统的内存管理,使用Instrument查看Unity游戏的内存状况,使用命令行工具深入挖掘Unity游戏的内存问题以及文末小彩蛋。
0x01 iOS的内存管理 - Unity Profiler统计错了吗?
首先,我想强调的一点是,Profiler工具所提供的内存数据只是一个(组)数字,而且不同的工具存在有不同统计内存的策略。因此,一个重要的问题是我们看到的数据究竟是如何获取的?
而根据所使用的工具不同,该工具用于查找数据的策略以及开发人员实际要查找的内容,最后的结果也有可能是不一样的。因此,如果要寻找一个数字来汇总某个应用或者游戏的所有内存信息,那么可能是把问题想简单了,或者说忽略了系统的复杂性。例如,不同版本的iOS其对内存开销的统计都是有区别的——在iOS12上运行的metal app的内存在 Xcode memory gauge的统计是高于iOS11的,这同样是由于苹果改变了对内存的统计策略,很多之前没有被统计的内存如今也被计算到了内存开销中。而同样都是iOS,Xcode memory gauge的统计和Instrument中的统计也有可能不完全一致,而早期Instrument的Allocation则主要用来统计heap内存,只能说根据各自工具的统计规则,大家都是正确的。因此,把时间浪费在对比不同工具的数据上还不如以一个工具作为标尺来衡量内存开销或者是判断内存的优化是否有效。
The accounting for purgeable, nonvolatile memory changed beginning in iOS 12 and tvOS 12. In iOS 11 and tvOS 11, allocations with this memory storage mode—commonly used by Metal apps to store buffers, textures, and state objects—weren’t counted toward an app’s memory limit and weren’t presented in tools like Xcode memory gauge.
所以,了解操作系统是如何管理内存就变得十分重要,对于如何解读Profiler工具提供的数据也很有帮助。接下来我们先来讨论一下iOS系统对内存的管理机制,之后再来分别看看Xcode抓取的内存数据和Unity抓取的内存数据。
首先,每一个进程都会有一个地址空间。其范围由指针size支持,比如32bit或64bit。并且地址空间首先会分为多个区域(regions),然后将这些区域细分为4KB(早期版本)或16KB(A7之后)为单位的page,这些page继承了该region的各种属性,例如是否是只读、可读写等等。当然,有些page可能存放的数据比这个page的尺寸要小,有的数据可能需要好几page才能存放,但是系统的内存单位是16kb的page,所以系统统计的内存开销约等于page的数量 x page的大小。
当然,系统还有真实的物理内存。
Virtual memory vs Resident memory
通过虚拟内存使我们能够建立从该地址空间到真实物理内存的映射,这点我想这些大家应该都知道。而映射其实是一个很有趣的事情。因为从每一个app进程的角度来看,它拥有所有的内存,即虚拟内存,但事实上只有一部分虚拟内存被映射到了真实的物理内存上,这部分被映射到物理内存的部分就是所谓的Resident memory。
就像上面这个图中描述的一样,一个app分配了内存,可以看到在虚拟内存上分配了4个region,其中第3个region包括了13个page。 但此时,真正映射到物理内存上的只有6个page。而虚拟内存到真实物理内存的映射发生在对内存的第一次使用时,比如从内存中读取数据或是向内存中写数据。Resident memory同样也是Virtual memory,只不过这部分Virtual memory已经映射到了真实的物理内存。 我想大家可能都通过XCode或者Instrument的统计看到过类似的数据,例如Instrument的VM Tracker中就分别列出了Resident和Virtual Size。
Dirty memory vs Clean memory
page有可能是dirty的也有可能是clean的。要如何区分dirty和clean呢?简单的说,dirty的页就是我们的app或者游戏对这个page的内容进行了修改即分配了内存同时也修改了内存的内容,常见的就是malloc在heap上分配的内存。这部分内存是不能被回收的,因为这些数据显然需要被保存在内存中以保证程序正常的运行。
而clean的页则是没有对其内容进行修改,可以被系统收回和重新创建的。例如内存映射文件(Memory-mapped file),如果操作系统需要更多的内存,那么就可以将其丢弃。因为系统总是可以从磁盘中重新加载它,创建内存空间和磁盘上文件的映射关系。clean的内存是可以被释放和重新创建的。但是可以看到,虽然Memory-mapped file并没有消耗真实的物理内存,但是它消耗了进程的虚拟内存。
除此之外还有可执行文件的__TEXT段以及一些framework的DATA CONST段,也会归为clean memory。
在WWDC2018上,iOS的开发人员举了一个很形象的例子。即分配20,000个integers组成的array,此时会有page被创建,如果只对第一个元素和最后一个元素赋值,则第一个page和最后一个page——即首尾元素所在的page——会变成dirty,但是首尾之间的page仍然是clean,即只分配了内存而没有修改或写数据。
Compressed memory
当内存吃紧时,会回收clean page。而dirty page是不能被回收的,那么如果dirty memory过多会如何呢?在iOS7之前,如果进程的dirty memory过高则系统会直接终止进程。iOS7之后,引入了Compressed Memory的机制。由于iOS没有传统意义上的disk swap 机制(mac OS有),因此我们在苹果的Profiler工具中看到的Swapped Size指的其实就是Compressed Memory。
iOS7之后,操作系统可以通过内存压缩器来对dirty内存进行压缩。首先,针对那些有一段时间没有被访问的dirty pages(多个page),内存压缩器会对其进行压缩。但是,在这块内存再次被访问时,内存压缩器会对它解压以正确的访问。举个例子,某个Dictionary使用了3个page的内存,如果一段时间没有被访问同时内存吃紧,则系统会尝试对它进行压缩从3个page压缩为1个page从而释放出2个page的内存。但是如果之后需要对它进行访问,则它占用的page又会变为3个。
Unity Profiler错了吗?
可以看到,从操作系统内存管理的角度来看,一个进程的内存其实是十分复杂的。而Unity记录的内存数据,以“Reserved Total - Unity”为例,则主要来自引擎内MemoryManager的记录。MemoryManager会根据不同的情况调用对应的Allocator来进行引擎的内存分配。
例如我们可以以Unity 3D Game Kit这个免费项目为例,使用Instrument来查看一下它的内存分配。
可以看到MemoryManager调用了UnityDefaultAllocator。 而下图的这个分配则使用了IphoneNewLabelAllocator来分配内存。
也就是说Unity的代码分配的内存,Unity是会进行记录的。但是我们可以看到除了Unity的代码本身分配的内存,还有很多framework或者第三方library也会分配内存。但是这部分内存,Unity的Profiler是不会记录的。
0x02 使用Instrument调试Unity 游戏的内存
这部分我推荐Valentin Simonov的这篇文章Understanding iOS Memory (WiP),对使用Instrument调试内存介绍的十分清晰。
0x03 使用命令行工具深入挖掘内存问题
除了使用Instrument来调查内存问题之外,我们还可以通过很棒的Xcode memory debugger工具来查找内存问题。尤其是将Memgraph导出后,还可以借助各种命令行工具来辅助调查以获取更多信息。
而且有时大家也会抱怨说在Xcode的Memory Report页面看到的内存数据有时候不仅和Unity Profiler不一样,有时甚至和Instrument等苹果自己的性能工具数值也不一样。上文已经说过了,不同的工具有不同的数据是正常的。但是我们同样可以通过Memgraph和命令行工具来查看一下,Memory Report的数据侧重什么内容。
还是以Unity 3D Kit这个工程作为演示,测试设备为iPhone X,不过在开始之前我们首先需要开启Scheme -> Run -> Diagnostics -> Malloc Stack选项。
运行游戏后从主菜单点击开始游戏加载第一个场景,我们可以在Memory Report中看到此时的内存已经达到了1.48G。但是Memory Report中它的内存刻度仍然在绿色部分,所以实事求是的讲Memory Report的刻度并不是一个好的优化建议,因为这个内存开销在iphone7上就直接会导致游戏被系统中止。
Animation Leak?
我们直接进入到Xcode memory debugger,如果想要在这里检查是否有内存leak的问题,可以点击Filter中的选项。这里有一个常见的假“leak”情况。
如果我们看一下它的堆栈信息的话,大多是和Animation有关的。这里我咨询了一下这个功能的开发者,确认这是一个苹果的误报,Unity还是会正常释放这部分内存的。当然如果大家遇到其他奇怪的和引擎有关的leak,可以按照这篇文章的介绍给Unity提交Bug Report。
之后我们可以将此时的数据导出为.memgraph文件。接下来就可以使用一些命令行工具来处理这些数据了。
VMMAP Summary
第一个命令行工具是vmmap,使用它我们可以查看当前的虚拟内存的数据。
首先拿到一个memgraph文件时,我们可以考虑使用这个指令同时加上--summary标记来输出当前虚拟内存的一个总览。
vmmap --summary Unity3DKit_ipx.memgraph
终端的输出如下图所示:
我们可以发现一些有趣的地方。首先有前4列是我们之前讨论过的内容:VIRTUAL SIZE、RESIDENT SIZE、DIRTY SIZE、SWAPPED SIZE。分别表示虚拟内存的大小,映射到物理内存的大小,Dirty内存的大小以及Compressed内存的大小。 我们可以看到TOTAL的部分,这个游戏进程分配了2.7G的虚拟内存其中有1.6G映射到了物理内存上,而DIRTY SIZE的值是1.4G——这个值很接近Memory Report中的数值,而SWAPPED SIZE的数值为52mb,根据苹果工程师在WWDC2018上的演讲,这个值是压缩前的内存而不是压缩后的内存。因此我们主要来关注DIRTY SIZE这一项。
IOKit
其次我们可以看到IOKit的开销最大,它的虚拟内存不仅达到了832.5mb,而且实际映射到物理内存上的空间也达到了750.4mb。这部分主要是一些和GPU相关的一些内存,例如render targets, textures, meshes, compiled shaders等等。而这个测试项目也的确是mesh、texture的内存占用很大。
MALLOC 和 Heap
再次,我们可以看到MALLOC_**分配了很多内存。这部分内存主要是调用Malloc进行分配的,其中即包括Unity的原生也就是C++代码的分配也包括第三方库和系统使用Malloc分配的内存,这部分内存在所谓的Heap上,在这几行的后面可以看到“see MALLOC ZONE table below”,也就是可以在下面看到各个heap zone的一个归类。在这里我们可以利用第二个命令行工具heap来检查一下Heap内存的内容。
heap --sortBySize Unity3DKit_ipx.memgraph
使用heap指令,我们还可以添加--sortBySize标志来对数据进行排序(默认按照类型实例的数量进行排序)。
可以看到Heap的绝大部分内存都被non-object占用了,达到了近700mb,而实际的object的内存分配其实都是很小的,比如类GpuProgramMetal的实例有573个,但是内存其实只占用了223kb。此时大家一定对non-object的内容很感兴趣,不过在这个页面里似乎也看不到太多的内容。所以接下来我们可以添加--showSize标志,将合并在一起的数据按照size进行分组。
heap --showSize --sortBySize Unity3DKit_ipx.memgraph
这样就清晰多了。
可以看到non-object这一类中,排名最高的几块内存分配的尺寸分别是1个31mb、3个10mb以及1个8.4mb,这样我们就确定了这个时候的调查方向。
当然,heap指令还提供了更多的功能,比如那些有Class Name的对象分配,我们可以通过ClassName匹配的方式获取每一个该类型实例的内存地址。此时需要-addresses标签即可。比如我们输出Unity的GpuProgramMetal类的所有实例的地址信息,可以看到其实这个类的实例本身并不大,但是它引用的真正的shader资源则可能是内存开销的大户之一。
heap -addresses GpuProgramMetal Unity3DKit_ipx.memgraph
同时,有了各个对象所在的内存地址,我们就可以通过下面要提到的malloc_history命令来查找它们是怎么来的。但是现在我们还是把目光转向内存分配比较大的目标吧。
此时返回终端,继续输出虚拟内存的信息,不过这次我们只关注MALLOC_LARGE的分配,所以我们可以借助grep来过滤出我们的目标。
vmmap -verbose Unity3DKit_ipx.memgraph | grep "MALLOC_LARGE"
这次输出了MALLOC_LARGE类型下的内存信息,包括它的地址、尺寸以及所在Heap Zone等等信息。我们可以在这里找到我们的目标,一个30mb、3个10mb以及一个8mb的内存分配。
接下来我们就来看一下分配它们的堆栈调用吧。这里我们会使用malloc_history命令,同时加上--fullStacks标志来输出堆栈信息。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000127c60000
可以看到这30mb的分配是为了给FMOD分配内存池。
另外3个10mb的分配,同样也是做类似的事情。可见这个项目使用的声音资源很多。最后我们来看一下这个8mb的分配是从哪里来的。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000113400000
可以看到是开启多线程渲染时,Unity创建CommandQueue时分配的内存。
VM_ALLOC == Mono Size?
接下来,我们可以看到vmmap –summary输出的结果中,有一项叫做VM_ALLOC。根据Valentin Simonov的说法,VM_ALLOC对应的是Mono内存也就是托管内存的大小。究竟是否如此呢?我们同样可以通过上面的方式,来查看一下VM_ALLOC部分的内存分配堆栈。 首先我们还是通过vmmap和grep来过滤出VM_ALLOC部分的内存信息。
vmmap -verbose Unity3DKit_ipx.memgraph | grep "VM_ALLOC"
可以看到这部的内存分配并不多,我们同样选择2块分配最大的内存下手。
我们首先使用malloc_history来查看一下3m部分的调用堆栈。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000152bd4000
我们可以看到这3m的内存是C#脚本中调用了SimplFXSynth的RenderAudio方法而触发了GC分配,托管堆进行了扩容。针对脚本中的方法定位,我们可以通过RuntimeInvoker这个符号来定位它在堆栈中的位置。
有趣,接下来我们再来看看1mb的这块内存是怎么分配的。
malloc_history Unity3DKit_ipx.memgraph --fullStacks 0x0000000150084000
这次是由于Unity的ScriptingGCHandle::Acquire方法在托管堆上进行了内存分配。
可见,VM_ALLOC这部分内存主要对应了Unity的Mono托管堆的内存而且这个项目的Mono内存并不大。而具体是哪个函数触发了GC分配,则可以通过malloc_history来查看。
Command Summary
至此,使用命令行调试和查找iOS平台上内存问题就介绍完了。简单来个小结,拿到一个Unity游戏的内存.Memgraph文件之后,可以先通过vmmap --summary来查看一下内存的全景图。对于heap也就是malloc分配的内存,可以进一步通过heap指令来进一步分析。 而一旦获取了目标对象的内存地址之后,就可以使用malloc_history指令来获取分配这块内存的堆栈信息了。当然前提是要开启Malloc Stack的选项。之后,可以做一个自动化的分析工具,对数据进行处理和输出来定位内存问题。
0x04 小彩蛋
-
Unity 3D Game Kit是一个很棒的Unity的学习工程。它的教学页面可以查看这里:https://learn.unity.com/tutorial/3d-game-kit-reference-guide#5c7f8528edbc2a002053b73f
-
iOS13之后提供了一个新的API-os_proc_available_memory,利用这个API我们可以获取当前这个进程还能获取多少内存的预估值。嗯,怪不得我的iphone7跑不动这个项目。
Useful Link:
-
https://connect.unity.com
-
https://developer.apple.com/documentation/metal/reducing_the_memory_footprint_of_metal_apps
-
https://developer.apple.com/library/archive/documentation/FileManagement/Conceptual/FileSystemAdvancedPT/MappingFilesIntoMemory/MappingFilesIntoMemory.html
-
https://docs.google.com/document/d/1J5wbf0Q2KWEoerfFzVcwXk09dV_l1AXJHREIKUjnh3c/edit
-
https://developer.apple.com/videos/play/wwdc2013/704/
-
https://developer.apple.com/videos/play/wwdc2018/416/
https://docs.microsoft.com/zh-cn/learn/?WT.mc_id=DT-MVP-5001664