首页
/ 深入 Go 编译器 SSA 后端:Value、Memory、Block、优化 Pass 与重写规则全解(go/go)

深入 Go 编译器 SSA 后端:Value、Memory、Block、优化 Pass 与重写规则全解(go/go)

2026-09-05 18:04:45作者:史锋燃Gardner

本文基于 Go 源码仓库中 src/cmd/compile/internal/ssa 包的官方文档 src/cmd/compile/internal/ssa/README.md 展开,系统讲解 Go 编译器(gc)SSA 后端的三大核心概念——Value、Memory 类型与 Block,并结合仓库源码梳理完整的优化 pass 流水线(passes 列表)、lower 降低 pass 的特殊角色、GOSSAFUNC 调试技巧,以及用 .rules 重写规则和 *Ops.go 表扩展 SSA 的方法。读完后,你可以读懂 ssa.html 中每一列 pass 输出的含义,并能独立追踪一个 Go 函数从 SSA 形式到机器相关代码的完整变换链。

SSA 在 Go 编译器中的位置

要理解 SSA 后端的意义,先看整个 cmd/compile 编译流水线。根据编译器总览文档 src/cmd/compile/README.md,编译逻辑上分为若干阶段:

  1. 解析cmd/compile/internal/syntax(词法、语法分析、语法树);
  2. 类型检查cmd/compile/internal/types2
  3. IR 构建(noding)cmd/compile/internal/ircmd/compile/internal/noder,把语法树转换成编译器自己的 AST 表示;
  4. 中端:内联(inline)、去虚化(devirtualize)、逃逸分析(escape);
  5. Walkcmd/compile/internal/walk,把复杂语句拆成简单语句、按求值顺序引入临时变量,并做语法脱糖(如 switch 转跳转表、map/channel 操作替换为 runtime 调用);
  6. 通用 SSAcmd/compile/internal/ssacmd/compile/internal/ssagen,把 IR 转成静态单赋值(Static Single Assignment)形式,随后运行一系列机器无关的 pass 与重写规则;
  7. 机器代码生成:从 lower pass 开始进入机器相关阶段,最终 SSA 被转换为 obj.Prog 指令序列交给汇编器 cmd/internal/obj 输出目标文件。

ssa 包正是第 6、7 两阶段的核心。它把程序转成 SSA 形式——一种让优化实现变得容易的低级中间表示(IR)——然后通过一系列 pass 逐步优化、降低,最终逼近可汇编的形态。需要注意的是:ssa 包里的概念(Value、Block 等)虽然与 Go 语言概念名称相近,但并不等价。例如 Go 的块语句有变量作用域,而 SSA 中根本没有“变量”与“变量作用域”的概念

核心概念一:Value

Value 是 SSA 的基本构建块。按照 SSA 的定义,一个 Value 恰好被定义一次,但可以被使用任意多次。一个 Value 主要由四部分组成:唯一标识符(ID)、操作符(Op)、类型,以及一些参数。

  • Op(操作符):描述计算该 Value 的操作。每个操作符的语义定义在 src/cmd/compile/internal/ssa/_gen/ 目录下的 *Ops.go 文件中(如 genericOps.go)。例如 OpAdd8 接收两个存放 8 位整数的 Value 参数,结果就是二者的加法。
  • 类型:通常是某个 Go 类型,比如 uint8bool;但也有一些不来自 Go 的特殊类型,最常见的是 memory(下文详述)。
  • 辅助字段(aux):某些操作符带有 aux 字段,打印时通常包在 []{} 里,可以是常量操作数、参数类型等。
  • 顺序编号:Value 和 Block 都以唯一的顺序 ID 命名(如 v4b2),它们很少直接对应源码中的变量或参数名。顺序 ID 的好处是:编译器可以避免使用 map(Value 数组按下标直接寻址);同时借助调试信息(position info)始终可以把 Value 追溯回原始 Go 代码。

一个 uint8 加法在 SSA 中的表示(源自 README 中的示例):

// var c uint8 = a + b
v4 = Add8 <uint8> v2 v3

aux 字段的两个例子:

v13 (?) = Const64 <int> [1]

这里 aux 字段是常量操作数——该 Op 创建了一个值为 1 的 Const64

v17 (361) = Store <mem> {int} v16 v14 v8

这里 aux 字段是被存储值的类型 int

