这一篇是把日常积累的「ELF 检查、强制 32 位、C++ 编译流程、静态/动态库、编译裁剪、各种编译优化(符号表 / BOLT / FDO ≡ PGO / LTO / AutoFDO / LLVM / Clang)、configure & cmake 小技巧」放在一起的笔记。原稿标题层级混乱、章节编号嵌在正文里、有些代码块没有围栏,这次只调整格式与层级、补上专有名词的解释,不删原有正文。
术语速查(先放在最前面,后面正文里多次用到):
- ELF:Executable and Linkable Format,Linux 下可执行文件 /
.so/.o的标准格式。- TU / 编译单元:Translation Unit,一个
.c/.cpp经过预处理后送给编译器的整体输入。- IR:Intermediate Representation,编译器中间表示。GCC 的叫 GIMPLE,LLVM 的叫 LLVM IR。
- AST:Abstract Syntax Tree,前端语法分析后产生的抽象语法树。
- CSE:Common Subexpression Elimination,公共子表达式消除。
- GCSE:Global CSE,跨基本块 / 函数级别的 CSE。
- PGO ≡ FDO:Profile-Guided / Feedback-Directed Optimization,先采样跑一遍,把分支概率等真实数据回喂给编译器再编一次。
- AutoFDO:用 perf 采样代替插桩的 FDO,可以在生产环境直接取数据。
- LTO:Link-Time Optimization,把 IR 留到链接阶段做整体优化。
- BOLT:Binary Optimization and Layout Tool(Facebook),对已链接好的二进制做 post-link layout 优化。
- Propeller:Google 出品,思路与 BOLT 类似,效果接近。
- gcov / .gcda:GCC 自带的覆盖率 / 计数信息工具与文件。
- perf / perf.data:Linux 内核内置的性能剖析工具与采样数据文件。
- strip:去掉二进制里的符号表 / 调试信息以减小体积。
查看32位还是64位
readelf 命令,参数为-h
例如 文件名为python
>>>readelf -h python
得到的是ELF Header中的项Magic
第五个数 02时为64位,01时为32位
强制编译32位程序
脚本中设置 USE_32BITS=1
需要安装gcc-X-multilib g++-x-multilib 版本
if(USE_32BITS)
message(STATUS "using 32bits")
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -m32")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -m32")
else()
endif(USE_32BITS)
【c++程序编译流程】
预处理→ 编译 → 汇编 → 链接

