详解Go内联优化

为了保证程序的执行高效与安全,现代编译器并不会将程序员的代码直接翻译成相应地机器码,它需要做一系列的检查与优化。Go编译器默认做了很多相关工作,例如未使用的引用包检查、未使用的声明变量检查、有效的括号检查、逃逸分析、内联优化、删除无用代码等。本文重点讨论内联优化相关内容。

内联

在《详解逃逸分析》一文中,我们分析了栈分配内存会比堆分配高效地多,那么,我们就会希望对象能尽可能被分配在栈上。在Go中,一个goroutine会有一个单独的栈,栈又会包含多个栈帧,栈帧是函数调用时在栈上为函数所分配的区域。但其实,函数调用是存在一些固定开销的,例如维护帧指针寄存器BP、栈溢出检测等。因此,对于一些代码行比较少的函数,编译器倾向于将它们在编译期展开从而消除函数调用,这种行为就是内联。

性能对比

首先,看一下函数内联与非内联的性能差异。

//go:noinline
func maxNoinline(a, b int) int {
    if a < b {
        return b
    }
    return a
}

func maxInline(a, b int) int {
    if a < b {
        return b
    }
    return a
}

func BenchmarkNoInline(b *testing.B) {
    x, y := 1, 2
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        maxNoinline(x, y)
    }
}

func BenchmarkInline(b *testing.B) {
    x, y := 1, 2
    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        maxInline(x, y)
    }
}

在程序代码中,想要禁止编译器内联优化很简单,在函数定义前一行添加//go:noinline即可。以下是性能对比结果

BenchmarkNoInline-8     824031799                1.47 ns/op
BenchmarkInline-8       1000000000               0.255 ns/op

因为函数体内部的执行逻辑非常简单,此时内联与否的性能差异主要体现在函数调用的固定开销上。显而易见,该差异是非常大的。

内联场景

此时,爱思考的读者可能就会产生疑问:既然内联优化效果这么显著,是不是所有的函数调用都可以内联呢?答案是不可以。因为内联,其实就是将一个函数调用原地展开,替换成这个函数的实现。当该函数被多次调用,就会被多次展开,这会增加编译后二进制文件的大小。而非内联函数,只需要保存一份函数体的代码,然后进行调用。所以,在空间上,一般来说使用内联函数会导致生成的可执行文件变大(但需要考虑内联的代码量、调用次数、维护内联关系的开销)。

问题来了,编译器内联优化的选择策略是什么?

package main

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

func iter(num int) int {
    res := 1
    for i := 1; i <= num; i++ {
        res = add(res, i)
    }
    return res
}

func main() {
    n := 100
    _ = iter(n)
}

假设源码文件为main.go,可通过执行go build -gcflags="-m -m" main.go命令查看编译器的优化策略。

$ go build -gcflags="-m -m" main.go
# command-line-arguments
./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }
./main.go:7:6: cannot inline iter: unhandled op FOR
./main.go:10:12: inlining call to add func(int, int) int { return a + b }
./main.go:15:6: can inline main with cost 67 as: func() { n := 100; _ = iter(n) }

通过以上信息,可知编译器判断add函数与main函数都可以被内联优化,并将add函数内联。同时可以注意到的是,iter函数由于存在循环语句并不能被内联:cannot inline iter: unhandled op FOR。实际上,除了for循环,还有一些情况不会被内联,例如闭包,selectfordefergo关键字所开启的新goroutine等,详细可见src/cmd/compile/internal/gc/inl.go相关内容。

    case OCLOSURE,
        OCALLPART,
        ORANGE,
        OFOR,
        OFORUNTIL,
        OSELECT,
        OTYPESW,
        OGO,
        ODEFER,
        ODCLTYPE, // can't print yet
        OBREAK,
        ORETJMP:
        v.reason = "unhandled op " + n.Op.String()
        return true

在上文提到过,内联只针对小代码量的函数而言,那么到底是小于多少才算是小代码量呢?

此时,我将上面的add函数,更改为如下内容

func add(a, b int) int {
    a = a + 1
    return a + b
}

