Unity教程之-Unity C#内存和性能优化技巧一

 

游戏开发要学习的第一件事就是不分配不必要的内存。这样做有很充分的理由。第一,内存是一种有限资源,尤其是在移动设备上。第二,分配内存需要消耗CPU周期(在堆上分配和回收都消耗CPU周期)。第三,在C或C++中手动管理内存,每次分配内存都是引入Bug的契机,Bug会引起严重问题,任何地方的内存泄露都会引起崩溃。

Unity使用.Net或者可以说是一个开源替代品Mono。它的自动内存管理解决了大量安全问题,例如,不能在内存被释放后再使用(忽略了不安全代码)。但是,分配和释放内存变得更加难以预测。

假设你已经很了解栈分配和堆分配的区别。简而言之,堆栈数据生存周期比较短,分配/释放几乎不会消耗CPU。堆数据生命周期比较长,分配/释放消耗多些,因为,内存管理器需要跟踪内存分配。在.Net和Mono种,堆内存通过垃圾回收器自动获取。实际上,可以说是个黑盒,用户无法对其进行很多控制。

.Net的两种数据类型分配方式不同。实例的引用类型总是在堆上分配,然后被GC回收,例如,类、数组(如int[])。数据的值类型在堆栈分配,除非他们的容器已经在堆上(如数组结构),例如基本类型(int,char等)或者结构体实例。最后,值类型可以通过传递引用而从堆栈数据变成堆数据。

好了,开场结束。让我们谈谈垃圾回收和Mono。

罪过

找到并回收堆上数据是GC的工作,不同的回收器在性能上差异很大。

旧的垃圾回收器因为会产生帧率问题而臭名昭著。例如,一个简单的标记-清除回收器(阻塞回收器),会暂停整个程序,以便一次处理整个堆。暂停时间取决于程序分配的数据数量,如果暂停时间很长,会产生长时间无响应。

新的垃圾回收器在减少回收暂停方面有不同的方法,例如,现代GC通过在同一位置对所有最近分配进行分组,这样就可以扫描并快速收集被拆分的小块。因为,很多程序喜欢分配可以快速使用和丢弃的临时对象,将它们放在一起管理,有助于GC更快的响应。

不幸的是,Unity并不支持这些功能。Unity使用的是Mono 2.6.5版本,其GC是旧版的Boehm GC,不属于现代GC。我相信,也不支持多线程。最新版本的Mono已经有了更好的垃圾回收器,然而,Unity并没有升级。反之,他们正在计划使用其它方法来替代。

虽然这听起来像是一个令人兴奋的改进,但现在我们不得不忍受Mono 2.x 和旧的GC一段时间。

换句话说,我们需要最小化内存分配。

机会

每个人的首要建议都是使用单元数组时用for循环取代foreach循环。这很令人惊讶,foreach循环是代码更加可读,为什么我们要摆脱foreach呢?

原因是foreach循环在内部创建了一个新的枚举实例,foreach循环用伪代码表示如下:
foreach (var element in collection) { … }
编译之后如下:
var enumerator = collection.GetEnumerator();
while (enumerator.MoveNext()) {
var element = enumerator.Current;
// the body of the foreach loop
}
这有下面几个后果:
1.   使用枚举意味着需要额外的函数调用来遍历集合
2.   另外:Unity附带的Mono C#编译器有个Bug,foreach循环在堆上抛出一个对象,以至于GC在之后才会清理 (更多细节见this discussion thread)。
3.   编译器不会尝试把foreach循环优化成for循环,即使是简单的List集合(除了一个特殊优化,就是Mono把通过数组使用的foreach转化为for循环)。

