Unity 검색

CoreCLR 가비지 컬렉터에서 AnimationEvent의 안전성 확보하기

2022년 10월 25일 테크놀로지 | 11 분 소요
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image

유니티는 사용자에게 최신 .NET 기술을 제공하기 위해 노력하고 있습니다. 이러한 노력의 일환으로 고성능의 더욱 효율적인 고급 가비지 컬렉터(GC)가 포함된 Microsoft의 .NET CoreCLR JIT 런타임과 기존 Unity 엔진의 코드가 호환되도록 구현하고자 합니다.

이번 블로그에서는 업데이트된 AnimationEvent와 고급 GC를 통합하기 위해 적용한 몇 가지 변경사항을 소개합니다.

관리형과 네이티브

Unity의 엔진 코드 작성에는 C#(관리형) 코드와 C++(네이티브) 코드가 모두 사용됩니다. 관리형 코드와 네이티브 코드를 동시에 사용하면 복잡하고 비용도 많이 들지만, 최고의 성능을 지닌 솔루션을 제공할 수 있습니다.

Unity의 AnimationClip 오브젝트에 있는 AddEvent 메서드를 예로 들 수 있습니다. 이 메서드 코드는 매우 간단합니다.

public void AddEvent(AnimationEvent evt)
{
    if (evt == null)
        throw new ArgumentNullException("evt");
    AddEventInternal(evt);
}

여기서 AddEventInternal은 네이티브 메서드입니다. Unity 엔진은 일반적인 p/invoke 마샬링 없이 메서드를 직접 호출하기 때문에 AddEventInternal을 구현하는 C++ 코드는 관리되는 힙에서 할당되는 메모리에 대한 포인터를 갖습니다. Unity는 보수적인 논컴팩팅 컬렉터인 잘 알려진 Boehm 가비지 컬렉터를 이용하므로 네이티브 코드가 관리되는 메모리에 직접 안전하게 액세스할 수 있습니다.

하지만 Unity에서 정밀한 컴팩팅 CoreCLR GC를 사용한다면 어떻게 될까요? 그러면 두 가지 문제에 부딪힐 수 있습니다. CoreCLR에서 다음과 같은 현상이 발생할 수 있습니다.

  1. 관리되는 메모리에서 AnimationEvent 유형의 레이아웃 변경
  2. AnimationEvent 인스턴스가 메모리에서 참조하는 오브젝트를 네이티브 코드가 사용하는 중에 이동

아래에서는 위 두 가지 문제를 어떻게 해결했는지 알아보겠습니다.

블릿의 여부

CoreCLR 런타임이 AnimationEvent 인스턴스의 데이터를 안정적으로 나타내고 이를 네이티브 코드에서 활용할 수 있어야 합니다. 이를 blittable 형식이라고 부르며, C# 및 C++ 모두에서 메모리의 비트가 완전히 동일함을 의미합니다. 현재 C#에서는 AnimationEvent가 다음과 같이 정의됩니다.

public sealed class AnimationEvent
{
    internal float m_Time;
    internal string m_FunctionName;
    internal string m_StringParameter;
    internal Object m_ObjectReferenceParameter;
    internal float m_FloatParameter;
    internal int m_IntParameter;

    internal int m_MessageOptions;
    internal AnimationEventSource m_Source;
    internal AnimationState m_StateSender;
    internal AnimatorStateInfo m_AnimatorStateInfo;
    internal AnimatorClipInfo m_AnimatorClipInfo;
}

CoreCLR 런타임은 메모리에서 레퍼런스 유형인 모든 필드를 표현의 앞쪽으로 이동하여 GC 코드가 캐시의 지역성을 활용할 수 있도록 합니다. 그러면 CoreCLR의 내부 레이아웃은 다음과 같습니다.

public sealed class AnimationEvent
{
    internal string m_FunctionName;
    internal string m_StringParameter;
    internal Object m_ObjectReferenceParameter;
    internal AnimationState m_StateSender;
    internal float m_Time;
    internal float m_FloatParameter;
    internal int m_IntParameter;
    internal int m_MessageOptions;
    internal AnimationEventSource m_Source;
    internal AnimatorStateInfo m_AnimatorStateInfo;
    internal AnimatorClipInfo m_AnimatorClipInfo;
}

