【AI系统】编译器基础介绍
【AI系统】编译器基础介绍
随着深度学习的不断发展,AI模型结构在快速演化,底层计算硬件技术更是层出不穷。对于广大开发者来说,不仅要考虑如何在复杂多变的场景下有效地将算力发挥出来,还要应对AI框架的持续迭代。AI编译器就成了应对以上问题广受关注的技术方向,让用户仅需专注于上层模型开发,降低手工优化性能的人力开发成本,进一步压榨硬件性能空间。
在本文内容里面,我们将会探讨编译器的一些基础概念,以便更好地去回答以下问题:
- 什么是编译器?
- 为什么AI框架需要引入编译器?
- AI框架和AI编译器之间是什么关系?
编译器与解释器
编译器(Compiler)和解释器(Interpreter)是两种不同的工具,都可以将编程语言和脚本语言转换为机器语言。虽然两者都是将高级语言转换成机器码,但是其最大的区别在于:解释器在程序运行时将代码转换成机器码,编译器在程序运行之前将代码转换成机器码。
机器语言:机器语言程序是由一系列二进制模式组成的(例如110110),表示应该由计算机执行的简单操作。机器语言程序是可执行的,所以可以直接在硬件上运行。
编译器 Compiler
编译器可以将整个程序转换为目标代码(object code),这些目标代码通常存储在文件中。目标代码也被称为二进制代码,在进行链接后可以被机器直接执行。典型的编译型程序语言有C和C++。
下面来打开看看编译器的几个重要的特点:
- 编译器读取源程序代码,输出可执行机器码,即把开发者编写的代码转换成CPU等硬件能理解的格式
- 将输入源程序转换为机器语言或低级语言,并在执行前并报告程序中出现的错误
- 编译的过程比较复杂,会消耗比较多的时间分析和处理开发者编写的程序代码
- 可执行结果,属于某种形式的特定于机器的二进制代码
目前主流如LLVM和GCC等经典的开源编译器的类型分为前端编译器、中间层编译器、后端编译器。1)编译器的分析阶段也称为前端编译器,将程序划分为基本的组成部分,检查代码的语法、语义和语法,然后生成中间代码。2)中间层主要是对源程序代码进行优化和分析,分析阶段包括词法分析、语义分析和语法分析;优化主要是优化中间代码,去掉冗余代码、子表达式消除等工作。3)编译器的合成阶段也称为后端,针对具体的硬件生成目标代码,合成阶段包括代码优化器和代码生成器。
解释器 Interpreter
解释器能够直接执行程序或脚本语言中编写的指令,而不需要预先将这些程序或脚本语言转换成目标代码或者机器码。典型的解释型语言有Python、PHP和Matlab。
下面来打开看看解释器的几个重要的特点:
- 将一个用高级语言编写的程序代码翻译成机器级语言
- 解释器在运行时,逐行转换源代码为机器码
- 解释器允许在程序执行时,求值和修改程序
- 用于分析和处理程序的时间相对较少
- 与编译器相比,程序执行相对缓慢
两者最大的差别在于编译器将一个程序作为一个整体进行翻译,而解释器则一条一条地翻译一个程序。编译器的情况下生成中间代码或目标代码,而解释器不创建中间代码。在执行效率上,编译器比解释器要快得多,因为编译器一次完成整个程序,而解释器则是依次编译每一行代码,非常的耗时。从资源占用方面来看,由于要生成目标代码,编译器比解释器需要更多的内存。
实际上编程的体验差异也非常大,编译器同时显示所有错误,很难检测错误,而解释器则逐个显示每条语句的错误,更容易检测错误。具体的,在编译器中,当程序中出现错误时,它会停止翻译,并在删除错误后重新翻译整个程序。相反,当解释器中发生错误时,它会阻止其翻译,在删除错误后,翻译才继续执行。
JIT和AOT编译方式
目前,程序主要有两种运行方式:静态编译和动态解释。
- 静态编译的代码程序在执行前全部被翻译为机器码,通常将这种类型称为AOT(Ahead of time),即“提前编译”;
- 动态解释的程序则是对代码程序边翻译边运行,通常将这种类型称为JIT(Just in time),即“即时编译”。
AOT程序的典型代表是用C/C++开发的应用,其必须在执行前编译成机器码,然后再交给操作系统具体执行;而JIT的代表非常多,如JavaScript、Python等动态解释的程序。
事实上,所有脚本语言都支持JIT模式。但需要注意的是JIT和AOT指的是程序运行方式,和编程语言本身并非强关联的,有的语言既可以以JIT方式运行也可以以AOT方式运行,如Java和Python。它们可以在第一次执行时编译成中间字节码,之后就可以直接执行字节码。
也许有人会说,中间字节码并非机器码,在程序执行时仍然需要动态将字节码转为机器码。理论上讲这没有错,不过通常区分是否为AOT的标准就是看代码在执行之前是否需要编译,只要需要编译,无论其编译产物是字节码还是机器码,都属于AOT的方式。
优缺点对比
下面是JIT和AOT两种编译方式的优点对比。在JIT中其优点为:
- 可以根据当前硬件情况实时编译生成最优机器指令
- 可以根据当前程序的运行情况生成最优的机器指令序列
- 当程序需要支持动态链接时,只能使用JIT的编译方式
- 可以根据进程中内存的实际情况调整代码,使内存能够更充分的利用
但是JIT缺点也非常明显:
- 编译需要占用运行时Runtime的资源,会导致进程执行时候卡顿
- 编译占用运行时间,对某些代码编译优化不能完全支持,需在流畅和时间权衡
- 在编译准备和识别频繁使用的方法需要占用时间,初始编译不能达到最高性能
相对而言,JIT的缺点也是AOT的优点所在:
- 在程序运行前编译,可以避免在运行时的编译性能消耗和内存消耗
- 可以在程序运行初期就达到最高性能
- 可以显著的加快程序的执行效率
其AOT的优点之下,也会带来一些问题:
- 在程序运行前编译会使程序安装的时间增加
- 将提前编译的内容保存起来,会占用更多的内存
- 牺牲高级语言的一致性问题
在AI框架中区别
目前主流的AI框架,都会带有前端的表达层,再加上AI编译器对硬件使能,因此AI框架跟AI编译器之间关系非常紧密,部分如MindSpore、TensorFlow等AI框架中默认包含了自己的AI编译器。目前PyTorch2.X版本升级后,也默认自带Inductor功能特性,可以对接多个不同的AI编译器。
如静态编译的代码程序在执行前全部被翻译为机器码,这种AOT(Ahead of time),即提前编译的方式,AOT更适合移动、嵌入式深度学习应用。在MLIR+TensorFlow框架中目前支持AOT和JIT的编译方式,不过在AI领域,目前AOT的典型代表有:
- 推理引擎,在训练的之后AI编译器把网络模型提前固化下来,然后在推理场景直接使用提前编译好的模型结构,进行推理部署;
- 静态图生成,通过AI编译器对神经网络模型表示称为统一的IR描述,接着在真正运行时执行编译后的内容。
另一方面,动态解释的程序则是对代码程序边翻译边运行,称为JIT(Just in time),即即时编译。典型的代表有:
- PyTorch框架中的JIT特性,可以将Python代码实时编译成本地机器代码,实现对神经网络模型的优化和加速。
- 清华发布的计图(Jittor),完全基于动态编译JIT,内部使用创新的元算子和统一计算图的AI框架,元算子和Numpy一样易于使用,并且超越Numpy能够实现更复杂更高效的操作。基于元算子开发的神经网络模型,可以被计图实时的自动优化并且运行在指定的硬件上。
Pass和中间表示IR
编译器是提高开发效率的工具链中不可或缺的部分。但是编译器被很多程序员和开发者视为黑箱,输入高层次的源程序程序,产生语义不变的低层次机器码。此时,编译器的内部结构中,Pass作为编译优化中间层的一个遍历程序或者模块,中间表示(intermediate representation,IR)负责串联起编译器内各层级和模块。
Pass定义和原理
Pass主要是对源程序语言的一次完整扫描或处理。在编译器中,Pass指所采用的一种结构化技术,用于完成编译对象(IR)的分析、优化或转换等功能。Pass的执行就是编译器对编译单元进行分析和优化的过程,Pass构建了这些过程所需要的分析结果。
一个Pass通常会完成一项较为独立的功能,例如LoopUnroll Pass会进行循环展开的操作。但Pass与Pass之间可能会存在一些依赖,部分Pass的执行会依赖于其它一些Pass的分析或者转换结果。
如图所示,现代编译器中,一般会采用分层、分段的结构模式,不管是在中间层还是后端,都存在若干条优化的Pipeline,而这些Pipeline,则是由一个个Pass组成的,对于这些Pass的管理,则是由PassManager完成的。
在编译器LLVM中提供的Pass分为三类:Analysis pass、Transform pass和Utility pass。
Analysis Pass:计算相关IR单元的高层信息,但不对其进行修改。这些信息可以被其他Pass使用,或用于调试和程序可视化。换言之,Analysis Pass会从对应的IR单元中挖掘出需要的信息,然后进行存储,并提供查询的接口,让其它Pass去访问其所存储的信息。同时,Analysis Pass也会提供invalidate接口,因为当其它Pass修改了IR单元的内容后,可能会造成已获取的分析信息失效,此时需调用invalidate接口来告知编译器此Analysis Pass原先所存储的信息已失效。常见的Analysis Pass有Basic Alias Analysis、Scalar Evolution Analysis等。
Transform Pass:可以使用Analysis Pass的分析结果,然后以某种方式改变和优化IR。此类Pass是会改变IR的内容的,可能会改变IR中的指令,也可能会改变IR中的控制流。例如Inline Pass会将一些函数进行inline的操作,从而减少函数调用,同时在inline后可能会暴露更多的优化机会。
Utility Pass:是一些功能性的实用程序,既不属于Analysis Pass,也不属于Transform Pass。例如,extract-blocks Pass将basic block从模块中提取出来供bug point使用,它仅完成这项工作。
IR中间表示
- 什么是IR
IR(Intermediate Representation)中间表示,是编译器中很重要的一种数据结构。编译器在完成前端工作以后,首先生成其自定义的IR,并在此基础上执行各种优化算法,最后再生成目标代码。
从广义上看,编译器的运行过程中,中间节点的表示,都可以统称为IR。从狭义上讲编译器的IR,是指该编译器明确定义的一种具体的数据结构,这个数据结构通常还伴随着一种语言来表达程序,这个语言程序用来实现这个明确定义的IR。
如图所示,在编译原理中,通常将编译器分为前端和后端。其中,前端会对所输入的程序进行词法分析、语法分析、语义分析,然后生成中间表达形式IR。后端会对IR进行优化,然后生成目标代码。
例如:LLVM把前端和后端给拆分出来,在中间层明确定义一种抽象的语言,这个语言就叫做IR。定义了IR以后,前端的任务就是负责最终生成IR,优化器则是负责优化生成的IR,而后端的任务就是把IR给转化成目标平台的语言。LLVM的IR使用LLVM assembly language或称为LLVM language来实现LLVM IR的类型系统,就指的是LLVM assembly language中的类型系统。
因此,编译器的前端,优化器,后端之间,唯一交换的数据结构类型就是IR,通过IR来实现不同模块的解耦。有些IR还会为其专门起一个名字,比如:Open64的IR通常叫做WHIRL IR,方舟编译器的IR叫做MAPLE IR,LLVM则通常就称为LLVM IR。
- IR的定义
IR在通常情况下有两种用途,1)一种是用来做分析和变换,2)一种是直接用于解释执行。
编译器中,基于IR的分析和处理工作,前期阶段可以基于一些抽象层次比较高的语义,此时所需的IR更接近源代码。而在编译器后期阶段,则会使用低层次的、更加接近目标代码的语义。基于上述从高到低的层次抽象,IR可以归结为三层:高层HIR、中间层MIR和底层LIR。
- HIR
HIR(High IR)高层IR,其主要负责基于源程序语言执行代码的分析和变换。假设要开发一款IDE,主要功能包括:发现语法错误、分析符号之间的依赖关系(以便进行跳转、判断方法的重载等)、根据需要自动生成或修改一些代码(提供重构能力)。此时对IR的需求是能够准确表达源程序语言的语义即可。
其实,AST和符号表就可以满足上述需求。也就是说,AST也可以算作一种特殊的IR。如果要开发IDE、代码翻译工具(从一门语言翻译到另一门语言)、代码生成工具、代码统计工具等,使用AST(加上符号表)即可。基于HIR,可以执行高层次的代码优化,比如常数折叠、内联关联等。在Java和Go的编译器中,有不少基于AST执行的优化工作。
- MIR
MIR(Middle IR),独立于源程序语言和硬件架构执行代码分析和具体优化。大量的优化算法是通用的,没有必要依赖源程序语言的语法和语义,也没有必要依赖具体的硬件架构。这些优化包括部分算术优化、常量和变量传播、死代码删除等,实现分析和优化功能。
因为MIR跟源程序代码和目标程序代码都无关,所以在编译优化算法(Pass)过程中,通常是基于MIR,比如三地址代码(Three Address Code,TAC)。
三地址代码TAC的特点:最多有三个地址(也就是变量),其中赋值符号的左边是用来写入,右边最多可以有两个地址和一个操作符,用于读取数据并计算。
- LIR
LIR(Low IR),依赖于底层具体硬件架构做优化和代码生成。其指令通常可以与机器指令一一对应,比较容易翻译成机器指令或汇编代码。因为LIR体现了具体硬件(如CPU)架构的底层特征,因此可以执行与具体CPU架构相关的优化。
多层IR和单层IR比较起来,具有较为明显的优点:
- 可以提供更多的源程序语言的信息
- IR表达上更加地灵活,更加方便优化
- 使得优化算法和优化Pass执行更加高效
如在LLVM编译器里,会根据抽象层次从高到低,采用了前后端分离的三段结构,这样在为编译器添加新的语言支持或者新的目标平台支持的时候,就十分方便,大大减小了工程开销。而LLVM IR在这种前后端分离的三段结构之中,主要分开了三层IR,IR在整个编译器中则起着重要的承上启下作用。从便于开发者编写程序代码的理解到便于硬件机器的理解。