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 handle this pointer 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:

  1. Runtime/Core/Models/IL2CPP.cs: Contains the P/Invoke definitions exposing the IL2CPP C API and the GetIl2CppMethod/GetIl2CppClass helper utilities.
  2. Runtime/Flow/Models/ExecutableReflection.cs: Handles function registration and resolves IL2CPP method pointers during setup, storing them in ExecutableFunction instances.
  3. 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.cs for API binding, ExecutableReflection.cs for pointer resolution, and ExecutableAction.cs for 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →