标签 编译器 下的文章

Go 1.20新特性前瞻

本文永久链接 – https://tonybai.com/2022/11/17/go-1-20-foresight


在近期Russ Cox代表Go核心团队发表的“Go, 13周年”一文中,他提到了“在Go的第14个年头,Go团队将继续努力使Go成为用于大规模软件工程的最好的环境,将特别关注供应链安全,提高兼容性和结构化日志记录,当然还会有很多其他改进,包括profile-guided optimization等”。

当前正在开发的版本是Go 1.20,预计2023年2月正式发布,这个版本也将是Go在其第14个年头发布的第一个版本。很多人没想到Go真的会进入到Go 1.2x版本,而不是Go 2.x。记得Russ Cox曾说过可能永远也不会有Go2了,毕竟Go泛型语法落地这么大的语法改动也没有让Go1兼容性承诺失效。

目前Go 1.20版本正在如火如荼的开发中,很多gopher都好奇Go 1.20版本会带来哪些新特性?在这篇文章中,我就带大家一起去Go 1.20 milestone的issues列表中翻翻,提前看看究竟会有哪些新特性加入Go。

1. 语法变化

Go在其1.18版本迎来了自开源以来最大规模的语法变化,然后呢?就没有然后了。Go在语法演进上再次陷入沉寂,没错,这就是Go长期以来坚持的风格。

如果Go 1.20版本真有语法层面的变化,那估计就是这个issue了:“spec: allow conversion from slice to array”,即允许切片类型到数组类型的类型转换

在Go 1.20版本之前,我们以Go 1.19版本为例写下下面代码:

package main

import "fmt"

func main() {
    var sl = []int{1, 2, 3, 4, 5, 6, 7}
    var arr = [7]int(sl) // 编译器报错:cannot convert sl (variable of type []int) to type [7]int
    fmt.Println(sl)
    fmt.Println(arr)
}

这段代码中,我们进行了一个[]int到[7]int的类型转换,但在Go 1.19版本编译器针对这个转换会报错!即不支持将切片类型显式转换数组类型。

在Go 1.20版本之前如果要实现切片到数组的转换,是有trick的,看下面代码:

func main() {
    var sl = []int{1, 2, 3, 4, 5, 6, 7}
    var parr = (*[7]int)(sl)
    var arr = *(*[7]int)(sl)
    fmt.Println(sl)  // [1 2 3 4 5 6 7]
    fmt.Println(arr) // [1 2 3 4 5 6 7]
    sl[0] = 11
    fmt.Println(sl)    // [11 2 3 4 5 6 7]
    fmt.Println(arr)   // [1 2 3 4 5 6 7]
    fmt.Println(*parr) // [11 2 3 4 5 6 7]
}

该trick的理论基础是Go允许获取切片的底层数组地址。在上面的例子中parr就是指向切片sl底层数组的指针,通过sl或parr对底层数组元素的修改都能在对方身上体现出来。但是arr则是底层数组的一个副本,后续通过sl对切片的修改或通过parr对底层数组的修改都不会影响arr,反之亦然。

不过这种trick语法还不是那么直观!于是上面那个“允许将切片直接转换为数组”的issue便提了出来。我们在go playground上选择“go dev branch”便可以使用最新go tip的代码,我们尝试一下最新语法:

func main() {
    var sl = []int{1, 2, 3, 4, 5, 6, 7}
    var arr = [7]int(sl)
    var parr = (*[7]int)(sl)
    fmt.Println(sl)   // [1 2 3 4 5 6 7]
    fmt.Println(arr)  // [1 2 3 4 5 6 7]
    sl[0] = 11
    fmt.Println(arr)  // [1 2 3 4 5 6 7]
    fmt.Println(parr) // &[11 2 3 4 5 6 7]
}

我们看到直接将sl转换为数组arr不再报错,但其语义与前面的“var arr = ([7]int)(sl)”语义是相同的,即返回一个切片底层数组的副本,arr不会受到后续切片元素变化的影响。

不过这里也有个约束,那就是转换后的数组长度要小于等于切片长度,否则会panic:

var sl = []int{1, 2, 3, 4, 5, 6, 7}
var arr = [8]int(sl) // panic: runtime error: cannot convert slice with length 7 to array or pointer to array with length 8

在写本文时,该issue尚未close,不过进入最终Go 1.20版本应该不是大问题。

2. 编译器/链接器和其他工具链

1) profile-guided optimization

Go编译器团队一直致力于对Go编译器/链接器的优化,这次在Go 1.20版本中,该团队很大可能会给我们带来“profile-guided optimization”

什么是“profile-guided optimization”呢?原先Go编译器实施的优化手段,比如内联,都是基于固定规则决策的,所有信息都来自编译的Go源码。而这次的“profile-guided optimization”顾名思义,需要源码之外的信息做“制导”来决定实施哪些优化,这个源码之外的信息就是profile信息,即来自pprof工具在程序运行时采集的数据,如下图(图来自profile-guided optimization设计文档)所示:

因此pgo优化实际上是需要程序员参与的,程序员拿着程序到生产环境跑,程序生成的profile性能采集数据会被保存下来,然后这些profile采集数据会提供给Go编译器,以在下次构建同一个程序时辅助优化决策。由于这些profile是来自生产环境或模拟生产环境的数据,使得这种优化更有针对性。并且,Google数据中心其他语言(C/C++)实施PGO优化的效果显示,优化后的性能保守估计提升幅度在5%~15%。

和其他新引入的特性一样,Go 1.20将包含该特性,但默认并不开启,我们可以手动开启进行体验,未来版本,pgo特性才会默认为auto开启。

2) 大幅减小Go发行版包的Size

随着Go语言的演进,Go发行版的Size也在不断增加,从最初的几十M到如今的上百M。本地电脑里多安装几个Go版本,(解压后)几个G就没有了,此外Size大也让下载时间变得更长,尤其是一些网络环境不好的地区。

为什么Go发行版Size越来越大呢?这很大程度是因为Go发行版中包含了GOROOT下所有软件包的预编译.a文件,以go 1.19的macos版本为例,在\$GOROOT/pkg下,我们看到下面这些.a文件,用du查看一下占用的磁盘空间,达111M:

$ls
archive/    database/   fmt.a       index/      mime/       plugin.a    strconv.a   time/
bufio.a     debug/      go/     internal/   mime.a      reflect/    strings.a   time.a
bytes.a     embed.a     hash/       io/     net/        reflect.a   sync/       unicode/
compress/   encoding/   hash.a      io.a        net.a       regexp/     sync.a      unicode.a
container/  encoding.a  html/       log/        os/     regexp.a    syscall.a   vendor/
context.a   errors.a    html.a      log.a       os.a        runtime/    testing/
crypto/     expvar.a    image/      math/       path/       runtime.a   testing.a
crypto.a    flag.a      image.a     math.a      path.a      sort.a      text/

$du -sh
111M    .

而整个pkg目录的size为341M,占Go 1.19版本总大小495M的近70%。

于是在Go社区提议下,Go团队决定从Go 1.20开始发行版不再为GOROOT中的大多数软件包提供预编译的.a文件,新版本将只包括GOROOT中使用cgo的几个软件包的.a文件。

因此Go 1.20版本中,GOROOT下的源码将像其他用户包那样在构建后被缓存到本机cache中。此外,go install也不会为GOROOT软件包安装.a文件,除非是那些使用cgo的软件包。这样Go发行版的size将最多减少三分之二。

取而代之的是,这些包将在需要时被构建并缓存在构建缓存中,就像已经为GOROOT之外的非主包所做的那样。此外,go install也不会为GOROOT软件包安装.a文件,除非是那些使用cgo的软件包。这些改变是为了减少Go发行版的大小,在某些情况下可以减少三分之二。

3) 扩展代码覆盖率(coverage)报告到应用本身

想必大家都用过go test的输出过代码覆盖率,go test会在unit test代码中注入代码以统计unit test覆盖的被测试包路径,下面是代码注入的举例:

func ABC(x int) {
    if x < 0 {
        bar()
    }
}

注入代码后:

func ABC(x int) {GoCover_0_343662613637653164643337.Count[9] = 1;
  if x < 0 {GoCover_0_343662613637653164643337.Count[10] = 1;
    bar()
  }
}

像GoCover_xxx这样的代码会被放置到每条分支路径下。

不过go test -cover也有一个问题,那就是它只是适合针对包收集数据并提供报告,它无法针对应用整体给出代码覆盖度报告。

Go 1.20版本中有关的“extend code coverage testing to include applications”的proposal就是来扩展代码覆盖率的,可以支持对应用整体的覆盖率统计和报告。

该特性在Go 1.20版本中也将作为实验性特性,默认是off的。该方案通过go build -cover方式生成注入了覆盖率统计代码的应用程序,在应用执行过程中,报告会被生成到指定目录下,我们依然可以通过go tool cover来查看这个整体性报告。

此外,新proposal在实现原理上与go test -cover差不多,都是source-to-source的方案,这样后续也可以统一维护。当然Go编译器也会有一些改动。

4) 废弃-i flag

这个是一个早计划好的“废弃动作”。自从Go 1.10引入go build cache后,go build/install/test -i就不会再将编译好的包安装到\$GOPATH/pkg下面了。

3. Go标准库

1) 支持wrap multiple errors

Go 1.20增加了一种将多个error包装(wrap)为一个error的机制,方便从打包后的错误的Error方法中一次性得到包含一系列关于该错误的相关错误的信息。

这个机制增加了一个(匿名)接口和一个函数:

interface {
    Unwrap() []error
}

func Join(errs ...error) error

同时增强了像fmt.Errorf这样的函数的语义,当在Errorf中使用多个%w verb时,比如:

e := errors.Errorf("%w, %w, %w", e1, e2, e3)

Errorf将返回一个将e1, e2, e3打包完的且实现了上述带有Unwrap() []error方法的接口的错误类型实例。

Join函数的语义是将传入的所有err打包成一个错误类型实例,该实例同样实现了上述带有Unwrap() []error方法的接口,且该错误实例的类型的Error方法会返回换行符间隔的错误列表。

我们看一下下面这个例子:

package main

import (
    "errors"
    "fmt"
)

type MyError struct {
    s string
}

func (e *MyError) Error() string {
    return e.s
}

func main() {
    e1 := errors.New("error1")
    e2 := errors.New("error2")
    e3 := errors.New("error3")
    e4 := &MyError{
        s: "error4",
    }
    e := fmt.Errorf("%w, %w, %w, %w", e1, e2, e3, e4)

    fmt.Printf("e = %s\n", e.Error()) // error1 error2, error3, error4
    fmt.Println(errors.Is(e, e1)) // true

    var ne *MyError
    fmt.Println(errors.As(e, &ne)) // true
    fmt.Println(ne == e4) // true
}

我们首先在Go 1.19编译运行上面程序:

e = error1 %!w(*errors.errorString=&{error2}), %!w(*errors.errorString=&{error3}), %!w(*main.MyError=&{error4})
false
false
false

显然Go 1.19的fmt.Errorf函数尚不支持多%w verb。

而Go 1.20编译上面程序的运行结果为:

e = error1 error2, error3, error4
true
true
true

将fmt.Errorf一行换为:

e := errors.Join(e1, e2, e3, e4)

再运行一次的结果为:

e = error1
error2
error3
error4
true
true
true

即Join函数打包后的错误类型实例类型的Error方法会返回换行符间隔的错误列表。

2) 新增arena实验包

Go是带GC语言,虽然Go GC近几年持续改进,绝大多数场合都不是大问题了。但是在一些性能敏感的领域,GC过程占用的可观算力还是让应用吃不消。

降GC消耗,主要思路就是减少堆内存分配、减少反复的分配与释放。Go社区的某些项目为了减少内存GC压力,在mmaped内存上又建立一套GC无法感知到的简单内存管理机制并在适当场合应用。但这些自实现的、脱离GC的内存管理都有各自的问题。

Go 1.18版本发布前,arena这个proposal就被提上了日程,arena包又是google内部的一个实验包,据说效果还不错的(在改进grpc的protobuf反序列化实验上),可以节省15%的cpu和内存消耗。但proposal一出,便收到了来自各方的comment,该proposal在Go 1.18和Go 1.19一度处于hold状态,直到Go 1.20才纳入到试验特性,我们可以通过GOEXPERIMENT=arena开启该机制。

arena包主要思路其实是“整体分配,零碎使用,再整体释放”,以最大程度减少对GC的压力。关于arena包,等进一步完善后,后续可能会有专门文章分析。

3) time包变化

time包增加了三个时间layout格式常量,相信不用解释,大家也知道如何使用:

    DateTime   = "2006-01-02 15:04:05"
    DateOnly   = "2006-01-02"
    TimeOnly   = "15:04:05"

time包还为Time增加了Compare方法,适用于time之间的>=和<=比较:

// Compare returns -1 if t1 is before t2, 0 if t1 equals t2 or 1 if t1 is after t2.
func (t1 Time) Compare(t2 Time) int

此外,time包的RFC3339时间格式是使用最广泛的时间格式,其解析性能在Go 1.20中得到优化,提升了70%左右,格式化性能提升30%

4. 其他

5. 参考资料

  • Go 1.20 milestone – https://github.com/golang/go/milestone/250
  • Exploring Go’s Profile-Guided Optimizations – https://www.polarsignals.com/blog/posts/2022/09/exploring-go-profile-guided-optimizations/
  • What’s coming to go 1.20 – https://twitter.com/mvdan_/status/1588242469577117696

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://tonybai.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博(暂不可用):https://weibo.com/bigwhite20xx
  • 微博2:https://weibo.com/u/6484441286
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

通过实例理解Go静态单赋值(SSA)

本文永久链接 – https://tonybai.com/2022/10/21/understand-go-ssa-by-example

在上一篇文章《通过实例理解Go内联优化》中,我们探讨了Go编译器在编译中端进行的内联优化。内联优化基于IR中间表示进行,不过Go编译过程不止有一种IR表示,这点和龙书《编译原理(第二版)》的在第六章“中间代码生成”一开始处的讲解是一致的,即在将给定源语言的一个程序翻译成特定的目标机器代码的过程中,一个编译器可能构造出一系列中间表示(IR),如下图:

高层中间表示更接近于源语言,而低层的中间表示则更接近于目标机器。在Go编译过程中,如果说内联优化使用的IR是高层中间表示,那么低层中间表示非支持静态单赋值(SSA)的中间代码形式莫属。

