WWDC Quick Look 💓 By SwiftGGTeam
Profile and optimize your game's memory

Profile and optimize your game's memory

观看原视频

Highlight

Apple GPU 软件团队的 Jack Xu 和 Seth Lu 系统讲解了游戏内存的分析和优化方法。内容从内存的基本概念开始,逐步深入到 Instruments 的内存分析工具、Xcode 的内存图分析、以及 Metal Debugger 的资源优化。


核心内容

游戏的内存问题常常先出现在一个数字上:Xcode Memory Report 里的当前内存使用量变高,设备开始紧张,最后游戏可能被系统限制。开发者第一反应通常是看分配量,但这场 session 先纠正了这个入口:分配发生在虚拟地址空间,真正被系统计入限制的是 memory footprint(内存占用)。

这个区别会改变排查顺序。游戏可以分配很大的虚拟区域,但只有被访问的页面才会进入物理内存。现代 Apple 设备的内存页粒度是 16 KiB。系统向游戏计费的是 dirty、compressed、swapped 这些页面,clean 页面虽然可能常驻内存,但可以从磁盘重新加载,不计入 footprint。

Metal 游戏还多了一层复杂度。Apple silicon 使用统一内存,CPU 和 GPU 共享同一池快速内存,所以被访问的 Metal buffer、texture、pipeline state object 也会进入 dirty memory。纹理、缓冲区、drawable、堆分配和匿名 VM 需要放在同一个分析流程里看。

Apple 给出的工作流分三步。第一步,用 Xcode Memory Gauge 和系统 API 确认 footprint 与可用内存。第二步,用 Instruments 的 Game Memory 模板记录内存增长。第三步,在某个时间点捕获 memory graph,用 Xcode Memory Debugger、footprintvmmapheapmalloc_historyleaks 找到大对象、分配来源和引用关系。最后,再进入 Metal Debugger 的 Memory Viewer 审计资源大小、最近使用时间、像素格式、storage mode 和 heap aliasing。


详细内容

1. 先看 footprint,不要只看 allocations

06:53)Jack 在前半段明确区分了 allocations 和 actual memory use。allocations 是游戏在虚拟地址空间里请求的区域。memory footprint 是系统实际向游戏计费的物理内存规模,也是部分 Apple 平台执行内存限制时使用的指标。

游戏可以在运行时查询当前环境还剩多少内存。iOS、iPadOS、tvOS 和 watchOS 上可以使用 os_proc_available_memory()

#import <os/proc.h>

API_UNAVAILABLE(macos) API_AVAILABLE(ios(13.0), tvos(13.0), watchos(6.0))
size_t os_proc_available_memory(void);

关键点:

  • #import <os/proc.h> 引入进程相关的系统接口。
  • API_UNAVAILABLE(macos) 表示这个查询接口不适用于 macOS。
  • API_AVAILABLE(...) 标出可用平台和最低系统版本。
  • os_proc_available_memory() 返回当前进程可用的系统内存规模,可用于动态调整资源加载策略。

07:07)如果要看当前 footprint 和生命周期峰值,可以用 proc_pid_rusage() 读取 rusage_info_current

#if __has_include(<libproc.h>)
#include <libproc.h> // On macOS.
#else
#include <sys/resource.h> // On iOS, iPadOS and tvOS.
int proc_pid_rusage(int pid, int flavor, rusage_info_t *buffer)  
    __OSX_AVAILABLE_STARTING(__MAC_10_9, __IPHONE_7_0);
#endif

rusage_info_current rusage_payload;
     
int ret = proc_pid_rusage(getpid(),
                          RUSAGE_INFO_CURRENT, // I.e., new RUSAGE_INFO_V6 this year.
                          (rusage_info_t *)&rusage_payload);
NSCAssert(ret == 0, @"Could not get rusage: %i.", errno); // Look up in `man errno`.
        
uint64_t footprint       = rusage_payload.ri_phys_footprint;
uint64_t footprint_peak  = rusage_payload.ri_lifetime_max_phys_footprint;

关键点:

  • __has_include(<libproc.h>) 根据平台选择头文件,macOS 使用 libproc.h
  • proc_pid_rusage(getpid(), RUSAGE_INFO_CURRENT, ...) 查询当前进程的资源使用情况。
  • rusage_info_current 在这里承载返回数据,session 说明它对应当年的 RUSAGE_INFO_V6
  • ri_phys_footprint 是当前 physical footprint。
  • ri_lifetime_max_phys_footprint 是进程生命周期内的 footprint 峰值。

