活跃区间(Live Interval)

线性扫描中的核心概念是活跃区间(Live Interval). 首先以某种方式给一段中间表示(Intermediate Representation)中的伪指令编(Pseudo Instruction)号, 比如按照伪指令在中间表示中出现的顺序, 或者根据这段中间表示对应的数据流图(Dataflow)对伪指令进行深度优先编号(Depth-first).

对于某个变量v, 其活跃区间的大概估计为v第一次出现对应的伪指令的编号到v最后一次出现对应的伪指令的编号减一(减一因为最后一次使用后v所占的寄存器就可以被释放了). 用符号表示就是, 对于某个编号区间, 在编号的伪指令中不会出现变量v, 那么区间称为这个变量v的活跃区间.

显然这只是一个"大致"估计, 因为在区间中完全可以有一个子区间不涉及变量v. 但对于线性扫描算法而言, 这个估计已经足够. 另外一点是, 假设这段中间表达共有条伪指令, 那一定是所有变量的活跃区间, 但这个区间估计没有任何意义, 它无法提供任何信息.

算法简述

线性扫描算法可以简述如下:

假设有个变量, 个寄存器可供分配().

  1. 预处理: 分析代码中涉及到的变量, 为每一个变量生成对应的活跃区间. 将这些活动区间按照从小到大排序.
  2. 初始化: 创建一个空的活跃列表(Active List), 追踪当前已分配寄存器且仍然处于活跃区间的变量. 创建curr_point用来追踪当前所处的伪指令编号
  3. 分配寄存器: 在为新变量分配寄存器时,
    1. 首先检查活跃列表并清理已经过期的变量, 也就是那些的变量. 将它们从寄存器中释放掉.
    2. 如果清理后有空闲的寄存器, 那么将新变量分配到空闲寄存器中, 并将其添加进活跃列表.
    3. 如果没有空闲的寄存器, 找出活跃列表中活跃区间最晚结束的变量, 与新变量的活跃区间结束位置进行比较. 如果后者结束位置更晚, 那么将新变量存入内存中; 如果后者结束位置更早, 那么将前者从寄存器中释放并存入内存中(称为"Spill"操作), 将新变量分配到该寄存器中.

有一点细节需要注意, 活跃列表是按照其中变量的活跃区间结束位置从大到小排序的, 这样可以提高算法的效率.

用图例说明一下算法的工作过程. 假设有5个变量A,B,C,D,E, 它们的活跃区间如图所示. 一共有个寄存器.

首先新建一个活跃列表active={}.

  1. 对于变量A, 此时active为空, 直接将A分配到寄存器R1中, 并将其添加到活跃列表中. 此时active={A(2)}.
  2. 对于变量B, 此时active={A(2)}, 变量A没有过期, 而且还有空余寄存器, 因此将B分配到寄存器R2中, 并将其添加到活跃列表中, 即active={A(2),B(5)}.
  3. 对于变量C, 此时寄存器已满, 活跃列表中没有过期的变量, 活跃区间最晚结束的变量是B, 结束位置为5, 小于C的结束位置. 因此对C执行Spill操作, 将其存到内存中.
  4. 对于变量D, 此时变量A已经过期, 将A从活跃列表中删除, 释放寄存器R1. 将D分配到寄存器R1中, 并将其添加到活跃列表中, 即active={B(5),D(6)}.
  5. 对于变量E, 此时变量B已经过期, 将B从活跃列表中删除, 释放寄存器R2. 将E分配到寄存器R2中, 并将其添加到活跃列表中, 即active={D(6),E(8)}.

最后的分配结果为:

变量名寄存器
AR1
BR2
C内存
DR1
ER2

我们考虑下面这段代码

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命令用来将其存进/读出内存.

算法复杂度分析

考虑时间复杂度, 易知活跃列表的最大长度等于寄存器数量. 如果用平衡树来维护活跃列表的顺序, 那么每次插入/删除操作的时间复杂度都是. 假设一共有个变量, 显然每个变量都一定会被插入到活跃列表中, 那么可以得到线性扫描算法的时间复杂度为. 而最差时间复杂度为

如果算上预处理的排序过程, 由于排序的时间复杂度为, 那么总的时间复杂度应该接近, 因为寄存器数量一般是一个固定值.

这个线性扫描算法思路简单, 时间复杂度不算高(相比图算法的甚至可能更高). 因此线性扫描算法可以用在JIT(即时编译)中, 或者嵌入系统这些对编译期性能要求较高的场景中.

不过到这里应该能想到线性扫描算法的问题所在了. 如果某个变量被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中. 因为这两个成员储存的是寄存器分欸记录和溢出记录, 某个变量完全可以先被分配到某个寄存器中, 然后又被溢出到内存中.

论文原文

Linear Scan Register Allocation