译文 Go 高性能系列教程之三:编译器优化

yudotyang · 2021年06月01日 · 651 次阅读
本帖已被设为精华帖!

这部分主要集中在 Go 编译器优化方面。其中逃逸分析内联优化是在编译器的前端处理的,这时代码仍然处于抽象语法树的形式。然后代码被传给 SSA(static single assignment form)编译器继续优化,会经过死代码消除边界检查消除Nil 检查消除

3.1 Go 编译器的历史

大约在 2007 年,Go 编译器开始是作为 Plan9 编译器工具链的一个分支。当时的编译器和 Aho 和 UIIman 的 Dragon Book 极为相似。

在 2015 年,Go 1.5 的编译器在技术上从 C 转换到了 Go 语言。

一年以后,Go 1.7 介绍了基于 SSA 技术的新的编译器后端实现,替换了之前的 Plan9 风格的代码生成器。这个新的后端实现介绍了为通用以及特定架构的优化提供了可能。

3.2 逃逸分析

我们首先要介绍的优化技术是逃逸分析

为了演示逃逸分析所做的事情,我们回想下 Go 规范没有提到的堆或栈。在引言中只涉及到了 Go 语言是基于垃圾回收的,但并没有提及是怎么实现的。

Go 规范的兼容实现版本可以存储堆上每次分配的内存。这会给垃圾收集器带来很大的压力,但这不是错误的实现方式。这几年来,gccgo对逃逸分析的支持非常有限,因此可以认为在这种模式下运行是有效的 (for several years, gccgo had very limited support for escape analysis so could effectively be considered to be operating in this mode)。

然而,goroutine 的用来存储局部变量是一个非常简单有效的地方;因为在函数返回时会自动对栈的信息进行收集,所以无需在栈上进行垃圾回收。因此,在安全的情况下,更有效的内存分配方式是在栈上进行分配。

在一些像 C、C++ 的语言中,在栈上分配内存还是堆上分配内存是由程序员自己决定的 --- 堆内存分配通过 malloc 和 free 函数进行管理,栈内存的分配是通过 alloca 函数进行的。在程序中错误的使用这两种内存分配方式是导致内存损坏的常见原因。

在 Go 中,如果变量的生命周期超出了函数的生命周期,编译器会自动的把变量值从栈内存移动到堆内存上。我们称这种机制为变量逃逸到堆上

type Foo struct {
    a, b, c, d int
}

func NewFoo() *Foo {
    return &Foo{a: 3, b: 1, c: 4, d:7}
}

在该示例中,在函数 NewFoo 中分配的 Foo 变量将会被移动到堆上,所以当 NewFoo 返回之后,Foo 的值依然是有效的。

这种情况自 Go 成立以来是一直存在的。与其说是一种自动纠正功能,不如说是一种优化机制。在 Go 中,意外返回栈的内存地址是不可能的。

但是,编译器可以做这样的事情(译者注:编译器可以直接获取堆栈的内存地址)。编译器可以知道哪些是在堆上分配的,并将它们移动到栈内存。

让我们一起看下下面的例子:

func Sum() int {
    const count = 100
    numbers := make([]int, count)
    for i := range numbers {
        numbers[i] = i + 1
    }

    var sum int
    for _, i := range numbers {
        sum += i
    }
    return sum
}

func main() {
    answer := Sum()
    fmt.Println(answer)
}

Sum 函数的功能是对 int 类型的值求从 1 到 100 的和并返回结果。

由于numbers切片只在函数 Sum 内引用,所以,编译器将存储这 100 个 integer 类型的数字在栈内存上,而不是堆内存上。对于 numbers 变量来说,并不需要进行内存回收(GC),当当 Sum 函数返回时会自动被回收。

3.2.1 证明

通过在 go build 命令上增加 -m flag 可以打印出编译器的逃逸分析的信息。

逃逸分析在 Go 1.13 中已被重写。这解决了一些长期存在的限制,更不用说在以前的实现中发现的一些有争议的边缘问题。

Go 1.13 的直接结果是类似于 1.12 的逃逸分析功能,但是其调试输出(不久之后也会看到)已经有所变化

% go build -gcflags=-m examples/esc/sum.go
# command-line-arguments
examples/esc/sum.go:21:13: inlining call to fmt.Println
examples/esc/sum.go:7:17: Sum make([]int, 100) does not escape
examples/esc/sum.go:21:13: answer escapes to heap
examples/esc/sum.go:21:13: main []interface {} literal does not escape
examples/esc/sum.go:21:13: io.Writer(os.Stdout) escapes to heap
<autogenerated>:1: (*File).close .this does not escape

由上面内容可知, 第 7 行中展示了编译器对 make([] int, 100) 的分配并没有逃逸到堆内存上。

第 21 行中中,answer变量逃逸到了堆内存上,原因是 fmt.Println() 是一个可变参数函数。可变参函数的参数被封装在切片中,在本例中为 [] interface{},因此 answer变量被封装到了一个 interface 类型的值中,所以这里是通过 fmt.Println 的引用。由于从 Go 1.6 版本开始的垃圾回收器要求通过接口传递的所有值都是指针,因此编译器看到的大致是这样的:

var answer = Sum()
fmt.Println([]interface{&answer}...)

我们可以通过使用-gcflags="-m -m" flag 来查看输出:

% go build -gcflags='-m -m' examples/esc/sum.go 2>&1 | grep sum.go:21
examples/esc/sum.go:21:13: inlining call to fmt.Println func(...interface {}) (int, error) { var fmt..autotmp_3 int; fmt..autotmp_3 = <N>; var fmt..autotmp_4 error; fmt..autotmp_4 = <N>; fmt..autotmp_3, fmt..autotmp_4 = fmt.Fprintln(io.Writer(os.Stdout), fmt.a...); return fmt..autotmp_3, fmt..autotmp_4 }
examples/esc/sum.go:21:13: answer escapes to heap
examples/esc/sum.go:21:13: main []interface {} literal does not escape
examples/esc/sum.go:21:13: io.Writer(os.Stdout) escapes to heap

总之,不要过于担心第 21 行的事情,这里不是那么重要。

3.2.2 练习与思考

  • 此优化技术是否适用于 count 的所有类型的值?

    如果 count 是 var 变量的时候,是会逃逸到堆内存上去的。但如果 count 是常量,则不会逃逸到堆内存上去。

  • 如果 count 是变量,而非常量,此优化是否适用?

    如果 count 是变量,是会逃逸到堆内存上去的。

  • 如果 count 是 Sum 函数的参数,此优化是否适用? 如果 count 作为 Sum 函数的参数,也会逃逸到堆内存上。

3.2.3 逃逸分析(续)

下面是个人为的例子,仅是示例。

type Point struct{ X, Y int }

const Width = 640
const Height = 480

func Center(p *Point) {
    p.X = Width / 2
    p.Y = Height / 2
}

func NewPoint() {
    p := new(Point)
    Center(p)
    fmt.Println(p.X, p.Y)
}

NewPoint 函数创建了一个 *Point 指针类型的变量 p。我们传递 p 到 Center 函数。最后我们打印出 p.X 和 p.Y 的值。

% go build -gcflags=-m examples/esc/center.go
# command-line-arguments
examples/esc/center.go:11:6: can inline Center
examples/esc/center.go:18:8: inlining call to Center
examples/esc/center.go:19:13: inlining call to fmt.Println
examples/esc/center.go:11:13: Center p does not escape
examples/esc/center.go:17:10: NewPoint new(Point) does not escape
examples/esc/center.go:19:15: p.X escapes to heap
examples/esc/center.go:19:20: p.Y escapes to heap
examples/esc/center.go:19:13: NewPoint []interface {} literal does not escape
examples/esc/center.go:19:13: io.Writer(os.Stdout) escapes to heap
<autogenerated>:1: (*File).close .this does not escape

即使 p 是通过 new 函数分配的,但它不会被存储到堆上,因为没有任何对 p 的引用逃逸到 Center 函数。

3.2.4 逃逸场景

  • 指针逃逸 指针逃逸是指在函数中创建了一个对象,返回了这个对象的指针。这种情况下,函数虽然退出了,但是指针指向对象的内存依然会被使用,所以对象的内存不能随着函数结束而回收(这时指针变量本身已经回收),因此只能分配在堆上。
package main

import "fmt"

type Point struct{x, y int}

func NewPoint(x, y int) *Point {
    p := new(Point)
    p.x = x
    p.y = y
    return p
}

func main() {
    p := NewPoint(10, 20)
    fmt.Println(p)
}

在这个例子中,函数 NewPoint 的局部变量 p 发生了逃逸。p 作为返回值,在 main 函数中继续使用,因此 p 指向的内存不能够分配在栈上,只能分配在堆上。

通过 -gcflags=-m 查看变量逃逸情况:

% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:7:6: can inline NewPoint
./main.go:15:15: inlining call to NewPoint
./main.go:16:13: inlining call to fmt.Println
./main.go:8:10: new(Point) escapes to heap
./main.go:15:15: new(Point) escapes to heap
./main.go:16:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

但如果将 NewPoint 的返回值换成是 Point 而非指针,我们来看看是什么结果。

func NewPoint(x, y int) Point {
    p := new(Point)
    p.x = x
    p.y = y
    return *p
}
% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:7:6: can inline NewPoint
./main.go:15:15: inlining call to NewPoint
./main.go:16:13: inlining call to fmt.Println
./main.go:8:10: new(Point) does not escape
./main.go:15:15: new(Point) does not escape
./main.go:16:13: p escapes to heap
./main.go:16:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

在第 8 行和 15 行显示 new(Point) does not escape,说明没有发生内存逃逸。

  • interface{}动态类型逃逸 在 Go 语言中,空接口即 interface{} 可以表示任意的类型,如果函数参数为 interface{},编译期间很难确定其参数的具体的类型,也会发生逃逸。 ```golang package main

import "fmt"

func PrintDemo(d interface{}) { fmt.Println(d) }

func main() { const count = 10 PrintDemo(count) }

在命令行执行 go build -gcflags='-m' main.go
```bash
% go build -gcflags='-m' main.go
./main.go:5:6: can inline PrintDemo
./main.go:6:13: inlining call to fmt.Println
./main.go:9:6: can inline main
./main.go:11:11: inlining call to PrintDemo
./main.go:11:11: inlining call to fmt.Println
./main.go:5:16: leaking param: d
./main.go:6:13: []interface {}{...} does not escape
./main.go:11:11: count escapes to heap
./main.go:11:11: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

由以上输出可知,第 11 行的 count 参数逃逸到了 heap 上。

  • 不确定长度大小 我们看上面 Sum 函数的例子。当 count 分别为 var 变量、常量时的情况。 情况一:当 count 为 var 变量时:

    func Sum() int {
    var count = 100
    numbers := make([]int, count)
    for i := range numbers {
        numbers[i] = i + 1
    }
    
    var sum int
    for _, i := range numbers {
        sum += i
    }
    return sum
    }
    

func main() { answer := Sum() fmt.Println(answer) }

