TECHARTNOMAD | MAZELINE.TECH

TECH.ART.FLOW.IO

[번역] UE5 GC의 변경 사항

jplee 2026. 9. 27. 15:49

저자: 난징 저우룬파 / 넷이즈 게임 프로그래머

개요

UE5의 GC는 UE4에서 몇 가지 변경 사항이 있지만, 기본 원리는 달라지지 않았습니다. 여전히 UObject를 기반으로 하는 마크-앤-스윕 알고리즘이며, 상당수 변경은 성능 최적화에 해당합니다.

먼저 GC의 각 단계를 살펴보겠습니다.

  • 모든 Object를 순회하며 도달 불가능 상태로 표시

이 단계의 변경은 비교적 적습니다. RefCount 참조 방식이 추가되었고, UnReachable 플래그가 변경되었으며, 루트 집합으로 사용할 별도의 RootArray가 추가되었습니다.

  • 참조 관계를 순회하며 도달 가능성 분석

변경이 비교적 많습니다. 배치 방식의 참조 수집, Token에서 UObject의 ClassPrivate 및 Outer 참조 제거, 참조 수집 디버그 모드가 추가되었습니다.

  • 도달 불가능한 Object 정리

큰 차이는 없습니다. 기존처럼 프레임을 나누어 퍼지(purge)를 진행하며, 각 UObject에 BeginDestroy, Destroy, FinishDestroy를 차례로 실행합니다.

이 밖에도 실험적인 증분식 도달 가능성 분석 모드가 있습니다. 도달 가능성 분석 때문에 한 프레임이 멈추는 현상을 완전히 없애려는 기능으로, 가장 큰 변화라고 볼 수 있습니다.

GC 클러스터에는 큰 변화가 없습니다.

그 밖에 알아둘 만한 내용도 있습니다. 예를 들어 IsPendingKill 인터페이스가 IsGarbage로 바뀌었고, ObjectFlags에는 Reachable0, Reachable1, Reachable2 등이 추가되었습니다.

이 글은 UE5.7을 기준으로 작성했습니다.

이전 글

https://zhuanlan.zhihu.com/p/67055774

1. 참조 카운팅 메커니즘 추가

RefCounted 플래그와 RefCount

InternalObjectFlags를 살펴보면 RefCounted라는 플래그가 추가된 것을 알 수 있습니다. UE의 GC가 마크-앤-스윕 방식에 참조 카운팅을 더했다는 뜻이므로, 적지 않은 변경입니다.

참조 카운팅은 또 다른 GC 방식으로, Python에서도 사용합니다. 간단히 말해 각 Object가 자신을 참조하는 다른 Object의 수를 기록하고, 참조 횟수가 0이 되면 해당 Object를 쓰레기로 판단합니다. 쓰레기 정리 방식은 참조 횟수가 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.    //...};

하지만 플래그만으로는 부족합니다. 각 Object에 참조 횟수를 기록할 RefCount 속성도 있어야 합니다. UE5.7 이전에는 FUObjectItem에 전용 int32 RefCount 속성이 있었지만, UE5.7부터는 RefCount와 Flags를 int64 하나에 함께 패킹합니다. 상위 32비트에는 플래그를, 하위 32비트에는 RefCount를 저장합니다. ( FlagsAndRefCount )

struct FUObjectItem
{
    friend class FUObjectArray;
    friend class UE::GC::Private::FGCFlags;

private:
    // EInternalObjectFlags를 저장합니다. UE_PACK_FUOBJECT_ITEM이 1이면 UObject 포인터의 상위 13비트도 함께 패킹합니다.
    // 이 값은 Set* 및 Clear* 함수로만 변경할 수 있습니다.
    // 플래그는 상위 32비트에, RefCount는 하위 32비트에 저장합니다. 따라서 RefCount에 InterlockedInc/InterlockedDec를 직접 사용할 수 있습니다.
    // 전체 값의 원자성을 유지하므로 RootFlags와 RefCount를 잠금 없이 확인할 수 있습니다.
    // 플래그를 더 추가하려면 RefCount를 24비트로 줄여 플래그에 더 많은 비트를 할당할 수 있습니다. 다만 이 경우 EInternalObjectFlags를 64비트로 변환해야 합니다.
    union
    {
        int64 FlagsAndRefCount;
#if !UE_WITH_REMOTE_OBJECT_HANDLE
        // natvis에서 표시할 더미 변수
        uint8 RemoteId;
#endif
    };
};

