How IL2CPP Calls Burst
Ever wonder how code compiled with IL2CPP can call code compiled by Burst? Today we’ll dive into the details and find out!
Update: A Russian translation of this article is available.
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!
#1 by Ulysses on July 25th, 2019 ·
Hello,
Thanks for your articles. I wonder if I can do some trick using these il2cpp internal functions. I tried to import il2cpp functions like this:
[DllImport(“__Internal”, CallingConvention = CallingConvention.Cdecl)]
public static extern UIntPtr il2cpp_resolve_icall(string methodName);
And I wrote a static method in C#: Core.Test();
Then I tried to get the pointer of this method with:
var ptr = il2cpp_resolve_icall(“Core::Test”); //or Core::Test()
I tested this on windows standalone build with il2cpp enabled. It compiles but the ptr is always 0.
I wonder if it’s possible to use `il2cpp_resolve_icall` and `il2cpp_add_internal_call` (or some other methods) to “replace” or “hook” a method at runtime (or some other black magic).
Thanks.
#2 by Ulysses on July 25th, 2019 ·
the ptr is always 0 -> No matter whether I called Core.Test(); before il2cpp_resolve_icall().
I also tried with `MonoBehaviour::Start` instead of `Core::Test` with no luck.
#3 by jackson on August 1st, 2019 ·
That you’re able to call the function and get a null pointer returned is progress. Since you’re experimenting, you might want to try other functions in the engine even if they’re not particularly useful to you just as a proof of concept. Good luck!