编译原理
把 canonical IR Tree 翻译成具体目标机器的 abstract assembly:树覆盖算法(Maximal Munch / Dynamic Programming)、指令模板与开销估计。
第 8 章已经做完两件事:
CJUMP 后面紧跟假分支标签。第 9 章接着做:
指令选择:从规范树生成伪汇编 / 抽象汇编代码。
整体位置:
中间表示
|
| 第 8 章
v
规范化后的中间表示
|
| 第 9 章:指令选择
v
抽象汇编代码
|
| 第 10/11 章:寄存器分配
v
汇编代码后端从 IR 到机器码主要分三步:
| 阶段 | 说法 | 做什么 |
|---|---|---|
| 指令选择 | 把 IR 映射成抽象汇编代码 | 选择机器指令来实现 IR |
| 寄存器分配 | 用真实寄存器替换抽象寄存器 | 决定哪些值放寄存器,并把抽象寄存器换成真实寄存器 |
| 指令调度 | 本课程不讲 | 重排指令以隐藏延迟、利用并行 |
IR 树语言中,每个树节点通常只表达一个原始操作:
但真实机器指令经常可以一次完成多个原始操作。
典型例子是:
LOAD ri <- M[rj + c]这条机器指令同时做了:
rj。c。ri。对应到 IR 树,大概是:
MEM
|
+
/ \
TEMP CONST
rj c所以指令选择要解决的问题就是:
找到合适的机器指令来实现给定的 IR 树。
两类 IR 对应不同的匹配方法:
| IR 形态 | 适合的方法 |
|---|---|
| 树形 IR | 在树上做模式匹配,用树模式 |
| 线性 IR | 在字符串或线性序列上做匹配,例如窥孔匹配 |
本章讨论的是树形 IR,所以核心工具是:
树模式 + 瓦片覆盖(tiling)
定义:
每条机器指令都可以表示成一片 IR 树,这片树称为 树模式。
也就是说,一条机器指令可以看成一片 IR 树。
例如 LOAD ri <- M[rj + c] 的模式可以画成:
指令:
LOAD ri <- M[rj + c]
树模式:
MEM
|
+
/ \
r CONST
c这里的 r 表示某个寄存器值,CONST c 表示常量偏移。
定义:
瓦片覆盖:用不重叠的树模式覆盖整棵树。
也就是用一块块树模式覆盖整棵 IR 树:
可以把它理解成:
IR 树节点 = 原始操作
树模式 / tile = 一条机器指令能实现的一片 IR
瓦片覆盖 = 把整棵 IR 树切成若干机器指令为了说明指令选择,本章使用教材中的假想机器 Jouette。
需要强调几点:
BINOP(PLUS, x, y) 可以简写成 +(x, y)。r0 = 0。CONST 和 TEMP 节点的实际值有时不会画出来。r0 永远包含 0。例如,常量 c 可以通过 r0 得到:
ADDI ri <- r0 + c对应的树模式可以看成:
CONST c因为 r0 固定为 0,所以 r0 + c 就是 c。
a[i] := x用 a[i] := x 展示同一棵 IR 树可以有多种瓦片覆盖方式。
原始树形大致如下:
MOVE
/ \
MEM MEM
| |
+ +
/ \ / \
MEM * FP CONST x
| / \
+ TEMP i CONST 4
/ \
FP CONST a这棵树表示:
MEM(...) 是赋值目标 a[i] 的地址。MEM(FP + x) 是变量 x 的值。MEM(FP + a) 取出数组 a 的基地址。TEMP i * CONST 4 计算下标偏移。+ 把数组基地址和偏移加起来,得到 a[i] 的地址。要点:
总是可以用很小的 tile 覆盖整棵树,让每个 tile 只覆盖一个节点。
也就是说,最保守的做法是每个 tile 只覆盖一个节点。这样一定能翻译,但指令会比较多。
对 a[i] := x,小 tile 风格的指令序列是:
ADDI r1 <- r0 + a
ADD r1 <- fp + r1
LOAD r1 <- M[r1 + 0]
ADDI r2 <- r0 + 4
MUL r2 <- ri * r2
ADD r1 <- r1 + r2
ADDI r2 <- r0 + x
ADD r2 <- fp + r2
LOAD r2 <- M[r2 + 0]
STORE M[r1 + 0] <- r2这里可以看到:小 tile 会把地址计算、常量构造、取内存、存内存都拆得很细。
还可以比较 a[i] := x 的两种覆盖方式:
普通覆盖:6 个单位
使用 MOVEM 的覆盖:5 + m 个单位其中:
MOVEM 的代价是 m。这说明:大 tile 不一定永远更好,关键要看代价。
一棵 IR 树可以有多种覆盖方式。所谓最佳覆盖,就是代价最低的指令序列。
在单发射、固定延迟机器上,最低代价通常就是:
指令条数最少
定义:
Optimum tiling(全局最优覆盖):所有 tile 的总代价达到全局最低。
也就是:
全局总代价最小定义:
Optimal tiling(局部最优覆盖):不存在两个相邻 tile 能合并成一个代价更低的 tile。
也就是:
局部不能再改进具体说,如果有两个相邻 tile:
tile A
|
tile B并且它们能合并成一个更便宜的大 tile:
tile C那原来的覆盖方式就不是局部最优。
结论:
每个全局最优覆盖都是局部最优覆盖,但反过来不一定成立。
也就是说:
原因很直观:如果一个覆盖方式已经是全局代价最低,那它不可能存在“两个相邻 tile 合并后更便宜”的局部改进;否则总代价还能下降,和“全局最低”矛盾。
反过来不成立,因为没有局部改进,不代表所有全局组合都已经最好。
注意:
如果某个树模式总能拆成若干个总代价更低的小 tile,那么就应该把这个树模式从模式表中删掉。
也就是说,模式表里不该保留“永远比拆开更贵”的 tile。
maximal munch 的定位:
Maximal Munch:用于寻找局部最优覆盖的算法。
它假设:
越大的 tile 越好算法思想是贪心:
“tile 的叶子接子树”的结构可以画成:
当前根节点
|
[ tile ]
/ \
叶子1 叶子2
| |
子树1 子树2其中 [ tile ] 是当前已经选中的机器指令模式;子树1、子树2 是还没覆盖的部分。
要点:
最大 tile:节点数最多的 tile。
例如:
单节点 tile:
TEMP
双节点 tile:
MEM
|
r
三节点 tile:
+
/ \
r CONSTmaximal munch 会优先选择能匹配的最大 tile。
如果两个 tile 大小相同,而且都能匹配根节点:
可以任意选择其中一个。
也就是任选一个。
a[i] := x 看自顶向下对这棵树:
MOVE
/ \
MEM MEM
| |
+ +
/ \ / \
MEM * FP CONST x
| / \
+ TEMP i CONST 4
/ \
FP CONST amaximal munch 的“选择方向”是自顶向下:
MOVE。MOVE 的最大存储 / 移动类 tile。注意:这里说的是“选择 tile 的方向”。实际输出指令时,子树结果通常要先算出来。
要点:
指令会按反向顺序生成。
一个简单例子:
IR 树:
*
/ \
+ MEM
/ \ |
a 3 p表示:
(a + 3) * M[p]maximal munch 选择 tile 时先看到根部 *,但真正输出指令要先产生 * 的两个操作数:
ADDI t1 <- a + 3 ; 先处理左边叶子子树
LOAD t2 <- M[p] ; 再处理右边叶子子树
MUL t3 <- t1 * t2 ; 最后输出根部 tile 对应的指令所以它是:
选择 tile:先根节点
输出指令:先子节点,最后父节点只需要掌握这几点:
munchStm 处理语句。munchExp 处理表达式。所以 maximal munch 的“贪心”在实现上体现为:
先尝试大模式
匹配不到再尝试小模式要点:
Maximal munch 总能找到局部最优覆盖,但不一定能找到全局最优覆盖。
原因:
如果要找 全局最优覆盖,需要动态规划。
定义:
节点
x的代价记为f(x),表示覆盖以x为根的子树所需的最小代价。
也就是说:
f(x) = 覆盖以 x 为根的整棵子树的最小代价递推式:
其中:
t:一个能覆盖当前节点 x 的 tile。c_t:tile t 自身的代价。leaves(t):tile 叶子处接上的子树根节点。f(i):这些叶子子树的最优覆盖代价。给定根节点 n:
n 的子节点、孙节点等所有子树的代价。n 处尝试每一种能匹配的树模式。tile 自身代价 + 所有叶子子树的 f 值所以 DP 是:
自底向上MEM(+(CONST 1, CONST 2))例子是这棵树:
MEM
|
+
/ \
CONST 1 CONST 2记号:
(a, b)其中:
a 是最小代价。b 是对应的模式编号。两个 CONST 节点都一样:
CONST 1 CONST 2
(1,8) (1,8)对应表格:
| 模式 | 代价 | 叶子代价 | 总计 |
|---|---|---|---|
(8) CONST | 1 | 0 | 1 |
所以:
f(CONST 1) = 1
f(CONST 2) = 1+ 节点现在看:
+
/ \
CONST 1 CONST 2
(1,8) (1,8)有三种模式可以匹配 +:
| 模式 | 代价 | 叶子代价 | 总计 |
|---|---|---|---|
(2) +(e1, e2) | 1 | 1 + 1 | 3 |
(6) +(CONST, e1) | 1 | 1 | 2 |
(7) +(e1, CONST) | 1 | 1 | 2 |
所以 + 节点的最小代价是 2:
+ (2,6)
/ \
CONST 1 CONST 2
(1,8) (1,8)如果 (6) 和 (7) 都是 2,选哪个都可以。这里沿用 (6)。
MEM 节点现在看整棵树:
MEM
|
+ (2,6)
/ \
CONST 1 CONST 2
(1,8) (1,8)有三种模式可以匹配 MEM:
| 模式 | 代价 | 叶子代价 | 总计 |
|---|---|---|---|
(13) MEM(e1) | 1 | 2 | 3 |
(10) MEM(+(e1, CONST)) | 1 | 1 | 2 |
(11) MEM(+(CONST, e1)) | 1 | 1 | 2 |
所以根节点:
MEM (2,10)
|
+ (2,6)
/ \
CONST 1 CONST 2
(1,8) (1,8)整棵树的全局最优代价是 2。
一旦根节点的代价找到了,就开始输出指令:
输出指令(节点 n):
对节点 n 选中的 tile 的每个叶子 li:
输出指令(li)
输出节点 n 匹配到的指令对上面的例子,输出是:
ADDI r1 <- r0 + 1
LOAD r1 <- M[r1 + 2]对应理解:
MEM(+(e1, CONST)) 这种读取模式。MEM、+、CONST 2 一起覆盖了。CONST 1。CONST 1 的指令,再发根部读取指令。maximal munch 和 DP 都需要检查“哪些 tile 能匹配当前节点”。
tile 能匹配的条件:
tile 中每个非叶节点的标签,都要和 IR 树中对应节点的算子一样。
例如:
tile:
MEM
|
+
/ \
r CONST
IR 树:
MEM
|
+
/ \
TEMP CONST这个 tile 可以匹配,因为非叶节点 MEM 和 + 都对上了。
为了快一点,可以按根节点标签分类:
如果当前节点是 MEM,只检查以 MEM 为根的模式。
如果当前节点是 BINOP,只检查以 BINOP 为根的模式。
如果当前节点是 CONST,只检查以 CONST 为根的模式。目标:
尽量避免反复检查同一个 IR 树节点。
定义:
| 符号 | 含义 |
|---|---|
K | 平均匹配到的 tile 含有 K 个带标签的非叶节点 |
N | 输入 IR 树的节点数 |
K' | 判断一个子树能匹配哪些 tile 时,最多需要检查的节点数 |
T' | 平均每个树节点能匹配的不同模式数 |
结论:
Maximal munch: 正比于 (K' + T') N / K
动态规划: 正比于 (K' + T') N(K' + T') N / K?可以拆成两部分看。
第一,每次 maximal munch 在某个根节点上选 tile,需要做两类工作:
检查树节点以判断是否匹配 -> 最多 K'
在可匹配模式中做选择 -> 平均 T'所以一次选择的成本近似是:
K' + T'第二,maximal munch 每选中一个 tile,平均会覆盖 K 个带标签的非叶节点。
整棵树有 N 个节点,所以大约需要选择:
N / K个 tile。
合起来就是:
每次选择成本 × 选择次数
= (K' + T') × (N / K)
= (K' + T') N / K如果 K、K'、T' 都看成常数,那么:
(K' + T') N / K = 常数 × N所以是线性时间。
(K' + T') N?DP 要给每个树节点都计算一个代价。
对每个节点,仍然要检查:
K' + T'量级的匹配工作。
一共有 N 个节点,所以总成本是:
(K' + T') × N如果 K'、T' 是常数,DP 也是线性时间。
和 maximal munch 相比,DP 这里没有除以 K,因为 DP 不是“每选一个 tile 跳过一片节点”,而是要给每个节点都算一次最优代价。
对于复杂指令集、多个寄存器类别和多种寻址模式:
因此可以使用指令选择器生成器:
解决方式:
用树文法描述树模式,把指令选择转化成解析问题,并使用推广后的动态规划算法求解。
简化版 Jouette 把寄存器分成两类:
| 寄存器类别 | 用途 |
|---|---|
a 寄存器 | 地址计算 |
d 寄存器 | 数据计算 |
要点:
每个 tile 的根和叶子都必须标记为
a或d。
也就是说,一个 tile 不只要说明树形结构,还要说明:
这个表达式算出来以后放在地址寄存器还是数据寄存器?对应的非终结符:
| 非终结符 | 含义 |
|---|---|
s | 语句 |
a | 计算结果放入 a 寄存器的表达式 |
d | 计算结果放入 d 寄存器的表达式 |
LOAD、MOVEA、MOVED 的规则可以写成:
d -> MEM(+(a, CONST))
d -> MEM(+(CONST, a))
d -> MEM(CONST)
d -> MEM(a)
d -> a
a -> d要点:
这样的文法高度歧义。
同一个表达式可能有许多条不同的指令序列实现它。
因此第 3 章的普通解析技术不太有用。更合适的是 DP 的推广:
对每个节点,记录它作为每个非终结符时的最小代价匹配。
例如:
节点 x:
作为 a 寄存器表达式的最小代价
作为 d 寄存器表达式的最小代价用表格表示:
| 节点 | 作为 a 的最小代价 | 作为 d 的最小代价 |
|---|---|---|
x | cost_a(x) | cost_d(x) |
这样就能处理寄存器类别。
RISC 和 CISC 的对照表:
| RISC 机器 | CISC 机器 |
|---|---|
| 32 个寄存器 | 寄存器较少,例如 16、8 或 6 个 |
| 整数 / 指针寄存器只有一类 | 寄存器分成不同类别 |
| 算术运算只在寄存器之间进行 | 算术运算可以访问寄存器或内存 |
三地址指令:r1 <- r2 op r3 | 二地址指令:r1 <- r1 op r2 |
取内存 / 存内存只支持 M[reg + const] | 有多种寻址模式 |
| 每条指令恰好 32 位 | 指令长度可变 |
| 每条指令只有一个结果或效果 | 指令可能有副作用,例如自动递增寻址 |
问题:
CISC 寄存器较少。解决办法:
自由生成 TEMP 节点,并假设寄存器分配器会处理好。
也就是指令选择阶段先自由地产生临时值,寄存器数量问题留给寄存器分配。
例子:Pentium 乘法指令。
eaxedx解决办法:
显式移动操作数和结果。
例子:
目标: t1 <- t2 * t3
mov eax, t2 eax <- t2
mul t3 eax <- eax * t3; edx <- 无用值
mov t1, eax t1 <- eax重点:如果某条指令会额外写坏某些寄存器,要让后续寄存器分配器知道。
问题:
目标寄存器必须和第一个源寄存器相同。
例如想实现:
t1 <- t2 + t3在二地址机器上可以生成:
mov t1, t2 t1 <- t2
add t1, t3 t1 <- t1 + t3之后希望寄存器分配器能把 t1 和 t2 分配到同一个寄存器,这样 mov 可以被删除。
CISC 算术指令可以直接访问内存。下面是两段等价代码:
mov eax, [ebp - 8]
add eax, ecx
mov [ebp - 8], eax和:
add [ebp - 8], ecx右边更简洁,但这两段代码速度差不多。
左边的明显缺点是:
它会破坏 eax。解决办法:
运算前先把所有操作数取到寄存器中,运算后再把结果存回内存。
一个完成六件事的寻址模式通常也要六步执行。
复杂寻址模式的两个优势:
结论:
要点:
这对编译器来说并不是真正的问题。
一旦指令选好了,具体编码交给汇编器做。
例子:自增式内存读取。
r2 <- M[r1]
r1 <- r1 + 4问题:
一条指令产生两个结果这很难用树模式表示。
三个解决办法:
总结:
原因:
CISC:某些指令每条能完成多个操作。
RISC:tile 小,而且代价比较统一。这一部分的重点不是具体写代码,而是说明指令选择和寄存器分配如何衔接。
问题:
在一棵由指令模式覆盖的树中,每个 tile 的根都会对应一个保存在寄存器中的中间结果。那么该使用哪个寄存器?
回答:
寄存器分配的任务,就是给这些节点分配真实寄存器编号。
如果在指令选择之前做寄存器分配,会有问题:
还不知道哪些树节点需要寄存器保存结果所以:
寄存器分配放在指令选择之后。AS_instr 是:
尚未分配真实寄存器的汇编指令。
它有三类:
| 类别 | 含义 |
|---|---|
OPER | 普通操作指令 |
LABEL | 跳转可以到达的程序位置 |
MOVE | 类似 OPER,但只能做数据搬运 |
OPER 保存:
| 字段 | 含义 |
|---|---|
assem | 汇编指令模板 |
src | 源操作数寄存器列表,可以为空 |
dst | 结果寄存器列表,可以为空 |
jumps | 可能跳转到的目标标签 |
如果一个操作总是顺序执行到下一条指令:
jumps = NULLLABEL:
assem 说明标签在汇编中长什么样。label 是实际使用的标签符号。MOVE:
OPER。MOVE 单独列出来,是因为后续寄存器分配器有机会处理 move 指令。
重点:
AS_instr类型独立于具体选择的目标机器汇编语言。
抽象汇编中还没有真实寄存器。
例如抽象形式:
LOAD `d0 <- M[`s0 + 8]这里:
`s0 表示第一个源 temp。`d0 表示第一个目标 temp。寄存器分配之后,可能打印成:
LOAD r1 <- M[r27 + 8]也就是说,指令选择先生成带 temp 的抽象汇编;真实寄存器名之后再填。
另一个例子:
抽象汇编:
ADDI `d0 <- `s0 + 3
LOAD `d0 <- M[`s0 + 0]
MUL `d0 <- `s0 * `s1寄存器分配后可能变成:
ADDI r1 <- r12 + 3
LOAD r2 <- M[r13 + 0]
MUL r1 <- r1 * r2例子:
add t1, t2效果:
t1 <- t1 + t2在抽象汇编中可以描述成:
| 汇编模板 | 目标 | 源 |
|---|---|---|
add `d0, `s1 | t1 | t1, t2 |
注意:
`s0` 隐式出现,但没有显式写在汇编模板字符串中。意思是:t1 既是目标,也是第一个源。汇编文本里不显式写 `s0,但 src 列表里必须包含它,让后续分析知道它被读了。
区分:
过程调用:EXP(CALL(f, args))
函数调用:MOVE(TEMP t, CALL(f, args))munchArgs 的任务:
生成代码,把所有参数移动到调用约定要求的位置,即寄存器或内存中。
CALL 会破坏一些寄存器:
这些 calldefs 应该列为 CALL 的目标寄存器。
一般原则:
任何带有“写入额外寄存器”副作用的指令,都需要这样处理。
传统帧指针做法:
然后给出虚拟帧指针:
优点:
处理方式:
把 FP + k 替换成 SP + k + fs其中 fs 是栈帧大小。
本章核心脉络可以压成一句话:
指令选择用树模式覆盖规范 IR 树,生成还没有分配真实寄存器的抽象汇编。
关键点:
| 主题 | 要记住什么 |
|---|---|
| 树模式 | 一条机器指令对应的一片 IR 树 |
| 瓦片覆盖 | 用不重叠的树模式覆盖整棵 IR 树 |
| Jouette | 用来说明瓦片覆盖的假想机器;r0 = 0 |
| Optimum | 全局代价最小 |
| Optimal | 相邻 tile 不能合并成更便宜的 tile |
| Maximal munch | 自顶向下贪心,找 optimal |
| 动态规划 | 自底向上,找 optimum |
| 树文法 | 用文法描述复杂树模式 |
| CISC | 寄存器少、寄存器分类、二地址指令、复杂寻址、副作用 |
| Tiger | 指令选择后再做寄存器分配 |
最容易混淆的两个词:
| 词 | 含义 | 对应算法 |
|---|---|---|
| optimal | 局部最优 | maximal munch |
| optimum | 全局最优 | 动态规划 |
复杂度也要记住:
Maximal munch: (K' + T') N / K
动态规划: (K' + T') N当 K、K'、T' 都是常数时,两者都是线性时间。
Written by
Comments