C++ 项目编译优化 —— TiFlash

C++ 提供了很大的工程自由度;如果缺少明确的依赖边界和持续度量,模板、头文件与编译单元的成本很容易随项目规模一起放大。

随着工程规模增大,TiFlash 项目也逐渐开始暴露出这类问题:

  • 头文件凌乱
  • 模板滥用
  • 编译缓慢
  • 工程质量愈发难以控制

本文旨在提供量化指标和参考方法用以优化 C++ 工程项目的编译流程。希望动员社区的力量,一起来优化 TiFlash 的工程质量。

全文包含两条主线:

  • 构建效率:time trace、模板实例化、头文件依赖、编译单元、PCH、PImpl 和 ccache。
  • 产物运行性能:LTO、PGO、BOLT 和代码大页。这些技术通常会增加构建成本,目标不是缩短日常编译时间。

基础概念

通常,完整的构建流程包括预处理(Preprocessing)、编译(Compilation)、汇编(Assembly)和链接(Linking)。Clang/LLVM 的编译流水线可以大致分为前端(Frontend)和后端(Backend):

  • 前端负责预处理、语法解析、语义分析、模板实例化以及 LLVM IR 生成。Clang time trace 中的 ParseClassInstantiateClassInstantiateFunctionCodeGen Function 等事件可用于定位相应成本。
  • 后端以 LLVM IR 为输入,执行优化 Pass、指令选择、寄存器分配和机器码生成。常见 Pass 可参考 LLVM’s Analysis and Transform Passes

C++ 编译器以源文件为编译单元,基本流程可参考 Phases of translationLinux 动态链接库相关整理#Linux-动态库 这篇文章介绍过相关的几个案例(主要偏后端和链接器),可供参考。

除明确标注 GNU ld 或 g++ 的案例外,编译命令默认使用 Clang。TiFlash 实验基于 LLVM 13 和指定版本的 TiFlash;不同编译器版本、优化等级、标准库与硬件环境可能产生不同结果,文中的耗时数据主要用于展示分析方法和相对变化。

编译流程分析

编译 TiFlash 时加上 CMake 选项 -DENABLE_TIME_TRACES=ON,即增加 Clang 编译参数 -ftime-trace,令编译每个目标文件时以 JSON 格式输出相关分析追踪数据。可在 Chromium 系浏览器中打开 chrome://tracing,或使用网站 Speedscope App 查看火焰图。FlameGraphsExample

以这个 commit 的 TiFlash 代码为例 2d234262eb551b04b0ce304c7c6bacac847ca264。编译完成后,可以在编译路径 ${TIFLASH_BUILD_DIR} 下找到各个目标文件对应的 json 文件,例如 ${TIFLASH_BUILD_DIR}/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/FunctionsTiDBConversion.cpp.json。根据文章 Aras Pranckevičius - Clang Build Analyzer,通过工具 ClangBuildAnalyzer 分析 TiFlash 编译过程的各项属性:

1
2
3
4
5
6
git clone https://github.com/aras-p/ClangBuildAnalyzer.git
cd ClangBuildAnalyzer
make -f projects/make/Makefile
tmpfile=$(mktemp /tmp/ClangBuildAnalyzer-capture_file.XXXXXX)
./build/ClangBuildAnalyzer --all ${TIFLASH_BUILD_DIR} ${tmpfile}
./build/ClangBuildAnalyzer --analyze ${tmpfile}

结果如下,前端累计耗时达到 7181.8 秒,高于后端的 6224.5 秒,说明模板实例化和头文件解析可能占据了较高成本,值得进一步分析。以下各个章节将针对此进行分析并简述优化过程。

ClangBuildAnalyzer 分析结果(未优化)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
**** Time summary:
Compilation (5485 times):
Parsing (frontend): 7181.8 s
Codegen & opts (backend): 6224.5 s

**** Files that took longest to parse (compiler frontend):
185258 ms: /data2/work/build-llvm-tiflash/dbms/src/Flash/CMakeFiles/flash_service.dir/Coprocessor/DAGExpressionAnalyzerHelper.cpp.o
182847 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/FunctionsTiDBConversion.cpp.o
181468 ms: /data2/work/build-llvm-tiflash/dbms/src/Flash/CMakeFiles/flash_service.dir/Coprocessor/DAGExpressionAnalyzer.cpp.o
...

**** Files that took longest to codegen (compiler backend):
406975 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/divide.cpp.o
205837 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/FunctionsComparison.cpp.o
205416 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/FunctionsTiDBConversion.cpp.o
179111 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/modulo.cpp.o
142676 ms: /data2/work/build-llvm-tiflash/dbms/CMakeFiles/dbms.dir/src/Dictionaries/CacheDictionary.cpp.o
133467 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/least.cpp.o
131571 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/greatest.cpp.o
...

