一、概述
死代码其实就是无用或者不可达代码,在《编译器设计》标量优化的消除无用和不可达代码中做过总结,如果不明白与该优化相关的一些术语,建议阅读后再往下看。
死代码删除通过分析代码中的控制流和数据流关系,从而找到并删除不再被使用的代码。Golang编译器对与死代码删除功能的实现代码在src/cmd/compile/internal/ssa/deadcode.go文件中,主要分为以下步骤:
- 通过控制流分析,找到可达的基本块(ReachableBlocks 函数)。
- 从不可达的基本块中删除出边,以及从可达的基本块中删除无效的出边。
- 通过数据流分析,从可达的基本块中找到活跃的值(liveValues 函数),并移除不活跃的值。
- 移除不可达的基本块。
二、可达基本块
一个函数,从该函数的Entry基本块开始,对CFG中的所有Blocks进行控制流分析。如果某个Block能够找到至少一条从Entry到其的路径,则该Block就是可达的,反之不可达。
所以,如果我们要找一个函数中的所有不可达基本块,只需要根据Entry找到所有可达基本块,同时将其mark,剩下的没有mark的基本块就是不可达的。
ReachableBlocks 函数采用对CFG深度优先(DFS)遍历所有基本块,标记出所有可达的基本块。返回一个布尔数组reachable
,数组的索引是Block.ID
,若reachable[Block.ID] == true
表示可达,反之不可达。
用《编译器设计》中常用的控制流图,将其带入到ReachableBlocks
函数中,简单介绍一下DFS遍历标记基本块的过程。
步骤如下:
- 创建一个DFS要用的栈结构p,程序的入口块是
B
0
B_0
B0。首先将
B
0
B_0
B0 push到p中,在
reachable
中将 B 0 B_0 B0对应的位置mark为true,开始遍历p。 - pop p的栈顶元素,此时为
B
0
B_0
B0,遍历
B
0
B_0
B0的后继块。
B
0
B_0
B0只有
B
1
B_1
B1一个后继块,
reachable
中没有mark,将其mark并push到p。 - pop p的栈顶元素,此时为
B
1
B_1
B1,遍历
B
1
B_1
B1的后继块。
B
1
B_1
B1有两个后继块,
B
2
B_2
B2和
B
5
B_5
B5,
reachable
中均没有mark,分别mark并push到p。 - pop p的栈顶元素,此时为
B
5
B_5
B5,遍历
B
5
B_5
B5的后继块。
B
5
B_5
B5有两个后继块,
B
6
B_6
B6和
B
8
B_8
B8,
reachable
中均没有mark,分别mark并push到p。 - pop p的栈顶元素,此时为
B
8
B_8
B8,遍历
B
8
B_8
B8的后继块。
B
8
B_8
B8只有
B
7
B_7
B7一个后继块,
reachable
中没有mark,将其mark并push到p。 - pop p的栈顶元素,此时为
B
7
B_7
B7,遍历
B
7
B_7
B7的后继块。
B
7
B_7
B7只有
B
3
B_3
B3一个后继块,
reachable
中没有mark,将其mark并push到p。 - pop p的栈顶元素,此时为
B
3
B_3
B3,遍历
B
3
B_3
B3的后继块。
B
3
B_3
B3有两个后继块,
B
4
B_4
B4和
B
1
B_1
B1,
reachable
中 B 4 B_4 B4没有mark、 B 1 B_1 B1已经mark,只需将 B 4 B_4 B4 mark并push到p。 - pop p的栈顶元素,此时为 B 4 B_4 B4, B 4 B_4 B4没有后继块,不再做任何操作。
- pop p的栈顶元素,此时为
B
6
B_6
B6,遍历
B
6
B_6
B6的后继块。
B
6
B_6
B6只有
B
7
B_7
B7一个后继块,
reachable
中已将其mark,不再做任何操作。 - pop p的栈顶元素,此时为
B
2
B_2
B2,遍历
B
2
B_2
B2的后继块。
B
2
B_2
B2只有
B
3
B_3
B3一个后继块,
reachable
中已将其mark,不再做任何操作。 - 此时p中的元素已为空,DFS遍历标记基本块的过程结束。
三、活跃值
活跃值也就是活跃变量,关于活跃值的定义,在《编译器设计》的利用活跃信息查找未初始化变量中做过总结。
对于活跃值更通俗的理解,如果一个值在程序执行过程中的某个时刻仍然对后续的计算或者控制流产生影响,那么这个值就是活跃的。这里后续计算指的是……
在下面这个函数中,变量 x 和 y 的值都是活跃的,因为它们在程序的执行过程中被返回结果使用。
def compute_sum(a, b, c):
x = a + b
y = x * c
return y
但是将函数的返回结果修改如下,变量 x 和 y 的值都是不活跃的,因为它们在程序的执行过程中没有被返回结果使用。
def compute_sum(a, b, c):
x = a + b
y = x * c
return (a + b) * c
liveValues 函数会遍历所有可达基本块,将可达的基本块中的控制值、调用值、具有副作用的值、以及返回值为void的值标记为活跃,并将它们添加到工作队列中。
然后,从工作队列中不断取出值,将其所有的参数标记为活跃,并将参数加入到工作队列中。这样不断循环,直到工作队列为空,表示已经找到了所有活跃的值。对于Phi
操作的值,如果值所在基本块的某个前趋块是不可达的,则不可达块对应的参数不需要标记为活跃。
liveValues
函数会返回一个布尔数组live
,数组的索引是Value.ID
,若live[Value.ID] == true
表示Value是活跃值。
3.1 控制值
Golang SSA IR的控制值不会产生一个新的Value,其作用只是根据控制值决定控制流的走向。下列v11
、v18
都是控制值,v11
是if
语句的分支条件,v18
是函数的返回值。
b2: ← b1 b4-
v9 (5) = Phi <int> v8 v16 (i[int])
v22 (8) = Phi <int> v7 v14 (r[int])
v11 (+5) = Leq64 <bool> v9 v6
If v11 → b4 b5 (likely) (5)
b4: ← b2-
v14 (+6) = Add64 <int> v22 v9 (r[int])
v16 (+5) = Add64 <int> v9 v8 (i[int])
Plain → b2 (5)
b5: ← b2-
v20 (8) = VarDef <mem> {~r0} v1
v18 (+8) = MakeResult <int,mem> v22 v20
Ret v18 (+8)
由 SSA IR Blocks 的定义可以知道,目前控制块的控制值都只有一个。理论上一个控制块最多可以有两个控制值,只有一个的时候第二个为nil
,且第一个如果为nil
,第二个必须为nil
。
3.2 调用值
调用值也就是函数调用指令,函数调用指令通常会被标记为活跃值,因为它们会引入控制流和数据流的改变,从而影响程序的执行路径和结果。
opcodeTable[v.Op].call
为true
的值是调用值,因为在定义的时候,将call
字段设置为true
,如下:
{name: "ClosureCall", argLength: -1, aux: "CallOff", call: true}
{name: "StaticCall", argLength: -1, aux: "CallOff", call: true}
{name: "InterCall", argLength: -1, aux: "CallOff", call: true}
{name: "TailCall", argLength: -1, aux: "CallOff", call: true}
{name: "ClosureLECall", argLength: -1, aux: "CallOff", call: true}
{name: "StaticLECall", argLength: -1, aux: "CallOff", call: true}
{name: "InterLECall", argLength: -1, aux: "CallOff", call: true}
{name: "TailLECall", argLength: -1, aux: "CallOff", call: true}
3.3 具有副作用值
副作用的值指的是hasSideEffects == true
的值。Golang SSA IR所有Atomic
写内存操作Value,其hasSideEffects
字段均设为true
。
AtomicStore32
和AtomicStoreRel32
:两者的区别是前者只是一个原子存储操作,不执行锁获取操作,存储过程中可能发生内存的并发访问冲突。后者在前者的基础上,并执行了锁获取操作,确保了存储过程不会发生并发访问冲突。AtomicExchange32/64
:该操作将arg1的值写入arg0所指定的内存位置,并返回该内存位置原来的值。在写入新值之前,操作会确保原子性,以避免并发写入导致的竞态条件。AtomicAdd32/64
:*arg0 += arg1
,将arg1的值加到arg0所指定的内存位置,并返回执行加法操作后的新值。- ……
与以上相似的操作还有很多,不再列举。让我比较疑惑的是,为什么不认为Store、StoreWB等非原子存储操作Value具有副作用呢?
3.4 返回值为void的值
Golang SSA IR只有两个指令的返回值类型为void
,NilCheck
和 InlMark
。
NilCheck
:用于检查指针ptr是否为nil,如果ptr是nil,则会引发运行时Panics,返回原始的指针ptr,即使在ptr是nil的情况下也是如此。注意其与IsNonNil的区别。InlMark
:标记了一个内联函数体的开始,arg[0]=mem, returns void。
NilCheck操作会引发运行时Panics,所以这也是将其标为活跃的原因。InlMark只有一个参数,且为mem,我理解的将其标为活跃值只是不将其删除方便内联相关的操作,并没有其他意义。
四、死代码删除
4.1 函数CFG的定义
每个函数都有一个入口基本块 entry,从该基本块出发,程序可以沿着各个基本块的边一直执行,直到到达程序的结束点。这样,这些基本块以及它们之间的边构成了函数的控制流图(CFG)。
CFG是一个有向图,由至少一个基本块(Block)组成,基本块代表了程序中的一段连续的代码。每个基本块有若干个后继(Succs)块和若干个前趋(Preds)块,块之间通过一条有向的边连接,每个边表示了程序执行时可能的控制流转移。
结构的定义及主要字段如下所示:
type Func struct {
Blocks []*Block // 函数的CFG集合
}
type Block struct {
ID ID // 块ID,f.Blocks[b.ID]可访问函数f的b块
Succs []Edge // 后继块集合
Preds []Edge // 前趋块集合
}
type Edge struct {
b *Block // 边所对应的基本块
// 反向边对应集合的索引,结合公式和下文举例理解
// e := x.Succs[idx]
// e.b.Preds[e.i] = Edge{x,idx}
i int
}
假设一个函数的CFG如下所示,有b
、c
、d
和其他未知的基本块。b
有 2 个后继块c
和d
;c
有 4 个前趋块,包括b
和其他;d
有 3 个前趋块,包括b
和其他。
---|
---|
---|
---|-> c
|
--> b
|
---|-> d
---|
---|
b.Succs = [{c,3}, {d,1}] // 边{c,3} 等于 c的前趋集合c.Preds[3]中的边
c.Preds = [?, ?, ?, {b,0}] // 边{b,0} 等于 b的后继集合b.Succs[0]中的边
d.Preds = [?, {b,1}, ?]
4.2 删除一条边
死代码删除时可能会删除两个块之间(b1 -> b2
)的边,对该功能的实现在removeEdge(i int) 函数中,参数i
是要删除的边对应b1.Succs
集合的索引。
通过边的 4.1 的内容可知,e := b1.Succs[i]
可以得到要删除的边e
,e.b
是另一个块b2
,e.i
是边e
在b2
的前趋集合中的索引。具体删除代码如下:
e := b.Succs[i]
c := e.b
j := e.i
// 将删除的边置为nil,将后续的边前移填充删除的位置
b.removeSucc(i)
c.removePred(j)
删除b1 -> b2
的边后,b2
中的Phi
操作也要做出相应的调整。遍历所有Phi
操作,将来自于b1
块的参数都删除。
4.3 删除一个函数的死代码
死代码删除在deadcode函数中实现,对于一个函数f
,删除其死代码的过程如下:
-
首先检查函数
f
是否经过寄存器分配(regalloc)。如果已经经过寄存器分配,那么无法进行死代码删除,因为寄存器分配可能生成多余的 SSA 代码,会导致一些必需的移动操作被消除。 -
调用
ReachableBlocks(f)
,找到函数f
中可达的基本块,将其放入数组reachable
。 -
删除从不可达块到可达块的边,确保不会有从死代码到活跃代码的控制流传播。具体做法是:遍历所有基本块,如果一个块不可达,则遍历其后继块,若后继块可达,则删除这两个块之间的边(具体做法见删除边)。
// A、B可达,C不可达,删除<C,B>之间的边 // A ——> B <—— C for _, b := range f.Blocks { if reachable[b.ID] { continue } for i := 0; i < len(b.Succs); { // 不可达块后继块 e := b.Succs[i] if reachable[e.b.ID] { b.removeEdge(i) } else { i++ } } }
-
删除可达块与可达块实际无法流动的边。有一些分支控制Value,其结果在编译时就可以确定,以此可以推断出分支的流向,将不流动的那条边删除。下列代码中,
v9
和v10
的值分别是常量1
和0
,所以v11
的值在编译时可以确定为false
,If v11 → b4 b5
将会恒定跳转到b5
。而b3 -> b4
这条边的流动,将永不流通,所以需要将其删掉。
Golang的在lower pass优化阶段,会对上面的情况分析并做出变换,将If v11 → b4 b5
变成Frist → b5 b4
。Frist → b5 b4
的语义等同于Plain → b5
,其第二个基本块永不流通。所以要删除可达块与可达块实际无法流动的边,只需要找到类型为Frist
的可达块,删除其到第二个参数块的边即可。b3: ... v9 = Const 1 v10 = Const 0 v11 = Leq64 <bool> v9 v10 If v11 → b4 b5 /* lower pass后变换成 ==> */ b3: ... v9 = Const 1 v10 = Const 0 // v10和v9最终会被优化掉 v11 = Leq64 <bool> v9 v10 // v11若在其他地方不用,将成为死代码 Frist → b5 b4 /* 删除不流通边后变换成 ==> */ b3: ... v9 = Const 1 v10 = Const 0 // v10和v9最终会被优化掉 v11 = Leq64 <bool> v9 v10 // v11若在其他地方不用,将成为死代码 Plain → b5
-
调用
copyelim(f)
进行复制传播消除Copy指令,在不可达块和死值移除过程中会引入额外的Copy操作。 -
调用
liveValues(f, reachable)
找到活跃值存放至数组live
中供后续使用,并在函数结束时释放相关资源。 -
从
LocalSlot
值的映射NamedValues
中移除死值并去重。 -
将不可达块的分支控制 Value 和死值的参数均设置为
nil
,并将其对应的引用次数Uses
减1
。 -
删除死值。将函数
f
的每个基本块的死值的Value
对象设置为nil
,并将b.Values[i]
数组中的活跃值前移填充死值的位置,最后将b.Values[i]
中活跃值以外的部分舍弃。// 删除死值前 b.Values[0]: v1 = ... // live b.Values[1]: v2 = ... // dead b.Values[2]: v3 = ... // live b.Values[4]: v4 = ... // live // 死值设为nil v2 = nil // 活跃值 b.Values[0]: v1 = ... // live b.Values[1]: v3 = ... // live b.Values[2]: v4 = ... // live b.Values[4]: nil // 舍弃活跃值以外部分 b.Values[0]: v1 = ... // live b.Values[1]: v3 = ... // live b.Values[2]: v4 = ... // live
-
删除不可达的基本块。因为上一步已经将死值删除了,所以这些块的
b.Values
数组均为nil
。将不可达块的Block
对象设为nil
,将f.Blocks
数组中可达块前移填充不可达块的位置,最后将f.Blocks
中可达块以外的部分舍弃。