函数内联(Inlining Functions)
如果你是那种经常查看汇编代码的开发者,你可能见过 CALL、PUSH、POP 和 RET 指令。在 x86 ISA 中,CALL 和 RET 指令用于调用函数和从函数返回。PUSH 和 POP 指令用于将寄存器值保存到栈上并恢复它。
函数调用的细节由调用约定(calling convention)描述:参数如何传递及传递顺序、结果如何返回、被调用函数必须保留哪些寄存器,以及调用者(caller)和被调用者(callee)之间如何分工。根据调用约定,当调用者发起函数调用时,它期望被调用者返回后某些寄存器仍保持相同的值。因此,如果被调用者需要修改某个应该被保留的寄存器,它需要在返回调用者之前保存(PUSH)并恢复(POP)这些寄存器。一系列 PUSH 指令称为序言(prologue),一系列 POP 指令称为尾声(epilogue)。
当函数较小时,调用函数的开销(序言和尾声)可能非常显著。通过将函数体内联到调用点,可以消除这种开销。函数内联是一个将对函数 foo 的调用替换为以实际调用参数特化的 foo 代码的过程。内联是最重要的编译器优化之一。不仅因为它消除了函数调用的开销,还因为它能够使能其他优化。这是因为当编译器内联一个函数时,编译器分析的范围扩展到了更大的代码块。然而,也存在缺点:内联可能导致代码体积增大和编译时间增加。20
许多编译器中函数内联的主要机制依赖于代价模型(cost model)。例如,在 LLVM 编译器中,函数内联基于计算每个函数调用点(call site)的代价。调用点是代码中调用函数的位置。内联函数调用的代价基于该函数中指令的数量和类型。如果代价低于某个阈值,就会发生内联,这个阈值通常是一个固定数字;但在特定情况下可能会有所调整。21 除了通用代价模型外,许多启发式规则在某些情况下可以覆盖代价模型的决策。例如:
- 微小函数(包装器)几乎总是被内联。
- 只有单个调用点的函数是内联的优选候选。
- 大型函数通常不会被内联,因为它们会使调用者函数的代码膨胀。
此外,还有一些情况下内联存在问题:
- 递归函数无法内联到自身,除非它是尾递归函数(见下一节)。另外,如果递归深度通常较小,可以对递归函数进行部分内联,即将递归函数的主体内联到自身几次,然后保留之前的递归调用。如果递归调用深度通常较小,这可能会消除函数调用的开销。
- 通过指针引用的函数可以在直接调用处内联,但该函数必须保留在二进制文件中,即它不能被完全内联和消除。具有外部链接(external linkage)的函数也是如此。
如前所述,编译器倾向于在决定是否内联函数时使用代价模型方法,这在实践中通常效果良好。一般来说,依赖编译器做出所有内联决策并在必要时进行调整是一个好策略。代价模型无法考虑所有可能的情况,这留下了改进的空间。有时编译器需要开发者提供特殊提示。在程序中找到内联潜在候选的一种方式是查看分析数据,特别是函数序言和尾声的热度。清单 FuncInlining 是一个函数分析的示例,其中序言和尾声消耗了约 ~50% 的函数时间:
清单:函数 foo 的分析结果,序言和尾声非常热
Overhead | Source code & Disassembly
(%) | of function `foo`
--------------------------------------------
3.77 : 418be0: push r15 # prologue
4.62 : 418be2: mov r15d,0x64
2.14 : 418be8: push r14
1.34 : 418bea: mov r14,rsi
3.43 : 418bed: push r13
3.08 : 418bef: mov r13,rdi
1.24 : 418bf2: push r12
1.14 : 418bf4: mov r12,rcx
3.08 : 418bf7: push rbp
3.43 : 418bf8: mov rbp,rdx
1.94 : 418bfb: push rbx
0.50 : 418bfc: sub rsp,0x8
...
# function body
...
4.17 : 418d43: add rsp,0x8 # epilogue
3.67 : 418d47: pop rbx
0.35 : 418d48: pop rbp
0.94 : 418d49: pop r12
4.72 : 418d4b: pop r13
4.12 : 418d4d: pop r14
0.00 : 418d4f: pop r15
1.59 : 418d51: ret
当你看到热的 PUSH 和 POP 指令时,这可能是一个强烈的信号,表明内联该函数可以节省序言和尾声所消耗的时间。注意,即使序言和尾声很热,内联函数也不一定是有利的。内联会触发许多不同的变化,因此很难预测结果。在强制编译器内联函数之前,始终要测量修改后代码的性能。
对于 GCC 和 Clang 编译器,你可以使用 C++11 的 [[gnu::always_inline]] 属性为内联 foo 提供提示,如下面的代码示例所示。在较早的 C++ 标准中,你可以使用 __attribute__((always_inline))。对于 MSVC 编译器,可以使用 __forceinline 关键字。
[[gnu::always_inline]] int foo() {
// foo body
}
尾调用优化(Tail Call Optimization)
在尾递归函数中,递归调用是函数在返回结果之前执行的最后一个操作。清单 TailCall 演示了一个简单示例。在原始代码中,sum 函数递归地累加从 0 到 n 的整数,例如,调用 sum(5,0) 将得到 5+4+3+2+1,即 15。
如果不加优化编译原始代码(-O0),编译器将生成包含递归调用的汇编代码。由于函数调用的开销,这非常低效。此外,如果用较大的 n 调用 sum,程序将在栈上创建大量嵌套的栈帧。由于栈内存是有限的,很可能导致栈溢出。
当对清单 TailCall 中的示例应用优化(例如 -O2)时,编译器会识别尾调用优化的机会。该变换将重用当前栈帧,而不是递归地创建新帧。为此,编译器刷新当前帧,并将 call 指令替换为跳转到函数开头的 jmp 指令。与内联一样,尾调用优化为进一步的优化提供了空间。因此,编译器之后可以应用更多变换,将原始版本替换为右侧所示的迭代版本。例如,GCC 13.2 为两个版本生成完全相同的机器代码。
清单:尾调用编译器优化
// original code // compiler intermediate transformation
int sum(int n, int acc) { int sum(int n, int acc) {
if (n == 0) { for (int i = n; i > 0; --i) {
return acc; => acc += i;
} else { }
return sum(n - 1, acc + n); return acc;
} }
}
与任何编译器优化一样,有时它无法执行你想要的代码变换。如果你使用 Clang 编译器,并且希望保证进行尾调用优化,可以用 __attribute__((musttail)) 标记 return 语句。这表明即使在禁用优化的情况下,编译器也必须为程序的正确性生成尾调用。一个有益的例子是语言解释器循环。22 如有疑问,最好使用迭代版本而不是尾递归,将尾递归留给函数式编程语言。
20. 参见文章:https://aras-p.info/blog/2017/10/09/Forced-Inlining-Might-Be-Slow/。 ↩
21. 例如:1) 当函数声明有内联提示时;2) 当函数有分析数据时;或 3) 当编译器为大小优化(-Os)而非性能优化(-O2)时。 ↩
22. Josh Haberman 的博客:保证尾调用的动机 - https://blog.reverberate.org/2021/04/21/musttail-efficient-interpreters.html。 ↩