Внутреннее устройство IL2CPP: Вызовы методов

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jun 3, 2015|11 Мин
Внутреннее устройство IL2CPP: Вызовы методов
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.

Это четвертая запись в блоге из серии IL2CPP Internals. В этом посте мы рассмотрим, как il2cpp.exe генерирует код C++ для вызовов методов в управляемом коде. В частности, мы изучим шесть различных типов вызовов методов:

- Прямые вызовы методов экземпляра и статических методов

- Вызовы через делегат времени компиляции

- Вызовы через виртуальный метод

- Вызов через метод интерфейса

- Вызовы через делегата времени выполнения

- Вызовы через отражение

В каждом случае мы сосредоточимся на том, что делает сгенерированный код C++ и, в частности, на том, сколько будут стоить эти инструкции.

Как и во всех других статьях этой серии, мы будем изучать код, который может быть изменен и, более того, скорее всего, изменится в новой версии Unity. Однако концепции должны оставаться неизменными. Пожалуйста, воспринимайте все, что обсуждается в этой серии, как детали реализации. Нам нравится раскрывать и обсуждать такие детали, когда это возможно!

Настройка

Я буду использовать Unity версии 5.0.1p4. Я запущу редактор под Windows и соберу его для платформы WebGL. Я делаю сборку с включенной опцией "Development Player", а для опции "Enable Exceptions" установлено значение "Full".

Я создам один файл сценария, измененный по сравнению с предыдущим постом, чтобы мы могли увидеть различные типы вызовов методов. Сценарий начинается с определения интерфейса и класса:

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);

Наконец, вот методы, которые нам интересно изучить (плюс обязательный метод Start, который не имеет здесь никакого содержания):

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 в проекте (пока редактор остается открытым). И не забудьте создать Ctags на сгенерированном коде, чтобы облегчить навигацию по нему.

Вызов метода напрямую

Самый простой (и самый быстрый, как мы увидим) способ вызвать метод - это вызвать его напрямую. Вот сгенерированный код для метода 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 - количество аргументов метода.

Метод Invoke здесь выглядит следующим образом:

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 ? Это выглядит как ситуация, просящаяся в вариативные шаблоны, и это действительно так. Однако код на C++, сгенерированный il2cpp.exe, должен работать с некоторыми компиляторами C++, которые еще не поддерживают все возможности C++ 11, включая вариативные шаблоны. По крайней мере, в этом случае мы не думали, что форк сгенерированного кода для компиляторов C++11 стоит дополнительных сложностей.

Но почему это вызов виртуального метода? Разве мы не вызываем метод экземпляра в коде C#? Вспомните, что мы вызываем метод экземпляра через делегат C#. Посмотрите еще раз на сгенерированный код выше. Фактический метод, который мы собираемся вызвать, передается через аргумент MethodInfo* (метаданные метода): ImportantMethodDelegate_Invoke_m5_MethodInfo. Если мы найдем в сгенерированном коде метод с именем "ImportantMethodDelegate_Invoke_m5", мы увидим, что на самом деле вызов относится к управляемому методу Invoke для типа ImportantMethodDelegate. Это виртуальный метод, поэтому нам нужно сделать виртуальный вызов. Именно эта функция ImportantMethodDelegate_Invoke_m5 будет выполнять вызов метода с именем Method в коде C#.

Ух ты, вот это был полный рот. Внеся, казалось бы, простое изменение в код 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);

Обратите внимание, что фактический вызов метода здесь осуществляется с помощью функции InterfaceFuncInvoker1::Invoke, которая находится в файле GeneratedInterfaceInvokers.h. Как и класс VirtFuncInvoker1, класс InterfaceFuncInvoker1выполняет поиск в vtable с помощью функции il2cpp::vm::Runtime::GetInterfaceInvokeData в libil2cpp.

Почему вызов метода интерфейса должен использовать другой API в libil2cpp, чем вызов виртуального метода? Обратите внимание, что при вызове 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 эти вызовы методов будут выглядеть иначе.

В следующий раз мы углубимся в реализацию методов и посмотрим, как разделить реализацию общих методов, чтобы минимизировать размер генерируемого кода и исполняемого файла.