在这一篇中,我们将沿着Go编译器的后端优化之路继续走下去,我们来认识一下静态单赋值(SSA)

1. 静态单赋值(SSA)的历史

静态单赋值(Static Single Assignment,SSA),也有称为Single Static Assignment的,是一种中间代码的表示形式(IR),或者说是某种中间代码所具备的属性,它是由IBM的三位研究员:Barry K. Rosen、Mark N. Wegman和F. Kenneth Zadeck于1988年提出的。

具有SSA属性的IR都具有这样的特征:

  • 每个变量在使用前都需要被定义
  • 每个变量被精确地赋值一次(使得一个变量的值与它在程序中的位置无关)

下面是一个简单的例子(伪代码):

y = 1
y = 2
x = y

转换为SSA形式为:

y1 = 1
y2 = 2
x1 = y2

我们看到由于SSA要求每个变量只能赋值一次,因此在转换为SSA后,变量y用y1和y2来表示,后面的序号越大,表明y的版本越新。从这一段三行的代码我们也可以看到,在SSA层面,y1 = 1这行代码就是一行死代码(dead code),即对结果不会产生影响的代码,可以在中间代码优化时被移除掉。

1991年,同样来自IBM研究院的Ron Cytron和Jeanne Ferrante以及前面的三位研究员又一起给出了构建SSA的快速算法,这进一步推动了SSA在编译器领域的快速应用。

SSA的提出以及后续的流行正是因为SSA形式中间代码具有很好的优化空间,基于SSA可以开启一些新的编译器优化算法或增强现有的优化算法,因此自SSA提出后,各种主流语言编译器后端均逐渐开始支持SSA,包括GCC、llvm、hotspot JVM、v8 js等。SSA也成为了一种IR表示的事实标准。

那么Go语言是何时开始与SSA结缘的呢?我们继续往下看。

2. Go与SSA

相对于GCC、LLVM,Go编译器还相对年轻,因此SSA加入Go的时间还不算太长。

Go SSA的工作始于Go 1.5版本实现自举之前,2015年2月初,负责编译器后端的Go团队核心成员的Keith Randall博士就在golang-dev google group上提出要让Go支持SSA的工作计划:

“我想从目前基于语法树的IR转换到更现代的基于SSA的IR。有了SSA IR,我们可以实现很多在当前编译器中难以做到的优化” - Keith Randall

同期,Keith Randall博士还编写了“New SSA Backend for the Go Compiler”文档,具体介绍了Go要支持SSA的理由以及分几步走的实现方案。

在为什么选择自己实现SSA IR,而不是转换为当时现成的诸如gcc, llvm等支持的IR形式并利用成熟后端进行中间代码优化这个问题上,Keith Randall博士给出了三点理由:

  • 从Go编译速度考虑:Go团队和社区对编译速度有着格外的青睐,Randall的目标是设计一个线性时间的SSA算法,实现快速SSA优化,但gcc, llmv等IR显然没有在速度方面给予额外的考虑;

  • 从功能完整性上考虑:Go运行时需要精确的栈帧地图(the map of stack frame),用来支持GC和栈拷贝,这些在gcc, llvm中都不会提供;

  • 从Go核心开发者的编译器使用体验方面考虑:如果使用llvm、gcc等ir,显然Go核心开发人员在编译go的时候还需要依赖llvm或gcc,这种额外的依赖对他们来说很难说是体验友好的。

2016年3月1日,在Go 1.7版本的master分支提交权限刚刚打开之后,Keith Randall就将支持ssa的dev.ssa分支合并到Go项目主线中了。

Go 1.7版本中,Go正式支持SSA,不过由于时间有限,Go 1.7 SSA仅支持针对amd64架构的优化。即便如此,Go支持SSA后,Keith Randall的benchmark显示性能提升12%,代码段缩小13%:


图:go 1.7 benchmark(图来自keith博士的slide)

Go 1.7正式发布时,其发布文档称Go程序的性能因对SSA的支持而提升5%-35%以上。由此看,Go SSA的实现达到了Keith Randall博士的预期目标,也为Go编译器后续的持续优化奠定了基础。

在2017年2月发布的Go 1.8版本中,Go SSA的支持范围扩展到其他所有Go支持的cpu架构,包括arm和arm64、mips和mips64、ppc64等。

了解了Go SSA的演进后,我们再来简单说说Go编译器中SSA的实现。

3. 转换为SSA

我们先来看看转换为SSA以及SSA优化在编译过程中所处的位置:


图:Go SSA所处的环节(图来自keith博士的slide)

上图是keith博士在2017年gophercon大会上slide中的一幅图,这幅图中明确了生成SSA形式以及SSA优化所处的环节。不过较新的Go版本中,convert to SSA之前也有一种不同于最初的抽象语法树的ir(比如:Go 1.19),SSA是由此种ir转换过来的。

从代码上来看,ir到SSA形式的转换发生在下面环节(Go 1.19版本代码,其他版本可能代码位置和内容均由不同):

