编译器优化:源代码到高效机器码的旅程

举报
yd_232225224 发表于 2026/10/18 11:59:50 2026/10/18
【摘要】 程序员写下的代码,往往并不是最高效的形式。代码中可能有重复的计算、永远不会执行的分支、可以在编译时就算出结果的表达式、可以合并的循环、可以避免的函数调用。如果将源代码逐行、直译式地翻译为机器指令,生成的程序会比实际所需的慢得多。优化编译器的任务,是在保证程序行为不变的前提下,对代码进行一系列变换,使其运行得更快、占用的空间更少,或者消耗的能量更低。现代编译器的优化能力已经相当惊人:同一份源代...

程序员写下的代码,往往并不是最高效的形式。代码中可能有重复的计算、永远不会执行的分支、可以在编译时就算出结果的表达式、可以合并的循环、可以避免的函数调用。如果将源代码逐行、直译式地翻译为机器指令,生成的程序会比实际所需的慢得多。

优化编译器的任务,是在保证程序行为不变的前提下,对代码进行一系列变换,使其运行得更快、占用的空间更少,或者消耗的能量更低。现代编译器的优化能力已经相当惊人:同一份源代码,在关闭优化和开启最高级别优化时编译出的程序,性能差距常常在数倍乃至数十倍之间。

本文介绍优化编译器的整体结构、中间表示与静态单赋值形式、常见的优化技术、循环优化与向量化、过程间优化、未定义行为与优化的关系,以及基于剖析的优化和链接时优化等进阶技术。

一、编译器的整体结构

现代编译器通常分为三个主要部分:

前端(Front End):负责理解源代码。它进行词法分析,将源代码切分为一个个记号;进行语法分析,根据语言的语法规则构建抽象语法树;进行语义分析,检查类型、解析名称、发现错误。最后,前端将抽象语法树转换为编译器内部使用的中间表示(Intermediate Representation,IR)。

中端(Middle End),也常称为优化器:在中间表示上进行各种与具体语言和目标机器无关的分析和优化。

后端(Back End):将优化后的中间表示转换为特定处理器架构的机器码。它负责指令选择,即为中间表示中的操作选择合适的机器指令;寄存器分配,即决定哪些值保存在寄存器中;指令调度,即调整指令的顺序以充分利用处理器的流水线;以及与具体架构相关的各种优化。

这种三段式的设计带来了很大的好处:如果要支持 M 种编程语言和 N 种处理器架构,只需要实现 M 个前端和 N 个后端,它们通过共同的中间表示连接,中端的优化可以被所有语言和架构共享。这大大降低了构建和维护编译器的成本。现代主流的编译器基础设施都采用了这种设计。

二、中间表示

为什么需要中间表示

源代码是为人类编写和阅读而设计的,包含大量高层次的结构,例如复杂的表达式、各种控制流语句、类和模板,直接在源代码上进行优化分析非常困难。机器码则过于底层,并且与特定的处理器紧密相关。

中间表示介于两者之间:它足够低层,将复杂的语言结构分解为简单的基本操作,使得分析和变换更加容易;又足够抽象,与具体的处理器无关,使得优化可以在多种平台上复用。

三地址码与控制流图

一种常见的中间表示形式是三地址码:每条指令最多包含一个运算符和三个操作数,通常为两个源操作数和一个目标操作数。例如,表达式 a = b * c + d 会被分解为:

t1 = b * c
a = t1 + d

程序的控制流则通过控制流图(Control Flow Graph,CFG)来表示:

  • 程序被划分为若干个基本块(Basic Block)。基本块是一段只能从开头进入、从末尾离开的连续指令序列,内部没有跳转,也不会有其他地方跳转到其中间;
  • 基本块之间用有向边连接,表示可能的控制流转移。例如,一个条件分支语句的基本块,会有两条边分别指向条件成立和不成立时执行的基本块。

控制流图使得编译器能够清晰地分析程序中所有可能的执行路径。

静态单赋值形式

静态单赋值形式(Static Single Assignment,SSA)是现代编译器中最重要的中间表示形式之一。它有一个简单的规则:每个变量只被赋值一次。

如果原始代码中一个变量被多次赋值,在转换为 SSA 形式时,每次赋值都会产生一个新的版本:

