ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [UE5] Garbage Collection
    UE4 & UE5/Core 2023. 7. 5. 08:00

    [UE5] Garbage Collection


    최초 작성 : 2023-07-05
    마지막 수정 : 2023-07-05
    최재호

     

    목차

    1. 환경
    2. 목표
    3. 내용
     3.1. 언리얼 GC 방식인 Mark and Sweep
     3.2. GC 가 GameThread 히칭을 발생시키는 주요 지점 중 하나
     3.3. UE5 GC 의 주요 단계
     3.4. UE5 가 레퍼런스를 유지하는 방식
      3.4.1. UCLASS 의 UPROPERTY() 멤버 변수에 등록
       3.4.1.1. ReferenceTokenStream 의 생성 과정
      3.4.2. FGCObject (UGCObjectReferencer) 를 상속 받기
       3.4.2.1. UGCObjectReferencer
      3.4.3. Cluster Root 에 등록 (GC 를 처리하는 대표 UObject)
      3.4.4. Root set 에 등록
     3.5. GC 코드 리뷰
      3.5.1. Mark 단계
       3.5.1.1. RootSet 수집과 Unreachable 설정 (MarkObjectsFunctions)
       3.5.1.2. RootSet 으로 부터 레퍼런스 추적(PerformReachabilityAnalysisOnObjects)
      3.5.2. Unreachable 플래그를 가진 UObject 수집(GatherUnreachableObjects)
      3.5.3. Sweep 단계
       3.5.3.1. UObject 가 소유한 리소스들의 해제 시작 (BeginDestroy)
       3.5.3.2. UObject 해제 완료 (FinishDestroy)
      3.5.4. UObject 소멸자 호출 후 FUObjectArray 에서 제거

     

    1. 환경

    Unreal Engine 5.2.1

    일부 코드는 줄 바꿈 되거나 접은 상태로 중요한 코드를 위주로 설명하니 보실 때 참고해 주세요.
    또한 코드를 직접 붙여 넣어서 진행했기 때문에 작아서 잘 보이지 않는 코드는 클릭하여 봐주세요.

     

    2. 목표

    언리얼의 Garbage Collection(GC) 가 어떻게 동작하는지 이해해 봅시다. GC 는 UObject 를 대상으로 합니다. 그래서 모든 UObject 가 저장되는 FUObjectArray 에 대해서 이해하고 있다면 UE5 의 GC 를 더 쉽게 이해할 수 있습니다. FUObjectArray 는 레퍼런스2 [UE5] FUObjectArray (GUObjectArray) 를 참고해 주세요.

     

    언리얼 리플렉션에 대한 기본 이해가 있다고 가정하고 작성한 글입니다. 리플렉션은 언리얼 공식문서 언리얼 프로퍼티 시스템 (리플렉션) 를 참고해 주세요.

     

    * 코드에는 설명에 대응하는 1, 2, 3 등의 번호가 매겨져 있으니 참고해 주세요.
    * 개인적인 공부 용도로 분석한 내용이라 틀린 내용이 있을 수 있습니다. 그런 부분은 지적 부탁드립니다.

     

    3. 내용

    3.1. 언리얼 GC 방식인 Mark and Sweep

    GC 를 구현하는 방법은 다양합니다만 언리얼에서 채택한 방식은 Mark and Sweep 입니다. 이 알고리즘은 이름 그대로 Mark, Sweep 단계를 가집니다.

    • Mark 단계는 Root set 으로 부터 레퍼런스가 있는 모든 오브젝트를 Mark
    • Sweep 단계에서는 Mark 되지 않는 오브젝트들을 제거합니다.

     

    여기서 당연히 Root set 은 GC 대상에서 제외됩니다.

    그림1. GC 구현의 한종류인 Mark and Sweep (출처 : 레퍼런스3)

     

    언리얼에서는 Mark 라는 용어를 사용하지 않고, Unreachable 이라는 UObject의 Flag 를 사용합니다. 처음 모든 UObject 를 Unreachable 로 설정한 후 Root set 으로부터 도달 가능한 UObject 들에 Unreachable flag 를 제거하는 형태로 동작합니다.

    그림2. UObject의 Flags

     

    3.2. GC 가 GameThread 히칭을 발생시키는 주요 지점 중 하나

    GC 는 UE5 의 기본 설정을 기준으로 60 초에 한번 Mark & Sweep 단계를 수행합니다. 모든 UObject 를 대상으로 하기 때문에 UObject 가 많아질 수록 수행시간이 길어집니다. UE5 는 이런 과정을 최소화 하기 위해서 다양한 방법을 사용합니다. UE5 의 GC 구현을 보면서 어떻게 UE5 가 이런 점들을 최적화해나가는지 확인해 봅시다.

     

    3.3. UE5 GC 의 주요 단계

    먼저 주요 함수들을 확인해 봅시다.

     

    1). Mark 단계 (Incremental 불가, multithread 로 다른 GameThread 작업과 동시에 작업 불가)

    • PerformReachabilityAnalysis
      • MarkObjectsAsUnreachable (RootSet 정보를 모으고, 모든 오브젝트에 Unreachable 설정)
      • PerformReachabilityAnalysisOnObjects (전 단계에서 구한 RootSet 으로부터 레퍼런스 순회)

    2). GatherUnreachableObjects (Unreachable 오브젝트 수집, GUnreachableObjects)

    3). Sweep 단계 - IncrementalPurgeGarbage

    • UnhashUnreachableObjects : Obj->ConditionalBeginDestroy (RF_BeginDestroyed 설정 및 BeginDestroy 호출)
    • IncrementalDestroyGarbage : Object->ConditionalFinishDestroy (RF_FinishDestroyed 설정 및 FinishDestroy 호출)
    • FAyncPurge 스레드에서 UObject 소멸자 호출

     

    언리얼에서 Mark & Sweep 에 대응하는 함수들을 모아봤습니다. 크게 3가지 단계로 볼 수 있을 것 같습니다.
    1). [Mark] PerformanceReachabilityAnalysis 단계입니다. 여기서는 먼저 모든 UObject 를 Unreachable 로 설정합니다. 그리고 RootSet 에서 연결된 모든 UObject 를 찾아다니면서 Unreachable 플래그를 제거해 줍니다.
    2). 이제 Unreachable 플래그가 남아있는 모든 UObject 를 수집합니다.

    1), 2) 번 단계는 여러 프레임간 분할하여 처리할 수 없습니다. 그래서 이 부분에서 UObject 가 많은 경우 히칭을 유발할 수 있습니다.

    3). [Sweep] IncrementalPurgeGarbage 단계입니다. 여기서는 제거할 오브젝트들을 처리합니다. 제거는 BeginDestroy(), FinishDestory() 총 2번의 UObject 해제 기회를 줍니다. BeginDestroy 에서는 UObject 가 소유하는 리소스들의 소멸을 시작하는 단계입니다. 그리고 UObject 가 소유하는 리소스들의 소멸이 마친 후 FinishDestroy 를 호출하여 최종적으로 리소스를 정리합니다. 그리고 각 단계를 처리했을 때 RF_BeginDestroyed, RF_FinishDestroyed 플래그가 설정됩니다. 하지만 Purge 단계 수행 전에는 UObject 가 아직 메모리에 남아있을 수 있습니다. 그래서 소멸 중인 메모리에 접근하고 있는지 의심스러운 경우 UObject Flag 에서 소멸 관련 플래그가 설정되어있는 게 아닌지 확인해 보면 좋을 것입니다.

    3) 번 단계는 BeginDestory와 FinishDestroy 는 프레임 분할 하여 처리할 수 있으며, UObject 가 실제로 메모리에서 delete 되는 것은 별도의 스레드에서 수행될 수 있습니다. 그래서 소멸자 호출이 항상 게임스레드에서 이루어진다고 가정하면 안 됩니다. (예를 들면 전역변수를 동기화 없이 수정하거나 TLS 의 데이터가 게임스레드일 것이라는 가정 등등…)

    여기에 있는 모든 함수들은 void CollectGarbage(EObjectFlags KeepFlags, bool bPerformFullPurge) 함수로 타고 들어갈 수 있습니다. GarbageCollection.cpp 파일을 참고해 주세요.

    (위의 설명에서 초록색 부분이 UObject 의 Flag 에 설정되는 값들이 변화를 나타냅니다.)

     

    3.4. UE5 가 레퍼런스를 유지하는 방식

    계속해서 GC 에 대해서 알아보기 전에 UObject 가 Root set 으로부터 레퍼런스를 유지하기 위해 어떤 방법이 있는지 알아봅시다. 여기서는 4가지 방법을 소개할 예정입니다.

    • UCLASS 의 UPROPERTY() 멤버 변수에 등록
    • FGCObject (UGCObjectReferencer)
    • Cluster Root 에 등록 (GC 를 처리하는 대표 UObject)
    • RootSet 에 등록

    차례대로 알아봅시다.

     

    3.4.1. UCLASS 의 UPROPERTY() 멤버 변수에 등록

    언리얼 공식문서에서 제안해 주는 가장 기본적인 GC 방식입니다. 그림3 와 같이 여러 타입의 변수에 UPROPERTY 를 설정할 수 있습니다. 여기에서 GC 가 관심 있는 부분은 UObject 와 관련된 멤버 변수들일 것입니다.

    그림3. UPROPERTY 매크로로 UObject 의 Reference를 지킬 수 있음.

     

    GC 하는 경우 RootSet 으로부터 연결되어 있는 UObject 를 빠르게 순회해서 Reachable 상태를 기록해야 합니다. 그렇다면 UObject 를 조금 더 빠르게 순회할 수 있게 전처리 할 수 있다면 좋겠죠? UObject 객체들의 타입과 클래스의 레이아웃에서의 Offset 정보를 알고 있다면, 인스턴스의 주소값을 갖고 있는 경우 즉시 원하는 UObject 로 캐스팅하여 사용할 수 있을 것입니다. 그것을 위해 사용하는 것이 FGCReferenceInfo 입니다. 이것은 uint32 크기이며, UCLASS 에 대한 모든 FGCReferenceInfo 들을 모아둔 정보를 ReferenceTokenStream 이라 부릅니다.

     

    3.4.1.1. ReferenceTokenStream 의 생성 과정

    ReferenceTokenStream UCLASS 가 소유한 UObject 의 멤버 정보를 모아둔 데이터입니다. UObject 의 멤버 정보는 uint32 에 저장되며 주요 정보는 UObject type 그리고 클래스 레이아웃에서의 UObject 멤버변수 Offset 입니다. ReferenceTokenStream UClass 객체가 생성되는 시점에 만들어지며, UClass 에 저장됩니다.

    그림4. 빠른 GC 를 위해서 사용하는 FGCReferenceInfo 클래스, 멤버변수인 UObject 들에 빠르게 접근하기 위해서 사용함.

     

    AssembleReferenceTokenStreamInternal 함수에서 ReferenceTokenStream 생성을 시작합니다.

    1. ReferenceTokenStream 을 만든 적이 없다면 생성을 시작합니다.

    2. 현재 UClass 가 보유한 UObject 를 모두 순회하면서 EmitReferenceInfo 를 호출합니다. 이 함수는 재귀적으로 자신이 가진 UObject 를 대상으로 동일한 함수를 호출합니다.

    3. 부모의 ReferenceTokenStream 정보를 얻어와서, 현재 UCLASS ReferenceTokenStream 과 병합합니다.

    그림5. ReferenceTokenStream 을 생성하는 과정

    그림5 의 코드에서 가장 중요해 보이는 것은 각 UPROPERTY 별로 호출되는 EmitReferenceInfo 인 것 같습니다. 이 부분을 확인해 봅시다. EmitReferenceInfo 는 모든 UPROPERTY 매크로를 사용할 수 있는 모든 타입에 대해서 제공됩니다. EmitReferenceInfo UObject FGCReferenceInfo 를 생성합니다.

    그림6. UObject 의 Token 을 만들어주는 EmitReferenceInfo 함수

     

    EmitReferenceInfo 가 호출된 것이 클래스라면 클래스 멤버들을 순회해야 할 것입니다.

    1. bHasPropertiesWithObjectReferences 변수를 사용하여 레퍼런싱 할 UObject 를 포함하고 있는지 검사합니다.

    2. UObject 가 있다면 그때서야 UPROPERTY 들에 대해서 EmitReferenceInfo 를 호출합니다. 재귀적으로 계속해서 EmitReferenceInfo 를 호출하다가 결국 말단에서는 UObject EmitReferenceInfo 를 호출할 것입니다.

    그림7. UCLASS 가 소유하는 UObject 들에 대해서 EmitReferenceInfo 를 호출하는 코드

     

    이제 클래스의 멤버에 있는 UObject FObjectProperty::EmitReferenceInfo 를 호출했다고 합시다.

    1. EmitReferenceInfo 가 호출되면, 해당 UObject 변수가 클래스의 레이아웃 상 얼마나 떨어진 위치에 있는지 정보와 함께 현재 UObject 의 타입인 GCRT_Object (기본 UObject)를 전달합니다.

    2. 그 후 EmitObjectReference 에서는 전달받은 변수 FGCReferenceInfo 를 생성하고 EmitReferenceInfo 에서

    3. TArray<uint32> Tokens 배열에 현재 UObject 에 대한 정보를 uint32 로 저장합니다.

    그림8. UObject 를 대상으로 EmitReferenceInfo 를 호출되어 Token 이 만들어지는 과정

     

    최종적으로 만들어진 ReferenceTokenStream FTokenStreamOwner 에 보관되며 UClass 의 멤버변수에 저장합니다

    그림9. 최종적으로 만들어진 Token 은 FTokenStreamView 에 들어가게 됨.

     

    UGCTest 클래스의 ReferenceTokenStream 이 어떻게 구성되는지 확인해 봅시다.. 이 클래스는 여러 UPROPERTY 를 갖고 있습니다. Float, FVector, UObject, Tarray<UObject>, FGCStruct 입니다. FGCStruct UObject, int32 UPROPERTY 를 가집니다. 그림10 을 봐주세요.

    그림10. ReferenceTokenStream 이 잘 만들어지는지 확인하기 위해 만든 임의의 클래스

     

    실제 ReferenceTokenStream 을 생성하면 우측 이미지와 같이 총 3개의 FGCReferenceInfo 정보만 생성됩니다.

    왜냐하면 여기서 UObject 에 대한 UPROPERTY 는 총 3가지뿐이기 때문입니다. UGCTest::Target, UGCTest::TargetElement 그리고 FGCStruct::Owner 입니다.

    1. 먼저 현재 생성된 Tokens 을 확인합니다. uint32 로 구성되어 있는 것을 확인할 수 있습니다.

    2. Token 을 FGCRefereceInfo 로 캐스팅해서 보면 실제 정보를 확인할 수 있습니다. 가장 중요한 정보인 Type 과 Offset 정보를 확인해 보면, UObject 타입과 ArrayObject 타입임을 확인할 수 있습니다.

    3. FGCReferenceInfo 의 Offset 이 실제로 해당 멤버변수가 이 UCLASS UGCTest 내에서 Offset 인지 확인해 봅시다.. offfsetof 함수를 통해 멤버 변수들의 Offset 을 검증해 주면 일치한다는 것을 확인할 수 있습니다.

    그림11. ReferenceTokenStream 을 통해 만들어진 Token 이 UObject 들에 대해서만 만들어졌고, type 과 offset 또한 잘 만들어진 것을 확인할 수 있음

    이렇게 ReferenceTokenStream 이 만들어졌습니다. 생성이 완료된 후에는 UCLASS_TokenStreamAssembled 플래그를 UCLASS 에 설정하여 중복하여 ReferenceTokenStream 을 생성하지 않도록 막아줍니다. 이 구조를 가지고 실제 Root set 에서 UObject 의 Reference 를 따라가는 부분은 뒤에서 계속해서 보겠습니다.

     

    3.4.2. FGCObject (UGCObjectReferencer) 를 상속 받기

    가끔은 UObject UObject 가 아닌 객체의 멤버여야 하는 경우가 있습니다. 그런 경우 레퍼런스 유지는 FGCObject 를 통해 할 수 있습니다. AddReferenceObjects 함수에 레퍼런싱 해줘야 할 UObject 를 전달해 주면 해당 UObject 의 레퍼런스가 유지되는 방식입니다. 그림12를 확인해 주세요.

    그림12. FGCObject 를 사용한 예제

    어떻게 이것이 가능한 간단히 확인해 봅시다.

     

    3.4.2.1. UGCObjectReferencer

    FGCObject 는 전역 객체인 GGCObjectReferencer 를 객체를 사용합니다. 그리고 이 객체는 RootSet 입니다. 그림13 에 UGCObjectReferencer 를 Root set 으로 등록하는 부분을 확인할 수 있습니다.

    그림13. GGCObjectReferencer 는 RootSet 이며, FGCObject 의 레퍼런스를 유지해줌

     

    그림14 를 보면 FGCObject 의 생성 및 소멸 시에 UGCObjectReferencer 에 자신을 등록/해제하는 것을 볼 수 있습니다.

    그림14. 생성자/소멸자에서 FGCObject는 GGCObjectReferencer 에 추가/제거 됨.

     

    그림15 의 코드는 실제 RootSet 에서 레퍼런스가 있는 UObject 를 순회하는 코드입니다(PerformReachabilityAnalysisOnObjects 과정). 뒤에서 자세히 다룰 거지만 UGCObjectReferencer 에 대해서만 먼저 확인해 봅시다. 모든 UObject 는 자신이 가진 멤버함수인 AddReferenceObjects 를 호출하게 됩니다. UGCObjectReferencer 는 이때 자신이 보유하고 있는 UObject 를 레퍼런스 콜렉터에 전달합니다.

    그림15. RootSet 에서 부터 UObject 의 레퍼런스를 추적하는 중, AddReferencedObjects 함수를 통해서 자신이 소유하고 있는 FGCObject 들에 레퍼런스를 유지시켜주는 코드

    그리고 각각의 UObject 는 자신의 AddReferencedObjects 를 호출하게 되는데 결과적으로 그림16 의 코드를 호출하여 Unreachable 플래그를 제거합니다. 이 부분은 뒤에서 전체 코드를 따라갈 때 다시 한번 확인해 봅시다.

    그림16. AddReferencedObjects 코드는 결국 HandleValidReference 함수를 호출하여 Unreachable 플래그를 제거해줌

     

    3.4.3. Cluster Root 에 등록 (GC 를 처리하는 대표 UObject)

    Clustering에 대해서도 알아봅시다. Clustering GC 동안 순회할 오브젝트가 너무 많으니 Cluster Root 가 될 UObject 를 설정하고, Cluster Root 의 Reachability 에 따라서 Clustered UObject GC 여부를 결정하는 것입니다.

    이 부분을 활용하는 가장 큰 예제는 SpawnActor Spawn 되는 AActor 입니다. 먼저 ULevel AActor 를 관리하는 방식을 알아봅시다. SpawnActor 로 생성된 AActor 는 특별한 레퍼런스를 유지해주지 않아도 ULevel 이 소멸되기까지 레퍼런스가 유지됩니다. ULevelAddReferencedObjects 에서 레퍼런싱을대신해 주기 때문입니다.

     

    ULevel 이 소멸될 때 그 하위에 있는 모든 Aactor 가 소멸된다면 ULevel 만 Reachability 를 테스트하는 것으로 충분할 것입니다.. 그래서 바로 여기가 Clustering 을 하기 좋은 곳입니다. 그림17에서 빨간색 네모 부분은 Clustering 을 하지 않은 경우입니다.. 이때는 모든 Aactor 들에 대해서 레퍼런스를 유지해 달라고 GC에 요청합니다.. 하지만 Clustering 을 사용한다면 위쪽에 있는 조건문에 들어가게 되며 여기서는 ActorsForGC 에 있는 객체에만 레퍼런스를 유지해 줍니다. ActorsForGC 는 예외적으로 Clustering 을 하지 않겠다고 설정한 AActor 들을 처리합니다. 그리고 나머지 Clustering 가능한 것들은 ULevel 이 갖고 있는 Cluster Root 객체에 AActor 를 등록합니다.

    그림17. ULevel 이 소유한 AActor 의 레퍼런스를 유지해주는 코드, ActorClustering 을 사용하지 않는 경우 빨간색 네모의 Actors를, 그렇지 ActorClustering 을 사용하는 경우 ActorsForGC 의 Reference를 유지시킴. ActorsForGC 는 Clustering 을 지원하지 않는 AActor 들이 들어가있음.

     

    ULevel 에서 Clustering 을 담당하는 UObject ULevelActorContainer 입니다. 클래스의 내용은 아주 간단합니다. 그림18 에서 ULevel 에 있는 ULevelActorContainer 와 클래스 내부 구조를 볼 수 있습니다.

    그림18. ULevel 의 ActorClustering 을 담당하는 ULevelActorContainer 클래스

     

    Cluster Root 생성 과정은 AActor Clustering 가능한 AActor CULevelActorContainer 에 추가해 줍니다.. 나머지는 ActorsForGC 에 담아주어 그림19와 같이 ULevel 이 레퍼런스를 관리하도록 합니다.

    그림19. ULevelActorContainer 의 생성과정

     

    3.4.4. Root set 에 등록

    RootSet 을 사용한 레퍼런스 유지는 아주 간단합니다. UObject AddToRoot 를 호출합니다. 그럼 ObjectFlags RootSet 이 설정됩니다. RootSet 설정을 해제할 때까지 이제 이 UObject 는 GC 에서 자유롭습니다.

    그림20. RootSet 에 등록하는 코드

    GC Mark 단계 중 MarkObjectsAsUnreachable 에서는 일단 모든 UObject 를 Unreachable 로 설정한다고 하였습니다. 그림21 를 보면 RootSet 인 경우 예외로 ObjectsToSerializeArray 에 추가해 주는 것을 볼 수 있습니다. 이 배열은 레퍼런스 추적을 시작할 RootSet 오브젝트들을 담는 컨테이너이며, 다음 단계인 PerformReachabilityAnalysisOnObjects 에서 이 컨테이너를 사용하여 레퍼런스를 추적을 시작합니다.

    그림21. RootSet 인 UObject 의 경우 별도로 모아서 해당 UObject 에서의 Reference 추적할 수 있도록 준비함.

     

    3.5. GC 코드 리뷰

    Mark & Sweep 단계에서 실행되는 GC 함수는 CollectGarbageImpl 에서 확인할 수 있습니다.

     

    GC 코드를 쉽게 보기 위해서 2가지 부분을 가정할 것입니다. 첫째로, Clustering 관련 코드는 이번에는 Clustering 부분은 제외하고 확인해 볼 예정입니다. 그리고 Unreachable 로 설정한 UObject 를 CollectGarbageImpl 내에서 FullPurge 된다고 가정하고 코드를 보겠습니다.

     

    1. PerformReachabilityAnalysis 는 Mark 단계에 속합니다. 이 함수 내에는 2개의 함수가 있는데 첫 번째 함수인 MarkObjectsAsUnreachable 에서는 RootSet 정보를 모으고, 모든 오브젝트에 Unreachable 설정합니다. 두 번째 함수 PerformReachabilityAnalysisOnObjects 에서는 RootSet 으로부터 레퍼런스가 있는 UObject 에 Unreachable 설정을 제거합니다.

    2. Mark 를 마쳤기 때문에 Unreachable 설정이 남이 있는 UObject 를 모두 모읍니다.

    3. FullPurge 옵션이 켜져 있을 때, 진입하며 UObject 에 ConditionalBeginDestroy 를 호출합니다.

    4. FullPurge 옵션이 켜져있을 때, 진입하며 UObject 에 ConditionalFinishDestroy 를 호출합니다. 그리고 FAsyncPurge 를 통해서 UObject 의 소멸자를 호출해 줍니다.

    그림22. GC 의 전체 과정 코드

     

    3.5.1. Mark 단계

    먼저 Mark 단계부터 보겠습니다.

    1. PerformReachabilityAnalysis 에서 Mark 단계를 수행합니다.

    2. InitialObjects 에 FGCObject::GGCObjectReferencer 를 넣어줍니다. 이 UObject는 FGCObject 의 레퍼런스를 유지하는 특수한 UObject 라고 위에서 봤었습니다.

    3. MarkObjectsFunctions 라는 함수포인터 배열을 통해서 함수를 호출합니다. 이렇게 구성한 이유는 Options 에 따라서 함수를 교체해서 사용하기 위해서입니다. 이 함수에서는 UObject 의 Root set 들을 수집하며, 동시에 UObject 들에 Unreachable flag 를 설정해 줍니다.

    4. PerformReachabilityAnalysisOnObjects 는 전단계에서 수집한 Root set 으로부터 레퍼런스가 있는 UObject 를 따라가면서 Unreachable flag 를 제거합니다. 이 함수 또한 옵션에 따른 함수 포인터들을 가지고 있습니다. 함수 설정 부분은 그림24 를 봐주세요.

    그림23. Mark 단계 코드

     

    그림24. 옵션에 따라서 실행될 수 있는 함수들을 배열에 함수 포인터 형태로 보관

     

    3.5.1.1. RootSet 수집과 Unreachable 설정(MarkObjectsFunctions)

    1. 먼저 MarkObjectsAsUnreachable 함수를 호출합니다. 이 함수의 EObjectFlags KeepFlags 는 이번 GC 에서 살려줄 ObjectFlag 정보입니다.

     2. ObjectsToSerializeArrays 함수는 GC 당하지 않는 RootSet 과 KeepFlags 를 가진 UObject 를 모아줍니다.

    3. ParallelFor 내부로 들어가 보면 GUObjectArray.GetFirstGCIndex() 를 보다 더 큰 인덱스를 가진 UObject 만 GC 대상으로 하는 것을 볼 수 있습니다. 이 부분은 레퍼런스2의 FUObjectArray 의 DisregardForGC 에서 확인할 수 있습니다.

    4. 이제 현재 스레드가 담당하는 UObject 를 순회합니다.

    5. RootSet 인 경우 바로 LocalObjectsToSerialize 에 UObject 를 담고 넘어갑니다.

    6. Cluster에 속하지 않거나 Cluster Root 의 처리를 확인합니다. Cluster 에 대해서는 다루지 않을 것이므로 Cluster 에 속하지 않는 오브젝트로 생각하고 코드를 보겠습니다.

    7. KeepFlag 에 해당하는 flag 를 현재 처리 중인 UObject 가 가지고 있는지 확인합니다. KeepFlag 를 갖고 있다면 Root set 처럼 Unreachable 설정을 하지 않도록 합니다.

    8. 실제 Unreachable 을 설정하는 부분입니다. Unreachable 되거나 Root set 리스트에 넣어주거나 합니다.

    9. ParallelFor 를 마치고 InitialObjects 에 Root set 인 UObject 를 모두 넣어줍니다.

    10. 이후 Cluster 에 대한 추가 처리는 이번에 다루지 않을 것입니다.

    그림25. RooSet 인 UObject 를 모으고, 나머지 UObject 는 Unreachable flag 를 설정함.

     

    3.5.1.2. RootSet 으로 부터 레퍼런스 추적(PerformReachabilityAnalysisOnObjects)

    MarkObjectsFunctions 이 호출되고 나면 InitialObjects 에 Root set 인 UObject 가 모여있을 것이고, 그렇지 않은 Object 는 Unreachable flag 가 설정되어 있을 것입니다.

    1. 이제 InitialObjects 를 PerformReachabilityAnalysisOnObjects 에 전달하여 Root set 으로부터 레퍼런스가 있는 UObject 들에 Unreachable flag 를 제거해 줍니다. 이 부분을 자세히 따라가 봅시다.

    그림26. RootSet 으로 부터 레퍼런스를 추적하는 PerformanceReachabilityAnalysisOnObjects

     

    ReachabilityAnalysisFunctions 함수를 호출하면, 옵션에 맞는 적절한 함수가 호출될 것입니다.

    1. CollectReferences 함수가 호출됩니다.

    2. 계속해서 옵션에 맞는 적절한 ReachabilityCollector 를 사용하여 CollectReferences 함수를 호출합니다.

    3. CollectReferences 내부에는 병렬 처리인 경우와 아닌 경우가 있는데, 병렬 처리인 경우도 처리할 작업을 나눠서 ProcessObjectArray 함수를 호출합니다. 우리는 바로 ProcessObjectArray 함수로 들어가 봅시다.

    4. ProcessObjectArray 에서 실제로 UObject 의 Reference 를 따라가면서 Unreachable flag 를 제거합니다. 그림28 에서 함수 내부를 더 자세히 확인해 봅시다.

    그림27. 실제 레퍼런스 추적하는 ProcessObjectArray 함수까지의 실행과정

     

    1. ProcessObjectArray 에서는 먼저 Root set 의 UObject 가 담겨있는 InitialObjects 를 기준으로 순회를 시작합니다.

    2. 모든 Root set 을 순회하기 시작합니다.

    3. 현재 처리 중인 UObject 의 UClass 로 부터 미리 만들어둔 TokenStream 을 얻어옵니다.

    4. 현재 UObject 의 주소를 저장해둡니다. 이 UObject 의 시작 주소와 멤버 변수의 Offset 이 있으면 실제 멤버변수를 캐스팅하여 멤버 변수 정보를 바로 얻어올 수 있을 것입니다.

    5. 처리 중인 UObject 가 가진 Token 을 하나씩 처리하면서 멤버 UObject 들을 차례로 순회합니다.

    6. 가장 간단한 형태인 UObject 형태를 추적해 봅시다. UObject 의 주소가 담긴 StackEntryData 와 Offset 정볼르 통해서 UObject 로 바로 캐스팅하는 것을 볼 수 있습니다. 그리고 HandleKillableReference 를 호출하여 Unreachable 을 flag 를 해지합니다. Unreachable 을 해지한 UObject 는 또다시 자신이 가지고 있는 UObject 멤버변수를 추적해야 할 것입니다. 그래서 Context.ObjectsToSerialize 배열에 Unreachable 을 해지한 UObject 를 따로 모읍니다. 그리고 하나더 UObject 에 MarkPendingKill() 또는 MarkAsGarbage 함수를 통해서 UPROPERTY() 매크로로 설정되어있어서 레퍼런스를 갖고 있지만 GC 를 시키는 기능이 있습니다. 이 경우 UPROPERTY() 에 바인딩되어있는 UObject 변수는 nullptr 로 초기화 됩니다. 이 부분도 여기서 처리됩니다. HandleKillableReference 의 상세한 부분은 그림29 에서 실제 코드를 더 보겠습니다.

    7. HandleKillableReference 를 호출하면 ObjectsToSerialize 에 Unreachable 을 해지한 UObject 가 따로 모인다고 하였습니다. 이제 재귀적으로 현재 처리 중인 UObject 의 멤버 UObject 들의 Reference 를 추적하기 위해서 CurrentObjects 를 갱신해 줍니다.

    그림28. 현재 처리중인 UObject 에 대한 ReferenceTokenStream 을 사용하여, 자신의 멤버인 UObject 를 빠르게 순회하는 코드

     

    1. 레퍼런스가 있는 UObject 에 HandleKillableReference 를 호출합니다.

    2. 처리를 한 번에 하기 위해서 특정 Queue 에 담아둡니다. Killable 옵션은 PendingKill 여부입니다.

    3. 다 모은 Queue 를 Flush 하여 모두 처리하는 부분은 그림28에 7번 과정에 나오는 FlushQueuedReferences() 함수를 통해 수행됩니다.

    4. HandleBatchedReference 함수가 호출됩니다.

    5. PendingKill or Garbage 가 설정되어 있는 UObject 가 설정되어 있는지 확인합니다. 만약 그렇다면 KillReference 함수를 통해서 현재 UObject 의 멤버인 UObject 에 nullptr 를 설정해줍니다. 이런 형태로 UPROPERTY() 를 사용하여 레퍼런스를 유지하는 UObject 가 강제로 다른 위치에서 GC 를 요청한 경우에도 일관성있게 UCLASS 의 UObject 의 상태를 업데이트해줍니다.

    6. KillReference 함수에서 현재 UObject 의 멤버인 UObject 에 nullptr 를 설정해 주는 것을 볼 수 있습니다.

    7. KillFlag 가 없는 UObject 는 계속해서 HandleValidReference 를 호출합니다.

    8. HandleValidReference 에서는 가장 먼저 Unreachable flag 를 제거합니다.

    9. 그런 뒤 Unreachable flag 를 제거한 UObject 가 자신의 멤버변수들에 대해서 레퍼런스를 확인할 수 있도록 ObjectsToSerialize 에 추가해 줍니다.

    그림29. UObject 를 Unreachable flag 해제 하고, Unreachable flag 가 해제된 UObject 의 레퍼런스를 따라갈 수 있게 ObjectsToSerialize 배열에 추가해주는 코드

     

    3.5.2. Unreachable 플래그를 가진 UObject 수집(GatherUnreachableObjects)

    다음으로 Unreachble 플래그가 있는 UObject 를 모으는 단계입니다.

    1. GatherUnreachableObjects 함수가 호출됩니다.

    2. ParallelFor 를 사용하여 모든 FUObjectArray 를 대상으로 합니다.

    3. Unreachable 인 오브젝트들을 모읍니다.

    4. GUnreachableObjects 배열에 담습니다. 이 배열에 담긴 UnreachableObjects 는 Incremental 방식으로 천천히 실행될 수 있습니다. 지금까지의 Reachability test 와 GatherUnreachable 과정은 경우 반드시 이번 프레임에 모두 마쳐야 합니다. 하지만 이후 과정은 incremental 방식으로 처리해도 됩니다. 그래서 UObject 가 굉장히 많은 경우 이 지점까지가 히칭에 가장 큰 영향을 주는 지점일 것입니다.

    그림30. Mark 과정을 마친 뒤 Sweep 을 위해 Unreachable flag 를 가진 UObject 를 모음

     

     

    3.5.3. Sweep 단계

    다음으로 Sweep 단계입니다. Incremental 방식으로 처리할 수 있지만 우리는 FullPurge 방식으로 이번 프레임에 모두 마치는 방식으로 코드를 볼 것입니다.

     

    Sweep 은 두 단계인데, BeginDestroy, FinishDestroy 입니다.

    BeginDestroy 는 리소스 해지를 시작한다고 알리는 단계입니다. UObject 와 연관된 여러 리소스들이 비동기적으로 소멸될 것입니다. 예를 들면, UWorld 의 경우 BeginDestroy 에서 PhysicsScene 을 Destroy 하라고 요청할 수 있을 것입니다. 그리고 다음 단계인 FinishDestroy 에서는 PhysicsScene 의 소멸이 완료된 후 호출하여 최종 리소스 해제를 마무리합니다.

     

    3.5.3.1. UObject 가 소유한 리소스들의 해제 시작 (BeginDestroy)

    첫 번째로 Sweep 의 첫번째 단계인 BeginDestroy 호출 과정입니다. 

    1. UnhashUnreachableObjects 는 바로 전 과정에서 구한 GUnreachableObjects 를 순회합니다.

    2. 각각의 UObject 에 ConditionalBeginDestroy 함수를 호출하여 리소스 해제를 알립니다.

    3. Incremental 방식으로 처리하는 경우 timeout 을 설정해 주는데, 우리는 FullPurge 로 처리하기 때문에 무시합니다.

    4. ConditionalBeginDestroy 가 호출되면, UObject flag 에 RF_BeginDestoroyed 가 설정되며 BeginDestroy 함수가 호출됩니다. 여기서 리소스 해지를 할 수 있습니다.

    그림31. Sweep 단계의 BeginDestroy 함수 호출

     

    3.5.3.2. UObject 해제 완료 (FinishDestroy)

    두 번째로 Sweep 의 첫 번째 단계인 FinishDestroy 호출 과정입니다.

    1. IncrementalPurgeGarbage 함수를 호출합니다.

    2. FullPurge 단계라 IncrementalDestroyGarbage 로 계속해서 들어갑니다.

    3. FAsyncPurge 를 생성합니다. 이 객체는 실제 UObject 에 소멸자를 호출해 주는 객체로 FinishDestroy 를 마치고 다시 나옵니다.

    4. GUnreachableObjects 를 순회합니다.

    5. FinishDestroy 를 호출할 수 있는지 여부를 확인합니다. 가능하면 ConditionalFinishDestory 를 호출합니다. 그렇지 않으면 FinishDestory 호출을 지연한 UObject 리스트에 담아둡니다.

    6. UObject flag 에 RF_FinishDestoryed 설정되었는지 확인하고 UObject 의 FinishDestroy() 를 호출합니다.

    그림32. Sweep 단계의 FinishDestroy 단계

     

    3.5.4. UObject 소멸자 호출 후 FUObjectArray 에서 제거

    계속해서 IncrementalDestroyGarbage 를 수행합니다.

    1. 그림32 의 3에서 만들어둔 GAsyncPurge 를 사용하여 Purge 를 시작합니다. 여기서는 UObject 의 소멸자를 호출합니다.

    2. TickDestroyGameThreadObjects 를 호출합니다.

    3. 내부에서는 GUnreachableObjects 를 순회합니다.

    4. UObject 에 소멸자를 호출합니다.

    5. UObject 의 소멸자를 호출하면 결국 FUObjectArray 에서 해당 UObject 의 위치를 초기화하여 재사용 가능하게 해 줍니다.

    그림33. 최종 Purge 단계, UObject 를 소멸자를 호출하여 해지함

     

    4. 레퍼런스

    1. https://github.com/EpicGames/UnrealEngine/commit/463443057fb97f1af0d2951705324ce8818d2a55

    2. [UE5] FUObjectArray (GUObjectArray)

    3. https://en.wikipedia.org/wiki/Tracing_garbage_collection

    4. 멀티 플랫폼 최적화: 프로그래머편

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

    6. https://zhuanlan.zhihu.com/p/133293284

    7. https://www.unrealengine.com/ko/blog/unreal-property-system-reflection

     

    'UE4 & UE5 > Core' 카테고리의 다른 글

    [UE5] FUObjectArray (GUObjectArray)  (4) 2021.06.20

    댓글

Designed by Tistory & scahp.