背景

最近帮项目组的 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),它在运行时开销非常低,可以生成性能数据;另一种是构建带有插桩的代码版本,收集更详细的性能信息。这两种性能数据都能提供代码中指令的执行次数、分支执行情况以及函数调用信息。

无论使用哪种性能分析方式,都要注意用能够代表典型行为的输入来运行代码并收集性能数据。性能分析中未被执行的代码会被编译器视为不重要,从而可能导致编译器对实际使用频繁的代码做出不合理的优化决策。

采样和插桩的不同

  1. 通过一种方式生成的性能数据不能被另一种方式使用,且没有转换工具可以将一种性能数据转换为另一种。因此,通过 -fprofile-generate-fprofile-instr-generate 生成的性能数据必须与 -fprofile-use-fprofile-instr-use 一起使用。类似地,由外部分析器生成的采样性能数据必须经过转换后,才能与 -fprofile-sample-use-fauto-profile 一起使用;

  2. 插桩生成的性能数据既可以用于代码覆盖率分析,也可以用于优化;

  3. 采样生成的性能数据只能用于优化,不能用于代码覆盖率分析。虽然从技术上讲可以使用采样性能数据进行代码覆盖率分析,但采样数据的粒度过于粗糙,无法满足代码覆盖率的需求,结果会很差;

  4. 采样性能数据必须由外部工具生成。该工具生成的性能数据随后必须转换成 LLVM 能够读取的格式。关于采样分析器的章节介绍了支持的采样性能数据格式之一。

使用 Sampling Profilers

采样分析器用于在应用程序运行时收集运行时信息,例如硬件计数器。它们通常非常高效,不会带来较大的运行时开销。分析器收集的采样数据可以在编译过程中使用,以确定代码中执行频率最高的部分。

使用采样分析器的数据需要对程序的构建方式进行一些调整。在编译器能够利用性能分析信息之前,代码必须先在分析器下运行。以下是使用采样分析器进行优化时的典型构建流程:

  1. 使用包含源代码行号信息的方式构建代码。你可以使用平时构建应用程序时使用的所有常规编译选项。唯一的要求是必须生成包含源代码行号信息的 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步之间发生变化,那么生成的唯一函数名称也会随之改变,导致性能分析数据无法被正确使用。在这种情况下,建议考虑省略该选项。

  1. 在采样分析器下运行可执行文件。具体使用哪个分析器并不重要,只要其输出能够转换为 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 输入选项一起使用。

  2. 将收集到的分析数据转换为 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
    
  3. 使用收集到的分析数据重新编译代码。此步骤将分析数据反馈给优化器,从而生成比原始版本执行更快的二进制文件。请注意,重新编译时不必使用与第一步完全相同的参数,唯一的要求是使用相同的调试信息选项和 -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)的步骤:

  1. 通过使用 -fprofile-generate-fprofile-instr-generate 选项进行编译和链接,构建带有插桩的代码版本。

    clang++ -O2 -fprofile-instr-generate code.cc -o code
    
  2. 使用能够反映典型使用场景的输入运行带有插桩的可执行文件。默认情况下,性能分析数据会被写入当前目录下的 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
    
  3. 将多次运行生成的性能数据合并,并将“原始”(raw)性能数据格式转换为 clang 所期望的输入格式。可以使用 llvm-profdata 工具的 merge 命令来完成这一步骤。

    llvm-profdata merge -output=code.profdata code-*.profraw
    

    请注意,即使只有一个“raw”性能数据文件,也必须执行此步骤,因为合并操作同时会更改文件格式。

  4. 使用 -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) :重置所有计数为 0

  • int __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