具体的就是:
源代码(source code)→ 预处理器(processor)→ 编译器(compiler)→ 汇编程序(assembler)→ 目标程序(object code)→ 链接器(Linker)→ 可执行程序(executables)
下面详细介绍每个流程的具体事项:
1、预处理 :预处理相当于根据预处理处理指令组装新的C++程序。经过预处理,会产生一个没有宏定义,没有条件编译指令,没有特殊符号的输出文件,这个文件含义同原本的文件无异,只是内容上有所不同
读取C++源程序,对其中的伪指令(以#开头的指令)进行处理
* 将所有的#define删除,并且展开所有的宏定义
* 处理所有的条件编译指令,如“#if”、“#ifdef”、“#elif”、“#else”、“endif”等。这些伪指令的引入使得程序员可以通过定义不同的宏来决定编译程序对哪些代码进行处理。预编译程序将根据有关的文件,将那些不必要的代码过滤掉
* 处理#include预编译指令,将被包含的文件插入到该预编译指令的位置(可递归执行)
删除所有的注释
添加行号和文件名标识
* 以便于编译时编译器产生调试用的行号信息及用于编译时产生的编译错误或警告时能够显示行号
保留所有的#pragma编译器指令
* https://baike.baidu.com/item/%23pragma
* #pragma once:只要在头文件的最开始加入这条指令就能够保证头文件被编译一次
2、编译过程
将预处理完的文件进行一系列词法分析、语法分析、语义分析,在确认所有的指令都符合语法规则后,将其翻译成汇编代码文件。在这一步中,编译器会对代码进行检查优化,指出语法错误、重载决议错误及其他各种编译错误
* 词法分析:编译过程的第一个阶段。这个阶段的任务是从左到右一个字符一个字符地读入源程序,即对构成源程序的字符流进行扫描然后根据构词规则识别单词(也称单词符号或符号)
* 语法分析:编译过程的一个逻辑阶段。语法分析的任务是在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等
* 语义分析:编译过程的一个逻辑阶段。语义分析的任务是对结构上正确的源程序进行上下文有关性质的审查, 进行类型审查
# 源程序的结构是正确的,语义分析将审查类型并报告错误:不能在表达式中使用一个数组变量,赋值语句的右端和左端的类型不匹配
3、汇编过程
将编译完的汇编代码文件翻译成机器指令,并生成可重定位目标程序的.o文件,该文件为二进制文件。
汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。所以汇编器的汇编过程相对于编译器来讲比较简单,它没有复杂的语法,也没有语义,也不需要做指令优化,只是根据汇编指令和机器指令的对照表一一翻译即可。
对于被翻译系统处理的每一个C++语言源程序,都将最终经过这一处理得到相应的目标文件。目标文件中所存放的也是与源程序等效的目标的机器语言代码
[表格]
其中MOV指令表示传送字或字节
4、链接过程
链接器利用编译器产生的目标文件,生成最终的可执行文件。
在这一阶段,编译器将把上一阶段中编译器产生的各种目标文件链接起来,将未定义标识符的引用全部替换成它们对应的正确地址。没有把目标文件链接起来,就无法生成能够正常工作的程序——就像一页没有页码的目录一样,没什么用处。完成链接工作之后,链接器根据编译目的不同,把链接的结果生成为一个动态链接库,或是一个可执行文件。
静态库和动态库
从编译的角度来看两者之间最主要的差别体现在是否发生了链接动作。静态库是.o文件的集合,.o文件并没有执行过链接动作,.o文件中引用其他文件的符号并没有经过解引用操作,其他文件可以是同一个代码库下其他的.o文件,也可以是其他模块的静态库,还可以是其他模块或操作系统的动态库。静态库是按需链接,链接的粒度是.o文件,不是大家常在编译命令中看到的.a文件,只有被引用的符号所在的.o 文件才会被写入应用程序。如果代码中没有用静态库中的函数,即便在编译命令中指定了该库,连接器也不会发生连接。
目标文件经过了链接处理便成为了动态库,从操作系统的角度而言,动态库包含了指令和数据,从文件结构上更接近于应用程序,给动态库增加入口函数,动态库也可以像应用程序一样正常运行。
编译裁剪
debug、release
线上移除不需要的lib文件,test二进制文件,提高部署速度
LIBRARY micontinuity_sdk
EXPORTS
GetErrMsg;
extern "C++" {
"class::Get()";
class::Packet::*;
};
参考链接
llvm 寄存器,图着色 https://zhuanlan.zhihu.com/p/55287942
字节 envovy 编译优化收益: http://www.it120.vip/yq/8534.html
编译优化
符号表优化
去除符号表信息
bolt 链接后优化技术 (sample-guided-optimization)
fdo + pgo 后依然有效,全局角度
https://zhuanlan.zhihu.com/p/550895670
google 效果与bolt基本相同
https://github.com/google/llvm-propeller
FDO(feedback-directed-optimization) == PGO (profile-guided-optimization)
LTO (link-time-optimization)
全局有效
# -flto 开启后,可以减少so的体积,提高程序运行效率, apk 体积增加了 139k,140k
merge_native_libs 大小从129k降低至99k
set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} -O3 -ffunction-sections -fdata-sections -flto")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -fno-exceptions -ffunction-sections -fdata-sections -flto")
3.0 优化方向
3.0.1 代码优化
1、公共子表达式消除:如果一个表达式e已经计算过了,并且从先前的计算到现在e中所有变量的值都没有发生变化,那么e的这次出现就成为公共子表达式。
2、删除无用代码:永远不能被执行到的代码或者没有任何意义的代码会被清除掉
3、常量传播:在编译优化时, 能够将计算出结果的变量直接替换为常量
4、常量折叠:在编译优化时,多个变量进行计算时,而且能够直接计算出结果,那么变量将有常量直接替换。
5、复写传播:两个相同的变量可以用一个代替。
6、数组范围检查消除:数组边界检查不是必须在运行期间一次不漏的检查,而是可以协商的。如果及时编译器能根据数据流分析出变量的取值范围在[0,max_length]之间,那么在循环期间就可以把数组的上下边界检查消除
7、方法内联:方法内联就是把调用方函数代码”复制”到调用方函数中,减少因函数调用开销的技术
8、逃逸分析:分析对象动态作用域,一旦确定对象不会发生方法逃逸和线程逃逸,就可以对这个变量进行高效的优化,比如栈上分配、同步消除、标量替换等。
- 栈上分配:将堆分配转化为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 分配对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分或全部可以不存储在内存,而是存储在CPU寄存器中
3.0.2 寄存器分配
1、寄存器基础知识
一般computer中存在内存,内存就像仓库,我们不常用的东西分类放到仓库里边去。等到用的时候就会拿出来放在手边,手边的一些柜子书桌就是CPU中的寄存器。寄存器的位数和指令的位宽是一样的。我们说128位的指令位宽,那么对应的寄存器的位数就是128位,而CPU每次可以计算的数据的宽度最大也是128位。因为我们常用的数据达不到这样的宽度,这样每个指令周期就可以执行多个数据的计算。这就是所谓向量化计算
2、寄存器分配
寄存器是位于CPU或GPU内部的少量的高速存储器,用于保存机器指令的操作数。由于其价格昂贵导致其数量有限,又由于存取速度快,使其不可或缺。因此,寄存器是计算机体系结构中的关键资源之一。在计算复杂表达式的过程中产生的中间结果也保存在寄存器中。更复杂的编译器会把经常使用的变量放在寄存器里,来避免反复地存取。如果是优化的编译器,会把公共子表达式消除或者循环不变量移动以后的重用值放在寄存器中。
在编译的代码生成阶段,程序中的变量会被编译器替换为寄存器。高级语言程序中使用的变量数量可以是几乎无限的,但CPU或GPU中的寄存器数量是有限的,寄存器分配器作为后端的一个模块要解决这对矛盾,控制寄存器的分配和使用。因此,寄存器分配是将程序中的数量无限的虚拟寄存器映射到数量有限的物理寄存器。寄存器分配可以工作在表达式、基本块、函数(也称全局)或整个程序级别。
附:https://zhuanlan.zhihu.com/p/552879423.0.3 指令调度
指令调度是编译优化中用于提高指令级并行,从而提高在计算机上指令流水线的性能。更直接的说,在没有改变原代码语义的情况下,它做了下面两件事:
``` - 通过重排指令顺序避免指令流水线停顿
- 避免非法或语义模糊的操作(涉及典型的细微的指令流水线时序问题或非互锁的资源) ```
3.1 gcc
3.1.1 gcc介绍
gcc的全称是GNU Compiler Collection,它是一个能够编译多种语言的编译器。最开始gcc是作为C语言的编译器(GNU C Compiler),现在除了c语言,还支持C++、java、Pascal等语言。gcc支持多种硬件平台。特点如下: * gcc是一个可移植的编译器,支持多种硬件平台。例如ARM、X86等等。 * gcc不仅是个本地编译器,它还能跨平台交叉编译。所谓的本地编译器,是指编译出来的程序只能够在本地环境进行运行。而gcc编译出来的程序能够在其他平台进行运行。例如嵌入式程序可在x86上编译,然后在arm上运行。 * gcc有多种语言前端,用于解析不同的语言。 * gcc是按模块化设计的,可以加入新语言和新CPU架构的支持。 * gcc是自由软件。任何人都可以使用或更改这个软件。 #### 3.1.2 gcc基本优化选项及举例
gcc 提供了为了满足用户不同程度的的优化需要,提供了近百种优化选项,用来对{编译时间,目标文件长度,执行效率}这个三维模型进行不同的取舍和平衡。优化的方法不一而足,总体上将有以下几类:1)精简操作指令;2)尽量满足cpu的流水操作;3)通过对程序行为地猜测,重新调整代码的执行顺序;4)充分使用寄存器;5)对简单的调用进行展开等等。 * -O0:不做任何优化,这是默认的编译选项 * -O和-O1: 对程序做部分编译优化,对于大函数,优化编译占用稍微多的时间和相当大的内存。使用本项优化,编译器会尝试减小生成代码的尺寸,以及缩短执行时间,但并不执行需要占用大量编译时间的优化。打开的优化选项包括 优化选项 l -fdefer-pop:延迟栈的弹出时间。当完成一个函数调用,参数并不马上从栈中弹出,而是在多个函数被调用后,一次性弹出。 l -fmerge-constants:尝试横跨编译单元合并同样的常量(string constants and floating point constants) l -fthread-jumps:如果某个跳转分支的目的地存在另一个条件比较,而且该条件比较包含在前一个比较语句之内,那么执行本项优化.根据条件是true或者false,前面那条分支重定向到第二条分支的目的地或者紧跟在第二条分支后面. l -floop-optimize:执行循环优化,将常量表达式从循环中移除,简化判断循环的条件,并且optionally do strength-reduction,或者将循环打开等。在大型复杂的循环中,这种优化比较显著。 l -fif-conversion:尝试将条件跳转转换为等价的无分支型式。优化实现方式包括条件移动,min,max,设置标志,以及abs指令,以及一些算术技巧等。 l -fif-conversion2基本意义相同,没有找到更多的解释。 l -fdelayed-branch:这种技术试图根据指令周期时间重新安排指令。 它还试图把尽可能多的指令移动到条件分支前, 以便最充分的利用处理器的治理缓存。 l -fguess-branch-probability:当没有可用的profiling feedback或__builtin_expect时,编译器采用随机模式猜测分支被执行的可能性,并移动对应汇编代码的位置,这有可能导致不同的编译器会编译出迥然不同的目标代码。 l -fcprop-registers:因为在函数中把寄存器分配给变量, 所以编译器执行第二次检查以便减少调度依赖性(两个段要求使用相同的寄存器)并且删除不必要的寄存器复制操作。
-
-O2: 是比O1更高级的选项,进行更多的优化。Gcc将执行几乎所有的不包含时间和空间折中的优化。与O1比较而言,O2优化增加了编译时间的基础上,提高了生成代码的执行效率。 O2打开所有的O1选项,并打开以下选项
优化选项
l -fforce-mem:在做算术操作前,强制将内存数据copy到寄存器中以后再执行。这会使所有的内存引用潜在的共同表达式,进而产出更高效的代码,当没有共同的子表达式时,指令合并将排出个别的寄存器载入。这种优化对于只涉及单一指令的变量, 这样也许不会有很大的优化效果. 但是对于再很多指令(必须数学操作)中都涉及到的变量来说, 这会时很显著的优化, 因为和访问内存中的值相比 ,处理器访问寄存器中的值要快的多。
l -foptimize-sibling-calls:优化相关的以及末尾递归的调用。通常, 递归的函数调用可以被展开为一系列一般的指令, 而不是使用分支。 这样处理器的指令缓存能够加载展开的指令并且处理他们, 和指令保持为需要分支操作的单独函数调用相比, 这样更快。
l -fstrength-reduce:这种优化技术对循环执行优化并且删除迭代变量。 迭代变量是捆绑到循环计数器的变量, 比如使用变量, 然后使用循环计数器变量执行数学操作的for-next循环。
l -fcse-follow-jumps:在公用子表达式消元时,当目标跳转不会被其他路径可达,则扫描整个的跳转表达式。例如,当公用子表达式消元时遇到if…else…语句时,当条为false时,那么公用子表达式消元会跟随着跳转。
l -fcse-skip-blocks:与-fcse-follow-jumps类似,不同的是,根据特定条件,跟随着cse跳转的会是整个的blocks
l -frerun-cse-after-loop:在循环优化完成后,重新进行公用子表达式消元操作。
l -frerun-loop-opt:两次运行循环优化 l -fgcse:执行全局公用子表达式消除pass。这个pass还执行全局常量和copy propagation。这些优化操作试图分析生成的汇编语言代码并且结合通用片段, 消除冗余的代码段。如果代码使用计算性的goto, gcc指令推荐使用-fno-gcse选项。
l-fgcse-lm:全局公用子表达式消除将试图移动那些仅仅被自身存储kill的装载操作的位置。这将允许将循环内的load/store操作序列中的load转移到循环的外面(只需要装载一次),而在循环内改变成copy/store序列。在选中-fgcse后,默认打开。
l -fgcse-sm:当一个存储操作pass在一个全局公用子表达式消除的后面,这个pass将试图将store操作转移到循环外面去。如果与-fgcse-lm配合使用,那么load/store操作将会转变为在循环前load,在循环后store,从而提高运行效率,减少不必要的操作。
l -fgcse-las:全局公用子表达式消除pass将消除在store后面的不必要的load操作,这些load与store通常是同一块存储单元(全部或局部)
l-fdelete-null-pointer-checks:通过对全局数据流的分析,识别并排出无用的对空指针的检查。编译器假设间接引用空指针将停止程序。 如果在间接引用之后检查指针,它就不可能为空。
l -fexpensive-optimizations:进行一些从编译的角度来说代价高昂的优化(这种优化据说对于程序执行未必有很大的好处,甚至有可能降低执行效率,具体不是很清楚)
l -fregmove:编译器试图重新分配move指令或者其他类似操作数等简单指令的寄存器数目,以便最大化的捆绑寄存器的数目。这种优化尤其对双操作数指令的机器帮助较大。
l -fschedule-insns:编译器尝试重新排列指令,用以消除由于等待未准备好的数据而产生的延迟。这种优化将对慢浮点运算的机器以及需要load memory的指令的执行有所帮助,因为此时允许其他指令执行,直到load memory的指令完成,或浮点运算的指令再次需要cpu。
l -fschedule-insns2:与-fschedule-insns相似。但是当寄存器分配完成后,会请求一个附加的指令计划pass。这种优化对寄存器较小,并且load memory操作时间大于一个时钟周期的机器有非常好的效果。
l -fsched-interblock:这种技术使编译器能够跨越指令块调度指令。 这可以非常灵活地移动指令以便等待期间完成的工作最大化。
l -fsched-spec-load:允许一些load指令进行一些投机性的动作。(具体不详)相同功能的还有-fsched-spec-load-dangerous,允许更多的load指令进行投机性操作。这两个选项在选中-fschedule-insns时默认打开。
l -fcaller-saves:通过存储和恢复call调用周围寄存器的方式,使被call调用的value可以被分配给寄存器,这种只会在看上去能产生更好的代码的时候才被使用。(如果调用多个函数, 这样能够节省时间, 因为只进行一次寄存器的保存和恢复操作, 而不是在每个函数调用中都进行。)
l -fpeephole2:允许计算机进行特定的观察孔优化(这个不晓得是什么意思),-fpeephole与-fpeephole2的差别在于不同的编译器采用不同的方式,由的采用-fpeephole,有的采用-fpeephole2,也有两种都采用的。
l -freorder-blocks:在编译函数的时候重新安排基本的块,目的在于减少分支的个数,提高代码的局部性。
l -freorder-functions:在编译函数的时候重新安排基本的块,目的在于减少分支的个数,提高代码的局部性。这种优化的实施依赖特定的已存在的信息:.text.hot用于告知访问频率较高的函数,.text.unlikely用于告知基本不被执行的函数。
l -fstrict-aliasing:这种技术强制实行高级语言的严格变量规则。 对于c和c++程序来说, 它确保不在数据类型之间共享变量. 例如, 整数变量不和单精度浮点变量使用相同的内存位置。
l -funit-at-a-time:在代码生成前,先分析整个的汇编语言代码。这将使一些额外的优化得以执行,但是在编译器间需要消耗大量的内存。(有资料介绍说:这使编译器可以重新安排不消耗大量时间的代码以便优化指令缓存。)
l -falign-functions:这个选项用于使函数对准内存中特定边界的开始位置。 大多数处理器按照页面读取内存,并且确保全部函数代码位于单一内存页面内, 就不需要叫化代码所需的页面。
l -falign-jumps:对齐分支代码到2的n次方边界。在这种情况下,无需执行傀儡指令(dummy operations)
l -falign-loops:对齐循环到2的n次幂边界。期望可以对循环执行多次,用以补偿运行dummy operations所花费的时间。
l -falign-labels:对齐分支到2的n次幂边界。这种选项容易使代码速度变慢,原因是需要插入一些dummy operations当分支抵达usual flow of the code.
l -fcrossjumping:这是对跨越跳转的转换代码处理, 以便组合分散在程序各处的相同代码。 这样可以减少代码的长度, 但是也许不会对程序性能有直接影响。 -
-O3: 比O2更进一步的进行优化。在包含了O2所有的优化的基础上,又打开了以下优化选项
优化选项
l -finline-functions:内联简单的函数到被调用函数中。由编译器启发式的决定哪些函数足够简单可以做这种内联优化。默认情况下,编译器限制内联的尺寸,3.4.6中限制为600(具体含义不详,指令条数或代码size?)可以通过-finline-limit=n改变这个长度。这种优化技术不为函数创建单独的汇编语言代码, 而是把函数代码包含在调度程序的代码中。 对于多次被调用的函数来说, 为每次函数调用复制函数代码。 虽然这样对于减少代码长度不利, 但是通过最充分的利用指令缓存代码, 而不是在每次函数调用时进行分支操作, 可以提高性能。
l -fweb:构建用于保存变量的伪寄存器网络。 伪寄存器包含数据, 就像他们是寄存器一样, 但是可以使用各种其他优化技术进行优化, 比如cse和loop优化技术。这种优化会使得调试变得更加的不可能,因为变量不再存放于原本的寄存器中。
l -frename-registers:在寄存器分配后,通过使用registers left over来避免预定代码中的虚假依赖。这会使调试变得非常困难,因为变量不再存放于原本的寄存器中了。
l -funswitch-loops:将无变化的条件分支移出循环,取而代之的将结果副本放入循环中。
这里我们使用各个优化参数选项进行实验对比数据如下:
| 优化参数 | 耗时对比 | 备注(编译程序命令) |
|---|---|---|
| 不优化 | 3039 ms | gcc sort.c -o sort |
| -O1 | 1323 ms | gcc -O1 sort.c -o sort.1 |
| -O2 | 1129 ms | gcc -O2 sort.c -o sort.2 |
| -O3 | 1126 ms | gcc -O3 sort.c -o sort.3 |
3.2 perf
Perf(Performance Event)是内置于Linux内核源码树中的性能剖析(profiling)工具。它基于事件采样的原理,以性能事件为基础,支持针对处理器相关性能指标与操作系统相关性能指标的性能剖析。可用于性能瓶颈的查找和热点代码的定位。
Perf的原理如下:每隔一个固定的时间,就在CPU上(每个核上都有)产生一个中断,在中断上可以看出,当前是哪个pid(进程id),哪个函数,然后给对应的pid和函数加一个统计值,这样我们就知道CPU有百分之几的时间在某个pid,或者某个函数了。原理图示如下。很明显可以看出,这是一种采样的模式,我们预期,运行时间越多的函数,被时钟中断击中的机会越大,从而推测,那个函数(或者pid)的CPU占用率越高。 
机器开启perf cpu时间监听权限:
root执行以下命令:
1.修改不重启机器,立即生效:
echo -1 > /proc/sys/kernel/perf_event_paranoid
2.设置永久生效(防止机器重启失效)
/etc/sysctl.conf
kernel.perf_event_paranoid = -1
3.3 FDO
3.3.1 基本原理
FDO(Feedback-Directed Optimization),是gcc等编译器的一个特性,Feedback-Directed Optimization(link)。编译程序有一个很难处理的问题是如何判断代码的分支是跳转还是不跳转(这东西影响流水线),芯片OoO(Out-of-Order,预测执行)设计很大程度上也是为了解决这个问题。FDO的方法是编译器先编译一个Instrumented版本(加通过gcov技术),运行一次,收集到所有的跳转数据了(在.gcda文件中),用这个数据来判断跳转的可能性是怎么样的,然后再用这个数据生成一个优化过的版本,正式使用。
在GCC中,传统的反馈式编译优化使用插桩的方式(https://baike.baidu.com/item/%E7%A8%8B%E5%BA%8F%E6%8F%92%E6%A1%A9)来收集边和值的性能信息。GCC使用由基本块和边频率计数的性能信息来指导优化,如指令调度,基本块重排序,函数拆分,以及寄存器分配。目前,GCC中的反馈式编译优化主要包含以下几个步骤: (1)生成一个测试版的程序,用来收集边和值的性能信息。 (2)运行测试版的程序,收集程序执行时的性能信息。在这一步会产生很大的执行开销,程序会运行得比较慢,这是因为有部分用于收集信息的代码也要执行。 (3)利用收集来的性能信息指导编译优化生成优化版的程序。 #### 3.3.2 举例
在这个过程中,程序插桩和FDO是高度耦合的。GCC要求前后两次编译都使用相同的内联决策和相同的优化选项,以保证插桩后的控制流图和标记了性能信息的控制流图是一致的。 针对该冒泡排序程序,操作如下: ``` (1)用-fprofile-generate选项创建一个插桩过的二进制文件 # gcc sort.c -o sort_instrumented -fprofile-generate (2)运行该二进制文件,生成.gcda文件 # ./sort_instrumented Bubble sorting array of 30000 elements 3622 ms (3)重新基于.gcda文件编译程序,并运行该程序 # gcc -O3 sort.c -o sort_fdo -fprofile-use=sort.gcda # ./sort_fdo Bubble sorting array of 30000 elements 1161 ms ```
从结果中可以看到,使用FDO编译的程序比只使用O3选项编译的程序快了3.46%(1161→1123)。实验结果表明,FDO可使程序获得更好的性能。
注意:在实际的开发过程中,FDO很少被使用,这是因为很难拿一个-fprofile的版本直接到工作环境里面去用,并且利用插桩的方式收集程序性能信息具有很高的运行时开销,另外程序在测试时,也很难生成有代表性的测试数据。但是AutoFDO解决了该问题。
3.4 AutoFDO
3.4.1 基本原理
为克服传统FDO的局限性,AutoFDO被提了出来。AutoFDO最早由Google提出,现在已经集成到gcc中。与传统FDO使用插桩的方式来收集程序性能信息不同,AutoFDO使用`perf`来收集采样性能信息。然后使用一个独立的工具将`perf.data`转换为gcov格式(gcov介绍:https://www.jianshu.com/p/c69b7889e878)的数据,然后基于gcov数据重新编译文件进行优化。AutoFDO跳过了程序插桩的步骤,转而使用基于采样的性能收集器来收集程序性能信息,以指导反馈式编译优化。 与FDO相比,AutoFDO有如下优点: (1) 性能信息的收集可以在生产系统上完成。 (2)开发和测试阶段的性能数据可用于编译优化二进制程序。 (3)传统的FDO使用程序插桩的方式来收集程序性能信息,但这种方式并不适合于收集如操作系统内核代码这种时间关键型代码的性能信息。AutoFDO很好地解决了这个问题。 (4)当前基于程序插桩的FDO不支持获取内核代码的执行计数信息。 #### 3.4.2 举例 AutoFDO主要有两个步骤: (1)生成程序性能文件 AutoFDO需要使用perf.data文件来提供处理器的BR_INST_RETIRED:TAKEN事件信息。这个事件会由于计算机体系结构的不同而在各种平台上有所不同,所以AutoFDO使用`ocperf`工具(pmu-tools项目的一部分)来收集相关信息,该工具会把所有需要的信息都放在一起并生成perf.data文件。用户可以免费使用这个工具,或者就是用perf工具。
抓取perf数据
# ocperf.py record -b -e br_inst_retired.near_taken:pp -- ./sort Bubble sorting array of 30000 elements 3731 ms [ perf record: Woken up 7 times to write data ] [ perf record: Captured and wrote 1.580 MB perf.data (3902 samples) ]
也可以使用perf抓取: perf record -e br_inst_retired:near_taken -b -o perf.data \ -- your_program
获得perf.data数据之后,还需使用一个独立的工具将perf.data转换为gcov格式的数据。AutoFDO工具集提供了工具create_gcov来完成这个任务。
# create_gcov --binary=./sort --profile=perf.data --gcov=sort.gcov -gcov_version=1(注意gcov_version参数必须等于1,因为这是AutoFDO目前所支持的版本)
(2)使用性能信息指导编译优化
在这一步中,GCC从程序对应的gcov文件中读取以下性能信息:
- 函数名和文件名。
- 源文件级的性能信息,从内联栈到采样计数之间的映射。
- 模块性能信息,模块到辅助模块的映射。
为了读取性能信息文件,我们还需要重新编译源文件:
# gcc -O3 -fauto-profile=sort.gcov sort.c -o sort_autofdo
经过编译后,我们便得到了经过AutoFDO指导编译的程序sort_autofdo,测试结果如下:
# ./sort_autofdo
Bubble sorting array of 30000 elements
1160 ms
从结果中可看出,我们得到了与FDO相似的结果。
3.5 LTO(llvm ldd 链接器)
LTO: (link-time optimizations) 使整个程序在链接过程中实现二进程优化,降低目标码的体积,例如:一个LTO的内核可以减少超过10%的尺寸大小,并且内核优化后比常规的内核快百分之几,但是它目前的问题是需要占用更多的 系统内存 以及 更长的编译时间。
LTO背后的理念是: 通过检查编译完单独文件后的整个程序,探索可能出现的任何优化机会。最重要的机会是小函数的inlining。编译器也可以更积极的检测和消除为使用的代码和数据。当源文件编译时,LTO将编译器中间表示(GIMPLE,与机器无关的中间表示)放入到目标文件中。实际LTO阶段加载所有的GIMPLE代码到一个单一核心的映像中,重写进一步优化的目标代码。LTO功能最开始在GCC4.5上出现,但是在4.7上才变得可用。
LTO就是build settings中的一个编译选项,正如其名一样,Link Time Optimization,就是在链接的时候对程序进行了一些优化。

在开启LTO(Monolithic)后这些.o文件会附带一些优化信息,让它们在link的时候生成一个单一的整体的.o文件,再和需要的framework链接生成可执行文件。如下图:

开启LTO主要有这几点好处:
(1)将一些函数內联化
(2)去除了一些无用代码
(3)对程序有全局的优化作用
区别于传统的优化,LTO的优势有:
(1)完成传统编译器无法实现的过程间优化;eg:常量传播、生存期分析等
(2)可以针对库函数在特定上下文环境做进一步优化;eg:caller地方发现inline库函数更合适,就不在调用库函数了
(3)LIR层级的优化:针对芯片指令集的优化,比如:cache相关(指令对齐d等)
(4)根据linke确定的地址信息做优化eg:内存L2→L1
通常很多软件不用LTO的原因(待补充)
(1)大型软件分布式编译导致周期太长
(2)钩子函数优化可能有风险
(3)调试问题
3.6 LLVM
3.6.1 基本原理
LLVM是构建编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time)。
在理解LLVM时,我们可以认为它包括一个狭义的LLVM和广义的LLVM。广义的LLVM其实就是指整个LLVM编译器架构,包括了前端、后端、优化器、众多的库函数以及很多的模块;而狭义的LLVM其实就是聚焦于编译器后端功能(代码生成、代码优化等)的一系列模块和库。
传统的编译器分三个阶段:前端(Frontend)-- 优化器(Optimizer)-- 后端(Backend)。前端负责分析源代码,可以检查语法级错误,并构建针对语言的抽象语法树(AST);抽象语法树可以进一步转换优化,最终转换为新的表示方式,然后再让优化器和后端处理;最后由后端生成可执行的机器码。

LLVM也分为三个阶段,但是设计上有些略微的差别,LLVM不同的就是对于不同的语言它都提供了同一种中间表示:前端可以使用不同的编译工具对代码文件做词法分析以形成抽象语法树AST,然后将分析好的代码转换成LLVM的中间表示IR(intermediate representation);中间部分的优化器只对中间表示IR操作,通过一系列的pass对IR做优化;后端负责将优化好的IR解释成对应平台的机器码。LLVM的优点在于,中间表示IR代码编写良好,而且不同的前端语言最终都转换成同一种的IR。

3.6.2 什么是Clang
Clang是LLVM项目的一个子项目,基于LLVM架构的C/C++/Objective-C编译器前端。相比于GCC,Clang具有如下优点:
- 编译速度快:在某些平台上,Clang的编译速度显著的快过GCC(Debug模式下编译OC速度比GGC快3倍)
- 占用内存小:Clang生成的AST所占用的内存是GCC的五分之一左右
- 模块化设计:Clang采用基于库的模块化设计,易于 IDE 集成及其他用途的重用
- 诊断信息可读性强:在编译过程中,Clang 创建并保留了大量详细的元数据 (metadata),有利于调试和错误报告
- 设计清晰简单,容易理解,易于扩展
LLVM整体架构,前端用的是clang,广义的LLVM是指整个LLVM架构,一般狭义的LLVM指的是LLVM后端(包含代码优化和目标代码生成)。
源代码(c/c++)经过clang–> 中间代码(经过一系列的优化,优化用的是Pass) –> 机器码

3.7 编译后优化bolt
3.7.1 优化流程
完整的编译过程分为多个阶段。而采样优化可以发生在各个阶段,比如AutoFDO是在编译阶段,LTO是链接阶段,Ispike是后链接阶段(post-link)。近年来的主要工作集中在编译和链接阶段的FDO技术。AutoFDO的一个主要问题是难以把收集到的数据映射到中间文件。对于后链接(post-link)技术来说,由于采样优化比较晚,受编译和链接优化的影响,因此精确度比较高。基于以上原因开发了一个静态二进制优化器,BOLT,主要可以优化代码的layout。需要说明的是BOLT与前面提到的多个工具是互补关系,可以根据不同场景来使用。
BOLT是由Facebook推出的一种应用程序动态优化方案,通过perf采集程序运行数据,并使用采集数据对应用程序符号重新排列,提升cpu 指令的cache命中率,最终达到程序性能提升的目的。
BOLT的运行步骤如下:
1) 基于perf record收集分析数据,并将其记录在数据文件perf.data中。其中perf record用于记录一段时间内系统/进程的性能事件,默认性能事件为cycles(CPU周期数)
```
./perf record -F 4000 -e cycles:u -o perf.data -j any,u -p $pid -- sleep $sleep # 其中参数说明如下: -F:采样频次 -e:选择性能事件,cycles:u表示 -o:指定输出文件,默认为perf.data -p:表示采集服务对应的进程id -- sleep:表示采集时长
```
2) 把收集到的分析数据转换为BOLT格式
perf2bolt -p perf.data -o perf.fdata <executable>
#其中参数说明如下:
perf2bolt:二进制工具,用于将perf数据转换为bolt文件
-p:输入的原始perf文件
-o:转换后的目标文件
3) 基于bolt文件,llvm生成优化后二进制程序转换
```
lvm-bolt bin -o bin.bolt -data=bin.fdata -align-macro-fusion=all -reorder-blocks=cache+ -reorder-functions=hfsort+ -split-functions=3 -split-all-cold -split-eh -dyno-stats -icf=1 -update-debug-sections # 其中参数说明如下: bin:原始bin服务 bin.bolt:优化后的bin服务 -data:转换的数据
```
另外,如果目标程序有多个运行模式,那么可以把在各个模式下采集到的数据合并为一个,然后再优化。
merge-fdata *.fdata > combined.fdata #其中参数说明: merge-fdata:二进制工具,用于将多个fdata数据进行合并
configure 编译配置
–host 编译工具链 bin文件夹下 prefix, 前缀部分例如 -gcc的前缀
CC=path/arm-linux-gcc ./configure --cache-file=cache_file_0 --prefix=/home/eatjpg/arm-bin --host=arm-fsl-linux-gnueabi
cmake strip
function(utils_strip TARGET)
add_custom_command(
TARGET "${TARGET}" POST_BUILD
DEPENDS "${TARGET}"
COMMAND $<$<CONFIG:release>:${CMAKE_STRIP}>
ARGS --strip-all $<TARGET_FILE:${TARGET}>
)
endfunction()
usage:
utils_strip(${mbedcrypto_target})
exe release 版本
好像会直接strip, so 需要脚本strip才行 T_T