对活跃性分析的颂歌;SSA 到寄存器
An ode to livelyness analysis; SSA to registers

原始链接: https://xnacly.me/posts/2026/an-ode-to-livelyness-analysis/

作者描述了 Purple Garden 语言的字节码虚拟机和 x86 JIT 的寄存器分配工作。它解释了在没有 phi 节点的 SSA IR 上的活跃性分析,使用块参数和终结器。值从线性化的块位置分配生命周期;一次遍历收集使用和定义以构建 (lo, hi) 范围。这个活跃集送入预分配阶段,将虚拟寄存器映射到物理寄存器或栈槽。随后的降低使用辅助函数来确保/定义寄存器,必要时溢出到栈。示例展示了优化后的 IR、生命周期注解,以及生成的 VM 字节码和 x86 JIT 输出。分配器将块转换视为边移动,并承诺未来提供更多细节。总体上,它涵盖 Purple Garden 的虚拟机和 JIT 的活跃性、分配、降低和生成代码。

Hacker News 上的一篇帖子链接到一篇关于活跃性分析和将 SSA 转换为寄存器的文章。评论者讨论了寄存器分配与 SSA 构建:一人指出前向遍历需要最后使用追踪,而后向遍历可以简化分配;另一人批评多次遍历,但作者回复说该语言是静态类型,遍历代价低。一条详细评论解释了 phi 节点、控制流图和支配树方法的困难,将 SSA 与 ANF/CPS 联系起来,并介绍了 Braun 的线性时间 SSA 构建方法(使用 SCC 与拓扑排序),以及线性扫描寄存器分配和 JIT 相关性。帖子还引用了一篇关于寄存器分配的论文。
相关文章

原文

Or a loveletter? Read this while listening to Ode to Joy, also look at this awesome pixelart i did just for this article:

livelyness analysis its analyzing a life 🤓

I am currently working on register allocation for both my register based bytecode virtual machine and my x86 just in time compiler, both for my programming language purple-garden.

Register allocation is assigning the unlimited amount of virtual registers to the limited set of storage resources hardware has, specifically the gprs (general purpose registers), the fprs (floating point registers) and the stack (see CPU Registers x86 for an examplary overview). The first step in this odeal is livelyness analsis.

THe simplest, yet somewhat non trivial example would be:

GARDEN

1fn f(a:Int n:Int acc:Int) Int {
2    match {
3        n < 1 { a + acc }
4        { f(a n-1 acc*n) }
5    }
6}

This is equiv to the following c code, which i “disassembled” from the very well written Linear scan register allocation on SSA article by Max Bernstein, in which i didnt really understand the IR display, before translating it to c and then to my own language (purple garden does not have loops 🤓 so this is only equiv when called with f(a n 1)):

C

1long f(long a, long n) {
2    long acc = 1;
3    while (n >= 1) {
4        acc = acc * n;
5        n = n - 1;
6    }
7    return a + acc;
8}

Feeding f(a,n,acc: Int): Int into purple gardens pipeline produces the following, optimised immediate representation (using -I), this means some values were dead and thus removed :

LLVM

 1fn f1(%v0:Int, %v1:Int, %v2:Int) -> Int {
 2        b0(%v0, %v1, %v2):
 3                %v4:Bool = ILt %v1, 1
 4                br %v4, b1(%v0, %v1, %v2), b2(%v0, %v1, %v2)
 5        b1(%v5, %v6, %v7):
 6                %v8:Int = IAdd %v5, %v7
 7                ret %v8
 8        b2(%v9, %v10, %v11):
 9                %v13:Int = ISub %v10, 1
10                %v14:Int = IMul %v11, %v10
11                tail f1(%v9, %v13, %v14)
12}

Purple gardens immediate representation is static single assigment (SSA) form based, meaning each value / virtual register is assigned exactly once and all are immutable. They are notated with %vN, for virtual register or value, whatever you (the reader) like more.

Compared to many other ssa irs, pgir does not use phi nodes. I opted for block params and terminators instead. It is made up of blocks, where each block has a list of parameters, a terminator and a body of instructions, where each instruction is in ssa form. All values are block local and the only interblock dataflow is facilitated via block params and block terminators.

LLVM

 1; block with id N
 2; |
 3; v
 4  b2(%v0, %v1, %v2):
 5;    ^^^^^^^^^^^^^ parameters 0..2
 6
 7;     value / virtual reg def
 8;      |        instr kind
 9;      |        |
