Unity 검색

Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
공유

Is this article helpful for you?

Thank you for your feedback!

Unity는 사용자에게 최신 .NET 기술을 제공하기 위해 지속적으로 노력하고 있습니다. 이러한 과정에 매진하고 있는 팀의 일원으로서 기쁜 마음으로 진행 상황을 공유해 드립니다. 지속적인 노력의 일환으로, 더욱 발전된 형태의 효율적인 고성능 GC(가비지 컬렉터)가 포함된 .NET CoreCLR JIT 런타임과 기존 Unity 코드가 호환되도록 구현하고자 합니다.

이 블로그 게시물에서는 CoreCLR GC와 Unity 엔진의 네이티브 코드가 함께 작동할 수 있도록 최근에 도입한 변경 사항을 설명합니다. 먼저 포괄적인 수준에서 시작한 다음, 점차 기술적인 세부 사항을 다루겠습니다.

가비지 컬렉터에 대한 짧은 설명

C# 언어로 수행한 메모리 할당은 가비지 컬렉터가 관리합니다. 메모리 할당이 필요할 때마다 해당 메모리를 할당하는 코드는 더 이상 사용되지 않는 메모리를 무시할 수 있습니다. GC는 이 메모리를 재활용하여 나중에 다른 코드에서 사용할 수 있게 합니다.

Unity는 현재 움직이지 않는 보수적 GC인 Boehm GC를 사용합니다. Boehm GC는 수집할 관리 오브젝트를 찾아 모든 스레드 스택(관리되는 코드와 네이티브 코드 포함)을 스캔하며, 관리되는 오브젝트를 할당하고 나면 해당 오브젝트의 메모리 내 위치는 절대 변하지 않습니다.

반면 .NET은 움직이는 정밀한 GC인 CoreCLR GC를 사용합니다. CoreCLR GC는 관리되는 코드에서만 할당된 오브젝트를 추적하며, 메모리에서 오브젝트를 옮겨 성능을 향상합니다. 따라서 CoreCLR GC는 훨씬 적은 오버헤드로 작동하며 게임의 성능은 더욱 향상됩니다.

두 GC는 모두 각자의 영역에서 뛰어난 성능을 발휘하지만, 각각을 사용하는 코드에 대한 요구 사항은 서로 다릅니다. Unity 엔진과 에디터 코드는 Boehm GC의 요구 사항을 기반으로 개발되었기 때문에, CoreCLR GC를 사용하려면 Unity 코드를 일정 부분 변경해야 합니다. 여기에는 Unity가 개발한 커스텀 마샬링 툴인 바인딩 제너레이터와 프록시 제너레이터에 대한 변경도 포함됩니다.

가비지 컬렉터의 기능

관리되는 코드는 길모퉁이에 카페가 있고 거리에는 슈퍼마켓이 있으며 우리가 일상을 보내는 도시라고 생각할 수 있습니다. 이를 ‘Managed Code Landia’라고 하겠습니다. 개발자들에게 이 도시는 너무나 살기 좋은 곳입니다. 하지만 때로는 야생의 C++ 코드가 서식하는 ‘Native Code Wildlands’로 훌쩍 떠나고 싶을 때도 있을 것입니다.

Drawing depicting how a metaphorical garbage collector manages code between “Managed Code Landia” and “Native Code Wildlands.” Each element is enclosed within a red, dotted rectangle and a garbage collector is shown traveling between the two.

두 곳을 오갈 때는 관리되는 메모리를 가지고 다닐 수 있습니다. 마샬링 철도는 객실에 반입 가능한 여행 가방을 지니고 탑승하도록 허용하기 때문입니다. 그러면 Native Code Wildlands에서 소소한 기념품을 챙겨 집으로 가져올 수 있을 것입니다.

더 이상 사용하지 않는 메모리가 어디에 있든 GC가 이를 열심히 추적해서 재활용해 주기 때문에 개발자에게는 매우 편리합니다. 하지만 GC는 해야 할 일이 많습니다. 수많은 스레드와 호출 스택이 순식간에 누적되기 때문입니다. 그래서 Native Code Wildlands를 여러 번 오가다 보면 GC는 이를 따라잡기에 급급해질 것입니다.

GC의 부담을 완화하는 방법

Unity 엔진을 CoreCLR에 포팅하는 과정의 대부분은 엔진 코드가 GC와 효과적으로 호환되도록 만드는 작업입니다.

Drawing depicting how a metaphorical garbage collector manages code between “Managed Code Landia” and “Native Code Wildlands.” In it, Managed Code Landia is enclosed within a green, dotted square and a garbage collector is shown inside the green box.

GC와 마샬링 철도는 관리되는 메모리가 Native Code Wildlands로 넘어가지 못하게 하는 협약을 맺었습니다. 이 협약 덕분에 GC가 할 일이 크게 줄어들어 효율성이 증대되었습니다. CoreCLR GC는 바로 이러한 모드로 작동하면서 정확히 어떤 오브젝트가 존재하는지를 알고 오직 관리되는 코드만을 처리합니다. 또한 오브젝트를 메모리에서 옮겨 효율성을 높일 수 있습니다.

