搜索 Unity

Unity 2021.2功能亮点:IL2CPP运行时的性能改进

2022年2月17日 类别 技术 | 7 分 阅读
Runtime Performance banner
Runtime Performance banner
涵盖的主题
分享

热知识:在Unity里码代码时,你可以利用C#的可读性和安全性最大程度地提高项目在目标平台上的运行时性能。为了进一步提高性能,Unity的.NET Tech Group一直在马不停蹄地更新C#脚本背后的基础技术栈。

在Unity 2021中,我们推出了几项改进以加快IL2CPP脚本后端的运行速度。这里,我们来仔细了解几个可明显加快脚本运行速度的关键改动。

委托(Delegate)的调用

作为C#的一种特色功能,委托(Delegate)的CreateDelegate API有点太过复杂。灵活性是委托的一大特性:它可以是开放的,也可以是封闭的,可以用虚拟或接口调用来调用实例或静态方法,甚至泛型方法和泛型类方法的区别!由于有这么多种用法,项目运行时要想保证调用和被调用的方法都正确,就必须进行多次检查。

在Unity 2021.2里,IL2CPP将在编译时预先验证所有的调用类型。意味着开放委托可只用两条间接调用指令调用,而封闭委托只需一条。这使委托的调用能与.NET Framework和.NET Core的调用相同。

得益于此,委托现在的调用要比以往更快捷,在部分基准测试下也要快得多。

Delegate Invocation Performance

多余的装箱(Boxing)检测

C#的装箱(Boxing)是一种将特定类型的值打包成System.Object对象的过程。这一过程涉及到在内存堆上分配存储空间,因此也是一个较缓慢的过程。为了进一步加快IL2CPP运行时的运行速度、挤出更多性能,我们在Unity 2021.2中取消了部分boxing操作。

这就要求可空类型须以特定的方式进行装箱,让运行时能在每次装箱后仍能识别可空类型。通过取消这些非必要的检测,装箱的性能(尤其在可空类型与泛型上)将有不小的提高。

Boxing performance graph

虚拟泛用方法的调用

虚拟泛用方法是C#的特色表达,其应用较为困难。不同于直接调用,虚拟泛用方法在构建时并不会指明调用的目标,而是在运行时另行确定。

为了提高虚拟泛用方法和接口调用的性能,我们在Unity 2021.2中做出了一定的改进,如下例所示:

interface Interface { T GetValue<T>(); } class Base : Interface { public virtual T GetValue<T>() { return default(T); } } class Derived : Base { public override T GetValue<T>() { return default(T); } } private Base obj = new Derived(); private Interface iface = obj; public void CallToVirtualGenericMemberFunction() { obj.GetValue<int>(); } public void CallToGenericInterfaceMemberFunction() { iface.GetValue<int>(); }
Generic Virtual Method Performance

Enum.HasFlag

你可以为C#枚举类型加上[Flags] 特性来组成多种选项组合。Enum.HasFlag方法常用于在flags枚举中检测特定值。在Unity 2021.2中,该方法的调用速度被提高100倍以上。

像下方的这个基准测试:

public void CallToEnumHasFlag() { _enum.HasFlag(_flag); }

会将以下生成代码...

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void EnumHasFlag_CallToEnumHasFlag_m5819FE655D569D7AF856B879164E6416EFEFC30E (EnumHasFlag_t72757859AA4C348BBEE0A64FDBB747AFCFE326C2 * __this, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_runtime_metadata((uintptr_t*)&MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var); s_Il2CppMethodInitialized = true; }{ int32_t L_0 = __this->____enum_0; int32_t L_1 = L_0; RuntimeObject * L_2 = Box(MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var, &L_1); int32_t L_3 = __this->____flag_1; int32_t L_4 = L_3; RuntimeObject * L_5 = Box(MyEnum_t67450C4DBC081C689DA95C351B619398929DC7A1_il2cpp_TypeInfo_var, &)L_4); NullCheck((Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E2 *)L_2); bool L_6;L_6 = Enum_HasFlag_m15293B523AA7BA15272699C7304E908106AD7F7B((Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E *)L_2, (Enum_t2A1A94B24E3B776EEF4E5E485E290BB9D4D072E2 *)L_5, NULL) ; return; }}

转换成以下代码。

IL2CPP_EXTERN_C IL2CPP_METHOD_ATTR void EnumHasFlag_CallToEnumHasFlag_m5819FE655D569D7AF856B879164E6416EFEFC30E (EnumHasFlag_t72757859AA4C348BBEE0A64FDBB747AFCFE326C2 * __this,const RuntimeMethod* method) { { int32_t L_0 = __this->____enum_0;int32_t L_1 = L_0; int32_t L_2 = __this->____flag_1; int32_t L_3 = L_2; bool L_4 = il2cpp_codegen_enum_has_flag(L_1, L_3) ; return; }}

它将删去非必要的装箱和可空类型检测,让C++编译器能够更好地优化代码。在这个小基准测试里,代码的运行时间从162纳秒缩短成了1.18纳秒。

Enum.HasFlag Performance graph

限制性调用

限制性调用是C#的另一个实用功能。它们能以更低的性能成本完成虚拟方法的调用等常被认为过于繁重的操作。这是怎么做到的呢? 

它们本质上是一种调用方式的“提示”。IL2CPP现在支持抓取更多“提示”,将繁重的调用转换成轻便的直接调用。  

假设代码中有一个这样的值类型:

private struct SimpleValueType { }

以及另一个可在任意类型上调用System.Object下Equals虚拟方法的通用方法:

private static void Equals<T>(T t) { t.Equals(null); }

然后,类似下方的基准测试...

public void CallToEqualsValueType() { Equals(_simpleValueType); }

...将以快10倍的速度完成。

因为IL2CPP得知自己无须调用虚拟方法,而是可以直接进行调用。如你所见,限制性调用在基准测试中表现出了极大的改善。

Constrained Call performance graph

快来试试吧

我们鼓励大家使用Profiler来详细了解自己的脚本。文中所有的改进数据皆以基准测试中的表现为基准,在其他项目中可能会有出入。 并且,我们很期待听到大家的使用体验。请加入我们的Unity论坛 ,分享你自己的性能分析结果、做出的改进和一路上的挑战。

2022年2月17日 类别 技术 | 7 分 阅读
涵盖的主题