Ever wonder how code compiled with IL2CPP can call code compiled by Burst? Today we’ll dive into the details and find out!

The test

Let’s set up a tiny script so we have something to inspect the output of. Here’s a trivial Burst-compiled job and a C# function that runs it:

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

Now let’s build for macOS and open up the PROJECTNAME_macOS_BackUpThisFolder_ButDontShipItWithYourGame/il2cppOutput directory to see the IL2CPP output.

Assembly-CSharp.cpp

Here we find the RunJob function itself. I’ve annotated it with comments and whitespace to better explain what’s going on. Some of the identifiers are really long though, so some horizontal scrolling may be necessary.

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)
{
    // Function call overhead, mostly on the first call
    static bool s_Il2CppMethodInitialized;
    if (!s_Il2CppMethodInitialized)
    {
        il2cpp_codegen_initialize_method (TestScript_RunJob_m8CC0C7D38A7D2356765B2FF3DED6604D147B631C_MetadataUsageId);
        s_Il2CppMethodInitialized = true;
    }
 
    // Initialize the job struct
    Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  V_0;
    memset((&V_0), 0, sizeof(V_0));
 
    {
        // Clear the job struct's fields to all zeroes
        il2cpp_codegen_initobj((&V_0), sizeof(Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 ));
 
        // Set the A, B, and Sum fields of the job struct
        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);
 
        // Call Job.Run(), which is really the extension method IJobExtensions.Run(Job)
        // Pass in the job struct and the global RuntimeMethod pointer for the extension method
        Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  L_3 = V_0;
        IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823(
            L_3,
            /*hidden argument*/IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823_RuntimeMethod_var);
 
        return;
    }
}

Next let’s jump to IJobExtensions.Run(Job) which implements Job.Run():

inline void IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823 (
    Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  p0,
    const RuntimeMethod* method)
{
    // This is just a passthrough to the global
    // IJobExtensions_Run_TisJob_..._gshared function pointer
    ((  void (*) (Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 , const RuntimeMethod*))IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823_gshared)(
        p0,
        method);
}
GenericMethods1.cpp

The next stop is to the function pointer called above, which points to this functions:

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void
IJobExtensions_Run_TisJob_t38E17FBF016E094161289D2A6FBF5D7E42F4F458_m6485C7A28C7D5FF7E849D5BB5E442771CCBDB823_gshared (
    Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458  ___jobData0,
    const RuntimeMethod* method)
{
    // Create and zero out a JobScheduleParameters
    JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F  V_0;
    memset((&V_0), 0, sizeof(V_0));
 
    // Create and zero out a JobHandle
    JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1  V_1;
    memset((&V_1), 0, sizeof(V_1));
 
    {
        // Get function pointer #0 for the RuntimeMethod representing the
        // extension method and call it with the job struct and a pointer to its
        // method info
        void* L_0 = ((  void* (*) (Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 *, const RuntimeMethod*))IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 0)->methodPointer)(
            (Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 *)(Job_t38E17FBF016E094161289D2A6FBF5D7E42F4F458 *)(&___jobData0),
            /*hidden argument*/IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 0));
 
        // Get function pointer #1 for the RuntimeMethod representing the
        // extension method and call it with a pointer to its method info
        intptr_t L_1 = ((  intptr_t (*) (const RuntimeMethod*))IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 1)->methodPointer)(
            /*hidden argument*/IL2CPP_RGCTX_METHOD_INFO(method->rgctx_data, 1));
 
        // Initialize the JobHandle created above
        il2cpp_codegen_initobj((&V_1), sizeof(JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 ));
 
        // Call the constructor for the JobScheduleParameters created above
        // Pass the JobHandle and returns of the above method calls to it
        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,
            /*hidden argument*/NULL);
 
        // Call JobsUtility.Schedule with the JobScheduleParameters
        JobsUtility_Schedule_m544BE1DBAEFF069809AE5304FD6BBFEE2927D4C3(
            (JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)(JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)(&V_0),
            /*hidden argument*/NULL);
 
        return;
    }
}
UnityEngine.CoreModule.cpp

Next we follow the last call to JobsUtility.Schedule to see how the job is run.

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1
JobsUtility_Schedule_m544BE1DBAEFF069809AE5304FD6BBFEE2927D4C3 (
    JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F * ___parameters0,
    const RuntimeMethod* method)
{
    // Initialize a JobHandle to zero
    JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1  V_0;
    memset((&V_0), 0, sizeof(V_0));
 
    {
        // Call JobsUtility.Schedule_Injected with the JobScheduleParameters and
        // a pointer to the JobHandle
        JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F * L_0 = ___parameters0;
        JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01(
            (JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *)L_0,
            (JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 *)(&V_0),
            /*hidden argument*/NULL);
 
        // Return the JobHandle
        JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1  L_1 = V_0;
        return L_1;
    }
}

