寄存器分配问题?

最近想自己写个编译器,到了寄存器分配这一步,有很大的疑惑,不知能否后序遍历表达式树来分配寄存器?寄存器分配有没有什么可参考的简单实现?
关注者
534
被浏览
21345
寄存器分配有很多做法。当然有可以通过后序遍历表达式树来分配寄存器。

引用University of Washington CSE401关于寄存器分配的课件
Varieties of register allocation

Register allocation may be performed at many levels:
  • Expression tree
  • Local (basic block)
  • Loop
  • Global (routine)
  • Interprocedural

Global optimization suggests global register allocation.
在表达式树层面上做寄存器分配是其中比较简单的一种,比local(在基本块内做分配)更弱一些。

(这个回答的剩余部分其实并不长,只是列举了两种简易思路以及各自的小变种,文字很少,只是举例用的代码占篇幅多而已。请跟我一起默念三次“文章不长能读下去”然后读到底 >_<)

===================================================

正统的做法大多是global的,例如:
  • 图着色(graph coloring);
  • 线性扫描(linear-scan);
  • 以上两者的一些变种,像是Graph Fusion,Graph Coloring over SSA,Linear-Scan over SSA,Extended Linear Scan,Greedy Linear Scan(LLVM的Greedy分配器);
  • 以及一些比较新奇或暂时还比较冷门的,例如PBQP、puzzle solving之类,这些都有实现在一些编译器里。还有一些没怎么实现在编译器里的,像是王垠大大提出的Model Transformer Semantics之类。

商用的编译到native code的编译器如果不好好做寄存器分配的话那都得是渣渣,不用找借口。

渣渣的例子之一是Dalvik VM的JIT编译器:都什么年代了还用奇怪的local heuristic来做分配;而且更搞笑的是采用local heuristic之前其实实现过linear-scan,但是发现效果不好所以在产品里没启用——因为实现得太糟糕了orz
Remove vestiges of code intended for linear scan register allocation in the trace compiler. New plan is to stick with local allocation for traces and build a new linear scan allocator for the method compiler.

========================================================

然后有一些从现在的角度看不那么正统的、更为简易的做法。
自己出于学习目的写入门级编译器时,先从简单的做法开始未尝不可。有不少教学用的入门级编译器可以说是跳过了寄存器分配的这一环。

这里让我们先从看似简单但不太现实的做法开始,逐步进化到简单又现实的做法。
下面介绍的都是所谓“on-the-fly”寄存器分配的做法,因为它们不需要单独遍历一趟IR,而直接融合在代码生成(code generation)阶段就好了。

最简单的就是:不分配!

把目标平台提供的通用寄存器都用作特殊用途,而把函数的局部变量和表达式临时值都放在栈帧里。

就算目标平台的ABI要求的调用约定(calling convention)是通过寄存器来传递参数和返回值也没关系:在函数入口的地方就把所有通过寄存器传递的参数都存到栈上,然后在临返回之前把返回值读到返回用的寄存器里。

用一个例子来说明这种做法的效果:
int foo(int a, int b, int c, int d) {
  int e = a * b + c * d;
  return e + d;
}
这个函数有一个基本块,其中有两个语句,也就是说有两棵表达式树(每个语句有一棵)。

让我们把后序遍历表达式树的过程表现为线性代码,那么对应的遍历过程可以是:
// int e = a * b + c * d;
_t0 = a * b
_t1 = c * d
e = _t0 + _t1
// return e + d;
_t0 = e + d
return _t0
其中_t开头的是计算表达式所产生的临时值。
语句之间可以复用临时变量,因为临时变量的生命期仅在一棵表达式树内,而表达式树不会跨越语句的边界。所以上面例子提到的_t0、_t1这些临时变量都可以复用。

如果我们把这个函数里出现的所有具名参数、局部变量,以及匿名的临时值,都划分到栈帧的固定位置上,那我们就可以得到这样的栈帧:
 sp -> -56 [ tmp 1: _t1     ] ^ (lower address)
       -48 [ tmp 0: _t0     ] |
       -40 [ loc 0: e       ] |
       -32 [ spill arg 3: d ] |
       -24 [ spill arg 2: c ] | stack growth
       -16 [ spill arg 1: b ] |
       -8  [ spill arg 0: a ] |
 fp -> +0  [ old fp         ] |  
       +8  [ return address ] | (higher address)
