通用优化提示
前言
在一个理想的世界里, 计算机将以无限的速度运行. 我们唯一的限制是我们的想象力. 然而, 在现实世界中, 制造出能让最快的计算机也屈服的软件实在是太容易了.
因此, 设计游戏和其他软件是在我们希望可能的情况下, 和在保持良好性能的前提下, 能够实际实现的情况之间的折中.
要达到最佳效果, 我们有两种方法:
工作更快.
工作更智能。
我们最好将两者混合使用.
烟雾和镜子
更聪明地工作的一部分是认识到, 在游戏中, 我们经常可以让玩家相信他们所处的世界比实际情况要复杂得多, 互动性强, 图形上也更刺激. 一个好的程序员是一个魔术师, 应该努力学习行业的技巧, 同时努力发明新的技巧.
缓慢的本质
在外界观察者看来, 业绩问题往往被归纳在一起. 但实际上, 业绩问题有几种不同的类型:
每一帧都发生的缓慢过程, 导致持续的低帧率.
一个断断续续的过程, 造成缓慢的到达 “巅峰”, 导致停滞不前.
在正常游戏之外发生的缓慢进程, 例如加载关卡时.
每一种都会给用户带来烦恼, 但方式不同.
测量性能
对于优化来说, 最重要的工具可能是衡量性能的能力—找出瓶颈所在, 并衡量我们突破瓶颈的尝试是否成功.
有几种衡量性能的方法, 包括:
在感兴趣的代码周围放置一个 开启/停止 的计时器.
使用 Godot 分析器 。
使用 外部 CPU 分析器 。
使用外部 GPU 分析器或调试器,例如: NVIDIA Nsight Graphics , Radeon GPU Profiler 或 Intel Graphics Performance Analyzers 。
查看帧率(禁用 V-Sync )。也可以用第三方工具,如 RivaTuner Statistics Server (Windows)or MangoHud (Linux)。
使用一个非官方的`调试菜单附加组件 <https://github.com/godot-extended-libraries/godot-debug-menu>`__.
要非常清楚, 不同区域的相对性能在不同的硬件上会有所不同. 在一个以上的设备上测量计时通常是个好主意. 如果你的目标是移动设备, 情况尤其如此.
限制
CPU分析器通常是测量性能的常用方法. 然而, 它们并不总是能反映全部情况.
瓶颈往往在GPU上,”由于”CPU给出的指令.
由于在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瓶颈.