JobsUtility.Schedule_Injected is in the same file:

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01 (
    JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F * ___parameters0,
    JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 * ___ret1,
    const RuntimeMethod* method)
{
    // Make a type definition for the kind of function pointer to call
    typedef void (*JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01_ftn) (
        JobScheduleParameters_t55DC8564D72859191CE4E81639EFD25F6C6A698F *,
        JobHandle_tDA498A2E49AEDE014468F416A8A98A6B258D73D1 *);
 
    // Create a single function pointer for all calls of this function
    static JobsUtility_Schedule_Injected_mC918219A2045C68697BD2C7FCE7DCA515CE09C01_ftn _il2cpp_icall_func;
 
    // When the function pointer isn't set (i.e. on the first call), call
    // il2cpp_codegen_resolve_icall with a string representing the function to
    // call and set the function pointer to the return value
    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&)");
 
    // Call the function pointer returned by il2cpp_codegen_resolve_icall with
    // the JobScheduleParameters and JobHandle
    _il2cpp_icall_func(___parameters0, ___ret1);
}
il2cpp/libil2cpp/codegen/il2cpp-codegen-il2cpp.cpp

At this point we’re calling into Unity engine code, not code generated by IL2CPP. To see it, open up the Unity installation directory. The code from here on out looks a lot better since it’s hand-written by Unity.

Il2CppMethodPointer il2cpp_codegen_resolve_icall(const char* name)
{
    // Get a function pointer for the string name of the function
    Il2CppMethodPointer method = il2cpp::vm::InternalCalls::Resolve(name);
 
    // If we can't get the function pointer, throw an exception
    if (!method)
    {
        il2cpp::vm::Exception::Raise(il2cpp::vm::Exception::GetMissingMethodException(name));
    }
 
    // Return the function pointer
    return method;
}
il2cpp/libil2cpp/vm/InternalCalls.cpp

The very last step is to look at il2cpp::vm::InternalCalls::Resolve itself to see how the function pointer is retrieved. This function has a comment explaining it, so I’ll omit mine.

Il2CppMethodPointer InternalCalls::Resolve(const char* name)
{
    // Try to find the whole name first, then search using just type::method
    // if parameters were passed
    // ex: First, System.Foo::Bar(System.Int32)
    // Then, 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 is declared at the top of the file as a global variable. It’s just a map of strings to function pointers that acts as a cache.

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

This map is populated using another function in the same file:

void InternalCalls::Add(const char* name, Il2CppMethodPointer method)
{
    //ICallMap::iterator res = s_InternalCalls.find(name);
 
    // TODO: Don't assert right now because Unity adds some icalls multiple times.
    //if (res != icalls.end())
    //  IL2CPP_ASSERT(0 && "Adding internal call twice!");
 
    IL2CPP_ASSERT(method);
 
    s_InternalCalls[name] = method;
}
il2cpp/libil2cpp/il2cpp-api.cpp

All calls to InternalCalls::Add go through a plain pass-through function that’s part of the IL2CPP API:

void il2cpp_add_internal_call(const char* name, Il2CppMethodPointer method)
{
    return InternalCalls::Add(name, method);
}
Beyond Source Code

At this point we’ve gone through all the code that finds and calls the Burst-compiled function. The remaining piece of the puzzle is to open up the macOS application for the game itself and look for the binary that Burst compiled to. We can find it here:

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

Looking at the lib_burst_generated.txt file provides some insights:

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

Here we see that Burst is compiling our code two times: once for SSE4 and once for SSE2. This makes the game compatible with a wider range of CPUs since macOS will load the appropriate shared library for the CPU out of lib_burst_generated.bundle when the game is run.

Conclusion

IL2CPP operates at arm’s length with Burst. It does this by calling into Burst-compiled code very similarly to how it calls into any other native code via P/Invoke. This makes sense because, at least on macOS, the Burst-compiled code is in a shared library so no direct function calls are possible.

This OS-level integration comes with some overhead. All of these calls are not free and come with some cost, especially on the first call. Still, the cost is relatively low so there’s no need to only use Burst for massive jobs. As long as you steer clear from using it for tiny amounts of work, the overhead should be insignificant. This is great news!