Unity 프로젝트용 코드를 작성할 때 C#의 높은 표현성과 안전성을 활용하면 타겟 플랫폼에서 최상의 런타임 성능을 발휘할 수 있다는 사실을 알고 계셨나요? 그래서 유니티의 .NET Tech Group은 스크립트의 기본 기술 스택을 업데이트하기 위해 부지런히 노력하고 있습니다.
Unity 2021에서는 IL2CPP 스크립팅 백엔드를 사용할 때 속도를 높이기 위해 여러 사항을 개선했습니다. 코드 실행 속도를 현저히 높일 수 있는 주요 변경 사항을 자세히 살펴보겠습니다.
델리게이트 호출(delegate invocation) 메커니즘은 C#의 강점이지만, CreateDelegate API는 다소 복잡합니다. 델리게이트는 열거나 닫을 수 있으며, 가상 호출이나 인터페이스 호출을 통해 인스턴스 또는 정적 메서드를 호출할 수 있습니다. 심지어 제네릭 메서드와 제네릭 타입의 메서드 간에도 차이가 있습니다. 이렇게 차이가 있다는 것은 다시 말해, 적절한 호출(invocation)을 통해 적절한 메서드가 호출(call)되도록 런타임 시 여러 검사를 수행해야 한다는 뜻입니다.
Unity 2021.2에서 IL2CPP는 컴파일 시간에 호출 타입이 올바른지 확인하는 데 필요한 거의 모든 사항을 미리 계산합니다. 즉, 열린 델리게이트에는 두 개의 간접 호출 명령이 필요하지만 닫힌 델리게이트에는 하나의 간접 호출 명령만 있으면 됩니다. 이는 .NET Framework와 .NET Core에서 사용되는 델리게이트 호출과 같은 방식입니다.
이러한 측면에서 볼 때 델리게이트 호출이 이전보다 빨라졌으며 일부 타겟 벤치마크에서는 훨씬 더 빨라졌습니다.
C#의 박싱(boxing)은 값 유형을 System.Object 유형의 오브젝트로 변환하는 과정입니다. 여기에는 관리되는 힙에서의 공간 할당이 포함되기 때문에 과정이 꼭 빠르지만은 않습니다. 그러나 Unity 2021.2에서는 이 작업에서 더 높은 성능을 끌어내기 위해 일부 박싱을 제거하여 IL2CPP 런타임 속도를 높입니다.
런타임에 null을 지원하는 유형의 박싱은, 모든 박싱 작업에서 주어진 유형이 null을 지원하는지 결정할 수 있도록 특정 방식으로 처리되어야 합니다. 유니티는 런타임에서 일부 불필요한 검사 작업을 없애 박싱 성능을 개선했습니다. 특히 null을 지원하는 유형과 제네릭 유형에서 성능이 매우 향상되었습니다.
제네릭 가상 메서드는 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>(); }
[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까지 빨라졌습니다.
제약이 있는 호출(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가 여기에서는 가상 호출을 할 필요가 없다는 것을 인지하고 직접 메서드 호출을 수행하기 때문입니다. 표에서 볼 수 있듯이, 제약이 있는 호출 벤치마크의 상당수에서 성능이 대폭 개선되었습니다.