一文带大家了解Go语言中的内联优化

 更新时间:2023年05月30日 09:04:02   作者:赵不贪  
内联优化是一种常见的编译器优化策略,通俗来讲,就是把函数在它被调用的地方展开,这样可以减少函数调用所带来的开销,本文主要为大家介绍了Go中内联优化的具体使用,需要的可以参考下

内联优化是一种常见的编译器优化策略,通俗来讲,就是把函数在它被调用的地方展开,这样可以减少函数调用所带来的开销(栈的创建、参数的拷贝等)。

当函数/方法被内联时,具体是什么样的表现呢?

观察内联

举个例子,现在有以下代码

// ValidateName 验证给定的用户名是否合法
//
//go:noinline
func ValidateName(name string) bool { // AX: 字符串指针  BX: 字符串长度
    if len(name) < 1 {
        return false
    } else if len(name) > 12 {
        return false
    }
    return true
}
//go:noinline
func (s *Server) CreateUser(name string, password string) error {
    if !ValidateName(name) {
        return errors.New("invalid name")
    }
    // ...
    return nil
}
type Server struct{}

为了便于理解,我为函数和方法增加了//go:noinline注释。Go编译器在遇到该注释时,不会将函数/方法进行内联处理。我们先看一下禁止内联时,该段代码生成的汇编指令:

// ...
// ValidateName函数 
// 此时:
// AX寄存器: 指向name字符串数组的指针
// BX寄存器: name字符串的长度
TEXT github.com/bootun/example/user.ValidateName(SB) github.com/bootun/example/user/user.go
    user.go:9   0x4602c0  MOVQ AX, 0x8(SP) // 保存name字符串的指针到栈上(后面没有用到)
    user.go:10  0x4602c5  TESTQ BX, BX     // BX & BX, 用来检测BX是否为0, 等价于:CMPQ 0, BX
    user.go:10  0x4602c8  JE 0x4602d9      // 如果为0则跳转到0x4602d9
    user.go:12  0x4602ca  CMPQ $0xc, BX    // 比较常数12和name的长度
    user.go:12  0x4602ce  JLE 0x4602d3     // 小于等于12则跳转到0x4602d3
    user.go:13  0x4602d0  XORL AX, AX      // return false
    user.go:13  0x4602d2  RET
    user.go:15  0x4602d3  MOVL $0x1, AX    // return true
    user.go:15  0x4602d8  RET
    user.go:11  0x4602d9  XORL AX, AX      // return false
    user.go:11  0x4602db  RET
// CreateUser方法
TEXT github.com/bootun/example/user.(*Server).CreateUser(SB) /github.com/bootun/example/user/user.go
    // 省略了一些函数调用前的准备工作(寄存器赋值等操作)
    user.go:20    0x460300  CALL user.ValidateName(SB)
    user.go:20    0x460305  TESTL AL, AL
    user.go:20    0x460307  JE 0x460317
    user.go:24    0x460309  XORL AX, AX
    user.go:24    0x46030b  XORL BX, BX
    user.go:24    0x46030d  MOVQ 0x10(SP), BP
    user.go:24    0x460312  ADDQ $0x18, SP
    user.go:24    0x460316  RET
    errors.go:62  0x460317  LEAQ 0x9302(IP), AX
    errors.go:62  0x46031e  NOPW
    errors.go:62  0x460320  CALL runtime.newobject(SB)
    // ...

上面的汇编里只截取了最关键的两段: ValidateName函数和CreateUser方法。

看不懂汇编的同学也没关系,注意看CreateUser方法内有一行user.go:20 CALL user.ValidateName, 说明在CreateUser方法内调用了ValidateName函数,刚好和我们的代码能够对应的上。

现在让我们去掉源代码ValidateName函数上的//go:noinline再次编译后查看生成的汇编指令:

如果你想使用文章里的代码进行尝试,请不要删除CreateUser方法上的//go:noinline,因为例子中的CreateUser太简短了,编译器会把它也内联优化掉,不方便我们进行试验和观察

// CreateUser函数
// 此时:
// AX寄存器: 方法Recever,即Server结构体
// BX寄存器: name字符串的指针
// CX寄存器: name字符串的长度
TEXT github.com/bootun/example/user.(*Server).CreateUser(SB) /github.com/bootun/example/user/user.go
    // ...              
    user.go:18    0x4602d4  MOVQ BX, 0x28(SP)    // 保存name字符串的指针到栈上
    user.go:19    0x4602d9  TESTQ CX, CX         // 验证name的长度是否为0
    user.go:9     0x4602dc  JE 0x4602e6          // 为0则跳转到0x4602e6
    user.go:9     0x4602de  NOPW
    user.go:11    0x4602e0  CMPQ $0xc, CX        // 比较常数12和字符串的长度
    user.go:11    0x4602e4  JLE 0x460318         // 小于等于则跳转到0x460318继续执行(name合法)
    errors.go:62  0x4602e6  LEAQ 0x9333(IP), AX  // 构造错误返回                   
    errors.go:62  0x4602ed  CALL runtime.newobject(SB)                  
    errors.go:62  0x4602f2  MOVQ $0xc, 0x8(AX)                    
    // ...
    user.go:23    0x460318  XORL AX, AX      // AX = 0
    user.go:23    0x46031a  XORL BX, BX      // BX = 0				
    user.go:23    0x46031c  MOVQ 0x10(SP), BP  // 恢复BP寄存器
    user.go:23    0x460321  ADDQ $0x18, SP     // 增加栈指针, 减小栈空间
    user.go:23    0x460325  RET                // return
    // ...

