General optimization tips

简介

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

因此,设计游戏和其他软件是在我们希望可能的情况下,和在保持良好性能的前提下,能够实际实现的情况之间的折中。

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

  • 工作更快。

  • 工作更快。

我们最好将两者混合使用。

Smoke and mirrors

更聪明地工作的一部分是认识到,在游戏中,我们经常可以让玩家相信他们所处的世界比实际情况要复杂得多,互动性强,图形上也更刺激。一个好的程序员是一个魔术师,应该努力学习行业的技巧,同时努力发明新的技巧。

The nature of slowness

在外界观察者看来,业绩问题往往被归纳在一起。但实际上,业绩问题有几种不同的类型:

  • A slow process that occurs every frame, leading to a continuously low frame rate.

  • 一个断断续续的过程,造成缓慢的到达"巅峰",导致停滞不前。

  • 在正常游戏之外发生的缓慢进程,例如加载关卡时。

每一种都会给用户带来烦恼,但方式不同。

Measuring performance

对于优化来说,最重要的工具可能是衡量性能的能力--找出瓶颈所在,并衡量我们突破瓶颈的尝试是否成功。

有几种衡量性能的方法,包括:

  • 在感兴趣的代码周围放置一个 开启/停止 的计时器。

  • 使用Godot分析器。

  • 使用外部第三方CPU分析器。

  • 使用GPU分析器和调试器,如 NVIDIA Nsight Graphicsapitrace

  • 检查帧速率(禁用垂直同步)。

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

限制

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

  • 瓶颈往往在GPU上,"由于"CPU给出的指令。

  • 由于在Godot中使用的指令(例如,动态内存分配)"导致"操作系统进程(在Godot之外)可能出现巅峰。

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

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

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

Detective work

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

Hypothesis testing

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

  • 当你添加更多的精灵或移除一些精灵时,测量其性能。

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

  • 你可以通过保持一切不变,但改变精灵的大小,并测量性能来进行测试。

Profilers

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

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

有关使用Godot内置分析器的更多信息,请参阅:Debugger panel

Principles

Donald Knuth 说:

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

这些消息非常重要:

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

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

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

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

Performant design

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

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

Incremental design

Of course, in practice, unless you have prior knowledge, you are unlikely to come up with the best design the first time. Instead, you'll often make a series of versions of a particular area of code, each taking a different approach to the problem, until you come to a satisfactory solution. It's important not to spend too much time on the details at this stage until you have finalized the overall design. Otherwise, much of your work will be thrown out.

It's difficult to give general guidelines for performant design because this is so dependent on the problem. One point worth mentioning though, on the CPU side, is that modern CPUs are nearly always limited by memory bandwidth. This has led to a resurgence in data-oriented design, which involves designing data structures and algorithms for cache locality of data and linear access, rather than jumping around in memory.

The optimization process

假设我们有一个合理的设计,听取Knuth的教训,优化的第一步应该是找出最大的瓶颈--最慢的功能,可轻松实现的目标。

Once we've successfully improved the speed of the slowest area, it may no longer be the bottleneck. So we should test/profile again and find the next bottleneck on which to focus.

The process is thus:

  1. Profile / Identify bottleneck.

  2. 优化瓶颈。

  3. 返回步骤1。

Optimizing bottlenecks

有些分析器甚至会告诉你一个函数的哪个部分在减慢速度(哪些数据访问、计算)。

As with design, you should concentrate your efforts first on making sure the algorithms and data structures are the best they can be. Data access should be local (to make best use of CPU cache), and it can often be better to use compact storage of data (again, always profile to test results). Often, you precalculate heavy computations ahead of time. This can be done by performing the computation when loading a level, by loading a file containing precalculated data or simply by storing the results of complex calculations into a script constant and reading its value.

一旦算法和数据良好,可以经常在例程中做一些小的改变来提高性能。例如,可以将一些计算移到循环之外,或者将嵌套的 "for" 循环转化为非嵌套的循环。如果你事先知道一个2D数组的宽度或高度,这应该是可行的。

每次更改后,一定要重新测试您的时长和瓶颈。有些改变会提高速度,有些则可能会产生负面效果。有时,一个小的积极效果会被更复杂的代码的负面效果所抵消,可以选择不做这种优化。

Appendix

Bottleneck math

谚语 "一条链子的强度取决于其最薄弱的环节 " 直接适用于性能优化。如果你的项目90%的时间都花在功能 A 上,那么优化 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倍,总体帧时间减少5倍,同时每秒帧数增加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瓶颈。