原始代码:          SSA 形式:
x = 1               x1 = 1
x = x + 2           x2 = x1 + 2
y = x * 3           y1 = x2 * 3

当控制流汇合时,例如条件分支的两个方向分别给变量赋了不同的值,在汇合点需要选择使用哪个版本。SSA 为此引入了一种特殊的 φ 函数(Phi 函数):

if (cond)
    x1 = 1
else
    x2 = 2
x3 = φ(x1, x2)    // 如果从 if 分支来,取 x1;如果从 else 分支来,取 x2

φ 函数并不是真正的运算,它只是表示"根据控制流从哪个前驱基本块到达这里,选择对应的值"。在生成机器码之前,φ 函数会被消除,转换为在各个前驱基本块末尾的赋值操作。

SSA 形式极大地简化了许多优化分析:

  • 定义与使用的关系一目了然:每个变量只有唯一的定义点,要知道一个值从哪里来,只需找到它的定义;要知道一个值被用在哪里,只需追踪它的使用。在非 SSA 形式中,这需要复杂的数据流分析;
  • 许多优化变得简单而高效:常量传播、死代码消除、公共子表达式消除等,在 SSA 形式上都有简洁而高效的实现方法;
  • 消除了不同赋值之间的干扰:同一个变量的不同版本是相互独立的,可以分别分析和优化。

三、经典优化技术

常量折叠与常量传播

常量折叠(Constant Folding):如果一个表达式的所有操作数在编译时都是已知的常量,就在编译时计算出它的值,用结果替换表达式。

x = 3 * 4 + 5    →    x = 17

常量传播(Constant Propagation):如果一个变量被赋予了一个常量值,就将后续使用该变量的地方替换为这个常量。

x = 17
y = x * 2        →    y = 34

两者通常交替进行:常量传播产生新的可以折叠的表达式,常量折叠又产生新的可以传播的常量。

更强大的条件常量传播还会考虑控制流:如果一个条件分支的条件在编译时可以确定为常量,那么永远不会执行的那个分支中的赋值,就不会影响常量的传播结果,编译器可以据此推导出更多的常量。

死代码消除

死代码是指其执行结果不会对程序的输出产生任何影响的代码,主要有两类:

  • 不可达代码:永远不会被执行的代码,例如位于无条件返回语句之后的代码,或者条件恒为假的分支中的代码;
  • 无用代码:会被执行,但其计算结果从未被使用的代码,例如给一个之后再也没被读取的变量赋值。

死代码消除(Dead Code Elimination)将这些代码删除。需要注意的是,只有在确定代码没有副作用时才能删除,例如,一个函数调用的返回值虽然没有被使用,但如果这个函数会写文件或修改全局变量,就不能删除这次调用。

死代码消除本身看似简单,但它往往是其他优化之后的"清理工作":常量传播可能使某些分支变得不可达,内联可能使某些计算变得多余,这些都需要死代码消除来最终清除。

公共子表达式消除

如果同一个表达式被计算了多次,并且其操作数在两次计算之间没有改变,就可以只计算一次,后续直接使用第一次的结果,这称为公共子表达式消除(Common Subexpression Elimination)。

a = b * c + d          t = b * c
e = b * c - f    →     a = t + d
                       e = t - f

在 SSA 形式中,这一优化常以全局值编号(Global Value Numbering)的方式实现:为每个计算的结果分配一个"值编号",如果两个计算的运算符相同、操作数的值编号也相同,那么它们的结果一定相同,可以共享同一个值编号,后一个计算可以被消除。

复制传播

如果代码中出现 y = x 这样的简单复制,那么在 x 和 y 都没有被重新赋值的范围内,所有对 y 的使用都可以直接替换为 x。之后,y = x 这个赋值可能就变成了死代码,可以被删除。在 SSA 形式中,由于每个变量只赋值一次,复制传播尤其简单直接。

强度削弱