이 방식의 장점은 RefCount가 보통 int32 전체 범위를 사용할 정도로 커지지 않는다는 점입니다. 따라서 남는 비트를 EInternalObjectFlags에 더 할당해 사용자 정의 플래그를 추가하거나 UE 엔진 자체의 기능을 확장할 수 있습니다.

조작 인터페이스

RefCount 증가

void UObjectBase::AddRef() const
{
	FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this);
	ObjectItem->AddRef();
}

FUObjectItem::AddRef() 내부 구현은 다음과 같습니다. 첨부된 캡처에서는 조건식의 오른쪽 일부가 잘려 있어, 확인 가능한 부분만 옮겼습니다.

void AddRef()
{
    UE_AUTORTFM_OPEN
    {
        const int64 NewRefCount = FPlatformAtomics::InterlockedIncrement(&FlagsAndRefCount);

        // 루트 플래그가 없는 상태에서 참조 카운트가 처음 설정될 때만 루트를 dirty 상태로 표시합니다.
        if (
            (NewRefCount & EInternalObjectFlags_RefCountMask) == 1
            // && ... 캡처에서 조건식의 오른쪽 일부가 잘려 생략했습니다.
        )
        {
            if (UE::GC::GIsIncrementalReachabilityPending)
            {
                // 증분식 도달 가능성 분석 중 Object에 루트 플래그를 설정할 때는 GC 배리어가 필요합니다.
                // 루트 플래그가 설정된 Object가 GC에 의해 수거되지 않도록 보장합니다.
                checkf(GetObject(), TEXT("Setting an internal object flag on a null object entry"));
                GetObject()->MarkAsReachable();
            }

            MarkRootAsDirty();
        }
    }
};

RefCount 감소

void UObjectBase::ReleaseRef() const
{
	FUObjectItem* ObjectItem = GUObjectArray.ObjectToObjectItem(this);
	ObjectItem->ReleaseRef();
}

RefCount 조회

엔진 내부 코드, 예를 들어 GC 과정에서만 RefCount를 조회할 필요가 있습니다. 따라서 UObject에는 조회 인터페이스가 없고 FUObjectItem에만 다음 함수가 있습니다.

FORCEINLINE int32 GetRefCount() const
{
	return RefCount;
}

어떤 경우에 UObject의 참조 카운트를 증가시키는가

참조 카운팅으로 UObject를 관리하면 실수하기 쉽습니다. AddRef와 ReleaseRef가 짝을 이루지 않으면 UObject가 누수될 수 있기 때문입니다. 현재 엔진에서는 StrongObjectPtr만 이 방식을 사용하며, 누수를 막기 위해 생성자와 소멸자에서 RefCount를 조작하는 RAII 패턴을 적용합니다. 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();
	}
}

UObject 관리에 참조 카운팅을 사용하는 이유

한 가지 이유는 도달 가능성 분석에 걸리는 시간을 줄일 수 있기 때문일 수 있습니다.

도달 가능성 분석 단계에서는 Object 사이의 모든 참조 관계를 순회해야 하며, 소요 시간은 참조 수에 비례합니다.

예를 들어 아래 그림처럼 UObject 네 개가 서로 참조하면 총 10개의 참조 관계를 순회해야 합니다. 하지만 모두 참조 카운팅으로 관리한다면 순회 비용을 줄이고 네 Object를 곧바로 도달 가능한 것으로 판단할 수 있습니다.

Object 간 참조 관계와 참조 횟수

다만 이론적으로 참조 카운팅은 순회 비용을 런타임 중 참조 횟수를 증감하는 비용으로 분산하는 방식일 뿐입니다. 위 사례는 사실 순환 참조입니다. 외부 참조가 없다면 네 UObject 모두 쓰레기로 판정되어야 합니다. 따라서 완성도 높은 GC 구현은 참조 카운팅만 사용하지 않습니다. 예를 들어 Python의 Full GC는 보조 수단으로 마크-앤-스윕 알고리즘을 사용합니다. UE도 마크-앤-스윕을 주 GC 흐름으로 유지하고 참조 카운팅은 작은 보조 수단으로만 사용하지만, 사용할 때는 반드시 주의해야 합니다.

또 다른 이유는 StrongObjectPtr만을 위한 최적화입니다.