// $GOROOT/src/cmd/compile/internal/gc/main.go
func Main(archInit func(*ssagen.ArchInfo)) {
    base.Timer.Start("fe", "init")

    defer handlePanic()

    archInit(&ssagen.Arch)
    ... ...

    // Compile top level functions.
    // Don't use range--walk can add functions to Target.Decls.
    base.Timer.Start("be", "compilefuncs")
    fcount := int64(0)
    for i := 0; i < len(typecheck.Target.Decls); i++ {
        if fn, ok := typecheck.Target.Decls[i].(*ir.Func); ok {
            // Don't try compiling dead hidden closure.
            if fn.IsDeadcodeClosure() {
                continue
            }
            enqueueFunc(fn)
            fcount++
        }
    }
    base.Timer.AddEvent(fcount, "funcs")

    compileFunctions()

    ... ...
}

在Main中,我们看到代码会将所有Target.Decls(函数)通过enqueueFunc入队列(compilequeue),然后调用compileFunctions来实现各个函数从AST ir到SSA形式的转换,compileFunctions在compile.go中,其实现如下:

// $GOROOT/src/cmd/compile/internal/gc/compile.go
func compileFunctions() {
    if len(compilequeue) == 0 {
        return
    }

    ... ...
    // By default, we perform work right away on the current goroutine
    // as the solo worker.
    queue := func(work func(int)) {
        work(0)
    }
    ... ...

    var compile func([]*ir.Func)
    compile = func(fns []*ir.Func) {
        wg.Add(len(fns))
        for _, fn := range fns {
            fn := fn
            queue(func(worker int) {
                ssagen.Compile(fn, worker)
                compile(fn.Closures)
                wg.Done()
            })
        }
    }
    types.CalcSizeDisabled = true // not safe to calculate sizes concurrently
    base.Ctxt.InParallel = true

    compile(compilequeue)
    ... ...
}

在compileFunctions中我们看到,编译器从compilequeue取出AST IR形式的函数,并调用ssagen.Compile将其编译为SSA形式。下面是ssagen.Compile的代码:

// $GOROOT/src/cmd/compile/internal/ssagen/pgen.go

// Compile builds an SSA backend function,
// uses it to generate a plist,
// and flushes that plist to machine code.
// worker indicates which of the backend workers is doing the processing.
func Compile(fn *ir.Func, worker int) {
    f := buildssa(fn, worker)
    // Note: check arg size to fix issue 25507.
    if f.Frontend().(*ssafn).stksize >= maxStackSize || f.OwnAux.ArgWidth() >= maxStackSize {
        largeStackFramesMu.Lock()
        largeStackFrames = append(largeStackFrames, largeStack{locals: f.Frontend().(*ssafn).stksize, args: f.OwnAux.ArgWidth(), pos: fn.Pos()})
        largeStackFramesMu.Unlock()
        return
    }
    pp := objw.NewProgs(fn, worker)
    defer pp.Free()
    genssa(f, pp)
    // Check frame size again.
    // The check above included only the space needed for local variables.
    // After genssa, the space needed includes local variables and the callee arg region.
    // We must do this check prior to calling pp.Flush.
    // If there are any oversized stack frames,
    // the assembler may emit inscrutable complaints about invalid instructions.
    if pp.Text.To.Offset >= maxStackSize {
        largeStackFramesMu.Lock()
        locals := f.Frontend().(*ssafn).stksize
        largeStackFrames = append(largeStackFrames, largeStack{locals: locals, args: f.OwnAux.ArgWidth(), callee: pp.Text.To.Offset - locals, pos: fn.Pos()})
        largeStackFramesMu.Unlock()
        return
    }

    pp.Flush() // assemble, fill in boilerplate, etc.
    // fieldtrack must be called after pp.Flush. See issue 20014.
    fieldtrack(pp.Text.From.Sym, fn.FieldTrack)
}

这里贴出了Compile的完整实现,Compile函数中真正负责生成具有SSA属性的中间代码的是buildssa函数,看了一下buildssa函数有近300行代码,有点复杂,这里挑挑拣拣,把主要的调用摘录出来:

// $GOROOT/src/cmd/compile/internal/ssagen/ssa.go

// buildssa builds an SSA function for fn.
// worker indicates which of the backend workers is doing the processing.
func buildssa(fn *ir.Func, worker int) *ssa.Func {
    name := ir.FuncName(fn)
    ... ...

    // Convert the AST-based IR to the SSA-based IR
    s.stmtList(fn.Enter)
    s.zeroResults()
    s.paramsToHeap()
    s.stmtList(fn.Body)

    // fallthrough to exit
    if s.curBlock != nil {
        s.pushLine(fn.Endlineno)
        s.exit()
        s.popLine()
    }
    ... ...

    // Main call to ssa package to compile function
    ssa.Compile(s.f)
    ... ...
}

