返回全部文章

Unity 的“内存使用量”该看哪个数字?八项读取和 Framedash 送出的值

用 Unity 做的游戏,它的“内存使用量”会因为你在哪里看而不一样。这次把绘制 20,000 个立方体的演示放在 Windows 上跑起来测了一遍:Framedash 的仪表盘上是 167 MB,Windows 的任务管理器上是 739 MB,用 Unity 的 API 读 C# 堆则是 8 MB。这三个不是同一时刻的值,也不是同一次运行的值:仪表盘和 API 的值各自是一次运行的中位数,任务管理器的 739 MB 是某一时刻的值(怎么读后面会讲)。数字对不上,是因为它们各自在数不同的东西。这篇文章从同一个构建里用八种方法同时记录内存数值,把每个数字在数什么、Framedash 送出的是哪一个理清楚。

“内存”从里到外分成四层

游戏的内存使用量,按数到哪里为止,可以分成四层。

最里面一层,是 C# 代码创建的对象所在的区域。new 出来的类和数组堆在这里,垃圾回收要清理的也是这里。

再往外一层,是 Unity 引擎实际在用的量。除了 C# 对象,还包括纹理这类引擎在内部握着的东西。

更外面一层,是 Unity 从 OS 一次性借过来、握在自己手里的量。用完的部分不会马上还回去,而是留着应付下一次分配,所以这一层比实际在用的量更大。

最外面一层,是从 OS 看到的整个进程的量。可执行文件、DLL、图形驱动这些 Unity 的分配器不去追踪的东西也算在里面。

这次测量里,越往外的层,数字越大。不过这四层并不是干净的包含关系:工作集数的是当前留在物理内存里的页,私有(提交)数的是连被换出去的那部分也算在内、进程已经提交的页。内存显示大多是从这四层里截取的其中一层。例外是后面出场的图形驱动数值,它不是包住这四层的第五层,而是另立的一个计数器。

把 20,000 个立方体用两种方式绘制来测量

用的是和上一篇一样的、绘制 20,000 个立方体的项目。只在绘制方式上做出区别,准备了两种模式。

  • Naive:每个立方体各带一个游戏对象和一个 MeshRenderer
  • InstancedDirect:不为每个立方体创建游戏对象,用 Graphics.RenderMeshInstanced 绘制

对应上一篇的 NAIVE 和 INSTANCED_DIRECT。环境是 Unity 6000.4.11f1、Built-in Render Pipeline,图形 API 是 D3D12,GPU 是 RTX 4070 Ti,分辨率 1920x1080。测量用的不是 Development 构建,而是 Release 播放器。

先预热 15 秒,再测量 120 秒。八项读取都在调用 SDK 的 Collect() 之后的同一帧里一起取到。不过表里放的数值,是把 120 秒的帧按每一项读取取中位数(p50)得到的,并不是照抄某一帧的数字。

在 Unity 播放器里取 OS 那一侧的值时,System.Diagnostics.Process.WorkingSet64PrivateMemorySize64 用不了。在 Mono 运行时里两者都返回 0。这次是用 P/Invoke 调用 psapi 的 GetProcessMemoryInfo,再和从外面用 Get-Process 同时取到的值做了对照。中位数是 848.5 MB 和 848.7 MB,没有偏差。

从同一个构建里读到的八个值

读取方式 Naive InstancedDirect 变化
GetMonoUsedSizeLong 8.2 MB 6.9 MB -16%
GC.GetTotalMemory(false) 8.2 MB 6.9 MB -16%
GetMonoHeapSizeLong 10.3 MB 7.9 MB -23%
GetTotalAllocatedMemoryLong 167.2 MB 56.6 MB -66%
GetTotalReservedMemoryLong 263.4 MB 155.2 MB -41%
OS 工作集 852.4 MB 412.0 MB -52%
OS 私有(提交) 1,168.3 MB 681.9 MB -42%
GetAllocatedMemoryForGraphicsDriver 0 0 -

八项的构成是:看最里面那一层的有三项,引擎在用的量和借来的量各一项,OS 那一侧两项,图形驱动一项。带底色的那一行,就是 Framedash 的 Unity SDK 送出的那一层。帧时间 p50 是 9.25 ms 和 1.44 ms,和上一篇的趋势一致。上一篇里写的 169.0 MB 和这次的 167.2 MB,是用同样的参数、同样的构建配置分别跑出来的两次结果。两边的 memory_used_bytes 指的都是这一层已分配内存。