让我们比较一下拥有16M元素的List<int>和int[]的for、foreach循环。每种里面都使用一个Linq扩展。
// const SIZE = 16 * 1024 * 1024;
// array is an int[] // list is a List<int>
1a. for (int i = 0; i < SIZE; i++) { x += array; }
1b. for (int i = 0; i < SIZE; i++) { x += list; }
2a. foreach (int val in array) { x += val; }
2b. foreach (int val in list) { x += val; }
5.    x = list.Sum(); // linq extension
time   memory
1a. for loopover array   …. 35 ms     …. 0 B
1b. for loopover list      ….. 62 ms     …. 0 B
2a. foreach overarray    ….. 35 ms     …. 0 B
2b. foreach overlist .     …. 120 ms    … 24 B
3. linq sum()                 …………271 ms       … 24 B

显然,通过数组大小的for遍历用的时间更少。(通过数组大小的foreach遍历进行了优化,所以,和for遍历时间相同)。

但是,为什么通过list遍历的for循环要比通过数组遍历慢呢?这是因为访问list元素需要通过函数调用,因此,比数组访问要慢一些。如果,我们通过ILSpy这种工具查看这些循环的IL代码,我们可以看见“x += list”已经被编译为“x += list.get_Item(i)”的函数调用。

Linq Sum()扩展最慢,查看其IL代码,Sum()主体本质上是一个foreach循环,看起来像“tmp = enum.get_Current();x = fn.Invoke(x, tmp)”,其中fn是一个加法函数的委托实例。难怪会比for循环慢一些。

现在我们看看其它方面的比较。这次二维数组的大小是4K,list也是4K。分别使用for循环和foreach循环,结果如下:
time    memory
1a. for loops over array[][] ……  35 ms ….. 0 B
1b. for loops over list<list<int>> . 60 ms ….. 0 B
2a. foreach on array[][] ……….. 35 ms ….. 0 B
2b. foreach on list<list<int>> …. 120 ms …. 96 KB <– !

不出意外的话,结果和上一次差不多,但是,这里重要的是foreach循环浪费了多少内存:(1 + 4026)x 24 byteseach ~= 96 KB。想象一下,如果你在每一帧都使用这样的循环的话,会浪费多少内存!

最后,在紧凑循环或循环遍历大的集合时,数组比其它通用集合性能更好,for循环比foreach循环性能好(执行时间,浪费内存方面)。
我们可以通过降级为数组来改进性能,更别提内存分配上的改善。

除了循环和大型集合,其它数据结构并没有太多差别(foreach循环和普通集合简化了编程逻辑)。

这些数据是怎么得到的

一旦我们开始查找,我们可以在各种奇怪地方发现内存分配。例如,调用具有可变参数的函数,实际上会在堆上分配一个临时数组来存放这些参数(有C开发背景的人会感到一些意外)。让我们看看操作一个256K的循环体,返回最大数字:
1. Math.Max(a, b) ……… 0.6 ms ….. 0 B
2. Mathf.Max(a, b) …….. 1.1 ms ….. 0 B
3. Mathf.Max(a, b, b) …… 25 ms … 9.0 MB <– !!!

传入三个参数调用Max意味着调用的是可变参数的”Mathf.Max(params int[] args)”,每次的函数调用将会在堆上分配36字节(36B * 256K = 9MB)。

另外一个示例,让我们看看委托。解耦合和抽象时委托非常有用,但是委托有个意外行为:将委托分配给局部变量也会引起装箱操作(堆上传递数据)。甚至是仅仅把委托存储在一个局部变量中也会引起堆分配。

下面是一个在紧凑循环中进行256K次函数调用的例子。
protected static int Fn () { return 1; }
1. for (…) { result += Fn(); }
2. Func fn = Fn; for (…) { result += fn.Invoke(); }
3. for (…) { Func fn = Fn; result += fn.Invoke(); }
1. Static function call ……. 0.1 ms …. 0 B
2. Assign once, invoke many … 1.0 ms … 52 B
3. Assign many, invoke many …. 40 ms … 13 MB <– !!!

在ILSpy中查看代码,每个像 “Func<int>fn = Fn”这样的局部变量赋值都会在堆上创建一个新的委托类Func 的实例,然后占用的52字节立即会被丢弃,但是,编译器还不够智能到把这些局部变量放到循环体之外以节约内存。

