有很多关于
unity内存和性能优化的有用知识。一开始我的很多知识都参考了
Wendelin Reich和
Andrew Fray 的文章。他们的文章都很优秀,值得我们学习。
我希望在本文添加一些有趣的优化细节,以及我自己探索出的优化经验和如何改善使用引擎的性能。
下面的重点是在代码层面提升性能,例如看看不同的代码结构及它们执行时的速度和内存使用情况(其它一些提升也很有用,例如,优化资源,压缩纹理,共享材质,但这不是本文要阐述的,可能下一篇会揭晓)。
首先快速回顾一下内存分配和垃圾回收。
记忆犹新
游戏开发要学习的第一件事就是不分配不必要的内存。这样做有很充分的理由。第一,内存是一种有限资源,尤其是在移动设备上。第二,分配内存需要消耗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扩展。
(以下测量都是通过Unity的性能探查器,使用
unity 5.0.1独立版,英特尔 i7 桌面计算机。我知道这个有硬件限制会导致测试结果不准确,你也可以自己编写代码进行分析。)
// 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和其它有效的扩展,并经常使用。这些优化只在频繁处理数据或者处理大量数据时使用,但是,多数情况下并不必要。
最终,优化的标准做法是:首先,我们应该写好代码。然后是分析,只有那时再谈优化实际观察到的热点问题。因为,每个优化都牺牲了灵活性。
这是
unity的C#内存和性能优化技巧的第二篇。建议先阅读
第一篇了解更多背景内容。
在上一篇中,我们看了许多有关内存分配的惊人数字,比如foreach循环所产生的堆栈垃圾,数组,以及函数调用。
在堆中清理对象对性能有很大的影响,因此我们应该尽量少在运行时回收对象以减少内存的压力和垃圾回收对时间的消耗。
在上一篇发布后,很多人评论指出将struct或者enum储存在数组(List<>)中时,会导致Unity和Mono中由内存分配所造成的其它性能
问题,我用和上次相似的方法去测试了。以下是结果:
简而言之,有三个会造成意料之外的垃圾回收的地方值得我们注意:
- 结构体(structs)的数组
- 以结构体为key的字典
- 以enum为key的字典
稍后再细说。先看看怎么解决它们:
- 确保你的结构体实现了IEquatable<T >
- 确保你的结构体重写了Equals()和 GetHashCode()
- 给以enum作为key的字典添加自定义比较器
很简单吧,细节稍后再议。先来了解一点自动装箱的知识。
有点神奇
在深入分析结构体与内存分配之前,先来回忆一下装箱(boxing)和自动装箱(autoboxing)。如果你已经很熟悉.NET的这部分,可以略过这一节。
在.NET中数值类型和引用类型是有区别的。数值类型是一些基本的类型(int,float等等)和结构体,他们通过数值传递并且可以存在于堆栈中,这样会极大的提高创建和删除的速度。
引用类型(类的实例以及字符串) 是通过引用指针来传递的,存在于堆中并且在不需要的时候被“回收”。
当把堆栈中的值传递给一个引用类型如基类“Object”,方便起见这个数据会自动被复制到堆中(装箱),最终传递的是它的引用指针。
这里的最后一步-自动复制数据到堆中,就是造成我们大多数问题的原因。
这里举一个装箱的例子,想像这样的方法Object.Equals(object)。它接受一个引用类型作为参数,那么当我们试图传递一个数值类型时,它首先会被自动装箱(boxing)。
还有类似的装箱,比如下面的代码试图将一个数值类型转换为基类“Object”:
object obj = 1;
bool flag = obj.Equals(2);
这个代码如果编译成IL代码如下:
IL_0000:ldc.i4.1
IL_0001: box[mscorlib]System.Int32
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008:ldc.i4.2
IL_0009: box[mscorlib]System.Int32
IL_000e:callvirt instance bool [mscorlib]System.Object::Equals(object)
IL_0013: stloc.1
上面的IL有两个装箱的地方,首先是将int类型的1转换为object时,其次是将整数2传递给System.Object.Equals(object)。
通常autoboxing(自动装箱)非常方便并且不会消耗很多性能,但缺点是非常频繁的复制使得被打包后的引用类型对象在它们被创建和使用之后立刻就会被扔掉。
这些堆上积累的垃圾给垃圾回收机制带来了压力,因此必须停下来等待它被打扫干净。我们需要更好的办法来避免产生垃圾。
下面来看看几个数字吧,
首先,我们来试试搜索一个储存结构体的List,我们创建两个这样的结构体:
public structSmallStruct {
// 两个int变量,大小: 2 * 4 B + 8 B = 16 B
public int a, b;
}
public structLargeStruct {
// 20个int变量. 大小: 20 * 4 B + 8 B = 88 B
public int a, b, /* ... 更多 */;
}
如果你有C++背景并且熟悉STL,可能会想由于我们是强引用,有了这些信息编译器应该足够聪明来避免重复的装箱。
但不幸的是,运行的时候,结果是这样的:
mem alloc time
SmallStruct................... 4.0 MB ....... 28 ms
LargeStruct................... 22.0 MB ....... 70 ms
这是个惊吓,看上去Contains()给list中的每个元素都创造了两个而非一个临时的拷贝(每个结构体都是2*128k*16B =4MB)。
到底发生了什么?Contains()应该是一个很简单的函数,仅仅是把每个数组中的元素都比较一遍。但这有两个问题:
1 如果不给任何提示,编译器对所有的类都会调用相同的函数:ValueType.Equals(object)因此会对结构体进行装箱。
2ValueType.Equals()对结构体的内容一无所知,因此它必须要用反射来进行一一比较。
那么如果我们自己写一个Equals()会不会有所改善呢?
//List.Contains() over a list with 128K struct elements
// structsoverride Equals(), GetHashCode()
SmallStruct................... 2.0 MB ....... 12 ms
LargeStruct................... 11.0 MB ....... 26 ms
UnityEngine.Vector4........... 3.0 MB ....... 15 ms
(顺便提一下,我还加入了UnityEngine.Vector4这个结构体。由于它同样重写了Equals(),所以对它的操作也没有耗费太多时间。)
到此为止这个问题算是得到了解决,这些数字看起来很不错。现在当我们调用Contains()时仅仅把每个元素复制了一遍,很好!但我们需要的是有一个针对每个类型的Equals(T)函数而不是Equals(object),这样就能完全避免装箱了。
我们通过让结构体自己实现IEquatable<T >来解决这个问题。这是一个“神奇的”接口,泛型集合用其实例化不同类型,结果如下:
// List.Contains()over a list with 128K struct elements
// structsimplement IEquatable< T >
SmallStruct................... 0 B ....... 2 ms
LargeStruct................... 0 B ....... 8 ms
这看上去更不错了,通过实现这个接口,我们完全的避免了boxing并大大的节省了时间。
因此我们应该把所有可能会在数组(以及list)中使用的结构体都实现IEquatable<T >接口以避免降低性能。
又有麻烦了
把结构体作为字典的key同样带来了一个挑战。在下面的测试中,我们通过不断的调用Dictionary.ContainsKey()以增加对key比较的压力。
即使是一个空的结构体,被当作key来使用时性能也是出乎意料的差:
// dict is atiny Dictionary with just one entry
// 128K calls todict.ContainsKey()
SmallStruct................... 2.0 MB ....... 20 ms
LargeStruct................... 11.0 MB ....... 52 ms
我们再一次遇到了内存垃圾的问题。
同样,由于我们把结构体当做key,字典会使用默认的ValueType.GetHashCode()来获得hash,这些获取hash的方法可能不是最快的。
让我们来试试之前对list所用的方法:
// 128K calls todict.ContainsKey(SmallStruct)
plain................................... 2.0 MB ....... 20 ms
IEquatable< T> ......................... 2.0 MB ....... 20 ms
Equals(),GetHashCode() ................ 2.0 MB....... 14 ms
IEquatable T,Equals(), GetHashCode() .. 0 B ....... 2 ms
// 128K calls todict.ContainsKey(LargeStruct)
plain.................................. 11.0 MB ....... 52 ms
IEquatable< T> ........................ 11.0 MB ....... 52 ms
Equals(),GetHashCode() ................ 11.0 MB ....... 38 ms
IEquatable T,Equals(), GetHashCode() .. 0 B ....... 13 ms
像之前一样,自己实现能改善性能,无论是比较还是获取hashcode。
事实上,当实现自定义结构体时,最好完成下面这些:
1 通过IEquatable<T >来实现泛型比较函数
2用更快的(自定义)版本重写GetHashCode() 和 Equals()
3 同样的,可以重写==和!=改善比较的性能
你还可以自己实现IEqualityComparer并将其传进字典,以此避免在字典中有过多的装箱,但那需要基于各集合而非整个结构体。
但不幸的是,内置结构体如Vector3等并未完全这样做。因此当将它们储存在数组中时性能可能不是最好。
顺便提一下,你可能注意到我们总是同时重写Equals()和 GetHashCode(),为什么这么做呢?
这两个函数一定是可以和睦相处的。具体点,当两个对象是相同的时候,他们的hash code也一定是相同的。(试想如果不是这样,那可能永远都没法在数组中找到一个数据)
当实现这两个函数的时候,这有两个值得一提的规定,我打算说的简短一些。如果你想看更多内容,可以读一下Wagner的
Effective C#。
1 自定义的Equals()必须满足以下条件:
- 可反射的 (a == a 总是 true)
- 对称的(如果 a == b ,b == a)
- 有传递性的(如果 a == b 且 b == c 那么 a == c)
2 自定义的GetHashCode()必须满足:
- 如果a.Equals(b)那么它们的hash code必须返回同样的值。
- 如果对象被修改但Equals()的返回值不变,则hash code必须依然相同
- 实际上这对于引用类型非常重要,但对于被使用到数组中的结构体来说可能没那么重要,因为我们无法获得一个用于字典中的key的结构体指针。
同样,hash code应该是一些有符号的整数,为了性能。
我快要疯了
最后,我们来看看枚举(Enum)。
这是另一个有趣的来自C或C++的东西。在这些语言中,枚举可以与整数(int)进行转换。
在.NET中,enum同样是基于int的(你也可以选择其它的
内置类型)。不管怎样,因为是基于int所以属于数值类型,继承自ValueType父类。
让我们开门见山,你可能不会喜欢看这些数字:
// loop of 128Kiterations, a and b are enums
// enum does notimplement IEquatable< T >
1. a == b....................... - B ...... 0.1 ms
2. (int)a ==(int)b ............. - B ...... 0.1 ms
3. a.Equals(b).................. 3.0 MB ...... 24.0 ms
事实上,第一个和第二个生成的IL都是一样的:
// result = (a== b);
IL_0007: ldsfld valuetype TestEnumBaseEnumTest::a
IL_000c: ldsfld valuetype TestEnumBaseEnumTest::b
IL_0011: ceq
... 然而第三个则造成了更多的装箱:
// result =a.Equals(b);
IL_0007: ldsfld valuetype TestEnumBaseEnumTest::a
IL_000c: box TestEnum
IL_0011: ldsfld valuetype TestEnumBaseEnumTest::b
IL_0016: box TestEnum
IL_001b: callvirt instance bool[mscorlib]System.Enum::Equals(object)
当我们将它们用做字典的key时,得到了这些数字
// loop of 128klookups keyed by enum
//
// edict is oftype Dictionary
// idict is oftype Dictionary
1. result =edict[enum] .......... 4.5 MB ...... 40 ms
2. result =idict[int] ........... - B ...... 2 ms
已经够迷惑了,这没有造成装箱但当把enum作为字典的key时却依然有堆分配。
这可能不是很好,因为把enum作为词典的key非常方便。比如当试图把enum转换为可读性好的数组时。
幸运的是,我们有办法解决这个。两个办法,第一我们可以给enum实现自己的IEqualityComparer并将其传递给字典的构造函数,这样就可以避免装箱了。
第二个办法是将enum转换为int,然后使用int的字典,来看几个比较的例子:
// loop of 128klookups
// edict is oftype Dictionary
// newedict issame as edict, but with a custom IEqualityComparer
// idict is oftype Dictionary
1. result =edict[enum] ..................... 4.5 MB ...... 40 ms
2. result =newedict[enum] .................. - B ...... 2 ms
3. result =idict[(int)enum] ................ - B ...... 2 ms
再说一次其中的技巧是你已经知道会发生什么了所以提前做出应对。这两种办法都可以在每次调用节省交换带来的36B的垃圾。
运行游戏
希望这篇文章对于数值类型和通用数组做出了实用的讲解。
关注这方面的人不多且没有太深的研究,但它十分有趣而且实用,尤其是对于我们这种从C或C++转为C#的程序员,enum、struct和数据结构看起来非常相似却完全不一样。