Attention: Here be dragons
This is the latest
(unstable) version of this documentation, which may document features
not available in or compatible with released stable versions of Godot.
Checking the stable version of the documentation...
通用优化提示
介绍
在理想的世界里,计算机将以无限的速度运行,限制我们成就的唯一因素只是我们的想象力。然而,在现实世界中,制造出能让最快的计算机也屈服的软件实在是太容易了。
因此,设计游戏和其他软件的过程,实际上就是一种折衷的过程,我们必须在“想做的事情”和“在保证良好性能的前提下能做的事情”之间取得平衡。
要达到最佳效果,我们有两种方法:
工作得更快速。
工作得更巧妙。
而最好的做法是将两者结合使用。
障眼法
所谓“工作得更巧妙”,就是要认识到:在游戏开发中,我们往往能让玩家以为自己身处一个远比实际更复杂、互动性更强、画面更炫酷的世界。优秀的程序员就像魔术师,既要精通这门手艺的经典戏法,也要努力创造新的把戏。
缓慢的本质
在外界观察者看来,性能问题往往被混为一谈。但实际上,性能问题有着若干不同的类型:
每一帧都发生的缓慢处理过程,导致持续的低帧率。
间歇性的处理过程引发“卡顿峰值”,导致画面停顿。
在正常游戏流程之外的缓慢处理过程,例如加载关卡时。
这些都会给用户带来烦恼,只是方式不同。
测量性能
对优化来说,最重要的工具或许就是测量性能的能力——识别瓶颈所在,并衡量我们为加快处理速度所做的尝试是否成功。
测量性能的方法有多种,包括:
在关注的目标代码前后放置启动/停止计时器。
使用 Godot 内置的分析器。
使用外部 CPU 分析器。
使用外部的 GPU 分析器/调试器,例如 NVIDIA Nsight Graphics、Radeon GPU Profiler、PIX(仅限 Direct3D 12)、Xcode(仅限 Metal)或Arm Performance Studio。
查看帧率(禁用垂直同步)。第三方工具(如 RivaTuner Statistics Server(Windows)、Special K(Windows)或 MangoHud(Linux))也可以在此发挥作用。
使用一个非官方的调试菜单附加组件。
要非常清楚,不同区域的相对性能在不同的硬件上会有所不同。在不止一个设备上测量用时通常是个好主意。如果你的目标是移动设备,情况尤其如此。
限制
CPU 分析器通常是测量性能的头号方法。然而,它们并不总是能反映全部情况。
CPU 给出的指令可能“导致”瓶颈在 GPU 上。
Godot 中使用的指令(例如动态内存分配)可能“导致”(Godot 之外的)操作系统进程出现峰值。
由于需要进行初始设置,你可能并不总是能够对特定设备进行分析,例如手机。
你可能需要解决你无法访问的硬件上出现的性能问题。
由于这些限制,你经常需要使用侦查工作来找出瓶颈所在。
侦查工作
侦查工作对于开发人员来说是一项至关重要的技能(无论是在性能方面,还是在错误修复方面)。这可以分为假设测试和二分查找。
假设检验
比如说,你认为是精灵使你的游戏速度变慢。可以通过以下方式来验证这个假设:
添加更多精灵或移除一些精灵后,测量性能。
这可能会引出一个进一步的假设:精灵的大小是否决定了性能的下降?
要验证这个假设,你可以保持一切不变,但改变精灵的大小,并测量性能。
二分查找
如果你知道帧的用时比它们应该的用时长得多,但你不确定瓶颈在哪里;你可以先注释掉正常帧上发生的大约一半例程。测量性能的提升比预期的多还是少?
一旦你知道了哪一半包含瓶颈,你就可以重复这个过程,直到你确定问题区域。
分析器
分析器允许你在运行程序时对其进行计时。然后,分析器提供结果,告诉你在不同的功能和区域所花费的时间百分比,以及功能被调用的频率。
这对于确定瓶颈和衡量改进的结果都非常有用。有时,改善性能的尝试可能会适得其反,导致性能变慢。始终使用分析器和计时来指导你的工作。
有关使用 Godot 内置分析器的更多信息见性能分析器。
原则
Donald Knuth 说过:
程序员浪费了大量的时间去考虑或者担心程序中非关键部分的速度,如果考虑到调试和维护,这些提高效率的尝试实际上会产生强烈的负面影响。我们应该忘掉小的效率,比如说 97% 左右的时候:过早的优化是万恶之源。然而,不应该放弃那关键的 3% 的机会。
这些观点非常重要:
开发者的时间是有限的。与其盲目地试图加快一个程序的所有方面,不如集中精力在真正重要的方面。
在优化方面的努力,最终往往会得到比非优化代码更难阅读和调试的代码。将这种情况限制在真正受益的领域更符合我们的利益。
仅仅因为我们可以优化某段代码,并不一定意味着应该。知道什么时候优化,什么时候不优化,是一项更重要的技能。
这句话有一个误导性的地方,就是人们往往把注意力集中在“过早的优化是万恶之源”这句话上。虽然过早的优化是不可取的,但软件之所以高性能是高性能设计的结果。
高性能设计
鼓励人们在必要时再考虑优化的危险在于,它轻易地忽略了考虑性能的最重要时间是在设计阶段,甚至是在按下键盘按键之前。如果一个程序的设计或算法是低效的,那么以后再多的细节打磨也不会使它运行得很快。它可能运行得更快,但永远不会像一个以性能为设计目标的程序那样快。
这在游戏或图形编程中往往比在一般编程中更为重要。一个高性能的设计,即使没有底层优化,通常也会比一个平庸设计加上底层优化快很多倍。
渐进式设计
当然,在实践中,除非你事先有相关知识,否则你不可能在第一次就拿出最好的设计。相反,你往往会对某一特定区域的代码做出一系列版本,每一个版本都采取不同的方法来解决这个问题,直到你得出一个满意的解决方案。重要的是,在你最终确定整体设计之前,在这个阶段不要在细节上花费太多时间。否则,你的很多工作都会被淘汰。
很难给出高性能设计的一般规范,因为这与问题本身有很大关系。不过有一点值得一提,在 CPU 方面,现代 CPU 几乎总是受到内存带宽的限制。这导致了面向数据的设计的重新兴起,涉及到围绕数据的缓存局部性(cache locality)和线性访问进行数据结构和算法的设计,避免在内存中进行跳转。
优化过程
假设我们有一个合理的设计,吸取 Knuth 的教训,优化的第一步应该是找出最大的瓶颈——最慢的函数,也就是最容易实现的目标。
一旦我们成功地提高了最慢区域的速度,它可能就不再是瓶颈了。因此,我们应该再次进行测试/分析,找到下一个需要关注的瓶颈。
因此,该过程是:
分析/确定瓶颈。
优化瓶颈。
返回步骤 1。
优化瓶颈
有些分析器甚至会告诉你一个函数的哪个部分(哪些数据访问、计算)在减慢速度。
与设计一样,你应该首先集中精力确保算法和数据结构是最佳的。数据访问应该是局部的(以最好地利用 CPU 缓存),而且使用紧凑的数据存储通常会更好(同样,总是要对测试结果进行性能分析)。通常情况下,你会提前预计算繁重的计算。这可以通过在加载关卡时执行计算、加载包含预计算数据的文件,或将复杂的计算结果存储到脚本常量中并读取其值来实现。
一旦算法和数据没有问题了,你通常可以在例程中做一些小的改变来提高性能。例如,可以将一些计算移到循环之外,或者将嵌套的 for 循环转化为非嵌套的循环。(如果你事先知道 2D 数组的宽度或高度,这应该是可行的。)
每次更改后,一定要重新测试你的计时和瓶颈。有些改变会提高速度,有些则可能会产生负面效果。有时,一个小的积极效果会被更复杂的代码的负面效果所抵消,你可以选择不做这种优化。
附录
瓶颈计算
有一句谚语很适合描述性能优化:“链条的强度取决于最弱的一环”。如果你的项目在函数 A 上花费了 90% 的时间,那么针对 A 进行优化就能够大幅影响性能。
A: 9 ms
Everything else: 1 ms
Total frame time: 10 ms
A: 1 ms
Everything else: 1ms
Total frame time: 2 ms
在这个例子中,将瓶颈 A 提升 9 倍可以将整体的帧耗时降低至 20%,将每秒帧数增加 5 倍。
但是,如果其他东西运行缓慢,也给你的项目带来了瓶颈,那么同样的改进只能带来不那么显著的收益:
A: 9 ms
Everything else: 50 ms
Total frame time: 59 ms
A: 1 ms
Everything else: 50 ms
Total frame time: 51 ms
在这个例子中,尽管我们对函数 A 进行了大量的优化,但实际的帧率收益却相当小。
在游戏中,事情变得更加复杂,因为 CPU 和 GPU 彼此独立运行。你的总帧时间是由两者中较慢的那一个决定的。
CPU: 9 ms
GPU: 50 ms
Total frame time: 50 ms
CPU: 1 ms
GPU: 50 ms
Total frame time: 50 ms
在这个例子中,我们也对 CPU 进行了大量的优化,但是帧时间并没有提高,因为瓶颈在GPU。