最近周末在家学习时看到 @Dave Cheney 的《Inlining optimisations in Go[1]》还是有不少养分的,翻译分享给大家,部分内容有所修改、删减、调整。
这是一篇介绍 Go 编译器如何实现内联的文章,以及这种优化将如何影响你的 Go 代码,并最终提高性能的。
接下来和煎鱼一起开始吸取知识。

什么是内联?

内联是将较小的函数合并到它们各自的调用者中的行为。其在不同的计算历史时期的做法不一样,如下:

  • 早期:这种优化通常是由手工完成的。
  • 现在:内联是在编译过程中自动进行的一类基本优化之一。

    为什么内联很重要?

    内联是很重要的,每一门语言都必然会有。具体的原因如下:

  • 它消除了函数调用本身的开销。

  • 它允许编译器更有效地应用其他优化策略。

核心来讲,就是性能更好了。

函数调用的开销

基本知识

在任何语言中调用一个函数都是有代价的。将参数编入寄存器或堆栈(取决于ABI),并在返回时反转这一过程,这些都是开销。
调用一个函数需要将程序计数器从指令流中的一个点跳到另一个点,这可能会导致流水线停滞。
一旦进入函数,通常需要一些前言来为函数的执行准备一个新的堆栈框架,在返回调用者之前,还需要一个类似的尾声来退掉这个框架。

Go 中的开销和优化

在 Go 中,一个函数的调用需要额外的成本来支持动态堆栈的增长。在进入时,goroutine 可用的堆栈空间的数量与函数所需的数量进行比较。
如果可用的堆栈空间不足,前言就会跳转到运行时逻辑,通过将堆栈复制到一个新的、更大的位置来增加堆栈。
消除这些开销的解决方案必须是消除函数调用本身,Go 编译器在某些条件下通过用函数的内容替换对函数的调用来做到这一点,这被称为内联。因为它使函数的主体与它的调用者保持一致。

改善优化的机会

Cliff Click 博士(HotSpot JIT 的创造者)将内联描述为现代编译器进行的优化,因为它是常量传播和死代码消除等优化的基础。
实际上,内联允许编译器看得更远,允许它在特定函数被调用的情况下,观察到可以进一步简化或完全消除的逻辑。
由于内联可以递归应用,优化决策不仅可以在每个单独的函数的上下文中做出,还可以应用于调用路径中的函数链。

进行内联优化

不允许内联

内联的效果可以通过这个小例子来证明:

  1. package main
  2. import "testing"
  3. //go:noinline
  4. func max(a, b int) int {
  5. if a > b {
  6. return a
  7. }
  8. return b
  9. }
  10. var Result int
  11. func BenchmarkMax(b *testing.B) {
  12. var r int
  13. for i := 0; i < b.N; i++ {
  14. r = max(-1, i)
  15. }
  16. Result = r
  17. }

运行这个基准可以得到以下结果:

  1. % go test -bench=.
  2. BenchmarkMax-4 530687617 2.24 ns/op

从执行结果来看,max(-1, i)的成本大约是 2.24ns,感觉性能不错。

允许内联

现在让我们去掉 //go:noinline pragma 的语句,再看看不允许内联的情况下,性能是否会改变。
如下结果:

  1. % go test -bench=.
  2. BenchmarkMax-4 1000000000 0.514 ns/op

两个结果对比一看,2.24ns 和 0.51ns。差距至少一倍以上。
另外根据 benchstat 的建议,在内联情况下,性能提高了 78%。
如下结果:

  1. % benchstat {old,new}.txt
  2. name old time/op new time/op delta
  3. Max-4 2.21ns ± 1% 0.49ns ± 6% -77.96% (p=0.000 n=18+19)

内联后的性能简直杠杠的!

这些改进从何而来?

首先,取消函数调用和相关的前导动作是主要的改进贡献者。其将 max 函数的内容拉到它的调用者中,减少了处理器执行的指令数量,并消除了几个分支。
把函数内容拉进来后,现在 max 函数的内容对编译器来说是直接可见的。当我们进一步内联优化 BenchmarkMax 函数时,编译会有一些额外的改进措施。
如下代码:

  1. func BenchmarkMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. if -1 > i {
  5. r = -1
  6. } else {
  7. r = i
  8. }
  9. }
  10. Result = r
  11. }

再次运行基准测试,我们看到我们手动内联的版本与编译器内联的版本表现一样好。
如下结果:

  1. % benchstat {old,new}.txt
  2. name old time/op new time/op delta
  3. Max-4 2.21ns ± 1% 0.48ns ± 3% -78.14% (p=0.000 n=18+18)

现在,编译器可以获得 max 内联到 BenchmarkMax 的结果,它可以应用以前不可能的优化方法,如下所述。
例如:编译器注意到 i 被初始化为 0,并且只被递增,所以任何与 i 的比较都可以假定 i 永远不会是负数。因此,条件 -1 > i 将永远不会为真。
在证明了 -1 > i 永远不会为真之后,编译器可以将代码简化为:

  1. func BenchmarkMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. if false { // 注意已为 false
  5. r = -1
  6. } else {
  7. r = i
  8. }
  9. }
  10. Result = r
  11. }

并且由于该分支现在是一个常数,编译器可以消除无法到达的路径,只留下如下代码:

  1. func BenchmarkMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. r = i
  5. }
  6. Result = r
  7. }

通过内联和它所释放的优化,编译器已经将表达式 r = max(-1, i) 简化为 r = i。
这个例子非常不错,很好的体现了内联的优化过程和性能提升的缘由。

内联的限制

在这篇文章中,讨论了所谓的叶子内联:将调用栈底部的一个函数内联到其直接调用者中的行为。
内联是一个递归的过程,一旦一个函数被内联到它的调用者中,编译器就可能将产生的代码内联到它的调用者中,依此类推。
例如如下代码:

  1. func BenchmarkMaxMaxMax(b *testing.B) {
  2. var r int
  3. for i := 0; i < b.N; i++ {
  4. r = max(max(-1, i), max(0, i))
  5. }
  6. Result = r
  7. }

该运行速度将会和前面的例子一样快,因为编译器能够反复应用上面的优化,将代码减少到相同的 r = i 表达式。

总结

这篇文章针对内联进行了基本的概念介绍和分析,并且通过 Go 的例子进行了一步步的剖析,让大家对真实案例有了一个更贴切的理解。
Go 编译器的优化总是无处不在的。

参考资料

[1]
Inlining optimisations in Go: https://dave.cheney.net/2020/04/25/inlining-optimisations-in-go