函数内联(Inlining Functions)

如果你是那种经常查看汇编代码的开发者,你可能见过 CALLPUSHPOPRET 指令。在 x86 ISA 中,CALLRET 指令用于调用函数和从函数返回。PUSHPOP 指令用于将寄存器值保存到栈上并恢复它。

函数调用的细节由调用约定(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

当你看到热的 PUSHPOP 指令时,这可能是一个强烈的信号,表明内联该函数可以节省序言和尾声所消耗的时间。注意,即使序言和尾声很热,内联函数也不一定是有利的。内联会触发许多不同的变化,因此很难预测结果。在强制编译器内联函数之前,始终要测量修改后代码的性能。

对于 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

results matching ""

    No results matching ""