이전 StrongObjectPtr 구현에서는 감싸고 있는 UObject를 별도의 대형 배열에 넣고, 그 배열을 FGCObject가 참조하도록 했습니다. 배열 원소가 많아지면 원소를 추가할 때 이미 등록된 원소인지 확인하려고 배열 전체를 순회해야 했습니다. 삭제할 때도 해당 원소를 찾아 제거하려고 배열 전체를 순회하므로 상당한 시간이 들 수 있습니다.

RefCount가 도달 가능성 분석에 미치는 영향

실제로 RefCount는 Root와 같은 역할을 합니다. AddRef의 호출 흐름을 따라가면 최종적으로 AddToRoot가 실행됩니다.

void AddRef()
{
    UE_AUTORTFM_OPEN
    {
        const int64 NewRefCount = FPlatformAtomics::InterlockedIncrement(&FlagsAndRefCount);

        // 루트 플래그가 없는 상태에서 참조 카운트가 처음 설정될 때만 루트를 더티 상태로 표시합니다.
        if ((NewRefCount & EInternalObjectFlags_RefCountMask) == 1
            && (GetFlagsInternal(NewRefCount) & EInternalObjectFlags_RootFlags) == 0)
        {
            if (UE::GC::GIsIncrementalReachabilityPending)
            {
                // 증분식 도달 가능성 분석 중 객체에 루트 플래그를 설정할 때는 GC 배리어가 필요합니다.
                // 루트 플래그가 설정된 객체가 GC에 수거되지 않도록 보장합니다.
                checkf(GetObject(), TEXT("Setting an internal object flag on a null object entry"));
                GetObject()->MarkAsReachable();
            }

            MarkRootAsDirty(); // <-- 여기
        }
    };
}

첫 단계에서 모든 UObject를 도달 불가능으로 표시한 뒤 GRoots 컨테이너를 순회해 그 안의 UObject를 도달 가능 상태로 바꿉니다. 따라서 RefCounted 상태인 Object도 도달 가능한 것으로 처리됩니다.

실제로 루트 목록을 갱신하는 ProcessDirtyRootNoLock()은 다음과 같습니다.

void ProcessDirtyRootNoLock(int32 Index)
{
    // SetRootFlags 또는 ClearRootFlags와 경합하더라도 큰 문제는 없습니다.
    // 다른 MarkRootAsDirty 호출이 나중에 처리되도록 큐에 추가되기 때문입니다.
    const FUObjectItem* ObjectItem = GUObjectArray.IndexToObjectUnsafeForGC(Index);

    if (ObjectItem->HasAnyFlags(EInternalObjectFlags_RootFlags) || ObjectItem->GetRefCount() != 0)
    {
        // GC에서 제외되는 Object는 GC가 완전히 건너뛰어야 하므로 루트 목록에 추가하면 안 됩니다.
        if (!GUObjectArray.IsIndexDisregardForGC(Index))
        {
            GRoots.Add(Index); // <-- 여기
        }
    }
    else
    {
        GRoots.Remove(Index);
    }
}

2. 도달 가능성 분석 단계의 최적화

FPrefetchingObjectIterator

먼저 세부적인 최적화가 적용된 ObjectIterator를 소개하겠습니다. 경험적 수치에 따라 프리페치(prefetch)를 수행해 Object의 Outer와 ClassPrivate에 접근할 때 발생하는 캐시 미스를 줄입니다. 도달 가능성 분석 단계 전반에서 프리페치 기법을 적극적으로 활용합니다.

다음 코드는 UObject 배열을 순회하며 각 Object의 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 캐시에서 읽는 것보다 메모리에서 읽는 편이 훨씬 느립니다. 대략적으로 메모리 접근 지연 시간은 60ns, CPU L1 캐시 접근 지연 시간은 1ns로 볼 수 있으며, 차이가 상당합니다.

많은 Object의 ClassPrivate와 Outer 속성에 접근할 것을 알고 있다면, 이 데이터를 미리 CPU 캐시에 올려 접근 속도를 높일 수 있지 않을까요? FPrefetchingObjectIterator가 바로 이 작업을 합니다. 운영체제의 프리페치 인터페이스를 사용해 비동기적으로 미리 읽기 요청을 보냅니다. 데이터를 CPU 캐시에 가져올 때까지 기다릴 필요가 없으므로, CPU는 다음 명령을 계속 실행할 수 있습니다.

FPrefetchingObjectIterator는 ++ 연산을 할 때 여섯 번째 뒤에 있는 Object의 Outer 및 ClassPrivate 스키마와 16번째 뒤에 있는 Object의 Class를 프리페치합니다. 이 숫자들은 경험에 따라 정한 값입니다. 해당 메모리에 접근할 시점에는 이미 CPU 캐시에 올라와 있어야 하지만, 너무 많이 프리페치해 CPU 캐시를 잠식해서도 안 됩니다.

