Это четвертая статья из серии по IL2CPP. В ней мы поговорим о том, как il2cpp.exe генерирует код C++ для вызовов методов в управляемом коде.
В частности, мы рассмотрим шесть типов вызовов:
- прямые вызовы методов экземпляра и статических методов;
- вызовы через делегат времени компиляции;
- вызовы через виртуальный метод;
- вызовы через метод интерфейса;
- вызовы через делегат времени выполнения;
- вызовы через рефлексию.
Мы обратим внимание на действия генерируемого кода C++, а также на затраты, связанные с каждым типом вызовов. Как я уже говорил, представленный код наверняка изменится в следующих версиях Unity. Но основные принципы останутся неизменными.
Предыдущие статьи из серии:
» Введение в IL2CPP.
» IL2CPP: экскурсия по генерируемому коду.
» IL2CPP: советы по отладке генерируемого кода.
Подготовка к работе
Я буду использовать версию Unity 5.0.1p4 на Windows для сборки проекта под WebGL. При этом я включу опцию Development Player и задам значение Full для Enable Exceptions. Чтобы проанализировать различные типы вызовов методов, я буду использовать модифицированный скрипт из предыдущей статьи, начинающийся с интерфейса и определения класса:
[csharp]
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; }
}
[/csharp]
За ними следуют константное поле и тип делегата:
[csharp]
private const string question = "What is the answer to the ultimate question of life, the universe, and everything?";
private delegate int ImportantMethodDelegate(string question);
[/csharp]
Наконец, мы указываем интересующие нас методы, а также обязательный метод Start (в нашем случае пустой):
[csharp]
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 () {}
[/csharp]
Итак, всё готово. Учтите, что, пока открыт редактор, генерируемый код C++ находится в директории TempStagingAreaDatail2cppOutput. И не забудьте сгенерировать файл тегов с помощью Ctags, чтобы упростить навигацию по коду.
Прямые вызовы
Вызвать метод проще и, как вы сможете убедиться, быстрее всего напрямую. Вот так выглядит генерируемый код для метода CallDirectly:
[cpp]
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);
[/cpp]
Последняя строка и есть вызов метода. Обратите внимание, что она всего лишь вызывает свободную функцию, заданную в коде C++. Как мы уже говорили в предыдущей статье, IL2CPP не использует функции-члены или виртуальные функции, а генерирует все методы как свободные функции C++. Аналогичным образом работает прямой вызов статического метода. Вот так выглядит генерируемый код для метода CallStaticMethodDirectly:
[cpp]
Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);
[/cpp]
Можно сказать, что вызывать статический метод менее затратно, поскольку нам не нужно создавать и инициализировать экземпляр объекта. Но сам вызов метода – суть один и тот же. Единственное отличие в том, что для первого аргумента статических функций IL2CPP всегда передает значение NULL. Учитывая, что разница между вызовами статических методов и методов экземпляра настолько мала, в рамках этой статьи мы их отождествим.
Вызовы через делегат времени компиляции
У непрямого вызова через делегат есть своя специфика. Сперва уточню, что я имею в виду под делегатом времени компиляции – уже во время компиляции мы знаем, какой метод вызывается из какого экземпляра объекта. Код для этого типа находится в методе CallViaDelegate. В генерируемом коде он выглядит так:
[cpp]
// 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);
[/cpp]
Обратите внимание, что вызываемый метод фактически не является частью генерируемого кода. Метод VirtFuncInvoker1<int32_t, String_t*>::Invoke находится в файле GeneratedVirtualInvokers.h, генерируемом утилитой il2cpp.exe на основе использования виртуальных функций, которые возвращают значение (VirtFuncInvokerN) и не возвращают его (VirtActionInvokerN), где N означает количество аргументов. Вот так выглядит метод Invoke:
[cpp]
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);
}
};
[/cpp]
Вызов GetVirtualInvokeData ищет виртуальный метод в таблице vtable, генерируемой на основе управляемого кода, и затем вызывает этот метод.
Почему мы не использовали вариативные шаблоны C++ 11 для реализации методов VirtFuncInvokerN? Всё указывает на то, что в этом случае они пришлись бы как нельзя кстати. Однако для работы с кодом C++, генерируемым il2cpp.exe, нам понадобятся компиляторы C++, которые пока поддерживают не все аспекты C++ 11. Поэтому мы решили, что создание отдельной ветки генерируемого кода для компиляторов только усложнит процесс, и не стали этого делать.
Но почему это именно виртуальный вызов метода? Разве мы не вызываем метод экземпляра в коде C#? Не забывайте, что мы делаем это через делегат C#. Взгляните еще раз на генерируемый код. Вызываемый метод передается через аргумент MethodInfo* (метаданные метода) – ImportantMethodDelegate_Invoke_m5_MethodInfo. Найдите метод под названием ImportantMethodDelegate_Invoke_m5 в генерируемом коде, и вы увидите, что вызов идет к управляемому методу Invoke типа ImportantMethodDelegate. Это виртуальный метод, и значит мы должны совершить виртуальный вызов: функция ImportantMethodDelegate_Invoke_m5 вызовет метод с именем Method в коде C#.
Итак, за счет небольшого изменения в коде C# мы перешли от одного вызова свободной функции C++ к нескольким вызовам, включая поиск по таблице. Тем не менее, вызывать метод через делегат намного затратнее, чем напрямую. Кстати, в процессе рассмотрения этого типа вызовов мы заодно поговорили о том, как работают вызовы через виртуальный метод.
Вызовы через интерфейс
Вызвать метод в C# также можно через интерфейс. Il2cpp.exe совершает такие вызовы по аналогии с вызовами виртуальных методов:
[cpp]
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);
[/cpp]
Обратите внимание, что вызов метода осуществляется через функцию InterfaceFuncInvoker1::Invoke в файле GeneratedInterfaceInvokers.h. Как и VirtFuncInvoker1, класс InterfaceFuncInvoker1 совершает поиск в таблице vtable с помощью функции il2cpp::vm::Runtime::GetInterfaceInvokeData в libil2cpp.
Почему вызовы через метод интерфейса и вызовы через виртуальный метод используют разные API в libil2cpp? Обращение к функции InterfaceFuncInvoker1::Invoke передает не только вызываемый метод и его аргументы, но и интерфейс (в этом случае L_1). Для каждого типа сохраняется таблица vtable, чтобы методы интерфейса фиксировались при смещении. Таким образом, il2cpp.exe должен предоставить интерфейс, чтобы определить, какой метод вызвать. В сухом остатке можно сказать, что вызовы через виртуальный метод и вызовы через интерфейс одинаково затратны в IL2CPP.
Вызовы через делегат времени выполнения
Делегат также можно создать во время выполнения с помощью метода Delegate.CreateDelegate. Это похоже на создание делегата во время компиляции, но требует вызова еще одной функции. Генерируемый код выглядит так:
[cpp]
// 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);
[/cpp]
Для создания и инициализации такого делегата требуется намного больше кода. Да и сам вызов метода получается более затратным. Во-первых, нужно создать массив для аргументов метода. Затем – вызвать метод DynamicInvoke из экземпляра Delegate. Обратите внимание, что этот метод вызывает функцию VirtFuncInvoker1::Invoke – так же, как и делегат времени компиляции. Таким образом, для делегата времени выполнения требуется не только еще один вызов функции, но и дополнительный поиск по таблице vtable.
Вызовы через рефлексию
Неудивительно, что самый затратный тип вызова метода – через рефлексию. Вот так выглядит генерируемый код для метода CallViaReflection:
[cpp]
// 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);
[/cpp]
Как и в случае делегата времени выполнения, нам нужно создать массив для аргументов метода. Затем мы совершаем вызов виртуального метода MethodBase::Invoke – функции MethodBase_Invoke_m24, которая, в свою очередь, вызывает другую виртуальную функцию. И только тогда осуществляется требуемый вызов метода.
Вывод
Хоть это и не сравнится с профилированием, разбор генерируемого кода C++ позволяет лучше понять затраты, связанные с тем или иным вызовом метода. Например, вызывать методы через делегат времени выполнения и через рефлексию лучше не стоит. Чтобы повысить производительность, измеряйте затраты на ранних этапах, желательно – профайлерами.
Мы продолжаем работать над оптимизацией кода, генерируемого il2cpp.exe, поэтому вероятно, что в следующих версиях Unity перечисленные типы вызовов будут выглядеть иначе. В следующей статье мы поговорим о выполнении универсальных методов и способах уменьшения размера исполняемого файла и генерируемого кода.
Автор: Plarium