现代硬件算法[4.1]: 编译阶段
编译阶段
在直接跳到编译器优化(这是本章的大部分内容)之前,让我们先简要回顾一下“大局”。跳过无聊的部分,将C程序转化为可执行程序有4个阶段:
- 预处理(preprocessing):宏展开,从头文件中提取包含的源代码,并从源代码中删除注释:
gcc-E Source.c
(将预处理的源代码输出到stdout
) - 编译(compiling):解析源代码,检查语法错误,将其转换为中间表示形式(intermediate representation,IR),执行优化,最后将其翻译为汇编语言:
gcc -S file.c
(生成.S
文件) - 汇编(assembly):将汇编语言转换为机器代码,除了像
printf
这样的外部函数调用外都被占位符取代:gcc -c file.c
(生成一个.o
文件,称为对象文件(object file)) - 链接(linking):最后通过插入函数调用的实际地址来解决函数调用问题,并生成一个可执行的二进制文件:
gcc -o binary file.c
在这些阶段中的每一个阶段都有提高程序性能的可能性。
程序间优化
程序是逐个文件编译的,然后再将这些文件链接在一起。这样更容易也更快,因为你可以并行地完成这项工作,还可以缓存中间结果。
它还提供了将代码作为库(libraries)分发的能力,这些库可以是静态的(static),也可以是共享的(shared):
- 静态库只是预编译的对象文件的集合,编译器将这些文件与其他库合并以生成单个可执行文件,就像通常编译一样。
- 动态库或共享库是预编译的可执行文件,它们包含有关其可调用文件所在位置的附加元信息,运行时会解析这些信息。顾名思义,共享库允许在多个程序之间共享这些编译后的二进制文件。
使用静态库的主要优点是,你可以执行各种过程间优化(interprocedural optimizations),这些优化需要比库函数签名更多的上下文,例如函数内联或无用代码消除。要强制链接器查找并仅接受静态库,可以使用-static
选项。
这个过程被称为链接时优化(link-time optimization,LTO),这是可能的,因为现代编译器还在对象文件中存储某种形式的中间表示(intermediate representation),这使得编译器能够对程序整体执行某些轻量级优化。这也允许在同一程序中使用不同的编译语言,如果编译器使用相同的中间表示,甚至可以跨语言进行优化。
LTO是一个相对较新的功能(它大约在2014年才出现在GCC中),且还远远不够完美。在C和C++中,确保不因单独编译而损失性能的方法是创建一个仅含头文件的库(例如Eigen)。顾名思义,它们只有头文件,包含所有函数的完整定义,因此通过简单地包含它们,编译器可以执行所有可能的优化。尽管每次都必须从头开始重新编译库代码,但这种方法保留了完全的控制权,并确保不会损失任何性能。
检查输出
检查每个阶段的输出可以对程序中发生的事情产生有用的见解。
可以通过将-S
标志传递给编译器来将源代码编译为汇编代码,编译器将生成一个可读的*.S
文件。如果设置了-fverbose-asm
标志,该文件还将包含关于源代码行号和正在使用的变量信息的编译器注释。如果只是一小段代码,而你又懒得编译它,你可以使用Compiler Explorer,这是一个非常方便的在线工具,可以将源代码转换为汇编代码,按颜色突出显示汇编代码块,它包括一个小的x86指令集,还可以选择大量其他编译器、目标和语言。
除了汇编之外,另一个最有用的抽象级别是编译器执行优化的中间表示。IR定义了计算本身的流程,且对架构特性,如寄存器数量或特定指令集等的依赖性要小得多。检查这些内容通常很有用,可以深入了解编译器如何看待你的程序,但这有点超出了本书的范围。
在本章中我们主要使用GCC,但也会在必要时尝试附上Clang的示例。这两个编译器在很大程度上相互兼容,在大多数情况下只是在一些优化标志和次要的语法细节上有所不同。