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

В частности, мы рассмотрим шесть типов вызовов:
Мы обратим внимание на действия генерируемого кода C++, а также на затраты, связанные с каждым типом вызовов. Как я уже говорил, представленный код наверняка изменится в следующих версиях Unity. Но основные принципы останутся неизменными.
Предыдущие статьи из серии:
» Введение в IL2CPP.
» IL2CPP: экскурсия по генерируемому коду.
» IL2CPP: советы по отладке генерируемого кода.
Я буду использовать версию Unity 5.0.1p4 на Windows для сборки проекта под WebGL. При этом я включу опцию Development Player и задам значение Full для Enable Exceptions. Чтобы проанализировать различные типы вызовов методов, я буду использовать модифицированный скрипт из предыдущей статьи, начинающийся с интерфейса и определения класса:
За ними следуют константное поле и тип делегата:
Наконец, мы указываем интересующие нас методы, а также обязательный метод Start (в нашем случае пустой):
Итак, всё готово. Учтите, что, пока открыт редактор, генерируемый код C++ находится в директории Temp\StagingArea\Data\il2cppOutput. И не забудьте сгенерировать файл тегов с помощью Ctags, чтобы упростить навигацию по коду.
Вызвать метод проще и, как вы сможете убедиться, быстрее всего напрямую. Вот так выглядит генерируемый код для метода CallDirectly:
Последняя строка и есть вызов метода. Обратите внимание, что она всего лишь вызывает свободную функцию, заданную в коде C++. Как мы уже говорили в предыдущей статье, IL2CPP не использует функции-члены или виртуальные функции, а генерирует все методы как свободные функции C++. Аналогичным образом работает прямой вызов статического метода. Вот так выглядит генерируемый код для метода CallStaticMethodDirectly:
Можно сказать, что вызывать статический метод менее затратно, поскольку нам не нужно создавать и инициализировать экземпляр объекта. Но сам вызов метода – суть один и тот же. Единственное отличие в том, что для первого аргумента статических функций IL2CPP всегда передает значение NULL. Учитывая, что разница между вызовами статических методов и методов экземпляра настолько мала, в рамках этой статьи мы их отождествим.
У непрямого вызова через делегат есть своя специфика. Сперва уточню, что я имею в виду под делегатом времени компиляции – уже во время компиляции мы знаем, какой метод вызывается из какого экземпляра объекта. Код для этого типа находится в методе CallViaDelegate. В генерируемом коде он выглядит так:
Обратите внимание, что вызываемый метод фактически не является частью генерируемого кода. Метод VirtFuncInvoker1<int32_t, String_t*>::Invoke находится в файле GeneratedVirtualInvokers.h, генерируемом утилитой il2cpp.exe на основе использования виртуальных функций, которые возвращают значение (VirtFuncInvokerN) и не возвращают его (VirtActionInvokerN), где N означает количество аргументов. Вот так выглядит метод Invoke:
Вызов 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 совершает такие вызовы по аналогии с вызовами виртуальных методов:
Обратите внимание, что вызов метода осуществляется через функцию InterfaceFuncInvoker1::Invoke в файле GeneratedInterfaceInvokers.h. Как и VirtFuncInvoker1, класс InterfaceFuncInvoker1 совершает поиск в таблице vtable с помощью функции il2cpp::vm::Runtime::GetInterfaceInvokeData в libil2cpp.
Почему вызовы через метод интерфейса и вызовы через виртуальный метод используют разные API в libil2cpp? Обращение к функции InterfaceFuncInvoker1::Invoke передает не только вызываемый метод и его аргументы, но и интерфейс (в этом случае L_1). Для каждого типа сохраняется таблица vtable, чтобы методы интерфейса фиксировались при смещении. Таким образом, il2cpp.exe должен предоставить интерфейс, чтобы определить, какой метод вызвать. В сухом остатке можно сказать, что вызовы через виртуальный метод и вызовы через интерфейс одинаково затратны в IL2CPP.
Делегат также можно создать во время выполнения с помощью метода Delegate.CreateDelegate. Это похоже на создание делегата во время компиляции, но требует вызова еще одной функции. Генерируемый код выглядит так:
Для создания и инициализации такого делегата требуется намного больше кода. Да и сам вызов метода получается более затратным. Во-первых, нужно создать массив для аргументов метода. Затем – вызвать метод DynamicInvoke из экземпляра Delegate. Обратите внимание, что этот метод вызывает функцию VirtFuncInvoker1::Invoke – так же, как и делегат времени компиляции. Таким образом, для делегата времени выполнения требуется не только еще один вызов функции, но и дополнительный поиск по таблице vtable.
Неудивительно, что самый затратный тип вызова метода – через рефлексию. Вот так выглядит генерируемый код для метода CallViaReflection:
Как и в случае делегата времени выполнения, нам нужно создать массив для аргументов метода. Затем мы совершаем вызов виртуального метода MethodBase::Invoke – функции MethodBase_Invoke_m24, которая, в свою очередь, вызывает другую виртуальную функцию. И только тогда осуществляется требуемый вызов метода.
Хоть это и не сравнится с профилированием, разбор генерируемого кода C++ позволяет лучше понять затраты, связанные с тем или иным вызовом метода. Например, вызывать методы через делегат времени выполнения и через рефлексию лучше не стоит. Чтобы повысить производительность, измеряйте затраты на ранних этапах, желательно – профайлерами.
Мы продолжаем работать над оптимизацией кода, генерируемого il2cpp.exe, поэтому вероятно, что в следующих версиях Unity перечисленные типы вызовов будут выглядеть иначе. В следующей статье мы поговорим о выполнении универсальных методов и способах уменьшения размера исполняемого файла и генерируемого кода.

В частности, мы рассмотрим шесть типов вызовов:
- прямые вызовы методов экземпляра и статических методов;
- вызовы через делегат времени компиляции;
- вызовы через виртуальный метод;
- вызовы через метод интерфейса;
- вызовы через делегат времени выполнения;
- вызовы через рефлексию.
Мы обратим внимание на действия генерируемого кода 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++ находится в директории Temp\StagingArea\Data\il2cppOutput. И не забудьте сгенерировать файл тегов с помощью 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 перечисленные типы вызовов будут выглядеть иначе. В следующей статье мы поговорим о выполнении универсальных методов и способах уменьшения размера исполняемого файла и генерируемого кода.