观察这一次的代码可以发现,ValidateName函数的逻辑直接被内嵌到了CreateUser方法里展开了。我们在生成的汇编代码里也搜索不到ValidateName相关的符号了。 现在的代码等价于:

func (s *Server) CreateUser(name string, password string) error {
    if len(name) < 1 {
        return errors.New("invalid name")
    } else if len(name) > 12 {
        return errors.New("invalid name")
    }
    return nil
}

什么样的函数会被内联?

内联相关的代码在cmd/compile/internal/inline/inl.go里,属于编译器的一部分。在该文件的最上面有这样一段注释, 里面很好的概括了内联的控制和规则:

// The Debug.l flag controls the aggressiveness. Note that main() swaps level 0 and 1,
// making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and
// are not supported.
//      0: disabled
//      1: 80-nodes leaf functions, oneliners, panic, lazy typechecking (default)
//      2: (unassigned)
//      3: (unassigned)
//      4: allow non-leaf functions
//
// At some point this may get another default and become switch-offable with -N.
//
// The -d typcheckinl flag enables early typechecking of all imported bodies,
// which is useful to flush out bugs.
//
// The Debug.m flag enables diagnostic output.  a single -m is useful for verifying

总结一下上面这段话中的核心部分:

  • 80个节点的叶子函数,oneliners,panic,懒惰的类型检查 会被内联
  • 使用-N -l来告诉编译器禁止内联
  • 使用-m启用诊断输出

也就是说,只要我们的函数/方法足够小,就可能会被内联。 因此,很多人会使用许多小的函数组合来代替大段代码提升性能。比如我们经常使用的互斥锁(标准库中sync包里的Mutex)就利用了这一点, 我们平时使用的Lock方法一共就只有这几行:

func (m *Mutex) Lock() {
    // Fast path: grab unlocked mutex.
    if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
        if race.Enabled {
            race.Acquire(unsafe.Pointer(m))
        }
        return
    }
    // Slow path (outlined so that the fast path can be inlined)
    m.lockSlow()
}

注意倒数第三行的注释:outlined so that the fast path can be inlined,利用这一特性,Lock里的FastPath就能被内联到我们的程序里而不需要额外的函数调用,从而提升代码的性能。

函数内联部分的入口是函数inline.InlinePackage, 想要深入了解的小伙伴可以去看一看。

内联能为我的程序带来多少性能上的提升?

前面介绍了这么多内联,连标准库都刻意使用内联来提升Go程序的性能,那么内联究竟能为我们带来多少性能上的提升呢?

我们来扩充一下文章开篇提到的例子:

package user
import (
      "errors"
)
func ValidateName(name string) bool {
      if len(name) < 1 {
            return false
      } else if len(name) > 12 {
            return false
      }
      return true
}
//go:noinline
func ValidateNameNoInline(name string) bool {
      if len(name) < 1 {
            return false
      } else if len(name) > 12 {
            return false
      }
      return true
}
func (s *Server) CreateUser(name string, password string) error {
      if !ValidateName(name) {
            return errors.New("invalid name")
      }
      return nil
}
// CreateUserNoInline 使用的是禁止内联版本的 ValidateName
func (s *Server) CreateUserNoInline(name string, password string) error {
      if !ValidateNameNoInline(name) {
            return errors.New("invalid name")
      }
      return nil
}
type Server struct{}

我们复制了以ValidateName函数,在上面标注上//go:noinline来禁止编译器对其进行内联优化,并将其并将其更名为ValidateNameNoInline。同时我们也复制了CreateUser方法,新的方法内部使用ValidateNameNoInline来验证name参数,除此之外所有的地方都和原方法相同。

我们来写两个Benchmark测试一下:

package user
import "testing"
// BenchmarkCreateUser 测试内联过的函数的性能
func BenchmarkCreateUser(b *testing.B) {
      srv := Server{}
      for i := 0; i < b.N; i++ {
            if err := srv.CreateUser("bootun", "123456"); err != nil {
                  b.Logf("err: %v", err)
            }
      }
}
// BenchmarkValidateNameNoInline 测试函数禁止内联后的性能
func BenchmarkValidateNameNoInline(b *testing.B) {
      srv := Server{}
      for i := 0; i < b.N; i++ {
            if err := srv.CreateUserNoInline("bootun", "123456"); err != nil {
                  b.Logf("err: %v", err)
            }
      }
}