buildssa中的ssa.Compile咱们后续再看,那个涉及到SSA的多轮(pass)优化,我们看一下从基于AST形式的IR到基于SSA形式的IR的转换,无论是fn.Enter还是fn.Body,本质都是一组ir Node,stmtList将这些node逐个转换为SSA形式。Go提供了可视化的ssa dump工具,我们可以更直观的来看一下。

Go语言隶属于命令式编程语言(imperative programming language),这类编程范式有三大典型控制结构:顺序结构、选择结构和循环结构,我们先来看看一个最简单的顺序结构是如何翻译为SSA的:

// github.com/bigwhite/experiments/tree/master/ssa-examples/sequential.go

package main

func sum(a, b, c int) int {
    d := a + b
    e := d + c
    return e
}

func main() {
    println(sum(1, 2, 3))
}

我们通过下面命令来生成函数sum的SSA转换过程:

$GOSSAFUNC=sum go build sequential.go
dumped SSA to ./ssa.html
$mv ssa.html ssa-sequential.html
$open ./ssa-sequential.html

上面的open命令会在本地打开浏览器并显示ssa-sequential.html页面:

上图中,最左侧是源码(源码显示两次,感觉是bug),中间的是AST形式的IR,最右侧的框框中就是Go编译器生成的第一版SSA,为了更好说明,我们将其贴到下面来:

// github.com/bigwhite/experiments/tree/master/ssa-examples/ssa-sequential.html

b1:-
  v1 (?) = InitMem <mem>
  v2 (?) = SP <uintptr>
  v3 (?) = SB <uintptr>
  v4 (?) = LocalAddr <*int> {a} v2 v1
  v5 (?) = LocalAddr <*int> {b} v2 v1
  v6 (?) = LocalAddr <*int> {c} v2 v1
  v7 (?) = LocalAddr <*int> {~r0} v2 v1
  v8 (3) = Arg <int> {a} (a[int])
  v9 (3) = Arg <int> {b} (b[int])
  v10 (3) = Arg <int> {c} (c[int])
  v11 (?) = Const64 <int> [0]
  v12 (+4) = Add64 <int> v8 v9 (d[int])
  v13 (+5) = Add64 <int> v12 v10 (e[int])
  v14 (+6) = MakeResult <int,mem> v13 v1
Ret v14 (+6)

name a[int]: v8
name b[int]: v9
name c[int]: v10
name d[int]: v12
name e[int]: v13

从结构上来看,SSA分为两部分,一部分是由b1、Ret组成的blocks,另一部分则是命名变量与SSA value的对应关系。

在SSA中,一个block代表了一个函数控制流图(control flow graph)中的基本代码块(basic block),从代码注释中可以看到SSA有四种block类型:Plain,If、Exit和Defer:

// $GOROOT/src/cmd/compile/internal/ssa/block.go

// BlockKind is the kind of SSA block.
//
//    kind          controls        successors
//  ------------------------------------------
//    Exit      [return mem]                []
//   Plain                []            [next]
//      If   [boolean Value]      [then, else]
//   Defer             [mem]  [nopanic, panic]  (control opcode should be OpStaticCall to runtime.deferproc)
type BlockKind int16

但实际的BlockKind已经与注释不一致了,opGen.go是一个自动生成的文件,其中的BlockKind类型的常量值有数十个,即便滤掉CPU架构相关的常量,剩下的还有8个(从BlockPlain到BlockFirst):

// $GOROOT/src/cmd/compile/internal/ssa/opGen.go

const (
    BlockInvalid BlockKind = iota
    ... ...

    BlockPlain
    BlockIf
    BlockDefer
    BlockRet
    BlockRetJmp
    BlockExit
    BlockJumpTable
    BlockFirst
)

上面的sum函数的SSA代码例子中,b1应该就是Plain类型的,Ret显然是BlockRet类型。

Plain类型的Block中是一组values,value是SSA的基本构成要素。根据SSA的定义,一个value只能被精确地定义一次,但是它可以被使用任意多次。如示例,一个value主要包括一个唯一的标识符,一个操作符,一个类型和一些参数,下面的Value类型的LongString和LongHTML方法返回的字符串更能说明Value的格式。尤其是LongHTML方法就是输出ssa html中内容的方法:

// $GOROOT/src/cmd/compile/internal/ssa/value.go

// long form print.  v# = opcode <type> [aux] args [: reg] (names)
func (v *Value) LongString() string {
    ... ...
}