强度削弱(Strength Reduction)将代价较高的运算替换为等价的、代价较低的运算。例如:

  • 乘以 2 的幂,替换为左移:x * 8 替换为 x << 3;
  • 除以 2 的幂的无符号整数除法,替换为右移;
  • 除以一个常量,替换为乘以一个精心计算的"魔数"再移位。整数除法在处理器上通常需要数十个时钟周期,而乘法和移位只需要几个时钟周期,这一变换能带来明显的加速;
  • 在循环中,将每次迭代都要进行的乘法,替换为每次迭代递增的加法。例如,在遍历数组时,计算 base + i * 4 可以替换为维护一个每次加 4 的指针。

窥孔优化

窥孔优化(Peephole Optimization)在一个很小的指令窗口中,查找可以被替换为更优指令序列的模式。例如:

  • 删除冗余的加载和存储:先将一个值存入内存,紧接着又从同一位置读出;
  • 合并连续的跳转:跳转到一条无条件跳转指令,可以直接跳转到其目标;
  • 使用更短或更快的指令:例如用异或自身来将寄存器清零,而不是将常量零加载到寄存器中;
  • 代数化简:例如 x + 0、x * 1 直接替换为 x。

窥孔优化既可以在中间表示上进行,也可以在后端生成机器指令后进行。

四、函数内联

函数内联(Inlining)是对性能影响最大的优化之一:在调用一个函数的地方,直接将被调用函数的代码嵌入进来,取代函数调用。

内联的直接收益

内联首先消除了函数调用本身的开销:传递参数、保存和恢复寄存器、跳转到函数入口、建立栈帧、返回时的跳转。对于一些很小的函数,例如获取对象的某个字段,调用本身的开销可能比函数体的执行时间还要长。

内联的间接收益

内联更重要的价值在于,它为其他优化打开了大门。

在调用发生之前,编译器在分析调用者时,只能将被调用的函数视为一个黑盒,不知道它会做什么。而内联之后,被调用函数的代码与调用者的代码合为一体,编译器可以将两者放在一起分析:

  • 调用者传入的常量参数,可以传播到被调用函数的代码中,触发常量折叠和死代码消除。例如,一个函数内部根据参数选择不同的处理方式,当调用者传入的是一个常量时,内联后其余的分支都可以被删除;
  • 被调用函数的计算,可能与调用者中的计算重复,可以进行公共子表达式消除;
  • 编译器可以更准确地分析指针的指向和内存访问,为更多优化提供依据。

内联的代价

内联也有代价:

  • 代码膨胀:被调用函数的代码在每个调用点都会被复制一份。如果一个较大的函数在很多地方被内联,程序的体积会显著增加;
  • 指令缓存压力:程序体积增大,可能导致指令缓存的命中率下降,反而降低性能;
  • 编译时间增加:内联后的函数更大,后续优化的分析范围更广,编译耗时更长。

因此,编译器需要通过内联启发式决定是否内联一个函数,通常会考虑:被调用函数的大小,较小的函数更倾向于内联;调用的频率,在循环中的调用更值得内联;内联后能否带来进一步的优化机会,例如传入的参数是常量;当前函数已经增长的大小。

对于虚函数调用或通过函数指针的调用,编译器在编译时通常无法确定实际调用的是哪个函数,因此无法直接内联。一种称为去虚化的优化,会在能够确定实际类型的情况下,将虚调用转换为直接调用,从而使内联成为可能。

五、循环优化

程序的大部分运行时间通常花在循环中,因此循环是优化的重点。

循环不变量外提

如果循环中某个计算的结果在每次迭代中都相同,就可以将它移到循环之前,只计算一次。这称为循环不变量外提(Loop-Invariant Code Motion)。

for (i = 0; i < n; i++)           t = x * y;
    a[i] = b[i] + x * y;    →     for (i = 0; i < n; i++)
                                      a[i] = b[i] + t;

外提需要满足一定的条件:被外提的计算不能有副作用;如果它可能产生异常,例如除以零,需要确保在原程序中它一定会被执行,否则外提后可能在原本不会出错的情况下引发错误。

归纳变量优化

归纳变量是指在每次循环迭代中按固定步长变化的变量,例如循环计数器 i,以及与之线性相关的变量,例如 j = 4 * i + 8。

编译器可以识别归纳变量之间的关系,进行一系列优化:

  • 强度削弱:将 j = 4 * i + 8 改为在每次迭代中将 j 加 4,用加法代替乘法;
  • 归纳变量消除:如果循环计数器 i 只用于计算数组地址和判断循环结束,可以直接用地址指针代替 i,并相应地调整循环结束条件,从而消除 i。

