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.

通用优化提示

介绍

在理想的世界里,计算机将以无限的速度运行,限制我们成就的唯一因素只是我们的想象力。然而,在现实世界中,制造出能让最快的计算机也屈服的软件实在是太容易了。

因此,设计游戏和其他软件的过程,实际上就是一种折衷的过程,我们必须在“想做的事情”和“在保证良好性能的前提下能做的事情”之间取得平衡。

要达到最佳效果,我们有两种方法:

  • 工作得更快速。

  • 工作得更巧妙。

而最好的做法是将两者结合使用。

障眼法

所谓“工作得更巧妙”,就是要认识到:在游戏开发中,我们往往能让玩家以为自己身处一个远比实际更复杂、互动性更强、画面更炫酷的世界。优秀的程序员就像魔术师,既要精通这门手艺的经典戏法,也要努力创造新的把戏。

缓慢的本质

在外界观察者看来,性能问题往往被混为一谈。但实际上,性能问题有着若干不同的类型:

  • 每一帧都发生的缓慢处理过程,导致持续的低帧率。

  • 间歇性的处理过程引发“卡顿峰值”,导致画面停顿。

  • 在正常游戏流程之外的缓慢处理过程,例如加载关卡时。

这些都会给用户带来烦恼,只是方式不同。

测量性能

对优化来说,最重要的工具或许就是测量性能的能力——识别瓶颈所在,并衡量我们为加快处理速度所做的尝试是否成功。

测量性能的方法有多种,包括:

要非常清楚,不同区域的相对性能在不同的硬件上会有所不同。在不止一个设备上测量用时通常是个好主意。如果你的目标是移动设备,情况尤其如此。

限制

CPU 分析器通常是测量性能的头号方法。然而,它们并不总是能反映全部情况。

  • CPU 给出的指令可能“导致”瓶颈在 GPU 上。

  • Godot 中使用的指令(例如动态内存分配)可能“导致”(Godot 之外的)操作系统进程出现峰值。

  • 由于需要进行初始设置,你可能并不总是能够对特定设备进行分析,例如手机。

  • 你可能需要解决你无法访问的硬件上出现的性能问题。

由于这些限制,你经常需要使用侦查工作来找出瓶颈所在。

侦查工作

侦查工作对于开发人员来说是一项至关重要的技能(无论是在性能方面,还是在错误修复方面)。这可以分为假设测试和二分查找。

假设检验

比如说,你认为是精灵使你的游戏速度变慢。可以通过以下方式来验证这个假设:

  • 添加更多精灵或移除一些精灵后,测量性能。

这可能会引出一个进一步的假设:精灵的大小是否决定了性能的下降?

  • 要验证这个假设,你可以保持一切不变,但改变精灵的大小,并测量性能。

分析器

分析器允许你在运行程序时对其进行计时。然后,分析器提供结果,告诉你在不同的功能和区域所花费的时间百分比,以及功能被调用的频率。

这对于确定瓶颈和衡量改进的结果都非常有用。有时,改善性能的尝试可能会适得其反,导致性能变慢。始终使用分析器和计时来指导你的工作。

有关使用 Godot 内置分析器的更多信息见性能分析器

原则

Donald Knuth 说过:

程序员浪费了大量的时间去考虑或者担心程序中非关键部分的速度,如果考虑到调试和维护,这些提高效率的尝试实际上会产生强烈的负面影响。我们应该忘掉小的效率,比如说 97% 左右的时候:过早的优化是万恶之源。然而,不应该放弃那关键的 3% 的机会。

这些观点非常重要:

  • 开发者的时间是有限的。与其盲目地试图加快一个程序的所有方面,不如集中精力在真正重要的方面。

  • 在优化方面的努力,最终往往会得到比非优化代码更难阅读和调试的代码。将这种情况限制在真正受益的领域更符合我们的利益。

仅仅因为我们可以优化某段代码,并不一定意味着应该。知道什么时候优化,什么时候不优化,是一项更重要的技能。

这句话有一个误导性的地方,就是人们往往把注意力集中在“过早的优化是万恶之源”这句话上。虽然过早的优化是不可取的,但软件之所以高性能是高性能设计的结果。

高性能设计

鼓励人们在必要时再考虑优化的危险在于,它轻易地忽略了考虑性能的最重要时间是在设计阶段,甚至是在按下键盘按键之前。如果一个程序的设计或算法是低效的,那么以后再多的细节打磨也不会使它运行得很快。它可能运行得更快,但永远不会像一个以性能为设计目标的程序那样快。

这在游戏或图形编程中往往比在一般编程中更为重要。一个高性能的设计,即使没有底层优化,通常也会比一个平庸设计加上底层优化快很多倍。

渐进式设计

当然,在实践中,除非你事先有相关知识,否则你不可能在第一次就拿出最好的设计。相反,你往往会对某一特定区域的代码做出一系列版本,每一个版本都采取不同的方法来解决这个问题,直到你得出一个满意的解决方案。重要的是,在你最终确定整体设计之前,在这个阶段不要在细节上花费太多时间。否则,你的很多工作都会被淘汰。

很难给出高性能设计的一般规范,因为这与问题本身有很大关系。不过有一点值得一提,在 CPU 方面,现代 CPU 几乎总是受到内存带宽的限制。这导致了面向数据的设计的重新兴起,涉及到围绕数据的缓存局部性(cache locality)和线性访问进行数据结构和算法的设计,避免在内存中进行跳转。

优化过程

假设我们有一个合理的设计,吸取 Knuth 的教训,优化的第一步应该是找出最大的瓶颈——最慢的函数,也就是最容易实现的目标。

一旦我们成功地提高了最慢区域的速度,它可能就不再是瓶颈了。因此,我们应该再次进行测试/分析,找到下一个需要关注的瓶颈。

因此,该过程是:

  1. 分析/确定瓶颈。

  2. 优化瓶颈。

  3. 返回步骤 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。