FORCEINLINE_DEBUGGABLE void Advance()
{
    // 다음 오브젝트의 참조 스키마를 미리 가져옵니다.
    FPlatformMisc::Prefetch(PrefetchedSchema->GetWords());
    PrefetchedSchema = &It[2]->GetClass()->ReferenceSchema.Get();

    // 미리 지정한 거리만큼 앞선 오브젝트의 Outer와 클래스 스키마를 프리페치합니다.
    UObjectBase::PrefetchOuter(It[6]);
    FPlatformMisc::Prefetch(It[6]->GetClass(), offsetof(UClass, ReferenceSchema));
    UObjectBase::PrefetchClass(It[ObjectLookahead]);

    ++It;
}

개념도는 다음과 같습니다.

배치 기반 Object 참조 수집

배치(Batch)

UE4의 방식

도달 가능성을 분석할 Object 배열이 주어지면 참조를 반복해서 순회하며 너비 우선 탐색을 수행합니다. 이를 통해 도달 가능한 UObject를 모두 방문하면 작업이 끝납니다. StructArray를 만나면 재귀와 비슷한 처리가 발생해 코드가 더 복잡해집니다. 전체 과정은 600줄이 넘는 대형 ProcessObjectArray 함수 하나가 담당합니다.

UE5의 배치 방식

UE5는 배치 단위로 순회합니다. 먼저 ProcessObjectArray의 로직을 여러 함수로 나누어 각 함수의 로직을 간결하게 만들었습니다. 가장 긴 함수도 90줄을 넘지 않아 CPU 명령어 캐시에 더 유리합니다. 그런 다음 여러 함수가 실행 흐름을 구성합니다. 약 500개의 Object를 하나의 배치로 묶어 각 실행 흐름을 차례로 통과시킵니다. 처리 데이터가 줄어들면 메모리 사용량도 줄어 CPU 캐시가 감당하기 쉬워집니다. 각 처리 함수의 로직이 단순해진 덕분에 앞서 언급한 프리페치 기법도 더 쉽게 적용해 효율을 높일 수 있습니다.

스키마(Schema)

먼저 UE5에는 스키마 개념이 도입되었습니다. 이는 사실상 UE4의 TokenStream과 같습니다. 참조를 포함하는 각 멤버의 주소 오프셋과 멤버 유형을 기록합니다.

/** GC 스키마이며, AssembleReferenceTokenStream에서 완성됩니다. */
UE::GC::FSchemaOwner ReferenceSchema;

ProcessObjectArray

다음은 ProcessObjectsArray의 주요 흐름입니다. 먼저 ProcessObjects를 실행해 참조를 수집하고 Dispatcher에 전달합니다. Dispatcher의 구체 형식은 BatchedDispatcher입니다. CurrentObjects 처리가 끝나면 전체 ObjectsToSerialize 배열에서 BlockSize만큼의 UObject를 가져와 ProcessObjects를 다시 실행해 참조를 수집합니다. ObjectsToSerialize도 비어 있다면 FlushWork를 실행해 수집한 UObject를 모두 ObjectsToSerialize에 추가한 뒤 처리를 이어갑니다.

void ProcessObjectArray(FWorkerContext& Context)
{
    CollectorType Collector(Processor, Context);

    // 스택에 생성되는 TDirectDispatcher 또는 Collector가 소유한 TBatchDispatcher 참조를 가져옵니다.
    decltype(GetDispatcher(Collector, Processor, Context)) Dispatcher = GetDispatcher(Collector, Processor, Context);

    TConstArrayView<UObject*> CurrentObjects = Context.InitialObjects;
    while (true)
    {
        Context.Stats.AddObjects(CurrentObjects.Num());
        ProcessObjects(Dispatcher, CurrentObjects); // <-- 여기

        // 처리가 끝난 작업 블록을 해제합니다.
        if (CurrentObjects.GetData() != Context.InitialObjects.GetData())
        {
            Context.ObjectsToSerialize.FreeOwningBlock(CurrentObjects.GetData());
        }

        if (Processor.IsTimeLimitExceeded())
        {
            FlushWork(Dispatcher);
            Dispatcher.Suspend();
            SuspendWork(Context);
            return;
        }

        int32 BlockSize = FWorkBlock::ObjectCapacity;
        FWorkBlockifier& RemainingObjects = Context.ObjectsToSerialize;
        FWorkBlock* Block = RemainingObjects.PopFullBlock<Options>();
        if (!Block)
        {
            if constexpr (bIsParallel)
            {
                FSlowARO::ProcessUnbalancedCalls(Context, Collector);
            }

            FlushWork(Dispatcher); // <-- 여기
            Block = RemainingObjects.PopFullBlock<Options>();
            if (!Block)
            {
                break;
            }
        }

        CurrentObjects = MakeArrayView(Block->Objects, BlockSize); // <-- 여기
    } // 반복문 종료
}