循环展开

循环展开(Loop Unrolling)将循环体复制多份,减少循环的迭代次数:

for (i = 0; i < n; i++)          for (i = 0; i < n; i += 4) {
    a[i] = b[i] * 2;      →          a[i]   = b[i]   * 2;
                                     a[i+1] = b[i+1] * 2;
                                     a[i+2] = b[i+2] * 2;
                                     a[i+3] = b[i+3] * 2;
                                 }
                                 // 处理剩余的 n % 4 个元素

循环展开的收益包括:

  • 减少循环控制的开销,即计数器的递增、比较和跳转;
  • 增大循环体,为指令调度提供更多的空间,让处理器能够并行执行更多相互独立的指令;
  • 为向量化等进一步的优化创造条件。

代价同样是代码膨胀。展开的倍数需要在收益与代码体积之间权衡。

循环变换

编译器还可以对循环的结构进行更大幅度的变换:

  • 循环交换:交换嵌套循环的内外层顺序。例如,对于按行存储的二维数组,将按列遍历改为按行遍历,使内存访问变为连续的,大幅提高缓存命中率;
  • 循环融合:将两个遍历相同范围的相邻循环合并为一个,减少循环开销,并让同一数据在被加载到缓存后能被多次使用;
  • 循环分裂:将一个循环拆分为多个,例如将不同的操作分开,以便各自进行向量化,或者减少单个循环中的寄存器压力;
  • 循环分块:将大的循环迭代空间划分为能够放入缓存的小块,在一个小块内完成尽可能多的计算后再处理下一块,提高数据的复用率。这对矩阵运算等计算密集型代码尤为重要;
  • 循环判断外提:如果循环中有一个条件判断,其条件在循环中不变,可以将判断移到循环外,生成两个版本的循环,分别对应条件成立和不成立的情况,消除每次迭代中的判断。

这些变换需要进行依赖分析:判断循环的不同迭代之间是否存在数据依赖,例如某次迭代写入的数据被后续迭代读取。只有在不违反这些依赖关系的前提下,变换才是合法的。

六、向量化

现代处理器普遍提供 SIMD(单指令多数据)指令,一条指令可以同时对多个数据进行相同的操作。例如,一条 256 位的向量加法指令,可以同时完成 8 个 32 位整数的加法。

自动向量化(Auto-Vectorization)是编译器自动将标量代码转换为 SIMD 指令的优化。

循环向量化

最常见的形式是循环向量化:将循环的多次迭代合并为一次,使用向量指令同时处理。例如,对数组中的每个元素乘以 2 的循环,可以转换为每次处理 8 个元素的向量乘法。

循环向量化需要满足一定的条件:

  • 没有跨迭代的依赖:如果某次迭代的计算依赖于前一次迭代的结果,例如 a[i] = a[i-1] + b[i],就不能简单地将多次迭代同时执行;
  • 内存访问模式合适:最好是连续的内存访问。非连续的访问,例如跨步访问或间接访问,虽然在某些处理器上可以借助特殊的指令实现,但效率较低;
  • 没有指针别名的问题:如果编译器无法确定两个指针是否指向重叠的内存区域,就不能安全地进行向量化,因为对一个数组的写入可能会影响对另一个数组的读取。

别名分析

别名分析(Alias Analysis)判断两个指针或引用是否可能指向同一块内存。它对许多优化都至关重要,而在向量化中尤其关键。

考虑以下函数:

void add(float *a, float *b, float *c, int n) {
    for (int i = 0; i < n; i++)
        a[i] = b[i] + c[i];
}

如果 a 与 b 或 c 指向的内存区域有重叠,那么对 a[i] 的写入可能会改变后续迭代中读取的 b 或 c 的值,向量化会改变程序的结果。编译器在无法确定的情况下,只能保守地放弃向量化,或者在运行时检查数组是否重叠,生成向量化和非向量化两个版本,根据检查结果选择执行。

程序员可以通过语言提供的手段,例如 C 语言中的限定符,告诉编译器某个指针所指向的内存不会被其他指针访问,从而帮助编译器进行更积极的优化。

超字级并行

除了循环向量化,编译器还可以在一段直线代码中,寻找多个相互独立、操作相同的标量运算,将它们打包为一条向量指令,这称为超字级并行(Superword-Level Parallelism)向量化。例如,分别对一个结构体的 x、y、z、w 四个字段进行相同的运算,可以合并为一次向量运算。

七、过程间优化与链接时优化

过程间优化

前面介绍的大部分优化都在单个函数内部进行。过程间优化(Interprocedural Optimization)则跨越函数边界,在整个程序的范围内进行分析:

  • 过程间常量传播:如果一个函数在所有调用点都以相同的常量参数被调用,就可以将这个常量传播到函数内部;
  • 过程间别名分析:跨函数地分析指针的指向关系,得到更精确的别名信息;
  • 函数特化:为一个函数创建多个针对特定参数值或参数类型的专门版本;
  • 死函数消除:删除从未被调用的函数;
  • 副作用分析:分析每个函数会读写哪些全局状态,帮助调用者进行更积极的优化;
  • 参数优化:删除从未被使用的参数,将通过指针传递的参数改为直接传值。

链接时优化

在传统的编译模式中,每个源文件被独立编译为一个目标文件,然后由链接器将所有目标文件合并为最终的可执行程序。编译器在编译一个源文件时,看不到其他源文件中的代码,因此无法内联其他文件中定义的函数,也无法进行跨文件的过程间分析。

链接时优化(Link-Time Optimization,LTO)打破了这一限制:编译器在编译每个源文件时,不直接生成机器码,而是保存中间表示;在链接阶段,将所有源文件的中间表示合并在一起,在整个程序的范围内进行优化,最后再生成机器码。

链接时优化能够进行跨文件的内联、更全面的死代码消除、更精确的过程间分析,常常能带来可观的性能提升和代码体积的减小。

其代价是链接阶段的时间和内存消耗大幅增加,因为需要在一个巨大的整体上进行优化,并且失去了并行编译的好处。为此,出现了一种并行化的链接时优化:先对整个程序进行一次轻量级的全局分析,生成摘要信息,例如哪些函数值得跨模块内联,再根据这些信息,将各个模块分别并行地进行优化和代码生成。这种方式在获得大部分链接时优化收益的同时,保持了较好的编译速度和可扩展性。

八、未定义行为与优化

什么是未定义行为

在 C 和 C++ 等语言中,标准规定某些操作的结果是未定义行为(Undefined Behavior),例如:

  • 有符号整数溢出;
  • 解引用空指针或无效指针;
  • 数组越界访问;
  • 使用未初始化的变量;
  • 违反严格别名规则,即通过不兼容类型的指针访问同一块内存;
  • 数据竞争。

对于未定义行为,语言标准对程序的行为不做任何要求:程序可能崩溃,可能产生错误的结果,可能看起来正常工作,也可能出现任何其他情况。

编译器如何利用未定义行为

未定义行为的存在,给了编译器很大的优化空间:编译器可以假设程序中不会发生未定义行为,并基于这一假设进行优化。

有符号整数溢出。 考虑以下循环:

for (int i = 0; i <= n; i++) { ... }

如果 n 是 int 类型的最大值,那么 i 会在某次递增时溢出。但由于有符号整数溢出是未定义行为,编译器可以假设它不会发生,从而推断出这个循环一定会终止,并且恰好执行 n + 1 次。这使得编译器可以更容易地分析循环的迭代次数,进行向量化、展开等优化,也可以将 32 位的循环变量提升为 64 位,用于计算数组地址,而无需担心溢出时的回绕行为。

类似地,编译器可以将 x + 1 > x 直接化简为真,因为对于有符号整数,在不溢出的前提下这总是成立的。

空指针解引用。 考虑以下代码:

int value = *p;
if (p == NULL) return -1;
return value;

由于 p 在检查之前已经被解引用了,如果 p 为空,这里就已经发生了未定义行为。因此编译器可以推断,在执行到检查时,p 一定不为空,于是将空指针检查删除。

严格别名规则。 根据该规则,不同类型的指针,除了一些例外情况,被假设不会指向同一块内存。这让编译器在没有更多信息的情况下,也能判断出一个 int* 和一个 float* 不会互相影响,从而进行更积极的优化。

