配置文件引导型优化 (PGO) 的工作原理

配置文件引导型优化(也称为 PGO 或“Pogo”)是一种使用游戏在现实中运行时的行为方式信息,对已优化的游戏 build 实施进一步优化的方法。这种方法可将不经常运行的代码(例如错误或极端情况)从代码的关键执行路径中忽略,从而加快游戏的运行速度。

示意图:PGO 工作原理概览

图 1. PGO 工作原理概览。

如要使用 PGO,首先需要对 build 进行插桩,以便生成可供编译器使用的配置数据。然后,通过运行该 build 并生成一个或多个配置数据文件来演练代码。最后,从设备中复制这些文件并提供给编译器,编译器会使用您捕获的配置文件信息来优化可执行文件。

非 PGO 优化型 build 的工作原理

未使用配置数据进行优化的 build 在确定如何生成优化代码时会使用各种启发法。

一些方法由开发者明确地使用信号,例如在 C++ 20 或更高版本中会使用分支方向提示,例如 [[likely]][[unlikely]]。又例如使用 inline 关键字,甚至是 __forceinline(但通常来说,使用前者是更好也更灵活的方式)。默认情况下,一些编译器会假设分支的第一部分(即 if 语句,而非 else 部分)是执行概率最高的部分。优化器可能还会通过代码静态分析对代码执行方式做出假设,但这种方法的适用范围通常有限。

这些启发法的问题在于,它们无法在所有情况下都为编译器提供正确帮助,就算使用详尽的手动标记也是如此。因此,虽然生成的代码通常已经过充分优化,但如果编译器能获得更多运行时的代码行为信息,优化的结果会更好。

生成配置文件

如果可执行文件是在插桩模式下启用 PGO 构建的,那么可执行文件会在每个代码块的开头(例如,函数开头或分支的每个子分支开头)使用增强代码。这类代码的作用是跟踪正在运行中的代码进入代码块的次数,编译器随后可以使用这类数据生成优化代码。

此外,这类代码还会执行其他一些跟踪,例如,跟踪代码块中典型复制操作的大小,以便稍后生成该操作的快速内联版本。

在游戏执行某种代表性作业后,可执行文件必须调用函数 __llvm_profile_write_file() 才能将配置数据写出到设备上的某个可自定义位置。如果您的 build 配置启用了 PGO 插桩,该函数会自动关联到您的游戏。

然后,应将写出的配置数据文件复制回主机,并且最好与同一 build 中的其他配置文件保存在相同位置,以便一起使用这些文件。

例如,您可以将游戏代码修改为在当前游戏场景结束时调用 __llvm_profile_write_file()。然后,为了获取配置文件,您需要在启用插桩模式的情况下构建游戏,再将游戏部署到 Android 设备。运行期间,系统会自动捕获配置数据。质量检查工程师会完整运行游戏,演练不同的场景(或只是进行正常的测试)。

针对游戏不同部分完成演练后,您可以返回主菜单,这会结束当前游戏场景并写出配置数据。

您可以使用脚本从测试设备中复制配置数据,并将其上传到中央仓库,以备日后使用。

合并配置数据

从设备获取配置文件后,需要将插桩 build 生成的配置数据文件转换为编译器可以使用的格式。AGDE 会为您添加到项目中的所有配置数据文件自动完成这项工作。

PGO 旨在将多个同时运行的插桩配置文件的运行结果合并在一起,如果您在单个项目中存在多个配置文件,AGDE 也会为您自动完成这项工作。

为了说明合并配置数据集的作用,我们来举一个例子。假设您有一个实验室,其中人员全部是质量检查工程师,这些工程师都在试玩游戏的不同关卡。他们每一次玩游戏的过程都会被记录下来,然后提供给启用 PGO 插桩的游戏 build 用于生成配置数据。通过合并配置文件,您可以将所有这些不同测试的运行结果组合在一起(各次测试执行的可能是代码中完全不同的部分),从而获得更好的结果。

更好的是,在执行纵向测试时(您需要保留各个内部版本配置数据的副本),重新构建不一定会使旧版配置数据失效。大多数情况下,各个版本的代码是相对稳定的,因此通过旧版 build 生成的配置数据可能仍然有用,而且不会立即过时。

生成配置文件引导型优化 build

将配置数据添加到项目中后,通过在 build 配置中采用优化模式启用 PGO,您可使用这些配置数据构建可执行文件。

