当前位置:丝路教程网 > Unity3D >

Unity C#内存和性能优化技巧

2016-11-04 10:10 来源: 丝路教程网 分类: Unity3D
391 作者:丝路教程网

[导读] 游戏开发要学习的第一件事就是不分配不必要的内存。这样做有很充分的理由。第一,内存是一种有限资源,尤其是在移动设备上。第二,分配内存需要消耗CPU周期(在堆上分配和回收都

  游戏开发要学习的第一件事就是不分配不必要的内存。这样做有很充分的理由。第一,内存是一种有限资源,尤其是在移动设备上。第二,分配内存需要消耗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[]的for、foreach循环。每种里面都使用一个Linq扩展。

  // const SIZE = 16 * 1024 * 1024;

  // array is an int[] // list is a List

  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> . 60 ms ….. 0 B

  2a. foreach on array[][] ……….. 35 ms ….. 0 B

  2b. foreach on list> …. 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中查看代码,每个像 “Funcfn = 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 ints = new List();

  for (…) { ints.Add(something); }

  作为实现细节,当使用默认构造函数分配List时,List会非常小(即仅仅分配一个只有少量元素的内部存储,例如4)。当超出list大小时,会重新分配一块更大的内存,并将数据复制到新分配内存。

  因此,如果游戏需要创建一个list并加入大量元素,最好像下面这样指定list的容量。甚至可以多分配一点以避免不必要的重置大小和重新分配内存。

  List ints = newList(expectedSize);

  List另一个有趣的副作用是,即使当清除list时,list不会释放分配的内存(例如,容量保存不变)。如果list中有许多元素,调用Clear()时内存也不会被释放,而仅仅只是清除数据内容并设置为0。同样,增加新元素时list也不会分配新的内存,直到容量用完。

  和第一个小技巧相似,如果函数需要在每一帧填入并使用一个大量数据的list,一个猥琐却很有效的优化技巧是,在使用之前预先分配好list,然后维护重用并在每次使用之后清除数据,从而不会引起内存的重新分配。

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

  值得吗?

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

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

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


免责声明:

丝路教程网的部分文章信息来源于网络以及网友投稿,本网站只负责对文章进行整理、排版、编辑,是出于传递更多信息之目的,如权利人发现存在误传其作品情形,请及时与本站联系。

Unity C#内存和性能优化技巧

的相关文章
Copyright © 2008-2017 blog.silucg.com 丝路教程网 版权所有 网站地图
点击这里给我发消息
丝路教育