이렇게 하면 마킹 단계를 여러 Pass로 나누어 각 Pass에서 일부 Object만 처리할 수 있습니다. 아래 그림은 전체 흐름을 보여줍니다.

ProcessObjects

ProcessObjects는 CurrentObjects의 모든 참조를 수집합니다. FPrefetchingObjectIterator는 앞에서 설명했습니다. 여기서는 Class와 Outer 처리를 별도로 분리했습니다. UE4에서는 일반 스키마 안에서 처리했습니다. Class와 Outer는 모든 UObject에 기본적으로 존재하는 참조이므로, 이를 한곳에서 처리하면 스키마 크기를 줄일 수 있다고 판단했을 가능성이 있습니다.

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();

        FSchemaView Schema = Class->ReferenceSchema.Get();
        Dispatcher.Context.ReferencingObject = CurrentObject;

        // 기본 참조를 처리합니다.
        Dispatcher.HandleImmutableReference(Class, EMemberlessId::Class, EOrigin::Other); //<--여기
        Dispatcher.HandleImmutableReference(Outer, EMemberlessId::Outer, EOrigin::Other); //<-- 여기
        if (!Schema.IsEmpty())
        {
            typename DispatcherType::SchemaStackScopeType SchemaStack(Dispatcher.Context, Schema);
            Private::VisitMembers(Dispatcher, Schema, CurrentObject); // <-- 여기
        }
    }
}

VisitMembers 함수는 스키마에 정의된 다양한 참조를 실제로 처리합니다. 여기에는 UPROPERTY가 단일 Object를 참조하는 경우나 TArray<TObjectPtr>가 배열을 참조하는 경우 등이 포함됩니다. 여기서는 흔한 예인 단일 Object 참조와 StructArray 참조만 살펴봅니다.

FORCEINLINE_DEBUGGABLE void VisitMembers(DispatcherType& Dispatcher, FSchemaView Schema, ObjectType* Instance)
{
    for (const FMemberWord* WordIt = Schema.GetWords(); true; ++WordIt)
    {
        const FMemberWordUnpacked Quad(WordIt->Members);
        for (FMemberUnpacked Member : Quad.Members)
        {
            uint8* MemberPtr = (uint8*)(InstanceCursor + Member.WordOffset);
            switch (Member.Type)
            {
            case EMemberType::Reference:
                Dispatcher.HandleKillableReference(*(UObject**)MemberPtr, EMemberId(DebugIdx), Origin);
                break; // <-- 여기

            case EMemberType::StructArray:
                VisitStructArray(Dispatcher, FSchemaView((++WordIt)->InnerSchema, Origin), *(FScriptArray*)MemberPtr);
                break; // <-- 여기

            // 그 밖의 멤버 유형을 처리합니다.
            }
        }
    }
}

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 itstruct 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 배열에 넣어 이후에 순회할 수 있도록 합니다.

FORCEINLINE static bool HandleValidReference(FWorkerContext& Context, FImmutableReference Reference, FReferenceMetadata Metadata)
{
    using namespace UE::GC::Private;

    if (FGCFlags::MarkAsReachableInterlocked_ForGC(Metadata.ObjectItem))
    {
        if (!Metadata.Has(EInternalObjectFlags::ClusterRoot))
        {
            // 직렬화할 객체 목록에 추가합니다.
            Context.ObjectsToSerialize.Add<Options>(Reference.Object); // <-- 여기
        }
        else
        {
            // 클러스터 루트 참조이므로 참조된 모든 클러스터를 도달 가능 상태로 표시합니다.
            MarkReferencedClustersAsReachableThunk<Options>(Metadata.ObjectItem->GetClusterIndex(), Context.ObjectsToSerialize);
        }

        return true;
    }

    return false;
}

3. 디버그 기능 보강

gc.history로 누수 찾기