这让我很焦虑。Lists或者dictionaries委托会是什么样的呢?例如,当执行观察者模式或者一个handler函数的dictionary时,如果通过迭代反复调用每个委托会引起大量混乱的堆分配吗?

让我们试试通过一个256K大小的List<>迭代并执行委托:
4. For loop over list of delegates …. 1.5 ms …. 0 B
5. Foreach over list of delegates ….. 3.0 ms … 24 B

至少通过循环遍历List委托不会重新装箱委托,可以通过IL确认。

生活本是如此

还有很多的随机最小化内存分配的机会,简而言之:
·        UnityAPI有些地方希望用户为属性分配一个数组结构,例如在Mesh组件:
void Update () {
// new up Vector2[] and populate it
Vector2[] uvs = MyHelperFunction();
mesh.uvs = uvs;
}
不幸的是,如之前所述,一个局部值类型数组会引起堆分配,即使Vector2 是值类型,该数组仅仅只是一个局部变量。如果这段代码在每一帧执行,每次创建一个24B新数组,再加上每个元素的大小(假设Vector2每个元素大小为8B)。

有个修复办法,但是有些不好看:维护一个合适大小的list并重复使用。
// assume a member variable initialized once:
// private Vector2[] tmp_uvs;
void Update () {
MyHelperFunction(tmp_uvs); // populate
mesh.uvs = tmp_uvs;
}
这很管用,因为Unity API属性设置器将默默地生成一个传入数据的数组副本,而不是引用数组(和想象中有些不同)。所以,始终没有生成临时复制的时间点。

因为数组不能被重置大小,所以,常常使用List<>添加或者移除元素,例如:
List<int> ints = new List<int>();
for (…) { ints.Add(something); }
作为实现细节,当使用默认构造函数分配List时,List会非常小(即仅仅分配一个只有少量元素的内部存储,例如4)。当超出list大小时,会重新分配一块更大的内存,并将数据复制到新分配内存。

因此,如果游戏需要创建一个list并加入大量元素,最好像下面这样指定list的容量。甚至可以多分配一点以避免不必要的重置大小和重新分配内存。
List<int> ints = newList<int>(expectedSize);
List另一个有趣的副作用是,即使当清除list时,list不会释放分配的内存(例如,容量保存不变)。如果list中有许多元素,调用Clear()时内存也不会被释放,而仅仅只是清除数据内容并设置为0。同样,增加新元素时list也不会分配新的内存,直到容量用完。
和第一个小技巧相似,如果函数需要在每一帧填入并使用一个大量数据的list,一个猥琐却很有效的优化技巧是,在使用之前预先分配好list,然后维护重用并在每次使用之后清除数据,从而不会引起内存的重新分配。

最后,简短说明一下字符串。Strings在C#和.Net中是不可变对象。因此,string在堆上生成新的实例。当我们把多个组件的字符串集合一起时,通常最好使用StringBuilder,它拥有内部字符缓冲区可以最终创建一个新的字符串实例。任何实例化代码都是单线程的、不可重入。即使是共享一个静态builder实例,在调用之间重置,那样才可以重用缓冲区。

值得吗?

我在收集所有这些优化技巧时受到一些企发,通过挖掘、简化代码摆脱了一些非常烂的内存分配。在特别坏的情况下,仅仅因为使用了错误的数据结构和迭代器,一帧分配了约1MB的临时对象。在移动设备上面缓解内存压力更加重要,因为纹理内存和游戏内存必须共享非常有限的内存池。

最后,这些技巧并不是一成不变的规则,只是一些优化时机。实际上我非常喜欢使用Linq,foreach和其它有效的扩展,并经常使用。这些优化只在频繁处理数据或者处理大量数据时使用,但是,多数情况下并不必要。

最终,优化的标准做法是:首先,我们应该写好代码。然后是分析,只有那时再谈优化实际观察到的热点问题。因为,每个优化都牺牲了灵活性。好了,本篇unity3d教程关于Unity C#内存和性能优化技巧到此结束,下篇我们再会!