**** Templates that took longest to instantiate:
247991 ms: DB::FunctionTiDBCast::createWrapper<false> (3 times, avg 82663 ms)
240476 ms: DB::FunctionTiDBCast::createWrapper<true> (3 times, avg 80158 ms)
228596 ms: std::__function::__func<(lambda at /root/work/tiflash/dbms/src/Functions/FunctionsTiDBC... (7200 times, avg 31 ms)
227424 ms: std::__function::__func<(lambda at /root/work/tiflash/dbms/src/Functions/FunctionsTiDBC... (7200 times, avg 31 ms)
194991 ms: std::function<void (DB::Block &, const std::vector<unsigned long> &, unsigned long, bool, const ti... (2400 times, avg 81 ms)
194077 ms: std::function<void (DB::Block &, const std::vector<unsigned long> &, unsigned long, bool, const ti... (2400 times, avg 80 ms)
193740 ms: std::__function::__value_func<void (DB::Block &, const std::vector<unsigned long> &, unsigned long... (2400 times, avg 80 ms)
192884 ms: std::__function::__value_func<void (DB::Block &, const std::vector<unsigned long> &, unsigned long... (2400 times, avg 80 ms)
191873 ms: std::__function::__value_func<void (DB::Block &, const std::vector<unsigned long> &, unsigned long... (2400 times, avg 79 ms)
191093 ms: std::__function::__value_func<void (DB::Block &, const std::vector<unsigned long> &, unsigned long... (2400 times, avg 79 ms)
132499 ms: DB::castTypeToEither<DB::DataTypeNumber<unsigned char>, DB::DataTypeNumber<unsigned short>, DB::Da... (20 times, avg 6624 ms)
132198 ms: DB::castTypeToEither<DB::DataTypeNumber<unsigned char>, DB::DataTypeNumber<unsigned short>, DB::Da... (320 times, avg 413 ms)
131016 ms: std::__function::__alloc_func<(lambda at /root/work/tiflash/dbms/src/Functions/Function... (7200 times, avg 18 ms)
130531 ms: std::__function::__alloc_func<(lambda at /root/work/tiflash/dbms/src/Functions/Function... (7200 times, avg 18 ms)
128066 ms: DB::castTypeToEither<DB::DataTypeNumber<unsigned char>, DB::DataTypeNumber<unsigned short>, DB::Da... (5120 times, avg 25 ms)
...

**** Template sets that took longest to instantiate:
734148 ms: std::function<$>::function<$> (9422 times, avg 77 ms)
729456 ms: std::__function::__value_func<$>::__value_func<$> (9422 times, avg 77 ms)
598582 ms: std::__function::__func<$>::__func (9422 times, avg 63 ms)
494799 ms: std::__function::__alloc_func<$>::__alloc_func (28266 times, avg 17 ms)
488467 ms: DB::FunctionTiDBCast::createWrapper<$> (6 times, avg 81411 ms)
419445 ms: DB::FunctionTiDBCast::createWrapperForDecimal<$> (480 times, avg 873 ms)
381118 ms: std::forward_as_tuple<$> (39069 times, avg 9 ms)
...

**** Function sets that took longest to compile / optimize:
31177 ms: DB::IAggregateFunctionHelper<$>::addBatchLookupTable8(unsigned long, char**, unsigned long, std::__1::function<$>, unsigned char const*, DB::IColumn const**, DB::Arena*) const (1250 times, avg 24 ms)
...

*** Expensive headers:
1091837 ms: /root/work/tiflash/dbms/src/Common/FmtUtils.h (included 750 times, avg 1455 ms), included via:
1022467 ms: /root/work/tiflash/libs/libcommon/include/common/StringRef.h (included 754 times, avg 1356 ms), included via:
985876 ms: /root/work/tiflash/dbms/src/Common/Exception.h (included 748 times, avg 1318 ms), included via:
974680 ms: /root/work/tiflash/dbms/src/Common/StackTrace.h (included 749 times, avg 1301 ms), included via:
681167 ms: /data2/tiflash-env/sysroot/include/c++/v1/string (included 1903 times, avg 357 ms), included via:
501559 ms: /data2/tiflash-env/sysroot/include/c++/v1/algorithm (included 1969 times, avg 254 ms), included via:
498058 ms: /root/work/tiflash/dbms/src/Columns/IColumn.h (included 516 times, avg 965 ms), included via:
422329 ms: /root/work/tiflash/dbms/src/Core/Types.h (included 706 times, avg 598 ms), included via:
405688 ms: /data2/tiflash-env/sysroot/include/c++/v1/functional (included 1971 times, avg 205 ms), included via:

ClangBuildAnalyzer 展示的是各编译任务的累计耗时,不等同于并行构建下的墙钟时间。判断最终收益还应结合 clean build 和增量构建的实际等待时间。

编译优化

当时 TiFlash 使用 LLVM 13 和 LLD。现有 ClangBuildAnalyzer 数据主要暴露编译前端与后端成本,没有显示链接是主要长尾,因此本文首先关注模板实例化、头文件依赖、编译单元和缓存;若要比较 LLD、GNU ld 或 mold,仍应单独记录链接阶段的墙钟时间和峰值内存。后文的 LTOPGOPost-link Optimizer 则用于提升编译产物的运行性能,代价通常是更大的构建耗时和资源消耗。

就绝大部分实际场景而言(尤其是滥用模板的场景),少生成重复代码,便足以有效减轻后端的负担。

模板

在大型 C++ 项目中,过度使用模板容易放大头文件依赖、重复实例化和代码生成成本,是常见的构建性能瓶颈。

模板实例化

模板在什么时候会被实例化?

案例(1.0)
1
2
3
4
5
6
7
8
// test1.h
#pragma once
#include <unordered_map>
struct Test1 {
std::unordered_map<int, int> d_;
};
// test1.cpp
#include "test1.h"
1
2
clang++ -c test1.cpp -o test1.o -ftime-trace -std=gnu++17
// get test1.json

用浏览器打开 test1.json 查看火焰图可知 std::unordered_map<int, int> 已经被实例化,且属于前端行为 ParseClass。意味着编译包含 test1.h 的源文件时均会产生这一重复行为。

1
2
3
4
InstantiateClass {"detail":"std::__1::unordered_map<int, int, std::__1::hash<int>, std::__1::equal_to<int>, std::__1::allocator<std::__1::pair<const int, int> > >"}
> ParseClass {"detail":"Test1"}
> Frontend
> ExecuteCompiler

对于 test4 和 test5 也是同样效果。

1
2
3
4
5
// test4.cpp
#include <unordered_map>
struct Test4 {
template <typename T> std::unordered_map<int, int> test4(T) {}
};
1
2
3
// test5.cpp
#include <unordered_map>
template <typename T> int test5(T) { std::unordered_map<int, int> s; }
1
2
3
// test6.cpp
#include <unordered_map>
template <typename T> struct Test1 { std::unordered_map<int, int> d_; };

然而,对于下面的 test2,火焰图中却看不到 std::unordered_map<int, int> 被实例化的过程。

1
2
3
4
5
6
7
8
9
// test2.cpp
#include <unordered_map>
struct Test2 {
std::unordered_map<int, int> test2(std::unordered_map<int, int>);
template <typename T>
std::unordered_map<int, int> test2_1(std::unordered_map<int, int>);
};
template <typename T>
std::unordered_map<int, int> test2_2(std::unordered_map<int, int>);
分析(1.0)

对于编译过程来说 函数变量 本身就是符号,编译器前端推导基于一定的规则。对于上述案例:

  • test2 只在函数声明的参数和返回类型中使用 std::unordered_map<int, int>,当前编译单元不需要获知该对象的布局,因此没有出现与 test1 相同的完整类实例化事件。
  • 对于 test1/test4/test5/test6,编译器解析类或模板时识别出类模板 std::unordered_map 在结构体或函数实现中被完整定义为 std::unordered_map<int, int> 类型,然后实例化该模板类。
    • test1:普通类成员对象
    • test4:成员函数模板的返回值
    • test5:函数模板中的局部对象
    • test6:类模板的成员对象

需要重点关注的是:如 test4/test5/test6 结果所示,即便编译单元最终没有为这些模板生成机器码,编译器仍可能为了完成名称查找、类型检查和其他语义分析而实例化相关声明或定义。这是 C++ 模板定义可见性和语义检查规则带来的编译成本。C++20 的 Modules 可以减少文本式头文件的重复解析,但不能自动消除所有模板实例化成本。目前而言,仍需优化模板定义和头文件依赖,或者参考 PImplPCH 技术。

案例(1.0.1)

以下方案依赖目标类型在特定平台和标准库实现下的大小、对齐方式与对象生命周期规则,维护风险较高。一般应优先使用标准 PImpl;只有在性能测试确认动态分配和间接访问不可接受时,才考虑这种手工不透明存储方案。

类似于 test1.cpp 的场景理论上比较容易用 PImpl 改造。倘若是性能攸关的代码,无法忍受 PImpl 带来的局部性损失,可参考下面的方式,代价就是牺牲代码可读性以及使用模板的便利性。

  • std::unordered_map<int, int> aka Data
  • Test7::Inner 在当前支持的平台和标准库实现下满足目标类型的大小与对齐要求
    • 内存对齐方式(8 Bytes)与 Data 相同(暂不考虑非 64 位平台或其他目标平台)。
    • 在该实验使用的标准库版本中,Data 在 macOS 下是 40 Bytes,在 Linux 下是 56 Bytes;本例中直接取最大值(或可通过宏定义判断编译平台再设置大小),并通过 static_assert 阻止不满足假设的构建。
  • 在源文件中加上 static_assert 以防止其他改动破坏基本假设。
  • Test7 内部封装构造/析构、以及转换相关接口。
  • 当头文件 test7.h 被其他不相干的源文件所包含,编译器前端解析 struct Test7 时则不需要再实例化 std::unordered_map<int, int>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// test7.h
#pragma once
#include <unordered_map>
using Data = std::unordered_map<int, int>;
struct Test7 {
struct alignas(8) Inner {
unsigned char bytes[56];
};
Data &data();
const Data &data() const;
Test7();
~Test7();
Test7(const Test7 &) = delete;
Test7 &operator=(const Test7 &) = delete;
Test7(Test7 &&) = delete;
Test7 &operator=(Test7 &&) = delete;

private:
Inner data_;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// test7.cpp
#include "test7.h"
#include <new>
#include <type_traits>
static_assert(sizeof(Test7::Inner) >= sizeof(Data));
static_assert(alignof(Test7::Inner) == alignof(Data));
Data &Test7::data() {
return *std::launder(reinterpret_cast<Data *>(&data_));
}
const Data &Test7::data() const {
return *std::launder(reinterpret_cast<const Data *>(&data_));
}
Test7::Test7() { ::new (static_cast<void *>(&data_)) Data(); }
Test7::~Test7() {
static_assert(std::is_destructible_v<Data>);
data().~Data();
}

这里显式删除复制和移动操作,避免按字节复制 Data 后发生重复释放。构造时直接在原始存储地址上 placement new,访问时通过 std::launder 获取已开始生命周期的对象。即便如此,固定的 56 Bytes 和 8 Bytes 对齐仍绑定具体 ABI,只适合作为受严格测试约束的特殊方案。

案例(1.0.2)

类似 test4、test5、test6 的场景,可以将 std::unordered_map<int, int> 从模板实现之处消去,由调用方通过模板参数传入类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// test8.h
struct Test4 {
template <typename T, typename MAP> MAP test4(T) { return {}; }
};
template <typename T, typename MAP> int test5(T) {
MAP s;
(void)s;
return 0;
}
template <typename T, typename MAP> struct Test1 { MAP d_; };
// test8.cpp
#include "test8.h"
#include <unordered_map>
void foo() { Test1<void, std::unordered_map<int, int>>(); }
案例(1.1)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// test9.cpp
#include <cstdint>
#include <optional>
template <typename V> struct Test9_2 {
void foo(bool b) { b ? goo<float>() : goo<double>(); }
template <typename T> void goo() { std::optional<T>{}; }
std::optional<V> v_;
};
struct Test9_1 {
void foo(bool b) { b ? goo<int>() : goo<bool>(); }
template <typename T> void koo() {
goo<std::int64_t>();
Test9_2<char>{}.foo(true);
}
template <typename T> void goo() { std::optional<T>{}; }
};
// template struct Test9_2<char>;

test9 中可以看到编译器前端在 PerformPendingInstantiations 阶段,实例化了模板函数 Test9_1::goo<int>Test9_1::goo<bool>。因为编译器解析普通类成员函数 Test9_1::foo 的实现,至少要推导出这 2 个依赖函数的完整上下文,否则就会失败报错。
模板函数 Test9_1::koo 中调用了其他模板函数 Test9_1::goo<std::int64_t>Test9_2<char>::foo。如果 koo 本身没有实例化,编译器通常不会为这些调用生成代码。对 Test9_2<char> 增加显式实例化定义 template struct Test9_2<char>; 后,才能在该案例的 time trace 中看到 Test9_2<char>::goo<float>Test9_2<char>::goo<double> 的实例化事件。

案例(1.2)

test10 中的 struct Test10 同时定义了 3 个成员函数,并 显式 地实现了 Test10::goo,最终只有 Test10::goo 生成了对应的符号和汇编。

1
2
3
4
5
6
7
// test10.cpp
struct Test10 {
int foo() { return 2; }
template <typename...> int foo2() { return 3; }
int goo();
};
int Test10::goo() { return 1; }
1
2
3
➜ clang++ -c test10.cpp -o test10.o -ftime-trace -std=gnu++17
➜ nm -C test10.o
0000000000000000 T Test10::goo()

当编译单元存在其他函数实现依赖 Test10::fooTest10::foo2<> 时,才会生成相关的符号和汇编。此例中,内联成员函数和模板实例对应的 ELF 符号显示为 W/w(弱符号),链接器可按 C++ ODR/COMDAT 规则合并等价定义。

1
2
3
4
5
6
7
8
// test10.cpp
struct Test10 {
int foo() { return 2; }
template <typename...> int foo2() { return 3; }
int goo();
};
int Test10::goo() { return 1; }
int koo() { return Test10{}.foo() + Test10{}.foo2(); }
1
2
3
4
5
6
➜ clang++ -c test10.cpp -o test10.o -ftime-trace -std=gnu++17
➜ nm -C test10.o
0000000000000010 T koo()
0000000000000000 W Test10::foo()
0000000000000000 T Test10::goo()
0000000000000000 W int Test10::foo2<>()

删减重复模板实例化

案例(2.0)

根据 ClangBuildAnalyzer 分析结果,排名靠前的 FunctionsTiDBConversion.cpp,DAGExpressionAnalyzer.cpp,DAGExpressionAnalyzerHelper.cpp 都是重度模板使用者。而且 template <bool return_nullable> WrapperType createWrapper 是实例化耗时最久的模板函数。

1
2
3
4
5
6
7
8
9
InstantiateFunction {"detail":"DB::FunctionTiDBCast::createWrapper<true>"}
> PerformPendingInstantiations
> Frontend
> ExecuteCompiler

InstantiateFunction {"detail":"DB::FunctionTiDBCast::createWrapper<false>"}
> PerformPendingInstantiations
> Frontend
> ExecuteCompiler

函数的模板参数是一个 bool 类型,全部枚举一遍也就是 2 种情况,共被实例化了 6 次显得很不合理。WHY ?

分析(2)

class FunctionTiDBCast 本身也就只在 DB::FunctionBuilderTiDBCast::buildImpl 函数中被创建使用。为了删除多余的模板实例化,一种简单的改动就如 tiflash/pull/4978

  • 将 class FunctionTiDBCast 变成一个模板类 template <typename...> class FunctionTiDBCast
  • 把 DB::FunctionBuilderTiDBCast::buildImpl 的实现挪到源文件 FunctionsTiDBConversion.cpp 中。如此一来,即便其他源文件包含 FunctionsTiDBConversion.h,因为没有实际使用关系也就不会实例化该模板类及其成员函数。

改动效果如下,DAGExpressionAnalyzerHelper.cpp 的前端耗时从 185258 ms 下降到 20012 ms(降幅 89.2%),DAGExpressionAnalyzer.cpp 则是 181468 ms 降到 19898 ms(降幅 89.0%)。模板函数被实例化的次数也符合预期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
**** Files that took longest to parse (compiler frontend):
188112 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/FunctionsTiDBConversion.cpp.o
...
20012 ms: /data2/work/build-llvm-tiflash/dbms/src/Flash/CMakeFiles/flash_service.dir/Coprocessor/DAGExpressionAnalyzerHelper.cpp.o
19898 ms: /data2/work/build-llvm-tiflash/dbms/src/Flash/CMakeFiles/flash_service.dir/Coprocessor/DAGExpressionAnalyzer.cpp.o

**** Files that took longest to codegen (compiler backend):
...
210576 ms: /data2/work/build-llvm-tiflash/dbms/src/Functions/CMakeFiles/clickhouse_functions.dir/FunctionsTiDBConversion.cpp.o
...

**** Files that took longest to parse (compiler frontend):
...
85109 ms: DB::FunctionTiDBCast<>::createWrapper<false> (1 times, avg 85109 ms)
84390 ms: DB::FunctionTiDBCast<>::createWrapper<true> (1 times, avg 84390 ms)
...
144093 ms: DB::FunctionTiDBCast<$>::createWrapperForDecimal<$> (160 times, avg 900 ms)
...

PS:理论上把头文件里非模板的实现都挪到源文件中(例如 案例(3.1)),可以达到相同的效果,现在这种方式无非是代码改动比较小而已。

拆分|合并编译单元

删减重复模板实例化之后,可以发现 divide.cpp 是最大的编译单元,编译器前端耗时 30783 ms,后端 416467 ms。后端耗时主要是编译这 4 个模板类 dbms/src/Functions/divide.cpp#L308-L333 以及其衍生出的
template <typename... Ts, typename F> static bool castTypeToEither。巨大的编译单元令每次修改代码均陷入漫长的等待。为了充分利用多核资源,避免单核瓶颈,一种简单的方法就是把这 4 个函数及其相关的模板实例拆分到 4 个源文件中。

  • 事实上之前 TiFlash 代码中注册这类 Function 函数的风格也是继承 Clickhouse,按功能独立拆分成:registerFunctionDivideFloating.cpp、registerFunctionDivideIntegral.cpp、registerFunctionDivideIntegralOrZero.cpp、registerFunctionTiDBDivideFloating.cpp 这 4 个源文件。
  • 是在 92e668158c87893535d812773e4bfa501f0a2dc8 中这些源文件被合并成现在这样巨大的编译单元。

合并源文件所带来的好处包括以下几点:

  • 减少头文件前端处理开销:例如某个头文件被多个源文件包含,每个编译单元都需独立解析一遍。
  • 减少重复编译:与 案例(1.2) 类似,头文件中有函数实现,每个编译单元用到该函数时均会生成相关的符号和汇编。但最终链接器只会为符号绑定一种实现。
  • 利于编译器优化:普通编译优化无法跨编译单元

进一步还可以使用 Unity Build,将若干源文件按批次包含进自动生成的 unity 源文件,减少重复解析开销。CMake 通过 CMAKE_UNITY_BUILD 提供支持,并可用 UNITY_BUILD_BATCH_SIZE 控制每批文件数;默认 batch size 是 8,并非必然把整个目标合成一个编译单元。

Unity Build 能让同一批文件共享编译上下文,但它与 LTO 所处阶段和优化能力不同,不能笼统地说比 LTO“更彻底”。主要代价包括:

  • 减少可并行调度的编译任务;修改一个源文件时,需要重编译它所在的整个 unity batch
  • 需解决静态变量或本地定义重名冲突
  • 编译单元资源内存消耗峰值较大
  • CMAKE_EXPORT_COMPILE_COMMANDS、部分静态分析工具和按文件编译选项配合时可能需要额外处理

就工程实践而言,为了兼顾开发效率,对于编译单元可以适度地拆分/合并。可参考的标准为:

  • 根据 ClangBuildAnalyzer 分析结果中后端编译耗时的前几项,找出明显偏高的单元。
  • 如果某个编译单元后端耗时明显高于其他单元,并且成为并行构建的关键路径,可以考虑按逻辑模块拆分。拆分后需要重新测量墙钟时间,因为更多编译单元也会带来重复的头文件解析成本
  • 对于前端耗时相比于后端较高的模块,则可以按需合并
  • 理想情况下,应避免少数超大编译单元成为并行构建的长尾任务,同时控制拆分带来的重复解析成本

显式实例化

基本概念参考 Explicit instantiation,核心语法为:

1
2
template class-key template-name < argument-list > ;
extern template class-key template-name < argument-list > ;
案例(3.0)

extern template 主要用于抑制隐式实例化产生重复代码,并不保证编译器跳过完成语义检查所需的所有实例化工作。此外,声明位置也很重要,通常应在可能触发隐式实例化的使用点之前可见。因此,需要分别通过 time trace 和目标文件符号判断“前端工作是否减少”与“代码是否重复生成”。

下面把显式实例化声明放在调用点之前。这样可以抑制当前编译单元为相应 specialization 生成代码,但模板定义仍需被解析,编译器也仍可能为名称查找和语义检查完成部分实例化工作。因此,应同时检查 time trace 和 nm 输出,不要把“目标文件不再生成定义”等同于“前端完全没有成本”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// test11.cpp
#include <cstdint>
#include <map>
#include <optional>
template <typename...> struct Test11 {
template <typename T> void foo_1() {
std::optional<T>{};
doo_1<T>();
}
template <typename T> void doo_1() { std::map<T, T>{}; }
};
template <typename T> void doo_2() { std::map<T, T>{}; }
template <typename T> void foo_2() {
std::optional<T>{};
doo_2<T>();
}
extern template void Test11<>::foo_1<std::int64_t>();
extern template void Test11<>::doo_1<std::int64_t>();
extern template void foo_2<float>();
void goo() {
Test11{}.foo_1<std::int64_t>();
foo_2<float>();
}
1
2
3
4
5
➜ clang++ -c test11.cpp -o test11.o -ftime-trace -std=gnu++17
➜ nm -C test11.o
0000000000000000 T goo()
U void foo_2<float>()
U void Test11<>::foo_1<long long>()

类可采用定义实现分离的方式来达到类似的效果,不过需要注意的是:如果模板函数有被实际用到(例如 test12_2.cpp),需要像 (1) 处在模板函数实现的源文件内显式实例化,否则会出现找不到相关符号的链接错误。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// test12_1.h
#pragma once
template <typename...> struct Test12 {
template <typename T> void foo_1();
template <typename T> void doo_1();
};

// test12_2.cpp
#include "test12_1.h"
#include <cstdint>
void foo() { Test12<>{}.foo_1<std::int64_t>(); }

// test12_1.cpp
#include "test12_1.h"
#include <cstdint>
#include <map>
#include <optional>
template <typename... Args>
template <typename T>
void Test12<Args...>::foo_1() {
std::optional<T>{};
doo_1<T>();
}
template <typename... Args>
template <typename T>
void Test12<Args...>::doo_1() {
std::map<T, T>{};
}
template void Test12<>::foo_1<std::int64_t>(); // (1)

抽离非模板公共实现

如果类模板中实现了与模板参数无关的函数,可以将这部分逻辑下沉到非模板基类或独立函数,以免每次模板实例化时重复生成代码。这种拆分本身并不自动提供稳定 ABI;若接口需要跨动态库发布,还需单独控制数据布局、符号可见性和版本兼容。

案例(3.1)

每次实例化类模板,编译器均会生成函数 Node<xxx>::foo。如果采用定义实现分离的方式,则需要像 案例(3.0) 显式实例化每个被用到的模板类。

1
2
3
4
5
6
// test15.h
#include <optional>
template <typename T> struct Node {
void foo() { std::optional<float> _; }
T v_;
};

将函数分离到非模板基类中,并通过继承复用,则最后只需要生成一份 NodeBase::foo

1
2
3
4
5
6
7
8
9
10
11
// test15_base.h
struct NodeBase {
void foo();
};
// test15_2.h
#include "test15_base.h"
template <typename T> struct Node : NodeBase { T v_; };
// test15_base.cpp
#include "test15_base.h"
#include <optional>
void NodeBase::foo() { std::optional<float> _; }

头文件优化

头文件依赖

现有工具:

  • 头文件分析工具 include-what-you-use 相对细致,但结果需要人工排查确认,成本较高。例如 TiFlash 的 CMake 编译选项 USE_INCLUDE_WHAT_YOU_USE
  • ClangBuildAnalyzer 工具输出项 Expensive headers 比较简单清晰,展示头文件被包含的路径。需要人工分析依赖关系,找出瓶颈并优化(模板相关可参考上文)。

头文件依赖解耦,可列出说明使用文档和标准,让社区参与。

前向声明

前向声明(Forward declaration)是在尚未给出完整定义时先声明名称和类型。指针、引用和部分函数声明只需不完整类型;按值成员、继承、sizeof、成员访问以及许多模板操作则需要完整定义及其大小、对齐方式等信息。


前向声明对于解耦头文件依赖有较大帮助。然而 Google C++ Style Guide 中却提出 Avoid using forward declarations where possible。比较关键的原因是在某些场景下,前向声明会导致代码静默行为产生变化,例如 案例(4.1)

案例(4.0)

test13_2.cpp 中使用前向声明 struct Test13_1;,并在 struct Test13_2 的成员函数和成员变量中引用。该源文件可以正常编译。

1
2
3
4
5
6
7
8
// test13_2.cpp
struct Test13_1;
struct Test13_2 {
Test13_1 &data;
Test13_1 foo(Test13_1);
};
static_assert(sizeof(Test13_2) == sizeof(void *));
static_assert(alignof(Test13_2) == alignof(void *));
分析(4)
  • 成员变量 Test13_2::data 的类型实际是 引用,其内存大小和对齐方式和 指针 相同,编译器解析时并不需要知道 Test13_1 的具体实现。
  • 成员函数 Test13_2::foo 引用了 Test13_1,但编译单元内没有实现该函数,编译器则将其作为一个函数声明(本身也是种前向声明)。
案例(4.1)

class B 继承了 class A,如果隐藏类定义,则编译单元 test14.cppvoid test_14(B *) 最后会调用 f(void *),如果包含具体定义,在 test14_1.cpp 中则调用 f(A *)

1
2
3
4
5
6
7
8
9
// test14.cpp
#include <cstdio>
class A;
class B;
// struct A {};
// struct B : A {};
void f(A *);
void f(void *);
void test_14(B *x) { f(x); } // call f(void *);
1
2
3
4
5
6
7
8
9
// test14_1.cpp
#include <cstdio>
class A;
class B;
class A {};
class B : public A {};
void f(A *);
void f(void *);
void test_14(B *x) { f(x); } // call f(A *);

这个案例中引起不确定性行为的是 C++ 特有的函数重载和类继承方式:

  • void * 指针可以匹配任意其他指针。
  • test14_1.cpp 中编译器优先按照继承关系匹配,由于不存在 f(B *),则查找顺序为从 f(A *)f(void *)
  • test14.cpp 中不存在继承关系,且不存在 f(B *),则直接匹配到 f(void *)

解决这个案例中的问题,可参考以下途径:

  • 去掉 f(void *) 函数,令编译时直接报错,由开发者排查并补上继承关系。
    • 绝大部分实际应用中,比较常见的是子类继承并重写基类的虚函数,用 void * 兜底很难说有什么实际意义。
  • f(void *) 的逻辑实现中直接抛异常或手动 panic。

在代码设计和审查环节应当注意避开这类问题而非一味禁止使用前向声明。

预编译头文件(PCH)

Precompiled header(简称 PCH):预编译(C 或 C++)头文件,使之被编译成编译器可以更快处理的中间形式,使用预编译头文件可以显著减少编译时间(主要是前端)。CMake 中支持 PCH 的命令为 target_precompile_headers

  • 在 Windows 系统下用 Visual Studio 开发桌面软件时经常能看到头文件 stdafx.h,里面基本会包含开发所用的重型 API。
  • 参考 模板实例化 章节中的几个案例,编译器解析头文件会有不小的开销(尤其涉及到模板实例化),对于 TiFlash 这种 C++ 项目也是个不可忽略的问题。
案例(4.2)

根据 ClangBuildAnalyzer 分析结果Expensive headers 中包含不少公共模块和 STL 库的头文件。5847f1c235b996eb6fb970029da926909e1819fd 为相关库编译选项加上 PCH:

  • pch-common.h 包含 Exception.h,FmtUtils.h,StackTrace.h 之类的公共模块头文件
  • pch-stl.h 包含常用的 STL 库头文件,例如 algorithm,string,vector,functional 等
  • pch-kvpb.h 包含比较重型的 gRPC 相关头文件

优化后,分析结果显示前端总耗时和重型头文件解析耗时显著下降。

1
2
3
4
5
6
7
8
9
10
11
12
13
**** Time summary:
Compilation (5487 times):
Parsing (frontend): 4167.5 s

...

*** Expensive headers:
188125 ms: /data2/work/tiflash/dbms/src/Columns/IColumn.h (included 515 times, avg 365 ms), included via:
...
178572 ms: /data2/work/tiflash/dbms/src/Common/Decimal.h (included 657 times, avg 271 ms), included via:
...
122002 ms: /data2/work/tiflash/dbms/src/Core/Block.h (included 416 times, avg 293 ms), included via:
...

PImpl

PImpl(Pointer to implementation)又称编译防火墙(Compilation firewall)。在类定义中隐藏数据结构的实现细节,只对外暴露不透明的指针/引用/其他固定类型作为访问入口。

  • 例如发布二进制库给第三方时,可用 PImpl 隔离私有数据结构变化并降低 ABI 波动
  • PImpl 的劣势包括以下几点,如有必要可参考 案例(1.0.1) 来优化
    • 常见的指针式 PImpl 需要一次额外动态分配
    • 存取变量时增加一次间接寻址,对于看重 CPU Cache 和数据局部性的场景不利
案例(5.0)

根据 ClangBuildAnalyzer 结果,有关 struct TiFlashError 的几个模板实例化总开销不小。

1
2
3
4
5
6
7
8
**** Templates that took longest to instantiate:
...
53692 ms: std::optional<DB::TiFlashError> (663 times, avg 80 ms)
...
47327 ms: std::map<std::pair<std::string, std::string>, DB::TiFlashError> (663 times, avg 71 ms)
24149 ms: std::__rebind_alloc_helper<std::allocator_traits<std::allocator<std::pair<const std::pair<std::string, std::string>, DB::TiFlashError>>>, std::__value_type<std::pair<std::string, std::string>, DB::TiFlashError>> (663 times, avg 36 ms)
23596 ms: std::allocator_traits<std::allocator<std::pair<const std::pair<std::string, std::string>, DB::TiFlashError>>> (663 times, avg 35 ms)
22289 ms: std::__value_type<std::pair<std::string, std::string>, DB::TiFlashError> (663 times, avg 33 ms)

使用这些模板的地方主要在 class TiFlashErrorRegistry。这种把成员函数实现直接放在头文件里的做法 不值得提倡,因为包含其的源文件均会解析并实例化涉及到的模板,建议分离定义和实现。
但是对于使用了 STL 模板的成员变量 std::map<std::pair<std::string, std::string>, TiFlashError> all_errors 则需要多加考量。

  • 头文件中分离函数实现后,只要编译单元内没有其他符号对函数有依赖关系,编译器就可以不实例化函数涉及到的模板
  • 对于类定义,编译器会解析其成员变量和依赖以确定大小和对齐方式。

分析上下文信息可知这个类主要以 单例 的形式用来维护/查找预设数据,可以使用 PImpl 改造以降低前端开销(对于该类的使用场景几乎不会有性能影响)。本案例中类重构效果如
TiFlashException.h#L192-L225。改造完成后,上面提到的模板实例化开销均被消除。
除了裸指针,用 std::unique_ptr 也是同样原理,例如 KVStore.h 的改动。

前文主要关注构建效率。从本节开始,讨论重点转为编译产物的运行性能。LTO 和 PGO 通常会增加编译或链接成本,其目标不是缩短开发构建时间,而是通过跨模块信息或运行时 profile 生成性能更好的二进制文件。

链接时优化(Link Time Optimization)是比较典型的后端优化,属于 Interprocedural optimization。LLVM 提供了强大的链接时跨模块优化功能,以获取更好的运行时性能。

ThinLTO 是面向可扩展性设计的 LTO 模式。传统 Full LTO 将输入合并为单一大模块,时间和内存扩展性通常较差;ThinLTO 则令 bitcode 额外携带模块摘要(compact summary),链接器先合并摘要索引,再并行执行跨模块导入和后端优化。

ThinLTO 支持增量缓存,但需要显式配置 linker。例如 ELF lld 可增加 -Wl,--thinlto-cache-dir=<cache-dir>;如果没有启用 cache,不能仅凭 -flto=thin 就假设增量链接会复用此前的后端结果。

tiflash/pull/4890 中加入了 CMake 选项 ENABLE_THINLTO 并在 release build 流程中启用。跨模块优化的好处有以下几点:

  • 全局优化:去虚拟化(whole-program devirtualization),跨模块函数内联
  • 删减无用代码

把函数直接实现在头文件中可以向普通编译暴露函数体,但也会放大依赖和重复编译成本。LTO 能为定义/实现分离后的函数提供跨模块内联和去虚拟化机会,因此可以降低二者之间的矛盾;不过是否真正优化仍受符号可见性、DSO/插件边界、优化预算和调用特征影响,不能视为保证。

案例(6.0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// t1.cpp
int goo(int x) {
if (x > 0)
return x * 10;
else
return x / 10;
}
int &get(int *data, int idx) { return data[idx]; }
bool check(int i, int len) { return i < len; }
struct Base {
virtual void joo();
virtual int foo(int x);
};
void Base::joo() {}
int Base::foo(int x) { return x + 1; }
int doo(int x, Base &d) { return 2 + d.foo(x); }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// t2.cpp
int goo(int x);
__attribute__((noinline)) int foo(int x) {
if (x > 0)
return goo(x) * 10;
else
return goo(x) / 10;
}
int &get(int *data, int idx);
bool check(int i, int len);
__attribute__((noinline)) void sum(int *a, int *b, int *c, int len) {
for (int i = 0; check(i, len); ++i)
get(c, i) = get(a, i) + get(b, i);
}

struct Base {
virtual void joo();
virtual int foo(int x);
};
int doo(int x, Base &d);
struct Impl : Base {
int foo(int x) override { return x + 4; }
};

__attribute__((noinline)) int koo(int x) {
Impl tt{};
return doo(x, tt);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// t3.cpp
int foo(int x);
void sum(int *a, int *b, int *c, int len);
int koo(int x);

int main() {
int a[] = {1};
int b[] = {2};
int c[] = {0};
const int foo_result = foo(123);
sum(a, b, c, 1);
const int koo_result = koo(0);
return foo_result == 12300 && c[0] == 3 && koo_result == 6 ? 0 : 1;
}

这里给 foosumkoo 增加 noinline,只是为了让两个版本都保留可供 objdump 对比的顶层函数;它不会阻止 LTO 把 googetcheck 和虚调用目标优化进这些函数。

下面两组汇编保留的是原文 LLVM 13、x86-64 环境中的历史输出。指令地址、寄存器分配和向量化展开会随 LLVM、目标 CPU、PIE 和链接器版本变化,阅读时应关注跨模块调用是否消失以及语义是否被简化,而不是逐字匹配地址和指令序列。

未启用 LTO 时,编译器仍会执行编译单元内部优化,但无法看到其他源文件中的函数体,因此 foosumkoo 中保留了跨模块调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
clang++  -O3 -DNDEBUG -std=gnu++17 -o t1.o -c t1.cpp
clang++ -O3 -DNDEBUG -std=gnu++17 -o t2.o -c t2.cpp
clang++ -O3 -DNDEBUG -std=gnu++17 -o t3.o -c t3.cpp
clang++ -O3 -DNDEBUG -fuse-ld=lld t1.o t2.o t3.o -o test
objdump -d test


00000000002019e0 <_Z3dooiR4Base>:
2019e0: 50 push %rax
2019e1: 89 f8 mov %edi,%eax
2019e3: 48 8b 0e mov (%rsi),%rcx
2019e6: 48 89 f7 mov %rsi,%rdi
2019e9: 89 c6 mov %eax,%esi
2019eb: ff 51 08 callq *0x8(%rcx)
2019ee: 83 c0 02 add $0x2,%eax
2019f1: 59 pop %rcx
2019f2: c3 retq

0000000000201a00 <_Z3fooi>:
201a00: 53 push %rbx
201a01: 89 fb mov %edi,%ebx
201a03: e8 78 ff ff ff callq 201980 <_Z3gooi>
201a08: 85 db test %ebx,%ebx
201a0a: 7e 07 jle 201a13 <_Z3fooi+0x13>
201a0c: 01 c0 add %eax,%eax
201a0e: 8d 04 80 lea (%rax,%rax,4),%eax
201a11: 5b pop %rbx
201a12: c3 retq
201a13: 48 98 cltq
201a15: 48 69 c0 67 66 66 66 imul $0x66666667,%rax,%rax
201a1c: 48 89 c1 mov %rax,%rcx
201a1f: 48 c1 e9 3f shr $0x3f,%rcx
201a23: 48 c1 f8 22 sar $0x22,%rax
201a27: 01 c8 add %ecx,%eax
201a29: 5b pop %rbx
201a2a: c3 retq
201a2b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)

0000000000201a30 <_Z3sumPiS_S_i>:
201a30: 55 push %rbp
201a31: 41 57 push %r15
201a33: 41 56 push %r14
201a35: 41 55 push %r13
201a37: 41 54 push %r12
201a39: 53 push %rbx
201a3a: 50 push %rax
201a3b: 41 89 cd mov %ecx,%r13d
201a3e: 49 89 d6 mov %rdx,%r14
201a41: 49 89 f7 mov %rsi,%r15
201a44: 49 89 fc mov %rdi,%r12
201a47: 31 ed xor %ebp,%ebp
201a49: 31 ff xor %edi,%edi
201a4b: 89 ce mov %ecx,%esi
201a4d: e8 5e ff ff ff callq 2019b0 <_Z5checkii>
201a52: 84 c0 test %al,%al
201a54: 74 3f je 201a95 <_Z3sumPiS_S_i+0x65>
201a56: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
201a5d: 00 00 00
201a60: 4c 89 e7 mov %r12,%rdi
201a63: 89 ee mov %ebp,%esi
201a65: e8 36 ff ff ff callq 2019a0 <_Z3getPii>
201a6a: 8b 18 mov (%rax),%ebx
201a6c: 4c 89 ff mov %r15,%rdi
201a6f: 89 ee mov %ebp,%esi
201a71: e8 2a ff ff ff callq 2019a0 <_Z3getPii>
201a76: 03 18 add (%rax),%ebx
201a78: 4c 89 f7 mov %r14,%rdi
201a7b: 89 ee mov %ebp,%esi
201a7d: e8 1e ff ff ff callq 2019a0 <_Z3getPii>
201a82: 89 18 mov %ebx,(%rax)
201a84: 83 c5 01 add $0x1,%ebp
201a87: 89 ef mov %ebp,%edi
201a89: 44 89 ee mov %r13d,%esi
201a8c: e8 1f ff ff ff callq 2019b0 <_Z5checkii>
201a91: 84 c0 test %al,%al
201a93: 75 cb jne 201a60 <_Z3sumPiS_S_i+0x30>
201a95: 48 83 c4 08 add $0x8,%rsp
201a99: 5b pop %rbx
201a9a: 41 5c pop %r12
201a9c: 41 5d pop %r13
201a9e: 41 5e pop %r14
201aa0: 41 5f pop %r15
201aa2: 5d pop %rbp
201aa3: c3 retq
201aa4: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
201aab: 00 00 00
201aae: 66 90 xchg %ax,%ax

0000000000201ab0 <_Z3kooi>:
201ab0: 50 push %rax
201ab1: 48 c7 04 24 f8 05 20 movq $0x2005f8,(%rsp)
201ab8: 00
201ab9: 48 89 e6 mov %rsp,%rsi
201abc: e8 1f ff ff ff callq 2019e0 <_Z3dooiR4Base>
201ac1: 59 pop %rcx
201ac2: c3 retq

启用 LTO 后

  • foo 函数逻辑直接被优化成等效于 if (x > 0) return x * 100; else return x / 100; 的实现
  • sum 中自动向量化生成 SIMD 指令
  • koo 去虚拟化实现等效于 x + 6 的逻辑

下面的 -fvisibility=hidden-fvisibility-inlines-hidden-fwhole-program-vtables 适合这个封闭的可执行文件实验,但会改变符号可见性和 whole-program 假设。共享库、插件或需要稳定导出 ABI 的目标不能直接照搬,应先配置导出符号并确认跨 DSO 边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
clang++ -flto=thin -fvisibility=hidden -fvisibility-inlines-hidden -fwhole-program-vtables -fsplit-lto-unit -O3 -DNDEBUG -std=gnu++17 -o t1.o -c t1.cpp
clang++ -flto=thin -fvisibility=hidden -fvisibility-inlines-hidden -fwhole-program-vtables -fsplit-lto-unit -O3 -DNDEBUG -std=gnu++17 -o t2.o -c t2.cpp
clang++ -flto=thin -fvisibility=hidden -fvisibility-inlines-hidden -fwhole-program-vtables -fsplit-lto-unit -O3 -DNDEBUG -std=gnu++17 -o t3.o -c t3.cpp
clang++ -flto=thin -fvisibility=hidden -fvisibility-inlines-hidden -fwhole-program-vtables -fsplit-lto-unit -O3 -DNDEBUG -fuse-ld=lld -pthread -flto=thin -flto-jobs=0 -Wl,--thinlto-cache-dir=.thinlto-cache -fvisibility=hidden -fvisibility-inlines-hidden -fwhole-program-vtables -fsplit-lto-unit t1.o t2.o t3.o -o test
objdump -d test


00000000002019f0 <_Z3fooi>:
2019f0: 85 ff test %edi,%edi
2019f2: 7e 04 jle 2019f8 <_Z3fooi+0x8>
2019f4: 6b c7 64 imul $0x64,%edi,%eax
2019f7: c3 retq
2019f8: f7 df neg %edi
2019fa: 48 69 c7 1f 85 eb 51 imul $0x51eb851f,%rdi,%rax
201a01: 48 c1 e8 25 shr $0x25,%rax
201a05: f7 d8 neg %eax
201a07: c3 retq

0000000000201a10 <_Z3sumPiS_S_i>:
201a10: 85 c9 test %ecx,%ecx
201a12: 0f 8e 7f 01 00 00 jle 201b97 <_Z3sumPiS_S_i+0x187>
201a18: 41 89 c8 mov %ecx,%r8d
201a1b: 83 f9 08 cmp $0x8,%ecx
201a1e: 73 7b jae 201a9b <_Z3sumPiS_S_i+0x8b>
201a20: 31 c9 xor %ecx,%ecx
201a22: 49 89 c9 mov %rcx,%r9
201a25: 49 f7 d1 not %r9
201a28: 4d 01 c1 add %r8,%r9
201a2b: 4d 89 c2 mov %r8,%r10
201a2e: 49 83 e2 03 and $0x3,%r10
201a32: 74 1f je 201a53 <_Z3sumPiS_S_i+0x43>
201a34: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
201a3b: 00 00 00
201a3e: 66 90 xchg %ax,%ax
201a40: 8b 04 8e mov (%rsi,%rcx,4),%eax
201a43: 03 04 8f add (%rdi,%rcx,4),%eax
201a46: 89 04 8a mov %eax,(%rdx,%rcx,4)
201a49: 48 83 c1 01 add $0x1,%rcx
201a4d: 49 83 c2 ff add $0xffffffffffffffff,%r10
201a51: 75 ed jne 201a40 <_Z3sumPiS_S_i+0x30>
201a53: 49 83 f9 03 cmp $0x3,%r9
201a57: 0f 82 3a 01 00 00 jb 201b97 <_Z3sumPiS_S_i+0x187>
201a5d: 0f 1f 00 nopl (%rax)
201a60: 8b 04 8e mov (%rsi,%rcx,4),%eax
201a63: 03 04 8f add (%rdi,%rcx,4),%eax
201a66: 89 04 8a mov %eax,(%rdx,%rcx,4)
201a69: 8b 44 8e 04 mov 0x4(%rsi,%rcx,4),%eax
201a6d: 03 44 8f 04 add 0x4(%rdi,%rcx,4),%eax
201a71: 89 44 8a 04 mov %eax,0x4(%rdx,%rcx,4)
201a75: 8b 44 8e 08 mov 0x8(%rsi,%rcx,4),%eax
201a79: 03 44 8f 08 add 0x8(%rdi,%rcx,4),%eax
201a7d: 89 44 8a 08 mov %eax,0x8(%rdx,%rcx,4)
201a81: 8b 44 8e 0c mov 0xc(%rsi,%rcx,4),%eax
201a85: 03 44 8f 0c add 0xc(%rdi,%rcx,4),%eax
201a89: 89 44 8a 0c mov %eax,0xc(%rdx,%rcx,4)
201a8d: 48 83 c1 04 add $0x4,%rcx
201a91: 49 39 c8 cmp %rcx,%r8
201a94: 75 ca jne 201a60 <_Z3sumPiS_S_i+0x50>
201a96: e9 fc 00 00 00 jmpq 201b97 <_Z3sumPiS_S_i+0x187>
201a9b: 4a 8d 0c 82 lea (%rdx,%r8,4),%rcx
201a9f: 4a 8d 04 87 lea (%rdi,%r8,4),%rax
201aa3: 48 39 d0 cmp %rdx,%rax
201aa6: 41 0f 97 c2 seta %r10b
201aaa: 4a 8d 04 86 lea (%rsi,%r8,4),%rax
201aae: 48 39 f9 cmp %rdi,%rcx
201ab1: 41 0f 97 c3 seta %r11b
201ab5: 48 39 d0 cmp %rdx,%rax
201ab8: 0f 97 c0 seta %al
201abb: 48 39 f1 cmp %rsi,%rcx
201abe: 41 0f 97 c1 seta %r9b
201ac2: 31 c9 xor %ecx,%ecx
201ac4: 45 84 da test %r11b,%r10b
201ac7: 0f 85 55 ff ff ff jne 201a22 <_Z3sumPiS_S_i+0x12>
201acd: 44 20 c8 and %r9b,%al
201ad0: 0f 85 4c ff ff ff jne 201a22 <_Z3sumPiS_S_i+0x12>
201ad6: 44 89 c1 mov %r8d,%ecx
201ad9: 83 e1 f8 and $0xfffffff8,%ecx
201adc: 48 8d 41 f8 lea -0x8(%rcx),%rax
201ae0: 49 89 c1 mov %rax,%r9
201ae3: 49 c1 e9 03 shr $0x3,%r9
201ae7: 49 83 c1 01 add $0x1,%r9
201aeb: 48 85 c0 test %rax,%rax
201aee: 0f 84 a4 00 00 00 je 201b98 <_Z3sumPiS_S_i+0x188>
201af4: 4d 89 ca mov %r9,%r10
201af7: 49 83 e2 fe and $0xfffffffffffffffe,%r10
201afb: 49 f7 da neg %r10
201afe: 31 c0 xor %eax,%eax
201b00: f3 0f 6f 04 87 movdqu (%rdi,%rax,4),%xmm0
201b05: f3 0f 6f 4c 87 10 movdqu 0x10(%rdi,%rax,4),%xmm1
201b0b: f3 0f 6f 14 86 movdqu (%rsi,%rax,4),%xmm2
201b10: 66 0f fe d0 paddd %xmm0,%xmm2
201b14: f3 0f 6f 44 86 10 movdqu 0x10(%rsi,%rax,4),%xmm0
201b1a: 66 0f fe c1 paddd %xmm1,%xmm0
201b1e: f3 0f 7f 14 82 movdqu %xmm2,(%rdx,%rax,4)
201b23: f3 0f 7f 44 82 10 movdqu %xmm0,0x10(%rdx,%rax,4)
201b29: f3 0f 6f 44 87 20 movdqu 0x20(%rdi,%rax,4),%xmm0
201b2f: f3 0f 6f 4c 87 30 movdqu 0x30(%rdi,%rax,4),%xmm1
201b35: f3 0f 6f 54 86 20 movdqu 0x20(%rsi,%rax,4),%xmm2
201b3b: 66 0f fe d0 paddd %xmm0,%xmm2
201b3f: f3 0f 6f 44 86 30 movdqu 0x30(%rsi,%rax,4),%xmm0
201b45: 66 0f fe c1 paddd %xmm1,%xmm0
201b49: f3 0f 7f 54 82 20 movdqu %xmm2,0x20(%rdx,%rax,4)
201b4f: f3 0f 7f 44 82 30 movdqu %xmm0,0x30(%rdx,%rax,4)
201b55: 48 83 c0 10 add $0x10,%rax
201b59: 49 83 c2 02 add $0x2,%r10
201b5d: 75 a1 jne 201b00 <_Z3sumPiS_S_i+0xf0>
201b5f: 41 f6 c1 01 test $0x1,%r9b
201b63: 74 29 je 201b8e <_Z3sumPiS_S_i+0x17e>
201b65: f3 0f 6f 04 87 movdqu (%rdi,%rax,4),%xmm0
201b6a: f3 0f 6f 4c 87 10 movdqu 0x10(%rdi,%rax,4),%xmm1
201b70: f3 0f 6f 14 86 movdqu (%rsi,%rax,4),%xmm2
201b75: 66 0f fe d0 paddd %xmm0,%xmm2
201b79: f3 0f 6f 44 86 10 movdqu 0x10(%rsi,%rax,4),%xmm0
201b7f: 66 0f fe c1 paddd %xmm1,%xmm0
201b83: f3 0f 7f 14 82 movdqu %xmm2,(%rdx,%rax,4)
201b88: f3 0f 7f 44 82 10 movdqu %xmm0,0x10(%rdx,%rax,4)
201b8e: 4c 39 c1 cmp %r8,%rcx
201b91: 0f 85 8b fe ff ff jne 201a22 <_Z3sumPiS_S_i+0x12>
201b97: c3 retq
201b98: 31 c0 xor %eax,%eax
201b9a: 41 f6 c1 01 test $0x1,%r9b
201b9e: 75 c5 jne 201b65 <_Z3sumPiS_S_i+0x155>
201ba0: eb ec jmp 201b8e <_Z3sumPiS_S_i+0x17e>
201ba2: cc int3

0000000000201bb0 <_Z3kooi>:
201bb0: 8d 47 06 lea 0x6(%rdi),%eax
201bb3: c3 retq

PGO(Profile-Guided Optimization)

PGO(Profile-Guided Optimization)也称 FDO(Feedback Directed Optimization),指通过工具采集程序运行时的 profile 数据,用以在重新编译流程中优化程序。

  • FDO 的方案普遍被大厂用于优化数据中心的应用
  • TiFlash 中支持 PGO:PR#5160

根据 llvm/docs/profile-guided-optimization,LLVM 的 PGO 主要分为 2 种:

  • 代码插桩(Profiling with Instrumentation
    • 增加编译参数 -fprofile-instr-generate
    • 运行特定的 benchmark 负载,令程序自动采集 profile 数据(缺点是程序运行较慢)
    • 使用 llvm-profdata merge 将一个或多个 .profraw 转换为 .profdata
    • 重新编译时指定 profile 数据 -fprofile-instr-use=<xxx.profdata>
  • 运行时采样(Using Sampling Profilers
    • 增加编译参数 -gline-tables-only -fdebug-info-for-profiling -funique-internal-linkage-names
    • 正常部署运行程序,利用 Linux perf 采集实际负载下的数据
      • 在支持 LBR(Last Branch Record)的平台上,可用 -j/--branch-filter 采集 branch stack;它不是普通的函数调用栈
    • 利用 AutoFDO 将 perf 数据转换为 LLVM 格式的 profile 数据
    • 重新编译时指定 profile 数据 -fprofile-sample-use=<profile.prof>

一个完整的插桩式 PGO 最小流程如下:

1
2
3
4
clang++ -O2 -fprofile-instr-generate app.cpp -o app.instrumented
LLVM_PROFILE_FILE="app-%p.profraw" ./app.instrumented <benchmark-args>
llvm-profdata merge -o app.profdata app-*.profraw
clang++ -O2 -fprofile-instr-use=app.profdata app.cpp -o app.pgo

即使只有一个 .profraw,也需要执行 llvm-profdata merge 完成格式转换。多进程或多轮负载应使用不会互相覆盖的文件名,并合并所有具有代表性的 profile。

采样现有进程、采样整个系统和直接启动工作负载是三种不同范围,不应把 -p-a 混在同一条命令里:

1
2
3
perf record -p <pid> -e cycles:up -j any,u -o perf.data -- sleep 180
sudo perf record -a -e cycles:up -j any,u -o perf.data -- sleep 180
perf record -e cycles:up -j any,u -o perf.data -- <executable> <args> ...

事件修饰符 p 请求更精确的硬件采样,并非所有 CPU 都支持;遇到 event syntax error 或 precise event 不可用时,可退回 cycles:u-j any,u 同样依赖处理器的分支记录能力以及系统的 perf 权限配置。

1
create_llvm_prof --profile <perf.data> --binary <binary> --out=<profile.prof>

如果存在稳定且具有代表性的 benchmark,插桩式 PGO 通常可以获得更精确的 profile;如果希望从真实线上负载采集数据,并尽量降低运行开销,则可以采用采样式 PGO。两者应根据负载代表性、采集成本和部署条件选择。

案例(7.0)

该案例中虚函数 Extend::foo 被调用的频率明显高于 Base::foo,正常的 O3 优化可以按照一定的步长展开循环,但无法在运行时预测分支。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// fdo-test.cpp
#include <chrono>
#include <cstdio>
#include <vector>

struct Base
{
virtual void foo(int & x)
{
x += -1;
}
virtual ~Base() = default;
};

struct Extend : Base
{
void foo(int & x) override
{
x += 1;
}
};

int main()
{
std::vector<Base> a;
std::vector<Extend> b;
std::vector<Base *> c;
{
for (int i = 0; i < 100; ++i)
{
a.emplace_back(Base{});
for (int j = 0; j < 100; ++j)
{
b.emplace_back(Extend{});
}
}
for (auto & x : a)
c.emplace_back(&x);
for (auto & x : b)
c.emplace_back(&x);
}
auto start = std::chrono::steady_clock::now();
{
int res = 0;
for (int i = 0; i < 100000; ++i)
for (auto & x : c)
{
x->foo(res);
}
std::printf("%d\n", res);
}
auto end = std::chrono::steady_clock::now();
std::printf("time cost %lld ms\n", std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count());
}
1
2
3
4
clang++ ./fdo-test.cpp -O3 -Wl,--no-rosegment -gline-tables-only -fdebug-info-for-profiling -funique-internal-linkage-names -o ori.out -flto=thin -fvisibility=hidden -fvisibility-inlines-hidden -fwhole-program-vtables -fsplit-lto-unit -DNDEBUG -fuse-ld=lld '-Rpass=.*' >pass.ori 2>&1
perf record -e cycles:up -j any,u -o test-perf.data -- ./ori.out
create_llvm_prof --profile test-perf.data --binary ./ori.out --out=test.prof
clang++ ./fdo-test.cpp -O3 -Wl,--no-rosegment -gline-tables-only -fdebug-info-for-profiling -funique-internal-linkage-names -flto=thin -fvisibility=hidden -fvisibility-inlines-hidden -fwhole-program-vtables -fsplit-lto-unit -DNDEBUG -o fdo.out -fprofile-sample-use=./test.prof -fuse-ld=lld '-Rpass=.*' >pass.new 2>&1

这里的 --no-rosegment 来自 LLVM 13/LLD 的历史实验配置,不是所有新版工具链都必须使用的通用 PGO 参数。采集 profile 的二进制必须与 create_llvm_prof --binary 指向的文件一致,并保留匹配的 build ID、符号和调试行表。

FDO 优化效果:编译器根据虚函数调用频率执行 Promote indirect call 优化,实现去虚拟化。

  • 2012d8: 对比虚函数地址和 <_ZN6Extend3fooERi> 函数地址是否相同
  • 2012de: 如果比较相同则直接跳转到 2012c0
  • 2012c0: 内联实现 <_ZN6Extend3fooERi>
1
2
3
4
5
6
7
./ori.out
990000000
time cost 2688 ms

./fdo.out
990000000
time cost 2581 ms
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# pass.new
./fdo-test.cpp:48:20: Promote indirect call to _ZN6Extend3fooERi with count 63471 out of 64151

# objdump -d fdo.out
...
201293: e8 48 08 00 00 callq 201ae0 <_ZNSt3__16chrono12steady_clock3nowEv@plt>
201298: 49 89 c6 mov %rax,%r14
20129b: c7 44 24 20 00 00 00 movl $0x0,0x20(%rsp)
2012a2: 00
2012a3: 48 8b 2c 24 mov (%rsp),%rbp
2012a7: 48 8b 5c 24 08 mov 0x8(%rsp),%rbx
2012ac: 48 39 dd cmp %rbx,%rbp
2012af: 0f 84 ec 00 00 00 je 2013a1 <main+0x471>
2012b5: 45 31 ff xor %r15d,%r15d
2012b8: 4c 8d 64 24 20 lea 0x20(%rsp),%r12
2012bd: eb 0a jmp 2012c9 <main+0x399>
2012bf: 90 nop
2012c0: 83 44 24 20 01 addl $0x1,0x20(%rsp)
2012c5: 48 83 c5 08 add $0x8,%rbp
2012c9: 48 39 dd cmp %rbx,%rbp
2012cc: 74 1d je 2012eb <main+0x3bb>
2012ce: 48 8b 7d 00 mov 0x0(%rbp),%rdi
2012d2: 48 8b 07 mov (%rdi),%rax
2012d5: 48 8b 00 mov (%rax),%rax
2012d8: 48 3d 10 1a 20 00 cmp $0x201a10,%rax
2012de: 74 e0 je 2012c0 <main+0x390>
2012e0: 4c 89 e6 mov %r12,%rsi
2012e3: ff d0 callq *%rax
2012e5: 48 83 c5 08 add $0x8,%rbp
2012e9: eb de jmp 2012c9 <main+0x399>
2012eb: 41 83 c7 01 add $0x1,%r15d
2012ef: 41 81 ff a0 86 01 00 cmp $0x186a0,%r15d
2012f6: 74 0b je 201303 <main+0x3d3>
2012f8: 48 8b 2c 24 mov (%rsp),%rbp
2012fc: 48 8b 5c 24 08 mov 0x8(%rsp),%rbx
201301: eb c6 jmp 2012c9 <main+0x399>
201303: 8b 74 24 20 mov 0x20(%rsp),%esi
201307: bf af 0a 20 00 mov $0x200aaf,%edi
20130c: 31 c0 xor %eax,%eax
20130e: e8 dd 07 00 00 callq 201af0 <printf@plt>
201313: e8 c8 07 00 00 callq 201ae0 <_ZNSt3__16chrono12steady_clock3nowEv@plt>
...
0000000000201a10 <_ZN6Extend3fooERi>:
201a10: ff 06 incl (%rsi)
201a12: c3 retq
...

案例(7.1)

详见 tiflash#5160

TiFlash 以 commit 5b61ae70550624d3bf0b5ca6bac89013ed5a6a4b 为例

  • 测试工具为 go-tpc,并以 TPC-H 负载用于 perf 采样和优化验证
  • 单节点 TPC-H 10G 数据
  • 通过 cgroup 限制程序最多使用 5 核
1
2
3
mkdir -p /sys/fs/cgroup/cpu/pgo_test
lsof -i:9000 | grep 'TiFlash' | grep -v 'grep' | awk '{print $2}' > /sys/fs/cgroup/cpu/pgo_test/cgroup.procs
echo "500000" > /sys/fs/cgroup/cpu/pgo_test/cpu.cfs_quota_us

测试结果显示,对于重计算的场景例如 Q1,FDO+LTO 比起 LTO 的性能提升为 8.98%,相对于未做任何优化的版本提升 13.46%。Q13、Q18 之类的查询也因为聚合/过滤等计算占比较大获得一定的收益。而其他查询受计算模式中资源调度、数据瓶颈等因素影响存在一定的波动,优化效果不如 Q1 那么明显。

下表的提升比例按 基线耗时 / 优化后耗时 - 1 计算,表示等效 speedup,而非耗时下降比例。表中结果未展示多次运行的方差;对于执行时间较短或差异较小的查询,结果可能受到调度、缓存和系统负载影响,应通过多次运行的中位数及离散程度进一步验证。

Time Cost(s) original LTO FDO+LTO FDO+LTO : LTO FDO+LTO : original
Q1 6.32 6.07 5.57 8.98% 13.46%
Q2 2.99 2.99 2.92 2.40% 2.40%
Q3 2.79 2.65 2.65 0.00% 5.28%
Q4 1.91 2.05 1.85 10.81% 3.24%
Q6 0.91 0.91 0.84 8.33% 8.33%
Q7 2.38 2.32 2.32 0.00% 2.59%
Q8 4.8 4.73 4.73 0.00% 1.48%
Q9 16.81 16.54 16.48 0.36% 2.00%
Q10 3.72 3.72 3.66 1.64% 1.64%
Q11 0.5 0.5 0.5 0.00% 0.00%
Q12 1.98 1.91 1.85 3.24% 7.03%
Q13 4.66 4.6 4.33 6.24% 7.62%
Q14 1.04 1.11 0.97 14.43% 7.22%
Q15 2.05 1.98 2.11 -6.16% -2.84%
Q16 1.04 1.04 0.97 7.22% 7.22%
Q17 5.67 5.8 5.67 2.29% 0.00%
Q18 8.62 8.41 7.99 5.26% 7.88%
Q19 3.12 3.05 3.05 0.00% 2.30%
Q20 1.58 1.58 1.64 -3.66% -3.66%
Q21 2.99 2.85 2.89 -1.38% 3.46%
Q22 0.64 0.64 0.5 28.00% 28.00%

3 节点跑 TPCH-100,限制每个节点 CPU 使用上限 1000%,结果如下

Time Cost(s) LTO FDO+LTO FDO+LTO : LTO
Q1 12.08 11.04 9.42%
Q2 4.33 4.26 1.64%
Q3 8.22 8.09 1.61%
Q4 21.17 21.64 -2.17%
Q5 19.36 19.83 -2.37%
Q6 1.91 1.85 3.24%
Q7 9.83 9.97 -1.40%
Q8 11.98 11.58 3.45%
Q9 65.26 64.32 1.46%
Q10 10.57 10.37 1.93%
Q11 2.05 2.05 0.00%
Q12 5.2 5.27 -1.33%
Q13 12.72 12.11 5.04%
Q14 2.18 2.11 3.32%
Q15 4.13 4.06 1.72%
Q16 2.32 2.25 3.11%
Q17 18.29 18.22 0.38%
Q18 23.12 22.85 1.18%
Q19 5.87 5.74 2.26%
Q20 4.06 4.13 -1.69%
Q21 39.02 39.29 -0.69%
Q22 1.38 1.31 5.34%

除了 Q1,其他查询或多或少涉及到资源调度和等待,测试过程中无法全程打满 CPU,提升效果不如 Q1 那么明显。

Post-link Optimizer

BOLT

BOLT(Binary Optimization and Layout Tool) 是 LLVM 中的 Post-link Optimizer。它根据 perf 等工具采集的运行 profile,分析最终 ELF 二进制中的函数地址、基本块和跳转关系,再重新排列代码布局。因为 BOLT 工作在链接完成之后,所以能看到编译器和链接器处理后的实际地址与最终布局。

案例(8.0)

假设某个服务的请求处理路径如下:

1
2
3
4
5
6
7
8
9
10
bool handleRequest(Request & request)
{
if (!parseRequest(request))
return reportParseError(request);
if (!authenticate(request))
return reportAuthError(request);
if (request.isQuery())
return executeQuery(request);
return executeRareCommand(request);
}

生产负载中,parseRequestauthenticateexecuteQuery 几乎被每个请求调用,而错误处理和其他命令很少执行。链接后的函数仍可能分散在不同地址:

1
2
3
4
5
6
7
0x1000  handleRequest           hot
0x1800 reportParseError cold
0x2400 executeRareCommand cold
0x5000 parseRequest hot
0x6800 reportAuthError cold
0x9000 authenticate hot
0xb000 executeQuery hot

正常查询需要在多个相距较远的地址间跳转:

1
0x1000 -> 0x5000 -> 0x9000 -> 0xb000

BOLT 根据真实分支轨迹把热函数集中排列,并将冷函数移到远处:

1
2
3
4
5
6
7
8
0x1000  handleRequest           hot
0x1100 parseRequest hot
0x1400 authenticate hot
0x1800 executeQuery hot

0x9000 reportParseError cold
0x9200 reportAuthError cold
0x9500 executeRareCommand cold

优化后的常见路径更加紧凑:

1
0x1000 -> 0x1100 -> 0x1400 -> 0x1800

这个过程通常不改变算法,而是通过函数重排、基本块重排和冷热代码分离,降低指令 Cache miss、iTLB miss 和取指停顿。BOLT 也可以在单个函数内部将低频错误处理和异常路径拆到冷区。

按照当前 BOLT README,正式使用前还要确认以下前提:

  • 输入是 x86-64 或 AArch64 ELF,并且至少保留未剥离的符号表;保留 relocation 才能获得完整的函数重排能力。
  • GCC 8 及以上默认可能启用 BOLT 不兼容的 -freorder-blocks-and-partition,因此使用 GCC 构建输入文件时应显式增加 -fno-reorder-blocks-and-partition
  • perf 的 branch stack 是高质量基本块 profile 的主要来源。硬件或虚拟机不支持 branch stack 时可以只采样地址,并向 perf2bolt 增加 -ba,但 profile 精度和预期收益都会下降。
  • profile 应尽量来自同一份二进制和代表性负载。BOLT 虽能容忍一定差异,但报告中的 stale function 越多,profile 越不可信;release 分支应重新采集。
  • 默认不会重写完整调试信息;确实需要调试优化后二进制时,应评估 -update-debug-sections 的额外处理成本并验证调试体验。

BOLT 的基本使用流程如下。首先在最终链接时保留 relocation:

1
2
clang++ -O3 -g -Wl,--emit-relocs ... -o application
readelf -SW ./application | grep -E '\.rela?\.text'

使用具有代表性的负载采集分支信息,并转换为 BOLT profile:

1
2
perf record -e cycles:u -j any,u -o perf.data -- ./application <args>
perf2bolt -p perf.data -o perf.fdata ./application

最后优化二进制:

1
2
3
4
5
6
7
8
9
llvm-bolt ./application \
-o ./application.bolt \
-data=perf.fdata \
-reorder-blocks=ext-tsp \
-reorder-functions=cdsort \
-split-functions \
-split-all-cold \
-split-eh \
-dyno-stats

运行优化后二进制的 smoke test 和回归测试,还应记录 llvm-bolt --versionperf --version、输入二进制校验和、profile 采集命令和 stale profile 统计,避免把不同 BOLT 版本或不同二进制的结果混在一起。

BOLT 与 LTOPGO 不是替代关系,可以继续优化已经经过 PGO 和 ThinLTO 处理的二进制。LLVM 官方构建流程也支持组合使用 PGO、ThinLTO 和 BOLT,详见 LLVM Advanced Build Configurations

代码大页

代码大页是独立于 Post-link Optimizer 的操作系统内存管理机制。BOLT 的 -hugify 是一种自动化实现,程序也可以通过 madvise 或定制 Loader 手动处理代码大页。

假设热代码占 32 MiB,使用 4 KiB 普通页需要 8192 个页,而使用 2 MiB 大页只需 16 个页,理论上可以显著降低热代码的 iTLB 压力。

BOLT -hugify

BOLT 将热代码集中到连续区域后,可以通过 -hugify 请求使用 Transparent Huge Pages(THP):

1
2
3
4
5
6
7
8
9
10
llvm-bolt ./application \
-o ./application.bolt \
-data=perf.fdata \
-reorder-blocks=ext-tsp \
-reorder-functions=cdsort \
-split-functions \
-split-all-cold \
-split-eh \
-hugify \
-dyno-stats

BOLT main 分支的 hugify runtime 会识别热代码区间,按 2 MiB 边界处理,并通过 MADV_HUGEPAGE 请求 THP。该实现没有调用 MADV_COLLAPSE:在较新的文件 THP 路径上,它还会检查全局 THP 模式是否为 alwaysmadvise。因此,当系统把 THP 全局设置为 never 时,不能假设 -hugify 会像手动 MADV_COLLAPSE 一样绕过该策略;必须以目标 BOLT 版本的源码、运行日志和 smaps 为准。

案例(8.1):在 THP 上执行一个返回 42 的函数

在操作真实 ELF 代码段之前,可以先用一个微型 JIT 风格的案例验证代码大页机制。程序申请一块 2 MiB 对齐的匿名内存,写入一段“返回 42”的机器码,请求 THP,然后将内存权限从 RW 改成 RX 并执行。这个案例只支持 Linux x86-64 和 AArch64。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/mman.h>
#include <unistd.h>

namespace
{

constexpr std::size_t huge_page_size = 2 * 1024 * 1024;

std::uintptr_t alignUp(std::uintptr_t value, std::size_t alignment)
{
return (value + alignment - 1) & ~(alignment - 1);
}

// mmap does not guarantee a 2 MiB-aligned address. Allocate 4 MiB and
// trim it to one complete, aligned 2 MiB VMA.
void * mapAlignedHugePageCandidate()
{
constexpr std::size_t total_size = huge_page_size * 2;

void * raw = mmap(
nullptr,
total_size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1,
0);
if (raw == MAP_FAILED)
{
std::perror("mmap");
std::exit(EXIT_FAILURE);
}

const auto raw_begin = reinterpret_cast<std::uintptr_t>(raw);
const auto raw_end = raw_begin + total_size;
const auto aligned_begin = alignUp(raw_begin, huge_page_size);
const auto aligned_end = aligned_begin + huge_page_size;
const std::size_t prefix_size = aligned_begin - raw_begin;
const std::size_t suffix_size = raw_end - aligned_end;

if (prefix_size != 0
&& munmap(reinterpret_cast<void *>(raw_begin), prefix_size) != 0)
{
std::perror("munmap prefix");
std::exit(EXIT_FAILURE);
}
if (suffix_size != 0
&& munmap(reinterpret_cast<void *>(aligned_end), suffix_size) != 0)
{
std::perror("munmap suffix");
std::exit(EXIT_FAILURE);
}

return reinterpret_cast<void *>(aligned_begin);
}

void writeReturn42(void * memory)
{
#if defined(__x86_64__)
// mov eax, 42; ret
const unsigned char code[] = {
0xb8, 0x2a, 0x00, 0x00, 0x00,
0xc3,
};
std::memcpy(memory, code, sizeof(code));
#elif defined(__aarch64__)
// mov w0, #42; ret
const std::uint32_t code[] = {
0x52800540,
0xd65f03c0,
};
std::memcpy(memory, code, sizeof(code));
#else
#error "This example supports only x86-64 and AArch64"
#endif

__builtin___clear_cache(
static_cast<char *>(memory),
static_cast<char *>(memory) + huge_page_size);
}

void printMappingInfo(void * address)
{
FILE * file = std::fopen("/proc/self/smaps", "r");
if (file == nullptr)
{
std::perror("fopen /proc/self/smaps");
return;
}

const auto target = reinterpret_cast<std::uintptr_t>(address);
char line[512];
bool found = false;

while (std::fgets(line, sizeof(line), file) != nullptr)
{
unsigned long begin = 0;
unsigned long end = 0;

if (std::sscanf(line, "%lx-%lx", &begin, &end) == 2)
{
if (found)
break;
found = target >= begin && target < end;
if (found)
std::printf("mapping: %s", line);
continue;
}
if (!found)
continue;

if (std::strncmp(line, "Size:", 5) == 0
|| std::strncmp(line, "KernelPageSize:", 15) == 0
|| std::strncmp(line, "MMUPageSize:", 12) == 0
|| std::strncmp(line, "AnonHugePages:", 14) == 0
|| std::strncmp(line, "Private_Hugetlb:", 16) == 0
|| std::strncmp(line, "Shared_Hugetlb:", 15) == 0
|| std::strncmp(line, "THPeligible:", 12) == 0
|| std::strncmp(line, "VmFlags:", 8) == 0)
std::printf("%s", line);
}

std::fclose(file);
}

} // namespace

int main()
{
void * memory = mapAlignedHugePageCandidate();
std::printf("mapping address: %p, size: 2 MiB\n", memory);

if (madvise(memory, huge_page_size, MADV_HUGEPAGE) != 0)
std::perror("MADV_HUGEPAGE");
else
std::puts("MADV_HUGEPAGE accepted");

// Make every native page resident before attempting synchronous collapse.
std::memset(memory, 0, huge_page_size);
writeReturn42(memory);

#ifdef MADV_COLLAPSE
if (madvise(memory, huge_page_size, MADV_COLLAPSE) != 0)
{
std::perror("MADV_COLLAPSE");
std::puts("waiting for khugepaged");
sleep(15);
}
else
{
std::puts("MADV_COLLAPSE succeeded");
}
#else
std::puts("MADV_COLLAPSE is unavailable; waiting for khugepaged");
sleep(15);
#endif

// Keep W^X: the mapping is writable or executable, never both.
if (mprotect(memory, huge_page_size, PROT_READ | PROT_EXEC) != 0)
{
std::perror("mprotect RX");
return EXIT_FAILURE;
}

using Function = int (*)();
auto function = reinterpret_cast<Function>(memory);
const int result = function();

std::printf("hello from executable memory, result = %d\n", result);
printMappingInfo(memory);
munmap(memory, huge_page_size);

return result == 42 ? EXIT_SUCCESS : EXIT_FAILURE;
}

编译并运行:

1
2
clang++ -std=c++17 -O2 -Wall -Wextra thp_code_hello.cpp -o thp_code_hello
./thp_code_hello

运行前可以确认系统 THP 模式和 PMD 大页大小:

1
2
3
4
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/hpage_pmd_size
cat /sys/kernel/mm/transparent_hugepage/khugepaged/scan_sleep_millisecs
cat /sys/kernel/mm/transparent_hugepage/khugepaged/alloc_sleep_millisecs

理想结果中,函数返回 42,且 smaps 显示:

1
2
hello from executable memory, result = 42
AnonHugePages: 2048 kB

MADV_HUGEPAGE 返回成功仅表示内核接受建议。THPeligible: 1 也只表示该 VMA 有资格尝试 THP,只有 AnonHugePages 大于 0 才能证明当前确实映射了匿名 PMD THP。如果 AnonHugePages 仍为 0,可能是 THP 被禁用、MADV_COLLAPSE 不可用、khugepaged 尚未完成合并,或者受到内存碎片、NUMA 和 cgroup 限额影响。khugepaged 是全局扫描器;即使等待时间超过 scan_sleep_millisecsalloc_sleep_millisecs,也不保证目标 VMA 已经被扫描并成功合并。因此固定 sleep 只能提供观察窗口,不能构成确定性验证。若需要同步、尽力触发合并,应在 Linux 6.1 及以上使用 MADV_COLLAPSE;若要求映射成功即确定使用大页,则需要显式 HugeTLB。如果 mprotect 返回 EACCES,则可能是 SELinux、容器或其他安全策略禁止匿名可执行内存。

这个案例演示的是 JIT 风格的匿名可执行代码,目的在于隔离验证代码大页机制;它不会把现有 ELF 的 .text 自动搬到大页上。下一节再讨论如何为真实 ELF 热点代码建立独立 section。

手动请求 THP

下面给出一个完整的 ELF 实验模板。它把一个热点函数放入 2 MiB 对齐的 .hottext,程序启动后对该区间调用 MADV_HUGEPAGE,并在可用时调用 MADV_COLLAPSE。模板可以独立编译和运行,但目标内核不支持文件 THP 时,预期结果就是 THPeligible: 0FilePmdMapped: 0 kB,不能把“程序运行成功”写成“代码大页成功”。

该案例针对 Linux 上常见的 2 MiB PMD THP。它故意把 .hottext 补足到 2 MiB,以便稳定观察映射结果,因此会显著增大实验二进制;生产环境不应为了凑满大页而盲目填充冷代码。

项目只包含两个文件:

1
2
3
manual-thp/
├── hottext.ld
└── manual_thp.cpp

1. 编写增量链接脚本

hottext.ld 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
SECTIONS
{
.hottext ALIGN(0x200000) :
{
__hot_text_start = .;
KEEP(*(.hottext))
KEEP(*(.hottext.*))
. = ALIGN(0x200000);
__hot_text_end = .;
}
}
INSERT AFTER .text;

为避免 .hottext 被默认的 .text.* 规则提前合并,这里没有使用 .text.hotpage 之类的名称。INSERT AFTER .text 会在默认链接布局中插入新 section,不需要复制和维护整份默认链接脚本;GNU ldlld 都支持这种写法。

KEEP 防止链接器在启用 --gc-sections 时删除输入 section。脚本同时将区间起止地址导出为 __hot_text_start__hot_text_end,供程序运行时使用。

2. 编写完整程序

manual_thp.cpp 内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif

#include <cerrno>
#include <cstddef>
#include <cstdint>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <sys/mman.h>
#include <unistd.h>

extern "C" char __hot_text_start[];
extern "C" char __hot_text_end[];

extern "C" __attribute__((noinline, used, section(".hottext")))
std::uint64_t hotFunction(std::uint64_t value)
{
// 数据相关的循环可以避免优化器把整个函数折叠成一个常量。
for (std::uint64_t i = 0; i < 1'000'000; ++i)
{
value ^= value << 13;
value ^= value >> 7;
value ^= value << 17;
value += i;
}
return value;
}

namespace
{

constexpr std::uintptr_t huge_page_size = 2 * 1024 * 1024;

void printMadviseError(const char * operation)
{
const int error = errno;
std::fprintf(
stderr,
"%s failed: errno=%d (%s)\n",
operation,
error,
std::strerror(error));
}

unsigned char prefaultRange(const char * begin, std::size_t size)
{
const long native_page_size = sysconf(_SC_PAGESIZE);
if (native_page_size <= 0)
{
std::perror("sysconf(_SC_PAGESIZE)");
std::exit(EXIT_FAILURE);
}

auto bytes = reinterpret_cast<volatile const unsigned char *>(begin);
unsigned char checksum = 0;
for (std::size_t offset = 0; offset < size;
offset += static_cast<std::size_t>(native_page_size))
checksum ^= bytes[offset];
return checksum;
}

} // namespace

int main()
{
char * const begin = __hot_text_start;
char * const end = __hot_text_end;
const auto begin_address = reinterpret_cast<std::uintptr_t>(begin);
const auto end_address = reinterpret_cast<std::uintptr_t>(end);
const std::size_t size = end_address - begin_address;

std::printf(
"pid=%ld, .hottext=[%p, %p), size=%zu KiB\n",
static_cast<long>(getpid()),
static_cast<void *>(begin),
static_cast<void *>(end),
size / 1024);

if (begin_address % huge_page_size != 0
|| end_address % huge_page_size != 0
|| size == 0)
{
std::fputs(".hottext is not 2 MiB aligned\n", stderr);
return EXIT_FAILURE;
}

if (madvise(begin, size, MADV_HUGEPAGE) == 0)
std::puts("MADV_HUGEPAGE accepted");
else
printMadviseError("MADV_HUGEPAGE");

// 读取每个普通页,使整个文件映射进入内存,然后再尝试同步合并。
const unsigned int checksum = prefaultRange(begin, size);
std::printf("prefault checksum=%u\n", checksum);

#ifdef MADV_COLLAPSE
if (madvise(begin, size, MADV_COLLAPSE) == 0)
std::puts("MADV_COLLAPSE succeeded");
else
printMadviseError("MADV_COLLAPSE");
#else
std::puts("MADV_COLLAPSE is unavailable in the system headers");
#endif

const std::uint64_t result = hotFunction(42);
std::printf("hotFunction(42)=%llu\n", static_cast<unsigned long long>(result));

// 留出时间从另一个终端检查 /proc/<pid>/smaps。
std::puts("sleeping for 30 seconds; inspect /proc/<pid>/smaps now");
std::fflush(stdout);
sleep(30);
return EXIT_SUCCESS;
}

属性 noinline 防止热点函数被内联到普通 .textused 防止编译器认为它不需要单独生成。它们只能控制编译阶段,链接脚本中的 KEEP 则处理链接阶段的 section 回收。

3. 编译和链接

使用 GNU ld:

1
2
3
4
5
6
7
8
g++ -std=c++17 -O2 \
-no-pie \
-ffunction-sections -fdata-sections \
manual_thp.cpp \
-Wl,-T,hottext.ld \
-Wl,-z,max-page-size=0x200000 \
-Wl,--gc-sections \
-o manual_thp

使用 Clang 和 lld 时,只需替换编译器并指定 linker:

1
2
3
4
5
6
7
8
clang++ -fuse-ld=lld -std=c++17 -O2 \
-no-pie \
-ffunction-sections -fdata-sections \
manual_thp.cpp \
-Wl,-T,hottext.ld \
-Wl,-z,max-page-size=0x200000 \
-Wl,--gc-sections \
-o manual_thp

实验使用 -no-pie,避免 PIE 加载基址干扰第一次验证。生产环境如果启用 PIE,需要确认内核和动态 Loader 保留 PT_LOAD 的大页对齐,并以程序打印的运行时地址为准。本文后述的 Clang 20 / GNU ld 实机复核中,PIE 版本也保持了 2 MiB 地址和文件偏移对齐,但这只是该工具链与 Loader 组合的结果,不能替代目标环境检查。

-z max-page-size=0x200000 将 ELF PT_LOAD 的最大页对齐提高到 2 MiB,使 .hottext 的虚拟地址和文件偏移有机会同时满足文件 THP 的自然对齐要求。代价是其他 Segment 之间也可能产生较大空洞,令实验二进制明显增大。

4. 链接后静态检查

先检查 section:

1
readelf -SW ./manual_thp | grep -E '\.(text|hottext)'

.hottext 应带有 AX 标志,AddressOff 都应按 2 MiB 对齐,Size 应为 0x200000 或其整数倍。然后检查 Program Header:

1
readelf -lW ./manual_thp

确认 .hottext 位于具有 R E 权限的 LOAD Segment 中,并且该 Segment 的 Align0x200000。如果虚拟地址对齐但文件偏移没有对齐,文件映射仍不满足 THP 条件。

5. 运行并检查实际映射

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
./manual_thp &
pid=$!
sleep 2

awk -v exe="$(readlink -f ./manual_thp)" '
/^[0-9a-f]+-[0-9a-f]+/ {
show = ($2 ~ /r-x/ && $NF == exe)
if (show)
print
next
}
show && /^(KernelPageSize|MMUPageSize|FilePmdMapped|AnonHugePages|THPeligible|VmFlags):/ {
print
}
' /proc/${pid}/smaps

wait ${pid}

这个案例操作的是 ELF 文件支持的可执行映射,因此主要成功信号是:

1
FilePmdMapped:      2048 kB

前一个微型 JIT 案例操作的是匿名映射,主要观察 AnonHugePages。两者不能混为一谈。Linux 内核文档也分别使用 FilePmdMappedAnonHugePages 统计这两类 THP,详见 Transparent Hugepage Support

6. 为什么仍可能失败

MADV_HUGEPAGE 返回成功只表示内核接受建议,不代表已经生成 THP。对于普通文件支持的映射,还需要满足以下条件:

  • 内核启用了 CONFIG_TRANSPARENT_HUGEPAGECONFIG_READ_ONLY_THP_FOR_FS
  • 映射具有可执行权限,底层文件没有以写方式打开。
  • 虚拟地址与文件内偏移都在 THP 边界上自然对齐。
  • VMA 没有被标记为 VM_NOHUGEPAGE,进程也没有完全禁用 THP。
  • 内存、NUMA 和 cgroup 条件允许内核分配并合并大页。

先检查运行内核的构建配置;发行版内核未必开放 /proc/config.gz,也可能只在 /boot 保留配置:

1
2
zgrep -E 'CONFIG_(TRANSPARENT_HUGEPAGE|READ_ONLY_THP_FOR_FS)' /proc/config.gz 2>/dev/null \
|| grep -E 'CONFIG_(TRANSPARENT_HUGEPAGE|READ_ONLY_THP_FOR_FS)' /boot/config-"$(uname -r)"

即使配置项存在,也要以当前 VMA 的 THPeligibleFilePmdMapped 为最终运行时证据。

MADV_COLLAPSE 从 Linux 6.1 开始提供,会同步、尽力尝试合并当前映射。它不依赖 /sys/kernel/mm/transparent_hugepage 下的全局策略,但仍可能因为内核不支持文件 THP、映射不合格、内存不足或 cgroup 限额而失败。完整语义和错误码见 madvise(2)

此外,开启 LTO 后,编译器仍可能克隆或重写函数;支持 ICF 的链接器也可能折叠内容相同的函数。实验时应通过符号表和 section 表确认最终布局,不能只相信源码中的属性。

这个案例验证的是“为特定 ELF 文件映射建立对齐布局并请求 THP”的完整路径。只有 FilePmdMapped 大于 0 时,才能进一步说目标代码当前位于文件 THP 上;即使映射成功,也不代表实际性能一定提升。生产程序还需要用真实 profile 选择热点代码,并对比 iTLB miss、IPC、RSS、二进制大小和启动时间。

显式 HugeTLB

如果要求“映射成功后一定使用大页”,则需要预留显式 HugeTLB,并通过 MAP_HUGETLB 创建代码映射:

1
2
3
4
5
6
7
8
9
10
#include <linux/mman.h>
#include <sys/mman.h>

void * memory = mmap(
nullptr,
size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_HUGETLB | MAP_HUGE_2MB,
-1,
0);

映射成功后,需要写入已经完成 relocation 的代码,再通过 mprotect 把权限从 RW 改成 RX。普通 ELF 的 .text 中可能包含 PC-relative 引用、GOT/PLT、跳转表和异常展开信息,不能简单复制到新地址执行,因此现有程序通常需要定制 Loader 或运行时支持。当前 BOLT 上游的显式 HugeTLB 支持仍在改进讨论中,详见 BOLT hugify improvements

统一验证与性能对照

“系统支持大页”“madvise 返回成功”和“目标代码当前确实映射在大页上”是三个不同状态。最终应根据映射类型检查 /proc/<pid>/smaps

  • 匿名 THP,例如微型 JIT:AnonHugePages
  • 文件支持的可执行 THP,例如 .hottextFilePmdMapped
  • 显式 HugeTLB:Private_HugetlbShared_Hugetlb

实机复核环境为 Rocky Linux 9.2、Linux 5.14.0、Clang 20.1.8、GNU ld 2.35.2 和 LLD 20.1.8;系统 THP 模式为 always,PMD 大页为 2 MiB。结果如下:

测试 布局或执行结果 smaps 结果 结论
匿名 JIT 2 MiB 对齐,代码成功返回 42 THPeligible: 1,等待 70 秒后仍为 AnonHugePages: 0 kB 请求路径和可执行内存有效,但该次运行没有获得匿名 THP;异步扫描未命中或合并条件未满足
GNU ld、no-PIE .hottext 地址 0x800000、文件偏移 0x400000、大小 2 MiB,函数运行成功 THPeligible: 0FilePmdMapped: 0 kB ELF 对齐成功,目标内核不接受该文件映射使用 THP
GNU ld、PIE .hottext 运行时地址与文件偏移保持 2 MiB 对齐,函数运行成功 THPeligible: 0FilePmdMapped: 0 kB 该环境证明 PIE 可以保持对齐,但仍没有文件 THP
LLD、no-PIE .hottext 地址 0x600000、文件偏移 0x200000、大小 2 MiB,函数运行成功 THPeligible: 0FilePmdMapped: 0 kB LLD 布局有效,目标内核仍不支持该映射

该内核早于 Linux 6.1,采集器对 MADV_COLLAPSE 的能力探测返回 EINVAL;内核配置采集中也没有出现已启用的 CONFIG_READ_ONLY_THP_FOR_FS。这些结果说明链接布局正确只是必要条件,不能据此宣称代码已使用大页。BOLT 工具在该环境中未安装,因此这次复核不覆盖 -hugify

下面的命令显示指定可执行文件的所有可执行映射及其大页统计:

1
2
3
4
5
6
7
8
9
10
11
awk -v exe="$(readlink -f ./application)" '
/^[0-9a-f]+-[0-9a-f]+/ {
show = ($2 ~ /r-x/ && $NF == exe)
if (show)
print
next
}
show && /^(KernelPageSize|MMUPageSize|FilePmdMapped|AnonHugePages|Private_Hugetlb|Shared_Hugetlb|THPeligible|VmFlags):/ {
print
}
' /proc/<pid>/smaps

映射验证通过后,再在相同二进制、负载、CPU 绑定、NUMA 策略和预热条件下比较性能:

1
2
3
4
perf stat \
-e cycles,instructions,iTLB-loads,iTLB-load-misses \
-p <pid> \
-- sleep 180

至少应对比普通页、THP 和(如实际部署允许)显式 HugeTLB,并同时记录查询延迟、吞吐、IPC、iTLB miss、RSS、二进制大小和启动时间。仅看到 FilePmdMapped: 2048 kB 不能证明业务性能一定提升。

jemalloc 能否直接用于代码大页

不能。jemalloc 的 opt.thp 控制默认 extent hooks 管理的用户内存映射,opt.metadata_thp 控制分配器内部元数据;它们可以影响堆和 jemalloc 元数据,但不会重新布局或替换 ELF Loader 创建的代码段映射。

例如下面的配置可以用于评估 jemalloc 管理的内存是否适合 THP,但不会令 .text.hottext 自动使用代码大页:

1
MALLOC_CONF="thp:always,metadata_thp:auto" ./application

如果程序通过 jemalloc 的自定义 extent hooks 自行管理 JIT 代码内存,可以在 hooks 中实现特定映射策略;这属于应用自定义的可执行内存管理,不是 jemalloc 对普通 ELF 代码段的直接支持。

本文尚未完成代码大页在 TiFlash 上的严格对照实验,因此不评价实际收益。后续应在相同负载和构建配置下,分别比较原始版本、PGO + ThinLTO、PGO + ThinLTO + BOLT,以及 PGO + ThinLTO + BOLT + -hugify

编译缓存

ccache 作为一种编译器缓存用于加速重编译过程。tiflash/cmake/find_ccache.cmake 也默认开启这一优化项来加速 CI 流程。C++ 的特性决定了头文件耦合程度会大幅影响编译缓存命中率,所以问题的核心又回到了 头文件优化

根据当前 ccache - Precompiled headers 文档,ccache 配合 PCH 使用时需要设置 sloppiness=pch_defines,time_macros;Clang 还应增加 -fno-pch-timestamp,避免 PCH 时间戳令缓存失效。
在实践中,我们发现 ccache 默认处理 PCH 时会判断相关文件的修改时间,当 PCH 中包含编译期生成的代码时(例如 protobuf)会导致相关目标文件缓存失败,对此需要进行更细粒度的划分和针对性调整。

就目前而言,ccache 主要用于日常开发以及 Pull Request 的 CI 流程(增量编译),PCH 则主要用于 release 编译(全量编译)。

本次优化中,限制模板实例化使两个典型编译单元的前端耗时分别下降约 89%;启用 PCH 后,前端累计耗时由 7181.8 秒下降至 4167.5 秒,降幅约 42%。由于两次分析包含的编译任务数略有差异,这些数据应视为工程效果的近似对照,最终收益仍需以相同环境下的墙钟时间验证。

社区|新开发者如何参与优化

  • 根据 tiflash/README.md 中的指示部署基本编译环境。
  • 设置 CMake 选项:
    • -DENABLE_TIME_TRACES=ON 开启编译分析追踪
    • -DUSE_CCACHE=OFF 禁用 ccache
    • -DENABLE_PCH=OFF 禁用 PCH(目前先专注于优化头文件相关的开销)
    • -DCMAKE_BUILD_TYPE=RelWithDebInfo-DCMAKE_BUILD_TYPE=Debug 需分别优化两种编译模式下的各项指标
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# prepare environment: llvm, rust
# clone https://github.com/pingcap/tiflash.git to ${TIFLASH_WORKSPACE}
# mkdir ${TIFLASH_BUILD}

cd ${TIFLASH_BUILD}
CC=clang CXX=clang++ cmake \
-S ${TIFLASH_WORKSPACE} \
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
-DLINKER_NAME=lld \
-DUSE_CCACHE=0 \
-DENABLE_TIME_TRACES=ON \
-DENABLE_PCH=0 \
-Wno-dev \
-DNO_WERROR=ON \
-GNinja

time cmake --build . --target tiflash --parallel 5
  • 编译后在 ${TIFLASH_BUILD} 中可获取每个目标文件的编译期火焰图
  • 参考 编译流程分析 编译部署 ClangBuildAnalyzer,并用其分析 ${TIFLASH_BUILD}
  • 保存 ClangBuildAnalyzer 生成的中间文件和分析结果,作为对照组
  • 根据 ClangBuildAnalyzer 分析结果,找出瓶颈点,参照上文进行优化
  • CMAKE_EXPORT_COMPILE_COMMANDS 默认开启,可在文件 ${TIFLASH_BUILD}/compile_commands.json 中找到各个编译单元的执行命令。
    • 每次修改后,找到关联源文件的编译命令并手动执行,分析编译时的火焰图
    • 可安装 include-what-you-use 后用 iwyu_tool.py 辅助。其中存在误报,需人工排查。
      • 例如 iwyu_tool.py -p ${TIFLASH_BUILD} ${TIFLASH_WORKSPACE}/dbms/src/Storages/Transaction/TMTContext.cpp

优化目标

  • 降低 clean build 的实际墙钟时间和累计 CPU 时间
  • 降低常见代码修改触发的增量构建时间
  • 避免少数超大编译单元成为并行构建的长尾任务
  • 降低不必要的模板实例化和头文件传递依赖
  • 提高 ccache 命中率,并控制编译峰值内存
  • 在不牺牲代码可维护性的前提下优化构建效率

参考文档