这两个接口适合放进游戏的资源管理层。比如进入高质量地图前先看可用内存;检测到 footprint 接近预算时,减少预加载纹理、降低缓存上限,或者延后加载不在视野内的资源。

2. 用 Game Memory 模板记录增长过程

09:10)Seth 介绍了 Xcode 14 新增的 Instruments Game Memory 模板。这个模板包含 Allocations、Metal Resource Events、VM Tracker、Virtual Memory Trace、Metal Application 和 GPU 等 instruments。它把 CPU 分配、Metal 资源事件、VM footprint 放在同一个时间轴上。

游戏常在启动阶段集中分配资源。session 建议从一次新的游戏启动开始 profiling,而不是附加到已经运行的进程。这样才能看见启动期的增长曲线。

10:04)除了从 Xcode 点 Profile,也可以用 xctrace 自动录制。

xctrace record --template "Game Memory" \
               --attach ModernRenderer \
               --output ModernRenderer.trace \
               --time-limit 30s

关键点:

  • xctrace record 启动一次 Instruments 录制。
  • --template "Game Memory" 指定本场 session 讲解的新模板。
  • --attach ModernRenderer 附加到名为 ModernRenderer 的进程。
  • --output ModernRenderer.trace 把结果保存成 trace 文件。
  • --time-limit 30s 控制录制时长,适合在自动化流程中复现启动期或某段固定玩法。

10:14)如果要指定设备,可以加 --device-name

xctrace record --device-name "Seth's iPhone" \
               --template "Game Memory" \
               --attach ModernRenderer \
               --output ModernRenderer.trace \
               --time-limit 30s

关键点:

  • --device-name 选择 iPhone、iPad 或 Apple TV 作为目标设备。
  • 其余参数保持一致,方便把同一套 profiling 命令用于不同设备。
  • 生成的 trace 可以反复打开,比较不同资源包、不同关卡或不同画质设置的内存增长。

10:23)录制后先看 Allocations。它显示堆分配、匿名 VM、分配大小、对象引用计数和分配栈。Metal 游戏里,IOAccelerator 通常对应 Metal resources,IOSurface 对应 drawables。Allocations 默认显示分配大小,也可以切换到 Allocation Density,看哪些时间点出现分配峰值。

12:50)然后看 Metal Resource Events。这里可以看到 Metal resource 的分配和释放历史。session 特别提到,资源可以通过 Metal API 设置 label,这样在工具里就能按名称识别具体 texture 或 buffer。

13:38)最后看 VM Tracker。Dirty Size 是未压缩的 dirty memory,Swapped Size 是 compressed 或 swapped memory。它回答的问题是:这些分配到底有多少进入了 footprint。

3. 捕获 memory graph,拆开当前状态

15:09)Instruments 适合看时间线。memory graph 适合看某个时刻的完整状态。它保存对象创建历史、引用关系、压缩和换出信息。你可以在问题发生时抓一次,也可以在问题发生前后各抓一次做比较。

分析 memory graph 前,session 建议开启 Malloc Stack Logging。它会记录分配信息,让后续工具能追到对象来源。

16:52)不从 Xcode 启动时,可以用环境变量启用。

# See `man malloc`.
MallocStackLogging=lite # Live allocations only.
MallocStackLogging=1    # All allocation and free history.

关键点:

  • MallocStackLogging=lite 只记录仍然存活的分配,开销较低。
  • MallocStackLogging=1 记录所有分配和释放历史,适合查 fragmentation(碎片化)等问题。
  • session 中 Jack 说,多数只看 live object 引用的场景,推荐 Live Allocation Only。
  • 开启后,heapmalloc_history 等工具能提供更清楚的对象来源。

18:07)Mac 游戏可以用 leaks 在命令行捕获 memory graph。

leaks $PID     --outputGraph foo.memgraph
# or
leaks GameName --outputGraph foo.memgraph

关键点:

  • leaks $PID 按进程 ID 捕获。
  • leaks GameName 按进程名捕获。
  • --outputGraph foo.memgraph 把快照保存为 memory graph 文件。
  • session 提到,这种方式可以在 SSH 中远程执行,适合全屏游戏需要保持焦点的场景。