유용한 기능 중 하나는 직전 GC 과정에서 각 Object가 누구에게 참조되었는지 기록해 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 디버그 기능이 추가되었습니다. 직전 GC의 스냅샷을 저장해 두었다가 나중에 출력할 수 있습니다.

먼저 다음 콘솔 명령으로 기능을 활성화합니다.

gc.ForceEnableGCProcessor
gc.Historysize 1

그런 다음 Exec 코드를 다음과 같이 바꿉니다.

FReferenceChainSearch::FindAndPrintStaleReferencesToObject(TestPawn, EPrintStaleReferencesOptions::Log);

이제 gchistory에서 과거 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

구현 원리

UE5에는 디버깅용 TDebugReachabilityProcessor가 추가되었습니다. 도달 가능성 분석 단계에서 Object를 처음 순회할 때 현재 참조 경로를 기록합니다.

FORCENOINLINE static void HandleGarbageReference(FWorkerContext& Context, const UObject* ReferencingObject, UObject*& Object, FMemberId MemberId)
{
    const UObject* Referencer = ReferencingObject ? ReferencingObject : Context.GetReferencingObject();
    if (IsValid(Referencer))
    {
        if (Referencer != FGCObject::GCGCObjectReferencer)
        {
            // 일반 참조자의 프로퍼티 이름과 참조 정보를 기록합니다.
            FName PropertyName = GetMemberDebugInfo(Referencer->GetClass()->ReferenceSchema.Get(), MemberId).Name;
            Context.GarbageReferences.Add(FGarbageReferenceInfo(Referencer, Object, PropertyName)); // <--여기
        }
        else
        {
            // FGCObject의 현재 직렬화 대상 객체와 참조 정보를 기록합니다.
            FGCObject* GCObjectReferencer = FGCObject::GCGCObjectReferencer->GetCurrentlySerializingObject();
            Context.GarbageReferences.Add(FGarbageReferenceInfo(GCObjectReferencer, Object)); // <-- 여기
        }
    }
}

4. 증분식 도달 가능성 분석

증분식 도달 가능성 분석이 필요한 이유

증분식 GC는 아직 실험적(Experimental) 단계이며, 개인적으로는 필요성이 다소 모호하다고 느낍니다.

대표적인 예로 Lua 가상 머신 구현을 들 수 있습니다. 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를 먼저 도달 불가능으로 표시하는 단계, 도달 가능성 분석 단계, 쓰레기를 파괴하는 단계입니다. 쓰레기 파괴는 원래 여러 프레임에 나누어 실행되므로 프레임 정지를 일으키지 않습니다. 과거에는 앞의 두 단계는 프레임에 나누어 실행할 수 없었고, 그중 도달 가능성 분석이 주된 비용이었습니다. 보통 첫 단계보다 열 배 이상 오래 걸립니다. 게임이 60fps 이상을 유지하려 한다면 상당한 부담이 될 수 있습니다. 이 정지를 줄이기 위해 UE는 일찍이 클러스터 최적화를 도입했고, 최근에는 증분 GC 방식도 추가했습니다.

그렇다면 이전에는 왜 도달 가능성 분석을 여러 프레임에 나누어 실행할 수 없었을까요?

도달 가능성 분석이 여러 프레임에 걸쳐 진행되는 상황을 생각해 보겠습니다. 진행 도중 A.XX = B라는 코드가 실행되어 A가 B를 참조하게 되었지만, A에 대한 분석은 이미 끝났다면 UE는 새로 추가된 참조를 인식하지 못할 수 있습니다. 그러면 B가 잘못해서 쓰레기로 수거될 가능성이 있습니다.

그런데 이것이 공식 문서의 설명이지만, 개인적으로는 일반적인 사용 상황과 잘 맞지 않는다고 느낍니다. 아직 충분히 이해하지 못했으므로 B를 다음 두 경우로 나누어 생각해 보겠습니다.

  1. B가 이번 GC가 시작되기 전에 생성된 경우입니다. 이 시점에 B를 가리키는 다른 참조가 없다면, 코드가 B를 어떻게 가져올 수 있을까요? 원시 포인터를 저장하면 가능하겠지만, 그 자체가 권장되지 않는 방식입니다.
  2. B가 이번 GC가 시작된 뒤 생성된 경우입니다. 새로 생성된 UObject는 기본적으로 Reachable 상태이므로 이번 GC에서 삭제되지 않습니다. Object가 생성되면 기본적으로 도달 불가능 상태가 되는 Lua와 다른 점입니다.
