编译器与解释器
编译器(Compiler)和解释器(Interpreter)是两种不同的工具,都可以将编程语言和脚本语言转换为机器语言。虽然两者都是将高级语言转换成机器码,但是其最大的区别在于:解释器在程序运行时将代码转换成机器码,编译器在程序运行之前将代码转换成机器码。
Interpreter 解释器 | Compile 编译器 | |
---|---|---|
程序步骤 | 1、创建代码2、没有文件链接或机器代码生成3、源语句在执行过程中逐行执行 | 1、创建代码2、将解析或分析所有语言语句的正确性3、将把源代码转换为机器码4、链接到可运行程序5、运行程序 |
Input | 每次读取一行 | 整个程序 |
Output | 不产生任何的中间代码 | 生成中间目标代码 |
工作机制 | 编译和执行同时进行 | 编译在执行之前完成 |
存储 | 不保存任何机器代码 | 存储编译后的机器代码在机器上 |
执行 | 程序执行是解释过程的一部分,因此是逐行执行的 | 程序执行与编译是分开的,它只在整个输出程序编译后执行 |
生成程序 | 不生成输出程序,所以他们在每次执行过程中都要评估源程序 | 生成可以独立于原始程序运行的输出程序(以exe的形式) |
修改 | 直接修改就可运行 | 如果需要修改代码,则需要修改源代码,重新编译 |
运行速度 | 慢 | 快 |
内存 | 它需要较少的内存,因为它不创建中间对象代码 | 内存需求更多的是由于目标代码的创建 |
错误 | 解释器读取一条语句并显示错误。你必须纠正错误才能解释下一行 | 编译器在编译时显示所有错误和警告。因此,不修正错误就不能运行程序 |
错误监测 | 容易 | 难 |
编程语言 | PHP, Perl, Python, Ruby | C, C++, C#, Scala, Java |
Just in time and Ahead of time - JIT和AOT编译方式
目前,程序主要有两种运行方式:静态编译和动态解释。
- 静态编译的代码程序在执行前全部被翻译为机器码,通常将这种类型称为 AOT(Ahead of time),即“提前编译”;
- 动态解释的程序则是对代码程序边翻译边运行,通常将这种类型称为 JIT(Just in time),即“即时编译”。
Just in time | Ahead of time | |
---|---|---|
优点 | 可以根据当前硬件情况实时编译生成最优机器指令 可以根据当前程序的运行情况生成最优的机器指令序列 当程序需要支持动态链接时,只能使用JIT 可以根据进程中内存的实际情况调整代码,使内存能够更充分的利用 |
在程序运行前编译,可以避免在运行时的编译性能消耗和内存消耗 可以在程序运行初期就达到最高性能 可以显著的加快程序的启动 |
缺点 | 编译需要占用运行时资源,会导致进程卡顿 编译占用运行时间,对某些代码编译优化不能完全支持,需在流畅和时间权衡 在编译准备和识别频繁使用的方法需要占用时间,初始编译不能达到最高性能 |
在程序运行前编译会使程序安装的时间增加 牺牲高级语言的一致性问题 将提前编译的内容保存会占用更多的外 |
Pass:One complete scan or processing of the source program. 对源程序的一次完整扫描或处理
Intermediate Representations, IR
IR: 中间表示 (IR) 是编译器或虚拟机内部使用的数据结构或代码,用于表示源代码
编译器基本组成
目前主流如 LLVM 和 GCC 等经典的开源编译器,通常分为三个部分,前端(frontEnd),优化器(Optimizer)和后端(backEnd)。
- Front-End:主要负责词法和语法分析,将源代码转化为抽象语法树,即将程序划分为基本的组成部分,检查代码的语法、语义和语法,然后生成中间代码
- Optimizer:优化器则是在前端的基础上,对得到的中间代码进行优化(如去掉冗余代码、子表达式消除等工作),使代码更加高效
- Back-end:后端则是将已经优化的中间代码,针对具体的硬件生成目标代码,转换成为包括代码优化器和代码生成器
GCC
GCC 的编译过程可以大致分为预处理(前端)、编译(优化)、汇编和链接(后端)四个阶段。
- 预处理(Pre-Processing):包括宏定义,文件包含,条件编译三部分。预处理过程读入源代码,检查包含预处理指令的语句和宏定义,并对其进行响应和替换。预处理过程还会删除程序中的注释和多余空白字符。最后会生成 .i 文件。
- 编译器(Compiling):编译器会将预处理完的 .i 文件进行一些列的语法分析,并优化后生成对应的汇编代码。会生成 .s 文件。
- 汇编器(Assembling):汇编器会将编译器生成的 .s 汇编程序汇编为机器语言或指令,也就是可以机器可以执行的二进制程序。会生成 .o 文件。
- 链接器(Linking):链接器会来链接程序运行的所需要的目标文件,以及依赖的库文件,最后生成可执行文件,以二进制形式存储在磁盘中
优点
- 支持 JAVA/ADA/FORTRAN
- GCC 支持更多平台,GCC 更流行,广泛使用,
- 支持完备GCC 基于 C,不需要 C++ 编译器即可编译
缺点
- GCC 代码耦合度高,很难独立,如集成到专用 IDE 上,模块化方式来调用 GCC 难
- GCC 被构建成单一静态编译器,使得难以被作为 API 并集成到其他工具中
- gcc大约有1500万行代码,越是后期的版本,代码质量越差
LLVM
LLVM 提出了IR,GCC没有这种概念
LLVM 架构具有独立的组件和库化的特点,使得前端和后端工程师能够相对独立地进行工作,从而提高了开发效率和代码维护性。其核心在于中间表示(IR),通过统一且灵活的IR实现了对不同编程语言和目标平台的支持。优化器能够将IR转换为高效的形式,再由后端生成目标平台的机器码。这种设计使得LLVM具有适应不同编程需求和硬件架构的灵活性和高性能,为软件开发提供了强大的支持。
LLVM 具有一个显著的特点,即其组件的独立性和库化架构。
- 在使用 LLVM 时,前端工程师只需实现相应的前端,而无需修改后端部分,从而使得添加新的编程语言变得更加简便。这是因为后端只需要将中间表示(IR)翻译成目标平台的机器码即可。
- 对于后端工程师而言,他们只需将目标硬件的特性如寄存器、硬件调度以及指令调度与IR进行对接,而无需干涉前端部分。这种灵活的架构使得编译器的前端和后端工程师能够相对独立地进行工作,从而极大地提高了开发效率和维护性。
LLVM Core | 即 LLVM 的核心库,主要是围绕 LLVM 中间代码的一些工具,它提供了一个“源”和“目标”无关的优化器和几乎所有主流 CPU 类型的代码(机器码)生成器。 |
---|---|
Clang | 是 LLVM 项目中的一个子项目。它是基于 LLVM 架构的轻量级编译器,诞生之初是为了替代 GCC,提供更快的编译速度。它是负责编译C、C++、Objecte-C 语言的编译器,它属于整个 LLVM 架构中的,编译器前端。 |
Compiler-RT | 项目用于为硬件不支持的低级功能提供特定于目标的支持。例如,32位目标通常缺少支持64位的除法指令。Compier-RT通过提供特定于目标并经过优化的功能来解决这个问题,该功能在使用32位指令的同时实现了64位除法。为代码生成器提供了一些中间代码指令的实现,这些指令通常是目标机器没有直接对应的,例如在32位机器上将 double 转换为 unsigned integer 类型。此外该库还为一些动态测试工具提供了运行时实现,例如 AddressSanitinzer、ThreadSanitizer、MemorySanitizer 和 DataFlowSanitizer 等。 |
LLDB | LLDB是一个LLVM的原生调试器项目,最初是XCode的调试器,用以取代GDB。LLDB提供丰富的流程控制和数据检测的调试功能。 |
LLD | clang/llvm内置的链接器。 |
Dragonegg | GCC插件,可将GCC的优化和代码生成器替换为LLVM的相应工具。 |
libc | C标准库实现。 |
libcxx/libcxxabi | C++标准库实现。 |
libclc | OpenCL标准库的实现。 |
OpenMP | 提供一个OpenMP运行时,用于Clang中的OpenMP实现。 |
polly | 支持高级别的循环和数据本地化优化支持的LLVM框架,使用多面体模型实现一组缓存局部优化以及自动并行和矢量化。 |
vmkit | 基于LLVM的Java和.Net虚拟机实现。 |
klee | 基于LLVM编译基础设施的符号化虚拟机。它使用一个定理证明器来尝试评估程序中的所有动态路径,以发现错误并证明函数的属性。klee的一个主要特征是它可以在检测到错误时生成测试用例。 |
SAFECode | 用于C/C++程序的内存安全编译器。它通过运行时检查来检测代码,以便在运行时检测内存安全错误(如缓冲区溢出)。它可以用户保护软件免受安全攻击,也可用作Valgrind等内存安全错误调试工具。 |
LLVM vs GCC
-
把编译器移植给新的语言只需要实现一个新的编译前端,已有的优化和后端都能实现复用;
-
如果前后端和解析器没有相互解耦,新语言编译器需要支持 N 个目标机和 M 种语言(N*M);
- LLVM 组件之间交互发生在高层次抽象,不同组件隔离为单独程序库,易于在整个编译流水线中集成转换和优化 Pass。现在被作为实现各种静态和运行时编译语言的通用基础结构;
- GCC 饱受分层和抽象漏洞困扰:编译前端生成编译后端数据的结构,编译后端遍历前端抽象语法树(AST)来生成调试信息,整个编译器依赖命令行设置的全局数据结构;
Clang
LLVM 是一个模块化和可重用的编译器和工具链技术库。它的整体架构包含从前端语言处理到最终生成目标机器码的完整优化流程。对于用户而言,通常会使用 Clang 作为前端,而 LLVM 的优化器和后端处理则是透明的。
- 前端(Front-End):负责处理高级语言(如 C/C++/Obj-C)的编译,生成中间表示(IR)。
- 优化器(Optimizer):对中间表示进行各种优化,提高代码执行效率。
- 后端(Back-End):将优化后的中间表示转换为目标平台的机器码。
LLVM 的优化器通过多个优化 pass 来提升中间表示(IR)的性能。每个 pass 都对 IR 进行特定的优化操作,例如:
- 常量折叠(Constant Folding):将编译时已知的常量表达式直接计算并替换。
- 循环优化(Loop Optimizations):如循环展开、循环交换等,以提高循环执行效率。
- 死代码消除(Dead Code Elimination):移除不必要的代码,提高执行效率。 经过优化后的 IR 是一个更高效的中间表示,准备好进行后续的代码生成。
LLVM 的后端负责将优化后的中间表示转换为目标平台的机器码。这包含以下步骤:
- 指令选择(Instruction Selection):将 IR 转换为目标架构的汇编指令。
- 寄存器分配(Register Allocation):为指令分配合适的寄存器。
- 指令调度(Instruction Scheduling):优化指令执行顺序,以提高指令流水线的效率。
- 代码布局(Code Layout):调整代码的排列顺序,以适应目标硬件的执行特性。
- 代码生成(Code Generation):生成目标平台的汇编代码和最终的机器码。 最终,LLVM 后端输出目标平台的可执行文件。
LLVM 的整体架构清晰地分为前端、优化器和后端三个部分。用户与 Clang 前端直接交互,输入高级语言代码,而 Clang 将其转换为中间表示。之后,LLVM 的优化器和后端在后台处理,进行复杂的优化和代码生成步骤,最终输出高效的目标机器码。
LLVM IR
LLVM IR数据结构
LLVM 并非使用单一的 IR 进行表达,前端传给优化层时传递的是一种抽象语法树(Abstract Syntax Tree,AST)的 IR。因此 IR 是一种抽象表达,没有固定的形态。
抽象语法树的作用在于牢牢抓住程序的脉络,从而方便编译过程的后续环节(如代码生成)对程序进行解读。AST 就是开发者为语言量身定制的一套模型,基本上语言中的每种结构都与一种 AST 对象相对应。
在中端优化完成之后会传一个 DAG 图的 IR 给后端,DAG 图能够非常有效的去表示硬件的指定的顺序。
DAG(Directed Acyclic Graph,有向无环图)是图论中的一种数据结构,它是由顶点和有向边组成的图,其中顶点之间的边是有方向的,并且图中不存在任何环路(即不存在从某个顶点出发经过若干条边之后又回到该顶点的路径)。
在计算机科学中,DAG 图常常用于描述任务之间的依赖关系,例如在编译器和数据流分析中。DAG 图具有拓扑排序的特性,可以方便地对图中的节点进行排序,以确保按照依赖关系正确地执行任务。
编译的不同阶段会产生不同的数据结构和中间表达,如前端的抽象语法树(AST)、优化层的 DAG 图、后端的机器码等。后端优化时 DAG 图可能又转为普通的 IR 进行优化,最后再生产机器码。
LLVM IR 语法
LLVM IR 作为一种编译器 IR,它的两个基本原则指导着核心库的开发:
- 静态单赋值形式(Static single assignment,SSA)表示,代码组织为三地址指令序列和无限寄存器(避免和硬件相关,只是中间IR)让优化能够快速执行。
- 1 * 2 + 3 为例
- 每个值只有单一赋值定义了它。每次使用一个值,可以立刻向后追溯到给出其定义的唯一的指令。极大简化优化,因为SSA形式建立了平凡的use-def链,也就是一个值到达使用之处的定义的列表。
- 整个程序的 IR 存储到磁盘让链接时优化易于实现
LLVM IR 是类似于精简指令集(RISC)的底层虚拟指令集;
- 和真实精简指令集一样,支持简单指令的线性序列,例如添加、相减、比较和分支;
- 指令都是三地址形式,它们接受一定数量的输入然后在不同的寄存器中存储计算结果;
- 与大多数精简指令集不同,LLVM 使用强类型的简单类型系统,并剥离了机器差异;
- LLVM IR 不使用固定的命名寄存器,它使用以 % 字符命名的临时寄存器;
三地址指令
三地址指令格式
三地址码是一种中间代码表示形式,广泛用于编译器设计中。它提供了一种简洁而灵活的方式来描述程序的中间步骤,有助于优化和代码生成。下面是对三地址码的详细总结。
什么是三地址码
三地址码(Three-Address Code, TAC)是一种中间表示形式,每条指令最多包含三个操作数:两个源操作数和一个目标操作数。这些操作数可以是变量、常量或临时变量。三地址码可以看作是一系列的四元组(4-tuple),每个四元组表示一个简单的操作。
四元组表示
每个三地址码指令都可以分解为一个四元组的形式:
1 |
|
- 运算符(Operator):表示要执行的操作,例如加法(
+
)、减法(-
)、乘法(*
)、赋值(=
)等。 - 操作数1(Operand1):第一个输入操作数。
- 操作数2(Operand2):第二个输入操作数(有些指令可能没有这个操作数)。
- 结果(Result):操作的输出结果存储的位置。
LLVM IR 内存模型
LLVM IR 文件的基本单位称为 module
一个 module 中可以拥有多个顶层实体,比如 function 和 global variavle
一个 function define 中至少有一个 basicblock
每个 basicblock 中有若干 instruction,并且都以 terminator instruction 结尾
Module | Module类聚合了整个翻译单元用到的所有数据,它是LLVM术语中的“module”的同义词。它声明了Module::iterator typedef,作为遍历这个模块中的函数的简便方法。你可以用begin()和end()方法获取这些迭代器。 |
---|---|
Function | Function类包含有关函数定义和声明的所有对象。对于声明来说(用isDeclaration()检查它是否为声明),它仅包含函数原型。无论定义或者声明,它都包含函数参数的列表,可通过getArgumentList()方法或者arg_begin()和arg_end()这对方法访问它。你可以通过Function::arg_iterator typedef遍历它们。如果Function对象代表函数定义,你可以通过这样的语句遍历它的内容:for (Function::iterator i = function.begin(), e = function.end(); i != e; ++i),你将遍历它的基本块。 |
BasicBlock | BasicBlock类封装了LLVM指令序列,可通过begin()/end()访问它们。你可以利用getTerminator()方法直接访问它的最后一条指令,你还可以用一些辅助函数遍历CFG,例如通过getSinglePredecessor()访问前驱基本块,当一个基本块有单一前驱时。然而,如果它有多个前驱基本块,就需要自己遍历前驱列表,这也不难,你只要逐个遍历基本块,查看它们的终结指令的目标基本块。 |
Instruction | Instruction类表示LLVM IR的运算原子,一个单一的指令。利用一些方法可获得高层级的断言,例如isAssociative(),isCommutative(),isIdempotent(),和isTerminator(),但是它的精确的功能可通过getOpcode()获知,它返回llvm::Instruction枚举的一个成员,代表了LLVM IR opcode。可通过op_begin()和op_end()这对方法访问它的操作数,它从User超类继承得到。 |
LLVM 前端
Lexical analysis 词法分析
前端的第一个步骤处理源代码的文本输入,将语言结构分解为一组单词和标记,去除注释、空白、制表符等。每个单词或者标记必须属于语言子集,语言的保留字被变换为编译器内部表示。
Syntactic analysis 语法分析
分组标记以形成表达式、语句、函数体等。检查一组标记是否有意义,考虑代码物理布局,未分析代码的意思,就像英语中的语法分析,不关心你说了什么,只考虑句子是否正确,并输出语法树(AST)
Semantic analysis 语义分析
借助符号表检验代码没有违背语言类型系统。符号表存储标识符和其各自的类型之间的映射,以及其它内容。类型检查的一种直觉的方法是,在解析之后,遍历AST的同时从符号表收集关于类型的信息。
LLVM 优化层
LLVM 中间表示(IR)是连接前端和后端的中枢,让 LLVM 能够解析多种源语言,为多种目标生成代码。前端产生 IR,而后端接收 IR。IR 也是大部分 LLVM 目标无关的优化发生的地方。
LLVM 优化层在输入的时候是一个 AST 语法树,输出的时候已经是一个 DAG 图
优化层每一种优化的方式叫做 pass,pass 就是对程序做一次遍历
LLVM’s Analysis and Transform Passes
优化通常由分析 Pass 和转换 Pass 组成。
- 分析 Pass(Analysis Pass):分析 Pass 用于分析程序的特定属性或行为而不对程序进行修改。它们通常用于收集程序的信息或执行静态分析,以便其他Pass可以使用这些信息进行进一步的优化。分析Pass通常是只读的,不会修改程序代码。
- 转换 Pass(Transformation Pass):转换 Pass 用于修改程序代码以进行优化或重构。它们会改变程序的结构或行为,以改善性能或满足特定的需求。转换Pass通常会应用各种优化技术来重写程序的部分或整体,以产生更高效的代码。
在转换Pass和分析Pass之间,有两种主要的依赖类型
- 显式依赖:转换Pass需要一种分析,则Pass管理器自动地安排它所依赖的分析Pass在它之前运行
- 隐式依赖:转换或者分析Pass要求IR代码运用特定表达式。需要手动地以正确的顺序把这个Pass加到Pass队列中,通过命令行工具(clang或者opt)或者Pass管理器。
Pass类是实现优化的主要资源。
然而,我们从不直接使用它,而是通过清楚的子类使用它。当实现一个Pass时,你应该选择适合你的Pass的最佳粒度,适合此粒度的最佳子类,例如基于函数、模块、循环、强联通区域,等等。常见的这些子类如下:
-
ModulePass
- FunctionPass
- BasicBlockPass
LLVM 后端
后端由一套分析和转换 Pass 组成,它们的任务是代码生成,即将LLVM IR变换为目标代码(或者汇编)
整个后端流水线用到了四种不同层次的指令表示:
- 内存中的LLVM IR,SelectionDAG 节点,MachineInstr,和 MCInst。
Instruction Scheduling 指令调度
-
第1次指令调度(Instruction Scheduling),也称为前寄存器分配(RA)调度;
-
对指令排序,同时尝试发现尽可能多的指令层次的并行;
-
然后指令被变换为MachineInstr三地址表示。
Register Allocation 寄存器分配
- LLVMIR 两个重要特性之一:LLVM IR 寄存器集是无限;
- 这个性质一直保持着,直到寄存器分配(Register Allocation);
- 寄存器分配将无限的虚拟寄存器引用转换为有限的目标特定的寄存器集;
- 寄存器不够时挤出(spill)到内存。
第二次Instruction Scheduling 指令调度
- 第2次指令调度,也称为后寄存器分配(RA)调度;
- 此时可获得真实的寄存器信息,某些类型寄存器存在延迟,它们可被用以改进指令顺序。
Code Emission 代码输出
-
代码输出阶段将指令从 MachineInstr 表示变换为 MCInst 实例;
-
新的表示更适合汇编器和链接器,可以输出汇编代码或者输出二进制块特定目标代码格式。
AI编译器概念
why 需要
- 越来越多新算子被提出,算子库的开发、维护、优化和测试工作量指数上升;
- 增加新算子,硬件不仅需要实现,还需要结合硬件进行特性优化和测试,尽量充分发挥硬件性能。
- 硬件供应商还会有针对性发布优化库(如 MKL-DNN 和 CuDNN)。但是对于专用硬件,需要提供开发类似的优化库,不仅会增加大量算子优化、封装的工作,还会过于依赖库无法有效利用专用硬件芯片能力。
- 专用加速芯片爆发导致性能可移植性成为一种刚需;
AI编译器干什么的
- 推理场景:输入 AI 框架训练出来的模型文件,输出能够在不同硬件高效执行的程序
- 训练场景:输入高级语言表示的神经网络代码,输出能够在不同硬件高效执行的程序
- 主要是计算图和算子优化,打开计算图和算子的边界,进行重新组合优化,发挥芯片的算力。
- 图算统一表达,实现融合优化
- 算子实现上自动 Schedule、Tiling、Codegen,降低开发门槛
- 针对神经网络,更泛化优化能力,实现动静统一、动态 Shape、稀疏性、高阶微分、自动并行等
- 包括编译器、运行时,异构计算、边缘到数据中心都模块化表示和组合,并专注于可用性
AI 编译器跟传统编译器有什么区别吗
- 目标相同:通过自动化方式进行程序优化和代码生成,从而降低对不同硬件的手工优化;
- 优化方式类似:在编译优化层通过统一 IR 执行不同的Pass进行优化,从而提高执行性能;
- 软件结构栈类似:分成前端、优化、后端三段式,IR 解耦前端和后端使得模块化表示;
- AI编译器依赖传统编译器:AI编译器对 Graph IR 进行优化后,将优化后的 IR 转换成传统编译器 IR,最后依赖传统编译器针进行机器码生成。
不同
- 传统输入高级语言输出低级语言,AI输入计算图/算子,输出低级语言
- 传统主要问题是降低编程难度,其次是优化程序性能,AI相反
- IR 差异:AI 编译器的 IR 与传统编译器的IR所抽象出来的概念和意义并不相同
- AI编译器一般会有 high-level IR,用来抽象描述深度学习模型中的运算,如:Convolution、Matmul 等,甚至部分会有 Transformer 带有图的结构
- 传统编译器相对而言 low-level IR,用于描述基本指令运算,如 load、store 等。有了high-level IR,AI编译器在描述深度学习模型类 DSL 更加方便
- 优化策略:AI 编译器面向AI领域,优化时引入更多领域特定知识,从而进行更 high-level,更加有效的优化手段。
- 如:AI编译器在 high-level IR 执行算子融合,传统编译器执行类似 loop fusion 时候,往往更加保守。缺点是可能会导致调试执行信息跟踪难;
- AI编译器可以降低计算精度,比如int8、fp16、bf16等,因为深度学习对计算精度不那么敏感。但传统编译器一般不执行改变变量类型和精度等优化。
AI 编译器架构
表达上:以 PyTorch 为标杆的表达转换到计算图层 IR 进行优化。
性能上:打开计算图和算子的边界,进行重新组合优化,发挥芯片的算力。
The Deep Learning Compiler: A Comprehensive Survey
IR 中间表达
编译器主要分为前后端,分别针对于硬件无关和硬件相关的处理。每一个部分都有自己的 IR (Intermediate Representation,中间表达),每个部分也会对进行优化:
- High-level IR:用于表示计算图,其出现主要是为了解决传统编译器中难以表达深度学习模型中的复杂运算这一问题,为了实现更高效的优化所以新设计了一套 IR。
- Low-level IR:能够在更细粒度的层面上表示模型,从而能够针对于硬件进行优化,文中将其分为了三类。
Frontend 前端优化
构造计算图后,前端将应用图级优化。因为图提供了计算全局概述,所以更容易在图级发现和执行许多优化。前端优化与硬件无关,这意味着可以将计算图优化应用于各种后端目标。前端优化分为三类
- 节点级优化,如 Zero-dim-tensor elimination、Nop Elimination
- 块级优化,如代数简化、常量折叠、算子融合
- 数据流级优化,如Common sub-expression elimination、DCE
Backend 后端优化
特定硬件的优化
- 目标针对特定硬件体系结构获取高性能代码。
- 1)低级IR转换为LLVM IR,利用LLVM基础结构生成优化的CPU/GPU代码。
- 2)使用领域知识定制优化,这可以更有效地利用目标硬件。
- 自动调整
- 由于在特定硬件优化中用于参数调整的搜索空间巨大,因此有必要利用自动调整来确定最佳参数设置。
- 1)Halide/TVM允许调度和计算表达分开,使用自动调节来得出较佳配置。
- 2)应用多面体模型 Polyhedral model 进行参数调整。
- 由于在特定硬件优化中用于参数调整的搜索空间巨大,因此有必要利用自动调整来确定最佳参数设置。
- 优化内核库
- 厂商特定优化内核库,广泛用于各种硬件上的加速DL训练和推理。特定优化原语可以满足计算要求时,使用优化的内核库可显著提高性能,否则可能会受到进一步优化的约束。
AI 编译器对比
The Deep Learning Compiler: A Comprehensive Survey
XLA:图层下发子图中的算子打开成小算子,基于小算子组成的子图进行编译优化,包括 buffer fusion、水平融合等,关键是大算子怎样打开、小算子如何重新融合、新的大算子如何生成,整体设计主要通过HLO/LLO/LLVM IR 实现,所有 Pass 规则都是手工提前指定
TVM:分为Relay和TVM两层,Relay关注图层,TVM关注算子层,拿到前端子图进行优化,Relay关注算子间融合、TVM关注新算子和kernel生成,区别在于TVM 开放架构,Relay目标是可以接入各种前端,TVM也是一个可以独立使用的算子开发和编译的工具,算子实现方面采用 Compute(设计计算逻辑)和 Schedule(指定调度优化逻辑)分离方案。
TensorComprehensions 是一种可以构建 just in time(JIT)系统的语言,程序员可通过该语言用高级编程语言去高效的实现 GPU 等底层代码。