필드는 모두 동일하지만 순서가 다른 것을 볼 수 있습니다. AddEventInternal의 코드는 두 가지 순서를 모두 다뤄야 합니다. 게다가 CoreCLR은 추후에 이 필드의 레이아웃을 자유롭게 변경할 수 있으므로 Unity 엔진 네이티브 코드를 이에 따라 바꿔야 할 수 있습니다.

이런 문제를 해결하려면 관리형/네이티브의 경계 사이에서 AnimationEvent의 데이터를 이동하기 위해서만 사용하는 새로운 내부 유형을 도입할 수 있습니다.

[StructLayout(LayoutKind.Sequential)]
internal struct AnimationEventBlittable
{
    internal float m_Time;
    internal IntPtr m_FunctionName;
    internal IntPtr m_StringParameter;
    internal IntPtr m_ObjectReferenceParameter;
    internal float m_FloatParameter;
    internal int m_IntParameter;

    internal int m_MessageOptions;
    internal AnimationEventSource m_Source;
    internal IntPtr m_StateSender;
    internal AnimatorStateInfo m_AnimatorStateInfo;
    internal AnimatorClipInfo m_AnimatorClipInfo;
}

참고로 이 유형의 필드 중에 레퍼런스 유형이 없으므로 CoreCLR에서 필드의 순서를 바꾸지 않습니다. 이름에서도 알 수 있듯이, 이는 AnimationEvent의 데이터를 blittable 형식으로 표현한 것입니다.

GC 핸들

m_FunctionName과 같은 레퍼런스 유형 필드가 어떻게 바뀌었는지 보셨나요? 이제 문자열이 아닌 IntPtr 형태입니다.

여기에 두 번째 문제에 대한 해결책이 있습니다. 일반적인 AnimationEvent에서 m_FunctionName은 GC에 할당된 이동 가능한 문자열에 대한 포인터입니다. 하지만 AnimationEventBlittable에서는 GCHandle 구조체이므로 네이티브 코드로 관리되는 오브젝트에 안전하게 액세스할 수 있습니다.

이제 AnimationEvent에서 AnimationEventBlittable로 변환하기 위한 메서드를 작성할 수 있습니다.

internal static AnimationEventBlittable FromAnimationEvent(AnimationEvent animationEvent)
{
    var animationEventBlittable = new AnimationEventBlittable
    {
        m_Time = animationEvent.m_Time,
        m_FunctionName = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_FunctionName)),
        m_StringParameter = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_StringParameter)),
        m_ObjectReferenceParameter = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_ObjectReferenceParameter)),
        m_FloatParameter = animationEvent.m_FloatParameter,
        m_IntParameter = animationEvent.m_IntParameter,
        m_MessageOptions = animationEvent.m_MessageOptions,
        m_Source = animationEvent.m_Source,
        m_StateSender = GCHandle.ToIntPtr(GCHandle.Alloc(animationEvent.m_StateSender)),
        m_AnimatorStateInfo = animationEvent.m_AnimatorStateInfo,
        m_AnimatorClipInfo = animationEvent.m_AnimatorClipInfo
    };
    return animationEventBlittable;
}

이제 안전한 AddEvent 메서드는 다음과 같습니다.

public void AddEvent(AnimationEvent evt)
{
    if (evt == null)
        throw new ArgumentNullException("evt");
    var animationEventBlittable = AnimationEventBlittable.FromAnimationEvent(evt);
    AddEventInternal(animationEventBlittable);
    animationEventBlittable.Dispose();
}

마지막으로 AddEventInternal에 몇 가지 변경 사항만 적용하면 GCHandle을 언래핑하고 실질적인 관리형 오브젝트 데이터로 돌아갈 수 있습니다. 이러한 변경사항을 적용하면 CoreCLR GC를 완전히 활용하면서 메모리도 안전하게 유지할 수 있으므로 매우 유용합니다.

작업 속도 변화

사실 작업 속도가 그렇게 빨라지지는 않았습니다. 실행해야 할 새로운 코드가 많기 때문이죠. 하지만 그렇기 때문에 더 즐거운 개발이 가능합니다.