(这里偷个懒用固定8字节宽的stack slot,无论是4字节还是8字节宽的值都占用一个slot)

假定我们要遵循Linux x86-64的ABI(System V AMD64 ABI),那么传入的头4个整型参数会从寄存器RDI、RSI、RDX、RCX传入,而返回值会从RAX传出。

那么上述线性代码就可以生成为下面的伪代码:
  // register usage:
  // rbp: frame pointer
  // rsp: stack pointer
  // arguments: rdi, rsi, rdx, rcx
  // temporary register: rax
  // return: rax

  // function prologue: frame setup
  push rbp          // save old fp
  rbp = rsp         // set up new fp
  rsp -= 56         // allocate new stack frame: 7 slots * 8 bytes per slot
  // spill incoming arguments from registers to stack
  [rbp - 8]  = rdi  // spill a
  [rbp - 16] = rsi  // spill b
  [rbp - 24] = rdx  // spill c
  [rbp - 32] = rcx  // spill d

  // expression tree for:
  //  int e = a * b + c * d;

  // _t0 = a * b
  eax = [rbp - 8]   // load a into temp register
  eax *= [rbp - 16] // load b and multiply to temp register
  [rbp - 48] = eax  // store temp to stack _t0

  // _t1 = c * d
  eax = [rbp - 24]  // load c into temp register
  eax *= [rbp - 32] // load d and multiply to temp register
  [rbp - 56] = eax  // store temp to stack: _t1

  // e = _t0 + _t1
  eax = [rbp - 48]  // load _t0 into temp register
  eax += [rbp - 56] // load _t1 and add to temp register
  [rbp - 40] = eax  // store temp to stack: e

  // expression tree for:
  //   return e + d;

  // _t0 = e + d
  eax = [rbp - 40]  // load e into temp register
  eax += [rbp - 32] // load d and add to temp register
  [rbp - 48] = eax  // store temp to stack: _t0

  // return _t0
  eax = [rbp - 48]  // load _t0 into return register

  // function epilogue: tear down frame
  rsp = rbp         // deallocate stack frame
  pop rbp           // restore old frame pointer
  return
这样我们就可以完全不关心寄存器分配的问题,只要计算好栈帧布局,把参数、局部变量和临时值都放进去,就完事了。

当然,上面生成的代码有些冗余,其中部分可以通过窥孔优化(peephole optimization)来消除。
例如说我们特意选择使用RAX作为临时寄存器,而它同时也是返回值寄存器。所以在return _t0的时候,我们知道RAX其实还持有_t0的值,所以最后返回前的那个拷贝(eax = _t0)就可以消除掉。

上面例子所使用的思路可以概括为“基于虚拟寄存器”:每个变量(包括参数、局部变量和临时值)都看作一个“虚拟寄存器”。

虚拟寄存器映射到实际寄存器最简单的办法就是“不映射”——而是把每个虚拟寄存器都分配到栈帧上的一个slot里。外加把一个或多个实际寄存器固定分配为“临时寄存器”,在做实际运算时把虚拟寄存器的内容从栈帧的stack slot里读到临时寄存器,在临时寄存器上完成运算,然后再把结果保存回到栈帧上的虚拟寄存器里,代码生成完成了。

========================================================

自然的,既然我们都用上“虚拟寄存器”这个词了,为何不把它们(至少其中一些)映射到实际寄存器上呢?

这就衍生出一种非常简单的寄存器分配策略:把临时变量分配到实际寄存器上。这里的假设是:表达式求值是频繁的操作,其中间结果应该尽量停留在实际寄存器里,这样就已经能提升代码性能了。

上文已经提到,从表达式树生成出来的临时变量的生命期仅限于表达式树内;它们可以在语句间复用。所以这种分配思路就是一种在表达式树层面上做的寄存器分配。

这种思路下最简单的分配策略就是挑选若干个实际寄存器,把临时变量的头几个固定映射到这些寄存器上。

假如我们用以下的固定映射:
_t0 => rcx
_t1 => rdi
_t2 => rsi
...
并且还是保留RAX作为临时寄存器,那么前面例子中的int e = a * b + c * d;就可以生成为:
  // _t0 = a * b
  eax = [rbp - 8]   // load a into temp register
  eax *= [rbp - 16] // load b and multiply to temp register
  ecx = eax         // move temp to _t0

  // _t1 = c * d
  eax = [rbp - 24]  // load c into temp register
  eax *= [rbp - 32] // load d and multiply to temp register
  edi = eax         // move temp to _t1

  // e = _t0 + _t1
  eax = ecx         // move _t0 into temp register
  eax += edi        // add _t1 to temp register
  [rbp - 40] = eax  // store temp to stack: e
可以看到:
  • 之前把虚拟寄存器完全映射到栈上时,这棵表达式树有6次内存读,3次内存写,0次寄存器间移动(register shuffling);
  • 而把临时变量映射到固定寄存器上之后,就只有4次内存读,1次内存写,3次寄存器间移动了。
这样性能就已经比把虚拟寄存器完全映射到栈帧上要好了。

把上面的foo()例子交给GCC 4.9.2做-O0编译,得到的代码是(Intel语法):
// stack frame layout:
//       -32 [ spilled d     ]
//       -28 [ spilled c     ]
//       -24 [ spilled b     ]
//       -20 [ spilled a     ]
//       ...
//       -4  [ local   e     ]
// fp -> +0  [ old fp        ]
//       +8  [ return address]

foo(int, int, int, int):
  // function prologue: setup frame
  push	rbp
  mov	rbp, rsp
  // spill incoming arguments onto stack
  mov	DWORD PTR [rbp-20], edi // a
  mov	DWORD PTR [rbp-24], esi // b
  mov	DWORD PTR [rbp-28], edx // c
  mov	DWORD PTR [rbp-32], ecx // d

  // expression tree for:
  //  int e = a * b + c * d;

  // _t0 = a * b
  mov	eax, DWORD PTR [rbp-20]
  imul	eax, DWORD PTR [rbp-24]
  mov	edx, eax

  // _t1 = c * d
  mov	eax, DWORD PTR [rbp-28]
  imul	eax, DWORD PTR [rbp-32]

  // e = _t0 + _t1
  add	eax, edx
  mov	DWORD PTR [rbp-4], eax

  // expression tree for:
  //   return e + d;

  // _t0 = e + d
  mov	edx, DWORD PTR [rbp-4]  // e
  mov	eax, DWORD PTR [rbp-32] // d
  add	eax, edx

  // function epilogue: tear down frame
  pop	rbp
  ret
跟上述的简单映射方式得到的代码质量也差不多了。
只看int e = a * b + c * d;的部分,GCC在-O0生成的代码用了4次内存读,1次内存写,1次寄存器间移动,略好于上面的简单映射法。

读到这里,如果再去看另一个问题应该就不会觉得陌生了:Visual C++ 6以debug模式编译很拙笨,为何要做无用功? - RednaxelaFX 的回答

========================================================

在“基于虚拟寄存器”+“固定映射临时变量”的基础上,还可以进一步做一些改进:
  • 尽可能把可用的实际寄存器都映射给临时变量;
  • 如果在一个基本块里,临时变量并没有消耗完所有可用的实际寄存器,那么可以把一些局部变量也映射到寄存器上。映射策略可以用“先到先得、轮询分配”,也可以用“使用频度高者优先”,等等;
  • 上一点可以进一步扩展到整个函数里;

再要往上提高性能,如果继续沿着这种小补小改的做法走下去就可能会代码越来越混乱,夹杂着各种随意(ad-hoc)的局部小优化。所以再要继续下去还是转用本文开头说的那些正统算法比较好。

========================================================

既然提到了“基于虚拟寄存器”的思路,接下来就该说说与其相对的另一种思路:“基于(表达式)栈”。

所谓“表达式栈”就是用来存放表达式临时值的地方。“基于虚拟寄存器”的做法是给每个临时值都赋予一个“临时变量”的名字;而“基于表达式栈”则不赋予“临时变量”的名,总是通过栈来隐式操作临时值。