未定义行为带来的问题

这类优化在提升性能的同时,也带来了严重的问题:当程序中确实存在未定义行为时,优化可能导致完全出乎意料的结果。

在上面空指针的例子中,程序员的本意是进行空指针检查,但由于检查前的解引用,这个检查被编译器删除了。如果在某些平台上,解引用空指针并不会立即导致崩溃,程序就会带着一个空指针继续运行,可能引发更严重的后果。历史上,曾有操作系统内核的安全漏洞,正是因为这种优化删除了必要的安全检查。

又如,一些程序员会写出类似 if (x + 100 < x) 的代码来检测溢出,但由于有符号整数溢出是未定义行为,编译器会将这个条件判断为恒为假,直接删除整个检查。

更令人困惑的是,同一段存在未定义行为的代码,在不开启优化时可能"正常"工作,开启优化后却表现异常;在一个编译器版本中正常,升级编译器后出现问题。

应对未定义行为

  • 了解语言中的未定义行为:熟悉所使用语言中常见的未定义行为,在编写代码时加以避免。例如,检测有符号整数溢出时,应当在运算之前检查操作数,或者使用编译器提供的带溢出检查的内建函数;
  • 使用检测工具:编译器通常提供未定义行为检测器,在编译时插入检查代码,在运行时检测到未定义行为时报告错误。在测试阶段开启这些检测,能够发现许多隐藏的问题;
  • 开启编译器警告:许多可能导致未定义行为的代码模式,编译器能够在编译时给出警告;
  • 使用编译选项改变语义:编译器通常提供一些选项,例如将有符号整数溢出定义为回绕、禁用基于严格别名规则的优化。这会牺牲一部分优化机会,但能让程序的行为更可预测。一些对安全性要求较高的项目会选择开启这些选项。

更从根本上看,这也是一些较新的系统编程语言在设计时所考虑的问题:它们尽量减少未定义行为的范围,或者将可能产生未定义行为的操作限制在明确标记的不安全代码块中,在保持高性能的同时,让程序的行为更加可预测。

九、后端优化

中端的优化完成后,后端负责生成高效的机器码。后端的优化与目标处理器的特性紧密相关。

指令选择

指令选择为中间表示中的操作选择合适的机器指令。同一个操作,在不同的处理器上可能有多种实现方式;而多个简单的操作,有时可以合并为一条复杂的机器指令。例如,许多处理器提供了融合乘加指令,可以将一次乘法和一次加法合并为一条指令完成;x86 处理器的地址计算指令可以用来高效地完成某些形式的整数加法和乘法。

指令选择通常通过模式匹配实现:将中间表示视为一棵树或一个有向无环图,用代表各种机器指令的模式去覆盖它,寻找总代价最低的覆盖方式。

寄存器分配

处理器的寄存器数量有限,通常只有十几到几十个通用寄存器,而程序中的变量和临时值数量可能远远超过这个数目。寄存器分配决定在每个时刻,哪些值保存在寄存器中,哪些值需要暂存到内存中,后者称为溢出(Spill)。

访问寄存器的速度远快于访问内存,因此寄存器分配的质量对性能影响很大。

经典的寄存器分配算法将其建模为图着色问题:

  • 每个变量是图中的一个节点;
  • 如果两个变量在某个时刻同时存活,即它们的生存区间有重叠,就在它们之间连一条边,表示它们不能使用同一个寄存器;
  • 用 k 种颜色给图着色,每种颜色代表一个寄存器,相邻的节点不能使用相同的颜色。

图着色问题在一般情况下是 NP 完全的,编译器使用启发式算法来求解。当无法用 k 种颜色完成着色时,选择一些变量溢出到内存中,通常选择那些使用频率低、溢出代价小的变量。

另一种常用的方法是线性扫描:将所有变量的生存区间按起始位置排序,依次扫描,为每个区间分配一个空闲的寄存器,没有空闲寄存器时选择一个区间溢出。线性扫描的分配质量通常略低于图着色,但速度快得多,常用于对编译速度敏感的场景,例如即时编译器。

指令调度

现代处理器采用流水线和超标量设计,能够同时执行多条指令。但如果一条指令需要使用前一条指令的结果,而前一条指令尚未完成,处理器就必须等待,这称为流水线停顿。