10;      v        v
11      %v7:Int = ISub %v1, 1 ; <-- imm value
12;         ^           ^ argument
13;         |
14;         instr result type
15
16;     terminator with params
17      tail f1(%v0, %v7, %v8)

Or in rust terms:

RUST

 1pub struct Block<'b, A: Allocator = Global> {
 2    pub id: Id,
 3    pub instructions: Vec<Instr<'b>, A>,
 4    pub params: ParamsId,
 5    pub term: Option<Terminator>,
 6    // [...]
 7}
 8
 9pub enum Instr<'i> {
10    BinImm {
11        op: BinOp,
12        dst: TypeId<'i>,
13        lhs: Id,
14        imm: i32,
15    },
16    // [...]
17    Noop,
18}
19
20pub enum Terminator {
21    Return {
22        value: Option<Id>,
23    },
24    // [...]
25    Tail {
26        func: Id,
27        args: ParamsId,
28    },
29    // [...]
30}

Pretty easy right?

LEts look at the previous IR example, but this time with virtual register lifetime annotations (when the cli is invoked with -II):

LLVM

 1fn f1(%v0:Int, %v1:Int, %v2:Int) -> Int {             %v0 %v1 %v2 %v4 %v5 %v7 %v8 %v9 %v10 %v11 %v13 %v14
 2    b0(%v0, %v1, %v2):                                |   |   |
 3        %v4:Bool = ILt %v1, 1                         |   |   |   |
 4        br %v4, b1(%v0, %v1, %v2), b2(%v0, %v1, %v2)  |   |   |   |
 5    b1(%v5, %v6, %v7):                                                |   |
 6        %v8:Int = IAdd %v5, %v7                                       |   |   |
 7        ret %v8                                                               |
 8    b2(%v9, %v10, %v11):                                                          |   |    |
 9        %v13:Int = ISub %v10, 1                                                   |   |    |    |
10        %v14:Int = IMul %v11, %v10                                                |   |    |    |    |
11        tail f1(%v9, %v13, %v14)                                                  |             |    |
12}

This display enables a visual understanding of the livetimes of each value, encoded as lo for start, and hi for last, the following enables a non visual understanding: (The positions are linearised: block params are at the block base, block instructions are at block base+1+instr pos and terminators are at block base+instr count+1)

valuelohi
%v002
%v102
%v202
%v412
%v534
%v734
%v845
%v969
%v1068
%v1168
%v1379
%v1489

With that out of the way the algorithm amounts to a simple:

  1. walk blocks
  2. visit terminator, observe all virtual register usages
  3. visit instructions in reverse, observe all virtual register definition and usages
  4. visit parameters to block, observe each

Observing

Where observing is defined as:

  • with tuple (lo,hi) and pos
  • if hi==0; set hi=pos
  • lo=pos

In oxidised terms:

RUST

1/// observer Id := (lo,hi), always updates lo, only fills hi, if previously unset
2fn observe<V: Allocator>(out: &mut Vec<(u32, u32), V>, id: Id, pos: u32) {
3    let idx = id.0 as usize;
4    let e = &mut out[idx];
5    if e.1 == 0 {
6        e.1 = pos;
7    }
8    e.0 = pos;
9}

Helpers to help with defs and usages:

Asking for defs and uses in instructions and terminators:

What it says, Func::def_of returns a definition of a virtual register, if instr has one:

RUST

 1impl Func<'_> {
 2    #[must_use]
 3    pub fn def_of(instr: &Instr<'_>) -> Option<Id> {
 4        match instr {
 5            Instr::Alloc { dst, .. }
 6            | Instr::Load { dst, .. }
 7            | Instr::AddrOf { dst, .. }
 8            | Instr::Bin { dst, .. }
 9            | Instr::BinImm { dst, .. }
10            | Instr::LoadConst { dst, .. }
11            | Instr::Call { dst, .. }
12            | Instr::Sys { dst, .. }
13            | Instr::Cast { dst, .. }
14            | Instr::Lookup { dst, .. }
15            | Instr::Assert { dst, .. } => Some(dst.id),
16            Instr::Store { .. } | Instr::Noop => None,
17        }
18    }
19}

Calling f for each usage of value in an instruction

What it says, calling f for each value usage in an instruction

RUST

 1pub fn for_each_use_of_instr(&self, instr: &Instr<'_>, mut f: impl FnMut(Id)) {
 2    match instr {
 3        Instr::Bin { lhs, rhs, .. } => {
 4            f(*lhs);
 5            f(*rhs);
 6        }
 7        Instr::BinImm { lhs, .. } => f(*lhs),
 8        Instr::Call { args, .. } | Instr::Sys { args, .. } => {
 9            for &a in self.params(*args) {
10                f(a);
11            }
12        }
13        Instr::Cast { from, .. } => f(from.id),
14        Instr::Lookup { subject, .. } => f(*subject),
15        Instr::Store { src, base, .. } => {
16            f(*src);
17            f(*base);
18        }
19        Instr::Assert { cond, .. } => f(*cond),
20        Instr::Load { base, .. } | Instr::AddrOf { base, .. } => f(*base),
21        Instr::LoadConst { .. } | Instr::Noop | Instr::Alloc { .. } => {}
22    }
23}

Calling f for each usage of value in a terminator

What it says, calling f for each value usage in a terminator

RUST

 1pub fn for_each_use_of_term(&self, term: &Terminator, mut f: impl FnMut(Id)) {
 2    match term {
 3        Terminator::Return {
 4            value: Some(id), ..
 5        } => f(*id),
 6        Terminator::Return { value: None, .. } => {}
 7        Terminator::Jump { params, .. } => {
 8            for &p in self.params(*params) {
 9                f(p);
10            }
11        }
12        Terminator::Tail { args, .. } => {
13            for &a in self.params(*args) {
14                f(a);
15            }
16        }
17        Terminator::Switch {
18            subject,
19            cases,
20            default: (_, default_params),
21            ..
22        } => {
23            f(*subject);
24            for case in self.cases(*cases) {
25                for &p in self.params(case.target.1) {
26                    f(p);
27                }
28            }
29            for &p in self.params(*default_params) {
30                f(p);
31            }
32        }
33        Terminator::Branch {
34            cond,
35            yes: (_, yes_params),
36            no: (_, no_params),
37            ..
38        } => {
39            f(*cond);
40            for &p in self.params(*yes_params) {
41                f(p);
42            }
43            for &p in self.params(*no_params) {
44                f(p);
45            }
46        }
47        Terminator::BranchCmpImm {
48            lhs,
49            yes: (_, yes_params),
50            no: (_, no_params),
51            ..
52        }
53        | Terminator::BranchCmp {
54            lhs,
55            yes: (_, yes_params),
56            no: (_, no_params),
57            ..
58        } => {
59            f(*lhs);
60            if let Terminator::BranchCmp { rhs, .. } = term {
61                f(*rhs);
62            }
63            for &p in self.params(*yes_params) {
64                f(p);
65            }
66            for &p in self.params(*no_params) {
67                f(p);
68            }
69        }
70    }
71}

Fully rusted together:

A few quick context injectors:

  • out is a reusable Vec allocated in the V allocator, thats why its reset and resized
  • UNSET is used as the default empty value
  • Func::next_id() returns the highest id defined in the function (self)+1

RUST

 1impl<A: Allocator> Func<'_, A> {
 2    pub fn live_set_into<V: Allocator>(&self, out: &mut Vec<(u32, u32), V>) {
 3        const UNSET: (u32, u32) = (u32::MAX, 0u32);
 4
 5        out.clear();
 6        out.resize(self.next_id() as usize, UNSET);
 7
 8        // observe defined as before
 9
10        let mut base = 0;
11        for block in self.blocks.iter().filter(|b| !b.tombstone) {
12            let block_offset = block.instructions.len();
13            let offset = base;
14            if let Some(term) = &block.term {
15                self.for_each_use_of_term(term, |id| {
16                    // term is instruction count +1
17                    observe(out, id, (offset + block_offset + 1) as u32)
18                });
19            }
20
21            for (i, instr) in block.instructions.iter().enumerate().rev() {
22                // +1, so it cant start at 0, since thats where the params start :)
23                let pos = (base + 1 + i) as u32;
24                self.for_each_use_of_instr(instr, |id| observe(out, id, pos));
25                if let Some(def) = Func::def_of(instr) {
26                    observe(out, def, pos)
27                }
28            }
29
30            for &p in self.params(block.params) {
31                observe(out, p, base as u32);
32            }
33
34            // params + instructions + terminator
35            base += block_offset + 2;
36        }
37    }
38}

Since we now know where what value is alive and where it is to be considered dead we can pass this information to the register allocator and ask it for the register or stack slot of any value we have to fit into hardware or virtual machine constraints.

Prep and register allocation prepass

When compiling the IR to bytecode for the purple garden vm, the first step is to compute the liveset, with the algorithm introduced before and then performing the preallocation pass with the Cc::prealloc method:

RUST

 1impl<'cc, S: Allocator + Clone, A: Allocator + Clone> Cc<'cc, S, A> {
 2    fn cc<F: Allocator>(
 3        &mut self,
 4        fun: &'cc Func<'cc, F>,
 5        native: Option<&mut purple_garden_jit::CodeArena<impl Pages>>,
 6    ) {
 7        // [...]
 8
 9        // compute liveset
10        fun.live_set_into(&mut self.live_set);
11
12        // walk the IR once before lowering, after this its just a simple
13        // lookup
14        self.prealloc(fun);
15
16        // [...] iterate blocks
17
18            // [...] iterate instructions
19
20                // secret postprocessing of instructions which dst points to the stack
21                if let Some(Location::Stack(slot)) =
22                    Func::def_of(instruction).and_then(|dst| self.regalloc.location(dst))
23                {
24                    self.emit(Op::StoreSlot { slot, src: TMP });
25                }
26    }
27}

Lowering Bin ir ops

Bin is the baseline operator. The BinImm ops we see in our example (ISub %v10, 1) are optimisations (one less dispatch, less memory interactions) compiled in the same way, just with an immediate rhs:

RUST

 1impl<'cc, S: Allocator + Clone, A: Allocator + Clone> Cc<'cc, S, A> {
 2    fn instr<F: Allocator>(&mut self, owner: &'cc Func<'cc, F>, i: &'cc ir::Instr<'cc>) {
 3        match i {
 4            // [...] all the other IR instructions
 5            ir::Instr::Bin {
 6                op, dst, lhs, rhs, ..
 7            } => {
 8                // interacting with the register alloc prepass
 9                let dst = self.def_register(dst.id);
10                let lhs = self.ensure_register(*lhs);
11                let rhs = self.ensure_register(*rhs);
12
13                macro_rules! emit_bins {
14                    ($($name:ident),*) => {
15                        match op {
16                            $(
17                                ir::BinOp::$name => {
18                                    Op::$name {
19                                        dst,
20                                        lhs,
21                                        rhs,
22                                    }
23                                },
24                            )*
25                        }
26                    };
27                }
28
29                self.emit(emit_bins! {
30                    IAdd, ISub, IMul, IDiv, IMod, IAnd, ILt, IGt, IEq,
31                    DAdd, DSub, DMul, DDiv, DLt, DGt,
32                    BEq, SEq
33                });
34            }
35            ir::Instr::BinImm {
36                op, dst, lhs, imm, ..
37            } => {
38                let dst = self.def_register(dst.id);
39                let lhs = self.ensure_register(*lhs);
40                let imm = *imm;
41
42                macro_rules! emit_bin_imms {
43                    ($($ir:ident => $op:ident),* $(,)?) => {
44                        match op {
45                            $(ir::BinOp::$ir => Op::$op { dst, lhs, imm },)*
46                            _ => unreachable!("only integer immediate binops are represented as BinImm"),
47                        }
48                    };
49                }
50
51                self.emit(emit_bin_imms! {
52                    IAdd => IAddI,
53                    ISub => ISubI,
54                    IMul => IMulI,
55                    IDiv => IDivI,
56                    IMod => IModI,
57                    IAnd => IAndI,
58                    IEq  => IEqI,
59                    IGt  => IGtI,
60                    ILt  => ILtI,
61                });
62            }
63        }
64    }
65}

This is so clean and a nice use of rust macros, i love this, dont @me; lowering all binary and binary with immediate value operations in 57 lines is where its at.

Ensuring and definining a register

Two helpers for making sure a requested virtual register is accessible to a point where operations can be applied on it, the first one being ensure_register:

RUST

 1/// Translate virtual register into vm register, abstracts spilling, restoring and register
 2/// allocation in general away
 3fn ensure_register(&mut self, id: Id) -> u8 {
 4    match self.regalloc.location(id) {
 5        Some(Location::Reg(r)) => return r.num(),
 6        // requested value lives on the stack, we need to restore it and we use a temp
 7        // register
 8        Some(Location::Stack(slot)) => {
 9            let dst = self.next_tmp();
10            self.emit(Op::LoadSlot { dst, slot });
11            dst
12        }
13        None => unreachable!("%v{} has no location", id.0),
14    }
15}
16
17fn next_tmp(&self) -> u8 {
18    match self.buf.last() {
19        Some(Op::LoadSlot { dst: SCRATCH, .. }) => TMP,
20        _ => SCRATCH,
21    }
22}

