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 信息如下,看起来是可用的(文档能提一嘴的话就更好了):
commit de8db5ff7665e5c3db77b0782793c35763007d55
Author: kirill zorin <kirill.zorin@epicgames.com>
Date: 2023/5/16 22:52
Converting ARO-facing raw pointers to TObjectPtr ahead of raw pointer ARO API deprecation.
#rb zousar.shaker
#rb markus.breyer
#rb robert.manuszewski
#preflight 646391406b1406b54ab15460
[CL 25489627 by kirill zorin in ue5-main branch]
修改的内容大体包括:
- 容器包裸指针
- GenerateValueArray 等类似方法
- MutableView 不是 const 的,const 方法期望保持函数签名的话,需要 ConstCast 一下 this
- 容器包裸指针赋值到容器包 TObjectPtr 的情况
- TArrayView 的转换
替换完成后,在项目的 Project.target.cs 和 ProjectEditor.target.cs 添加:
NativePointerMemberBehaviorOverride = PointerMemberBehavior.Disallow;
以启用 UBT 的报错。启用后,当出现 UPROPERTY 标记的裸指针时,编译会出现错误:
11>Fxxxxxxxxxxxx.h(31): Error : Native pointer usage in member declaration detected [[[UObject*]]]. This is disallowed for the target/module, consider TObjectPtr as an alternative.
然后,在项目的 DefaultEngine.ini 添加:
[ConsoleVariables]
gc.AllowIncrementalReachability=1 ;启用增量可达性分析
gc.AllowIncrementalGather=1 ;启用增量收集不可达对象
gc.IncrementalReachabilityTimeLimit=0.002 ;将软时间限制设置为2毫秒
即可启用增量垃圾回收。
二、原理跟踪
详细的 GC 流程不再分析,网上文章也比较多,本文里只简单跟踪一下新版本修改的地方及原理。
新版的核心流程,还是在 FRealtimeGC 类中的 PerformReachabilityAnalysis() 方法中:
void PerformReachabilityAnalysis(EObjectFlags KeepFlags, const EGCOptions Options)
{
...
if (!GReachabilityState.IsSuspended())
{
StartReachabilityAnalysis(KeepFlags, Options);
}
...
{
while (true)
{
PerformReachabilityAnalysisPass(Options);
...
if (GReachabilityState.IsSuspended())
{
if (EnumHasAnyFlags(Options, EGCOptions::IncrementalReachability))
{
break;
}
}
}
}
...
}
这个方法中完成了垃圾回收的前两个阶段:
- StartReachabilityAnalysis -> 标记不可达阶段
- PerformReachabilityAnalysisPass -> 可达性分析阶段
清理回收阶段在外围的 PerformReachabilityAnalysisAndConditionallyPurgeGarbage 方法中,可自行查看。
标记不可达-位翻转
先来看下标记不可达阶段:
void StartReachabilityAnalysis(EObjectFlags KeepFlags, const EGCOptions Options)
{
...
MarkObjectsAsUnreachable(KeepFlags);
...
}
转调了 MarkObjectsAsUnreachable 方法:
FORCENOINLINE void MarkObjectsAsUnreachable(const EObjectFlags KeepFlags)
{
...
// Don't swap the flags if we're re-entering this function to track garbage references
if (const bool bInitialMark = !Stats.bFoundGarbageRef)
{
// This marks all UObjects as MaybeUnreachable
FGCFlags::SwapReachableAndMaybeUnreachable();
}
...
MarkClusteredObjectsAsReachable(GatherOptions, InitialObjects);
MarkRootObjectsAsReachable(GatherOptions, KeepFlags, InitialObjects);
}
方法中完成了标记不可达阶段,那就只能是在这一新的方法 SwapReachableAndMaybeUnreachable 中完成的。
末尾的 MarkRootObjectsAsReachable 方法中会完成 RootObject 与传入 Flag Object 的标记与收集,存入 InitialObjects 以便作为后续的 RootSet 来处理。
接下来看下 SwapReachableAndMaybeUnreachable 方法的实现:
FORCEINLINE static void SwapReachableAndMaybeUnreachable()
{
// It's important to lock the global UObjectArray so that the flag swap doesn't occur while a new object is being created
// as we set the GReachableObjectFlag on all newly created objects
GUObjectArray.LockInternalArray();
Swap(ReachableObjectFlag, MaybeUnreachableObjectFlag);
...
GUObjectArray.UnlockInternalArray();
}
可以看到,方法中只是将两个 Flag 的值进行了交换,也就是说,将可达与可能不可达的解释语义翻转了,如此操作后,原始被标记有可达的对象,在新的解释下自然存在的就是不可达的标记,很巧妙的一个方案。
commit 信息中也提到,改造后 "down from up to 20ms to < 1ms",看下面的 MOAU (MarkObjectasUnreachable),平均消耗提升了 16 ms 左右:
commit 3f219e2bdda1315c5d4c1fe761b0e4dcfe9e056d
Author: robert manuszewski <robert.manuszewski@epicgames.com>
Date: 2024/1/15 16:22
Fast MarkObjectsAsUnreachable. Slightly faster on the client AND SIGNIFICANTLY FASTER on the server where everything runs single-threaded (down from up to 20ms to < 1ms so no need to make it incremental).
- Introduced UE::GC::GReachableObjectFlag which makes flipping all objects from Reachable -> MaybeUnreachable a single Swap() function call.
- Roots and clustered objects are then marked back as Reachable (instead of MaybeUnreachable) but there's significantly fewer of these than all objects that previously needed to be marked as MaybeUnreachable.
- ReachabilityAnalysis now clears MaybeUnreachable AND marks as Reachable (no observable perf regression because of that)
TestClient ReplayRun results (MOAU - MarkObjectsAsUnreachable, RA - ReachabilityAnalysis)
Before:
Min MOAU Time: 0.367427
Max MOAU Time: 1.21097
Avg MOAU Time: 0.709261134146342
Min RA Time: 3.46
Max RA Time: 16.03
Avg RA Time: 11.2565957446808
After:
Min MOAU Time: 0.250281
Max MOAU Time: 1.280585
Avg MOAU Time: 0.692970346153846
Min RA Time: 3.72
Max RA Time: 13.97
Avg RA Time: 11.0717777777778
#rb Johan.Torp
[CL 30615034 by robert manuszewski in ue5-main branch]
但是,位翻转很明显有一个前提,就是所有的 UObject 在翻转以前,都应当具有可达标识,只有这样,才能保证语义翻转后,所有的 UObject 都具有可能不可达的标识。
为了确保这一问题,UE 在将 UObject 添加到全局数组时,会为其附加可达的 Flag:
void FUObjectArray::AllocateUObjectIndex(UObjectBase* Object, EInternalObjectFlags InitialFlags, int32 AlreadyAllocatedIndex, int32 SerialNumber)
{
...
if (!(IsOpenForDisregardForGC() & GUObjectArray.DisregardForGCEnabled())) //-V792
{
// It's safe to access FGCFlags::GetReachableFlagValue_ForGC() here because creating new objects is being performed
// under the same UObjectArray lock as swapping reachability flags inside of GC, see FGCFlags::SwapReachableAndMaybeUnreachable()
ObjectItem->Flags |= (int32)UE::GC::Private::FGCFlags::GetReachableFlagValue_ForGC();
}
...
}
因此也就可以使用位翻转来快速的完成标记不可达的过程了。
可达性分析-写屏障
在查看具体的代码前,先来考虑一个问题,为什么原来的可达性分析阶段无法采用增量的方案?
这里我们简单构造一个例子来分析下:
假设采用了增量可达性分析,在可达性分析期间,已经处理完了 UObject A 与 UObject B 及其引用链,此时保存上下文,然后外部发生了赋值 UObject D 到 A 的 a1,那么此时:因为 UObject A 已经处理完毕了,当从上下文中恢复继续执行时,并不会再处理到 D,那么很明显,D 就会被视为垃圾处理掉。
为了解决这一问题,其实只要在赋值等操作发生时,执行一些额外的处理即可(写屏障)。这也是为什么新版的增量垃圾回收依赖于 TObjectPtr 的原因。
UE 在 TObjectPtr 中的成员 FObjectPtr ObjectPtr 的构造、赋值,移动构造、赋值中,均会执行一个 ConditionallyMarkAsReachable 方法:
explicit FORCEINLINE FObjectPtr(UObject* Object)
: Handle(UE::CoreUObject::Private::MakeObjectHandle(Object))
{
#if UE_OBJECT_PTR_GC_BARRIER
ConditionallyMarkAsReachable(Object);
#endif // UE_OBJECT_PTR_GC_BARRIER
}
#if UE_WITH_OBJECT_HANDLE_LATE_RESOLVE
explicit FORCEINLINE FObjectPtr(FObjectHandle Handle)
: Handle(Handle)
{
#if UE_OBJECT_PTR_GC_BARRIER
ConditionallyMarkAsReachable(*this);
#endif // UE_OBJECT_PTR_GC_BARRIER
}
#endif
// TObjectPtr 的构造也会显式的调用 FObjectPtr 的相关方法,避免按位拷贝等导致的问题
#if UE_OBJECT_PTR_GC_BARRIER
TObjectPtr(TObjectPtr<T>&& Other)
: ObjectPtr(MoveTemp(Other.ObjectPtr))
{
}
TObjectPtr(const TObjectPtr<T>& Other)
: ObjectPtr(Other.ObjectPtr)
{
}
#else
TObjectPtr(TObjectPtr<T>&& Other) = default;
TObjectPtr(const TObjectPtr<T>& Other) = default;
#endif // UE_OBJECT_PTR_GC_BARRIER
这个方法最终会执行到 MarkObjectItemAsReachable 方法:
template <bool bIsVerse>
FORCEINLINE static void MarkObjectItemAsReachable(FUObjectItem* ObjectItem)
{
using namespace UE::GC;
using namespace UE::GC::Private;
#if WITH_VERSE_VM || defined(__INTELLISENSE__)
if constexpr (bIsVerse)
{
// When verse VM is enabled, this method is also used to report that a UObject is being referenced inside of a VCell
checkf(GIsFrankenGCCollecting, TEXT("%s is marked as MaybeUnreachable but Reachability Analysis is not in progress"), *static_cast<UObject*>(ObjectItem->Object)->GetFullName());
}
else
#endif
{
checkf(GIsIncrementalReachabilityPending, TEXT("%s is marked as MaybeUnreachable but Incremental Reachability Analysis is not in progress"), *static_cast<UObject*>(ObjectItem->Object)->GetFullName());
}
if (FGCFlags::MarkAsReachableInterlocked_ForGC(ObjectItem))
{
if (ObjectItem->GetOwnerIndex() >= 0)
{
// This object became reachable so add it to a list of new objects to process in the next iteration of incremental GC because
// we need to mark objects it's referencing as reachable too
GReachableObjects.Push(static_cast<UObject*>(ObjectItem->Object));
}
else
{
GReachableClusters.Push(ObjectItem);
}
}
}
可以看到,方法中主要执行了两个操作:
- 标记可达
- 将对象添加到 GReachableObjects 数组中
这里的 GReachableObjects 数组,最终也会被组合到 InitialObjects 中来完成可达性分析的过程:
void PerformReachabilityAnalysisPass(const EGCOptions Options)
{
...
if (!Private::GReachableObjects.IsEmpty())
{
// Add objects marked with the GC barrier to the inital set of objects for the next iteration of incremental reachability
Private::GReachableObjects.PopAllAndEmpty(InitialObjects);
GGCStats.NumBarrierObjects += InitialObjects.Num();
UE_LOG(LogGarbage, Verbose, TEXT("Adding %d object(s) marker by GC barrier to the list of objects to process"), InitialObjects.Num());
ConditionallyAddBarrierReferencesToHistory(*Context);
}
else if (GReachabilityState.GetNumIterations() == 0 || (Stats.bFoundGarbageRef && !GReachabilityState.IsSuspended()))
{
Context->InitialNativeReferences = GetInitialReferences(Options);
}
if (!Private::GReachableClusters.IsEmpty())
{
// Process cluster roots that were marked as reachable by the GC barrier
TArray<FUObjectItem*> KeepClusterRefs;
Private::GReachableClusters.PopAllAndEmpty(KeepClusterRefs);
for (FUObjectItem* ObjectItem : KeepClusterRefs)
{
// Mark referenced clusters and mutable objects as reachable
MarkReferencedClustersAsReachable<EGCOptions::None>(ObjectItem->GetClusterIndex(), InitialObjects);
}
}
...
}
可以看到,执行了 Private::GReachableObjects.PopAllAndEmpty(InitialObjects),将 GReachableObjects 拷贝到了 InitialObjects 中:
/**
* Moves all items to the provided array and empties the list
**/
FORCEINLINE void PopAllAndEmpty(TArray<T>& OutArray)
{
Lock.Lock();
FChunk* Current = Head;
Head = nullptr;
Lock.Unlock();
for (FChunk* Chunk = Current; Chunk; Chunk = Chunk->Next)
{
OutArray.Append(Chunk->Items, Chunk->NumItems);
}
DeleteChunks(Current);
}
那么基于如上逻辑,我们再来查看刚刚的例子,当在增量可达性分析进行中,发生 A->a1 = D 时,状态就变为了:
此时,D 由于 TObjectPtr 的写屏障,会被 MarkReachable,并且添加到 InitialObjects 中,作为后续需要处理的 Root Object 来处理,因而也就不会被错误的清理掉。
理解了原理,就能感觉到虽然新版的垃圾回收完全变成了增量,但 GC 部分的改动并不算太大,只是额外添加了一个 FReachabilityAnalysisState GReachabilityState 来完成上下文保存与恢复,当发现 GReachabilityState.IsSuspended() 时,恢复上一帧保存的上下文,并将由于写屏障产生的额外 Objects 添加到待处理的数组中,处理超时后,保存上下文留待下帧处理,其它与之前的 GC 流程基本一致,整体逻辑也不算复杂。
三、总结
附一张结果图:
这里找了一个 UObject 总数量差不多的位置,都是 35 万左右,可以明显的看到,增量可达性分析(橙色)的部分被分帧处理,整体而言体验肯定舒适了很多:
启用 TObjectPtr 后,其实还有一点需要考虑,那就是指针的操作性能,无疑是产生了一些额外的损耗,不过这一损耗应该也不会那么敏感,除非单帧存在极高频的指针赋值等操作。
Comments NOTHING