(Russian translation from English by Maxim Voloshin)

Когда-нибудь задумывались как код, скомпилированный IL2CPP, может вызывать код, скомпилированный Burst? Сегодня мы углубимся в детали и выясним это!

Тест

Давайте напишем маленький скрипт, вывод компиляции которого мы сможем исследовать. Это простенькая задача, скомпилированная Burst компилятором, и запускающая ее функция на C#:

using UnityEngine;
using Unity.Jobs;
using Unity.Collections;
using Unity.Burst;
 
[BurstCompile]
struct Job : IJob
{
    public NativeArray<float> A;
    public NativeArray<float> B;
    public NativeArray<float> Sum;
 
    public void Execute()
    {
        for (int i = 0; i < A.Length; ++i)
        {
            Sum[i] = A[i] + B[i];
        }
    }
}
 
class TestScript : MonoBehaviour
{
    void RunJob(
        NativeArray<float> a,
        NativeArray<float> b,
        NativeArray<float> sum)
    {
        new Job { A=a, B=b, Sum=sum }.Run();
    }
}

Теперь соберем билд под macOS и откроем папку PROJECTNAME_macOS_BackUpThisFolder_ButDontShipItWithYourGame/il2cppOutput, чтобы увидеть вывод IL2CPP.

Assembly-CSharp.cpp

Здесь мы нашли функцию RunJob. Для лучшего понимания, что именно происходит в коде, я добавил комментариев и пробелов. Некоторые идентификаторы в именах на самом деле длинные и может потребоваться горизонтальная прокрутка.

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void TestScript_RunJob_m8CC0C7D38A7D2356765B2FF3DED6604D147B631C (
    TestScript_t292BEAEA5C665F1E649B7CCA16D364E5E836A4D1 * __this,
    NativeArray_1_t2F93EF84A543D826D53EFEAFE52F5C42392D0D67  ___a0,
    NativeArray_1_t2F93EF84A543D826D53EFEAFE52F5C42392D0D67  ___b1,
    NativeArray_1_t2F93EF84A543D826D53EFEAFE52F5C42392D0D67  ___sum2,
    const RuntimeMethod* method)
{
    // Затратный вызов, в основном в первый раз
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestScript_RunJob_m8CC0C7D38A7D2356765B2FF3DED6604D147B631C_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
 
    // Инициализация структуры задачи
    Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  V_0;
    memset((&V_0), 0, sizeof(V_0));
 
    {
        // Все поля структуры зануляются
        il2cpp_codegen_initobj((&V_0), sizeof(Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 ));
 
        // Присваиваются значения A, B, и Sum полям структуры задачи
        NativeArray_1_t2F93EF84A543D826D53EFEAFE52F5C42392D0D67  L_0 = ___a0;
        (&V_0)->set_A_0(L_0);
        NativeArray_1_t2F93EF84A543D826D53EFEAFE52F5C42392D0D67  L_1 = ___b1;
        (&V_0)->set_B_1(L_1);
        NativeArray_1_t2F93EF84A543D826D53EFEAFE52F5C42392D0D67  L_2 = ___sum2;
        (&V_0)->set_Sum_2(L_2);
 
        // Вызов Job.Run(), который, на самом деле, является методом расширением IJobExtensions.Run(Job)
        // Передача структуры и глобального RuntimeMethod указателя в метод расширение
        Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  L_3 = V_0;
        IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823(
            L_3,
            /*скрытый аргумент*/IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823_RuntimeMethod_var);
 
        return;
    }
}

Теперь перейдем к IJobExtensions.Run(Job), который реализует Job.Run():

inline void IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823 (
    Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  p0,
    const RuntimeMethod* method)
{
    // Это просто передача аргументов через
    // глобальный указатель IJobExtensions_Run_TisJob_..._gshared 
    ((  void (*) (Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 , const RuntimeMethod*))IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823_gshared)(
        p0,
        method);
}
GenericMethods1.cpp

