活跃区间(Live Interval)
线性扫描中的核心概念是活跃区间(Live Interval). 首先以某种方式给一段中间表示(Intermediate Representation)中的伪指令编(Pseudo Instruction)号, 比如按照伪指令在中间表示中出现的顺序, 或者根据这段中间表示对应的数据流图(Dataflow)对伪指令进行深度优先编号(Depth-first).
对于某个变量v
, 其活跃区间的大概估计为v
第一次出现对应的伪指令的编号到v
最后一次出现对应的伪指令的编号减一(减一因为最后一次使用后v
所占的寄存器就可以被释放了). 用符号表示就是, 对于某个编号区间v
, 那么区间v
的活跃区间.
显然这只是一个"大致"估计, 因为在区间v
. 但对于线性扫描算法而言, 这个估计已经足够. 另外一点是, 假设这段中间表达共有
算法简述
线性扫描算法可以简述如下:
假设有
- 预处理: 分析代码中涉及到的变量, 为每一个变量生成对应的活跃区间
. 将这些活动区间按照 从小到大排序. - 初始化: 创建一个空的活跃列表(Active List), 追踪当前已分配寄存器且仍然处于活跃区间的变量. 创建
curr_point
用来追踪当前所处的伪指令编号 - 分配寄存器: 在为新变量分配寄存器时,
- 首先检查活跃列表并清理已经过期的变量, 也就是那些
的变量. 将它们从寄存器中释放掉. - 如果清理后有空闲的寄存器, 那么将新变量分配到空闲寄存器中, 并将其添加进活跃列表.
- 如果没有空闲的寄存器, 找出活跃列表中活跃区间最晚结束的变量, 与新变量的活跃区间结束位置进行比较. 如果后者结束位置更晚, 那么将新变量存入内存中; 如果后者结束位置更早, 那么将前者从寄存器中释放并存入内存中(称为"Spill"操作), 将新变量分配到该寄存器中.
- 首先检查活跃列表并清理已经过期的变量, 也就是那些
有一点细节需要注意, 活跃列表是按照其中变量的活跃区间结束位置
用图例说明一下算法的工作过程. 假设有5个变量A,B,C,D,E, 它们的活跃区间如图所示. 一共有
首先新建一个活跃列表active={}
.
- 对于变量A, 此时
active
为空, 直接将A分配到寄存器R1中, 并将其添加到活跃列表中. 此时active={A(2)}
. - 对于变量B, 此时
active={A(2)}
, 变量A没有过期, 而且还有空余寄存器, 因此将B分配到寄存器R2中, 并将其添加到活跃列表中, 即active={A(2),B(5)}
. - 对于变量C, 此时寄存器已满, 活跃列表中没有过期的变量, 活跃区间最晚结束的变量是B, 结束位置为5, 小于C的结束位置. 因此对C执行Spill操作, 将其存到内存中.
- 对于变量D, 此时变量A已经过期, 将A从活跃列表中删除, 释放寄存器R1. 将D分配到寄存器R1中, 并将其添加到活跃列表中, 即
active={B(5),D(6)}
. - 对于变量E, 此时变量B已经过期, 将B从活跃列表中删除, 释放寄存器R2. 将E分配到寄存器R2中, 并将其添加到活跃列表中, 即
active={D(6),E(8)}
.
最后的分配结果为:
变量名 | 寄存器 |
---|---|
A | R1 |
B | R2 |
C | 内存 |
D | R1 |
E | R2 |
我们考虑下面这段代码
int example() {
int a = 1;
int b = 2;
int c = a + b;
int d = 4;
int e = 5;
return c + (d + e);
}
它对应的活跃区间图应该是这样的:
那么根据以上的讨论, 仍然是变量C会被Spill到内存中, 转化为汇编大概会有以下的结果, 其中s0
是栈帧指针. 省略函数调用时的栈空间分配, 并且根据RISC-V约定函数返回值存放在a0
寄存器中.
example:
addi sp, sp, -16 # 分配栈空间
sw ra, 0(sp) # 保存返回地址 (RV32)
sw s0, 4(sp) # 保存栈帧指针 (RV32)
addi s0, sp, 16 # 设置栈帧指针
li a0, 1 # 加载a到寄存器a0
li t0, 2 # 加载b到寄存器t0
add a0, a0, t0 # 计算c
li t0, 4 # 加载d到寄存器t0
sw a0, -4(s0) # 将c存入内存
li a0, 5 # 加载e到寄存器a0
add a0, a0, t0 # 计算d+e
lw t0, -4(s0) # 将c从内存中加载到寄存器t0
add a0, a0, t0 # 计算c+(d+e)
ret
总之, 如果某个变量被Spill(溢出)到内存中, 就要为它插入一段load/store
命令用来将其存进/读出内存.
算法复杂度分析
考虑时间复杂度, 易知活跃列表的最大长度等于寄存器数量
如果算上预处理的排序过程, 由于排序的时间复杂度为
这个线性扫描算法思路简单, 时间复杂度不算高(相比图算法的
不过到这里应该能想到线性扫描算法的问题所在了. 如果某个变量被Spill到内存中, 那在它剩余的活跃期间中, 它将永远在内存中. 换句话说, Spill操作是永久性的. 这就导致如果有一个变量的活跃区间很长, 偏偏又在它的活跃区间后期才频繁使用, 就会因为频繁的内存读写导致性能下降.
其它缺点也同样显然, 线性扫描算法实际上是一个贪心算法, 它假设活跃区间最晚结束的变量是一段时间内是最少被使用到的变量, 而且活跃区间较早结束的那些变量在活跃区间结束后自然而然就被释放掉了. 但这种假设并不一定成立. 对于比较复杂的计算流图, 这么干可能导致过多的Spill操作, 而且中间表示的编号排序质量直接影响到活跃区间的估计质量, 进而影响寄存器分配的质量.
如果一段代码是这样的
int a = 1;
int b = 2;
int c = a + b;
a = 3;
这里变量a
的活跃区间是[1,4]
吗? 包括第四行吗? 实际上并不一定这样. 线性扫描算法是在中间表示上进行的. 如果中间表示采用的是SSA形式, 也就是静态单赋值, 那么变量a
的活跃区间应该是[1,3]
, 也就是不包括第四行. 例如上面的代码可以用以下中间表示表达
%a = arith.constant 1 : i32
%b = arith.constant 2 : i32
%c = arith.addi %a, %b : i32
%d = arith.constant 3 : i32 // 会创建一个新的SSA值
所以具体问题具体分析, 不过用SSA形式的中间表达显然会带来更好的寄存器分配质量.
代码实例(In Rust)
以下是一个简单的线性扫描算法的实现, 它打印出每个变量被分配到的寄存器名称. 用Rust写只是单纯因为最近在学Rust.
use std::collections::{HashMap, HashSet, BTreeMap};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct Variable(usize);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct Register(usize);
#[derive(Debug)]
struct LiveInterval {
var: Variable,
start: usize,
end: usize
}
struct LinearScanAllocator {
free_registers: Vec<Register>,
active_list: BTreeMap<usize, (Variable, Register)>,
allocations: HashMap<Variable, Register>,
spilled: HashSet<Variable>
}
impl LinearScanAllocator {
fn new(num_regs: usize) -> Self {
let free_registers = (0..num_regs).map(Register).collect();
Self {
free_registers,
active_list: BTreeMap::new(),
allocations: HashMap::new(),
spilled: HashSet::new()
}
}
fn expire_old_intervals(&mut self, curr_point: usize) {
let expired_ends: Vec<usize> = self
.active_list.keys()
.filter(|&&end| end <= curr_point)
.cloned()
.collect();
for end in expired_ends {
if let Some((_, reg)) = self.active_list.remove(&end) {
self.free_registers.push(reg);
}
}
}
fn handle_spill(&mut self, interval: LiveInterval) {
if let Some(&max_end, &(var, reg)) = self.active_list.iter().next_back() {
if interval.end > max_end {
self.spilled.insert(interval.var);
} else {
self.active_list.remove(&max_end);
self.active_list.insert(interval.end, (interval.var, reg));
self.spilled.insert(var);
self.allocations.insert(interval.var, reg);
}
}
}
fn allocate(&mut self, intervals: Vec<LiveInterval>) {
let mut sorted_intervals = intervals.clone();
sorted_intervals.sort_by_key(|interval| interval.start);
for interval in sorted_intervals {
self.expire_old_intervals(interval.start);
if self.free_registers.is_empty() {
self.handle_spill(interval);
} else {
let reg = self.free_registers.pop().unwrap();
self.allocations.insert(interval.var, reg);
self.active_list.insert(interval.end, (interval.var, reg));
}
}
}
fn get_allocations(&self) -> &HashMap<Variable, Register> {
&self.allocations
}
fn get_spilled(&self) -> &HashSet<Variable> {
&self.spilled
}
}
fn main() {
let intervals = vec![
LiveInterval { var: Variable(0), start: 1, end: 4 },
LiveInterval { var: Variable(1), start: 2, end: 5 },
LiveInterval { var: Variable(2), start: 3, end: 8 },
LiveInterval { var: Variable(3), start: 4, end: 6 },
LiveInterval { var: Variable(4), start: 5, end: 7 },
];
let mut allocator = LinearScanAllocator::new(2);
allocator.allocate(intervals);
for (var, reg) in allocator.get_allocations() {
println!("Variable {:?} allocated to Register {:?}", var, reg);
}
for var in allocator.get_spilled() {
println!("Variable {:?} spilled to memory", var);
}
}
运行以上程序, 应该能得到和前面的分析一致的结果. 需要注意的是, 同一个变量完全可以既在allocations
中又在spilled
中. 因为这两个成员储存的是寄存器分欸记录和溢出记录, 某个变量完全可以先被分配到某个寄存器中, 然后又被溢出到内存中.