聊聊 Unity 程序员可能会遇到的 iOS 内存问题。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
ref: WWDC 2013 通过虚拟内存使我们能够建立从该地址空间到真实物理内存的映射,这点我想这些大家应该都知道。而映射其实是一个很有趣的事情。因为从每一个 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,即只分配了内存而没有修改或写数据。
ref: WWDC 2018 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。 https://blogs.unity3d.com/2016/08/24/attaching-your-project-to-a-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 跑不动这个项目。
原文链接: https://connect.unity.com/p/xie-gei-unitykai-fa-zhe-de-iosnei-cun-diao-shi-zhi-nan?app=true 更多干货资源学习,戳上方链接下载 Unity 官方 app,在线技术互动答疑,结识更多 Unity 小伙伴,交友学习两不误!
这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。
V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。
V2EX is a community of developers, designers and creative people.