경계를 설정하는 방법

위와 같이 다이어그램과 이모티콘을 사용한 재미있는 설명도 좋지만, 우리는 실제로 관리되는 코드와 네이티브 코드를 수천 번 왕복하며 10년 이상 발전해 온 프로덕션 코드 기반에서 시스템을 구현해야 합니다.

시스템 설계 관점에서 이 문제에 접근하려면 먼저 경계를 찾아야 합니다. Unity에는 두 가지 중요한 내부 경계가 존재합니다.

  1. 관리되는 코드에서 네이티브 코드로의 호출(p/invoke와 유사), 바인딩 제너레이터 툴 사용
  2. 네이티브 코드에서 관리되는 코드로의 호출(Mono의 runtime invoke와 유사), 프록시 제너레이터 툴 사용

이 두 가지 툴은 C++과 IL 코드를 생성해 철도처럼 두 코드를 연결하며 그 사이에서 메모리를 전달하고 조정합니다. 지난 1년간 Unity 개발자들은 GC를 통해 할당된 오브젝트가 경계 밖으로 유출되는 것을 방지하는 한편, 실제로 유출이 발생했을 때는 유용한 진단을 제공할 수 있도록 이 두 가지 코드 제너레이터를 수정해 왔습니다. 또한 Unity는 관리되는 코드와 네이티브 코드의 경계를 넘나드는 코드를 모색하고 있으며, 해당 코드를 이러한 코드 제너레이터로 이전하고자 노력하고 있습니다.

이를 위해 Unity의 수많은 개발자들은 열심히 엔진 코드를 변경하며 사용자에게 새로운 기능을 제공하고 버그를 수정하고 있습니다. 이처럼 Unity는 기존 시스템을 유지하면서 개선 작업을 계속 진행하고자 합니다. 이러한 전환을 어떻게 점진적으로 구현하고 있는지 이해할 수 있도록, 관리되는 경계 및 네이티브 경계의 한 가지 측면인 System.Object를 집중적으로 살펴보겠습시다.

System.Object의 변치 않는 중요성

.NET의 GC에 의해 할당된 모든 메모리는 반드시 System.Object 유형의 오브젝트에 연결되어야 합니다. 이는 모든 .NET 유형의 기본 클래스이므로, 네이티브 코드로 넘어가는 메모리의 중심점이 되는 경우가 많습니다. Unity 엔진 C++ 코드는 ScriptingObjectPtr 추상화를 사용해서 System.Object를 다음과 같이 나타냅니다.

typedef MonoObject* ScriptingBackendNativeObjectPtr;

class ScriptingObjectPtr {
    public:
        ScriptingObjectPtr(ScriptingBackendNativeObjectPtr target) : m_Target(target) {}
    protected:
        ScriptingBackendNativeObjectPtr m_Target;
};

관리되는 메모리는 바로 이런 방식으로 네이티브 코드로 전달됩니다. ScriptingBackendNativeObjectPtr는 GC를 통해 할당된 메모리의 포인터입니다. Unity의 현재 GC는 네이티브 코드의 호출 스택을 모두 순회하면서 ScriptingObjectPtr와 같은 메모리를 보수적인 방식으로 찾습니다. 이러한 인스턴스들이 GC를 통해 할당된 메모리를 가리키는 포인터가 되지 않도록 변경할 수 있다면, GC의 부담을 완화하여 결국에는 더 빠른 CoreCLR GC로 전환할 수 있게 될 것입니다.

세 가지 방식의 공존

ScriptingObjectPtr를 한 가지 방식으로 나타내는 대신, 다음 세 가지 방식 중 하나로 나타낼 필요가 있습니다.

  1. GC를 통해 할당된 포인터(현재 표현 방식)
  2. 관리되는 스택 레퍼런스
  3. System.Runtime.InteropServices.GCHandle

GC를 통해 할당된 포인터는 GC에 안전하지 않은 사용 사례를 모두 제거하기 위해 일시적으로 거치는 단계입니다. 이는 ScriptingObjectPtr가 지금과 같은 방식으로 계속 작동할 수 있게 합니다. 모든 Unity 코드가 CoreCLR GC에 대해 안전해지면 이 사용 사례를 제거하게 됩니다.

관리되는 스택 레퍼런스는 관리되는 코드에서 네이티브 코드로 값이 전달될 때 관리되는 오브젝트에 대한 간접 레퍼런스를 효율적으로 나타냅니다. GC를 통해 할당된 포인터 변수의 주소는 GC를 통해 할당돤 포인터 그 자체가 아니라 네이티브 코드로 전달됩니다. 이때 로컬 주소 자체는 GC가 옮기지 않으며 관리되는 오브젝트는 호출 스택에서 관리되는 코드로 유지되므로 이는 GC에 안전한 과정입니다. 이러한 방식은 CoreCLR 런타임 내부에서 사용되는 유사한 기법에서 착안한 것입니다.

