UE5 增量垃圾回收 (Incremental Garbage Collection)

发布于 59 分钟前  5 次阅读


UE5 增量垃圾回收 (Incremental Garbage Collection)

增量垃圾回收是 UE 5.4 版本提供的一个新的垃圾回收的方案,其主要是将原始的可达性分析部分,改为分帧执行,以减少 GC 执行时的卡顿感,能提供较优的体验,尤其对于 UObject 数量较多的项目,对于单帧瓶颈、性能峰值的优化很有帮助,目前这一方案还是 Experimental 的,并且需要替换项目中 UPROPERTY 标记的裸指针到 TObjectPtr 才可以启用,项目可以斟酌替换,修改的代码并不复杂,并且有一部分逻辑已经默认启用了,应该也不会有太多问题。

支持引擎版本:5.4
笔者引擎版本:5.5.3
P.S. 推荐 5.5 以后版本再启用,5.5 也有一些更新与修复

一、指针替换

官方提供了指针替换的工具,可以查看如下文档:

Optional Conversion Tool

这里简述一下:引擎路径换成自己的

  1. Engine\Programs\UnrealHeaderTool\Config\DefaultEngine.ini 添加修改 NonEngineNativePointerMemberBehavior=AllowAndLog
  2. Rebuild Project(以便保证搜集到所有的原始指针的 log)
  3. 引擎 Program 编译 UnrealObjectPtrTool,发布版引擎可以执行直接步骤 4
  4. Engine\Binaries\Win64\ 路径下编译出的 UnrealObjectPtrTool.exe 添加参数 Engine\Programs\UnrealBuildTool\Log.txt 执行。

注意点:

  1. 官方参数还指定了 SCCCommand,可能是因为 p4 默认的策略:文件未迁出时为只读。也可以不指定 SCCCommand 命令;
  2. 推荐用编出来的 exe 执行,如果用 IDE 附加参数启动,执行中也会修改 UnrealBuildTool 其中的文件;
  3. 推荐 rebuild 编完拷下其中较新的文件,以免出了问题还要重编;
  4. 找不到 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]

修改的内容大体包括:

  1. 容器包裸指针
  2. GenerateValueArray 等类似方法
  3. MutableView 不是 const 的,const 方法期望保持函数签名的话,需要 ConstCast 一下 this
  4. 容器包裸指针赋值到容器包 TObjectPtr 的情况
  5. 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;
                    }
                }
            }
        }
...
    }

这个方法中完成了垃圾回收的前两个阶段:

  1. StartReachabilityAnalysis -> 标记不可达阶段
  2. 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);
        }
    }
}

可以看到,方法中主要执行了两个操作:

  1. 标记可达
  2. 将对象添加到 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 后,其实还有一点需要考虑,那就是指针的操作性能,无疑是产生了一些额外的损耗,不过这一损耗应该也不会那么敏感,除非单帧存在极高频的指针赋值等操作。