优化

Video Game Optimization——大量数据储存

硬盘、CD、DVD、蓝光碟、SSD、闪存都是大储存。

性能问题在哪里

大储存真的很慢。不光是慢,而且是不可预期的慢。它决定于你的IO请求啥时候调度,内部文件系统的状态还有磁盘的转速,读写头的位置,速度可能从还行到完全很慢。

在旋转没接,例如硬盘和光盘,查询时间是一个巨大的限制搜索时间是磁头移到正确位置。读取时间大概在2ms到20ms之间。而且还有旋转的延迟,也就是大磁盘用于读取时旋转的时间。在每分钟7200转的硬件上旋转延迟可以达到8ms,虽然需要转完整个旋转的读取的情况比较少。

最终的数据储存参数是传输率,用于计算磁头转到正确位置并且把数据写入到内存的时间。变化范围很大,基本在50~500MB/秒之间。你需要根据你的用户群来确定具体的数字。高端游戏玩家使用RAID数字可以接近500MB/秒,而一些普通玩家幸运的话可以达到75MB/秒。(不过在固态普及的今天应该已经不止这么多了)

如何Profile

Profile储存非常简单,只要及记录加载的时间就可以了。你可以做一些更详细的测量,比如读写了多少bytes,但是对于大部分的游戏场景,重要的东西是消耗的时间而不是吞吐量。API花费的时间和实际的宫锁比不是很重要,除非储存驱动错误执行导致了大量的CPU耗时。

最差的情况

最差的情况是如果你有着磁盘上最糟糕的排列情况(需要近乎转完整圈在每一次读取查询之间),你可能会发现有8ms的耗时。

如果你的文件系统是4kb为一块,那么加载1MB的文件可能会话将近两秒钟(1024kb/4kb = 256block 256 * 8ms = 2048ms = 2sec)加载1G的文件会加载半个小时。你的游戏会把所有的时间花在加载上面。使用500kb/sec的速度不是一个好的加载方案。

幸运的是加载问题基本上不会发生。文件系统设计用于保存连续对象来防止出现这种糟糕的情况。即使是重度的碎片化也基本上不会命中这种最糟糕的情况,虽然碎片化趋向于随机。硬盘的控制器会把IO调度基于当前磁盘的旋转,大大减少了查找时间增加吞吐量。

最好的情况

那如果你在最好的情况呢,首先,如果查找时间为0你基本上就只被传输率限制。平均来说是75M/sec.所以你的1G文件可以在13秒加载完,比半个小时好太多了,虽然依旧远比内存要慢。

碎片化

磁盘只有有限的空间。文件以kb为单位的小块写入磁盘。文件系统会尝试把文件储存成连续的块,因为这样会让加载速度更快,但也不是总是可以这么做的。在文件系统碎片化之后,文件被分成多个片来储存在磁盘上的空余区域。

碎片化把你导向最坏的场景。每一个gap都导致更多的搜索时间,吃掉大量的性能。也导致了更多的bookkeeping用于存放文件位置。

磁盘和文件系统越来越好,碎片化问题也越来越少。通过去碎片化工具可以保持一个比较好的性能。但是作为开发者需要在自己的项目上处理好这一点。

SSD带来希望

固态硬盘看上去像机械硬盘,但是不用磁盘储存。而使用闪存技术,就想U盘。这可以移除搜索时间。读写更快,也意味着碎片化不会 明显影响性能。

SSD有自己的tradeoff。写和擦数据会更慢,因为固态的特性。他们也更贵。作为成熟的科技,它们会变得更便宜、可靠以及普遍。(10年后的今天已经证实了这一点)

实际的数据

不要忘记你从磁盘加载完之后你可能还需要进一步处理。图片和音频解压,代码和Xml解析,关卡转换成实际显示。磁盘加载之后还需要花一部分时间处理这些工作。

底线

底线是你不要在你的内部循环中做。磁盘读写会阻塞主线程。

你的游戏总是会在某些垃圾电脑上跑,磁盘被装满并且碎片化严重。不要假设在你电脑上没问题,所以在其他电脑上就没问题。

Profile时的警告

因为加载的路径太复杂,小心追查数据加载性能过深。文件系统会基于访问模式自己优化自身的性能。磁盘会重新根据加载的数据来平衡。磁盘控制器、文件系统、操作系统都会尝试优化性能。用户的软件可能会经过扫描和备份。即使完全重启也很难复现这个状态。

所以最好的方式是测试并刷新电脑状态。可重现的磁盘benchmark并不值得花时间去做。

最大的优化机会在哪里?

底层的数据读写性能你几乎无法处理,但是你可以在重度IO时尝试去做其他事情来保持你游戏的流畅。

隐藏延迟,避免Hitch

IO操作很慢,不要放在主循环。最少也要做到每帧限制读取时间。一般在加载画面或者保存画面的时候可以在主线程做一些工作。但是游戏开始的时候有卡顿,因为你在读磁盘的话玩家会很不开心。

例如列出文件夹的操作可能会很耗时间,建议避免相关的操作。

最小化读写

1个G的文件一次只读1byte和一次性读有什么区别?区别有2的10次方这么大。

当你在读盘的时候不要零散读取,一次性拿到内存会更好。

Reading a gigabyte of data in different size increments. At 1-byte chunks, it takes ~5,000ms, while at 16-byte chunks, it takes 500ms, a 10x speedup.
Reading 32-byte chunks takes 250ms, while 512-byte chunks takes 75ms, a 4x improvement. Notice that 512 increment is slightly slower—one of several hard-to-explain interactions between different parts of the disk subsystem.
Performance gains increase until 256KB chunks, after which behavior becomes somewhat erratic.

看上面三张图,可以发现如果每次只读一点点,那性能消耗差异非常大。

策略就是尽可能一次性读更多的数据。当你有很多小文件的时候速度很快就会下降。

并行访问

最好的隐藏读取消耗的方法是开第二个线程。

首先用户察觉不到任何卡顿。其次,磁盘读取的时候CPU实际上没做任何事情,这个线程基本上都在等待,所以成本也很低。第三是你可以使用标准的POSIX文件API。如果你尝试使用其他的IO函数,或许并不是在每个平台都工作得很好。第四是如果系统可以处理,你可以一次性在后台读完所有东西,这样对性能更好。

Multithreaded IO performance scales better than linearly. Each thread performs the same amount of IO (i.e., one thread reads N total bytes, two threads 2*N total bytes, and so on), but eight IO threads only take ~4x the time to execute.
Left column shows how long it took to perform IO and computation in same thread. Right column shows how long it took to perform IO and computation in different threads.

可以看到在无计算负载的线程上性能要比有计算负载的线程上运行要快3倍。

你也可以看到多线程后台读取线程的效率,并没有线性增长。从1到8只增长了四分之一,因为磁盘有更多机会去服务IO,因为同时有更多的请求在队列中。

优化文件顺序

如果你控制数据写入,你可以基于你游戏的信息来进行优化。一些游戏通过光盘发售,所有很难控制数据写入的情况。

基本思想是降低光盘的搜索时间。最频繁读取的数据放在磁盘的边缘,因为转速更快。然后数据需要根据最多的读取频繁度来排列,这样可以尽量减少搜索时间。

如果你有大量的小文件或者文件夹展开很慢。你可以把场景打包成单个大文件。大部分现在的游戏使用不同的Zip格式来实现这个操作,早期的时候也会用WAD这种简单的格式来做。

为快速加载优化数据

一个大的trade-off针对游戏文件的是磁盘空间和加载时间。打个彼方jpg压缩后再磁盘上很小,但是解压很花时间。另一方面未压缩的格式类似于DDS可以直接从内存加载,但是有更多的资源需要从磁盘加载,哪一个更快?

在我们的测试中DDS加载远快于Jpg解压。下图可以看到大概的数据:

Despite its larger size on disk, the DDS format does not require any complex processing to load, and thus takes much less time.

解压的overhead远远超过直接加载。

像Xml的格式非常是和编辑,但是在运行时则有很大的性能问题,在运行时你可以直接会转二进制来使用。

做一个简单的测试,直接读取数据和解析xml可以看到:

Parsing even simple human-readable text is slow; directly loading structs and doing pointer fix-up can be nearly an order of magnitude faster.

直接从二进制读入内存是强大的及时。标准C有fread和fwrite用于直接读取bytes数组。平台的可执行文件格式被设计成可以直接加载到内存并运行。

但是动态内存管理有安全性问题,字节序问题,动态内存管理问题,以及改变内部数据结构,所以现在已经不能直接简单从磁盘加载到内存中。而需要一些抽象。xml就是一种抽象,非常鲁棒,但是消耗太大了。

一种有效的方法是直接从结构体序列化,但是储存足够的元数据来保证每个域是哪个类型。通过这个方法可以在加载时期做字节码转换。指针不能简单地序列化,可以保存在文件的开头。文件中的数据可以放在单个buffer中,然后通过offset来确定指针指向的地方。单buffer减少了小的申请,所以性能很高。然后格式可以通过比较metadata来确定版本。

这些修改会花一些时间,但是比XML更快速和简单。

