背景
最近帮项目组的 Unity 引擎从零接入 Android PGO,在记录下一些知识点和需要注意的事项。
知识点
什么是 PGO
Profile-guided optimization (PGO)又称 feedback-directed optimization (FDO) 是指利用程序运行过程中采集到的 profile 数据,来重新编译程序以达到优化效果的 post-link 优化技术。它是一种通用技术,不局限于某种语言。具体解释可以参考下方链接:
https://zhuanlan.zhihu.com/p/652814504
clang pgo 相关知识点
Android 平台主要通过 clang 编译器进行编译,因此以下介绍来自 clang 相关文档。
Profile 信息有助于实现更优的优化效果。例如,了解到某个分支(branch)的执行频率非常高,编译器在对基本块(basic blocks)进行排序时就能做出更合理的决策。了解到函数 foo
的调用频率高于另一个函数 bar
,则有助于内联优化的执行。对于使用配置文件引导优化(profile guided optimization,简称 PGO),建议编译优化等级设置为 -O2 及以上。
Clang 支持两种不同类型的性能分析来实现基于性能分析的优化。一种是采样分析器(sampling profiler),它在运行时开销非常低,可以生成性能数据;另一种是构建带有插桩的代码版本,收集更详细的性能信息。这两种性能数据都能提供代码中指令的执行次数、分支执行情况以及函数调用信息。
无论使用哪种性能分析方式,都要注意用能够代表典型行为的输入来运行代码并收集性能数据。性能分析中未被执行的代码会被编译器视为不重要,从而可能导致编译器对实际使用频繁的代码做出不合理的优化决策。
采样和插桩的不同
通过一种方式生成的性能数据不能被另一种方式使用,且没有转换工具可以将一种性能数据转换为另一种。因此,通过
-fprofile-generate
或-fprofile-instr-generate
生成的性能数据必须与-fprofile-use
或-fprofile-instr-use
一起使用。类似地,由外部分析器生成的采样性能数据必须经过转换后,才能与-fprofile-sample-use
或-fauto-profile
一起使用;插桩生成的性能数据既可以用于代码覆盖率分析,也可以用于优化;
采样生成的性能数据只能用于优化,不能用于代码覆盖率分析。虽然从技术上讲可以使用采样性能数据进行代码覆盖率分析,但采样数据的粒度过于粗糙,无法满足代码覆盖率的需求,结果会很差;
采样性能数据必须由外部工具生成。该工具生成的性能数据随后必须转换成 LLVM 能够读取的格式。关于采样分析器的章节介绍了支持的采样性能数据格式之一。
使用 Sampling Profilers
采样分析器用于在应用程序运行时收集运行时信息,例如硬件计数器。它们通常非常高效,不会带来较大的运行时开销。分析器收集的采样数据可以在编译过程中使用,以确定代码中执行频率最高的部分。
使用采样分析器的数据需要对程序的构建方式进行一些调整。在编译器能够利用性能分析信息之前,代码必须先在分析器下运行。以下是使用采样分析器进行优化时的典型构建流程:
使用包含源代码行号信息的方式构建代码。你可以使用平时构建应用程序时使用的所有常规编译选项。唯一的要求是必须生成包含源代码行号信息的 DWARF 调试信息。该 DWARF 信息对于分析器能够将指令映射回源代码行位置非常重要。通过使用
-fdebug-info-for-profiling
和-funique-internal-linkage-names
选项,可以进一步提升这些 DWARF 信息的有效性。On Linux:
clang++ -O2 -gline-tables-only \\ -fdebug-info-for-profiling -funique-internal-linkage-names \\ code.cc -o code
虽然 MSVC 风格的目标默认使用 CodeView 调试信息,但生成基于源代码级别的 LLVM 性能分析需要 DWARF 调试信息。使用
-gdwarf
选项来包含 DWARF 调试信息:clang-cl /O2 -gdwarf -gline-tables-only ^ /clang:-fdebug-info-for-profiling /clang:-funique-internal-linkage-names ^ code.cc /Fe:code /fuse-ld=lld /link /debug:dwarf
注意:
-funique-internal-linkage-names
会根据命令行中给定的源文件路径生成唯一的名称。如果你的构建系统使用的是绝对源文件路径,且这些路径可能在第1步到第4步之间发生变化,那么生成的唯一函数名称也会随之改变,导致性能分析数据无法被正确使用。在这种情况下,建议考虑省略该选项。
在采样分析器下运行可执行文件。具体使用哪个分析器并不重要,只要其输出能够转换为 LLVM 优化器能够理解的格式即可。
例如:Linux 的 Perf 和 Intel 的 Sampling Enabling Product(SEP),后者作为英特尔 VTune 的一部分提供。Perf 仅适用于 Linux,而 SEP 则可以在 Linux、Windows 和 FreeBSD 上使用。 LLVM 工具 llvm-profgen 可以转换 Perf 或 SEP 的输出。一个外部项目 AutoFDO(https://github.com/google/autofdo) 也提供了一个 create_llvm_prof 工具,支持 Linux Perf 的输出。
当使用 Perf:
perf record -b -e BR_INST_RETIRED.NEAR_TAKEN:uppp ./code
如果上述事件不可用,branches:u 可能是次佳选择。
请注意 -b 参数的使用。该参数告诉 Perf 使用“最后分支记录”(Last Branch Record,LBR)来记录调用链。虽然这不是严格必须的,但它能提供更好的调用信息,从而提高分析数据的准确性。
当使用 SEP:
sep -start -out code.tb7 -ec BR_INST_RETIRED.NEAR_TAKEN:precise=yes:pdir -lbr no_filter:usr -perf-script brstack -app./code
这会生成一个 code.perf.data.script 输出文件,该文件可以与 llvm-profgen 的 --perfscript 输入选项一起使用。
将收集到的分析数据转换为 LLVM 的采样分析格式。目前这项功能通过 AutoFDO 提供的转换工具 create_llvm_prof 支持。构建并安装完成后,可以使用以下命令将 perf.data 文件转换为 LLVM 格式:
create_llvm_prof --binary=./code --out=code.prof
这将读取 perf.data 和二进制文件 ./code,并生成名为 code.prof 的 profile data。请注意,如果你运行 perf 时没有使用 -b 参数,调用 create_llvm_prof 时需要加上 --use_lbr=false。
或者,也可以使用 LLVM 工具 llvm-profgen 来生成 LLVM 采样分析文件:
llvm-profgen --binary=./code --output=code.prof --perfdata=perf.data
请注意,perf.data 必须使用 Linux perf 的 -b 参数收集,以上步骤才能正常工作。
使用 SEP 时,输出是与 llvm-profgen 的 --perfscript 选项对应的文本格式。例如:
llvm-profgen --binary=./code --output=code.prof --perfscript=code.perf.data.script
使用收集到的分析数据重新编译代码。此步骤将分析数据反馈给优化器,从而生成比原始版本执行更快的二进制文件。请注意,重新编译时不必使用与第一步完全相同的参数,唯一的要求是使用相同的调试信息选项和 -fprofile-sample-use 参数。
On Linux:
clang++ -O2 -gline-tables-only \\ -fdebug-info-for-profiling -funique-internal-linkage-names \\ -fprofile-sample-use=code.prof code.cc -o code
On Window:
clang-cl /O2 -gdwarf -gline-tables-only ^ /clang:-fdebug-info-for-profiling /clang:-funique-internal-linkage-names ^ -fprofile-sample-use=code.prof code.cc /Fe:code -fuse-ld=lld /link /debug:dwarf
【可选】基于采样的分析数据可能存在不准确或缺失的代码块/边计数。可以使用分析推断算法(profi)来推断缺失的代码块和边计数,从而提升分析数据的质量。通过添加参数 -fsample-profile-use-profi 来启用该功能。例如,在 Linux 上:
clang++ -fsample-profile-use-profi -O2 -gline-tables-only \\
-fdebug-info-for-profiling -funique-internal-linkage-names \\
-fprofile-sample-use=code.prof code.cc -o code
Windows:
clang-cl /clang:-fsample-profile-use-profi /O2 -gdwarf -gline-tables-only ^
/clang:-fdebug-info-for-profiling /clang:-funique-internal-linkage-names ^
-fprofile-sample-use=code.prof code.cc /Fe:code -fuse-ld=lld /link /debug:dwarf
Sample Profile Formats
外部性能分析器生成的分析数据格式多样,为了让 LLVM 后端能够读取这些数据,必须将它们转换成 LLVM 支持的三种采样分析数据格式之一。具体说明如下:
ASCII 文本格式
这是最容易生成的一种格式。文件被划分为多个部分,每个部分对应一个带有分析信息的函数。该格式有详细的描述,也可以通过 llvm-profdata 工具将二进制格式或 gcov 格式转换成这种文本格式。
二进制编码格式
这种格式采用更高效的编码方式,生成的分析文件体积更小。它是由 https://github.com/google/autofdo 中的 create_llvm_prof 工具生成的。
GCC 编码格式
基于 gcov 格式,是 GCC 接受的格式。主要适用于 GCC 和 Clang 共存的环境。该格式由 https://github.com/google/autofdo 中的 create_gcov 工具生成。LLVM 和 llvm-profdata 可以读取这种格式,但无法生成。
如果你使用 Linux Perf 来生成采样分析数据,可以使用前面提到的 create_llvm_prof 转换工具将数据转换成 LLVM 支持的格式。否则,你需要自己编写转换工具,将你所用分析器的原生格式转换成上述三种格式之一,才能被 LLVM 后端正确读取和使用。
插桩性能分析
Clang 也支持通过插桩进行性能分析。这需要构建一个特殊的插桩版本的代码,并且在分析期间会带来一定的运行时开销,但它比采样分析器提供更详细的结果。此外,它还能提供可复现的结果,至少在代码在多次运行中行为一致的情况下是如此。
Clang 支持两种类型的插桩:基于前端的插桩和基于中间表示(IR)的插桩。基于前端的插桩可以通过选项 -fprofile-instr-generate
启用,基于 IR 的插桩可以通过选项 -fprofile-generate
启用。为了获得最佳的 PGO 性能,应该使用基于 IR 的插桩。它具有插桩开销更低、生成的原始分析数据文件更小以及运行时性能更好的优点。另一方面,基于前端的插桩在源代码关联性方面表现更好,因此适合用于基于源代码行的覆盖率测试。
选项 -fcs-profile-generate
也使用与 -fprofile-generate
相同的插桩方法对程序进行插桩,但它是在内联后期进行插桩,能够生成上下文敏感的分析数据。
以下是使用插桩进行基于性能的优化(PGO)的步骤:
通过使用
-fprofile-generate
或-fprofile-instr-generate
选项进行编译和链接,构建带有插桩的代码版本。clang++ -O2 -fprofile-instr-generate code.cc -o code
使用能够反映典型使用场景的输入运行带有插桩的可执行文件。默认情况下,性能分析数据会被写入当前目录下的
default.profraw
文件。你可以通过使用选项-fprofile-instr-generate=
或设置环境变量LLVM_PROFILE_FILE
来指定其他文件名以覆盖默认设置。如果环境变量和命令行选项都指定了非默认的文件名,则以环境变量为准。指定的文件名模式可以包含不同的修饰符:%p
、%h
、%m
、%b
、%t
和%c
。文件名中的
%p
会被替换为进程 ID,这样你就可以轻松区分多次运行生成的性能分析输出文件。LLVM_PROFILE_FILE="code-%p.profraw" ./code
修饰符
%h
适用于同一个带插桩的二进制文件在多台不同主机上运行,并将性能分析数据写入共享的网络存储的场景。%h
会被替换为主机名,从而避免不同主机收集的性能数据相互覆盖。虽然使用
%p
修饰符可以减少不同进程生成的性能数据文件相互覆盖的可能性,但由于操作系统可能会重用进程 ID,覆盖问题仍然可能发生。使用%p
的另一个副作用是原始性能数据文件的存储需求大幅增加。为避免这些问题,可以在性能数据文件名中使用%m
修饰符。当使用该修饰符时,性能分析运行时会将%m
替换为与带插桩二进制文件关联的整数标识符。此外,来自不同进程且共享文件系统(可能位于不同主机)的多个原始性能数据文件会在写入时由性能分析运行时自动合并。如果程序链接了多个带插桩的共享库,每个库会将性能数据写入各自的性能数据文件(文件名中包含对应的整数标识符)。需要注意的是,%m
启用的合并仅针对性能分析运行时生成的原始性能数据。合并后的“原始”性能数据文件仍需转换为编译器所需的格式(见下面第3步)。LLVM_PROFILE_FILE="code-%m.profraw" ./code
将多次运行生成的性能数据合并,并将“原始”(raw)性能数据格式转换为 clang 所期望的输入格式。可以使用 llvm-profdata 工具的 merge 命令来完成这一步骤。
llvm-profdata merge -output=code.profdata code-*.profraw
请注意,即使只有一个“raw”性能数据文件,也必须执行此步骤,因为合并操作同时会更改文件格式。
使用
-fprofile-use
或-fprofile-instr-use
选项重新编译代码,以指定已收集的性能数据。clang++ -O2 -fprofile-instr-use=code.profdata code.cc -o code
你可以多次重复第4步,而无需重新生成性能数据。随着你对代码的修改,clang 可能无法继续使用已有的性能数据,届时它会给出警告。
请注意,-fprofile-use 选项在语义上等同于其 GCC 对应选项,但它不支持 GCC 生成的性能数据格式。无论性能数据是由前端还是 IR 传递生成,-fprofile-use 和 -fprofile-instr-use 两个选项都接受索引格式的性能数据。
微调
PGO 框架为用户程序提供了用于微调配置文件收集的控制选项。具体而言,PGO 运行时提供了以下函数,可用于控制程序中应收集配置文件数据的代码区域。
void __llvm_profile_set_filename(const char* Name)
:改变 profile 文件的名称void __llvm_profile_reset_counters(void)
:重置所有计数为 0int __llvm_profile_dump(void)
:写入 profile 数据到磁盘上
这些 API 的名称可以通过两种方式引入用户程序中。在支持弱符号(weak symbols)在链接期间视为空值(null)的平台上,可以将它们声明为弱符号。例如,用户可以这样做:
__attribute__((weak)) int __llvm_profile_dump(void);
// Then later in the same source file
if (__llvm_profile_dump)
if (__llvm_profile_dump() != 0) { ... }
// The first if condition tests if the symbol is actually defined.
// Profile dumping only happens if the symbol is defined. Hence,
// the user program works correctly during normal (not profile-generate)
// executions.
另一种方式,用户程序可以包含头文件 profile/instr_prof_interface.h,该头文件中包含了这些 API 的名称定义。例如:
#include "profile/instr_prof_interface.h"
// Then later in the same source file
if (__llvm_profile_dump() != 0) { ... }
用户代码无需检查这些 API 名称是否已定义,因为当 clang 编译器未以“生产配置文件”(profile generation)模式进行编译时,这些 API 名称会被自动替换为 (0) 或等效于“空操作”(noop)的内容。
之所以能实现上述替换,是因为 clang 编译器会根据 -fprofile-generate
和 -fprofile-use
这两个编译标志,自动添加两个宏中的其中一个。
__LLVM_INSTR_PROFILE_GENETATE
:对应于-fprofile[-instr]-generate
/-fcs-profile-generate
__LLVM_INSTR_PROFILE_USE
:对应于-fprofile-use
/-fprofile-instr-use
注意
如何针对生成的多个 profile 文件进行加权处理:
llvm-profdata merge -o TargetName-Android.profdata --weighted-input=10,DataFilename_A.pgo DataFilename_B.pgo
PGO 检测系统通常会在某项程序结束后,将 PGO 写入磁盘。在 Android 上,应用程序不会结束,而会一律终止。这表示永远也不会触发预设的写入磁盘功能,因此必须手动写入 PGO 文件。
__llvm_profile_dump
如果程序加载了多个插桩二进制文件/库,则每个库会生成一个单独的 profile 文件。
参考链接
https://source.android.com/docs/core/perf/pgo?hl=zh-cn
https://developer.android.com/games/agde/configure-pgo?hl=zh-tw
https://clang.llvm.org/docs/UsersManual.html#profile-guided-optimization
https://zhuanlan.zhihu.com/p/673637699