这会指示编译器的优化器在做出优化决策时使用您之前捕获的配置数据。

何时使用配置文件引导型优化

PGO 并不适合在开发之初或代码日常迭代期间启用。在开发过程中,您应该专注于算法优化和基于数据布局的优化,因为这些优化会给您带来更多好处。

PGO 更适合在开发过程后期完善发布版本时使用。您可以将配置文件引导型优化看作一种锦上添花的手段,在已经花费一些时间自行优化代码后,它可以帮助您充分挖掘代码中最后一点可提升的性能。

使用 PGO 后的预期效果提升

这取决于多种因素,包括配置文件的全面程度和过时程度,以及在使用传统优化型 build 时您的代码与最佳优化代码的接近程度。

一般来说,非常保守的估计是,关键线程中的 CPU 开销会降低大约 5%。您看到的结果可能会有所不同。

插桩开销

PGO 的插桩功能非常全面,虽然它是自动生成的,但并不是没有开销的。PGO 插桩的开销可能因代码库而异。

配置文件引导型插桩的性能开销

使用插桩 build 时,帧速率可能会下降。在某些情况下(具体取决于正常操作期间 CPU 利用率有多接近 100%),降幅可能会很大,以致游戏难以正常运行。

我们建议大多数开发者为其游戏构建半确定性的重玩模式。这种功能可让质量检查团队在游戏中已知的、可重复的起始位置(例如游戏存档或特定测试关卡)启动游戏,然后记录他们的输入。通过运行测试 build 记录的输入可以馈送给 PGO 插桩 build,然后重玩并生成现实的配置数据,无论处理单个帧需要多长时间都不会受到影响,即使游戏运行速度慢到无法玩也是如此。

这种功能还具有其他一些重要优势,例如帮助测试人员事半功倍地完成工作:一位测试人员可以在一部设备上记录输入,然后在多种不同类型的设备上重玩来进行冒烟测试。

对于存在大量设备变体的 Android 生态系统来说,这样的重玩系统具备极大的优势,而且优势还不止于此:它还可以构成持续集成构建系统的核心部分,让您得以执行常规的夜间性能回归和冒烟测试。

记录操作应该在游戏输入机制内最合适的位置记录用户输入(可能不是直接的触摸屏事件,而是将其后果记录为命令)。这些输入还应包含一个在游戏过程中单调递增的帧计数,以便在执行期间,重玩机制可以等待相应的帧并在该帧上触发某个事件。

在执行模式下,游戏应避免在线登录,不应展示广告,并且应按照固定的时间步长(以您的目标帧速率)运行。您应该考虑停用 vsync。

游戏中的所有内容(例如粒子系统)是否完全确定性地可重复并不重要,但相同的操作应该产生相同的游戏内后果和结果,也就是说,游戏的玩法应保持一致。

配置文件引导型插桩的内存开销

PGO 插桩的内存开销因被编译的特定库不同而有很大差异。在测试中,我们发现测试可执行文件的总体大小增加了约 2.2 倍。增加的大小既包括对代码块插桩所需的额外代码,也包括存储计数器所需的空间。这些测试并非详尽无遗,您的体验也可能会有所不同。

何时更新或舍弃配置数据

每当您对代码(或游戏内容)进行大量更改时,都应该更新配置文件。

具体时间取决于您的构建环境以及您所处的开发阶段。

如前所述,您不应在构建环境发生重大变更时使用配置数据;虽然这样做并不会阻止构建过程或破坏 build,但会降低使用 PGO 的性能优势,因为只有极少的配置数据可适用于新的构建环境。不过,配置数据可能过时的情况并不只有这一种。

我们首先假设您只在开发接近尾声、准备发布之前才会使用 PGO,除此之外您要做的可能只是收集每周捕获数据,以便负责性能的工程师能够确保临近发布之时不会出现任何意外问题。

接近发布时期时,情况就不同了,这时质量检查团队每天都在进行测试并详尽地运行游戏。在此阶段,您每天都可以通过测试数据生成配置文件,并使用这些配置文件为未来的 build 提供相关信息,以便进行性能测试并调整您自己的性能预算。

在准备发布某个版本时,您应锁定计划发布的 build 版本,然后由质量检查团队完整运行该 build,从而生成新的配置数据。您随后可以使用这些数据进行构建,生成可执行文件的最终版本。

然后,质量检查团队会对这个经过优化且即将交付的 build 进行最后一次完整运行,确保该 build 可以顺利发布。