在终端输出结果:
```bash
% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:21:13: inlining call to fmt.Println
./main.go:7:17: make([]int, count) escapes to heap
./main.go:21:13: answer escapes to heap
./main.go:21:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

由输出结果可知,make([] int, count) escapes to heap 产生了逃逸分析。因为,在编译期间,make 函数不知道 count 的具体值,所以也会在堆上分配内存。

如果我们把 count 换成常量看看如何?

const count = 100

在终端输出结果:

% go build -gcflags='-m' main.go
# command-line-arguments
./main.go:21:13: inlining call to fmt.Println
./main.go:7:17: make([]int, count) does not escape
./main.go:21:13: answer escapes to heap
./main.go:21:13: []interface {}{...} does not escape
<autogenerated>:1: .this does not escape

由以上结果可知,make([] int, count) does not escape,没有发生逃逸,因为在编译器期间,常量的值是确定的。

  • 栈空间不足 操作系统对内核线程使用的栈空间是有大小限制的,64 位系统一般为 8MB。在终端下,通过 ulimit -a 命令可以查看当前机器上栈允许占用的内存的大小。如下: bash sh-3.2# ulimit -a ... stack size (kbytes, -s) 8192 cpu time (seconds, -t) unlimited ... 其中,stack size 之处机器的栈空间最大是 8MB。对于 Go 编译器来说,超过一定大小的局部变量将逃逸到堆上 或无法判断当前切片长度时会将对象分配到堆中。我们看下下面的例子:
func generate8191() {
    numSlice := make([]int, 8191) // < 64KB
    for i := 0; i < 8191; i++ {
        numSlice[i] = i
    }
}

func generate8192() {
        numSlice := make([]int, 8192) // =64KB
    for i := 0; i < 8192; i++ {
        numSlice[i] = i
    }   
}

    func generateN(n int) {
        numSlice := make([]int, n) // 不确定
        for i := 0; i < n; i++ {
            numSlice[i] = i
        }   
}

func main() {
    generate8191()
    generate8192()
    generateN(1)
}
  • generate8191() 创建了大小为 8191 的 int 型切片,恰好小于 64 KB(64 位机器上,int 占 8 字节),不包含切片内部字段占用的内存大小。
  • generate8192() 创建了大小为 8192 的 int 型切片,恰好占用 64 KB。
  • generate(n),切片大小不确定,调用时传入。

编译结果如下:

% go build -gcflags='-m' main.go
./main.go:4:6: can inline generate8191
./main.go:11:6: can inline generate8192
./main.go:18:7: can inline generateN
./main.go:25:6: can inline main
./main.go:26:14: inlining call to generate8191
./main.go:27:14: inlining call to generate8192
./main.go:28:11: inlining call to generateN
./main.go:5:18: make([]int, 8191) does not escape
./main.go:12:19: make([]int, 8192) escapes to heap
./main.go:19:19: make([]int, n) escapes to heap
./main.go:26:14: make([]int, 8191) does not escape
./main.go:27:14: make([]int, 8192) escapes to heap
./main.go:28:11: make([]int, n) escapes to heap

make([] int, 8191) 没有发生逃逸,make([] int, 8192) 和 make([] int, n) 逃逸到堆上,也就是说,当切片占用内存超过一定大小,或无法确定当前切片长度时,对象占用内存将在堆上分配

场景示例参考了极客兔兔的示例,参考来源:https://geektutu.com/post/hpg-escape-analysis.html

  • 闭包函数
package main

import "fmt"

func Fibonacci() func() int {
    a, b := 0, 1
    return func() int {
        a, b = b, a+b
        return a
    }
}

func main() {
    f := Fibonacci()

    for i := 0; i < 10; i++ {
        fmt.Printf("Fibonacci: %d\n", f())
    }
}

Fibonacci() 函数中原本属于局部变量的 a 和 b 由于闭包的引用,不得不将二者放到堆上,以致产生逃逸。

$ go build -gcflags=-m
#gitHub/test/pool
./main.go:7:9: can inline Fibonacci.func1
./main.go:7:9: func literal escapes to heap
./main.go:7:9: func literal escapes to heap
./main.go:8:10: &b escapes to heap
./main.go:6:5: moved to heap: b
./main.go:8:13: &a escapes to heap
./main.go:6:2: moved to heap: a
./main.go:17:34: f() escapes to heap
./main.go:17:13: main ... argument does not escape

小结

编译器决定内存分配位置的方式,就称为逃逸分析。逃逸分析由编译器完成,作用于编译阶段。

传值会拷贝整个对象,而传指针只会拷贝指针地址,指向的对象是同一个。传指针可以减少值的拷贝,但是会导致内存分配逃逸到堆中,增加垃圾回收 (GC) 的负担。在对象频繁创建和删除的场景下,传递指针导致的 GC 开销可能会严重影响性能

一般情况下,对于需要修改原对象值,或占用内存比较大的结构体,选择传指针。对于只读的占用内存较小的结构体,直接传值能够获得更好的性能

3.3 内联

在 Go 中,函数调用具有固定的开销:栈分配和抢占检查。

通过硬件预测期可以改善其中的某些功能,但对于功能大小和时钟周期而言,这仍具有一定的开销。

内联是避免这种开销较经典的优化技术。

直到 Go 1.11 版本,内联也仅仅是在叶子函数中起作用,即一个函数没有再调用其他函数则成为叶子函数。这样做的理由是:

  • 如果你的函数做了很多事情,那么那些固定的开销可以忽略不计。这就是为什么函数要达到一定的大小(目前有一些指令,加上一些啊哦做无法阻止所有内容的内联,例如 Go 1.7 之前的 switch 语句)
  • 另一方面,小函数会为执行了少量功能而付出一定的固定开销。这就是内联机制的作用,因为他们会最大程度的减少函数固定开销。

另外一个原因是非常大的内联会使栈跟踪非常困难。

3.3.1 内联(示例)

func Max(a, b int) int {
    if a > b {
        return a    
    }

    return b
}

func F() {
    const a, b = 100, 20
    if Max(a, b) == b {
        panic(b)    
    }
}

我们使用 -gcflags=-m flag 以便查看编译器的优化机制:

% go build -gcflags=-m examples/inl/max.go
examples/inl/max.go:4:6: can inline Max
examples/inl/max.go:11:6: can inline F
examples/inl/max.go:13:8: inlining call to Max
examples/inl/max.go:20:6: can inline main
examples/inl/max.go:21:3: inlining call to F
examples/inl/max.go:21:3: inlining call to Max

编译器打印出两个信息:

  • 首先,在第三行中,Max 函数的定义,告诉我们该函数可以被内联
  • 其次,告诉我们 Max 函数的内容可以被内联到函数调用中。

3.3.2 内联看起来是什么样的?

编译 max.go 并且查看 F 函数被优化的版本变成什么了。

% go build -gcflags=-S examples/inl/max.go 2>&1 | grep -A5 '"".F STEXT'
"".F STEXT nosplit size=2 args=0x0 locals=0x0
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     TEXT    "".F(SB), NOSPLIT|ABIInternal, $0-0
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:11)     FUNCDATA        $3, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (/Users/dfc/devel/high-performance-go-workshop/examples/inl/max.go:13)     PCDATA  $2, $0

当 Max 函数被内联以后,F 函数的主体部分 -- 在这个函数中没有发生任何东西。我知道屏幕上有很多文本,但有用的不多。依我之见,唯一发生的是 RET。 实际上,F 变为:

func F() {
    return
}

什么是 FUNCDATA 和 PCDATA? 从-S flag 中输出的内容并非是写入二进制文件的最终的机器码。在最后的链接阶段,连接器做了一些处理。FUNCDATA 和 PCDATA 之类的行是垃圾收集器的元数据,在链接时会移至其他位置。如果你在阅读-S flag 输出的时候,请忽略 FUNDATA 和 PCDATA 行,因为他们不是最终二进制文件的一部分。 在其余的演示中,我将使用一个小的 Shell 脚本来减少程序集输出中的混乱情况。

asm() {
        go build -gcflags=-S 2>&1 $@ | grep -v PCDATA | grep -v FUNCDATA >| less
}

3.4 消除无效代码 (Dead code elimination)

死码消除 (dead code elimination, DCE)是一种编译器优化技术,用处是在编译阶段去掉对程序运行结果没有任何影响的代码。

死代码消除有很多好处:减少程序体积,程序运行过程中避免执行无用的指令,缩短运行时间。

在下面的代码中,为什么说 a 和 b 是常量会这么重要?

为了理解发生了什么,让我们看看当函数 Max 被内联到 F 以后,编译器看到的内容是什么。我们不能很容易的从编译器中得到这个结果,但可以直接手动实现

之前是这样的:

func Max(a, b int) int {
    if a > b {
        return a
    }
    return b
}

func F() {
    const a, b = 100, 20
    if Max(a, b) == b {
        panic(b)
    }
}

内联优化之后:

func F() {
    const a, b = 100, 20
    var result int
    if a > b {
        result = a
    } else {
        result = b
    }
    if result == b {
        panic(b)
    }
}

因为 a 和 b 是常量,所以编译器可以在编译时证明该分支永远不会为假。100 永远大于 20。所以编译器才可以进一步优化 F 函数成这样:

func F() {
    const a, b = 100, 20
    var result int
    if true {
        result = a
    } else {
        result = b
    }
    if result == b {
        panic(b)
    }
}

既然知道了分支的结果,那么结果的内容也就知道了。 这就是消除分支。

func F() {
    const a, b = 100, 20
    const result = a
    if result == b {
        panic(b)
    }
}

现在消除了分支,我们知道结果总是等于 a,并且因为 a 是一个常数,所以我们知道结果是一个常数。 编译器将此证明应用于第二个分支

func F() {
    const a, b = 100, 20
    const result = a
    if false {
        panic(b)
    }
}

并再次使用分支消除,将 F 的最终形式简化为。

func F() {
    const a, b = 100, 20
    const result = a
}

最后就成了这样:

func F() {
}

3.4.1 消除无效代码(续)

分支消除是称为消除无效代码的一种优化类别之一。 实际上,使用静态证明来显示一段代码是永远无法到达的,俗称死代码,因此无需在最终二进制文件中进行编译,优化或提交代码。

我们看到了无效代码消除如何与内联一起工作,通过删除被证明无法访问的循环和分支而减少代码量。

您可以利用此优势实施昂贵的调试,并将其隐藏在后面。

const debug = false

和编译的 tag 一起使用将会非常有用。

3.4.2 调整内联级别

使用-gcflags=-l 标志来执行调整内联级别。 令人困惑的传递单个-l 将禁用内联,而两个或多个将启用更激进的设置中的内联。

  • -gcflags=-l, 禁用内联.
  • 什么都不传, 常规内联.
  • -gcflags='-l -l' 内联级别 2, 更具攻击性,可能更快,可能会生成更大的二进制文件.
  • -gcflags='-l -l -l' 内联级别 3, 再次变得更具攻击性,二进制文件肯定更大,也许再次更快,但也可能有问题.
  • -gcflags=-l=4 (4 个 -ls) Go 1.11 中的版本将使实验性中间堆栈内联优化成为可能。 我相信从 Go 1.12 开始它没有任何作用.

3.5 证明通过(Prove pass)

prove pass 的功能是对全局中 SSA 值的取值范围做一个推断,这样就可以消除掉许多不必要的分支判断。

看下下面的代码:

package main

func foo(x int32) bool {
    if x > 5 {
        if x > 3 {
            return true
        }
        panic("x less than 3")
    }
    return false
}

func main() {
    foo(-1)
}

解释说明:

  • **if x > 5 ** 这个分支中,我们已经知道了 x 是大于 5 的
  • 因此,在 *** if x > 3*** 这个分支中,那么 x 一定是大于 3 的。

3.5.1 论证

类似于初始化和逃逸分析,我们可以要求编译器向我们展示 Prove pass 的工作原理。通过在 go tool compile 的-gcflags 中增加 -d flag 即可。如下:

% go build -gcflags=-d=ssa/prove/debug=on examples/prove/foo.go
#command-line-arguments
examples/prove/foo.go:5:10: Proved Greater64

第 5 行是 if x > 3。编译器已经告诉我们它已经证明了这个分支永远都是 true。

3.6 编译器的 intrinsic 函数

Go 允许用汇编的方式编写函数。这项技术实现首先包含一个函数定义和一个对应的汇编函数的实现。例如:

//decl.go
package asm

// Add returns the sum of a and b.
func Add(a int64, b int64) int64

这里定义了一个 Add 函数,该函数有两个 int64 类型的参数,返回两数字和。注意这里的 Add 函数没有函数体,只有定义。如果我们直接编译,会看到下面的提示:

% go build
#high-performance-go-workshop/examples/asm [high-performance-go-workshop/examples/asm.test]
./decl.go:4:6: missing function body

为了满足编译器能够顺利编译通过,我们必须为该函数提供给一个汇编的实现,我们可以在相同的包中建立一个 .s 后缀的文件来实现:

//add.s
TEXT ·Add(SB),$0
    MOVQ a+0(FP), AX
    ADDQ b+8(FP), AX
    MOVQ AX, ret+16(FP)
    RET

现在我们就可以像 Go 的正常代码一样来 build,test 以及使用 asm.Add 函数了。

但是,这样存在一个问题,汇编函数是不能被内联优化的。这一直是 Go 开发人员长期所抱怨的,他们需要使用汇编来提高性能,或不想在语言中公开的操作。向量指令,原子原语等等,当需要使用汇编编写这些函数会付出高昂的成本,就因为他们无法被内联。

对于汇编内联的语法已经有了很多的提案,像 GCC's 的 asm( ... ) 指令,但它们都没有被 Go 开发者接受。相反,Go 增加了 intrinsic 函数。

一个 intrinsic 函数就是用 Go 语言编写的 Go 代码,然而,编译器对这些函数有专门的替换处理。

以下两个包使用了之中技术:

  • math/bits
  • sync/atomic

这种替换的实现是在编译器内部实现的;如果你的计算机架构支持更快的的方式,那么它将被用同等的指令来无缝替换掉。

同样也会生成的更高效的代码,因为 intrinsic 函数就是普通的 Go 代码,内联规则以及栈内联规则对它们都适用。

3.6.1 Popcnt 示例

让我们以前面的 Popcnt 为例。Population count 是一个重要的加密操作,所以现代的 CPU 有一个本地指令来执行实现它。

在 math/bits 的包中提供了一组函数,OnesCount...等可被编译器识别并替换为它们的原生等效项。

func BenchmarkMathBitsPopcnt(b *testing.B) {
    var r int
    for i := 0; i < b.N; i++ {
        r = bits.OnesCount64(uint64(i))
    }
    Result = uint64(r)
}

执行基准测试并和手动移位的实现进行性能比较:

Run the benchmark and compare the performance of the hand rolled shift implementation and math/bits.OnesCount64.

% go test -bench=.  ./examples/popcnt-intrinsic/

3.6.2 Atomic counter 示例

下面是一个原子计数的示例。我们已经有一些特定类型上的方法,每个方法调用了一些更深层次的方法,更多的包调用等等。你可能会误认为这会产生很多的开销。

package main

import (
    "sync/atomic"
)

type counter uint64

func (c *counter) get() uint64 {
    return atomic.LoadUint64((*uint64)(c))
}
func (c *counter) inc() uint64 {
    return atomic.AddUint64((*uint64)(c), 1)
}
func (c *counter) reset() uint64 {
    return atomic.SwapUint64((*uint64)(c), 0)
}

var c counter

func f() uint64 {
    c.inc()
    c.get()
    return c.reset()
}

func main() {
    f()
}

但是,因为在内联和编译器 intrinsics 之间的交互转换,这部分代码在大多数平台上会转换为高效的本地代码。

"".f STEXT nosplit size=36 args=0x8 locals=0x0
        0x0000 00000 (/tmp/counter.go:21)       TEXT    "".f(SB), NOSPLIT|ABIInternal, $0-8
        0x0000 00000 (<unknown line number>)    NOP
        0x0000 00000 (/tmp/counter.go:22)       MOVL    $1, AX
        0x0005 00005 (/tmp/counter.go:13)       LEAQ    "".c(SB), CX
        0x000c 00012 (/tmp/counter.go:13)       LOCK
        0x000d 00013 (/tmp/counter.go:13)       XADDQ   AX, (CX) //标注1
        0x0011 00017 (/tmp/counter.go:23)       XCHGL   AX, AX
        0x0012 00018 (/tmp/counter.go:10)       MOVQ    "".c(SB), AX //标注2
        0x0019 00025 (<unknown line number>)    NOP
        0x0019 00025 (/tmp/counter.go:16)       XORL    AX, AX
        0x001b 00027 (/tmp/counter.go:16)       XCHGQ   AX, (CX)  //标注3
        0x001e 00030 (/tmp/counter.go:24)       MOVQ    AX, "".~r0+8(SP)
        0x0023 00035 (/tmp/counter.go:24)       RET
        0x0000 b8 01 00 00 00 48 8d 0d 00 00 00 00 f0 48 0f c1  .....H.......H..
        0x0010 01 90 48 8b 05 00 00 00 00 31 c0 48 87 01 48 89  ..H......1.H..H.
        0x0020 44 24 08 c3                                      D$..
        rel 8+4 t=15 "".c+0
        rel 21+4 t=15 "".c+0
  • 标注 1:c.inc()
  • 标注 2:c.get()
  • 标注 3:c.reset()

深入阅读

3.7 边界检查预估

Go 是一种边界检查语言。这就意味着,数组、切片(slice)相关的操作代码会被做边界检查以确保它们都在各自类型的边界之内。

对于数组来说,边界检查会在编译阶段完成,因为数组的大小是固定的。但对于切片来说,边界检查的工作必须在运行时才能完成。

var v = make([]int, 9)

var A, B, C, D, E, F, G, H, I int

func BenchmarkBoundsCheckInOrder(b *testing.B) {
    var a, _b, c, d, e, f, g, h, i int
    for n := 0; n < b.N; n++ {
        a = v[0]
        _b = v[1]
        c = v[2]
        d = v[3]
        e = v[4]
        f = v[5]
        g = v[6]
        h = v[7]
        i = v[8]
    }
    A, B, C, D, E, F, G, H, I = a, _b, c, d, e, f, g, h, i
}

使用 -gcflags=-S 来反编译 BenchmarkBoundsCheckInOrder。查看下再每个循环中有多少次的边界检查。

3.8 编译器 flags

编译器的 flags 以以下方式提供: go build -gcflags=$FLAGS

考察以下编译器函数的运行情况:

  • -S 打印正在编译的包的汇编(Go 风格)
  • -l 控制编译器的内联行为; -l 禁用内联, -l -l 增加内联级别。(更多 -l,就增加编译器对内联代码的要求)
  • -m 控制编译器优化详细输出,像内联,逃逸分析。-m -m 会打印更详细的优化输出信息。
  • -l -N 禁用所有的优化机制
  • -d=ssa/prove/debug=0n,类似于-l 和-S
  • -d flag 还会有其他参数,使用 go tool compile -d help 查看更多。

深入阅读

小结

编译器优化会从内联、逃逸分析、边界检查、intrinsic 函数、死代码消除等方面进行优化,以提高效率。其中内联可以通过编译 flag 时的参数控制内联的程度。死代码消除、边界检查、intrinsic 函数在编译过程或运行时自动执行。

那么对程序员来说,最有用处的应该是逃逸分析。通过逃逸分析可以优化代码以降低在堆上分配对象的个数以减少 GC 的压力。从而提高程序性能

原文链接 https://dave.cheney.net/high-performance-go-workshop/gophercon-2019.html#benchmarking

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
_Lin GoCN 每日新闻 (2021-06-01) 中提及了此贴 06月01日 04:46
astaxie 将本帖设为了精华贴 06月02日 07:15
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册