유니티에서 이전만큼 실행 속도를 빠르게 만들 수 있었을까요? 결론부터 말씀드리자면 안전성을 유지하면서 성능을 회복하기 위해 엔지니어 팀이 세 가지 중요한 작업을 수행했습니다.

먼저, AnimationEventBlittable이 클래스가 아닌 구조체라는 점을 살펴보세요. 이 코드의 초안에서는 분명 클래스였습니다. GC를 통하지 않고 스택에 할당되므로 비용이 절감됩니다. FromAnimationEvent 메서드에 대한 Mono, Il2CPP, CoreCLR의 코드 생성은 뛰어나며, 메서드 자체에서 눈에 띄는 오버헤드가 발생하지 않습니다.

물론 FromAnimationEvent 메서드는 GCHandle.Alloc 메서드를 무려 네 번이나 호출하며, 이 메서드에는 적지 않은 비용이 소요됩니다. 모든 .NET 가상 머신 구현은 GCHandle을 할당하고 이를 추적하기 위한 내부 데이터 구조를 업데이트하는 중요 작업을 수행해야 합니다. 이러한 변경사항을 프로파일링하면서 GCHandle의 수명이 길지 않다는 중요한 사항을 깨달을 수 있었습니다. 각 핸들은 네이티브 코드 실행 시에만 필요하기 때문에 쉽게 재사용할 수 있습니다. 이러한 구현 방식은 적은 수의 GCHandle을 풀링하고 각 FromAnimationEvent 호출에 재사용합니다. 따라서 FromAnimationEvent가 여러 번 호출되는 실제 사용 사례에서는 GCHandle의 할당에 거의 비용이 들지 않습니다.

또한 네이티브 코드에는 아직 드러나지 않은 숨은 비용이 있습니다. 앞서 C++ 코드가 GCHandle을 언래핑한다고 이야기했습니다. 알고 보니 CoreCLR이 이 과정의 속도를 매우 빠르게 만듭니다. CoreCLR을 활용하면 GCHandle의 언래핑을 수행하는 데 있어 단순한 포인터 역참조만큼의 비용만 소요됩니다.

하지만 동일한 코드에서 Mono와 IL2CPP에 대한 벤치마크는 현저히 느렸습니다. 무엇이 문제일까요? 팀은 Mono와 IL2CPP에 있어 GCHandle 언래핑이 많은 비용을 소요한다는 사실을 깨달았습니다. 주요 경로에서는 이러한 현상이 거의 발생하지 않아 다행이긴 하지만, 이번에 적용한 변경 사항을 고려하면 염두에 두어야 할 요소라 할 수 있습니다. 따라서 CoreCLR이 사용하는 동일한 알고리즘을 Mono와 IL2CPP에도 적용했습니다.

이제 변경사항 적용 후에도 AddEvent 메서드 및 기타 공개 API 메서드를 포함한 AnimationEvent의 내부 벤치마크에는 이전 코드와 새 코드 사이에 차이점이 없습니다. 멋진 결과입니다.

성능과 안전성: 두 가지 모두 선택하세요.

CoreCLR 런타임과 GC를 사용하면 전반적인 성능의 향상을 기대할 수 있습니다. Microsoft는 .NET에 많은 투자를 하고 있으며, 이번 개선 사항을 Unity 사용자들도 누릴 수 있게 되어 매우 기쁩니다.

기존 코드의 안전성과 안정성을 유지하면서 최신 .NET 애플리케이션의 최대 성능을 제공할 수 있을 것입니다. 팀에서는 이번 조사를 통해 배운 기술을 Unity 엔진 코드의 다른 관리형/네이티브 경계 전환에 계속해서 적용할 예정입니다.

애니메이션 및 CoreCLR과 관련된 더 많은 팁은 포럼에서 확인하세요. Twitter에서 @petersonjm1로 언제든지 직접 연락하셔도 좋습니다. 또한 연재 중인 Tech from the Trenches 시리즈에서 다른 Unity 개발자들의 새로운 기술 블로그도 확인해 보시기 바랍니다.

2022년 10월 25일 테크놀로지 | 11 분 소요
관련 게시물