在这种思路下,我们还是可以把参数和局部变量都固定放在栈帧上,然后在栈帧里找块地方当作“表达式栈”来用。
JVM以及不少高级语言虚拟机的字节码设计就是照这个思路走的。

以JVM字节码为例,它就是把局部变量和表达式临时值都放在栈帧里;其中局部变量放在栈帧的局部变量区(local variable area)里,而表达式临时值则放在栈帧的“操作数栈”(operand stack)或者叫“表达式栈”(expression stack)里。
要看图解的话,请参考我之前做的一份讲义,从第76页开始的部分。
关于Java源码与JVM字节码的对应关系,我在另一个回答里举过些例子:如何理解ByteCode、IL、汇编等底层语言与上层语言的对应关系? - RednaxelaFX 的回答

继续用前面的代码来举例,我们可以看看这种思路的最基本实现是怎样的。

首先,把后序遍历表达式树的结果从下面的线性代码来表示。这其实就是逆波兰记法(Reverse-Polish Notation)。
注释里是完成该语句后表达式栈里的内容,右边表示栈顶:
                   // expression stack:
                   // [ ]

// int e = a * b + c * d;

load a             // [ a ]
load b             // [ a, b ]
mul                // [ _t0 ]      // _t0 = a * b

load c             // [ _t0, c ]
load d             // [ _t0, c, d ]
mul                // [ _t0, _t1 ] // _t1 = c * d

add                // [ _t2 ]      // _t2 = _t0 + _t1

store e            // [ ]          // e = _t2

// return e + d;

load e             // [ e ]
load d             // [ e, d ]
add                // [ _t0 ]      // _t0 = e + d
return             // [ ]          // return _t0
清楚可见语句之间表达式栈会是空的。这个例子里表达式栈最大深度为3。

表达式栈比起虚拟寄存器有个有趣的好处:前者明确的表达了临时值的生命期有多长——还在表达式栈上的临时值就是活的,离开了表达式栈就死掉了;后者则没有把这种信息直接表达出来,如果要知道临时值的生命期还得另外计算和记录。

然后让我们用最简单的方式生成代码。先是计算栈帧布局,跟之前的做法类似:
                                (lower address)
       -64 [                ] ^ \ 
       -56 [                ] | | reserved for expression stack
       -48 [... exp stack...] | /
 sp -> -40 [ loc 0: e       ] |
       -32 [ spill arg 3: d ] |
       -24 [ spill arg 2: c ] | stack growth
       -16 [ spill arg 1: b ] |
       -8  [ spill arg 0: a ] |
 fp -> +0  [ old fp         ] |  
       +8  [ return address ] | (higher address)

// 4 spill slots, 1 local slot, max operand stack depth 3

然后用RAX和RDX作为固定的临时寄存器,按照Linux x86-64 ABI生成出来的代码就是(伪代码):
  // register usage:
  // rbp: frame pointer
  // rsp: stack pointer
  // arguments: rdi, rsi, rdx, rcx
  // temporary register: rax, rdx
  // return: rax

  // function prologue: frame setup
  push rbp          // save old fp
  rbp = rsp         // set up new fp
  rsp -= 40         // allocate new stack frame: 5 slots * 8 bytes per slot
  // spill incoming arguments from registers to stack
  [rbp - 8]  = rdi  // spill a
  [rbp - 16] = rsi  // spill b
  [rbp - 24] = rdx  // spill c
  [rbp - 32] = rcx  // spill d

  // expression tree for:
  //  int e = a * b + c * d;

  // load a
  push [rbp - 8]    // push a onto expression stack

  // load b
  push [rbp - 16]   // push b onto expression stack

  // mul
  pop rdx           // pop rhs to temp register rdx
  pop rax           // pop lhs to temp register rax
  eax *= edx        // do mul
  push rax          // push result onto expression stack

  // load c
  push [rbp - 24]   // push c onto expression stack

  // load d
  push [rbp - 32]   // push d onto expression stack

  // mul
  pop rdx           // pop rhs to temp register rdx
  pop rax           // pop lhs to temp register rax
  eax *= edx        // do mul
  push rax          // push result onto expression stack

  // add
  pop rdx           // pop rhs to temp register rdx
  pop rax           // pop lhs to temp register rax
  eax += edx        // do add
  push rax          // push result onto expression stack

  // store e
  pop [rbp - 40]    // pop top of expression stack to e

  // expression tree for:
  //   return e + d;

  // load e
  push [rbp - 24]   // push c onto expression stack

  // load d
  push [rbp - 32]   // push d onto expression stack

  // add
  pop rdx           // pop rhs to temp register rdx
  pop rax           // pop lhs to temp register rax
  eax *= edx        // do add
  push rax          // push result onto expression stack

  // return
  pop rax           // pop top of expression stack to return register

  // function epilogue: tear down frame
  rsp = rbp         // deallocate stack frame
  pop rbp           // restore old frame pointer
  return
