Unity 검색

기능 하이라이트: Unity 2021.2의 IL2CPP 런타임 성능 개선

2022년 2월 17일 테크놀로지 | 7 분 소요
Runtime Performance banner
Runtime Performance banner
다루는 주제
공유

Unity 프로젝트용 코드를 작성할 때 C#의 높은 표현성과 안전성을 활용하면 타겟 플랫폼에서 최상의 런타임 성능을 발휘할 수 있다는 사실을 알고 계셨나요? 그래서 유니티의 .NET Tech Group은 스크립트의 기본 기술 스택을 업데이트하기 위해 부지런히 노력하고 있습니다.

Unity 2021에서는 IL2CPP 스크립팅 백엔드를 사용할 때 속도를 높이기 위해 여러 사항을 개선했습니다. 코드 실행 속도를 현저히 높일 수 있는 주요 변경 사항을 자세히 살펴보겠습니다.

델리게이트 호출

델리게이트 호출(delegate invocation) 메커니즘은 C#의 강점이지만, CreateDelegate API는 다소 복잡합니다. 델리게이트는 열거나 닫을 수 있으며, 가상 호출이나 인터페이스 호출을 통해 인스턴스 또는 정적 메서드를 호출할 수 있습니다. 심지어 제네릭 메서드와 제네릭 타입의 메서드 간에도 차이가 있습니다. 이렇게 차이가 있다는 것은 다시 말해, 적절한 호출(invocation)을 통해 적절한 메서드가 호출(call)되도록 런타임 시 여러 검사를 수행해야 한다는 뜻입니다.

Unity 2021.2에서 IL2CPP는 컴파일 시간에 호출 타입이 올바른지 확인하는 데 필요한 거의 모든 사항을 미리 계산합니다. 즉, 열린 델리게이트에는 두 개의 간접 호출 명령이 필요하지만 닫힌 델리게이트에는 하나의 간접 호출 명령만 있으면 됩니다. 이는 .NET Framework와 .NET Core에서 사용되는 델리게이트 호출과 같은 방식입니다.

이러한 측면에서 볼 때 델리게이트 호출이 이전보다 빨라졌으며 일부 타겟 벤치마크에서는 훨씬 더 빨라졌습니다.

Delegate Invocation Performance

불필요한 박싱 확인

C#의 박싱(boxing)은 값 유형을 System.Object 유형의 오브젝트로 변환하는 과정입니다. 여기에는 관리되는 힙에서의 공간 할당이 포함되기 때문에 과정이 꼭 빠르지만은 않습니다. 그러나 Unity 2021.2에서는 이 작업에서 더 높은 성능을 끌어내기 위해 일부 박싱을 제거하여 IL2CPP 런타임 속도를 높입니다.

런타임에 null을 지원하는 유형의 박싱은, 모든 박싱 작업에서 주어진 유형이 null을 지원하는지 결정할 수 있도록 특정 방식으로 처리되어야 합니다. 유니티는 런타임에서 일부 불필요한 검사 작업을 없애 박싱 성능을 개선했습니다. 특히 null을 지원하는 유형과 제네릭 유형에서 성능이 매우 향상되었습니다.

Boxing performance graph

제네릭 가상 메서드 호출

제네릭 가상 메서드는 C#의 주요 기능이지만 효율적으로 구현하기 어렵습니다. 직접 메서드 호출과 달리, 컴파일러에는 빌드 시간에 제네릭 가상 메서드 호출에 대한 타겟 정보가 거의 없기 때문에 런타임 시 타겟을 찾아야 합니다.

Unity 2021.2에서는 다음 예제처럼 제네릭 가상 메서드와 인터페이스 메서드 호출의 성능을 두 배로 높였습니다.

interface Interface { T GetValue<T>(); } class Base : Interface { public virtual T GetValue<T>() { return default(T); } } class Derived : Base { public override T GetValue<T>() { return default(T); } } private Base obj = new Derived(); private Interface iface = obj; public void CallToVirtualGenericMemberFunction() { obj.GetValue<int>(); } public void CallToGenericInterfaceMemberFunction() { iface.GetValue<int>(); }
Generic Virtual Method Performance

Enum.HasFlag

[Flags] 속성과 함께 C# 열거형 유형을 사용하면 가능한 모든 옵션 조합을 편리하게 표시할 수 있습니다. 플래그 열거형에서 주어진 값을 확인하는 코드는 주로 Enum.HasFlag 메서드를 사용합니다. Unity 2021.2에서 IL2CPP는 이 메서드의 호출을 개선하여 100배 이상 빠르게 메서드를 호출할 수 있습니다.

