优化

Video Game Optimization——托管语言

微软把哪些跑在它们CLR的语言,例如C#,称作托管语言。但是大部分其他语言以及语言变体也是一样的。非托管或本地语言也代表直接在CPU架构直接跑在操作系统的语言。CLR提供了语言特性套件,例如垃圾回收、JIT、字节码格式(IL),庞大的标准库,debug/profile基础设施,还有别的更多。

在这一章我们使用托管来代码任意跨平台和需要runtime的语言。Python、PHP、Java、Perl、JavaScript、ActionScript、C#、Ruby、Lua以及其他满足这个分类的语言。本地则是任意语言发布为二进制机器码的,C++是最好的例子。

为啥有一章关注于托管语言性能?两个原因:因为主机游戏依赖脚本语言。Gamplay使用原生语言会消耗大量的时间,所以大部分游戏最终会嵌入语言用于业务工作,例如管理清单、制定任务之类的。Lua非常受欢迎,Java和C#也会被使用。

第二越来越多的产品使用纯托管语言实现。例如flash或者unity。你会完全使用C#或者ActionScript来编写你的游戏。网页开发会完全使用PHP,Java,Python,Ruby或者JavaScripts,而完全不使用任何的本地语言。

大部分的本书内容也适用于拖入语言。找到最大的瓶颈并且移除他们,这在PHP上和C或者汇编上没有区别。然而大部分的假设必须改变,当你使用托管语言的时候,这些改变的假设则是我们编写这章的目的。

托管语言的特性

首先:每个语言是不一样的。即使是CLR的语言之间也有不一样的特性。所以你需要检查语言的特性集合。我们不打算面面俱到,但是会指出你可能会遇到的问题。

对于托管语言的共同特性,我们会集中在性能视角最重要的点上。

托管语言并不会直接操作内存。一般来说,所有东西都通过引用来获取,没有直接的指针。这就导致了更多的随机访问以及更少的优化内存的机会。你也有更少的内存申请策略,虽然还是有一些选择,我们在这章会提及。

有些语言在内存管理上比其他语言更好,例如C#允许你直接从一块内存上储存数据,ActionScript3尝试使用值而不使用引用,当它可以的时候。当你使用嵌入式语言的时候,你可以写一些本地代码来让部分操作变快。或者你可以改变一个语言的特性。举个例子,字符串一般都储存在临近的内存,所以你可以快速遍历字符串数据。或者你也许知道整数数组是临近的来利用这些特性。

一般托管语言的内存通过垃圾回收管理,从开发者身上去除内存管理的工作。这移除了大部分需要记录并简化了开发工作。当然即使有GC,你依旧希望手动去管理内存有时候,所有有一些trick来做这个事情。我们将会在后面提到。

大部分托管语言使用了跨平台的字节码。Java是可能是最好的关于这个的例子,但是几乎所有的托管语言都使用了这种方式。这意味着你可以分配字节码而不是源文件。一些语言之间解释执行源码,但是这样做的很少见。并且因为需要解释源码而导致很缓慢。

字节码不差,但是比本地代码要更慢,因为每一个字节码都意味着多个本地操作。所以许多语言支持JIT(Just In Time)功能。在第一次执行代码的时候将字节码转换为本地代码,但是大部分时候实现gameplay的逻辑不需要这么快。重要的是当你遇到缓慢的东西时可以使用JIT来解决。

不像本地代码直接跑在硬件和操作系统,托管语言依赖运行时。有时候(比如C#)这是一个公用的库,可以被用户安装在本地,另外一些时候(比如Lua或者Javascript)则会把运行时嵌入在应用中。比如你的游戏或者web浏览器。这影响了应用程序的大小,而且一些特性必须打包入应用才能使用(比如调试和profile)。

关心Profile

在profile托管语言的时候有很大的区别。在你普通的本地应用中,获取数组非常容易并且有固定的性能消耗。在C语言里面有一个加操作和一个内存加载。在托管语言当中,数组获取会涉及到更多。例如在ActionScript里面数字是通过一个泛型数据结构实现的,所以每一个数组加载意味着查询数据结构。你可以重载数组访问,可能跑在用户每一个访问的地方。你至少进行了好几个函数调用,而本地代码则只需要几个cycle就能完成。在CLI中你如果有getter和setter则会引入更多的overhead。

大部分成熟的托管语言都有profile。在C#世界Jetbrain的Profiler是个不错的选择。其他也有nProf、SlimTune还有VS内置的。ActionScript在Flash Builder中有内置的profiler。Lua和Python也类似。

如果你在数组中添加数据,那么也许会有内存申请,造成GC发生,或者等一些其他随机的工作做完之后会发生。一次GC回收可以导致上百毫秒或者更长的耗时。这导致更难精确profile你的代码。

而拥有JIT支持的语言,也会影响你的测量。因为JIT系统运行时编译代码,这样就让你难以预测中间会发生什么工作,什么时候发生。不像GC,一旦JIT编译了一部分代码,不会重新处理那段代码,其他代码会等待这段代码编译完之后继续执行。

另一个主要的因素,是因为托管语言没有这么精确的时间信息。首先它的标准库中可能使用的不是高精度时间。第二个,即使你获取时间,在获取时间的时候如果插入太多也会有很大的耗时,从而影响你测量实际运行时间的效率。

为何你直接使用timer来profiling?因为很多托管语言没有很好的profile工具。当你是自制或者专利语言的时候就很容易出现这种情况。在很多浏览器上没有Javascript的profile。有良好设计的profile远比你自己的hack解决方案要好。

这些可能是你profile托管代码的主要问题。但是每种语言的特性都不一样,最好的方式还是通过profile基础操作。在这章的其他部分,我们会选择ActionScript3和Flash,因为跑在大部分主要的平台上(注:虽然如今已经物是人非),相对于.net没有这么重的性能调试。所以比大部分的托管语言要更好介绍一点。

改变你的假设

托管语言的基础操作耗时远比本地语言的更多变。有些表现得很高,有些时候则是远高于本地语言。这让我们开发时避免高耗时的操作尤为重要。

举个粒子,在AS3中构建新对象是非常耗时的。一个有效的方法是使用一个对象池,而不是每次都创建新的。但是这需要保证不能泄露对象,这对性能而言是巨大的提升,对于基础对象是五倍,而sprite对象则是80倍。(数据来自这个文章 http://lab.polygonal.de/2008/06/18/usingobject-pools.)

许多的托管语言不会从栈上分配,而只从堆上。这意味着临时储存会远高于C++因为他们需要从GC中获取和释放资源。降低你的迭代有很大的意义,在循环里面申请是一个巨大的问题。这在本地语言里可能是个问题,但是在托管语言里面这个问题会严重得多。

字符串的消耗可能远超你的想象。一些语言有非常低级的字符串管理,导致及时是简单的C字符串管理也会比他们好。其他语言实现了复杂的字符串管理策略,而用于实现更高效的字符串,甚至接近本地语言。做一些测试来确定需要用哪种策略的优化。

最后的一个问题是数值精度。一些托管语言使用数据精度。比如有些语言会在处理的时候把不同的东西转成字符串,有时候你就会损失精度。在AS3中,浮点数储存在堆上,而整数直接储存在引用中。节省了大量的overhead。如果这个类型有问题一般会在文档中说明,大部分托管语言会倾向于更精确。

什么应该在托管语言中实现

大量的应用使用托管语言运行。只要是功能性大于速度要求的地方都是使用托管语言的好地方。因为像GC不是直接访问指针的基本上不会产生灾难性的崩溃。几乎大部分的gameplay都是这个分类的。在你profile游戏的时候逻辑消耗肯定远没有物理或者渲染的消耗高。

另一个地方就是服务器端。你一般会想要高可靠性,因为在网络上用户可以忍受一段时间的处理时间(即使有GC)。

托管语言也擅长快速放出接口,让本地语言实现。最好的实践是嵌入托管语言,尽可能快地实现功能,当你后续发现瓶颈之后,将部分代码挪到本地代码中。这样你就又享受到托管语言的便利,也享受到本地语言的速度。

转换数据可能会有耗时。取决你使用的技术,序列化可能会有调用,或者是额外的内存拷贝。当你关心的时候可以很容易从profile看出来。你会发现即使调用一个空函数也会有部分消耗。

JIT提高了需要从托管语言移植到本地语言的标准。因为JIT直接让函数快了4倍(LuaJit的数据)这样就更少的代码需要从托管语言移植到本地语言。

什么东西不应该通过托管语言实现

本地代码可以尽可能利用标准库和内建功能。这对于本地代码的世界比较不好搞,但是在托管代码里面则要更好做。当然你需要保证方便的东西是不是真的很快。AS3中的迭代方法会引入性能问题。在C#中一些迭代方法也会导致overhead。

如果你有选择的话最好对你的不同使用场景使用不同的语言。你在实现渲染代码的时候最好使用本地代码,因为可以更方便地访问底层API。而在上层则可以通过开放接口来进行开发。比如C#有XNA,包装了DirectX的API。

C++主要用于实现快速的集中的代码。而不适合去写松散的gameplay代码。如果你给你的设计师开放一种易于使用的脚本系统,你可以避免一大部分问题。更重要的是你可以在更少的时间做更多的事情。因为维护脚本代码比维护C++代码更加容易。

一些语言提供了特殊的计算API。比如Adobe开始把一些OpenCL的negligible放到flash里面。Python有NumPy,用于科学计算。C#有本地并行基本设施。通过使用这些可以利用的提高你的代码效率。

处理垃圾回收

对于提供商所说,垃圾回收是美妙的技术,无需担心任何性能问题。虽然有可能和垃圾回收和平相处,但是并不是自然而然的结果。如果不正确地使用垃圾回收会导致宕机,尽管供应商已经提供了最大努力。

垃圾回收有很多种类型。这本书不是为了讨论垃圾回收,如果要了解的话需要花很多时间去wiki来了解它。这个网站提供了很好的介绍。www.javaworld.com/javaworld/jw-01-2002/ jw-0111-hotspotgc.html。

如果你需要深入了解可以看一下《Garbage Collection: Algorithms for Automatic Dynamic Memory Management》

压力之下

使用手动内存申请,有巨大的问题在于你引入的footprint,或者碎片化,或者清理速度过慢。使用垃圾回收,最大的问题是内存压力,长时间申请的内存数量。

大部分的GC语言限制在栈上储存的数据,所以你会分配马上停止使用的零食对象。这些可能是字符串,对象,域信息,或者其他数据。副作用就是在程序运行时持续地分配内存。不小心的代码可能在几秒钟分配掉几MB,小心的代码则可以几乎不分配任何东西。

大部分的垃圾回收主要是配置回收这一部分的内容,在数量到达一定数量之后进行回收。比如你使用50MB的内存,GC的规则也许会说当你的内存翻倍的时候进行回收,如果你的代码每秒申请1mb,那么不到一分钟你就有100mb的内存,垃圾回收再次进行。

下图显示了两个程序的内存使用,注意锯齿的形状。当代码运行的时候临时对象在队中申请。当对象到达一定的数量之后,GC就运行,每次运行的时候性能就会下降,也就是hitch。A有更高的内存压力,所以运行GC更频繁。程序B则压力没这么大,所以GC运行更少。所以程序B会更流畅。

Consumed memory (Y-axis) over time (X-axis). Each time the line moves downward, the GC runs. Notice that Program A causes the GC to run much more often than Program B.

如果程序无视内存压力。你也许会发现每秒都在垃圾回收,导致严重的性能问题。每帧多次回收也救不了写的不行的代码。

压力可以导致你的程序有更大的内存footprint,因为GC会让使用的的内存增长得更快,如果运行过于频繁的话,而且会严重影响缓存效率。如果你有大量小对象,那就会分散在内存的各个地方。

第二个需要考虑的是GC是整个工作集的内存。为了进行回收,GC必须遍历所有的指针(有时候甚至是每个byte)确认目前到底有没有在使用。如果你有几个G的资源并且有大量的引用,那这将会消耗大量时间。所以降低内存使用量可以获益。(类似于MMGC和Boehm GC : www.hpl.hp.com/personal/Hans_Boehm/gc/gcdescr.html)

使用耗时并不像内存压力这么大,因为大部分GC是增量或者分代的。增量GC可以在一段时间里面持续进行,所以你不需要等待完整的GC完成。收集只是作为GC的副产品而产生的。如果内存压力很高,GC会强制进行紧急回收,然后一次性做完所有的工作来释放一些内存。

而分代GC会跟踪内存被使用了多久,更老的对象被分开管理并且主要检查新分配的释放更频繁的对象。因为大部分的新对象是马上就进行释放的,所以很好地降低了需要检查的内存总量,大大降低了GC的耗时。但是你必须适当地对待内存才能让GC正常工作。

写入屏障

一些GC使用了写入屏障的逻辑。这个优化来防止GC重新处理没有改变的数据。但是这个也在写入时造成了惩罚。

因为这个原因,写入可能远比想象中要慢。当希望进行写入的时候,一些算法(往往设计在写入廉价的系统上)会往内存中写入相同的值。在这种情况下通过检查值是否一致来得到性能上的提升。

更好GC行为的策略

怎么样才能让GC更友好?我们在之前的几个部分知道了应该降低申请的次数,并且尝试降低使用内存的总数。这里我们可以介绍一些技术。

首先是完全从GC手中拿出数据。比如你也许会直接从数组中申请数据,而不是存在单独的堆上。一些语言给你明确的机会来手动内存管理。比如C#可以直接调用本地语言,然后本地语言来自己进行申请。

另一个一般的选项就是用池子。保持一些类型的实例,并且进行复用,而不是申请更多。你可以立刻降低内存压力。并且降低这部分需要GC的东西。池子的item会受到分代GC的好处,更少检查。

池子不止降低了内存压力,而且降低了语言本身的overhead。

另一个选项是重用临时对象。比如你需要一个Matrix对象,以及一些中间计算,你可能可以存一个静态对象而不是申请一个新的。你要对内存消耗和副作用的风险进行权衡(比如多线程)。

处理JIT

很多流行的托管语言提供了JIT编译器。因为最大的问题是解释型语言需要解释每一个中间的字节码,消耗了很多本地指令,JIT可以很大程度提升这部分性能。

JIT可能会引起大量的overhead,所以不同的运行时使用不同的做法。比如Java跑在服务器环境会使用更进攻式的JIT,因为服务器会长时间运行,而在客户,Java则倾向于函数跑过若干次之后再进行JIT。 JIT在一个最大化优化性能和让系统运行的两者之间保持了比较好的平衡。一个普通的编译器有成千上百毫秒和几个G的内存来产生优化代码,而JIT只有一毫秒以及非常少内存的。了解你什么时候该启用JIT是应该要解决的问题。

什么时候激活JIT

编译器处理你每一块的应用程序的代码,而JIT则更有选择性。JIT倾向于编译一个函数或者更少的代码一次性。比如JIT只会编译最频繁运行的代码。这可能是你在profile性能中可能会注意到的地方。

由于性能考虑,其他地方JIT可能会被禁用。比如构造函数经常被排除在JIT之外,因为运行不够频繁。你应该把你的重量级初始化代码放在帮助函数中,而不是构造器中。

特定的语言也是JIT运行的因素。在AS3中,提供类型信息可以导致巨大的性能提升。使用正确的迭代器非常关键。函数调用经常不是内联的,可以手动内联,帮助巨大的提升,比C++世界的更加有效。

分析JIT

JIT如果可用并且运行正常,你可以获得客观的性能提升。内部循环,JIT编译代码可以接近未优化的本地代码。不过这里有一些需要注意的点。

因为JIT必须保证所有语言的原意,它经常需要引入额外的检查,并且函数调用往往不会像本地语言一样直接。在动态语言,从对象上获取属性可能会有一个复杂的查找。即使通过JIT生成本地代码,它能做的也只是把查找放到native中。一些运行时会检测代码通过数据结构来优化这些访问的情况。(详见Google V8设计笔记:)

http://code.google.com/apis/v8/design.html#prop_access.

如果你真的需要搞清楚发生了什么,最好的方法就是比较输入的bytecode和输出的本地汇编。这个映射可能会非常令人惊讶。一些东西很简单,但是生成了令人难以置信的低效代码。但是大部分时间profile会带你到你需要去的地方。

保证profiler和JIT正常交互。有时候debug运行时不会进行JIT。导致你profiling的结果和你实际的结果并不一样。

实践案例——AS3和C#

大部分本书的性能指导可以在很多年里都不变。虽然渲染变得更快,但是大部分的trade-off结论并没有改变。CPU每年都越来越好,但是芯片厂商会努力让快速代码继续跑的很快。

对于解释型语言,如果不太成熟的话,在版本提升的时候对性能会带来巨大的影响。比如FireFox,Chrome对JavaScript在过去两年(2007-2009)性能的巨大提升。

对这本书所有的章节而言,这一章是最容易过时的。如果你在使用一个托管语言的工程,你需要保证与最新的性能指导一致,这样可以避免隐藏的性能陷阱。

这里的例子是AS3的。以及C#的。

(既然容易过时,就不再写了~而且AS已经过时,C#则可以在《EffectiveC#》之类的书里面看到相关案例)

小心函数调用的Overhead(AS3)

语言特性可能会成为陷阱(AS3)

装箱拆箱(C#)

总结

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注