如果只看int e = a * b + c * d;的部分,这种做法用了10次内存读,8次内存写,0次寄存器间移动。push mem是1读1写,push reg是1写,pop mem是1读1写,pop reg是1读。
…看起来有点糟糕?比之前“基于虚拟寄存器”的做法糟糕多了?

这里显得很糟糕是因为x86的运算指令本质上还是倾向于使用寄存器的,不接受两个操作数都是内存操作数的形式,更没有内建的指令能直接把栈顶两个数弹出->做运算->压回栈顶,所以这里需要用临时寄存器来模拟一下,看起来就“糟糕”了。

另外我上面生成的代码严格保持了操作数的顺序。如果应用上整数的加法和乘法满足交换律,就可以把运算部分的代码略微简化。例如add的部分就可以变成:
  // add
  pop rax           // pop rhs to temp register rax
  [rsp + 0] += eax  // load lhs, do add, and store back to stack top
但是从内存读写次数看这跟原先的版本没任何区别。

别着急,接下来稍做改进就好了。

========================================================

在最原始的“基于表达式栈”的基础上有两个变种,都是利用“栈顶缓存”(top-of-stack caching,简称stack-caching,或者TOSCA——Top-Of-Stack-CAching)的思路:
  • 单状态栈顶缓存(1-top-of-stack caching,简称1-TOSCA)
  • 多状态栈顶缓存(n-top-of-stack caching,简称n-TOSCA)

先看单状态栈顶缓存。这个思路是:总是把表达式栈的栈顶值放在一个实际寄存器里;如果表达式栈有多于一个值,则其余部分分配在栈帧上。

假如把RAX用作缓存表达式栈顶值的寄存器,那么上面的例子可以变成:(只看第一个语句的部分)
  // expression tree for:
  //  int e = a * b + c * d;

  // load a
  eax = [rbp - 8]   // push a onto expression stack

  // load b
  push rax
  eax = [rbp - 16]  // push b onto expression stack

  // mul
  pop rdx           // pop lhs to temp register rdx
  eax *= edx        // do mul

  // load c
  push rax
  eax = [rbp - 24]  // push c onto expression stack

  // load d
  push rax
  eax = [rbp - 32]  // push d onto expression stack

  // mul
  pop rdx           // pop lhs to temp register rdx
  eax *= edx        // do mul

  // add
  pop rdx           // pop lhs to temp register rdx
  eax += edx        // do add

  // store e
  [rbp - 40] = eax  // pop top of expression stack to stack: e
有没有比原始版清爽多了?这个1-TOSCA版的int e = a * b * c * d;用了7次内存读,4次内存写,0次寄存器间移动,比原始版略有改进。

仔细看上面这段代码,可以发现在原始版里每次要把一个值压入表达式栈都是直接用push指令,而在这个版本里则是第一次(当表达式栈还是空的时候)压入时直接move到RAX里,等到要压入下一个值时才用push指令把当前在RAX里的值压到在栈帧里的表达式栈,然后下一个值move到RAX里。这就是栈顶缓存的体现。

有不少简单的编译器都会选用这种设计,因为它简单实用:基于表达式栈的代码生成方式与表达式树的后序遍历可以轻松结合起来,实现简单;而通过栈顶缓存又可以让实际生成的代码利用上(少量)寄存器,还算实用。

