文章目录
Part One : 传统编译器
编译器就是一个将编程语言所编写的程序翻译成另一种目标语言的程序。传统编译器的执行流程如下所示
编译器的前端技术分为词法分析,语法分析,语义分析三个部分,后端部分从生成中间代码,到各种优化,到最终生成目标代码的过程,有时又会将中间代码和优化部分称之为中端。
下文将从前端,中端和后端三个角度来阐述。
1.1 前端
词法分析器 scanner 以源代码作为输入,将源代码转换成 token stream,然后传递给 parser 进行处理,parser 按照语法规则,对 token 进行处理,然后生成一棵抽象语法树。在抽象语法树的基础上,进行类型检查,比如类型绑定,类型推导,变量消解等语义相关操作。我们可以直观的看下这个过程。比如对下面这段简单的代码片段进行词法和语法解析
int main(int argc, char **argv)
{
printf("Hello, World!\n");
return 0;
}
生成的tokens如下所示,每一个token都会有其编号、所在文件中的位置、内容以及类型信息,经过语法分析器处理后生成抽象语法树
抽象语法树中,整个源代码片段被组织成了一棵树的形式,源代码中的每一行语句都对应了树中的一个有实际含义的节点。
以上这两个截图,出自我之前基于 antlr4 编写的一个 cbc-cpp 编译器前端,语法与 c 语言类似,仓库地址为 https://github.com/small-cat/cbc-cpp。最近在 csdn 准备写一个关于 antlr4 的专栏,专门介绍一下 antlr4 这个强大的词法语法生成器的使用以及上下文无关文法相关的内容,希望对编译器前端感兴趣的小伙伴能带来一点帮助。专栏地址
1.2 中端
编译器经过前端部分处理之后,生成抽象语法树,然后将抽象语法树转换成中间表示(IR, intermediate representation)。从抽象层次上,可以将 IR 归结为 HIR,MIR 和 LIR 这三类。
抽象语法树可以算作一种 HIR,在这个层次上可以做一些高层次的优化,比如常数折叠,内联等。
MIR 是独立于源语言和硬件架构的,所作的优化都是机器无关的优化工作,常见的形式有三地址代码(three address code, TAC)的形式。TAC的特点是,最多有三个地址(也就是变量),其中赋值符号的左边是用来写入的,而右边最多可以有两个地址和一个操作符,用于读取数据并计算。
x = y op z
x = uop y
x = y
goto I
if x goto L
if x op y goto L
比如
do {
i = i + 1;
a[i]++;
} while (a[i] < v)
TAC 的形式为
L: i = i + 1
t1 = a[i]
t1 = t1 + 1
if t1 < v goto L
在 TAC 基础上,在三地址代码上再加一些限制,就能得到另一种重要的代码,即静态单赋值代码(Static Single Assignment, SSA),在静态单赋值代码中,一个变量只能被赋值一次,来看个例子。
y = x1 + x2 + x3 + x4
的普通三地址代码如下:
y = x1 + x2;
y = y + x3;
y = y + x4;
其中,y被赋值了三次,如果写成SSA的形式,就只能写成下面的样子:
t1 = x1 + x2;
t2 = t1 + x3;
y = t2 + x4;
明确了 use-define 的关系,每一个变量只会定义一次,可以多次使用,这种特点使得基于SSA更容易做数据流分析,而数据流分析又是很多代码优化技术的基础,所以,几乎所有语言的编译器、解释器或虚拟机中都使用了SSA,因为有利于做代码优化。
而基于 MIR 所作的优化方法很多,这里只是介绍几个跟我们后面理解 AI 编译器有关的几个优化方法,提供一点思路。
常见的优化
思路1: 把常量提前计算出来
比如表达式 x = 2 * 3
,就可以提前将表达式的值计算出来,优化成 x = 6
。这种优化方法就叫做常量折叠(constant folding)。
对于 x 这个变量,已经知道了它的值就是 6,在后面表达式计算中如果使用到了 x,就可以直接将 x 的值替换成 6,这种优化方式叫做常量传播(constant propagation)。而替换 x 后,可能又会找到新的常量折叠和常量传播的优化机会。
思路2: 用低代价的方法做计算
比如 x = x + 0
,操作前后 x 没有任何变化,这行代码可以直接删掉。又比如 x = x * 0
,可以简化成 x = 0
,这种优化方法就叫做代数简化(algebra simplification)。对于有些 cpu 来说,乘法运算改成移位运算会更快,比如 x * 2
优化成 x << 1
,x * 9
优化成 x << 3 + x
,这种就叫做强度消弱(strength reduction)
思路3: 消除重复的计算
x = a + b
y = x
z = 2 * y
上面代码中,z 中的表达式 y 可以直接替换成 x,因为y的值就等于x。这个时候,可能x的值已经在寄存器中,所以直接采用x,运算速度会更快。这种优化叫做拷贝传播(Copy Propagation)。
值编号(Value Numbering)也能减少重复计算。值编号是把相同的值,在系统里给一个相同的编号,并且只计算一次即可。比如
w = 3
x = 3
y = x + 4
z = w + 4
w 和 x 的值相同,因此他们的编号相同,这又导致 y 和 z 的编号相同,那么加法计算只需要计算一次即可。
还有一种优化方法叫做公共子表达式消除(Common Subexpression Elimination,CSE),也会减少计算次数。下面这两行代码,x和y右边的形式是一样的,如果这两行代码之间,a和b的值没有发生变化(比如采用SSA形式),那么x和y的值一定是一样的。
x = a + b
y = a + b
那我们就可以让y等于x,从而减少了一次对“a+b”的计算,这就是公共子表达式消除。
思路4: 针对循环的优化
第一种:归纳变量优化(Induction Variable Optimization)。
看下面这个循环,其中的变量j是由循环变量派生出来的,这种变量叫做该循环的归纳变量。归纳变量的变化是很有规律的,因此可以尝试做强度折减优化。示例代码中的乘法可以由加法替代。
int j = 0;
for (int i = 1; i < 100; i++) {
j = 2*i; //2*i可以替换成j+2
}
return j;
第二种:边界检查消除(Unnecessary Bounds-checking Elimination)。
当引用一个数组成员的时候,通常要检查下标是否越界。在循环里面,如果每次都要检查的话,代价就会相当高(例如做多个数组的向量运算的时候)。如果编译器能够确定,在循环中使用的数组下标&#x