18:41)拿到 memory graph 后,先用 footprint 看高层分类。它会把 IOAccelerator、MALLOC_*、VM_ALLOCATE 等类别列出来。游戏使用 Unity 或自定义 allocator 时,大块匿名内存可能显示为 untagged VM_ALLOCATE。

20:12)Apple 平台允许应用使用最多 16 个 app-specific tags。自定义 allocator 可以给匿名映射打标签,后续在工具里更容易识别。

size_t length;

int tag = VM_MAKE_TAG(VM_MEMORY_APPLICATION_SPECIFIC_1); // Check out `man mmap`.
    
void * reservation = mmap(NULL,
                          length,
                          PROT_READ | PROT_WRITE,
                          MAP_ANONYMOUS | MAP_PRIVATE,
                          tag, // Instead of using default `-1`.
                          0);

if (reservation == MAP_FAILED) {
    @throw [[NSError alloc] initWithDomain:NSPOSIXErrorDomain
                                      code:errno
                                  userInfo:nil];    
}

return reservation;

关键点:

  • VM_MAKE_TAG(VM_MEMORY_APPLICATION_SPECIFIC_1) 从 16 个应用自定义标签中创建一个 VM tag。
  • mmap(...) 创建匿名私有映射。
  • 第五个参数使用 tag,替代默认的 -1
  • MAP_FAILED 分支把 POSIX 错误转成 NSError,方便上层处理。
  • 这样生成的内存区域会在后续分析工具里以更清晰的类别出现。

20:30)如果代码使用 mach_vm_allocate,也可以在 flags 里带上同类标签。

size_t page_count;

mach_vm_size_t allocation_size = page_count * PAGE_SIZE;
mach_vm_address_t vm_address;
kern_return_t kr;

kr = mach_vm_allocate(mach_task_self(),
                      &vm_address,
                      allocation_size,
                      VM_FLAGS_ANYWHERE | VM_MAKE_TAG(VM_MEMORY_APPLICATION_SPECIFIC_1));
    
if (kr != KERN_SUCCESS) { // Refer to mach/kern_return.h.
    @throw [[NSError alloc] initWithDomain:NSMachErrorDomain
                                      code:kr
                                  userInfo:nil];
}
    
return vm_address;

关键点:

  • allocation_size = page_count * PAGE_SIZE 按页数计算分配大小。
  • mach_task_self() 指向当前 task。
  • VM_FLAGS_ANYWHERE 让系统选择合适地址。
  • VM_MAKE_TAG(...) 把 app-specific tag 合入 flags。
  • KERN_SUCCESS 之外的返回值会被转成 Mach 错误域。

21:12)接下来用 vmmap 看 dirty 和 swapped 的拆分。footprint 里 dirty size 包含 swapped 和 compressed,vmmap 会把它们拆到不同列。swapped 列常指向较少使用、可能可以优化的内存。

23:32)再用 heap 看 malloc 对象。Xcode 14 的 heap 可以利用 Malloc Stack Logging 记录的信息识别 caller 或 responsible library,帮助定位大型非对象内存来自哪个库或插件。session 的例子里,Manifold Garden 的 memory graph 显示 FMOD Studio 和 GameAssembly.dylib 占用了可观堆内存。

26:45)找到大对象地址后,用 malloc_history 查 allocation call stack。用 Xcode Memory Debugger 也可以在 inspector 里看到对象的 allocation history。

27:48)最后查引用关系。leaks 可以用 memory graph 检查引用树、泄漏和 retain cycles。Xcode 14 重新设计的 memory graph view 会显示选中对象的 ingoing 和 outgoing edges,并提供 neighbor selection popover,适合复杂游戏状态下理解对象为什么还活着。

4. 用 Metal Debugger 审计 GPU 资源

30:37)Metal resources 在游戏里经常占用大块内存。Seth 的建议是从一次 GPU frame capture 进入 Metal Debugger,然后打开 Memory Viewer。

Memory Viewer 的表格能按类别过滤资源,比如只看 textures。几个最有用的列是:

  • Insights:列出工具发现的内存优化建议。
  • Allocated Size:按资源大小排序,先审计最大的 texture、buffer、model 数据。
  • Time Since Last Bound:找出很久没有被绑定过,甚至从未使用的资源。
  • Pixel Format:检查 texture 是否可以换成更省内存的格式。