把六项读取按 Naive 与 InstancedDirect 并排画出的横向条形图。由上至下依次是 GetMonoUsedSizeLong 8.2 / 6.9 MB、GetMonoHeapSizeLong 10.3 / 7.9 MB、带底色的 GetTotalAllocatedMemoryLong 167.2 / 56.6 MB、GetTotalReservedMemoryLong 263.4 / 155.2 MB、OS 工作集 852.4 / 412.0 MB、OS 私有(提交)1,168.3 / 681.9 MB
刻度是线性的。八项读取里画出了六项:GC.GetTotalMemory(false) 在这个位数上和 GetMonoUsedSizeLong 相同,GetAllocatedMemoryForGraphicsDriver 在 Release 播放器里是 0。深色条是 Naive,浅色条是 InstancedDirect,带底色的那一行就是 Framedash 的 Unity SDK 送出的那一层。

不用游戏对象带来的效果,从哪一层看,幅度就不一样。已分配内存上是 -66%,C# 堆的使用量上是 -16%,堆本身已保留的量上是 -23%。“把内存减少了 66%”和“只减少了 16%”,是同一处改动从不同层看到的报告,两个数字都没错。

每一层在数什么

GetMonoUsedSizeLongGetMonoHeapSizeLong 看的是最里面那一层。承载 C# 对象的这块区域,叫做托管堆。使用量 8.2 MB,已保留量 10.3 MB,差出来的 2.1 MB 是作为堆保留下来的空闲区域。GC.GetTotalMemory(false) 在显示的位数上完全一致,是因为它返回的也是同一个托管堆的使用量。要跟踪 GC 压力,看的就是这一层。

GetTotalAllocatedMemoryLong 是 Unity 的分配器实际在用的总量。167.2 MB 里托管堆只有 8.2 MB,占全体的 5%。剩下的都在原生那一侧,拿掉 20,000 个游戏对象后少掉的 110.6 MB,也几乎都是这一侧的。

GetTotalReservedMemoryLong 是 Unity 从 OS 拿过来、握在自己手里的那个池的大小。已分配内存相当于这个池里实际被用掉的部分。Naive 保留了 263.4 MB、用掉 167.2 MB,所以有 96.2 MB 一直空着。InstancedDirect 这边空着的是 98.6 MB,几乎没变。按比例看,已保留内存的 -41% 和已分配内存的 -66% 差得挺远,但减少的量是 108.2 MB 和 110.6 MB,几乎一样。

OS 工作集是进程此刻放在物理内存上的页面量,和任务管理器里的“工作集”(Working set)指的是同一个东西。私有(提交)是进程为自己专用而提交的页面,也包含被换出物理内存的部分,对应任务管理器里的“提交大小”(Commit size)。

Naive 这边,已分配的 167.2 MB 对上的工作集是 852.4 MB。差出 685.2 MB。不过这是两个数的东西并不相同的计数器之差:工作集数的是留在物理内存里的页,已分配内存数的是 Unity 的分配器在用的量,所以这 685.2 MB 本身并不是量出来的某一个东西。可执行文件和 DLL 的映射、图形驱动、插件的分配,都在进程里且在 Unity 分配器的追踪范围之外,但哪一项各占多少,这次的测量并没有拆开。

剩下的 GetAllocatedMemoryForGraphicsDriver,不属于这四层里的任何一层,是另立的、专门看图形驱动分配了多少的 API。它在 Release 构建里返回 0。这个 API 需要 ENABLE_PROFILER,在 Development 构建里测的话两种模式都是 99.0 MB。两种模式取到同一个值,表示拿掉游戏对象并没有让驱动那一侧的分配量动起来。这里的 0 不是“用了 0 字节”,而是“没取到”。Framedash 的 Unity SDK 也一样,值为 0 时连 mem.vram 这个键都不送。

任务管理器的“内存”列和表里的哪个都不一样

在任务管理器的 [进程] 选项卡里,这个可执行文件的“内存”列显示 738.7 MB。同一时刻从外面取到的工作集是 795.5 MB,私有是 1,091.3 MB。三个都不一样。[进程] 选项卡的这一列指的是“专用工作集”(Private working set),不是上面表里那两个 OS 侧数值中的任何一个。

这三个和表里的 852.4 MB、1,168.3 MB 也对不上。任务管理器上看到的是某一时刻的值,而表里是 120 秒的中位数。

不要把 Development 构建的数字和 Release 的数字混在一起

同样的两种模式也在 Development 构建里测了一遍。已分配内存是 183.6 MB 和 84.6 MB,已保留内存是 337.7 MB 和 221.5 MB。和 Release 的已分配 167.2 MB / 56.6 MB、已保留 263.4 MB / 155.2 MB 相比,四个都涨了。因为性能分析器的插桩本身也要占内存。

工作集这边没有朝一个方向动。是 792.1 MB 和 469.6 MB,Naive 比 Release 低,InstancedDirect 比 Release 高。就算在已分配和已保留上涨了,工作集也不保证朝同一个方向动。不能把 Development 构建的数字和 Release 的数字并排放着谈增减,原因就在这里。

对比 Release 构建和 Development 构建的两张图。左边是已分配内存,Release 是 167.2 和 56.6 MB,Development 是 183.6 和 84.6 MB。右边是 OS 工作集,Release 是 852.4 和 412.0 MB,Development 是 792.1 和 469.6 MB
两块面板的纵轴刻度不同,只能在同一块面板里比较。已分配内存在 Development 构建里两种模式都涨了,分别是 +16.4 MB 和 +28.0 MB;工作集却没有跟着同向走,Naive 是 -60.3 MB,InstancedDirect 是 +57.6 MB。

Framedash 送出的,在不同引擎上是不同的层

Framedash 的上报 schema 里,内存这一栏只有一个 int64 的 memory_used_bytes。仪表盘把它除以 1048576 后按 MB 显示。栏位只有一个,但填进去的值属于哪一层,各引擎并不相同。

  • UnityProfiler.GetTotalAllocatedMemoryLong()。就是本文里 167.2 MB 和 56.6 MB 的那一层,随每 10 秒的 perf_heartbeat 一起送出
  • UE5FPlatformMemory::GetStats().UsedPhysical。是物理内存上的驻留量,所以这一层比 Unity 更靠近 OS 那一侧的数字
  • Godot(C#)Performance.Monitor.MemoryStatic。在导出的 Release 构建里这个监视器用不了,SDK 会回退到 System.GC.GetTotalMemory

在 Godot(C#)上,同一列的含义会随构建类型改变。只数托管堆的值会低到什么程度,从上面的表里就能有个概念:在 Unity 上是 8.2 MB 和 167.2 MB。

在 Unity SDK 里,托管堆和驱动那一侧可以作为单独的键补上去。mem.heap 对应 GetMonoUsedSizeLongmem.vram 对应 GetAllocatedMemoryForGraphicsDriver。两者为 0 时都不送。

在另一次开着 SDK 上报遥测的运行里,GetMonoUsedSizeLong 的 p50 涨了:Naive 从 8.2 MB 到 9.8 MB,InstancedDirect 从 6.9 MB 到 8.8 MB。同一次运行的已分配内存是 167.0 MB 和 56.6 MB,工作集是 857.3 MB 和 415.3 MB,和不上报那次的差距都在 5 MB 以内。多出来的 1.6 MB 和 1.9 MB,是这个配置下 SDK 落在托管堆上的部分。事件缓冲区、序列化和 SDK 自身的日志各占多少,这次运行没有分开。

构建之间的比较里,p50 和 p95 是并排给出的。中位数没动、只有尾部被拉长的变化,也会显示在那里。从 CI 执行 framedash perf-diff,就能在发布前以这个粒度确认构建之间的差异。

Framedash 的构建比较画面,20260828-Naive-memlayers-tel 和 20260828-InstancedDirect-memlayers-tel 两个 build id 并排显示,可以看到内存的 p50 和 p95 两列
把两个 build id 并排放在一起的比较画面。这是开着遥测上报测出来的那一次运行,内存 p50 从 167.0 MB 到 56.6 MB。这个画面上的百分位是对每个构建上报的 133 条事件算的,不是像上面表格那样按帧算的。

这次的结果适用到什么范围

到这里为止的数值,都来自 Windows、D3D12、Mono 后端、桌面 GPU 这样一个配置。已分配内存和工作集之间会拉开多大,会随平台和脚本后端而变。IL2CPP 构建没有测。

层与层之间的对应关系,也只是这个配置下的结果。GC.GetTotalMemory(false)GetMonoUsedSizeLong 一致,是这次 Mono 后端上的情况,换成别的后端不一定还是这样。

改分辨率的话工作集也会动。1920x1080 以外的值手上没有。这还是一个每帧改写全部 20,000 个位置的工作负载,往自己的项目上套之前也请先确认这一点。

报数字的时候,也一起说清楚是哪一层

只说“内存是 852 MB”,并不能确定在说什么。同一次测量里,说 8.2 MB 也行,说 1,168.3 MB 也行。

想知道什么,决定了该看哪一层。

  • 我这处改动有没有让引擎握着的量变少:已分配内存
  • 能不能装进设备的内存里:工作集和提交大小
  • GC 压力:托管堆

要做比较的时候,把层、构建类型和分辨率都对齐。如果在用 Framedash,先确认一下自己的引擎送出的是哪一层。