// $GOROOT/src/cmd/compile/internal/ssa/html.go
func (v *Value) LongHTML() string {
    // TODO: Any intra-value formatting?
    // I'm wary of adding too much visual noise,
    // but a little bit might be valuable.
    // We already have visual noise in the form of punctuation
    // maybe we could replace some of that with formatting.
    s := fmt.Sprintf("<span class=\"%s ssa-long-value\">", v.String())

    linenumber := "<span class=\"no-line-number\">(?)</span>"
    if v.Pos.IsKnown() {
        linenumber = fmt.Sprintf("<span class=\"l%v line-number\">(%s)</span>", v.Pos.LineNumber(), v.Pos.LineNumberHTML())
    }

    s += fmt.Sprintf("%s %s = %s", v.HTML(), linenumber, v.Op.String())

    s += " &lt;" + html.EscapeString(v.Type.String()) + "&gt;"
    s += html.EscapeString(v.auxString())
    for _, a := range v.Args {
        s += fmt.Sprintf(" %s", a.HTML())
    }
    r := v.Block.Func.RegAlloc
    if int(v.ID) < len(r) && r[v.ID] != nil {
        s += " : " + html.EscapeString(r[v.ID].String())
    }
    var names []string
    for name, values := range v.Block.Func.NamedValues {
        for _, value := range values {
            if value == v {
                names = append(names, name.String())
                break // drop duplicates.
            }
        }
    }
    if len(names) != 0 {
        s += " (" + strings.Join(names, ", ") + ")"
    }

    s += "</span>"
    return s
}

以例子中的v12这一个value为例:

  v12 (+4) = Add64 <int> v8 v9 (d[int])
  • v12是该value的唯一标识符,其中的12为ID,ID是从1开始的整数;
  • (+4)是对应的源码的行号;
  • Add64是操作符;
  • 是value的类型(v.Type());
  • v8, v9则是Add64操作符的参数;
  • (d[int])是v12对应的LocalSlot,LocalSlot代表栈帧上的一个位置(location),用来识别和存储输出参数、输出参数或其他变量node。

ssa dump输出的另一部分则是命名变量与SSA value的对应关系,其格式也是:name LocalSlot: value:

name a[int]: v8
name b[int]: v9
name c[int]: v10
name d[int]: v12
name e[int]: v13

输出上述第二部分的代码如下:

// $GOROOT/src/cmd/compile/internal/ssa/print.go
func (p stringFuncPrinter) named(n LocalSlot, vals []*Value) {
    fmt.Fprintf(p.w, "name %s: %v\n", n, vals)
}

顺序结构的代码执行流是从上到下的,每个block后面仅有一个后继block,这样的SSA转换较为好理解。

下面我们再来看看一个选择控制结构 – if控制语句的ssa,下面是我们的示例Go源码:

// github.com/bigwhite/experiments/tree/master/ssa-examples/selection_if.go

package main

func foo(b bool) int {
    if b {
        return 2
    }
    return 3
}

func main() {
    println(foo(true))
}

我们通过下面命令输出函数foo的SSA中间代码:

$GOSSAFUNC=foo go build selection_if.go
dumped SSA to ./ssa.html
$mv ssa.html ssa-selection-if.html
$open ./ssa-selection-if.html

open命令启动浏览器显示foo函数的SSA形式:

有了上面关Go SSA格式的基础,这段SSA代码分析起来就容易一些了。

这段SSA中有多个block,包括plain block、if block、ret block等。我们重点关注SSA对if语句的处理。

经典SSA转换理论中,SSA将if分支转换为带有Φ函数的SSA代码(如下图):


图:if语句的SSA转换(图来自keith博士的slide)

Φ函数(希腊字母fài)是代码中的一个merge point,它可以将其前置的n个block的执行路径汇聚在一起。不过它仅用于代码分析使用,最终生成的代码中并不会有Φ函数的存在。关于在何处插入Φ函数等算法太理论了,这里就不展开了。

我们看看现实中go针对if语句的处理:

b1:
  v1 (?) = InitMem <mem>
  v2 (?) = SP <uintptr>
  v3 (?) = SB <uintptr>
  v4 (?) = LocalAddr <*bool> {b} v2 v1
  v5 (?) = LocalAddr <*int> {~r0} v2 v1
  v6 (3) = Arg <bool> {b} (b[bool])
  v7 (?) = Const64 <int> [0]
  v8 (?) = Const64 <int> [2]
  v11 (?) = Const64 <int> [3]
If v6 → b3 b2 (4)

b2: ← b1
  v13 (7) = Copy <mem> v1
  v12 (7) = MakeResult <int,mem> v11 v13
Ret v12 (+7)

b3: ← b1
  v10 (5) = Copy <mem> v1
  v9 (5) = MakeResult <int,mem> v8 v10
Ret v9 (+5)

name b[bool]: v6

这里关键是if block,if判断v6即变量b的值,如果为true,代码执行就流向block b3,否则流向block b2。

下面的b2、b3 block也都包含了前置block的属性,以b2为例,对于来自b1 block的流,执行对应block的代码。基于switch的选择语句更为复杂,有兴趣的朋友可以自己看一下ssa-selection-switch.html。

我们最后看一下循环结构,下面是Go代码:

// github.com/bigwhite/experiments/tree/master/ssa-examples/for_loop.go
package main

func sumN(n int) int {
    var r int
    for i := 1; i <= n; i++ {
        r = r + i
    }
    return r
}

func main() {
    println(sumN(10))
}

其生成的SSA如下图:

