How Ceres Achieves Near-Native Performance with IL2CPP Optimization for Function Calls
Ceres eliminates reflection overhead in Unity IL2CPP builds by resolving native method pointers through il2cpp_class_get_method_from_name and invoking them via C# unmanaged function pointers, achieving C++-level call performance for flow-graph nodes.
The akikurisu/ceres repository implements a high-performance visual scripting runtime for Unity that must execute user-defined functions with minimal overhead. When targeting IL2CPP—the Ahead-of-Time (AOT) compilation backend used by Unity for consoles and mobile devices—Ceres leverages a specialized IL2CPP optimization path that bypasses the expensive reflection and delegate creation typically required for dynamic method invocation.
The Two-Step IL2CPP Optimization Strategy
Ceres implements a dual-phase approach to transform managed method calls into direct native calls. This strategy is conditionally compiled behind #if ENABLE_IL2CPP blocks, ensuring the same codebase remains compatible with both Mono and IL2CPP runtimes.
Step 1: Discovering the Native Method Address
When a flow-graph node references a C# instance method, Ceres must locate the exact native entry point generated by the IL2CPP compiler. This occurs in Runtime/Flow/Models/ExecutableReflection.cs during function registration.
The system queries the IL2CPP runtime through the thin wrapper IL2CPP.GetIl2CppMethod, defined in Runtime/Core/Models/IL2CPP.cs. This wrapper internally invokes the IL2CPP C API function il2cpp_class_get_method_from_name, passing the class pointer (_il2cppClass) and constructing the method name Invoke_<FunctionName>—the IL2CPP-generated invoker that matches the original C# signature.
// Simplified excerpt from ExecutableReflection<T>.GetFunction_Internal
#if ENABLE_IL2CPP && (UNITY_STANDALONE_WIN || UNITY_ANDROID)
unsafe {
if (functionType == ExecutableFunctionType.InstanceMethod && _il2cppClass != IntPtr.Zero) {
int invokeParameterCount = functionInfo.ParameterCount >= 0
? functionInfo.ParameterCount + 1 // 'this' pointer + arguments
: -1;
var ptr = IL2CPP.GetIl2CppMethod(
_il2cppClass,
$"Invoke_{functionName}",
invokeParameterCount);
// Native pointer stored for later invocation
}
}
#endif
Step 2: Direct Invocation via Function Pointers
Once discovered, the native address (IntPtr) is cached within an ExecutableFunction instance. The actual invocation logic resides in Runtime/Flow/Models/ExecutableAction.cs, where ExecutableAction<TTarget> overloads check for the presence of this cached pointer.
When _functionPtr is non-zero, Ceres casts it to an unmanaged function pointer using C# 9.0 function pointer syntax (delegate*) and executes a direct call. This bypasses the .NET delegate system entirely, resulting in a single instruction jump to the compiled native code.
public void Invoke<T1>(TTarget target, T1 arg1) {
if (IsStatic) {
if (_functionPtr != IntPtr.Zero) {
((delegate*<T1, void>)_functionPtr)(arg1); // Direct native call
return;
}
}
#if ENABLE_IL2CPP
if (_functionPtr != IntPtr.Zero) {
((delegate*<TTarget, T1, void>)_functionPtr)(target, arg1); // Direct native call
return;
}
#endif
// Fallback to reflection-based delegate invocation
}
Why This Approach Eliminates Reflection Overhead
The IL2CPP optimization in Ceres avoids three major performance bottlenecks associated with dynamic invocation:
- Reflection elimination: The system never calls
MethodInfo.Invoke, avoiding metadata lookups and boxing of arguments. - Delegate allocation bypass: Unlike the Mono fallback path that uses
Delegate.CreateDelegate, the IL2CPP path requires no managed delegate object allocation. - Pre-generated marshalling: IL2CPP generates
Invoke_<Name>wrappers at build time that handlethispointer passing and argument marshalling exactly as the native signature expects, eliminating JIT-style conversion overhead.
The result is that flow-graph function calls in IL2CPP builds exhibit the same performance characteristics as hand-written C++ function calls, with zero per-call lookup costs after the initial registration phase.
Implementation Details in the Ceres Source Code
The optimization spans three critical files in the repository:
Runtime/Core/Models/IL2CPP.cs: Contains the P/Invoke definitions exposing the IL2CPP C API and theGetIl2CppMethod/GetIl2CppClasshelper utilities.Runtime/Flow/Models/ExecutableReflection.cs: Handles function registration and resolves IL2CPP method pointers during setup, storing them inExecutableFunctioninstances.Runtime/Flow/Models/ExecutableAction.cs: Implements the execution logic, selecting the fast native-pointer path when available (starting at line 604 in the repository).
Registration occurs once per function during graph initialization:
// From ExecutableReflection<T>.RegisterExecutableFunction
var functionInfo = new ExecutableFunctionInfo(
ExecutableFunctionType.InstanceMethod,
methodInfo.Name,
methodInfo.GetParameters().Length);
var functionStructure = new ExecutableFunction(functionInfo, methodInfo);
_functions.Add(functionStructure);
Summary
-
Ceres achieves near-native performance by resolving IL2CPP method pointers at registration time using
il2cpp_class_get_method_from_name. -
Invocation uses C# unmanaged function pointers (
delegate*) to execute direct native calls without reflection or delegate overhead. -
The optimization is conditionally compiled for IL2CPP targets while maintaining fallback compatibility with standard Mono reflection.
-
Critical files include
IL2CPP.csfor API binding,ExecutableReflection.csfor pointer resolution, andExecutableAction.csfor fast-path execution.
Frequently Asked Questions
How does Ceres handle method calls when IL2CPP is not enabled?
When ENABLE_IL2CPP is undefined, Ceres falls back to standard .NET reflection. It creates cached delegates using Delegate.CreateDelegate from MethodInfo objects. While this is significantly slower than the native pointer approach, it maintains functional compatibility across all Unity platforms.
What is the Invoke_<FunctionName> pattern used in the IL2CPP lookup?
IL2CPP generates C++ wrapper functions for every managed method to handle calling conventions and this pointer marshalling. These follow the naming convention Invoke_<OriginalMethodName>. Ceres constructs this string dynamically to locate the correct native entry point through the IL2CPP runtime API.
Does this optimization work on all Unity platforms?
The current implementation specifically targets UNITY_STANDALONE_WIN and UNITY_ANDROID as indicated by the preprocessor conditions in the source. Other platforms may require additional P/Invoke definitions or IL2CPP API availability verification in Runtime/Core/Models/IL2CPP.cs.
Why use delegate* instead of standard Marshal.GetDelegateForFunctionPointer?
delegate* (C# function pointers) generate direct call instructions at the IL level without allocating a delegate object on the managed heap. This reduces memory pressure and eliminates the indirection layer that standard delegates require, resulting in the "near-native" performance characteristic that the optimization targets.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →