[2015-06-29] 补:有同学问手机上代码横向显示不下的问题。可以向左滑动对应的代码块,就能看到右边的部分。不过的确不如 PC 上方便 :)
这两年,游戏行业的重心逐渐迁移到了手游,引擎开发工具大有被 Unity 席卷之势,其掀起的声浪可谓是横扫了当年曾在国内风光一时的 Unreal / Gamebryo / OGRE 等诸前辈。好了,废话不多说,今天我们简单地聊一下 Unity 项目中的一些具体的实践。
"Unity can only be manifested by the Binary. Unity itself and the idea of Unity are already two."
- Buddha
高精度计时器
在日常开发中,出于测试或优化的需要,我们常常需要使用比毫秒数更精确的计时器。因此在深入之前,我们先了解一下如何在 Unity 平台上获取足够的计时精度。
我们知道,在传统的 Win32 平台上,游戏开发者通常使用 QueryPerformanceFrequency 和 QueryPerformanceCounter 来计时,这两个函数依赖 rdtsc 指令来获取时钟周期数,能得到相对比较精确的计时结果。而在 .Net 平台上,System.Diagnostics.StopWatch 的功能与这两个函数类似,在 MSDN 文档中有提到:
The Stopwatch class assists the manipulation of timing-related performance counters within managed code. Specifically, the Frequency field and GetTimestamp method can be used in place of the unmanaged Win32 APIs QueryPerformanceFrequency and QueryPerformanceCounter.
也就是说 StopWatch 在可能的情况下,会使用与 QueryPerformanceFrequency/QueryPerformanceCounter 类似的机制来达到与其相当的精度。
这里同时还提到 StopWatch 的几个有趣的接口:
- 可以通过
Stopwatch.IsHighResolution
来判断当前是否为高精度 - 可以通过
StopWatch.Frequency
来获取当前的时钟频率 - 可以通过
StopWatch.GetTimestamp()
来获取当前的时间戳
我们在 Unity 中简单验证一下这些接口在 Mono 2.x 上的可用性:
|
|
输出的结果是
|
|
这里可看出几个有趣的现象:
- 高精度计时是开启的,在 Mono 2.x 上是可用的
- 频率被归一化为 1e7 了,而非返回实际的 CPU 频率,这个的好处是 ElapsedTicks 从周期数变为了一个有逻辑意义的时间计量,这也就是在说,StopWatch 提供的计时服务__最高精度为 0.1 微秒(也即 100 纳秒)__
- 时间戳
GetTimestamp()
可用,而且返回的值是一个有效的 64 bits long (大于 2^32),也就是基本不用担心溢出回绕的问题
我在 StopWatch 上封装了一个简单的 Timer 类,用法如下:
|
|
输出如下:
|
|
这个工具 SSTimer 的代码在这里。后面的性能相关的对比数据,均由此计时器统计而来。
Mono/C# 代码实践
for / foreach 问题
这个问题之前有一些争论,不过这里 (“Should you avoid foreach loops?” 一节的最后的 [EDIT] 补充部分)和这里 (见 @王建飞 和 @权然 的回答) 已经说得很清楚了。我自己也分别看了不同情况下 VS 和 Mono 编译出来的 il 代码,肯定了他们的观点,在这里简单归纳一下结论吧:
- 直到 Unity 5.0.1 (说好的 5.x 修复呢?) 为止,如果你的代码用 Unity 自带的 Mono 编译器,无论使用的是标准容器 (自带 struct-enumerator 优化) 还是自定义容器 (手动 struct-enumerator 优化),都无法避免 foreach 展开后经由 GetEnumerator() 所获取出的 struct-enumerator 产生的一个额外的 boxing 动作 (及对应的内存分配)。简单地说,由 Unity 自带编译器编译的代码,建议不要使用 foreach。
- 然而,如果你的代码使用 VS 的 C# 编译器以目标为 “Unity 3.5 .net Subset Base Class Libraries” 编译出 dll,并把此 dll 放在项目的 Assets 目录下供 Unity 直接使用的话,就可以得到 struct-enumerator 优化所带来的好处,无需担心额外开销。也就是说,以 dll (由 VS 编译) 方式使用的代码,可以放心用 foreach。
- 再补充一点我实测的,如果是数组的遍历,在这两种方式下都不会产生额外的开销,而且在 il 中不创建 enumerator,也就没有对应的 MoveNext() / Dispose() 调用,这样连 try / finally block 也不再生成,生成的代码短了不少。也就是说,数组使用 foreach 没有任何限制,而且遍历效率较容器要高。(使用上面的高精度计时器测得:遍历百万元素的数组 (int[]),列表 (List
) 和字典 (Dictionary<int, int>) 分别耗时 6.263ms / 32.65ms / 32.385ms,后两者耗时是数组的 5 倍多,所以能用数组就尽量用数组吧)
相关的对比测试的代码在这里。
lambda 表达式 vs. 闭包 (closure)
我们知道,闭包 (closure) 本质上是包含了"外部"变量状态的 lambda 表达式。从这里 (见 “Should you avoid closures and LINQ?” 一段)可以知道,lambda 表达式能够被编译和替换为对应类的一个静态字段,而 closure 由于储存了额外的修改状态,编译器需要创建一个新类来表示和引用,由于C#有比较丰富的类型信息,不仅创建时开销比前者高,而且也隐含着 100 字节以上的内存开销。
简单地说,跟闭包相比,lambda 表达式要轻量得多,可以放心使用。
但是这里才是真正的问题——由于普通的 lambda 表达式和闭包长得非常相似,实践中如果不小心,是非常容易弄混淆的。不信的话,试试判断一下,下面这几个函数中的 func
,有几个会被 Unity 认为是闭包?
|
|
如果你能无需思考地给出答案,那么还请受俺真心的一拜,佩服佩服。
C# 下的闭包与普通的 lambda 表达式难以区分的原因是,C# 的闭包不像 C++ 那样需要显式地指明自己引用的外部变量。C++ 通过所谓的方括号“捕获”语法 ( square brackets capturing ) 来声明一个闭包,非常清晰地指明自己需要的外部环境,比如:
|
|
这里的方括号显式地以引用方式“捕获”了 total
作为闭包的一部分,而 C# 把这个简化成由编译器推导了。这里俺觉得还是 “When in Doubt, Be Explicit” 好一些。
嗯,刚才的练习俺就卖个关子,不给出答案了。代码在这里,感兴趣的同学运行这些代码所在的测试 Unity 工程,一试便知。
关于 lambda 表达式和闭包,这次先讲这么多吧。要点是__保持短小__即可。对于这种嵌套的逻辑,随着代码量的增加,可读性会急剧下降,就会损失局部定义的好处。
枚举项的 ToString() vs. Enum.GetName()
简单说一下吧。有种说法是说不要使用枚举的 ToString() 来获取单个枚举项对应的字符串,应该使用 Enum.GetName(typeof(Foo), Foo.Bar)
据说后者的速度是前者的两倍。
我实测了一下,一个含有 10 个枚举项的枚举 FooTypes
在遍历并转换每一项 10000 次的结果是:
- ToString() 耗时 553.817ms
- Enum.GetName() 耗时 437.2ms
测试中的差距并不十分显著,所以结论是__直接使用枚举项的 ToString() 并无不妥,可读性亦较 Enum.GetName 更佳__。但有一点要注意的是,这种字符串化的绝对开销不低,算下来转 1000 次会花 5ms 左右,这个开销感觉对游戏来说已经比较高了。
具体的测试代码在这里。
使用 “as” 转型 vs. 使用 C-Style Cast
同上。按照这里的说法:
“When casting a variable use the post fix “as type” instead of pre fixing with (type) as this is faster.”
直接说测试结果吧,实测百万次的有效转型的结果是:
- as 方式耗时 5.287ms
- C-Style 方式耗时 4.640ms
同样是差距并不显著,而 as 方式甚至更慢一点。这是为什么呢?查阅 MSDN 可以在这里看到,
|
|
实际上等价于
|
|
也就是说,对于有效转型,“as” 的开销 = “is” 的开销 + “c-style cast” 的开销。这样就解释了前面的测试结果。
结论,在 使用 “as” 还是 C-Style Cast 这个问题上,不要考虑性能影响,应按照它们各自提供的功能去选择。进一步说,如果你需要自动地把错误的转型以 null 为结果返回的话,使用 “as” 关键字;如果你认为这个转型足够重要,不希望别人由于忘记检查返回值导致没有处理该关键错误,那么就需要使用 C-Style Cast,这种方式会在转型失败时抛出 InvalidCastException 异常。多说一句,在 Unity 的框架内即使不处理,这个异常也会被 Unity 吞掉,然而我个人对这种不管三七二十一都一网打尽的方式持保留意见。
具体的测试代码在这里。
矩阵优化 (I) - 矩阵乘法
在 UnityEngine.dll
的 struct Matrix4x4
定义 (网页版在这里) 中,我们可以看到 Unity 的标准矩阵相乘函数的定义如下:
|
|
那么它的性能具体如何呢?我简单地测试了一下,在 AMD 的 3.5G 八核处理器上,百万次矩阵相乘的时间为 339.681 (ms)。
operator *
是 Unity 引擎提供给用户的标准矩阵相乘的接口。对于一个典型的 3D 游戏来说,这个操作可能会被频繁地用到,因此对其进行适当的优化还是很有必要的。下面我们一起来看看,对矩阵相乘操作可以做哪些优化尝试,具体效果又能达到什么程度。
为了方便,operator *
的版本,下面我们简单地称为__官方版__。
优化版本 1 - brute-force (耗时 1596.76%)
首先,我们实现一个拿衣服版本的矩阵相乘 Mul_v1_naive:
|
|
这个是很直接的教科书式的矩阵相乘运算。这个运算告诉了我们如下的信息:
- 把拿衣服版本的运算结果与官方版做值比较,得到了一致的结果——这说明了官方版的行为与我们的期望完全一致。
- 把运算的开销与官方版比较,不出所料,拿衣服版本够慢的,百万次耗时为 5413.076 (ms) 左右,是官方版的 15 倍。
优化版本 2 - 循环展开 (耗时 100.54%)
拿衣服的 v1 版本有三重循环嵌套,光是肉眼看起来就很慢。那么我们把循环展开一下,写个 v2 测一下看看有什么变化。
这是 Mul_v2_naive_expanded:
|
|
这个版本跑下来已经跟官方版很接近了,上文提到官方版百万次是 339.681 (ms),而 Mul_v2_naive_expanded 的开销是 341.534 (ms),是前者耗时的 100.54%。有理由推断,官方版的 operator *
或多或少就是如此实现的。
优化版本 3 - 使用 ref 处理参数和返回值 (耗时 25.52%)
我们注意到,由于 Matrix4x4
是一个 struct,参数传递时是传值而不是传引用,这意味着每次调用会产生三次矩阵的复制,算下来是 16 * 3 个 float 也就是 192 bytes 的复制。我们改为 ref 来消除这些无谓的复制,就得到了 v3 的实现。
这是 Mul_v3_ref:
|
|
这个版本的耗时为 86.703 (ms),缩短到了官方版的 1/4 左右。
可见 “复制 192 字节” 所花的时间至少是 “64 次乘法 + 48 次加法” 的三倍 (“至少"二字是考虑到函数调用的开销),所以减少无谓的复制还是蛮重要的,呵呵。
优化版本 4 - 利用 3D 变换矩阵的特点 (耗时 20.97%)
我们知道,在 3D 矩阵的变换中,最后一行是 (0, 0, 0, 1) 不会变化,如下图:
所以可以省掉这一部分冗余运算,得到 Mul_v4_for_3d_trans:
|
|
这个版本的耗时为 71.062 (ms),约是官方版的 1/5 。
这里拿矩阵相乘做优化实际上是举个栗子,旨在说明在实践中__避免不必要的拷贝__的重要性。 至于何时使用 struct,何时使用 class,何时需要使用 ref 加持的 struct,相信大家能够根据具体的情况去更好地判断。
矩阵优化 (II) - 变换矩阵的缓存
这里我们还是拿矩阵来举例子吧。下面是一个典型的变换到摄像机空间的操作 (为简化讨论仍使用官方版的乘法):
|
|
这里的运算本身并无不妥,但通常我们做这种变换的时候,是对一系列输入矩阵做变换,如下:
|
|
这时问题就来了,Camera.main.worldToCameraMatrix
看起来只是获取对象的只读属性,但实际上是对一系列复杂表达式的求值,有时甚至还涉及到 safe/unsafe 的切换。
那么反复对这个表达式求值,就值得被从循环中提取出来,如下所示:
|
|
经过测试,提取前后,百万次变换的开销分别为 977.077 (ms) / 338.626 (ms)。可以看出,后者本质上就是百万次的矩阵相乘操作,与上边官方版的矩阵相乘的运算开销 (339.681 (ms)) 非常接近。而前者花了__近3倍__的运算时间,其中一大半都在对 Camera.main.worldToCameraMatrix
表达式求值。
这个例子告诉我们,C# 的属性 (Property) 是非常有欺骗性的,可能内部隐藏了使用者难以预计的运算开销。这种欺骗性较 C++ 中的赋值和拷贝构造内的隐含逻辑更甚,因为后者毕竟有该类对应对象的尺寸作为参考,如果对象的尺寸偏大,我们理所当然地认为它的赋值会更费。而 C# 中的 Property 如果看不到代码,唯一可资参考的就是该 Property 的类型了,可是“某个属性的类型是什么”,与“它是怎么被计算出来的”,本质上是没啥关系的,所以欺骗性要强得多。
补充一句,当我们使用没有代码的实现时,更要加倍留意这种隐藏的陷阱。
这个测试的代码可以在这里看到。
其他的一些零碎常识
还有一些常识,简单归纳一下:
- 利用 string 的 immutable 特性,在内存中单一实例 (Interning) 的特性
- 利用 string 的比较性能好 (当引用方式为 object 时进行地址比较) 的特性
- 在需要时使用 StringBuilder
- 利用好容器的 Capacity 来优化内存访问
- 利用 ref 和 struct 来把堆 (heap) 上的访问往栈 (stack) 上挪
- 避免使用 LINQ 来降低零碎的内存分配
这些常识稍有经验的 C# 程序员应该都很熟悉,就不一一赘述了。
内存相关的实践
Mono 2.6.5 的 GC 特性和应对方案
直到目前我手头上的 Unity 5.0.1 为止,其内含 Mono 始终停留在 “2.6.5.0” 上 (特定 Unity 版本可查看 “Editor\Data\Mono\lib\mono\2.0\mscorlib.dll” 内的 Consts.MonoVersion
得知)。这个版本的 Mono 使用的 GC 仍是较老的 Boehm garbage collector。
这里先简单说一下 Boehm GC 实现的一些特点:
- 基于 Mark/Sweep,无分代/并行
- 执行时所有线程阻塞 (Stop-The-World)
- 堆越接近满的状态,执行得越频繁
- 每次标记都会扫描访问到所有可到达的对象
- 标记阶段 (Mark Phase) 的性能数据 (仅作为参考)
- 在小对象的情况下,1.4GHz Itanium 能达到 500MB/Sec 的速度
- 每个对象 90 个时钟周期左右 (大量时间是 cache-missing 所致)
- 算下来每秒 15M 数目的对象,也就是__每毫秒标记 15000 个__左右
- 清除本身开销很小,但 Finalization 较耗时 (取决于对象的 finalizer)
具体到 Mono 的实现中,有以下这些需要注意的地方:
- (Mono 实现) 无法精确地读取寄存器和栈,且无法区分一个给定值是指针还是标量,这会造成大块的内存无法正常回收,而且难以压缩空闲列表
- (Mono 实现) 碎片化会导致直接的新堆分配,即使空间仍充足(也就是说 Mono 没有做 Copy Optimization 相关的 Defragmentation)
- (Mono 实现) Mono 的 Finalizer 运行在独立的线程上,因此 GC.Collect() 和 obj.Dispose() 是需要线程同步的。
- (Mono 实现) 由于第一条,GC.Collect() 不会处理栈,寄存器,静态变量(这些东东被称为所谓的 “Roots”)
- (Mono 实现) GC 的开销与堆的尺寸是__正相关__的 (分配得越多,堆尺寸越大,新的分配和回收就会越慢)
这里的信息来自The Boehm-Demers-Weiser Conservative Garbage Collector (Hans-J. Boehm, HP Labs),GitHub (ivmai/bdwgc) ,Transparent Programmer-Directed Garbage Collection for C++ (2007) 和 SGen: Mono’s Generational Garbage Collector
实践中的 GC 控制手法
从实践上看,与 GC 相关的控制手法主要是以下这些:
- 避免无谓的反复分配,尤其是__隐含的每帧分配__ 典型的例子是在 Update() 函数里面拼接字符串
- 在可能的时刻__主动触发 GC__,这些时刻包括:
- “刚刚进入某张地图时”
- “刚刚打开某个(静态)界面时”
- “结束掉某一段剧情/新手引导时”
- 使用对象池__策略性地重用对象__
- 把对象的引用归还到对象池,主动有计划地持有引用,而非交给 GC
- 做好平衡和取舍(最小化分配/释放的行为,同时妥善考虑内存占用量的调整)
- 在 GC.Collect() 之前,确保__置空所有能被清理的对象__,以最大化 GC.Collect() 运行一次的性价比
- 2 和 3 的意义在于,对于每次 GC 而言,如果没有需要释放的对象,速度会非常快。
- 可以连续触发多帧的 GC ,就能在 Profiler 中看到,时间消耗的峰值就是第一次 GC。
- 所以尽量手动 GC 的好处就是,会降低 GC 发生在你不期望的时间的几率,也能降低万一发生时的时间开销。
- 考虑到很多 Unity 程序员之前有过丰富的 C++ 经验,对象池就不再展开细说了。
内存布局的效率改善 (以对象为单位 vs. 以类型为单位)
除了对 GC 的行为有一定的理解,并加以适当的控制以外,内存方面还需要注意布局方面的因素:
看下面的例子:
|
|
这是典型的__以对象为单位__组织数据。
|
|
这是典型的__以类型为单位__组织数据。
按照 Unity 程序员 Marco Trivellato 同学的说法,后者对 GC 比前者友好,因为按照后者的方式组织,不同类型的数据被 GC 回收时仅需扫描自己那块;而前者(按照对象组织)被 GC 的时候,所有的数据都需要被扫描到,会花费更多的时间。
导致内存碎片化的各种常见点
- 前面已经提到的,这里汇总一下
- foreach
- FindObject()
- LINQ
- ToString()
- Unity 接口中一些导致零碎内存分配的常见点
.tag
GetComponents<T>
(这个据说还要调到 native code 里面去)Vector3[] Mesh.vertices
Camera[] Camera.allCameras
美术资源相关的运行时控制
UnloadUnusedAssets() 和 UnloadAsset()
Resources.UnloadUnusedAssets() 的特点:
- 会扫描所有的未引用资源
- 发现时就会触发回收操作
- 是一个异步操作
- 在加载一个关卡后自动调用
Resources.UnloadAsset() 的特点:
- 由程序员主动调用
- Unity 扫描开销比前者低很多 (只考虑相关的依赖关系)
结论:如有可能,尽可能地使用后者手动释放。
资源控制常识
还有一些常识,可能不是很系统,这里也简单提一下:
- 绝大部分 Mesh 是不需要 CPU 端的读写的,可以把 Read/Write 关掉 (少一份 copy)
- 不要对 Mesh 做非标准的缩放 (少一份 copy)
- Instantiate() 内做了下面这些事
- 克隆整个对象树 (GameObject Hierarchy)
- 克隆它们的组件 (Components)
- 复制它们的属性 (Properties)
- Awake()
- 清除各种状态
- 内部状态缓存
- 预计算
- 需要的话应用变换 (Apply Transform)
慢慢地引申到了图形方面,这方面实践中也有一些内容,考虑到篇幅,这一次就不展开了。代码相关的实践,这一次就先讨论这么多吧。
工程相关的实践
下面我们来简单聊一下工程方面的实践,这些实践我基本上都只是简单地提一下思路,仅供参考。
耗电发热问题改善
常见的手机发热问题根源有这些:
- 后台运行多个任务导致CPU超载;
- 系统I/O处理遇到瓶颈和阻塞;
- 手机充电时导致过热;
- 后台多个应用消耗一定的电量;
- 手机硬件连接网络时电量损耗最多;
降低发热可以做的事有以下这些:
- 在特定的界面控制帧率,降低 CPU/GPU 的使用率
- 检测后台应用并提示关闭,提示关闭 GPS 和 蓝牙
- 或者提供一键关闭,游戏关闭或退到后台时再自动恢复)
- 亮度动态调整
- (甚至可考虑当前地图的光照风格)
- 提示关闭背景数据和关闭自动同步,退出时再自动恢复
- (但将无法及时接收到邮件)
- IO 异步化,串行化,可等待化,可丢弃化,Throttling (流速控制)
新手引导防卡死
正如我在 “一个有趣的交互 bug ——兼谈游戏的引导系统” 一文中提到的新手引导问题那样,卡新手是一类常见问题。对于这一类问题,除了把可能有冲突的系统及时修复以外,我们还应当采取一些防御性的设计,当玩家陷入卡死状态时,能借助这些机制跳出当前的卡死状态。
由于现在的新手引导普遍傻瓜化,如果玩家停留在某个步骤超过 5 秒钟,我们就可以假设该玩家遇到状况了。这时我们可以检测玩家是否在连续 tap 屏幕,如果连续 tap 三次以上,可以弹出信息提示:“请长按屏幕 x 秒钟退出当前的引导” 如果玩家按提示操作,就 break 出当前的引导,视情况跳过或重新开始。
错误处理和异常捕获时机
这一节只需讲一句话:关键逻辑路径上不要裸调。关键逻辑路径上不要裸调。关键逻辑路径上不要裸调。(是谁说重要的话要讲三遍来着?)
不要依赖 Unity 对未捕获异常的宽容性。
在游戏的关键逻辑路径上,如果裸调一个可能抛出异常的函数,就会冒着部分关键业务逻辑被跳过的风险。这种风险除了会造成可能的内部状态错误和运行不稳定以外,更有可能被破解者或熟练用户利用,达成各种你非常非常不希望见到的目的。
宕机信息的采集,处理,统计和反馈
这里必须宣传一下,我司(西山居)质量中心部门近期出品了一个服务 Crasheye,可以帮助开发人员采集,分类和梳理各种宕机记录,给出项目稳定情况的分类趋势统计,并借助符号文件把堆栈转换为程序员可读的样式。第一次看到这个工具就我伙呆了(这个词过时得好快)。
点这里可以看到一个使用此产品来捕获宕机信息和统计的演示,简直华丽得不能直视。
好了废话不多说了,我要跑去质量中心那边,请他们收下我的膝盖了。
[2015-06-28] 补:
本文中的代码都在这里,如有错误请不吝指出,更新和扩展都会出现在那里。
(全文完)