用 Unreal Insights 做 Trace 与单帧性能分析的方法论
用 Unreal Insights 做 Trace 与单帧性能分析的方法论
这是一份把 Unreal Insights trace 当成工程数据来读的指南。重点不是"怎么打开 Insights",而是抓到一份 trace 之后,怎么从里头逐层读出可执行的优化结论——以及一路上的坑。文中所有数据示例均来自一个 UE 5.4 的 ARPG 项目在 Win64 Development / Test 构建下的实测,已脱敏。
1. 为什么需要"方法论",而不是只看 Insights GUI
Insights GUI 直观,但有两个固有问题:
- 它一次只能让你看一个视角——Timing / Tasks / Counters / Memory 切来切去,跨视角对照人脑负担大;
- 它会让你把 "Scope 时间"当帧率——这是新人最常踩的雷(见 §3.1)。
实际项目里更常用的工作流是:
把 trace 变成 CSV 后,可以批量统计上千帧、对比多次 trace、做帧时分布直方图——这些都是 GUI 里点不动的。
2. 帧时间数据源——五种数字,别再混着用
性能分析里最容易出现的低级错误是把不同来源的"帧时间"拿来直接比。下面这张对照是项目落地后做的统一约定。
2.1 各数据源定义速查
| 数据源 | 来自哪里 | 含义 | 是否平滑 |
|---|---|---|---|
| Trace 报告"壁钟帧间隔" | Insights TimingEvents.csv 中相邻 FEngineLoop::Tick 的 StartTime 差 | 整帧时长(含 GT+RT+GPU+Present+VSync 等待) | 否(原始) |
stat fps | 引擎运行时面板,UEngine::UpdateTimeAndHandleMaxTickRate 维护 | 整帧时长,UI 显示 | 是(指数滑动平均) |
stat unit:Frame | 同上,但独立列出 | 整帧时长 ≈ stat fps | 视引擎版本,通常轻度平滑 |
stat unit:Game/Draw/GPU/RHIT | 各线程 scope 累计 | 某条线程的忙时(不是整帧) | 否 |
Trace "Scope 帧时间"(FEngineLoop::Tick 自身 EndTime-StartTime) | Insights 中 Tick scope 时长 | GameThread C++ scope 忙时——通常远小于整帧 | 否 |
2.2 各数据源用在什么场景
| 目的 | 用什么 |
|---|---|
| 和 30/60 FPS 目标对照 | stat unit:Frame 或 trace 壁钟帧间隔 的 P95/P99(不要用平均) |
| 玩家主观流畅度 | stat fps(带平滑,接近主观感受) |
| 找 spike 帧 | 原始未平滑数据——trace 壁钟间隔、csvprofiler FrameTime 原始列 |
| 区分 CPU / GPU 瓶颈 | stat unit 的 Game / Draw / GPU 分项,配合 ProfileGPU |
| 衡量 GameThread 工作量 | Insights 的 FEngineLoop::Tick scope 时长(不用作帧率) |
| 衡量渲染工作量 | Insights 的 FDrawSceneCommand / FDeferredShadingSceneRenderer_Render |
| 衡量 GPU 工作量 | stat GPU + ProfileGPU —— Insights 的 GPU scope 采样粒度不一定准确 |
2.3 四个最常见的坑
坑 1:把 Scope 帧时间当帧率
某次 trace 里 FEngineLoop::Tick scope 平均 0.09 ms——机械换算出来 11,000 FPS。看着就离谱。正确的解读是:
0.09 ms 只是 GameThread C++ scope 忙时(很快就 yield 给 RT/GPU 做并行工作)
真实帧率要看壁钟间隔。同一份 trace 的壁钟均值 27.6 ms = 36 FPS。
报告里别照抄 "scope FPS",那只是个机械数字。
坑 2:用 4 帧样本做均值汇报
错误:"本次采集平均帧率 36.2 FPS"
正确:"共 4 个 Tick scope 抓帧,2 个有效壁钟间隔,均值 27.6 ms。样本过少仅作参考。"
经验值:有效样本 < 30 帧 时,报告里要标注"样本数不足",并优先用最差帧 / P95 替代均值。
坑 3:拿 Scope 占比直接对比不同 trace
Scope 占比(如 GPU 类别占总时间 25%)受 GameThread 其它工作影响。两次 trace 的 GPU Scope 占比一升一降,不等于 GPU 真的恶化或改善。要下定量结论必须拿绝对 ms(来自 ProfileGPU / stat GPU)。
坑 4:没剔除无效样本
Insights 分段导出时,同一段内相邻两帧的起始时间差可能极小(trace 缓冲拼接伪像)——见过 0.11 ms 的"最佳帧",那不是真的,是同一帧被拆成了两段。对 < 1 ms 的间隔应当人工剔除。
3. 抓到 trace 之后,先看什么
一个标准的 trace 报告 / 分析过程,按从粗到细的顺序大致是:
下面分别讲每一步要看什么、怎么用结论指向下一步。
3.1 帧时分布——比均值更有用
均值会被尖峰带飞。一份 trace 真正能让人下结论的,是帧时桶分布:
区间 帧数 占比
< 8ms (>125 FPS) 0 0.0%
8–16.67ms (60–125 FPS) 0 0.0%
16.67–33.33ms (30–60 FPS) 0 0.0%
33.33–50ms (20–30 FPS) 15 78.9%
50–100ms (10–20 FPS) 3 15.8%
>100ms (<10 FPS) 1 5.3%
这告诉你两件事:
- 稳态在哪个区间:78.9% 落在 20–30 FPS 桶,说明常态就到不了 30 FPS——这是稳态瓶颈问题;
- 有没有尖峰:5.3% 落在 < 10 FPS 桶,说明有偶发卡顿——这是单帧 spike问题。
这两类问题必须分开治理。稳态瓶颈靠系统性优化(关 Tick、Significance LOD、Nanite 转换),单帧 spike 靠定位具体调用链(同步 I/O、关卡流送、GC、批量 spawn)。
3.2 线程负载——找到临界路径
把所有线程的 top-level 累计耗时摆出来:
GameThread: 963.06 ms (= FEngineLoop::Tick 总和)
RenderThread 0: 916.34 ms
RHIThread: 912.36 ms
GPU1: 756.01 ms ← 落后 GT ~200ms,GPU 不是瓶颈
Foreground Worker #0/#1: 430 / 478 ms
判定规则:
- 三条主线程几乎同步(GT ≈ RT ≈ RHI):CPU bound,且并行度不足;
- GPU 明显高于 GT:GPU bound,GameThread 等 GPU;
- GT 明显高于 RT/GPU:GameThread bound(典型场景:Tick 过量、蓝图逻辑爆炸);
Wait/Idle类别占比 > 15%:哪条线程在空等,谁就是被另一条拖住的——找出阻塞者。
例如:某帧 GameThread WaitForTasks 78 ms(占 65%),同时 RT WaitForTasks 79 ms——说明 GT 和 RT 都在等任务,真正的工作在 Worker 线程上完成,瓶颈是任务调度 / 关键路径任务。
3.3 类别耗时——锁定子系统
把 timer 按系统打 tag(Rendering / Tick / Animation / Physics / UI/Slate / Wait/Idle / Other ...),然后看哪个类别每帧占比最高:
| 类别 | 每帧均值 (ms) | 占比 |
|---|---|---|
| Other(含未分类) | 935.9 | 46.5% |
| Rendering | 302.8 | 15.0% |
| Tick/Timer | 201.3 | 10.0% |
| Wait/Idle | 141.6 | 7.0% |
| Shadows | 114.6 | 5.7% |
| UI/Slate | 59.2 | 2.9% |
| ... | ... | ... |
踩雷点:类别累计是跨线程 inclusive 总和,父子事件会叠加,不能跟整帧时间直接比,也不能跨类别求和。只能用作"哪个子系统重"的相对指标。
3.4 Top-N 热点——按 inclusive vs self 看两遍
按 inclusive 时间排序的 Top 30,能告诉你哪条调用链重;按 self / exclusive 时间排序,则告诉你哪个函数本身重。两份榜要分别看:
- inclusive 榜里
FEngineLoop::Tick/UWorld_Tick/WaitForTasks一定排前——这些是"包裹一切"的容器,告诉你哪条主线程繁忙,但不是优化目标本身; - self 榜里的函数才是优化候选。例如
UPhysicsControlComponent::UpdateControls自耗时 5.5 ms × 31 实例——这是直接可以下手的对象。
3.5 Spike Analysis——单次调用 > 1ms 的摘要
最差帧分析里,把所有 单次 Max > 1ms 的 timer 列一张表:
| Max (ms) | 调用次数 | 类别 | 名称 |
|---|---|---|---|
| 35.19 | 17 | UI/Slate | SlateDrawWindowsCommand |
| 16.98 | 5 | Other | WinPumpMessages(含同步配置写盘) |
| 15.33 | 1 | AssetLoading | FRenderAssetStreamingMipCalcTask_DoWork |
| 14.12 | 1 | Other | FConfigCacheIni::Flush |
| 14.05 | 1 | Other | SaveConfigFileWrapper |
"调用次数 = 1"且耗时 > 10ms 的条目,几乎一定是意外的同步操作——同步写盘、关卡流送阻塞、强制 GC、全量 Mip 重算。这些是 P0 候选。
3.6 最差帧调用树——线索的终点
抓到最差帧后,至少导出四个线程的 callee 树:GameThread / RenderThread 0 / RHIThread / GPU1。
读法:
- 找 incl 时间最大、且 excl/incl 比例大的节点——它本身就是热点;
- incl 远大于 excl 的节点是容器,要钻进去看子节点;
- excl 大但 cnt 小的节点是单次重操作(同步 I/O、阻塞调用);
- excl 小但 cnt 大的节点是高频小操作(值得批量化或缓存)。
一个典型的最差帧 GameThread callee 树骨架:
+ FEngineLoop::Tick incl=114.05ms excl=4.83
+ UWorld_Tick incl=99.95ms excl=4.49
+ TickCompletionEvents (x7) incl=86.35ms
+ WaitUntilTasksComplete (x7) incl=86.00ms
+ ExecuteTask (x1069) incl=84.48ms excl=9.08
+ FActorComponentTickFunction::ExecuteTick (x95) incl=71.68ms
+ UCharacterMovementComponent_TickComponent (x19) incl=66.91ms
+ USkeletalMeshComponent_TickAnimation (x19) incl=65.59ms excl=45.94
+ FClothingSimulation_CreateActor (x2) incl=1.07ms ← spawn hitch 证据
+ Slate::Tick → DrawWindows → PaintSlowPath incl=2.73ms
+ WinPumpMessages incl=16.98ms
+ FConfigCacheIni::Flush incl=14.12ms
+ SaveConfigFileWrapper incl=14.05ms ← 同步写盘
每个 + 节点都可以问:"这条路径在最差帧是否本来就该这么走?" 例如 FClothingSimulation_CreateActor 出现在最差帧——说明那一帧创建了角色(spawn hitch)。SaveConfigFileWrapper 出现在帧内——说明配置 ini 在战斗中被同步写盘。这些都直接指向具体的 bug / 设计缺陷。
4. 单帧分析——抽一帧看个透
整体趋势看完之后,抽一帧最差帧做单帧分析,是另一个独立维度。单帧分析的产出,是一份比整体 trace 报告更具体的"这一帧到底干了什么"清单。
4.1 单帧分析报告骨架
4.2 单帧 GameThread 自耗时 Top N
# | 函数 | self ms | 帧占% | calls
1 | FEngineLoop::Tick | 14.0 | 7.5% | 1
2 | WaitUntilTasksComplete | 7.2 | 3.9% | 41
3 | UPhysicsControlComponent::UpdateControls | 5.5 | 2.9% | 31
4 | UWorld_Tick | 5.3 | 2.9% | 1
5 | HandleCollisionTraceHitEvent | 4.4 | 2.3% | 2
6 | UCharacterMovementComponent_TickComponent | 4.2 | 2.2% | 22
7 | InteracteeCollision | 4.0 | 2.1% | 44
...
这张表能立刻引出几个判断:
UPhysicsControlComponent::UpdateControls × 31—— 31 个组件每帧都在 Tick,需要 Significance 节流;UCharacterMovementComponent × 22—— 22 个角色全速 Tick,同上;InteracteeCollision × 44—— 每个角色每帧多次 sweep 检测,可考虑远处关闭;HandleCollisionTraceHitEvent × 2—— 自耗时 4.4 ms(含子调用 21.9 ms)—— 单帧两次伤害链触发。
4.3 GPU 阶段分解
GPU 端不挑帧也能看出问题,但单帧分解能告诉你这一帧 GPU 是不是被某个 pass 拖累:
Basepass 9.07 ms (NaniteBasePass 仅 1.25 ms → 7.82 ms 是非 Nanite 网格)
RenderVelocities 6.44 ms
ShadowDepths 6.10 ms
RenderDeferredLighting 4.15 ms (LumenReflections 0.67 ms)
Lights 3.06 ms (ShadowProjection 0.59 ms)
Postprocessing 1.63 ms (FSR Upscaling 1.10 ms)
NaniteVisBuffer 0.80 ms
Prepass 0.75 ms
判读:
Basepass中 NaniteBasePass 只占 14% → 场景里仍有大量非 Nanite 静态网格,应识别并转换;RenderVelocities6.44 ms 偏高 → 动态对象 transform 写入量大,可考虑r.VelocityOutputPass=2合并到 Basepass;ShadowDepths6.10 ms → 检查投影光源数量、r.Shadow.RadiusThreshold是否能调高让小物件不投阴影。
5. 写报告的 Checklist
每次 trace 报告交付之前对照这一份清单:
6. 帧时分类 + 关键 cvar 一览
整理一份团队内常用的 cvar / 配置,让 trace 结论可以直接转为可落地的开关:
6.1 渲染
; 阴影
r.Shadow.MaxResolution=1024
r.Shadow.MaxCSMResolution=1024
r.Shadow.RadiusThreshold=0.04 ; 屏幕投影半径 < 4% 不投影
r.Shadow.DistanceScale=0.85
; Velocity Pass 合并入 Basepass
r.VelocityOutputPass=2
; Lumen 战外降频
r.LumenScene.Radiosity.UpdateFactor=8
; Streaming
r.Streaming.PoolSize=3072
r.Streaming.FramesForFullUpdate=5
r.Streaming.MaxNumTexturesToStreamPerFrame=10
6.2 物理
bSubstepping=True
bSubsteppingAsync=True
MaxSubsteps=6
MaxSubstepDeltaTime=0.01667
MaxPhysicsDeltaTime=0.1
bEnableStabilization=True
MaxDepenetrationVelocity=500
6.3 GC
gc.TimeBetweenPurgingPendingKillObjects=0.5
gc.MaxObjectsNotConsideredByGC=5000
gc.CreateGCClusters=1
gc.NumRetriesBeforeForcingGC=10
6.4 Mesh Draw
r.MeshDrawCommands.DynamicInstancing=1
7. 参考
- 引擎源码:
Runtime/Engine/Private/UnrealEngine.cpp—UEngine::UpdateTimeAndHandleMaxTickRate、FApp::GetDeltaTimeRuntime/Core/Public/Misc/App.h—FApp时间接口Runtime/Core/Public/Stats/StatsData.h—stat命令族Runtime/Core/Public/ProfilingDebugging/CsvProfiler.h— csvprofiler
- 同目录文章: