UE 内存 Profile 方法论:memreport 与 Insights 内存 trace 双管齐下
UE 内存 Profile 方法论:memreport 与 Insights 内存 trace 双管齐下
适用引擎:UE 5.4。平台以 Windows(dev/editor)为主,主机同理。
这是一份通用操作方法论,不针对某一次具体分析。单次分析的原始数据建议另存成一份带日期的记录,别和方法论混在一起。文中路径/类名均已脱敏,技术细节原样保留。
内存排查有两套互补工具,各有强项。我的经验是两套一起用、相互印证——单看任何一套都容易把「换血」误判成「泄漏」。
| 工具 | 强项 | 弱项 |
|---|---|---|
| memreport | 快、离线可读、给出各类对象/贴图/RT 的绝对占用,能判断「池子总量是否封顶」 | 只是快照,看不到分配来源/调用栈 |
| Unreal Insights 内存 trace | 逐条分配、时间轴、按 A/B/C 时间点筛「存活增长」、可到调用栈 | 文件巨大;调用栈需额外开启 + 配符号;区分不了「池内换血」和「真泄漏」 |
一、memreport(快照对比法)
1.1 触发
游戏内控制台敲:
memreport -full
- 不带
-full:执行引擎 ini 的[MemReportCommands]段。 - 带
-full:额外执行[MemReportFullCommands]段。 - 命令清单在
Engine/Config/BaseEngine.ini;项目可在Config/DefaultEngine.ini(或平台 ini)用+Cmd="..."追加。
注意:不同 build/配置实际跑出的命令可能被裁剪。跑之前先确认
[MemReportCommands]里有下面这几条,否则查泄漏最有用的数据不会出来:+Cmd="Mem FromReport" ; 整体物理内存 +Cmd="obj list -resourcesizesort" ; 全类对象(不是只有几类) +Cmd="ListSpawnedActors" ; 所有已生成 actor(查战斗对象泄漏神器) +Cmd="LogCountedInstances"
1.2 输出位置
<Build>/Windows/<Project>/Saved/Profiling/MemReports/<关卡>-<时间>/PidXXXX_....memreport
.memreport 是纯文本,可直接打开读。控制台敲的所有命令输出同时写进日志:
<Build>/Windows/<Project>/Saved/Logs/<Project>.log (当前)
<Build>/Windows/<Project>/Saved/Logs/<Project>-backup-*.log(历史轮转)
1.3 单独命令(不想跑整套 memreport 时)
Mem // 整体物理内存
obj list -countsort // 全类对象,按实例数排序
obj list -resourcesizesort // 按资源大小排序
obj list class=Projectile // 指定类
ListSpawnedActors // 所有已生成 actor
obj list 输入可以在日志看到
1.4 用法:战前 / 战后各打一份,对比
关注:
- 各 Class 的 Count 是否单调增长(尤其战斗系:
Projectile、GameplayEffect、AbilityTask、AGameCharacter、PooledActor)。池化对象只增不减 = 没归还到对象池。 - 贴图/网格常驻内存是否封顶。贴图池有预算,战斗中换内容属正常;总量不涨甚至回落 = 不是泄漏。
memreport 里 obj list 汇总行示例:
29833 Objects (Total: 107.024M / Max: 113.202M / Res: 102.636M | ...)
二、Unreal Insights 内存 trace
2.1 录制
启动带内存 channel 的 trace(命令行示例):
-trace=memory,default,counters
这个只能在包启动的时候打开
想要分配调用栈(定位到函数/代码行),必须额外开启内存 callstack 捕获,并配符号路径:
- 设环境变量
UE_INSIGHTS_SYMBOL_PATH指向本次 build 的 PDB 目录; - 或在 Insights 的 Modules 视图里手动加载符号。
若不配符号,导出的
Function (Alloc)/Alloc Callstack大量会是No callstack recorded!0x00000000 (Not found),只能靠 LLM Tag 定方向、无法到函数。
2.2 Memory Insights → Investigation(查泄漏核心)
打开 trace 后切到 Memory Insights → Investigation 标签页。
在时间轴上设时间标记(A/B/C),选 Rule,Run Query:
| Rule | 含义 | 用途 |
|---|---|---|
Active Allocs (*A*) | A 时刻活着的全部 | 基线快照 |
Growth (A*B*) | A→B 分配、B 时仍存活 | 两点快查(含 churn,会高估) |
Memory Leaks (A*B C*) | A→B 分配、一直活到 C | 真泄漏(推荐) |
Short Living (A**B) | A→B 分配又释放 | 用完就还,健康 |
Long Living (*A B*) | A、B 都活 | 常驻资源 |
Decline (*A*B) | A 前分配、A→B 释放 | 减少的 |
记号约定:A B C 是时间标记,* 表示「这段区间存活」。
2.3 规则怎么选
- 只有两点、快速看方向 →
Growth (A*B*)。 - 要定性是不是泄漏 →
Memory Leaks (A*B C*):- A = 行为前(如战斗前);
- B = 行为后(如战斗后);
- C = 再往后一段,且必须在一次真正的 GC 之后。
2.4 导出
结果表格(如 Allocs Table)里右键 → Export:
Export Visible Tree to File(CTRL+E):只导当前可见(先分组 + 排序后用这个,文件小、可读)。Export Entire Tree (+ Leaf Nodes)(SHIFT+E):连每条叶子全导(几十万行、几百 MB,除非要逐条别用)。
导之前先把表变得能读:左上 Hierarchy 下拉改成按 LLM Tag 或 Function (Alloc) 分组,点 Size 表头降序。
导出的 CSV 是 UTF-16、带引号字段(调用栈内含逗号),别用简单 , 切分。
2.5 大 CSV 聚合脚本
导出常有几十万行、几百 MB,直接看不了。用 Python 流式聚合(正确处理 UTF-16 + 引号):
import csv
from collections import defaultdict
path = r"<导出的 CSV 路径>"
# 列序:0 StartIdx 1 EndIdx 2 EventDist 3 StartTime 4 EndTime 5 Duration
# 6 Address 7 MemPage 8 Size 9 LLMTag 10 Asset 11 ClassName
# 12 Function(Alloc) 13 Function(Free) 14 Source(Alloc) 15 Source(Free)
# 16 AllocCallstack 17 FreeCallstack
tag_sz=defaultdict(int); tag_ct=defaultdict(int)
fn_sz=defaultdict(int); fn_ct=defaultdict(int)
tot_sz=tot_ct=0
with open(path, encoding="utf-16") as f:
r=csv.reader(f); next(r)
for row in r:
if len(row)<13: continue
try: sz=int(row[8])
except: continue
tag=row[9].strip(); fn=row[12].strip()
tot_sz+=sz; tot_ct+=1
tag_sz[tag]+=sz; tag_ct[tag]+=1
fn_sz[fn]+=sz; fn_ct[fn]+=1
mb=lambda b:b/1048576.0
print(f"TOTAL {tot_ct:,} allocs {mb(tot_sz):.1f} MB")
print("== BY LLM TAG ==")
for k in sorted(tag_sz,key=tag_sz.get,reverse=True)[:25]:
print(f"{mb(tag_sz[k]):9.1f} MB {tag_ct[k]:>8,} {k}")
print("== BY FUNCTION(ALLOC) ==")
for k in sorted(fn_sz,key=fn_sz.get,reverse=True)[:30]:
print(f"{mb(fn_sz[k]):9.1f} MB {fn_ct[k]:>8,} {k[:110]}")
三、结果判读(最容易踩坑的地方)
3.1 「增长」不等于「泄漏」
Growth / Memory Leaks 统计的是按分配年龄筛出的存活量,它区分不了两种情况:
- 固定预算池换内容(例:贴图流送。战斗拉进新贴图、挤掉旧贴图,池总量封顶)——不是泄漏。
- 无上限累积——是泄漏。
判断依据要靠 memreport:如果某类(尤其贴图)在 Insights 里「增长」很大,但 memreport 显示其常驻总量稳定甚至回落,那就是换血、不是泄漏。
3.2 按数量 vs 按大小
- 几百 MB 的大分配常是贴图/RT,多为流送/渲染临时。
- 数量极大、单个极小的存活者(EngineMisc / UObject / Niagara 动辄十万条)——按数量看的累积更像泄漏征兆,即便总大小不大。重点看这些。
3.3 C 点没跑过 GC = 结论无效
若 B→C 之间没有真正的 GC,Memory Leaks 筛出的「存活对象」其实是还在使用的现场状态(敌人没死、GE/Cue 还生效),不是泄漏。
- 判据:对比
Growth与Memory Leaks两版,如果 UObject/EngineMisc 数量几乎没掉(只降个位数百分比),说明 GC 没跑,C 无效,需重录。
四、干净的泄漏测试 Checklist
- 录 trace 时开
-trace=memory,...+ 内存 callstack + 配UE_INSIGHTS_SYMBOL_PATH。 - A 标记:行为发生前。
- 执行行为(如一整场战斗)。
- 行为彻底结束:敌人清场、VFX 播完。
- 控制台
obj gc强制 GC(或等 AutoGC),等 GC 真正跑完。 - C 标记:GC 之后。(B 可设在行为刚结束时)
Memory Leaks (A*B C*)→ 按 LLM Tag 分组 + Size 降序 →Export Visible Tree。- 同时跑战前/战后两份
memreport -full做绝对量对比。 - 判读:过 C 仍不回落、且对应 memreport 常驻总量持续上涨(非封顶)→ 才是泄漏,再开调用栈定位到函数。
把上面第 9 步的判读逻辑画成决策流,遇到「到底算不算泄漏」时照着走:
五、相关路径速查
| 内容 | 路径 |
|---|---|
| memreport 输出 | <Build>/Windows/<Project>/Saved/Profiling/MemReports/ |
| 运行日志 | <Build>/Windows/<Project>/Saved/Logs/<Project>.log |
| Insights trace | <TraceDir>/<Build>/<时间>/ |
| memreport 命令配置 | Engine/Config/BaseEngine.ini 的 [MemReportCommands] / [MemReportFullCommands] |