"游戏吃了 8GB 内存"是个没法行动的数字。内存问题要分成三层看:系统层(进程实际占了多少)、引擎层(各系统认领了多少)、分配器层(内部碎片浪费了多少)。三层数字永远对不齐,但对账的过程本身就是定位的过程。这一篇讲 UE5.3 的对账工具和一条实战排查路径。
三层账本
- 系统层:任务管理器 / Activity Monitor 看的 RSS。它包含引擎数据、分配器缓存、GPU 驱动映射、第三方库的一切 malloc,是唯一"真实"的数字,但也是最没结构的;
- 引擎层:LLM(Low Level Memory Tracker)按标签记账。
LLM_SCOPE(Stat_XXX)在代码里标作用域,LLM_PLATFORM_SCOPE标平台分配,运行期-llm开启,结果落 CSV(Saved/Profiling/LLM)或控制台stat llm实时看。标签体系把 RSS 拆成 Engine、Rendering、Physics、Audio、UObject 这些桶; - 分配器层:UE 默认 MallocBinned2/3,按尺寸分级池化。记账单位是池粒度,申请 100 字节可能占 128 字节的池槽,差额就是内部碎片。
stat memory和 MemReport 里的 allocator 段能看到池统计。
对账恒等式可以写成:
$$\text{RSS} = \sum \text{LLM 标签} + \text{Untagged} + \text{分配器内部碎片} + \text{驱动/外部映射}$$
三项"解释不了的"里,Untagged 是可控的(该打标签没打),碎片是半可控的(换分配器或调池参数),驱动映射基本不可控(显存映射、IO 缓存)。排查顺序从可控项开始。
Untagged 桶:标签缺失的信号
stat llm 里 Untagged 占比高(经验上超过两三成就该查)说明有大量分配没走 LLM_SCOPE。两个常见来源:第三方库自己 malloc(Lua、物理中间件),以及项目代码里直接 FMemory::Malloc / new 没包作用域。治理手段是在模块初始化处加 LLM_SCOPE,第三方库要么接受它算 Untagged,要么在其分配回调里挂标签。Untagged 降不下去时,对账就永远差一块,后续的所有判断都带着不确定性。
MemReport 实战流程
控制台 MemReport -full 会输出一份全量报告:平台内存概况、分配器池统计、按类统计的 UObject 数量和体积、纹理/声音/动画各自的预算占用。留档做 diff 是主要用法,经典流程:
- 游戏跑到稳态,执行
MemReport -full,存为 baseline; - 反复执行可疑操作 N 次(比如反复进出某个关卡、反复打开关闭 UI);
- 再出一份 MemReport,和 baseline 逐段 diff。
判断标准:做 N 次操作后,UObject 某类的实例数应该回到基线值。回不去就是泄漏特征——要么对象没被 GC 根解除(还挂在某个容器的强引用里),要么资源型对象(纹理、动画)的缓存没清。报告里按类列出的数量和体积就是嫌疑清单,配合 obj list class=XXX 看实例都挂在谁的引用下。
配合 GC 命令把干扰去掉:两次快照之间手动 gc.CollectGarbage,把"只是还没轮到 GC"的对象和"永远 GC 不掉"的对象分开。后者才是真泄漏。
专项:纹理与流送
Rendering 标签下的大头常年是纹理。纹理有两个独立账本要分开看:CPU 侧的解压缩/驻留内存(LLM Rendering 的一部分)和 纹理流送池(r.Streaming.PoolSize,GPU 侧预算,超了会降 mip 导致糊)。stat streaming 看流送状态:池占用率、强制流送的纹理数。池长期顶满不是"内存泄漏",是内容超预算—— mip 尾数、纹理组预算(DeviceProfiles 的 TextureLODGroups)和流送目标精度(r.Streaming.MipBias)是三个常规调节旋钮。
常见误区
- 把 RSS 当指标优化:分配器会缓存 freed 内存不立刻还给系统,RSS 高位不等于泄漏,看趋势不看绝对值;
- 只看总量不分类:MemReport 的价值全在分类段,总量 diff 对不上时先怀疑驱动映射和第三方库,不是引擎泄漏;
- 快照不手动 GC:不 GC 的 diff 里混着"还没到 GC 时机"的噪声,手动 GC 一次再对比,嫌疑名单直接短一半。
内存对账的核心心法:永远拿同一把尺子(LLM 标签 / MemReport 分类段)量同一个时刻(稳态 + 手动 GC),三次以上的测量趋势才构成结论。