UE5 增量垃圾回收 (Incremental Garbage Collection)
增量垃圾回收是 UE 5.4 版本提供的一个新的垃圾回收的方案,其主要是将原始的可达性分析部分,改为分帧执行,以减少 GC 执行时的卡顿感,能提供较优的体验,尤其对于 UObject 数量较多的项目,对于单帧瓶颈、性能峰值的优化很有帮助,目前这一方案还是 Experimental 的,并且需要替换项目中 UPROPERTY 标记的裸指针到 TObjectPtr 才可以启用,项目可以斟酌替换,修改的代码并不复杂,并且有一部分逻辑已经默认启用了,应该也不会有太多问题。
支持引擎版本:5.4
笔者引擎版本:5.5.3
P.S. 推荐 5.5 以后版本再启用,5.5 也有一些更新与修复
一、指针替换
官方提供了指针替换的工具,可以查看如下文档:
这里简述一下:引擎路径换成自己的
- Engine\Programs\UnrealHeaderTool\Config\DefaultEngine.ini 添加修改 NonEngineNativePointerMemberBehavior=AllowAndLog
- Rebuild Project(以便保证搜集到所有的原始指针的 log)
- 引擎 Program 编译 UnrealObjectPtrTool,发布版引擎可以执行直接步骤 4
- Engine\Binaries\Win64\ 路径下编译出的 UnrealObjectPtrTool.exe 添加参数 Engine\Programs\UnrealBuildTool\Log.txt 执行。
注意点:
- 官方参数还指定了 SCCCommand,可能是因为 p4 默认的策略:文件未迁出时为只读。也可以不指定 SCCCommand 命令;
- 推荐用编出来的 exe 执行,如果用 IDE 附加参数启动,执行中也会修改 UnrealBuildTool 其中的文件;
- 推荐 rebuild 编完拷下其中较新的文件,以免出了问题还要重编;
- 找不到 log 看看官方给的其他几个地址,笔者源码版本 5.5 引擎为如上地址。
使用工具跑完以后,会有很多编译错误,一一修复即可,针对容器包裸指针的情况,UE 提供了 MutableView 和 ToRawPtr 宏可以使用,commit 信息如下,看起来是可用的(文档能提一嘴的话就更好了):
修改的内容大体包括:
- 容器包裸指针
- GenerateValueArray 等类似方法
- MutableView 不是 const 的,const 方法期望保持函数签名的话,需要 ConstCast 一下 this
- 容器包裸指针赋值到容器包 TObjectPtr 的情况
- TArrayView 的转换
替换完成后,在项目的 Project.target.cs 和 ProjectEditor.target.cs 添加:
以启用 UBT 的报错。启用后,当出现 UPROPERTY 标记的裸指针时,编译会出现错误:
然后,在项目的 DefaultEngine.ini 添加:
即可启用增量垃圾回收。
二、原理跟踪
详细的 GC 流程不再分析,网上文章也比较多,本文里只简单跟踪一下新版本修改的地方及原理。
新版的核心流程,还是在 FRealtimeGC 类中的 PerformReachabilityAnalysis() 方法中:
这个方法中完成了垃圾回收的前两个阶段:
- StartReachabilityAnalysis -> 标记不可达阶段
- PerformReachabilityAnalysisPass -> 可达性分析阶段
清理回收阶段在外围的 PerformReachabilityAnalysisAndConditionallyPurgeGarbage 方法中,可自行查看。
标记不可达-位翻转
先来看下标记不可达阶段:
转调了 MarkObjectsAsUnreachable 方法:
方法中完成了标记不可达阶段,那就只能是在这一新的方法 SwapReachableAndMaybeUnreachable 中完成的。
末尾的 MarkRootObjectsAsReachable 方法中会完成 RootObject 与传入 Flag Object 的标记与收集,存入 InitialObjects 以便作为后续的 RootSet 来处理。
接下来看下 SwapReachableAndMaybeUnreachable 方法的实现:
可以看到,方法中只是将两个 Flag 的值进行了交换,也就是说,将可达与可能不可达的解释语义翻转了,如此操作后,原始被标记有可达的对象,在新的解释下自然存在的就是不可达的标记,很巧妙的一个方案。
commit 信息中也提到,改造后 "down from up to 20ms to < 1ms",看下面的 MOAU (MarkObjectasUnreachable),平均消耗提升了 16 ms 左右:
但是,位翻转很明显有一个前提,就是所有的 UObject 在翻转以前,都应当具有可达标识,只有这样,才能保证语义翻转后,所有的 UObject 都具有可能不可达的标识。
为了确保这一问题,UE 在将 UObject 添加到全局数组时,会为其附加可达的 Flag:
因此也就可以使用位翻转来快速的完成标记不可达的过程了。
可达性分析-写屏障
在查看具体的代码前,先来考虑一个问题,为什么原来的可达性分析阶段无法采用增量的方案?
这里我们简单构造一个例子来分析下:
假设采用了增量可达性分析,在可达性分析期间,已经处理完了 UObject A 与 UObject B 及其引用链,此时保存上下文,然后外部发生了赋值 UObject D 到 A 的 a1,那么此时:因为 UObject A 已经处理完毕了,当从上下文中恢复继续执行时,并不会再处理到 D,那么很明显,D 就会被视为垃圾处理掉。
为了解决这一问题,其实只要在赋值等操作发生时,执行一些额外的处理即可(写屏障)。这也是为什么新版的增量垃圾回收依赖于 TObjectPtr 的原因。
UE 在 TObjectPtr 中的成员 FObjectPtr ObjectPtr 的构造、赋值,移动构造、赋值中,均会执行一个 ConditionallyMarkAsReachable 方法:
这个方法最终会执行到 MarkObjectItemAsReachable 方法:
可以看到,方法中主要执行了两个操作:
- 标记可达
- 将对象添加到 GReachableObjects 数组中
这里的 GReachableObjects 数组,最终也会被组合到 InitialObjects 中来完成可达性分析的过程:
可以看到,执行了 Private::GReachableObjects.PopAllAndEmpty(InitialObjects),将 GReachableObjects 拷贝到了 InitialObjects 中:
那么基于如上逻辑,我们再来查看刚刚的例子,当在增量可达性分析进行中,发生 A->a1 = D 时,状态就变为了:
此时,D 由于 TObjectPtr 的写屏障,会被 MarkReachable,并且添加到 InitialObjects 中,作为后续需要处理的 Root Object 来处理,因而也就不会被错误的清理掉。
理解了原理,就能感觉到虽然新版的垃圾回收完全变成了增量,但 GC 部分的改动并不算太大,只是额外添加了一个 FReachabilityAnalysisState GReachabilityState 来完成上下文保存与恢复,当发现 GReachabilityState.IsSuspended() 时,恢复上一帧保存的上下文,并将由于写屏障产生的额外 Objects 添加到待处理的数组中,处理超时后,保存上下文留待下帧处理,其它与之前的 GC 流程基本一致,整体逻辑也不算复杂。
三、总结
附一张结果图:
这里找了一个 UObject 总数量差不多的位置,都是 35 万左右,可以明显的看到,增量可达性分析(橙色)的部分被分帧处理,整体而言体验肯定舒适了很多:
启用 TObjectPtr 后,其实还有一点需要考虑,那就是指针的操作性能,无疑是产生了一些额外的损耗,不过这一损耗应该也不会那么敏感,除非单帧存在极高频的指针赋值等操作。
Comments NOTHING