图着色问题
给定一个无向图
但并不打算在这里直接讨论图着色的精确解, 因为这是个NP-Hard问题, 精确解的算法复杂度的指数级的, 即使解出来对于大型图的编译过程也没有实际意义. 接下来我们讨论一个近似解法.
算法简述
假设我们有
但这个图着色和之前提到的图着色问题有一些区别, 首先我们不要求使用最少的颜色, 只要不超过
接下来讨论怎么具体解决这个问题. 关于活跃区间的构建, 这里不做赘述, 可以参考另一篇寄存器分配算法线性扫描. 假设已经有了活跃区间, 接着是构建干扰图.
干扰图可以用位矩阵(Bit Matrix)+邻接向量(Adjacency Vector)来表示. 位矩阵的大小是
接下来对这个干扰图进行化简. 容易观察到这样一个事实: 如果一个节点的度小于
如果幸运的是, 干扰图化简到最后是个空图, 那么我们可以直接从栈中弹出所有节点并为它们着色(分配寄存器). 但如果最终的图中剩下一些节点(这些节点的度一定都大于或等于
溢出操作就是不断进行尝试, 从原始的图中删除一个或者多个最后剩下的节点, 然后对新图进行着色, 看新图的色数是否小于等于
下面是一个示例, 对于这样一段汇编($a
和$b
只表示两个变量, 并非汇编用法), 寄存器均为虚拟寄存器, 因此数量是无限的:
ld t0, $a # 加载变量`a`到寄存器
ld t1, $b # 加载变量`b`到寄存器
add t2, t0, t1 # 将`a`和`b`相加, 得到变量`c`
add t3, t0, t0 # 将`a`和`a`相加, 得到变量`d`
add t4, t0, t2 # 将`a`和`c`相加, 得到变量`e`
因此可以得到活跃区间表如下
变量 | 活跃区间 |
---|---|
a | [1, 4] |
b | [2, 2] |
c | [3, 4] |
d | [4, 4] |
e | [5, 5] |
然后构建干扰图, 其中变量a和b,c,d都有重合的活跃区间, 变量c和d也有重合的活跃区间. 因此画出干扰图如下:
假设我们现在只有
现在节点a,c,d的度都为2, 恰好等于寄存器数量, 因此不能直接移除, 需要选择其中的一个或者多个节点进行溢出. 溢出规则通常会选择溢出成本最小的节点, 计算溢出成本又有不同的方法. 这里就简单选择节点a进行溢出. 更新图, 得到
此时节点3,4的度都为1, 因此将它们从图中移除, 并推入栈中, 最后会得到一个空图和栈:
依次从栈中弹出节点, 并考虑它们在原始图中的邻接关系进行寄存器分配. 首先节点d的邻接节点是a,c, a被溢出, c未分配, 因此将节点d分配到寄存器R1
中. 接着节点c的邻接节点是a,d, a被溢出, d已经分配到寄存器R1
, 因此将节点c分配到寄存器R2
中. 节点b的邻接节点是a, a被溢出, 所以将节点b分配到寄存器R1
或者R2
都行. 节点e是孤立节点, 因此分配到寄存器R1
或者R2
都行. 最终得到的寄存器分配如下:
变量 | 寄存器 |
---|---|
a | 溢出到内存 |
b | R1 /R2 |
c | R2 /R1 |
d | R1 /R2 均可 |
e | R1 /R2 均可 |
算法细节
对于上面的算法, 还有一些细节需要注意.
节点合并
有一些不必要的寄存器拷贝操作, 比如当变量a
和b
的活跃区间没有重叠, 但存在指令a=b
的时候, 这就是一个不必要的拷贝操作. 如果不做任何处理, 变量a
和b
可能会被分配到不同寄存器中, 但实际上是没有必要的. 例如:
a = x + 1;
b = a;
b = y + b;
这个时候a
和b
的活跃区间不重叠, 可以将干扰图上的a
和b
合并. 合并的方式就是合并a
和b
的邻接向量. 比如在这里a
的邻接向量是x
, b
的邻接向量是y
, 那么合并后新节点ab
的邻接向量就是[x,y]
. 这个时候可以构建出一个新图. 如果在旧图上直接操作, 后续计算会变得比较麻烦, 因此论文中的操作是在合并后修改IR, 然后基于新的IR重新构建干扰图.
邻接关系的储存方式
除了邻接向量+位矩阵的方式, 还可以用邻接表来储存邻接关系. 如果使用邻接表的话, 那么可以有以下的基本数据结构:
typedef int register_t; // 寄存器类型
typedef struct {
std::string name; // 节点名称
int value; // 节点值
std::vector<Node*> neighbors; // 邻接节点
register_t reg; // 寄存器分配, -1表示未分配
bool spilled; // 是否溢出
} Node;
typedef struct {
std::vector<Node*> nodes; // 图中的所有节点
std::stack<Node*> alloc_stack; // 分配栈
void removeNode(Node* node) {
// 邻接节点列表中删除节点
// 从图中删除节点
}
} Graph;
int simplifyGraph(Graph &g) {
int degree_less_than_R = 0;
int degree_greater_than_R = 0;
// iterate the nodes
// if node.degree < R, g.alloc_stack.push(node); then removeNode(node)
// if graph is empty (!degree_greater_than_R && !degree_less_than_R)), return 0
// if graph is not empty, recusively call simplifyGraph(g)
// if graph cannot be simplified, return -1 (degree_greater_than_R && !degree_less_than_R)
}
void spill(Graph &g) {
// rank the nodes
// choose the node to spill
// increasingly spill nodes according to the rank
for (auto& node : nodes_rank) {
g.removeNode(node);
node.spilled = true; // mark the node as spilled
if (simplifyGraph(g_tmp) == 0)
break;
}
// or other strategies
// there must be a solution to make the graph empty
// the worst case is to spill all nodes
}
void getFreeRegister(Node* node) {
// get a free register for the node
// if no free register, return -1
// else return the free register
}
void allocate(Graph &g) {
while (!g.alloc_stack.empty()) {
Node* node = g.alloc_stack.top();
g.alloc_stack.pop();
if (!node->spilled) {
node->reg = getFreeRegister(node); // get a free register
}
}
}
if (simplifyGraph(g) != 0)
spill(g); // spill the graph
allocate(g); // allocate registers
这段代码写得很👎, 它是有问题的, 大概能说明一个思路就行, 不要细究里面的问题🤪.
溢出策略
理论上讲, 溢出策略应该选择代价最小的. 怎么计算这个代价呢? 假设我们对干扰图化简到最后得到了一个非空图, 这个图中有
那么可以采用一些别的策略, 例如优先溢出那些对引入内存开销最小的节点. 假设每个变量的load/store
都要花费1个周期, 可以计算每个变量如果被溢出产生的内存开销, 例如在一个嵌套层级为n
的循环中, 某个溢出变量x
的内存开销就是2^n
. 这种做法的好处是能尽量减少最昂贵的溢出(这样你就不会溢出循环计数器了), 但溢出变量的度数可能比较小, 需要溢出很多个变量才能让图化简为空图. 这会导致一些问题, 一是干扰图迭代次数会增加, 二是所有溢出的变量的内存开销加起来并不一定会降低, 虽然单个溢出变量的内存开销小, 但要溢出的变量多了.
于是有了一种折中的策略, 对每个要尝试溢出的变量, 计算它的度数和内存开销的乘积, 或者进行其它的加权处理. 这个值越大, 表明这个变量的溢出优先级越高. 这种方法应该能在以上两种情况之间取得一些平衡, 但缺点是计算代价是最高的.
寄存器重用(Register Reload)
如果要对某个节点做溢出操作, 就要在定义它时插入store
指令, 在每次使用它时插入load
指令. 并且这个溢出操作是永久性的, 如果某个节点最终被决定溢出, 那么它被定义后将一直储存在内存中, 不会再为它分配寄存器.
a = x + 1;
c = x * 2;
d = x + 3;
如果x
是被溢出的, 那么上面的代码会被转换为
load R1, [address_of_x]
add R2, R1, #1
store R2, [address_of_x]
; ...something else...
load R1, [address_of_x]
mul R3, R1, #2
store R3, [address_of_x]
; ...something else...
load R1, [address_of_x]
add R4, R1, #3
store R4, [address_of_x]
所以如果x
被决定全局溢出, 但在某个局部区域中多次使用, 就会因为内存操作导致性能下降.
显然这里存在一个可优化的点, Chaitin算法是一个全局分配算法, 它基于全局中寄存器使用数量的峰值来决定寄存器的分配. 但在某个局部区域中, 很可能寄存器的必须使用量小于寄存器总数. 因此在局部区域中可能找到一个空闲的寄存器分配给x
. 如果x
的活跃区间横跨了几个局部区域, 那么每个局部区域中都可能找到空闲的寄存器分配给x
. 但并不保证每次找到的空闲寄存器都是同一个.
换句话说, 如果使用Chaitin算法+局部优化, 那一个变量被决定溢出, 并不代表它在整个活跃区间都在内存中, 而是大部分时间在内存中, 剩下的时间可能在不同的临时寄存器中.
局部优化在CodeGen阶段进行, 不属于Chaitin核心算法的一部分, 因此在这里不做讨论.
如果寄存器已满
Chaitin算法的分配结果中可能出现这样一个情况, 某个时刻寄存器已经满了, 又要使用溢出的变量进行计算(因为分配寄存器的时候使用了全部
这个时候就需要选择一个寄存器, 将它其中的未溢出变量临时储存到内存中, 然后将溢出变量加载到这个寄存器中进行计算. 这个被替换掉的变量称为"牺牲品"变量(Victim Variable), 编译器会为它插入load/store
指令, 临时将它放到内存中. 当溢出变量计算完成后, 再将牺牲品变量从内存中加载回来.
但关于牺牲品变量如何选择就不是Chaitin算法本身解决的问题了. Chaitin算法是一个全局算法, 它只能决定某个变量在活跃期间的大部分时间内待在哪里, 对局部优化无能为力.