接上篇文章,outline 不仅仅是用作减少代码体积的优化技术,还可以用来优化性能,可以有 2% 左右的提升,本文简单介绍一下。

1、hot/cold splitting

hot/cold splitting(冷热块分离) 旨在提升代码内存的局部性,这个优化技术是识别出冷块(cold block,或 the sink,即使用频率低的代码块),然后将它们移出到单独的函数当中。这样我们保持更多的热块在一起,提升内存的使用效率,对程序启动性能也有帮助。

在 LLVM 中通过 -llvm -hot-cold-split=true 来开启 HotColdSplitting Pass,一般而言是默认开启的优化,主要实现逻辑在 lib/Transforms/IPO/HotColdSplitting.cpp 文件中。

这个优化是在 IR 层面上实现的,利用运行时 profile 信息和静态分析的手段,来进行优化。还可以通过 -llvm -enable-cold-section=true 来把冷块都移到一个单独的 section 里存放,可设置段名,默认名字是 __llvm_cold

当然也会有在 Machine IR 级别上的 hot/cold splitting 优化,通过 -mllvm -split-machine-functions=true 开启 MachineFunctionSplitter Pass,不过这个 pass 需要基于运行时 profile 信息来处理,或者也可以直接应用于异常处理(exception handler) 的代码。

2、依靠运行时信息的静态优化

这部分简单展开一下,然后再讲到 hot/cold splitting。

引入一套框架来在运行时进行动态优化或许过于臃肿,但只要拿到运行时信息记录 profile,那么在静态优化的时候也是可以基于这个信息做优化的。但是对于 profile 的准确度和匹配程度要求会比较高,且不能动态调整,操作流程会比较繁琐一点。

这种技术称之为 FDO(feedback-driven optimization) 或 PGO(profile-guided optimization)。在 “LLVM Instrumentation 程序探测” 文章中介绍过,实现 PGO 的方式有两种,一种是提前编译插桩,然后运行程序生成 profile 信息;另一种是在程序运行的时候用额外的工具(如 perf 等) 收集 profile 信息。然后都是基于 profile 信息再进行编译静态优化,生成最终的二进制产物。

2.1、instrumentation-based PGO

这里拿基于插桩获取 profile 信息的 PGO 方式展开介绍。

插桩是基于 MST(minimum spanning tree、最小生成树) 计算,在函数当中选取最小量的必要的代码块,插入 profiling 计数器的代码。在二进制产物当中,会多出用来存放计数器以及相关数据的 section 段。

多出的指令如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fun():  // @fun()
adrp  x8, .L__profc_fun()             // 获取指针
ldr  x9, [x8, :lo12:.L__profc_fun()]  // 获取指针中的数据
add  x9, x9, #1                       // +1
str  x9, [x8, :lo12:.L__profc_fun()]  // 存放到指针指向的内存中

__llvm_profile_raw_version:
.xword  72057594037927944 // 0x100000000000008

.L__profc_fun():
.zero  8

__llvm_profile_filename:
.asciz  "file/default_%m.profraw"

插桩后的二进制,默认会使用静态初始化(static initializer) 来进行 profiling 的准备和注册,也可以自行手动调用。在进程启动的时候,调用 __llvm_profile_init 开始初始化操作,profile 文件的生成路径可以使用 LLVM_PROFILE_FILE 环境变量控制,由 __llvm_profile_initialize_file 解析。在进程结束时调用 __llvm_profile_write_file 写入到 profile 文件到磁盘当中。

拿到 profile 文件之后,使用 llvm-profdata 工具进行进一步的处理。

举一些优化的例子,可以用来提升性能:

  • block layout/block ordering。代码块的布局重排,对分支跳转相关的代码进行良好调整,可以有效提升局部性、减少内存操作。
  • spill placement/register allocation。关于有限的寄存器如何有效地存储过多的存活的变量。
  • inlining heuristics。函数内联的可能性。
  • hot/cold partitioning。冷热块分离,指执行频率的高低。
  • optimizing virtual calls。脱虚,在 “C++ 虚函数优化探索简介” 中有介绍过。

简单展开其中的几个讲讲。

2.1.1、block layout

一般情况下,最简单的代码块的排列顺序的方式,是按照开发者源码的顺序来设置。但这通常不是性能最好的方式。

比如下图,函数 a 有两个分支,分别是函数 b 和函数 c,基于 profile 信息,如果我们发现有 90% 的情况下,都不会调用到函数 b,那么我们为了提升函数的局部性,可以把函数 c 挪到函数 a 的后面,对于现代 CPU 的分支预测也有一定的好处。

2.1.2、register allocation

虽然 RISC(精简指令集) 架构的寄存器数量会比 CISC(复杂指令集) 架构的多,但是否能对寄存器合理使用,还是对性能会有不少影响。

有了 profile 信息,编译器可以比较容易地发现使用得频繁的活跃变量,然后优先分配寄存器,减少从 cache/内存当中读取数据,提升数据读写的性能。

2.1.3、inlining heuristics

基于 profile 信息,把被高频调用的函数 inline 到调用者中,减少函数调用的开销。正常情况下,编译器决定是否要 inline 是基于代码块的大小;而当有了 profile 信息,做这个决定就变得不那么困难。主要判断逻辑在 llvm/lib/Transforms/Utils/InlineFunction.cpp 中。

2.1.4、hot/cold partitioning

冷热代码块基于 profile 当中的执行频次来进行判断,通过 ProfileSummaryInfo 可以拿到相关的信息,可以判断 BlockFrequencyInfo 或 BranchProbabilityInfo。如果分离冷块的收益(分离出来的指令总大小) 大于损耗(调用被分离的冷块和读取数据的额外操作占用),那么就可以进行提取分离。

被分离的冷块会以 <原函数名>.cold.<num> 来命名。

3、静态分析

获取运行时 profile 信息并不是那么简单的事情,那么其实 hot/cold splitting 也可以利用一些静态分析来进行辅助判断。

在 LLVM 的 unlikelyExecuted 函数中会对 BasicBlock 进行静态分析判断,像 Exception handling blocks(异常处理的块)、调用了冷函数的块、以及 unreachable 的块都可能判断为冷块。

虽然但是,没有运行时 profile 信息的帮助,靠静态分析,这个优化会大打折扣。

我们还可以利用在源码中使用注解来辅助编译器判断,类似 __builtin_expect 函数,可以告知编译器更可能的执行路径。还有 __attribute__((cold)) 的注解,直接标明这是一个冷函数。

4、扩展

分离冷块成独立的函数之后,可以结合 -llvm -enable-merge-functions=true 来开启 MergeFunctions Pass,合并相同的函数,减少一些代码体积。

还有个没合入的激进想法 Randomly outline code for cold regions,即在没有 profile 信息的情况下,二八法则划分冷热块,认为 20% 热块,80% 冷块,怎么划分?随机!生成随机数然后判断。有点暴力,所以这个肯定不会被接受了。

与 HotColdSplitting Pass 类似而不太相同的一个优化:PartialInlining Pass,可以通过 -llvm -enable-partial-inlining=true 开启。它不把整个函数体内联到调用处,而是选择函数体的某部分来进行 inline 操作,这里通常是热块。

参考