第一篇:IL2CPP优化(上):去虚拟化

第二篇:IL2CPP优化(中):虚方法调用

这是IL2CPP小优化系列的最后一篇,我们将探讨一种高消耗的操作“装箱”,并看看IL2CPP如何避免不必要的装箱。

堆内存分配很慢

如同大多数编程语言,C#允许对象内存分配在栈(Stack)上(一种小而“快”,限定范围的内存块)或者是堆(Heap)上(一种大而“慢”,全局内存块)。通常在堆(Heap)上分配空间要比在栈(Stack)上分配空间消耗更高。此外它还牵涉在垃圾回收器上追踪已分配的内存,这也带来了额外的消耗。因此我们要尽量避免堆内存分配。

C#中可以通过将各种类型区分为“值类型”(可以在栈(Stack)上被分配)和“引用类型”(必须在堆(Heap)上分配)来避免出现堆内存分配。一般像int和float这样的为值类型,而string和object则为引用类型。用户定义值类型使用struct关键字,而用户定义引用类型则使用class关键字。

作为追求优秀性能的开发者,除非必要情况,我们要尽量避免堆内存分配。

但有些时候我们需要将栈(Stack)上的值类型转换为堆(Heap)上的引用类型,这个过程被称为“装箱”,它具有以下特性:
在堆(Heap)上分配空间 通知垃圾回收器有关新对象的信息 复制值类型对象中的数据并传递给新的引用类型对象


把装箱拉进黑名单吧!

那个讨厌的编译器

我们正开心地写着代码,并且规避了不必要的堆内存分配以及装箱。可能这个程序世界中有一些树木,每一棵树的尺寸都因年龄不同而有所区别:
[C#] 纯文本查看 复制代码interface HasSize { int CalculateSize(); } struct Tree : HasSize { private int years; public Tree(int age) { years = age; } public int CalculateSize() { return years*3; } }

在代码的某个地方,有一个方便的办法可以统计很多东西的尺寸(包括Tree对象):
[C#] 纯文本查看 复制代码public static int TotalSize<T>(params T[] things) where T : HasSize { var total = 0; for (var i = 0; i < things.Length; ++i) if (things[i] != null) total += things[i].CalculateSize(); return total; }

这样看起来似乎足够安全了,但是让我们仔细观察C#编译器生成的中间语言(IL)的部分代码:
[C#] 纯文本查看 复制代码// This is the start of the for loop // Load the array IL_0009: ldarg.0 // Load the current index IL_000a: ldloc.1 // Load element at the current index IL_000b: ldelem.any !!T // What is this box call doing in here?!? // (Hint: see the null check in the C# code) IL_0010: box !!T IL_0015: brfalse IL_002f // Set up the arguments for the method and it call IL_001a: ldloc.0 IL_001b: ldarg.0 IL_001c: ldloc.1 IL_001d: ldelema !!T IL_0022: constrained. !!T IL_0028: callvirt instance int32 Unity.IL2CPP.IntegrationTests.Tests.ValueTypeTests.ValueTypeTests/IHasSize::CalculateSize() IL_002f: // Do the next loop iteration...

C#编译器使用了装箱来实现if (things != null)语句的检测!如果T已经是一个引用类型,那么装箱的操作还是挺划算的,因为它只是返回了数组元素的现有指针。但如果T是一个值类型(比如Tree),那装箱的过程将消耗“巨大”。当然,值类型不可能是null,所以为什么要在一开始执行检查呢?并且如果需要计算一百棵树的大小呢,甚至是一千棵树呢?这种不必要的装箱也变得“尤为”关键。

最快的代码就是不执行的代码

C#编译器需要为任何可能类型的T提供一个大致的实现。所以它必须要用这一段比较缓慢的代码。但像IL2CPP这样的编译器可以更激进一些,当它生成代码时就立即执行,不生成代码就不执行!

IL2CPP会实现一个名为TotalSize<T>的方法来专门针对其中T是Tree对象的情况,上面的IL代码生成的C++代码如下:
[C++] 纯文本查看 复制代码IL_0009: // Load the array TreeU5BU5D_t4162282477* L_0 = ___things0; // Load the current index int32_t L_1 = V_1; NullCheck(L_0); IL2CPP_ARRAY_BOUNDS_CHECK(L_0, L_1); int32_t L_2 = L_1; // Load the element at the current index Tree_t1533456772 L_3 = (L_0)->GetAt(static_cast<il2cpp_array_size_t>(L_2)); // Look Ma, no box and no branch! // Set up the arguments for the method and it call int32_t L_4 = V_0; TreeU5BU5D_t4162282477* L_5 = ___things0; int32_t L_6 = V_1; NullCheck(L_5); IL2CPP_ARRAY_BOUNDS_CHECK(L_5, L_6); int32_t L_7 = Tree_CalculateSize_m1657788316((Tree_t1533456772 *)((L_5)->GetAddressAt(static_cast<il2cpp_array_size_t>(L_6))), /*hidden argument*/NULL);

IL2CPP意识到装箱操作对于一个值类型来说是不必要的,因为我们可以提前证明值类型的对象永远不可能是null。在一个封闭的循环中,删除这种不必要的分配和数据拷贝会对性能带来明显改善。

正如本系列讨论的小优化一样,这是.NET代码生成器常见的优化,所有Unity目前使用的脚本后端都将执行此优化,这样您就可以放心写代码了。

我们希望您喜欢这个关于小优化的系列文章。我们会不断改善Unity使用的代码生成器和运行时,向您提供更深入的底层小优化。
Unity, IL2CPP, 编译器优化锐亚教育

锐亚教育 锐亚科技 unity unity教程