内联函数
如果你是那种经常查看汇编代码的开发人员,你可能见过 CALL
、PUSH
、POP
和 RET
指令。在 x86 指令集中,CALL
和 RET
指令用于调用和返回函数。PUSH
和 POP
指令用于将寄存器值保存到堆栈上并恢复它。
函数调用的微妙之处由调用约定描述,即如何传递参数和顺序,如何返回结果,调用的函数必须保留哪些寄存器以及工作在调用方和被调用方之间如何分配。基于调用约定,当调用者调用一个函数时,它期望在被调用者返回后一些寄存器将保持相同的值。因此,如果被调用者需要更改应保留的寄存器之一,它需要在返回给调用者之前保存(PUSH
)和恢复(POP
)它们。一系列 PUSH
指令称为序言,一系列 POP
指令称为尾声。
当一个函数很小的时候,调用函数的开销(序言和尾声)可能非常明显。通过将函数体内联到调用位置,可以消除这种开销。函数内联是将对函数 F
的调用替换为对实际参数专门化的 F
代码的过程。内联是最重要的编译器优化之一。不仅因为它消除了调用函数的开销,而且还使其他优化变得可能。这是因为当编译器内联一个函数时,编译器分析的范围会扩大到一个更大的代码块。然而,也有缺点:内联可能会增加代码大小和编译时间20。
许多编译器中函数内联的主要机制依赖于成本模型。例如,在 LLVM 编译器中,它基于为每个函数调用(调用点)计算成本。内联函数调用的成本基于该函数中指令的数量和类型。如果成本低于阈值,通常是固定的阈值,则会进行内联,但在某些情况下可以变化21。除了通用成本模型之外,还有许多启发式方法可以在某些情况下覆盖成本模型的决策。例如:
- 微小的函数(包装器)几乎总是被内联。
- 只有一个调用点的函数是内联的首选候选项。
- 大型函数通常不会被内联,因为它们会膨胀调用函数的代码。
此外,有些情况下内联会有问题:
- 递归函数不能内联到自身。
- 通过指针引用的函数可以内联到直接调用的地方,但是该函数必须保留在二进制文件中,即它不能完全内联和消除。对于具有外部链接的函数也是如此。
正如我们之前所说,编译器在决定是否内联函数时倾向于使用成本模型方法,这在实践中通常效果很好。一般来说,依靠编译器做出所有内联决策并在需要时进行调整是一个很好的策略。成本模型无法考虑到每种可能的情况,这为改进留下了空间。有时候编译器需要开发人员的特殊提示。一种找到程序中潜在内联候选项的方法是查看分析数据,特别是函数序言和尾声的热度。下面是一个函数剖面的示例,其中序言和尾声消耗了函数时间的~50%
:
开销 | 函数 `foo` 的源代码和反汇编
(%) |
--------------------------------------------
3.77 : 418be0: push r15 # 序言
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
...
# # 函数体
...
4.17 : 418d43: add rsp,0x8 # 尾声
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
}
20. 参见文章:https://aras-p.info/blog/2017/10/09/Forced-Inlining-Might-Be-Slow/。 ↩
21. 例如,1) 当函数声明带有内联提示时,2) 当存在函数的分析数据时,或者 3) 当编译器优化大小 (-Os
) 而不是性能 (-O2
) 时。 ↩