执行go build -gcflags="-m -m" main.go命令,得到信息

./main.go:3:6: can inline add with cost 9 as: func(int, int) int { a = a + 1; return a + b }

对比之前的信息

./main.go:3:6: can inline add with cost 4 as: func(int, int) int { return a + b }

可以发现,存在cost 4cost 9的区别。这里的数值代表的是抽象语法树AST的节点,a = a + 1包含的是5个节点。Go函数中超过80个节点的代码量就不再内联。例如,如果在add中写入16个a = a + 1,则不再内联。

./main.go:3:6: cannot inline add: function too complex: cost 84 exceeds budget 80
内联表

内联会将函数调用的过程抹掉,这会引入一个新的问题:代码的堆栈信息还能否保证。举个例子,如果程序发生panic,内联之后的程序,还能否准确的打印出堆栈信息?看以下例子。

package main

func sub(a, b int) {
    a = a - b
    panic("i am a panic information")
}

func max(a, b int) int {
    if a < b {
        sub(a, b)
    }
    return a
}

func main() {
    x, y := 1, 2
    _ = max(x, y)
}

在该代码样例中,max函数将被内联。执行程序,输出结果如下

panic: i am a panic information

goroutine 1 [running]:
main.sub(...)
        /Users/slp/go/src/workspace/example/main.go:5
main.max(...)
        /Users/slp/go/src/workspace/example/main.go:10
main.main()
        /Users/slp/go/src/workspace/example/main.go:17 +0x3a

我们可以发现,panic依然输出了正确的程序堆栈信息,包括源文件位置和行号信息。那,Go是如何做到的呢?这是由于Go内部会为每个存在内联优化的goroutine维持一个内联树(inlining tree),该树可通过 go build -gcflags="-d pctab=pctoinline" main.go 命令查看

funcpctab "".sub [valfunc=pctoinline]
...
wrote 3 bytes to 0xc000082668
 00 42 00
funcpctab "".max [valfunc=pctoinline]
...
wrote 7 bytes to 0xc000082f68
 00 3c 02 1d 01 09 00
-- inlining tree for "".max:
0 | -1 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=59
--
funcpctab "".main [valfunc=pctoinline]
...
wrote 11 bytes to 0xc0004807e8
 00 1d 02 01 01 07 04 16 03 0c 00
-- inlining tree for "".main:
0 | -1 | "".max (/Users/slp/go/src/workspace/example/main.go:17:9) pc=30
1 | 0 | "".sub (/Users/slp/go/src/workspace/example/main.go:10:6) pc=29
--
内联控制

Go程序编译时,默认将进行内联优化。我们可通过-gcflags="-l"选项全局禁用内联,与一个-l禁用内联相反,如果传递两个或两个以上的-l则会打开内联,并启用更激进的内联策略。如果不想全局范围内禁止优化,则可以在函数定义时添加 //go:noinline 编译指令来阻止编译器内联函数。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,377评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,390评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,967评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,344评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,441评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,492评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,497评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,274评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,732评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,008评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,184评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,837评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,520评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,156评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,407评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,056评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,074评论 2 352

推荐阅读更多精彩内容

  • 前言 C 语言的 #include 一上来不太好说明白 Go 语言里 //go: 是什么,我们先来看下非常简单,也...
    Chole121阅读 2,453评论 0 9
  • 本文为国外Gopher的常见错误总结,以下为出处: 原文:http://devs.cloudimmunity.co...
    GoFuncChan阅读 1,033评论 0 5
  • 内联,就是将一个函数调用原地展开,替换成这个函数的实现。尽管这样做会增加编译后二进制文件的大小,但是它可以提高程序...
    朴素的心态阅读 1,928评论 1 10
  • 目录 统一规范篇 命名篇 开发篇 优化篇 统一规范篇 本篇主要描述了公司内部同事都必须遵守的一些开发规矩,如统一开...
    零一间阅读 1,916评论 0 2
  • 零 前置知识 操作系统的每个进程都认为自己可以访问计算机的所有物理内存,但由于计算机必定运行着多个程序,每个进程都...
    voidFan阅读 1,168评论 0 1