Unity 검색

CoreCLR 가비지 컬렉터에서 문자열 마샬링의 안전성 확보하기

2023년 2월 14일 엔진 & 플랫폼 | 10 분 소요
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!

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

이 블로그 게시물에서는 관리형/네이티브 경계 전반에서 GC의 안전성을 보장하는 방식으로 마샬 문자열 데이터에 최근 적용한 변경 사항을 소개합니다. 이러한 작업을 진행한 이유는 AnimationEvent 마샬링에 대한 이전 블로그 게시물에서 확인하세요.

관리형과 네이티브 - 2부

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

C# 문자열 형식을 예로 들 수 있습니다. 그냥 단순한 문자들의 배열로 보일 수 있지만, 문자열 마샬링에는 흥미롭고 복합적인 특징들이 많이 숨겨져 있습니다.

다음과 같이 Unity의 툴팁 프로퍼티를 구현하면 적절한 데이터를 가져오거나 설정하기 위한 네이티브 코드가 호출됩니다.

public static string tooltip { get { return Internal_GetTooltip();} set { Internal_SetTooltip(value); } }

그런 다음, 다음과 같이 C++ 코드로 Internal_GetTooltipInternal_SetTooltip을 선언합니다.

ScriptingStringPtr Internal_GetTooltip(); void Internal_SetTooltip(const core::string& value);

여기서 ScriptingStringPtr은 .NET 가비지 컬렉터에서 관리하는 메모리인 문자열 오브젝트에 대한 포인터이며 core::string은 Unity에서 내부적으로 C++의 문자열을 표현하는 방식입니다(std::string과 유사). 만약 벌써 문제점을 몇 개 알아 내셨다면 가산점을 드리겠습니다!

다양한 문자열 표현 방식

이상하게 들릴 수 있지만, 현대 프로그래밍 언어에서는 문자열의 표현이 매우 다양합니다. C#에서 문자열은 해당 문자열의 문자 수를 저장하는 32비트 정수로 표현되며, 그 뒤에 2바이트의 UTF16 인코딩 문자 배열이 이어집니다. C++에서 Unity의 core::string은 숫자에 머신 크기의 정수를 사용하나, 이러한 문자를 1바이트의 UTF8 인코딩 값 배열에 저장합니다.

이렇게 다양한 표현은 문자열이 Blittable 형식이 아님을 뜻하므로 이 관리형/네이티브 경계에 걸쳐 데이터를 주고받으려면 마샬링이 필요합니다. 이 마샬링에서는 적절하게 크기가 지정된 데이터 버퍼를 할당하고, 또한 캐릭터 정보 전환을 통해 모든 로케일의 값이 제대로 작동하도록 해야 합니다.

일반적으로 C# 개발자는 빌트인 p/invoke 마샬링을 사용하여 .NET 런타임에 세부 정보를 처리하도록 요청할 수 있습니다. 하지만 Unity에는 바인딩 제너레이터라는 커스텀 마샬링 툴이 있습니다.

마샬링을 위한 바인딩

이 커스텀 마샬링 툴을 사용하기 위해, 관리형 모드에서 네이티브 모드로 함수를 호출할 때 CoreCLR에 대한 Unity 포크의 .NET 런타임에 추가된 특수 기능을 사용하는데, 이를 internal call(간략히 'icall'이라고도 함)이라고 합니다. 일반 용도로는 권장되지 않지만 icall은 반환 값이나 함수 인수의 마샬링이 없는 함수 포인터에 대한 호출입니다. icall의 네이티브 모드에서는 함수 호출의 관리형 모드에 있는 모든 인수의 위치 및 레이아웃을 상세히 알고 있어야 합니다. icall은 근본적으로 안전성이 보장되지 않지만, 기본 p/invoke 마샬링을 사용했을 때보다 성능을 높일 수 있습니다.

바인딩 제너레이터는 Unity C# 코드를 파싱하고 icall로 구현된 extern 메서드를 찾습니다. 여기에서 각 icall에 대해 .NET IL(중간 언어) 코드와 C++ 코드가 모두 생성됩니다. .NET 중간 언어 코드는 관리형 어셈블리로 바로 내보내지고, C++ 코드는 나중에 Unity 빌드 프로세스에서 컴파일하는 파일로 내보내집니다. Unity의 코드베이스에는 10,000여 가지의 icall이 있으며, 바인딩 제너레이터는 이 프로세스를 자동화하고 최적화를 위한 후크를 제공하는 중요한 툴입니다.

툴팁 개선

