Search Unity

IL2CPP: экскурсия по генерируемому коду

, 13 мая, 2015

Это второй пост из серии по IL2CPP. В этой статье мы будем исследовать C++ код, сгенерированный il2cpp.exe. Мы увидим, как представлены управляемые типы в машинном коде, посмотрим на проверки во время выполнения, используемые для поддержки виртуальной машины .NET, как генерируются циклы и многое другое!

Мы получим очень версия-зависимый код, который, безусловно, будет меняться в более поздних версиях Unity. Тем не менее, концепции останутся такими же.

Пример проекта

Для этого примера я буду использовать последнюю доступную версию Unity, 5.0.1p1. Как и в первом посте этой серии, я начну с пустого проекта и добавлю один скрипт со следующим содержанием:

[csharp]
using UnityEngine;

public class HelloWorld : MonoBehaviour {
private class Important {
public static int ClassIdentifier = 42;
public int InstanceIdentifier;
}

void Start () {
Debug.Log("Hello, IL2CPP!");

Debug.LogFormat("Static field: {0}", Important.ClassIdentifier);

var importantData = new [] {
new Important { InstanceIdentifier = 0 },
new Important { InstanceIdentifier = 1 } };

Debug.LogFormat("First value: {0}", importantData[0].InstanceIdentifier);
Debug.LogFormat("Second value: {0}", importantData[1].InstanceIdentifier);
try {
throw new InvalidOperationException("Don’t panic");
}
catch (InvalidOperationException e) {
Debug.Log(e.Message);
}

for (var i = 0; i < 3; ++i) {
Debug.LogFormat("Loop iteration: {0}", i);
}
}
}
[/csharp]

Я соберу этот проект для WebGL в редакторе Unity на Windows. Я выбрал опцию «Development Player» в Build Settings, так что мы можем получить относительно хорошие имена в сгенерированном коде C++. Я также установил опцию «Enable Exceptions» для WebGL в Player Settings в значение «Full».

Обзор сгенерированного кода

После завершения сборки под WebGL, сгенерированный C++ код доступен в директории Temp\StagingArea\Data\il2cppOutput в папке моего проекта. После закрытия редактора, эта директория будет удалена. Пока редактор открыт, этот каталог будет существовать, так что мы можем проверить его.

Утилита il2cpp.exe генерирует несколько файлов, даже для этого небольшого проекта. Я вижу 4625 файлов заголовков и 89 файлов исходного кода C++. Чтобы рассмотреть этот код, я хотел бы использовать текстовый редактор, который работает с Exuberant CTags. CTags обычно для такого кода быстро генерирует файл тегов, что позволяет легче в нём ориентироваться.

Вы можете увидеть, что многие из файлов C++ созданы не из простого кода скрипта, а содержат преобразованный код стандартных библиотек, таких как mscorlib.dll. Как уже упоминалось в первом посте в этой серии, скриптовый движок IL2CPP использует тот же код стандартных библиотек, что и Mono. Обратите внимание, что мы преобразовываем код mscorlib.dll и других стандартных библиотек при каждом запуске il2cpp.exe. Это может показаться ненужным, так как код не изменяется.

Тем не менее, IL2CPP всегда использует зачистку байт-кода, чтобы уменьшить размер исполняемого файла. Как следствие, даже небольшие изменения в коде скрипта могут вызвать много различных частей кода стандартной библиотеки, которые будут использоваться или нет, в зависимости от ситуации. Поэтому мы должны преобразовывать mscorlib.dll каждый раз. Мы ищем лучший способ сделать инкрементную сборку, но пока нам этого не удалось.

Как управляемый код отображается в сгенерированном C++ коде

Для каждого типа в управляемом коде il2cpp.exe будет генерировать один файл заголовка C++ для определения типа и другой файл заголовка для объявления метода для этого типа. Например, давайте посмотрим на сгенерированный код для типа UnityEngine.Vector3. Файл заголовка для этого типа имеет название UnityEngine_UnityEngine_Vector3.h. Имя создается на основе имени сборки, UnityEngine.dll, затем берется пространство имен и в конце имя типа. Код выглядит следующим образом:

[cpp]
// UnityEngine.Vector3
struct Vector3_t78
{
// System.Single UnityEngine.Vector3::x
float ___x_1;
// System.Single UnityEngine.Vector3::y
float ___y_2;
// System.Single UnityEngine.Vector3::z
float ___z_3;
};
[/cpp]

Утилита il2cpp.exe преобразует каждое из трех полей экземпляра, и делает небольшое изменение имени, используя добавление подчеркиваний в начале, чтобы избежать конфликтов с зарезервированными словами. Мы используем некоторые зарезервированные имена в C++, но пока не видели каких-либо конфликтов с кодом стандартных библиотек C++.

Файл UnityEngine_UnityEngine_Vector3MethodDeclarations.h содержит объявления для всех методов в Vector3. Например, Vector3 переопределяет метод Object.ToString:

[cpp]
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR
[/cpp]

Обратите внимание на комментарий, в котором указан управляемый метод, представляющий это объявление. Это часто полезно для поиска файлов на выходе по имени управляемого метода в этом формате, особенно для методов с общими именами, такими как ToString.

Вот несколько интересных особенностей методов, созданных il2cpp.exe:

  • Это не функции-члены в C++. Все методы — свободные функции, где первый аргумент — указатель «this». Для статических функций в управляемом коде, IL2CPP всегда передаёт значение NULL для первого аргумента. Объявляя методы с указателем «this» в качестве первого аргумента, мы упрощаем способ генерации кода в il2cpp.exe и вызов методов через другие методы (например, делегаты) для сгенерированного кода.
  • Каждый метод имеет дополнительный аргумент типа MethodInfo*, который включает в себя метаданные о методе, которые используются для таких вещей, как вызов виртуального метода. Mono использует специфичные для платформы транспорты, чтобы передать эти метаданные. Для IL2CPP, мы решили избежать использования транспортов, чтобы улучшить переносимость
  • Все методы объявлены внешне, чтобы il2cpp.exe мог иногда обмануть компилятор C++ и рассматривать все методы, как если бы они имели такой же тип.
  • Имена типов содержат суффикс «_t». Имена методов — суффикс «_m». Конфликты имен решаются добавлением уникального номера для каждого имени. Эти цифры будут меняться, если что-либо изменить в коде пользовательского скрипта, так что вы не можете рассчитывать на них во время сборки.

Первые два пункта говорят, что каждый метод имеет по крайней мере два параметра, указатель «this» и указатель MethodInfo. Но добавляют ли эти параметры ненужные расходы? Мы не обнаружили того, чтобы эти дополнительные аргументы могли привести к снижению производительности. Хотя может показаться, что они будут, профилирование показало, что разница в производительности не поддается измерению.

Мы можем перейти к определению метода ToString, используя Ctags. Оно находится в файле Bulk_UnityEngine_0.cpp. Код в этом определении метода не выглядит похожим на C# код в методе Vector3::ToString(). Однако, если вы используете инструмент, такой как ILSpy, чтобы посмотреть код для метода Vector3::ToString(), вы увидите, что cгенерированный код C++ очень похож на IL код.

Почему il2cpp.exe не генерирует отдельный файл C++ для определения методов для каждого типа, как делает это для объявления методов? Bulk_UnityEngine_0.cpp файл на самом деле довольно большой, 20481 строка! Компиляторы C++, которые мы использовали, с трудом работали с большим количеством исходных файлов. Компиляция четырех тысяч .cpp файлов длилась дольше, чем компиляция того же кода в 80 .cpp файлах. Поэтому il2cpp.exe делит определения методов для типов на группы и генерирует один файл C++ для каждой группы.

Теперь вернемся к заголовочному файлу объявления методов и обратим внимание на строку в верхней части файла:

[cpp]
#include "codegen/il2cpp-codegen.h"
[/cpp]

Файл il2cpp-codegen.h содержит интерфейс, который использует сгенерированный код для доступа к libil2cpp во время выполнения. Мы обсудим несколько способов, которые во время выполнения используются сгенерированным кодом, позже.

Пролог метода

Давайте взглянем на определение метода Vector3::ToString(). В частности, он имеет общий пролог, который создается il2cpp.exe во всех методах.

[cpp]
StackTraceSentry _stackTraceSentry(&Vector3_ToString_m2315_MethodInfo);
static bool Vector3_ToString_m2315_init;
if (!Vector3_ToString_m2315_init)
{
ObjectU5BU5D_t4_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&ObjectU5BU5D_t4_0_0_0);
Vector3_ToString_m2315_init = true;
}
[/cpp]

В первой строке этого пролога создается локальная переменная типа StackTraceSentry. Эта переменная используется для отслеживания управляемых вызовов стека, так что IL2CPP можете сообщить об этом в вызовах, таких как Environment.StackTrace. Генерация этого кода на самом деле необязательна, и сработала в этом случае из-за передачи il2cpp.exe флага —enable-StackTrace (так как я установил для WebGL параметр «Enable Exceptions» в Player Settings в значение «Full»). Мы обнаружили, что для маленьких функций эта переменная добавляет расходы и оказывает негативное влияние на производительность. Таким образом, для iOS и других платформ, где можно получить информацию трассировки стека без этого кода, мы никогда не добавляем эту строку в генерируемый код. Для WebGL, мы не имеем поддержки трассировки стека, поэтому для правильной работы необходимо перехватывать исключения управляемого кода.

Вторая часть пролога делает «ленивую» инициализацию типа метаданных для любого массива или сгенерированных типов, используемых в теле метода. Так ObjectU5BU5D_t4 — это имя типа System.Object[]. Эта часть пролога выполняется только один раз, и часто не делает ничего, если тип был уже инициализирован, таким образом, мы не видели каких-либо неблагоприятных последствий для производительности от этого сгенерированного кода.

Этот код потокобезопасный? Что делать, если два потока вызывают Vector3::ToString () одновременно? На самом деле, этот код не создает проблем, так как весь код в libil2cpp используется для инициализации типа, безопасного для вызова из нескольких потоков. Возможно (даже вероятно), что функция il2cpp_codegen_class_from_type будет вызвана более чем один раз, но реально она сработает только один раз, в одном потоке. Выполнение метода не будет продолжаться, пока инициализация не завершена. Поэтому этот пролог метода является потокобезопасным.

Проверки во время выполнения

Следующая часть метода создает массив объектов, сохраняет значение поля Х для Vector3 в локальную переменную, затем создает локальную переменную “ящика” и добавляет его в массив с индексом ноль. Вот сгенерированный код C++ (с некоторыми аннотациями):

[cpp]
// Create a new single-dimension, zero-based object array
ObjectU5BU5D_t4* L_0 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 3));
// Store the Vector3::x field in a local
float L_1 = (__this->___x_1);
float L_2 = L_1;
// Box the float instance, since it is a value type.
Object_t * L_3 = Box(InitializedTypeInfo(&Single_t264_il2cpp_TypeInfo), &L_2);
// Here are three important runtime checks
NullCheck(L_0);
IL2CPP_ARRAY_BOUNDS_CHECK(L_0, 0);
ArrayElementTypeCheck (L_0, L_3);
// Store the boxed value in the array at index 0
*((Object_t **)(Object_t **)SZArrayLdElema(L_0, 0)) = (Object_t *)L_3;
[/cpp]

Три проверки отсутствуют в коде IL, но добавляются il2cpp.exe.

  • Проверка NullCheck бросит исключение NullReferenceException, если значение массива равно null.
  • IL2CPP_ARRAY_BOUNDS_CHECK бросит исключение IndexOutOfRangeException, если индекс массива не является правильным.
  • ArrayElementTypeCheck бросит исключение ArrayTypeMismatchException, если тип элемента, добавленного в массив, не является правильным.

Эти три проверки гарантируют правильность данных для виртуальной машины .NET. Вместо внедрения кода, Mono использует механизмы целевой платформы для обработки этих же проверок во время выполнения. Мы хотели, чтобы IL2CPP мог охватить больше платформ, включая такие как WebGL, где нет своего механизма проверок, поэтому il2cpp.exe вводит эти проверки.

Эти проверки создают проблемы с производительностью? В большинстве случаев, мы не видели какого-либо ухудшения производительности, к тому же они обеспечивают преимущества и безопасность, которые требуются в виртуальной машине .NET. Однако, в некоторых отдельных случаях мы заметили, что эти проверки привели к снижению производительности, особенно в труднодоступных циклах. Мы работаем над способом, который позволит управляемому коду удалить эти динамические проверки, когда il2cpp.exe генерирует C++ код. Следите за обновлениями.

Статические поля

Теперь, когда мы увидели, как выглядят поля экземпляра (на примере Vector3), давайте посмотрим, как преобразуются статические поля и как организован доступ к ним. Найдем определение метода HelloWorld_Start_m3, который находится в файле Bulk_Assembly-CSharp_0.cpp в моей сборке. Оттуда переходим к типу Important_t1 (в файле theAssemblyU2DCSharp_HelloWorld_Important.h):

[cpp]
struct Important_t1  : public Object_t
{
// System.Int32 HelloWorld/Important::InstanceIdentifier
int32_t ___InstanceIdentifier_1;
};
struct Important_t1_StaticFields
{
// System.Int32 HelloWorld/Important::ClassIdentifier
int32_t ___ClassIdentifier_0;
};
[/cpp]

Обратите внимание, что il2cpp.exe создала отдельную С++ структуру, чтобы предоставить статическое поле для этого типа, так как оно должно быть доступно всем экземплярам этого типа. Таким образом, во время выполнения, будет создан один экземпляр типа Important_t1_StaticFields, и все экземпляры типа Important_t1 будут использовать этот экземпляр как статическое поле. В сгенерированном коде доступ к статическому полю происходит следующим образом:

[cpp]
int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);
[/cpp]

Метаданные типа для Important_t1 содержит указатель на один экземпляр типа Important_t1_StaticFields, и информацию о том, что этот экземпляр используется для получения значения статического поля.

Исключения

Управляемые исключения преобразуются il2cpp.exe в C++ исключения. Мы выбрали такой подход, чтобы избежать зависимости от платформы. Когда il2cpp.exe нужно создавать код, способный бросить управляемое исключение, он вызывает функцию il2cpp_codegen_raise_exception.

Блок try…catch для управляемых исключений в нашем методе HelloWorld_Start_m3 выглядит так:

[cpp]
try
{ // begin try (depth: 1)
InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new (InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));
InvalidOperationException__ctor_m8(L_17, (String_t*) &_stringLiteral5, /*hidden argument*/&InvalidOperationException__ctor_m8_MethodInfo);
il2cpp_codegen_raise_exception(L_17);
// IL_0092: leave IL_00a8
goto IL_00a8;
} // end try (depth: 1)
catch(Il2CppExceptionWrapper& e)
{
__exception_local = (Exception_t8 *)e.ex;
if(il2cpp_codegen_class_is_assignable_from (&InvalidOperationException_t7_il2cpp_TypeInfo, e.ex->object.klass))
goto IL_0097;
throw e;
}
IL_0097:
{ // begin catch(System.InvalidOperationException)
V_1 = ((InvalidOperationException_t7 *)__exception_local);
NullCheck(V_1);
String_t* L_18 = (String_t*)VirtFuncInvoker0< String_t* >::Invoke(&Exception_get_Message_m9_MethodInfo, V_1);
Debug_Log_m6(NULL /*static, unused*/, L_18, /*hidden argument*/&Debug_Log_m6_MethodInfo);
// IL_00a3: leave IL_00a8
goto IL_00a8;
} // end catch (depth: 1)
[/cpp]

Все управляемые исключения заворачиваются в Il2CppExceptionWrapper. Когда сгенерированный код ловит исключение этого типа, он распаковывает C++ представление управляемого исключения (которое имеет тип Exception_t8). В данном случае, мы ищем только InvalidOperationException, поэтому если мы не найдем исключение этого типа, C++  снова бросит это исключение. Если же мы встречаем исключение правильного типа, то код переходит к реализации обработчика, и выводит сообщение исключения.

Goto!?!

Этот код вызывает интересный вопрос. Что эти ярлыки и goto там делают? Эти конструкции не являются необходимыми в структурном программировании! Тем не менее, IL не имеет концепции структурированного программирования, таких как циклы, конструкции if/then. Так как этот код низкоуровневый, il2cpp.exe придерживается концепции низкоуровневого программирования в сгенерированном коде.

Для примера, давайте посмотрим на цикл в методе HelloWorld_Start_m3:

[cpp]
IL_00a8:
{
V_2 = 0;
goto IL_00cc;
}
IL_00af:
{
ObjectU5BU5D_t4* L_19 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 1));
int32_t L_20 = V_2;
Object_t * L_21 =
Box(InitializedTypeInfo(&Int32_t5_il2cpp_TypeInfo), &L_20);
NullCheck(L_19);
IL2CPP_ARRAY_BOUNDS_CHECK(L_19, 0);
ArrayElementTypeCheck (L_19, L_21);
*((Object_t **)(Object_t **)SZArrayLdElema(L_19, 0)) = (Object_t *)L_21;
Debug_LogFormat_m7(NULL /*static, unused*/, (String_t*) &_stringLiteral6, L_19, /*hidden argument*/&Debug_LogFormat_m7_MethodInfo);
V_2 = ((int32_t)(V_2+1));
}
IL_00cc:
{
if ((((int32_t)V_2) < ((int32_t)3)))
{
goto IL_00af;
}
}
[/cpp]

Здесь переменная V_2 является индексом цикла. В начале она имеет значение 0, и увеличивается в нижней части цикла в этой строке:

[cpp]
V_2 = ((int32_t)(V_2+1));
[/cpp]

Условие окончания цикла проверяется здесь:

[cpp]
if ((((int32_t)V_2) < ((int32_t)3)))
[/cpp]

Пока V_2 меньше 3-х, управление переходит на метку IL_00af, которая является верхней частью тела цикла. Вы могли догадаться, что на данный момент il2cpp.exe генерирует C++ код непосредственно из IL, без использования промежуточного абстрактного представления синтаксического дерева. И это действительно так. Возможно, вы также заметили выше в разделе динамических проверок, что некоторые части из сгенерированного кода выглядят следующим образом:

[cpp]
float L_1 = (__this->___x_1);
float L_2 = L_1;
[/cpp]

Очевидно, что здесь не требуется переменная L_2. Большинство компиляторов C++ может оптимизировать эту задачу, но мы хотели бы вообще избежать ее создания. В настоящее время мы исследуем возможности использования AST, чтобы лучше понять код IL и генерировать лучший C++ код для случаев, использующих локальные переменные и циклов.

Вывод

Мы рассмотрели только малую часть C++ кода, сгенерированного IL2CPP для очень простого проекта. Если вы этого не сделали, я призываю вас погрузиться в сгенерированный код вашего проекта. Изучая его, имейте в виду, что генерируемый C++ код будет выглядеть по-другому в будущих версиях Unity, так как мы постоянно работаем над улучшением качества и производительности IL2CPP.

Путем преобразования IL кода в C++, мы добились хорошего баланса между портативностью и производительностью кода. Мы смогли получить много полезных для разработчиков функций управляемого кода, и в то же время по-прежнему пользуемся преимуществами машинного кода, которые обеспечивает компилятор С++ для различных платформ.

