Golang编译优化——死代码删除

本文详细介绍了Golang编译器如何通过控制流分析和数据流分析来识别和删除死代码,包括可达基本块的判定、活跃值的确定以及死代码删除的具体步骤。
摘要由CSDN通过智能技术生成

一、概述

死代码其实就是无用或者不可达代码,在《编译器设计》标量优化的消除无用和不可达代码中做过总结,如果不明白与该优化相关的一些术语,建议阅读后再往下看。

死代码删除通过分析代码中的控制流和数据流关系,从而找到并删除不再被使用的代码。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 B5reachable中均没有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 B8reachable中均没有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 B1reachable 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,其作用只是根据控制值决定控制流的走向。下列v11v18都是控制值,v11if语句的分支条件,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].calltrue的值是调用值,因为在定义的时候,将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

  • AtomicStore32AtomicStoreRel32:两者的区别是前者只是一个原子存储操作,不执行锁获取操作,存储过程中可能发生内存的并发访问冲突。后者在前者的基础上,并执行了锁获取操作,确保了存储过程不会发生并发访问冲突。
  • AtomicExchange32/64:该操作将arg1的值写入arg0所指定的内存位置,并返回该内存位置原来的值。在写入新值之前,操作会确保原子性,以避免并发写入导致的竞态条件。
  • AtomicAdd32/64*arg0 += arg1,将arg1的值加到arg0所指定的内存位置,并返回执行加法操作后的新值。
  • ……

与以上相似的操作还有很多,不再列举。让我比较疑惑的是,为什么不认为Store、StoreWB等非原子存储操作Value具有副作用呢

3.4 返回值为void的值

Golang SSA IR只有两个指令的返回值类型为voidNilCheckInlMark

  • 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如下所示,有bcd 和其他未知的基本块。b有 2 个后继块cdc有 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]可以得到要删除的边ee.b是另一个块b2e.i是边eb2的前趋集合中的索引。具体删除代码如下:

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,其结果在编译时就可以确定,以此可以推断出分支的流向,将不流动的那条边删除。下列代码中,v9v10的值分别是常量10,所以v11的值在编译时可以确定为falseIf v11 → b4 b5将会恒定跳转到b5。而b3 -> b4这条边的流动,将永不流通,所以需要将其删掉。
    Golang的在lower pass优化阶段,会对上面的情况分析并做出变换,将If v11 → b4 b5变成Frist → b5 b4Frist → 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,并将其对应的引用次数Uses1

  • 删除死值。将函数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中可达块以外的部分舍弃。

  • 21
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yelvens

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值