33:03)如果某个资源长期没有使用,可以考虑释放。如果以后还可能用到,可以把它设为 volatile purgeable state。Metal 在系统内存压力高时可以驱逐这类资源;一旦资源变成 empty,系统就不再把它计入游戏 footprint。重新使用前,需要检查内容是否还在,并在需要时重新加载。

这段逻辑可以用伪代码表达。下面不是官方片段,只描述控制流。

for each metal_resource in captured_resources:
    if metal_resource.was_never_bound:
        audit_asset_loading_path(metal_resource)
    else if metal_resource.has_not_been_bound_for_a_while:
        mark_as_volatile_or_release(metal_resource)
    when_resource_is_needed_again:
        if resource_content_is_empty:
            reload_resource_content()

关键点:

  • was_never_bound 对应 Memory Viewer 里从未使用的资源,优先检查是否值得加载。
  • has_not_been_bound_for_a_while 对应 Time Since Last Bound 很大的资源。
  • mark_as_volatile_or_release 表示两种策略:可重建资源设为 volatile,不再需要的资源直接释放。
  • resource_content_is_empty 对应 purgeable state 变成 empty 后的恢复路径。
  • reload_resource_content() 必须由游戏自己的资源系统完成,session 没有给出具体 API 片段。

34:18)纹理还有几个明确的节省方向:许多纹理可以使用 16-bit half precision pixel format;只需要 alpha 时避免多个颜色通道;只读纹理可以考虑 block compression,比如 ASTC 和 BC;A15 Bionic 以后,纹理和 render target 还可以使用 lossy compression,在尽量保持质量的前提下降低内存。

35:10)storage mode 也会影响内存。只在单个 pass 使用的临时 render target,可以用 memoryless。典型例子是 depth、stencil 或 multisampled textures。只由 GPU 使用的 texture,优先考虑 private,而不是 shared 或 managed。session 还提醒,Apple silicon Mac 上不需要 managed mode,和 iPhone、iPad 一样。

36:05)如果多个资源不会同时使用,可以从 heap 分配 aliased resources,让它们共享同一块 backing allocation。Seth 同时提醒,要非常小心这些资源访问的同步。


核心启发

  • 做一个内存预算 HUD。 在开发版游戏里显示当前 footprint、峰值 footprint 和可用内存。为什么值得做:session 明确说 footprint 是 Apple 平台理解实际内存使用和执行限制的核心指标。怎么开始:用 proc_pid_rusage() 读取 ri_phys_footprintri_lifetime_max_phys_footprint,在支持的平台上用 os_proc_available_memory() 读取可用内存。

  • 把关卡加载做成可录制流程。 为每个典型关卡写一条 xctrace record --template "Game Memory" 命令。为什么值得做:Game Memory 模板把 Allocations、Metal Resource Events、VM Tracker 放在同一条时间线上,适合比较不同资源包的启动期内存增长。怎么开始:为启动、进入关卡、返回大厅各录制 30 秒 trace,并保存到 CI 或性能测试产物。

  • 给自定义 allocator 打 VM tag。 自研引擎、Unity 插件或资源系统如果使用匿名 VM,把不同用途映射到 app-specific tags。为什么值得做:session 的 Manifold Garden 例子显示,未标记的 VM_ALLOCATE 会降低分析清晰度。怎么开始:mmap 路径把默认 -1 换成 VM_MAKE_TAG(...)mach_vm_allocate 路径把 tag 合入 flags。

  • 建立 memory graph 排查手册。 遇到内存峰值时先抓 .memgraph,再按 footprintvmmapheapmalloc_historyleaks 的顺序排查。为什么值得做:这些工具分别回答类别、dirty/swapped 拆分、对象分布、分配栈、引用关系五个问题。怎么开始:在团队 wiki 里记录每个命令的输入、输出和下一步判断条件。

  • 给 Metal 资源做定期审计。 每个 milestone 用 Metal Debugger Memory Viewer 排序检查 Allocated Size 和 Time Since Last Bound。为什么值得做:Metal resources 在 Apple silicon 上会进入 footprint,纹理格式、storage mode、purgeable state 都能影响实际内存。怎么开始:先处理最大纹理,再处理长期未绑定资源,最后检查临时 render target 是否可以改成 memoryless。


关联 Session

评论

GitHub Issues · utterances