我们看到循环结构的ssa block更多,流向更为复杂,如果将其转换为一张图的话,那就应该是这样的:

我们看到:无论是选择结构还是循环结构,SSA实质上构建了一个函数的控制流图(control flow graph),图中每个节点就是一个block,函数的执行控制流在各个block间转移。而后续基于SSA的优化就是基于block中value的仅赋值一次的特性以及block的控制流图进行的

接下来,我们简单看看目前Go基于SSA IR都做了哪些优化。

4. 基于SSA的多轮(pass)优化

buildssa函数中ssa.Compile调用执行了基于SSA IR的多轮(passes)优化:

// $GOROOT/src/cmd/compile/internal/ssa/compile.go

func Compile(f *Func) {
    ... ...
    for _, p := range passes {
        ... ...
        tStart := time.Now()
        p.fn(f)
        tEnd := time.Now()
        ... ...
    }
}

我们看到,针对某个函数,Compile函数对其安装预置的passes进行多轮优化,都有哪些pass呢?我们来看看:

// $GOROOT/src/cmd/compile/internal/ssa/compile.go

// list of passes for the compiler
var passes = [...]pass{
    {name: "number lines", fn-3693: numberLines, required: true},
    {name: "early phielim", fn-3693: phielim},
    {name: "early copyelim", fn-3693: copyelim},
    {name: "early deadcode", fn-3693: deadcode}, // remove generated dead code to avoid doing pointless work during opt
    {name: "short circuit", fn-3693: shortcircuit},
    {name: "decompose user", fn-3693: decomposeUser, required: true},
    {name: "pre-opt deadcode", fn-3693: deadcode},
    ... ...
    {name: "regalloc", fn-3693: regalloc, required: true},   // allocate int & float registers + stack slots
    {name: "loop rotate", fn-3693: loopRotate},
    {name: "stackframe", fn-3693: stackframe, required: true},
    {name: "trim", fn-3693: trim}, // remove empty blocks
}

粗略数了一下,这里约有50个pass(其中包含多轮的deadcode清理),每个pass执行的代码都位于$GOROOT/src/cmd/compile/internal/ssa目录下,我们也可以通过dump出的html查看每一pass后得到的SSA结果,以ssa-sequential.html为例,其多轮优化的示意图如下:

点击浏览器页面上的黑体字优化标题(比如:lowered deadcode for cse),这一步产生的SSA代码都会显示出来,最后一个框框中是基于SSA生成目标架构的汇编代码。

每一个pass都有其独特性,比如cse,代表Common Subexpression Elimination(共同子表达式删除) ,下面是一个cse优化的例子:

y = x + 5
...
z = x + 5

cse优化后(前提中间过程中x值没变过):

y = x + 5
...
z = y

在这个示例中,经过一轮cse,Go便可以节省下一次没必要的加法运算(z = x + 5)。别看一次加法运算不起眼,积累多了也是不小的性能提升,

如果你对某一pass的优化动作感兴趣,可以对照$GOROOT/src/cmd/compile/internal/ssa目录下的代码与浏览器中生成的SSA来对其进行深入研究。

5. 小结

编译器后端的逻辑总是很难理解的,本文对Go编译器与SSA的渊源、Go编译器中驱动SSA转换和优化的环节以及Go生成的SSA的形式与过程做了介绍,算是对SSA入了个门。但要想真正搞懂SSA转换以及基于SSA的优化步骤的细节,认真阅读SSA相关的paper和资料(见参考资料)以及相关code是不可或缺的。

本文涉及的代码在这里可以下载。

6. 参考资料

  • 《编译原理(第二版)》- https://book.douban.com/subject/3296317/
  • SSA: Static Single-Assignment Form – https://www.slideserve.com/heidi-farmer/ssa-static-single-assignment-form
  • 《Static Single Assignment Book》 – https://pfalcon.github.io/ssabook/latest/book-full.pdf
  • Static single-assignment form – https://en.wikipedia.org/wiki/Static_single_assignment_form
  • GopherCon 2017: Keith Randall – Generating Better Machine Code with SSA – https://about.sourcegraph.com/blog/go/generating-better-machine-code-with-ssa
  • Generating Better Machine Code with SSA(slide) – https://raw.githubusercontent.com/gophercon/2017-talks/master/KeithRandall-GeneratingBetterMachineCodeWithSSA/GeneratingBetterMachineCodeWithSSA.pdf
  • New SSA Backend for the Go Compiler – https://docs.google.com/document/d/1szwabPJJc4J-igUZU4ZKprOrNRNJug2JPD8OYi3i1K0/edit

“Gopher部落”知识星球旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!2022年,Gopher部落全面改版,将持续分享Go语言与Go应用领域的知识、技巧与实践,并增加诸多互动形式。欢迎大家加入!

img{512x368}
img{512x368}

img{512x368}
img{512x368}

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 博客:tonybai.com
  • github: https://github.com/bigwhite

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats