Go语言内存管理篇——Go编译器的分析与优化 | 青训营笔记

154 阅读6分钟

这是我参与「第五届青训营」伴学笔记创作活动的第 9 天

前言

本文主要介绍:

  • 编译器和静态分析
  • Go编译器优化

PS:若文章有什么问题,可以在文末进行留言或者私信。

1编译器和静态分析

1.1 编译器

  • 编译器——重要的系统软件

    • 编译器的作用
      1. 识别符合语法和非法的程序
      2. 生成正确且高效的代码
  • 前端 front end——分析部分

    • 词法分析:生成词素(lexeme)
    • 语法分析:生成语法树
    • 语义分析:收集类型信息,进行语义检查
    • 中间代码生成:生成intermediate representation(IR
  • 后端 back end——综合部分:

    • 代码优化:生成优化后的IR,其中,这个优化与机器无关
    • 代码生成:生成目标代码
  • 我们主要针对于编译器后端的优化,关注点在编译器的综合部分

  • 结构图: image.png

1.2 静态分析

控制流和数据流

静态分析 :不执行程序代码,推导程序的行为,分析程序的性质。

例子:

//随便一个c的例子
int a=30
int b=9-(a/5)
int c
c=b*4
if(c>10){
   c=c-10
}
return c*(60/a)

一般有两种分析:

  • 控制流(Control flow):程序执行的流程
    • 控制流图示:
    • image.png
  • 数据流(Data flow):数据在控制流上的传递(基于控制流)
    • 数据流图示:
    • image.png 通过分析控制流和数据流,我们可以知道更多关于程序的性质(properties) ,可以根据这些性质优化代码。

过程内分析和过程间分析

  • 过程内分析(Intra-procedural analysis)
    • 关键点:仅在函数内部进行分析
  • 过程间分析(Inter-procedural analysis):
    • 关键点:考虑过程调用时参数传递和返回值的数据流和控制流

我们重点关注过程间分析,因为它存在着一个比较复杂的问题:

  • 举个例子:
type I interface{
    bin()
}
type a struct{}
type b struct{}

func (A *a) bin(){
    ...
}
func (B *b) bin(){
    ...
}

func using(){
    i=&a{}
    ...
    i.bin() //关注这个调用
}
  • 对于编译器来说,它需要通过数据流分析得知i的具体类型,才能知道i.bin()调用的是哪个bin()
  • 根据i的具体类型,产生了新的控制流A.bin(),分析继续
  • 过程间分析需要同时分析控制流和数据流——由于联合求解的缘故,因此处理起来比较复杂

2 Go 编译器优化

2.1 介绍

  • 做编译器优化的原因
    • 用户无感知,重新编译即可获得性能收益
    • 通用性优化
  • 现状
    • 采用的优化少
    • 编译时间较短,没有进行较复杂的代码分析和优化
    • 如果要进行优化,必然会影响到编译时间

编译优化的思路:

  • 场景:面向后端长期执行任务
  • Tradeoff:用编译时间换取更高效的机器码

Beast mode:

  • 函数内联(重点关注)
  • 逃逸分析(重点关注)
  • 默认栈大小调整
  • 边界检查消除
  • 循环展开,等等

2.2 函数内联 inlining

函数内联的定义:将被调用函数的函数体(callee)的副本替换到调用位置(caller)上,同时重写代码以反映参数的绑定

优点:

  • 清除函数调用开销,例如传递参数、保存寄存器等
  • 将过程间分析转化为过程内分析,帮助其他优化,例如逃逸分析

仅仅是从理论层面来说不直观

可以使用micro-benchmark验证“函数内联到底能多大程度影响性能”

// micro-benchmark的性能测试示例
func BenchmarkInline(b *testing.B) {
    x := genInteger()
    y := genInteger()
    for i := 0; i < n.N; i++ {
        addInline(x, y)
    }
}

func addInline(a, b int) int {
    return a + b
}

func BenchmarkInlineDisabled(b *testing.B) {
	x := genInteger()
    y := genInteger()
    for i := 0; i < n.N; i++ {
        addNoInline(x, y)
    }
}

// go:noinline
func addNoInline(a, b int) int {
    return a + b
}

使用上述代码进行micro-benchmark快速验证和对比性能优化后,结果如下图所示, image.png

虽然说函数内联有着相当不错的效果,但它也是存在着缺点的。

缺点:

  • 函数体变大,instruction cache,不友好(占用变大)
  • 编译生成的Go镜像变大,就是用更大的空间(可能存在重复)换取时间的提升

但是,函数内联在大多数情况下是正向优化

一般来说,优化时会采取一定的内联策略,例如,考虑调用和被调用函数的规模

还有一种优秀的内联策略:Beast Mode方案——一种时间和空间的折中方案

  • Go函数内联受到的限制较多
    • 语言特性,例如interface,defer等,限制了函数内联
    • 内联策略非常保守
  • Beast mode:调整函数内联的策略,使更多函数被内联
    • 降低函数调用的开销
    • 增加了其他优化机会:逃逸分析
  • 开销:
    • Go镜像增加约10%
    • 编译时间增加

2.3 逃逸分析

逃逸分析的定义:分析代码中指针的动态作用域:指针在何处可以被访问

大致思路

  • 观察对象的数据流从对象分配处出发,沿着控制流,

  • 若发现指针 p 在当前作用域 s:

    • 作为参数传递给其他函数
    • 传递给全局变量
    • 传递给其他的 goroutine(协程)
    • 传递给已逃逸的指针指向的对象
  • 则指针 p 指向的对象逃逸出 s,反之则没有逃逸出s

  • 对于之前提到的Beast mode

    • Beast mode使得函数内联拓展了函数边界,更多对象不逃逸
  • 优化: 未逃逸的对象可以在栈上分配

    • 对象在栈上分配和回收很快: 移动 sp
    • 减少在 heap 上的分配,降低 GC 负担

利用到Beast Mode方案,其性能收益主要在函数内联和逃逸分析的检测上,Beast Mode方案带来的优化能够得到令人满意的效果。

心得

通过课程的学习,我重温了编译器相关知识,了解了静态分析的本质,对控制流与数据流有了充分的认识,也理解了何为过程内分析和何为过程间分析,而对于我学习go语言来说,关键还是在于go编译器的优化。通过对前置知识的学习以及逐步学会分析go编译器的优化方向,能够很好的理解课程提出来的Beast mode方案,理解其优化的意义,获益匪浅!

引用

ppt:‌⁤⁢⁤‬⁣⁣⁡‌⁡⁡⁢⁢⁢‬⁢⁤⁤‬‍‬⁤‌‍⁣‬⁣⁡‬⁣⁢⁤⁣‍‌‌⁤⁤高性能 Go 语言发行版优化与落地实践 .pptx - 飞书云文档 (feishu.cn)