Granny,RAD Game工具,实现了成熟版本的这种方法,而且提供了完善的文档。在Granny实现3D模型加载器和动画系统时就已经拥有了一个好的3D引擎的大量模块,如果你正在搞这块可以去看一看。

文件系统暴露了大量的overhead。打开和关闭文件是个很好的例子。文件按序分组是否降低了overhead,从测试中可以看到优化效果非常明显。打开100个随机文件读取一个int,和打开一个很大的文件然后读取100个随机int,大文件速度快100倍。在把文件数量从100改到100000性能都没有特别大的差异。

Time to fopen()100 files as the number of files in the directory is varied. As you can see, even a large number of files in a directory doesn’t adversely affect performance.
Loading 100 ints from a single file is about 6x faster than loading one int each from 100 files.

列举文件也很耗。但是这和文件夹中文件数量线性相关,除非你有许多文件,否则这不应该是个很大的问题,当然你也不希望频繁做这个操作。

Time to list files in a folder. Notice that the X-axis is logarithmically scaled. It is scaling sublinearly.

建议和技巧

底线:你没办法在运行时处理很多磁盘性能的优化。

因为你无法控制玩家用什么机器玩,所以你需要在开发的时候就把一切情况都想好。

了解你磁盘的Budget

每个游戏有最大尺寸限制。或许你会做一些下载功能去下载一些功能。或许你在做一个AAA游戏需要用好几张光盘。不管怎么样都要搞清楚你的储存budget。

最大的资源是video,接下来是音频和图片,然后是Mesh和代码,最小的可能是游戏二进制。

下面是资源尺寸:

如果你有磁盘budget,你需要探索更好的压缩方法为了让你的游戏在正确的尺寸。帮助你计划分配加载时间。

筛选器

文件获取被第三方软件hook,比如杀毒软件会检查恶意行为,及时杀软出现之前,用户也会跑磁盘扩展来压缩或解压来用时间换空间。这些对于profiler来说都是不可知的。

最好的方式就是你信任API。读的文件越大越好,越不频繁越好。在性能低于预期的时候优雅地降级。在碎片化严重的硬件和大量杀软的电脑上多测试一下。

支持开发模式和运行时模式的文件格式

艺术家和程序员从快速迭代中受益。如果他们能直接从贴图修改中看到结果,可以帮助更快地迭代。这可能会导致运行时的耗时增加。加载越快的文件编辑起来就越慢。

对于内部版本,使用易于修改的文件是很重要的。保证你的文件同时支持方便编辑的版本和发布时高效的版本。有加载PSD文件的库,也有很多其他的类型的库,可以帮助你进行下一步。

支持动态重载

对于艺术家来说最好的迭代特性就是在修改原文件的时候直接看到游戏中改变。如果你从头实现游戏,这个很容易实现。如果在之后加入这个功能就会很蛋疼。

为了实现这个你需要做两块,一个是检查文件夹改变的文件。另一个是你需要在内部刷新使用的资源。

允许艺术家快速修改可以带来快速迭代。小的功能带来大大的提升。

自动资源处理

如果资源需要在使用或优化之前处理,最好的方式是自动化处理,在构建的时候自动处理。资源构建,就像普通构建,不需要人力去处理。

对于小工程用一个小脚本可能就可以处理完了。对于大工程则需要大量的管理工具,面向游戏艺术家。

集中资源加载

最好的方式是只在一个地方加载资源,这样的话方便检查加载了什么东西,以及加载请求的profile都会更加简化。以及作出更有效的策略。

适当的时候预加载

如果你知道有些资源一直会用的话就在游戏开始的时候就预加载。这样游戏内就不需要在运行时再加载了。

对于使用流的情况

如果你使用流数据,需要保证有一些东西来填满资源不可用的时候的空隙。比如声音,你可以保留声音中的第一秒,而剩下的可以在需要的时候加载。

对于贴图,将低等级的level保存在内存,这样可以减少streaming系统的压力。你可以延迟加载知道资源需要细节的时候。使用DXT压缩,你可以把一些mip压缩到只有几个bytes。

对于集合体你可以把场景做成低到高的LOD。GTA有常驻的低模世界。高模世界在需要的时候加载。及时对于很大的世界也只需要一些LOD就够了,而不是连续的。这个科技用在了GTA SA和GTA4中。

如果你使用streaming,保证辨别仅需要用于显示的资源。这样的话就不需要加载碰撞体了。

可下载资源

如果你是下载资源,那么下载尺寸时常比加载速度更重要。对于AAA游戏一般用未压缩的DDS用来优化加载时间。对于更小的游戏,使用Jpg、Png甚至是GIF来保证你的下载量足够小。

总结

发表回复

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