위의 툴팁 정의와 관련하여 문제점을 찾지 못했더라도, 아주 미묘한 부분이니 걱정하지 마세요. 첫 번째로, GC 관리형 메모리에 대한 원시 포인터(ScriptingStringPtr)가 컴팩팅 CoreCLR GC와 호환되지 않습니다. 두 번째로, Internal_SetTooltip에서는 UTF8 문자열을 허용하나, C#은 UTF16 문자열을 사용합니다. 여기서 성능 개선을 위해 전환을 피할 수 있을까요? 한번 살펴보겠습니다.

불필요한 작업 방지

core::string이 Unity의 네이티브 코드에서 문자열을 표현하는 유일한 방법은 아닙니다. Unity에는 UTF16String이라는 유형도 있으며, 이는 문자 수를 나타내는 32비트 정수와 2바이트의 UTF16 인코딩 값 배열로서 C#의 문자열 표현과 유사합니다.

위에서 언급한 공개 C# API를 통해 툴팁을 설정하면 다음과 같이 네이티브 구현이 됩니다.

static void Internal_SetTooltip(const core::string& value) { UTF16String str(value.c_str()); GUIState &cState = GetGUIState(); cState.m_OnGUIState.SetMouseTooltip(str); cState.m_OnGUIState.SetKeyTooltip(str); }

내부적으로, core::string의 UTF8 문자열이 UTF16 표현으로 다시 복사됩니다. 그 대신 UTF16String을 허용하도록 메서드 서명을 바로 수정할 수 있습니다.

void Internal_SetTooltip(const UTF16String& str);

바인딩 제너레이터에서는 일부 MSIL(Microsoft 중간 언어) 코드가 생성되며, 이는 C# 문자열을 ReadOnlySpan로 처리하고 해당 메모리를 임시로 고정하여 호출 도중 GC에 의해 옮겨지지 않도록 합니다. 여기서 바인딩 제너레이터에 이미 빌트인된 Span 형식의 마샬링에 대한 기존 지원이 활용됩니다. 마지막으로, 생성된 일부 C++ 코드에 의해 해당 SpanUTF16String으로 사용됩니다.

처리해야 하는 중요하고 특수한 상황이 하나 있는데, 바로 Span이 비어 있거나 비어 있지 않은 경우입니다. 문자열은 null이거나 null이 아니지만 비어 있을 수 있습니다. 그래서 null Span을 처리하기 위해 특수 ManagedSpanWrapper 유형을 생성하였습니다. 생성된 MSIL 코드는 C#에서 다음과 같이 나타납니다.

private unsafe static void Internal_SetTooltip(string value) { ManagedSpanWrapper managedSpanWrapper; if (!StringMarshaller.TryMarshalEmptyOrNullString(value, ref managedSpanWrapper)) { fixed (char* begin = value.AsSpan()){ managedSpanWrapper = new ManagedSpanWrapper(begin, value.Length); Internal_SetTooltip_Injected(in managedSpanWrapper); } } else { Internal_SetTooltip_Injected(in managedSpanWrapper); } }

GC에서 자동 처리

이전 게시글에서 CoreCLR GC는 네이티브 코드에 대한 제약을 생성하는 대신 성능을 높여 준다고 언급하였습니다. 구체적으로는 네이티브 코드에서 더 이상 GC 관리형 메모리에 바로 액세스할 수 없습니다. C#에서 문자열은 GC 관리형이기 때문에 Internal_GetTooltip에서 원시 포인터를 반환할 수 없습니다. 위에서 살펴본 바와 같이 내부적으로 Unity는 해당 툴팁 값을 이미 UTF16 문자열로 표현하고 있습니다. 이는 문자열의 C# 표현과 일치하므로 서명을 다음과 같이 변경합니다.

UTF16String Internal_GetTooltip();

이제 바인딩 제너레이터는 GC를 일시 중지하거나 메모리를 고정하기 위한 특수 코드 없이도 GC에 모든 제어 권한을 부여하여 GC가 C# 측면의 문자열 메모리를 관리하도록 합니다.

우수한 기능

위 내용은 Unity 코드베이스에서 자주 수행되는 문자열 파라미터 마샬링의 한 가지 예일 뿐입니다. 바인딩 제너레이터는 문자열뿐 아니라 관리형 코드에서 네이티브 코드로 전달될 수 있는 모든 형식을 처리해야 하지만, 대규모 Unity 코드베이스에서 소규모 팀의 작업을 활용할 수 있는 훌륭한 툴이기도 합니다.

이와 같은 광범위한 변경 사항을 적용하여 CoreCLR GC에서 Unity의 안전성을 보장하면서도 성능을 개선할 수 있습니다.

성능과 안전성을 모두 충족하는 방법

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

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

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

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

Is this article helpful for you?

Thank you for your feedback!

관련 게시물