返回全部文章

Unity 的“内存使用量”该看哪个数字?八项测量与 Framedash 上报值

同一个 Unity 游戏,在 Framedash 仪表盘、任务管理器和 C# 堆计数器中显示的内存使用量可能相差很大。我们在 Windows 上运行绘制 20,000 个立方体的演示,读到了 167 MB、739 MB 和 8 MB。这三个数字来自不同的观测:仪表盘和堆的数字是两次不同运行的中位数,任务管理器的 739 MB 则是某一时刻的读数。

比较之前,先要确认各个计数器统计什么。Framedash 的 Unity SDK 上报的是 Unity 分配器正在使用的内存,既不同于托管堆使用量,也不同于进程占用的物理内存。为在相同条件下比较这些计数器,我们对同一个构建逐帧记录了八项读数。

内存使用量的四种统计范围

这些读数可以分为四类。用“层”来理解有助于区分用途,但它们不是严格的包含关系。

  • 托管堆:存放 C# 类实例、数组等对象,由垃圾回收器管理的内存
  • Unity 已分配内存:Unity 分配器内存池中正在使用的部分,包含纹理数据等托管堆之外的引擎内存分配
  • Unity 已保留内存:Unity 向 OS 申请并保留的整个内存池,包括供后续复用的空闲容量
  • 进程内存:OS 视角下的内存,统计范围还包括可执行文件和 DLL 的映射等 Unity 分配器未追踪的部分

OS 也不只有一种统计口径。工作集统计当前驻留在物理内存中的页面;私有提交量统计进程专用的已提交内存,包括已换出的部分。本次测试的 OS 读数大于 Unity 的读数,但不能把不同范围的数字相减,就当作某一类内存的准确用量。图形驱动内存也是独立的计数器,并不是包在外面的第五层。

用两种方式绘制 20,000 个立方体

测试使用上一篇文章的项目,以两种方式绘制同样的 20,000 个立方体:

  • Naive:每个立方体有一个 GameObject 和一个 MeshRenderer
  • InstancedDirect:不为每个立方体创建 GameObject,由脚本调用 Graphics.RenderMeshInstanced 绘制

两者对应上一篇的 NAIVE 和 INSTANCED_DIRECT。环境为 Unity 6000.4.11f1、Built-in Render Pipeline、D3D12、Mono 脚本后端、RTX 4070 Ti、1920x1080。主要比较表采用关闭遥测发送的 Release Player 数据。

每个模式预热 15 秒,再测量 120 秒。在这次实验中,八项读数都在 SDK 的 Collect() 调用结束后、同一帧内采集。表中每个值是对应计数器在测量期间所有帧中的中位数(p50),并不共同代表某一帧的状态。

本次测试的 Mono Player 中,System.Diagnostics.Process.WorkingSet64 和 PrivateMemorySize64 都返回 0。我们改用 P/Invoke 调用 psapi 的 GetProcessMemoryInfo,并与 Player 外部执行的 Get-Process 交叉核对。核对得到的中位数分别为 848.5 MB 和 848.7 MB,相差 0.2 MB。

八项读数,各自取 120 秒的中位数

读取方式 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 -

带底色的行是 Framedash Unity SDK 上报的计数器。Naive 和 InstancedDirect 的帧时间 p50 分别为 9.25 ms 和 1.44 ms,与上一篇的趋势一致。上一篇的 169.0 MB 和本次的 167.2 MB 来自相同构建配置、相同启动参数下的两次独立运行,统计的都是已分配内存。

把六项读取按 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 送出的那一层。

从 Naive 切换到 InstancedDirect 后,Unity 已分配内存减少 66%,托管堆使用量减少 16%,托管堆容量减少 23%。三者描述的是同一次改动。报告“内存减少 66%”时,需要同时说明使用了哪个计数器。

各个计数器能说明什么

托管堆的使用量与容量

GetMonoUsedSizeLong 返回托管堆使用量,包含等待垃圾回收的对象;GetMonoHeapSizeLong 返回托管堆容量。Naive 的中位数分别为 8.2 MB 和 10.3 MB,相差 2.1 MB。堆中存在尚未被对象占用的空间,但这两个中位数之差并不是单独测得的空闲容量。

在本次 Mono 后端测试中,GC.GetTotalMemory(false) 与堆使用量的读数在显示精度内一致;其他后端不保证如此。堆使用量也不能直接说明分配速率或垃圾回收耗时。排查 GC 相关的帧时间问题时,还要在 Profiler 中查看每帧分配量和垃圾回收活动。

Unity 内存池的已用量与保留量

GetTotalAllocatedMemoryLong 返回 Unity 分配器内存池中正在使用的量。8.2 MB 的托管堆读数,仅相当于 167.2 MB 已分配读数的约 5%。托管堆的小幅下降不足以解释已分配内存减少的 110.6 MB。这表明,这次模式切换节省的主要是原生引擎内存;不过本次测试没有按对象拆分内存用量。

GetTotalReservedMemoryLong 还包括池内的空闲容量。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 的工作集中位数是 852.4 MB,比已分配内存中位数 167.2 MB 高 685.2 MB。这个差值来自定义不同的计数器,并不是某一类内存的测量结果。可执行文件和 DLL 的映射、图形驱动与插件的内存分配,都可能不在 Unity 分配器的追踪范围内。本次实验无法确定它们分别贡献了多少。

图形驱动内存与不可用的读数

GetAllocatedMemoryForGraphicsDriver 可在 Editor 和 Development Player 中提供读数,其原生端测量受 ENABLE_PROFILER 控制。本次 Release Player 返回 0,两个 Development 模式则均为 99.0 MB。在这个精度下,没有观察到模式间的差异。

Release 中的 0 表示读数不可用,不代表驱动没有使用内存。API 未返回正值时,Framedash 不发送 mem.vram 键。

任务管理器为什么显示另一个数字

在任务管理器的 进程 选项卡中,该程序的 内存 列显示 738.7 MB。同一时刻,外部查询得到的工作集为 795.5 MB,私有提交量为 1,091.3 MB。显示列统计的是专用工作集,即驻留在物理内存中的进程专用页面。它不包含共享的驻留页面,也不包含已换出的专用页面,因此不同于表中的两个 OS 计数器。

这些是单个时刻的观测值,也就不必与表中 120 秒的中位数 852.4 MB 和 1,168.3 MB 相同。比较时既要对齐计数器,也要对齐采样窗口。

比较改动时,保持构建类型一致

我们还用 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。Development 构建包含性能分析插桩,但仅凭这次比较,无法确定插桩占了多少增量。

工作集的变化方向却不同:Naive 为 792.1 MB,低于 Release;InstancedDirect 为 469.6 MB,高于 Release。分配器使用量上升,并不要求驻留页面量也同步上升。判断优化效果时,应将 Release 与 Release、Development 与 Development 比较,否则构建配置就成了额外变量。

对比 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 的内存字段记录什么

传输 schema 只有一个 int64 内存字段 memory_used_bytes,但它的数据源取决于引擎。仪表盘将字节数除以 1048576 后标为 MB;严格来说,这个除数对应 MiB。

  • Unity:Profiler.GetTotalAllocatedMemoryLong(),即本文读出 167.2 MB 和 56.6 MB 的计数器。SDK 每 10 秒发送一次 perf_heartbeat,其中包含这个值
  • UE5:FPlatformMemory::GetStats().UsedPhysical,是平台提供的进程物理内存统计,不是 Unity 的分配器计数器
  • Godot(C#):Performance.Monitor.MemoryStatic 返回正值时使用该值,否则回退到 System.GC.GetTotalMemory(false)。导出的 Release 构建通常会使用这个托管堆回退值

因此,Godot 的同一字段可能随构建配置改变含义。不要直接比较数据源不同的构建。Unity 的 8.2 MB 托管堆读数和 167.2 MB 已分配读数可以说明这种区别,但不能用作 Godot 的换算比例。

Unity SDK 还会将 GetMonoUsedSizeLong 作为 mem.heap,将 GetAllocatedMemoryForGraphicsDriver 作为 mem.vram 附加发送。只有相应 API 返回正值时才会附带该键,缺少键不等于测到了 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 日志各占多少,运行记录也无法确认两次的日志设置是否一致。

Framedash 的构建比较并排显示 p50 和 p95。即使中位数变化不大,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 和 1920x1080,没有测试 IL2CPP 或其他分辨率。工作负载每帧都会改写全部 20,000 个位置。其他平台、后端、分辨率和工作负载需要重新测量;这里的大小关系与降幅不是 Unity 的通用比例。

先选计数器,再统一比较条件

只报告“内存是 852 MB”,没有说明统计范围。同一次运行还得到了 8.2 MB 和 1,168.3 MB。应根据要回答的问题选择指标:

  • 检查 Unity 分配器使用量的变化:比较已分配内存;若还要了解内存池容量,同时查看已保留内存
  • 评估设备内存需求:查看目标平台的进程工作集和提交量,包括峰值。120 秒的中位数不足以证明游戏能在设备内存中运行
  • 排查垃圾回收问题:结合托管堆使用量、分配速率和垃圾回收活动分析

前后对比时,保持计数器、平台、脚本后端、构建类型、分辨率、工作负载和遥测设置一致,预热时间、采样间隔和聚合窗口也要相同。任务管理器的瞬时值、逐帧 p50 和遥测事件的百分位数,即使都显示为 MB,回答的仍是不同的问题。