用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
一 绪论芯片是一个硬件,接收的是二进制的指令,要想让自己的编程语言编程指令,就需要一个编译器。 这个部分的重要程度丝毫不亚于芯片本身。最近国内很多公司在做AI芯片,经常出现芯片很快就做出来了,但芯片受限于编译器无法发挥最大能效的窘境。总之,了解编译器还是很重要的。 这个系列讲讲如何用LLVM做一个最简单的编译器。万变不离其宗,其他复杂的编译器可以从这个例子上拓展。 本部分主要讲基础知识,不需要了解细节,但是对编译器整体如何工作的要有概念。 不专业做编译器,难免有错误,欢迎指出,另外,部分图来自网络。
二 LLVM是个什么东西首先一个东西要搞明白,为什么要用LLVM? LLVM的是什么? LLVM提供了一个模块化的编译器框架,让程序员可以绕开繁琐的编译原理,快速实现一个可以运行的编译器。 常见的结构如下图。
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
主要由三个部分组成。 前端:将高级语言例如C或者其他语言转换成LLVM定义的中间表达方式 LLVM IR。例如非常有名的clang, 就是一个转换C/C++的前端。 中端:中端主要是对LLVM IR本身进行一下优化,输入是LLVM, 输出还是LLVM, 主要是消除无用代码等工作,一般来讲这个部分是不需要动的,可以不管他。 后端:后端输入是LLVM IR, 输出是我们的机器码。我们通常说的编译器应该主要是指这个部分。大部分优化都从这个地方实现。 至此,LLVM架构的模块化应该说的比较清楚了。很大的一个特点是隔离了前后端。 如果你想支持一个新语言,就重新实现一个前端,例如华为“仓颉”就有自己的前端来替换clang。 如果你想支持一个新硬件,那你就重行实现一个后端,让它可以正确的把LLVM IR映射到自己的芯片。 接下来我们大致讲讲前后端的流程。 二 前端在干什么我们以Clang举例,前端主要实现四件事。
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
经过词法分析、语法分析、语义分析、LLVM IR生产,最终将C++转化成后端认可的LLVM IR。 词法分析:将编程语言取出一个个词,遇到不认识的字符就报错。例如将a=b+c 拆成a,= ,b ,+, c 语法分析:将语法提取出来,例如你写了个a+b=c, 明显不符合语法,直接报错 语义分析:分析一下你写的代码实际含义是不是对,例如a=b+c, a,b,c有没有定义,类型是不是对的 LLVM IR生产:经过上述三步,将你写的代码转化成树状描述(抽象语法树),然后再转化成IR定义的IR即可。 举个直观的栗子,你写的C++ // add.cpp
int add(int a, int b) {
return a + b;
} 生产的LLVM IR (这个地方你不需要看懂每个细节,知道大概想类汇编的语言就行了, 专业的形式叫SSA, Static Single Assignment (SSA) ; ModuleID = 'add.cpp'
source_filename = "add.cpp"
target datalayout = "e-m-i64:64-f80:128-n8:16:32:64-S128"
target triple = "x86_64-apple-macosx10.15.0"
; Function Attrs: noinline nounwind optnone ssp uwtable
define i32 @_Z3addii(i32, i32) #0 {
%3 = alloca i32, align 4
%4 = alloca i32, align 4
store i32 %0, i32* %3, align 4
store i32 %1, i32* %4, align 4
%5 = load i32, i32* %3, align 4
%6 = load i32, i32* %4, align 4
%7 = add nsw i32 %5, %6
ret i32 %7
} 三 后端在干什么后端把你的LLVM转换成真正的汇编(或者机器码)。主要的流程如下。这个我们要重点讲讲,因为后续我们就是要实现一个这个东西支持一个新的芯片。
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
按步骤一个一个讲。 3.1 DAG Lowering这个主要负责将你的LLVM IR转换为有向无环图,便于后续利用图算法优化。 例如将下面的LLVM IR 转换成图,每个节点是一个指令。
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
3.2 DAG LegalizationDAG图合法化,3.1中的DAG图都是LLVM IR指令,但实际上LLVM IR指令不可能被芯片全部支持,这个步骤就是替换这些不合法的指令。 3.3 Instruction Selection这个步骤其实和3.2算是一起的功能,都是为了将LLVM IR转换成机器支持的Machine DAG.
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
如上图,将store换成机器仍可的st, 将16位的寄存器转向32位。一切向机器指令靠拢。 3.4 Scheduling这个步骤主要是调整指令顺序的,从有向无环图再展开成顺序的指令。 例如把下面的指令调成这样的。
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
把%C的store提前一些,因为下一条ld要用C啦。 3.4 SSA-based Machine Code Optimization这一步骤主要是做一些公共表达式合并啊去除的操作。 3.5 Register Allocation这一步就要分配寄存器了。在3.5之前我们认为寄存器其实是可以无限用的,但实际硬件的寄存器有限的。所以我们得考虑寄存器数量与寄存器值的生命周期,将虚拟的寄存器替换成实际的寄存器。这个一般会用到图着色等等算法,贼复杂,好在LLVM都实现好了,不用在重复造轮子。 例如一个芯片,有32个可用的寄存器,如果函数使用到了64个,多的就只能压如堆栈或者等着了。 3.6 Prologue/Epilogue Code Insertion这个主要是加上函数调用前的指令和函数结束后的指令。主要是调用前把参数存下来,调用后把结果写到固定的寄存器里。 3.7 Peephole optimizations这个步骤主要是对代码再最后抢救一番。比如把x*2换成x<1 再比如下面这样
用LLVM写一个芯片编译器(一)——一文读懂编译器基本概念
将两个32bit的存储换成一个64bit的存储 3.8 Code Emission最后一步显然,将上述优化好的中间格式转换成我们真正需要的汇编,由汇编器翻译成机器码,大功告成。 三 总结这篇文章,我们介绍了程序编译的最基本概念,编译中的大部分流程都有所涉及。下一章开始我们介绍如何用LLVM快捷的实现上面的流程。LLVM的精髓就在于,你不必对上面每一个步骤内部如何实现的彻底了解细节。你只需要知道有这么个东西就能很快攒出你的编译器。
|