Function Injection Strategy
To avoid dirty function contamination, by default a small section of check and jump code is injected at the beginning of all functions. This injection code has a significant impact on short function performance and the length of the finally generated code (increasing code by about 30%). Although in most cases injection code has negligible impact on overall performance, in rare special occasions, this performance degradation can be observed. Starting from version v4.5.9, custom configuration of this injection behavior is allowed.
Dirty Function Contamination
We call changed functions dirty functions. If no modifications are made to the original code generated by il2cpp, for non-virtual function calls, there is a problem of dirty function chain contamination. For example: function A calls function B, function B calls function C. If function C changes, then A, B, and C will all be marked as dirty functions. In practice, changes to some commonly used basic functions may cause massive amounts of code to be marked as dirty functions, which is obviously not what we expect.
class Foo
{
public static void A()
{
B();
}
public static void B()
{
C();
}
public static void C()
{
// Old code was new object();
// After modification, causes A, B, C to all be marked as dirty functions
new List<int>();
}
}
Indirect Function Optimization Technology
We use indirect function optimization technology to overcome this problem. When il2cpp generates code, it inserts a section of check code at the beginning of DHE functions. If the function hasn't changed, it continues execution; otherwise, it jumps to interpreted function execution.
Taking the following C# code as an example:
public class IndirectChangedNotInjectMethod
{
public static int ChangeMethod10(int x)
{
return ChangeMethod0(x);
}
public static int ChangeMethod100(int x)
{
return ChangeMethod10(x);
}
}
The original il2cpp code generated by the `ChangeMethod100` function is as follows:
```cpp
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR int32_t IndirectChangedNotInjectMethod_ChangeMethod100_mFE65234D8ACE343677581C1D96E05E9DFC7C2D1A (int32_t ___0_x, const RuntimeMethod* method)
{
{
// return ChangeMethod10(x);
int32_t L_0 = ___0_x;
int32_t L_1;
L_1 = IndirectChangedNotInjectMethod_ChangeMethod10_m1CFE86C6F8D9E11116BA0F8CACB72A31D4F8401E(L_0, NULL);
return L_1;
}
}
After inserting check and jump code, it becomes:
IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR int32_t IndirectChangedNotInjectMethod_ChangeMethod100_mFE65234D8ACE343677581C1D96E05E9DFC7C2D1A (int32_t ___0_x, const RuntimeMethod* method)
{
static bool s_Il2CppMethodInitialized;
if (!s_Il2CppMethodInitialized)
{
il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&IndirectChangedNotInjectMethod_ChangeMethod100_mFE65234D8ACE343677581C1D96E05E9DFC7C2D1A_RuntimeMethod_var);
s_Il2CppMethodInitialized = true;
}
method = IndirectChangedNotInjectMethod_ChangeMethod100_mFE65234D8ACE343677581C1D96E05E9DFC7C2D1A_RuntimeMethod_var;
if (method->isInterpterImpl)
{
typedef int32_t (*RedirectFunc)(int32_t, const RuntimeMethod*);
return ((RedirectFunc)method->methodPointerCallByInterp)(___0_x, method);
}
{
// return ChangeMethod10(x);
int32_t L_0 = ___0_x;
int32_t L_1;
L_1 = IndirectChangedNotInjectMethod_ChangeMethod10_m1CFE86C6F8D9E11116BA0F8CACB72A31D4F8401E(L_0, NULL);
return L_1;
}
}
The injected code includes the following content:
- The metadata initialization block at the beginning adds initialization code for the metadata corresponding to the current function. If the function originally had no metadata that needed initialization, the entire metadata initialization code block is added
- A new branch check code is added. If the current function is replaced with interpretation execution, it jumps to interpretation execution
For most cases, the injected code only adds one additional check if (method->isInterpterImpl)
, which has negligible impact on overall performance. But for short functions (such as int GetValue() { return value; }
),
since short functions themselves have brief code and often have no metadata that needs initialization, this introduces two additional checks and may prevent function inlining, causing observable performance degradation (10% or more) and significant code bloat (adding two code blocks).
Even for non-short functions, injected code causes the overall size of code files generated by DHE assemblies to increase by 30%, which has a non-negligible impact on package size.
In fact, many short functions will not change, so injected code is unnecessary. Avoiding injection can significantly improve their performance and also reduce the size of the finally generated cpp code to some extent. For this reason, we introduced injection strategy files to configure this behavior.
Injection Strategy Files
We optimize the performance degradation and code bloat problems caused by indirect function optimization by configuring some or all functions (use with caution, not recommended!) not to inject. Function Injection Strategy (InjectRules) files are used to achieve this purpose.
Even if a function is marked as non-injectable, modifying this function in subsequent hot updates will not cause runtime errors or execution of old logic, it will only cause dirty function contamination problems, i.e., all functions that directly call this function will be marked as dirty functions.
HybridCLR Settings Configuration
In the InjectRuleFiles
field in HybridCLR Settings
, fill in the injection strategy file path. The file's relative path is from the project root directory (such as Assets/InjectRules/DefaultInjectRules.xml
).
You can provide 0-N configuration strategy files. If there are no configuration strategy files, injection is performed by default for all functions in DHE assemblies.
Configuration Rules
The configuration syntax is very similar to link.xml. For a function, if it matches multiple rules, the last rule takes precedence.
A typical injection strategy file is as follows:
<rules>
<assembly fullname="*">
<type fullname="*">
<property name="*"/> <!-- All properties do not inject -->
</type>
</assembly>
<assembly fullname="AssemblyA">
<type fullname="Type1">
<method name="*"/>
</type>
<type fullname="Type2">
<property name="Age*"/>
<property name="Age_3" mode="proxy"/>
<property name="Count" mode="none"/>
<property signature="System.String Name"/>
<method name="Run*"/>
<method name="Run_3" mode="proxy"/>
<method name="Foo"/>
<method signature="System.Int32 Sum(System.Int32,System.Int32)"/>
<method signature="System.Int32 Sum2(System.Int32,System.Int32)"/>
<event name="OnEvent*"/>
<event name="OnEvent_3" mode="proxy"/>
<event name="OnHello"/>
</type>
</assembly>
<assembly fullname="AssemblyB">
<type fullname="*">
<method name="*"/>
</type>
</assembly>
</rules>
rules
The top-level tag is rules, which can contain 0-n assembly rules.
Name | Type | Nullable | Description |
---|---|---|---|
assembly | Child element | No | Assembly rule |
assembly
Configure rules for a specific assembly or a class of assemblies.
Name | Type | Nullable | Description |
---|---|---|---|
fullname | Attribute | No | Assembly name, without '.dll' suffix. Supports wildcards, such as '', 'Unity.', 'MyCustom*', etc. |
type | Child element | Yes | Type rule. Can contain 0-N |
type
Configure injection rules for a specific type or a class of types. Note that it supports injection rules for generic original types, but does not support configuring injection rules for generic instance classes. For example, you can configure injection rules for List`1, but cannot configure injection rules for List<int>.
- If a function satisfies multiple rules, the last rule takes precedence
- property is treated as two functions
get_{name}
andset_{name}
, soint Count
can also be matched by<method name="get_Count">
Name | Type | Nullable | Description |
---|---|---|---|
fullname | Attribute | No | Type full name. Supports wildcards, such as '', 'Unity.', 'MyCustom.*.TestType', etc. |
method | Child element | Yes | Function rule |
property | Child element | Yes | Property rule |
event | Child element | Yes | Event rule |
method
Configure function injection rules.
Name | Type | Nullable | Description |
---|---|---|---|
name | Attribute | No | Function name. Supports wildcards, such as '', 'Run', etc. |
signature | Attribute | Yes | Function signature. Supports wildcards, such as '*', 'System.Int32 *(System.Int32)' |
mode | Child element | Yes | Injection type, valid values are 'none' or 'proxy'. If not filled or empty, defaults to 'none' |
property
Configure property injection rules. Note that property is treated as two functions get_{name}
and set_{name}
, so the getter function get_Count
of int Count
can also be matched by <method name="get_Count">
.
Name | Type | Nullable | Description |
---|---|---|---|
name | Attribute | No | Function name. Supports wildcards, such as '', 'Run', etc. |
signature | Attribute | Yes | Function signature. Supports wildcards, such as '*', 'System.Int32 *' |
mode | Child element | Yes | Injection type, valid values are 'none' or 'proxy'. If not filled or empty, defaults to 'none' |
event
Configure event injection rules. Note that event is treated as two functions add_{name}
and remove_{name}
, so the add function add_OnDone
of Action OnDone
can also be matched by <method name="add_OnDone">
.
Name | Type | Nullable | Description |
---|---|---|---|
name | Attribute | No | Function name. Supports wildcards, such as '', 'Run', etc. |
signature | Attribute | Yes | Function signature. Supports wildcards, such as '*', 'Action<System.Int32> On*' |
mode | Child element | Yes | Injection type, valid values are 'none' or 'proxy'. If not filled or empty, defaults to 'none' |
Code Generation Injection Rules
Manually adding injection rules can be a tedious task. When wildcard matching by name cannot meet requirements, for example when you want to not inject short functions with less than 10 instructions, code generation of corresponding injection rules can greatly simplify the workload of constructing injection rules.
The code implementation for generating injection rules is not complex, basically iterating through each DHE assembly, and if a function satisfies a certain rule, adding the corresponding injection rule. Example code is as follows:
public static void GenerateInjectRule(List<string> dheAssemblyNames, string outputInjectRuleFile)
{
int minInjectMethodInstructions = 10;
foreach (string dheDllPath in dheAssemblyNames)
{
using (var dheMod = ModuleDefMD.Load(dheDllPath))
{
// Add injection rule <assembly fullname="{dheMod.Assembly.Name}" />
for (uint i = 1, n = dheMod.Metadata.TablesStream.MethodTable.Rows; i <= n; i++)
{
MethodDef methodDef = dheMod.ResolveMethod(i);
if (methodDef.HasBody && methodDef.Body.Instructions.Count < minInjectMethodInstructions)
{
// Add injection rule
// <type name="{methodDef.DeclaringType.Name}">
// <method name="{methodDef.Name}" />
// </type>
}
}
}
}
}
Build Workflow Related
Injection strategy files need to be consistent with the built main package, i.e., each independently released main package must backup the injection strategy files used at that time. Just as each time dhao files are generated, the AOT dll generated when building the main package must be used, when generating dhao files, the injection strategy files backed up when building the main package must be used. If incorrect injection strategy files are used, it will cause incorrect dhao files to be generated, which may cause wrong logic to run or even crashes!