用LLVM写一个芯片编译器:一文读懂编译器基本概念
芯片是一个硬件,接收的是二进制的指令,要想让自己的编程语言编程指令,就需要一个编译器。
这个部分的重要程度丝毫不亚于芯片本身。最近国内很多公司在做AI芯片,经常出现芯片很快就做出来了,但芯片受限于编译器无法发挥最大能效的窘境。总之,了解编译器还是很重要的。
这个系列讲讲如何用LLVM做一个最简单的编译器。万变不离其宗,其他复杂的编译器可以从这个例子上拓展。
本部分主要讲基础知识,不需要了解细节,但是对编译器整体如何工作的要有概念。
不专业做编译器,难免有错误,欢迎指出,另外,部分图来自网络。
首先一个东西要搞明白,为什么要用LLVM?LLVM的是什么?
LLVM提供了一个模块化的编译器框架,让程序员可以绕开繁琐的编译原理,快速实现一个可以运行的编译器。
常见的结构如下图。
主要由三个部分组成。
前端:将高级语言例如C或者其他语言转换成LLVM定义的中间表达方式 LLVM IR。例如非常有名的clang, 就是一个转换C/C++的前端。
中端:中端主要是对LLVM IR本身进行一下优化,输入是LLVM, 输出还是LLVM, 主要是消除无用代码等工作,一般来讲这个部分是不需要动的,可以不管他。
后端:后端输入是LLVM IR, 输出是我们的机器码。我们通常说的编译器应该主要是指这个部分。大部分优化都从这个地方实现。
至此,LLVM架构的模块化应该说的比较清楚了。很大的一个特点是隔离了前后端。
如果你想支持一个新语言,就重新实现一个前端,例如华为“仓颉”就有自己的前端来替换clang。
如果你想支持一个新硬件,那你就重行实现一个后端,让它可以正确的把LLVM IR映射到自己的芯片。
接下来我们大致讲讲前后端的流程。
我们以Clang举例,前端主要实现四件事。
经过词法分析、语法分析、语义分析、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:o-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转换成真正的汇编(或者机器码)。主要的流程如下。这个我们要重点讲讲,因为后续我们就是要实现一个这个东西支持一个新的芯片。
按步骤一个一个讲。
4.1 DAG Lowering
这个主要负责将你的LLVM IR转换为有向无环图,便于后续利用图算法优化。
例如将下面的LLVM IR 转换成图,每个节点是一个指令。
4.2 DAG Legalization
DAG图合法化,3.1中的DAG图都是LLVM IR指令,但实际上LLVM IR指令不可能被芯片全部支持,这个步骤就是替换这些不合法的指令。
4.3 Instruction Selection
这个步骤其实和3.2算是一起的功能,都是为了将LLVM IR转换成机器支持的Machine DAG.
如上图,将store换成机器仍可的st, 将16位的寄存器转向32位。一切向机器指令靠拢。
4.4 Scheduling
这个步骤主要是调整指令顺序的,从有向无环图再展开成顺序的指令。
例如把下面的指令调成这样的。
把%C的store提前一些,因为下一条ld要用C啦。
4.5 SSA-based Machine Code Optimization
这一步骤主要是做一些公共表达式合并啊去除的操作。
4.6 Register Allocation
这一步就要分配寄存器了。在3.5之前我们认为寄存器其实是可以无限用的,但实际硬件的寄存器有限的。所以我们得考虑寄存器数量与寄存器值的生命周期,将虚拟的寄存器替换成实际的寄存器。这个一般会用到图着色等等算法,贼复杂,好在LLVM都实现好了,不用在重复造轮子。
例如一个芯片,有32个可用的寄存器,如果函数使用到了64个,多的就只能压如堆栈或者等着了。
4.7 Prologue/Epilogue Code Insertion
这个主要是加上函数调用前的指令和函数结束后的指令。主要是调用前把参数存下来,调用后把结果写到固定的寄存器里。
4.8 Peephole optimizations
这个步骤主要是对代码再最后抢救一番。比如把x*2换成x<1
再比如下面这样
将两个32bit的存储换成一个64bit的存储
4.9 Code Emission
最后一步显然,将上述优化好的中间格式转换成我们真正需要的汇编,由汇编器翻译成机器码,大功告成。
这篇文章,我们介绍了程序编译的最基本概念,编译中的大部分流程都有所涉及。下一章开始我们介绍如何用LLVM快捷的实现上面的流程。LLVM的精髓就在于,你不必对上面每一个步骤内部如何实现的彻底了解细节。你只需要知道有这么个东西就能很快攒出你的编译器。
本文来源:https://zhuanlan.zhihu.com/p/474933058
本文内容仅代表作者观点,不代表平台观点。
如有任何异议,欢迎联系我们。
如有侵权,请联系删除。
2021年的第一场雪!英特尔2020年Q4财报解读
可能是DFT最全面的入门介绍
市场需求因素推动物联网多重技术创新
可追溯性,虽不熟悉但很重要