更多 Value 的定义可参考 value.gosrc/cmd/compile/internal/ssa/_gen/ 中的 *Ops.go 文件。

核心概念二:Memory 类型——用数据流表示内存状态

memory 类型表示全局内存状态。在 SSA 中一切皆为“值”(定义一次、可多次使用),但真实的内存读写会破坏这个性质:读的结果依赖之前所有写。Go 编译器 SSA 的解法是把内存本身也建模成一个值:

  • 接收 memory 参数的 Op,表示它依赖某个内存状态;
  • 产生 memory 类型结果的 Op,表示它改变了内存状态。

这样,内存操作就能通过值的依赖链保持正确的先后顺序。以 README 中的例子为例:

// *a = 3
// *b = *a

对应的 SSA:

v10 = Store <mem> {int} v6 v8 v1
v14 = Store <mem> {int} v7 v8 v10

这里每个 Store 把第二个参数(类型 int,即要存的值)写入第一个参数(类型 *int,即目标地址);最后一个参数是输入内存状态。第二个 Store 的内存状态参数是 v10——即第一个 Store 的输出,因此第二个 Store 依赖第一个 Store 定义的内存状态,两者不能重排序。这条“store 链”是理解 SSA 中所有内存相关 pass(如死存储消除 dse、memcombine)的钥匙。

核心概念三:Block(基本块)

Block 表示函数控制流图(CFG)中的一个基本块,本质上是“定义该块行为的一组 Value 的列表”。除 Value 列表外,Block 主要由唯一标识符、kind(种类)以及后继块列表组成。三种最重要的 kind:

  • plain 块:把控制流交给另一个块,后继列表恰好一个元素;
  • exit 块:带有一个最终的控制值(control value),且该值必须返回一个 memory 状态。函数要能返回结果,调用方必须依赖某个内存状态才能正确读到返回值,所以 exit 块必须吐出内存状态;
  • if 块:有一个布尔控制值,恰好两个后继块——控制值为 true 时把控制流交给第一个后继,否则交给第二个。

下面是一个完整的 if-else 控制流示例(README 原样示例,注释给出了对应的 Go 源码):

// func(b bool) int {
// 	if b {
// 		return 2
// 	}
// 	return 3
// }
b1:
  v1 = InitMem <mem>
  v2 = SP <uintptr>
  v5 = Addr <*int> {~r1} v2
  v6 = Arg <bool> {b}
  v8 = Const64 <int> [2]
  v12 = Const64 <int> [3]
  If v6 -> b2 b3
b2: <- b1
  v10 = VarDef <mem> {~r1} v1
  v11 = Store <mem> {int} v5 v8 v10
  Ret v11
b3: <- b1
  v14 = VarDef <mem> {~r1} v1
  v15 = Store <mem> {int} v5 v12 v14
  Ret v15

值得注意的细节:返回值并非直接“return”,而是先把结果 Store 到栈上的一个地址 v5Addr <*int> {~r1} v2,即从栈指针 SP 推出返回值的存储位置),再由 Ret 指令带上新的内存状态结束。这正体现了“一切皆 Value”的 SSA 思想——函数返回值也是一次显式的内存写。更多 Block 定义见 block.go

核心概念四:Function

SSA 中的 Function 对应一个函数声明及其函数体,主要由名称、类型(签名)、组成函数体的 Block 列表,以及其中的**入口块(entry block)**构成:

  • 函数被调用时,控制流交给其入口块;函数结束时控制流会到达某个 exit 块,从而结束调用;
  • 一个函数可以有零个或多个 exit 块(Go 函数可以有任意多个 return 点),但入口块必须恰好一个
  • 部分 SSA 函数是自动生成的,例如每种用作 map key 的类型都会生成对应的哈希函数。

一个空函数在 SSA 中的形态(README 原样示例):

foo func()
  b1:
    v1 = InitMem <mem>
    Ret v1

它只有一个 exit 块,返回一个“无关紧要”的内存状态。更多细节见 func.go

优化 Pass 流水线:passes 列表与顺序约束

程序被表示成 SSA 之后,价值才真正体现出来:SSA 让“修改程序使其更好”的优化更容易编写。Go 编译器通过一组顺序执行的 pass 完成这件事——每个 pass 以某种方式变换一个 SSA 函数。例如死代码消除 pass 会删除可证明永远不会执行的块与值;nil 检查消除 pass 会删除冗余的 nil 检查。pass 每次只处理一个函数,默认顺序执行、各跑一次

