假如你身怀精湛独特的技艺,喜好进行一些研讨探究,乐意去分享并且广泛吸收他人之长,我们期盼你的加入,使得智慧的火花相互撞击交织在一起,让知识的传递持续不断地进行下去!
总览
UE5的垃圾回收机制,相较于UE4而言是做出了某些改动的,然而其本质并未发生改变,依旧是基于UObject的标记清扫算法,并且其中诸多改动都属于优化范畴。
先看GC的各个阶段:
此外,存在着实验性质的Increment可达性分析模式,想要彻底规避可达性分析所引发的一帧卡顿现象,这算得上是一个最为显著的差异之处。
对于GC Cluster,变化不大。
针对另外存在的一些别的Tips,举例来说,像是IsPendingKill接口被改成了IsGarbage,ObjectFlags增添了Reachable0,Reachable1,Reachable2等等。
文章中所使用的为UE5.7。
前篇:《UE4垃圾回收》
一、加入引用计数机制
1. RefCounted Flag & RefCount
观察InternalObjectFlags,发觉多出了一个名为RefCounted的Flag,表明UE的GC在标记清扫的基础之上,还增添了引用计数机制,这算得上是一项不太小的改动。
提到另一种GC方式,那就是引用计数,Python里采用了它,简要来讲,是每个Object记录自身被别的Object引用的数量,一旦发觉引用计数为0,便判定自身为垃圾,至于垃圾的清理,能选择在为0时即刻清理,也能选择之后一并清理,总之垃圾不会再被重新引用到。
enum class EInternalObjectFlags : int32
{
None = 0,
//...
RefCounted UE_DEPRECATED(5.7, "Use GetRefCount() to determine if a refcount exists instead.") = 1 << 29, ///< Object currently has ref-counts associated with it.
//...
};
但仅仅有了Flag是不够的,还需要给每一个Object再去定义一个RefCount属性,以此来记录被引用的次数。在UE5.7之前,于FUObjectItem当中有一个专门的int32 RefCount属性,然而从UE5.7开始,把RefCount和Flags一起打包成了int64。Flag存于高32位里,RefCount存于低32位里。
如此这般存在着一个益处,RefCount一般而言难以用到int32这般大的规模,所以能够将更多的位数给予EInternalObjectFlags来使用,举例来讲,增添一些我们自行定义的Flag,或者是UE引擎自身去进行扩展。
2. 操作接口
添加RefCount:
void UObjectBase::AddRef() const
{
FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this);
ObjectItem->AddRef();
}
减去RefCount:
void UObjectBase::ReleaseRef() const
{
FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this);
ObjectItem->ReleaseRef();
}
获取RefCount:
于引擎内部代码当中,像在GC过程里,才会存在获取RefCount的需求,所以UObject之上并没有获取RefCount的接口,而仅仅是在FUObjectItem上才有。
FORCEINLINE int32 GetRefCount() const
{
return RefCount;
}
哪些地方会给UObject加上引用计数?
UObject由引用计数管理,明显更易错,一旦AddRef与ReleaseRef不成对,便会致使UObject泄漏,当前引擎里仅StrongObjectPtr运用了,并且采用RAII模式,于构造及析构时操作RefCount,以防泄漏。StrongObjectPtr能够对一个UObject添加强引用,防其被GC,与AddToRoot相似。以下是StrongObjectPtr中操作的代码:。
FORCEINLINE_DEBUGGABLE void Reset(ObjectType* InNewObject)
{
if (InNewObject)
{
if (Object == InNewObject)
{
return;
}
if (Object)
{
// UObject type is forward declared, ReleaseRef() is not known.
// So move the implementation to the cpp file instead.
UEStrongObjectPtr_Private::ReleaseUObject(Object);
}
InNewObject->AddRef();
Object = InNewObject;
}
else
{
Reset();
}
}
3. 为什么要使用引用计数管理UObject
一个原因可能是能减少可达性分析时间。
达成可达性剖析阶段之时,要对Object之间的全部引用关联予以遍历,所耗费的时间与引用的数量呈现出正比关系。
假设存在这样一种情况,就如同下面所展示的图那样,有4个UObject呈现出相互引用的状态,总共需要去遍历其中涵盖的10个引用关系。然而,要是都采用引用计数这种方式去进行管理的话,能够将遍历开销省略掉,直接对四个对象是否全都可达进行判定。
但是从理论层面来讲,引用计数这种方式仅仅是将遍历所产生的开销分摊到了在运行期间对引用计数进行增加或者减少的操作上。并且留意到上面所提及的例子实际上属于环形引用的情形,在不存在外部引用的状况下,四个UObject都应该被判定为是垃圾才正确。所以说,一个完备的垃圾回收实现,是不会仅仅采用引用计数的,就好比Python的FullGC采用的是标记 - 清扫算法,以此作为辅助手段。UE依旧把标记 - 清扫当作主要的GC流程,引用计数仅仅是起到微小的辅助作用,不过在使用的时候是绝对需要加以注意的。
另一个原因是单独针对StrongObjectPtr的优化。
在StrongObjectPtr之前的实现当中,是将包起来的UObject单独放置于一个大数组里,随后这个数组被一个FGCObject添加引用。如此一来存在一个问题,那就是当数组元素数量很多的时候,添加工作需要遍历整个数组,以此判断是否已经添加过,而删除的时候同样要遍历整个数组,去找到元素并实施删除操作,遍历所花费的时间是相当可观的。
4. RefCount如何影响可达性分析
实际上,RefCount跟Root是等同的,去观察AddRef的调用链,最终是会执行AddToRoot这个操作的。
第一步,将所有UObject标记为不可达,之后,会对GRoots容器进行遍历,把其中的UObject转变为可达,如此一来,RefCounted的Object同样变为可达了。
二、可达性分析阶段的优化
1. FPrefetchingObjectIterator
最先介绍一个颇具细节之处的ObjectIterator,依据经验实施了Prefetch,尽可能规避因访问Object的Outer以及ClassPrivate而引发的CacheMiss。整个可达性分析的阶段,都充分运用了Prefetch技术。
首先,对下面的代码加以观察,它会对一组UObject展开遍历行为,之后,会分别针对它们的Class以及Outer进行访问。
FORCEINLINE_DEBUGGABLE void ProcessObjects(DispatcherType& Dispatcher, TConstArrayView CurrentObjects)
{
for (FPrefetchingObjectIterator It(CurrentObjects); It.HasMore(); It.Advance())
{
UObject* CurrentObject = It.GetCurrentObject();
UClass* Class = CurrentObject->GetClass();
UObject* Outer = CurrentObject->GetOuter();
//...
}
}
这般必定会致使读取Object内存里所进行的操作,相较于从CPU Cache当中读取,无疑确实是更为迟缓的。大概估算一下,对内存予以访问所产生的延迟为60ns,然而对CPU L1 Cache进行访问所产生的延迟则是1ns,在这两者之间存在着相当大的差距。
既然晓得要去访问诸多Object的ClassPrivate以及Outer属性,那么可不可以预先就把它们读取至CPU Cache里头,以此加快访问的速度呢?这正是FPrefetchingObjectIterator所做的事儿,它运用了操作系统的Prefetch接口,以异步方式发出一个预读请求,无需当场等候内容返回至CPU Cache,CPU能够接着去执行后续的指令。
当前,存在这样一种情况,当FPrefetchingObjectIterator迭代器执行自增操作时,此时该迭代器会预先提取后续第六个对象的Outer以及ClassPrivate的Schema,并且还会预先提取后续第16个对象的Class。需要注意的是,这里所提及的这些数字,皆是基于经验所得出的值,其目的在于既要确保当访问到相应内存区域时,该内存区域已经成功被加载至CPU Cache当中,同时又不能过度过多地进行预先提取操作,避免出现挤占CPU Cache的不利情况。
示意图如下:
2. 基于Batch的Object引用收集
Batch
UE4怎么做的:
提供一个Objects数组,用于待分析可达性,会持续遍历其引用,进行广度优先搜索,最终遍历完所有可达的UObject,以此完成任务。其中当碰到StructArray结构时,还会出现类似递归的情形,处理代码更为复杂些。整个过程通过一个大的ProcessObjectArray函数来实现,总共有着600+行。
UE5的Batch:
UE5采用了Batch的遍历方式,先是将ProcessObjectArray逻辑拆解为多个函数,这些函数逻辑精简,最长不超过90行,对CPU指令Cache更具友好性,接着由多个函数构成执行流,大概以500个Object作为一个Batch,逐个经过这些执行流,更少的数据量对应着更少的内存使用量,使得CPU Cache足以应对,另外,每段处理函数逻辑变得简单后,能够更轻易地运用上面提到的Prefetch技术进行Cache预取,进一步实现效率的提升。
Schema
首先,UE5增添了Schema的概念,实际上它等同于UE4里的TokenStream,用于记录带上引用的Object的各个成员地址偏移,还有成员类型。
ProcessObjectArray
底下是ProcessObjectsArray的关键流程,最先开展ProcessObjects,去收集引用至Dispatcher,此Dispatcher的具体类别是BatchedDispatcher。CurrentObjects处理完毕后,接着尝试从所有的ObjectsToSerialize数组里拿出BlockSize数量的UObject,而后持续执行ProcessObjects来进行引用收集。倘若 ObjectsToSerialize 同样变为空的状态了,那就去执行 FlushWork,将所引用到的 UObject 全都增添到 ObjectsToSerialize 里面然后继续进行。
如此一来,便能够将标记阶段划分成多个Pass,每一个Pass针对一点Object进行处理,流程示意图展列如下:
ProcessObjects
用于收集CurrentObjects所有引用的是ProcessObjects,上面提到过的FPrefetchingObjectIterator。这里注意到,把Class和Outer的处理单独提出来了,对于它们,UE4依旧是在正常的Schema里进行处理。或许是鉴于Class和Outer是每个UObject都必备的引用,将其单独提出来统一处理能够减小Schema的大小。
存在一个名为VisitMembers的函数,它是专门用于切实处理Schema里所定义的各类引用情形的函数,这些情形涵盖了诸如UPROPERTY引用单个Object,还有TArray数组引用等诸多情况,在此仅呈现单个Object引用以及StructArray引用这两种经常会出现的例子。
VisitStructArray仅仅是将StructArray缓存于相应的容器里面,随后在Flush的时候再去进行处理。
FORCEINLINE_DEBUGGABLE void QueueStructArray(FSchemaView Schema, uint8* Data, int32 Num)
{
StructBatcher.PushStructArray(Schema, Data, Num);
}
注意到,对于单个UObject的引用,进行了划分,划分出了ImmutableReference和KillableReference(FMutableReference),实际上,只有UObject对Class和Outer的引用属于Immutable,其余的都属于Killable,这对于UE4来讲,并没有提出新的内容,仅仅是把老内容包装成了新概念,这便是整个情况,是这样的,是如此这般的。
比如有如下代码,想强制删除B,但B又是A的属性,被引用了
A->Prop = B;
B->MarkAsGarbage();
关于Class以及Outer引用,具备极强的力量,强大到足以阻拦对B进行强力删除操作,致使B能够持续以Garbage的形态留存。
普通的KillableReference,是无法加以阻止的,与此同时,A的Prop属性会变为nullptr。
因此,去查看它们的初始化方式之时,FMutableReference所存储的,乃是指向属性的指针。
// Retains address of reference to allow killing it
struct FMutableReference { UObject** Object; };
struct FImmutableReference { UObject* Object; };
FlushWork
按顺序处理StructArray以及其他Object引用,主要的工作便是对各种各样的Array进行遍历。
FORCEINLINE_DEBUGGABLE void FlushWork(DispatcherType& Dispatcher)
{
if constexpr (DispatcherType::bBatching)
{
if (Dispatcher.FlushToStructBlocks())
{
ProcessStructs(Dispatcher);
}
Dispatcher.FlushQueuedReferences();
}
}
最后经由HandleValidReference函数,将UObject标记成可达状态,且把它插入到ObjectsToSerialize数组里,用来等待后续进行遍历。
三、配套Debug功能的完善
1. gc.history协助找泄露
其中存在一个相对较为实用的功能,此功能能够记下来上次进行GC过程期间,每一个对象具体是被谁所引用了,借由这种方式方便去排查UObject泄露方面的问题。
看个例子,假如有如下代码:
FActorSpawnParameters SpawnParameters;
SpawnParameters.Name = TEXT("TestObject123");
APawn* TestPawn = GetWorld()->SpawnActor(SpawnParameters);
USceneComponent* TempObject = NewObject(TestPawn, USceneComponent::StaticClass());
TempObject->RegisterComponent();
{
TStrongObjectPtr StrongPtr(TempObject);
TestPawn->Destroy();
CollectGarbage(GARBAGE_COLLECTION_KEEPFLAGS, true);
TempObject->MarkAsGarbage();
}
GEngine->Exec(GetWorld(), TEXT("OBJ Refs Name=TestObject123"));
在存有StrongObjectPtr之际,哪怕进行MarkAsGarbage,此次的GC也是没法将TempObject以及TestPawn给删掉的,于严谨的逻辑范畴内,可算是UObject出现了泄露。Obj Refs的输出也为此提供了印证:
LogReferenceChain: (Garbage) Pawn /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123 is not currently reachable. Try using GC history to debug transient leaks with 'gc.historysize 1'
在输出里,仅仅能够看到UObject出现了泄露情况,然而,当打印Obj Refs的时候,引用现场已然消失不见,不清楚究竟是因为什么而发生泄露的。以往的时候,若要调试这种泄露问题,就必须先把Object地址记载下来,接下来再去设置GC下的数据断点,然后还要针对Flags设置下断点等一系列操作,这整个过程是相当麻烦的。
当下,UE5增添了HistorySize的Debug功能,该功能会针对上次的GC去存储Snapshot ,然后在后续进行输出。
首先通过如下控制台指令开启:
gc.ForceEnableGCProcessor
gc.Historysize 1
然后把Exec代码改成:
FReferenceChainSearch::FindAndPrintStaleReferencesToObject(TestPawn, EPrintStaleReferencesOptions::Log);
紧接着,便能够从GC History当中获取到历史GC引用,进而迅速定位至泄露原因,极为便利:
LogLoad: Old Pawn /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123 not cleaned up by GC! Garbage object SceneComponent /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123.SceneComponent_0 was previously being referenced by NULL:
(refcounted<1>) (Garbage) SceneComponent /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123.SceneComponent_0
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
^ This reference is preventing the old Pawn from being GC'd ^
-> UObject* UObject::Outer = (Garbage) Pawn /Game/UEDPIE_0_Zoo.Zoo:PersistentLevel.TestObject123
2. 实现原理
UE5里面,有个新增加的用于Debug的TDebugReachabilityProcessor,在可达性分析这个时期,当首次去遍历一个Object的时候,把当下的路径记录下来就行。
四、增量式可达性分析
1. 为什么要做增量式可达性分析
能够进行增量式处理的垃圾回收方式,当前仍然处于Experimental这个阶段,就个人的感受而言,会觉得有那么一点意义不太清晰明确。
文档,史诗级开发者社区的相关材料 ,相关说明 ,相关记录。
有一个典型的例子,那便是Lua虚拟机的实现,其采用了黑白灰这三种颜色的标记,UE的实现与之情况较为相像,是这样的。
开启方式:
DefaultEngine.ini加入下面配置:
[ConsoleVariables]
gc.AllowIncrementalReachability=1 ; enables Incremental Reachability Analysis
gc.AllowIncrementalGather=1 ; enables Incremental Gather Unreachable Objects
gc.IncrementalReachabilityTimeLimit=0.002 ; sets the soft time limit to 2ms
为什么有必要把可达性分析阶段改成增量式的?
GC耗时能划分成三个部分,先是所有UObject得标记成不可达,这里面可达性分析是耗时主要部分,通常要超过第一部分十倍,然后是销毁垃圾,其中销毁垃圾它本身是分帧的,不会导致卡顿,前两个部分以往没办法分帧。要是游戏期望维持60帧或者更高帧率来运行,这肯定会算是一个阻碍,为了减轻这个卡顿,UE很早之前就提出了Cluster优化方案,如今又进一步提出了增量GC方案。
为什么之前可达性分析不能分帧执行?
想着这样的情景,要是可达性分析得用多帧才搞完,于进行当中执行了A.XX等于B这条语句,致使A引用到B,然而A已然完成了可达性分析,UE没办法识别出这个新添加的引用。那么B就有被错误当作垃圾回收清除掉的可能性。
然而,这却是官方文档所给出的解释,不过,个人内心觉得它不太契合正常的使用场景,到现在都还没有想明白。不妨将那个B划分成下面的两种情况。
在此次GC开始以前,B就已经被创建出来了,要是在这个时候B不存在其他引用的话,那么在代码当中是怎样获取到B的呢?把裸指针存起来固然是可行的,然而这本身就是不符合规范的做法。B是在这次GC开始之后才被创建的,不过新创建的UObject默认是处于Reachable状态的,也就不会被这一次的GC给删除。这一点跟Lua是存在区别的,原因在于在Lua里面Object创建之后默认是不可达的。
或许这和增量回收尚处于Experimental有联系,其逻辑还未成熟,不妨沿着UE的思路接着往下探寻实现方式。
2. Write Barrier
去处理这种问题依靠的仍旧是Write Barrier。因为UE5里将裸指针替换成了TObjectPtr ,所以A.XX = B这样的语句能够被捕捉到了。
TObjectPtr会于Operator =函数当中进行检查,要是发觉正处于GC状态,便会即刻将B当作可达对象,添加至全局GReachableObjects或者GReachableClusters链表里。
以后面所提及的GReachableObjects作为例子,在可达性分析的那个PerformReachabilityAnalysisPass函数的起始位置,会率先将GReachableObjects容器当中的Object当作InitialObjects,也就是作为初始可达对象,随后接着去做可达性分析遍历。
这单单是TObjectPtr的处理,我们还会去定义一些自定义逻辑,接着运用AddReferenceObjects函数增添引用,这般就不太容易处理了。
3. 如何实现分帧
回忆先前的ProcessObjectsArray函数,已然达成了以Batch方式来做Object遍历这个行为,每一趟处理五百个Object事宜,如此一来分帧这件事也是相对便利去达成的。这一阶段的TimeLimit逻辑发挥管控效能,从而规定了每帧能耗费多长一段时间去开展可达性分析工作,一旦超出这个时间限制就给予下一帧持续往下去执行,并且会从CollectGarbage入口之处起始进入执行流程之中。
既然进行分帧,那就得保留当下的GC进度,得清楚还有哪些Object有待遍历,这些信息存于Context.InitialObjects数组中,由于可达性分析阶段是多线程执行的,所以每个线程都有自身的Context。
实际上,并非仅仅在可达性分析阶段实施了增量式的处理操作,而且于后续的收集不可达对象阶段同样也对增量执行予以了支持,只是其耗费的时间本身并不多,并且逻辑也更为简易,将进度留存于Context当中便可以了。
开启增量GC之后的Trace是这样的,能够看到实现了分帧,然而却还没办法做到堪称完美的Timelimit分帧。
五、其他Tips
当UE5处于标记所有Object不可达的状况时,它并非是去遍历所有Object然后设置UnReachable标记,而是采用交替运用两种ReachabilityFlag的方式,并且与此同时,仍然存在着UnReachableFlag,其作用是用以表示真正的不可达Object。
ReachabilityFlag0 = 1 << 14, ///< One of the flags used by Garbage Collector to determine UObject's reachability state
ReachabilityFlag1 = 1 << 15, ///< One of the flags used by Garbage Collector to determine UObject's reachability state
ReachabilityFlag2 = 1 << 16, ///< One of the flags used by Garbage Collector to determine UObject's reachability state
Unreachable = 1 << 28, ///< Object is not reachable on the object graph.
交替两个ReachabilityFlag代码如下:
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);
// Maintain the old flag variables for backwards compatibility
PRAGMA_DISABLE_DEPRECATION_WARNINGS
UE::GC::GReachableObjectFlag = ReachableObjectFlag;
UE::GC::GMaybeUnreachableObjectFlag = MaybeUnreachableObjectFlag;
PRAGMA_ENABLE_DEPRECATION_WARNINGS
GUObjectArray.UnlockInternalArray();
}
这般便可达成这一帧,Flag0示意为可达,Flag1示意为并非必然可达,紧接着下一帧呈现相反状况,之后的下下一帧又反向呈现另一种相反状况。
在一次GC结束之后,PostCollectGarbageImpl,GatherUnreachableObjects会对所有UObject展开遍历,将那些仍旧存在MaybeUnreachableObjectFlag的Object当作垃圾看待,为其标记上Unreachable Flag。
在遍历结束之后,将其插入到专门用于储存等待清理对象的GUnreachableObjects数组里头。
2. 将MarkAsPendingKill替换成MarkAsGarbage ,有这样的取代行为。
UE4针对于那种强行删除的Object,就举例来讲Actor这种,会实施MarkAsPendingKill操作,UE5则将接口变更成了MarkAsGarbage。
inline void MarkAsGarbage()
{
check(!IsRooted());
AtomicallySetFlags(RF_MirroredGarbage);
GUObjectArray.IndexToObject(InternalIndex)->SetGarbage();
// If we explicitly marked the object as garbage, remove the async flag so it's visible to the GC
AtomicallyClearInternalFlags(EInternalObjectFlags::Async);
}
与之相对应的,原本的IsPendingKill判断,也已经被更改成为了IsValid。
inline bool UKismetSystemLibrary::IsValid(const UObject* Object)
{
return ::IsValid(Object);
}
这属于侑虎科技的第1958篇文章,表达对作者南京周润发提供稿件的感激之情,倡导欢迎进行转发分享,明确未经作者授权禁止转载,倘若您拥有任何独特的看法或者发现同样欢迎与我们取得联系,共同展开探讨。(QQ群:793972859)
作者主页:https://www.zhihu.com/people/xu-chen-71-65
万分感激再度承蒙南京周润发予以的分享,要是您存有任何别具一格的见解或者所发掘到的内容,同样欢迎与我们取得联系,一块儿展开探讨。(QQ群编号为:793972859)