This means if a value is requested and:

  • the prepass assigned it to a register, thats perfect and we can simply use it
  • or if its on the stack we have to ask the compiler state for a temporary register and materialise the value into it

Temporary registers in the virtual machine are defined as SCRATCH and TMP, respectively in the preamble of the virtual machines source code:

RUST

 1pub const REGISTER_COUNT: usize = 32;
 2const SCRATCH: u8 = REGISTER_COUNT as u8 - 1;
 3const TMP: u8 = SCRATCH - 1;
 4
 5/// Every vm register but [`SCRATCH`] and [`TMP`], r0 last since xralloc2 hands
 6/// out the lowest free one first, which also puts the caller-saved registers
 7/// before the callee-saved ones.
 8const POOL: [u8; TMP as usize] = {
 9    let mut pool = [0; TMP as usize];
10    let mut i = 0;
11    while i < TMP as usize {
12        pool[i] = (TMP as usize - 1 - i) as u8;
13        i += 1;
14    }
15    pool
16};

Thusfar ensure is simple to understand, the other helper is def_register, which means we, again, take the registers number, if it has one, otherwise we take TMP, which our previously introduced (and probably not noticed by anyone) snipped at the end of the codeblock in Prep and register allocation prepass then “spills” into its slot on the stack with the respective bytecode instruction.

RUST

1/// The register an op writes `id` into, [`TMP`] if `id` lives in a slot: the block loop stores
2/// it there after the op.
3fn def_register(&self, id: Id) -> u8 {
4    match self.regalloc.location(id) {
5        Some(Location::Reg(r)) => r.num(),
6        Some(Location::Stack(_)) => TMP,
7        None => unreachable!("%v{} has no location", id.0),
8    }
9}

All the bytes and the codes together:

Swapping -I for -D (with --no-jit, otherwise we would get machinecode 🤓), results in the following virtual machine registers assigned to the ssa values:

ASM

 100000000 <f>:
 2  0000:    ilt_imm r3, r1, #1        ; 4: n < 1 { a + acc }
 3  0001:    jmpf r3, 0004 <f.bb_0004>
 4  0002:    iadd r0, r0, r2
 5  0003:    ret
 600000004 <f.bb_0004>:
 7  0004:    isub_imm r3, r1, #1       ; 5: { f(a n-1 acc*n) }
 8  0005:    imul r2, r2, r1
 9  0006:    mov r1, r3
10  0007:    tail 0000 <f>

JIT :O

For the crazy hackermans, here is the same example with the jit on, jit and bc share IR, liveness analysis, register allocation and standard libary, aint that fun?

ASM

 10000000000000000 <jit_f>:
 2   0:   48 81 fa 01 00 00 00    cmp    $0x1,%rdx
 3   7:   49 c7 c0 00 00 00 00    mov    $0x0,%r8
 4   e:   41 0f 9c c0             setl   %r8b
 5  12:   4d 85 c0                test   %r8,%r8
 6  15:   0f 84 0b 00 00 00       je     26 <jit_f+0x26>
 7  1b:   48 01 ce                add    %rcx,%rsi
 8  1e:   48 89 f0                mov    %rsi,%rax
 9  21:   c3                      ret
10  22:   cc                      int3
11  23:   cc                      int3
12  24:   cc                      int3
13  25:   cc                      int3
14  26:   49 89 d0                mov    %rdx,%r8
15  29:   49 81 e8 01 00 00 00    sub    $0x1,%r8
16  30:   49 89 c9                mov    %rcx,%r9
17  33:   4c 0f af ca             imul   %rdx,%r9
18  37:   4c 89 c2                mov    %r8,%rdx
19  3a:   4c 89 c9                mov    %r9,%rcx
20  3d:   e9 be ff ff ff          jmp    0 <jit_f>
21  42:   c3                      ret

Dont be mean to the x86 output, i know this could be shorter, the compare and jmp could be a single instr and there is a lot of dead code and long encodings, but this is my first jit and im glad it works at all :)

The attentive reader may have figured out how i have described the inputs and ouputs of the register allocator, respectively the liveset (and the pool of regs) and assignment of virtual to physical register, but have not actually shown or introduced the register allocator itself, or the results of the IR design which forces the regalloc to treat all block transitions (jumps, entries, returns) as edge moves and has to do extra work to resolve these.

If my motivation holds up i will write a follow up article about these topics, but right now i am still in the process of “perfecting” the interactions between my ir, my regalloc and my backends, so maybe stay tuned? I have an rss feed if anyone is actually at the end of the abyss im screaming my incohesive blabbering into.

联系我们 contact @ memedata.com