Pass 的结构定义

pass 的数据结构定义在 src/cmd/compile/internal/ssa/compile.go

type Pass struct {
	Name     string
	Fn       func(*Func)
	Required bool
	Disabled bool
	Time     bool             // report time to run pass
	Mem      bool             // report mem stats to run pass
	Stats    int              // pass reports own "stats" (e.g., branches removed)
	Debug    int              // pass performs some debugging.
	Test     int              // pass-specific ad-hoc option
	Dump     map[string]bool  // dump if function name matches
	Keywords map[string]int64 // ad hoc parameters, typically for experiments/tuning
	UsedKW   map[string]bool
}

Required 标记的 pass 不可用 -d=ssa/... 调试选项禁用;DumpKeywords 则支撑了 GOSSAFUNC 里“指定 pass 打印”与“调参实验”的能力。

完整的 passes 列表

pass 列表定义在 src/cmd/compile/internal/ssacompile/compile.go,这是理解整个 SSA 后端的地图。完整流水线如下(注释为源码原注释):

var passes = [...]ssa.Pass{
	{Name: "number lines", Fn: numberLines, Required: true},
	{Name: "early phielim and copyelim", Fn: copyelim},
	{Name: "early deadcode", Fn: deadcode}, // remove generated dead code to avoid doing pointless work during opt
	{Name: "short circuit", Fn: shortcircuit},
	{Name: "decompose user", Fn: decomposeUser, Required: true},
	{Name: "pre-opt deadcode", Fn: deadcode},
	{Name: "opt", Fn: opt, Required: true},
	{Name: "zero arg cse", Fn: zcse, Required: true},     // required to merge OpSB values
	{Name: "opt deadcode", Fn: deadcode, Required: true}, // remove any blocks orphaned during opt
	{Name: "generic cse", Fn: cse},
	{Name: "phiopt", Fn: phiopt},
	{Name: "gcse deadcode", Fn: deadcode, Required: true}, // clean out after cse and phiopt
	{Name: "nilcheckelim", Fn: nilcheckelim},
	{Name: "prove", Fn: prove},
	{Name: "divisible", Fn: divisiblePass, Required: true},
	{Name: "divmod", Fn: divmodPass, Required: true},
	{Name: "middle opt", Fn: opt, Required: true},
	{Name: "known bits", Fn: ssa.KnownBits},
	{Name: "early fuse", Fn: fuseEarly},
	{Name: "expand calls", Fn: expandCalls, Required: true},
	{Name: "decompose builtin", Fn: postExpandCallsDecompose, Required: true},
	{Name: "softfloat", Fn: softfloat, Required: true},
	{Name: "branchelim", Fn: branchelim},
	{Name: "late opt", Fn: opt, Required: true},
	{Name: "dead auto elim", Fn: elimDeadAutosGeneric},
	{Name: "sccp", Fn: sccp},
	{Name: "generic deadcode", Fn: deadcode, Required: true}, // remove dead stores, which otherwise mess up store chain
	{Name: "late fuse", Fn: fuseLate},
	{Name: "check bce", Fn: checkbce},
	{Name: "dse", Fn: dse},
	{Name: "memcombine", Fn: memcombine},
	{Name: "writebarrier", Fn: writebarrier, Required: true}, // expand write barrier ops
	{Name: "insert resched checks", Fn: insertLoopReschedChecks,
		Disabled: !buildcfg.Experiment.PreemptibleLoops},
	{Name: "cpufeatures", Fn: cpufeatures, Required: buildcfg.Experiment.SIMD, Disabled: !buildcfg.Experiment.SIMD},
	{Name: "rewrite tern", Fn: rewriteTern, Required: false, Disabled: !buildcfg.Experiment.SIMD},
	{Name: "lower", Fn: lower, Required: true},
	{Name: "addressing modes", Fn: addressingModes, Required: false},
	{Name: "late lower", Fn: lateLower, Required: true},
	{Name: "pair", Fn: pair},
	{Name: "lowered deadcode for cse", Fn: deadcode},
	{Name: "lowered cse", Fn: cse},
	{Name: "elim unread autos", Fn: elimUnreadAutos},
	{Name: "tighten tuple selectors", Fn: tightenTupleSelectors, Required: true},
	{Name: "lowered deadcode", Fn: deadcode, Required: true},
	{Name: "checkLower", Fn: checkLower, Required: true},
	{Name: "loop invariant", Fn: licm},
	{Name: "late phielim and copyelim", Fn: copyelim},
	{Name: "tighten", Fn: tighten, Required: true}, // move values closer to their uses
	{Name: "late deadcode", Fn: deadcode},
	{Name: "critical", Fn: critical, Required: true}, // remove critical edges
	{Name: "phi tighten", Fn: phiTighten},
	{Name: "likelyadjust", Fn: likelyadjust},
	{Name: "layout", Fn: layout, Required: true},     // schedule blocks
	{Name: "schedule", Fn: schedule, Required: true}, // schedule values
	{Name: "late nilcheck", Fn: nilcheckelim2},
	{Name: "flagalloc", Fn: flagalloc, Required: true}, // allocate flags register
	{Name: "regalloc", Fn: regalloc, Required: true},   // allocate int & float registers + stack slots
	{Name: "loop rotate", Fn: loopRotate},
	{Name: "trim", Fn: trim}, // remove empty blocks
}

从这份列表可以读出流水线的大致分段:

  1. 通用优化段(机器无关):从 number linescheck bce,反复出现 opt(重写规则应用)、deadcodecse。注意 deadcode 被调用了很多次(early / pre-opt / opt / gcse / generic / late / lowered…),因为几乎每个会引入或暴露新死代码的 pass 之后都要清理一次;
  2. 内存相关段dse(死存储消除)依赖 CSE 识别“两个地址表达式相同”,memcombine 合并相邻的内存操作,writebarrier 展开写屏障;
  3. 机器相关段:以 lower 为分界线,之后还有 addressing modeslate lowerpair(成对指令组合)、tighten(把值挪近使用点)、layout/schedule(块与指令调度)、regalloc(寄存器分配)等;
  4. 实验开关cpufeaturesrewrite tern 仅在 SIMD 实验开启时启用,insert resched checks 依赖可抢占循环实验。

顺序约束(passOrder)

为什么 pass 的顺序很重要?同一文件下方(src/cmd/compile/internal/ssacompile/compile.go)用一张 passOrder 约束表显式记录了关键顺序要求:

// Double-check phase ordering constraints.
var passOrder = [...]constraint{
	// "insert resched checks" uses mem, better to clean out stores first.
	{"dse", "insert resched checks"},
	// insert resched checks adds new blocks containing generic instructions
	{"insert resched checks", "lower"},
	{"insert resched checks", "tighten"},
	// prove relies on common-subexpression elimination for maximum benefits.
	{"generic cse", "prove"},
	// deadcode after prove to eliminate all new dead blocks.
	{"prove", "generic deadcode"},
	// common-subexpression before dead-store elim, so that we recognize
	// when two address expressions are the same.
	{"generic cse", "dse"},
	// cse substantially improves nilcheckelim efficacy
	{"generic cse", "nilcheckelim"},
	// nilcheckelim relies on the first opt to rewrite user nil checks
	{"opt", "nilcheckelim"},
	// tighten will be most effective when as many values have been removed as possible
	{"generic deadcode", "tighten"},
	...
}

这解释了 README 中“pass 顺序很重要”的原因:比如 prove(谓词推理)要先有 generic cse 才最有收益;dse 必须先做 CSE 才能认出相同地址;tighten 要在尽可能多的值被删除之后才最有效。同一优化多次出现(如 deadcode、opt、cse、phielim/copyelim)也由此得到解释——不同阶段清理的目标不同。

lower pass 的特殊性

所有 pass 中 lower 是特殊的:它把 SSA 从机器无关表示转换为机器相关表示——一些抽象操作符被替换成机器相关的非通用版本,最终 Value 的数量可能减少也可能增加。以 amd64 为例(见编译器总览文档):amd64 支持内存操作数,因此许多 load-store 操作可以合并。并且 lower 会运行所有机器相关重写规则(如 AMD64.rules),因此它实际上也承担了大量优化工作。

动手观察 SSA:GOSSAFUNC 实战

最直观的上手方式是用 GOSSAFUNC 环境变量观察 SSA:

基本用法

GOSSAFUNC=Foo go build

会针对名为 Foo 的函数生成 ssa.html。该文件包含该函数在每个编译 pass 阶段的 SSA 快照,以多列形式并排展示,可以直观看到每个 pass 对程序做了什么;页面上还可以点击 Value 和 Block 高亮跟踪控制流与数据流。

包限定的函数名

GOSSAFUNC=blah.Foo go build

这会匹配任何包名最后一段blah 的包中名为 Foo 的函数(例如 something/blah.Fooanotherthing/extra/blah.Foo 都会命中)。

打印指定 pass 的 CFG

GOSSAFUNC="blah.Foo:sccp,generic deadcode" go build

格式为 $FunctionName:$PassName1,$PassName2,...,会在 sccpgeneric deadcode 两列旁边附加打印控制流图(CFG),便于分析这两个阶段前后的控制流差异。

输出到 stdout

如果不需要 HTML,在值后加 + 号,dump 直接写到标准输出:

GOSSAFUNC=Bar+ go build

GOSSADIR 与 per-pass 文件 dump

从源码看,pass 还支持把每个阶段的结果写成独立文件:src/cmd/compile/internal/ssa/compile.goDumpFileForPhase 会生成 <函数名>_<序号>__<阶段名>.dump 文件,若设置了 GOSSADIR 环境变量则写入该目录。对应 Pass 结构中的 Dump map[string]bool 字段,即“函数名匹配时 dump 该阶段”。

扩展 SSA:重写规则(.rules)与 Op 表

大多数编译器 pass 用 Go 代码直接实现,但另一部分优化是代码生成出来的,其源头是 src/cmd/compile/internal/ssa/_gen/ 目录下的 .rules 文件。这些文件有自己的语法,由 rulegen.go 解析生成 Go 代码。简单的优化用重写规则可以快速轻松地表达;但复杂优化不适合用规则表达,需要写专门的 pass。

generic.rules 头部注释给出了规则语法的核心约定:

// values are specified using the following format:
// (op <type> [auxint] {aux} arg0 arg1 ...)
// the type, aux, and auxint fields are optional
// on the matching side
//  - the type, aux, and auxint fields must match if they are specified.
//  - the first occurrence of a variable defines that variable.
//    Subsequent uses must match (be == to) the first use.
//  - v is defined to be the value matched.
//  - an additional conditional can be provided after the match pattern with "&&".
// on the generated side
//  - the type of the top-level expression is the same as the one on the left-hand side.
//  - the type of any subexpressions must be specified explicitly.
//  - auxint will be 0 if not specified.
//  - aux will be nil if not specified.

直观的例子就是文件开头的常量折叠规则:

// constant folding
(Trunc16to8  (Const16  [c])) => (Const8   [int8(c)])
(Trunc32to8  (Const32  [c])) => (Const8   [int8(c)])
(Cvt32Fto32  (Const32F [c])) && c >= -1<<31 && c < 1<<31 => (Const32 [int32(c)])

规则左侧是匹配模式(type/aux/auxint 在匹配侧是可选的、变量首次出现即定义),右侧是替换生成侧(子表达式类型必须显式给出),还可以用 && 追加布尔条件。文件顶部注释也举了经典例子:y := 0 * x 可以被规则安全地化简为 y := 0,省去一次无谓的乘法指令。

_gen/ 目录下按架构组织了规则与 Op 定义:

文件/目录 作用
generic.rules / genericOps.go 所有架构通用的重写规则与通用操作符定义
AMD64.rules / AMD64Ops.go amd64 架构相关规则与操作符
ARM64.rulesRISCV64.rulesWasm.rules 其他 GOARCH 的规则文件
simd*.rules 系列 SIMD 实验相关的规则
main.go / rulegen.go 规则解析器与代码生成器(go generate 的入口)

同样,管理操作符(Op)本身的代码也是从 _gen/*Ops.go 生成的——维护少数表格比维护大量重复代码更容易。修改规则或操作符后,运行:

go generate cmd/compile/internal/ssa

即可重新生成 Go 代码。

关键源码索引

按阅读顺序,理解 SSA 后端的仓库路径:

需要强调的是适用前提:以上 pass 列表、规则文件均以当前仓库源码为准,实验特性(如 buildcfg.Experiment.SIMDPreemptibleLoops)的默认开关可能随版本变化;阅读具体行为时,请结合 src/cmd/compile/internal/ssacompile/compile.go 中的 Disabled 字段与 goexperiment 包确认实际启用状态。

登录后查看全文
热门项目推荐
相关项目推荐