测试结果如下:

# 内联版本的基准测试结果(BenchmarkCreateUser)
goos: windows
goarch: amd64
pkg: github.com/bootun/example/user
cpu: AMD Ryzen 7 6800H with Radeon Graphics
BenchmarkCreateUser
BenchmarkCreateUser-16          1000000000               0.2279 ns/op
PASS


# 禁止内联版本的基准测试结果(BenchmarkValidateNameNoInline)
goos: windows
goarch: amd64
pkg: github.com/bootun/example/user
cpu: AMD Ryzen 7 6800H with Radeon Graphics
BenchmarkValidateNameNoInline
BenchmarkValidateNameNoInline-16        733243102                1.635 ns/op
PASS

可以看到,禁止内联后每次操作耗费1.6纳秒,而内联后只需要0.22纳秒(因机器而异)。从比例上看,内联优化带来的收益还是很可观的。

我需要做什么来启用内联优化吗

当然不需要,在Go编译器中,内联优化是默认启用的,如果你的函数符合文中提到的内联优化的策略(比如函数很小),并且没有显式的禁用内联,就可能会被编译器执行内联优化。

在某些场景下,我们可能不希望函数进行内联(比如使用dlv进行DEBUG时,或者查看程序生成的汇编代码时),可以使用go build -gcflags='-N -l' xxx.go来禁用内联优化。

编译器默认优化出来的代码可能比较难以阅读和理解,不方便我们进行调试和学习。

-gcflags是传递给go编译器gc的命令行标志, go build 背后做了很多事,也不止用到了gc一个程序。使用go build -x main.go可以查看编译过程中的详细步骤。

以上就是一文带大家了解Go语言中的内联优化的详细内容,更多关于Go内联优化的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:

相关文章

  • Go语言使用Timeout Context取消任务的实现

    Go语言使用Timeout Context取消任务的实现

    本文主要介绍了Go语言使用Timeout Context取消任务的实现,包括基本的任务取消和控制HTTP客户端请求的超时,具有一定的参考价值,感兴趣的可以了解一下
    2024-01-01
  • 利用Go语言实现流量回放工具的示例代码

    利用Go语言实现流量回放工具的示例代码

    今天给大家推荐一款使用Go语言编写的流量回放工具 -- goreplay;工作中你一定遇到过需要在服务器上抓包的场景,有了这个工具就可以助你一臂之力,废话不多,我们接下来来看一看这个工具
    2022-09-09
  • go-zero 应对海量定时/延迟任务的技巧

    go-zero 应对海量定时/延迟任务的技巧

    这篇文章主要介绍了go-zero 如何应对海量定时/延迟任务,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下
    2020-10-10
  • 8个Elasticsearch高频面试题和答案整理

    8个Elasticsearch高频面试题和答案整理

    这篇文章为大家精选了8道Elasticsearch高频面试题和答案,并且给出了这些知识点的应用场景、也给出了解决这些问题的思路,希望对大家有所帮助
    2023-06-06
  • Go 语言单例模式示例详解

    Go 语言单例模式示例详解

    这篇文章主要为大家介绍了Go 语言单例模式示例详解,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪
    2022-10-10
  • Golang pprof性能测试与分析讲解

    Golang pprof性能测试与分析讲解

    刚开始接触go就遇到了一个内存问题,在进行内存分析的时候发现了一下比较好的工具,在此留下记录,下面这篇文章主要给大家介绍了关于go性能分析工具pprof的性能测试,需要的朋友可以参考下
    2023-04-04
  • 解决Goland中利用HTTPClient发送请求超时返回EOF错误DEBUG

    解决Goland中利用HTTPClient发送请求超时返回EOF错误DEBUG

    这篇文章主要介绍了解决Goland中利用HTTPClient发送请求超时返回EOF错误DEBUG,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧
    2020-12-12
  • Go语言学习之context包的用法详解

    Go语言学习之context包的用法详解

    日常Go开发中,Context包是用的最多的一个了,几乎所有函数的第一个参数都是ctx,那么我们为什么要传递Context呢,Context又有哪些用法,底层实现是如何呢?相信你也一定会有探索的欲望,那么就跟着本篇文章,一起来学习吧
    2022-10-10
  • Goland中Protobuf的安装、配置和使用

    Goland中Protobuf的安装、配置和使用

    本文记录了mac环境下protobuf的编译安装,并通过一个示例来演示proto自动生成go代码,本文使用的mac os 12.3系统,不建议使用homebrew安装,系统版本太高,会安装报错,所以自己下载新版压缩包编译构建安装
    2022-05-05
  • 浅析golang如何在多线程中避免CPU指令重排

    浅析golang如何在多线程中避免CPU指令重排

    这篇文章主要为大家详细介绍了golang在多线程中避免CPU指令重排的相关知识,文中的示例代码讲解详细,感兴趣的小伙伴可以跟随小编一起学习一下
    2024-03-03

最新评论