IL2CPP 내부: 메서드 호출

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jun 3, 2015|11 분
IL2CPP 내부: 메서드 호출
이 웹페이지는 이해를 돕기 위해 기계 번역으로 제공됩니다. 기계 번역으로 제공되는 콘텐츠에 대한 정확도나 신뢰도는 보장되지 않습니다. 번역된 콘텐츠의 정확도에 관해 의문이 있는 경우 웹페이지의 공식 영어 원문을 참고해 주시기 바랍니다.

이 글은 IL2CPP 내부 시리즈의 네 번째 블로그 게시물입니다. 이 게시물에서는 관리 코드의 메서드 호출을 위해 il2cpp.exe가 C++ 코드를 생성하는 방법을 살펴보겠습니다. 구체적으로 6가지 유형의 메서드 호출에 대해 살펴봅니다:

- 인스턴스 및 정적 메서드에 대한 직접 호출

- 컴파일 타임 델리게이트를 통한 호출

- 가상 방법을 통한 통화

- 인터페이스 메서드를 통한 호출

- 런타임 델리게이트를 통한 호출

- 리플렉션을 통한 통화

각각의 경우 생성된 C++ 코드가 수행하는 작업과 특히 해당 명령어의 비용이 얼마인지에 초점을 맞출 것입니다.

이 시리즈의 모든 게시물과 마찬가지로 변경될 수 있고 실제로 최신 버전의 Unity에서 변경될 가능성이 있는 코드를 살펴볼 것입니다. 그러나 개념은 동일하게 유지되어야 합니다. 이 시리즈에서 설명하는 모든 내용을 구현 세부 사항으로 받아들여 주세요. 하지만 저희는 가능하면 이런 세부 사항을 공개하고 토론하는 것을 좋아합니다!

설정

Unity 버전 5.0.1p4를 사용하겠습니다. Windows에서 에디터를 실행하고 WebGL 플랫폼용으로 빌드하겠습니다. '개발 플레이어' 옵션을 활성화하고 '예외 활성화' 옵션을 '전체' 값으로 설정한 상태로 빌드하고 있습니다.

다양한 유형의 메서드 호출을 확인할 수 있도록 지난 포스트에서 수정한 단일 스크립트 파일로 빌드해 보겠습니다. 스크립트는 인터페이스와 클래스 정의로 시작됩니다:

interface Interface {
int MethodOnInterface(string question);
}

class Important : Interface {
public int Method(string question) { return 42; }
public int MethodOnInterface(string question) { return 42; }
public static int StaticMethod(string question) { return 42; }
}

그런 다음 코드 후반부에 사용되는 상수 필드와 델리게이트 유형이 있습니다:

private const string question = "What is the answer to the ultimate question of life, the universe, and everything?";

private delegate int ImportantMethodDelegate(string question);

마지막으로 살펴보고자 하는 메서드는 다음과 같습니다(여기에는 내용이 없는 필수 시작 메서드도 포함되어 있습니다):

private void CallDirectly() {
var important = ImportantFactory();
important.Method(question);
}

private void CallStaticMethodDirectly() {
Important.StaticMethod(question);
}

private void CallViaDelegate() {
var important = ImportantFactory();
ImportantMethodDelegate indirect = important.Method;
indirect(question);
}

private void CallViaRuntimeDelegate() {
var important = ImportantFactory();
var runtimeDelegate = Delegate.CreateDelegate(typeof (ImportantMethodDelegate), important, "Method");
runtimeDelegate.DynamicInvoke(question);
}

private void CallViaInterface() {
Interface importantViaInterface = new Important();
importantViaInterface.MethodOnInterface(question);
}

private void CallViaReflection() {
var important = ImportantFactory();
var methodInfo = typeof(Important).GetMethod("Method");
methodInfo.Invoke(important, new object[] {question});
}

private static Important ImportantFactory() {
var important = new Important();
return important;
}

void Start () {}

이 모든 것이 정의되었으니 이제 시작하겠습니다. 생성된 C++ 코드는 프로젝트의 Temp\StagingArea\Data\il2cppOutput 디렉터리에 위치합니다(에디터가 열려 있는 한). 그리고 생성된 코드에 Ctag를 생성하여 탐색을 돕는 것도 잊지 마세요.

메서드 직접 호출하기

메서드를 호출하는 가장 간단한(그리고 가장 빠른) 방법은 메서드를 직접 호출하는 것입니다. 다음은 CallDirectly 메서드에 대해 생성된 코드입니다:

Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
Important_t1 * L_1 = V_0;
NullCheck(L_1);
Important_Method_m1(L_1, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_Method_m1_MethodInfo);

마지막 줄은 실제 메서드 호출입니다. C++ 코드에 정의된 자유 함수를 호출할 뿐 특별한 작업을 수행하지 않는다는 점에 유의하세요. 생성된 코드에 대한 이전 게시물에서 il2cpp.exe가 모든 메서드를 C++ 자유 함수로 생성한다는 점을 기억하세요. IL2CPP 스크립팅 백엔드는 생성된 코드에 C++ 멤버 함수나 가상 함수를 사용하지 않습니다. 그렇다면 정적 메서드 디렉터리를 호출하는 것도 비슷해야 합니다. 다음은 CallStaticMethodDirectly 메서드에서 생성된 코드입니다:

Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);

객체 인스턴스를 생성하고 초기화할 필요가 없으므로 정적 메서드 호출에 대한 오버헤드가 적다고 할 수 있습니다. 그러나 메서드 호출 자체는 C++ 자유 함수에 대한 호출로 완전히 동일합니다. 여기서 유일한 차이점은 첫 번째 인수는 항상 NULL 값으로 전달된다는 점입니다.

정적 메소드와 인스턴스 메소드 호출의 차이는 매우 미미하므로 이 글의 나머지 부분에서는 인스턴스 메소드에만 초점을 맞추겠지만, 이 정보는 정적 메소드에도 적용됩니다.

컴파일 타임 델리게이트를 통해 메서드 호출하기

델리게이트를 통한 간접 호출과 같이 조금 더 이색적인 메서드 호출은 어떻게 될까요? 먼저 컴파일 타임 델리게이트라고 부르는 것을 살펴보겠습니다. 컴파일 타임에 어떤 메서드가 어떤 객체 인스턴스에서 호출될지 알 수 있다는 의미입니다. 이 유형의 호출에 대한 코드는 CallViaDelegate 메서드에 있습니다. 생성된 코드는 다음과 같습니다:

// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
Important_t1 * L_1 = V_0;

// Create the delegate.
IntPtr_t L_2 = { &Important_Method_m1_MethodInfo };
ImportantMethodDelegate_t4 * L_3 = (ImportantMethodDelegate_t4 *)il2cpp_codegen_object_new (InitializedTypeInfo(&ImportantMethodDelegate_t4_il2cpp_TypeInfo));
ImportantMethodDelegate__ctor_m4(L_3, L_1, L_2, /*hidden argument*/&ImportantMethodDelegate__ctor_m4_MethodInfo);
V_1 = L_3;
ImportantMethodDelegate_t4 * L_4 = V_1;

// Call the method
NullCheck(L_4);
VirtFuncInvoker1< int32_t, String_t* >::Invoke(&ImportantMethodDelegate_Invoke_m5_MethodInfo, L_4, (String_t*) &_stringLiteral1);

생성된 코드의 다른 부분을 표시하기 위해 몇 가지 주석을 추가했습니다.

여기서 호출되는 실제 메서드는 생성된 코드의 일부가 아니라는 점에 유의하세요. VirtFuncInvoker1<int32_t, String_t*>::Invoke 메서드는 GeneratedVirtualInvokers.h 파일에 있습니다. 이 파일은 il2cpp.exe에 의해 생성되지만 IL 코드에서 생성된 것이 아닙니다. 대신, il2cpp.exe는 값을 반환하는 가상 함수(VirtFuncInvokerN)와 그렇지 않은 가상 함수(VirtActionInvokerN)의 사용법을 기반으로 이 파일을 생성하며, 여기서 N은 메서드의 인수 개수입니다.

여기서 호출 메서드는 다음과 같습니다:

template <typename R, typename T1>
struct VirtFuncInvoker1
{
typedef R (*Func)(void*, T1, MethodInfo*);

static inline R Invoke (MethodInfo* method, void* obj, T1 p1)
{
VirtualInvokeData data = il2cpp::vm::Runtime::GetVirtualInvokeData (method, obj);
return ((Func)data.methodInfo->method)(data.target, p1, data.methodInfo);
}
};

libil2cpp GetVirtualInvokeData에 대한 호출은 관리 코드를 기반으로 생성된 vtable 구조체에서 가상 메서드를 조회한 다음 해당 메서드를 호출합니다.