GCHandle은 관리되는 오브젝트에 대해 강력한 간접 레퍼런스의 역할을 하면서 GC가 해당 오브젝트를 수집하지 못하게 합니다. Managed Code Landia에 일부 메모리를 남겨 둔 채로 Native Code Wildlands에서 휴가를 즐기더라도, GC는 휴가가 끝나고 돌아올 때까지 해당 메모리가 보존되기를 바란다는 것을 알고 있습니다. 이는 관리되는 스택 레퍼런스 사례와 유사하지만 명시적인 수명 관리를 필요로 합니다. GCHandle을 구축하고 허무는 과정에서는 오버헤드가 추가적으로 발생합니다. 따라서 이 표현 방식은 정말로 필요한 경우에만 사용하는 것이 좋습니다.

이는 ScriptingBackendNativeObjectPtr를 대체하는 새로운 유형인 ScriptingReferenceWrapper를 사용해서 구현할 수 있습니다.

struct ScriptingReferenceWrapper
{
    // Various constructors elided for brevity
    void* GetGCUnsafePtr() const;
    static ScriptingReferenceWrapper FromRawPtr(void* ptr);
private:
    // Assumption: all pointers are 8 byte aligned.
    // This leaves 2 bits for tracking.
    // One bit is already in use by GCHandle
    // Bits
    // 0 - reserved for GC Handles.
    // 1 - 0 - object reference
    //   - 1 - gc handle
    // 2 - 0 - this is a managed object pointer
    //   - 1 - this is a GCHandle or object reference

    // 0b00 - object pointer
    // 0b01 - object reference
    // 0b1_ - gc handle; lowest bit is implementation specific

    bool IsPointer() const { 
return (((uintptr_t)value) & 0b11) == 0b00; }
    bool IsRef() const { 
return (((uintptr_t)value) & 0b11) == 0b01; }
    bool IsHandle() const { 
return (((uintptr_t)value) & 0b10) == 0b10; }

    uintptr_t value;
};

위에서는 내부 리소스의 적절한 수명 관리를 실행하는 상당수의 생성자와 할당 연산자를 제거했습니다.

이 유형의 크기에 주목해 보세요. 이는 포인터와 크기가 동일한 단 하나의 uintptr_t 값으로만 구성되는데, 즉 ScriptingReferenceWrapper의 크기가 ScriptingBackendNativeObjectPtr와 동일하다는 것입니다. ScriptingObjectPtr가 차이를 인식하므로 코드를 사용하지 않고도 일대일 1:1가 가능합니다.

여기서 핵심은 코드의 주석에 언급된 4바이트 얼라인먼트 요건입니다. C# 언어로 이루어진 메모리 할당은 가비지 컬렉터가 관리합니다. 이를 바탕으로 해당 값의 2비트를 다시 사용하여 세 가지 표현 방식 중 무엇이 사용되는지를 나타낼 수 있습니다. GetGCUnsafePtrFromRawPtr 메서드는 Unity 코드를 전환하는 동안 GC를 통해 할당된 포인터 표현 방식에 대해 일시적인 상호호환성을 제공합니다.

호환 과정의 마무리

이상적인 조건에서 ScriptingObjectPtr 추상화는 불필요할 것이며, 관리되는 메모리는 네이티브 코드로 나타나지 않을 것입니다. 그러나 이를 허용하는 것이 유용한 사례가 있으므로, 엔진에서 GC 안전 작업을 완료하여 관리되는 스택 레퍼런스와 GCHandle 사례를 보존하는 한편 GC를 통해 할당된 포인터 사례를 완전히 제거하고자 합니다.

바로 이 부분에서 GC와 코드 제너레이터 사이의 협약이 필요합니다. 이제 세 개의 모든 하위 시스템이 ScriptingObjectPtr의 표현 방식을 이해할 수 있게 되었으며, Unity는 엔진 코드에서의 사용법을 점진적으로 대체해 나가고 있습니다. 불필요한 ScriptingObjectPtr는 제거하고, 적재적소에 가장 효율적인 표현 방식을 사용하는 것이죠. 각 사용법이 완전히 뒤바뀌지 않는 한, 각기 다른 표현 방식은 서로 공존할 것이며 시스템은 원활하게 유지될 것입니다.

GC에 완전히 안전한 엔진을 통해 CoreCLR GC를 활성화하면 GC는 Managed Code Landia에서 재활용할 메모리를 찾는 것에만 집중할 수 있습니다. 따라서 작업량은 훨씬 줄어들 것이며, 코드가 실행되는 프레임마다 더 많은 시간을 확보할 수 있습니다.

CoreCLR에 대한 Unity의 전환을 자세히 알아보려면 포럼을 방문하거나, Unite 2023에서 소개하는 Unity의 제품 로드맵을 확인해 보세요. X에서 @petersonjm1에게 직접 연락하셔도 됩니다. 연재 중인 Tech from the Trenches 시리즈에서 다른 Unity 개발자들의 새로운 기술 블로그도 확인할 수 있습니다.

2023년 10월 27일 엔진 & 플랫폼 | 10 분 소요

Is this article helpful for you?

Thank you for your feedback!

관련 게시물