예를 들어, 벤치마크가 다음과 같다고 생각해 보겠습니다.

public void CallToEnumHasFlag() { _enum.HasFlag(_flag); }

그리고 다음과 같은 생성 코드가 있다고 가정합니다.

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void EnumHasFlag_CallToEnumHasFlag_m5819FE655D569D7AF856B879164E6416EFEFC30E (EnumHasFlag_t72757859AA4C348BBEE0A64FDBB747AFCFE326C2 * __this, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var); s_Il2CppMethodInitialized = true; }{ int32_t L_0 = __this->____enum_0; int32_t L_1 = L_0; RuntimeObject * L_2 = Box(MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var, &L_1); int32_t L_3 = __this->____flag_1; int32_t L_4 = L_3; RuntimeObject * L_5 = Box(MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var, &)L_4); NullCheck((Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E2 *)L_2); bool L_6;L_6 = Enum_HasFlag_m15293B523AA7BA15272699C7304E908106AD7F7B((Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E *)L_2, (Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E2 *)L_5, NULL) ; return; }}

벤치마크는 위 코드를 다음 생성 코드로 가져옵니다.

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void EnumHasFlag_CallToEnumHasFlag_m5819FE655D569D7AF856B879164E6416EFEFC30E (EnumHasFlag_t72757859AA4C348BBEE0A64FDBB747AFCFE326C2 * __this,const RuntimeMethod* method) { { int32_t L_0 = __this->____enum_0;int32_t L_1 = L_0; int32_t L_2 = __this->____flag_1; int32_t L_3 = L_2; bool L_4 = il2cpp_codegen_enum_has_flag(L_1, L_3) ; return; }}

그러면 불필요한 박싱과 null 검사가 제거되어 C++ 컴파일러에서 코드를 완전히 최적화할 수 있습니다. 이 경우에는 마이크로 벤치마크가 162ns에서 1.18ns까지 빨라졌습니다.

Enum.HasFlag Performance graph

제약이 있는 호출

제약이 있는 호출(constrained call)도 유용한 C# 기능입니다. 제약이 있는 호출 기능을 사용하면 개발자가 가상 메서드 호출처럼 일반적으로 리소스를 많이 사용한다고 알려진 작업을 훨씬 적은 리소스로 수행할 수 있습니다. 그 원리는 무엇일까요? 

제약이 있는 호출은 기본적으로 특정 호출이 발생하는 방식에 대한 '힌트'를 런타임에 제공합니다. 그러면 IL2CPP는 이 힌트를 더 많이 얻어 리소스를 많이 사용하는 호출을, 리소스를 적게 사용하는 직접 호출로 전환합니다.  

코드에 다음과 같은 값 유형이 있다고 가정해 보겠습니다.

private struct SimpleValueType { }

그리고 모든 유형의 System.Object에서 Equals 가상 메서드를 호출할 수 있는 제네릭 메서드가 있다고 가정해 보겠습니다.

private static void Equals<T>(T t) { t.Equals(null); }

그리고 아래와 같은 벤치마크가 있다고 하겠습니다.

public void CallToEqualsValueType() { Equals(_simpleValueType); }

이 벤치마크는 10배 더 빨라지게 됩니다.

IL2CPP가 여기에서는 가상 호출을 할 필요가 없다는 것을 인지하고 직접 메서드 호출을 수행하기 때문입니다. 표에서 볼 수 있듯이, 제약이 있는 호출 벤치마크의 상당수에서 성능이 대폭 개선되었습니다.

Constrained Call performance graph

지금 사용해 보세요

다른 성능 분석과 마찬가지로 스크립팅 코드를 더 잘 이해하려면 스크립팅 코드를 프로파일링해보는 것이 좋습니다. 소개해 드린 개선 사항은 모두 타겟 벤치마크에 잘 드러나 있지만, 성능 특성은 프로젝트에 따라 달라질 수 있습니다. 여러분의 IL2CPP 사용 경험을 공유해 주시기 바랍니다. Unity 포럼에 참여하여 프로젝트 성능 분석, 개선 사항, 사용 중 발견한 문제를 알려 주세요.

2022년 2월 17일 테크놀로지 | 7 분 소요
다루는 주제