Теперь остановимся на упомянутом выше указателе, который ведет к следующей функции:

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void
IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823_gshared (
    Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  ___jobData0,
    const RuntimeMethod* method)
{
    // Создание и зануление JobScheduleParameters
    JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F  V_0;
    memset((&V_0), 0, sizeof(V_0));
 
    // Создание и зануление JobHandle
    JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1  V_1;
    memset((&V_1), 0, sizeof(V_1));
 
    {
        // Получение указателя #0 для RuntimeMethod, представляющего собой
        // метод расширение, и вызов его с передачей в качестве аргументов 
        // структуры задачи и указателя на MethodInfo
        void* L_0 = ((  void* (*) (Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 *, const RuntimeMethod*))IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 0)->methodPointer)(
            (Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 *)(Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 *)(&___jobData0),
            /*скрытый аргумент*/IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 0));
 
        // Получение указателя #1 для RuntimeMethod, представляющего собой
        // метод расширение, и вызов его с указателя на MethodInfo
        intptr_t L_1 = ((  intptr_t (*) (const RuntimeMethod*))IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 1)->methodPointer)(
            /*скрытый аргумент*/IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 1));
 
        // Инициализация JobHandle, созданного выше
        il2cpp_codegen_initobj((&V_1), sizeof(JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 ));
 
        // Вызов конструктора JobScheduleParameters, созданного выше
        // Передает JobHandle и упомянутые указатели
        JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1  L_2 = V_1;
        JobScheduleParameters__ctor_m09A522B620ED79BDFD86DE2544175159B6179E48(
            (JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)(JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)(&V_0),
            (void*)(void*)L_0,
            (intptr_t)L_1,
            (JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 )L_2,
            (int32_t)0,
            /*скрытый аргумент*/NULL);
 
        // Вызов JobsUtility.Schedule с JobScheduleParameters
        JobsUtility_Schedule_m544BE1DBAEFF069809AE5304FD6BBFEE2927D4C3(
            (JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)(JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)(&V_0),
            /*скрытый аргумент*/NULL);
 
        return;
    }
}
UnityEngine.CoreModule.cpp

Далее рассмотрим последний вызов JobsUtility.Schedule, чтобы увидеть как запускается задача в JobSystem.

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1
JobsUtility_Schedule_m544BE1DBAEFF069809AE5304FD6BBFEE2927D4C3 (
    JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F * ___parameters0,
    const RuntimeMethod* method)
{
    // Инициализация JobHandle нулями
    JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1  V_0;
    memset((&V_0), 0, sizeof(V_0));
 
    {
        // Вызов JobsUtility.Schedule_Injected с аргументами JobScheduleParameters
        // и указателем на JobHandle
        JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F * L_0 = ___parameters0;
        JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01(
            (JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)L_0,
            (JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 *)(&V_0),
            /*скрытый аргумент*/NULL);
 
        // Возврат JobHandle
        JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1  L_1 = V_0;
        return L_1;
    }
}

JobsUtility.Schedule_Injected в том же самом файле:

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01 (
    JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F * ___parameters0,
    JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 * ___ret1,
    const RuntimeMethod* method)
{
    // Определение типа указателя на функцию для вызова
    typedef void (*JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01_ftn) (
        JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *,
        JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 *);
 
    // Создание одиночного указателя для всех вызовов этой функции
    static JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01_ftn _il2cpp_icall_func;
 
    // Когда указатель на функцию не установлен(например, при первом вызове), вызвать
    // il2cpp_codegen_resolve_icall со строкой, представляющей вызов функции
    // и инициализировать указатель возвращаемым значением
    if (!_il2cpp_icall_func)
        _il2cpp_icall_func = (JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01_ftn)il2cpp_codegen_resolve_icall (
            "Unity.Jobs.LowLevel.Unsafe.JobsUtility::Schedule_Injected(Unity.Jobs.LowLevel.Unsafe.JobsUtility/JobScheduleParameters&,Unity.Jobs.JobHandle&)");
 
    // Вызов функции по указателю, который вернула il2cpp_codegen_resolve_icall
    // с аргументами JobScheduleParameters и JobHandle
    _il2cpp_icall_func(___parameters0, ___ret1);
}
il2cpp/libil2cpp/codegen/il2cpp-codegen-il2cpp.cpp

Теперь мы делаем вызов в код движка Unity, а не в код, сгенерированный IL2CPP. Чтобы в этом убедиться, откройте указанный файл в папке с установленным Unity. Теперь немного удобнее читать код т.к. он написан вручную в Unity.

Il2CppMethodPointer il2cpp_codegen_resolve_icall(const char* name)
{
    // Получить указатель на функцию через имя функции
    Il2CppMethodPointer method = il2cpp::vm::InternalCalls::Resolve(name);
 
    // Если мы не можем получить указатель, бросить исключение
    if (!method)
    {
        il2cpp::vm::Exception::Raise(il2cpp::vm::Exception::GetMissingMethodException(name));
    }
 
    // Вернуть указатель
    return method;
}
il2cpp/libil2cpp/vm/InternalCalls.cpp

И в последнюю очередь взглянем на il2cpp::vm::InternalCalls::Resolve, чтобы узнать, как возвращается указатель. Эта функция уже имеет комментарии, объясняющие свою работу, поэтому я воздержусь и не буду добавлять что-либо.