if (!IsIndexDisregardForGC(Index))
{
    // 새 객체를 생성하는 동안이므로 GetReachableFlagValue_ForGC()에 안전하게 접근할 수 있습니다.
    // GC에서 도달 가능성 플래그를 교체할 때와 동일한 UObjectArray 잠금이 적용됩니다.
    // 자세한 내용은 FGCFlags::SwapReachableAndMaybeUnreachable()를 참고하세요.
    ObjectItem->FlagsAndRefCount |= ((int64)UE::GC::Private::FGCFlags::GetReachableFlagValue_ForGC()) << 32; // <-- 여기
}

ObjectItem->SetObject(Object);
ObjectItem->ClusterRootIndex = 0;

이 점은 증분식 GC가 아직 Experimental 단계라 로직이 충분히 성숙하지 않았기 때문일 수도 있습니다. 일단 UE의 설명을 따라가며 구현을 더 살펴보겠습니다.

쓰기 장벽(Write Barrier)

이 문제를 해결하려면 쓰기 장벽(Write Barrier)이 필요합니다. UE5에서 원시 포인터를 TObjectPtr로 바꾼 덕분에 A.XX = B와 같은 대입문을 감지할 수 있습니다.

TObjectPtr는 operator = 함수에서 검사합니다. GC가 진행 중이라면 B를 즉시 도달 가능한 Object로 처리하고 전역 GReachableObjects 또는 GReachableClusters 연결 리스트에 추가합니다.

[[nodiscard]] explicit FORCEINLINE constexpr FObjectPtr(UObject* Object)
    : Handle(UE::CoreUObject::Private::MakeObjectHandle(Object))
{
#if UE_OBJECT_PTR_GC_BARRIER
    ConditionallyMarkAsReachable(Object); // <-- 여기
#endif // UE_OBJECT_PTR_GC_BARRIER 조건부 처리 종료
}
FORCEINLINE constexpr void ConditionallyMarkAsReachable(const FObjectPtr& InPtr) const
{
    UE_IF_NOT_CONSTEVAL
    {
        // 증분식 도달 가능성 분석 중 해석된 핸들의 객체를 도달 가능 상태로 표시합니다.
        if (UE::GC::GIsIncrementalReachabilityPending && InPtr.IsResolved())
        {
            if (UObject* Obj = UE::CoreUObject::Private::ReadObjectHandlePointerNoCheck(InPtr.GetHandleRef()))
            {
                UE::GC::MarkAsReachable(Obj); // <-- 여기
            }
        }
    }
}
if (FGCFlags::MarkAsReachableInterlocked_ForGC(ObjectItem))
{
    if (ObjectItem->GetOwnerIndex() >= 0)
    {
        // 이 객체가 도달 가능해졌으므로 증분 GC의 다음 반복에서 처리할 목록에 추가합니다.
        // 이 객체가 참조하는 다른 객체도 도달 가능 상태로 표시해야 합니다.
        GReachableObjects.Push(static_cast<UObject*>(ObjectItem->GetObject())); // <-- 여기
    }
    else
    {
        GReachableClusters.Push(ObjectItem); // <-- 여기
    }
}

이후 GReachableObjects를 예로 들면, 도달 가능성 분석의 PerformReachabilityAnalysisPass 함수가 시작될 때 GReachableObjects 컨테이너의 Object를 먼저 InitialObjects로 가져옵니다. 이를 초기 도달 가능 Object로 간주한 뒤 도달 가능성 분석을 계속합니다.

if (!Private::GReachableObjects.IsEmpty())
{
    // GC 배리어로 표시된 객체를 증분 도달 가능성 분석의 다음 반복을 위한 초기 집합에 추가합니다.
    Private::GReachableObjects.PopAllAndEmpty(InitialObjects); // <-- 여기
    GGCStats.NumBarrierObjects += InitialObjects.Num();
    UE_LOG(LogGarbage, Verbose, TEXT("Adding %d object(s) marked by GC barrier to the list of objects to process"), InitialObjects.Num());
    ConditionallyAddBarrierReferencesToHistory(*Context);
}

여기까지는 TObjectPtr 처리입니다. 사용자 정의 로직을 만들고 AddReferenceObjects 함수를 사용해 참조를 추가하는 경우에는 처리가 그리 간단하지 않습니다.

프레임 분할은 어떻게 구현하는가