C++11을 사용하지 않는 이유 가변 템플릿 을 사용하여 VirtFuncInvokerN 메서드를 구현하지 않는 이유는 무엇인가요? 이것은 다양한 템플릿이 필요한 상황처럼 보이며 실제로 그렇습니다. 그러나 il2cpp.exe에서 생성된 C++ 코드는 아직 다양한 템플릿을 포함하여 모든 C++ 11 기능을 지원하지 않는 일부 C++ 컴파일러에서 작동해야 합니다. 적어도 이 경우에는 C++11 컴파일러를 위해 생성된 코드를 포크하는 것이 추가적인 복잡성을 감수할 가치가 있다고 생각하지 않았습니다.

그렇다면 왜 가상 메서드 호출일까요? C# 코드에서 인스턴스 메서드를 호출하고 있지 않나요? C# 델리게이트를 통해 인스턴스 메서드를 호출하고 있다는 점을 기억하세요. 위에서 생성된 코드를 다시 살펴보세요. 실제 호출할 메서드는 MethodInfo*(메서드 메타데이터) 인수를 통해 전달됩니다: ImportantMethodDelegate_Invoke_m5_MethodInfo. 생성된 코드에서 "중요 메서드 델리게이트_인보크_m5"라는 메서드를 검색하면 실제로 호출이 중요 메서드 델리게이트 유형에서 관리되는 Invoke 메서드에 대한 호출임을 알 수 있습니다. 이 방법은 가상 방식이므로 가상으로 전화를 걸어야 합니다. 실제로 C# 코드에서 Method라는 메서드를 호출하는 것은 바로 이 중요 메서드 델리게이트_인보크_m5 함수입니다.

와, 정말 입이 떡 벌어지네요. C# 코드에 간단한 변경을 가함으로써 이제 단일 호출에서 C++ 자유 함수 호출과 테이블 조회를 포함한 다중 함수 호출이 가능해졌습니다. 델리게이트를 통해 메서드를 호출하는 것은 동일한 메서드를 직접 호출하는 것보다 훨씬 더 많은 비용이 듭니다.

델리게이트 메서드 호출을 살펴보는 과정에서 가상 메서드를 통한 호출이 어떻게 작동하는지 살펴봤습니다.

인터페이스를 통해 메서드 호출하기

인터페이스를 통해 C#에서 메서드를 호출하는 것도 가능합니다. 이 호출은 가상 메서드 호출과 유사하게 il2cpp.exe에 의해 구현됩니다:

Important_t1 * L_0 = (Important_t1 *)il2cpp_codegen_object_new (InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo));
Important__ctor_m0(L_0, /*hidden argument*/&Important__ctor_m0_MethodInfo);
V_0 = L_0;
Object_t * L_1 = V_0;
NullCheck(L_1);
InterfaceFuncInvoker1< int32_t, String_t* >::Invoke(&Interface_MethodOnInterface_m22_MethodInfo, L_1, (String_t*) &_stringLiteral1);

여기서 실제 메서드 호출은 GeneratedInterfaceInvokers.h 파일에 있는 InterfaceFuncInvoker1::Invoke 함수를 통해 이루어집니다. 인터페이스펀크인보커1 클래스는 버트펀크인보커1클래스와 마찬가지로 libil2cpp의 il2cpp::vm::Runtime::GetInterfaceInvokeData 함수를 통해 vtable에서 룩업을 수행합니다.

인터페이스 메서드 호출이 가상 메서드 호출과 다른 API를 사용해야 하는 이유는 무엇인가요? InterfaceFuncInvoker1::Invoke 호출은 호출할 메서드와 그 인자뿐만 아니라 해당 메서드를 호출할 인터페이스(이 경우 L_1)도 전달한다는 점에 유의하세요. 인터페이스 메서드가 특정 오프셋에 기록되도록 각 유형에 대한 vtable이 저장됩니다. 따라서 il2cpp.exe는 호출할 메서드를 결정하기 위해 인터페이스를 제공해야 합니다.

결론은 가상 메서드를 호출하는 것과 인터페이스를 통해 메서드를 호출하는 것은 IL2CPP에서 사실상 동일한 오버헤드가 발생한다는 것입니다.

런타임 델리게이트를 통해 메서드 호출하기

델리게이트를 사용하는 또 다른 방법은 Delegate.CreateDelegate 메서드를 통해 런타임에 델리게이트를 생성하는 것입니다. 이 접근 방식은 컴파일 타임 델리게이트와 유사하지만 런타임에 몇 가지 방식으로 수정된다는 점이 다릅니다. 추가 함수 호출을 통해 이러한 유연성에 대한 비용을 지불합니다. 생성된 코드는 다음과 같습니다:

// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;

// Create the delegate.
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo));
Type_t * L_1 = Type_GetTypeFromHandle_m19(NULL /*static, unused*/, LoadTypeToken(&ImportantMethodDelegate_t4_0_0_0), /*hidden argument*/&Type_GetTypeFromHandle_m19_MethodInfo);
Important_t1 * L_2 = V_0;
Delegate_t12 * L_3 = Delegate_CreateDelegate_m20(NULL /*static, unused*/, L_1, L_2, (String_t*) &_stringLiteral2, /*hidden argument*/&Delegate_CreateDelegate_m20_MethodInfo);
V_1 = L_3;
Delegate_t12 * L_4 = V_1;

// Call the method
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1));
NullCheck(L_5);
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0);
ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1);
*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1;
NullCheck(L_4);
Delegate_DynamicInvoke_m21(L_4, L_5, /*hidden argument*/&Delegate_DynamicInvoke_m21_MethodInfo);

이 델리게이트는 생성 및 초기화를 위해 상당한 양의 코드가 필요합니다. 하지만 메서드 호출 자체에도 훨씬 더 많은 오버헤드가 있습니다. 먼저 메서드 인수를 담을 배열을 생성한 다음 델리게이트 인스턴스에서 DynamicInvoke 메서드를 호출해야 합니다. 생성된 코드에서 해당 메서드를 따라가 보면 컴파일 타임 델리게이트가 하는 것처럼 VirtFuncInvoker1::Invoke 함수를 호출하는 것을 볼 수 있습니다. 따라서 이 델리게이트는 컴파일 타임 델리게이트보다 함수 호출이 한 번 더 필요하며, 한 번이 아닌 두 번의 조회를 vtable에서 수행해야 합니다.

리플렉션을 통해 메서드 호출하기

메서드를 호출하는 가장 비용이 많이 드는 방법은 당연하게도 리플렉션을 이용하는 것입니다. CallViaReflection 메서드에 대해 생성된 코드를 살펴보겠습니다:

// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;

// Get the method metadata from the type via reflection.
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo));
Type_t * L_1 = Type_GetTypeFromHandle_m19(NULL /*static, unused*/, LoadTypeToken(&Important_t1_0_0_0), /*hidden argument*/&Type_GetTypeFromHandle_m19_MethodInfo);
NullCheck(L_1);
MethodInfo_t * L_2 = (MethodInfo_t *)VirtFuncInvoker1< MethodInfo_t *, String_t* >::Invoke(&Type_GetMethod_m23_MethodInfo, L_1, (String_t*) &_stringLiteral2);
V_1 = L_2;
MethodInfo_t * L_3 = V_1;

// Call the method.
Important_t1 * L_4 = V_0;
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1));
NullCheck(L_5);
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0);
ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1);
*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1;
NullCheck(L_3);
VirtFuncInvoker2< Object_t *, Object_t *, ObjectU5BU5D_t9* >::Invoke(&MethodBase_Invoke_m24_MethodInfo, L_3, L_4, L_5);

런타임 델리게이트의 경우와 마찬가지로 메서드의 인수를 위한 배열을 만드는 데 시간을 할애해야 합니다. 그런 다음 MethodBase::Invoke에 가상 메서드 호출(MethodBase_Invoke_m24 함수)을 수행합니다. 이 함수는 차례로 다른 가상 함수를 호출하고, 마침내 실제 메서드 호출에 도달합니다!

맺는말

이것이 실제 프로파일링과 측정을 대신할 수는 없지만, 생성된 C++ 코드가 다양한 유형의 메서드 호출에 어떻게 사용되는지 살펴봄으로써 특정 메서드 호출의 오버헤드에 대한 인사이트를 얻을 수 있습니다. 특히 런타임 델리게이트와 리플렉션을 통한 호출은 가능하면 피하고 싶다는 것이 분명합니다. 언제나 그렇듯이 성능 개선을 위한 최선의 조언은 프로파일링 도구를 사용하여 조기에 자주 측정하는 것입니다.

유니티는 항상 il2cpp.exe에서 생성된 코드를 최적화할 방법을 모색하고 있으므로 이후 버전의 Unity에서는 이러한 메서드 호출이 다르게 보일 수 있습니다.

다음 시간에는 메서드 구현에 대해 자세히 살펴보고 생성되는 코드와 실행 파일 크기를 최소화하기 위해 일반 메서드의 구현을 공유하는 방법을 살펴보겠습니다.