指令调度在不改变程序语义的前提下,调整指令的执行顺序,将相互独立的指令穿插在有依赖关系的指令之间,尽量减少停顿。例如,在一条耗时较长的内存加载指令之后,先执行一些不依赖于加载结果的计算,再执行使用加载结果的指令。

现代高性能处理器大多具有乱序执行能力,能够在运行时动态地调整指令的执行顺序,这在一定程度上降低了编译器指令调度的重要性。但对于乱序执行能力较弱的处理器,例如许多嵌入式处理器和低功耗处理器,编译器的指令调度仍然很重要。

分支优化

分支预测失败的代价很高,可能导致处理器丢弃流水线中的大量指令。编译器可以通过一些方式减少分支的影响:

  • 条件传送:对于简单的条件赋值,使用条件传送指令代替分支,消除分支预测失败的可能。但如果分支本身很容易预测,条件传送反而可能更慢,因为它需要计算两个分支的值;
  • 代码布局:将经常执行的代码路径排列在一起,使其在内存中连续,让最常见的情况不需要发生跳转。将很少执行的代码,例如错误处理,移到函数的末尾或单独的区域,减少对指令缓存的占用。

十、基于剖析的优化

编译器在进行优化决策时,常常需要猜测程序的运行时行为:哪个分支更常被执行?哪些函数被调用得最频繁?循环通常迭代多少次?虚函数调用的实际目标通常是哪个?在没有实际运行信息的情况下,编译器只能依靠启发式规则做出估计,而这些估计并不总是准确。

基于剖析的优化(Profile-Guided Optimization,PGO)利用程序实际运行时收集的数据,指导编译器的优化决策。其典型流程是:

  1. 插桩编译:用特殊的选项编译程序,编译器在代码中插入计数代码,用于统计基本块的执行次数、分支的走向、函数的调用次数、间接调用的目标等;
  2. 运行收集:用具有代表性的输入或负载运行插桩后的程序,生成剖析数据文件;
  3. 优化编译:将剖析数据提供给编译器,重新编译程序。

利用剖析数据,编译器可以做出更好的决策:

  • 内联:优先内联在热点路径上被频繁调用的函数,避免内联很少被调用的函数,以控制代码膨胀;
  • 代码布局:将热路径上的基本块和函数排列在一起,提高指令缓存和指令预取的效率;将冷代码分离出去;
  • 分支优化:根据实际的分支走向,将最常见的路径安排为不需要跳转的直线路径;
  • 间接调用推测:对于虚函数调用或函数指针调用,如果剖析数据显示绝大多数情况下调用的是某个特定的目标,就插入一个检查,在检查通过时直接调用该目标并可以将其内联;
  • 寄存器分配:优先为热路径上的变量分配寄存器;
  • 循环优化:根据实际的迭代次数决定是否展开、展开多少倍。

基于剖析的优化通常能够为大型应用带来百分之十到百分之二十甚至更高的性能提升。其主要挑战在于:需要准备具有代表性的训练负载,如果训练负载与实际的生产负载差异较大,优化效果可能会打折扣,甚至在某些情况下适得其反;并且构建流程变得更加复杂。

为了降低使用门槛,还出现了基于采样的剖析优化:不对程序进行插桩,而是在生产环境中利用处理器的硬件性能计数器,以很低的开销对正在运行的程序进行采样,收集热点和分支信息,再将这些数据用于编译优化。这种方式收集的剖析数据直接来自真实的生产负载,代表性更好,也避免了维护单独的训练流程。

此外,还有一类链接后优化工具,在程序已经编译链接完成后,根据剖析数据直接对二进制文件中的代码布局进行重新排列,进一步提升指令缓存的利用率。

十一、优化的局限与权衡

优化级别

编译器通常提供多个优化级别,例如:

  • 不优化:编译速度最快,生成的代码与源代码的结构对应关系最清晰,便于调试;
  • 基本优化:进行大部分不显著增加编译时间的优化;
  • 完整优化:进行几乎所有不以显著增加代码体积为代价的优化,是发布版本最常用的选择;
  • 激进优化:在完整优化的基础上,进行更积极的内联、循环展开和向量化,可能显著增加代码体积,性能不一定总是更好;
  • 体积优化:以减小代码体积为优先目标,适用于嵌入式设备或对程序体积敏感的场景。