앞서 살펴본 ProcessObjectsArray 함수는 이미 배치 방식으로 Object를 순회하며 한 번에 500개씩 처리합니다. 따라서 프레임 분할도 비교적 간단하게 구현할 수 있습니다. 아래의 TimeLimit 로직은 매 프레임 도달 가능성 분석에 사용할 수 있는 시간을 제어합니다. 제한 시간을 넘기면 다음 프레임에서 처리를 이어가며, CollectGarbage 진입점에서 실행됩니다.

TConstArrayView<UObject*> CurrentObjects = Context.InitialObjects;
while (true)
{
    Context.Stats.AddObjects(CurrentObjects.Num());
    ProcessObjects(Dispatcher, CurrentObjects);

    // <-- 여기서부터
    if (Processor.IsTimeLimitExceeded())
    {
        FlushWork(Dispatcher);
        Dispatcher.Suspend();
        SuspendWork(Context);
        return;
    }
    // <-- 여기까지

    FWorkBlock* Block = RemainingObjects.PopFullBlock<Options>();
    if (!Block)
    {
        FlushWork(Dispatcher);
        // 나머지 블록 처리 로직은 생략합니다.
    }

    CurrentObjects = MakeArrayView(Block->Objects, BlockSize);
} // 반복문 종료

프레임을 나누어 처리하려면 현재 GC 진행 상황을 보존해야 하며, 아직 순회해야 할 Object가 무엇인지도 알아야 합니다. 이 정보는 Context.InitialObjects 배열에 저장됩니다. 도달 가능성 분석 단계는 멀티스레드로 실행되므로 각 스레드가 각자의 Context를 가집니다.

도달 가능성 분석 단계뿐 아니라 이후의 도달 불가능 Object 수집 단계도 증분 실행을 지원합니다. 다만 이 단계는 원래 비용이 크지 않고 로직도 더 단순하므로, Context에 진행 상황을 보존하면 됩니다.

증분 GC를 활성화한 뒤의 Trace는 다음과 같습니다. 프레임 분할은 구현되었지만, 시간 제한에 맞춰 완벽하게 프레임을 나누지는 못한다는 점을 확인할 수 있습니다.

5. 기타 팁

ReachabilityFlag0, ReachabilityFlag1

UE5는 모든 Object를 도달 불가능 상태로 표시할 때 더 이상 모든 Object를 순회하며 UnReachable 플래그를 설정하지 않습니다. 대신 두 종류의 ReachabilityFlag를 번갈아 사용합니다. 실제로 도달 불가능한 Object를 표시하기 위한 UnReachableFlag도 계속 사용합니다.

ReachabilityFlag0 = 1 << 14, ///< One of the flags used by Garbage Collector to determine UObject's reachability stateReachabilityFlag1 = 1 << 15, ///< One of the flags used by Garbage Collector to determine UObject's reachability stateReachabilityFlag2 = 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 플래그를 설정합니다.

while (Iterator.Index <= Iterator.LastIndex)
{
    FUObjectItem* ObjectItem = &GUObjectArray.GetObjectItemArrayUnsafe()[Iterator.Index++];
    if (FGCFlags::IsMaybeUnreachable_ForGC(ObjectItem)) // <-- 여기
    {
        // 캡처에서 checkf 진단 문자열 뒷부분이 잘려 조건만 옮겼습니다.
        check(!ObjectItem->HasAnyFlags(EInternalObjectFlags::ClusterRoot));
        FGCFlags::SetUnreachable(ObjectItem); // <-- 여기
        Iterator.Payload.Add({ ObjectItem });
    }

    if (Timer.IsTimeLimitExceeded())
    {
        return;
    }
}
FORCEINLINE static void SetUnreachable(FUObjectItem* ObjectItem)
{
    // ObjectItem에 Unreachable 플래그를 원자적으로 설정합니다.
    ObjectItem->AtomicallySetFlag_ForGC(EInternalObjectFlags::Unreachable);
}

순회가 끝나면 정리 대기 Object를 전용으로 저장하는 GUnreachableObjects 배열에 추가합니다.

if (bCompleted)
{
    // 수집이 완료되면 도달 불가능 객체의 상태 처리를 마무리합니다.
    GGatherUnreachableObjectsState.Finish(GUnreachableObjects);
}

MarkAsPendingKill을 대체한 MarkAsGarbage

UE4에서는 Actor처럼 Object를 강제로 삭제할 때 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);
}

 


Original Post: (85 封私信 / 63 条消息) UE5 GC的改动 - 知乎