Il2CppMethodPointer InternalCalls::Resolve(const char* name)
{
    // Для начала пробуем найти полное имя, если аргументы переданы,
    // потом ищем используя type::method
    // Пример: First, System.Foo::Bar(System.Int32)
    // потом, System.Foo::Bar
    ICallMap::iterator res = s_InternalCalls.find(name);
 
    if (res != s_InternalCalls.end())
        return res->second;
 
    std::string shortName(name);
    size_t index = shortName.find('(');
 
    if (index != std::string::npos)
    {
        shortName = shortName.substr(0, index);
        res = s_InternalCalls.find(shortName);
 
        if (res != s_InternalCalls.end())
            return res->second;
    }
 
    return NULL;
}

s_InternalCalls объявлена в начале файла как глобальная переменная. Это просто словарь, который действует как кэш, и содержит имена функций с указателями на них.

typedef std::map<std::string, Il2CppMethodPointer> ICallMap;
static ICallMap s_InternalCalls;

Этот словарь заполняется с помощью другой функции из того же файла:

void InternalCalls::Add(const char* name, Il2CppMethodPointer method)
{
    //ICallMap::iterator res = s_InternalCalls.find(name);
 
    // TODO: Не вызывать Assert прямо сейчас, потому что Unity добавляет icalls несколько раз.
    //if (res != icalls.end())
    //  IL2CPP_ASSERT(0 && "Adding internal call twice!");
 
    IL2CPP_ASSERT(method);
 
    s_InternalCalls[name] = method;
}
il2cpp/libil2cpp/il2cpp-api.cpp

Все вызовы InternalCalls::Add идут через простую сквозную функцию, которая является частью IL2CPP:

void il2cpp_add_internal_call(const char* name, Il2CppMethodPointer method)
{
    return InternalCalls::Add(name, method);
}
Помимо исходного кода

На данный момент мы прошлись по всему коду, который находит и вызывает скомпилированную через Burst функцию. Оставшаяся часть головоломки – зайти в папку с билдом игры для macOS и посмотреть бинарный файл, скомпилированный Burst. Мы можем найти его здесь:

Contents/
|--Plugins/
   |--lib_burst_generated.bundle
   |--lib_burst_generated.txt

Откройте lib_burst_generated.txt, который вносит некоторую ясность:

--platform=macOS
--backend=burst-llvm
--target=X64_SSE4
--noalias
--dump=Function
--float-precision=Standard
--output=/path/to/project/Temp/StagingArea/UnityPlayer.app/Contents/Plugins/lib_burst_generated
--method=System.Void Unity.Jobs.IJobExtensions/JobStruct`1<Job>::Execute(T&,System.IntPtr,System.IntPtr,Unity.Jobs.LowLevel.Unsafe.JobRanges&,System.Int32)--a3a2767d0992906d22747c995417de24
--method=System.Void Unity.Jobs.IJobParallelForExtensions/ParallelForJobStruct`1<ParallelJob>::Execute(T&,System.IntPtr,System.IntPtr,Unity.Jobs.LowLevel.Unsafe.JobRanges&,System.Int32)--e822743a36a5dcf3c62a16e009c55d24
--platform=macOS
--backend=burst-llvm
--target=X64_SSE2
--noalias
--dump=Function
--float-precision=Standard
--output=/path/to/project/Temp/StagingArea/UnityPlayer.app/Contents/Plugins/lib_burst_generated
--method=System.Void Unity.Jobs.IJobExtensions/JobStruct`1<Job>::Execute(T&,System.IntPtr,System.IntPtr,Unity.Jobs.LowLevel.Unsafe.JobRanges&,System.Int32)--a3a2767d0992906d22747c995417de24
--method=System.Void Unity.Jobs.IJobParallelForExtensions/ParallelForJobStruct`1<ParallelJob>::Execute(T&,System.IntPtr,System.IntPtr,Unity.Jobs.LowLevel.Unsafe.JobRanges&,System.Int32)--e822743a36a5dcf3c62a16e009c55d24

Тут мы видим, что Burst компилирует наш код дважды: один раз для SSE4 и один для SSE2. Это делает игру совместимой с более широким набором процессоров, поскольку macOS будет загружать подходящую для процессора библиотеку из lib_burst_generated.bundle при запуске игры.

Conclusion

IL2CPP работает независимо от Burst. Это происходит через вызовы в скомпилированный Burst код, очень похожим образом происходит вызов в любой другой нативный код через P/Invoke. Это имеет смысл потому, что, как минимум, в macOS, Burst компилирует код в динамическую библиотеку и прямые вызовы невозможны.

Эта интеграция на уровне ОС идет с накладными расходами. Все вызовы не бесплатны и идут с некоторыми затратами, особенно при первом вызове. Это может привести к падению производительности, если этим злоупотреблять и использовать Burst для слишком малого объема работ. Тем не менее, стоимость относительно низкая, поэтому нет необходимости использовать Burst только для самых тяжелых задач. Это отличные новости!