调试与优化的冲突

优化会改变代码的结构:变量可能被保存在寄存器中,或者被完全消除;语句可能被重新排列、合并或删除;函数可能被内联而在调用栈中消失。这使得调试优化后的程序变得困难:断点可能无法命中,变量的值可能无法查看,单步执行时可能在源代码中来回跳跃。

编译器会尽力生成调试信息,描述优化后的代码与源代码之间的对应关系,但这种对应往往不完整。在开发阶段通常使用较低的优化级别以便于调试,在发布时使用较高的优化级别,同时需要确保在高优化级别下也进行了充分的测试,因为某些问题只在优化后才会暴露,特别是与未定义行为相关的问题。

编译器的保守性

编译器必须保证优化后的程序在所有可能的输入下都与原程序行为一致。当编译器无法证明某个优化是安全的时候,它只能放弃这个优化。许多优化机会因为编译器缺乏足够的信息而被错失:

  • 无法确定两个指针是否指向不同的内存;
  • 无法确定一个外部函数调用是否会修改某个全局变量;
  • 无法确定一个循环的迭代次数是否足够多,值得向量化;
  • 无法确定某个浮点运算的重排是否可以接受。

在这些情况下,程序员可以通过向编译器提供更多信息来帮助优化:使用限定符声明指针不会别名、将函数标记为没有副作用、使用编译器提供的提示指明哪个分支更可能执行,或者在可以接受精度变化的情况下启用宽松的浮点优化选项。

十二、编写对编译器友好的代码

虽然编译器的优化能力很强,但代码的写法仍然会影响优化的效果:

让代码意图清晰。 编译器最擅长优化简单、直接、模式常见的代码。过度"聪明"的手工优化,例如复杂的位运算技巧、手动展开的循环,反而可能干扰编译器的分析,导致其无法进行更好的优化。在大多数情况下,应当编写清晰的代码,让编译器去做微观层面的优化。

减少不必要的间接性。 过多的指针间接访问、虚函数调用和函数指针,会阻碍编译器的分析和内联。在性能敏感的代码中,可以考虑使用更直接的数据结构和调用方式。

帮助编译器了解别名关系。 在确定指针不会重叠时,使用语言提供的方式声明这一点;尽量使用局部变量保存中间结果,而不是反复通过指针访问。

设计便于向量化的数据布局。 对大量数据执行相同的操作时,使用连续存储的数组,并采用结构体数组还是数组结构体的布局,要根据访问模式来选择。

避免未定义行为。 未定义行为不仅可能导致错误,还会让程序的行为随编译器和优化级别的变化而变化。

查看编译器的输出。 许多编译器可以输出优化报告,说明哪些循环被向量化了、哪些没有被向量化以及原因、哪些函数被内联了。也可以直接查看生成的汇编代码,了解编译器实际做了什么。在进行性能优化时,这些信息比猜测可靠得多。

以测量为准。 编译器的优化决策复杂,代码的微小变化可能导致生成的机器码发生较大变化。任何性能优化都应当基于实际的测量,而不是直觉。

结语

优化编译器是计算机科学中最精巧、最成熟的工程成果之一。从前端对源代码的理解,到中间表示上与平台无关的分析和变换,再到后端针对具体处理器的指令选择、寄存器分配和调度,编译器在每一个阶段都在保证程序语义的前提下,竭力挖掘代码的性能潜力。

静态单赋值形式让数据流分析变得简洁高效;常量传播、死代码消除和公共子表达式消除去除了冗余;内联打开了跨函数优化的大门;循环优化和向量化充分利用了现代处理器的并行能力;链接时优化和基于剖析的优化则将优化的视野扩展到整个程序和真实的运行行为。

理解编译器的工作原理,对于程序员同样有价值。它解释了为什么清晰的代码往往比手工优化的代码更快,为什么未定义行为会导致匪夷所思的结果,为什么同一段代码在不同的编译选项下表现迥异。当面对性能问题时,了解编译器能做什么、不能做什么,能够帮助我们写出既易于理解又易于优化的代码,与编译器形成良好的协作。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。