UE5版本中GC的改动

UE5版本中GC的改动

【USparkle专栏】如果你深怀绝技,爱“搞点研究”,乐于分享也博采众长,我们期待你的加入,让智慧的火花碰撞交织,让知识的传递生生不息!


总览

UE5的GC,相对UE4做了一些改动,但本质没变,依然是基于UObject的标记清扫算法,许多改动都属于优化。

先看GC的各个阶段:

  • 遍历所有Object,标记为不可达
    改动较少,增加了RefCount引用方式;更改了Unreachable Flag;增加了单独的RootArray作为根集。

  • 遍历引用关系做可达性分析
    改动较多,Batch模式的引用收集;UObject对ClassPrivate和Outer的引用从Token中移除;增加了引用收集的Debug模式。

  • 清理不可达Object
    差别不大,依然是分帧Purge,对每个UObject依次执行BeginDestroy, Destroy, FinishDestroy。

除此之外,还有实验性的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<UObject*> 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迭代器在++时,会Prefetch后面第六个的Outer和ClassPrivate的Schema,以及后面第16个的Class。这些数字都是经验值,既需要保证访问到这块内存时,其已经被加载到了CPU Cache,又不能Prefetch太多,从而挤占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
ProcessObjects用于收集CurrentObjects的所有引用,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);
}

ImmutableReference & KillableReference
注意到对于单个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<APawn>(SpawnParameters);
USceneComponent* TempObject = NewObject<USceneComponent>(TestPawn, USceneComponent::StaticClass());
TempObject->RegisterComponent();
{
    TStrongObjectPtr<UObject> 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阶段,个人感觉有点意义不明。

Documentation | Epic Developer Community

一个典型例子是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分成下面两种情况:

  1. B在这次GC开始前已创建,如果此时B没有其他引用了,那么代码里又是如何获取到B的?存裸指针当然可以,但本身也是不规范的做法。
  2. 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遍历,每趟处理500个Object,那么分帧也是比较方便实现的。这一段TimeLimit逻辑控制了每帧可以花费多少时间做可达性分析,超过了就给下一帧继续执行,会从CollectGarbage入口进来。

既然分帧,就要保留当前GC进度,要知道还有哪些Object需要遍历。这些信息就存储在Context.InitialObjects数组里。因为可达性分析阶段是多线程执行的,因此每个线程都有自己的Context。

其实不仅可达性分析阶段做了增量式处理,后面的收集不可达对象阶段也支持了增量执行。只是耗时本身不高,逻辑也更简单,进度保留在Context中即可。

开启增量GC后的Trace如下,可以看到实现了分帧,但还不能做到完美的Timelimit分帧:

五、其他Tips

1. ReachabilityFlag0, ReachabilityFlag1
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. MarkAsGarbage取代MarkAsPendingKill
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)