В будущих постах, мы будем изучать больше сгенерированного кода, в том числе вызовы методов, распределения реализаций методов и обертки для вызова нативных библиотек. Но в следующий раз мы будем отлаживать некоторую часть из сгенерированного кода для 64-битной сборки iOS, используя Xcode.

24 replies on “IL2CPP: экскурсия по генерируемому коду”

[…] the second blog post in this series (about generated code), we mentioned that all method definitions are free functions […]

[…] 文章的源地址:http://blogs.unity3d.com/2015/05/13/il2cpp-internals-a-tour-of-generated-code/ […]

Just curious, why haven’t you used AST from the beginning for code generation but? With AST you do have a lot of info about the context.

[…] this post, I’m using Unity 5.0.1p3 on OSX. I’ll use the same example project as in the post about generated code, but this time I’ll build for the iOS target using the IL2CPP scripting backend. As I did in […]

Why don’t you pre-compile the .net core dlls in advance and then link them when compiling our custom C++ code? As I understand this could heavily reduce the compile time. You could at least do this as a fast-compile option so when we don’t care about the file size could test the game on the device quickly, and then when we want to release the game use the more optimized compilation path.

IL2CPP has a long way to go before it reaches maturity but it’s definitely many steps in the right direction. Given how much more work has to be done to get it there, I think it’s about time Unity seriously considered open sourcing the IL2CPP project. I trust that the guys at Unity Tech are very capable of pulling this off, but at this rate, it will be another year and a half (maybe 2) before we have a trusty IL2CPP.exe that can take just about any IL and spit out highly optimised, lean and error free C++ code. Besides I also think it’s a very inefficient use of your software engineering talent, who should be focusing their energies on game engine tech, not a general purpose IL to C++ transpiler. I appreciate all the work that is being done and the weekly patches that are fixing bugs as quickly as possible. But I can’t help but feel like it’s far too big a project for a team as small as yours and it brings back memories of all the man hours that were wasted porting the engine to flash!

Yes !

Please Unity, Open source it, wait until it matures a few years, see how people you could benefit from the community…and in the meanwhile update Mono.

Does/will il2cpp support all CLR 2.0 assemblies, including those compiled from C# 6.0 sources with all that fancy language features like async/await etc?

This was a nice read. Thanks for sharing it, much appreciated! Can hardly wait for the next post :)

I wonder if all the C# optimization knowledge we built up over the years still applies when using Il2Cpp.

For example, in C# it is often beneficial to use ValueType’s rather than ReferenceType’s, or to avoid using an enum to index into a dictionary, or the whole 2d arrays vs jagged arrays topic, or to cache anonymous delegates to avoid memory allocation each time the runtime comes across them and all these things we now do to squeeze out the last bit of performance.

Can forget all these C# specific optimizations when targeting Il2Cpp?

Nice post ! very informative :)

From my experience (and also from looking at how the generated code looks like), it seems that in some cases, it could help to have a dedicated c++ implementation (not a generated one).

You said that you guys didn’t want to implement all the Mono class libraries, but what about the engine itself? do you have the option to «plug in» a c++ implementation instead of generating code ?
(I realize that probably most of the engine *is* native already, but for the managed parts, is this possible ?)

I like the fact that you guys are working on implementing annonations to disable array bounds checks for specified places in the code. Was one of the first things that came to my mind the moment loops were mentioned in the intro of this post. Was glad to find out it was already being worked on.

The tech looks promising, surely keeping my eyes open for more blog posts about this!

This doesn’t seem to me c++, it is more c-style classes. I can understand the way because of simplicity. I wonder you found out generating more cpp files increases compile time drastic. This is in my experience the case related to the count of header include preprocessor directives per cpp file, because the compiler need to recompile for every cpp file. This could be solved with precompiled headers and is the preferred way.
And I don’t really understand the double underscore, it’s really a no go by default.
Very interesting!

Comments are closed.