青木峰郎的编译器书「ふつうのコンパイラをつくろう」所讲述的C♭(读作C-flat)编译器用的就是这种1-TOSCA设计。
斯坦福的编译器入门课(CS143)教的入门编译器也是用1-TOSCA的设计。关于这个课请参考我另一个回答:斯坦福大学编译原理课程质量怎么样? - RednaxelaFX 的回答
这两者都把缓存栈顶值的寄存器称为“累加器”(accumulator)。在斯坦福编译器课Coursera版的11-06第7页开始的地方提到了top-of-stack caching,并提到1-TOSCA的那个缓存用寄存器叫做累加器。
历史上确实存在过“累加器计算机”,这里说的“累加器”可以说是从那种老式机器传承下来的叫法。有趣的是,x86家族里AX系列寄存器(AX / EAX / RAX)的“A”就是累加器的意思,暗示着这个寄存器原本的设计用途。
我的一个老帖里提到过累加器式的指令集与基于寄存器/基于栈的指令集之间的关系:虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩

微软为研究目的公开的Shared Source Common Language Infrastructure(SSCLI)里带的JIT编译器,FJIT,也使用这种代码生成方式。关于FJIT的血缘,请参考:JIT编译器杂谈#1:JIT编译器的血缘(一) - 编程语言与高级语言虚拟机杂谈(仮) - 知乎专栏

TOSCA在许多解释器中也相当流行。HotSpot VM、Strongtalk VM家族都是用1-TOSCA的。
我在这里之所以用“TOSCA”这种缩写方式就是因为HotSpot VM的源码里是这么写的。

以前写过一个笔记对比了HotSpot VM与Dalvik VM的解释器,前者是用1-TOSCA方式实现基于表达式栈的指令集(JVM字节码),后者是用全部映射到栈帧上的方式实现基于虚拟寄存器的指令集(Dalvik字节码),如果抛去解释器的固有开销(fetch-dispatch),只看执行逻辑的话,其实两者差不了多少:A code snippet to show some relationship between JVM/HotSpot's and Dalvik's interpreter

========================================================

多状态栈顶缓存有几种不同的做法。让我们就前面的例子看看3-TOSCA的版本可以是怎样的。

假设我们用的变种是这样的:
表达式栈深度为0时:压栈 => 移至RAX,到达深度为1状态
表达式栈深度为1时:压栈 => 移至RDX,到达深度为2状态
表达式栈深度为2时:压栈 => 移至RCX,到达深度为3状态
后面的状态因为例子用不到所以留给大家作为课后作业 >_<

使用这种代码生成策略,再来看int e = a * b + c * d;的例子:
  // expression tree for:
  //  int e = a * b + c * d;

  // load a
  eax = [rbp - 8]   // push a onto expression stack

  // load b
  edx = [rbp - 16]  // push b onto expression stack

  // mul
  eax *= edx        // do mul

  // load c
  edx = [rbp - 24]  // push c onto expression stack

  // load d
  ecx = [rbp - 32]  // push d onto expression stack

  // mul
  edx *= ecx        // do mul

  // add
  eax += edx        // do add

  // store e
  [rbp - 40] = eax  // pop top of expression stack to stack: e
这样就只有4次内存读,1次内存写,0次寄存器间移动,效果就跟GCC -O0差不多了,比上文提到的“基于虚拟寄存器”+“固定映射临时变量”还要好。
可见,通过利用寄存器做栈顶缓存,从一个原本“基于表达式栈”的中间表示完全可以直接生成出利用实际寄存器的代码。上面的3-TOSCA例子生成的代码完全没用到x86的push/pop指令,纯利用寄存器就完成了运算。

n-TOSCA其实就是一种非常简单实用的、适用于后序遍历表达式树的寄存器分配思路。
像是说早期的JVM JIT编译器之一,shuJIT就用了n-TOSCA的思路来分配寄存器。
Jikes RVM、Harmony里也有应用。

V8的初级编译器full code generator采用的寄存器分配策略也是衍生自n-TOSCA。

关于栈顶缓存,放个传送门:关于虚拟机里的stack caching(栈顶缓存)的随笔

========================================================

(…待续…)

Strahler number
Sethi–Ullman算法